[charts] Stable progressive scatter batching + responsive zoom/pan#22862
Conversation
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.
Deploy previewBundle size
PerformanceTotal duration: 1,791.17 ms -52.28 ms(-2.8%) | Renders: 63 (+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.
| export function packScatterSeriesCoords( | ||
| data: readonly { x: number | Date; y: number | Date }[], | ||
| getXPosition: (value: number | Date) => number, | ||
| getYPosition: (value: number | Date) => number, | ||
| bounds: ScatterVisibilityBounds, | ||
| ): ScatterSeriesRenderData { |
There was a problem hiding this comment.
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
| * 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. */ |
There was a problem hiding this comment.
I'm realising I don't know where this formula come from
| /** 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; |
There was a problem hiding this comment.
The Math.min is already applied on next line
| const effectiveRevealed = isInteracting ? Math.min(1, total) : revealed; | |
| const effectiveRevealed = isInteracting ? 1 : revealed; |
| /** | ||
| * 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; |
There was a problem hiding this comment.
A solution can be to batch evenly across the entire data instead of just the begining.
- compute the number of batch to render
nbBatch = Math.ceil(data.length/batchSize) - 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)
27367f9 to
ace11cf
Compare
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).
…ive-scatter-zoom-perf
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.
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:
ScatterAsyncbatched over the viewport-filtered coords array (batch membership = how many lower-dataIndexpoints 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
dataIndexranges and decide visibility per point at render time.scatterRenderData.selectors: packedFloat64Arrayis now indexed bydataIndex, 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
dataIndexprefix (INTERACTION_POINT_BUDGETper 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