developer guide
Sorting, filtering and grouping
Single and multi-column sort, the filter menu and condition tree, quick filter, header histograms, and grouping with totals and pivot.
Developer guide › Sorting, filtering and grouping
Sorting
Setting and reading
grid.sort.set([{ col: 'capacity', dir: 'desc' }]);
// A multi-column sort is one call, in priority order.
grid.sort.set([
{ col: 'region', dir: 'asc' },
{ col: 'capacity', dir: 'desc' },
]);
grid.sort.get(); // [{ col, dir }, …]
grid.sort.clear();
Clicking a header cycles ascending, descending, none; shift-clicking a second header adds to
the sort rather than replacing it. A column can supply its own compare, and a
type already has one: dates compare chronologically, IP addresses numerically rather than as
strings, durations by length.
Filtering
There are two filters and they are separate on purpose. The quick filter is one string matched across every readable column. The filter set is a structured condition tree.
Quick filter
grid.filters.quick('singapore');
grid.filters.quick(''); // clear
A condition tree
grid.filters.set({
op: 'and',
conditions: [
{ col: 'region', op: 'in', value: ['EMEA', 'APAC'] },
{ col: 'utilisation', op: 'gt', value: 0.9 },
{ op: 'or', conditions: [
{ col: 'slaBreached', op: 'eq', value: true },
{ col: 'margin', op: 'lt', value: 0 },
]},
],
});
That structure is a published wire protocol, not an internal detail. It is what
grid.state.get() serialises, what a saved view carries, and what you can send to
a server to evaluate the same filter against the full dataset. A remote source hands it
straight to your backend.
Operators are per family: text has contains, startsWith,
matches; numbers and dates have between, gt,
lte; multi-value columns have containsAny,
containsAll, containsNone. blank and
notBlank work everywhere.
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: { enabled: true }
// per column, layered over the grid's settings
{ field: 'price', type: 'number', facet: { strategy: 'quantile' } }
{ field: 'notes', facet: false }
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.
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.
Distributions are the only work the grid moves to a Worker. Sorting, filtering and grouping
run on the main thread; nothing waits on a histogram, which is what makes this one
offloadable. 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.
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.
Quick filter modes
One box, four ways to match: contains (the default), words,
fuzzy and regex.
grid.filters.quick('acme london', { mode: 'words' }); // every term, any column
grid.filters.quick('crc', { mode: 'fuzzy' }); // characters in order
grid.filters.quick('^CIR-[12]', { mode: 'regex' });
grid.filters.quick('acme'); // mode persists: still 'regex' here
grid.filters.quickState(); // { text, mode }
Compiled once per query, not per row. A regular expression rebuilt for each of a hundred thousand rows is a hundred thousand compiles for one keystroke. The predicate is built in the filter stage and applied to a cached text blob per row, which is why typing stays responsive at scale.
An unfinished pattern does not blank the grid. foo( is what
foo(bar) looks like halfway through typing. An invalid expression falls back to a
literal search, so the list stays sensible until the pattern is valid again.
Fuzzy does not rank. Subsequence matching decides what stays; it never reorders. Sorting results by match quality would fight the sort the user chose, and a filter that quietly re-sorts is worse than one that matches too much.
Permissions still apply. The text blob is built only from columns the viewer may see. A hidden column is not searchable, or the row count becomes a way to probe the value behind it.
Grouping, totals and pivot
Group by one or more columns
grid.columns.group(['region', 'country']);
grid.rows.expandAll();
grid.rows.collapse('EMEA');
Totals
{ field: 'capacity', type: 'number', total: 'sum' }
{ field: 'margin', type: 'number', total: 'avg' }
{ field: 'lastSeen', type: 'datetime', total: 'max' }
{ field: 'weighted', total: (values, rows) => weightedMean(values, rows) }
Totals appear on every group row and on the grand total. grandTotalRow: 'bottom'
pins the grand total below the rows instead of leaving it inline.
On a memory source the grand total is maintained incrementally: a cell
update moves the running value by the difference rather than re-reducing the column, so a
totals row costs the same on a million rows as on a thousand. This applies to
sum, avg, countValues, min and
max on numeric columns. Everything else re-reduces, and so does the
incremental path itself whenever it cannot reach the right answer:
| Case | What happens |
|---|---|
| sum, avg, countValues | Maintained by difference, with a compensated running sum so a long session does not accumulate floating-point error. |
| min, max | Maintained while values move past the extreme. A value moving off the current extreme re-reduces, because a running extreme cannot know what the next one is. |
| count | The number of rows in scope, already a single read. |
A custom total function | Re-reduced on every change. A reduction supplied as a function has no inverse, so there is nothing to apply a difference to. |
| Adding or removing rows | Re-reduced once, then incremental again. |
| Filtering, sorting or grouping | Re-reduced once, because the rows contributing to the total have changed. |
| Totals above ~1e15 | Re-reduced. Past that magnitude a small change no longer moves a 64-bit float, and a running total would silently stop tracking the data. |
| Group totals | Re-reduced per group on every change. Only the grand total is incremental. |
Nothing has to be configured for this, and the reported number is the same either way, where a running value cannot be trusted, the column falls back to a full pass rather than reporting a value it is unsure of.
showTotalInHeader
Under grouping or pivot, a totalled column's cells hold an aggregate rather than a row's own
value. On by default, the heading says which, a small SUM line above
Capacity, AVERAGE above Margin. The heading returns to
the column's own title when grouping and pivot are both off.
Leave the headings alone
showTotalInHeader: false
The reduction goes on its own line rather than reading Sum of Capacity across
one. A header cell reserves width for its sort, filter and menu buttons whether or not they
are showing, so the label gets well under half the column: on a default column, 54px of
129px. One line truncated to Sum o…, trading the column's identity for its
reduction. Stacked, it costs no width at all.
Such a heading carries data-total on its header cell, naming the reduction, and
its two lines are .lat-header-total-fn and
.lat-header-total-name. --lattice-header-total-size and
--lattice-header-total-color set the reduction line's size and colour; the full
phrase is on the label's title for the pointer. Where a pivot has a single value
column its leaf is titled with the pivot value rather than the column's name, and that
heading is left alone: it is naming the category, not the measure.
A total beside the pivoted columns
The grand total across every pivot value
pivot: { groupTotals: 'after' } // or 'before'; omitted, none
pivot: { groupTotals: 'after', totalsLabel: 'All regions' }
It answers the question the pivot took apart. Pivoting by Country turns
one Sales column into one per country, and the number a reader most often wants next is the
one that was there before: sales across all of them. 'before' puts that group at
the near edge, beside the row headings; 'after' puts it at the far edge, which is
where a spreadsheet puts a grand total.
It costs a column, not a pass. A pivoted group row still carries its
reduction over every one of its leaves, which is exactly the total across all pivot values,
so these columns read a number that has already been computed. They also count towards
maxColumns, since they are columns like any other.
Opt in. Omitted, a pivot has the columns it has always had, so the option cannot quietly change what an existing grid exports or what a saved view restores.
totalFilteredOnly
Total the dataset rather than the view
totalFilteredOnly: false
By default a total describes what is on screen: filter the grid and every total moves with
it. Setting this to false makes the filter a lens instead: totals report the
whole dataset no matter what is filtered out. Both the grand total and each group total
follow the setting, so a group row shows the total for every row belonging to that group,
not only the ones currently visible.
A group whose every row the filter removed has no row to appear on, but its rows still count toward the totals above it. Editing a hidden row moves the totals, because it is part of the dataset they describe: under the default it does not, because it is not part of the view they describe.
The count shown on the grand total row follows the total, so it never reports fewer rows than the total covers. Group row counts stay as the number of rows a user can expand to see, which is what that number is for.
The unfiltered row set and its grouping are computed once and reused, so the cost lands when rows are added or removed rather than on every edit or filter change. Reducing over the full dataset is more work than reducing over a filtered subset: on 500,000 rows grouped and half-filtered, a single-cell update measured 2.1ms by default and 3.2ms with this off.
totalOnlyChangedColumns
Skip the columns an edit did not touch
totalOnlyChangedColumns: true
Group totals re-reduce every totalled column on every change, including columns the change did not touch. Switching this on reduces only the columns whose values actually moved, and an update that rewrites a field with the value it already held reduces nothing at all, which is what a feed resending unchanged fields looks like.
On a million rows in seven groups, a single-cell update with four totalled columns:
| The update changes | Off | On |
|---|---|---|
| One of the four columns | 15.4ms | 7.3ms |
| All four columns | 15.0ms | 14.5ms |
| Nothing: same values rewritten | 15.6ms | 7.3ms |
The saving is proportional to the totalled columns an update leaves alone, so there is nothing to gain when every totalled column changes on every update.
It is off by default because it is an assertion, not just an optimisation.
Skipping a column assumes its total depends on nothing but that column's own values. That
is true of every built-in reduction. It need not be true of a total supplied as
a function, which also receives the row, the grid and config.context: such a
total is only recomputed when its own column changes, so a function that reads application
state outside the column will report the value from the last time that column moved. Leave
the option off if any of your total functions work that way.
Anything that changes which rows a total covers: adding or removing rows, filtering, sorting, grouping, or changing which columns are totalled: reduces everything again regardless of the option.
Pivot
grid.columns.group(['region']);
grid.columns.pivot(['statusId']); // a column per distinct status
Totals that a type can refuse
Some values do not add up the way plain numbers do. A data type can say which aggregates are meaningful for it, and supply its own arithmetic where the built-in one would be wrong.
The failure this prevents is a confident wrong number. You cannot add decibels: 90 dB and 90 dB make 93 dB, not 180. The mean of a column of rates is not the mean, a 100% conversion on two visits and a 1% conversion on ten thousand average to 1.02%, not 50.5%. Both mistakes produce a plausible figure rather than an error, and a footer nobody can check gets used. A missing total gets asked about; a wrong one does not.
A type declaring what it supports
{
base: 'number',
totals: {
// Anything else is refused when a column is configured, not at render.
supported: ['sum', 'avg', 'min', 'max', 'count', 'countValues'],
// And where the built-in arithmetic is wrong, replace it.
implement: { sum: (values) => logDomainSum(values) },
},
}
A type that declares no totals supports everything, so nothing that shipped
before this behaves differently. An implement function receives the values
index-aligned with their rows and a context carrying column and
valueAt(colId, i), which is how a weighted mean reaches the denominators in
another column.
The types that use it
| Type | What it does differently |
|---|---|
| decibel | Sums and averages in the linear domain and converts back. Power scale, factor 10. |
| decibelAmplitude | The same, on the field scale: factor 20, for voltage and current. |
| ratio | Averages by weight, using the column named in typeOptions.weight. Refuses sum, since two rates do not add to a rate. |
| percentRate | As ratio, displayed with a percent sign. |
A conversion rate averaged properly
{ field: 'conversion', type: 'percentRate', total: 'avg',
typeOptions: { weight: 'visits' } }
Without a weight column the average returns nothing rather than falling back to the unweighted mean: falling back would be the exact mistake the type exists to prevent, arrived at silently. Rows with no rate, or no weight, are left out rather than counted as zero.
Sticky group headings
Scrolling inside a group keeps that group's headings pinned above the rows, so the rows on screen always say which group they belong to. Nested groups stack, up to a cap.
On by default; turn it off or change the cap
createGrid(element, { stickyGroupHeaders: false }); // off
createGrid(element, { stickyGroupHeaders: 3 }); // stack up to three
This could not be position: sticky. The heading row is very
often not in the page at all: the grid renders a window of rows, and a heading five hundred
rows above the viewport was recycled long ago. So the pinned heading is synthesised from
whichever group the top visible row belongs to, which the grid answers by binary search over
an index it builds while flattening the rows, the ten-thousandth row of a group costs what
the second one does.
The cap exists because each heading costs a row of viewport. A five-level grouping without one would spend a third of the screen describing what the other two thirds contain.
The pinned headings are hidden from assistive technology. Each is a duplicate of a row that is already in the tree, and announcing it again would report a group the reader has not moved to, and add an entry to a row count that virtualisation already makes hard to reconcile.
The same answer is available directly as grid.rows.groupHeadings(index), which
returns the enclosing group rows outermost first, for a breadcrumb, or a heading elsewhere on
your page.
Pinned rows
A pinned row sits outside the scrolling body, against the header or above the status bar, and stays there while the rows scroll past it. Use one for a column-units line, a target or budget to compare against, a precomputed summary, or a note that must not scroll away.
Pinning a units row
createGrid(element, {
columns,
rows,
pinnedTopRows: [{ product: 'Units', capacity: 'MW', margin: '%' }],
});
// Or at runtime, at either edge:
grid.setPinnedRows([summaryLine], { edge: 'top' });
grid.setPinnedRows([], { edge: 'top' }); // clear
The objects are yours and are rendered through the ordinary column pipeline: value getters, formatters, cell renderers and conditional formatting all run, so a pinned row looks like the data it sits against without you rebuilding any of that.
They are not part of the data, and that separation is the point. A pinned
row is not counted by rows.count(), not sorted, not filtered, not grouped, not
selectable, not included in a total and not exported. A units row that sorted itself into the
middle of the data, or a target line that was added to the sum it is there to be compared
against, would be worse than no feature at all. If you want a row that behaves like data,
make it data.
A filter that matches nothing still leaves the pinned rows visible, which is usually what you want: an empty grid with its column-units line is readable, and an empty grid without one is not.
| Point | Behaviour |
|---|---|
| Order | Rows appear in the order of the array. At the bottom edge, the grand total comes first and your rows sit below it. |
| Height | From rowHeight, including the function form, which is called with the pinned row, so you can measure your own content. The body reserves exactly the strip's height, so no data row hides underneath it. |
| Updating | Pass a new array. Array identity is how the grid knows the rows changed; pushing into the array you passed before will not repaint. |
| Editing | A pinned row has no place in the store to write to, so it is not editable. |