Lattice Grid Buy a licence

blog

Chart a Derived Grid: Source and Derived Data, One Chart

Building…
Loading a live grid…

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.

Read next

All posts RSS