Documentation

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.

PackageWhat you get
Model@kanunilabs/datagrid-reactuseGridChartData() — categories and aggregated series, ready for any chart library
Panel@kanunilabs/datagrid-react-enterpriseThe 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:

sourceRows 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. categoryOrder is 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, not 0.
  • 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.adjusted says 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 chartzoom in and out, around the pointer
Dragpan (only while zoomed)
← →pan by a quarter of the window
+ −zoom from the centre
0 or Homeshow 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 threadWorker
Aggregate111,7 ms33,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 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".