import { useEffect, useRef } from 'react' import L from 'leaflet' import { sportColor } from '../../utils/format' delete L.Icon.Default.prototype._getIconUrl L.Icon.Default.mergeOptions({ iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png', iconRetinaUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png', shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png', }) const TILE_LAYERS = { dark: { url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', attribution: '© OSM © CARTO', }, street: { url: 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png', attribution: '© OSM © CARTO', }, satellite: { url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', attribution: '© Esri', }, } function decodePolyline(encoded) { const coords = [] let index = 0, lat = 0, lng = 0 while (index < encoded.length) { let b, shift = 0, result = 0 do { b = encoded.charCodeAt(index++) - 63; result |= (b & 0x1f) << shift; shift += 5 } while (b >= 0x20) lat += (result & 1) ? ~(result >> 1) : result >> 1 shift = 0; result = 0 do { b = encoded.charCodeAt(index++) - 63; result |= (b & 0x1f) << shift; shift += 5 } while (b >= 0x20) lng += (result & 1) ? ~(result >> 1) : result >> 1 coords.push([lat / 1e5, lng / 1e5]) } return coords } function drawRoute(map, polyline, sportType, trackRef) { if (trackRef.current) { trackRef.current.remove() trackRef.current = null } if (!polyline) return const coords = decodePolyline(polyline) if (!coords.length) return trackRef.current = L.polyline(coords, { color: sportColor(sportType), weight: 3, opacity: 0.9, }).addTo(map) map.fitBounds(trackRef.current.getBounds(), { padding: [20, 20] }) const dot = (color) => L.divIcon({ html: `
`, iconSize: [12, 12], iconAnchor: [6, 6], className: '', }) L.marker(coords[0], { icon: dot('#22c55e') }).addTo(map) L.marker(coords[coords.length - 1], { icon: dot('#ef4444') }).addTo(map) } export default function ActivityMap({ polyline, dataPoints, hoveredDistance, sportType, mapType = 'dark' }) { const mapRef = useRef(null) const mapInstanceRef = useRef(null) const markerRef = useRef(null) const trackRef = useRef(null) const tileLayerRef = useRef(null) const polylineRef = useRef(polyline) const sportTypeRef = useRef(sportType) useEffect(() => { polylineRef.current = polyline }, [polyline]) useEffect(() => { sportTypeRef.current = sportType }, [sportType]) useEffect(() => { if (!mapRef.current || mapInstanceRef.current) return mapInstanceRef.current = L.map(mapRef.current, { zoomControl: true, attributionControl: true, }) const tile = TILE_LAYERS.dark tileLayerRef.current = L.tileLayer(tile.url, { attribution: tile.attribution, maxZoom: 19, }).addTo(mapInstanceRef.current) return () => { mapInstanceRef.current?.remove() mapInstanceRef.current = null } }, []) useEffect(() => { if (!mapInstanceRef.current) return const tile = TILE_LAYERS[mapType] || TILE_LAYERS.dark if (tileLayerRef.current) tileLayerRef.current.remove() tileLayerRef.current = L.tileLayer(tile.url, { attribution: tile.attribution, maxZoom: 19, }).addTo(mapInstanceRef.current) drawRoute(mapInstanceRef.current, polylineRef.current, sportTypeRef.current, trackRef) }, [mapType]) useEffect(() => { if (!mapInstanceRef.current) return drawRoute(mapInstanceRef.current, polyline, sportType, trackRef) }, [polyline, sportType]) useEffect(() => { if (!mapInstanceRef.current || !dataPoints || hoveredDistance == null) return const point = dataPoints.find(p => p.distance_m >= hoveredDistance) if (!point?.latitude || !point?.longitude) return if (markerRef.current) { markerRef.current.setLatLng([point.latitude, point.longitude]) } else { const icon = L.divIcon({ html: '', iconSize: [14, 14], iconAnchor: [7, 7], className: '', }) markerRef.current = L.marker([point.latitude, point.longitude], { icon }) .addTo(mapInstanceRef.current) } }, [hoveredDistance, dataPoints]) return }