From 9885cc5b4633c484cd0edc6c9f736766186b34cb Mon Sep 17 00:00:00 2001 From: Jose Quintas Date: Mon, 15 Jun 2026 12:47:58 +0200 Subject: [PATCH 01/11] [charts] Keep progressive scatter responsive while zooming/panning 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 `` 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. --- .../src/ScatterChart/async/ScatterAsync.tsx | 14 +++- .../ScatterChart/async/ScatterAsyncBatch.tsx | 14 +++- .../useProgressiveRendering.selectors.ts | 12 +++- .../useProgressiveRendering.ts | 64 +++++++++++++++++-- 4 files changed, 92 insertions(+), 12 deletions(-) diff --git a/packages/x-charts/src/ScatterChart/async/ScatterAsync.tsx b/packages/x-charts/src/ScatterChart/async/ScatterAsync.tsx index 5832644c76652..c80eb1a504466 100644 --- a/packages/x-charts/src/ScatterChart/async/ScatterAsync.tsx +++ b/packages/x-charts/src/ScatterChart/async/ScatterAsync.tsx @@ -9,6 +9,10 @@ import { selectorProgressiveSeriesRevealedBatches, type UseProgressiveRenderingSignature, } from '../../internals/plugins/featurePlugins/useProgressiveRendering'; +import { + selectorChartZoomIsInteracting, + type UseChartCartesianAxisSignature, +} from '../../internals/plugins/featurePlugins/useChartCartesianAxis'; import { selectorScatterSeriesRenderData } from './scatterRenderData.selectors'; /** @@ -17,18 +21,23 @@ import { selectorScatterSeriesRenderData } from './scatterRenderData.selectors'; 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); + const isZoomInteracting = store.use(selectorChartZoomIsInteracting); // 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 renderData = store.use(selectorScatterSeriesRenderData, series.id); const count = renderData?.count ?? 0; const nBatches = count === 0 ? 0 : Math.ceil(count / Math.max(1, batchSize)); + // While zooming/panning only the first level is ever visible, so don't even + // mount the other batches: their `` would stay empty yet still re-render + // on every interaction frame (the store subscription bypasses `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); batches.push( @@ -43,6 +52,7 @@ function ScatterAsync(props: ScatterProps) { end={end} classes={classes} revealed={b < revealedBatches} + isInteracting={isZoomInteracting} />, ); } diff --git a/packages/x-charts/src/ScatterChart/async/ScatterAsyncBatch.tsx b/packages/x-charts/src/ScatterChart/async/ScatterAsyncBatch.tsx index 75c387044206c..eb98718cb80fd 100644 --- a/packages/x-charts/src/ScatterChart/async/ScatterAsyncBatch.tsx +++ b/packages/x-charts/src/ScatterChart/async/ScatterAsyncBatch.tsx @@ -39,6 +39,13 @@ export interface ScatterAsyncBatchProps extends Pick< * paint. When `false` the `` still mounts but stays empty. */ revealed: boolean; + /** + * Whether a zoom/pan interaction is in progress. While interacting the + * per-marker highlight state and interaction handlers are skipped: they are + * useless mid-drag (no hover/tooltip) and recomputing them for every visible + * point on every frame is the dominant cost of the interaction. + */ + isInteracting?: boolean; } /** @@ -54,6 +61,7 @@ function ScatterAsyncBatchComponent(props: ScatterAsyncBatchProps) { start, end, revealed, + isInteracting, classes: inClasses, } = props; @@ -99,7 +107,7 @@ function ScatterAsyncBatchComponent(props: ScatterAsyncBatchProps) { const dataIndex = view[local * 3 + 2]; 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'; @@ -124,7 +132,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} />, ); diff --git a/packages/x-charts/src/internals/plugins/featurePlugins/useProgressiveRendering/useProgressiveRendering.selectors.ts b/packages/x-charts/src/internals/plugins/featurePlugins/useProgressiveRendering/useProgressiveRendering.selectors.ts index 8e22856f259d0..6b31148fca889 100644 --- a/packages/x-charts/src/internals/plugins/featurePlugins/useProgressiveRendering/useProgressiveRendering.selectors.ts +++ b/packages/x-charts/src/internals/plugins/featurePlugins/useProgressiveRendering/useProgressiveRendering.selectors.ts @@ -7,6 +7,7 @@ import { selectorChartExperimentalFeaturesState, type UseChartExperimentalFeaturesSignature, } from '../../corePlugins/useChartExperimentalFeature'; +import { selectorChartZoomIsInteracting } from '../useChartCartesianAxis'; import { type ChartState } from '../../models/chart'; import { type ChartOptionalRootSelector } from '../../utils/selectors'; import { type UseProgressiveRenderingSignature } from './useProgressiveRendering.types'; @@ -140,12 +141,19 @@ export const selectorProgressiveTotalRounds = createSelector( * How many of `seriesId`'s own batches are revealed so far. Capped at that * series' total batch count, so series with fewer batches simply stop * progressing while longer ones keep filling in. + * + * While a zoom/pan interaction is in progress, this is clamped to the first + * batch: only the first level is ever visible during the interaction, + * regardless of how many rounds the scheduler had revealed before it started. */ export const selectorProgressiveSeriesRevealedBatches = createSelector( selectorProgressiveAggregate, selectorProgressiveRevealedRounds, - function selectorProgressiveSeriesRevealedBatches(agg, revealed, seriesId: SeriesId) { - return Math.min(revealed, agg.nBatchesBySeries.get(seriesId) ?? 0); + selectorChartZoomIsInteracting, + function selectorProgressiveSeriesRevealedBatches(agg, revealed, isInteracting, seriesId: SeriesId) { + const total = agg.nBatchesBySeries.get(seriesId) ?? 0; + const effectiveRevealed = isInteracting ? Math.min(1, total) : revealed; + return Math.min(effectiveRevealed, total); }, ); diff --git a/packages/x-charts/src/internals/plugins/featurePlugins/useProgressiveRendering/useProgressiveRendering.ts b/packages/x-charts/src/internals/plugins/featurePlugins/useProgressiveRendering/useProgressiveRendering.ts index 6435d77585867..83e205ef5bf61 100644 --- a/packages/x-charts/src/internals/plugins/featurePlugins/useProgressiveRendering/useProgressiveRendering.ts +++ b/packages/x-charts/src/internals/plugins/featurePlugins/useProgressiveRendering/useProgressiveRendering.ts @@ -7,9 +7,11 @@ import { type UseProgressiveRenderingSignature } from './useProgressiveRendering import { sameSeriesIds, selectorProgressivePlans, + selectorProgressiveRevealedRounds, selectorProgressiveTotalRounds, selectorShouldUseProgressiveRenderer, } from './useProgressiveRendering.selectors'; +import { selectorChartZoomIsInteracting } from '../useChartCartesianAxis'; import type { RendererType } from '../../../../ScatterChart'; const EMPTY_PLANS: ReadonlyMap = new Map(); @@ -31,6 +33,21 @@ const REVEAL_ROUNDS_PER_FRAME = 1; */ const REVEAL_FRAMES_SKIPPED = 0; +/** + * Number of rounds kept visible during a zoom/pan interaction. Only the first + * level is ever painted while interacting so the interaction stays fluid; the + * remaining rounds resume once the interaction settles. + */ +const INTERACTION_REVEALED_ROUNDS = 1; + +/** + * How long the chart must stay free of zoom/pan interaction before the + * progressive reveal of the remaining rounds resumes. The pro zoom plugin + * already debounces the end of an interaction, so this only adds a small extra + * settle window on top of that. + */ +const RESUME_AFTER_INTERACTION_DELAY = 200; + /** * Chart-wide progressive rendering coordinator. * @@ -76,12 +93,28 @@ export const useProgressiveRendering: ChartPlugin { const startTotal = selectorProgressiveTotalRounds(store.state); if (startTotal === 0) { return undefined; } + + // While the user zooms/pans, keep only the first level revealed and pause + // the reveal so the interaction stays fluid. Resetting here also restarts + // the progressive wave from the first level once the interaction ends. + if (isZoomInteracting) { + const target = Math.min(INTERACTION_REVEALED_ROUNDS, startTotal); + if (selectorProgressiveRevealedRounds(store.state) !== target) { + store.set('progressiveRendering', { + ...store.state.progressiveRendering, + revealedRounds: target, + }); + } + return undefined; + } + if (typeof requestAnimationFrame !== 'function') { store.set('progressiveRendering', { ...store.state.progressiveRendering, @@ -91,11 +124,14 @@ export const useProgressiveRendering: ChartPlugin | undefined; let cancelled = false; - // Tracked in a closure variable, not derived inside a state updater (those - // must be pure and StrictMode double-invokes them, which would schedule - // the animation-frame chain twice). - let revealed = 0; + // Resume from the rounds already revealed rather than restarting from zero, + // so coming out of a zoom/pan keeps the first level on screen and only + // fills in the rest. Tracked in a closure variable, not derived inside a + // state updater (those must be pure and StrictMode double-invokes them, + // which would schedule the animation-frame chain twice). + let revealed = selectorProgressiveRevealedRounds(store.state); function scheduleNext() { let remaining = REVEAL_FRAMES_SKIPPED; @@ -128,13 +164,29 @@ export const useProgressiveRendering: ChartPlugin= startTotal) { + return undefined; + } + + // A partially revealed paint (`revealed > 0`) means we are resuming after a + // zoom/pan reset, so wait out the inactivity delay before filling in the + // rest. The initial paint (`revealed === 0`) starts immediately. + if (revealed > 0) { + resumeTimeout = setTimeout(() => { + frame = requestAnimationFrame(step); + }, RESUME_AFTER_INTERACTION_DELAY); + } else { + frame = requestAnimationFrame(step); + } return () => { cancelled = true; cancelAnimationFrame(frame); + if (resumeTimeout !== undefined) { + clearTimeout(resumeTimeout); + } }; - }, [plans, store]); + }, [plans, isZoomInteracting, store]); return { instance: { registerProgressivePlan } }; }; From dd769a6b072109660c9d0d20e94866782a4973d6 Mon Sep 17 00:00:00 2001 From: Jose Quintas Date: Mon, 15 Jun 2026 12:47:59 +0200 Subject: [PATCH 02/11] [docs] Make progressive scatter demo zoomable --- docs/data/charts/scatter/ScatterAsyncRenderer.js | 8 ++++++-- docs/data/charts/scatter/ScatterAsyncRenderer.tsx | 8 ++++++-- docs/data/charts/scatter/scatter.md | 1 + 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/docs/data/charts/scatter/ScatterAsyncRenderer.js b/docs/data/charts/scatter/ScatterAsyncRenderer.js index 836db3d8920c4..9506ba31e068c 100644 --- a/docs/data/charts/scatter/ScatterAsyncRenderer.js +++ b/docs/data/charts/scatter/ScatterAsyncRenderer.js @@ -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; @@ -204,10 +204,14 @@ export default function ScatterAsyncRenderer() { />
-
- Date: Tue, 16 Jun 2026 15:49:59 +0200 Subject: [PATCH 03/11] improvements --- .../src/ScatterChart/async/ScatterAsync.tsx | 13 ++--- .../ScatterChart/async/ScatterAsyncBatch.tsx | 10 ++-- .../async/scatterRenderData.selectors.ts | 47 +++++-------------- 3 files changed, 27 insertions(+), 43 deletions(-) diff --git a/packages/x-charts/src/ScatterChart/async/ScatterAsync.tsx b/packages/x-charts/src/ScatterChart/async/ScatterAsync.tsx index c80eb1a504466..d1ca2c12292b8 100644 --- a/packages/x-charts/src/ScatterChart/async/ScatterAsync.tsx +++ b/packages/x-charts/src/ScatterChart/async/ScatterAsync.tsx @@ -25,15 +25,16 @@ function ScatterAsync(props: ScatterProps) { const batchSize = store.use(selectorProgressiveBatchSize); const revealedBatches = store.use(selectorProgressiveSeriesRevealedBatches, series.id); const isZoomInteracting = store.use(selectorChartZoomIsInteracting); - // 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 renderData = store.use(selectorScatterSeriesRenderData, series.id); + // Batch over `dataIndex` ranges (matching the scheduler's total-based sizing) + // so a point's batch is fixed across zoom/pan; off-screen points are skipped + // at render time. Batching the viewport-filtered array instead makes points + // pop while panning. const count = renderData?.count ?? 0; const nBatches = count === 0 ? 0 : Math.ceil(count / Math.max(1, batchSize)); - // While zooming/panning only the first level is ever visible, so don't even - // mount the other batches: their `` would stay empty yet still re-render - // on every interaction frame (the store subscription bypasses `React.memo`). + // While interacting only the first level shows, so don't mount the rest: + // their `` stays empty yet re-renders every frame (subscription bypasses + // `React.memo`). const mountedBatches = isZoomInteracting ? Math.min(1, nBatches) : nBatches; const batches: React.ReactNode[] = []; diff --git a/packages/x-charts/src/ScatterChart/async/ScatterAsyncBatch.tsx b/packages/x-charts/src/ScatterChart/async/ScatterAsyncBatch.tsx index eb98718cb80fd..6f580d17d9d68 100644 --- a/packages/x-charts/src/ScatterChart/async/ScatterAsyncBatch.tsx +++ b/packages/x-charts/src/ScatterChart/async/ScatterAsyncBatch.tsx @@ -29,9 +29,9 @@ 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` @@ -102,9 +102,13 @@ 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 = isInteracting ? 'none' : getHighlightState(dataPoint); diff --git a/packages/x-charts/src/ScatterChart/async/scatterRenderData.selectors.ts b/packages/x-charts/src/ScatterChart/async/scatterRenderData.selectors.ts index 190b1d83a38f5..c6f0f078f07bc 100644 --- a/packages/x-charts/src/ScatterChart/async/scatterRenderData.selectors.ts +++ b/packages/x-charts/src/ScatterChart/async/scatterRenderData.selectors.ts @@ -9,30 +9,21 @@ import { } from '../../internals/plugins/featurePlugins/useChartCartesianAxis'; /** - * Pre-computed render data for a single scatter series. - * - * Coordinates are stored in a packed `Float64Array` (stride 3: `[x0, y0, i0, - * x1, y1, i1, ...]`, where `iN` is the original `dataIndex`). Only points that - * project inside the drawing area are kept, so the progressive renderer can - * size its batches by the number of *visible* points — when zoomed in tightly - * the wave finishes in a single tick. Batches are contiguous slices of this - * array, so a batch's data is obtained with a zero-copy `subarray` view (see - * {@link getScatterBatchView}). + * Render data for a single scatter series. Packed `Float64Array` indexed by + * `dataIndex` (stride 3: `[x, y, visible]`, `visible` = `1`/`0`). Off-screen + * points keep their slot so a point's batch stays fixed across zoom/pan (no + * popping); visibility is filtered per point at render time. */ export interface ScatterSeriesRenderData { - /** Packed projected pixel coordinates + dataIndex, stride 3. */ + /** Packed projected coordinates + visibility flag, stride 3. */ coords: Float64Array; - /** Number of visible points (i.e. `coords.length / 3`). */ + /** Total number of points (`coords.length / 3`). */ count: number; } const EMPTY_RENDER_DATA = new Map(); -/** - * Packed projected coordinates for every scatter series, filtered to the - * drawing area. Recomputes when the processed series, axis scales, or drawing - * area change. - */ +/** Packed projected coordinates for every scatter series, indexed by `dataIndex`. */ export const selectorScatterRenderData = createSelectorMemoized( selectorChartSeriesProcessed, selectorChartXAxis, @@ -70,24 +61,16 @@ export const selectorScatterRenderData = createSelectorMemoized( const n = data.length; const packed = new Float64Array(n * 3); - let j = 0; for (let i = 0; i < n; i += 1) { const x = getXPosition(data[i].x); - if (!(x >= xMin && x <= xMax)) { - continue; - } const y = getYPosition(data[i].y); - if (!(y >= yMin && y <= yMax)) { - continue; - } - packed[j] = x; - packed[j + 1] = y; - packed[j + 2] = i; - j += 3; + const visible = x >= xMin && x <= xMax && y >= yMin && y <= yMax; + packed[i * 3] = x; + packed[i * 3 + 1] = y; + packed[i * 3 + 2] = visible ? 1 : 0; } - const coords = packed.slice(0, j); - result.set(seriesId, { coords, count: j / 3 }); + result.set(seriesId, { coords: packed, count: n }); } return result; @@ -103,11 +86,7 @@ export const selectorScatterSeriesRenderData = createSelector( (renderData, seriesId: SeriesId) => renderData.get(seriesId), ); -/** - * Zero-copy view of one batch's coordinates. `start`/`end` are visible-point - * indices (not original `dataIndex` values). The returned `Float64Array` shares - * the buffer with `renderData.coords`. - */ +/** Zero-copy view of the `dataIndex` range `[start, end)`, sharing `coords`' buffer. */ export function getScatterBatchView( renderData: ScatterSeriesRenderData, start: number, From a708b3d6b1f3106e306a9b7ede6dd0c757a77a30 Mon Sep 17 00:00:00 2001 From: Jose Quintas Date: Tue, 16 Jun 2026 16:13:31 +0200 Subject: [PATCH 04/11] improve docs --- .../charts/scatter/ScatterAsyncRenderer.js | 3 +- .../charts/scatter/ScatterAsyncRenderer.tsx | 3 +- .../src/ScatterChart/async/ScatterAsync.tsx | 11 ++-- .../ScatterChart/async/ScatterAsyncBatch.tsx | 15 ++--- .../async/scatterRenderData.selectors.ts | 12 ++-- .../useProgressiveRendering.selectors.ts | 59 +++++-------------- .../useProgressiveRendering.ts | 58 +++++------------- 7 files changed, 47 insertions(+), 114 deletions(-) diff --git a/docs/data/charts/scatter/ScatterAsyncRenderer.js b/docs/data/charts/scatter/ScatterAsyncRenderer.js index 9506ba31e068c..189336ad6e2cc 100644 --- a/docs/data/charts/scatter/ScatterAsyncRenderer.js +++ b/docs/data/charts/scatter/ScatterAsyncRenderer.js @@ -208,8 +208,7 @@ export default function ScatterAsyncRenderer() { key={runId} series={series} height={400} - // Zoom/pan to see the progressive renderer keep only the first level - // painted while interacting, then fill in the rest once it settles. + // Zoom/pan: first level stays painted while interacting, rest fills on settle. xAxis={[{ zoom: true }]} yAxis={[{ zoom: true }]} // Force the renderer so the two modes are directly comparable: diff --git a/docs/data/charts/scatter/ScatterAsyncRenderer.tsx b/docs/data/charts/scatter/ScatterAsyncRenderer.tsx index c2aac9d4cdada..c44a72412c59c 100644 --- a/docs/data/charts/scatter/ScatterAsyncRenderer.tsx +++ b/docs/data/charts/scatter/ScatterAsyncRenderer.tsx @@ -212,8 +212,7 @@ export default function ScatterAsyncRenderer() { key={runId} series={series} height={400} - // Zoom/pan to see the progressive renderer keep only the first level - // painted while interacting, then fill in the rest once it settles. + // Zoom/pan: first level stays painted while interacting, rest fills on settle. xAxis={[{ zoom: true }]} yAxis={[{ zoom: true }]} // Force the renderer so the two modes are directly comparable: diff --git a/packages/x-charts/src/ScatterChart/async/ScatterAsync.tsx b/packages/x-charts/src/ScatterChart/async/ScatterAsync.tsx index d1ca2c12292b8..ed16ce8c56f77 100644 --- a/packages/x-charts/src/ScatterChart/async/ScatterAsync.tsx +++ b/packages/x-charts/src/ScatterChart/async/ScatterAsync.tsx @@ -26,15 +26,12 @@ function ScatterAsync(props: ScatterProps) { const revealedBatches = store.use(selectorProgressiveSeriesRevealedBatches, series.id); const isZoomInteracting = store.use(selectorChartZoomIsInteracting); const renderData = store.use(selectorScatterSeriesRenderData, series.id); - // Batch over `dataIndex` ranges (matching the scheduler's total-based sizing) - // so a point's batch is fixed across zoom/pan; off-screen points are skipped - // at render time. Batching the viewport-filtered array instead makes points - // pop while panning. + // 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)); - // While interacting only the first level shows, so don't mount the rest: - // their `` stays empty yet re-renders every frame (subscription bypasses - // `React.memo`). + // Only the first level shows while interacting; skip mounting the rest (empty + // `` still re-renders every frame, bypassing `React.memo`). const mountedBatches = isZoomInteracting ? Math.min(1, nBatches) : nBatches; const batches: React.ReactNode[] = []; diff --git a/packages/x-charts/src/ScatterChart/async/ScatterAsyncBatch.tsx b/packages/x-charts/src/ScatterChart/async/ScatterAsyncBatch.tsx index 6f580d17d9d68..7009d01c32ae0 100644 --- a/packages/x-charts/src/ScatterChart/async/ScatterAsyncBatch.tsx +++ b/packages/x-charts/src/ScatterChart/async/ScatterAsyncBatch.tsx @@ -34,16 +34,14 @@ export interface ScatterAsyncBatchProps extends Pick< /** 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 `` 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 `` mounts empty. */ revealed: boolean; /** - * Whether a zoom/pan interaction is in progress. While interacting the - * per-marker highlight state and interaction handlers are skipped: they are - * useless mid-drag (no hover/tooltip) and recomputing them for every visible - * point on every frame is the dominant cost of the interaction. + * 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; } @@ -151,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 }; diff --git a/packages/x-charts/src/ScatterChart/async/scatterRenderData.selectors.ts b/packages/x-charts/src/ScatterChart/async/scatterRenderData.selectors.ts index c6f0f078f07bc..e5ac9e6fab26b 100644 --- a/packages/x-charts/src/ScatterChart/async/scatterRenderData.selectors.ts +++ b/packages/x-charts/src/ScatterChart/async/scatterRenderData.selectors.ts @@ -9,10 +9,9 @@ import { } from '../../internals/plugins/featurePlugins/useChartCartesianAxis'; /** - * Render data for a single scatter series. Packed `Float64Array` indexed by - * `dataIndex` (stride 3: `[x, y, visible]`, `visible` = `1`/`0`). Off-screen - * points keep their slot so a point's batch stays fixed across zoom/pan (no - * popping); visibility is filtered per point at render time. + * Render data for one scatter series. Packed `Float64Array` indexed by + * `dataIndex` (stride 3: `[x, y, visible]`). Off-screen points keep their slot + * so a point's batch stays fixed across zoom/pan (no popping). */ export interface ScatterSeriesRenderData { /** Packed projected coordinates + visibility flag, stride 3. */ @@ -77,10 +76,7 @@ export const selectorScatterRenderData = createSelectorMemoized( }, ); -/** - * Render data for a single scatter series, or `undefined` while it is not - * available yet (processors/axes still pending). - */ +/** Render data for one series, or `undefined` while processors/axes are pending. */ export const selectorScatterSeriesRenderData = createSelector( selectorScatterRenderData, (renderData, seriesId: SeriesId) => renderData.get(seriesId), diff --git a/packages/x-charts/src/internals/plugins/featurePlugins/useProgressiveRendering/useProgressiveRendering.selectors.ts b/packages/x-charts/src/internals/plugins/featurePlugins/useProgressiveRendering/useProgressiveRendering.selectors.ts index 6b31148fca889..0c1c17b29f82b 100644 --- a/packages/x-charts/src/internals/plugins/featurePlugins/useProgressiveRendering/useProgressiveRendering.selectors.ts +++ b/packages/x-charts/src/internals/plugins/featurePlugins/useProgressiveRendering/useProgressiveRendering.selectors.ts @@ -13,40 +13,22 @@ import { type ChartOptionalRootSelector } from '../../utils/selectors'; import { type UseProgressiveRenderingSignature } from './useProgressiveRendering.types'; import type { RendererType } from '../../../../ScatterChart'; -/** - * Total point count above which the auto (`renderer` unset) mode switches to - * the progressive renderer. Below it the synchronous renderer is used, since - * the progressive paint's overhead is not worth it for small datasets. - */ +/** Auto mode switches to the progressive renderer above this total point count. */ const PROGRESSIVE_POINT_THRESHOLD = 20000; -/** - * Target number of reveal commits. Each commit repaints every already-painted - * circle, so the total progressive wall time is roughly `(C + 1) / 2` times a - * 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. */ const TARGET_PROGRESSIVE_COMMITS = 5; -/** - * Lower bound for the per-tick reveal budget (total points across every - * series). Prevents tiny commits whose React overhead would dominate. - */ +/** Min per-tick reveal budget (total points). Avoids tiny React-bound commits. */ const MIN_BATCH_TOTAL = 1000; -/** - * Upper bound for the per-tick reveal budget (total points across every - * series). Prevents a single commit from blocking the main thread for too - * long; very large datasets simply use more commits. - */ +/** Max per-tick reveal budget (total points). Caps main-thread block per commit. */ const MAX_BATCH_TOTAL = 10000; /** - * Per-series points revealed per tick, derived from the total point count - * across visible series and the number of visible series. The total per-tick - * budget aims for {@link TARGET_PROGRESSIVE_COMMITS} commits, clamped by - * {@link MIN_BATCH_TOTAL} / {@link MAX_BATCH_TOTAL}, then split evenly across - * the visible series so every series progresses together. + * Per-series points per tick: total budget aims for + * {@link TARGET_PROGRESSIVE_COMMITS} commits, clamped by + * {@link MIN_BATCH_TOTAL}/{@link MAX_BATCH_TOTAL}, split evenly across series. */ const getEffectiveBatchSize = (nSeries: number, totalPoints: number) => { const safeSeries = Math.max(1, nSeries); @@ -86,10 +68,9 @@ function getSeriesPointCount(processedSeries: ProcessedSeries, seriesId: SeriesI } /** - * Aggregated view of every registered plan: the per-series batch counts, the - * total number of rounds, and the per-series batch size (so every consumer - * sizes its batches the same way). Point counts are read straight from the - * processed series rather than carried by the registration. + * Aggregate of every plan: per-series batch counts, total rounds, and batch + * size (so every consumer sizes batches the same). Point counts read from the + * processed series, not the registration. */ export const selectorProgressiveAggregate = createSelectorMemoized( selectorProgressivePlans, @@ -138,13 +119,8 @@ export const selectorProgressiveTotalRounds = createSelector( ); /** - * How many of `seriesId`'s own batches are revealed so far. Capped at that - * series' total batch count, so series with fewer batches simply stop - * progressing while longer ones keep filling in. - * - * While a zoom/pan interaction is in progress, this is clamped to the first - * batch: only the first level is ever visible during the interaction, - * regardless of how many rounds the scheduler had revealed before it started. + * How many of `seriesId`'s batches are revealed, capped at its total batch + * count. Clamped to the first batch while interacting. */ export const selectorProgressiveSeriesRevealedBatches = createSelector( selectorProgressiveAggregate, @@ -158,14 +134,11 @@ export const selectorProgressiveSeriesRevealedBatches = createSelector( ); /** - * Whether `seriesIds` should be rendered progressively given the requested - * `renderer`: - * - `svg-single` / `svg-batch`: never (those are non-progressive renderers). + * Whether `seriesIds` render progressively: + * - `svg-single`/`svg-batch`: never. * - `svg-progressive`: always. - * - unset (auto): only when the `progressiveRendering` experimental feature is - * enabled and the total point count is above - * {@link PROGRESSIVE_POINT_THRESHOLD}. The flag keeps the auto behavior - * opt-in so the default (`svg-single`) stays non-breaking. + * - unset (auto): only when the experimental feature is on and total points + * exceed {@link PROGRESSIVE_POINT_THRESHOLD}. */ const selectorProgressiveRenderingEnabled = ( state: ChartState<[UseChartExperimentalFeaturesSignature]>, diff --git a/packages/x-charts/src/internals/plugins/featurePlugins/useProgressiveRendering/useProgressiveRendering.ts b/packages/x-charts/src/internals/plugins/featurePlugins/useProgressiveRendering/useProgressiveRendering.ts index 83e205ef5bf61..739259ba7179d 100644 --- a/packages/x-charts/src/internals/plugins/featurePlugins/useProgressiveRendering/useProgressiveRendering.ts +++ b/packages/x-charts/src/internals/plugins/featurePlugins/useProgressiveRendering/useProgressiveRendering.ts @@ -16,47 +16,23 @@ import type { RendererType } from '../../../../ScatterChart'; const EMPTY_PLANS: ReadonlyMap = new Map(); -/** - * How many *rounds* are revealed per reveal tick once the render data is ready. - * A round reveals one batch in every series simultaneously, so the chart looks - * complete from the first paint rather than appearing series-by-series. Lower - * spreads the paint over more ticks (smoother, more visibly progressive); - * higher finishes sooner. - */ +/** Rounds revealed per tick. One round adds a batch to every series at once. */ const REVEAL_ROUNDS_PER_FRAME = 1; -/** - * How many animation frames are skipped between two reveal ticks. `0` reveals - * on every frame; `1` reveals every other frame, leaving the browser an idle - * frame for layout, paint and input handling between commits. Higher values - * give the browser more CPU headroom at the cost of a slower paint. - */ +/** Frames skipped between reveal ticks. `0` = every frame; higher = more headroom. */ const REVEAL_FRAMES_SKIPPED = 0; -/** - * Number of rounds kept visible during a zoom/pan interaction. Only the first - * level is ever painted while interacting so the interaction stays fluid; the - * remaining rounds resume once the interaction settles. - */ +/** Rounds kept visible during a zoom/pan interaction (first level only). */ const INTERACTION_REVEALED_ROUNDS = 1; -/** - * How long the chart must stay free of zoom/pan interaction before the - * progressive reveal of the remaining rounds resumes. The pro zoom plugin - * already debounces the end of an interaction, so this only adds a small extra - * settle window on top of that. - */ +/** Settle delay before the reveal resumes after an interaction ends (ms). */ const RESUME_AFTER_INTERACTION_DELAY = 200; /** - * Chart-wide progressive rendering coordinator. - * - * Lives on the chart store, so every renderer composed into the same chart - * (e.g. several `ScatterPlot` instances under one `ChartsContainer`) shares - * the same scheduler. Each renderer registers a plan via - * `setProgressivePlan(plotId, plan)`; the plugin aggregates them, computes a - * single per-tick budget, and ramps a global "rounds" counter — one round - * adds one batch in every registered series at once. + * Chart-wide progressive rendering coordinator. Lives on the store so every + * renderer in the chart shares one scheduler: each registers a plan via + * `registerProgressivePlan`, and the plugin ramps a global "rounds" counter, + * one round adding a batch to every series at once. */ export const useProgressiveRendering: ChartPlugin = ({ store, @@ -101,9 +77,8 @@ export const useProgressiveRendering: ChartPlugin | undefined; let cancelled = false; - // Resume from the rounds already revealed rather than restarting from zero, - // so coming out of a zoom/pan keeps the first level on screen and only - // fills in the rest. Tracked in a closure variable, not derived inside a - // state updater (those must be pure and StrictMode double-invokes them, - // which would schedule the animation-frame chain twice). + // Resume from the rounds already revealed (keeps the first level on screen). + // Closure var, not a state-updater derivation: StrictMode double-invokes + // those, scheduling the frame chain twice. let revealed = selectorProgressiveRevealedRounds(store.state); function scheduleNext() { @@ -168,9 +141,8 @@ export const useProgressiveRendering: ChartPlugin 0`) means we are resuming after a - // zoom/pan reset, so wait out the inactivity delay before filling in the - // rest. The initial paint (`revealed === 0`) starts immediately. + // `revealed > 0` means resuming after an interaction: wait the settle delay. + // Initial paint (`revealed === 0`) starts immediately. if (revealed > 0) { resumeTimeout = setTimeout(() => { frame = requestAnimationFrame(step); From 9fd8e225d43c507b6d50028f0cb64d1f2583fbf6 Mon Sep 17 00:00:00 2001 From: Jose Quintas Date: Wed, 17 Jun 2026 15:35:10 +0200 Subject: [PATCH 05/11] [charts] Cap progressive scatter interaction render to a stable budget 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. --- .../x-charts/src/ScatterChart/async/ScatterAsync.tsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/x-charts/src/ScatterChart/async/ScatterAsync.tsx b/packages/x-charts/src/ScatterChart/async/ScatterAsync.tsx index ed16ce8c56f77..93994ffa4dd27 100644 --- a/packages/x-charts/src/ScatterChart/async/ScatterAsync.tsx +++ b/packages/x-charts/src/ScatterChart/async/ScatterAsync.tsx @@ -15,6 +15,13 @@ import { } 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 (a uniform sample for unsorted data) to keep + * frames cheap; the rest fills in once the interaction settles. + */ +const INTERACTION_POINT_BUDGET = 2000; + /** * @ignore - internal component. */ @@ -37,7 +44,9 @@ function ScatterAsync(props: ScatterProps) { const batches: React.ReactNode[] = []; 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( Date: Wed, 17 Jun 2026 15:42:42 +0200 Subject: [PATCH 06/11] remove cmm --- docs/data/charts/scatter/ScatterAsyncRenderer.js | 1 - docs/data/charts/scatter/ScatterAsyncRenderer.tsx | 1 - 2 files changed, 2 deletions(-) diff --git a/docs/data/charts/scatter/ScatterAsyncRenderer.js b/docs/data/charts/scatter/ScatterAsyncRenderer.js index 189336ad6e2cc..4c84337c51837 100644 --- a/docs/data/charts/scatter/ScatterAsyncRenderer.js +++ b/docs/data/charts/scatter/ScatterAsyncRenderer.js @@ -208,7 +208,6 @@ export default function ScatterAsyncRenderer() { key={runId} series={series} height={400} - // Zoom/pan: first level stays painted while interacting, rest fills on settle. xAxis={[{ zoom: true }]} yAxis={[{ zoom: true }]} // Force the renderer so the two modes are directly comparable: diff --git a/docs/data/charts/scatter/ScatterAsyncRenderer.tsx b/docs/data/charts/scatter/ScatterAsyncRenderer.tsx index c44a72412c59c..d466464a32738 100644 --- a/docs/data/charts/scatter/ScatterAsyncRenderer.tsx +++ b/docs/data/charts/scatter/ScatterAsyncRenderer.tsx @@ -212,7 +212,6 @@ export default function ScatterAsyncRenderer() { key={runId} series={series} height={400} - // Zoom/pan: first level stays painted while interacting, rest fills on settle. xAxis={[{ zoom: true }]} yAxis={[{ zoom: true }]} // Force the renderer so the two modes are directly comparable: From bff89eaacc26f081655e26a4b8586705905aa32c Mon Sep 17 00:00:00 2001 From: Jose Quintas Date: Wed, 17 Jun 2026 16:06:28 +0200 Subject: [PATCH 07/11] [charts] Clarify interaction first-level is a first-N prefix, not a uniform sample --- docs/data/charts/scatter/scatter.md | 1 + packages/x-charts/src/ScatterChart/async/ScatterAsync.tsx | 7 +++++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/data/charts/scatter/scatter.md b/docs/data/charts/scatter/scatter.md index 024fe5921e6f7..308b00eac2bad 100644 --- a/docs/data/charts/scatter/scatter.md +++ b/docs/data/charts/scatter/scatter.md @@ -175,6 +175,7 @@ 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"}} diff --git a/packages/x-charts/src/ScatterChart/async/ScatterAsync.tsx b/packages/x-charts/src/ScatterChart/async/ScatterAsync.tsx index 93994ffa4dd27..065c43bf794a5 100644 --- a/packages/x-charts/src/ScatterChart/async/ScatterAsync.tsx +++ b/packages/x-charts/src/ScatterChart/async/ScatterAsync.tsx @@ -17,8 +17,11 @@ import { selectorScatterSeriesRenderData } from './scatterRenderData.selectors'; /** * Per-series points rendered while interacting. The first level is capped to a - * short stable `dataIndex` prefix (a uniform sample for unsorted data) to keep - * frames cheap; the rest fills in once the interaction settles. + * 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; From d6e733e6ba0d6d4ff4ae65b996bfc7c24292e05d Mon Sep 17 00:00:00 2001 From: Jose Quintas Date: Wed, 17 Jun 2026 16:16:22 +0200 Subject: [PATCH 08/11] [charts] Don't re-reveal a complete progressive wave on every interaction 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. --- .../useProgressiveRendering/useProgressiveRendering.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/x-charts/src/internals/plugins/featurePlugins/useProgressiveRendering/useProgressiveRendering.ts b/packages/x-charts/src/internals/plugins/featurePlugins/useProgressiveRendering/useProgressiveRendering.ts index 739259ba7179d..76ee69fdbb86f 100644 --- a/packages/x-charts/src/internals/plugins/featurePlugins/useProgressiveRendering/useProgressiveRendering.ts +++ b/packages/x-charts/src/internals/plugins/featurePlugins/useProgressiveRendering/useProgressiveRendering.ts @@ -80,8 +80,16 @@ export const useProgressiveRendering: ChartPlugin= startTotal) { + return undefined; + } const target = Math.min(INTERACTION_REVEALED_ROUNDS, startTotal); - if (selectorProgressiveRevealedRounds(store.state) !== target) { + if (revealed !== target) { store.set('progressiveRendering', { ...store.state.progressiveRendering, revealedRounds: target, From 5a9890221b4352be96adddce2bc4670c4dac4261 Mon Sep 17 00:00:00 2001 From: Jose Quintas Date: Wed, 17 Jun 2026 16:18:04 +0200 Subject: [PATCH 09/11] rev --- .../useProgressiveRendering/useProgressiveRendering.ts | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/packages/x-charts/src/internals/plugins/featurePlugins/useProgressiveRendering/useProgressiveRendering.ts b/packages/x-charts/src/internals/plugins/featurePlugins/useProgressiveRendering/useProgressiveRendering.ts index 76ee69fdbb86f..739259ba7179d 100644 --- a/packages/x-charts/src/internals/plugins/featurePlugins/useProgressiveRendering/useProgressiveRendering.ts +++ b/packages/x-charts/src/internals/plugins/featurePlugins/useProgressiveRendering/useProgressiveRendering.ts @@ -80,16 +80,8 @@ export const useProgressiveRendering: ChartPlugin= startTotal) { - return undefined; - } const target = Math.min(INTERACTION_REVEALED_ROUNDS, startTotal); - if (revealed !== target) { + if (selectorProgressiveRevealedRounds(store.state) !== target) { store.set('progressiveRendering', { ...store.state.progressiveRendering, revealedRounds: target, From 7ecef3724c2cd422a6b2453eb9bcdb976610b935 Mon Sep 17 00:00:00 2001 From: Jose Quintas Date: Wed, 17 Jun 2026 16:23:16 +0200 Subject: [PATCH 10/11] lint --- .../useProgressiveRendering.selectors.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/x-charts/src/internals/plugins/featurePlugins/useProgressiveRendering/useProgressiveRendering.selectors.ts b/packages/x-charts/src/internals/plugins/featurePlugins/useProgressiveRendering/useProgressiveRendering.selectors.ts index 0c1c17b29f82b..c75fe3e532b73 100644 --- a/packages/x-charts/src/internals/plugins/featurePlugins/useProgressiveRendering/useProgressiveRendering.selectors.ts +++ b/packages/x-charts/src/internals/plugins/featurePlugins/useProgressiveRendering/useProgressiveRendering.selectors.ts @@ -126,7 +126,12 @@ export const selectorProgressiveSeriesRevealedBatches = createSelector( selectorProgressiveAggregate, selectorProgressiveRevealedRounds, selectorChartZoomIsInteracting, - function selectorProgressiveSeriesRevealedBatches(agg, revealed, isInteracting, seriesId: SeriesId) { + function selectorProgressiveSeriesRevealedBatches( + agg, + revealed, + isInteracting, + seriesId: SeriesId, + ) { const total = agg.nBatchesBySeries.get(seriesId) ?? 0; const effectiveRevealed = isInteracting ? Math.min(1, total) : revealed; return Math.min(effectiveRevealed, total); From 88555a280ec7bc66d1d0c7731c9b5209335bfe51 Mon Sep 17 00:00:00 2001 From: Jose Quintas Date: Wed, 17 Jun 2026 16:31:28 +0200 Subject: [PATCH 11/11] [charts] Unit-test progressive scatter packing, batch view, and reveal 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. --- .../async/scatterRenderData.selectors.test.ts | 98 +++++++++++++++++++ .../async/scatterRenderData.selectors.ts | 57 ++++++++--- .../useProgressiveRendering.selectors.test.ts | 25 +++++ .../useProgressiveRendering.selectors.ts | 16 ++- 4 files changed, 180 insertions(+), 16 deletions(-) create mode 100644 packages/x-charts/src/ScatterChart/async/scatterRenderData.selectors.test.ts create mode 100644 packages/x-charts/src/internals/plugins/featurePlugins/useProgressiveRendering/useProgressiveRendering.selectors.test.ts diff --git a/packages/x-charts/src/ScatterChart/async/scatterRenderData.selectors.test.ts b/packages/x-charts/src/ScatterChart/async/scatterRenderData.selectors.test.ts new file mode 100644 index 0000000000000..44989bdd1e59c --- /dev/null +++ b/packages/x-charts/src/ScatterChart/async/scatterRenderData.selectors.test.ts @@ -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); + }); +}); diff --git a/packages/x-charts/src/ScatterChart/async/scatterRenderData.selectors.ts b/packages/x-charts/src/ScatterChart/async/scatterRenderData.selectors.ts index e5ac9e6fab26b..48e528e5037c8 100644 --- a/packages/x-charts/src/ScatterChart/async/scatterRenderData.selectors.ts +++ b/packages/x-charts/src/ScatterChart/async/scatterRenderData.selectors.ts @@ -56,26 +56,55 @@ export const selectorScatterRenderData = createSelectorMemoized( const getXPosition = getValueToPositionMapper(xAxis.scale); const getYPosition = getValueToPositionMapper(yAxis.scale); - const data = series.data; - const n = data.length; - const packed = new Float64Array(n * 3); - - for (let i = 0; i < n; i += 1) { - const x = getXPosition(data[i].x); - const y = getYPosition(data[i].y); - const visible = x >= xMin && x <= xMax && y >= yMin && y <= yMax; - packed[i * 3] = x; - packed[i * 3 + 1] = y; - packed[i * 3 + 2] = visible ? 1 : 0; - } - - result.set(seriesId, { coords: packed, count: n }); + result.set( + seriesId, + packScatterSeriesCoords(series.data, getXPosition, getYPosition, { + xMin, + xMax, + yMin, + yMax, + }), + ); } return result; }, ); +/** Pixel bounds (inclusive) a point must fall within to be flagged visible. */ +export interface ScatterVisibilityBounds { + xMin: number; + xMax: number; + yMin: number; + yMax: number; +} + +/** + * Projects `data` into a `dataIndex`-indexed packed array (stride 3: + * `[x, y, visible]`). Every point keeps its slot; `visible` is `1` inside + * `bounds`, `0` otherwise. + */ +export function packScatterSeriesCoords( + data: readonly { x: number | Date; y: number | Date }[], + getXPosition: (value: number | Date) => number, + getYPosition: (value: number | Date) => number, + bounds: ScatterVisibilityBounds, +): ScatterSeriesRenderData { + const n = data.length; + const packed = new Float64Array(n * 3); + + for (let i = 0; i < n; i += 1) { + const x = getXPosition(data[i].x); + const y = getYPosition(data[i].y); + const visible = x >= bounds.xMin && x <= bounds.xMax && y >= bounds.yMin && y <= bounds.yMax; + packed[i * 3] = x; + packed[i * 3 + 1] = y; + packed[i * 3 + 2] = visible ? 1 : 0; + } + + return { coords: packed, count: n }; +} + /** Render data for one series, or `undefined` while processors/axes are pending. */ export const selectorScatterSeriesRenderData = createSelector( selectorScatterRenderData, diff --git a/packages/x-charts/src/internals/plugins/featurePlugins/useProgressiveRendering/useProgressiveRendering.selectors.test.ts b/packages/x-charts/src/internals/plugins/featurePlugins/useProgressiveRendering/useProgressiveRendering.selectors.test.ts new file mode 100644 index 0000000000000..1d9f80bdbeae3 --- /dev/null +++ b/packages/x-charts/src/internals/plugins/featurePlugins/useProgressiveRendering/useProgressiveRendering.selectors.test.ts @@ -0,0 +1,25 @@ +import { getRevealedBatchCount } from './useProgressiveRendering.selectors'; + +describe('getRevealedBatchCount', () => { + it('returns the revealed rounds when not interacting', () => { + expect(getRevealedBatchCount(5, 3, false)).to.equal(3); + }); + + it('never exceeds the total batch count', () => { + expect(getRevealedBatchCount(2, 5, false)).to.equal(2); + }); + + it('clamps to the first batch while interacting', () => { + expect(getRevealedBatchCount(5, 5, true)).to.equal(1); + expect(getRevealedBatchCount(5, 0, true)).to.equal(1); + }); + + it('reveals nothing when the series has no batches', () => { + expect(getRevealedBatchCount(0, 3, false)).to.equal(0); + expect(getRevealedBatchCount(0, 3, true)).to.equal(0); + }); + + it('treats an undefined interaction flag as not interacting', () => { + expect(getRevealedBatchCount(5, 3, undefined)).to.equal(3); + }); +}); diff --git a/packages/x-charts/src/internals/plugins/featurePlugins/useProgressiveRendering/useProgressiveRendering.selectors.ts b/packages/x-charts/src/internals/plugins/featurePlugins/useProgressiveRendering/useProgressiveRendering.selectors.ts index c75fe3e532b73..86b254d48bdbd 100644 --- a/packages/x-charts/src/internals/plugins/featurePlugins/useProgressiveRendering/useProgressiveRendering.selectors.ts +++ b/packages/x-charts/src/internals/plugins/featurePlugins/useProgressiveRendering/useProgressiveRendering.selectors.ts @@ -118,6 +118,19 @@ export const selectorProgressiveTotalRounds = createSelector( (a) => a.totalRounds, ); +/** + * Revealed batch count for a series: `revealed` rounds normally, clamped to the + * first batch while interacting, never above `total`. + */ +export function getRevealedBatchCount( + total: number, + revealed: number, + isInteracting: boolean | undefined, +): number { + const effectiveRevealed = isInteracting ? Math.min(1, total) : revealed; + return Math.min(effectiveRevealed, total); +} + /** * How many of `seriesId`'s batches are revealed, capped at its total batch * count. Clamped to the first batch while interacting. @@ -133,8 +146,7 @@ export const selectorProgressiveSeriesRevealedBatches = createSelector( seriesId: SeriesId, ) { const total = agg.nBatchesBySeries.get(seriesId) ?? 0; - const effectiveRevealed = isInteracting ? Math.min(1, total) : revealed; - return Math.min(effectiveRevealed, total); + return getRevealedBatchCount(total, revealed, isInteracting); }, );