Skip to main content

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.

src/examples/fit-run-chart.tsx
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:

src/examples/fit-ride-profile.tsx
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.