Files
MileVault/frontend/src/components/activity/ActivityMap.jsx
T
owain 0aa27713ca
Build and push images / validate (push) Successful in 3s
Build and push images / build-backend (push) Successful in 5s
Build and push images / build-worker (push) Successful in 5s
Build and push images / build-frontend (push) Successful in 9s
Fix follow-ups: lap bests, segments, charts, dashboard health
- 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>
2026-06-08 20:39:26 +01:00

205 lines
8.1 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 5th95th 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' }} />
}