Просмотр исходного кода

Add guest mode: browse map and view local GPX files without login

- New LocalViewer module (local.js): parse and display GPX files
  client-side with no backend API calls; supports drag-drop onto map
  and file picker; tracks toggled on/off with remove button
- initGuestMode() in app.js: shows app with Login button in topbar,
  hides Stats tab, initialises MapView + Elevation + LocalViewer
- 'Browse without account' link on auth page enters guest mode
- .local-active CSS style highlights visible local tracks

No backend restart required (frontend-only change).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
k4be 10 часов назад
Родитель
Сommit
27e1c2fe9e

+ 6 - 0
gpx-vis-frontend/css/style.css

@@ -482,6 +482,12 @@ form button[type="submit"]:hover {
   opacity: 0.4;
 }
 
+/* ===== Local (guest) active track ===== */
+.track-item.local-active {
+  background: #e8f4fd;
+  border-left: 3px solid var(--color-blue);
+}
+
 /* ===== Tree loading placeholder ===== */
 .tree-loading {
   padding-top: 4px;

+ 4 - 0
gpx-vis-frontend/index.html

@@ -28,6 +28,9 @@
         <input type="password" id="reg-password" placeholder="Password (min 6 chars)" required>
         <button type="submit">Register</button>
       </form>
+      <div style="margin-top:16px;text-align:center">
+        <a id="guest-mode-link" href="#" style="color:var(--color-text-lighter);font-size:13px">Browse without account →</a>
+      </div>
     </div>
   </div>
 
@@ -153,6 +156,7 @@
   <script src="js/elevation.js"></script>
   <script src="js/browser.js"></script>
   <script src="js/stats.js"></script>
+  <script src="js/local.js"></script>
   <script src="js/app.js"></script>
 </body>
 </html>

+ 42 - 0
gpx-vis-frontend/js/app.js

@@ -198,6 +198,42 @@ async function initSharePage(code) {
   document.title = (window.APP_CONFIG?.appName || 'GPX Visualizer') + ' - Shared Track';
 }
 
+// ===== Guest Mode =====
+
+function initGuestMode() {
+  document.getElementById('auth-page').style.display = 'none';
+  document.getElementById('app').style.display = 'flex';
+
+  // Hide Stats tab — not useful without backend data
+  document.querySelectorAll('.sidebar-tab').forEach(btn => {
+    if (btn.dataset.tab === 'stats') btn.style.display = 'none';
+  });
+
+  const appName = escHtml(window.APP_CONFIG?.appName || 'GPX Visualizer');
+  document.getElementById('topbar-left').innerHTML = `
+    <button id="sidebar-toggle-btn" class="icon-btn" title="Toggle sidebar">☰</button>
+    <span id="topbar-title">${appName}</span>
+  `;
+  document.getElementById('topbar-right').innerHTML = `
+    <span style="color:rgba(255,255,255,0.6);font-size:13px">Guest</span>
+    <button id="login-btn" class="topbar-btn" onclick="window.location.reload()">Login</button>
+  `;
+
+  // Re-bind sidebar toggle (innerHTML replaced the element)
+  const sidebar = document.getElementById('sidebar');
+  document.getElementById('sidebar-toggle-btn').addEventListener('click', () => {
+    sidebar.classList.toggle('collapsed');
+    setTimeout(() => {
+      const m = MapView.getMap();
+      if (m) m.invalidateSize();
+    }, 250);
+  });
+
+  MapView.init();
+  Elevation.init();
+  LocalViewer.init();
+}
+
 // ===== Main Init =====
 
 async function main() {
@@ -262,6 +298,12 @@ async function main() {
     }, 250);
   });
 
+  // Guest mode link
+  document.getElementById('guest-mode-link').addEventListener('click', (e) => {
+    e.preventDefault();
+    initGuestMode();
+  });
+
   // Check auth
   Auth.setupForms();
   const loggedIn = await Auth.init();

+ 245 - 0
gpx-vis-frontend/js/local.js

@@ -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 };
+})();