Browse Source

Add elevation profile chart and map hover tooltip

- New js/elevation.js: canvas-based altitude profile rendered in the
  track info panel; gradient fill, grid with elevation/distance labels,
  dashed cursor + dot on hover; cross-linked with map hover
- map.js: flatten track segments into [{lat,lon,ele,time,dist}] on load
  (haversine cumulative distance); hover marker (red dot) follows mouse
  within 20 px of any track line; Leaflet tooltip shows dist/ele/time/
  coords; click pins/unpins sticky tooltip
- Chart hover moves the map marker; map hover draws chart indicator;
  Elevation.formatTooltip() is shared between both surfaces
- Elevation profile also shown on share-link pages
- CLAUDE.md: document elevation/hover design, track-point format,
  and when a backend restart is required after git pull

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
k4be 10 hours ago
parent
commit
f345200b4c

+ 30 - 1
CLAUDE.md

@@ -43,7 +43,8 @@ gpx-vis/
 **JS modules (loaded in order):**
 **JS modules (loaded in order):**
 - `js/api.js` — all `fetch` wrappers (`API.*`)
 - `js/api.js` — all `fetch` wrappers (`API.*`)
 - `js/auth.js` — login/register forms, JWT storage
 - `js/auth.js` — login/register forms, JWT storage
-- `js/map.js` — Leaflet map, track layers, URL hash state (`#map=lat,lng,zoom&tracks=id1,id2&open=id`)
+- `js/map.js` — Leaflet map, track layers, hover marker, URL hash state (`#map=lat,lng,zoom&tracks=id1,id2&open=id`)
+- `js/elevation.js` — canvas altitude-profile chart, chart↔map hover cross-linking
 - `js/browser.js` — file/folder tree browser, drag-and-drop, track actions
 - `js/browser.js` — file/folder tree browser, drag-and-drop, track actions
 - `js/stats.js` — stats tab rendering
 - `js/stats.js` — stats tab rendering
 - `js/app.js` — global utilities, admin panel, share-page init, main entry point
 - `js/app.js` — global utilities, admin panel, share-page init, main entry point
@@ -56,9 +57,37 @@ gpx-vis/
 - `dirContents` cache: key `'root'` or numeric dir id → `{dirs, tracks}`
 - `dirContents` cache: key `'root'` or numeric dir id → `{dirs, tracks}`
 - `dirMeta` cache: dir id → `{id, name, parentId}` (used for breadcrumb path)
 - `dirMeta` cache: dir id → `{id, name, parentId}` (used for breadcrumb path)
 
 
+**`elevation.js` design:**
+- `Elevation.setTrack(pts)` — accepts flat `[{lat,lon,ele,time,dist}]` array from `MapView.getTrackPoints()`
+- `Elevation.onMapHover(point)` / `onMapLeave()` — called by `map.js` when hovering the current track
+- `Elevation.formatTooltip(point)` — shared HTML formatter used by the Leaflet map tooltip
+- Chart hover calls `MapView.showHoverMarker()` / `hideHoverMarker()` to move the map dot
+- Both modules reference each other at call time (not at definition time) — load order doesn't matter
+
+**`map.js` hover design:**
+- `flattenPoints(trackData)` converts segments `[[lat,lon,ele,time],…]` → `[{lat,lon,ele,time,dist}]` with cumulative haversine distance
+- On every `mousemove`, iterates all loaded track points to find the nearest within `HOVER_TOLERANCE_PX = 20` px
+- If nearest point belongs to `currentTrackId`, calls `Elevation.onMapHover()`; otherwise calls `Elevation.onMapLeave()`
+- Click on the map pins/unpins the hover tooltip (sticky mode)
+
+## Deployment — when to restart the backend
+
+**Backend restart required** (changes to Node.js source, config, or dependencies):
+- Any edit under `gpx-vis-backend/src/`
+- Changes to `gpx-vis-backend/index.js` or `config.js`
+- Running `npm install` (new/updated packages)
+- After `git pull` when any backend file changed
+
+**No restart needed** (frontend-only changes):
+- Any edit under `gpx-vis-frontend/` (HTML, CSS, JS, config.js)
+- Changes to `CLAUDE.md`, `README.md`, or other docs
+
+After `git pull`, check `git diff HEAD~1 --name-only` — if all changed files are under `gpx-vis-frontend/` or are docs, skip the restart.
+
 ## Development notes
 ## Development notes
 
 
 - Do not mock the database in tests — `test/setup.js` wires real in-memory SQLite
 - Do not mock the database in tests — `test/setup.js` wires real in-memory SQLite
 - All track geometry is stored as `TrackPoint` rows; no file storage after upload
 - All track geometry is stored as `TrackPoint` rows; no file storage after upload
 - The `Module._load` override in `test/setup.js` is required because `config.js` is loaded at module level in several files
 - The `Module._load` override in `test/setup.js` is required because `config.js` is loaded at module level in several files
 - `API.getTracks('')` returns root-level tracks (directoryId = null)
 - `API.getTracks('')` returns root-level tracks (directoryId = null)
+- Track point array format from `/api/tracks/:id/points`: `segments: [[[lat, lon, elevation, time], …], …]`

+ 64 - 2
gpx-vis-frontend/css/style.css

@@ -585,11 +585,73 @@ form button[type="submit"]:hover {
   border-radius: var(--radius-lg);
   border-radius: var(--radius-lg);
   box-shadow: var(--shadow-md);
   box-shadow: var(--shadow-md);
   padding: 16px;
   padding: 16px;
-  min-width: 220px;
-  max-width: 300px;
+  min-width: 280px;
+  max-width: 400px;
   z-index: 1000;
   z-index: 1000;
 }
 }
 
 
+/* ===== Elevation Chart ===== */
+#elevation-chart-container {
+  position: relative;
+  height: 120px;
+  margin-top: 10px;
+  border-top: 1px solid var(--color-border-light);
+  padding-top: 8px;
+}
+
+#elevation-canvas {
+  display: block;
+  width: 100%;
+  height: 100%;
+  cursor: crosshair;
+}
+
+#elevation-tooltip {
+  position: absolute;
+  top: 0;
+  left: 0;
+  background: rgba(44, 62, 80, 0.92);
+  color: #fff;
+  padding: 6px 10px;
+  border-radius: var(--radius-sm);
+  font-size: 11px;
+  line-height: 1.55;
+  pointer-events: none;
+  white-space: nowrap;
+  opacity: 0;
+  transition: opacity 0.12s;
+  z-index: 10;
+}
+
+#elevation-tooltip.visible {
+  opacity: 1;
+}
+
+/* ===== Map hover marker ===== */
+.hover-marker {
+  background: #e74c3c;
+  border: 3px solid white;
+  border-radius: 50%;
+  box-shadow: 0 2px 5px rgba(0, 0, 0, 0.35);
+}
+
+/* ===== Map hover tooltip ===== */
+.map-tooltip.leaflet-tooltip {
+  background: rgba(44, 62, 80, 0.92) !important;
+  color: #fff !important;
+  border: none !important;
+  border-radius: var(--radius-sm) !important;
+  padding: 6px 10px !important;
+  font-size: 11px !important;
+  line-height: 1.55;
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25) !important;
+  white-space: nowrap;
+}
+
+.map-tooltip.leaflet-tooltip::before {
+  display: none !important;
+}
+
 #track-info-panel h3 {
 #track-info-panel h3 {
   margin-bottom: 8px;
   margin-bottom: 8px;
   font-size: 14px;
   font-size: 14px;

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

@@ -75,6 +75,10 @@
         <div id="map"></div>
         <div id="map"></div>
         <div id="track-info-panel" style="display:none">
         <div id="track-info-panel" style="display:none">
           <div id="track-info-content"></div>
           <div id="track-info-content"></div>
+          <div id="elevation-chart-container">
+            <canvas id="elevation-canvas"></canvas>
+            <div id="elevation-tooltip"></div>
+          </div>
           <button id="track-info-close">×</button>
           <button id="track-info-close">×</button>
         </div>
         </div>
       </div>
       </div>
@@ -146,6 +150,7 @@
   <script src="js/api.js"></script>
   <script src="js/api.js"></script>
   <script src="js/auth.js"></script>
   <script src="js/auth.js"></script>
   <script src="js/map.js"></script>
   <script src="js/map.js"></script>
+  <script src="js/elevation.js"></script>
   <script src="js/browser.js"></script>
   <script src="js/browser.js"></script>
   <script src="js/stats.js"></script>
   <script src="js/stats.js"></script>
   <script src="js/app.js"></script>
   <script src="js/app.js"></script>

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

@@ -165,6 +165,7 @@ async function initSharePage(code) {
   `;
   `;
 
 
   MapView.init();
   MapView.init();
+  Elevation.init();
 
 
   const panel = document.getElementById('track-info-panel');
   const panel = document.getElementById('track-info-panel');
   const infoContent = document.getElementById('track-info-content');
   const infoContent = document.getElementById('track-info-content');
@@ -173,6 +174,9 @@ async function initSharePage(code) {
     const data = await API.getShared(code);
     const data = await API.getShared(code);
     MapView.addTrack(data, 'shared');
     MapView.addTrack(data, 'shared');
     MapView.fitTrack('shared');
     MapView.fitTrack('shared');
+    MapView.setCurrentTrack('shared');
+    const pts = MapView.getTrackPoints('shared');
+    if (pts) Elevation.setTrack(pts);
 
 
     panel.style.display = 'block';
     panel.style.display = 'block';
     infoContent.innerHTML = `
     infoContent.innerHTML = `
@@ -289,6 +293,7 @@ async function main() {
 
 
   // Init map
   // Init map
   MapView.init();
   MapView.init();
+  Elevation.init();
 
 
   // Init browser
   // Init browser
   await Browser.init();
   await Browser.init();

+ 5 - 0
gpx-vis-frontend/js/browser.js

@@ -341,6 +341,7 @@ const Browser = (() => {
         MapView.removeTrack(trackId);
         MapView.removeTrack(trackId);
         MapView.setCurrentTrack(null);
         MapView.setCurrentTrack(null);
         document.getElementById('track-info-panel').style.display = 'none';
         document.getElementById('track-info-panel').style.display = 'none';
+        if (typeof Elevation !== 'undefined') Elevation.clear();
         return;
         return;
       }
       }
       const data = await API.getTrackPoints(trackId);
       const data = await API.getTrackPoints(trackId);
@@ -348,6 +349,10 @@ const Browser = (() => {
       MapView.fitTrack(trackId);
       MapView.fitTrack(trackId);
       MapView.setCurrentTrack(trackId);
       MapView.setCurrentTrack(trackId);
       showTrackInfo(data.meta);
       showTrackInfo(data.meta);
+      if (typeof Elevation !== 'undefined') {
+        const pts = MapView.getTrackPoints(trackId);
+        if (pts) Elevation.setTrack(pts);
+      }
     } catch (e) {
     } catch (e) {
       showToast('Error loading track: ' + e.message, 'error');
       showToast('Error loading track: ' + e.message, 'error');
     }
     }

+ 279 - 0
gpx-vis-frontend/js/elevation.js

@@ -0,0 +1,279 @@
+const Elevation = (() => {
+  const CHART_MAX_PTS = 500;
+  const PAD = { top: 20, right: 16, bottom: 28, left: 46 };
+
+  let canvas = null;
+  let tooltip = null;
+  let points = null;    // full-res flat [{lat,lon,ele,time,dist}]
+  let chartPts = null;  // downsampled
+  let bounds = null;    // computed after draw: {cw,ch,minE,eRange,totDist}
+
+  // ===== Public API =====
+
+  function init() {
+    canvas  = document.getElementById('elevation-canvas');
+    tooltip = document.getElementById('elevation-tooltip');
+    if (!canvas) return;
+    canvas.addEventListener('mousemove', onChartMove);
+    canvas.addEventListener('mouseleave', onChartLeave);
+    window.addEventListener('resize', () => { if (points) raf(draw); });
+  }
+
+  function setTrack(pts) {
+    points   = pts;
+    chartPts = downsample(pts, CHART_MAX_PTS);
+    bounds   = null;
+    raf(draw);
+  }
+
+  function clear() {
+    points = chartPts = bounds = null;
+    hideTooltip();
+    if (canvas) {
+      canvas.width = canvas.width; // reset context
+    }
+  }
+
+  // Called by MapView when hovering a point that belongs to the current track
+  function onMapHover(point) {
+    if (!bounds || !canvas) return;
+    drawIndicator(point);
+    const x = PAD.left + (point.dist / bounds.totDist) * bounds.cw;
+    positionTooltip(point, x, PAD.top + bounds.ch / 2);
+  }
+
+  // Called by MapView when hover leaves the current track
+  function onMapLeave() {
+    hideTooltip();
+    if (bounds) draw();
+  }
+
+  // Shared tooltip formatter used by MapView for the Leaflet map tooltip
+  function formatTooltip(p) {
+    const dist = p.dist >= 1000
+      ? (p.dist / 1000).toFixed(2) + ' km'
+      : Math.round(p.dist) + ' m';
+    let html = `<div><b>Dist:</b> ${dist}</div>`;
+    if (p.ele != null) html += `<div><b>Ele:</b> ${Math.round(p.ele)} m</div>`;
+    if (p.time) {
+      const t = new Date(p.time);
+      if (!isNaN(t)) {
+        html += `<div><b>Time:</b> ${t.toLocaleTimeString(undefined,
+          { hour: '2-digit', minute: '2-digit', second: '2-digit' })}</div>`;
+      }
+    }
+    html += `<div style="color:rgba(255,255,255,0.6)">${p.lat.toFixed(5)}, ${p.lon.toFixed(5)}</div>`;
+    return html;
+  }
+
+  // ===== Internal =====
+
+  function raf(fn) {
+    requestAnimationFrame(() => requestAnimationFrame(fn));
+  }
+
+  function downsample(pts, max) {
+    if (pts.length <= max) return pts;
+    const out = [];
+    const step = (pts.length - 1) / (max - 1);
+    for (let i = 0; i < max; i++) out.push(pts[Math.round(i * step)]);
+    return out;
+  }
+
+  // ===== Chart drawing =====
+
+  function draw() {
+    if (!canvas || !chartPts || chartPts.length === 0) return;
+    const rect = canvas.getBoundingClientRect();
+    if (!rect.width || !rect.height) return;
+
+    const dpr = window.devicePixelRatio || 1;
+    canvas.width  = rect.width  * dpr;
+    canvas.height = rect.height * dpr;
+    const ctx = canvas.getContext('2d');
+    ctx.scale(dpr, dpr);               // from here on, all coords are CSS pixels
+    ctx.clearRect(0, 0, rect.width, rect.height);
+
+    const hasEle = chartPts.some(p => p.ele != null);
+    if (!hasEle) {
+      ctx.fillStyle = '#95a5a6';
+      ctx.font = '11px sans-serif';
+      ctx.textAlign = 'center';
+      ctx.fillText('No elevation data', rect.width / 2, rect.height / 2);
+      bounds = null;
+      return;
+    }
+
+    const cw = rect.width  - PAD.left - PAD.right;
+    const ch = rect.height - PAD.top  - PAD.bottom;
+    const eles   = chartPts.map(p => p.ele).filter(e => e != null);
+    const minE   = Math.min(...eles);
+    const maxE   = Math.max(...eles);
+    const eRange = maxE - minE || 1;
+    const totDist = chartPts[chartPts.length - 1].dist;
+
+    drawGrid(ctx, rect, cw, ch, minE, maxE, eRange, totDist);
+    drawProfile(ctx, cw, ch, minE, eRange, totDist);
+
+    bounds = { cw, ch, minE, maxE, eRange, totDist };
+  }
+
+  function drawGrid(ctx, rect, cw, ch, minE, maxE, eRange, totDist) {
+    ctx.strokeStyle = '#e8e8e8';
+    ctx.lineWidth   = 1;
+    ctx.fillStyle   = '#999';
+    ctx.font        = '10px sans-serif';
+
+    // Horizontal grid + elevation labels
+    for (let i = 0; i <= 4; i++) {
+      const y = PAD.top + ch * i / 4;
+      ctx.beginPath();
+      ctx.moveTo(PAD.left, y);
+      ctx.lineTo(PAD.left + cw, y);
+      ctx.stroke();
+      ctx.textAlign = 'right';
+      ctx.fillText(Math.round(maxE - eRange * i / 4) + 'm', PAD.left - 4, y + 3);
+    }
+
+    // Distance labels
+    ctx.textAlign = 'center';
+    for (let i = 0; i <= 5; i++) {
+      const x = PAD.left + cw * i / 5;
+      const d = totDist * i / 5;
+      const label = d >= 1000 ? (d / 1000).toFixed(1) + 'k' : Math.round(d) + 'm';
+      ctx.fillText(label, x, rect.height - 5);
+    }
+  }
+
+  function drawProfile(ctx, cw, ch, minE, eRange, totDist) {
+    const toX = p => PAD.left + (p.dist / totDist) * cw;
+    const toY = p => PAD.top + ch - ((p.ele - minE) / eRange) * ch;
+
+    // Gradient fill
+    const grad = ctx.createLinearGradient(0, PAD.top, 0, PAD.top + ch);
+    grad.addColorStop(0, 'rgba(52,152,219,0.55)');
+    grad.addColorStop(1, 'rgba(52,152,219,0.07)');
+
+    ctx.beginPath();
+    let first = true;
+    for (const p of chartPts) {
+      if (p.ele == null) continue;
+      if (first) { ctx.moveTo(toX(p), toY(p)); first = false; }
+      else ctx.lineTo(toX(p), toY(p));
+    }
+    ctx.lineTo(PAD.left + cw, PAD.top + ch);
+    ctx.lineTo(PAD.left, PAD.top + ch);
+    ctx.closePath();
+    ctx.fillStyle = grad;
+    ctx.fill();
+
+    // Profile line
+    ctx.beginPath();
+    ctx.strokeStyle = '#3498db';
+    ctx.lineWidth   = 1.5;
+    first = true;
+    for (const p of chartPts) {
+      if (p.ele == null) continue;
+      if (first) { ctx.moveTo(toX(p), toY(p)); first = false; }
+      else ctx.lineTo(toX(p), toY(p));
+    }
+    ctx.stroke();
+  }
+
+  // Draw a vertical cursor + dot at the given point (CSS pixel coords)
+  function drawIndicator(point) {
+    if (!bounds || !canvas) return;
+    draw(); // resets canvas and re-applies ctx.scale(dpr,dpr) — use CSS px below
+
+    const { cw, ch, minE, eRange, totDist } = bounds;
+    const ctx = canvas.getContext('2d');
+
+    const x = PAD.left + (point.dist / totDist) * cw;
+    const y = point.ele != null
+      ? PAD.top + ch - ((point.ele - minE) / eRange) * ch
+      : PAD.top + ch / 2;
+
+    ctx.save();
+
+    // Vertical dashed line
+    ctx.strokeStyle = 'rgba(231,76,60,0.55)';
+    ctx.lineWidth   = 1;
+    ctx.setLineDash([4, 4]);
+    ctx.beginPath();
+    ctx.moveTo(x, PAD.top);
+    ctx.lineTo(x, PAD.top + ch);
+    ctx.stroke();
+    ctx.setLineDash([]);
+
+    // Dot
+    ctx.fillStyle   = '#e74c3c';
+    ctx.strokeStyle = 'white';
+    ctx.lineWidth   = 1.5;
+    ctx.beginPath();
+    ctx.arc(x, y, 4, 0, Math.PI * 2);
+    ctx.fill();
+    ctx.stroke();
+
+    ctx.restore();
+  }
+
+  // ===== Hover =====
+
+  function onChartMove(e) {
+    if (!bounds || !chartPts) return;
+    const rect = canvas.getBoundingClientRect();
+    const x = e.clientX - rect.left;
+
+    if (x < PAD.left || x > PAD.left + bounds.cw) {
+      onChartLeave();
+      return;
+    }
+
+    const dist = ((x - PAD.left) / bounds.cw) * bounds.totDist;
+
+    // Find nearest downsampled point for chart indicator
+    const chartPt = findNearestByDist(chartPts, dist);
+    // Find nearest full-res point for map marker
+    const fullPt  = findNearestByDist(points,   dist);
+
+    if (!chartPt) return;
+
+    drawIndicator(chartPt);
+    positionTooltip(chartPt, x, e.clientY - rect.top);
+
+    if (fullPt && typeof MapView !== 'undefined') {
+      MapView.showHoverMarker(fullPt.lat, fullPt.lon, fullPt);
+    }
+  }
+
+  function onChartLeave() {
+    hideTooltip();
+    if (typeof MapView !== 'undefined') MapView.hideHoverMarker();
+    if (bounds) draw();
+  }
+
+  function findNearestByDist(pts, targetDist) {
+    if (!pts || pts.length === 0) return null;
+    let nearest = null, nearestD = Infinity;
+    for (const p of pts) {
+      const d = Math.abs(p.dist - targetDist);
+      if (d < nearestD) { nearestD = d; nearest = p; }
+    }
+    return nearest;
+  }
+
+  function positionTooltip(point, cx, cy) {
+    if (!tooltip || !canvas) return;
+    tooltip.innerHTML = formatTooltip(point);
+    tooltip.classList.add('visible');
+    const cRect = canvas.getBoundingClientRect();
+    tooltip.style.left = Math.min(cx + 10, cRect.width  - tooltip.offsetWidth  - 4) + 'px';
+    tooltip.style.top  = Math.max(4,        cy - tooltip.offsetHeight / 2)           + 'px';
+  }
+
+  function hideTooltip() {
+    if (tooltip) tooltip.classList.remove('visible');
+  }
+
+  return { init, setTrack, clear, formatTooltip, onMapHover, onMapLeave };
+})();

+ 154 - 58
gpx-vis-frontend/js/map.js

@@ -1,11 +1,44 @@
 const MapView = (() => {
 const MapView = (() => {
   let map = null;
   let map = null;
-  let trackLayers = {};  // trackId → { layer, color, markers }
+  let trackLayers = {};  // trackId → { layer, color, markers, points }
   let currentTrackId = null;
   let currentTrackId = null;
+  let hoverMarker = null;
+  let stickyPoint = false;
 
 
   const COLORS = ['#e74c3c', '#3498db', '#2ecc71', '#f39c12', '#9b59b6', '#1abc9c', '#e67e22', '#34495e'];
   const COLORS = ['#e74c3c', '#3498db', '#2ecc71', '#f39c12', '#9b59b6', '#1abc9c', '#e67e22', '#34495e'];
+  const HOVER_TOLERANCE_PX = 20;
   let colorIndex = 0;
   let colorIndex = 0;
 
 
+  // ===== Haversine / point helpers =====
+
+  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));
+  }
+
+  // Flatten segments [[lat,lon,ele,time], ...] into [{lat,lon,ele,time,dist}]
+  function flattenPoints(trackData) {
+    const pts = [];
+    let dist = 0;
+    for (const seg of (trackData.segments || [])) {
+      for (const p of seg) {
+        if (pts.length > 0) {
+          const prev = pts[pts.length - 1];
+          dist += haversine(prev.lat, prev.lon, p[0], p[1]);
+        }
+        pts.push({ lat: p[0], lon: p[1], ele: p[2] ?? null, time: p[3] ?? null, dist });
+      }
+    }
+    return pts;
+  }
+
+  // ===== Init =====
+
   function init() {
   function init() {
     map = L.map('map');
     map = L.map('map');
 
 
@@ -34,15 +67,25 @@ const MapView = (() => {
     L.control.layers(baseMaps).addTo(map);
     L.control.layers(baseMaps).addTo(map);
     L.control.scale({ metric: true, imperial: false }).addTo(map);
     L.control.scale({ metric: true, imperial: false }).addTo(map);
 
 
-    // Restore state from URL hash
-    restoreFromHash();
+    // Hover marker (red dot, non-interactive)
+    hoverMarker = L.marker([0, 0], {
+      icon: L.divIcon({ className: 'hover-marker', iconSize: [14, 14], iconAnchor: [7, 7] }),
+      interactive: false,
+      zIndexOffset: 1000
+    });
+
+    map.on('mousemove', onMapMouseMove);
+    map.on('mouseout',  onMapMouseOut);
+    map.on('click',     onMapClick);
 
 
-    // Save state on map move/zoom
+    restoreFromHash();
     map.on('moveend zoomend', saveToHash);
     map.on('moveend zoomend', saveToHash);
 
 
     return map;
     return map;
   }
   }
 
 
+  // ===== Track management =====
+
   function getNextColor() {
   function getNextColor() {
     const c = COLORS[colorIndex % COLORS.length];
     const c = COLORS[colorIndex % COLORS.length];
     colorIndex++;
     colorIndex++;
@@ -53,53 +96,48 @@ const MapView = (() => {
     if (trackLayers[trackId]) return trackLayers[trackId].layer;
     if (trackLayers[trackId]) return trackLayers[trackId].layer;
 
 
     const color = getNextColor();
     const color = getNextColor();
-    const lines = [];
     const addedMarkers = [];
     const addedMarkers = [];
 
 
     if (!trackData.segments || trackData.segments.length === 0) return null;
     if (!trackData.segments || trackData.segments.length === 0) return null;
 
 
+    const lines = [];
     for (const seg of trackData.segments) {
     for (const seg of trackData.segments) {
       if (!seg || seg.length === 0) continue;
       if (!seg || seg.length === 0) continue;
-      const latlngs = seg.map(p => [p[0], p[1]]);
-      lines.push(latlngs);
+      lines.push(seg.map(p => [p[0], p[1]]));
     }
     }
-
     if (lines.length === 0) return null;
     if (lines.length === 0) return null;
 
 
     const layer = L.polyline(lines, { color, weight: 3, opacity: 0.8 });
     const layer = L.polyline(lines, { color, weight: 3, opacity: 0.8 });
 
 
-    // Add start marker (green)
+    // Start marker (green)
     const firstSeg = trackData.segments.find(s => s && s.length > 0);
     const firstSeg = trackData.segments.find(s => s && s.length > 0);
     if (firstSeg) {
     if (firstSeg) {
       const first = firstSeg[0];
       const first = firstSeg[0];
-      const startMarker = L.circleMarker([first[0], first[1]], {
-        radius: 7,
-        color: '#27ae60',
-        fillColor: '#27ae60',
-        fillOpacity: 1,
-        weight: 2
+      const m = L.circleMarker([first[0], first[1]], {
+        radius: 7, color: '#27ae60', fillColor: '#27ae60', fillOpacity: 1, weight: 2
       }).bindTooltip('Start: ' + (trackData.meta?.name || 'Track'));
       }).bindTooltip('Start: ' + (trackData.meta?.name || 'Track'));
-      startMarker.addTo(map);
-      addedMarkers.push(startMarker);
+      m.addTo(map);
+      addedMarkers.push(m);
     }
     }
 
 
-    // Add end marker (red)
+    // End marker (red)
     const lastSeg = [...trackData.segments].reverse().find(s => s && s.length > 0);
     const lastSeg = [...trackData.segments].reverse().find(s => s && s.length > 0);
     if (lastSeg) {
     if (lastSeg) {
       const last = lastSeg[lastSeg.length - 1];
       const last = lastSeg[lastSeg.length - 1];
-      const endMarker = L.circleMarker([last[0], last[1]], {
-        radius: 7,
-        color: '#e74c3c',
-        fillColor: '#e74c3c',
-        fillOpacity: 1,
-        weight: 2
+      const m = L.circleMarker([last[0], last[1]], {
+        radius: 7, color: '#e74c3c', fillColor: '#e74c3c', fillOpacity: 1, weight: 2
       }).bindTooltip('End: ' + (trackData.meta?.name || 'Track'));
       }).bindTooltip('End: ' + (trackData.meta?.name || 'Track'));
-      endMarker.addTo(map);
-      addedMarkers.push(endMarker);
+      m.addTo(map);
+      addedMarkers.push(m);
     }
     }
 
 
     layer.addTo(map);
     layer.addTo(map);
-    trackLayers[trackId] = { layer, color, markers: addedMarkers };
+    trackLayers[trackId] = {
+      layer,
+      color,
+      markers: addedMarkers,
+      points: flattenPoints(trackData)
+    };
 
 
     return layer;
     return layer;
   }
   }
@@ -107,10 +145,7 @@ const MapView = (() => {
   function removeTrack(trackId) {
   function removeTrack(trackId) {
     if (trackLayers[trackId]) {
     if (trackLayers[trackId]) {
       map.removeLayer(trackLayers[trackId].layer);
       map.removeLayer(trackLayers[trackId].layer);
-      // Remove markers
-      if (trackLayers[trackId].markers) {
-        trackLayers[trackId].markers.forEach(m => map.removeLayer(m));
-      }
+      (trackLayers[trackId].markers || []).forEach(m => map.removeLayer(m));
       delete trackLayers[trackId];
       delete trackLayers[trackId];
     }
     }
   }
   }
@@ -124,9 +159,7 @@ const MapView = (() => {
     if (!trackLayers[trackId]) return;
     if (!trackLayers[trackId]) return;
     try {
     try {
       const bounds = trackLayers[trackId].layer.getBounds();
       const bounds = trackLayers[trackId].layer.getBounds();
-      if (bounds.isValid()) {
-        map.fitBounds(bounds, { padding: [20, 20] });
-      }
+      if (bounds.isValid()) map.fitBounds(bounds, { padding: [20, 20] });
     } catch (e) {
     } catch (e) {
       console.warn('fitTrack error:', e);
       console.warn('fitTrack error:', e);
     }
     }
@@ -138,15 +171,90 @@ const MapView = (() => {
     try {
     try {
       const group = L.featureGroup(layers);
       const group = L.featureGroup(layers);
       const bounds = group.getBounds();
       const bounds = group.getBounds();
-      if (bounds.isValid()) {
-        map.fitBounds(bounds, { padding: [20, 20] });
-      }
+      if (bounds.isValid()) map.fitBounds(bounds, { padding: [20, 20] });
     } catch (e) {
     } catch (e) {
       console.warn('fitAll error:', e);
       console.warn('fitAll error:', e);
     }
     }
   }
   }
 
 
-  function hasTrack(trackId) { return !!trackLayers[trackId]; }
+  function hasTrack(trackId)     { return !!trackLayers[trackId]; }
+  function getTrackPoints(trackId) { return trackLayers[trackId]?.points || null; }
+
+  // ===== Hover =====
+
+  function onMapMouseMove(e) {
+    if (stickyPoint) return;
+
+    const mp = map.latLngToContainerPoint(e.latlng);
+    let nearest = null, nearestD = Infinity, nearestTid = null;
+
+    for (const [tid, tl] of Object.entries(trackLayers)) {
+      if (!tl.points) continue;
+      for (const p of tl.points) {
+        const pp = map.latLngToContainerPoint(L.latLng(p.lat, p.lon));
+        const d = mp.distanceTo(pp);
+        if (d < nearestD) { nearestD = d; nearest = p; nearestTid = tid; }
+      }
+    }
+
+    if (nearest && nearestD <= HOVER_TOLERANCE_PX) {
+      showHoverMarker(nearest.lat, nearest.lon, nearest);
+      // Update elevation chart only when hovering the current (active) track
+      if (String(nearestTid) === String(currentTrackId) && typeof Elevation !== 'undefined') {
+        Elevation.onMapHover(nearest);
+      } else if (typeof Elevation !== 'undefined') {
+        Elevation.onMapLeave();
+      }
+    } else {
+      hideHoverMarker();
+      if (typeof Elevation !== 'undefined') Elevation.onMapLeave();
+    }
+  }
+
+  function onMapMouseOut() {
+    if (stickyPoint) return;
+    hideHoverMarker();
+    if (typeof Elevation !== 'undefined') Elevation.onMapLeave();
+  }
+
+  function onMapClick() {
+    if (stickyPoint) {
+      stickyPoint = false;
+      hideHoverMarker();
+      if (typeof Elevation !== 'undefined') Elevation.onMapLeave();
+    } else if (map.hasLayer(hoverMarker)) {
+      stickyPoint = true;
+    }
+  }
+
+  function showHoverMarker(lat, lon, point) {
+    if (!hoverMarker) return;
+    hoverMarker.setLatLng([lat, lon]);
+    if (!map.hasLayer(hoverMarker)) hoverMarker.addTo(map);
+    hoverMarker.unbindTooltip();
+    const content = typeof Elevation !== 'undefined'
+      ? Elevation.formatTooltip(point)
+      : fallbackTooltip(point);
+    hoverMarker.bindTooltip(content, {
+      permanent: true,
+      direction: 'top',
+      className: 'map-tooltip',
+      offset: [0, -10]
+    }).openTooltip();
+  }
+
+  function hideHoverMarker() {
+    if (hoverMarker && map && map.hasLayer(hoverMarker)) hoverMarker.remove();
+  }
+
+  function fallbackTooltip(p) {
+    const dist = p.dist >= 1000 ? (p.dist / 1000).toFixed(2) + ' km' : Math.round(p.dist) + ' m';
+    let s = `Dist: ${dist}`;
+    if (p.ele != null) s += `<br>Ele: ${Math.round(p.ele)} m`;
+    return s;
+  }
+
+  // ===== Hash state =====
 
 
   function saveToHash() {
   function saveToHash() {
     if (!map) return;
     if (!map) return;
@@ -161,21 +269,16 @@ const MapView = (() => {
 
 
   function restoreFromHash() {
   function restoreFromHash() {
     const params = getHashParams();
     const params = getHashParams();
-
     if (params.map) {
     if (params.map) {
       const parts = params.map.split(',');
       const parts = params.map.split(',');
       if (parts.length === 3) {
       if (parts.length === 3) {
-        const lat = parseFloat(parts[0]);
-        const lng = parseFloat(parts[1]);
-        const zoom = parseInt(parts[2]);
+        const lat = parseFloat(parts[0]), lng = parseFloat(parts[1]), zoom = parseInt(parts[2]);
         if (!isNaN(lat) && !isNaN(lng) && !isNaN(zoom)) {
         if (!isNaN(lat) && !isNaN(lng) && !isNaN(zoom)) {
           map.setView([lat, lng], zoom);
           map.setView([lat, lng], zoom);
           return params;
           return params;
         }
         }
       }
       }
     }
     }
-
-    // Default view
     map.setView([51.505, -0.09], 5);
     map.setView([51.505, -0.09], 5);
     return params;
     return params;
   }
   }
@@ -187,8 +290,7 @@ const MapView = (() => {
     hash.split('&').forEach(part => {
     hash.split('&').forEach(part => {
       const eqIdx = part.indexOf('=');
       const eqIdx = part.indexOf('=');
       if (eqIdx > 0) {
       if (eqIdx > 0) {
-        const k = part.slice(0, eqIdx);
-        const v = part.slice(eqIdx + 1);
+        const k = part.slice(0, eqIdx), v = part.slice(eqIdx + 1);
         if (k && v) params[k] = v;
         if (k && v) params[k] = v;
       }
       }
     });
     });
@@ -197,23 +299,17 @@ const MapView = (() => {
 
 
   function setCurrentTrack(id) {
   function setCurrentTrack(id) {
     currentTrackId = id;
     currentTrackId = id;
+    stickyPoint = false;
     saveToHash();
     saveToHash();
   }
   }
 
 
   function getMap() { return map; }
   function getMap() { return map; }
 
 
   return {
   return {
-    init,
-    addTrack,
-    removeTrack,
-    clearTracks,
-    fitTrack,
-    fitAll,
-    hasTrack,
-    saveToHash,
-    restoreFromHash,
-    getHashParams,
-    setCurrentTrack,
-    getMap
+    init, addTrack, removeTrack, clearTracks,
+    fitTrack, fitAll, hasTrack, getTrackPoints,
+    showHoverMarker, hideHoverMarker,
+    saveToHash, restoreFromHash, getHashParams,
+    setCurrentTrack, getMap
   };
   };
 })();
 })();