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.
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.
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:
| Metric | pond | the head unit |
|---|---|---|
| Average power | 135 W | 135 W |
| Max power | 440 W | 440 W |
| Normalized power | 192 W | 192 W |
| Intensity factor | 0.96 | 0.962 |
| Training load (TSS) | 176 | 174.2 |
| Total work | 918 kJ | 918 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.
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:
ordinalgives 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.openEndedmarks the band with no upper bound, so the label can read300+ Wrather than inventing a ceiling.endis 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.
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 — heremutedrenders the raw trace in a neutral grey so theprimarymoving 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
| Piece | What it did |
|---|---|
pond-ts | the series, smooth, byColumn |
@pond-ts/fit | normalized power, IF, TSS, zones |
@pond-ts/charts | lines, 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
- pond-ts core — the foundation in depth. Start with the Mental model.
- Learn charts — the charts track, from first chart to live data.
- How-to guides — longer worked builds, including ingesting messy data.