Charts
Try it live — 50,000 rows, the panel and every option on this page.
Charting is split the same way the rest of the grid is: the model is Community, the renderers and the panel are Enterprise.
| Package | What you get | |
|---|---|---|
| Model | @kanunilabs/datagrid-react | useGridChartData() — categories and aggregated series, ready for any chart library |
| Panel | @kanunilabs/datagrid-react-enterprise | The dock panel, six renderers, pickers, fullscreen, cross-filtering |
The panel (Enterprise)
One prop:
import { EnterpriseDataGrid } from '@kanunilabs/datagrid-react-enterprise';
<EnterpriseDataGrid
licenseKey={key}
dataSource={rows}
rowKey="id"
columns={columns}
charting
/>
That adds a Chart button to the toolbar and a Chart by this column entry to every column header's right-click menu. The panel docks above the grid — not over it, because watching the chart follow a filter you just applied is the point, and a modal would hide the grid doing the filtering.
The pickers are built from the grid's own column model, so there is no list to keep in sync: hide a column and it leaves the pickers with it.
Options
<EnterpriseDataGrid
charting={{
crossFilter: true, // clicking a category filters the grid. Default false.
defaultOpen: true, // dock the panel open instead of waiting for the button.
height: 280, // docked canvas height in px. Default 280.
defaultSpec: { categoryColumnId: 'region' },
labels: { title: 'Grafik' },
}}
/>
recharts is an optional peer dependency, loaded only when the panel is
first opened. A grid that never charts does not pull it into the bundle:
npm i recharts
Chart types
column · bar · line · area · pie · donut
The list is a registry, not a switch. Register your own renderer — D3,
ECharts, hand-written SVG — and it appears in the type picker beside the
built-ins:
import { chartRegistry } from '@kanunilabs/datagrid-react-enterprise';
chartRegistry.register({
def: { id: 'radar', label: 'Radar', requiresCategory: true },
render: (model, ctx) => <MyRadar model={model} colors={ctx.colors} />,
});
model is the same ChartModel described below; ctx carries the locale,
colours, hidden-series set, the measured canvas size and the cross-filter
callback.
What the chart aggregates
A chart is a group-by: one category column, one or more value series.
const spec = {
categoryColumnId: 'region',
series: [
{ columnId: 'revenue', aggregate: 'sum' },
{ columnId: 'units', aggregate: 'avg' },
],
source: 'view',
};
Aggregations are the grid's own: sum avg min max count countDistinct.
They are computed by the same reducers as the footer totals, so a bar and the
footer total of that column can never disagree — blanks are skipped rather than
counted as zero, count is COUNTA, and date columns are parsed before reducing.
A non-numeric column only offers count and countDistinct: summing a warehouse
code runs Number('WH-04'), which is NaN for every row and a silently blank
series.
Which rows
source decides, and the answer is explicit rather than implied:
source | Rows charted |
|---|---|
'view' (default) | Filtered and searched rows, before paging |
'selection' | The selected rows — falling back to 'view' when nothing is selected, so the chart is never silently empty |
'all' | Every source row, ignoring the filter |
Continuous columns: dates and numbers
A date column has one distinct value per order date. Charted raw, 900 dates become 900 categories, of which the cap below keeps 30 and folds 870 into "Other" — an axis with no chronology left in it. So a date axis is grouped by an interval instead:
const spec = {
categoryColumnId: 'orderedAt',
series: [{ columnId: 'revenue', aggregate: 'sum' }],
categoryBucket: { kind: 'date', unit: 'month' }, // or 'auto'
};
unit is 'auto' (the default) or day · week · month · quarter ·
year. 'auto' measures the data's own span and picks the finest unit that
still fits a readable axis. In the panel this is the By picker, which
appears whenever the category column is a date or a number.
A numeric column bins the same way:
categoryBucket: { kind: 'number', bins: 20 } // or an exact { size: 50 }
bins is a target: the width is rounded to a 1/2/5×10ⁿ step, so asking for 20
bins over a 0–500.000 range gives ten bins of 50.000 rather than twenty of
23.817.
A bucketed axis behaves differently on purpose:
- Ordered by the interval, never by value.
categoryOrderis ignored. - Empty intervals are kept. A month with no orders is information, and a
chart that closes the gap draws a line straight through it as though the
months were adjacent. Those bars are
NaN, not0. - No "Other" and no top-N. An "Other" in the middle of a time axis is not a
category, it is a hole. When there would be too many intervals the unit is
coarsened instead — every row stays in the chart, the axis just gets less
granular, and
model.bucket.adjustedsays it happened. - Rows whose value will not parse go to the blank category rather than being dropped without trace.
Everything is UTC. Local bucketing would put the same row in a different day
depending on where the browser is, and Date.parse('2023-01-15') is UTC
midnight anyway.
Weeks start on Monday and are labelled by that Monday's date rather than
2023-W03: the ISO week number disagrees with the calendar year at both ends of
December, and a chart axis is a bad place to explain that.
Categories are capped
maxCategories (default 30) keeps the top categories by value and collapses the
rest into a single Other bucket. This is not cosmetic: a grid's category axis
is a raw column, so pointing it at an id column would otherwise ask for a million
categories.
"Other" is folded from the accumulators, not from the finished numbers — so its average is the average of its rows, not the average of the averages it swallowed, and the grand total stays exact.
Two axes when the scales differ
Revenue summed over a million rows is ~10⁸; units over the same rows ~10⁶. On one axis the second series is a hundredth of the chart and draws as a flat line — present in the legend, invisible in the plot. When one series is more than 25× smaller than the largest, it moves to its own right-hand axis automatically.
Cross-filtering
Off by default, because a chart click that suddenly re-filters the grid is
surprising. With crossFilter: true, clicking a bar or slice applies that
category as a grid filter — and the chart, following the grid, narrows to it.
On a bucketed axis the click applies a half-open range —
>= 2023-01-01 AND < 2023-04-01 — not an equality. = '2023-Q1' would match no
row, because "2023-Q1" is a label the engine invented; and an inclusive
between would put the first of April in the first quarter as well as the
second.
Clicking the same mark again clears the filter, and while one is applied the panel says so with a Clear link beside it. Both exist because the condition does not come from an input: the filter row stays empty, so without them the user is looking at 8.000 of 50.000 rows with nothing on screen to press.
The filter replaces whatever was active rather than narrowing it — one click, one condition, and a second click gets you back.
Zoom and pan
Once an axis is bucketed it can legitimately hold hundreds of points, so the panel lets you look closer:
| Scroll over the chart | zoom in and out, around the pointer |
| Drag | pan (only while zoomed) |
| ← → | pan by a quarter of the window |
| + − | zoom from the centre |
| 0 or Home | show everything |
While a window is active the panel says "Showing 295 of 900" with a Show all beside it.
This is a slice of the model, not a transform of the picture and not a
library feature. The chart engine hands the renderer a smaller ChartModel;
nothing is recomputed, and the series are subarray views over the same
buffers, so panning allocates nothing. That also means a renderer you register
yourself gets zooming without writing any:
chartRegistry.register({
def: { id: 'radar', label: 'Radar', requiresCategory: true, windowable: true },
render: (model, ctx) => <MyRadar model={model} colors={ctx.colors} />,
});
windowable is off by default, and deliberately false for pie and
donut: a pie states "these are the parts of a whole", and a pie of 30 slices
out of 900 categories states something untrue. Zooming is offered only where it
does not lie.
A drag that ends on a bar does not cross-filter — the two gestures are kept apart, so panning cannot filter the grid by accident.
Performance
Aggregation runs off the main thread whenever the grid's worker is in play (20.000 rows by default), reusing the columns the worker already encoded for filtering and sorting. Measured on a million rows, two series:
| Main thread | Worker | |
|---|---|---|
| Aggregate | 111,7 ms | 33,0 ms |
What crosses the worker boundary is categories × series numbers — 90 floats for
a 30-category, 3-series chart — not the rows.
Two rules keep it cheap while you use the grid:
- A sort does not recompute the chart. Aggregation is order-independent, so reordering a million rows costs the chart nothing.
- Changing chart type does not recompute either — the model is reused and only the renderer changes.
A filter, a search, or a data change does recompute; those are the things that change the numbers.
aggregate: 'custom'takes a reducer function, and a function cannot be sent to a worker. A spec using one is computed on the main thread.
The model without the panel (Community)
If you would rather draw the chart yourself, take the data and stop there:
import { useGridChartData } from '@kanunilabs/datagrid-react';
const spec = useMemo(
() => ({ categoryColumnId: 'region', series: [{ columnId: 'revenue', aggregate: 'sum' }] }),
[],
);
const model = useGridChartData(controller, spec);
Memoize the spec — a new object reference recomputes. The hook subscribes to the grid and returns:
interface ChartModel {
categories: string[]; // '' for blanks, matching the checkbox filter
series: Array<{
label: string;
columnId: string;
aggregate: SummaryType;
values: Float64Array; // one per category; NaN = nothing to total
}>;
truncated: boolean; // categories were collapsed or dropped
otherIndex: number | null; // where "Other" sits, if present
rowCount: number;
categoryCount: number;
rankingExact: boolean; // false past the distinct-category ceiling
// Only on a bucketed axis:
bucket?: { kind: 'date' | 'number'; unit?: ChartDateUnit; size?: number; adjusted: boolean };
bucketStarts?: Float64Array; // inclusive lower bound per category
bucketEnds?: Float64Array; // exclusive upper bound; dates are ms
}
NaN rather than 0 is deliberate: "no numeric values here" and "totals to
zero" are different answers, and a zero-height bar claims the second.
Fullscreen and a fixed height
height sets the docked height; fullscreen ignores it and fills the screen.
The height is a number rather than "fill" for a reason worth knowing: recharts
sizes itself from a ResizeObserver, which never fires while an element is
display: none. A chart inside a collapsed accordion or an inactive tab would
stay blank until it is shown. A fixed height sidesteps the observer entirely.
Image export
The panel has PNG and SVG buttons. The file is named after what the
chart shows — region-revenue.png, not chart.png.
Both go through the same step, which is the part worth knowing about: the SVG in the page is not self-contained. Every colour in it reads a CSS custom property that resolves against the document, and the text inherits a font from an ancestor. Serialised naively it would open as the fallback palette in the browser's default serif. So the export flattens the clone first — the values the browser actually computed are written onto each element — and adds an opaque background, because a transparent PNG dropped on a dark slide loses every dark label on it.
PNG is rasterised at 2× by default; a chart exported at CSS pixels looks soft the moment it lands in a document.
Call them directly if you would rather not use the buttons:
import { exportChartPng, exportChartSvg } from '@kanunilabs/datagrid-react-enterprise';
const svg = container.querySelector('svg');
await exportChartPng(svg, { fileName: 'sales-by-region', pixelRatio: 3 });
exportChartSvg(svg, { background: null }); // keep transparency deliberately
Not yet
- The chart aggregates data rows; grid grouping is not carried into it. The chart's category axis is its own group-by, and stacking the grid's grouping on top would leave two competing answers to "what is grouped here".