Skip to content
Open
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
6 changes: 4 additions & 2 deletions docs/data/charts/scatter/ScatterAsyncRenderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import * as React from 'react';
import Stack from '@mui/material/Stack';
import Button from '@mui/material/Button';
import Typography from '@mui/material/Typography';
import { ScatterChart } from '@mui/x-charts/ScatterChart';
import { ScatterChartPro } from '@mui/x-charts-pro/ScatterChartPro';
import Chance from 'chance';

const NUMBER_OF_SERIES = 3;
Expand Down Expand Up @@ -204,10 +204,12 @@ export default function ScatterAsyncRenderer() {
/>
</Stack>
<div ref={containerRef} style={{ width: '100%' }}>
<ScatterChart
<ScatterChartPro
key={runId}
series={series}
height={400}
xAxis={[{ zoom: true }]}
yAxis={[{ zoom: true }]}
// Force the renderer so the two modes are directly comparable:
// - `svg-single`: original synchronous per-item renderer.
// - `svg-progressive`: batched renderer that paints over several
Expand Down
6 changes: 4 additions & 2 deletions docs/data/charts/scatter/ScatterAsyncRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import * as React from 'react';
import Stack from '@mui/material/Stack';
import Button from '@mui/material/Button';
import Typography from '@mui/material/Typography';
import { ScatterChart } from '@mui/x-charts/ScatterChart';
import { ScatterChartPro } from '@mui/x-charts-pro/ScatterChartPro';
import Chance from 'chance';

const NUMBER_OF_SERIES = 3;
Expand Down Expand Up @@ -208,10 +208,12 @@ export default function ScatterAsyncRenderer() {
/>
</Stack>
<div ref={containerRef} style={{ width: '100%' }}>
<ScatterChart
<ScatterChartPro
key={runId}
series={series}
height={400}
xAxis={[{ zoom: true }]}
yAxis={[{ zoom: true }]}
// Force the renderer so the two modes are directly comparable:
// - `svg-single`: original synchronous per-item renderer.
// - `svg-progressive`: batched renderer that paints over several
Expand Down
2 changes: 2 additions & 0 deletions docs/data/charts/scatter/scatter.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,8 @@ The main thread stays responsive while a large dataset is being drawn.

The example below renders 20,000 points.
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.
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.
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.

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

Expand Down
32 changes: 26 additions & 6 deletions packages/x-charts/src/ScatterChart/async/ScatterAsync.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,28 +9,47 @@ import {
selectorProgressiveSeriesRevealedBatches,
type UseProgressiveRenderingSignature,
} from '../../internals/plugins/featurePlugins/useProgressiveRendering';
import {
selectorChartZoomIsInteracting,
type UseChartCartesianAxisSignature,
} from '../../internals/plugins/featurePlugins/useChartCartesianAxis';
import { selectorScatterSeriesRenderData } from './scatterRenderData.selectors';

/**
* Per-series points rendered while interacting. The first level is capped to a
* short stable `dataIndex` prefix (the cheap contiguous slice) to keep frames
* light; the rest fills in once the interaction settles. The prefix is a
* representative sample only when the data is unordered — for data sorted along
* an axis it is a spatial corner, so panning to a high-index region may show a
* partial cloud until the interaction settles.
*/
const INTERACTION_POINT_BUDGET = 2000;

/**
* @ignore - internal component.
*/
function ScatterAsync(props: ScatterProps) {
const { series, colorGetter, onItemClick, slots, slotProps, classes } = props;

const store = useStore<[UseProgressiveRenderingSignature]>();
const store = useStore<[UseProgressiveRenderingSignature, UseChartCartesianAxisSignature]>();
const batchSize = store.use(selectorProgressiveBatchSize);
const revealedBatches = store.use(selectorProgressiveSeriesRevealedBatches, series.id);
// Size batches by the number of *visible* points so that zooming in (which
// shrinks the filtered set in the selector) collapses the progressive wave
// into a single tick once everything fits in one batch.
const isZoomInteracting = store.use(selectorChartZoomIsInteracting);
const renderData = store.use(selectorScatterSeriesRenderData, series.id);
// Batch by `dataIndex` range, not by visible count: fixes a point's batch
// across zoom/pan so it can't pop. Off-screen points skipped at render time.
const count = renderData?.count ?? 0;
const nBatches = count === 0 ? 0 : Math.ceil(count / Math.max(1, batchSize));
// Only the first level shows while interacting; skip mounting the rest (empty
// `<g>` still re-renders every frame, bypassing `React.memo`).
const mountedBatches = isZoomInteracting ? Math.min(1, nBatches) : nBatches;

const batches: React.ReactNode[] = [];
for (let b = 0; b < nBatches; b += 1) {
for (let b = 0; b < mountedBatches; b += 1) {
const start = b * batchSize;
const end = Math.min(count, start + batchSize);
// Shrink the first level to a smaller stable prefix while interacting.
const cap = isZoomInteracting ? Math.min(batchSize, INTERACTION_POINT_BUDGET) : batchSize;
const end = Math.min(count, start + cap);
batches.push(
<ScatterAsyncBatch
key={b}
Expand All @@ -43,6 +62,7 @@ function ScatterAsync(props: ScatterProps) {
end={end}
classes={classes}
revealed={b < revealedBatches}
isInteracting={isZoomInteracting}
/>,
);
}
Expand Down
31 changes: 21 additions & 10 deletions packages/x-charts/src/ScatterChart/async/ScatterAsyncBatch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,16 +29,21 @@ export interface ScatterAsyncBatchProps extends Pick<
> {
series: DefaultizedScatterSeriesType;
colorGetter: ColorGetter<'scatter'>;
/** First point index of this batch (inclusive). */
/** First `dataIndex` of this batch (inclusive). */
start: number;
/** Last point index of this batch (exclusive). */
/** Last `dataIndex` of this batch (exclusive). */
end: number;
/**
* Whether this batch is allowed to render its markers yet. `ScatterAsync`
* ramps this up batch by batch across animation frames for a progressive
* paint. When `false` the `<g>` still mounts but stays empty.
* Whether this batch may render its markers yet. Ramped batch by batch across
* frames for the progressive paint. When `false` the `<g>` mounts empty.
*/
revealed: boolean;
/**
* Whether a zoom/pan interaction is in progress. While interacting, per-marker
* highlight state and interaction handlers are skipped: useless mid-drag and
* the dominant per-frame cost.
*/
isInteracting?: boolean;
}

/**
Expand All @@ -54,6 +59,7 @@ function ScatterAsyncBatchComponent(props: ScatterAsyncBatchProps) {
start,
end,
revealed,
isInteracting,
classes: inClasses,
} = props;

Expand Down Expand Up @@ -94,12 +100,16 @@ function ScatterAsyncBatchComponent(props: ScatterAsyncBatchProps) {
const markers: React.ReactNode[] = [];
const nLocal = view.length / 3;
for (let local = 0; local < nLocal; local += 1) {
// Skip off-screen points (kept in-array to keep batches stable across pan).
if (view[local * 3 + 2] === 0) {
continue;
}
const x = view[local * 3];
const y = view[local * 3 + 1];
const dataIndex = view[local * 3 + 2];
const dataIndex = start + local;

const dataPoint = { x, y, dataIndex, seriesId: series.id, type: 'scatter' as const };
const highlightState = getHighlightState(dataPoint);
const highlightState = isInteracting ? 'none' : getHighlightState(dataPoint);
const isItemHighlighted = highlightState === 'highlighted';
const isItemFaded = highlightState === 'faded';

Expand All @@ -124,7 +134,9 @@ function ScatterAsyncBatchComponent(props: ScatterAsyncBatchProps) {
}
data-highlighted={isItemHighlighted || undefined}
data-faded={isItemFaded || undefined}
{...(skipInteractionHandlers ? undefined : getInteractionItemProps(instance, dataPoint))}
{...(skipInteractionHandlers || isInteracting
? undefined
: getInteractionItemProps(instance, dataPoint))}
{...markerProps}
/>,
);
Expand All @@ -137,8 +149,7 @@ function ScatterAsyncBatchComponent(props: ScatterAsyncBatchProps) {
);
}

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

export { ScatterAsyncBatch };
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import {
getScatterBatchView,
packScatterSeriesCoords,
type ScatterSeriesRenderData,
} from './scatterRenderData.selectors';

const identity = (value: number | Date) => value as number;
const bounds = { xMin: 0, xMax: 100, yMin: 0, yMax: 100 };

describe('packScatterSeriesCoords', () => {
it('packs every point into a dataIndex slot (stride 3)', () => {
const data = [
{ x: 10, y: 20 },
{ x: 30, y: 40 },
{ x: 50, y: 60 },
];

const { coords, count } = packScatterSeriesCoords(data, identity, identity, bounds);

expect(count).to.equal(3);
expect(coords.length).to.equal(9);
// Slot i holds [x, y, visible] for dataIndex i.
expect(Array.from(coords)).to.deep.equal([10, 20, 1, 30, 40, 1, 50, 60, 1]);
});

it('flags off-screen points invisible but keeps their slot', () => {
const data = [
{ x: 10, y: 20 }, // inside
{ x: 200, y: 20 }, // x past xMax
{ x: 10, y: -5 }, // y below yMin
];

const { coords, count } = packScatterSeriesCoords(data, identity, identity, bounds);

expect(count).to.equal(3);
expect(coords[2]).to.equal(1);
expect(coords[5]).to.equal(0);
expect(coords[8]).to.equal(0);
// Coordinates are stored even for invisible points (slot preserved).
expect(coords[3]).to.equal(200);
expect(coords[7]).to.equal(-5);
});

it('treats the bounds as inclusive on both edges', () => {
const data = [
{ x: 0, y: 0 },
{ x: 100, y: 100 },
];

const { coords } = packScatterSeriesCoords(data, identity, identity, bounds);

expect(coords[2]).to.equal(1);
expect(coords[5]).to.equal(1);
});

it('applies the position mappers', () => {
const data = [{ x: 5, y: 5 }];
const getX = (value: number | Date) => (value as number) * 2;
const getY = (value: number | Date) => (value as number) + 1;

const { coords } = packScatterSeriesCoords(data, getX, getY, bounds);

expect(coords[0]).to.equal(10);
expect(coords[1]).to.equal(6);
});

it('returns an empty array for an empty series', () => {
const { coords, count } = packScatterSeriesCoords([], identity, identity, bounds);

expect(count).to.equal(0);
expect(coords.length).to.equal(0);
});
});

describe('getScatterBatchView', () => {
// coords for dataIndex 0..3
const renderData: ScatterSeriesRenderData = {
coords: Float64Array.from([0, 0, 1, 1, 1, 1, 2, 2, 1, 3, 3, 0]),
count: 4,
};

it('returns the stride-3 slice for the dataIndex range [start, end)', () => {
const view = getScatterBatchView(renderData, 1, 3);

expect(view.length).to.equal(6);
expect(Array.from(view)).to.deep.equal([1, 1, 1, 2, 2, 1]);
});

it('shares the underlying buffer (zero-copy)', () => {
const view = getScatterBatchView(renderData, 0, 1);

expect(view.buffer).to.equal(renderData.coords.buffer);
});

it('returns an empty view for an empty range', () => {
expect(getScatterBatchView(renderData, 2, 2).length).to.equal(0);
});
});
Loading
Loading