local.js 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245
  1. // Guest-mode local GPX viewer — no backend, no auth required.
  2. const LocalViewer = (() => {
  3. let tracks = {}; // id → { data: {segments,meta}, visible: bool }
  4. let nextId = 0;
  5. let currentId = null; // track shown in info panel / elevation chart
  6. // ===== Public =====
  7. function init() {
  8. setupSidebar();
  9. setupMapDrop();
  10. }
  11. // ===== Sidebar =====
  12. function setupSidebar() {
  13. // Replace logged-in actions with a simple "Open GPX" button
  14. document.getElementById('browser-actions').innerHTML = `
  15. <button id="local-open-btn" class="action-btn">Open GPX</button>
  16. <input type="file" id="local-file-input" accept=".gpx" multiple style="display:none">
  17. `;
  18. document.getElementById('local-open-btn').addEventListener('click', () => {
  19. document.getElementById('local-file-input').click();
  20. });
  21. document.getElementById('local-file-input').addEventListener('change', (e) => {
  22. handleFiles(Array.from(e.target.files));
  23. e.target.value = '';
  24. });
  25. // Clear breadcrumb — not relevant in guest mode
  26. document.getElementById('breadcrumb').innerHTML =
  27. '<span style="color:var(--color-text-lighter);font-size:12px">Local viewer</span>';
  28. renderList();
  29. }
  30. // ===== Map drop zone =====
  31. function setupMapDrop() {
  32. const mc = document.getElementById('map-container');
  33. mc.addEventListener('dragover', (e) => {
  34. if (e.dataTransfer.types.includes('Files')) {
  35. e.preventDefault();
  36. mc.classList.add('drag-over');
  37. }
  38. });
  39. mc.addEventListener('dragleave', (e) => {
  40. if (!mc.contains(e.relatedTarget)) mc.classList.remove('drag-over');
  41. });
  42. mc.addEventListener('drop', (e) => {
  43. e.preventDefault();
  44. mc.classList.remove('drag-over');
  45. const files = Array.from(e.dataTransfer.files)
  46. .filter(f => f.name.toLowerCase().endsWith('.gpx'));
  47. if (files.length) handleFiles(files);
  48. });
  49. }
  50. // ===== File loading =====
  51. async function handleFiles(files) {
  52. for (const file of files) {
  53. try {
  54. const text = await file.text();
  55. const data = parseGPX(text, file.name);
  56. const id = 'local_' + (nextId++);
  57. tracks[id] = { data, visible: false };
  58. toggleTrack(id); // open immediately
  59. } catch (e) {
  60. showToast('Error reading ' + file.name + ': ' + e.message, 'error');
  61. }
  62. }
  63. }
  64. // ===== Track toggling =====
  65. function toggleTrack(id) {
  66. const entry = tracks[id];
  67. if (!entry) return;
  68. if (entry.visible) {
  69. MapView.removeTrack(id);
  70. entry.visible = false;
  71. if (currentId === id) {
  72. currentId = null;
  73. MapView.setCurrentTrack(null);
  74. document.getElementById('track-info-panel').style.display = 'none';
  75. if (typeof Elevation !== 'undefined') Elevation.clear();
  76. }
  77. } else {
  78. MapView.addTrack(entry.data, id);
  79. MapView.fitTrack(id);
  80. MapView.setCurrentTrack(id);
  81. entry.visible = true;
  82. currentId = id;
  83. showTrackInfo(entry.data.meta);
  84. if (typeof Elevation !== 'undefined') {
  85. const pts = MapView.getTrackPoints(id);
  86. if (pts) Elevation.setTrack(pts);
  87. }
  88. }
  89. renderList();
  90. }
  91. function removeTrack(id) {
  92. if (tracks[id]?.visible) MapView.removeTrack(id);
  93. if (currentId === id) {
  94. currentId = null;
  95. MapView.setCurrentTrack(null);
  96. document.getElementById('track-info-panel').style.display = 'none';
  97. if (typeof Elevation !== 'undefined') Elevation.clear();
  98. }
  99. delete tracks[id];
  100. renderList();
  101. }
  102. // ===== Rendering =====
  103. function renderList() {
  104. const list = document.getElementById('browser-list');
  105. const ids = Object.keys(tracks);
  106. if (ids.length === 0) {
  107. list.innerHTML = '<div class="empty-list">Open a GPX file or drop it onto the map.</div>';
  108. return;
  109. }
  110. let html = '';
  111. for (const id of ids) {
  112. const { data, visible } = tracks[id];
  113. const meta = data.meta;
  114. const dist = meta.totalDistance ? formatDistance(meta.totalDistance) : '';
  115. html += `<div class="tree-item track-item${visible ? ' local-active' : ''}"
  116. data-id="${escAttr(id)}" style="padding-left:12px">
  117. <span class="item-icon">🗺️</span>
  118. <span class="item-name">${escHtml(meta.name)}</span>
  119. <span class="item-meta">${dist}</span>
  120. <span class="item-actions">
  121. <button class="item-btn local-remove-btn" data-id="${escAttr(id)}" title="Remove">🗑️</button>
  122. </span>
  123. </div>`;
  124. }
  125. list.innerHTML = html;
  126. list.querySelectorAll('.track-item').forEach(el => {
  127. el.addEventListener('click', (e) => {
  128. if (e.target.closest('.item-actions')) return;
  129. toggleTrack(el.dataset.id);
  130. });
  131. });
  132. list.querySelectorAll('.local-remove-btn').forEach(btn => {
  133. btn.addEventListener('click', (e) => {
  134. e.stopPropagation();
  135. removeTrack(btn.dataset.id);
  136. });
  137. });
  138. }
  139. // ===== Track info panel =====
  140. function showTrackInfo(meta) {
  141. if (!meta) return;
  142. document.getElementById('track-info-content').innerHTML = `
  143. <h3>${escHtml(meta.name || 'Track')}</h3>
  144. <div class="info-row"><label>Distance</label><span>${formatDistance(meta.totalDistance)}</span></div>
  145. <div class="info-row"><label>Points</label><span>${meta.pointCount || 0}</span></div>
  146. ${meta.trackDate ? `<div class="info-row"><label>Date</label><span>${formatDate(meta.trackDate)}</span></div>` : ''}
  147. `;
  148. const panel = document.getElementById('track-info-panel');
  149. panel.style.display = 'block';
  150. document.getElementById('track-info-close').onclick = () => { panel.style.display = 'none'; };
  151. }
  152. // ===== GPX parsing =====
  153. function haversine(lat1, lon1, lat2, lon2) {
  154. const R = 6371000;
  155. const dLat = (lat2 - lat1) * Math.PI / 180;
  156. const dLon = (lon2 - lon1) * Math.PI / 180;
  157. const a = Math.sin(dLat / 2) ** 2 +
  158. Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
  159. Math.sin(dLon / 2) ** 2;
  160. return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
  161. }
  162. function parseGPX(xmlText, filename) {
  163. const doc = new DOMParser().parseFromString(xmlText, 'application/xml');
  164. if (doc.querySelector('parsererror')) throw new Error('Invalid GPX XML');
  165. const nameNode = doc.querySelector('trk > name, rte > name, metadata > name');
  166. const name = nameNode ? nameNode.textContent.trim()
  167. : filename.replace(/\.gpx$/i, '');
  168. const segments = [];
  169. let trackDate = null;
  170. // Standard tracks (trkseg/trkpt)
  171. for (const seg of doc.querySelectorAll('trkseg')) {
  172. const pts = parsePts(seg.querySelectorAll('trkpt'));
  173. if (pts.length) {
  174. if (!trackDate && pts[0][3]) trackDate = pts[0][3];
  175. segments.push(pts);
  176. }
  177. }
  178. // Routes as fallback (rte/rtept)
  179. if (segments.length === 0) {
  180. for (const rte of doc.querySelectorAll('rte')) {
  181. const pts = parsePts(rte.querySelectorAll('rtept'));
  182. if (pts.length) segments.push(pts);
  183. }
  184. }
  185. if (segments.length === 0) throw new Error('No track points found');
  186. // Compute totals
  187. let totalDistance = 0;
  188. let pointCount = 0;
  189. for (const seg of segments) {
  190. pointCount += seg.length;
  191. for (let i = 1; i < seg.length; i++) {
  192. totalDistance += haversine(seg[i - 1][0], seg[i - 1][1], seg[i][0], seg[i][1]);
  193. }
  194. }
  195. return { segments, meta: { name, totalDistance, pointCount, trackDate } };
  196. }
  197. function parsePts(nodeList) {
  198. const pts = [];
  199. for (const pt of nodeList) {
  200. const lat = parseFloat(pt.getAttribute('lat'));
  201. const lon = parseFloat(pt.getAttribute('lon'));
  202. if (isNaN(lat) || isNaN(lon)) continue;
  203. const eleNode = pt.querySelector('ele');
  204. const timeNode = pt.querySelector('time');
  205. const ele = eleNode ? parseFloat(eleNode.textContent) : null;
  206. const time = timeNode ? timeNode.textContent : null;
  207. pts.push([lat, lon, (ele != null && !isNaN(ele)) ? ele : null, time]);
  208. }
  209. return pts;
  210. }
  211. return { init };
  212. })();