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', }, } // Tile options tuned for smoother panning/zooming: keep a larger off-screen // buffer of tiles and don't defer loads until the map is idle. const TILE_OPTS = { maxZoom: 19, keepBuffer: 6, updateWhenIdle: false, updateWhenZooming: false } // Slow → fast colour ramp for speed-coloured routes (red → purple). export const SPEED_STOPS = ['#ef4444', '#f97316', '#22c55e', '#3b82f6', '#a855f7'] // CSS gradient string for the speed legend. export const SPEED_GRADIENT = `linear-gradient(to right, ${SPEED_STOPS.join(', ')})` const SPEED_LEVELS = 24 // quantisation steps → smooth gradient while limiting layer count function lerpColor(c1, c2, t) { const a = parseInt(c1.slice(1), 16), b = parseInt(c2.slice(1), 16) const r = Math.round(((a >> 16) & 255) + (((b >> 16) & 255) - ((a >> 16) & 255)) * t) const g = Math.round(((a >> 8) & 255) + (((b >> 8) & 255) - ((a >> 8) & 255)) * t) const bl = Math.round((a & 255) + ((b & 255) - (a & 255)) * t) return `#${((1 << 24) + (r << 16) + (g << 8) + bl).toString(16).slice(1)}` } function rampColor(t) { t = Math.max(0, Math.min(1, t)) const seg = t * (SPEED_STOPS.length - 1) const i = Math.min(SPEED_STOPS.length - 2, Math.floor(seg)) return lerpColor(SPEED_STOPS[i], SPEED_STOPS[i + 1], seg - i) } 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 } const dot = (color) => L.divIcon({ html: `
`, iconSize: [12, 12], iconAnchor: [6, 6], className: '', }) function drawRoute(map, { polyline, dataPoints, sportType, colorMode }, trackRef) { if (trackRef.current) { trackRef.current.remove() trackRef.current = null } // Prefer the data-point track when colouring by speed; fall back to the encoded polyline. const speedPts = (colorMode === 'speed' && dataPoints) ? dataPoints.filter(p => p.latitude != null && p.longitude != null) : [] const group = L.layerGroup() if (speedPts.length >= 2 && speedPts.some(p => p.speed_ms != null)) { const speeds = speedPts.map(p => p.speed_ms).filter(s => s != null && s > 0) speeds.sort((a, b) => a - b) // Clamp the range to the 5th–95th percentile so a couple of GPS spikes don't wash out the ramp. const lo = speeds[Math.floor(speeds.length * 0.05)] ?? 0 const hi = speeds[Math.floor(speeds.length * 0.95)] ?? lo + 1 const levelOf = (s) => { const t = (hi > lo) ? (((s ?? lo) - lo) / (hi - lo)) : 0.5 return Math.round(Math.max(0, Math.min(1, t)) * SPEED_LEVELS) } // Group consecutive points into runs of the same colour level → one polyline per run. let runStart = 0 let runLevel = levelOf(speedPts[0].speed_ms) const flush = (end) => { const coords = speedPts.slice(runStart, end + 1).map(p => [p.latitude, p.longitude]) if (coords.length >= 2) { L.polyline(coords, { color: rampColor(runLevel / SPEED_LEVELS), weight: 3, opacity: 0.95 }).addTo(group) } } for (let i = 1; i < speedPts.length; i++) { const level = levelOf(speedPts[i].speed_ms) if (level !== runLevel) { flush(i) // include current point so runs join up runStart = i runLevel = level } } flush(speedPts.length - 1) const coords = speedPts.map(p => [p.latitude, p.longitude]) L.marker(coords[0], { icon: dot('#22c55e') }).addTo(group) L.marker(coords[coords.length - 1], { icon: dot('#ef4444') }).addTo(group) group.addTo(map) trackRef.current = group map.fitBounds(L.latLngBounds(coords), { padding: [20, 20] }) return } // Solid single-colour route from the encoded polyline. if (!polyline) return const coords = decodePolyline(polyline) if (!coords.length) return L.polyline(coords, { color: sportColor(sportType), weight: 3, opacity: 0.9 }).addTo(group) L.marker(coords[0], { icon: dot('#22c55e') }).addTo(group) L.marker(coords[coords.length - 1], { icon: dot('#ef4444') }).addTo(group) group.addTo(map) trackRef.current = group map.fitBounds(L.latLngBounds(coords), { padding: [20, 20] }) } export default function ActivityMap({ polyline, dataPoints, hoveredDistance, sportType, mapType = 'street', colorMode = 'speed', onMapClick }) { const mapRef = useRef(null) const mapInstanceRef = useRef(null) const markerRef = useRef(null) const trackRef = useRef(null) const tileLayerRef = useRef(null) const drawArgsRef = useRef({ polyline, dataPoints, sportType, colorMode }) const clickRef = useRef(onMapClick) drawArgsRef.current = { polyline, dataPoints, sportType, colorMode } useEffect(() => { clickRef.current = onMapClick }, [onMapClick]) useEffect(() => { if (!mapRef.current || mapInstanceRef.current) return mapInstanceRef.current = L.map(mapRef.current, { zoomControl: true, attributionControl: true, preferCanvas: true, }) const tile = TILE_LAYERS.street tileLayerRef.current = L.tileLayer(tile.url, { attribution: tile.attribution, ...TILE_OPTS }) .addTo(mapInstanceRef.current) mapInstanceRef.current.on('click', (e) => { if (clickRef.current) clickRef.current({ lat: e.latlng.lat, lng: e.latlng.lng }) }) return () => { mapInstanceRef.current?.remove() mapInstanceRef.current = null } }, []) useEffect(() => { if (!mapInstanceRef.current) return const tile = TILE_LAYERS[mapType] || TILE_LAYERS.street if (tileLayerRef.current) tileLayerRef.current.remove() tileLayerRef.current = L.tileLayer(tile.url, { attribution: tile.attribution, ...TILE_OPTS }) .addTo(mapInstanceRef.current) }, [mapType]) useEffect(() => { if (!mapInstanceRef.current) return drawRoute(mapInstanceRef.current, drawArgsRef.current, trackRef) }, [polyline, sportType, colorMode, dataPoints]) 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
}