Skip to content

[charts-pro] Add render-only series sampling (line, bar, scatter)#22671

Open
JCQuintas wants to merge 30 commits into
mui:masterfrom
JCQuintas:feat/charts-sampling-core
Open

[charts-pro] Add render-only series sampling (line, bar, scatter)#22671
JCQuintas wants to merge 30 commits into
mui:masterfrom
JCQuintas:feat/charts-sampling-core

Conversation

@JCQuintas

@JCQuintas JCQuintas commented Jun 2, 2026

Copy link
Copy Markdown
Member

Summary

Adds render-only sampling (downsampling) to line, bar, and scatter charts (Pro). Sampling reduces the number of points actually rendered on large datasets, while keeping the full dataset for everything else: axis extremums, the axis domain, tooltips, highlighting, and item interaction all read the complete data.

This PR is the core implementation. Support for additional chart types (range bar, candlestick, heatmap) is split into a follow-up PR for ease of review.

API

Set the sampling prop on a series to a built-in method or a custom function:

<LineChartPro series={[{ data, sampling: 'lttb' }]} />
  • line'lttb' (Largest-Triangle-Three-Buckets) or 'm4' (pixel-column min/max/first/last).
  • scatter'bucket' (one point per marker-sized grid cell).
  • bar'bucket' (one representative per pixel-width bucket).
  • custom — a DataSampler function receiving { length, target, zoomLevel, getValue, getPosition } and returning the indices to render.

The sampling types (BarSampling, LineSampling, ScatterSampling, DataSampler, DataSamplerParams) live in the Pro package; the community series types expose empty *SeriesExtension interfaces that Pro augments.

How it works

  • The Pro useChartProSampling plugin computes render-only sampled indices — a sidecar keyed by series id (selectorChartSampledIndices). Only the plot hooks read it; everything else keeps reading the processed (full) series, so the full-data guarantee is structural.
  • Sampling is driven by a quantized zoom level, not the live scale, so the rendered shape stays stable while panning and only changes resolution in discrete steps when zooming — avoiding flicker. Once few enough points remain visible, the full visible data is rendered.
  • Bars are pixel-limited: they render at roughly one bar per few pixels regardless of zoom level, so zooming in reveals detail gradually and the sampled→full-data hand-off stays smooth (no sudden jump to many sub-pixel bars).
  • Stacked series in the same group share their sampled indices so the layers stay aligned.

JCQuintas added 4 commits June 2, 2026 16:52
Add the per-series `sampling` prop (line, bar, scatter) and the `ChartSampler`
public types. Introduce `selectorChartSeriesRendered`, consumed only by the
rendering context hooks, which applies a registered sampler while leaving
extremums, axis domain, tooltip, highlight and interaction on the full data.
The plot hooks consume the resulting `sampledIndices`.
Implement the LTTB and M4 line samplers, the scatter pixel-bucket sampler, and
the bar bucket-aggregate sampler, all driven by a quantized zoom level so the
sampled set stays stable while panning. Register them through the
`useChartProSampling` plugin on the line, bar and scatter Pro charts.
Unit tests for the LTTB/M4/bucket algorithms and the zoom-level behaviour, plus
render tests asserting line and bar series downsample (including horizontal,
reversed and negative data). Visual regression fixtures for line, scatter and
bar sampling.
Document per-series sampling for line, scatter and bar, with demos for each
method, stacked series, and a custom sampling function. Register the page in the
navigation and the charts feature grid.
@JCQuintas JCQuintas added scope: charts Changes related to the charts. plan: Pro Impact at least one Pro user. type: new feature Expand the scope of the product to solve a new problem. labels Jun 2, 2026
@JCQuintas JCQuintas self-assigned this Jun 2, 2026
@code-infra-dashboard

code-infra-dashboard Bot commented Jun 2, 2026

Copy link
Copy Markdown

Deploy preview

Bundle size

