developer guide
Data Grid Facet Filters and Header Histograms
A header histogram shows what is actually in a column, and clicking a bar filters to it, the way a shopping facet works. The counts are recomputed against the filters already applied, so what a reader sees is what they will get.
Developer guide › Sorting, filtering and find › Data Grid Facet Filters and Header Histograms
Header histograms and facet filtering
The shape of a column, drawn in its heading, and clickable. Explore a dataset by clicking through headers instead of writing queries.
Turning them on
facets: true
// per column, layered over the grid's settings
{ field: 'price', type: 'number', facet: { strategy: 'quantile' } }
{ field: 'notes', facet: false }
The header makes room for a readable strip. Turning histograms on
gives the header a strip at least 28px tall (collapsed or expanded, or
facets.height when larger) beneath the title row; no headerHeight
is needed. An explicit headerHeight still wins: it is the whole leaf header row,
and the strip takes what the title row leaves. A grid without histograms is unchanged.
The column being filtered is not counted against its own filter. Every other active filter applies; that column's own conditions are pruned out of the tree before counting. This is the whole of faceted browsing and it is the part that is easy to get subtly wrong, a self-filtered chart collapses to a single bar the moment you click one, and there is then no way to see what you excluded or to widen the selection. Getting it wrong does not degrade the feature, it removes it.
Pruning is not symmetric across operators. An and group
narrows with each condition, so dropping one widens the result, the direction faceting
wants. An or group widens with each branch, so dropping one would show
fewer rows than the user's actual filter. There is no partial answer that is correct,
so a disjunction naming the column is dropped whole.
Bucket edges are placed once and kept. They are computed against the unfiltered column and survive every filter change until the data is replaced. Not only an optimisation: bars that resized on every click would make the chart unusable as a control, because the thing you are pointing at would move as you pointed at it.
Each bar carries two readings. Its full height is the bucket's share of the unfiltered column; the solid fill inside is how much survives the current filters. Either alone misleads: scaling to the filtered maximum draws a full-height chart out of three surviving rows, and scaling everything down together flattens the whole chart into a few pixels the moment anyone filters anything.
The filters are ordinary filters. They go through the same
filters.set as everything else, so they undo, serialise into saved views, and
appear in whatever filter UI already exists. Nothing downstream can tell a filter made by
clicking a bar from one typed into the filter panel. A drag emits a between
range rather than a set of bucket indices, so it still means the same thing after the data is
replaced and the edges move.
Selection is derived, never stored. Which buckets look selected is read back out of the filter tree. Remove the filter through the filter panel, an undo or a saved view and the chart is correct without anything having to tell it.
High-cardinality columns are refused, and it costs nothing to know. Text
columns are dictionary-encoded in the store, so the distinct count is a property read rather
than a scan. The first column anyone points this at is a name or an id, and one hairline per
customer looks like a rendering fault rather than a distribution. Above
cardinalityLimit the chart is suppressed, or shows a top-N with an aggregated
remainder if you ask for aboveLimit: 'topN': aggregated rather than
truncated, because silently dropping the tail would misrepresent the bars it did draw.
Nulls are never dropped. They land in a terminal bucket, always last, and
the counts always sum to the row count. A column where nine thousand of ten thousand rows are
empty is a fact about the data, and a chart that quietly showed the thousand would be lying
about the shape. NaN joins them rather than forming its own bucket: it is the
same answer to the same question.
A dataset above rowCeiling is refused before anything is counted.
Cardinality is a property of the values; row count is a property of the set, and a very large
set can suppress every histogram regardless of how few distinct values each column holds - cheaply, because the check runs before a single row is touched.
Live streams suppress the charts. Constantly shifting distributions are
unreadable, recounting on every batch is wasteful, and a filter control whose buckets move
under the pointer is actively hostile. Filters already made stay applied, because they are
ordinary filters. Pausing the stream brings the charts back, a paused stream is a still
one: unless you set whilePaused: false.
Counting runs off the main thread above workerThreshold.
A distribution was the first work the grid moved to a Worker: nothing waits on a histogram,
which is what made it offloadable without an async pipeline. A portable sort - a built-in
collation with no custom comparator - now also recomputes off-thread above the threshold,
serving the prior order until the new one lands. Filtering and grouping still run on the main
thread. The column is copied, or shared where cross-origin isolation makes
SharedArrayBuffer available; it is never transferred, because transferring would
detach the buffer the grid is still rendering from.
The Worker settings. useWorker and
workerThreshold decide whether and when a distribution is offloaded. Two more
control how the Worker is built, and both are settled when it is constructed: changing
either discards the running Worker so the next offload builds a new one.
| Setting | What it does |
|---|---|
workerUrl | Loads the Worker from a URL you host instead of a blob:. Required under a Content-Security-Policy that forbids blob: workers: without it the Worker cannot be constructed at all on such a page, and compute stays on the main thread. |
sharedMemory | Off by default. Passes columns to the Worker in a SharedArrayBuffer rather than copying them on every message, at the cost of retaining a shared copy of each column that crosses. Needs the page to be cross-origin isolated; where it is not, it falls back to copying and says so once. |
grid.diagnostics.renders().worker reports what the Worker host is actually
doing: whether one was spawned, how many calls ran locally versus remotely, and the
threshold, sharedMemory and workerUrl it was built with.
A DuckDB source needs no provider. duckdbAdapter
answers the grid's histogram requests itself, as bound SQL over the whole table or file:
equal-width bins for a numeric column (the same edges the in-memory grid places, including
the trimmed range and overflow bins for a long tail, and strategy: 'quantile'
or 'log'), the cardinalityLimit commonest values for a text column
(with an Other remainder under aboveLimit: 'topN'), false and true
for a boolean, and a last Empty bar for blanks (blanks: false leaves it
out). A DATE or TIMESTAMP column draws calendar buckets, at the
granularity you set or one picked from the span, exactly as an in-memory date
column does. Every other column's filter and the
quick search apply; the column's own filter is left out, and the bars follow a filter
change. A
facets.provider you give the grid still wins, and a source whose adapter
answers nothing is unchanged.
Histograms over a Parquet file, no provider
const grid = createGrid(el, {
rowKey: 'id',
columns: [{ field: 'id', type: 'number' }, { field: 'amount', type: 'number' }, { field: 'city' }],
facets: { enabled: true, buckets: 12, cardinalityLimit: 20, aboveLimit: 'topN' },
source: createPushdownSource({
adapter: duckdbAdapter({ connection, from: "read_parquet('sales.parquet')" }),
pageSize: 100,
}),
});
Leaving the Empty bar out, and choosing the date unit. These two
settings go on the grid's facets for every column, or on a column's
facet for that column alone, and apply in memory and over a pushdown source.
| Setting | What it does |
|---|---|
blanks | true by default: rows with no value land in a last Empty bar. blanks: false leaves the bar out, so a column that is mostly empty (a weather station that reports a temperature one day in three) still shows the shape of the values it has. The counts then sum to the rows that have a value. |
granularity | The calendar unit of a date or timestamp column's buckets: 'hour', 'day', 'week', 'month', 'quarter' or 'year'. Picked from the span and buckets when unset. |
Dates by month, without the Empty bar, executed
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const rows = [
{ id: 1, day: new Date(2025, 0, 5), temp: 4 },
{ id: 2, day: new Date(2025, 0, 20), temp: null },
{ id: 3, day: new Date(2025, 1, 2), temp: 9 },
{ id: 4, day: new Date(2025, 2, 9), temp: null },
];
const columns = [{ field: 'id', type: 'number' }, { field: 'day', type: 'date' }, { field: 'temp', type: 'number' }];
const make = (facets) => createHeadlessGrid({ rowKey: 'id', rows, columns, facets });
const grid = make({ enabled: true, granularity: 'month', blanks: false, buckets: 2 });
const day = grid.facets.get('day');
const temp = grid.facets.get('temp');
const plain = make({ enabled: true, buckets: 2 }).facets.get('temp');
return `${day.bounds.kind} ${day.bounds.granularity} ${day.bounds.buckets.length} | no empty ${temp.bounds.buckets.length} | with empty ${plain.bounds.buckets.length}`;
A histogram never goes in front of the rows, and never goes quiet. Over a
pushdown source the histogram queries run one at a time and only after the visible window's
page and count have been answered, so the first rows paint before any histogram query is
sent and a scroll never waits behind more than one of them. If a histogram query fails the
column's band says Histogram unavailable (its data-suppressed is
unavailable), facet:failed fires, and the
facets.unavailable warning is logged once.
An adapter that answers facet requests, executed
const { createPushdownSource } = await import('../packages/core/src/source/pushdown.js');
const adapter = {
name: 'demo',
capabilities: { filter: 'tree', operators: ['equals'], range: true, total: true },
async execute() { return { rows: [], total: 0 }; },
async facets(request) { return { bounds: { kind: 'category', buckets: [{ value: 3 }, { value: 5 }] }, counts: [2, 1] }; },
};
const source = createPushdownSource({ adapter, pageSize: 10 });
const answer = await source.facetProvider({ colId: 'size', filters: null });
return (`${typeof source.facetProvider} | ${answer.bounds.buckets.map((b, i) => `${b.value}:${answer.counts[i]}`).join(',')}`);
Other server-side sources need a provider, and its absence is silent.
A grid holding one page of data cannot compute a distribution over the whole set. Supply a
function and it receives the column, the pruned filter state and the bucketing settings, and
returns counts. Without one the charts are simply absent, no error, because most
deployments will never supply one. Be clear-eyed about the load: results are cached against
the filter state, but this is one query per column per filter change, and a grid with eight
faceted columns asks eight questions every time a filter moves.
Keyboard and screen reader. Focus enters the chart from the header and arrows move between buckets, so a column costs one tab stop rather than twenty. Enter toggles, Shift with arrows extends a range on ordered columns, Escape clears. Selected buckets carry an outline as well as a colour. Beyond per-bucket labels the chart carries a sentence describing the distribution's shape, because twenty bucket readings do not add up to "most of the mass is at the low end", and that shape is the entire value of the chart.