const MapView = (() => { let map = null; let trackLayers = {}; // trackId → { layer, color, markers, points } let currentTrackId = null; let hoverMarker = null; let stickyPoint = false; const COLORS = ['#e74c3c', '#3498db', '#2ecc71', '#f39c12', '#9b59b6', '#1abc9c', '#e67e22', '#34495e']; const HOVER_TOLERANCE_PX = 20; 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() { 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); // 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); restoreFromHash(); map.on('moveend zoomend', saveToHash); return map; } // ===== Track management ===== 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 addedMarkers = []; if (!trackData.segments || trackData.segments.length === 0) return null; const lines = []; for (const seg of trackData.segments) { if (!seg || seg.length === 0) continue; lines.push(seg.map(p => [p[0], p[1]])); } if (lines.length === 0) return null; const layer = L.polyline(lines, { color, weight: 3, opacity: 0.8 }); // Start marker (green) const firstSeg = trackData.segments.find(s => s && s.length > 0); if (firstSeg) { const first = firstSeg[0]; const m = L.circleMarker([first[0], first[1]], { radius: 7, color: '#27ae60', fillColor: '#27ae60', fillOpacity: 1, weight: 2 }).bindTooltip('Start: ' + (trackData.meta?.name || 'Track')); m.addTo(map); addedMarkers.push(m); } // End marker (red) const lastSeg = [...trackData.segments].reverse().find(s => s && s.length > 0); if (lastSeg) { const last = lastSeg[lastSeg.length - 1]; const m = L.circleMarker([last[0], last[1]], { radius: 7, color: '#e74c3c', fillColor: '#e74c3c', fillOpacity: 1, weight: 2 }).bindTooltip('End: ' + (trackData.meta?.name || 'Track')); m.addTo(map); addedMarkers.push(m); } layer.addTo(map); layer.on('dblclick', (e) => { L.DomEvent.stopPropagation(e); fitTrack(trackId); }); trackLayers[trackId] = { layer, color, markers: addedMarkers, points: flattenPoints(trackData), meta: trackData.meta || null }; return layer; } function removeTrack(trackId) { if (trackLayers[trackId]) { map.removeLayer(trackLayers[trackId].layer); (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 getTrackPoints(trackId) { return trackLayers[trackId]?.points || null; } // ===== Track highlight (sidebar hover) ===== let highlightedTrackId = null; function highlightTrack(trackId) { if (String(highlightedTrackId) === String(trackId)) return; unhighlightTrack(); const tl = trackLayers[trackId]; if (!tl) return; tl.layer.setStyle({ weight: 6, opacity: 1 }); tl.layer.bringToFront(); highlightedTrackId = trackId; } function unhighlightTrack() { if (highlightedTrackId === null) return; const tl = trackLayers[highlightedTrackId]; if (tl) tl.layer.setStyle({ weight: 3, opacity: 0.8 }); highlightedTrackId = 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, trackLayers[nearestTid]?.meta); // 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, meta, noTooltip) { if (!hoverMarker) return; hoverMarker.setLatLng([lat, lon]); if (!map.hasLayer(hoverMarker)) hoverMarker.addTo(map); hoverMarker.unbindTooltip(); if (!noTooltip) { const content = typeof Elevation !== 'undefined' ? Elevation.formatTooltip(point, meta) : fallbackTooltip(point, meta); 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, meta) { const dist = p.dist >= 1000 ? (p.dist / 1000).toFixed(2) + ' km' : Math.round(p.dist) + ' m'; let s = ''; if (meta?.name) s += `${escHtml(meta.name)}
`; s += `Dist: ${dist}`; if (p.ele != null) s += `
Ele: ${Math.round(p.ele)} m`; return s; } // ===== Hash state ===== 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]), lng = parseFloat(parts[1]), zoom = parseInt(parts[2]); if (!isNaN(lat) && !isNaN(lng) && !isNaN(zoom)) { map.setView([lat, lng], zoom); return params; } } } 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), v = part.slice(eqIdx + 1); if (k && v) params[k] = v; } }); return params; } function setCurrentTrack(id) { currentTrackId = id; stickyPoint = false; saveToHash(); } function getMap() { return map; } return { init, addTrack, removeTrack, clearTracks, fitTrack, fitAll, hasTrack, getTrackPoints, showHoverMarker, hideHoverMarker, highlightTrack, unhighlightTrack, saveToHash, restoreFromHash, getHashParams, setCurrentTrack, getMap }; })();