Trend Figures for Any Series, as Grid Rows
Whoever reports on trend ends up building the same six numbers from scratch, every time someone asks for them: how far the figure moved since the start of the run, how choppy that run has been, the worst dip along the way and when it happened, how many periods went up against how many went down. Each one is its own formula in a spreadsheet column, copied down next to a date column, and recalculated by hand the moment someone asks to see just one channel instead of the whole book. Time series statistics in JavaScript, computed inside the grid that already holds the rows, removes that spreadsheet from the loop.
A derived grid turns the formula column into a second grid. Point it at the
orders grid, name the column to measure and the column that orders it, and
statistics: { fn: 'series', of: 'amount', by: 'date' } hands back the same
figures as rows: the change since the first point, the volatility, the
worst drawdown and where it fell, the up periods against the down ones.
Filter the orders grid to one channel and every one of those rows
recomputes from just the rows that are left, on the next frame.
That is the spreadsheet rebuild gone, not hidden somewhere else: the trend figures live on a grid that already holds the orders, so a narrower filter produces a narrower trend without anyone reopening a sheet to redo the formulas. Six figures, one definition each, correct for whatever the filter currently shows.
Time series statistics in JavaScript
import { createGrid } from '@toclocoinc/lattice-grid';
const orders = [
{ id: 1, date: '2026-09-01', channel: 'Online', amount: 1180 },
{ id: 2, date: '2026-09-02', channel: 'Wholesale', amount: 1240 },
{ id: 3, date: '2026-09-03', channel: 'Online', amount: 1050 },
{ id: 4, date: '2026-09-04', channel: 'Wholesale', amount: 1310 },
{ id: 5, date: '2026-09-05', channel: 'Online', amount: 1395 },
{ id: 6, date: '2026-09-08', channel: 'Wholesale', amount: 1288 },
{ id: 7, date: '2026-09-09', channel: 'Online', amount: 1460 },
{ id: 8, date: '2026-09-10', channel: 'Wholesale', amount: 1120 },
{ id: 9, date: '2026-09-11', channel: 'Online', amount: 980 },
{ id: 10, date: '2026-09-12', channel: 'Wholesale', amount: 1035 },
{ id: 11, date: '2026-09-15', channel: 'Online', amount: 1210 },
{ id: 12, date: '2026-09-16', channel: 'Wholesale', amount: 1375 },
{ id: 13, date: '2026-09-17', channel: 'Online', amount: 1520 },
{ id: 14, date: '2026-09-18', channel: 'Wholesale', amount: 1448 },
{ id: 15, date: '2026-09-19', channel: 'Online', amount: 1605 },
{ id: 16, date: '2026-09-22', channel: 'Wholesale', amount: 1690 },
{ id: 17, date: '2026-09-23', channel: 'Online', amount: 1572 },
{ id: 18, date: '2026-09-24', channel: 'Wholesale', amount: 1715 },
{ id: 19, date: '2026-09-25', channel: 'Online', amount: 1840 },
{ id: 20, date: '2026-09-26', channel: 'Wholesale', amount: 1920 },
];
const book = createGrid(document.querySelector('#orders'), {
rowKey: 'id',
columns: [
{ field: 'id', title: 'Order' },
{ field: 'date', title: 'Date', type: 'dateString' },
{ field: 'channel', title: 'Channel', filter: { type: 'set' } },
{ field: 'amount', title: 'Amount', type: 'number' },
],
rows: orders,
});
// A second grid whose rows are the trend figures for the amount series,
// ordered by date: one row per metric, not one row per order.
const trend = createGrid(document.querySelector('#trend'), {
source: {
mode: 'derived',
from: book, // the grid to read
statistics: { fn: 'series', of: 'amount', by: 'date' },
},
columns: [
{ field: 'metric', title: 'Metric' },
{ field: 'value', title: 'Value', type: 'number' },
{ field: 'n', title: 'N', type: 'number' },
],
});
Filter the orders grid’s Channel column to Online, dropping every Wholesale
order, and trend recomputes every figure from just those ten rows, with
no second pass over the orders array anywhere in the example above.
React
import React from 'react';
import { createGrid } from '@toclocoinc/lattice-grid';
import '@toclocoinc/lattice-grid/css';
import { createLatticeGrid } from '@toclocoinc/lattice-grid/modules/react';
// Build the component once, at module scope, not on every render.
const LatticeGrid = createLatticeGrid({ React, createGrid });
function AmountTrend({ orders }) {
const [book, setBook] = React.useState(null);
const orderColumns = [
{ field: 'id', title: 'Order' },
{ field: 'date', title: 'Date', type: 'dateString' },
{ field: 'channel', title: 'Channel', filter: { type: 'set' } },
{ field: 'amount', title: 'Amount', type: 'number' },
];
const trendColumns = [
{ field: 'metric', title: 'Metric' },
{ field: 'value', title: 'Value', type: 'number' },
{ field: 'n', title: 'N', type: 'number' },
];
return (
<>
<LatticeGrid
rowKey="id"
columns={orderColumns}
rows={orders}
onGridReady={setBook}
style={{ height: 320 }}
/>
{book && (
<LatticeGrid
columns={trendColumns}
source={{
mode: 'derived',
from: book,
statistics: { fn: 'series', of: 'amount', by: 'date' },
}}
style={{ height: 320 }}
/>
)}
</>
);
}
onGridReady hands back the live orders grid as soon as it mounts, which is
what the trend panel’s from needs; the panel itself only renders once that
instance exists. Filtering the orders grid’s Channel column moves the trend
panel the same way it does in the plain example, because both read the
grid’s public API, not a copy of the rows taken at render time.
The options used
| Option | What it does |
|---|---|
mode: 'derived' |
This grid’s rows are computed from another grid’s, and re-derived when that one changes. |
from |
The grid to read. |
statistics |
Projects a figure that needs more than one column, or a second grid, into rows. It replaces the rest of the pipeline rather than joining it. |
statistics.fn: 'series' |
Selects the series-summary producer: volatility, growth, drawdown, autocorrelation, one row per metric. |
statistics.of |
The column to summarise. |
statistics.by |
The column that orders the series. Required, and never guessed, because a derived grid’s own sort is not the order a trend is computed in. |
What the example actually produced
Running the plain JavaScript example above with createHeadlessGrid from the
published package, and printing trend’s rows, gives:
| Metric | Value | N |
|---|---|---|
| n | 20 | 20 |
| first | 1,180 | 20 |
| last | 1,920 | 20 |
| change | 740 | 20 |
| changePercent | 62.7 | 20 |
| volatility | 0.121 | 20 |
| annualisedVolatility | null | 20 |
| growth | 0.026 | 20 |
| maxDrawdown | 0.329 | 20 |
| maxDrawdownFrom | 6 | 20 |
| maxDrawdownTo | 8 | 20 |
| autocorrelation | 0.715 | 20 |
| upDays | 13 | 20 |
| downDays | 6 | 20 |
annualisedVolatility is null because no periodsPerYear was given; pass
one and volatility and growth are annualised against it.
maxDrawdownFrom and maxDrawdownTo are positions in the ordered series,
not dates: row 6 is 9 September, the peak at 1,460, and row 8 is 11
September, the trough at 980.
Filtering the orders grid’s Channel column to Online, dropping every
Wholesale order, and re-reading trend gives:
| Metric | Value | N |
|---|---|---|
| n | 10 | 10 |
| first | 1,180 | 10 |
| last | 1,840 | 10 |
| change | 660 | 10 |
| changePercent | 55.9 | 10 |
| volatility | 0.206 | 10 |
| annualisedVolatility | null | 10 |
| growth | 0.051 | 10 |
| maxDrawdown | 0.329 | 10 |
| maxDrawdownFrom | 3 | 10 |
| maxDrawdownTo | 4 | 10 |
| autocorrelation | 0.359 | 10 |
| upDays | 6 | 10 |
| downDays | 3 | 10 |
n drops to the ten Online rows, and most figures move with it, the
volatility rises because the smaller, noisier series has fewer points
smoothing it out. maxDrawdown lands on the same ratio, because the same
peak and trough both happen to fall on Online days, but the positions that
name them shift to 3 and 4, their place in the shorter, filtered series.
The derived and chained grids guide covers joins, column profiles, chaining a derived grid from a derived grid, and cross-filtering back into the source in full. For another grid that reads a time column, see the monthly revenue trend post, which feeds the same kind of derived grid into a line chart instead of a statistics row.