Skip to main content

BarList & BoxList — ranked row lists

Standalone, DOM-rendered row lists: one row per entity (an interface, a split, a symbol), a label cell, one glyph line per configured column, optional data cells, custom sort, and an optional per-row expander. <BarList> draws a proportional value bar per line; its sister <BoxList> draws a five-number distribution — range band, q1q3 body, median line — plus an optional current-value tick with a printed label (the classic traffic-by-interface table).

This pattern is a table, not a plot, and the components embrace that: they render a real <table> (labels can be links, cells align by table layout, the expander is a spanning row) and take no <ChartContainer> — there is no time axis here. The in-plot histogram remains <BarChart orientation="horizontal">; reach for the lists when the rows are entities rather than buckets.

Data contract

Rows are plain records — { key, label?, values } — where values is a flat Record<string, number | string | undefined>:

  • key is the row's stable identity (selection, expansion, React keys).
  • label is the first cell's content (any node — a link is fine); the key renders when omitted.
  • values feeds everything else by name: bar lengths, box quantiles, sortBy, and whatever your data cells read. A missing / non-numeric entry is a gap — an empty track / line, sorting last in either direction.

Both components are generic over R extends ListRow, so extra fields on your rows flow into render / renderExpanded fully typed.

From your TimeSeries

Three acquisition shapes cover the real cases; every one composes from core's own vocabulary (the list never computes statistics — the same stance as <BoxPlot> takes on quantiles).

Rows are events — a per-split rollup

An aggregate produces one event per split; the series feeds directly — one row per event, every value column (numeric and string) landing in values under its own name, label naming the first cell. A ValueSeries (run.byValue('km')) rows per axis key identically. No shaping step.

const splits = run.aggregate(Sequence.every('5m'), {
speed: { from: 'speed', using: 'avg' },
climb: { from: 'elevGain', using: 'sum' },
});

<BarList
series={splits}
label={(i) => `${i + 1}`}
columns={[{ column: 'speed' }]}
/>;

(The listRowsFromTimeSeries / listRowsFromValueSeries readers remain for building record rows you want to post-process before passing as rows.)

Rows are entities — partition facts

reduce's mapping form returns exactly a row's values shape, so partition facts spread straight in. Quantiles come from pond's reducers:

const rows = [...traffic.partitionBy('iface').toMap()].map(([name, s]) => ({
key: name,
label: <a href={`/ifaces/${name}`}>{name}</a>,
values: s.reduce({
p5: { from: 'in', using: 'p5' },
p25: { from: 'in', using: 'p25' },
p50: { from: 'in', using: 'p50' },
p75: { from: 'in', using: 'p75' },
p95: { from: 'in', using: 'p95' },
now: { from: 'in', using: 'last' },
}),
}));

<BoxList
rows={rows}
columns={[
{
lower: 'p5',
q1: 'p25',
median: 'p50',
q3: 'p75',
upper: 'p95',
value: 'now',
format: (v) => `${(v / 1000).toFixed(1)}Gbps`,
},
]}
sortBy="now"
/>;

Rows are entities, distributed over buckets — grouped aggregate

For "what does each band typically see per window vs now", bucket first, then summarize the bucket column. Two semantics matter:

  • { groups } keeps silent entities as rows — empty declared groups still appear as empty series in the map, in declared order. For an ordinal scale (risk bands low → severe), omit sortBy and the declared order is the display order.
  • Pass a shared { range } to aggregate. The default range is each partition's own extent, so bands get different reporting windows and quiet periods fall outside the grid — biasing a count distribution upward. With a shared range, empty buckets emit honest n = 0 rows (count of an empty bucket is 0, not a gap) and every band lands on one comparable scale.
const range = riskReadings.timeRange()!;
const byBand = riskReadings
.partitionBy('band', { groups: RISK_BANDS })
.aggregate(
Sequence.every('5m'),
{ n: { from: 'band', using: 'count' } },
{ range },
)
.toMap();

