Skip to main content

Getting started

We'll build one real thing, end to end: take a bike ride, analyse it, and draw it. It's a small enough problem to hold in your head and big enough to touch every layer of pond — the core series, a domain library, and the charts.

The data is a genuine ride, not a generated one: 1 h 54 m, 36.7 km, 712 m of climbing, recorded at 1 Hz by a head unit with a power meter. Real data comes with real texture — dropouts, coasting, spikes — and that texture is most of what makes the next few steps worth doing.

Here's what we're building. Hover it.

src/examples/getting-started-ride.tsx
import {
Baseline,
BarChart,
ChartContainer,
ChartRow,
Layers,
Legend,
LineChart,
YAxis,
} from '@pond-ts/charts';
import { computePower } from '@pond-ts/fit';
import { useSiteChartTheme } from '@site/src/theme/useSiteChartTheme';
import { ride, RIDE_ELAPSED_S, RIDE_FTP } from './lib/ride-fixtures';

// ── 1. The ride, and one core transform ────────────────────────────────────
const rideSeries = ride(); // 1 h 54 m of real 1 Hz power
const smoothed = rideSeries.smooth('watts', 'movingAverage', {
window: '30s',
output: 'watts30',
});

// A pedalling-only column: 0 W is real (you're coasting) but drawing it as a
// line to the floor turns every descent into a picket fence. Blank it and the
// chart's gap handling breaks the line instead. The 30 s average still
// averages the zeros — coasting is part of your effort, just not of the trace.
const wattsRaw = rideSeries.column('watts').toFloat64Array();
const withGaps = rideSeries.withColumn(
'pedalling',
Array.from(wattsRaw, (w) => (w > 0 ? w : undefined)),
);

// ── 2. The histogram — pond's own value-axis aggregation ───────────────────
// Samples are 1 Hz, so counting rows in a watt band *is* seconds in that band.
const bins = rideSeries
.byColumn('watts', { width: 25 }, { secs: { from: 'watts', using: 'count' } })
.map((b) => ({ ...b, minutes: b.secs / 60 }));

