Skip to content

[charts] Stable progressive scatter batching + responsive zoom/pan#22862

Merged
JCQuintas merged 18 commits into
mui:masterfrom
JCQuintas:progressive-scatter-zoom-perf
Jun 19, 2026
Merged

[charts] Stable progressive scatter batching + responsive zoom/pan#22862
JCQuintas merged 18 commits into
mui:masterfrom
JCQuintas:progressive-scatter-zoom-perf

Conversation

@JCQuintas

@JCQuintas JCQuintas commented Jun 17, 2026

Copy link
Copy Markdown
Member

Progressive scatter: stable batching + responsive zoom/pan

Builds on the progressive scatter renderer to fix point popping while panning and keep interactions responsive.

Fix: points popping in/out while panning

Root cause: ScatterAsync batched over the viewport-filtered coords array (batch membership = how many lower-dataIndex points were currently visible), while the scheduler sizes batches over the total point count. As the visible set shifted each pan frame, a point's batch changed → mid-screen popping.

Fix: batch over dataIndex ranges and decide visibility per point at render time.

  • scatterRenderData.selectors: packed Float64Array is now indexed by dataIndex, stride 3 [x, y, visible]. Off-screen points keep their slot instead of being dropped, so a point's batch is fixed across zoom/pan.
  • ScatterAsyncBatch: skips points flagged invisible; dataIndex = start + local.
  • ScatterAsync: batch count is total-based, matching the scheduler.

Perf: cheaper interaction frames

While interacting, the first level is capped to a short stable dataIndex prefix (INTERACTION_POINT_BUDGET per series) instead of the full batch, cutting per-frame <circle> reconciliation. The rest fills in once the interaction settles. Stable prefix = no popping; a uniform sample for unsorted data.

Docs

Progressive scatter demo is now zoomable to exercise the interaction path.

Closes #22817
Closes #22731

Only the first level of the progressive scatter render is ever visible
during a zoom/pan interaction, and the per-frame work is minimized:

- `selectorProgressiveSeriesRevealedBatches` clamps the revealed batches
  to the first level while interacting, so no frame can show more than
  the first level regardless of how far the reveal had progressed.
- The scheduler resets the revealed rounds to the first level and pauses
  while interacting, then resumes the progressive fill from there once
  the interaction settles (after a short inactivity delay).
- `ScatterAsync` mounts only the first batch while interacting; the other
  batches would otherwise render an empty `<g>` yet still re-render every
  frame, since their store subscription bypasses `React.memo`.
- `ScatterAsyncBatch` skips the per-marker highlight state and interaction
  handlers while interacting; they are useless mid-drag and were the
  dominant per-frame cost for large datasets.
While zooming/panning, render only a short stable dataIndex prefix
(INTERACTION_POINT_BUDGET points per series) for the first level instead
of the full batch, cutting per-frame element reconciliation. The rest
fills in once the interaction settles.
@JCQuintas JCQuintas requested a review from alexfauquette as a code owner June 17, 2026 13:35
@JCQuintas JCQuintas self-assigned this Jun 17, 2026
@JCQuintas JCQuintas added scope: charts Changes related to the charts. performance type: enhancement It’s an improvement, but we can’t make up our mind whether it's a bug fix or a new feature. labels Jun 17, 2026
@code-infra-dashboard

code-infra-dashboard Bot commented Jun 17, 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 🔺+474B(+0.12%) 🔺+194B(+0.16%)
@mui/x-charts-pro 🔺+486B(+0.09%) 🔺+187B(+0.12%)
@mui/x-charts-premium 🔺+471B(+0.08%) 🔺+197B(+0.10%)
@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: 1,791.17 ms -52.28 ms(-2.8%) | Renders: 63 (+0)

Test Duration Renders
FunnelChart with big data amount 6.24 ms 🔺+1.12 ms(+21.8%) 2 (+0)

25 tests within noise — details


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

…tion

Collapsing the revealed rounds to the first level on every zoom/pan
hid already-painted points for the whole gesture and forced a settle-
delayed re-reveal on each pan, flickering on repeated panning. Skip the
collapse when the wave is already complete; ScatterAsync still caps the
mounted batches while interacting, so the gesture stays cheap.
…l clamp