const rows = [...byBand].map(([band, s]) => ({
key: band,
values: s.reduce({
p5: { from: 'n', using: 'p5' },
q1: { from: 'n', using: 'p25' },
q3: { from: 'n', using: 'p75' },
p95: { from: 'n', using: 'p95' },
now: { from: 'n', using: 'last' },
}),
}));

<BoxList
rows={rows}
columns={[
{
lower: 'p5',
q1: 'q1',
q3: 'q3',
upper: 'p95',
value: 'now',
format: (n) => `${n}/5m`,
},
]}
/>;

The same byBand map feeds stacksFromGroups → a stacked <BarChart> — the list is the "typical vs now" summary view and the stack the time view of one acquisition.

Reference markers

markers={[{ value, label? }]} draws a dotted vertical rule through every row at a value on the shared scale, with the label printed above the list, centred on the rule — an SLA threshold, a capacity line, the fleet average. Markers draw in the annotation (marks) register (the canvas <Marker> / <Baseline> siblings' colour), never a data hue. Under an auto-fitted domain, marker values join the fit — a threshold above the data max widens the scale instead of clamping to the edge; under an explicit domain an out-of-range marker clamps.

Labels are positioned, not laid out: two markers close together overlap their labels, and a label near a domain edge overhangs it. Keep marker sets small and spaced (they are reference lines, not an axis).

One shared scale

Every glyph line of every row maps through one [min, max] — cross-row comparison is the point of a ranked list. The domain resolves from the data ([min(0, data min), data max] over every bar column, or every box lower/upper/value); pass domain={[min, max]} to pin it across live updates or sibling lists.

Shared table props

PropTypeDefaultPurpose
rows / seriesR[] / TimeSeriesone-ofThe record door (entities) / the series door (one row per event, label names the cell).
columnssee below— (req)The glyph lines, top→bottom within each row.
domain[number, number]data fitPin the shared scale.
sortBystringinput ordervalues entry that ranks the list (with several columns, this picks the driver).
sortDirection'asc' | 'desc''desc'Largest on top by default.
sort(a, b) => numberFull comparator; overrides sortBy.
before / afterListCellSpec[]Data cell columns flanking the glyphs ({ key, align?, render(row) }).
renderExpanded(row) => ReactNodePer-row detail; providing it adds the chevron column.
defaultExpanded / onExpandTogglestring[] / (key, open)Seed + observe expansion (uncontrolled, keyed on row.key).
selected / onRowClickstring | string[] | null / (row)Consumer-owned selection (inset accent edge) + row clicks. See Row selection.
hovered / onHoverstring | string[] | null / (row | null)Controlled hover-highlight + the outbound half — light rows from outside the list, or mirror the list's hover out.
onRowSelect(rows, modifiers)The gesture: a click, a drag over a run of rows, or a keyboard extend. Plural, with the chord that was held.
barHeightnumber8 / 10Height per glyph line in px (bar / box).
dividedbooleantrueRules between rows.
markersListMarker[]Dotted reference rules through every row + a label strip above (see Reference markers).
baselinebooleanbox / barVertical rule at the scale origin — on for BoxList (its lines float at lower; the origin is what relates rows), off for BarList (tracks already show zero).
themeChartThemedefaultThe same one styling channel the canvas charts read.

Column specs

BarList{ column, as? }: the values entry for the bar's length, plus a theme.bar[as] role (secondary pairs a second direction).

BoxList{ lower, q1?, median?, q3?, upper, value?, as?, format? }, each naming a values entry — the <BoxPlot> vocabulary: lower/upper required, q1+q3 both-or-neither (omit both for a range-only band), median optional. value adds the current-value tick; format prints its inline label. Styles resolve theme.box[as] (both built-in themes ship a secondary role).

BarList bars are length-encoded from the domain minimum and assume non-negative values — a negative value stays in-domain but draws as a short left-anchored bar, not a diverging one (diverging bar lists are out of scope; transform upstream, or use BoxList, whose marks are positional).

Because box columns name plain values entries, any stat can rank the listsortBy="now" for the current value, sortBy="p95" for tail latency; there is no stat-picking rule to remember.

Row selection

The list family is a second interaction surface, speaking the same vocabulary as the canvas — not a parallel one. selected and hovered each take one key or a set, the same union <Selector hovered> takes, so a list and a chart can be wired to the same state in both directions.

Passing a bare key still means exactly what it looks like; the plural exists because a sweep lights several marks at once.

onRowSelect — the gesture

onRowClick is one row, one click. onRowSelect is the selection gesture, and it reports plurally with the chord that was held:

<BarList
rows={rows}
columns={[{ column: 'bps' }]}
selected={sel}
onRowSelect={(rows, m) =>
setSel((cur) => {
const keys = rows.map((r) => r.key);
return m.additive ? [...new Set([...cur, ...keys])] : keys;
})
}
/>

Three gestures produce it:

  • A click — one row.
  • A drag down or up over a run of rows — the row-range gesture, the list's answer to a canvas sweep. ⌘/Ctrl-drag adds to the selection; a plain drag replaces.
  • The keyboard — see below.

modifiers.additive is the platform add chord already resolved (⌘ on macOS, Ctrl elsewhere). shiftKey is reported but carries no built-in meaning in the modifier payload — an ordinal range is a gesture here, not a modifier.

The library applies no set arithmetic. It reports what the gesture covered and renders what you hand back.

Keyboard

Interactive rows are focusable and have full parity with the pointer:

KeyDoes
/ Move focus to the previous / next row
Home / EndJump to the first / last row
Shift + ↑/↓Extend the selection from the anchor
Space / EnterSelect the focused row

Shift-extend shares its anchor with the drag gesture, so a drag followed by a Shift+↓ continues the same run rather than starting a new one.

Two deliberate details: a drag sets user-select: none only once the press is armed, so ordinary label text stays selectable when you aren't dragging; and touch is excluded from the drag gesture, because claiming it would break scrolling the list on a phone.

Broader plot-level keyboard access and screen-reader announcement are tracked as [PND-A11Y]; the list family is the surface that has it today.

Sorting semantics

Numbers order numerically, strings lexicographically; numbers rank before strings; missing / non-finite sorts last regardless of direction (a dead interface stays at the bottom whether you rank best-first or worst-first). Ties keep input order (stable).

Theming

Bars read theme.bar[as], boxes theme.box[as], and text takes the axis inks, so swapping defaultTheme for estelaTheme (or a cssVarTheme) restyles the list wholesale.

Row state has its own small register, theme.list, because a row's state channels are a band (the row's background) and a rail (the inset edge) — the glyph itself is already spending colour on data:

TokenIs
hoverBandTransient row background under the pointer
hoverRailTransient inset edge — never the selection hue
selectedBandCommitted row background
selectedRailCommitted inset edge
markerInkReference-marker ink, held clear of the selection hue

The channel rule the whole library follows applies here too: state may only use a channel the mark isn't already using for data. Hover and selected are distinguished by hue, not just intensity, so a lit row and a committed one never read as "the same thing, slightly more so".

Two details worth knowing before you theme it:

  • The glyph fills stay per-metric. A selected bar takes theme.bar[as].highlight and a dimmed one theme.bar[as].dimmed, so theming your bars gets you a coherent list without theming it twice. The rail is deliberately not per-metric — there is one rail per row and a row may carry several metrics, so it can't resolve through bar[as] the way a fill can.
  • markerInk is reserved away from the selection hue on purpose. On a bullet row the target marker sits inside the mark that selection recolours, so a tick in the selection blue is the one collision the language can't absorb: you couldn't tell a target from a selected bar.

theme.list is optional and back-compatible when omitted — rows fall back to the pre-token look (hover band from legend.border, selection rail from the annotation register) and get no dimmed state at all, which is the thing to weigh if you're carrying a custom theme forward.

See also

  • Selection & hover — the canvas half of the same vocabulary (SelectInfo, sameMark, the modifier payload).
  • Sweeps & multi-select — the canvas sweep the row-range gesture mirrors.
  • Theming — the channel rule, and the theme.list register.

Storybook: Lists/BarList and Lists/BoxList fan out every knob; Lists/Row states walks the state ladder plus the drag and keyboard gestures; Lists/Scenarios composes both full tables (traffic by interface, activity splits) from real pond pipelines.