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, drag it, scroll to zoom.

One dataset, several views of it. On top, the ride as it happened: raw power (noisy, because power meters are), a 2-minute average riding through it, and a normalized power baseline — with the elevation profile on its own row beneath, sharing the same time axis. Then the four numbers a rider actually looks at, and 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.

The profile earns its row by explaining the one above it. The holes in the power trace aren't dropouts — they line up with the descents, and the dense, spiky stretch is the long climb to 400 m. Two rows in one ChartContainer share an x scale, so they pan, zoom and track the cursor together.

The same NP line appears on both — as a horizontal baseline against time, and as a vertical marker across the distribution. It's worth seeing where it lands: 192 W, well right of the 135 W average. Normalized power is deliberately not the mean, because an hour spent alternating between 0 and 300 W costs you far more than a steady 150.

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

  • Both traces break where you stop pedalling rather than 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. Those holes are the descents.
  • 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: '2m',
output: 'trend',
});

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', '2m'), not sample counts, so the result means the same thing whether your device logged at 1 Hz or 4 Hz.

Two minutes is a deliberate choice. A 30-second window still carries most of the noise — you end up with a slightly tidier version of the same unreadable trace. Widen it until the line stops describing pedal strokes and starts describing climbs, and for this ride that's about two minutes. Zoom in on the chart above and you can watch the raw and the average disagree exactly where the terrain changes.

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.

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.

That 30 seconds is fixed by the definition of NP — it's not the two-minute window we picked for the trend line. One is a metric, the other is a reading aid.

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={width}
cursor="crosshair"
origin="data"
panZoom="panZoom"
bounds={rideBounds}
>
<ChartRow height={220}>
<YAxis id="w" label="watts" min={0} />
<Layers>
<LineChart
series={traces}
column="pedalling"
axis="w"
as="muted"
legend="power"
/>
<LineChart
series={traces}
column="trend"
axis="w"
as="primary"
legend="2 min average"
/>
<Baseline
value={power.normalizedWatts}
axis="w"
label="NP 192 W"
indicator
/>
</Layers>
</ChartRow>
<ChartRow height={80}>
<YAxis id="ele" label="metres" pad={0.05} />
<Layers>
<AreaChart
series={ride}
column="elevation"
axis="ele"
legend="elevation"
/>
</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.
  • panZoom="panZoom" adds drag-to-pan and wheel-to-zoom, and bounds fences them to the ride's own extent so you can't wander off into empty time. ("pan" if you want dragging but would rather the wheel kept scrolling the page.)
  • width is a number, not a CSS length — the chart measures its container with a ResizeObserver and passes the result down. See the responsive width recipe.
  • origin="data" turns the time axis into a duration axis: ticks read 0:15, 0:30, 0:45 — time into the ride — instead of the wall clock it happened to be in 2016. It's a labelling mode, not a transform; the data is still absolute timestamps, and range still speaks in them.
  • <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. <Marker> is its vertical twin, at an x coordinate: the same NP, drawn across the histogram's watt axis.

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

<ChartContainer range={[0, 450]} width={histogramWidth}>
<ChartRow height={130}>
<YAxis id="min" label="minutes" min={0} max={pedallingPeak} />
<Layers>
<BarChart bins={bins} column="minutes" axis="min" gap={2} />
<Marker at={power.normalizedWatts} label="NP 192 W" />
</Layers>
</ChartRow>
</ChartContainer>

Rows inside one ChartContainer share an x-scale — that's what lets power and elevation stack and stay in step. A chart on a different x axis, like this one, is therefore 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