Live charts
- The one indirection every live chart needs:
LiveSeries→ snapshot → chart - Why a chart re-renders on snapshot cadence, not event cadence
- Deriving many views (scatter, curve, bars) from one live stream with the batch operators you already know
createLiveValuefor a pill that updates without re-rendering the chart
Every chart in this track so far has drawn a fixed series. This chapter
is what changes when the data keeps arriving.
A doc that shows live.on('event') → chart teaches the wrong model — a
chart re-rendering on every single push doesn't scale, and React state
updates aren't supposed to fire at push cadence anyway. The real shape has
one indirection in the middle:
import { useEffect, useRef } from 'react';
import {
ChartContainer,
ChartRow,
Layers,
LineChart,
YAxis,
} from '@pond-ts/charts';
import { LiveSeries } from 'pond-ts';
import { useSnapshot } from '@pond-ts/react';
import { useSiteChartTheme } from '@site/src/theme/useSiteChartTheme';
const schema = [
{ name: 'time', kind: 'time' },
{ name: 'cpu', kind: 'number' },
] as const;
/** A tiny deterministic PRNG (mulberry32) — no external dependency. */
function mulberry32(seed: number): () => number {
let a = seed;
return () => {
a |= 0;
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/** Pushes a new event every 150ms; `useSnapshot`'s 400ms throttle means
* several pushes coalesce into one re-render — the re-render model this
* chapter is teaching, made visible by choosing push < throttle. */
export default function LearnLiveSeries() {
const theme = useSiteChartTheme();
const live = useRef(
new LiveSeries({ name: 'live-cpu', schema, retention: { maxEvents: 60 } }),
).current;
const rand = useRef(mulberry32(7)).current;
const cpu = useRef(0.4);
useEffect(() => {
const id = setInterval(() => {
cpu.current = Math.max(
0.05,
Math.min(0.95, cpu.current + (rand() - 0.5) * 0.06),
);
live.push([Date.now(), cpu.current]);
}, 150);
return () => clearInterval(id);
}, [live, rand]);
const snapshot = useSnapshot(live, { throttle: 400 });
if (snapshot === null || snapshot.length === 0) {
return <div style={{ height: 220 }} />;
}
return (
<ChartContainer range={snapshot.timeRange()} width={560} theme={theme}>
<ChartRow height={220}>
<YAxis id="pct" side="right" format=".0%" />
<Layers>
<LineChart series={snapshot} column="cpu" axis="pct" />
</Layers>
</ChartRow>
</ChartContainer>
);
}
import { LiveSeries } from 'pond-ts';
import { useSnapshot } from '@pond-ts/react';
const live = new LiveSeries({
name: 'live-cpu',
schema: [
{ name: 'time', kind: 'time' },
{ name: 'cpu', kind: 'number' },
] as const,
retention: { maxEvents: 60 },
});
// elsewhere, on an interval or a socket message:
live.push([Date.now(), reading]);
// in the component:
const snapshot = useSnapshot(live, { throttle: 400 });
if (snapshot === null || snapshot.length === 0) return <Skeleton />;
<ChartContainer range={snapshot.timeRange()} width={560}>
<ChartRow height={220}>
<Layers>
<LineChart series={snapshot} column="cpu" axis="pct" />
</Layers>
<YAxis id="pct" side="right" format=".0%" />
</ChartRow>
</ChartContainer>;
LiveSeries is a mutable, growing store — you .push() rows onto it from
wherever your data arrives (a socket handler, an interval, a poll).
useSnapshot is the bridge: it hands your component an immutable
TimeSeries — the exact same type every chart in this track has drawn —
and re-renders on a throttle, not on every push. The chart in this
example pushes a new reading every 150ms but only repaints at most every
400ms; several pushes coalesce into one snapshot, one re-render. That's
the whole model: the chart never touches LiveSeries directly, it only
ever draws the latest snapshot.
retention: { maxEvents: 60 } caps how much history LiveSeries keeps —
without a policy it would grow forever. The full eviction/windowing model
is LiveSeries.
snapshot is a fresh TimeSeries reference on every throttled re-render
— any projection you derive from it (.select(...), a .rolling(...)
pass, a filtered view) recomputes every time too, unless you memoize it.
This is chapter 2's prop-identity caution again, one level up: there it
was an inline format function re-allocating; here it's a whole
derived series. useMemo(() => snapshot.select('cpu'), [snapshot]) is
the fix once a projection stops being free.
One stream, many views
That memoized-derivation move is where live charts get fun. A snapshot is
just a TimeSeries, so every batch operator you already know works on it —
and re-runs each frame over the current window. Here one LiveSeries of
raw points drives three layers: the points themselves as a scatter, a
moving-average curve threaded through them (withColumn), and
2-second-average bars (aggregate) — each useMemo'd on the snapshot so
it only recomputes when new data lands:
import { useEffect, useMemo, useRef } from 'react';
import {
BarChart,
ChartContainer,
ChartRow,
Layers,
LineChart,
ScatterChart,
YAxis,
} from '@pond-ts/charts';
import { LiveSeries, Sequence } from 'pond-ts';
import { useSnapshot } from '@pond-ts/react';
import { useSiteChartTheme } from '@site/src/theme/useSiteChartTheme';
const schema = [
{ name: 'time', kind: 'time' },
{ name: 'signal', kind: 'number' },
] as const;
/** A tiny deterministic PRNG (mulberry32) — no external dependency. */
function mulberry32(seed: number): () => number {
let a = seed;
return () => {
a |= 0;
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/** One live stream, three views. Raw noisy points stream into a `LiveSeries`
* (a 12-second sliding window); from each snapshot we **re-derive** two
* things with plain pond operators — a moving-average curve threaded through
* the cloud (`withColumn`) and 2-second average bars (`aggregate`). Nothing
* is mutated in place: every frame is a fresh derivation over the current
* window, which is exactly how a "forming" bar stays correct. */
export default function LearnLivePipeline() {
const theme = useSiteChartTheme();
// Retain more than we show (WINDOW_MS): the extra left buffer means the bar
// straddling the left edge is still inside the aggregation range, so it's
// drawn (clipped) and slides out cleanly instead of popping.
const live = useRef(
new LiveSeries({ name: 'signal', schema, retention: { maxAge: '18s' } }),
).current;
const rand = useRef(mulberry32(19)).current;
const tick = useRef(0);
useEffect(() => {
const id = setInterval(() => {
const i = tick.current++;
// a clear wave the eye can follow, plus scatter noise around it.
// Phase step halved vs. the push rate so faster points don't make the
// wave itself busier — same shape, twice the density.
const wave = 0.5 + 0.3 * Math.sin(i / 14);
const value = Math.max(
0.05,
Math.min(0.95, wave + (rand() - 0.5) * 0.16),
);
live.push([Date.now(), value]);
}, 60);
return () => clearInterval(id);
}, [live, rand]);
const raw = useSnapshot(live, { throttle: 150 });
// Re-derive the curve + bars from the current window each snapshot.
const withCurve = useMemo(() => {
if (raw === null || raw.length === 0) return null;
const n = raw.length;
const col = raw.column('signal');
const smooth = new Float64Array(n);
const k = 14; // trailing moving-average window (~0.8s at 60ms/point)
for (let i = 0; i < n; i++) {
let sum = 0;
let count = 0;
for (let j = Math.max(0, i - k + 1); j <= i; j++) {
const v = col?.read(j);
// Skip gaps entirely — a missing sample shouldn't drag the average
// toward zero (it isn't a zero reading, it's no reading).
if (v !== undefined && Number.isFinite(v)) {
sum += v;
count++;
}
}
smooth[i] = count > 0 ? sum / count : NaN;
}
return raw.withColumn('smooth', smooth);
}, [raw]);
const bars = useMemo(() => {
if (raw === null || raw.length === 0) return null;
// During the first couple of seconds every point can still fall inside one
// leading bucket that `aggregate` doesn't emit (its start precedes the
// data) — so the result can be empty until a bucket completes.
const b = raw.aggregate(Sequence.every('2s'), { signal: 'avg' });
return b.length === 0 ? null : b;
}, [raw]);
if (raw === null || withCurve === null || bars === null) {
return <div style={{ height: 340 }} />;
}
// A fixed-width window that slides with the latest point — both edges track
// `end`, so the domain never changes width (no smoosh) and scrolls smoothly.
// Clamped on the left only during warm-up, before a full window has arrived.
const WINDOW_MS = 12_000;
const span = raw.timeRange()!;
const end = span.end();
const start = Math.max(end - WINDOW_MS, span.begin());
const view: [number, number] = [start, end];
return (
<ChartContainer range={view} width={560} theme={theme}>
<ChartRow height={200}>
<YAxis id="pct" side="right" format=".0%" min={0} max={1} />
<Layers>
<ScatterChart
series={withCurve}
column="signal"
axis="pct"
as="secondary"
radius={2.5}
/>
<LineChart
series={withCurve}
column="smooth"
axis="pct"
curve="natural"
/>
</Layers>
</ChartRow>
<ChartRow height={110}>
<YAxis id="avg" side="right" format=".0%" min={0} max={1} />
<Layers>
<BarChart series={bars} column="signal" axis="avg" gap={2} />
</Layers>
</ChartRow>
</ChartContainer>
);
}
Nothing here is a special "live" API beyond useSnapshot — the curve and the
bars are the same withColumn and aggregate from chapters 3 and 4, run
against the latest window. The bars re-form as points stream in because each
snapshot re-aggregates the whole window, which is the next point.
A pill that skips the chart entirely
Sometimes you don't want a whole chart re-render for a fast-moving number
— a live price tag, a current-value readout. createLiveValue builds a
store YAxisIndicator can subscribe to directly, bypassing React
re-renders for the rest of the chart:
import { useEffect, useRef } from 'react';
import {
ChartContainer,
ChartRow,
Layers,
LineChart,
YAxis,
YAxisIndicator,
createLiveValue,
} from '@pond-ts/charts';
import { useSiteChartTheme } from '@site/src/theme/useSiteChartTheme';
import { singleHostSeries } from './lib/server-metrics';
/** A tiny deterministic PRNG (mulberry32) — no external dependency. */
function mulberry32(seed: number): () => number {
let a = seed;
return () => {
a |= 0;
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/** `createLiveValue` updates the pinned pill via `.set()` — an isolated
* repaint of just that pill, not a re-render of the whole chart tree
* (`YAxisIndicator`'s `source` subscribes via `useSyncExternalStore`). */
export default function LearnLiveValue() {
const theme = useSiteChartTheme();
const series = singleHostSeries();
const live = useRef(createLiveValue(0.4)).current;
const rand = useRef(mulberry32(11)).current;
useEffect(() => {
const id = setInterval(() => {
live.set(Math.max(0.05, Math.min(0.95, 0.4 + (rand() - 0.5) * 0.4)));
}, 200);
return () => clearInterval(id);
}, [live, rand]);
return (
<ChartContainer range={series.timeRange()} width={560} theme={theme}>
<ChartRow height={220}>
<YAxis id="pct" side="right" format=".0%" />
<Layers>
<LineChart series={series} column="cpu" axis="pct" />
<YAxisIndicator source={live} axis="pct" />
</Layers>
</ChartRow>
</ChartContainer>
);
}
import { createLiveValue, YAxisIndicator } from '@pond-ts/charts';
const live = createLiveValue(0.4); // create once, e.g. in a ref
live.set(newReading); // updates the pill only — no chart re-render
<YAxisIndicator source={live} axis="pct" />;
YAxisIndicator takes either a static value (chapter 7 — re-renders
with its parent, fine for occasional changes) or a source — the
high-frequency path, isolated from the rest of the tree.
Live data is append-only
One honesty note before you build a real feed: LiveSeries is
append-only — there's no snapshot-replace. A "forming candle" that
keeps changing until its bar closes isn't a live edit to one event; it's
re-aggregation — you push raw ticks and re-run aggregate over the
open bucket, you don't mutate a pushed row after the fact. That's exactly
what the rightmost bar in the demo above is doing: it isn't being edited,
its 2-second window is being re-aggregated every snapshot until it fills.
See the @pond-ts/financial section for the calendar-aware
side of that shape.
Recap
Live charts run through one indirection: LiveSeries.push() on one side,
useSnapshot on the other, throttled so the chart repaints on its own
schedule rather than the feed's. createLiveValue skips the chart
re-render entirely for a single fast-moving number. And a live source
only ever grows — updating what's already there is a re-aggregation, not
a mutation.
Next: Beyond the time axis — every chart in this track has had time on the x axis. It isn't the only one.