index.html 58 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646
  1. <!DOCTYPE html>
  2. <html lang="en">
  3. <head>
  4. <meta charset="UTF-8">
  5. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  6. <title>GPX Visualizer</title>
  7. <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
  8. <style>
  9. * {
  10. box-sizing: border-box;
  11. margin: 0;
  12. padding: 0;
  13. }
  14. body {
  15. font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
  16. display: flex;
  17. height: 100vh;
  18. overflow: hidden;
  19. }
  20. #map-container {
  21. flex: 1;
  22. position: relative;
  23. transition: margin-right 0.3s ease;
  24. }
  25. #map {
  26. width: 100%;
  27. height: 100%;
  28. }
  29. #drop-overlay {
  30. position: absolute;
  31. top: 0;
  32. left: 0;
  33. right: 0;
  34. bottom: 0;
  35. background: rgba(52, 152, 219, 0.9);
  36. display: none;
  37. justify-content: center;
  38. align-items: center;
  39. z-index: 1000;
  40. pointer-events: none;
  41. }
  42. #drop-overlay.active {
  43. display: flex;
  44. }
  45. #drop-overlay-content {
  46. text-align: center;
  47. color: white;
  48. }
  49. #drop-overlay-content .icon {
  50. font-size: 64px;
  51. margin-bottom: 16px;
  52. }
  53. #drop-overlay-content p {
  54. font-size: 24px;
  55. font-weight: 500;
  56. }
  57. #file-prompt {
  58. position: absolute;
  59. top: 50%;
  60. left: 50%;
  61. transform: translate(-50%, -50%);
  62. text-align: center;
  63. z-index: 500;
  64. background: white;
  65. padding: 40px;
  66. border-radius: 12px;
  67. box-shadow: 0 4px 20px rgba(0,0,0,0.15);
  68. }
  69. #file-prompt h2 {
  70. margin-bottom: 20px;
  71. color: #2c3e50;
  72. }
  73. #file-prompt p {
  74. color: #7f8c8d;
  75. margin-bottom: 20px;
  76. }
  77. #file-input-label {
  78. display: inline-block;
  79. padding: 12px 24px;
  80. background: #3498db;
  81. color: white;
  82. border-radius: 6px;
  83. cursor: pointer;
  84. transition: background 0.2s;
  85. }
  86. #file-input-label:hover {
  87. background: #2980b9;
  88. }
  89. #file-input {
  90. display: none;
  91. }
  92. #sidebar {
  93. width: 380px;
  94. background: #f8f9fa;
  95. border-left: 1px solid #dee2e6;
  96. display: flex;
  97. flex-direction: column;
  98. transition: transform 0.3s ease, width 0.3s ease;
  99. overflow: hidden;
  100. }
  101. #sidebar.collapsed {
  102. width: 0;
  103. border-left: none;
  104. }
  105. #sidebar-toggle {
  106. position: absolute;
  107. right: 380px;
  108. top: 50%;
  109. transform: translateY(-50%);
  110. z-index: 1000;
  111. background: white;
  112. border: 1px solid #dee2e6;
  113. border-right: none;
  114. border-radius: 6px 0 0 6px;
  115. padding: 12px 8px;
  116. cursor: pointer;
  117. transition: right 0.3s ease;
  118. box-shadow: -2px 0 5px rgba(0,0,0,0.1);
  119. }
  120. #sidebar-toggle.collapsed {
  121. right: 0;
  122. }
  123. #sidebar-toggle:hover {
  124. background: #f0f0f0;
  125. }
  126. #sidebar-content {
  127. padding: 20px;
  128. overflow-y: auto;
  129. flex: 1;
  130. min-width: 380px;
  131. }
  132. .section {
  133. background: white;
  134. border-radius: 8px;
  135. padding: 16px;
  136. margin-bottom: 16px;
  137. box-shadow: 0 1px 3px rgba(0,0,0,0.1);
  138. }
  139. .section h3 {
  140. font-size: 14px;
  141. text-transform: uppercase;
  142. color: #7f8c8d;
  143. margin-bottom: 12px;
  144. letter-spacing: 0.5px;
  145. }
  146. .stat-grid {
  147. display: grid;
  148. grid-template-columns: 1fr 1fr;
  149. gap: 12px;
  150. }
  151. .stat-item {
  152. padding: 8px;
  153. background: #f8f9fa;
  154. border-radius: 6px;
  155. }
  156. .stat-item.full-width {
  157. grid-column: 1 / -1;
  158. }
  159. .stat-label {
  160. font-size: 11px;
  161. color: #95a5a6;
  162. text-transform: uppercase;
  163. margin-bottom: 4px;
  164. }
  165. .stat-value {
  166. font-size: 18px;
  167. font-weight: 600;
  168. color: #2c3e50;
  169. }
  170. .stat-value.small {
  171. font-size: 14px;
  172. }
  173. #elevation-chart-container {
  174. position: relative;
  175. height: 150px;
  176. margin-top: 8px;
  177. }
  178. #elevation-chart {
  179. width: 100%;
  180. height: 100%;
  181. cursor: crosshair;
  182. }
  183. #chart-tooltip {
  184. position: absolute;
  185. background: rgba(44, 62, 80, 0.95);
  186. color: white;
  187. padding: 8px 12px;
  188. border-radius: 4px;
  189. font-size: 12px;
  190. pointer-events: none;
  191. opacity: 0;
  192. transition: opacity 0.15s;
  193. z-index: 100;
  194. white-space: nowrap;
  195. }
  196. #chart-tooltip.visible {
  197. opacity: 1;
  198. }
  199. .settings-row {
  200. margin-bottom: 12px;
  201. }
  202. .settings-row:last-child {
  203. margin-bottom: 0;
  204. }
  205. .settings-row label {
  206. display: block;
  207. font-size: 12px;
  208. color: #7f8c8d;
  209. margin-bottom: 4px;
  210. }
  211. .settings-row select,
  212. .settings-row input[type="text"] {
  213. width: 100%;
  214. padding: 8px 10px;
  215. border: 1px solid #dee2e6;
  216. border-radius: 4px;
  217. font-size: 13px;
  218. }
  219. .settings-row select:focus,
  220. .settings-row input[type="text"]:focus {
  221. outline: none;
  222. border-color: #3498db;
  223. }
  224. .btn {
  225. padding: 8px 16px;
  226. border: none;
  227. border-radius: 4px;
  228. cursor: pointer;
  229. font-size: 13px;
  230. transition: background 0.2s;
  231. }
  232. .btn-primary {
  233. background: #3498db;
  234. color: white;
  235. }
  236. .btn-primary:hover {
  237. background: #2980b9;
  238. }
  239. .btn-secondary {
  240. background: #95a5a6;
  241. color: white;
  242. }
  243. .btn-secondary:hover {
  244. background: #7f8c8d;
  245. }
  246. .btn-block {
  247. width: 100%;
  248. }
  249. #track-info {
  250. font-size: 13px;
  251. color: #7f8c8d;
  252. margin-bottom: 12px;
  253. }
  254. .loading-indicator {
  255. display: none;
  256. align-items: center;
  257. justify-content: center;
  258. padding: 20px;
  259. color: #7f8c8d;
  260. }
  261. .loading-indicator.active {
  262. display: flex;
  263. }
  264. .spinner {
  265. width: 20px;
  266. height: 20px;
  267. border: 2px solid #dee2e6;
  268. border-top-color: #3498db;
  269. border-radius: 50%;
  270. animation: spin 0.8s linear infinite;
  271. margin-right: 10px;
  272. }
  273. @keyframes spin {
  274. to { transform: rotate(360deg); }
  275. }
  276. .elevation-warning {
  277. background: #fff3cd;
  278. border: 1px solid #ffc107;
  279. border-radius: 4px;
  280. padding: 8px 12px;
  281. font-size: 12px;
  282. color: #856404;
  283. margin-bottom: 12px;
  284. }
  285. .track-color-picker {
  286. display: flex;
  287. align-items: center;
  288. gap: 10px;
  289. }
  290. .track-color-picker input[type="color"] {
  291. width: 40px;
  292. height: 32px;
  293. border: 1px solid #dee2e6;
  294. border-radius: 4px;
  295. cursor: pointer;
  296. }
  297. .track-width-slider {
  298. display: flex;
  299. align-items: center;
  300. gap: 10px;
  301. }
  302. .track-width-slider input[type="range"] {
  303. flex: 1;
  304. }
  305. .track-width-slider span {
  306. min-width: 30px;
  307. text-align: right;
  308. font-size: 13px;
  309. }
  310. /* Map marker tooltip */
  311. .map-tooltip {
  312. background: rgba(44, 62, 80, 0.95);
  313. color: white;
  314. padding: 8px 12px;
  315. border-radius: 4px;
  316. font-size: 12px;
  317. white-space: nowrap;
  318. border: none;
  319. box-shadow: 0 2px 8px rgba(0,0,0,0.2);
  320. }
  321. .map-tooltip::before {
  322. display: none;
  323. }
  324. .leaflet-tooltip-left.map-tooltip::before,
  325. .leaflet-tooltip-right.map-tooltip::before {
  326. display: none;
  327. }
  328. /* Hover marker */
  329. .hover-marker {
  330. background: #e74c3c;
  331. border: 3px solid white;
  332. border-radius: 50%;
  333. box-shadow: 0 2px 5px rgba(0,0,0,0.3);
  334. }
  335. #no-track-message {
  336. text-align: center;
  337. color: #95a5a6;
  338. padding: 40px 20px;
  339. }
  340. .hidden {
  341. display: none !important;
  342. }
  343. .settings-row a {
  344. color: #3498db;
  345. text-decoration: none;
  346. }
  347. .settings-row a:hover {
  348. text-decoration: underline;
  349. }
  350. </style>
  351. </head>
  352. <body>
  353. <div id="map-container">
  354. <div id="map"></div>
  355. <div id="drop-overlay">
  356. <div id="drop-overlay-content">
  357. <div class="icon">&#128506;</div>
  358. <p>Drop GPX file here</p>
  359. </div>
  360. </div>
  361. <div id="file-prompt">
  362. <h2>GPX Visualizer</h2>
  363. <p>Drag and drop a GPX file onto the map<br>or click below to select</p>
  364. <label id="file-input-label">
  365. Select GPX File
  366. <input type="file" id="file-input" accept=".gpx">
  367. </label>
  368. </div>
  369. </div>
  370. <button id="sidebar-toggle" title="Toggle sidebar">&#9664;</button>
  371. <div id="sidebar">
  372. <div id="sidebar-content">
  373. <div id="no-track-message">
  374. <p>No track loaded</p>
  375. <p style="font-size: 12px; margin-top: 8px;">Load a GPX file to see statistics</p>
  376. </div>
  377. <div id="track-data" class="hidden">
  378. <div class="section">
  379. <h3>Track Information</h3>
  380. <div id="track-info">-</div>
  381. <div class="stat-grid">
  382. <div class="stat-item">
  383. <div class="stat-label">Distance</div>
  384. <div class="stat-value" id="stat-distance">-</div>
  385. </div>
  386. <div class="stat-item">
  387. <div class="stat-label">Duration</div>
  388. <div class="stat-value" id="stat-duration">-</div>
  389. </div>
  390. <div class="stat-item">
  391. <div class="stat-label">Avg Speed</div>
  392. <div class="stat-value" id="stat-avg-speed">-</div>
  393. </div>
  394. <div class="stat-item">
  395. <div class="stat-label">Max Speed</div>
  396. <div class="stat-value" id="stat-max-speed">-</div>
  397. </div>
  398. <div class="stat-item">
  399. <div class="stat-label">Elevation Gain</div>
  400. <div class="stat-value" id="stat-elev-gain">-</div>
  401. </div>
  402. <div class="stat-item">
  403. <div class="stat-label">Elevation Loss</div>
  404. <div class="stat-value" id="stat-elev-loss">-</div>
  405. </div>
  406. <div class="stat-item">
  407. <div class="stat-label">Min Elevation</div>
  408. <div class="stat-value" id="stat-elev-min">-</div>
  409. </div>
  410. <div class="stat-item">
  411. <div class="stat-label">Max Elevation</div>
  412. <div class="stat-value" id="stat-elev-max">-</div>
  413. </div>
  414. <div class="stat-item full-width">
  415. <div class="stat-label">Time Range</div>
  416. <div class="stat-value small" id="stat-time-range">-</div>
  417. </div>
  418. </div>
  419. </div>
  420. <div class="section">
  421. <h3>Elevation Profile</h3>
  422. <div id="elevation-warning" class="elevation-warning hidden">
  423. Elevation data missing. <button class="btn btn-primary" id="fetch-elevation-btn" style="padding: 4px 8px; font-size: 11px;">Fetch from API</button>
  424. </div>
  425. <div class="loading-indicator" id="elevation-loading">
  426. <div class="spinner"></div>
  427. <span>Fetching elevation data...</span>
  428. </div>
  429. <div id="elevation-chart-container">
  430. <canvas id="elevation-chart"></canvas>
  431. <div id="chart-tooltip"></div>
  432. </div>
  433. </div>
  434. <div class="section">
  435. <h3>Track Style</h3>
  436. <div class="settings-row">
  437. <label>Track Color</label>
  438. <div class="track-color-picker">
  439. <input type="color" id="track-color" value="#3498db">
  440. <span id="track-color-value">#3498db</span>
  441. </div>
  442. </div>
  443. <div class="settings-row">
  444. <label>Track Width</label>
  445. <div class="track-width-slider">
  446. <input type="range" id="track-width" min="1" max="10" value="3">
  447. <span id="track-width-value">3px</span>
  448. </div>
  449. </div>
  450. </div>
  451. <div class="section">
  452. <h3>Map Settings</h3>
  453. <div class="settings-row">
  454. <label>Tile Server</label>
  455. <select id="tile-server">
  456. <option value="freemap">Freemap.sk</option>
  457. <option value="osm">OpenStreetMap</option>
  458. <option value="osmde">OpenStreetMap DE</option>
  459. <option value="topo">OpenTopoMap</option>
  460. <option value="cycle">CyclOSM</option>
  461. <option value="satellite">ESRI Satellite</option>
  462. <optgroup label="Mapy.cz (requires API key)">
  463. <option value="mapy-outdoor">Mapy.cz Outdoor</option>
  464. <option value="mapy-basic">Mapy.cz Basic</option>
  465. <option value="mapy-aerial">Mapy.cz Aerial</option>
  466. <option value="mapy-winter">Mapy.cz Winter</option>
  467. </optgroup>
  468. <option value="custom">Custom...</option>
  469. </select>
  470. </div>
  471. <div class="settings-row hidden" id="mapy-apikey-row">
  472. <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>
  473. <input type="text" id="mapy-apikey" placeholder="Enter your API key">
  474. <button class="btn btn-primary btn-block" id="apply-mapy-apikey" style="margin-top: 8px;">Apply</button>
  475. </div>
  476. <div class="settings-row hidden" id="custom-tile-row">
  477. <label>Custom Tile URL</label>
  478. <input type="text" id="custom-tile-url" placeholder="https://{s}.tile.example.com/{z}/{x}/{y}.png">
  479. <button class="btn btn-primary btn-block" id="apply-custom-tile" style="margin-top: 8px;">Apply</button>
  480. </div>
  481. </div>
  482. <div class="section">
  483. <h3>Actions</h3>
  484. <button class="btn btn-secondary btn-block" id="fit-bounds-btn">Fit Track to View</button>
  485. <button class="btn btn-secondary btn-block" id="load-another-btn" style="margin-top: 8px;">Load Another File</button>
  486. </div>
  487. </div>
  488. </div>
  489. </div>
  490. <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
  491. <script>
  492. // ==================== Configuration ====================
  493. const TILE_SERVERS = {
  494. freemap: {
  495. url: 'https://{s}.freemap.sk/T/{z}/{x}/{y}.jpeg',
  496. attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>, <a href="https://freemap.sk">Freemap.sk</a> CC-By-SA 2.0',
  497. subdomains: 'abcd',
  498. minZoom: 8,
  499. maxZoom: 16
  500. },
  501. osm: {
  502. url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
  503. attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
  504. },
  505. osmde: {
  506. url: 'https://{s}.tile.openstreetmap.de/{z}/{x}/{y}.png',
  507. attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
  508. },
  509. topo: {
  510. url: 'https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png',
  511. attribution: '&copy; <a href="https://opentopomap.org">OpenTopoMap</a> (CC-BY-SA)'
  512. },
  513. cycle: {
  514. url: 'https://{s}.tile-cyclosm.openstreetmap.fr/cyclosm/{z}/{x}/{y}.png',
  515. attribution: '&copy; <a href="https://www.cyclosm.org">CyclOSM</a>'
  516. },
  517. satellite: {
  518. url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
  519. attribution: '&copy; Esri',
  520. subdomains: []
  521. },
  522. 'mapy-outdoor': {
  523. url: 'https://api.mapy.cz/v1/maptiles/outdoor/256/{z}/{x}/{y}?apikey={apikey}',
  524. attribution: '&copy; <a href="https://mapy.cz">Mapy.cz</a>',
  525. requiresApiKey: true
  526. },
  527. 'mapy-basic': {
  528. url: 'https://api.mapy.cz/v1/maptiles/basic/256/{z}/{x}/{y}?apikey={apikey}',
  529. attribution: '&copy; <a href="https://mapy.cz">Mapy.cz</a>',
  530. requiresApiKey: true
  531. },
  532. 'mapy-aerial': {
  533. url: 'https://api.mapy.cz/v1/maptiles/aerial/256/{z}/{x}/{y}?apikey={apikey}',
  534. attribution: '&copy; <a href="https://mapy.cz">Mapy.cz</a>',
  535. requiresApiKey: true
  536. },
  537. 'mapy-winter': {
  538. url: 'https://api.mapy.cz/v1/maptiles/winter/256/{z}/{x}/{y}?apikey={apikey}',
  539. attribution: '&copy; <a href="https://mapy.cz">Mapy.cz</a>',
  540. requiresApiKey: true
  541. }
  542. };
  543. const ELEVATION_API = 'https://api.open-elevation.com/api/v1/lookup';
  544. const ELEVATION_BATCH_SIZE = 100; // Points per API request
  545. const CHART_MAX_POINTS = 500; // Downsample for chart rendering
  546. const TRACK_HOVER_TOLERANCE_PX = 20; // Pixels distance for track hover detection
  547. // ==================== State ====================
  548. let map = null;
  549. let tileLayer = null;
  550. let trackLayer = null;
  551. let hoverMarker = null;
  552. let stickyPoint = null; // Non-null when tooltip is pinned by click
  553. let trackData = null; // { points: [], stats: {} }
  554. let chartData = null; // Downsampled for chart
  555. // ==================== DOM Elements ====================
  556. const elements = {
  557. map: document.getElementById('map'),
  558. dropOverlay: document.getElementById('drop-overlay'),
  559. filePrompt: document.getElementById('file-prompt'),
  560. fileInput: document.getElementById('file-input'),
  561. sidebar: document.getElementById('sidebar'),
  562. sidebarToggle: document.getElementById('sidebar-toggle'),
  563. noTrackMessage: document.getElementById('no-track-message'),
  564. trackDataSection: document.getElementById('track-data'),
  565. elevationChart: document.getElementById('elevation-chart'),
  566. chartTooltip: document.getElementById('chart-tooltip'),
  567. elevationWarning: document.getElementById('elevation-warning'),
  568. elevationLoading: document.getElementById('elevation-loading'),
  569. fetchElevationBtn: document.getElementById('fetch-elevation-btn'),
  570. trackColor: document.getElementById('track-color'),
  571. trackColorValue: document.getElementById('track-color-value'),
  572. trackWidth: document.getElementById('track-width'),
  573. trackWidthValue: document.getElementById('track-width-value'),
  574. tileServer: document.getElementById('tile-server'),
  575. customTileRow: document.getElementById('custom-tile-row'),
  576. customTileUrl: document.getElementById('custom-tile-url'),
  577. applyCustomTile: document.getElementById('apply-custom-tile'),
  578. mapyApikeyRow: document.getElementById('mapy-apikey-row'),
  579. mapyApikey: document.getElementById('mapy-apikey'),
  580. applyMapyApikey: document.getElementById('apply-mapy-apikey'),
  581. fitBoundsBtn: document.getElementById('fit-bounds-btn'),
  582. loadAnotherBtn: document.getElementById('load-another-btn'),
  583. stats: {
  584. distance: document.getElementById('stat-distance'),
  585. duration: document.getElementById('stat-duration'),
  586. avgSpeed: document.getElementById('stat-avg-speed'),
  587. maxSpeed: document.getElementById('stat-max-speed'),
  588. elevGain: document.getElementById('stat-elev-gain'),
  589. elevLoss: document.getElementById('stat-elev-loss'),
  590. elevMin: document.getElementById('stat-elev-min'),
  591. elevMax: document.getElementById('stat-elev-max'),
  592. timeRange: document.getElementById('stat-time-range')
  593. },
  594. trackInfo: document.getElementById('track-info')
  595. };
  596. // ==================== Initialization ====================
  597. function init() {
  598. initMap();
  599. initEventListeners();
  600. }
  601. function initMap() {
  602. map = L.map('map').setView([50.6616, 16.2814], 14); // Ruprechtický Špičák
  603. setTileServer('freemap');
  604. // Create hover marker (hidden initially)
  605. const hoverIcon = L.divIcon({
  606. className: 'hover-marker',
  607. iconSize: [14, 14],
  608. iconAnchor: [7, 7]
  609. });
  610. hoverMarker = L.marker([0, 0], { icon: hoverIcon, interactive: false });
  611. }
  612. function initEventListeners() {
  613. // File input
  614. elements.fileInput.addEventListener('change', handleFileSelect);
  615. // Drag and drop
  616. document.addEventListener('dragover', handleDragOver);
  617. document.addEventListener('dragleave', handleDragLeave);
  618. document.addEventListener('drop', handleDrop);
  619. // Sidebar toggle
  620. elements.sidebarToggle.addEventListener('click', toggleSidebar);
  621. // Track style
  622. elements.trackColor.addEventListener('input', updateTrackStyle);
  623. elements.trackWidth.addEventListener('input', updateTrackStyle);
  624. // Tile server
  625. elements.tileServer.addEventListener('change', handleTileServerChange);
  626. elements.applyCustomTile.addEventListener('click', applyCustomTileServer);
  627. elements.applyMapyApikey.addEventListener('click', applyMapyApikey);
  628. // Actions
  629. elements.fitBoundsBtn.addEventListener('click', fitTrackBounds);
  630. elements.loadAnotherBtn.addEventListener('click', () => elements.fileInput.click());
  631. elements.fetchElevationBtn.addEventListener('click', fetchElevationData);
  632. // Elevation chart interaction
  633. elements.elevationChart.addEventListener('mousemove', handleChartHover);
  634. elements.elevationChart.addEventListener('mouseleave', handleChartLeave);
  635. // Map hover for track (with tolerance)
  636. map.on('mousemove', handleMapHover);
  637. map.on('mouseout', handleMapLeave);
  638. map.on('click', handleMapClick);
  639. }
  640. // ==================== File Handling ====================
  641. function handleDragOver(e) {
  642. e.preventDefault();
  643. e.stopPropagation();
  644. elements.dropOverlay.classList.add('active');
  645. }
  646. function handleDragLeave(e) {
  647. e.preventDefault();
  648. e.stopPropagation();
  649. if (e.target === elements.dropOverlay || !elements.dropOverlay.contains(e.relatedTarget)) {
  650. elements.dropOverlay.classList.remove('active');
  651. }
  652. }
  653. function handleDrop(e) {
  654. e.preventDefault();
  655. e.stopPropagation();
  656. elements.dropOverlay.classList.remove('active');
  657. const files = e.dataTransfer.files;
  658. if (files.length > 0 && files[0].name.toLowerCase().endsWith('.gpx')) {
  659. loadGPXFile(files[0]);
  660. }
  661. }
  662. function handleFileSelect(e) {
  663. const file = e.target.files[0];
  664. if (file) {
  665. loadGPXFile(file);
  666. }
  667. }
  668. function loadGPXFile(file) {
  669. const reader = new FileReader();
  670. reader.onload = (e) => {
  671. try {
  672. parseGPX(e.target.result, file.name);
  673. elements.filePrompt.classList.add('hidden');
  674. } catch (err) {
  675. alert('Error parsing GPX file: ' + err.message);
  676. }
  677. };
  678. reader.readAsText(file);
  679. }
  680. // ==================== GPX Parsing ====================
  681. function parseGPX(xmlString, fileName) {
  682. const parser = new DOMParser();
  683. const doc = parser.parseFromString(xmlString, 'application/xml');
  684. const parseError = doc.querySelector('parsererror');
  685. if (parseError) {
  686. throw new Error('Invalid XML format');
  687. }
  688. // Extract track points - memory efficient approach
  689. const points = [];
  690. const trkpts = doc.querySelectorAll('trkpt');
  691. const rtepts = doc.querySelectorAll('rtept');
  692. const wpts = doc.querySelectorAll('wpt');
  693. // Process track points
  694. const allPoints = [...trkpts, ...rtepts];
  695. if (allPoints.length === 0 && wpts.length === 0) {
  696. throw new Error('No track points found in GPX file');
  697. }
  698. let hasElevation = false;
  699. let hasTime = false;
  700. for (const pt of allPoints) {
  701. const lat = parseFloat(pt.getAttribute('lat'));
  702. const lon = parseFloat(pt.getAttribute('lon'));
  703. if (isNaN(lat) || isNaN(lon)) continue;
  704. const eleNode = pt.querySelector('ele');
  705. const timeNode = pt.querySelector('time');
  706. const point = {
  707. lat,
  708. lon,
  709. ele: eleNode ? parseFloat(eleNode.textContent) : null,
  710. time: timeNode ? new Date(timeNode.textContent) : null
  711. };
  712. if (point.ele !== null && !isNaN(point.ele)) hasElevation = true;
  713. if (point.time !== null) hasTime = true;
  714. points.push(point);
  715. }
  716. if (points.length === 0) {
  717. throw new Error('No valid track points found');
  718. }
  719. // Get track name
  720. const nameNode = doc.querySelector('trk > name') || doc.querySelector('rte > name') || doc.querySelector('metadata > name');
  721. const trackName = nameNode ? nameNode.textContent : fileName.replace('.gpx', '');
  722. // Calculate derived data (distance from start for each point)
  723. let totalDist = 0;
  724. for (let i = 0; i < points.length; i++) {
  725. if (i === 0) {
  726. points[i].dist = 0;
  727. } else {
  728. const d = haversineDistance(
  729. points[i - 1].lat, points[i - 1].lon,
  730. points[i].lat, points[i].lon
  731. );
  732. totalDist += d;
  733. points[i].dist = totalDist;
  734. }
  735. }
  736. // Store track data
  737. trackData = {
  738. name: trackName,
  739. points,
  740. hasElevation,
  741. hasTime,
  742. stats: calculateStats(points, hasElevation, hasTime)
  743. };
  744. // Downsample for chart
  745. chartData = downsamplePoints(points, CHART_MAX_POINTS);
  746. // Update UI
  747. displayTrack();
  748. displayStats();
  749. // Delay chart drawing to ensure container is visible and has dimensions
  750. requestAnimationFrame(() => {
  751. requestAnimationFrame(() => {
  752. drawElevationChart();
  753. });
  754. });
  755. // Show elevation warning if no elevation data
  756. if (!hasElevation) {
  757. elements.elevationWarning.classList.remove('hidden');
  758. } else {
  759. elements.elevationWarning.classList.add('hidden');
  760. }
  761. // Show track data section
  762. elements.noTrackMessage.classList.add('hidden');
  763. elements.trackDataSection.classList.remove('hidden');
  764. }
  765. // ==================== Calculations ====================
  766. function haversineDistance(lat1, lon1, lat2, lon2) {
  767. const R = 6371000; // Earth's radius in meters
  768. const dLat = (lat2 - lat1) * Math.PI / 180;
  769. const dLon = (lon2 - lon1) * Math.PI / 180;
  770. const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
  771. Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
  772. Math.sin(dLon / 2) * Math.sin(dLon / 2);
  773. const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
  774. return R * c;
  775. }
  776. function calculateStats(points, hasElevation, hasTime) {
  777. const stats = {
  778. distance: points[points.length - 1].dist,
  779. duration: null,
  780. avgSpeed: null,
  781. maxSpeed: null,
  782. elevGain: null,
  783. elevLoss: null,
  784. elevMin: null,
  785. elevMax: null,
  786. startTime: null,
  787. endTime: null
  788. };
  789. // Time-based stats
  790. if (hasTime) {
  791. const validTimes = points.filter(p => p.time !== null);
  792. if (validTimes.length >= 2) {
  793. stats.startTime = validTimes[0].time;
  794. stats.endTime = validTimes[validTimes.length - 1].time;
  795. stats.duration = (stats.endTime - stats.startTime) / 1000; // seconds
  796. if (stats.duration > 0) {
  797. stats.avgSpeed = stats.distance / stats.duration; // m/s
  798. // Calculate max speed (with smoothing)
  799. let maxSpeed = 0;
  800. for (let i = 1; i < validTimes.length; i++) {
  801. const dt = (validTimes[i].time - validTimes[i - 1].time) / 1000;
  802. if (dt > 0) {
  803. const dist = validTimes[i].dist - validTimes[i - 1].dist;
  804. const speed = dist / dt;
  805. if (speed > maxSpeed && speed < 100) { // Filter unrealistic speeds
  806. maxSpeed = speed;
  807. }
  808. }
  809. }
  810. stats.maxSpeed = maxSpeed;
  811. }
  812. }
  813. }
  814. // Elevation stats
  815. if (hasElevation) {
  816. let gain = 0, loss = 0;
  817. let min = Infinity, max = -Infinity;
  818. let prevEle = null;
  819. // Use smoothing for elevation gain/loss calculation
  820. const smoothedEle = smoothElevation(points);
  821. for (let i = 0; i < points.length; i++) {
  822. const ele = smoothedEle[i];
  823. if (ele === null) continue;
  824. if (ele < min) min = ele;
  825. if (ele > max) max = ele;
  826. if (prevEle !== null) {
  827. const diff = ele - prevEle;
  828. if (diff > 0) gain += diff;
  829. else loss -= diff;
  830. }
  831. prevEle = ele;
  832. }
  833. stats.elevGain = gain;
  834. stats.elevLoss = loss;
  835. stats.elevMin = min === Infinity ? null : min;
  836. stats.elevMax = max === -Infinity ? null : max;
  837. }
  838. return stats;
  839. }
  840. function smoothElevation(points, windowSize = 5) {
  841. // Simple moving average smoothing
  842. const result = [];
  843. for (let i = 0; i < points.length; i++) {
  844. if (points[i].ele === null) {
  845. result.push(null);
  846. continue;
  847. }
  848. let sum = 0, count = 0;
  849. for (let j = Math.max(0, i - windowSize); j <= Math.min(points.length - 1, i + windowSize); j++) {
  850. if (points[j].ele !== null) {
  851. sum += points[j].ele;
  852. count++;
  853. }
  854. }
  855. result.push(count > 0 ? sum / count : null);
  856. }
  857. return result;
  858. }
  859. function downsamplePoints(points, maxPoints) {
  860. if (points.length <= maxPoints) {
  861. return points.map((p, i) => ({ ...p, originalIndex: i }));
  862. }
  863. const result = [];
  864. const step = (points.length - 1) / (maxPoints - 1);
  865. for (let i = 0; i < maxPoints; i++) {
  866. const idx = Math.round(i * step);
  867. result.push({ ...points[idx], originalIndex: idx });
  868. }
  869. return result;
  870. }
  871. // ==================== Map Display ====================
  872. function displayTrack() {
  873. // Remove existing track
  874. if (trackLayer) {
  875. map.removeLayer(trackLayer);
  876. }
  877. // Clear sticky state when loading a new track
  878. stickyPoint = null;
  879. hideHoverPoint(true);
  880. // Create polyline from points
  881. const latLngs = trackData.points.map(p => [p.lat, p.lon]);
  882. trackLayer = L.polyline(latLngs, {
  883. color: elements.trackColor.value,
  884. weight: parseInt(elements.trackWidth.value),
  885. opacity: 0.8,
  886. lineJoin: 'round'
  887. }).addTo(map);
  888. // Track hover is handled at map level for better tolerance
  889. // Fit map to track bounds
  890. fitTrackBounds();
  891. // Update track info
  892. elements.trackInfo.textContent = `${trackData.name} (${trackData.points.length.toLocaleString()} points)`;
  893. }
  894. function fitTrackBounds() {
  895. if (trackLayer) {
  896. map.fitBounds(trackLayer.getBounds(), { padding: [50, 50] });
  897. }
  898. }
  899. function updateTrackStyle() {
  900. const color = elements.trackColor.value;
  901. const width = parseInt(elements.trackWidth.value);
  902. elements.trackColorValue.textContent = color;
  903. elements.trackWidthValue.textContent = width + 'px';
  904. if (trackLayer) {
  905. trackLayer.setStyle({
  906. color: color,
  907. weight: width
  908. });
  909. }
  910. }
  911. // ==================== Tile Server ====================
  912. function setTileServer(serverId, customUrl = null, apikey = null) {
  913. if (tileLayer) {
  914. map.removeLayer(tileLayer);
  915. }
  916. let url, attribution, options = {};
  917. if (serverId === 'custom' && customUrl) {
  918. url = customUrl;
  919. attribution = 'Custom tiles';
  920. } else {
  921. const server = TILE_SERVERS[serverId];
  922. if (!server) return;
  923. url = server.url;
  924. attribution = server.attribution;
  925. // Handle API key substitution
  926. if (server.requiresApiKey && apikey) {
  927. url = url.replace('{apikey}', apikey);
  928. } else if (server.requiresApiKey && !apikey) {
  929. // Don't set tile layer without API key
  930. return;
  931. }
  932. if (server.subdomains !== undefined) {
  933. options.subdomains = server.subdomains;
  934. }
  935. if (server.minZoom !== undefined) {
  936. options.minZoom = server.minZoom;
  937. }
  938. if (server.maxZoom !== undefined) {
  939. options.maxZoom = server.maxZoom;
  940. }
  941. }
  942. tileLayer = L.tileLayer(url, {
  943. attribution,
  944. maxZoom: options.maxZoom || 19,
  945. ...options
  946. }).addTo(map);
  947. }
  948. function handleTileServerChange() {
  949. const value = elements.tileServer.value;
  950. // Hide all extra rows first
  951. elements.customTileRow.classList.add('hidden');
  952. elements.mapyApikeyRow.classList.add('hidden');
  953. if (value === 'custom') {
  954. elements.customTileRow.classList.remove('hidden');
  955. } else if (value.startsWith('mapy-')) {
  956. const server = TILE_SERVERS[value];
  957. if (server && server.requiresApiKey) {
  958. elements.mapyApikeyRow.classList.remove('hidden');
  959. // If we already have an API key, apply immediately
  960. const apikey = elements.mapyApikey.value.trim();
  961. if (apikey) {
  962. setTileServer(value, null, apikey);
  963. }
  964. }
  965. } else {
  966. setTileServer(value);
  967. }
  968. }
  969. function applyCustomTileServer() {
  970. const url = elements.customTileUrl.value.trim();
  971. if (url) {
  972. setTileServer('custom', url);
  973. }
  974. }
  975. function applyMapyApikey() {
  976. const apikey = elements.mapyApikey.value.trim();
  977. const serverId = elements.tileServer.value;
  978. if (apikey && serverId.startsWith('mapy-')) {
  979. setTileServer(serverId, null, apikey);
  980. } else if (!apikey) {
  981. alert('Please enter your Mapy.cz API key');
  982. }
  983. }
  984. // ==================== Statistics Display ====================
  985. function displayStats() {
  986. const stats = trackData.stats;
  987. // Distance
  988. if (stats.distance >= 1000) {
  989. elements.stats.distance.textContent = (stats.distance / 1000).toFixed(2) + ' km';
  990. } else {
  991. elements.stats.distance.textContent = Math.round(stats.distance) + ' m';
  992. }
  993. // Duration
  994. if (stats.duration !== null) {
  995. elements.stats.duration.textContent = formatDuration(stats.duration);
  996. } else {
  997. elements.stats.duration.textContent = '-';
  998. }
  999. // Average speed
  1000. if (stats.avgSpeed !== null) {
  1001. elements.stats.avgSpeed.textContent = (stats.avgSpeed * 3.6).toFixed(1) + ' km/h';
  1002. } else {
  1003. elements.stats.avgSpeed.textContent = '-';
  1004. }
  1005. // Max speed
  1006. if (stats.maxSpeed !== null) {
  1007. elements.stats.maxSpeed.textContent = (stats.maxSpeed * 3.6).toFixed(1) + ' km/h';
  1008. } else {
  1009. elements.stats.maxSpeed.textContent = '-';
  1010. }
  1011. // Elevation
  1012. if (stats.elevGain !== null) {
  1013. elements.stats.elevGain.textContent = Math.round(stats.elevGain) + ' m';
  1014. elements.stats.elevLoss.textContent = Math.round(stats.elevLoss) + ' m';
  1015. elements.stats.elevMin.textContent = Math.round(stats.elevMin) + ' m';
  1016. elements.stats.elevMax.textContent = Math.round(stats.elevMax) + ' m';
  1017. } else {
  1018. elements.stats.elevGain.textContent = '-';
  1019. elements.stats.elevLoss.textContent = '-';
  1020. elements.stats.elevMin.textContent = '-';
  1021. elements.stats.elevMax.textContent = '-';
  1022. }
  1023. // Time range
  1024. if (stats.startTime && stats.endTime) {
  1025. const options = {
  1026. year: 'numeric', month: 'short', day: 'numeric',
  1027. hour: '2-digit', minute: '2-digit'
  1028. };
  1029. elements.stats.timeRange.textContent =
  1030. stats.startTime.toLocaleDateString(undefined, options);
  1031. } else {
  1032. elements.stats.timeRange.textContent = '-';
  1033. }
  1034. }
  1035. function formatDuration(seconds) {
  1036. const h = Math.floor(seconds / 3600);
  1037. const m = Math.floor((seconds % 3600) / 60);
  1038. const s = Math.floor(seconds % 60);
  1039. if (h > 0) {
  1040. return `${h}h ${m}m`;
  1041. } else if (m > 0) {
  1042. return `${m}m ${s}s`;
  1043. } else {
  1044. return `${s}s`;
  1045. }
  1046. }
  1047. // ==================== Elevation Chart ====================
  1048. function drawElevationChart() {
  1049. const canvas = elements.elevationChart;
  1050. const ctx = canvas.getContext('2d');
  1051. // Set canvas size for high DPI displays
  1052. const rect = canvas.getBoundingClientRect();
  1053. const dpr = window.devicePixelRatio || 1;
  1054. canvas.width = rect.width * dpr;
  1055. canvas.height = rect.height * dpr;
  1056. ctx.scale(dpr, dpr);
  1057. // Clear canvas
  1058. ctx.clearRect(0, 0, rect.width, rect.height);
  1059. if (!chartData || chartData.length === 0) return;
  1060. // Check if we have elevation data
  1061. const hasElevation = chartData.some(p => p.ele !== null);
  1062. if (!hasElevation) {
  1063. ctx.fillStyle = '#95a5a6';
  1064. ctx.font = '14px sans-serif';
  1065. ctx.textAlign = 'center';
  1066. ctx.fillText('No elevation data', rect.width / 2, rect.height / 2);
  1067. return;
  1068. }
  1069. // Calculate bounds
  1070. const padding = { top: 20, right: 20, bottom: 30, left: 50 };
  1071. const chartWidth = rect.width - padding.left - padding.right;
  1072. const chartHeight = rect.height - padding.top - padding.bottom;
  1073. const elevations = chartData.map(p => p.ele).filter(e => e !== null);
  1074. const minEle = Math.min(...elevations);
  1075. const maxEle = Math.max(...elevations);
  1076. const eleRange = maxEle - minEle || 1;
  1077. const totalDist = chartData[chartData.length - 1].dist;
  1078. // Draw grid lines
  1079. ctx.strokeStyle = '#eee';
  1080. ctx.lineWidth = 1;
  1081. // Horizontal grid lines
  1082. const eleSteps = 4;
  1083. for (let i = 0; i <= eleSteps; i++) {
  1084. const y = padding.top + (chartHeight * i / eleSteps);
  1085. ctx.beginPath();
  1086. ctx.moveTo(padding.left, y);
  1087. ctx.lineTo(rect.width - padding.right, y);
  1088. ctx.stroke();
  1089. // Elevation labels
  1090. const ele = maxEle - (eleRange * i / eleSteps);
  1091. ctx.fillStyle = '#95a5a6';
  1092. ctx.font = '10px sans-serif';
  1093. ctx.textAlign = 'right';
  1094. ctx.fillText(Math.round(ele) + 'm', padding.left - 5, y + 3);
  1095. }
  1096. // Distance labels
  1097. ctx.textAlign = 'center';
  1098. const distSteps = 5;
  1099. for (let i = 0; i <= distSteps; i++) {
  1100. const x = padding.left + (chartWidth * i / distSteps);
  1101. const dist = totalDist * i / distSteps;
  1102. const label = dist >= 1000 ? (dist / 1000).toFixed(1) + 'km' : Math.round(dist) + 'm';
  1103. ctx.fillText(label, x, rect.height - 10);
  1104. }
  1105. // Draw elevation profile
  1106. ctx.beginPath();
  1107. ctx.moveTo(padding.left, padding.top + chartHeight);
  1108. let firstPoint = true;
  1109. for (const point of chartData) {
  1110. if (point.ele === null) continue;
  1111. const x = padding.left + (point.dist / totalDist) * chartWidth;
  1112. const y = padding.top + chartHeight - ((point.ele - minEle) / eleRange) * chartHeight;
  1113. if (firstPoint) {
  1114. ctx.moveTo(x, y);
  1115. firstPoint = false;
  1116. } else {
  1117. ctx.lineTo(x, y);
  1118. }
  1119. }
  1120. // Close path for fill
  1121. const lastPoint = chartData[chartData.length - 1];
  1122. const lastX = padding.left + chartWidth;
  1123. ctx.lineTo(lastX, padding.top + chartHeight);
  1124. ctx.lineTo(padding.left, padding.top + chartHeight);
  1125. ctx.closePath();
  1126. // Fill
  1127. const gradient = ctx.createLinearGradient(0, padding.top, 0, rect.height - padding.bottom);
  1128. gradient.addColorStop(0, 'rgba(52, 152, 219, 0.6)');
  1129. gradient.addColorStop(1, 'rgba(52, 152, 219, 0.1)');
  1130. ctx.fillStyle = gradient;
  1131. ctx.fill();
  1132. // Stroke
  1133. ctx.strokeStyle = '#3498db';
  1134. ctx.lineWidth = 2;
  1135. ctx.beginPath();
  1136. firstPoint = true;
  1137. for (const point of chartData) {
  1138. if (point.ele === null) continue;
  1139. const x = padding.left + (point.dist / totalDist) * chartWidth;
  1140. const y = padding.top + chartHeight - ((point.ele - minEle) / eleRange) * chartHeight;
  1141. if (firstPoint) {
  1142. ctx.moveTo(x, y);
  1143. firstPoint = false;
  1144. } else {
  1145. ctx.lineTo(x, y);
  1146. }
  1147. }
  1148. ctx.stroke();
  1149. // Store chart bounds for hover detection
  1150. canvas.chartBounds = {
  1151. padding,
  1152. chartWidth,
  1153. chartHeight,
  1154. minEle,
  1155. maxEle,
  1156. eleRange,
  1157. totalDist
  1158. };
  1159. }
  1160. // ==================== Hover Interactions ====================
  1161. function handleChartHover(e) {
  1162. const canvas = elements.elevationChart;
  1163. const rect = canvas.getBoundingClientRect();
  1164. const bounds = canvas.chartBounds;
  1165. if (!bounds || !chartData) return;
  1166. const x = e.clientX - rect.left;
  1167. const y = e.clientY - rect.top;
  1168. // Check if within chart area
  1169. if (x < bounds.padding.left || x > bounds.padding.left + bounds.chartWidth) {
  1170. handleChartLeave();
  1171. return;
  1172. }
  1173. // Calculate distance at cursor
  1174. const distRatio = (x - bounds.padding.left) / bounds.chartWidth;
  1175. const dist = distRatio * bounds.totalDist;
  1176. // Find nearest point
  1177. let nearestPoint = null;
  1178. let nearestDist = Infinity;
  1179. for (const point of chartData) {
  1180. const d = Math.abs(point.dist - dist);
  1181. if (d < nearestDist) {
  1182. nearestDist = d;
  1183. nearestPoint = point;
  1184. }
  1185. }
  1186. if (nearestPoint) {
  1187. showHoverPoint(nearestPoint, 'chart');
  1188. // Show tooltip
  1189. const tooltip = elements.chartTooltip;
  1190. tooltip.innerHTML = formatPointTooltip(nearestPoint);
  1191. tooltip.classList.add('visible');
  1192. // Position tooltip
  1193. const pointX = bounds.padding.left + (nearestPoint.dist / bounds.totalDist) * bounds.chartWidth;
  1194. const pointY = nearestPoint.ele !== null ?
  1195. bounds.padding.top + bounds.chartHeight - ((nearestPoint.ele - bounds.minEle) / bounds.eleRange) * bounds.chartHeight :
  1196. bounds.padding.top + bounds.chartHeight / 2;
  1197. tooltip.style.left = Math.min(pointX + 10, rect.width - tooltip.offsetWidth - 10) + 'px';
  1198. tooltip.style.top = Math.max(10, pointY - tooltip.offsetHeight / 2) + 'px';
  1199. // Draw hover indicator on chart
  1200. drawChartHoverIndicator(pointX, pointY, bounds);
  1201. }
  1202. }
  1203. function handleChartLeave() {
  1204. hideHoverPoint();
  1205. }
  1206. function handleMapHover(e) {
  1207. if (stickyPoint) return; // Don't update while sticky
  1208. if (!trackData || !trackData.points.length) return;
  1209. const latlng = e.latlng;
  1210. const mousePoint = map.latLngToContainerPoint(latlng);
  1211. // Find nearest point within pixel tolerance
  1212. let nearestPoint = null;
  1213. let nearestPixelDist = Infinity;
  1214. for (const point of trackData.points) {
  1215. const pointLatLng = L.latLng(point.lat, point.lon);
  1216. const pointPixel = map.latLngToContainerPoint(pointLatLng);
  1217. const pixelDist = mousePoint.distanceTo(pointPixel);
  1218. if (pixelDist < nearestPixelDist) {
  1219. nearestPixelDist = pixelDist;
  1220. nearestPoint = point;
  1221. }
  1222. }
  1223. if (nearestPoint && nearestPixelDist <= TRACK_HOVER_TOLERANCE_PX) {
  1224. showHoverPoint(nearestPoint, 'map');
  1225. } else {
  1226. hideHoverPoint();
  1227. }
  1228. }
  1229. function handleMapLeave() {
  1230. if (stickyPoint) return; // Keep sticky tooltip visible
  1231. hideHoverPoint();
  1232. }
  1233. function handleMapClick() {
  1234. if (stickyPoint) {
  1235. // Second click: un-stick and hide
  1236. stickyPoint = null;
  1237. hideHoverPoint(true);
  1238. } else if (hoverMarker && map.hasLayer(hoverMarker)) {
  1239. // Tooltip is showing: make it sticky
  1240. stickyPoint = true;
  1241. }
  1242. }
  1243. function hideHoverPoint(force) {
  1244. if (stickyPoint && !force) return; // Don't hide while sticky
  1245. if (hoverMarker && map.hasLayer(hoverMarker)) {
  1246. elements.chartTooltip.classList.remove('visible');
  1247. hoverMarker.remove();
  1248. if (chartData && chartData.length > 0) {
  1249. drawElevationChart(); // Redraw to clear hover indicator
  1250. }
  1251. }
  1252. }
  1253. function showHoverPoint(point, source) {
  1254. // Show marker on map
  1255. hoverMarker.setLatLng([point.lat, point.lon]);
  1256. hoverMarker.addTo(map);
  1257. // Show tooltip on map
  1258. hoverMarker.bindTooltip(formatPointTooltip(point), {
  1259. permanent: true,
  1260. direction: 'top',
  1261. className: 'map-tooltip',
  1262. offset: [0, -10]
  1263. }).openTooltip();
  1264. // Highlight on chart if source is map
  1265. if (source === 'map' && elements.elevationChart.chartBounds) {
  1266. const bounds = elements.elevationChart.chartBounds;
  1267. const pointX = bounds.padding.left + (point.dist / bounds.totalDist) * bounds.chartWidth;
  1268. const pointY = point.ele !== null ?
  1269. bounds.padding.top + bounds.chartHeight - ((point.ele - bounds.minEle) / bounds.eleRange) * bounds.chartHeight :
  1270. bounds.padding.top + bounds.chartHeight / 2;
  1271. drawChartHoverIndicator(pointX, pointY, bounds);
  1272. // Show chart tooltip
  1273. const tooltip = elements.chartTooltip;
  1274. const rect = elements.elevationChart.getBoundingClientRect();
  1275. tooltip.innerHTML = formatPointTooltip(point);
  1276. tooltip.classList.add('visible');
  1277. tooltip.style.left = Math.min(pointX + 10, rect.width - tooltip.offsetWidth - 10) + 'px';
  1278. tooltip.style.top = Math.max(10, pointY - 20) + 'px';
  1279. }
  1280. }
  1281. function drawChartHoverIndicator(x, y, bounds) {
  1282. const canvas = elements.elevationChart;
  1283. const ctx = canvas.getContext('2d');
  1284. const dpr = window.devicePixelRatio || 1;
  1285. // Redraw chart first
  1286. drawElevationChart();
  1287. // Draw vertical line
  1288. ctx.strokeStyle = 'rgba(231, 76, 60, 0.5)';
  1289. ctx.lineWidth = 1;
  1290. ctx.setLineDash([5, 5]);
  1291. ctx.beginPath();
  1292. ctx.moveTo(x * dpr, bounds.padding.top * dpr);
  1293. ctx.lineTo(x * dpr, (bounds.padding.top + bounds.chartHeight) * dpr);
  1294. ctx.stroke();
  1295. ctx.setLineDash([]);
  1296. // Draw point marker
  1297. ctx.fillStyle = '#e74c3c';
  1298. ctx.beginPath();
  1299. ctx.arc(x * dpr, y * dpr, 5 * dpr, 0, Math.PI * 2);
  1300. ctx.fill();
  1301. ctx.strokeStyle = 'white';
  1302. ctx.lineWidth = 2 * dpr;
  1303. ctx.stroke();
  1304. }
  1305. function formatPointTooltip(point) {
  1306. let html = '';
  1307. // Distance
  1308. if (point.dist >= 1000) {
  1309. html += `<div><strong>Distance:</strong> ${(point.dist / 1000).toFixed(2)} km</div>`;
  1310. } else {
  1311. html += `<div><strong>Distance:</strong> ${Math.round(point.dist)} m</div>`;
  1312. }
  1313. // Elevation
  1314. if (point.ele !== null) {
  1315. html += `<div><strong>Elevation:</strong> ${Math.round(point.ele)} m</div>`;
  1316. }
  1317. // Time
  1318. if (point.time) {
  1319. const timeStr = point.time.toLocaleTimeString(undefined, {
  1320. hour: '2-digit',
  1321. minute: '2-digit',
  1322. second: '2-digit'
  1323. });
  1324. html += `<div><strong>Time:</strong> ${timeStr}</div>`;
  1325. }
  1326. // Coordinates
  1327. html += `<div><strong>Lat:</strong> ${point.lat.toFixed(5)}</div>`;
  1328. html += `<div><strong>Lon:</strong> ${point.lon.toFixed(5)}</div>`;
  1329. return html;
  1330. }
  1331. // ==================== Elevation API ====================
  1332. async function fetchElevationData() {
  1333. if (!trackData) return;
  1334. elements.fetchElevationBtn.disabled = true;
  1335. elements.elevationLoading.classList.add('active');
  1336. elements.elevationWarning.classList.add('hidden');
  1337. try {
  1338. const points = trackData.points;
  1339. const batches = [];
  1340. // Create batches
  1341. for (let i = 0; i < points.length; i += ELEVATION_BATCH_SIZE) {
  1342. batches.push(points.slice(i, i + ELEVATION_BATCH_SIZE));
  1343. }
  1344. // Fetch elevation for each batch
  1345. for (let i = 0; i < batches.length; i++) {
  1346. const batch = batches[i];
  1347. const locations = batch.map(p => ({
  1348. latitude: p.lat,
  1349. longitude: p.lon
  1350. }));
  1351. const response = await fetch(ELEVATION_API, {
  1352. method: 'POST',
  1353. headers: { 'Content-Type': 'application/json' },
  1354. body: JSON.stringify({ locations })
  1355. });
  1356. if (!response.ok) {
  1357. throw new Error(`API error: ${response.status}`);
  1358. }
  1359. const data = await response.json();
  1360. // Update points with elevation data
  1361. for (let j = 0; j < data.results.length; j++) {
  1362. const pointIndex = i * ELEVATION_BATCH_SIZE + j;
  1363. if (pointIndex < points.length) {
  1364. points[pointIndex].ele = data.results[j].elevation;
  1365. }
  1366. }
  1367. // Small delay between batches to be nice to the API
  1368. if (i < batches.length - 1) {
  1369. await new Promise(resolve => setTimeout(resolve, 100));
  1370. }
  1371. }
  1372. // Update stats and chart
  1373. trackData.hasElevation = true;
  1374. trackData.stats = calculateStats(points, true, trackData.hasTime);
  1375. chartData = downsamplePoints(points, CHART_MAX_POINTS);
  1376. displayStats();
  1377. requestAnimationFrame(() => {
  1378. requestAnimationFrame(() => {
  1379. drawElevationChart();
  1380. });
  1381. });
  1382. } catch (err) {
  1383. alert('Failed to fetch elevation data: ' + err.message);
  1384. elements.elevationWarning.classList.remove('hidden');
  1385. } finally {
  1386. elements.fetchElevationBtn.disabled = false;
  1387. elements.elevationLoading.classList.remove('active');
  1388. }
  1389. }
  1390. // ==================== Sidebar ====================
  1391. function toggleSidebar() {
  1392. const isCollapsed = elements.sidebar.classList.toggle('collapsed');
  1393. elements.sidebarToggle.classList.toggle('collapsed', isCollapsed);
  1394. elements.sidebarToggle.innerHTML = isCollapsed ? '&#9654;' : '&#9664;';
  1395. // Invalidate map size after animation
  1396. setTimeout(() => {
  1397. map.invalidateSize();
  1398. }, 350);
  1399. }
  1400. // ==================== Window Resize ====================
  1401. window.addEventListener('resize', () => {
  1402. if (trackData) {
  1403. requestAnimationFrame(() => {
  1404. drawElevationChart();
  1405. });
  1406. }
  1407. });
  1408. // ==================== Start ====================
  1409. document.addEventListener('DOMContentLoaded', init);
  1410. </script>
  1411. </body>
  1412. </html>