Grid Pivot

Smart Grid Pivot New

Pivot turns the Grid into a cross-tab. Flat records go in, and the Grid renders them aggregated — a row hierarchy down the side, a column hierarchy across the top, one or more measures at the intersections, with subtotals and grand totals. It is driven by the pivot property bag and switched on with grid.view = 'pivot'.

The important part is that pivot is a native view, like card — the Grid keeps rendering the result itself rather than handing the area to another component. Everything the Grid already does keeps working on the aggregated table: frozen columns, cell-range selection, clipboard, conditional formatting, column resize and reorder, and xlsx export. Row groups are ordinary tree rows, so the expanders come for free, and the column axis becomes collapsible banded headers.

Live Demo

Two financial years of software bookings — 4,224 records across eleven territories, four product lines, four sales channels and three customer segments — with the designer docked beside the Grid. Drag a field between the Rows, Columns and Values wells to re-pivot, right-click a field for the same moves without dragging, or use the Filters tab to narrow the bookings. Switch Pivot Mode off to see the same records one row each.

Naming: the samples below use the distribution names — smart-grid, smart-pivot-panel and Smart.Utilities.PivotEngine. The demo in the frame is built from the internal sources, where the same elements carry the smart- prefix (smart-grid, smart-pivot-panel, Smart.Utilities.PivotEngine). The API is identical; only the prefix differs.

Quick Start

Bind the Grid to your flat records, describe the pivot, and switch the view.

<smart-grid id="grid"></smart-grid>

<script type="module" src="source/modules/smart.grid.js"></script>
const grid = document.querySelector('#grid');

// The facts: one record per booking, no aggregation of your own.
grid.dataSource = bookings;

grid.pivot = {
    rows: ['region', 'country', 'productLine'],   // down the side, outermost first
    columns: ['year', 'quarter'],                 // across the top, outermost first
    values: [{
        dataField: 'revenue',
        summary: 'sum',
        label: 'Revenue',
        formatSettings: { formatString: 'c0' }
    }],
    grandTotalRow: true,
    grandTotalColumn: true,
    rowSubtotals: true
};

grid.view = 'pivot';

That is the whole minimum. values is the only required part — with an empty values array the pivot does not build, because there is nothing to put in the cells.

Setting view during page load can run before the Grid has rendered, so build once more when it has. refreshPivot() is idempotent — it rebuilds from the source facts every time, so calling it twice costs a rebuild and nothing else.

grid.whenRendered(() => grid.refreshPivot());

Required Modules

FileProvidesNeeded for
modules/smart.grid.js The Grid, plus smart.pivotengine.js and smart.grid.pivot.js, which it imports Everything on this page
modules/smart.pivottable.js Registers smart-pivot-panel, the designer element Only if you show the designer

The designer is the same element smart-pivot-table docks. The Grid reuses it rather than growing a second one: it takes a field list in and emits one change event.

Fail loudly. If the pivot module is missing, the Grid still renders your flat data perfectly happily — the page looks fine and simply is not a pivot. A one-line guard is worth it:
if (typeof grid.refreshPivot !== 'function') {
    throw new Error('smart-grid: the pivot module is not loaded.');
}

The pivot Settings

PropertyTypeDefaultDescription
rowsstring[][] Row-axis fields, outermost first. Each becomes a level of the row hierarchy.
columnsstring[][] Column-axis fields, outermost first. Each becomes a level of banded headers.
valuesobject[][] The measures. At least one is required — see Measures.
grandTotalRowbooleantrue Appends a Grand Total row aggregating every record.
grandTotalColumnbooleantrue Appends a total column per measure, across the whole column axis.
rowSubtotalsbooleantrue Values on the group rows. When false the group rows stay but their value cells are null, so the hierarchy and the expand/collapse chain survive.
columnSubtotalsbooleantrue A total column under each column band. Ignored when the column axis has a single level, where the leaves already are the totals. A band needs a subtotal to collapse down to — without one, collapsing appears to do nothing.
designerbooleanfalse Creates the designer in the Grid's own side panel. For a docked panel beside the Grid, leave this off and call createPivotDesigner(container) instead.
fieldsarray[] Which source fields the designer offers, as a field name or { dataField, label, dataType }. Empty means every field found on the records, minus the underscore-prefixed bookkeeping the pivot writes onto its own rows.
freezeGrandTotalRowbooleantrue Pins the Grand Total row to the bottom using the Grid's row freezing.
rowSortfunction?null (a, b, level) => number — orders the members of the row axis.
columnSortfunction?null (a, b, level) => number — orders the members of the column axis.

