Skip to content

Commit 659fced

Browse files
authored
[charts] Stable progressive scatter batching + responsive zoom/pan (mui#22862)
1 parent 8c6d8ca commit 659fced

11 files changed

Lines changed: 365 additions & 156 deletions

File tree

docs/data/charts/scatter/ScatterAsyncRenderer.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import * as React from 'react';
22
import Stack from '@mui/material/Stack';
33
import Button from '@mui/material/Button';
44
import Typography from '@mui/material/Typography';
5-
import { ScatterChart } from '@mui/x-charts/ScatterChart';
5+
import { ScatterChartPro } from '@mui/x-charts-pro/ScatterChartPro';
66
import Chance from 'chance';
77

88
const NUMBER_OF_SERIES = 3;
@@ -204,10 +204,12 @@ export default function ScatterAsyncRenderer() {
204204
/>
205205
</Stack>
206206
<div ref={containerRef} style={{ width: '100%' }}>
207-
<ScatterChart
207+
<ScatterChartPro
208208
key={runId}
209209
series={series}
210210
height={400}
211+
xAxis={[{ zoom: true }]}
212+
yAxis={[{ zoom: true }]}
211213
// Force the renderer so the two modes are directly comparable:
212214
// - `svg-single`: original synchronous per-item renderer.
213215
// - `svg-progressive`: batched renderer that paints over several

docs/data/charts/scatter/ScatterAsyncRenderer.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import * as React from 'react';
22
import Stack from '@mui/material/Stack';
33
import Button from '@mui/material/Button';
44
import Typography from '@mui/material/Typography';
5-
import { ScatterChart } from '@mui/x-charts/ScatterChart';
5+
import { ScatterChartPro } from '@mui/x-charts-pro/ScatterChartPro';
66
import Chance from 'chance';
77

88
const NUMBER_OF_SERIES = 3;
@@ -208,10 +208,12 @@ export default function ScatterAsyncRenderer() {
208208
/>
209209
</Stack>
210210
<div ref={containerRef} style={{ width: '100%' }}>
211-
<ScatterChart
211+
<ScatterChartPro
212212
key={runId}
213213
series={series}
214214
height={400}
215+
xAxis={[{ zoom: true }]}
216+
yAxis={[{ zoom: true }]}
215217
// Force the renderer so the two modes are directly comparable:
216218
// - `svg-single`: original synchronous per-item renderer.
217219
// - `svg-progressive`: batched renderer that paints over several

docs/data/charts/scatter/scatter.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,8 @@ The main thread stays responsive while a large dataset is being drawn.
174174

175175
The example below renders 20,000 points.
176176
Use the buttons to compare the single and progressive renderers: the spinner keeps animating and "first paint" stays low with the progressive renderer, while the single renderer blocks the main thread until every point is drawn.
177+
Zoom and pan the chart to see the progressive renderer keep only the first level painted while you interact, then fill in the rest once the interaction settles.
178+
The first level is the first N points of each series, so it is representative only when the data is unordered; data sorted along an axis may show a partial cloud until the interaction settles.
177179

178180
{{"demo": "ScatterAsyncRenderer.js"}}
179181

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import * as React from 'react';
2+
import { createRenderer, waitFor } from '@mui/internal-test-utils';
3+
import { ScatterChart } from '@mui/x-charts/ScatterChart';
4+
import { isJSDOM } from 'test/utils/skipIf';
5+
import { getInteractionStep } from './ScatterAsync';
6+
7+
describe('getInteractionStep', () => {
8+
it('is a multiple of nBatches, so the sample is a subset of batch 0', () => {
9+
expect(getInteractionStep(60000, 6, 2000) % 6).to.equal(0);
10+
expect(getInteractionStep(100000, 10, 2000) % 10).to.equal(0);
11+
});
12+
13+
it('equals the batch-0 stride when batch 0 already fits the budget', () => {
14+
// count / nBatches = 1000 points in batch 0 <= budget, no coarsening.
15+
expect(getInteractionStep(6000, 6, 2000)).to.equal(6);
16+
});
17+
18+
it('coarsens beyond the batch-0 stride to stay within the budget', () => {
19+
// Batch 0 would be 12000 points; coarsen by 6x to land near 2000.
20+
const step = getInteractionStep(120000, 10, 2000);
21+
expect(step % 10).to.equal(0);
22+
expect(120000 / step).to.be.at.most(2000);
23+
});
24+
25+
it('returns 1 when there are no batches', () => {
26+
expect(getInteractionStep(0, 0, 2000)).to.equal(1);
27+
});
28+
});
29+
30+
// rAF-driven progressive reveal: browser only.
31+
describe.skipIf(isJSDOM)('ScatterAsync - progressive renderer', () => {
32+
const { render } = createRenderer();
33+
34+
const POINT_COUNT = 2500;
35+
const data = Array.from({ length: POINT_COUNT }, (_, i) => ({
36+
id: i,
37+
x: i % 100,
38+
y: Math.floor(i / 100),
39+
}));
40+
41+
const props = {
42+
series: [{ data }],
43+
xAxis: [{ position: 'none' }],
44+
yAxis: [{ position: 'none' }],
45+
width: 200,
46+
height: 200,
47+
margin: 0,
48+
// Force the progressive renderer regardless of point count.
49+
renderer: 'svg-progressive',
50+
} as const;
51+
52+
it('progressively paints every point across reveal frames', async () => {
53+
const { container } = render(<ScatterChart {...props} />);
54+
55+
// The reveal ramps across animation frames; every point is in the drawing
56+
// area, so the stride-based batches' union completes with one circle per point.
57+
await waitFor(() => {
58+
expect(container.querySelectorAll('circle').length).to.equal(POINT_COUNT);
59+
});
60+
});
61+
});

packages/x-charts/src/ScatterChart/async/ScatterAsync.tsx

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,28 +9,51 @@ import {
99
selectorProgressiveSeriesRevealedBatches,
1010
type UseProgressiveRenderingSignature,
1111
} from '../../internals/plugins/featurePlugins/useProgressiveRendering';
12+
import {
13+
selectorChartZoomIsInteracting,
14+
type UseChartCartesianAxisSignature,
15+
} from '../../internals/plugins/featurePlugins/useChartCartesianAxis';
1216
import { selectorScatterSeriesRenderData } from './scatterRenderData.selectors';
1317

18+
/** Per-series points sampled while interacting; the rest fills in on settle. */
19+
const INTERACTION_POINT_BUDGET = 2000;
20+
21+
/**
22+
* Interacting sample stride. Multiple of `nBatches` (batch 0's stride) so the
23+
* sample is a subset of batch 0 — settling only adds points, no jump. Coarsened
24+
* to stay within `budget`.
25+
*/
26+
export function getInteractionStep(count: number, nBatches: number, budget: number): number {
27+
if (nBatches <= 0) {
28+
return 1;
29+
}
30+
return nBatches * Math.max(1, Math.ceil(count / (budget * nBatches)));
31+
}
32+
1433
/**
1534
* @ignore - internal component.
1635
*/
1736
function ScatterAsync(props: ScatterProps) {
1837
const { series, colorGetter, onItemClick, slots, slotProps, classes } = props;
1938

20-
const store = useStore<[UseProgressiveRenderingSignature]>();
39+
const store = useStore<[UseProgressiveRenderingSignature, UseChartCartesianAxisSignature]>();
2140
const batchSize = store.use(selectorProgressiveBatchSize);
2241
const revealedBatches = store.use(selectorProgressiveSeriesRevealedBatches, series.id);
23-
// Size batches by the number of *visible* points so that zooming in (which
24-
// shrinks the filtered set in the selector) collapses the progressive wave
25-
// into a single tick once everything fits in one batch.
42+
const isZoomInteracting = store.use(selectorChartZoomIsInteracting);
2643
const renderData = store.use(selectorScatterSeriesRenderData, series.id);
2744
const count = renderData?.count ?? 0;
45+
// Batch `b` = every `nBatches`-th point from `b`: a uniform sample whose
46+
// membership depends only on `dataIndex` (stable across zoom/pan, no popping).
2847
const nBatches = count === 0 ? 0 : Math.ceil(count / Math.max(1, batchSize));
48+
// Only the first level shows while interacting; skip mounting the rest (empty
49+
// `<g>` still re-renders every frame, bypassing `React.memo`).
50+
const mountedBatches = isZoomInteracting ? Math.min(1, nBatches) : nBatches;
51+
// `count` (total points) is constant across zoom/pan, so the sampled set is
52+
// stable while panning.
53+
const interactionStep = getInteractionStep(count, nBatches, INTERACTION_POINT_BUDGET);
2954

3055
const batches: React.ReactNode[] = [];
31-
for (let b = 0; b < nBatches; b += 1) {
32-
const start = b * batchSize;
33-
const end = Math.min(count, start + batchSize);
56+
for (let b = 0; b < mountedBatches; b += 1) {
3457
batches.push(
3558
<ScatterAsyncBatch
3659
key={b}
@@ -39,10 +62,11 @@ function ScatterAsync(props: ScatterProps) {
3962
onItemClick={onItemClick}
4063
slots={slots}
4164
slotProps={slotProps}
42-
start={start}
43-
end={end}
65+
start={isZoomInteracting ? 0 : b}
66+
step={isZoomInteracting ? interactionStep : nBatches}
4467
classes={classes}
45-
revealed={b < revealedBatches}
68+
revealed={isZoomInteracting || b < revealedBatches}
69+
isInteracting={isZoomInteracting}
4670
/>,
4771
);
4872
}

packages/x-charts/src/ScatterChart/async/ScatterAsyncBatch.tsx

Lines changed: 28 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -18,27 +18,29 @@ import { type UseChartTooltipSignature } from '../../internals/plugins/featurePl
1818
import { type UseChartInteractionSignature } from '../../internals/plugins/featurePlugins/useChartInteraction';
1919
import { type UseChartHighlightSignature } from '../../internals/plugins/featurePlugins/useChartHighlight';
2020
import { type ScatterProps } from '../Scatter';
21-
import {
22-
getScatterBatchView,
23-
selectorScatterSeriesRenderData,
24-
} from './scatterRenderData.selectors';
21+
import { selectorScatterSeriesRenderData } from './scatterRenderData.selectors';
2522

2623
export interface ScatterAsyncBatchProps extends Pick<
2724
ScatterProps,
2825
'series' | 'colorGetter' | 'onItemClick' | 'slots' | 'slotProps' | 'classes'
2926
> {
3027
series: DefaultizedScatterSeriesType;
3128
colorGetter: ColorGetter<'scatter'>;
32-
/** First point index of this batch (inclusive). */
29+
/** First `dataIndex` this batch renders. */
3330
start: number;
34-
/** Last point index of this batch (exclusive). */
35-
end: number;
31+
/** Stride between rendered `dataIndex`es, so the batch is a uniform sample. */
32+
step: number;
3633
/**
37-
* Whether this batch is allowed to render its markers yet. `ScatterAsync`
38-
* ramps this up batch by batch across animation frames for a progressive
39-
* paint. When `false` the `<g>` still mounts but stays empty.
34+
* Whether this batch may render its markers yet. Ramped batch by batch across
35+
* frames for the progressive paint. When `false` the `<g>` mounts empty.
4036
*/
4137
revealed: boolean;
38+
/**
39+
* Whether a zoom/pan interaction is in progress. While interacting, per-marker
40+
* highlight state and interaction handlers are skipped: useless mid-drag and
41+
* the dominant per-frame cost.
42+
*/
43+
isInteracting?: boolean;
4244
}
4345

4446
/**
@@ -52,8 +54,9 @@ function ScatterAsyncBatchComponent(props: ScatterAsyncBatchProps) {
5254
slots,
5355
slotProps,
5456
start,
55-
end,
57+
step,
5658
revealed,
59+
isInteracting,
5760
classes: inClasses,
5861
} = props;
5962

@@ -89,17 +92,20 @@ function ScatterAsyncBatchComponent(props: ScatterAsyncBatchProps) {
8992
return <g data-series={series.id} className={classes.series} />;
9093
}
9194

92-
const view = getScatterBatchView(renderData, start, end);
95+
const { coords, count } = renderData;
9396

9497
const markers: React.ReactNode[] = [];
95-
const nLocal = view.length / 3;
96-
for (let local = 0; local < nLocal; local += 1) {
97-
const x = view[local * 3];
98-
const y = view[local * 3 + 1];
99-
const dataIndex = view[local * 3 + 2];
98+
const safeStep = Math.max(1, step);
99+
for (let dataIndex = start; dataIndex < count; dataIndex += safeStep) {
100+
// Skip off-screen points (kept in-array to keep batches stable across pan).
101+
if (coords[dataIndex * 3 + 2] === 0) {
102+
continue;
103+
}
104+
const x = coords[dataIndex * 3];
105+
const y = coords[dataIndex * 3 + 1];
100106

101107
const dataPoint = { x, y, dataIndex, seriesId: series.id, type: 'scatter' as const };
102-
const highlightState = getHighlightState(dataPoint);
108+
const highlightState = isInteracting ? 'none' : getHighlightState(dataPoint);
103109
const isItemHighlighted = highlightState === 'highlighted';
104110
const isItemFaded = highlightState === 'faded';
105111

@@ -124,7 +130,9 @@ function ScatterAsyncBatchComponent(props: ScatterAsyncBatchProps) {
124130
}
125131
data-highlighted={isItemHighlighted || undefined}
126132
data-faded={isItemFaded || undefined}
127-
{...(skipInteractionHandlers ? undefined : getInteractionItemProps(instance, dataPoint))}
133+
{...(skipInteractionHandlers || isInteracting
134+
? undefined
135+
: getInteractionItemProps(instance, dataPoint))}
128136
{...markerProps}
129137
/>,
130138
);
@@ -137,8 +145,7 @@ function ScatterAsyncBatchComponent(props: ScatterAsyncBatchProps) {
137145
);
138146
}
139147

140-
// Memoized so a reveal tick (which re-renders every `ScatterAsync`) only
141-
// re-renders the one batch whose `revealed` prop changed.
148+
// Memoized so a reveal tick only re-renders the batch whose `revealed` changed.
142149
const ScatterAsyncBatch = React.memo(ScatterAsyncBatchComponent);
143150

144151
export { ScatterAsyncBatch };
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { packScatterSeriesCoords } from './scatterRenderData.selectors';
2+
3+
const identity = (value: number | Date) => value as number;
4+
const bounds = { xMin: 0, xMax: 100, yMin: 0, yMax: 100 };
5+
6+
describe('packScatterSeriesCoords', () => {
7+
it('packs every point into a dataIndex slot (stride 3)', () => {
8+
const data = [
9+
{ x: 10, y: 20 },
10+
{ x: 30, y: 40 },
11+
{ x: 50, y: 60 },
12+
];
13+
14+
const { coords, count } = packScatterSeriesCoords(data, identity, identity, bounds);
15+
16+
expect(count).to.equal(3);
17+
expect(coords.length).to.equal(9);
18+
// Slot i holds [x, y, visible] for dataIndex i.
19+
expect(Array.from(coords)).to.deep.equal([10, 20, 1, 30, 40, 1, 50, 60, 1]);
20+
});
21+
22+
it('flags off-screen points invisible but keeps their slot', () => {
23+
const data = [
24+
{ x: 10, y: 20 }, // inside
25+
{ x: 200, y: 20 }, // x past xMax
26+
{ x: 10, y: -5 }, // y below yMin
27+
];
28+
29+
const { coords, count } = packScatterSeriesCoords(data, identity, identity, bounds);
30+
31+
expect(count).to.equal(3);
32+
expect(coords[2]).to.equal(1);
33+
expect(coords[5]).to.equal(0);
34+
expect(coords[8]).to.equal(0);
35+
// Coordinates are stored even for invisible points (slot preserved).
36+
expect(coords[3]).to.equal(200);
37+
expect(coords[7]).to.equal(-5);
38+
});
39+
40+
it('treats the bounds as inclusive on both edges', () => {
41+
const data = [
42+
{ x: 0, y: 0 },
43+
{ x: 100, y: 100 },
44+
];
45+
46+
const { coords } = packScatterSeriesCoords(data, identity, identity, bounds);
47+
48+
expect(coords[2]).to.equal(1);
49+
expect(coords[5]).to.equal(1);
50+
});
51+
52+
it('applies the position mappers', () => {
53+
const data = [{ x: 5, y: 5 }];
54+
const getX = (value: number | Date) => (value as number) * 2;
55+
const getY = (value: number | Date) => (value as number) + 1;
56+
57+
const { coords } = packScatterSeriesCoords(data, getX, getY, bounds);
58+
59+
expect(coords[0]).to.equal(10);
60+
expect(coords[1]).to.equal(6);
61+
});
62+
63+
it('returns an empty array for an empty series', () => {
64+
const { coords, count } = packScatterSeriesCoords([], identity, identity, bounds);
65+
66+
expect(count).to.equal(0);
67+
expect(coords.length).to.equal(0);
68+
});
69+
});

0 commit comments

Comments
 (0)