Extract pure helpers (packScatterSeriesCoords, getRevealedBatchCount) from the
selectors so the dataIndex-indexed packing, visibility flag, batch-view range
math, and interaction clamp are covered by unit tests.
Comment on lines +87 to +92
export function packScatterSeriesCoords(
data: readonly { x: number | Date; y: number | Date }[],
getXPosition: (value: number | Date) => number,
getYPosition: (value: number | Date) => number,
bounds: ScatterVisibilityBounds,
): ScatterSeriesRenderData {

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.

This is ever so slightly less speeeeed than the previous iteration.

New implementation stores coords for all items instead of only visible range, but since interacting got faster (due to rendering only first batch), this is technically a net improvement

This "architectural" change is what makes the panning stable when zoomed in.

You can check an "unstable" panning on the PR in #22817

Unstable

Screen.Recording.2026-06-17.at.16.50.22.mov

Stable

Screen.Recording.2026-06-17.at.16.51.52.mov

@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.

Clean 👍

* single synchronous render (where `C` is the number of commits). Targeting
* `5` commits keeps the progressive paint at roughly 2–3× the sync render time.
*/
/** Target reveal commits. Progressive wall time ≈ `(C + 1) / 2` × a sync render. */

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 realising I don't know where this formula come from

Suggested change
/** Target reveal commits. Progressive wall time ≈ `(C + 1) / 2` × a sync render. */
/** Target reveal commits. Progressive wall time ≈ `(nb_commits +1) / 2` × a sync render. */

revealed: number,
isInteracting: boolean | undefined,
): number {
const effectiveRevealed = isInteracting ? Math.min(1, total) : revealed;

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 Math.min is already applied on next line

Suggested change
const effectiveRevealed = isInteracting ? Math.min(1, total) : revealed;
const effectiveRevealed = isInteracting ? 1 : revealed;

Comment on lines +18 to +26
/**
* 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;

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.

A solution can be to batch evenly across the entire data instead of just the begining.

  1. compute the number of batch to render nbBatch = Math.ceil(data.length/batchSize)
  2. pick point when dataIndex % nbBatch === batchINdex

For bar chart we would need an option to swich from one startegy to another because it has a visual impact. For scatter I don't see drawbacks

Instead of having the scatter view as a sub array, it could return parameters indicating from which index and with which step the data should be rendered. And a renderer would do something like for(let locale=startIndex; 3*locale < data.lenght; locale += step)

@JCQuintas JCQuintas force-pushed the progressive-scatter-zoom-perf branch from 27367f9 to ace11cf Compare June 19, 2026 09:07
Batch by dataIndex stride (batch b = every nBatches-th point from b) instead
of contiguous ranges, so each batch — including the first level shown while
interacting — is a uniform sample of the whole series regardless of data
order. Removes the sorted-data 'spatial corner' caveat of the prefix approach.

- ScatterAsyncBatch takes start/step and reads coords directly (drops the
  contiguous subarray view).
- Interaction draws one uniform sample of ~INTERACTION_POINT_BUDGET points;
  step derives from the total (constant) point count, so the sample is stable
  across pan.
- Add a browser test asserting the progressive paint completes (all points).
The interacting sample used step ceil(count/BUDGET) while the first revealed
batch used step nBatches, so the two were different point sets and the
rendered points jumped when an interaction started/ended. Derive the
interaction step as a multiple of nBatches (getInteractionStep) so the
interacting points are a subset of batch 0: settling only adds points, the
already-drawn ones stay put.
@JCQuintas JCQuintas merged commit 659fced into mui:master Jun 19, 2026
21 checks passed
@JCQuintas JCQuintas deleted the progressive-scatter-zoom-perf branch June 19, 2026 10:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

performance scope: charts Changes related to the charts. type: enhancement It’s an improvement, but we can’t make up our mind whether it's a bug fix or a new feature.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[charts] Improve progressive scatter zoom

2 participants