Charting an activity
toSeries() gives the activity as a pond TimeSeries (for a ready-made chart series, see chartData), and
@pond-ts/charts draws a TimeSeries directly. So charting an
activity is the same as charting anything else in pond; this page shows the
three things activity charts usually need.
Over time, with the laps marked
The interval run: speed on top, heart rate underneath, the six repeats
shaded. A lap's fromSeconds and toSeconds are seconds from the start, so
a Region for a lap runs from start + fromSeconds * 1000 to
start + toSeconds * 1000. Hover to read both rows at one moment.
import {
ChartContainer,
ChartRow,
CrosshairCursor,
Layers,
LineChart,
Region,
YAxis,
} from '@pond-ts/charts';
import { Activity, haversineMeters } from '@pond-ts/fit';
import { col } from 'pond-ts';
import { useSiteChartTheme } from '@site/src/theme/useSiteChartTheme';
import { intervalRun } from './lib/fit-fixtures';
// The run, as `readActivity` reads interval-run.fit.
const run = Activity.from(intervalRun());
const track = run.toSeries();
const start = run.startedAt.getTime();
// Speed over the last 10 s from the GPS steps: one-second GPS speed is too
// jumpy to read. The rolled series keeps only what it computes, so heart rate
// is read from `track` and speed from `speed`, on the same time axis.
const lat = track.column('lat')!.toFloat64Array();
const lng = track.column('lng')!.toFloat64Array();
const step = lat.map((_, i) =>
i ? haversineMeters(lat[i - 1]!, lng[i - 1]!, lat[i]!, lng[i]!) : 0,
);
const speed = track
.withColumn('step', step)
.rolling('10s', { meters: { from: 'step', using: 'sum' } })
.withColumn('kmh', col('meters').mul(0.36));
// FIT laps have no names; the repeats are every other lap from the second.
const repeats = run.laps.filter((lap) => lap.id % 2 === 0 && lap.id < 13);
export default function FitRunChart({ width = 680 }: { width?: number }) {
const theme = useSiteChartTheme();
return (
<ChartContainer
range={track.timeRange()}
width={width}
theme={theme}
origin="data"
>
<CrosshairCursor />
<ChartRow height={170}>
<YAxis id="kmh" label="km/h" min={0} width={52} />
<Layers>
{repeats.map((lap, i) => (
<Region
key={lap.id}
from={start + lap.fromSeconds * 1000}
to={start + lap.toSeconds * 1000}
label={`R${i + 1}`}
selectable={false}
/>
))}
<LineChart series={speed} column="kmh" axis="kmh" as="primary" />
</Layers>
</ChartRow>
<ChartRow height={120}>
<YAxis id="bpm" label="bpm" width={52} pad={0.05} />
<Layers>
<LineChart series={track} column="hr" axis="bpm" />
</Layers>
</ChartRow>
</ChartContainer>
);
}
- Speed is smoothed before it's drawn. Speed worked out second by second from GPS jumps by a couple of km/h either way. A 10-second rolling sum of the distance steps reads as the runner's speed.
origin="data"labels the time axis as time into the run, not the time of day.- Gaps break the line. A heart-rate dropout is a missing cell in the series, and the chart leaves a hole there rather than joining across it.
Over distance
A ride is often easier to read by distance: the climbs are where they are on
the road, whatever the speed. byValue('km') turns a series keyed by time
into one keyed by a column that only goes up, and the chart's x axis follows:
import {
AreaChart,
ChartContainer,
ChartRow,
CrosshairCursor,
Layers,
LineChart,
YAxis,
} from '@pond-ts/charts';
import { Activity, haversineMeters } from '@pond-ts/fit';
import { useSiteChartTheme } from '@site/src/theme/useSiteChartTheme';
import { hillRide } from './lib/fit-fixtures';
// The ride, as `readActivity` reads hill-ride.fit.
const ride = Activity.from(hillRide());
const track = ride.toSeries();
// Kilometres from the start, from the GPS steps: a column that only goes up,
// so it can be the x axis instead of time.
const lat = track.column('lat')!.toFloat64Array();
const lng = track.column('lng')!.toFloat64Array();
let km = 0;
const along = lat.map((_, i) => {
if (i)
km += haversineMeters(lat[i - 1]!, lng[i - 1]!, lat[i]!, lng[i]!) / 1000;
return km;
});
// Power over the last 30 s, so the line reads as effort rather than as every
// pedal stroke. Then both channels go onto the distance axis.
const smoothed = track.smooth('power', 'movingAverage', {
window: '30s',
output: 'power30',
});
const byDistance = smoothed.withColumn('km', along).byValue('km');
export default function FitRideProfile({ width = 680 }: { width?: number }) {
const theme = useSiteChartTheme();
return (
<ChartContainer range={[0, km]} width={width} theme={theme}>
<CrosshairCursor />
<ChartRow height={130}>
<YAxis id="w" label="watts" min={0} width={52} />
<Layers>
<LineChart
series={byDistance}
column="power30"
axis="w"
as="primary"
/>
</Layers>
</ChartRow>
<ChartRow height={110}>
<YAxis id="m" label="metres" width={52} pad={0.05} />
<Layers>
<AreaChart
series={byDistance}
column="ele"
axis="m"
baseline="floor"
/>
</Layers>
</ChartRow>
</ChartContainer>
);
}
The six-minute café stop is invisible here: no distance passed. On a time axis it would be a flat gap in the middle of the ride.
Charts that come with fit
Some results are ready to chart as they are. Time in zones and the power
histogram come as bins with start and end edges, the shape
<BarChart bins> takes:
const zones = ride.analyze({ athlete }).zones.power!;
<BarChart bins={zones} column="seconds" axis="time" />;
The analysis guides draw one chart each: moving time, splits, normalized power, the power curve and time in zones.