Sfoglia il codice sorgente

Add sample GPX viewer and root gitignore

k4be 5 ore fa
commit
92ed699a57
2 ha cambiato i file con 1648 aggiunte e 0 eliminazioni
  1. 2 0
      .gitignore
  2. 1646 0
      sample/index.html

+ 2 - 0
.gitignore

@@ -0,0 +1,2 @@
+node_modules/
+*.log

+ 1646 - 0
sample/index.html

@@ -0,0 +1,1646 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>GPX Visualizer</title>
+    <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
+    <style>
+        * {
+            box-sizing: border-box;
+            margin: 0;
+            padding: 0;
+        }
+
+        body {
+            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
+            display: flex;
+            height: 100vh;
+            overflow: hidden;
+        }
+
+        #map-container {
+            flex: 1;
+            position: relative;
+            transition: margin-right 0.3s ease;
+        }
+
+        #map {
+            width: 100%;
+            height: 100%;
+        }
+
+        #drop-overlay {
+            position: absolute;
+            top: 0;
+            left: 0;
+            right: 0;
+            bottom: 0;
+            background: rgba(52, 152, 219, 0.9);
+            display: none;
+            justify-content: center;
+            align-items: center;
+            z-index: 1000;
+            pointer-events: none;
+        }
+
+        #drop-overlay.active {
+            display: flex;
+        }
+
+        #drop-overlay-content {
+            text-align: center;
+            color: white;
+        }
+
+        #drop-overlay-content .icon {
+            font-size: 64px;
+            margin-bottom: 16px;
+        }
+
+        #drop-overlay-content p {
+            font-size: 24px;
+            font-weight: 500;
+        }
+
+        #file-prompt {
+            position: absolute;
+            top: 50%;
+            left: 50%;
+            transform: translate(-50%, -50%);
+            text-align: center;
+            z-index: 500;
+            background: white;
+            padding: 40px;
+            border-radius: 12px;
+            box-shadow: 0 4px 20px rgba(0,0,0,0.15);
+        }
+
+        #file-prompt h2 {
+            margin-bottom: 20px;
+            color: #2c3e50;
+        }
+
+        #file-prompt p {
+            color: #7f8c8d;
+            margin-bottom: 20px;
+        }
+
+        #file-input-label {
+            display: inline-block;
+            padding: 12px 24px;
+            background: #3498db;
+            color: white;
+            border-radius: 6px;
+            cursor: pointer;
+            transition: background 0.2s;
+        }
+
+        #file-input-label:hover {
+            background: #2980b9;
+        }
+
+        #file-input {
+            display: none;
+        }
+
+        #sidebar {
+            width: 380px;
+            background: #f8f9fa;
+            border-left: 1px solid #dee2e6;
+            display: flex;
+            flex-direction: column;
+            transition: transform 0.3s ease, width 0.3s ease;
+            overflow: hidden;
+        }
+
+        #sidebar.collapsed {
+            width: 0;
+            border-left: none;
+        }
+
+        #sidebar-toggle {
+            position: absolute;
+            right: 380px;
+            top: 50%;
+            transform: translateY(-50%);
+            z-index: 1000;
+            background: white;
+            border: 1px solid #dee2e6;
+            border-right: none;
+            border-radius: 6px 0 0 6px;
+            padding: 12px 8px;
+            cursor: pointer;
+            transition: right 0.3s ease;
+            box-shadow: -2px 0 5px rgba(0,0,0,0.1);
+        }
+
+        #sidebar-toggle.collapsed {
+            right: 0;
+        }
+
+        #sidebar-toggle:hover {
+            background: #f0f0f0;
+        }
+
+        #sidebar-content {
+            padding: 20px;
+            overflow-y: auto;
+            flex: 1;
+            min-width: 380px;
+        }
+
+        .section {
+            background: white;
+            border-radius: 8px;
+            padding: 16px;
+            margin-bottom: 16px;
+            box-shadow: 0 1px 3px rgba(0,0,0,0.1);
+        }
+
+        .section h3 {
+            font-size: 14px;
+            text-transform: uppercase;
+            color: #7f8c8d;
+            margin-bottom: 12px;
+            letter-spacing: 0.5px;
+        }
+
+        .stat-grid {
+            display: grid;
+            grid-template-columns: 1fr 1fr;
+            gap: 12px;
+        }
+
+        .stat-item {
+            padding: 8px;
+            background: #f8f9fa;
+            border-radius: 6px;
+        }
+
+        .stat-item.full-width {
+            grid-column: 1 / -1;
+        }
+
+        .stat-label {
+            font-size: 11px;
+            color: #95a5a6;
+            text-transform: uppercase;
+            margin-bottom: 4px;
+        }
+
+        .stat-value {
+            font-size: 18px;
+            font-weight: 600;
+            color: #2c3e50;
+        }
+
+        .stat-value.small {
+            font-size: 14px;
+        }
+
+        #elevation-chart-container {
+            position: relative;
+            height: 150px;
+            margin-top: 8px;
+        }
+
+        #elevation-chart {
+            width: 100%;
+            height: 100%;
+            cursor: crosshair;
+        }
+
+        #chart-tooltip {
+            position: absolute;
+            background: rgba(44, 62, 80, 0.95);
+            color: white;
+            padding: 8px 12px;
+            border-radius: 4px;
+            font-size: 12px;
+            pointer-events: none;
+            opacity: 0;
+            transition: opacity 0.15s;
+            z-index: 100;
+            white-space: nowrap;
+        }
+
+        #chart-tooltip.visible {
+            opacity: 1;
+        }
+
+        .settings-row {
+            margin-bottom: 12px;
+        }
+
+        .settings-row:last-child {
+            margin-bottom: 0;
+        }
+
+        .settings-row label {
+            display: block;
+            font-size: 12px;
+            color: #7f8c8d;
+            margin-bottom: 4px;
+        }
+
+        .settings-row select,
+        .settings-row input[type="text"] {
+            width: 100%;
+            padding: 8px 10px;
+            border: 1px solid #dee2e6;
+            border-radius: 4px;
+            font-size: 13px;
+        }
+
+        .settings-row select:focus,
+        .settings-row input[type="text"]:focus {
+            outline: none;
+            border-color: #3498db;
+        }
+
+        .btn {
+            padding: 8px 16px;
+            border: none;
+            border-radius: 4px;
+            cursor: pointer;
+            font-size: 13px;
+            transition: background 0.2s;
+        }
+
+        .btn-primary {
+            background: #3498db;
+            color: white;
+        }
+
+        .btn-primary:hover {
+            background: #2980b9;
+        }
+
+        .btn-secondary {
+            background: #95a5a6;
+            color: white;
+        }
+
+        .btn-secondary:hover {
+            background: #7f8c8d;
+        }
+
+        .btn-block {
+            width: 100%;
+        }
+
+        #track-info {
+            font-size: 13px;
+            color: #7f8c8d;
+            margin-bottom: 12px;
+        }
+
+        .loading-indicator {
+            display: none;
+            align-items: center;
+            justify-content: center;
+            padding: 20px;
+            color: #7f8c8d;
+        }
+
+        .loading-indicator.active {
+            display: flex;
+        }
+
+        .spinner {
+            width: 20px;
+            height: 20px;
+            border: 2px solid #dee2e6;
+            border-top-color: #3498db;
+            border-radius: 50%;
+            animation: spin 0.8s linear infinite;
+            margin-right: 10px;
+        }
+
+        @keyframes spin {
+            to { transform: rotate(360deg); }
+        }
+
+        .elevation-warning {
+            background: #fff3cd;
+            border: 1px solid #ffc107;
+            border-radius: 4px;
+            padding: 8px 12px;
+            font-size: 12px;
+            color: #856404;
+            margin-bottom: 12px;
+        }
+
+        .track-color-picker {
+            display: flex;
+            align-items: center;
+            gap: 10px;
+        }
+
+        .track-color-picker input[type="color"] {
+            width: 40px;
+            height: 32px;
+            border: 1px solid #dee2e6;
+            border-radius: 4px;
+            cursor: pointer;
+        }
+
+        .track-width-slider {
+            display: flex;
+            align-items: center;
+            gap: 10px;
+        }
+
+        .track-width-slider input[type="range"] {
+            flex: 1;
+        }
+
+        .track-width-slider span {
+            min-width: 30px;
+            text-align: right;
+            font-size: 13px;
+        }
+
+        /* Map marker tooltip */
+        .map-tooltip {
+            background: rgba(44, 62, 80, 0.95);
+            color: white;
+            padding: 8px 12px;
+            border-radius: 4px;
+            font-size: 12px;
+            white-space: nowrap;
+            border: none;
+            box-shadow: 0 2px 8px rgba(0,0,0,0.2);
+        }
+
+        .map-tooltip::before {
+            display: none;
+        }
+
+        .leaflet-tooltip-left.map-tooltip::before,
+        .leaflet-tooltip-right.map-tooltip::before {
+            display: none;
+        }
+
+        /* Hover marker */
+        .hover-marker {
+            background: #e74c3c;
+            border: 3px solid white;
+            border-radius: 50%;
+            box-shadow: 0 2px 5px rgba(0,0,0,0.3);
+        }
+
+        #no-track-message {
+            text-align: center;
+            color: #95a5a6;
+            padding: 40px 20px;
+        }
+
+        .hidden {
+            display: none !important;
+        }
+
+        .settings-row a {
+            color: #3498db;
+            text-decoration: none;
+        }
+
+        .settings-row a:hover {
+            text-decoration: underline;
+        }
+    </style>
+</head>
+<body>
+    <div id="map-container">
+        <div id="map"></div>
+        <div id="drop-overlay">
+            <div id="drop-overlay-content">
+                <div class="icon">&#128506;</div>
+                <p>Drop GPX file here</p>
+            </div>
+        </div>
+        <div id="file-prompt">
+            <h2>GPX Visualizer</h2>
+            <p>Drag and drop a GPX file onto the map<br>or click below to select</p>
+            <label id="file-input-label">
+                Select GPX File
+                <input type="file" id="file-input" accept=".gpx">
+            </label>
+        </div>
+    </div>
+
+    <button id="sidebar-toggle" title="Toggle sidebar">&#9664;</button>
+
+    <div id="sidebar">
+        <div id="sidebar-content">
+            <div id="no-track-message">
+                <p>No track loaded</p>
+                <p style="font-size: 12px; margin-top: 8px;">Load a GPX file to see statistics</p>
+            </div>
+
+            <div id="track-data" class="hidden">
+                <div class="section">
+                    <h3>Track Information</h3>
+                    <div id="track-info">-</div>
+                    <div class="stat-grid">
+                        <div class="stat-item">
+                            <div class="stat-label">Distance</div>
+                            <div class="stat-value" id="stat-distance">-</div>
+                        </div>
+                        <div class="stat-item">
+                            <div class="stat-label">Duration</div>
+                            <div class="stat-value" id="stat-duration">-</div>
+                        </div>
+                        <div class="stat-item">
+                            <div class="stat-label">Avg Speed</div>
+                            <div class="stat-value" id="stat-avg-speed">-</div>
+                        </div>
+                        <div class="stat-item">
+                            <div class="stat-label">Max Speed</div>
+                            <div class="stat-value" id="stat-max-speed">-</div>
+                        </div>
+                        <div class="stat-item">
+                            <div class="stat-label">Elevation Gain</div>
+                            <div class="stat-value" id="stat-elev-gain">-</div>
+                        </div>
+                        <div class="stat-item">
+                            <div class="stat-label">Elevation Loss</div>
+                            <div class="stat-value" id="stat-elev-loss">-</div>
+                        </div>
+                        <div class="stat-item">
+                            <div class="stat-label">Min Elevation</div>
+                            <div class="stat-value" id="stat-elev-min">-</div>
+                        </div>
+                        <div class="stat-item">
+                            <div class="stat-label">Max Elevation</div>
+                            <div class="stat-value" id="stat-elev-max">-</div>
+                        </div>
+                        <div class="stat-item full-width">
+                            <div class="stat-label">Time Range</div>
+                            <div class="stat-value small" id="stat-time-range">-</div>
+                        </div>
+                    </div>
+                </div>
+
+                <div class="section">
+                    <h3>Elevation Profile</h3>
+                    <div id="elevation-warning" class="elevation-warning hidden">
+                        Elevation data missing. <button class="btn btn-primary" id="fetch-elevation-btn" style="padding: 4px 8px; font-size: 11px;">Fetch from API</button>
+                    </div>
+                    <div class="loading-indicator" id="elevation-loading">
+                        <div class="spinner"></div>
+                        <span>Fetching elevation data...</span>
+                    </div>
+                    <div id="elevation-chart-container">
+                        <canvas id="elevation-chart"></canvas>
+                        <div id="chart-tooltip"></div>
+                    </div>
+                </div>
+
+                <div class="section">
+                    <h3>Track Style</h3>
+                    <div class="settings-row">
+                        <label>Track Color</label>
+                        <div class="track-color-picker">
+                            <input type="color" id="track-color" value="#3498db">
+                            <span id="track-color-value">#3498db</span>
+                        </div>
+                    </div>
+                    <div class="settings-row">
+                        <label>Track Width</label>
+                        <div class="track-width-slider">
+                            <input type="range" id="track-width" min="1" max="10" value="3">
+                            <span id="track-width-value">3px</span>
+                        </div>
+                    </div>
+                </div>
+
+                <div class="section">
+                    <h3>Map Settings</h3>
+                    <div class="settings-row">
+                        <label>Tile Server</label>
+                        <select id="tile-server">
+                            <option value="freemap">Freemap.sk</option>
+                            <option value="osm">OpenStreetMap</option>
+                            <option value="osmde">OpenStreetMap DE</option>
+                            <option value="topo">OpenTopoMap</option>
+                            <option value="cycle">CyclOSM</option>
+                            <option value="satellite">ESRI Satellite</option>
+                            <optgroup label="Mapy.cz (requires API key)">
+                                <option value="mapy-outdoor">Mapy.cz Outdoor</option>
+                                <option value="mapy-basic">Mapy.cz Basic</option>
+                                <option value="mapy-aerial">Mapy.cz Aerial</option>
+                                <option value="mapy-winter">Mapy.cz Winter</option>
+                            </optgroup>
+                            <option value="custom">Custom...</option>
+                        </select>
+                    </div>
+                    <div class="settings-row hidden" id="mapy-apikey-row">
+                        <label>Mapy.cz API Key (<a href="https://developer.mapy.com/rest-api-mapy-cz/how-to-start/" target="_blank">get free key</a>)</label>
+                        <input type="text" id="mapy-apikey" placeholder="Enter your API key">
+                        <button class="btn btn-primary btn-block" id="apply-mapy-apikey" style="margin-top: 8px;">Apply</button>
+                    </div>
+                    <div class="settings-row hidden" id="custom-tile-row">
+                        <label>Custom Tile URL</label>
+                        <input type="text" id="custom-tile-url" placeholder="https://{s}.tile.example.com/{z}/{x}/{y}.png">
+                        <button class="btn btn-primary btn-block" id="apply-custom-tile" style="margin-top: 8px;">Apply</button>
+                    </div>
+                </div>
+
+                <div class="section">
+                    <h3>Actions</h3>
+                    <button class="btn btn-secondary btn-block" id="fit-bounds-btn">Fit Track to View</button>
+                    <button class="btn btn-secondary btn-block" id="load-another-btn" style="margin-top: 8px;">Load Another File</button>
+                </div>
+            </div>
+        </div>
+    </div>
+
+    <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
+    <script>
+        // ==================== Configuration ====================
+        const TILE_SERVERS = {
+            freemap: {
+                url: 'https://{s}.freemap.sk/T/{z}/{x}/{y}.jpeg',
+                attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>, <a href="https://freemap.sk">Freemap.sk</a> CC-By-SA 2.0',
+                subdomains: 'abcd',
+                minZoom: 8,
+                maxZoom: 16
+            },
+            osm: {
+                url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
+                attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
+            },
+            osmde: {
+                url: 'https://{s}.tile.openstreetmap.de/{z}/{x}/{y}.png',
+                attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
+            },
+            topo: {
+                url: 'https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png',
+                attribution: '&copy; <a href="https://opentopomap.org">OpenTopoMap</a> (CC-BY-SA)'
+            },
+            cycle: {
+                url: 'https://{s}.tile-cyclosm.openstreetmap.fr/cyclosm/{z}/{x}/{y}.png',
+                attribution: '&copy; <a href="https://www.cyclosm.org">CyclOSM</a>'
+            },
+            satellite: {
+                url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
+                attribution: '&copy; Esri',
+                subdomains: []
+            },
+            'mapy-outdoor': {
+                url: 'https://api.mapy.cz/v1/maptiles/outdoor/256/{z}/{x}/{y}?apikey={apikey}',
+                attribution: '&copy; <a href="https://mapy.cz">Mapy.cz</a>',
+                requiresApiKey: true
+            },
+            'mapy-basic': {
+                url: 'https://api.mapy.cz/v1/maptiles/basic/256/{z}/{x}/{y}?apikey={apikey}',
+                attribution: '&copy; <a href="https://mapy.cz">Mapy.cz</a>',
+                requiresApiKey: true
+            },
+            'mapy-aerial': {
+                url: 'https://api.mapy.cz/v1/maptiles/aerial/256/{z}/{x}/{y}?apikey={apikey}',
+                attribution: '&copy; <a href="https://mapy.cz">Mapy.cz</a>',
+                requiresApiKey: true
+            },
+            'mapy-winter': {
+                url: 'https://api.mapy.cz/v1/maptiles/winter/256/{z}/{x}/{y}?apikey={apikey}',
+                attribution: '&copy; <a href="https://mapy.cz">Mapy.cz</a>',
+                requiresApiKey: true
+            }
+        };
+
+        const ELEVATION_API = 'https://api.open-elevation.com/api/v1/lookup';
+        const ELEVATION_BATCH_SIZE = 100; // Points per API request
+        const CHART_MAX_POINTS = 500; // Downsample for chart rendering
+        const TRACK_HOVER_TOLERANCE_PX = 20; // Pixels distance for track hover detection
+
+        // ==================== State ====================
+        let map = null;
+        let tileLayer = null;
+        let trackLayer = null;
+        let hoverMarker = null;
+        let stickyPoint = null; // Non-null when tooltip is pinned by click
+        let trackData = null; // { points: [], stats: {} }
+        let chartData = null; // Downsampled for chart
+
+        // ==================== DOM Elements ====================
+        const elements = {
+            map: document.getElementById('map'),
+            dropOverlay: document.getElementById('drop-overlay'),
+            filePrompt: document.getElementById('file-prompt'),
+            fileInput: document.getElementById('file-input'),
+            sidebar: document.getElementById('sidebar'),
+            sidebarToggle: document.getElementById('sidebar-toggle'),
+            noTrackMessage: document.getElementById('no-track-message'),
+            trackDataSection: document.getElementById('track-data'),
+            elevationChart: document.getElementById('elevation-chart'),
+            chartTooltip: document.getElementById('chart-tooltip'),
+            elevationWarning: document.getElementById('elevation-warning'),
+            elevationLoading: document.getElementById('elevation-loading'),
+            fetchElevationBtn: document.getElementById('fetch-elevation-btn'),
+            trackColor: document.getElementById('track-color'),
+            trackColorValue: document.getElementById('track-color-value'),
+            trackWidth: document.getElementById('track-width'),
+            trackWidthValue: document.getElementById('track-width-value'),
+            tileServer: document.getElementById('tile-server'),
+            customTileRow: document.getElementById('custom-tile-row'),
+            customTileUrl: document.getElementById('custom-tile-url'),
+            applyCustomTile: document.getElementById('apply-custom-tile'),
+            mapyApikeyRow: document.getElementById('mapy-apikey-row'),
+            mapyApikey: document.getElementById('mapy-apikey'),
+            applyMapyApikey: document.getElementById('apply-mapy-apikey'),
+            fitBoundsBtn: document.getElementById('fit-bounds-btn'),
+            loadAnotherBtn: document.getElementById('load-another-btn'),
+            stats: {
+                distance: document.getElementById('stat-distance'),
+                duration: document.getElementById('stat-duration'),
+                avgSpeed: document.getElementById('stat-avg-speed'),
+                maxSpeed: document.getElementById('stat-max-speed'),
+                elevGain: document.getElementById('stat-elev-gain'),
+                elevLoss: document.getElementById('stat-elev-loss'),
+                elevMin: document.getElementById('stat-elev-min'),
+                elevMax: document.getElementById('stat-elev-max'),
+                timeRange: document.getElementById('stat-time-range')
+            },
+            trackInfo: document.getElementById('track-info')
+        };
+
+        // ==================== Initialization ====================
+        function init() {
+            initMap();
+            initEventListeners();
+        }
+
+        function initMap() {
+            map = L.map('map').setView([50.6616, 16.2814], 14); // Ruprechtický Špičák
+            setTileServer('freemap');
+
+            // Create hover marker (hidden initially)
+            const hoverIcon = L.divIcon({
+                className: 'hover-marker',
+                iconSize: [14, 14],
+                iconAnchor: [7, 7]
+            });
+            hoverMarker = L.marker([0, 0], { icon: hoverIcon, interactive: false });
+        }
+
+        function initEventListeners() {
+            // File input
+            elements.fileInput.addEventListener('change', handleFileSelect);
+
+            // Drag and drop
+            document.addEventListener('dragover', handleDragOver);
+            document.addEventListener('dragleave', handleDragLeave);
+            document.addEventListener('drop', handleDrop);
+
+            // Sidebar toggle
+            elements.sidebarToggle.addEventListener('click', toggleSidebar);
+
+            // Track style
+            elements.trackColor.addEventListener('input', updateTrackStyle);
+            elements.trackWidth.addEventListener('input', updateTrackStyle);
+
+            // Tile server
+            elements.tileServer.addEventListener('change', handleTileServerChange);
+            elements.applyCustomTile.addEventListener('click', applyCustomTileServer);
+            elements.applyMapyApikey.addEventListener('click', applyMapyApikey);
+
+            // Actions
+            elements.fitBoundsBtn.addEventListener('click', fitTrackBounds);
+            elements.loadAnotherBtn.addEventListener('click', () => elements.fileInput.click());
+            elements.fetchElevationBtn.addEventListener('click', fetchElevationData);
+
+            // Elevation chart interaction
+            elements.elevationChart.addEventListener('mousemove', handleChartHover);
+            elements.elevationChart.addEventListener('mouseleave', handleChartLeave);
+
+            // Map hover for track (with tolerance)
+            map.on('mousemove', handleMapHover);
+            map.on('mouseout', handleMapLeave);
+            map.on('click', handleMapClick);
+        }
+
+        // ==================== File Handling ====================
+        function handleDragOver(e) {
+            e.preventDefault();
+            e.stopPropagation();
+            elements.dropOverlay.classList.add('active');
+        }
+
+        function handleDragLeave(e) {
+            e.preventDefault();
+            e.stopPropagation();
+            if (e.target === elements.dropOverlay || !elements.dropOverlay.contains(e.relatedTarget)) {
+                elements.dropOverlay.classList.remove('active');
+            }
+        }
+
+        function handleDrop(e) {
+            e.preventDefault();
+            e.stopPropagation();
+            elements.dropOverlay.classList.remove('active');
+
+            const files = e.dataTransfer.files;
+            if (files.length > 0 && files[0].name.toLowerCase().endsWith('.gpx')) {
+                loadGPXFile(files[0]);
+            }
+        }
+
+        function handleFileSelect(e) {
+            const file = e.target.files[0];
+            if (file) {
+                loadGPXFile(file);
+            }
+        }
+
+        function loadGPXFile(file) {
+            const reader = new FileReader();
+            reader.onload = (e) => {
+                try {
+                    parseGPX(e.target.result, file.name);
+                    elements.filePrompt.classList.add('hidden');
+                } catch (err) {
+                    alert('Error parsing GPX file: ' + err.message);
+                }
+            };
+            reader.readAsText(file);
+        }
+
+        // ==================== GPX Parsing ====================
+        function parseGPX(xmlString, fileName) {
+            const parser = new DOMParser();
+            const doc = parser.parseFromString(xmlString, 'application/xml');
+
+            const parseError = doc.querySelector('parsererror');
+            if (parseError) {
+                throw new Error('Invalid XML format');
+            }
+
+            // Extract track points - memory efficient approach
+            const points = [];
+            const trkpts = doc.querySelectorAll('trkpt');
+            const rtepts = doc.querySelectorAll('rtept');
+            const wpts = doc.querySelectorAll('wpt');
+
+            // Process track points
+            const allPoints = [...trkpts, ...rtepts];
+
+            if (allPoints.length === 0 && wpts.length === 0) {
+                throw new Error('No track points found in GPX file');
+            }
+
+            let hasElevation = false;
+            let hasTime = false;
+
+            for (const pt of allPoints) {
+                const lat = parseFloat(pt.getAttribute('lat'));
+                const lon = parseFloat(pt.getAttribute('lon'));
+
+                if (isNaN(lat) || isNaN(lon)) continue;
+
+                const eleNode = pt.querySelector('ele');
+                const timeNode = pt.querySelector('time');
+
+                const point = {
+                    lat,
+                    lon,
+                    ele: eleNode ? parseFloat(eleNode.textContent) : null,
+                    time: timeNode ? new Date(timeNode.textContent) : null
+                };
+
+                if (point.ele !== null && !isNaN(point.ele)) hasElevation = true;
+                if (point.time !== null) hasTime = true;
+
+                points.push(point);
+            }
+
+            if (points.length === 0) {
+                throw new Error('No valid track points found');
+            }
+
+            // Get track name
+            const nameNode = doc.querySelector('trk > name') || doc.querySelector('rte > name') || doc.querySelector('metadata > name');
+            const trackName = nameNode ? nameNode.textContent : fileName.replace('.gpx', '');
+
+            // Calculate derived data (distance from start for each point)
+            let totalDist = 0;
+            for (let i = 0; i < points.length; i++) {
+                if (i === 0) {
+                    points[i].dist = 0;
+                } else {
+                    const d = haversineDistance(
+                        points[i - 1].lat, points[i - 1].lon,
+                        points[i].lat, points[i].lon
+                    );
+                    totalDist += d;
+                    points[i].dist = totalDist;
+                }
+            }
+
+            // Store track data
+            trackData = {
+                name: trackName,
+                points,
+                hasElevation,
+                hasTime,
+                stats: calculateStats(points, hasElevation, hasTime)
+            };
+
+            // Downsample for chart
+            chartData = downsamplePoints(points, CHART_MAX_POINTS);
+
+            // Update UI
+            displayTrack();
+            displayStats();
+
+            // Delay chart drawing to ensure container is visible and has dimensions
+            requestAnimationFrame(() => {
+                requestAnimationFrame(() => {
+                    drawElevationChart();
+                });
+            });
+
+            // Show elevation warning if no elevation data
+            if (!hasElevation) {
+                elements.elevationWarning.classList.remove('hidden');
+            } else {
+                elements.elevationWarning.classList.add('hidden');
+            }
+
+            // Show track data section
+            elements.noTrackMessage.classList.add('hidden');
+            elements.trackDataSection.classList.remove('hidden');
+        }
+
+        // ==================== Calculations ====================
+        function haversineDistance(lat1, lon1, lat2, lon2) {
+            const R = 6371000; // Earth's radius in meters
+            const dLat = (lat2 - lat1) * Math.PI / 180;
+            const dLon = (lon2 - lon1) * Math.PI / 180;
+            const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
+                Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
+                Math.sin(dLon / 2) * Math.sin(dLon / 2);
+            const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
+            return R * c;
+        }
+
+        function calculateStats(points, hasElevation, hasTime) {
+            const stats = {
+                distance: points[points.length - 1].dist,
+                duration: null,
+                avgSpeed: null,
+                maxSpeed: null,
+                elevGain: null,
+                elevLoss: null,
+                elevMin: null,
+                elevMax: null,
+                startTime: null,
+                endTime: null
+            };
+
+            // Time-based stats
+            if (hasTime) {
+                const validTimes = points.filter(p => p.time !== null);
+                if (validTimes.length >= 2) {
+                    stats.startTime = validTimes[0].time;
+                    stats.endTime = validTimes[validTimes.length - 1].time;
+                    stats.duration = (stats.endTime - stats.startTime) / 1000; // seconds
+
+                    if (stats.duration > 0) {
+                        stats.avgSpeed = stats.distance / stats.duration; // m/s
+
+                        // Calculate max speed (with smoothing)
+                        let maxSpeed = 0;
+                        for (let i = 1; i < validTimes.length; i++) {
+                            const dt = (validTimes[i].time - validTimes[i - 1].time) / 1000;
+                            if (dt > 0) {
+                                const dist = validTimes[i].dist - validTimes[i - 1].dist;
+                                const speed = dist / dt;
+                                if (speed > maxSpeed && speed < 100) { // Filter unrealistic speeds
+                                    maxSpeed = speed;
+                                }
+                            }
+                        }
+                        stats.maxSpeed = maxSpeed;
+                    }
+                }
+            }
+
+            // Elevation stats
+            if (hasElevation) {
+                let gain = 0, loss = 0;
+                let min = Infinity, max = -Infinity;
+                let prevEle = null;
+
+                // Use smoothing for elevation gain/loss calculation
+                const smoothedEle = smoothElevation(points);
+
+                for (let i = 0; i < points.length; i++) {
+                    const ele = smoothedEle[i];
+                    if (ele === null) continue;
+
+                    if (ele < min) min = ele;
+                    if (ele > max) max = ele;
+
+                    if (prevEle !== null) {
+                        const diff = ele - prevEle;
+                        if (diff > 0) gain += diff;
+                        else loss -= diff;
+                    }
+                    prevEle = ele;
+                }
+
+                stats.elevGain = gain;
+                stats.elevLoss = loss;
+                stats.elevMin = min === Infinity ? null : min;
+                stats.elevMax = max === -Infinity ? null : max;
+            }
+
+            return stats;
+        }
+
+        function smoothElevation(points, windowSize = 5) {
+            // Simple moving average smoothing
+            const result = [];
+            for (let i = 0; i < points.length; i++) {
+                if (points[i].ele === null) {
+                    result.push(null);
+                    continue;
+                }
+
+                let sum = 0, count = 0;
+                for (let j = Math.max(0, i - windowSize); j <= Math.min(points.length - 1, i + windowSize); j++) {
+                    if (points[j].ele !== null) {
+                        sum += points[j].ele;
+                        count++;
+                    }
+                }
+                result.push(count > 0 ? sum / count : null);
+            }
+            return result;
+        }
+
+        function downsamplePoints(points, maxPoints) {
+            if (points.length <= maxPoints) {
+                return points.map((p, i) => ({ ...p, originalIndex: i }));
+            }
+
+            const result = [];
+            const step = (points.length - 1) / (maxPoints - 1);
+
+            for (let i = 0; i < maxPoints; i++) {
+                const idx = Math.round(i * step);
+                result.push({ ...points[idx], originalIndex: idx });
+            }
+
+            return result;
+        }
+
+        // ==================== Map Display ====================
+        function displayTrack() {
+            // Remove existing track
+            if (trackLayer) {
+                map.removeLayer(trackLayer);
+            }
+            // Clear sticky state when loading a new track
+            stickyPoint = null;
+            hideHoverPoint(true);
+
+            // Create polyline from points
+            const latLngs = trackData.points.map(p => [p.lat, p.lon]);
+
+            trackLayer = L.polyline(latLngs, {
+                color: elements.trackColor.value,
+                weight: parseInt(elements.trackWidth.value),
+                opacity: 0.8,
+                lineJoin: 'round'
+            }).addTo(map);
+
+            // Track hover is handled at map level for better tolerance
+
+            // Fit map to track bounds
+            fitTrackBounds();
+
+            // Update track info
+            elements.trackInfo.textContent = `${trackData.name} (${trackData.points.length.toLocaleString()} points)`;
+        }
+
+        function fitTrackBounds() {
+            if (trackLayer) {
+                map.fitBounds(trackLayer.getBounds(), { padding: [50, 50] });
+            }
+        }
+
+        function updateTrackStyle() {
+            const color = elements.trackColor.value;
+            const width = parseInt(elements.trackWidth.value);
+
+            elements.trackColorValue.textContent = color;
+            elements.trackWidthValue.textContent = width + 'px';
+
+            if (trackLayer) {
+                trackLayer.setStyle({
+                    color: color,
+                    weight: width
+                });
+            }
+        }
+
+        // ==================== Tile Server ====================
+        function setTileServer(serverId, customUrl = null, apikey = null) {
+            if (tileLayer) {
+                map.removeLayer(tileLayer);
+            }
+
+            let url, attribution, options = {};
+
+            if (serverId === 'custom' && customUrl) {
+                url = customUrl;
+                attribution = 'Custom tiles';
+            } else {
+                const server = TILE_SERVERS[serverId];
+                if (!server) return;
+
+                url = server.url;
+                attribution = server.attribution;
+
+                // Handle API key substitution
+                if (server.requiresApiKey && apikey) {
+                    url = url.replace('{apikey}', apikey);
+                } else if (server.requiresApiKey && !apikey) {
+                    // Don't set tile layer without API key
+                    return;
+                }
+
+                if (server.subdomains !== undefined) {
+                    options.subdomains = server.subdomains;
+                }
+                if (server.minZoom !== undefined) {
+                    options.minZoom = server.minZoom;
+                }
+                if (server.maxZoom !== undefined) {
+                    options.maxZoom = server.maxZoom;
+                }
+            }
+
+            tileLayer = L.tileLayer(url, {
+                attribution,
+                maxZoom: options.maxZoom || 19,
+                ...options
+            }).addTo(map);
+        }
+
+        function handleTileServerChange() {
+            const value = elements.tileServer.value;
+
+            // Hide all extra rows first
+            elements.customTileRow.classList.add('hidden');
+            elements.mapyApikeyRow.classList.add('hidden');
+
+            if (value === 'custom') {
+                elements.customTileRow.classList.remove('hidden');
+            } else if (value.startsWith('mapy-')) {
+                const server = TILE_SERVERS[value];
+                if (server && server.requiresApiKey) {
+                    elements.mapyApikeyRow.classList.remove('hidden');
+                    // If we already have an API key, apply immediately
+                    const apikey = elements.mapyApikey.value.trim();
+                    if (apikey) {
+                        setTileServer(value, null, apikey);
+                    }
+                }
+            } else {
+                setTileServer(value);
+            }
+        }
+
+        function applyCustomTileServer() {
+            const url = elements.customTileUrl.value.trim();
+            if (url) {
+                setTileServer('custom', url);
+            }
+        }
+
+        function applyMapyApikey() {
+            const apikey = elements.mapyApikey.value.trim();
+            const serverId = elements.tileServer.value;
+            if (apikey && serverId.startsWith('mapy-')) {
+                setTileServer(serverId, null, apikey);
+            } else if (!apikey) {
+                alert('Please enter your Mapy.cz API key');
+            }
+        }
+
+        // ==================== Statistics Display ====================
+        function displayStats() {
+            const stats = trackData.stats;
+
+            // Distance
+            if (stats.distance >= 1000) {
+                elements.stats.distance.textContent = (stats.distance / 1000).toFixed(2) + ' km';
+            } else {
+                elements.stats.distance.textContent = Math.round(stats.distance) + ' m';
+            }
+
+            // Duration
+            if (stats.duration !== null) {
+                elements.stats.duration.textContent = formatDuration(stats.duration);
+            } else {
+                elements.stats.duration.textContent = '-';
+            }
+
+            // Average speed
+            if (stats.avgSpeed !== null) {
+                elements.stats.avgSpeed.textContent = (stats.avgSpeed * 3.6).toFixed(1) + ' km/h';
+            } else {
+                elements.stats.avgSpeed.textContent = '-';
+            }
+
+            // Max speed
+            if (stats.maxSpeed !== null) {
+                elements.stats.maxSpeed.textContent = (stats.maxSpeed * 3.6).toFixed(1) + ' km/h';
+            } else {
+                elements.stats.maxSpeed.textContent = '-';
+            }
+
+            // Elevation
+            if (stats.elevGain !== null) {
+                elements.stats.elevGain.textContent = Math.round(stats.elevGain) + ' m';
+                elements.stats.elevLoss.textContent = Math.round(stats.elevLoss) + ' m';
+                elements.stats.elevMin.textContent = Math.round(stats.elevMin) + ' m';
+                elements.stats.elevMax.textContent = Math.round(stats.elevMax) + ' m';
+            } else {
+                elements.stats.elevGain.textContent = '-';
+                elements.stats.elevLoss.textContent = '-';
+                elements.stats.elevMin.textContent = '-';
+                elements.stats.elevMax.textContent = '-';
+            }
+
+            // Time range
+            if (stats.startTime && stats.endTime) {
+                const options = {
+                    year: 'numeric', month: 'short', day: 'numeric',
+                    hour: '2-digit', minute: '2-digit'
+                };
+                elements.stats.timeRange.textContent =
+                    stats.startTime.toLocaleDateString(undefined, options);
+            } else {
+                elements.stats.timeRange.textContent = '-';
+            }
+        }
+
+        function formatDuration(seconds) {
+            const h = Math.floor(seconds / 3600);
+            const m = Math.floor((seconds % 3600) / 60);
+            const s = Math.floor(seconds % 60);
+
+            if (h > 0) {
+                return `${h}h ${m}m`;
+            } else if (m > 0) {
+                return `${m}m ${s}s`;
+            } else {
+                return `${s}s`;
+            }
+        }
+
+        // ==================== Elevation Chart ====================
+        function drawElevationChart() {
+            const canvas = elements.elevationChart;
+            const ctx = canvas.getContext('2d');
+
+            // Set canvas size for high DPI displays
+            const rect = canvas.getBoundingClientRect();
+            const dpr = window.devicePixelRatio || 1;
+            canvas.width = rect.width * dpr;
+            canvas.height = rect.height * dpr;
+            ctx.scale(dpr, dpr);
+
+            // Clear canvas
+            ctx.clearRect(0, 0, rect.width, rect.height);
+
+            if (!chartData || chartData.length === 0) return;
+
+            // Check if we have elevation data
+            const hasElevation = chartData.some(p => p.ele !== null);
+            if (!hasElevation) {
+                ctx.fillStyle = '#95a5a6';
+                ctx.font = '14px sans-serif';
+                ctx.textAlign = 'center';
+                ctx.fillText('No elevation data', rect.width / 2, rect.height / 2);
+                return;
+            }
+
+            // Calculate bounds
+            const padding = { top: 20, right: 20, bottom: 30, left: 50 };
+            const chartWidth = rect.width - padding.left - padding.right;
+            const chartHeight = rect.height - padding.top - padding.bottom;
+
+            const elevations = chartData.map(p => p.ele).filter(e => e !== null);
+            const minEle = Math.min(...elevations);
+            const maxEle = Math.max(...elevations);
+            const eleRange = maxEle - minEle || 1;
+            const totalDist = chartData[chartData.length - 1].dist;
+
+            // Draw grid lines
+            ctx.strokeStyle = '#eee';
+            ctx.lineWidth = 1;
+
+            // Horizontal grid lines
+            const eleSteps = 4;
+            for (let i = 0; i <= eleSteps; i++) {
+                const y = padding.top + (chartHeight * i / eleSteps);
+                ctx.beginPath();
+                ctx.moveTo(padding.left, y);
+                ctx.lineTo(rect.width - padding.right, y);
+                ctx.stroke();
+
+                // Elevation labels
+                const ele = maxEle - (eleRange * i / eleSteps);
+                ctx.fillStyle = '#95a5a6';
+                ctx.font = '10px sans-serif';
+                ctx.textAlign = 'right';
+                ctx.fillText(Math.round(ele) + 'm', padding.left - 5, y + 3);
+            }
+
+            // Distance labels
+            ctx.textAlign = 'center';
+            const distSteps = 5;
+            for (let i = 0; i <= distSteps; i++) {
+                const x = padding.left + (chartWidth * i / distSteps);
+                const dist = totalDist * i / distSteps;
+                const label = dist >= 1000 ? (dist / 1000).toFixed(1) + 'km' : Math.round(dist) + 'm';
+                ctx.fillText(label, x, rect.height - 10);
+            }
+
+            // Draw elevation profile
+            ctx.beginPath();
+            ctx.moveTo(padding.left, padding.top + chartHeight);
+
+            let firstPoint = true;
+            for (const point of chartData) {
+                if (point.ele === null) continue;
+
+                const x = padding.left + (point.dist / totalDist) * chartWidth;
+                const y = padding.top + chartHeight - ((point.ele - minEle) / eleRange) * chartHeight;
+
+                if (firstPoint) {
+                    ctx.moveTo(x, y);
+                    firstPoint = false;
+                } else {
+                    ctx.lineTo(x, y);
+                }
+            }
+
+            // Close path for fill
+            const lastPoint = chartData[chartData.length - 1];
+            const lastX = padding.left + chartWidth;
+            ctx.lineTo(lastX, padding.top + chartHeight);
+            ctx.lineTo(padding.left, padding.top + chartHeight);
+            ctx.closePath();
+
+            // Fill
+            const gradient = ctx.createLinearGradient(0, padding.top, 0, rect.height - padding.bottom);
+            gradient.addColorStop(0, 'rgba(52, 152, 219, 0.6)');
+            gradient.addColorStop(1, 'rgba(52, 152, 219, 0.1)');
+            ctx.fillStyle = gradient;
+            ctx.fill();
+
+            // Stroke
+            ctx.strokeStyle = '#3498db';
+            ctx.lineWidth = 2;
+            ctx.beginPath();
+            firstPoint = true;
+            for (const point of chartData) {
+                if (point.ele === null) continue;
+
+                const x = padding.left + (point.dist / totalDist) * chartWidth;
+                const y = padding.top + chartHeight - ((point.ele - minEle) / eleRange) * chartHeight;
+
+                if (firstPoint) {
+                    ctx.moveTo(x, y);
+                    firstPoint = false;
+                } else {
+                    ctx.lineTo(x, y);
+                }
+            }
+            ctx.stroke();
+
+            // Store chart bounds for hover detection
+            canvas.chartBounds = {
+                padding,
+                chartWidth,
+                chartHeight,
+                minEle,
+                maxEle,
+                eleRange,
+                totalDist
+            };
+        }
+
+        // ==================== Hover Interactions ====================
+        function handleChartHover(e) {
+            const canvas = elements.elevationChart;
+            const rect = canvas.getBoundingClientRect();
+            const bounds = canvas.chartBounds;
+
+            if (!bounds || !chartData) return;
+
+            const x = e.clientX - rect.left;
+            const y = e.clientY - rect.top;
+
+            // Check if within chart area
+            if (x < bounds.padding.left || x > bounds.padding.left + bounds.chartWidth) {
+                handleChartLeave();
+                return;
+            }
+
+            // Calculate distance at cursor
+            const distRatio = (x - bounds.padding.left) / bounds.chartWidth;
+            const dist = distRatio * bounds.totalDist;
+
+            // Find nearest point
+            let nearestPoint = null;
+            let nearestDist = Infinity;
+            for (const point of chartData) {
+                const d = Math.abs(point.dist - dist);
+                if (d < nearestDist) {
+                    nearestDist = d;
+                    nearestPoint = point;
+                }
+            }
+
+            if (nearestPoint) {
+                showHoverPoint(nearestPoint, 'chart');
+
+                // Show tooltip
+                const tooltip = elements.chartTooltip;
+                tooltip.innerHTML = formatPointTooltip(nearestPoint);
+                tooltip.classList.add('visible');
+
+                // Position tooltip
+                const pointX = bounds.padding.left + (nearestPoint.dist / bounds.totalDist) * bounds.chartWidth;
+                const pointY = nearestPoint.ele !== null ?
+                    bounds.padding.top + bounds.chartHeight - ((nearestPoint.ele - bounds.minEle) / bounds.eleRange) * bounds.chartHeight :
+                    bounds.padding.top + bounds.chartHeight / 2;
+
+                tooltip.style.left = Math.min(pointX + 10, rect.width - tooltip.offsetWidth - 10) + 'px';
+                tooltip.style.top = Math.max(10, pointY - tooltip.offsetHeight / 2) + 'px';
+
+                // Draw hover indicator on chart
+                drawChartHoverIndicator(pointX, pointY, bounds);
+            }
+        }
+
+        function handleChartLeave() {
+            hideHoverPoint();
+        }
+
+        function handleMapHover(e) {
+            if (stickyPoint) return; // Don't update while sticky
+            if (!trackData || !trackData.points.length) return;
+
+            const latlng = e.latlng;
+            const mousePoint = map.latLngToContainerPoint(latlng);
+
+            // Find nearest point within pixel tolerance
+            let nearestPoint = null;
+            let nearestPixelDist = Infinity;
+
+            for (const point of trackData.points) {
+                const pointLatLng = L.latLng(point.lat, point.lon);
+                const pointPixel = map.latLngToContainerPoint(pointLatLng);
+                const pixelDist = mousePoint.distanceTo(pointPixel);
+
+                if (pixelDist < nearestPixelDist) {
+                    nearestPixelDist = pixelDist;
+                    nearestPoint = point;
+                }
+            }
+
+            if (nearestPoint && nearestPixelDist <= TRACK_HOVER_TOLERANCE_PX) {
+                showHoverPoint(nearestPoint, 'map');
+            } else {
+                hideHoverPoint();
+            }
+        }
+
+        function handleMapLeave() {
+            if (stickyPoint) return; // Keep sticky tooltip visible
+            hideHoverPoint();
+        }
+
+        function handleMapClick() {
+            if (stickyPoint) {
+                // Second click: un-stick and hide
+                stickyPoint = null;
+                hideHoverPoint(true);
+            } else if (hoverMarker && map.hasLayer(hoverMarker)) {
+                // Tooltip is showing: make it sticky
+                stickyPoint = true;
+            }
+        }
+
+        function hideHoverPoint(force) {
+            if (stickyPoint && !force) return; // Don't hide while sticky
+            if (hoverMarker && map.hasLayer(hoverMarker)) {
+                elements.chartTooltip.classList.remove('visible');
+                hoverMarker.remove();
+                if (chartData && chartData.length > 0) {
+                    drawElevationChart(); // Redraw to clear hover indicator
+                }
+            }
+        }
+
+        function showHoverPoint(point, source) {
+            // Show marker on map
+            hoverMarker.setLatLng([point.lat, point.lon]);
+            hoverMarker.addTo(map);
+
+            // Show tooltip on map
+            hoverMarker.bindTooltip(formatPointTooltip(point), {
+                permanent: true,
+                direction: 'top',
+                className: 'map-tooltip',
+                offset: [0, -10]
+            }).openTooltip();
+
+            // Highlight on chart if source is map
+            if (source === 'map' && elements.elevationChart.chartBounds) {
+                const bounds = elements.elevationChart.chartBounds;
+                const pointX = bounds.padding.left + (point.dist / bounds.totalDist) * bounds.chartWidth;
+                const pointY = point.ele !== null ?
+                    bounds.padding.top + bounds.chartHeight - ((point.ele - bounds.minEle) / bounds.eleRange) * bounds.chartHeight :
+                    bounds.padding.top + bounds.chartHeight / 2;
+
+                drawChartHoverIndicator(pointX, pointY, bounds);
+
+                // Show chart tooltip
+                const tooltip = elements.chartTooltip;
+                const rect = elements.elevationChart.getBoundingClientRect();
+                tooltip.innerHTML = formatPointTooltip(point);
+                tooltip.classList.add('visible');
+                tooltip.style.left = Math.min(pointX + 10, rect.width - tooltip.offsetWidth - 10) + 'px';
+                tooltip.style.top = Math.max(10, pointY - 20) + 'px';
+            }
+        }
+
+        function drawChartHoverIndicator(x, y, bounds) {
+            const canvas = elements.elevationChart;
+            const ctx = canvas.getContext('2d');
+            const dpr = window.devicePixelRatio || 1;
+
+            // Redraw chart first
+            drawElevationChart();
+
+            // Draw vertical line
+            ctx.strokeStyle = 'rgba(231, 76, 60, 0.5)';
+            ctx.lineWidth = 1;
+            ctx.setLineDash([5, 5]);
+            ctx.beginPath();
+            ctx.moveTo(x * dpr, bounds.padding.top * dpr);
+            ctx.lineTo(x * dpr, (bounds.padding.top + bounds.chartHeight) * dpr);
+            ctx.stroke();
+            ctx.setLineDash([]);
+
+            // Draw point marker
+            ctx.fillStyle = '#e74c3c';
+            ctx.beginPath();
+            ctx.arc(x * dpr, y * dpr, 5 * dpr, 0, Math.PI * 2);
+            ctx.fill();
+            ctx.strokeStyle = 'white';
+            ctx.lineWidth = 2 * dpr;
+            ctx.stroke();
+        }
+
+        function formatPointTooltip(point) {
+            let html = '';
+
+            // Distance
+            if (point.dist >= 1000) {
+                html += `<div><strong>Distance:</strong> ${(point.dist / 1000).toFixed(2)} km</div>`;
+            } else {
+                html += `<div><strong>Distance:</strong> ${Math.round(point.dist)} m</div>`;
+            }
+
+            // Elevation
+            if (point.ele !== null) {
+                html += `<div><strong>Elevation:</strong> ${Math.round(point.ele)} m</div>`;
+            }
+
+            // Time
+            if (point.time) {
+                const timeStr = point.time.toLocaleTimeString(undefined, {
+                    hour: '2-digit',
+                    minute: '2-digit',
+                    second: '2-digit'
+                });
+                html += `<div><strong>Time:</strong> ${timeStr}</div>`;
+            }
+
+            // Coordinates
+            html += `<div><strong>Lat:</strong> ${point.lat.toFixed(5)}</div>`;
+            html += `<div><strong>Lon:</strong> ${point.lon.toFixed(5)}</div>`;
+
+            return html;
+        }
+
+        // ==================== Elevation API ====================
+        async function fetchElevationData() {
+            if (!trackData) return;
+
+            elements.fetchElevationBtn.disabled = true;
+            elements.elevationLoading.classList.add('active');
+            elements.elevationWarning.classList.add('hidden');
+
+            try {
+                const points = trackData.points;
+                const batches = [];
+
+                // Create batches
+                for (let i = 0; i < points.length; i += ELEVATION_BATCH_SIZE) {
+                    batches.push(points.slice(i, i + ELEVATION_BATCH_SIZE));
+                }
+
+                // Fetch elevation for each batch
+                for (let i = 0; i < batches.length; i++) {
+                    const batch = batches[i];
+                    const locations = batch.map(p => ({
+                        latitude: p.lat,
+                        longitude: p.lon
+                    }));
+
+                    const response = await fetch(ELEVATION_API, {
+                        method: 'POST',
+                        headers: { 'Content-Type': 'application/json' },
+                        body: JSON.stringify({ locations })
+                    });
+
+                    if (!response.ok) {
+                        throw new Error(`API error: ${response.status}`);
+                    }
+
+                    const data = await response.json();
+
+                    // Update points with elevation data
+                    for (let j = 0; j < data.results.length; j++) {
+                        const pointIndex = i * ELEVATION_BATCH_SIZE + j;
+                        if (pointIndex < points.length) {
+                            points[pointIndex].ele = data.results[j].elevation;
+                        }
+                    }
+
+                    // Small delay between batches to be nice to the API
+                    if (i < batches.length - 1) {
+                        await new Promise(resolve => setTimeout(resolve, 100));
+                    }
+                }
+
+                // Update stats and chart
+                trackData.hasElevation = true;
+                trackData.stats = calculateStats(points, true, trackData.hasTime);
+                chartData = downsamplePoints(points, CHART_MAX_POINTS);
+
+                displayStats();
+                requestAnimationFrame(() => {
+                    requestAnimationFrame(() => {
+                        drawElevationChart();
+                    });
+                });
+
+            } catch (err) {
+                alert('Failed to fetch elevation data: ' + err.message);
+                elements.elevationWarning.classList.remove('hidden');
+            } finally {
+                elements.fetchElevationBtn.disabled = false;
+                elements.elevationLoading.classList.remove('active');
+            }
+        }
+
+        // ==================== Sidebar ====================
+        function toggleSidebar() {
+            const isCollapsed = elements.sidebar.classList.toggle('collapsed');
+            elements.sidebarToggle.classList.toggle('collapsed', isCollapsed);
+            elements.sidebarToggle.innerHTML = isCollapsed ? '&#9654;' : '&#9664;';
+
+            // Invalidate map size after animation
+            setTimeout(() => {
+                map.invalidateSize();
+            }, 350);
+        }
+
+        // ==================== Window Resize ====================
+        window.addEventListener('resize', () => {
+            if (trackData) {
+                requestAnimationFrame(() => {
+                    drawElevationChart();
+                });
+            }
+        });
+
+        // ==================== Start ====================
+        document.addEventListener('DOMContentLoaded', init);
+    </script>
+</body>
+</html>