Add medals, HRV status dots, smooth segment hover, side-by-side map/timeline, HR zone times
- Silver/bronze medals (not just gold) on route & segment leaderboards - Colour HRV nightly-avg trend dots: orange unbalanced, red low - Project segment-hover dot smoothly along the track line (interpolated) - Show map and activity timeline side by side, half width each - Show time spent in each HR zone next to the percentage Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import L from 'leaflet'
|
||||
import { sportColor } from '../../utils/format'
|
||||
import { projectToTrack } from '../../utils/track'
|
||||
|
||||
delete L.Icon.Default.prototype._getIconUrl
|
||||
L.Icon.Default.mergeOptions({
|
||||
@@ -78,17 +79,6 @@ const SEG_TARGET_ICON = L.divIcon({
|
||||
iconSize: [14, 14], iconAnchor: [7, 7], className: '',
|
||||
})
|
||||
|
||||
// Nearest recorded GPS point to a lat/lng (squared planar distance is fine at
|
||||
// these scales). Mirrors nearestDistance() in ActivityDetailPage.
|
||||
function nearestPoint(points, lat, lng) {
|
||||
let best = null, bestD = Infinity
|
||||
for (const p of points) {
|
||||
if (p.latitude == null || p.longitude == null) continue
|
||||
const d = (p.latitude - lat) ** 2 + (p.longitude - lng) ** 2
|
||||
if (d < bestD) { bestD = d; best = p }
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
function drawRoute(map, { polyline, dataPoints, sportType, colorMode }, trackRef) {
|
||||
if (trackRef.current) {
|
||||
@@ -190,7 +180,7 @@ export default function ActivityMap({ polyline, dataPoints, hoveredDistance, spo
|
||||
mapInstanceRef.current.on('mousemove', (e) => {
|
||||
const pts = drawArgsRef.current.dataPoints
|
||||
if (!clickRef.current || !pts?.length) return
|
||||
const np = nearestPoint(pts, e.latlng.lat, e.latlng.lng)
|
||||
const np = projectToTrack(pts, e.latlng.lat, e.latlng.lng)
|
||||
if (!np) return
|
||||
if (segTargetRef.current) {
|
||||
segTargetRef.current.setLatLng([np.latitude, np.longitude])
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { formatDuration } from '../../utils/format'
|
||||
|
||||
const ZONE_CONFIG = [
|
||||
{ key: 'z1', label: 'Z1 Recovery', color: '#60a5fa' },
|
||||
{ key: 'z2', label: 'Z2 Base', color: '#34d399' },
|
||||
@@ -6,7 +8,9 @@ const ZONE_CONFIG = [
|
||||
{ key: 'z5', label: 'Z5 Max', color: '#f43f5e' },
|
||||
]
|
||||
|
||||
export default function HRZoneBar({ zones }) {
|
||||
// zones holds the % of time in each zone; multiply by the activity's active time
|
||||
// to show the approximate time spent in each.
|
||||
export default function HRZoneBar({ zones, totalSeconds }) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{/* Stacked bar */}
|
||||
@@ -34,6 +38,9 @@ export default function HRZoneBar({ zones }) {
|
||||
<div className="w-2.5 h-2.5 rounded-sm" style={{ backgroundColor: color }} />
|
||||
<span className="text-xs text-gray-400">{label}</span>
|
||||
<span className="text-xs font-medium text-white">{pct}%</span>
|
||||
{totalSeconds > 0 && (
|
||||
<span className="text-xs text-gray-500">{formatDuration(Math.round((pct / 100) * totalSeconds))}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { formatDuration, formatDate } from '../../utils/format'
|
||||
|
||||
const MEDALS = { 1: '🏆', 2: '🥈', 3: '🥉' }
|
||||
|
||||
// Compact +M:SS / +SS gap label (fastest effort shows nothing).
|
||||
function gapLabel(gapS) {
|
||||
if (gapS == null || gapS <= 0.5) return null
|
||||
@@ -59,8 +61,8 @@ export default function RouteLeaderboard({ data }) {
|
||||
: 'hover:bg-gray-800/30'
|
||||
}`}
|
||||
>
|
||||
<td className={`py-2 ${e.rank === 1 ? 'text-yellow-400' : 'text-gray-400'}`}>
|
||||
{e.rank === 1 ? '🏆' : e.rank}
|
||||
<td className={`py-2 ${e.rank <= 3 ? 'text-yellow-400' : 'text-gray-400'}`}>
|
||||
{MEDALS[e.rank] || e.rank}
|
||||
</td>
|
||||
<td className="py-2">
|
||||
<Link
|
||||
|
||||
@@ -4,7 +4,8 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import api from '../../utils/api'
|
||||
import { formatDuration, formatDistance } from '../../utils/format'
|
||||
|
||||
const MEDALS = { 1: '🥇', 2: '🥈', 3: '🥉' }
|
||||
const MEDALS = { 1: '🏆', 2: '🥈', 3: '🥉' }
|
||||
const PLACE_MEDALS = { 1: '🥇', 2: '🥈', 3: '🥉' }
|
||||
|
||||
// Compact +M:SS gap label (fastest effort shows nothing) — mirrors RouteLeaderboard.
|
||||
function gapLabel(gapS) {
|
||||
@@ -44,8 +45,8 @@ function Leaderboard({ segmentId, activityId }) {
|
||||
isCurrent ? 'bg-emerald-500/15 hover:bg-emerald-500/20' : 'hover:bg-gray-800/30'
|
||||
}`}
|
||||
>
|
||||
<td className={`py-2 ${e.rank === 1 ? 'text-yellow-400' : 'text-gray-400'}`}>
|
||||
{e.rank === 1 ? '🏆' : e.rank}
|
||||
<td className={`py-2 ${e.rank <= 3 ? 'text-yellow-400' : 'text-gray-400'}`}>
|
||||
{MEDALS[e.rank] || e.rank}
|
||||
</td>
|
||||
<td className="py-2">
|
||||
<Link
|
||||
@@ -119,7 +120,7 @@ export default function SegmentsPanel({ segments, activityId }) {
|
||||
</td>
|
||||
<td className="py-2 text-right font-mono">
|
||||
{isPodium
|
||||
? <span title="Podium time on this activity" className="text-yellow-400">{MEDALS[seg.rank]}</span>
|
||||
? <span title="Podium time on this activity" className="text-yellow-400">{PLACE_MEDALS[seg.rank]}</span>
|
||||
: delta != null
|
||||
? <span className="text-gray-500">+{formatDuration(delta)}</span>
|
||||
: <span className="text-gray-700">--</span>}
|
||||
|
||||
@@ -14,16 +14,7 @@ import {
|
||||
formatHeartRate, formatDateTime, formatCadence, sportIcon,
|
||||
} from '../utils/format'
|
||||
|
||||
// Find the cumulative distance along the track nearest a clicked lat/lng.
|
||||
function nearestDistance(points, lat, lng) {
|
||||
let best = null, bestD = Infinity
|
||||
for (const p of points) {
|
||||
if (p.latitude == null || p.longitude == null || p.distance_m == null) continue
|
||||
const d = (p.latitude - lat) ** 2 + (p.longitude - lng) ** 2
|
||||
if (d < bestD) { bestD = d; best = p.distance_m }
|
||||
}
|
||||
return best
|
||||
}
|
||||
import { projectToTrack } from '../utils/track'
|
||||
|
||||
const METRICS = [
|
||||
{ key: 'heart_rate', label: 'Heart Rate', unit: 'bpm', color: '#f43f5e' },
|
||||
@@ -83,8 +74,9 @@ export default function ActivityDetailPage() {
|
||||
|
||||
const handleMapClick = ({ lat, lng }) => {
|
||||
if (!segCreate || !dataPoints) return
|
||||
const dist = nearestDistance(dataPoints, lat, lng)
|
||||
if (dist == null) return
|
||||
const proj = projectToTrack(dataPoints, lat, lng)
|
||||
if (proj?.distance_m == null) return
|
||||
const dist = proj.distance_m
|
||||
setSegPoints(prev => (prev.length >= 2 ? [{ distance_m: dist }] : [...prev, { distance_m: dist }]))
|
||||
}
|
||||
|
||||
@@ -162,10 +154,12 @@ export default function ActivityDetailPage() {
|
||||
{activity.hr_zones && Object.values(activity.hr_zones).some(v => v > 0) && (
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||
<h3 className="text-sm font-medium text-gray-300 mb-3">Heart Rate Zones</h3>
|
||||
<HRZoneBar zones={activity.hr_zones} />
|
||||
<HRZoneBar zones={activity.hr_zones} totalSeconds={activity.moving_time_s ?? activity.duration_s} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Map and activity timeline side by side, each ~half width on large screens */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 items-start">
|
||||
{/* Map with controls — only when the activity has a GPS track */}
|
||||
{activity.polyline && activity.distance_m > 0 ? (
|
||||
<div className="bg-gray-900 rounded-xl overflow-hidden border border-gray-800">
|
||||
@@ -309,6 +303,7 @@ export default function ActivityDetailPage() {
|
||||
<p className="text-gray-600 text-sm text-center py-8">No timeline data available for this activity</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Laps · Routes · Segments — on one row, each shrinking to fit and
|
||||
expanding to fill the width when fewer are present. */}
|
||||
|
||||
@@ -649,7 +649,17 @@ function DailySnapshot({ day, snapshotWeight, avg30, intradayHr, bodyBattery, bb
|
||||
|
||||
// ── Trend Charts ────────────────────────────────────────────────────────────
|
||||
|
||||
function MetricChart({ data, dataKey, color, formatter, height = 140, selectedDate, onDayClick, connectNulls = false, showDots = false, domain, referenceLines }) {
|
||||
// Highlight problem days on a trend line by colouring the dot from a status field
|
||||
// (e.g. HRV status): orange = unbalanced, red = low/poor. Other days get no dot.
|
||||
const STATUS_DOT_COLORS = { unbalanced: '#f97316', low: '#ef4444', poor: '#ef4444' }
|
||||
const statusDot = (statusKey) => (props) => {
|
||||
const { cx, cy, payload } = props
|
||||
const color = STATUS_DOT_COLORS[String(payload?.[statusKey] || '').toLowerCase()]
|
||||
if (cx == null || cy == null || !color) return null
|
||||
return <circle cx={cx} cy={cy} r={3.5} fill={color} stroke="#111827" strokeWidth={1} />
|
||||
}
|
||||
|
||||
function MetricChart({ data, dataKey, color, formatter, height = 140, selectedDate, onDayClick, connectNulls = false, showDots = false, domain, referenceLines, statusDotKey }) {
|
||||
const vals = data.filter(d => d[dataKey] != null)
|
||||
if (!vals.length) return (
|
||||
<div className="flex items-center justify-center text-gray-600 text-xs" style={{ height }}>No data</div>
|
||||
@@ -686,7 +696,7 @@ function MetricChart({ data, dataKey, color, formatter, height = 140, selectedDa
|
||||
))}
|
||||
<Area type="monotone" dataKey={dataKey} stroke={color} strokeWidth={2}
|
||||
fill={`url(#grad-${dataKey})`}
|
||||
dot={showDots ? { fill: color, r: 3, strokeWidth: 0 } : false}
|
||||
dot={statusDotKey ? statusDot(statusDotKey) : (showDots ? { fill: color, r: 3, strokeWidth: 0 } : false)}
|
||||
connectNulls={connectNulls} isAnimationActive={false} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
@@ -1017,10 +1027,17 @@ export default function HealthPage() {
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||
<h3 className="text-sm font-medium text-gray-300 mb-3">HRV (nightly avg)</h3>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-medium text-gray-300">HRV (nightly avg)</h3>
|
||||
<div className="flex items-center gap-3 text-xs text-gray-500">
|
||||
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full" style={{ background: '#f97316' }} /> Unbalanced</span>
|
||||
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full" style={{ background: '#ef4444' }} /> Low</span>
|
||||
</div>
|
||||
</div>
|
||||
<MetricChart data={metrics} dataKey="hrv_nightly_avg" color="#8b5cf6"
|
||||
formatter={v => `${Math.round(v)} ms`}
|
||||
selectedDate={selDateForCharts} onDayClick={handleDayClick}
|
||||
statusDotKey="hrv_status"
|
||||
referenceLines={[
|
||||
{ y: 20, stroke: '#f59e0b', strokeDasharray: '3 3', label: { value: 'Low', position: 'insideTopRight', fill: '#f59e0b', fontSize: 9 } },
|
||||
{ y: 60, stroke: '#22c55e', strokeDasharray: '3 3', label: { value: 'Good', position: 'insideTopRight', fill: '#22c55e', fontSize: 9 } },
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
// Project a lat/lng onto the activity's GPS track, returning the nearest point
|
||||
// *along the line* (not just the nearest recorded sample) with an interpolated
|
||||
// cumulative distance. This gives smooth snapping anywhere along the route rather
|
||||
// than jumping between logged GPS points.
|
||||
//
|
||||
// Coordinates are treated as planar (lat/lng as y/x). At the scale of a single
|
||||
// activity this is accurate enough for visual snapping and segment selection.
|
||||
export function projectToTrack(points, lat, lng) {
|
||||
const valid = points.filter(p => p.latitude != null && p.longitude != null)
|
||||
if (valid.length === 0) return null
|
||||
if (valid.length === 1) {
|
||||
return { latitude: valid[0].latitude, longitude: valid[0].longitude, distance_m: valid[0].distance_m ?? 0 }
|
||||
}
|
||||
|
||||
let best = null
|
||||
let bestD = Infinity
|
||||
for (let i = 0; i < valid.length - 1; i++) {
|
||||
const a = valid[i]
|
||||
const b = valid[i + 1]
|
||||
const ax = a.longitude, ay = a.latitude
|
||||
const dx = b.longitude - ax, dy = b.latitude - ay
|
||||
const len2 = dx * dx + dy * dy
|
||||
let t = len2 > 0 ? (((lng - ax) * dx + (lat - ay) * dy) / len2) : 0
|
||||
t = Math.max(0, Math.min(1, t))
|
||||
const px = ax + t * dx
|
||||
const py = ay + t * dy
|
||||
const d = (lat - py) ** 2 + (lng - px) ** 2
|
||||
if (d < bestD) {
|
||||
bestD = d
|
||||
const da = a.distance_m, db = b.distance_m
|
||||
const dist = (da != null && db != null) ? da + (db - da) * t : (da ?? db ?? null)
|
||||
best = { latitude: py, longitude: px, distance_m: dist }
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
Reference in New Issue
Block a user