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 withundefinedvalue 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/limitcap filling all-or-nothing per gap: a gap whose temporal span exceedsmaxGap(or whose length exceedslimitcells) is left fully unfilled — a real absence you don't want to invent data across. Whatfillleaves behind is theNaNgap 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:
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>
);
}
gaps | What 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:
| Layer | Missing-value behaviour |
|---|---|
LineChart | gaps prop — all five modes. |
AreaChart | gaps prop — fill and outline both break (see the coupling above). |
BandChart | No gaps prop — always breaks at a gap on either edge, by design. |
ScatterChart | A NaN point isn't drawn (a scatter has nothing to bridge). |
BarChart | A NaN bucket draws no bar. |
BoxPlot | A box missing any present quantile draws nothing. |
Candlestick | A 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 withNumber.isFinite— notnull/undefined. If your data usesnullfor missing, it must becomeNaNbefore 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 withmaxGap.- Fill is upstream, render is downstream — don't try to express "fill gaps
under 3 minutes" with a
GapMode; that's afill({ maxGap })decision on the data.
See also
- LineChart · AreaChart —
the two layers with a
gapsprop. - Trading-time axis —
sessionBreaksis 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.