// The 0–25 W bucket is half an hour of coasting — three times any other bar.
// Cap the axis at the tallest *pedalling* bar so it doesn't flatten the part of
// the distribution worth reading; the coasting bar then runs off the top, and
// is drawn faintly so it reads as context rather than as the headline.
const pedallingPeak = Math.max(...bins.slice(1).map((b) => b.minutes));
const fadeFirstBar = (fill: string) =>
bins.map((_, i) =>
i === 0 ? (/^#[0-9a-f]{6}$/i.test(fill) ? `${fill}80` : fill) : undefined,
);

// ── 3. The domain layer — @pond-ts/fit turns watts into ride analytics ──────
// fit works in typed arrays: elapsed seconds + watts.
const startMs = rideSeries.keyColumn().begin[0]!;
const timeSec = Float64Array.from(
rideSeries.keyColumn().begin,
(ms) => (ms - startMs) / 1000,
);
const power = computePower(
timeSec,
rideSeries.column('watts').toFloat64Array(),
RIDE_FTP,
RIDE_ELAPSED_S,
);

export default function GettingStartedRide() {
const theme = useSiteChartTheme();

return (
<div>
{/* Time axis: the ride as it happened. */}
<ChartContainer
range={rideSeries.timeRange()}
width={680}
theme={theme}
cursor="crosshair"
>
<ChartRow height={200}>
<YAxis id="w" label="watts" min={0} width={52} />
<Layers>
<LineChart
series={withGaps}
column="pedalling"
axis="w"
as="muted"
legend="power"
/>
<LineChart
series={smoothed}
column="watts30"
axis="w"
as="primary"
legend="30 s average"
/>
<Baseline
value={power.normalizedWatts}
axis="w"
label={`NP ${Math.round(power.normalizedWatts)} W`}
indicator
/>
</Layers>
</ChartRow>
<Legend placement="top-right" />
</ChartContainer>

{/* Value axis: where the time actually went. */}
<ChartContainer range={[0, 450]} width={680} theme={theme}>
<ChartRow height={130}>
<YAxis
id="min"
label="minutes"
min={0}
max={pedallingPeak}
width={52}
/>
<Layers>
<BarChart
bins={bins}
column="minutes"
axis="min"
binColors={fadeFirstBar(theme.bar.default.fill)}
gap={2}
/>
</Layers>
</ChartRow>
</ChartContainer>
</div>
);
}

Two charts, one dataset. On top, the ride as it happened: raw power (noisy, because power meters are), the 30-second average riding through it, and a normalized power baseline. Underneath, the same ride keyed by watts instead of time — where the time actually went. That faint bar on the left is half an hour spent at almost no power at all: it's a descent-heavy loop, and you coast a lot of it. The time axis can't show you that; the value axis can.

Both charts make a concession to that coasting, and they're the two judgement calls in the whole example:

  • The raw trace breaks where you stop pedalling instead of diving to the floor. Zero watts is true, but drawing it turns every descent into a picket fence and buries the line you're meant to read.
  • The coasting bucket is drawn faintly, and the y-axis ignores it. It's three times any other bar; scaling to it would flatten the part of the distribution actually worth looking at. It runs off the top of the plot on purpose.

Everything below builds up to that, one step at a time.

Install

npm install pond-ts

That's the core. We'll add the other two pieces when we need them.

A series, by hand

Start with the smallest honest version: a few seconds of the ride, typed out. A schema declares the columns, the first of which is the temporal key:

import { TimeSeries } from 'pond-ts';

const schema = [
{ name: 'time', kind: 'time' },
{ name: 'watts', kind: 'number' },
] as const;

const ride = TimeSeries.fromJSON({
name: 'ride',
schema,
rows: [
['2016-07-20T14:27:50Z', 110],
['2016-07-20T14:27:51Z', 96],
['2016-07-20T14:27:52Z', 101],
['2016-07-20T14:27:53Z', 101],
['2016-07-20T14:27:54Z', 79],
],
});

Two columns is all this example needs. The device also recorded cadence, altitude, speed and GPS — you declare the columns you're going to use, and the rest never enters the series.

as const is doing real work there — it's what lets pond carry the column names and types through every transform that follows, so ride.column('watts') is known to be numeric and ride.column('wats') doesn't compile.

A series is an ordered, immutable collection of events. Read one:

const event = ride.at(3);

event.key().toDate().toISOString(); // '2016-07-20T14:27:53.000Z'
event.get('watts'); // 101
event.data(); // { watts: 101 }

Nothing is mutable, so every operator below returns a new series and the original stays intact.

Data arriving columnar?

Row tuples are the right default. If your data instead shows up struct-of-arrays — a bulk JSON payload, protobuf packed doubles, a decoder handing you typed arrays — TimeSeries.fromColumns skips the row round-trip entirely. See Creating series → Columnar ingest.

Smoothing it

Real power data is spiky — you can't read a rider's effort off the raw trace. The fix is a moving average, and it's one call:

const smoothed = ride.smooth('watts', 'movingAverage', {
window: '30s',
output: 'watts30',
});

That adds a column rather than replacing one; the smoothed series still carries watts, so you can draw both — which is exactly what the top chart does. Windows are written as durations ('30s', '5m'), not sample counts, so the result means the same thing whether your device logged at 1 Hz or 4 Hz.

Asking where the time went

Here's where time series get interesting. So far we've asked "what was the power at 8:04?" — a time question. But the useful training question is "how long did I spend at each power?" — a question about values, not clock time.

byColumn buckets rows by a numeric column's value instead of by time:

const bins = ride.byColumn(
'watts',
{ width: 25 }, // 25-watt-wide buckets
{ secs: { from: 'watts', using: 'count' } },
);
// → [{ start: 0, end: 25, secs: 1864 }, { start: 25, end: 50, secs: 82 }, …]

The samples are one second apart, so counting rows in a watt band is literally counting seconds in it — no extra bookkeeping. That array of { start, end, … } records is the power histogram, and it's the second chart.

That first bucket is the 31 minutes of coasting. It's also where the recording's own gaps quietly do the right thing: the head unit dropped 38 seconds across the ride, those cells are missing rather than zero, and byColumn leaves them out of the count instead of filing them under 0 W. Missing and zero mean very different things on a bike.

Note what came back: plain records, not a series. A power bucket isn't keyed by anything you'd keep chaining, so pond hands the bins straight back. (When you do want to keep the full-resolution channel but read it against a value — distance, say — that's byValue, which returns a ValueSeries.)

Bringing in the domain library

Everything so far was generic time-series work — pond core knows nothing about cycling. Normalized power, intensity factor, training load, and Coggan zones are real domain knowledge, and that's what @pond-ts/fit is for:

npm install @pond-ts/fit
import { computePower } from '@pond-ts/fit';

const power = computePower(timeSec, watts, 200 /* FTP */, elapsedSeconds);

Two lines, and you get the whole analysis:

Metricpondthe head unit
Average power135 W135 W
Max power440 W440 W
Normalized power192 W192 W
Intensity factor0.960.962
Training load (TSS)176174.2
Total work918 kJ918 kJ

The second column is what the Garmin wrote into the same file. We're not comparing against a fixture here — the device did this arithmetic on the bike in 2016, and pond reproduces it from the raw samples. (TSS differs by a point because the head unit divides by moving time and we passed elapsed.)

You also get a zones breakdown at that FTP — time in each of the seven Coggan bands. That's the third chart: bands on the y axis, minutes growing right. Note where the ride actually lived — 40% of it under 110 W, because what goes up comes back down.

src/examples/getting-started-zones.tsx
import {
BarChart,
ChartContainer,
ChartRow,
Layers,
YAxis,
} from '@pond-ts/charts';
import { computePower } from '@pond-ts/fit';
import { useSiteChartTheme } from '@site/src/theme/useSiteChartTheme';
import { ride, RIDE_ELAPSED_S, RIDE_FTP } from './lib/ride-fixtures';

const rideSeries = ride();
const startMs = rideSeries.keyColumn().begin[0]!;

const power = computePower(
Float64Array.from(
rideSeries.keyColumn().begin,
(ms) => (ms - startMs) / 1000,
),
rideSeries.column('watts').toFloat64Array(),
RIDE_FTP,
RIDE_ELAPSED_S,
);

// `power.zones` is already chart-ready: fit reports bands as { start, end, … },
// the same shape core's `byColumn` returns. The only thing added here is a
// minutes column — seconds would draw identically, just read worse.
const zones = power.zones.map((z) => ({ ...z, minutes: z.seconds / 60 }));

// Ordinal slots, so a 40 W zone and an open-ended one get equal height; the
// labels sit at each slot's centre. `openEnded` marks the band with no upper
// bound — say "300 W+", not "300–440 W".
const ticks = power.zones.map((z, i) => ({
at: i + 0.5,
label: `Z${z.zone} ${z.openEnded ? `${z.start}+` : `${z.start}${z.end}`} W`,
}));

export default function GettingStartedZones() {
const theme = useSiteChartTheme();

return (
<ChartContainer width={680} theme={theme}>
<ChartRow height={210}>
<YAxis id="zone" label="power zone" width={116} ticks={ticks} />
<Layers>
<BarChart
bins={zones}
column="minutes"
axis="zone"
orientation="horizontal"
ordinal
gap={6}
/>
</Layers>
</ChartRow>
</ChartContainer>
);
}
const zones = power.zones.map((z) => ({ ...z, minutes: z.seconds / 60 }));

<BarChart
bins={zones}
column="minutes"
orientation="horizontal"
ordinal
gap={6}
/>;

power.zones goes straight into the chart. fit reports its bands as { start, end, … } — the same shape byColumn returned two sections ago — so there's no adapter between the domain library and the visualization layer. The only thing that .map does is turn seconds into minutes, which is a units choice, not a reshape.

Two details worth stealing:

  • ordinal gives every band the same height. Zone widths are wildly unequal (Z2 is 40 W, Z6 is 60 W, Z7 has no top at all), and honouring those widths would make the chart about arithmetic instead of about time.
  • openEnded marks the band with no upper bound, so the label can read 300+ W rather than inventing a ceiling. end is still a real number — the chart has to draw something — but the flag tells you not to believe it.

computePower wants typed arrays rather than a series, so the bridge from pond is two accessors:

const startMs = ride.keyColumn().begin[0];
const timeSec = Float64Array.from(
ride.keyColumn().begin,
(ms) => (ms - startMs) / 1000,
);
const watts = ride.column('watts').toFloat64Array();

toFloat64Array() hands back the column's own buffer — no copy — which is why this stays cheap on a four-hour ride.

Under the hood it's still pond

fit isn't a separate engine. Normalized power is a 30-second rolling mean (pond's rolling), and the zone breakdown is value-axis bucketing (the same byColumn you just used). fit supplies the domain definitions; core does the work.

Drawing it

Now the charts. Install the visualization layer:

npm install @pond-ts/charts

Draw layers take a series and a column directly — there's no adapter step, no reshaping into {x, y} objects:

<ChartContainer range={ride.timeRange()} width={680} cursor="crosshair">
<ChartRow height={200}>
<YAxis id="w" label="watts" min={0} />
<Layers>
<LineChart
series={ride}
column="watts"
axis="w"
as="muted"
legend="power"
/>
<LineChart
series={smoothed}
column="watts30"
axis="w"
as="primary"
legend="30 s average"
/>
<Baseline
value={power.normalizedWatts}
axis="w"
label="NP 192 W"
indicator
/>
</Layers>
</ChartRow>
<Legend placement="top-right" />
</ChartContainer>

A few things worth naming, because they're the conventions you'll meet everywhere in pond charts:

  • cursor="crosshair" on the container is the whole hover interaction — it reads values off every layer at once.
  • <Legend> takes no data. It enumerates the registered layers and draws each one's resolved style, so the key can't drift from the plot. legend="…" names a row.
  • as="muted" / as="primary" are style roles, not colours. The theme decides what each one looks like — here muted renders the raw trace in a neutral grey so the primary moving average reads through it. That's why these charts follow the site's light/dark mode with no colour in this snippet.
  • <Baseline> is an annotation — a horizontal line at a value, here the normalized power fit just computed.

The histogram is the same components pointed at the bins, in its own container — because its x-axis is watts, not time:

<ChartContainer range={[100, 375]} width={680}>
<ChartRow height={130}>
<YAxis id="min" label="minutes" min={0} />
<Layers>
<BarChart bins={bins} column="minutes" axis="min" gap={2} />
</Layers>
</ChartRow>
</ChartContainer>

Rows inside one ChartContainer share an x-scale (that's what makes stacked rows line up), so a chart on a different axis is a separate container.

That's the whole example — the source under the chart at the top of this page is the real, complete thing, nothing elided.

What you just used

PieceWhat it did
pond-tsthe series, smooth, byColumn
@pond-ts/fitnormalized power, IF, TSS, zones
@pond-ts/chartslines, bars, baseline, legend, cursor

Core sits underneath; fit and charts build on it independently. @pond-ts/react is the fourth piece — hooks for binding a live series into React render, which is the same story with data still arriving.

Where to go next