|
@@ -0,0 +1,245 @@
|
|
|
|
|
+// Guest-mode local GPX viewer — no backend, no auth required.
|
|
|
|
|
+const LocalViewer = (() => {
|
|
|
|
|
+ let tracks = {}; // id → { data: {segments,meta}, visible: bool }
|
|
|
|
|
+ let nextId = 0;
|
|
|
|
|
+ let currentId = null; // track shown in info panel / elevation chart
|
|
|
|
|
+
|
|
|
|
|
+ // ===== Public =====
|
|
|
|
|
+
|
|
|
|
|
+ function init() {
|
|
|
|
|
+ setupSidebar();
|
|
|
|
|
+ setupMapDrop();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ===== Sidebar =====
|
|
|
|
|
+
|
|
|
|
|
+ function setupSidebar() {
|
|
|
|
|
+ // Replace logged-in actions with a simple "Open GPX" button
|
|
|
|
|
+ document.getElementById('browser-actions').innerHTML = `
|
|
|
|
|
+ <button id="local-open-btn" class="action-btn">Open GPX</button>
|
|
|
|
|
+ <input type="file" id="local-file-input" accept=".gpx" multiple style="display:none">
|
|
|
|
|
+ `;
|
|
|
|
|
+ document.getElementById('local-open-btn').addEventListener('click', () => {
|
|
|
|
|
+ document.getElementById('local-file-input').click();
|
|
|
|
|
+ });
|
|
|
|
|
+ document.getElementById('local-file-input').addEventListener('change', (e) => {
|
|
|
|
|
+ handleFiles(Array.from(e.target.files));
|
|
|
|
|
+ e.target.value = '';
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // Clear breadcrumb — not relevant in guest mode
|
|
|
|
|
+ document.getElementById('breadcrumb').innerHTML =
|
|
|
|
|
+ '<span style="color:var(--color-text-lighter);font-size:12px">Local viewer</span>';
|
|
|
|
|
+
|
|
|
|
|
+ renderList();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ===== Map drop zone =====
|
|
|
|
|
+
|
|
|
|
|
+ function setupMapDrop() {
|
|
|
|
|
+ const mc = document.getElementById('map-container');
|
|
|
|
|
+ mc.addEventListener('dragover', (e) => {
|
|
|
|
|
+ if (e.dataTransfer.types.includes('Files')) {
|
|
|
|
|
+ e.preventDefault();
|
|
|
|
|
+ mc.classList.add('drag-over');
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+ mc.addEventListener('dragleave', (e) => {
|
|
|
|
|
+ if (!mc.contains(e.relatedTarget)) mc.classList.remove('drag-over');
|
|
|
|
|
+ });
|
|
|
|
|
+ mc.addEventListener('drop', (e) => {
|
|
|
|
|
+ e.preventDefault();
|
|
|
|
|
+ mc.classList.remove('drag-over');
|
|
|
|
|
+ const files = Array.from(e.dataTransfer.files)
|
|
|
|
|
+ .filter(f => f.name.toLowerCase().endsWith('.gpx'));
|
|
|
|
|
+ if (files.length) handleFiles(files);
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ===== File loading =====
|
|
|
|
|
+
|
|
|
|
|
+ async function handleFiles(files) {
|
|
|
|
|
+ for (const file of files) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const text = await file.text();
|
|
|
|
|
+ const data = parseGPX(text, file.name);
|
|
|
|
|
+ const id = 'local_' + (nextId++);
|
|
|
|
|
+ tracks[id] = { data, visible: false };
|
|
|
|
|
+ toggleTrack(id); // open immediately
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ showToast('Error reading ' + file.name + ': ' + e.message, 'error');
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ===== Track toggling =====
|
|
|
|
|
+
|
|
|
|
|
+ function toggleTrack(id) {
|
|
|
|
|
+ const entry = tracks[id];
|
|
|
|
|
+ if (!entry) return;
|
|
|
|
|
+
|
|
|
|
|
+ if (entry.visible) {
|
|
|
|
|
+ MapView.removeTrack(id);
|
|
|
|
|
+ entry.visible = false;
|
|
|
|
|
+ if (currentId === id) {
|
|
|
|
|
+ currentId = null;
|
|
|
|
|
+ MapView.setCurrentTrack(null);
|
|
|
|
|
+ document.getElementById('track-info-panel').style.display = 'none';
|
|
|
|
|
+ if (typeof Elevation !== 'undefined') Elevation.clear();
|
|
|
|
|
+ }
|
|
|
|
|
+ } else {
|
|
|
|
|
+ MapView.addTrack(entry.data, id);
|
|
|
|
|
+ MapView.fitTrack(id);
|
|
|
|
|
+ MapView.setCurrentTrack(id);
|
|
|
|
|
+ entry.visible = true;
|
|
|
|
|
+ currentId = id;
|
|
|
|
|
+ showTrackInfo(entry.data.meta);
|
|
|
|
|
+ if (typeof Elevation !== 'undefined') {
|
|
|
|
|
+ const pts = MapView.getTrackPoints(id);
|
|
|
|
|
+ if (pts) Elevation.setTrack(pts);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ renderList();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function removeTrack(id) {
|
|
|
|
|
+ if (tracks[id]?.visible) MapView.removeTrack(id);
|
|
|
|
|
+ if (currentId === id) {
|
|
|
|
|
+ currentId = null;
|
|
|
|
|
+ MapView.setCurrentTrack(null);
|
|
|
|
|
+ document.getElementById('track-info-panel').style.display = 'none';
|
|
|
|
|
+ if (typeof Elevation !== 'undefined') Elevation.clear();
|
|
|
|
|
+ }
|
|
|
|
|
+ delete tracks[id];
|
|
|
|
|
+ renderList();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ===== Rendering =====
|
|
|
|
|
+
|
|
|
|
|
+ function renderList() {
|
|
|
|
|
+ const list = document.getElementById('browser-list');
|
|
|
|
|
+ const ids = Object.keys(tracks);
|
|
|
|
|
+
|
|
|
|
|
+ if (ids.length === 0) {
|
|
|
|
|
+ list.innerHTML = '<div class="empty-list">Open a GPX file or drop it onto the map.</div>';
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ let html = '';
|
|
|
|
|
+ for (const id of ids) {
|
|
|
|
|
+ const { data, visible } = tracks[id];
|
|
|
|
|
+ const meta = data.meta;
|
|
|
|
|
+ const dist = meta.totalDistance ? formatDistance(meta.totalDistance) : '';
|
|
|
|
|
+ html += `<div class="tree-item track-item${visible ? ' local-active' : ''}"
|
|
|
|
|
+ data-id="${escAttr(id)}" style="padding-left:12px">
|
|
|
|
|
+ <span class="item-icon">🗺️</span>
|
|
|
|
|
+ <span class="item-name">${escHtml(meta.name)}</span>
|
|
|
|
|
+ <span class="item-meta">${dist}</span>
|
|
|
|
|
+ <span class="item-actions">
|
|
|
|
|
+ <button class="item-btn local-remove-btn" data-id="${escAttr(id)}" title="Remove">🗑️</button>
|
|
|
|
|
+ </span>
|
|
|
|
|
+ </div>`;
|
|
|
|
|
+ }
|
|
|
|
|
+ list.innerHTML = html;
|
|
|
|
|
+
|
|
|
|
|
+ list.querySelectorAll('.track-item').forEach(el => {
|
|
|
|
|
+ el.addEventListener('click', (e) => {
|
|
|
|
|
+ if (e.target.closest('.item-actions')) return;
|
|
|
|
|
+ toggleTrack(el.dataset.id);
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+ list.querySelectorAll('.local-remove-btn').forEach(btn => {
|
|
|
|
|
+ btn.addEventListener('click', (e) => {
|
|
|
|
|
+ e.stopPropagation();
|
|
|
|
|
+ removeTrack(btn.dataset.id);
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ===== Track info panel =====
|
|
|
|
|
+
|
|
|
|
|
+ function showTrackInfo(meta) {
|
|
|
|
|
+ if (!meta) return;
|
|
|
|
|
+ document.getElementById('track-info-content').innerHTML = `
|
|
|
|
|
+ <h3>${escHtml(meta.name || 'Track')}</h3>
|
|
|
|
|
+ <div class="info-row"><label>Distance</label><span>${formatDistance(meta.totalDistance)}</span></div>
|
|
|
|
|
+ <div class="info-row"><label>Points</label><span>${meta.pointCount || 0}</span></div>
|
|
|
|
|
+ ${meta.trackDate ? `<div class="info-row"><label>Date</label><span>${formatDate(meta.trackDate)}</span></div>` : ''}
|
|
|
|
|
+ `;
|
|
|
|
|
+ const panel = document.getElementById('track-info-panel');
|
|
|
|
|
+ panel.style.display = 'block';
|
|
|
|
|
+ document.getElementById('track-info-close').onclick = () => { panel.style.display = 'none'; };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ===== GPX parsing =====
|
|
|
|
|
+
|
|
|
|
|
+ function haversine(lat1, lon1, lat2, lon2) {
|
|
|
|
|
+ const R = 6371000;
|
|
|
|
|
+ const dLat = (lat2 - lat1) * Math.PI / 180;
|
|
|
|
|
+ const dLon = (lon2 - lon1) * Math.PI / 180;
|
|
|
|
|
+ const a = Math.sin(dLat / 2) ** 2 +
|
|
|
|
|
+ Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
|
|
|
|
|
+ Math.sin(dLon / 2) ** 2;
|
|
|
|
|
+ return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function parseGPX(xmlText, filename) {
|
|
|
|
|
+ const doc = new DOMParser().parseFromString(xmlText, 'application/xml');
|
|
|
|
|
+ if (doc.querySelector('parsererror')) throw new Error('Invalid GPX XML');
|
|
|
|
|
+
|
|
|
|
|
+ const nameNode = doc.querySelector('trk > name, rte > name, metadata > name');
|
|
|
|
|
+ const name = nameNode ? nameNode.textContent.trim()
|
|
|
|
|
+ : filename.replace(/\.gpx$/i, '');
|
|
|
|
|
+
|
|
|
|
|
+ const segments = [];
|
|
|
|
|
+ let trackDate = null;
|
|
|
|
|
+
|
|
|
|
|
+ // Standard tracks (trkseg/trkpt)
|
|
|
|
|
+ for (const seg of doc.querySelectorAll('trkseg')) {
|
|
|
|
|
+ const pts = parsePts(seg.querySelectorAll('trkpt'));
|
|
|
|
|
+ if (pts.length) {
|
|
|
|
|
+ if (!trackDate && pts[0][3]) trackDate = pts[0][3];
|
|
|
|
|
+ segments.push(pts);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Routes as fallback (rte/rtept)
|
|
|
|
|
+ if (segments.length === 0) {
|
|
|
|
|
+ for (const rte of doc.querySelectorAll('rte')) {
|
|
|
|
|
+ const pts = parsePts(rte.querySelectorAll('rtept'));
|
|
|
|
|
+ if (pts.length) segments.push(pts);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (segments.length === 0) throw new Error('No track points found');
|
|
|
|
|
+
|
|
|
|
|
+ // Compute totals
|
|
|
|
|
+ let totalDistance = 0;
|
|
|
|
|
+ let pointCount = 0;
|
|
|
|
|
+ for (const seg of segments) {
|
|
|
|
|
+ pointCount += seg.length;
|
|
|
|
|
+ for (let i = 1; i < seg.length; i++) {
|
|
|
|
|
+ totalDistance += haversine(seg[i - 1][0], seg[i - 1][1], seg[i][0], seg[i][1]);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return { segments, meta: { name, totalDistance, pointCount, trackDate } };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function parsePts(nodeList) {
|
|
|
|
|
+ const pts = [];
|
|
|
|
|
+ for (const pt of nodeList) {
|
|
|
|
|
+ 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 ele = eleNode ? parseFloat(eleNode.textContent) : null;
|
|
|
|
|
+ const time = timeNode ? timeNode.textContent : null;
|
|
|
|
|
+ pts.push([lat, lon, (ele != null && !isNaN(ele)) ? ele : null, time]);
|
|
|
|
|
+ }
|
|
|
|
|
+ return pts;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return { init };
|
|
|
|
|
+})();
|