| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646 |
- <!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">🗺</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">◀</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: '© <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: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
- },
- osmde: {
- url: 'https://{s}.tile.openstreetmap.de/{z}/{x}/{y}.png',
- attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
- },
- topo: {
- url: 'https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png',
- attribution: '© <a href="https://opentopomap.org">OpenTopoMap</a> (CC-BY-SA)'
- },
- cycle: {
- url: 'https://{s}.tile-cyclosm.openstreetmap.fr/cyclosm/{z}/{x}/{y}.png',
- attribution: '© <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: '© Esri',
- subdomains: []
- },
- 'mapy-outdoor': {
- url: 'https://api.mapy.cz/v1/maptiles/outdoor/256/{z}/{x}/{y}?apikey={apikey}',
- attribution: '© <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: '© <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: '© <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: '© <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 ? '▶' : '◀';
- // 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>
|