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;
let userMovedMap = false; // true after manual pan/zoom; suppresses auto-fit
// ===== 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);
// Detect manual pan/zoom: Leaflet sets originalEvent only for user gestures,
// not for programmatic fitBounds/setView calls.
map.on('movestart zoomstart', (e) => {
if (e.originalEvent) userMovedMap = true;
});
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);
userMovedMap = false; // explicit user request — override the guard
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 (userMovedMap) return;
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() {
if (userMovedMap) return;
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
};
})();