tutorial
Build an analytics workbench: pivot, cross-filter and regression
Last updated 5 September 2026
Analysis is not one feature. It is pivoting to see the shape of the data, filtering to narrow to a question, charting to see the answer, and statistics to say whether the answer is real. When those live in separate tools you spend your time moving data between them. This tutorial builds them into one workbench over a single dataset: a cross-tab, charts that cross-filter with the grid, a statistics panel and a regression toolkit, with export at the end, and no backend to run.
Open the finished workbench in the sandbox
The problem: one tool, not three
A pivot table, a charting library and a statistics package each answer part of the question, but they do not share a selection. Filter the pivot and the chart does not know; brush the chart and the statistics do not follow. The value is in the connection: one set of rows, one filter, and every view of it moving together. That is what the grid gives you, so the workbench below is one dataset with several faces.
Set up the page
Load the grid and the charts module from the CDN with ordinary script tags.
No build step and no import: the grid is on the global
LatticeGrid, and the charts module adds
createChart to it. On localhost the grid is free to use; a
deployed site is licensed per domain, which the sandbox already carries for
you.
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@toclocoinc/lattice-grid@1.32.0/lattice-grid.min.css">
<script src="https://cdn.jsdelivr.net/npm/@toclocoinc/lattice-grid@1.32.0/lattice-grid.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@toclocoinc/lattice-grid@1.32.0/modules/charts.min.js"></script>
Lay out a chart strip and an element for the grid.
<style>
body { font-family: system-ui, sans-serif; margin: 0; padding: 16px; background: #f6f7f9; color: #1b2430; }
.bar { display: flex; gap: 10px; align-items: center; margin-bottom: 12px; }
.bar h1 { font-size: 15px; margin: 0; font-weight: 600; color: #3a4250; }
.bar .spacer { flex: 1; }
.bar button { font: inherit; font-size: 13px; font-weight: 600; padding: 8px 14px; border: 1px solid #cfd6df; border-radius: 8px; background: #fff; cursor: pointer; }
.bar button:hover { border-color: #2d6bff; color: #2d6bff; }
.charts { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 12px; }
.card { background: #fff; border: 1px solid #e3e6ea; border-radius: 10px; height: 220px; min-width: 0; }
#grid { height: 380px; background: #fff; border: 1px solid #e3e6ea; border-radius: 10px; overflow: hidden; }
@media (max-width: 820px) { .charts { grid-template-columns: 1fr; } }
</style>
<div class="bar">
<h1>Marketing performance</h1>
<span class="spacer"></span>
<button id="csv">Export CSV</button>
<button id="xlsx">Export Excel</button>
</div>
<div class="charts">
<div id="byRegion" class="card"></div>
<div id="trend" class="card"></div>
<div id="fit" class="card"></div>
<div id="resid" class="card"></div>
</div>
<div id="grid"></div>
Load the data
A seeded generator stands in for a warehouse query, so the example runs with nothing behind it. Each row is a region, channel and quarter with the spend and the revenue it drove.
// A seeded generator, so the workbench shows the same figures to everyone.
function rng(seed) {
var a = seed >>> 0;
return function () {
a = (a + 0x6d2b79f5) >>> 0;
var t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function generateSales() {
var rand = rng(29);
var REGIONS = ['EMEA', 'AMER', 'APAC', 'LATAM'];
var CHANNELS = ['Search', 'Social', 'Email'];
var QUARTERS = ['Q1', 'Q2', 'Q3', 'Q4'];
var rows = [];
var id = 0;
REGIONS.forEach(function (region) {
CHANNELS.forEach(function (channel) {
for (var month = 1; month <= 12; month++) {
var spend = Math.round(2000 + rand() * 8000);
// Revenue leans on spend with a channel-specific return, plus noise, so
// the regression has a real slope to find and residuals to look at.
var roi = channel === 'Search' ? 3.1 : channel === 'Social' ? 2.4 : 3.8;
var revenue = Math.round(spend * roi + (rand() - 0.5) * 6000);
rows.push({
id: 'r' + id++, region: region, channel: channel,
quarter: QUARTERS[Math.floor((month - 1) / 3)], month: month,
spend: spend, revenue: revenue, deals: Math.round(revenue / 900),
});
}
});
});
return rows;
}
Pivot and cross-tab
A cross-tab is the fastest way to see the shape of the data. One column becomes the rows down the side, another becomes the columns across the top, and a value column with a reduction fills each cell. Here that is regions down, quarters across, and the sum of revenue in each cell, from one configuration.
// A cross-tab from the same data: regions down the side, quarters across the
// top, and the sum of revenue in each cell. A column with group turns into a row
// dimension; a column with pivot becomes the across axis; a value column with a
// reduction fills the cells. maxColumns guards against a runaway transpose.
var grid = LatticeGrid.createGrid(document.getElementById('grid'), {
rowKey: 'id',
theme: 'light',
rows: generateSales(),
pivot: { enabled: true, maxColumns: 24 },
showTotalInHeader: true,
grandTotalRow: 'bottom',
columns: [
{ field: 'region', title: 'Region', group: { enabled: true, index: 0 } },
{ field: 'quarter', title: 'Quarter', pivot: { enabled: true, index: 0 } },
{ field: 'revenue', title: 'Revenue', type: 'number', format: { style: 'currency', currency: 'USD', decimals: 0 }, total: 'sum' },
],
});
Run the cross-tab in the sandbox
Columns and shadow diagnostics
Back to the flat view for the rest of the workbench. Alongside the value columns, two shadow columns carry the regression diagnostics: the fitted value and the residual for each row. They are computed from the fit and follow the filters, so the diagnostics refit as you narrow the data, with no code of your own.
// The model the diagnostics fit: revenue explained by spend.
var model = { predictors: ['spend'], response: 'revenue' };
var money = { style: 'currency', currency: 'USD', decimals: 0 };
var columns = [
{ field: 'region', title: 'Region', filter: { type: 'set' } },
{ field: 'channel', title: 'Channel', filter: { type: 'set' } },
{ field: 'quarter', title: 'Quarter', filter: { type: 'set' } },
{ field: 'spend', title: 'Spend', type: 'number', format: money, total: 'sum', filter: { type: 'number' } },
{ field: 'revenue', title: 'Revenue', type: 'number', format: money, total: 'sum', filter: { type: 'number' } },
{ field: 'deals', title: 'Deals', type: 'number', total: 'sum' },
// Shadow columns computed from the fit: each is a diagnostic the residual
// charts read. They follow the filters, so refitting is automatic.
{ id: 'yhat', title: 'Fitted', type: 'number', format: money, shadow: { kind: 'fitPredicted', model: model } },
{ id: 'resid', title: 'Residual', type: 'number', format: { decimals: 0 }, shadow: { kind: 'fitResidual', model: model } },
];
var grid = LatticeGrid.createGrid(document.getElementById('grid'), {
rowKey: 'id',
theme: 'light',
rows: generateSales(),
columns: columns,
selection: { mode: 'multiple', ranges: true },
grandTotalRow: 'bottom',
// The tool panel carries the statistics summary and the regression panel: name
// the predictors and the response and it fits the model and shows the
// coefficient table, each estimate with its standard error, over the filtered
// rows.
toolPanel: {
side: 'left',
panels: ['columns', 'filters', 'statistics', { name: 'regression', props: { predictors: ['spend'], response: 'revenue' } }],
openPanel: 'regression',
actions: ['excel', 'clipboard', 'restore'],
exportName: 'marketing',
},
});
Cross-filter the grid and the charts
The charts read the grid's filtered rows, so they always reflect what is on screen. The link runs the other way too: a click on a bar filters the grid to that region, and dragging a range across the trend line narrows it to those months. One gesture drives every view.
// Two charts that cross-filter with the grid. The bar follows the grid's
// filters, so it always reflects the visible rows; filterOnClick makes a click
// on a bar filter the grid to that region in turn. The trend line brushes: drag
// a range across it and the grid narrows to those months.
var byRegion = LatticeGrid.createChart({ grid: grid, container: '#byRegion', type: 'bar', x: 'region', y: { col: 'revenue', fn: 'sum' }, filterOnClick: true, title: 'Revenue by region (click to filter)' });
var trend = LatticeGrid.createChart({ grid: grid, container: '#trend', type: 'line', x: 'month', y: { col: 'revenue', fn: 'sum' }, brush: true, title: 'Revenue by month (brush to filter)' });
The statistics and regression panels
The tool panel carries a statistics summary and a regression panel. Name the predictors and the response and it fits the model and shows the coefficient table, each estimate with its standard error, over exactly the rows in view. The same figures are available in code if you want them elsewhere.
// The same figures in code, if you want them outside the panel. regression
// returns the slope, intercept, R2 and the count it was fitted on, over exactly
// the rows currently in view.
var fit = grid.statistics.regression('spend', 'revenue');
console.log('slope', fit.slope, 'r2', fit.r2, 'n', fit.n);
Regression diagnostics
A model is not finished when it has a slope; it is finished when the residuals have been looked at. A scatter with a fit draws the least-squares line and its R2, and a residuals-against-fitted plot reads the shadow columns, so a pattern in it is the sign the straight line is missing something. Both follow the filters, so narrowing the grid refits everything on screen.
// The regression pictures. A scatter with fit draws the least-squares line and
// its R2; a residuals-against-fitted scatter reads the shadow columns, so a
// pattern in it is the sign the straight line is missing something. Both follow
// the filters, so narrowing the grid refits everything on screen.
var scatter = LatticeGrid.createChart({ grid: grid, container: '#fit', type: 'scatter', x: 'spend', y: 'revenue', fit: true, title: 'Revenue against spend, fitted' });
var resid = LatticeGrid.createChart({ grid: grid, container: '#resid', type: 'scatter', x: 'yhat', y: 'resid', title: 'Residuals against fitted' });
That is the whole workbench: a cross-tab, cross-filtering charts, the statistics and regression panels, and the diagnostics beside them. Run it in the sandbox and filter any view to watch the rest follow.
Export to Excel or CSV
Both exports run on the client, from the grid's current rows and columns with the filters and sort applied. Excel is a real spreadsheet, written without a ZIP dependency; CSV is sanitised against formula injection.
// Both exports run on the client, from the grid's current rows and columns with
// the filters and sort applied. Excel is a real .xlsx, written without a ZIP
// dependency; CSV is sanitised against formula injection.
document.getElementById('xlsx').addEventListener('click', function () {
grid.export.excel({ fileName: 'marketing.xlsx' });
});
document.getElementById('csv').addEventListener('click', function () {
var text = grid.export.csv();
var blob = new Blob([text], { type: 'text/csv' });
var a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'marketing.csv';
a.click();
});
What you built
One dataset became a tool for asking questions of it: a cross-tab for shape, charts and a grid that filter each other, a statistics summary, a regression with its coefficients and residuals, and a spreadsheet on demand. Nothing moved between applications, because there was only one.
Next, see the regression diagnostics demo and the pivot demo as compact examples, or read the statistics overview for the full set of measures the grid computes.