Build your web apps using Smart.UI
Smart.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.
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
| File | Provides | Needed 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.
if (typeof grid.refreshPivot !== 'function') {
throw new Error('smart-grid: the pivot module is not loaded.');
}
The pivot Settings
| Property | Type | Default | Description |
|---|---|---|---|
rows | string[] | [] |
Row-axis fields, outermost first. Each becomes a level of the row hierarchy. |
columns | string[] | [] |
Column-axis fields, outermost first. Each becomes a level of banded headers. |
values | object[] | [] |
The measures. At least one is required — see Measures. |
grandTotalRow | boolean | true |
Appends a Grand Total row aggregating every record. |
grandTotalColumn | boolean | true |
Appends a total column per measure, across the whole column axis. |
rowSubtotals | boolean | true |
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.
|
columnSubtotals | boolean | true |
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. |
designer | boolean | false |
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.
|
fields | array | [] |
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.
|
freezeGrandTotalRow | boolean | true |
Pins the Grand Total row to the bottom using the Grid's row freezing. |
rowSort | function? | null |
(a, b, level) => number — orders the members of the row axis. |
columnSort | function? | 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.
| Key | Type | Description |
|---|---|---|
dataField | string |
Required. The field on the records to aggregate. |
summary | string | function |
An aggregator name or a custom function. Defaults to 'sum'.
|
label | string |
Column caption. Defaults to summary(dataField), e.g. sum(revenue). |
showValuesAs | string |
A post-aggregation transform. Defaults to 'default'. |
formatSettings | object |
Passed to the generated column, e.g. { formatString: 'c0' }. |
cellsFormat | string |
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;
}
}]
Show Values As
A post-aggregation transform on a measure, from
Smart.Utilities.PivotEngine.showValuesAsOptions.
| Value | Result |
|---|---|
default | The raw aggregate, untouched. |
percentOfRow | Cell divided by that row's total for the measure. |
percentOfColumn | Cell divided by that column's grand total. |
percentOfTotal | Cell divided by the grand total of the measure. |
percentOfParent | Cell divided by the same cell on the parent row. |
runningTotal | Accumulates down the column, over leaf rows. |
differenceFrom | Cell minus the column's grand total. |
rank | 1-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:
| Field | Meaning |
|---|---|
_pivotId | Row key. The Grid's data adapter uses it as id/keyDataField. |
_pivotParentId | Parent key — this is what makes the row hierarchy an ordinary tree. |
_pivotKind | 'group', 'leaf' or 'grandTotal'. |
_pivotDepth | 0-based level on the row axis. |
_pivotLabel | The member caption. The first Grid column is bound to this field. |
_pivotExpandable | Whether 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
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
| Method | Description |
|---|---|
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:
| Static | Description |
|---|---|
serializePivotFilters(filters) | Filters as plain JSON that survives JSON.stringify. |
deserializePivotFilters(serialized) | Rebuilds live FilterGroups from that JSON. |
humanizeFieldName(name) | productLine → Product 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
| Event | event.detail | Fires 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;
-
highlightis 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, notrgb(). -
Do not paint
cell.backgroundfromonCellRenderinstead. AssigningonCellRenderat all enables a branch in the cell pipeline that replaces the value the Grid just formatted withcell.valuewhenever the two differ — and they differ routinely in a pivot, because a null intersection is rewritten to0on 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
| Symptom | Cause |
|---|---|
| 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.