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: '© OpenStreetMap contributors', maxZoom: 19 }); const topoLayer = L.tileLayer('https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png', { attribution: '© OpenTopoMap contributors', maxZoom: 17 }); const satelliteLayer = L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', { attribution: 'Tiles © Esri — 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 }; })();