Bundle Parsed size Gzip size
@mui/x-data-grid 0B(0.00%) 0B(0.00%)
@mui/x-data-grid-pro 0B(0.00%) 0B(0.00%)
@mui/x-data-grid-premium 0B(0.00%) 0B(0.00%)
@mui/x-charts 🔺+3.76KB(+0.95%) 🔺+1.49KB(+1.27%)
@mui/x-charts-pro 🔺+7.23KB(+1.42%) 🔺+2.78KB(+1.83%)
@mui/x-charts-premium 🔺+7.4KB(+1.20%) 🔺+2.78KB(+1.50%)
@mui/x-date-pickers 0B(0.00%) 0B(0.00%)
@mui/x-date-pickers-pro 0B(0.00%) 0B(0.00%)
@mui/x-tree-view 0B(0.00%) 0B(0.00%)
@mui/x-tree-view-pro 0B(0.00%) 0B(0.00%)
@mui/x-license 0B(0.00%) 0B(0.00%)

Details of bundle changes

Performance

Total duration: 2,341.87 ms +191.62 ms(+8.9%) | Renders: 67 (+0) | Paint: 3,449.00 ms +335.71 ms(+10.8%)

Test Duration Renders
Heatmap: 100x100 grid 775.53 ms 🔺+133.88 ms(+20.9%) 2 (+0)
ScatterChart with big data amount 33.18 ms 🔺+8.15 ms(+32.6%) 2 (+0)
Area chart with big data amount (no marks) 28.51 ms 🔺+6.59 ms(+30.1%) 2 (+0)
RadialBarChart stacked with multiple series 20.82 ms 🔺+4.65 ms(+28.8%) 2 (+0)
SankeyChart with big data amount 12.08 ms 🔺+3.20 ms(+36.0%) 2 (+0)

…and 1 more (+20 within noise) — details


Check out the code infra dashboard for more information about this PR.

JCQuintas added 13 commits June 3, 2026 11:43
The sampled-indices selector took the live x/y scales as memoized inputs, so it
recomputed on every pan and zoom frame even though the kept set only changes when
the quantized zoom level changes. It also used the live scale to detect when few
points remained visible and, in that case, returned null to render the full
series - which dumped the entire (potentially huge) array to the DOM exactly when
zoomed in.

Drive sampling entirely by the quantized zoom level: drop the live scales from the
selector inputs and from the sampler context, and remove the visible-fraction
bail. The target count still grows with the zoom level, so detail returns on zoom
in, but the series is always sampled to that count and never rendered in full
until the target reaches its length. This matches the documented stable-while-
panning behaviour.
pixelBucket was exported and tested but never used by any sampler - the scatter
sampler builds its own data-space grid instead. estimateVisibleFraction became
dead once sampling stopped depending on the live scale. Remove both, along with
their exports and tests.
m4 was described as pixel-identical to drawing every point, but the buckets are
sized by index rather than by x-pixel, so that strict guarantee does not hold -
reword it as a close, shape-preserving approximation. Document that sampled bars
are laid out on a uniform grid rather than at their true category positions, so a
bar no longer lines up with its axis tick while tooltips and highlighting stay
accurate.
Sampled bars are repositioned onto a uniform slot grid that fills the band axis
range, but the axis highlight kept drawing the band rectangle at the value's
original band position, so it no longer lined up with the bar under the pointer.

Extract the slot geometry shared by the bar plot into a helper and add a selector
that exposes each band axis's sampled indices. The x/y axis highlight now map the
pointer to the slot drawn under it and highlight that slot, so the band rectangle
follows the displayed sampled bar.
@JCQuintas JCQuintas marked this pull request as ready for review June 3, 2026 16:03
@JCQuintas JCQuintas requested a review from alexfauquette as a code owner June 3, 2026 16:03
@JCQuintas JCQuintas marked this pull request as draft June 3, 2026 16:08
@JCQuintas JCQuintas marked this pull request as ready for review June 4, 2026 18:55
@alexfauquette alexfauquette changed the title [charts] Add render-only series sampling (line, bar, scatter) [charts-pro] Add render-only series sampling (line, bar, scatter) Jun 5, 2026
@alexfauquette

Copy link
Copy Markdown
Member

Just 2 feedback about docs before I forget:

  • you could add a section about sub-sampling in the performance page
  • the examples (especially line and scatter) looks too small to be realistic. Is it to avoid crashing reader computer when they desactivate the sub-sampling, or is it because of performance issues outside of the rendering aspect? If it's to preserve users computer, we should probably mention that in the docs. otherwise the feature seems as powerfull as the batch rendering but with different tradesof

