@@ -1,5 +1,5 @@
import { Area , AreaChart , ReferenceArea , ResponsiveContainer , Tooltip , XAxis , YAxis } from "recharts" ;
import type { Lap } from "../../types/api" ;
import type { Lap , Sample } from "../../types/api" ;
function paceSecPerKm ( mps : number | null ) : number | null {
if ( mps == null || mps <= 0 ) return null ;
@@ -55,6 +55,14 @@ const PHASE_LABELS: Record<string, string> = {
COOLDOWN : "Cool-down" ,
} ;
interface LapWindow {
start : number ; // minutes, elapsed since activity start
end : number ;
targetPaceRange : [ number , number ] | null ;
targetHRRange : [ number , number ] | null ;
color? : string ;
}
interface Point {
t : number ;
actualPace : number | null ;
@@ -63,27 +71,8 @@ interface Point {
targetHRRange : [ number , number ] | null ;
}
function rangeTooltipFormatter ( rangeLabel : string , formatValue : ( v : number ) = > string ) {
return ( value : unknown , name : unkn own ) : [ string , string ] = > {
if ( Array . isArray ( value ) ) return [ ` ${ formatValue ( value [ 0 ] ) } – ${ formatValue ( value [ 1 ] ) } ` , rangeLabel ] ;
return [ formatValue ( Number ( value ) ) , String ( name ) ] ;
} ;
}
// Shows each lap's actual pace/HR over elapsed time for every activity that
// has laps, with the workout's warm-up/effort/recovery/cool-down structure
// shaded behind it. The expected band from a structured Garmin workout (see
// backend's alignWorkoutTargets) is layered on top when available -- most
// activities aren't from a structured workout with target zones, so absence
// of that band is the common case, not an error.
export function ExpectedVsActualChart ( { laps } : { laps : Lap [ ] } ) {
if ( laps . length === 0 ) return null ;
const hasPaceTarget = laps . some ( ( l ) = > l . TargetPaceLowMps != null && l . TargetPaceHighMps != null ) ;
const hasHRTarget = laps . some ( ( l ) = > l . TargetHRLowBpm != null && l . TargetHRHighBpm != null ) ;
const points : Point [ ] = [ ] ;
const phaseBands : Array < { x1 : number ; x2 : number ; color : string } > = [ ] ;
function buildLapWindows ( laps : Lap [ ] ) : LapWindow [ ] {
const windows : LapWind ow[ ] = [ ] ;
let elapsedMin = 0 ;
for ( const l of laps ) {
const start = elapsedMin ;
@@ -94,23 +83,86 @@ export function ExpectedVsActualChart({ laps }: { laps: Lap[] }) {
const paceLow = l . TargetPaceHighMps != null ? paceSecPerKm ( l . TargetPaceHighMps ) : null ;
const paceHigh = l . TargetPaceLowMps != null ? paceSecPerKm ( l . TargetPaceLowMps ) : null ;
point s. push ( {
t : start,
actualPace : paceSecPerKm ( l . AvgSpeedMps ) ,
window s. push ( {
start ,
end : elapsedMin ,
targetPaceRange : paceLow != null && paceHigh != null ? [ paceLow , paceHigh ] : null ,
actualHR : l.AvgHR ,
targetHRRange : l.TargetHRLowBpm != null && l . TargetHRHighBpm != null ? [ l . TargetHRLowBpm , l . TargetHRHighBpm ] : null ,
color : PHASE_COLORS [ l . IntensityType ] ,
} ) ;
const color = PHASE_COLORS [ l . IntensityType ] ;
if ( color ) phaseBands . push ( { x1 : start , x2 : elapsedMin , color } ) ;
}
// Closing point so the last lap's step extends visually to the end.
points . push ( { . . . points [ points . length - 1 ] , t : elapsedMin } ) ;
return windows ;
}
// Finds the lap window covering elapsed time t (minutes), for attaching a
// lap's target band to a specific sample. Lap count is small (rarely more
// than a few dozen), so a linear scan is simplest and fast enough.
function lapWindowAt ( t : number , windows : LapWindow [ ] ) : LapWindow | undefined {
return windows . find ( ( w ) = > t >= w . start && t < w . end ) ? ? windows [ windows . length - 1 ] ;
}
function rangeTooltipFormatter ( rangeLabel : string , formatValue : ( v : number ) = > string ) {
return ( value : unknown , name : unknown ) : [ string , string ] = > {
if ( Array . isArray ( value ) ) return [ ` ${ formatValue ( value [ 0 ] ) } – ${ formatValue ( value [ 1 ] ) } ` , rangeLabel ] ;
return [ formatValue ( Number ( value ) ) , String ( name ) ] ;
} ;
}
// Shows actual pace/HR over elapsed time for every activity that has laps,
// with the workout's warm-up/effort/recovery/cool-down structure shaded
// behind it. The expected band from a structured Garmin workout (see
// backend's alignWorkoutTargets) is layered on top when available -- most
// activities aren't from a structured workout with target zones, so absence
// of that band is the common case, not an error.
//
// The actual trace is built from per-second samples when available, not lap
// averages: a lap can span many minutes, so plotting one flat value per lap
// would hide real within-lap variation (a single-lap hill repeat, for
// example, would otherwise render as a dead-flat line despite the pace/HR
// swinging throughout it).
export function ExpectedVsActualChart ( { laps , samples } : { laps : Lap [ ] ; samples : Sample [ ] } ) {
if ( laps . length === 0 ) return null ;
const hasPaceTarget = laps . some ( ( l ) = > l . TargetPaceLowMps != null && l . TargetPaceHighMps != null ) ;
const hasHRTarget = laps . some ( ( l ) = > l . TargetHRLowBpm != null && l . TargetHRHighBpm != null ) ;
const lapWindows = buildLapWindows ( laps ) ;
const lapTotalMin = lapWindows [ lapWindows . length - 1 ] . end ;
let points : Point [ ] ;
let elapsedMin : number ;
if ( samples . length > 0 ) {
points = samples . map ( ( s ) = > {
const t = s . ElapsedSeconds / 60 ;
const w = lapWindowAt ( t , lapWindows ) ;
return {
t ,
actualPace : paceSecPerKm ( s . SpeedMps ) ,
targetPaceRange : w?.targetPaceRange ? ? null ,
actualHR : s.HeartRate ,
targetHRRange : w?.targetHRRange ? ? null ,
} ;
} ) ;
elapsedMin = Math . max ( lapTotalMin , points [ points . length - 1 ] . t ) ;
} else {
// Fallback for activities without per-second telemetry: one flat step
// per lap, same as before samples were available.
points = lapWindows . map ( ( w ) = > ( {
t : w.start ,
actualPace : null ,
targetPaceRange : w.targetPaceRange ,
actualHR : null ,
targetHRRange : w.targetHRRange ,
} ) ) ;
points . push ( { . . . points [ points . length - 1 ] , t : lapTotalMin } ) ;
elapsedMin = lapTotalMin ;
}
const phaseBands = lapWindows . filter ( ( w ) = > w . color ) . map ( ( w ) = > ( { x1 : w.start , x2 : w.end , color : w.color ! } ) ) ;
const phasesPresent = [ . . . new Set ( laps . map ( ( l ) = > l . IntensityType ) . filter ( ( t ) = > PHASE_COLORS [ t ] ) ) ] ;
const paceDomain = paddedDomain ( points . flatMap ( ( p ) = > [ p . actualPace , . . . ( p . targetPaceRange ? ? [ ] ) ] ) , 10 ) ;
const hrDomain = paddedDomain ( points . flatMap ( ( p ) = > [ p . actualHR , . . . ( p . targetHRRange ? ? [ ] ) ] ) , 5 ) ;
const phasesPresent = [ . . . new Set ( laps . map ( ( l ) = > l . IntensityType ) . filter ( ( t ) = > PHASE_COLORS [ t ] ) ) ] ;
const paceTooltipFormatter = rangeTooltipFormatter ( "Target range" , ( v ) = > ` ${ formatPace ( v ) } ` ) ;
const hrTooltipFormatter = rangeTooltipFormatter ( "Target range" , ( v ) = > ` ${ Math . round ( v ) } bpm ` ) ;
@@ -140,7 +192,7 @@ export function ExpectedVsActualChart({ laps }: { laps: Lap[] }) {
{ hasPaceTarget && (
< Area type = "stepAfter" dataKey = "targetPaceRange" stroke = "none" fill = "#9aa0ab" fillOpacity = { 0.25 } isAnimationActive = { false } name = "Target range" connectNulls / >
) }
< Area type = "monotone" dataKey = "actualPace" stroke = { PACE_COLOR } strokeWidth = { 2 } fill = { PACE_COLOR } fillOpacity = { 0.3 } dot = { { r : 2 } } isAnimationActive = { false } name = "Actual" connectNulls / >
< Area type = "monotone" dataKey = "actualPace" stroke = { PACE_COLOR } strokeWidth = { 2 } fill = { PACE_COLOR } fillOpacity = { 0.3 } dot = { false } isAnimationActive = { false } name = "Actual" connectNulls / >
< / AreaChart >
< / ResponsiveContainer >
< / div >
@@ -157,7 +209,7 @@ export function ExpectedVsActualChart({ laps }: { laps: Lap[] }) {
{ hasHRTarget && (
< Area type = "stepAfter" dataKey = "targetHRRange" stroke = "none" fill = "#9aa0ab" fillOpacity = { 0.25 } isAnimationActive = { false } name = "Target range" connectNulls / >
) }
< Area type = "monotone" dataKey = "actualHR" stroke = { HR_COLOR } strokeWidth = { 2 } fill = { HR_COLOR } fillOpacity = { 0.3 } dot = { { r : 2 } } isAnimationActive = { false } name = "Actual" connectNulls / >
< Area type = "monotone" dataKey = "actualHR" stroke = { HR_COLOR } strokeWidth = { 2 } fill = { HR_COLOR } fillOpacity = { 0.3 } dot = { false } isAnimationActive = { false } name = "Actual" connectNulls / >
< / AreaChart >
< / ResponsiveContainer >
< / div >