Measures (Values)

Each entry in values becomes one column per column-axis leaf.

KeyTypeDescription
dataFieldstring Required. The field on the records to aggregate.
summarystring | function An aggregator name or a custom function. Defaults to 'sum'.
labelstring Column caption. Defaults to summary(dataField), e.g. sum(revenue).
showValuesAsstring A post-aggregation transform. Defaults to 'default'.
formatSettingsobject Passed to the generated column, e.g. { formatString: 'c0' }.
cellsFormatstring Passed to the generated column, the same way an ordinary Grid column takes it.
values: [
    { dataField: 'revenue', summary: 'sum', label: 'Revenue',
      formatSettings: { formatString: 'c0' } },
    { dataField: 'margin', summary: 'sum', label: 'Gross Margin',
      formatSettings: { formatString: 'c0' } },
    { dataField: 'deals', summary: 'count', label: 'Deals' }
]

Aggregators

The names are the ones Smart.DataAdapter.summarize implements, so the pivot and the Grid's summaryRow share one set of aggregators and cannot disagree about what a sum is. Read the list at runtime from Smart.Utilities.PivotEngine.aggregators:

sum, min, max, avg, count, product, median, stdev, stdevp, var, varp, unique, filled, blank

A custom aggregator is a function receiving the raw values, the underlying records and the measure descriptor — enough to compute a weighted or ratio measure, which is the usual reason to write one:

values: [{
    dataField: 'margin',
    label: 'Margin %',
    formatSettings: { formatString: 'p1' },
    summary: (values, records) => {
        const revenue = records.reduce((sum, record) => sum + record.revenue, 0);

        return revenue ? records.reduce((sum, record) => sum + record.margin, 0) / revenue : null;
    }
}]
A ratio like this must be computed from the records at each cell, not averaged from the cells below it — which is exactly what a custom summary gets to do, and what makes it different from summing a pre-computed percentage column.

Show Values As

A post-aggregation transform on a measure, from Smart.Utilities.PivotEngine.showValuesAsOptions.

ValueResult
defaultThe raw aggregate, untouched.
percentOfRowCell divided by that row's total for the measure.
percentOfColumnCell divided by that column's grand total.
percentOfTotalCell divided by the grand total of the measure.
percentOfParentCell divided by the same cell on the parent row.
runningTotalAccumulates down the column, over leaf rows.
differenceFromCell minus the column's grand total.
rank1-based rank down the column, highest first, over leaf rows.

runningTotal and rank operate on leaf rows only — a running total that counted the subtotals would double every group.

The Generated Model

grid.getPivotModel() returns what the engine produced and the Grid is rendering: { rows, columns, columnGroups, valueColumnIds }. The columns are emitted depth-first, which is what keeps every column group's members adjacent — the Grid requires that.

Each row carries bookkeeping the pivot writes for itself, and which you can read:

FieldMeaning
_pivotIdRow key. The Grid's data adapter uses it as id/keyDataField.
_pivotParentIdParent key — this is what makes the row hierarchy an ordinary tree.
_pivotKind'group', 'leaf' or 'grandTotal'.
_pivotDepth0-based level on the row axis.
_pivotLabelThe member caption. The first Grid column is bound to this field.
_pivotExpandableWhether the row has children.

Value columns are named pv__<encoded column path>__m<measure index>; the grand total column uses __total as its path. Do not build these strings by hand — take them from model.valueColumnIds, which is the list in render order.

grid.addEventListener('pivotChange', (event) => {
    // { rows, columns, valueColumnIds }
    console.log(event.detail.rows + ' rows, ' + event.detail.columns + ' columns');
});

If your aggregates are computed elsewhere — on a server, or by a warehouse query — run the engine yourself and hand the result over with setPivotModel(model), or point the Grid at a different set of facts with setPivotSource(records).