@JCQuintas

Copy link
Copy Markdown
Member Author

you could add a section about sub-sampling in the performance page

Should we move everything there? 🤔

I created this before that page existed

Is it to avoid crashing reader computer when they deactivate the sub-sampling

Pretty much, I tested again.

Line with 1M+sampling is smooth, scatter is ok, bar is choppy (I think because I created a check so the jump in bar size from one zoom level to another wouldn't be too abrupt, it was working smooth before, will look into it 🤔).

Then if we change to full set everything goes to hell 😆

@alexfauquette alexfauquette left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm wondering if we should simplify the API a bit.

For now it's super flexible, user can pass any sub sampling they want.

I think you could restrain the sub-samplign to only powers of 2.

One main adventage would be the possibility to compute and save all the subsample at once.

You generate an array of N=data.length intergers, and

  • values form 0 to N/2 are the first subsampling indexes
  • values form N/2 to N/2+N/4 are the second subsampling indexes
  • ...
  • up to when N/2^k corepsonds the the subsample of a fully view of the series
Image

This will allow to do the processing once for all at the beegining

If it simplifies you can also remove the scatter plot

Comment on lines +103 to +104
// The zoom slider preview always renders the full data, so no sampling is applied.
{},

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's weird. the preview should subsample, because it's goal is to give an overview, not a detailed representation

Keep the following in mind:

- **Rendering uses the sampled data only.** The drawn geometry behaves as if the dropped values never existed—bars, in particular, are laid out across the full width as if only the kept categories existed, so they become fewer and wider rather than thin with gaps.
- **Everything else uses the full data.** Tooltips, the axis domain, and item interaction always read the complete dataset. As a result, hovering a point that was dropped from a sampled scatter series does not highlight it.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The explaination is a bit tricky to understand

Comment on lines +49 to +50
// Built-in `'bucket'`: keep one point per data-space grid cell. Runs in data space (not pixels)
// so the kept set stays stable across pans.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The issue with such a sampling is that

  1. It destroys the notion of density. You keep the global shape but if some bucket has 100 point and the other just 1, at the end they look the same
  2. I does nto guaranty any data reduction factor. A series with points evenly spaced being a worst edge case compared to a dense area with few outliers that creates large min/max x/y

The main downsampling I've seen about scatter charts, are in fact fifty shades of heatmap. When there are too many data points, you don't show the points, you show their density

AG charts has a down-sampling that is more informative than this one but it feel useless. Your screen just ends up with lots of point and you've no idea what to look ag

Comment on lines +9 to +18
export type UseChartProSamplingDefaultizedParameters = UseChartProSamplingParameters;

export interface UseChartProSamplingState {
sampling: {
/**
* Computes the render-only sampled indices for every series that sets a `sampling` method.
*/
computeSampledIndices: ChartSampledIndicesComputer;
};
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From a DX point of view, it coudl be nice to add an extra parametter to pick the algorithm at the pluggin level according to the series type.

UseChartProSamplingParameters<SereiesType> {
sampling?: SeriesType extneds keyof SamplingAlgo ? SamplingAlgo[SereiesType] : never
}

It's imperfect because when mixing a line and bar, some algo might not be available for both (lttb and m4) but it will enable

<LineChart
  sampling='m4'
  {/* ... */}
/>

which is easier than having to pass the `sampling property to each series

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment on lines +149 to +153
const renderBudget = (pixelSpan: number, length: number) =>
Math.min(length, Math.max(2, Math.floor(pixelSpan / PIXELS_PER_POINT)) * 2 ** zoomLevel);

// `null` when the axis is not zoomed, so its whole extent is visible.
const visibleWindow = (axisId: AxisId): { start: number; end: number } | null => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You already have a sampledIndices I'm not sure to get what this function is doing. Why not just returning the sampled indexes?

@github-actions github-actions Bot added the PR: out-of-date The pull request has merge conflicts and can't be merged. label Jun 8, 2026
@github-actions

github-actions Bot commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

This pull request has conflicts, please resolve those before we can evaluate the pull request.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

plan: Pro Impact at least one Pro user. PR: out-of-date The pull request has merge conflicts and can't be merged. scope: charts Changes related to the charts. type: new feature Expand the scope of the product to solve a new problem.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants