Skip to main content

Missing data & gaps

A gap is a run of missing samples — a dropout, a coasted sensor, an empty aggregation bucket. pond represents it as a non-finite value (NaN), and the chart layers render it honestly by default. Handling a gap is a two-part pipeline: upstream you decide which gaps to fill and which to leave; downstream each layer decides how to draw what's left.

The gap contract — NaN, not null

A gap is a NaN on the numeric column (Number.isFinite is the test), never null or undefined. Missing cells are undefined in the pond store; they surface as NaN on the Float64 column the charts read. So "is this a gap?" is always !Number.isFinite(value) — one contract across every layer.

Upstream — creating and bounding gaps

Gaps enter a series two ways, both in core (pond-ts):

  • materialize(sequence) regularizes onto a time grid; an empty bucket emits a row with undefined value columns. This is the usual way a gap appears — a minute with no data becomes a hole.
  • aggregate(...) over an empty bucket reduces to nothing (non-finite), which reads as a gap.

Then fill(strategy, { limit?, maxGap? }) decides which of those gaps to bridge and which to keep:

series
.materialize(Sequence.every('1m')) // regularize → undefined for empty minutes
.fill({ cpu: 'linear' }, { maxGap: '3m' }); // fill short gaps, keep long ones
  • strategy'hold' (forward-fill), 'bfill', 'linear', 'zero', a literal, or a per-column object.
  • maxGap / limit cap filling all-or-nothing per gap: a gap whose temporal span exceeds maxGap (or whose length exceeds limit cells) is left fully unfilled — a real absence you don't want to invent data across. What fill leaves behind is the NaN gap the chart then renders.

The split is deliberate: filling is a data decision (do I believe a value here?), made upstream with pond; rendering is a view decision (how do I show what I chose to leave), made on the layer.

Downstream — GapMode on the line and area

LineChart and AreaChart take a gaps prop — a GapMode — for how a NaN run draws. Switch it below on one fixed gap:

src/examples/charts-gap-modes.tsx
import { useState } from 'react';
import {
ChartContainer,
ChartRow,
Layers,
LineChart,
YAxis,
type GapMode,
} from '@pond-ts/charts';
import { TimeSeries } from 'pond-ts';
import { useSiteChartTheme } from '@site/src/theme/useSiteChartTheme';

const N = 48;
const BASE = Date.UTC(2026, 0, 12, 9, 0, 0);
const STEP = 60_000;

/** A sine with a deliberate coast (gap) at indices 14–19, on a **falling
* slope** so `step` (flat at the average) reads distinctly from `dashed`
* (a diagonal bridge). Missing cells are `undefined` → `NaN` on the column. */
function sineWithGap() {
const rows: Array<[number, number | undefined]> = [];
for (let i = 0; i < N; i += 1) {
const inGap = i >= 14 && i < 20;
rows.push([BASE + i * STEP, inGap ? undefined : 50 + 34 * Math.sin(i / 5)]);
}
return new TimeSeries({
name: 'gap',
schema: [
{ name: 'time', kind: 'time' },
{ name: 'v', kind: 'number', required: false },
] as const,
rows: rows as never,
});
}

const MODES: readonly GapMode[] = ['empty', 'none', 'dashed', 'step', 'fade'];

/** One gap, five renderings — switch the `gaps` mode and watch how the same
* coast draws. `empty` (default) breaks honestly; `none` bridges; `dashed` /
* `step` add a faint inferred connector; `fade` drops to the baseline. */
export default function ChartsGapModes() {
const theme = useSiteChartTheme();
const series = sineWithGap();
const [mode, setMode] = useState<GapMode>('empty');

return (
<div>
<div
style={{ display: 'flex', gap: 6, marginBottom: 10, flexWrap: 'wrap' }}
>
{MODES.map((m) => (
<button
key={m}
onClick={() => setMode(m)}
style={{
padding: '4px 10px',
borderRadius: 6,
border: '1px solid var(--site-surface-border)',
background:
m === mode ? 'var(--ifm-color-primary)' : 'transparent',
color: m === mode ? '#fff' : 'inherit',
cursor: 'pointer',
fontSize: 13,
}}
>
{m}
</button>
))}
</div>
<ChartContainer
range={[BASE, BASE + (N - 1) * STEP]}
width={560}
theme={theme}
>
<ChartRow height={200}>
<YAxis id="v" side="right" min={0} max={100} />
<Layers>
<LineChart series={series} column="v" axis="v" gaps={mode} />
</Layers>
</ChartRow>
</ChartContainer>
</div>
);
}
gapsWhat it draws
'empty'Default. Break — end the line at the gap, restart after it. A gap reads as a gap.
'none'Bridge straight across (linear interpolation). The only non-honest mode — for an artefact you want to ignore, not a real absence. A leading/trailing gap still breaks (nothing to bridge from).
'dashed'Break, plus a faint dashed line bridging each gap straight (last-good → next-good).
'step'Break, plus a faint flat dashed line at the average of the two edge values — flatter and less committal than dashed.
'fade'The line fades to the baseline at each gap edge (opaque at the line, transparent at the floor) and fades back in on the far side.

'dashed' and 'step' are inferred connectors — drawn fainter than the solid line (theme.gap.connectorOpacity) so a guessed bridge reads as secondary to measured data. In every mode except 'none', an AreaChart's fill stays broken — only the outline gets the connector (and 'fade' drops the outline to the area's own baseline).

Per-layer behaviour

Only the line and area negotiate a gap's appearance. The other layers have one honest behaviour each:

LayerMissing-value behaviour
LineChartgaps prop — all five modes.
AreaChartgaps prop — fill and outline both break (see the coupling above).
BandChartNo gaps prop — always breaks at a gap on either edge, by design.
ScatterChartA NaN point isn't drawn (a scatter has nothing to bridge).
BarChartA NaN bucket draws no bar.
BoxPlotA box missing any present quantile draws nothing.
CandlestickA candle missing any of O/H/L/C draws nothing.

Bands have no gap mode on purpose — a filled envelope's break wants its own treatment (sharp vs. blurred), still to be designed; until then a band breaks honestly.

Sharp edges

  • A gap is NaN, checked with Number.isFinite — not null/undefined. If your data uses null for missing, it must become NaN before the chart.
  • 'none' is the one dishonest mode — it invents values across the gap. Reach for it only when the gap is a sampling artefact, not a real absence; otherwise cap the fill upstream with maxGap.
  • Fill is upstream, render is downstream — don't try to express "fill gaps under 3 minutes" with a GapMode; that's a fill({ maxGap }) decision on the data.

See also

  • LineChart · AreaChart — the two layers with a gaps prop.
  • Trading-time axissessionBreaks is a scale break (at session boundaries), orthogonal to a data gap.
  • Storybook: Gaps — all five modes on a line and an area, side by side.