Styling Pivot Rows

Because every row carries _pivotKind and _pivotDepth, group rows, leaves and the grand total can all be styled apart. Use onRowClass — it is recycle-safe, unlike setting attributes during render.

grid.onRowClass = (visibleIndex, data) => data && data._pivotKind
    ? 'pivot-' + data._pivotKind + ' pivot-depth-' + (data._pivotDepth || 0)
    : '';
smart-grid .pivot-group.pivot-depth-0 { background: #eef2f7; font-weight: 700; }
smart-grid .pivot-group.pivot-depth-1 { background: #f7f9fc; }
smart-grid .pivot-grandTotal { font-weight: 700; border-top: 2px solid #5b9bd5; }

A colour scale over the value cells is the way people actually read a pivot, and conditionalFormatting is the supported route to one — see How the Demo Is Built for the working recipe and the trap to avoid.

The Pivot Designer

smart-pivot-panel is a field list plus Rows / Columns / Values wells and a Filters tab. createPivotDesigner(container) creates one, wires its change event to grid.pivot and appends it to your element, so a drag re-runs the engine:

<div class="pivot-demo">
    <smart-grid id="grid"></smart-grid>
    <div id="designerHost" class="pivot-designer-host"></div>
</div>

<script type="module" src="source/modules/smart.grid.js"></script>
<!-- smart-pivot-panel, the designer, is registered by the pivot table module -->
<script type="module" src="source/modules/smart.pivottable.js"></script>
const designer = grid.createPivotDesigner(document.getElementById('designerHost'));

if (!designer) {
    // smart-pivot-panel is not registered - the page must also load smart.pivottable.js
}

The panel sizes itself to its host, so the host is the element that needs a height. Passing no container falls back to the Grid's own side panel — that is also what pivot.designer = true does. The side panel is an overlay on top of the data, which is the wrong shape for a panel you drag fields in while watching the result, so the docked layout is the one worth typing three extra lines for.

Right-click a field in the panel for the same moves without dragging — send it to Rows, Columns or Values, change its aggregator, or move it up and down its axis. Every action rewrites the pivot settings and rebuilds.

Related methods: getPivotDesigner(), refreshPivotDesigner(), destroyPivotDesigner(), and pivotDesignerFields() for the field descriptors the panel is handed.

Filtering

A pivot filter runs before aggregation, so the totals are recomputed from the records that survive rather than masked. The Filters tab in the designer writes through this same API, and the evaluator is Smart.Utilities.FilterGroup — the library's own — so a filter means the same thing here as it does anywhere else, conditions, case sensitivity and date handling included.

const filters = grid.getPivotFilters();   // [{ dataField, filter }]

grid.setPivotFilters(filters);            // replaces and rebuilds
grid.clearPivotFilters();                 // removes every filter and rebuilds
grid.addEventListener('pivotFilter', (event) => {
    const filters = event.detail.filters;

    // Recompute anything of your own that summarises the same records - a KPI strip
    // that keeps stating the unfiltered total while the table shows a subset is the
    // one thing a summary must never do.
});

To summarise the same records the pivot is aggregating, run the filters through the same evaluator:

const active = grid.getPivotFilters().length === 0
    ? bookings
    : bookings.filter((booking) => grid.getPivotFilters()
        .every((entry) => entry.filter.evaluate(booking[entry.dataField])));

Leaving Pivot Mode

Pivot is the only view that replaces what the Grid is bound to: it overwrites columns, columnGroups and dataSource with generated ones. Setting view back does not restore them — putting the flat view back is the application's job, deliberately, because reassigning all three on the Grid's behalf during a view switch is not something it survives.
const setPivotMode = (on) => {
    if (on) {
        grid.view = 'pivot';
        grid.refreshPivot();
        return;
    }

    // One batch: view, columns, groups and data are four separate re-renders otherwise.
    grid.beginUpdate();

    try {
        grid.view = 'grid';
        grid.columns = flatColumns();
        grid.columnGroups = flatColumnGroups();
        grid.dataSource = bookings;
    }
    finally {
        grid.endUpdate();
    }
};

Keep the flat definitions in functions rather than in variables assigned once, so switching back always hands the Grid fresh definitions. What the Grid does keep for you is the record set: the pivot remembers the facts it was given, so a rebuild never aggregates its own output.

Methods

MethodDescription
refreshPivot()Rebuilds the pivot from the current facts and applies it. Idempotent.
getPivotModel()The model currently rendered, or null.
setPivotModel(model)Applies an already-built model — e.g. server-computed aggregates.
setPivotSource(records)Sets the flat records to aggregate, overriding the Grid's data source.
sortPivotBy(columnId, sortOrder)Sorts by a value column without flattening the row hierarchy.
pivotTopN(columnId, count, sortOrder)Keeps only the top N leaf rows by a value column, with their ancestors.
collapseColumnGroup(name)Collapses a column band by name. Returns whether it happened.
expandColumnGroup(name)Expands a column band by name.
getPivotFilters()The filters narrowing the facts, as [{ dataField, filter }].
setPivotFilters(filters)Replaces the filters and rebuilds. Pass nothing to clear.
clearPivotFilters()Removes every filter and rebuilds.
getPivotLayout()The current arrangement as JSON-safe data.
setPivotLayout(layout)Applies a saved arrangement and rebuilds.
createPivotDesigner(container)Creates the designer, wires it, appends it to container.
getPivotDesigner()The designer element, or null.
refreshPivotDesigner()Re-reads the pivot settings into the designer.
destroyPivotDesigner()Removes the designer and unwires it.
pivotDesignerFields()The field descriptors the designer is handed.

Statics on Smart.Utilities.Grid.Pivot:

StaticDescription
serializePivotFilters(filters)Filters as plain JSON that survives JSON.stringify.
deserializePivotFilters(serialized)Rebuilds live FilterGroups from that JSON.
humanizeFieldName(name)productLineProduct Line.
inferFieldType(records, name)Guesses a field's data type from the records.

And on Smart.Utilities.PivotEngine: build(records, config), sortByValue(rows, columnId, sortOrder), topN(rows, columnId, count, sortOrder), plus the aggregators and showValuesAsOptions lists.

Events

Eventevent.detailFires when
pivotChange { rows, columns, valueColumnIds } A model has been applied. The model is in place by the time this runs.
pivotFilter { filters } The pivot filters changed, from the Filters tab or from the API.
pivotDesignerChange { rows, columns, values } A field moved in the designer. Assigning pivot rebuilds synchronously, so this arrives after the pivotChange it caused, reporting the arrangement that is now on screen.
columnGroupCollapse { name, group, collapsed } A column band is folding or unfolding. Cancelable with preventDefault().

Saving and Restoring a Layout

getPivotLayout() returns exactly what survives a trip through JSON.stringify — axes, measures, totals flags, collapsed column bands and serialized filters — so a saved layout means the same thing when it is loaded back. Function-valued settings (a custom aggregator, a sort comparator) cannot be written down, so they are carried over from the live configuration by setPivotLayout() rather than cleared.

localStorage.setItem('pivot-layout', JSON.stringify(grid.getPivotLayout()));

// later, after the Grid and its pivot settings are configured
const saved = localStorage.getItem('pivot-layout');

if (saved) {
    grid.setPivotLayout(JSON.parse(saved));
}

How the Demo Is Built

Four parts of the demo above are worth lifting, and two of them are traps worth knowing about.

The heatmap

The colour scale over the value cells is built with conditionalFormatting. It takes discrete rules with one colour each, so a gradient is a run of narrow between bands — nine per column in the demo — with the range taken from the leaf rows only. Including the subtotals and the grand total would put the top of the scale an order of magnitude above any real cell, and every leaf would come out the same pale shade. It also means the totals match no rule at all, so they keep the row styling that marks them out.

const model = grid.getPivotModel();
const rules = [];

for (const id of model.valueColumnIds) {
    // min/max over model.rows where row._pivotKind === 'leaf'
    for (let band = 0; band < BANDS; band++) {
        rules.push({
            column: id,
            condition: 'between',
            firstValue: low,
            secondValue: high,
            highlight: '#5b9bd5'   // six-digit hex, see below
        });
    }
}

grid.conditionalFormatting = rules;
Two traps.
  • highlight is validated against /^#[0-9A-F]{6}$/ and anything else is silently dropped — and a dropped colour makes the formatter return nothing for that column. Use six-digit hex, not rgb().
  • Do not paint cell.background from onCellRender instead. Assigning onCellRender at all enables a branch in the cell pipeline that replaces the value the Grid just formatted with cell.value whenever the two differ — and they differ routinely in a pivot, because a null intersection is rewritten to 0 on the way through.

Expanding on load

A report that opens showing three region rows is not showing anything. Expanding needs to happen after the render that builds the tree, not straight after refreshPivot(): setPivotModel replaces the data source inside a beginUpdate/endUpdate, and endUpdate schedules the render. Defer by a frame, and only on the first build — doing it on every one would undo the user's collapses each time the designer changed something.

let expanded = false;

grid.addEventListener('pivotChange', () => {
    if (expanded) { return; }

    expanded = true;

    requestAnimationFrame(() => grid.expandAllRows());
});

Collapse state across rebuilds

You do not have to do anything here — it is worth knowing it happens. Collapsed column bands are carried onto the incoming model on every rebuild, so a measure change, a sort, a filter or a trip through the flat view does not silently unfold everything the user had folded away.

A summary strip that moves with the filter

The KPI figures above the Grid in the demo are computed from activeBookings() — the records that pass the current pivot filters — and re-rendered on pivotFilter, using the same FilterGroup.evaluate the Grid applies. The two cannot disagree.

Troubleshooting

SymptomCause
The Grid shows the flat data and never pivots. smart.grid.js did not bring in the pivot module — check typeof grid.refreshPivot === 'function'. Or view is not 'pivot'.
getPivotModel() returns null after setting the view. values is empty. Without a measure there is nothing to aggregate and the build stops.
The Grid hides itself and logs a columnGroups error. A column group's members are not adjacent. The engine emits columns depth-first to prevent this and validates before assigning — if you build a model yourself, keep each group contiguous.
The designer never appears. smart-pivot-panel is not registered — also load modules/smart.pivottable.js.
The designer appears but has no height. The panel sizes itself to its host; give the host element a height.
Collapsing a column band does nothing. The band has no subtotal to collapse down to. Leave columnSubtotals on, or expect it with a single-level column axis, where the leaves already are the totals.
Switching back to 'grid' shows pivot columns and pivot rows. Reassign columns, columnGroups and dataSource — see Leaving Pivot Mode.
Cells show numbers from the wrong column, or row labels go blank. onCellRender is assigned. Use conditionalFormatting and onRowClass instead.

Keyboard Interaction

The Grid presents tabular information and groups other elements at the same time. As such it provides a way to navigate through all focusable elements using the keyboard.

The following keyboard shortcuts are available to interact with the Grid:

Key Action
Arrow Down/Up Navigate vertically to the next/previous cell inside the Grid.
Arrow Left/Right Navigate horizontally to the next/previous cell inside the Grid.
Home Navigate to the first cell inside the current column.
End Navigate to the last cell inside the current column.
Alt + Arrow Down/Up Open/Close the menu of the current column.
Alt + S Sorts the Grid data according to the curent column.
Alt + G Enables/Disabled column grouping.
Escape Cancels an ongoing operation or closes a popup depending on the focused item. For example, if cell editing has started, Escape will cancel the editing and return the content of the cell to it's previous state.
F2/Space Begins cell editing if a cell is focused and editing is enabled.
Enter Select a focused cell or update a cell if editing is started and navigates vertically to the next cell in the column.
Backspace/Delete Deletes the content of the currently focused cell.
Shift + Arrow Up/Down/Left/Right Selects multiple cells in the direction of the pressed arrow key.
Control + Arrow Up/Down Selects the first/last cell in the current column.
Control + Arrow Left/Right Selects the first/last cell in the current row.
Page Up/Down Navigate to the first/last cell from the next/previous scroll view. When pressed the Grid scrolls up/down to the target item by scrolling to it to make it visible.

Additional information on WAI-ARIA for the SMART Framework can be found here.