Segments and Av HR update
This commit is contained in:
@@ -16,36 +16,94 @@ function decodePolyline(encoded) {
|
||||
return coords
|
||||
}
|
||||
|
||||
function haversineDist([lat1, lng1], [lat2, lng2]) {
|
||||
const R = 6371000
|
||||
const dLat = (lat2 - lat1) * Math.PI / 180
|
||||
const dLng = (lng2 - lng1) * Math.PI / 180
|
||||
const a = Math.sin(dLat / 2) ** 2 + Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLng / 2) ** 2
|
||||
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
|
||||
}
|
||||
|
||||
// Internal viewBox dimensions — path is always drawn into this space, SVG scales it
|
||||
const VW = 100
|
||||
const VH = 80
|
||||
const PAD = 6
|
||||
|
||||
export default function RouteMiniMap({ polyline, sportType, width = 80, height = 60 }) {
|
||||
const pathD = useMemo(() => {
|
||||
if (!polyline) return null
|
||||
const coords = decodePolyline(polyline)
|
||||
if (coords.length < 2) return null
|
||||
function buildPaths(polyline, segStartM, segEndM) {
|
||||
if (!polyline) return null
|
||||
const coords = decodePolyline(polyline)
|
||||
if (coords.length < 2) return null
|
||||
|
||||
const lats = coords.map(c => c[0])
|
||||
const lngs = coords.map(c => c[1])
|
||||
const minLat = Math.min(...lats), maxLat = Math.max(...lats)
|
||||
const minLng = Math.min(...lngs), maxLng = Math.max(...lngs)
|
||||
const latRange = maxLat - minLat || 0.001
|
||||
const lngRange = maxLng - minLng || 0.001
|
||||
const lats = coords.map(c => c[0])
|
||||
const lngs = coords.map(c => c[1])
|
||||
const minLat = Math.min(...lats), maxLat = Math.max(...lats)
|
||||
const minLng = Math.min(...lngs), maxLng = Math.max(...lngs)
|
||||
const latRange = maxLat - minLat || 0.001
|
||||
const lngRange = maxLng - minLng || 0.001
|
||||
|
||||
const drawW = VW - PAD * 2
|
||||
const drawH = VH - PAD * 2
|
||||
const scale = Math.min(drawW / lngRange, drawH / latRange)
|
||||
const offX = PAD + (drawW - lngRange * scale) / 2
|
||||
const offY = PAD + (drawH - latRange * scale) / 2
|
||||
const drawW = VW - PAD * 2
|
||||
const drawH = VH - PAD * 2
|
||||
const scale = Math.min(drawW / lngRange, drawH / latRange)
|
||||
const offX = PAD + (drawW - lngRange * scale) / 2
|
||||
const offY = PAD + (drawH - latRange * scale) / 2
|
||||
|
||||
return coords.map((c, i) => {
|
||||
const x = offX + (c[1] - minLng) * scale
|
||||
const y = offY + (maxLat - c[0]) * scale
|
||||
return `${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`
|
||||
}).join(' ')
|
||||
}, [polyline])
|
||||
const toXY = ([lat, lng]) => [
|
||||
offX + (lng - minLng) * scale,
|
||||
offY + (maxLat - lat) * scale,
|
||||
]
|
||||
|
||||
const fullPath = coords.map((c, i) => {
|
||||
const [x, y] = toXY(c)
|
||||
return `${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`
|
||||
}).join(' ')
|
||||
|
||||
if (segStartM == null || segEndM == null) return { fullPath, segPath: null }
|
||||
|
||||
// Compute cumulative distances to find segment slice
|
||||
const cumDist = [0]
|
||||
for (let i = 1; i < coords.length; i++) {
|
||||
cumDist.push(cumDist[i - 1] + haversineDist(coords[i - 1], coords[i]))
|
||||
}
|
||||
const totalDist = cumDist[cumDist.length - 1] || 1
|
||||
|
||||
// Interpolate a point at a given distance along the route
|
||||
const interpAt = (targetM) => {
|
||||
for (let i = 1; i < cumDist.length; i++) {
|
||||
if (cumDist[i] >= targetM || i === cumDist.length - 1) {
|
||||
const t = cumDist[i] === cumDist[i - 1] ? 0 : (targetM - cumDist[i - 1]) / (cumDist[i] - cumDist[i - 1])
|
||||
const lat = coords[i - 1][0] + t * (coords[i][0] - coords[i - 1][0])
|
||||
const lng = coords[i - 1][1] + t * (coords[i][1] - coords[i - 1][1])
|
||||
return [lat, lng]
|
||||
}
|
||||
}
|
||||
return coords[coords.length - 1]
|
||||
}
|
||||
|
||||
const clampedStart = Math.max(0, Math.min(segStartM, totalDist))
|
||||
const clampedEnd = Math.max(0, Math.min(segEndM, totalDist))
|
||||
|
||||
// Collect segment points: interpolated start + all interior coords + interpolated end
|
||||
const segCoords = [interpAt(clampedStart)]
|
||||
for (let i = 0; i < coords.length; i++) {
|
||||
if (cumDist[i] > clampedStart && cumDist[i] < clampedEnd) {
|
||||
segCoords.push(coords[i])
|
||||
}
|
||||
}
|
||||
segCoords.push(interpAt(clampedEnd))
|
||||
|
||||
const segPath = segCoords.map((c, i) => {
|
||||
const [x, y] = toXY(c)
|
||||
return `${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`
|
||||
}).join(' ')
|
||||
|
||||
return { fullPath, segPath }
|
||||
}
|
||||
|
||||
export default function RouteMiniMap({ polyline, sportType, width = 80, height = 60, segmentStartM, segmentEndM }) {
|
||||
const paths = useMemo(
|
||||
() => buildPaths(polyline, segmentStartM, segmentEndM),
|
||||
[polyline, segmentStartM, segmentEndM],
|
||||
)
|
||||
|
||||
const svgProps = {
|
||||
viewBox: `0 0 ${VW} ${VH}`,
|
||||
@@ -54,15 +112,22 @@ export default function RouteMiniMap({ polyline, sportType, width = 80, height =
|
||||
style: { background: '#111827', width, height },
|
||||
}
|
||||
|
||||
if (!pathD) return (
|
||||
if (!paths) return (
|
||||
<svg {...svgProps}>
|
||||
<text x={VW / 2} y={VH / 2} textAnchor="middle" dominantBaseline="middle" fill="#374151" fontSize="10">—</text>
|
||||
</svg>
|
||||
)
|
||||
|
||||
const baseColor = paths.segPath ? '#374151' : sportColor(sportType)
|
||||
|
||||
return (
|
||||
<svg {...svgProps}>
|
||||
<path d={pathD} fill="none" stroke={sportColor(sportType)} strokeWidth="2" strokeLinejoin="round" strokeLinecap="round" />
|
||||
<path d={paths.fullPath} fill="none" stroke={baseColor} strokeWidth={paths.segPath ? 1.5 : 2}
|
||||
strokeLinejoin="round" strokeLinecap="round" />
|
||||
{paths.segPath && (
|
||||
<path d={paths.segPath} fill="none" stroke="#f97316" strokeWidth="3"
|
||||
strokeLinejoin="round" strokeLinecap="round" />
|
||||
)}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -153,16 +153,71 @@ function BodyBatteryChart({ bb, hiresValues }) {
|
||||
)
|
||||
}
|
||||
|
||||
function SleepStagesBar({ deep, light, rem, awake }) {
|
||||
const total = (deep || 0) + (light || 0) + (rem || 0) + (awake || 0)
|
||||
if (!total) return null
|
||||
const pct = s => `${((s || 0) / total * 100).toFixed(1)}%`
|
||||
// Sleep timeline bar spanning from sleep_start to sleep_end with proportional stage coloring
|
||||
function SleepTimeline({ sleepStart, sleepEnd, deep, light, rem, awake }) {
|
||||
if (!sleepStart || !sleepEnd) return null
|
||||
const stageSecs = (deep || 0) + (light || 0) + (rem || 0) + (awake || 0)
|
||||
if (!stageSecs) return null
|
||||
|
||||
const startMs = new Date(sleepStart).getTime()
|
||||
const endMs = new Date(sleepEnd).getTime()
|
||||
const windowMs = endMs - startMs
|
||||
if (windowMs <= 0) return null
|
||||
|
||||
// Build stage segments proportional to duration, but rendered across the sleep window
|
||||
const stages = [
|
||||
{ key: 'deep', secs: deep || 0, color: '#6366f1', label: 'Deep' },
|
||||
{ key: 'rem', secs: rem || 0, color: '#8b5cf6', label: 'REM' },
|
||||
{ key: 'light', secs: light || 0, color: '#a78bfa', label: 'Light' },
|
||||
{ key: 'awake', secs: awake || 0, color: '#374151', label: 'Awake' },
|
||||
].filter(s => s.secs > 0)
|
||||
|
||||
// Generate hour tick marks within the sleep window
|
||||
const startHour = new Date(startMs)
|
||||
startHour.setMinutes(0, 0, 0)
|
||||
startHour.setHours(startHour.getHours() + 1)
|
||||
const ticks = []
|
||||
let tick = startHour.getTime()
|
||||
while (tick < endMs) {
|
||||
const pct = Math.min(100, Math.max(0, (tick - startMs) / windowMs * 100))
|
||||
ticks.push({ pct, label: new Date(tick).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }) })
|
||||
tick += 3600000
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex rounded-full overflow-hidden h-2.5 w-full">
|
||||
<div style={{ width: pct(deep), backgroundColor: '#6366f1' }} />
|
||||
<div style={{ width: pct(rem), backgroundColor: '#8b5cf6' }} />
|
||||
<div style={{ width: pct(light), backgroundColor: '#a78bfa' }} />
|
||||
<div style={{ width: pct(awake), backgroundColor: '#374151' }} />
|
||||
<div className="space-y-1.5">
|
||||
{/* Time bar */}
|
||||
<div className="relative">
|
||||
<div className="flex rounded-md overflow-hidden h-5 w-full">
|
||||
{stages.map((s, i) => (
|
||||
<div
|
||||
key={s.key}
|
||||
style={{ width: `${(s.secs / stageSecs * 100).toFixed(2)}%`, backgroundColor: s.color }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{/* Tick marks */}
|
||||
{ticks.map((t, i) => (
|
||||
<div key={i} className="absolute top-0 h-5 flex flex-col items-center pointer-events-none" style={{ left: `${t.pct}%` }}>
|
||||
<div className="w-px h-full bg-black/40" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Time labels */}
|
||||
<div className="relative h-4">
|
||||
<span className="absolute left-0 text-xs text-gray-500" style={{ transform: 'translateX(-0%)' }}>
|
||||
{new Date(startMs).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
{ticks.map((t, i) => (
|
||||
<span key={i} className="absolute text-xs text-gray-600"
|
||||
style={{ left: `${t.pct}%`, transform: 'translateX(-50%)' }}>
|
||||
{t.label}
|
||||
</span>
|
||||
))}
|
||||
<span className="absolute right-0 text-xs text-gray-500">
|
||||
{new Date(endMs).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -253,11 +308,12 @@ function DailySnapshot({ day, avg30, intradayHr, bodyBattery, bbHires, onOlder,
|
||||
</div>
|
||||
{hasSleepStages ? (
|
||||
<>
|
||||
<SleepStagesBar
|
||||
<SleepTimeline
|
||||
sleepStart={day.sleep_start} sleepEnd={day.sleep_end}
|
||||
deep={day.sleep_deep_s} light={day.sleep_light_s}
|
||||
rem={day.sleep_rem_s} awake={day.sleep_awake_s}
|
||||
/>
|
||||
<div className="flex flex-wrap gap-x-5 gap-y-1.5">
|
||||
<div className="flex flex-wrap gap-x-5 gap-y-1.5 mt-1">
|
||||
{[
|
||||
['Deep', day.sleep_deep_s, '#6366f1'],
|
||||
['REM', day.sleep_rem_s, '#8b5cf6'],
|
||||
|
||||
@@ -11,61 +11,87 @@ function formatSegmentDist(m) {
|
||||
return m >= 1000 ? `${(m / 1000).toFixed(2)} km` : `${Math.round(m)} m`
|
||||
}
|
||||
|
||||
function SegmentRow({ seg, routeId, onDeleted }) {
|
||||
const [showTimes, setShowTimes] = useState(false)
|
||||
function SegmentRow({ seg, routeId, routePolyline, sportType }) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const { data: times, isLoading: timesLoading } = useQuery({
|
||||
queryKey: ['segment-times', routeId, seg.id],
|
||||
queryFn: () => api.get(`/routes/${routeId}/segments/${seg.id}/times`).then(r => r.data),
|
||||
enabled: showTimes,
|
||||
})
|
||||
|
||||
const deleteMut = useMutation({
|
||||
mutationFn: () => api.delete(`/routes/${routeId}/segments/${seg.id}`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['segments', routeId] })
|
||||
if (onDeleted) onDeleted()
|
||||
},
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['segments', routeId] }),
|
||||
})
|
||||
|
||||
const bestTime = times?.length ? Math.min(...times.map(t => t.duration_s)) : null
|
||||
const lastTime = times?.[0]?.duration_s ?? null
|
||||
|
||||
return (
|
||||
<div className="border border-gray-800 rounded-lg p-3 space-y-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="border border-gray-800 rounded-lg overflow-hidden">
|
||||
{/* Main row */}
|
||||
<div className="flex items-center gap-3 p-3">
|
||||
{/* Segment mini-map */}
|
||||
<div className="flex-shrink-0">
|
||||
<RouteMiniMap
|
||||
polyline={routePolyline}
|
||||
sportType={sportType}
|
||||
width={72}
|
||||
height={56}
|
||||
segmentStartM={seg.start_distance_m}
|
||||
segmentEndM={seg.end_distance_m}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-medium text-white truncate">{seg.name}</span>
|
||||
{seg.auto_generated && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-gray-800 text-gray-500">auto</span>
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-gray-800 text-gray-500">
|
||||
{seg.auto_generated_type || 'auto'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
{formatSegmentDist(seg.start_distance_m)} – {formatSegmentDist(seg.end_distance_m)}
|
||||
<span className="ml-2 text-gray-600">({formatSegmentDist(seg.end_distance_m - seg.start_distance_m)})</span>
|
||||
</p>
|
||||
{/* Times preview row */}
|
||||
{!timesLoading && (
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
{bestTime && (
|
||||
<span className="text-xs font-mono text-yellow-400">
|
||||
Best {formatDuration(bestTime)}
|
||||
</span>
|
||||
)}
|
||||
{lastTime && lastTime !== bestTime && (
|
||||
<span className="text-xs font-mono text-gray-400">
|
||||
Last {formatDuration(lastTime)}
|
||||
</span>
|
||||
)}
|
||||
{times?.length > 0 && (
|
||||
<span className="text-xs text-gray-600">
|
||||
{times.length} run{times.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
{times?.length === 0 && (
|
||||
<span className="text-xs text-gray-600">No times yet</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{timesLoading && <p className="text-xs text-gray-600 mt-1">Loading times…</p>}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-right flex-shrink-0">
|
||||
{showTimes && !timesLoading && bestTime && (
|
||||
<div>
|
||||
<p className="text-xs text-gray-500">Best</p>
|
||||
<p className="text-sm font-mono font-semibold text-yellow-400">{formatDuration(bestTime)}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{times?.length > 0 && (
|
||||
<button
|
||||
onClick={() => setExpanded(v => !v)}
|
||||
className="text-xs text-blue-400 hover:text-blue-300 transition-colors px-2 py-1 rounded border border-blue-500/30 hover:border-blue-400/50"
|
||||
>
|
||||
{expanded ? 'Hide' : 'All'}
|
||||
</button>
|
||||
)}
|
||||
{showTimes && !timesLoading && lastTime && (
|
||||
<div>
|
||||
<p className="text-xs text-gray-500">Last</p>
|
||||
<p className="text-sm font-mono text-gray-300">{formatDuration(lastTime)}</p>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setShowTimes(v => !v)}
|
||||
className="text-xs text-blue-400 hover:text-blue-300 transition-colors px-2 py-1 rounded border border-blue-500/30 hover:border-blue-400/50"
|
||||
>
|
||||
{showTimes ? 'Hide' : 'Times'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => deleteMut.mutate()}
|
||||
disabled={deleteMut.isPending}
|
||||
@@ -77,27 +103,20 @@ function SegmentRow({ seg, routeId, onDeleted }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showTimes && (
|
||||
<div className="pl-1">
|
||||
{timesLoading && <p className="text-xs text-gray-600">Loading times…</p>}
|
||||
{!timesLoading && !times?.length && (
|
||||
<p className="text-xs text-gray-600">No times recorded yet</p>
|
||||
)}
|
||||
{times?.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{times.map((t, i) => (
|
||||
<div key={t.activity_id} className="flex items-center gap-3 text-xs">
|
||||
<span className={`font-mono font-semibold w-14 ${i === 0 && t.duration_s === bestTime ? 'text-yellow-400' : 'text-gray-300'}`}>
|
||||
{formatDuration(t.duration_s)}
|
||||
</span>
|
||||
<Link to={`/activities/${t.activity_id}`} className="text-gray-500 hover:text-blue-400 transition-colors truncate">
|
||||
{t.name}
|
||||
</Link>
|
||||
<span className="text-gray-700 flex-shrink-0">{format(new Date(t.date), 'd MMM yyyy')}</span>
|
||||
</div>
|
||||
))}
|
||||
{/* Expanded times list */}
|
||||
{expanded && times?.length > 0 && (
|
||||
<div className="border-t border-gray-800 px-3 pb-3 pt-2 space-y-1">
|
||||
{times.map((t, i) => (
|
||||
<div key={t.activity_id} className="flex items-center gap-3 text-xs">
|
||||
<span className={`font-mono font-semibold w-14 ${t.duration_s === bestTime ? 'text-yellow-400' : 'text-gray-300'}`}>
|
||||
{formatDuration(t.duration_s)}
|
||||
</span>
|
||||
<Link to={`/activities/${t.activity_id}`} className="text-gray-500 hover:text-blue-400 transition-colors truncate">
|
||||
{t.name}
|
||||
</Link>
|
||||
<span className="text-gray-700 flex-shrink-0">{format(new Date(t.date), 'd MMM yyyy')}</span>
|
||||
</div>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -243,9 +262,14 @@ export default function SegmentsPage() {
|
||||
height={80}
|
||||
/>
|
||||
<p className="text-xs font-medium text-white mt-2 truncate">{r.name}</p>
|
||||
{r.distance_m && (
|
||||
<p className="text-xs text-gray-500">{(r.distance_m / 1000).toFixed(1)} km</p>
|
||||
)}
|
||||
<div className="flex items-center justify-between mt-0.5">
|
||||
{r.distance_m && (
|
||||
<p className="text-xs text-gray-500">{(r.distance_m / 1000).toFixed(1)} km</p>
|
||||
)}
|
||||
{r.activity_count > 0 && (
|
||||
<p className="text-xs text-gray-500">{r.activity_count} run{r.activity_count !== 1 ? 's' : ''}</p>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -260,6 +284,7 @@ export default function SegmentsPage() {
|
||||
<p className="text-xs text-gray-500">
|
||||
{selectedRoute.sport_type && <span className="capitalize">{selectedRoute.sport_type}</span>}
|
||||
{selectedRoute.distance_m && <span> · {formatDistance(selectedRoute.distance_m)}</span>}
|
||||
{selectedRoute.activity_count > 0 && <span> · {selectedRoute.activity_count} runs</span>}
|
||||
{selectedRoute.auto_detected && <span className="ml-1 text-gray-600">(auto-detected)</span>}
|
||||
</p>
|
||||
</div>
|
||||
@@ -303,7 +328,7 @@ export default function SegmentsPage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-600">Auto-generate replaces previously auto-generated segments. Manual segments are kept.</p>
|
||||
<p className="text-xs text-gray-600">Each auto-generate type (splits, turns, hills) replaces only its own previous segments. Manual segments are always kept.</p>
|
||||
</div>
|
||||
|
||||
{/* Segments list */}
|
||||
@@ -322,7 +347,13 @@ export default function SegmentsPage() {
|
||||
)}
|
||||
|
||||
{segments?.map(seg => (
|
||||
<SegmentRow key={seg.id} seg={seg} routeId={selectedRouteId} />
|
||||
<SegmentRow
|
||||
key={seg.id}
|
||||
seg={seg}
|
||||
routeId={selectedRouteId}
|
||||
routePolyline={selectedRoute.reference_polyline}
|
||||
sportType={selectedRoute.sport_type}
|
||||
/>
|
||||
))}
|
||||
|
||||
<NewSegmentForm routeId={selectedRouteId} />
|
||||
|
||||
Reference in New Issue
Block a user