Chart a Derived Grid: Source and Derived Data, One Chart
A revenue-by-region panel next to an orders table is two things to keep in
step. Filter the table and the panel either follows or it does not, and the
difference between those two outcomes is usually a line nobody wrote. The
posts in this series have shown the fix: a derived grid, built with
source: { mode: 'derived', from, ... }, reads another grid instead of a
dataset of its own, and re-derives whenever that grid’s rows change. What
has not had a post of its own yet is that a derived grid is still a grid,
and a chart only ever reads a grid, so a chart bound to a derived grid gets
the same live behaviour for free. Filter the source, and both the derived
grid and anything charted from it move on the next frame.
What makes a derived grid different
A derived grid takes a follow mode and re-derives from it: filtered reads
whatever the parent’s filters leave, selected reads only the ticked rows,
grouped reads one row per group, and all ignores the parent’s filter
entirely, for an exceptions list that should not shrink just because the
table did. Whichever mode it uses, the shape it builds, a group-by, a join,
a profile, a union of several grids, is declared once and re-run on every
change, rather than written as a one-off query against a snapshot. And
because it is a grid, not a derived value or a view model, it keeps every
ordinary grid capability: sort it, filter it further, export it, put a KPI
tile on it, chart it.
The usual alternative is a second query against the same backing store: a
fresh SELECT, or a second REST call, fired whenever the first table’s
filter changes. It works, and it also means two round trips doing
arithmetic the first one already did, two places for the business logic to
drift apart, and a second network failure mode to handle on a panel that
was only ever supposed to summarise the first. A derived grid skips the
round trip: the data already arrived once, and the summary is read from the
rows already sitting in memory.
A copied array is the version that skips the network but keeps the drift.
Someone reduces orders into revenueByRegion once, on load, and the two
live as separate variables from that point on. Nothing connects them, so
nothing tells the copy when the original changes, and a filter applied to
one is invisible to the other until somebody remembers to re-run the
reduction by hand. A derived grid is not a second variable: it holds no
rows of its own to fall out of step, because it reads the first grid’s rows
every time it is asked to.
A spreadsheet export is the version that gives up on “live” altogether. Someone exports the filtered table, pivots it in a separate tool, and pastes a chart into a deck. It is correct for exactly the moment it was taken, and wrong the moment the underlying numbers move, which on a table anyone is still filtering is most of the time. A derived grid is the same pivot, expressed once, that keeps re-running instead of being taken once and framed.
For the specific recipes, grouping by region, bucketing by month, joining a customers grid, limiting to a top few, this series has already covered them in revenue by region, monthly revenue trend and the other posts linked from the derived and chained grids guide. This post is about what you get once the derived grid exists: a chart.
Charting derived data
createChart takes a grid and reads its rows on every redraw. Nothing
about that grid needs to be a plain createGrid instance loaded from an
array. Point it at a derived grid, and the chart reads whatever that grid
currently holds, which is itself read live from its own from:
import { createGrid } from '@toclocoinc/lattice-grid';
import { createChart } from '@toclocoinc/lattice-grid/modules/charts';
const orders = [
{ id: 1, region: 'EMEA', date: '2026-01-03', amount: 420 },
{ id: 2, region: 'APAC', date: '2026-01-06', amount: 180 },
{ id: 3, region: 'Americas', date: '2026-01-09', amount: 960 },
{ id: 4, region: 'EMEA', date: '2026-01-13', amount: 310 },
{ id: 5, region: 'Americas', date: '2026-01-16', amount: 540 },
{ id: 6, region: 'APAC', date: '2026-01-20', amount: 275 },
{ id: 7, region: 'EMEA', date: '2026-01-24', amount: 890 },
{ id: 8, region: 'Americas', date: '2026-01-28', amount: 150 },
{ id: 9, region: 'APAC', date: '2026-02-02', amount: 410 },
{ id: 10, region: 'EMEA', date: '2026-02-05', amount: 220 },
{ id: 11, region: 'Americas', date: '2026-02-09', amount: 730 },
{ id: 12, region: 'APAC', date: '2026-02-13', amount: 95 },
{ id: 13, region: 'EMEA', date: '2026-02-17', amount: 605 },
{ id: 14, region: 'Americas', date: '2026-02-21', amount: 340 },
{ id: 15, region: 'APAC', date: '2026-02-25', amount: 515 },
{ id: 16, region: 'EMEA', date: '2026-02-28', amount: 175 },
];
const book = createGrid(document.querySelector('#orders'), {
rowKey: 'id',
columns: [
{ field: 'id', title: 'Order' },
{ field: 'region', title: 'Region', filter: { type: 'set' } },
{ field: 'date', title: 'Date', type: 'dateString' },
{ field: 'amount', title: 'Amount', type: 'number' },
],
rows: orders,
});
// A second grid, built from the first.
const byRegion = createGrid(document.querySelector('#by-region'), {
columns: [
{ field: 'region', title: 'Region' },
{ field: 'revenue', title: 'Revenue', type: 'number' },
],
source: {
mode: 'derived',
from: book,
follow: 'filtered',
refresh: 'live',
groupBy: 'region',
select: { revenue: { of: 'amount', fn: 'sum' } },
sort: [{ col: 'revenue', dir: 'desc' }],
},
});
// The chart reads byRegion, not a copy of its rows.
const chart = createChart({
grid: byRegion,
container: document.querySelector('#region-chart'),
type: 'bar',
x: 'region',
y: 'revenue',
});
The live version of this is the first demo on this page: sixteen orders across two months and three regions, a derived grid grouping them by region, and a bar chart reading that derived grid. Filter the orders to a region or two and both the grid beneath it and the bars above it redraw, because neither was ever holding a snapshot.
Running the plain JavaScript above with createHeadlessGrid from the
published package and reading byRegion’s rows gives three bars:
| Region | Revenue |
|---|---|
| Americas | 2,720 |
| EMEA | 2,620 |
| APAC | 1,475 |
Filter the orders grid to EMEA and Americas only, and byRegion drops to
two rows, the same two revenue figures, APAC gone from the grid and gone
from the chart in the same redraw, with no second call behind either.
Source and derived data, in one chart
A chart reads one grid. Overlaying the raw orders and a derived total means
building a third grid whose rows are a union of the other two: from takes
an array of sources instead of a single grid, each with its own label and
an optional map that reshapes its rows into a common shape before they are
stacked. Every combined row keeps a __source field naming which grid it
came from, which is what lets you tell the two shapes apart once they share
one grid, and one chart.
The map step is what actually makes this work: the orders grid’s rows and
the monthly-totals grid’s rows do not share a column, one has amount, the
other has revenue, so each source’s map renames its own value into a
column name chosen for the chart. Giving the two sources distinct column
names, rather than forcing them into one shared column, is what lets a
single chart draw one of them as bars and the other as a line, each against
its own axis:
// A derived grid of monthly totals, built from the same orders grid.
const monthlyTotal = createGrid(document.querySelector('#monthly'), {
columns: [
{ field: 'date', title: 'Month', type: 'dateString' },
{ field: 'revenue', title: 'Revenue', type: 'number' },
],
source: {
mode: 'derived',
from: book,
follow: 'filtered',
refresh: 'live',
groupBy: 'date',
bucket: { of: 'date', by: 'month' },
select: { revenue: { of: 'amount', fn: 'sum' } },
sort: [{ col: 'date', dir: 'asc' }],
},
});
// The union: every row from both grids, tagged with __source, each
// reshaped by its own map into a column the chart below can read.
const overlay = createGrid(document.querySelector('#overlay'), {
columns: [
{ field: '__source', title: 'Feed' },
{ field: 'date', title: 'Date' },
{ field: 'daily', title: 'Daily' },
{ field: 'monthly', title: 'Monthly' },
],
source: {
mode: 'derived',
refresh: 'live',
from: [
{ grid: book, label: 'orders', map: (row) => ({ date: row.date, daily: row.amount }) },
{ grid: monthlyTotal, label: 'monthly total', map: (row) => ({ date: row.date, monthly: row.revenue }) },
],
sort: [{ col: 'date', dir: 'asc' }],
},
});
// One chart, two measures: bars for the daily amounts, a line for the
// monthly total, on its own axis because the totals run far larger.
const overlayChart = createChart({
grid: overlay,
container: document.querySelector('#overlay-chart'),
type: 'combo',
x: 'date',
measures: [
{ col: 'daily', fn: 'sum', type: 'bar', axis: 'left', title: 'Daily orders' },
{ col: 'monthly', fn: 'sum', type: 'line', axis: 'right', title: 'Monthly total' },
],
});
That is the second demo on this page: the same sixteen orders, daily
amounts as bars, the derived monthly total drawn as a line over them. A
single order runs from under a hundred to under a thousand; a month of
orders summed runs into the thousands, so the monthly line sits on its own
right-hand axis rather than flattening against the daily bars on a shared
scale. Running the example above and reading overlay’s rows gives
eighteen rows, sixteen tagged orders and two tagged monthly total:
| Feed | Date | Daily | Monthly |
|---|---|---|---|
| orders | 2026-01-03 | 420 | |
| orders | 2026-01-06 | 180 | |
| … | … | … | |
| monthly total | 2026-01-01 | 3,725 | |
| … | … | … | |
| monthly total | 2026-02-01 | 3,090 |
January’s eight orders sum to 3,725; February’s eight sum to 3,090, and
those are exactly the two points the line draws, read from the same rows
the bars came from rather than typed in separately. Filter the orders grid
and monthlyTotal re-derives first, overlay re-derives from it next, and
the chart redraws last, all on one frame, because each step only ever reads
the one before it.
When not to reach for one
A derived grid re-derives on a change to its from, by default on an idle
frame rather than synchronously (refresh: 'idle'), so a hundred cell edits
in one event coalesce into one derivation rather than a hundred. refresh: 'live' re-derives on every change instead, which is right for a demo where
every edit should be seen, and wasteful for a feed updating faster than a
reader can look at it. refresh: 'manual' goes the other way and never
re-derives on its own: useful for a panel a reader deliberately refreshes
with a button, wrong for one that is supposed to be live. Picking the
mode that matches how often the source actually changes is the one cost a
derived grid asks you to think about.
Three cases are better served by something else. A figure that depends on
rows the grid will never hold, last year’s total from an archive table the
screen has no reason to load, is not a derive, because there is no from
to read; fetch it once, outside the grid. A one-off calculation nobody will
filter or re-run, a cell formatted for print, a single number computed for
an email subject line, does not need a live grid behind it; a plain
function call is the whole job. And a result that must be exactly
reproducible after the source rows are gone, an invoice total that has to
match what the customer was shown the day they paid, belongs stored as a
fact rather than re-derived from source rows that may since have been
corrected; a derived grid shows you the current truth, not the one that was
true when you looked.
Short of those, a chart on a derived grid costs nothing beyond the derived
grid itself, which was already the right way to keep a summary panel
honest. The two demos on this page are the same configuration you have
already read: a source.mode: 'derived' block and a createChart call
that happens to read it. For the group-by and bucket recipes behind the
first demo, see revenue by region
and monthly revenue trend; for
combining rows from grids that do not share a key, the
union source demo runs the same from array over
two incident feeds instead of orders and a total. For every other chart
type a grid, derived or not, can feed, see the charts module.