|
|
@@ -1,11 +1,44 @@
|
|
|
const MapView = (() => {
|
|
|
let map = null;
|
|
|
- let trackLayers = {}; // trackId → { layer, color, markers }
|
|
|
+ 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');
|
|
|
|
|
|
@@ -34,15 +67,25 @@ const MapView = (() => {
|
|
|
L.control.layers(baseMaps).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);
|
|
|
|
|
|
return map;
|
|
|
}
|
|
|
|
|
|
+ // ===== Track management =====
|
|
|
+
|
|
|
function getNextColor() {
|
|
|
const c = COLORS[colorIndex % COLORS.length];
|
|
|
colorIndex++;
|
|
|
@@ -53,53 +96,48 @@ const MapView = (() => {
|
|
|
if (trackLayers[trackId]) return trackLayers[trackId].layer;
|
|
|
|
|
|
const color = getNextColor();
|
|
|
- const lines = [];
|
|
|
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;
|
|
|
- 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;
|
|
|
|
|
|
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);
|
|
|
if (firstSeg) {
|
|
|
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'));
|
|
|
- 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);
|
|
|
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
|
|
|
+ const m = 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);
|
|
|
+ m.addTo(map);
|
|
|
+ addedMarkers.push(m);
|
|
|
}
|
|
|
|
|
|
layer.addTo(map);
|
|
|
- trackLayers[trackId] = { layer, color, markers: addedMarkers };
|
|
|
+ trackLayers[trackId] = {
|
|
|
+ layer,
|
|
|
+ color,
|
|
|
+ markers: addedMarkers,
|
|
|
+ points: flattenPoints(trackData)
|
|
|
+ };
|
|
|
|
|
|
return layer;
|
|
|
}
|
|
|
@@ -107,10 +145,7 @@ const MapView = (() => {
|
|
|
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));
|
|
|
- }
|
|
|
+ (trackLayers[trackId].markers || []).forEach(m => map.removeLayer(m));
|
|
|
delete trackLayers[trackId];
|
|
|
}
|
|
|
}
|
|
|
@@ -124,9 +159,7 @@ const MapView = (() => {
|
|
|
if (!trackLayers[trackId]) return;
|
|
|
try {
|
|
|
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) {
|
|
|
console.warn('fitTrack error:', e);
|
|
|
}
|
|
|
@@ -138,15 +171,90 @@ const MapView = (() => {
|
|
|
try {
|
|
|
const group = L.featureGroup(layers);
|
|
|
const bounds = group.getBounds();
|
|
|
- if (bounds.isValid()) {
|
|
|
- map.fitBounds(bounds, { padding: [20, 20] });
|
|
|
- }
|
|
|
+ if (bounds.isValid()) map.fitBounds(bounds, { padding: [20, 20] });
|
|
|
} catch (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() {
|
|
|
if (!map) return;
|
|
|
@@ -161,21 +269,16 @@ const MapView = (() => {
|
|
|
|
|
|
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]);
|
|
|
+ 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;
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
-
|
|
|
- // Default view
|
|
|
map.setView([51.505, -0.09], 5);
|
|
|
return params;
|
|
|
}
|
|
|
@@ -187,8 +290,7 @@ const MapView = (() => {
|
|
|
hash.split('&').forEach(part => {
|
|
|
const eqIdx = part.indexOf('=');
|
|
|
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;
|
|
|
}
|
|
|
});
|
|
|
@@ -197,23 +299,17 @@ const MapView = (() => {
|
|
|
|
|
|
function setCurrentTrack(id) {
|
|
|
currentTrackId = id;
|
|
|
+ stickyPoint = false;
|
|
|
saveToHash();
|
|
|
}
|
|
|
|
|
|
function getMap() { return map; }
|
|
|
|
|
|
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
|
|
|
};
|
|
|
})();
|