Grouping
Group by any number of columns and aggregate with thirteen functions. Group footers close each group, a grand total closes the set, and a number can be read as a share of any of them.
Basic Usage
by lists
column ids, outermost first. Grouping collects the rows that share a key wherever they are,
so the grid does not have to be sorted by that column first.
import { createDataGrid, grouping, virtualization } from '@sv5ui/datagrid';
const grid = createDataGrid<Person>({
columns,
data: people,
getRowId,
features: [
grouping<Person>({ by: ['team'], aggregations: { salary: 'sum' } }),
virtualization<Person>({ rowHeight: 40, overscan: 8 })
]
});
// The button above switches this grid between one level and two:
getGrouping(grid)!.setGroupBy(['team']);
getGrouping(grid)!.setGroupBy(['team', 'role']);
// A factory held in a variable has nothing to infer TRow from, so spell it
// out. Inside the features array above, the array's own type supplies it.
const group = grouping<Person>({ by: ['team', 'role'] });Aggregations
Aggregations are keyed by column id, because a feature cannot add a field to ColumnDef. Every group is collapsed below, so the grid is showing nothing but aggregates: a
headcount, a sum, a median, a 90th percentile, a weighted average and a distinct count.
// Keyed by column id, because a feature cannot add a field to ColumnDef.
// This is what the grid above runs: a second view of salary needs a column
// of its own, because a column id is the namespace an aggregate is written to.
grouping<Person>({
by: ['team'],
expandedByDefault: false,
aggregations: {
name: 'count',
salary: 'sum',
salaryMedian: 'median',
salaryP90: { kind: 'percentile', p: 0.9 },
country: 'distinctCount',
// Weighted by salary, so a better-paid rating counts for more.
rating: { kind: 'weightedAvg', weight: (row) => row.salary }
}
});
// The thirteen in full:
grouping<Person>({
by: ['team'],
aggregations: {
salary: 'sum',
salaryMin: 'min',
salaryMax: 'max',
salaryAvg: 'avg',
salaryMedian: 'median',
name: 'count', // rows, not values
country: 'distinctCount',
firstJoiner: 'first',
lastJoiner: 'last',
salaryP90: { kind: 'percentile', p: 0.9 }, // a fraction, not 90
rating: { kind: 'weightedAvg', weight: (row) => row.salary },
activeCount: (values, rows) => rows.filter((row) => row.active).length
}
});
// percentile interpolates the way a spreadsheet does: 0.9 over 1, 2, 3, 4
// is 3.7. Every numeric aggregator returns null when nothing numeric is left.The Thirteen
Anything that is not a number is ignored rather than counted as zero, which is why a column of mixed content still aggregates.
| Aggregation | What it does |
|---|---|
sum / min / max / avg / median | Read the numeric values and ignore anything that is not a number. All five return null when nothing numeric is left |
count | Counts the group's rows, not its values, so a column of blanks still reports the headcount |
distinctCount | Counts distinct non-blank values |
first / last | The first and last non-blank value in the group's own order |
{ kind: 'percentile', p } | p is a fraction between 0 and 1, so 0.9 is the 90th. Interpolates between the two neighbouring values the way a spreadsheet does, so 0.9 over 1, 2, 3, 4 is 3.7 |
{ kind: 'weightedAvg', weight } | sum(value * weight) / sum(weight), skipping a pair where either side is not numeric, and null when the weights total zero |
(values, rows) => unknown | A reducer of your own, given both the values and the rows behind them |
Footers and the Grand Total
groupFooters closes every open group with its own totals row, and grandTotal closes the set with one more. The grand total works with grouping off as well, and it follows
the filter rather than the data: filter the grid below and watch it move.
The status bar counts 24 rows, because it counts data. The pipeline
is holding 35 nodes: those 24 plus
5 group headers, 5 footers and 1
grand total. Counted live with isDataRow and totalsKindOf,
which is the distinction to keep in mind whenever you walk preWindowNodes yourself.
// Exactly what the grid above runs.
grouping<Person>({
by: ['team'],
aggregations: { salary: 'sum' },
groupFooters: true, // close every open group with its own totals row
grandTotal: true, // and the whole set with one at the end
// All three labels take the group's key and its count, so a language can
// put them in its own order.
groupLabel: (key, count) => `${key} (${count})`,
footerLabel: (key) => `${key} subtotal`,
grandTotalLabel: (count) => `All ${count} people`
});
// The grand total works with grouping off as well, and reflects the filter:
// it aggregates the leaf rows that survived, not the whole data array.
// It is in-flow, so it scrolls with the rows rather than sticking.The Group Panel
A strip above the grid holding one chip per grouped column, in nesting order. Drop a column on it, reorder the chips, or take one off. It writes the same state the column menu does, so the two can never disagree.
<script lang="ts">
import { DataGrid, Grid } from '@sv5ui/datagrid';
</script>
<!-- Drop a column on it, reorder the chips, or take one off. It writes the
same state the column menu does, so the two never disagree. -->
<Grid.GroupPanel {grid} placeholder="Drop a column here to group by it" />
<DataGrid {grid} />The Tool Panel
The fuller surface: three tabs over one grid. Groups is what the panel above shows, Values sets an aggregation per column, and Columns is the chooser. Everything it writes lands in the same state, so the grid follows immediately.
<!-- Three tabs over one grid: Groups, Values and Columns. -->
<div class="flex flex-col gap-3 lg:flex-row">
<DataGrid {grid} class="grow" />
<Grid.ToolPanel {grid} bind:collapsed aggregations={['sum', 'avg', 'count']} />
</div>
<!-- aggregations limits what the Values tab offers, from the thirteen down to
the ones worth choosing on this grid. tabs limits the panel itself, in the
order given, so a grid that only needs a column chooser says so. -->
<Grid.ToolPanel {grid} tabs={['columns']} />
<Grid.ToolPanel {grid} tabs={['group', 'values']} />
<!-- The panel is w-full below the lg breakpoint and takes its own 18rem
column from lg up, so the layout above is one line rather than two
branches. Give it lg:w-full to keep it full width in a narrow container:
the breakpoint reads the viewport, not the space the panel is actually
standing in. -->
<Grid.ToolPanel {grid} class="lg:w-full" />
<!-- collapsed folds it to a bar, so a reader who wants the width back does
not lose the panel to get it. -->
<Grid.ToolPanel {grid} bind:collapsed />It is drawn full width here because this column is narrow. Given the room, from the lg breakpoint up it takes its own 18rem column beside the grid instead, and collapsed folds it to a bar so the grid keeps the width.
Show Values As
showValuesAs() reads a number as a share of the grand total, of the row above it in the hierarchy, or of
named columns in the same row. It replaces the value rather than decorating it, so sorting, copying
and exporting all agree with what is on screen.
0.34, not 34. Declare the column type: 'percent' and the grid formats it as 34% everywhere. Without that the cell reads 0.34, which looks
like a bug and is not. import { grouping, showValuesAs } from '@sv5ui/datagrid';
features: [
// A shown column needs an aggregation of its own. percentOfParent divides
// by the same column on the group row, so without one there is no parent
// total to divide by and every share in that column reads blank.
grouping<Person>({
by: ['team'],
aggregations: { salary: 'sum', salaryShare: 'sum', salaryOfTeam: 'sum' }
}),
showValuesAs({
columns: {
// Of the column's total over every row the filter left.
salaryShare: 'percentOfGrandTotal',
// Of the same column on the row above in the hierarchy: the group a
// row sits in, or the parent row on a tree. At the top level it falls
// back to the grand total.
salaryOfTeam: 'percentOfParent',
// Of the named columns in the same row, which is how a
// month-per-column table reads across.
q1Share: { kind: 'percentOfRow', of: ['q1', 'q2', 'q3', 'q4'] }
}
})
]
// The share is a fraction, so the column has to be a percent one or the
// cell reads 0.34 rather than 34%.
const columns: ColumnDef<Person>[] = [
{ id: 'salary', header: 'Salary', type: 'currency', align: 'right' },
{ id: 'salaryShare', header: 'Of total', type: 'percent', align: 'right' },
{ id: 'salaryOfTeam', header: 'Of team', type: 'percent', align: 'right' }
];
const people = makePeople(60).map((person) => ({
...person,
salaryShare: person.salary,
salaryOfTeam: person.salary
}));Read down Of team inside one group and the shares total 100%; read down Of total and the whole grid does. On a group row percentOfParent has nothing above it, so it falls back to the grand total and the two columns agree.
A column id is a namespace, so showing one field several ways means several columns, each
with an id of its own: two columns answering to salary could not both be addressed. The share columns above carry their own field on the row, which
is what showValuesAs writes over.
The Same Numbers Outside the Grid
aggregate is the function the footers use, exported so a summary card or a chart can show the same figure
without a second implementation to keep in step.
| Call | Over all 60 rows |
|---|---|
aggregate('sum', salaries, people) | $5,821,693 |
aggregate('median', salaries, people) | $96,882 |
aggregate({ kind: 'percentile', p: 0.9 }, ...) | $130,775 |
aggregate('distinctCount', teams, people) | 5 |
import { aggregate, isDataRow, totalsKindOf } from '@sv5ui/datagrid';
// The number a group footer would show, computed somewhere else: a summary
// card, a chart, a second table. Same function the footers use, so the two
// cannot drift.
const values = people.map((person) => person.salary);
aggregate('median', values, people);
aggregate({ kind: 'percentile', p: 0.9 }, values, people);
aggregate({ kind: 'weightedAvg', weight: (person) => person.tenure }, ratings, people);
// Walking the rows the grid is about to draw, a grouped grid holds more than
// data rows: group headers, group footers, the grand total, detail panels and
// loading rows are all nodes.
for (const node of grid.preWindowNodes) {
if (isDataRow(node)) usePerson(node.row);
}
// Or ask what a non-data row is:
totalsKindOf(node.id); // 'footer' | 'grandTotal' | nullOptions
Everything grouping() accepts.
| Option | Description |
|---|---|
by | Column ids, outermost first |
aggregations | Keyed by column id, because a feature cannot add a field to ColumnDef |
expandedByDefault | Expand every group the first time it appears |
groupFooters | Close every open group with a totals row, Excel-style |
grandTotal | Append one totals row for the whole set. Works with grouping off, and follows the filter |
groupLabel | The label on a group row |
footerLabel | The label on a group footer. Unlike groupLabel, the default comes from the label table, so it follows the grid language |
grandTotalLabel | The label on the grand total. From the label table as well |
Grouping the Server Does
Everything above groups rows the browser is holding. Over 5,000 people that is still reasonable; over a million it is not, and the grouping has to happen where the rows live. The grid below starts with five rows, one per team, and each was counted and totalled by the server.
Open one and the children are fetched then, with the team as groupKeys; close it and they are dropped again. The count in the People column is the reason to do
it this way: 1,012 people are behind a row that costs one line, and no browser had to see
them to say so.
No data
The last few requests
- Nothing yet. Open a team.
// Grouping the server does. The grid renders one level and asks for the
// next when a row is opened, so a million rows never reach the browser.
serverRowModel<ServerRow>(source, {
mode: 'infinite',
blockSize: 100,
placeholder,
groupBy: ['team'],
// What makes a row a group row for the renderer. The chevron, the indent
// and the treegrid ARIA all come from the meta rather than from the row:
// declaring a hierarchy is what turns the container into a treegrid.
getRowMeta: (row) => (isGroupRow(row) ? { level: 0, expandable: true } : { level: 1 }),
// Opening one asks the server for its children rather than assuming they
// were loaded. The keys are outermost first.
groupKeysOf: (node) => [node.row.key],
// And closing it drops them again, which needs to know which loaded rows
// belonged to the group.
isChildOf: (node) => (row) => !isGroupRow(row) && row.team === node.row.key
});
// The server half. One handler answers both questions, because which one it
// was asked is readable from the request:
export const POST: RequestHandler = async ({ request }) => {
const { groupBy = [], groupKeys = [], startRow, endRow, filter, sort } = await request.json();
const matched = applyQuery(people, filter, sort);
// Fewer keys than levels means a group row was opened: answer with the
// next level down, counted and totalled over everything behind it.
if (groupBy.length > 0 && groupKeys.length < groupBy.length) {
const level = groupLevel(withinGroup(matched, groupBy, groupKeys), groupBy[groupKeys.length]);
return json({ rows: level.slice(startRow, endRow), rowCount: level.length });
}
const leaves = withinGroup(matched, groupBy, groupKeys);
return json({ rows: leaves.slice(startRow, endRow), rowCount: leaves.length });
};
// A count and a total are the reason to group on the server at all: the grid
// holds a hundred rows and cannot work out what 1,012 of them add up to.Where Grouping Stops
Two things build on groups without being part of grouping(). On a long list the row that names a group scrolls out of sight; Sticky Groups keeps it pinned under the header.
And when the question has two sides, which rows by which columns, pivot() gathers one field down the page and another across it, and generates its columns from the data. It takes the place of grouping rather than joining it. Both decide what a row is, so a grid cannot register the two together.
Driving It From Code
import { getGrouping } from '@sv5ui/datagrid';
const group = getGrouping(grid)!;
group.setGroupBy(['team', 'role']); // replace the whole nesting
group.groupBy('country'); // append one level
group.ungroup('role');
group.moveGroup('country', -1); // shift it one level outwards
group.clearGrouping();
group.expandAllGroups();
group.collapseAllGroups();
group.setAggregation('salary', 'sum');
group.setAggregation('salary', null); // and back off again
group.isGrouped; // boolean
group.isGroupedBy('team'); // boolean
group.by; // the column ids, outermost first
group.groupableColumns(); // [{ id, header }] for building your own UI
// The same calls are on grid.api, each one optional, because the grid that
// has them is the one that registered grouping():
grid.api.groupBy?.('team');
grid.api.setAggregation?.('salary', 'sum');