Skip to main content

Columns

Every TimeSeries is backed by typed columns (one per schema field) plus a key column. The column API exposes those directly — typed-array access and O(1)/O(n) reductions without materializing one Event object per row.

Reach for it when you want throughput: charting large series, computing a scalar over one field, or handing a dense Float64Array to a canvas / WebGL draw loop. The row API (series.events, series.at(i), toPoints()) is still the right default for per-event logic; the column API is the fast path beside it, not a replacement.

It's fully additive — nothing about the row/Event API changed.

series.column(name)

Returns a typed, schema-narrowed view of one value column:

const cpu = series.column('cpu'); // Float64Column (number kind)
const host = series.column('host'); // StringColumn (string kind)
const up = series.column('healthy'); // BooleanColumn (boolean kind)

The return type is narrowed by the schema, so column('cpu') is a Float64Column (no cast needed). The view is read-only and zero-copy over the series' backing storage.

series.keyColumn()

The key axis (the schema's first column), narrowed by kind:

const t = series.keyColumn(); // TimeKeyColumn for a time-keyed series
t.at(0); // 1_700_000_000_000 — begin timestamp (raw number, not a Time)
t.slice(100, 200); // zero-copy index-range view

For timeRange keys, at(i) returns { begin, end }; for interval keys, { begin, end, label }. Raw POJOs in the columnar idiom — if you want the Time / TimeRange / Interval class wrapper, reach for it via the row path (series.events[i].key()).

Numeric reductions

Float64Column carries the scalar reductions, each a single pass, validity-aware (undefined cells are skipped):

const cpu = series.column('cpu');

cpu.min(); // number | undefined
cpu.max();
cpu.sum(); // number (0 over an empty/all-undefined column)
cpu.mean();
cpu.stdev();
cpu.median();
cpu.percentile(95); // q in [0, 100]; throws RangeError outside
cpu.minMax(); // [min, max] | undefined — one scan, not two
cpu.count(); // count of DEFINED cells (not series.length when gaps exist)

minMax() is the fused single-pass [min, max] — cheaper than [cpu.min(), cpu.max()] and the natural per-frame Y-extent for a chart.

Missing values

The reductions skip undefined cells. count() returns the number of defined cells (the data-frame convention) — it diverges from series.length exactly when the column has gaps:

cpu.hasMissing(); // boolean
cpu.nullCount(); // length - definedCount

Positional access

cpu.at(5); // number | undefined (bounds-checked, like Array.at)
cpu.first(); // value at index 0
cpu.last(); // value at index length-1
cpu.firstDefined(); // first non-undefined value
cpu.lastDefined();
cpu.slice(100, 200); // zero-copy Float64Column view over [100, 200)

toFloat64Array() — the canvas bridge

A storage-agnostic gather into a dense Float64Array of length exactly column.length. Zero-copy when the backing buffer is exact-sized (the typical case); a bounded view or single linear copy otherwise:

const values = series.column('cpu').toFloat64Array();
// hand straight to a canvas / WebGL draw loop — no per-row Event, no per-row read

This replaces the substrate-internal storage guard (if (col.storage !== 'packed') …) with one call that works for packed and chunked columns alike. The buffer shares the column's storage and is read-only by convention — writing through it corrupts the series.

bin(bins, reducer) — index downsampling

Splits [0, length) into bins equal-index ranges and reduces each. The chart's per-frame downsampler: collapse a million points to one bucket per pixel.

const visible = series.column('cpu').slice(startIdx, endIdx);
const { lo, hi } = visible.bin(cssWidth, 'minMax');
for (let px = 0; px < cssWidth; px += 1) {
ctx.moveTo(px, scaleY(hi[px]!));
ctx.lineTo(px, scaleY(lo[px]!));
}

Scalar reducers ('min', 'max', 'sum', 'mean', 'stdev', 'median', 'count', and the percentile family 'p95' / 'p99.9') return a Float64Array(bins); 'minMax' returns { lo, hi } (two channels, the canvas hot path). Empty bins land as NaN (or 0 for 'sum' / 'count') — and ctx.lineTo(px, NaN) correctly breaks the line for "no data here."

Index bins, not time bins

bin splits by index, which matches pixel columns only when samples are uniformly time-spaced. For bursty / irregular data you want time-aware binning (on the roadmap); bin is the right tool for uniform input.

scan(fn, options) — skip-undefined walk

A storage-agnostic linear walk. By default it skips invalid cells (skipInvalid: true); pass { skipInvalid: false } to visit every slot:

let total = 0;
series.column('cpu').scan((value, i) => {
total += value;
});

Other column kinds

KindClassExtra methods
numberFloat64Columnreductions, bin, toFloat64Array
booleanBooleanColumnall(), any(), none(), count()
stringStringColumnuniqueCount()
arrayArrayColumnat, slice, first/last

All kinds share at / slice / first / last / firstDefined / lastDefined / hasMissing / nullCount.

series.column('healthy').all(); // every defined cell true?
series.column('host').uniqueCount(); // distinct hosts

Chunked columns

Some operations (e.g. concatSorted of live windows) produce chunked columns — the same public surface, backed by multiple segments. Reductions on chunked columns materialize internally (≈2× the packed-native cost) but the result and the API are identical; column('x')'s narrowed type already covers both (Float64Column | ChunkedFloat64Column). You don't branch on storage — call the method.

See also

  • Charting — the column API is the high-throughput path for large series and canvas rendering (the toFloat64Array / bin('minMax') loop above); toPoints() remains the bridge for row-shaped chart libraries.