Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
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
61 changes: 61 additions & 0 deletions packages/x-charts/src/ScatterChart/async/ScatterAsync.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import * as React from 'react';
import { createRenderer, waitFor } from '@mui/internal-test-utils';
import { ScatterChart } from '@mui/x-charts/ScatterChart';
import { isJSDOM } from 'test/utils/skipIf';
import { getInteractionStep } from './ScatterAsync';

describe('getInteractionStep', () => {
it('is a multiple of nBatches, so the sample is a subset of batch 0', () => {
expect(getInteractionStep(60000, 6, 2000) % 6).to.equal(0);
expect(getInteractionStep(100000, 10, 2000) % 10).to.equal(0);
});

it('equals the batch-0 stride when batch 0 already fits the budget', () => {
// count / nBatches = 1000 points in batch 0 <= budget, no coarsening.
expect(getInteractionStep(6000, 6, 2000)).to.equal(6);
});

it('coarsens beyond the batch-0 stride to stay within the budget', () => {
// Batch 0 would be 12000 points; coarsen by 6x to land near 2000.
const step = getInteractionStep(120000, 10, 2000);
expect(step % 10).to.equal(0);
expect(120000 / step).to.be.at.most(2000);
});

it('returns 1 when there are no batches', () => {
expect(getInteractionStep(0, 0, 2000)).to.equal(1);
});
});

// rAF-driven progressive reveal: browser only.
describe.skipIf(isJSDOM)('ScatterAsync - progressive renderer', () => {
const { render } = createRenderer();

const POINT_COUNT = 2500;
const data = Array.from({ length: POINT_COUNT }, (_, i) => ({
id: i,
x: i % 100,
y: Math.floor(i / 100),
}));

const props = {
series: [{ data }],
xAxis: [{ position: 'none' }],
yAxis: [{ position: 'none' }],
width: 200,
height: 200,
margin: 0,
// Force the progressive renderer regardless of point count.
renderer: 'svg-progressive',
} as const;

it('progressively paints every point across reveal frames', async () => {
const { container } = render(<ScatterChart {...props} />);

// The reveal ramps across animation frames; every point is in the drawing
// area, so the stride-based batches' union completes with one circle per point.
await waitFor(() => {
expect(container.querySelectorAll('circle').length).to.equal(POINT_COUNT);
});
});
});
44 changes: 34 additions & 10 deletions packages/x-charts/src/ScatterChart/async/ScatterAsync.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,28 +9,51 @@ 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 sampled while interacting; the rest fills in on settle. */
const INTERACTION_POINT_BUDGET = 2000;

/**
* Interacting sample stride. Multiple of `nBatches` (batch 0's stride) so the
* sample is a subset of batch 0 — settling only adds points, no jump. Coarsened
* to stay within `budget`.
*/
export function getInteractionStep(count: number, nBatches: number, budget: number): number {
if (nBatches <= 0) {
return 1;
}
return nBatches * Math.max(1, Math.ceil(count / (budget * nBatches)));
}

/**
* @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);
const count = renderData?.count ?? 0;
// Batch `b` = every `nBatches`-th point from `b`: a uniform sample whose
// membership depends only on `dataIndex` (stable across zoom/pan, no popping).
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;
// `count` (total points) is constant across zoom/pan, so the sampled set is
// stable while panning.
const interactionStep = getInteractionStep(count, nBatches, INTERACTION_POINT_BUDGET);

const batches: React.ReactNode[] = [];
for (let b = 0; b < nBatches; b += 1) {
const start = b * batchSize;
const end = Math.min(count, start + batchSize);
for (let b = 0; b < mountedBatches; b += 1) {
batches.push(
<ScatterAsyncBatch
key={b}
Expand All @@ -39,10 +62,11 @@ function ScatterAsync(props: ScatterProps) {
onItemClick={onItemClick}
slots={slots}
slotProps={slotProps}
start={start}
end={end}
start={isZoomInteracting ? 0 : b}
step={isZoomInteracting ? interactionStep : nBatches}
classes={classes}
revealed={b < revealedBatches}
revealed={isZoomInteracting || b < revealedBatches}
isInteracting={isZoomInteracting}
/>,
);
}
Expand Down
49 changes: 28 additions & 21 deletions packages/x-charts/src/ScatterChart/async/ScatterAsyncBatch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,27 +18,29 @@ import { type UseChartTooltipSignature } from '../../internals/plugins/featurePl
import { type UseChartInteractionSignature } from '../../internals/plugins/featurePlugins/useChartInteraction';
import { type UseChartHighlightSignature } from '../../internals/plugins/featurePlugins/useChartHighlight';
import { type ScatterProps } from '../Scatter';
import {
getScatterBatchView,
selectorScatterSeriesRenderData,
} from './scatterRenderData.selectors';
import { selectorScatterSeriesRenderData } from './scatterRenderData.selectors';

export interface ScatterAsyncBatchProps extends Pick<
ScatterProps,
'series' | 'colorGetter' | 'onItemClick' | 'slots' | 'slotProps' | 'classes'
> {
series: DefaultizedScatterSeriesType;
colorGetter: ColorGetter<'scatter'>;
/** First point index of this batch (inclusive). */
/** First `dataIndex` this batch renders. */
start: number;
/** Last point index of this batch (exclusive). */
end: number;
/** Stride between rendered `dataIndex`es, so the batch is a uniform sample. */
step: 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 @@ -52,8 +54,9 @@ function ScatterAsyncBatchComponent(props: ScatterAsyncBatchProps) {
slots,
slotProps,
start,
end,
step,
revealed,
isInteracting,
classes: inClasses,
} = props;

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

const view = getScatterBatchView(renderData, start, end);
const { coords, count } = renderData;

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

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 +130,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 +145,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,69 @@
import { packScatterSeriesCoords } 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);
});
});
Loading
Loading