|
@@ -0,0 +1,219 @@
|
|
|
|
|
+const MapView = (() => {
|
|
|
|
|
+ let map = null;
|
|
|
|
|
+ let trackLayers = {}; // trackId → { layer, color, markers }
|
|
|
|
|
+ let currentTrackId = null;
|
|
|
|
|
+
|
|
|
|
|
+ const COLORS = ['#e74c3c', '#3498db', '#2ecc71', '#f39c12', '#9b59b6', '#1abc9c', '#e67e22', '#34495e'];
|
|
|
|
|
+ let colorIndex = 0;
|
|
|
|
|
+
|
|
|
|
|
+ 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);
|
|
|
|
|
+
|
|
|
|
|
+ // Restore state from URL hash
|
|
|
|
|
+ restoreFromHash();
|
|
|
|
|
+
|
|
|
|
|
+ // Save state on map move/zoom
|
|
|
|
|
+ map.on('moveend zoomend', saveToHash);
|
|
|
|
|
+
|
|
|
|
|
+ return map;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ 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 lines = [];
|
|
|
|
|
+ const addedMarkers = [];
|
|
|
|
|
+
|
|
|
|
|
+ if (!trackData.segments || trackData.segments.length === 0) return null;
|
|
|
|
|
+
|
|
|
|
|
+ for (const seg of trackData.segments) {
|
|
|
|
|
+ if (!seg || seg.length === 0) continue;
|
|
|
|
|
+ const latlngs = seg.map(p => [p[0], p[1]]);
|
|
|
|
|
+ lines.push(latlngs);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (lines.length === 0) return null;
|
|
|
|
|
+
|
|
|
|
|
+ const layer = L.polyline(lines, { color, weight: 3, opacity: 0.8 });
|
|
|
|
|
+
|
|
|
|
|
+ // Add 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
|
|
|
|
|
+ }).bindTooltip('Start: ' + (trackData.meta?.name || 'Track'));
|
|
|
|
|
+ startMarker.addTo(map);
|
|
|
|
|
+ addedMarkers.push(startMarker);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Add 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
|
|
|
|
|
+ }).bindTooltip('End: ' + (trackData.meta?.name || 'Track'));
|
|
|
|
|
+ endMarker.addTo(map);
|
|
|
|
|
+ addedMarkers.push(endMarker);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ layer.addTo(map);
|
|
|
|
|
+ trackLayers[trackId] = { layer, color, markers: addedMarkers };
|
|
|
|
|
+
|
|
|
|
|
+ return layer;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ 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));
|
|
|
|
|
+ }
|
|
|
|
|
+ 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 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]);
|
|
|
|
|
+ const lng = parseFloat(parts[1]);
|
|
|
|
|
+ const 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;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ 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);
|
|
|
|
|
+ const v = part.slice(eqIdx + 1);
|
|
|
|
|
+ if (k && v) params[k] = v;
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+ return params;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function setCurrentTrack(id) {
|
|
|
|
|
+ currentTrackId = id;
|
|
|
|
|
+ saveToHash();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function getMap() { return map; }
|
|
|
|
|
+
|
|
|
|
|
+ return {
|
|
|
|
|
+ init,
|
|
|
|
|
+ addTrack,
|
|
|
|
|
+ removeTrack,
|
|
|
|
|
+ clearTracks,
|
|
|
|
|
+ fitTrack,
|
|
|
|
|
+ fitAll,
|
|
|
|
|
+ hasTrack,
|
|
|
|
|
+ saveToHash,
|
|
|
|
|
+ restoreFromHash,
|
|
|
|
|
+ getHashParams,
|
|
|
|
|
+ setCurrentTrack,
|
|
|
|
|
+ getMap
|
|
|
|
|
+ };
|
|
|
|
|
+})();
|