Skip to main content

Network traffic

How much is coming in, how much is going out, and when did that change? The mirrored area chart answers all three at a glance: one direction fills upward from a shared zero line, the other fills downward, and the asymmetry between them is the reading.

The chart

Hover anywhere: the crosshair reads both halves at once, which is the pair of numbers you actually want. Drag across the burst just after 09:00 to zoom into it.

The data

Six hours of real router telemetry from cern773-cr6, a CERN border router, captured 2026-08-02T05:51:00Z → 11:49:30Z (07:51–13:49 local — CERN is UTC+2 in August). It was supplied for use in these docs by the pond-ts maintainer, out of the production dashboard it drives. It is not a published open-data release, and no licence is asserted over it beyond its use as a documentation fixture here.

The router reports per SAP — a service access point, one logical peer or customer attachment on a physical port — and it reports in the shape pond already reads:

{
"name": "111-lag-3-522",
"columns": ["time", "in", "out"],
"points": [[1785649860000, 1183000000, 71827000000], ...]
}

Those are row tuples that TimeSeries.fromJSON({ schema, rows: points }) takes with no mapping step at all. That's the pondjs wire format — a fair amount of history showing, since ESnet built pondjs and react-timeseries-charts, of which pond-ts is the successor, and the tooling still emits it.

What the fixture kept, and what it cost

The raw capture is 1 MB — 24 SAPs × 718 points — which has no business in a docs bundle. website/scripts/fixtures/cern-traffic.mjs derives the committed 37 KB fixture offline, applying three reductions, none of them flattering:

ReductionWhat it cost
Kept the top 7 SAPs by carried volumeNothing visible — the 7 are 99.87% of all bytes
Kept the capture's native 30 s gridNothing — 718 points per interface, no averaging, no peak clipped
bits/s → Gbps at 2 dp, timestamp column dropped10 Mbps of resolution; the grid is exact 30 000 ms, so an origin + step replaces it

The 17 dropped SAPs are near-idle — 11 of the 24 never exceed 1 Mbps in either direction — and would be invisible slivers on any chart drawn to a 190 Gbps axis. They're described here rather than plotted.

The quirks that matter

  • A quiet morning that steps up hard just after 09:00 UTC, then minute-scale bursts on top of it. This is why the card sweeps a window rather than showing the whole six hours flat.
  • Wildly uneven scale between interfaces. The busiest SAP alone carries 75% of the bytes — more than the other 23 together.
  • In and out are strongly asymmetric, which is what a data-export site looks like from the outside, and the reason the mirrored form is worth the trouble.
  • No nulls and no gaps. The capture has none, so nothing was interpolated. (For the chart that does have a hole in it, see Multi-host CPU.)

Build it

Start with one direction. An <AreaChart> with a baseline fills between the column and that value:

import {
AreaChart,
ChartContainer,
ChartRow,
Layers,
YAxis,
} from '@pond-ts/charts';
import { siteTotal, TRAFFIC_RANGE } from './lib/cern-traffic';

const total = siteTotal();

<ChartContainer range={TRAFFIC_RANGE} width={640} theme={theme}>
<ChartRow height={220}>
<YAxis id="gbps" side="right" label="Gbps" width={52} />
<Layers>
<AreaChart series={total} column="in" as="in" axis="gbps" baseline={0} />
</Layers>
</ChartRow>
</ChartContainer>;

That's inbound traffic, filling up from zero. It's a perfectly good chart and it tells you half of what you need.

The mirror is one line of data work. Negate the out column, and the same baseline={0} that made the first area fill up makes this one fill down:

const mirrored = useMemo(() => total.mapColumns({ out: (v) => -v }), [total]);

<AreaChart series={mirrored} column="out" as="out" axis="gbps" baseline={0} />;

Both layers share one axis, so the two halves are on one scale and directly comparable — that's the whole point of the form. The as="in" / as="out" roles are the theme's two traffic colours; <AreaChart> names this composition in its own docs as "the esnet two-colour traffic look".

One thing is now wrong: the downward half's axis ticks read −180. Both directions are magnitudes — only the drawing is signed — so the axis should label |value|:

// Hoisted, not inline: an inline format *function* is a fresh reference every
// render, and would re-register the axis on every animated frame.
const absGbps = (v: number) => Math.abs(v).toFixed(0);

<YAxis id="gbps" side="right" label="Gbps" format={absGbps} width={52} />;

Linking a table to the chart

The chart above is one panel of a pair. The other is the interface table — and selecting a row drives the chart:

Click a row to scope the chart to that interface; click it again, or Clear selection, to go back to the site total.

The linking is deliberately dull, and that's the lesson. Nothing in @pond-ts/charts mediates it — a chart is a view of whatever series you hand it, so "selection drives the chart" is one piece of React state and a ternary:

const [selected, setSelected] = useState<string | null>(null);

// Both branches have the same `in` / `out` columns, so nothing else in the
// composition changes.
const series = selected === null ? siteTotal() : sapSeries(selected);

The table's inline bullet bars are plain DOM, not charts. At ~90 px per row a <BarChart> would cost a canvas, a scale and a container each, to draw one rectangle. A div with a width is the honest primitive; reach for a chart when you need an axis. The bars do share one scale across all rows — the busiest interface's peak — because a row's bar is only meaningful against the others, which is the entire reason to put them in a column.

Options to try

OptionWhat it doesReach for it when
cursor="crosshair" on <ChartContainer>Both halves read out at the hovered timeAlways, on a mirrored chart — the pair of numbers is the point
legend="to site" on each <AreaChart>Names the layer for <Legend> and the readoutThe direction isn't obvious from position alone, e.g. in a small multiple
Drop baseline on the out layerIt fills to the axis floor instead of the zero lineNever here — but it's the default, and it's why the negate-plus-baseline pair matters
stack the directions instead of mirroring themGives total-crossing-the-router, losing the in/out splitYou care about capacity, not direction
Swap siteTotal() for stackedTraffic('in')Breaks one direction down by interface — see the sibling page below"How much?" has become "whose?"

See also