Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions docs/data/charts/performance/performance.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,8 +113,9 @@ See [Bar charts—WebGL renderer](/x/react-charts/bars/#webgl-renderer), [Scatte

## Sampling [<span class="plan-pro"></span>](/x/introduction/licensing/#pro-plan 'Pro plan')

When a zoomable chart has more data than the drawing area can show without making elements
When a chart has more data than the drawing area can show without making elements
too small, the data can be reduced to an appropriate level of detail before rendering.
This applies to both zoomable and static charts.

`BarChartPro` and `LineChartPro` accept a `sampling` prop that selects the method:

Expand All @@ -123,7 +124,7 @@ too small, the data can be reduced to an appropriate level of detail before rend
- `'m4'`: pixel-accurate—keep the first, min, max, and last point of each bucket (line series).
- `'lttb'`: [Largest-Triangle-Three-Buckets](https://skemman.is/handle/1946/15343), keeps one representative point per bucket to preserve the shape (line series).

Zoom in to progressively reveal more detail; sampling turns off once the view can resolve every element.
On a zoomable chart, zoom in to progressively reveal more detail; sampling turns off once the view can resolve every element. A static chart (no `zoom`) is sampled at its full range, so a large dataset still renders at an appropriate level of detail.

Sampling also keeps a hard ceiling on the number of rendered elements (2,000). Once the view fits under it, the chart renders every visible element untouched; while a zoomed-in view still holds more than that, it stays sampled even when each element would be wide enough to draw on its own—so a very dense dataset never floods the renderer at any zoom level.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,27 @@ describe('<BarChartPro /> - Sampling', () => {

const range = (length: number) => Array.from({ length }, (_, i) => i);

// Sampling only applies to zoomable axes, so every case enables zoom.
// Regression: https://github.com/mui/mui-x/issues/23031 — sampling used to require zoom.
it('samples a static (non-zoomable) axis at full range', () => {
const dataLength = 256;
const { container } = render(
<BarChartPro
series={[{ data: range(dataLength) }]}
xAxis={[{ data: range(dataLength).map(String) }]}
yAxis={[{ position: 'none' }]}
width={200}
height={200}
margin={0}
sampling="minmax"
skipAnimation
/>,
);

const rendered = countBars(container);
expect(rendered).to.be.greaterThan(0);
expect(rendered).to.be.lessThan(dataLength / 2);
});

it('renders one bar per data point when bars are wide enough', () => {
const { container } = render(
<BarChartPro
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import * as React from 'react';
import { createRenderer } from '@mui/internal-test-utils';
import { lineClasses } from '@mui/x-charts/LineChart';
import { LineChartPro } from './LineChartPro';

// Count the drawing commands of the main line path (the one carrying `lineClasses.line`).
const linePointCount = (container: HTMLElement) => {
const paths = Array.from(container.querySelectorAll<SVGPathElement>('path[data-series]'));
const line = paths.find((p) => p.classList.contains(lineClasses.line));
return ((line?.getAttribute('d') ?? '').match(/[MLC]/g) ?? []).length;
};

describe('<LineChartPro /> - Sampling', () => {
const { render } = createRenderer();
const range = (n: number) => Array.from({ length: n }, (_, i) => i);

it('samples a static (non-zoomable) axis at full range', () => {
const dataLength = 1024;
const { container } = render(
<LineChartPro
series={[{ data: range(dataLength), showMark: false }]}
xAxis={[{ data: range(dataLength) }]}
yAxis={[{ position: 'none' }]}
width={400}
height={200}
margin={0}
sampling="minmax"
skipAnimation
/>,
);

const rendered = linePointCount(container);
expect(rendered).to.be.greaterThan(0);
expect(rendered).to.be.lessThan(dataLength / 2);
});

it('renders every point when sampling is disabled (default)', () => {
const dataLength = 1024;
const { container } = render(
<LineChartPro
series={[{ data: range(dataLength), showMark: false }]}
xAxis={[{ data: range(dataLength) }]}
yAxis={[{ position: 'none' }]}
width={400}
height={200}
margin={0}
sampling="none"
skipAnimation
/>,
);

expect(linePointCount(container)).to.equal(dataLength);
});
});
7 changes: 5 additions & 2 deletions packages/x-charts/src/BarChart/useBarPlotData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@ import {
import type { ChartDrawingArea } from '../hooks/useDrawingArea';
import { useChartId } from '../hooks/useChartId';
import { useStore } from '../internals/store/useStore';
import { selectorChartSamplingPyramids } from '../internals/plugins/featurePlugins/useChartCartesianAxis/sampling.selectors';
import {
selectorChartSamplingPyramids,
getFullRangeZoom,
} from '../internals/plugins/featurePlugins/useChartCartesianAxis/sampling.selectors';
import type {
SampledSeriesLookup,
SamplingStrategy,
Expand Down Expand Up @@ -215,7 +218,7 @@ export function processBarDataForPlot(

const pyramid = samplingPyramids[seriesId];
const baseAxisId = verticalLayout ? xAxisId : yAxisId;
const zoom = zoomMap?.get(baseAxisId);
const zoom = zoomMap?.get(baseAxisId) ?? (pyramid ? getFullRangeZoom(baseAxisId) : undefined);
const availableSize = verticalLayout ? drawingArea.width : drawingArea.height;
const minSpan = zoomOptions?.[baseAxisId]?.minSpan ?? 0;
// The sampler (pro) owns the level-of-detail math; community renders one merged rect per
Expand Down
3 changes: 2 additions & 1 deletion packages/x-charts/src/LineChart/useLinePlotData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type { ComputedAxisConfig } from '../internals/plugins/featurePlugins/use
import {
selectorChartSamplingState,
selectorChartSamplingPyramids,
getFullRangeZoom,
} from '../internals/plugins/featurePlugins/useChartCartesianAxis/sampling.selectors';
import type { SampledBucket } from '../internals/plugins/featurePlugins/useChartCartesianAxis/sampling.types';
import type { ZoomMap } from '../internals/plugins/featurePlugins/useChartCartesianAxis/zoom.types';
Expand Down Expand Up @@ -140,7 +141,7 @@ export function useLinePlotData(

// Only the plain (non-step) path is sampled; step expansion falls back to the full render.
const built = sampledSeries[seriesId];
const zoom = zoomMap?.get(xAxisId);
const zoom = zoomMap?.get(xAxisId) ?? (built ? getFullRangeZoom(xAxisId) : undefined);
let sampledBuckets: SampledBucket[] | null = null;
if (samplingEnabled && built && zoom && !shouldExpand && xData) {
// Pro owns the sampling math; community flattens the buckets into a polyline.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type { AxisId } from '../../../../models/axis';
import type { ChartRootSelector } from '../../utils/selectors';
import type { UseChartCartesianAxisSignature } from './useChartCartesianAxis.types';
import type { SampledSeriesLookup } from './sampling.types';
import type { ZoomData } from './zoom.types';
import {
selectorChartXAxis,
selectorChartYAxis,
Expand All @@ -17,6 +18,15 @@ import {
const EMPTY_PYRAMIDS: SampledSeriesLookup = {};
const EMPTY_BUCKET_SIZES: Map<AxisId, number> = new Map();

/**
* Full-range zoom (span 100%) for a non-zoomable axis. Sampling gates on a zoom entry; a static axis
* has none, so we sample it at full range — the `MAX_RENDERED_POINTS` cap still applies. Mirrors the
* full-range zoom the zoom-slider preview uses to keep its density stable.
*/
export function getFullRangeZoom(axisId: AxisId): ZoomData {
return { axisId, start: 0, end: 100 };
}

export const selectorChartSamplingState: ChartRootSelector<
UseChartCartesianAxisSignature,
'sampling'
Expand Down Expand Up @@ -103,9 +113,9 @@ export const selectorChartHighlightBucketSize = createSelectorMemoized(
).forEach(([axes, availableSize]) => {
axes.axisIds.forEach((axisId) => {
const data = axes.axis[axisId].data;
const zoom = zoomMap?.get(axisId);
const minSpan = zoomOptions[axisId]?.minSpan;
if (data && zoom && minSpan != null) {
const zoom = zoomMap?.get(axisId) ?? getFullRangeZoom(axisId);
const minSpan = zoomOptions[axisId]?.minSpan ?? 0;
if (data) {
bucketSizes.set(
axisId,
bucketSizeAt(zoom.end - zoom.start, {
Expand Down
Loading