Lattice Grid Buy a licence

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.

SettingWhat it does
workerUrlLoads 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.
sharedMemoryOff 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:

CaseWhat happens
sum, avg, countValuesMaintained by difference, with a compensated running sum so a long session does not accumulate floating-point error.
min, maxMaintained 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.
countThe number of rows in scope, already a single read.
A custom total functionRe-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 rowsRe-reduced once, then incremental again.
Filtering, sorting or groupingRe-reduced once, because the rows contributing to the total have changed.
Totals above ~1e15Re-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 totalsRe-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 changesOffOn
One of the four columns15.4ms7.3ms
All four columns15.0ms14.5ms
Nothing: same values rewritten15.6ms7.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

TypeWhat it does differently
decibelSums and averages in the linear domain and converts back. Power scale, factor 10.
decibelAmplitudeThe same, on the field scale: factor 20, for voltage and current.
ratioAverages by weight, using the column named in typeOptions.weight. Refuses sum, since two rates do not add to a rate.
percentRateAs 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.

PointBehaviour
OrderRows appear in the order of the array. At the bottom edge, the grand total comes first and your rows sit below it.
HeightFrom 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.
UpdatingPass a new array. Array identity is how the grid knows the rows changed; pushing into the array you passed before will not repaint.
EditingA pinned row has no place in the store to write to, so it is not editable.