Bläddra i källkod

Add Leaflet map module with track display and URL state

k4be 7 timmar sedan
förälder
incheckning
7d5297f6af
1 ändrade filer med 219 tillägg och 0 borttagningar
  1. 219 0
      gpx-vis-frontend/js/map.js

+ 219 - 0
gpx-vis-frontend/js/map.js

@@ -0,0 +1,219 @@
+const MapView = (() => {
+  let map = null;
+  let trackLayers = {};  // trackId → { layer, color, markers }
+  let currentTrackId = null;
+
+  const COLORS = ['#e74c3c', '#3498db', '#2ecc71', '#f39c12', '#9b59b6', '#1abc9c', '#e67e22', '#34495e'];
+  let colorIndex = 0;
+
+  function init() {
+    map = L.map('map');
+
+    const osmLayer = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
+      attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
+      maxZoom: 19
+    });
+
+    const topoLayer = L.tileLayer('https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png', {
+      attribution: '&copy; <a href="https://opentopomap.org">OpenTopoMap</a> contributors',
+      maxZoom: 17
+    });
+
+    const satelliteLayer = L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', {
+      attribution: 'Tiles &copy; Esri &mdash; Source: Esri, USGS, AeroGRID, IGN, and the GIS User Community',
+      maxZoom: 19
+    });
+
+    const baseMaps = {
+      'OpenStreetMap': osmLayer,
+      'Topographic': topoLayer,
+      'Satellite': satelliteLayer
+    };
+
+    osmLayer.addTo(map);
+    L.control.layers(baseMaps).addTo(map);
+    L.control.scale({ metric: true, imperial: false }).addTo(map);
+
+    // Restore state from URL hash
+    restoreFromHash();
+
+    // Save state on map move/zoom
+    map.on('moveend zoomend', saveToHash);
+
+    return map;
+  }
+
+  function getNextColor() {
+    const c = COLORS[colorIndex % COLORS.length];
+    colorIndex++;
+    return c;
+  }
+
+  function addTrack(trackData, trackId) {
+    if (trackLayers[trackId]) return trackLayers[trackId].layer;
+
+    const color = getNextColor();
+    const lines = [];
+    const addedMarkers = [];
+
+    if (!trackData.segments || trackData.segments.length === 0) return null;
+
+    for (const seg of trackData.segments) {
+      if (!seg || seg.length === 0) continue;
+      const latlngs = seg.map(p => [p[0], p[1]]);
+      lines.push(latlngs);
+    }
+
+    if (lines.length === 0) return null;
+
+    const layer = L.polyline(lines, { color, weight: 3, opacity: 0.8 });
+
+    // Add start marker (green)
+    const firstSeg = trackData.segments.find(s => s && s.length > 0);
+    if (firstSeg) {
+      const first = firstSeg[0];
+      const startMarker = L.circleMarker([first[0], first[1]], {
+        radius: 7,
+        color: '#27ae60',
+        fillColor: '#27ae60',
+        fillOpacity: 1,
+        weight: 2
+      }).bindTooltip('Start: ' + (trackData.meta?.name || 'Track'));
+      startMarker.addTo(map);
+      addedMarkers.push(startMarker);
+    }
+
+    // Add end marker (red)
+    const lastSeg = [...trackData.segments].reverse().find(s => s && s.length > 0);
+    if (lastSeg) {
+      const last = lastSeg[lastSeg.length - 1];
+      const endMarker = L.circleMarker([last[0], last[1]], {
+        radius: 7,
+        color: '#e74c3c',
+        fillColor: '#e74c3c',
+        fillOpacity: 1,
+        weight: 2
+      }).bindTooltip('End: ' + (trackData.meta?.name || 'Track'));
+      endMarker.addTo(map);
+      addedMarkers.push(endMarker);
+    }
+
+    layer.addTo(map);
+    trackLayers[trackId] = { layer, color, markers: addedMarkers };
+
+    return layer;
+  }
+
+  function removeTrack(trackId) {
+    if (trackLayers[trackId]) {
+      map.removeLayer(trackLayers[trackId].layer);
+      // Remove markers
+      if (trackLayers[trackId].markers) {
+        trackLayers[trackId].markers.forEach(m => map.removeLayer(m));
+      }
+      delete trackLayers[trackId];
+    }
+  }
+
+  function clearTracks() {
+    Object.keys(trackLayers).forEach(id => removeTrack(id));
+    colorIndex = 0;
+  }
+
+  function fitTrack(trackId) {
+    if (!trackLayers[trackId]) return;
+    try {
+      const bounds = trackLayers[trackId].layer.getBounds();
+      if (bounds.isValid()) {
+        map.fitBounds(bounds, { padding: [20, 20] });
+      }
+    } catch (e) {
+      console.warn('fitTrack error:', e);
+    }
+  }
+
+  function fitAll() {
+    const layers = Object.values(trackLayers).map(t => t.layer);
+    if (layers.length === 0) return;
+    try {
+      const group = L.featureGroup(layers);
+      const bounds = group.getBounds();
+      if (bounds.isValid()) {
+        map.fitBounds(bounds, { padding: [20, 20] });
+      }
+    } catch (e) {
+      console.warn('fitAll error:', e);
+    }
+  }
+
+  function hasTrack(trackId) { return !!trackLayers[trackId]; }
+
+  function saveToHash() {
+    if (!map) return;
+    const c = map.getCenter();
+    const z = map.getZoom();
+    const tracks = Object.keys(trackLayers).join(',');
+    let hash = `#map=${c.lat.toFixed(5)},${c.lng.toFixed(5)},${z}`;
+    if (tracks) hash += `&tracks=${tracks}`;
+    if (currentTrackId !== null && currentTrackId !== undefined) hash += `&open=${currentTrackId}`;
+    history.replaceState(null, '', hash);
+  }
+
+  function restoreFromHash() {
+    const params = getHashParams();
+
+    if (params.map) {
+      const parts = params.map.split(',');
+      if (parts.length === 3) {
+        const lat = parseFloat(parts[0]);
+        const lng = parseFloat(parts[1]);
+        const zoom = parseInt(parts[2]);
+        if (!isNaN(lat) && !isNaN(lng) && !isNaN(zoom)) {
+          map.setView([lat, lng], zoom);
+          return params;
+        }
+      }
+    }
+
+    // Default view
+    map.setView([51.505, -0.09], 5);
+    return params;
+  }
+
+  function getHashParams() {
+    const hash = window.location.hash.slice(1);
+    const params = {};
+    if (!hash) return params;
+    hash.split('&').forEach(part => {
+      const eqIdx = part.indexOf('=');
+      if (eqIdx > 0) {
+        const k = part.slice(0, eqIdx);
+        const v = part.slice(eqIdx + 1);
+        if (k && v) params[k] = v;
+      }
+    });
+    return params;
+  }
+
+  function setCurrentTrack(id) {
+    currentTrackId = id;
+    saveToHash();
+  }
+
+  function getMap() { return map; }
+
+  return {
+    init,
+    addTrack,
+    removeTrack,
+    clearTracks,
+    fitTrack,
+    fitAll,
+    hasTrack,
+    saveToHash,
+    restoreFromHash,
+    getHashParams,
+    setCurrentTrack,
+    getMap
+  };
+})();