| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356 |
- 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: '© <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: '© <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 © 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 += `<b>${escHtml(meta.name)}</b><br>`;
- s += `Dist: ${dist}`;
- if (p.ele != null) s += `<br>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
- };
- })();
|