Pivot

Pivot Panel

The options are where a crossing starts. After that a reader can rebuild it from a panel, from the column menu, or your code can through the api, and all three write the same state.

The Panel

Grid.PivotPanel draws the four things a pivot is described by: the fields running down, the fields running across, the measures, and how the result reads. It holds no state of its own. It reads the grid and writes back through the same methods your code would call, so the two cannot drift apart.

Down Fields gathered downward Region Product
Across Fields gathered across it Quarter
Values What is measured where two axes meet Amount
How it reads Totals, folding, sorting, and how many rows to keep
Region
Product
Q1
Q2
Q3
4 crossings from 12 rows
<script lang="ts">
  import { columnOps, createDataGrid, DataGrid, Grid, pivot } from '@sv5ui/datagrid';

  const grid = createDataGrid<Sale>({
    columns,
    data: sales,
    getRowId,
    features: [
      // For the fold control the panel's "Fold groups" setting turns on.
      columnOps<Sale>(),
      pivot<Sale>({
        rows: ['region', 'product'],
        columns: ['quarter'],
        values: [{ field: 'amount', agg: 'sum' }]
      })
    ]
  });
</script>

<!-- The panel takes the grid as a prop, so it can sit anywhere on the
     page. Inside Grid.Root it reads the grid from context instead. -->
<Grid.PivotPanel {grid} />
<DataGrid {grid} />

The first three zones are ordered lists, because the order in them is the order of the tiers. Each chip has a button to move it either way and one to take it off, and each zone has its own button to add a field. There is no drag and drop, so everything is reachable from the keyboard and each zone is a labelled group to a screen reader. A measure's chip also carries its reduction and its reading, both named through the label table, so a translated grid translates them too.

The fourth zone appears once a crossing is on. It holds the total column, the total row, the subtotals, whether groups fold, whether a crossing can be sorted by, whether the measures run down the page, and how many rows to keep. The button that opens every tier is shown only while the row axis has more than one field, since a single field has nothing to open.

It is a starting point rather than the whole surface. The panel keeps the 5, 10 or 20 largest rows and offers no smallest; a measure added from it is a sum until its chip says otherwise; and per, header, orderColumns and maxColumns are set from code. It takes grid and class, draws nothing on a grid without pivot(), and is styled through the group panel slots.

The Column Menu

The column menu offers the two axes on any column that is on neither, and a way off for one that is. While a crossing is on, only the row fields are still drawn, so the menu is mostly how a pivot gets started rather than how it gets widened. The grid below opens flat. Open the menu on Region and pivot down, then on Quarter and pivot across.

flat rows: [] · columns: []
Region
Quarter
Product
Channel
Amount
Profit
Units
North
Q1
Widget
Web
$100
$30
10
North
Q1
Gadget
Store
$80
$12
4
North
Q2
Widget
Web
$150
$45
15
South
Q1
Widget
Store
$200
$50
20
South
Q2
Gadget
Web
$60
$9
3
South
Q2
Widget
Web
$40
$10
4
South
Q3
Gadget
Store
$120
$18
6
East
Q1
Gadget
Web
$80
$16
4
East
Q2
Widget
Store
$120
$30
12
East
Q3
Gadget
Web
$100
$15
5
West
Q1
Widget
Web
$60
$18
6
West
Q3
Gadget
Store
$140
$28
7
12 rows
import { columnOps, pivot } from '@sv5ui/datagrid';

const grid = createDataGrid<Sale>({
  columns,
  data: sales,
  getRowId,
  features: [
    // The column menu is drawn by columnOps(). Without it the three
    // entries below have nowhere to appear.
    columnOps<Sale>(),
    pivot<Sale>({
      rows: ['region'],
      columns: ['quarter'],
      values: [{ field: 'amount', agg: 'sum' }]
    })
  ]
});

// A pivot has to be built with a field on each axis, so a grid that
// should open flat and let the reader start the crossing clears it first.
// The measures stay, ready for the fields the reader picks.
grid.api.clearPivot?.();

// On a column that is on neither axis, the menu offers
//   Pivot down this column     -> addPivotRowField(columnId)
//   Pivot across this column   -> addPivotColumnField(columnId)
// and on one that is,
//   Remove from pivot          -> removePivotField(columnId)

The menu is drawn by columnOps(), so that feature has to be registered. A pivot cannot be built with an empty axis, which is why this grid is built with one and cleared straight after.

Driving It From Code

Everything the panel does is on grid.api, twenty eight methods in four groups. All of them are optional there, since the grid that has them is the one that registered the feature.

// Every method is optional, because only a grid that registered pivot()
// has them. That is what the ?. is for.
grid.api.setPivotRows?.(['region', 'product']);
grid.api.setPivotColumns?.(['quarter']);
grid.api.setPivotValues?.([
  { field: 'amount', agg: 'sum', header: 'Amount' },
  { field: 'units', agg: 'sum', header: 'Units' }
]);

// A field lives on one axis. Adding it to the other moves it.
grid.api.addPivotColumnField?.('product'); // product leaves the rows

grid.api.setPivotTotalColumn?.('Total');
grid.api.setPivotTotalRow?.(undefined); // off

grid.api.isPivoting?.(); // what a toolbar reads to enable its buttons
grid.api.pivotRows?.();  // a copy, so changing it changes nothing

Building and Clearing

MethodWhat it does
setPivotRows(fields)Replaces the row axis
setPivotColumns(fields)Replaces the column axis
setPivotValues(measures)Replaces the measures
addPivotRowField(field)Adds a field at the inner end of the row axis, taking it off the column axis
addPivotColumnField(field)Adds a field at the inner end of the column axis, taking it off the row axis
removePivotField(field)Takes a field off whichever axis holds it
clearPivot()Empties both axes. The measures stay

Reading Back

MethodAnswers
isPivoting()Whether a crossing is on
pivotRows() / pivotColumns() / pivotValues()A copy of each
pivotValuesOn()'columns' or 'rows'
pivotKeepRows() / pivotOrderColumns()The setting as PivotKeepRows or PivotOrderColumns, or undefined
pivotMaxColumns()The ceiling on leaf columns
pivotRowsOpen()Whether any gathered row is open

Settings

MethodTakes
setPivotTotalColumn(header) / setPivotTotalRow(header)Turns a total on under that header, or off with undefined
setPivotColumnSubtotal(label)A function from a group header to its subtotal header, or undefined
setPivotColumnGroups(mode)'fixed', 'open' or 'closed'
setPivotSortableColumns(sortable)Whether a crossing can be sorted by
setPivotValuesOn(where)'columns' or 'rows'
setPivotKeepRows(keep) / setPivotOrderColumns(order)The object the option takes, or undefined
setPivotMaxColumns(limit)Moves the ceiling without rebuilding the grid

The Row Axis

Described on Row Axis.

MethodWhat it does
expandAllPivotRows() / collapseAllPivotRows()Opens or shuts every tier of the row axis
pivotRowsBehind(node, columnId)The rows a cell was measured from

The Typed Way In

The api names its settings by their types, with one exception: a measure goes in and comes back as unknown, because a measure can carry a function over your row type and grid.api does not know that type. getPivot(grid) returns the state itself, with the row type kept and nothing optional, and it carries the measure editors the panel uses.

import { getPivot } from '@sv5ui/datagrid';

// grid.api types its settings, but a measure crosses it as unknown:
// setPivotValues takes unknown[] and pivotValues returns it. getPivot(grid)
// is the same state with the row type kept and nothing optional.
const state = getPivot(grid);
if (!state) throw new Error('pivot() is not registered');

state.rows;        // string[]
state.values;      // PivotMeasure<Sale>[]
state.keepRows;    // { top?, bottom?, measure?, others? } | undefined
state.isCrossing;  // a field on each axis and at least one measure

// The measure editors the panel uses, which grid.api does not carry:
state.addValue('units');                    // reduced with 'sum'
state.setValueAgg(1, 'avg');
state.setValueShowAs(0, 'percentOfRow');
state.removeValue(1);
state.moveRowField('product', -1);          // one step outward
state.moveColumnField('quarter', 1);        // one step inward

// The columns not yet on an axis, as { id, header }:
state.pivotableColumns();

Pivot

What getPivot(grid) returns. Exported as a type only.

MemberWhat it is
rows / columns / valuesThe crossing as it stands now
totalColumn / totalRow / columnSubtotal / columnGroups / sortableColumns / valuesOn / keepRows / orderColumns / maxColumnsEach setting, readable and reactive
isCrossingA field on each axis and at least one measure
isDrawnWhether a generated column is on screen. False while the grid is refusing a configured crossing, past maxColumns for one
rowsOpenWhether any gathered row is open
serverHandledOn a server grid, whether a serverRowModel() is there to carry the crossing
addValue(field, agg?) / removeValue(at) / setValueAgg(at, agg) / setValueShowAs(at, showAs)Edit one measure, by its place in values
moveRowField(field, offset) / moveColumnField(field, offset)Move a field along its axis. Negative is outward
pivotableColumns()The columns on neither axis, as id and header
crossingMeasure(columnId)Which measure a generated column holds, undefined for a total or a subtotal
rowsBehind(node, columnId)The typed form of pivotRowsBehind

In a Saved View

A crossing goes into the grid's snapshot with the rest of its state, so saved views and state persistence carry it without being told to. Save below, rearrange the crossing, and apply what you saved.

crossing
Region
Q1
Q2
Q3
Total
North
$180
$150
-
$330
South
$200
$100
$120
$420
East
$80
$120
$100
$300
West
$60
-
$140
$200
4 crossings from 12 rows
Nothing saved yet.
const grid = createDataGrid<Sale>({
  columns,
  data: sales,
  getRowId,
  features: [
    pivot<Sale>({
      rows: ['region'],
      columns: ['quarter'],
      values: [{ field: 'amount', agg: 'sum' }],
      totalColumn: 'Total'
    })
  ]
});

// A crossing rides in the grid's snapshot like any other feature state.
const snapshot = grid.api.getState();

snapshot.features?.pivot;
// {
//   rows: ['region'],
//   columns: ['quarter'],
//   columnGroups: 'fixed',
//   columnSubtotal: false,
//   sortableColumns: false,
//   valuesOn: 'columns',
//   totalColumn: 'Total',
//   values: [{ field: 'amount', agg: 'sum' }]
// }

grid.api.setState(snapshot);

// The slice is there while the crossing is off too, with both axes empty,
// which is how a view saved from a flat grid turns a crossing off.

// Not written: maxColumns, columnHeader, columnKeys, and which rows were
// open. Those belong to the page rather than to a reader's arrangement.

The slice is written whether or not a crossing is on, so a view carries the off state too. Try it: turn the crossing off, save, rearrange, and apply. The grid goes back to flat, because the view says both axes were empty.

What Is Written

SettingWritten
rows / columnsAlways, empty while the crossing is off. A field naming no column is dropped on the way back
columnGroups / sortableColumns / valuesOnAlways
columnSubtotalAlways, as true or false, since a function cannot be written down. Coming back as true onto a grid that declared none, the label is taken from the locale
totalColumn / totalRowWhen on. A view saved without one turns it off when it is applied
keepRows / orderColumnsWhen set
valuesWhen every measure uses a named reduction. One function or parameterised reduction and the measures are left out, with a warning, and the grid keeps the ones it was built with
maxColumns / columnHeader / columnKeysNever. They are read from the options each time
Which rows were openNever