Pivot
One field gathered down the page, another across it, and a measure where the two meet. The columns come from the data, so a value the data has not held before arrives as a column with no code changed.
pivot() is new: one field gathered down the page, another across it, and a measure where they meet, with the columns generated from the data. The five pages after this one take it a part at a time, from measures to a pivot the server answers.Basic Usage
rows, columns and values are all it needs. Each names column ids rather than row properties, so the columns are
declared exactly as they would be for a flat grid, and a field with no column behind it is
one the grid cannot cross.
Grouping changes how many rows there are. A pivot changes how many columns there are as well. The grid
reads the distinct values of quarter and makes a column for each. Add the fourth quarter below: two rows join the data, and a column
nobody declared appears for them.
<script lang="ts">
import { createDataGrid, DataGrid, pivot, type ColumnDef } from '@sv5ui/datagrid';
// The rows, and two more from a quarter the first list has never held.
import { sales, fourthQuarter } from './sales';
interface Sale {
id: number;
region: string;
quarter: string;
product: string;
amount: number;
}
// Every field a pivot names is a column id, so the columns are declared
// as they would be for a flat grid.
const columns: ColumnDef<Sale>[] = [
// Pinned, so the row labels stay in view when a wide crossing scrolls.
{ id: 'region', header: 'Region', pinned: 'left' },
{ id: 'quarter', header: 'Quarter' },
{ id: 'product', header: 'Product' },
{ id: 'amount', header: 'Amount', type: 'currency' }
];
const grid = createDataGrid<Sale>({
columns,
data: sales,
getRowId: (sale) => String(sale.id),
features: [
// The factory does not infer the row type, so name it.
pivot<Sale>({
rows: ['region'],
columns: ['quarter'],
values: [{ field: 'amount', agg: 'sum' }]
})
]
});
// A quarter the data has not held before arrives as a column. Hand the
// grid a new array and nothing else changes.
function addQ4() {
grid.data = [...grid.data, ...fourthQuarter];
}
</script>
<DataGrid {grid} />Every Total Is Measured From the Rows
This is the rule the rest of the feature stands on. A total is never added up from the cells beside it. It is measured again from the rows those cells were measured from.
For a sum the two agree, which is why the rule is easy to overlook. For an average they do not. North has three sales: 100 and 80 in Q1, and 150 in Q2. Its quarters average 90 and 150. Average those two cells and you get 120. Average the three sales and you get 110, and 110 is the answer, because the first figure has forgotten that Q1 stood on two rows and Q2 on one.
// North has three rows: 100 and 80 in Q1, 150 in Q2.
pivot<Sale>({
rows: ['region'],
columns: ['quarter'],
values: [{ field: 'amount', agg: 'avg' }],
totalColumn: 'All quarters'
});
// Q1 Q2 All quarters
// North 90 150 110
//
// 110 is the average of the three rows. The average of the two cells
// beside it is 120, and that number is wrong: it has forgotten that Q1
// stood on two rows and Q2 on one.The same trap sits under a total row, a subtotal, a share and a ratio, and a wrong number there looks exactly like a right one. Every one of them is built on the rows.
Totals
Three options close a crossing, and each takes the text it is headed by. totalColumn adds a column at the end, totalRow adds a row at the bottom, and columnSubtotal closes each group of a nested column axis.
A subtotal needs a group to close. The grid below puts two fields across the page, so each quarter is a group holding its products. With a single column field every generated column is already a leaf, and a subtotal would only repeat it, so none is drawn.
pivot<Sale>({
rows: ['region'],
// Two column fields nest: products inside each quarter.
columns: ['quarter', 'product'],
values: [{ field: 'amount', agg: 'sum' }],
// A column closing the crossing, headed by this text.
totalColumn: 'Total',
// A row closing the page, headed by this text in the first row field.
totalRow: 'All regions',
// A column closing each group of the column axis. It is handed the
// group's own header and answers the subtotal's.
columnSubtotal: (header) => `${header} total`
});
// With one column field there are no groups, so columnSubtotal draws
// nothing: every generated column is already a leaf.A crossing with totals is often wider than the space it is given, and the grid scrolls
sideways like any other. Region stays put while it does, because its column is declared with pinned: 'left'. Pinning the row fields is worth doing on any pivot that may grow: the generated columns
scroll, and the labels that say what each row is do not.
What Happens to the Other Columns
While a crossing is on, the grid draws the row fields and the columns it generated, and nothing else. A column taking no part has no single value on a gathered row, so it would draw empty on every one.
Those columns are hidden rather than dropped, which means they still filter, and narrowing a
field that is not on screen is most of how a pivot gets read. product is on neither axis below. Filter it and every cell is measured again from the rows that are
left.
import { filtering, getFiltering, pivot } from '@sv5ui/datagrid';
const grid = createDataGrid<Sale>({
columns,
data: sales,
getRowId,
features: [
filtering<Sale>(),
pivot<Sale>({
rows: ['region'],
columns: ['quarter'],
values: [{ field: 'amount', agg: 'sum' }],
totalColumn: 'Total'
})
]
});
// product is on neither axis, so it is not drawn. It is hidden rather
// than dropped, and a hidden column still filters.
getFiltering(grid)?.setColumnFilter('product', { kind: 'set', values: ['Gadget'] });
// The quarters come from the data before any filter, so a quarter the
// filter emptied keeps its column and draws empty cells.Choose Widget and watch Q3. Every sale in that quarter was a gadget, so the filter leaves it nothing, and the column stays where it was with empty cells. The keys of the column axis are read from the data before any filter, so the set of columns is the same whatever a reader has filtered to. A table whose columns came and went with the filter would be a different table each time.
A generated column is not itself offered for filtering, and it sorts only when you ask for that.
Turning It Off
The three arrays are where the grid starts. From then on the feature owns them, and they
change through grid.api. Taking the last field off an axis is not an error. It turns the crossing off, and the grid
draws its rows the way a flat grid would, with every hidden column back in place.
const grid = createDataGrid<Sale>({
columns,
data: sales,
getRowId,
features: [
pivot<Sale>({
rows: ['region'],
columns: ['quarter'],
values: [{ field: 'amount', agg: 'sum' }]
})
]
});
// Taking the last field off an axis is not an error. It turns the
// crossing off, and the grid draws its rows as a flat grid would.
grid.api.removePivotField?.('quarter');
grid.api.isPivoting?.(); // false
// Putting a field back turns it on again.
grid.api.addPivotColumnField?.('quarter');
// clearPivot empties both axes and leaves the measures where they were,
// so the next field you add crosses the same values.
grid.api.clearPivot?.();
// The methods are optional on grid.api because only a grid that
// registered pivot() has them. getPivot(grid) is the same thing typed:
import { getPivot } from '@sv5ui/datagrid';
const state = getPivot(grid);
state?.isCrossing;
state?.addRowField('product');clearPivot() empties both axes and leaves the measures alone. After it, one call is not enough to bring the
crossing back: a crossing needs a field on each axis, so add a row field as well. The full
list of methods is on Pivot Panel.
What It Cannot Run Beside
A pivot row is a group, not a row from the data. grouping(), tree() and masterDetail() each decide what a row is too, so the grid refuses to build with any of them registered beside a pivot. This is what it says:
pivot() cannot run beside grouping(), which gathers the rows the same way pivot does. Both decide what a row is, so only one of them may be registered. // Each of these decides what a row is, and so does a pivot.
createDataGrid<Sale>({
columns,
data: sales,
getRowId,
features: [
grouping<Sale>({ by: ['region'] }),
pivot<Sale>({ rows: ['region'], columns: ['quarter'], values })
]
});
// throws: pivot() cannot run beside grouping(), ...
// The same goes for tree() and masterDetail().
// A crossed cell is a result rather than data, so editing() leaves it
// alone: every editor refuses a generated column.Everything else composes. A rule can paint it, and flashCells() can mark it when it moves.
Options
pivot(options). The three arrays are the crossing the grid starts on; the rest shape how it reads. Each of the later pages takes a few of them in turn.
| Option | Description |
|---|---|
rows | Column ids gathered down the page, outermost first. More than one nests: the second gathers inside the first |
columns | Column ids gathered across the page, outermost first. Every distinct value becomes a column, and each further field adds a header tier |
values | What each cell of the crossing holds. More than one draws a leaf per measure |
totalColumn | Header for a column at the end holding each measure over every row of the group |
totalRow | Header for a row at the bottom holding each measure over every row the grid is showing |
columnSubtotal | Closes each group of a nested column axis with a subtotal. Receives the group header and answers the subtotal header |
columnHeader | Names a generated header, replacing the label the grid would draw |
columnKeys | States the keys one column field puts on the axis and their order, keys with no rows behind them included |
orderColumns | Orders each tier of the column axis by what a measure makes of it |
columnGroups | Whether a reader can fold a group of the column axis, and which state it starts in |
sortableColumns | Lets the reader sort the gathered rows by a generated column |
valuesOn | Which axis the measures stack on |
keepRows | Keeps only the largest or smallest few gathered rows, ranked by a measure |
maxColumns | Refuses to draw when the data would generate more leaf columns than this, rather than cutting the list |
What It Throws
All three are thrown while the grid is being built, so a misconfigured pivot never reaches the screen.
| Message | When |
|---|---|
pivot() needs at least one row field and one column field | rows or columns was an empty array when the grid was built |
pivot() needs at least one value to measure | values was an empty array when the grid was built |
pivot() cannot run beside grouping(), tree() or masterDetail() | One of the three is registered on the same grid |
What It Warns About
Each is written to the console once per grid. None of them stops the grid drawing; each says what it drew instead.
| Situation | What the grid does |
|---|---|
A field names no column | A column field, a measure field or a per names an id with no column. The grid draws its rows unchanged |
More leaf columns than maxColumns | The grid refuses the crossing and draws its rows unchanged. The message names the limit and three ways out |
A runningTotal on anything but a sum or a count | The cells of that measure are left empty rather than guessed at |
keepRows.measure is past the end of values | Nothing ranks the rows, so the ones kept are whichever the data listed first |
Sorting by a crossing while valuesOn is rows | With more than one measure, the groups are sorted by the first, since the header cannot say which was meant |
A measure reduced by a function, saved or sent to a server | Only the named reductions can be written down. A saved view leaves the measures out; a server request carries no crossing at all |
rowsBehind() on a grid that masks values | The rows it returns have not been through the mask. See Row Axis before offering a drill-down beside policy() |
A server grid asked for something only the server can answer | totalRow, valuesOn rows, orderColumns, keepRows and rowsBehind each warn once. See Server Pivot |