map.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356
  1. const MapView = (() => {
  2. let map = null;
  3. let trackLayers = {}; // trackId → { layer, color, markers, points }
  4. let currentTrackId = null;
  5. let hoverMarker = null;
  6. let stickyPoint = false;
  7. const COLORS = ['#e74c3c', '#3498db', '#2ecc71', '#f39c12', '#9b59b6', '#1abc9c', '#e67e22', '#34495e'];
  8. const HOVER_TOLERANCE_PX = 20;
  9. let colorIndex = 0;
  10. let userMovedMap = false; // true after manual pan/zoom; suppresses auto-fit
  11. // ===== Haversine / point helpers =====
  12. function haversine(lat1, lon1, lat2, lon2) {
  13. const R = 6371000;
  14. const dLat = (lat2 - lat1) * Math.PI / 180;
  15. const dLon = (lon2 - lon1) * Math.PI / 180;
  16. const a = Math.sin(dLat / 2) ** 2 +
  17. Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
  18. Math.sin(dLon / 2) ** 2;
  19. return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
  20. }
  21. // Flatten segments [[lat,lon,ele,time], ...] into [{lat,lon,ele,time,dist}]
  22. function flattenPoints(trackData) {
  23. const pts = [];
  24. let dist = 0;
  25. for (const seg of (trackData.segments || [])) {
  26. for (const p of seg) {
  27. if (pts.length > 0) {
  28. const prev = pts[pts.length - 1];
  29. dist += haversine(prev.lat, prev.lon, p[0], p[1]);
  30. }
  31. pts.push({ lat: p[0], lon: p[1], ele: p[2] ?? null, time: p[3] ?? null, dist });
  32. }
  33. }
  34. return pts;
  35. }
  36. // ===== Init =====
  37. function init() {
  38. map = L.map('map');
  39. const osmLayer = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
  40. attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
  41. maxZoom: 19
  42. });
  43. const topoLayer = L.tileLayer('https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png', {
  44. attribution: '&copy; <a href="https://opentopomap.org">OpenTopoMap</a> contributors',
  45. maxZoom: 17
  46. });
  47. const satelliteLayer = L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', {
  48. attribution: 'Tiles &copy; Esri &mdash; Source: Esri, USGS, AeroGRID, IGN, and the GIS User Community',
  49. maxZoom: 19
  50. });
  51. const baseMaps = {
  52. 'OpenStreetMap': osmLayer,
  53. 'Topographic': topoLayer,
  54. 'Satellite': satelliteLayer
  55. };
  56. osmLayer.addTo(map);
  57. L.control.layers(baseMaps).addTo(map);
  58. L.control.scale({ metric: true, imperial: false }).addTo(map);
  59. // Hover marker (red dot, non-interactive)
  60. hoverMarker = L.marker([0, 0], {
  61. icon: L.divIcon({ className: 'hover-marker', iconSize: [14, 14], iconAnchor: [7, 7] }),
  62. interactive: false,
  63. zIndexOffset: 1000
  64. });
  65. map.on('mousemove', onMapMouseMove);
  66. map.on('mouseout', onMapMouseOut);
  67. map.on('click', onMapClick);
  68. // Detect manual pan/zoom: Leaflet sets originalEvent only for user gestures,
  69. // not for programmatic fitBounds/setView calls.
  70. map.on('movestart zoomstart', (e) => {
  71. if (e.originalEvent) userMovedMap = true;
  72. });
  73. restoreFromHash();
  74. map.on('moveend zoomend', saveToHash);
  75. return map;
  76. }
  77. // ===== Track management =====
  78. function getNextColor() {
  79. const c = COLORS[colorIndex % COLORS.length];
  80. colorIndex++;
  81. return c;
  82. }
  83. function addTrack(trackData, trackId) {
  84. if (trackLayers[trackId]) return trackLayers[trackId].layer;
  85. const color = getNextColor();
  86. const addedMarkers = [];
  87. if (!trackData.segments || trackData.segments.length === 0) return null;
  88. const lines = [];
  89. for (const seg of trackData.segments) {
  90. if (!seg || seg.length === 0) continue;
  91. lines.push(seg.map(p => [p[0], p[1]]));
  92. }
  93. if (lines.length === 0) return null;
  94. const layer = L.polyline(lines, { color, weight: 3, opacity: 0.8 });
  95. // Start marker (green)
  96. const firstSeg = trackData.segments.find(s => s && s.length > 0);
  97. if (firstSeg) {
  98. const first = firstSeg[0];
  99. const m = L.circleMarker([first[0], first[1]], {
  100. radius: 7, color: '#27ae60', fillColor: '#27ae60', fillOpacity: 1, weight: 2
  101. }).bindTooltip('Start: ' + (trackData.meta?.name || 'Track'));
  102. m.addTo(map);
  103. addedMarkers.push(m);
  104. }
  105. // End marker (red)
  106. const lastSeg = [...trackData.segments].reverse().find(s => s && s.length > 0);
  107. if (lastSeg) {
  108. const last = lastSeg[lastSeg.length - 1];
  109. const m = L.circleMarker([last[0], last[1]], {
  110. radius: 7, color: '#e74c3c', fillColor: '#e74c3c', fillOpacity: 1, weight: 2
  111. }).bindTooltip('End: ' + (trackData.meta?.name || 'Track'));
  112. m.addTo(map);
  113. addedMarkers.push(m);
  114. }
  115. layer.addTo(map);
  116. layer.on('dblclick', (e) => {
  117. L.DomEvent.stopPropagation(e);
  118. userMovedMap = false; // explicit user request — override the guard
  119. fitTrack(trackId);
  120. });
  121. trackLayers[trackId] = {
  122. layer,
  123. color,
  124. markers: addedMarkers,
  125. points: flattenPoints(trackData),
  126. meta: trackData.meta || null
  127. };
  128. return layer;
  129. }
  130. function removeTrack(trackId) {
  131. if (trackLayers[trackId]) {
  132. map.removeLayer(trackLayers[trackId].layer);
  133. (trackLayers[trackId].markers || []).forEach(m => map.removeLayer(m));
  134. delete trackLayers[trackId];
  135. }
  136. }
  137. function clearTracks() {
  138. Object.keys(trackLayers).forEach(id => removeTrack(id));
  139. colorIndex = 0;
  140. }
  141. function fitTrack(trackId) {
  142. if (userMovedMap) return;
  143. if (!trackLayers[trackId]) return;
  144. try {
  145. const bounds = trackLayers[trackId].layer.getBounds();
  146. if (bounds.isValid()) map.fitBounds(bounds, { padding: [20, 20] });
  147. } catch (e) {
  148. console.warn('fitTrack error:', e);
  149. }
  150. }
  151. function fitAll() {
  152. if (userMovedMap) return;
  153. const layers = Object.values(trackLayers).map(t => t.layer);
  154. if (layers.length === 0) return;
  155. try {
  156. const group = L.featureGroup(layers);
  157. const bounds = group.getBounds();
  158. if (bounds.isValid()) map.fitBounds(bounds, { padding: [20, 20] });
  159. } catch (e) {
  160. console.warn('fitAll error:', e);
  161. }
  162. }
  163. function hasTrack(trackId) { return !!trackLayers[trackId]; }
  164. function getTrackPoints(trackId) { return trackLayers[trackId]?.points || null; }
  165. // ===== Track highlight (sidebar hover) =====
  166. let highlightedTrackId = null;
  167. function highlightTrack(trackId) {
  168. if (String(highlightedTrackId) === String(trackId)) return;
  169. unhighlightTrack();
  170. const tl = trackLayers[trackId];
  171. if (!tl) return;
  172. tl.layer.setStyle({ weight: 6, opacity: 1 });
  173. tl.layer.bringToFront();
  174. highlightedTrackId = trackId;
  175. }
  176. function unhighlightTrack() {
  177. if (highlightedTrackId === null) return;
  178. const tl = trackLayers[highlightedTrackId];
  179. if (tl) tl.layer.setStyle({ weight: 3, opacity: 0.8 });
  180. highlightedTrackId = null;
  181. }
  182. // ===== Hover =====
  183. function onMapMouseMove(e) {
  184. if (stickyPoint) return;
  185. const mp = map.latLngToContainerPoint(e.latlng);
  186. let nearest = null, nearestD = Infinity, nearestTid = null;
  187. for (const [tid, tl] of Object.entries(trackLayers)) {
  188. if (!tl.points) continue;
  189. for (const p of tl.points) {
  190. const pp = map.latLngToContainerPoint(L.latLng(p.lat, p.lon));
  191. const d = mp.distanceTo(pp);
  192. if (d < nearestD) { nearestD = d; nearest = p; nearestTid = tid; }
  193. }
  194. }
  195. if (nearest && nearestD <= HOVER_TOLERANCE_PX) {
  196. showHoverMarker(nearest.lat, nearest.lon, nearest, trackLayers[nearestTid]?.meta);
  197. // Update elevation chart only when hovering the current (active) track
  198. if (String(nearestTid) === String(currentTrackId) && typeof Elevation !== 'undefined') {
  199. Elevation.onMapHover(nearest);
  200. } else if (typeof Elevation !== 'undefined') {
  201. Elevation.onMapLeave();
  202. }
  203. } else {
  204. hideHoverMarker();
  205. if (typeof Elevation !== 'undefined') Elevation.onMapLeave();
  206. }
  207. }
  208. function onMapMouseOut() {
  209. if (stickyPoint) return;
  210. hideHoverMarker();
  211. if (typeof Elevation !== 'undefined') Elevation.onMapLeave();
  212. }
  213. function onMapClick() {
  214. if (stickyPoint) {
  215. stickyPoint = false;
  216. hideHoverMarker();
  217. if (typeof Elevation !== 'undefined') Elevation.onMapLeave();
  218. } else if (map.hasLayer(hoverMarker)) {
  219. stickyPoint = true;
  220. }
  221. }
  222. function showHoverMarker(lat, lon, point, meta, noTooltip) {
  223. if (!hoverMarker) return;
  224. hoverMarker.setLatLng([lat, lon]);
  225. if (!map.hasLayer(hoverMarker)) hoverMarker.addTo(map);
  226. hoverMarker.unbindTooltip();
  227. if (!noTooltip) {
  228. const content = typeof Elevation !== 'undefined'
  229. ? Elevation.formatTooltip(point, meta)
  230. : fallbackTooltip(point, meta);
  231. hoverMarker.bindTooltip(content, {
  232. permanent: true,
  233. direction: 'top',
  234. className: 'map-tooltip',
  235. offset: [0, -10]
  236. }).openTooltip();
  237. }
  238. }
  239. function hideHoverMarker() {
  240. if (hoverMarker && map && map.hasLayer(hoverMarker)) hoverMarker.remove();
  241. }
  242. function fallbackTooltip(p, meta) {
  243. const dist = p.dist >= 1000 ? (p.dist / 1000).toFixed(2) + ' km' : Math.round(p.dist) + ' m';
  244. let s = '';
  245. if (meta?.name) s += `<b>${escHtml(meta.name)}</b><br>`;
  246. s += `Dist: ${dist}`;
  247. if (p.ele != null) s += `<br>Ele: ${Math.round(p.ele)} m`;
  248. return s;
  249. }
  250. // ===== Hash state =====
  251. function saveToHash() {
  252. if (!map) return;
  253. const c = map.getCenter();
  254. const z = map.getZoom();
  255. const tracks = Object.keys(trackLayers).join(',');
  256. let hash = `#map=${c.lat.toFixed(5)},${c.lng.toFixed(5)},${z}`;
  257. if (tracks) hash += `&tracks=${tracks}`;
  258. if (currentTrackId !== null && currentTrackId !== undefined) hash += `&open=${currentTrackId}`;
  259. history.replaceState(null, '', hash);
  260. }
  261. function restoreFromHash() {
  262. const params = getHashParams();
  263. if (params.map) {
  264. const parts = params.map.split(',');
  265. if (parts.length === 3) {
  266. const lat = parseFloat(parts[0]), lng = parseFloat(parts[1]), zoom = parseInt(parts[2]);
  267. if (!isNaN(lat) && !isNaN(lng) && !isNaN(zoom)) {
  268. map.setView([lat, lng], zoom);
  269. return params;
  270. }
  271. }
  272. }
  273. map.setView([51.505, -0.09], 5);
  274. return params;
  275. }
  276. function getHashParams() {
  277. const hash = window.location.hash.slice(1);
  278. const params = {};
  279. if (!hash) return params;
  280. hash.split('&').forEach(part => {
  281. const eqIdx = part.indexOf('=');
  282. if (eqIdx > 0) {
  283. const k = part.slice(0, eqIdx), v = part.slice(eqIdx + 1);
  284. if (k && v) params[k] = v;
  285. }
  286. });
  287. return params;
  288. }
  289. function setCurrentTrack(id) {
  290. currentTrackId = id;
  291. stickyPoint = false;
  292. saveToHash();
  293. }
  294. function getMap() { return map; }
  295. return {
  296. init, addTrack, removeTrack, clearTracks,
  297. fitTrack, fitAll, hasTrack, getTrackPoints,
  298. showHoverMarker, hideHoverMarker,
  299. highlightTrack, unhighlightTrack,
  300. saveToHash, restoreFromHash, getHashParams,
  301. setCurrentTrack, getMap
  302. };
  303. })();