0aa27713ca
- Lap bests: compare against OTHER activities on the route (exclude self), so single-activity routes no longer show every lap as "best" - Segment create: POST to trailing-slash URL (was a 307 that dropped the body); surface errors in the UI - PR splits: scale GPS distance stream to the activity's official distance so over-measured GPS no longer yields bogus split PRs - Speed route colours: red->orange->green->blue->purple (slow->fast) with smooth interpolation + a Slow/Fast gradient key under the map - Health body battery: snap activity highlight to the categorical axis; white tooltip text + % suffix - Health weight: y-min = lowest weight - 20kg; st/lb hover shows total lbs too - Health sleep: move 8h/avg reference labels into the right margin - Dashboard: Health-today pulls latest non-null values (sleep score, VO2 max); body battery tile renders a condensed colour-graded intraday graph Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
205 lines
8.1 KiB
React
205 lines
8.1 KiB
React
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: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>',
|
||
},
|
||
street: {
|
||
url: 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png',
|
||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>',
|
||
},
|
||
satellite: {
|
||
url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
|
||
attribution: '© <a href="https://www.esri.com/">Esri</a>',
|
||
},
|
||
}
|
||
|
||
// 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: `<div style="width:12px;height:12px;background:${color};border:2px solid white;border-radius:50%"></div>`,
|
||
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: '<div style="width:14px;height:14px;background:#fff;border:3px solid #3b82f6;border-radius:50%;box-shadow:0 0 6px rgba(59,130,246,0.8)"></div>',
|
||
iconSize: [14, 14], iconAnchor: [7, 7], className: '',
|
||
})
|
||
markerRef.current = L.marker([point.latitude, point.longitude], { icon })
|
||
.addTo(mapInstanceRef.current)
|
||
}
|
||
}, [hoveredDistance, dataPoints])
|
||
|
||
return <div ref={mapRef} style={{ height: '100%', width: '100%', background: '#1a1a2e' }} />
|
||
}
|