diff --git a/packages/x-charts-pro/src/ScatterChartPro/ScatterChartPro.zoom.test.tsx b/packages/x-charts-pro/src/ScatterChartPro/ScatterChartPro.zoom.test.tsx index e4f233dbcf4eb..7565fb5cccb52 100644 --- a/packages/x-charts-pro/src/ScatterChartPro/ScatterChartPro.zoom.test.tsx +++ b/packages/x-charts-pro/src/ScatterChartPro/ScatterChartPro.zoom.test.tsx @@ -243,6 +243,7 @@ describe.skipIf(isJSDOM)(' - Zoom', () => { onZoomChange={onZoomChange} zoomInteractionConfig={{ zoom: ['tapAndDrag'], + pan: [], }} />, options, diff --git a/packages/x-charts-pro/src/internals/plugins/useChartProZoom/ZoomInteractionConfig.test.tsx b/packages/x-charts-pro/src/internals/plugins/useChartProZoom/ZoomInteractionConfig.test.tsx index 47e57a6187570..7a3b654a1ac68 100644 --- a/packages/x-charts-pro/src/internals/plugins/useChartProZoom/ZoomInteractionConfig.test.tsx +++ b/packages/x-charts-pro/src/internals/plugins/useChartProZoom/ZoomInteractionConfig.test.tsx @@ -1,5 +1,5 @@ /* eslint-disable no-promise-executor-return */ -/* eslint-disable no-await-in-loop */ + import * as React from 'react'; import { createRenderer, fireEvent, act } from '@mui/internal-test-utils'; import { isJSDOM } from 'test/utils/skipIf'; @@ -75,20 +75,16 @@ describe.skipIf(isJSDOM)('ZoomInteractionConfig Keys and Modes', () => { ]); // Wheel without modifier keys - should not zoom - for (let i = 0; i < 10; i += 1) { - fireEvent.wheel(svg, { deltaY: -1, clientX: 50, clientY: 50 }); - await act(async () => new Promise((r) => requestAnimationFrame(r))); - } + fireEvent.wheel(svg, { deltaY: -10, clientX: 50, clientY: 50 }); + await act(async () => new Promise((r) => requestAnimationFrame(r))); expect(onZoomChange.callCount).to.equal(0); expect(getAxisTickValues('x')).to.deep.equal(['A', 'B', 'C', 'D']); await user.keyboard('{Control>}'); // Wheel with Control key - should zoom - for (let i = 0; i < 30; i += 1) { - fireEvent.wheel(svg, { deltaY: -1, clientX: 50, clientY: 50 }); - await act(async () => new Promise((r) => requestAnimationFrame(r))); - } + fireEvent.wheel(svg, { deltaY: -30, clientX: 50, clientY: 50 }); + await act(async () => new Promise((r) => requestAnimationFrame(r))); await user.keyboard('{/Control}'); expect(onZoomChange.callCount).to.be.greaterThan(0); @@ -315,10 +311,8 @@ describe.skipIf(isJSDOM)('ZoomInteractionConfig Keys and Modes', () => { const svg = document.querySelector('svg:not([aria-hidden="true"])')!; // Wheel - should not zoom since only pinch is enabled - for (let i = 0; i < 30; i += 1) { - fireEvent.wheel(svg, { deltaY: -1, clientX: 50, clientY: 50 }); - await act(async () => new Promise((r) => requestAnimationFrame(r))); - } + fireEvent.wheel(svg, { deltaY: -30, clientX: 50, clientY: 50 }); + await act(async () => new Promise((r) => requestAnimationFrame(r))); expect(onZoomChange.callCount).to.equal(0); expect(getAxisTickValues('x')).to.deep.equal(['A', 'B', 'C', 'D']); diff --git a/packages/x-charts-pro/src/internals/plugins/useChartProZoom/gestureHooks/usePanOnDrag.ts b/packages/x-charts-pro/src/internals/plugins/useChartProZoom/gestureHooks/usePanOnDrag.ts index b7b8e0bbe6316..fb3f269ca3bc7 100644 --- a/packages/x-charts-pro/src/internals/plugins/useChartProZoom/gestureHooks/usePanOnDrag.ts +++ b/packages/x-charts-pro/src/internals/plugins/useChartProZoom/gestureHooks/usePanOnDrag.ts @@ -23,7 +23,6 @@ export const usePanOnDrag = ( ) => { const drawingArea = useSelector(store, selectorChartDrawingArea); const optionsLookup = useSelector(store, selectorChartZoomOptionsLookup); - const startRef = React.useRef(null); const config = useSelector(store, selectorPanInteractionConfig, 'drag' as const); const isPanOnDragEnabled: boolean = @@ -47,6 +46,8 @@ export const usePanOnDrag = ( // Add event for chart panning React.useEffect(() => { const element = svgRef.current; + let isInteracting = false; + const accumulatedChange = { x: 0, y: 0 }; if (element === null || !isPanOnDragEnabled) { return () => {}; @@ -54,34 +55,38 @@ export const usePanOnDrag = ( const handlePanStart = (event: PanEvent) => { if (!(event.detail.target as SVGElement)?.closest('[data-charts-zoom-slider]')) { - startRef.current = store.state.zoom.zoomData; + isInteracting = true; } }; const handlePanEnd = () => { - startRef.current = null; + isInteracting = false; }; - const throttledCallback = rafThrottle((event: PanEvent, zoomData: readonly ZoomData[]) => { - const newZoomData = translateZoom( - zoomData, - { x: event.detail.activeDeltaX, y: -event.detail.activeDeltaY }, - { - width: drawingArea.width, - height: drawingArea.height, - }, - optionsLookup, + const throttledCallback = rafThrottle(() => { + const x = accumulatedChange.x; + const y = accumulatedChange.y; + accumulatedChange.x = 0; + accumulatedChange.y = 0; + setZoomDataCallback((prev) => + translateZoom( + prev, + { x, y: -y }, + { + width: drawingArea.width, + height: drawingArea.height, + }, + optionsLookup, + ), ); - - setZoomDataCallback(newZoomData); }); const handlePan = (event: PanEvent) => { - const zoomData = startRef.current; - - if (!zoomData) { + if (!isInteracting) { return; } - throttledCallback(event, zoomData); + accumulatedChange.x += event.detail.deltaX; + accumulatedChange.y += event.detail.deltaY; + throttledCallback(); }; const panHandler = instance.addInteractionListener('zoomPan', handlePan); @@ -103,6 +108,5 @@ export const usePanOnDrag = ( drawingArea.height, setZoomDataCallback, store, - startRef, ]); }; diff --git a/packages/x-charts-pro/src/internals/plugins/useChartProZoom/gestureHooks/usePanOnPressAndDrag.ts b/packages/x-charts-pro/src/internals/plugins/useChartProZoom/gestureHooks/usePanOnPressAndDrag.ts index 7d7a20fc668aa..5b1c5d6a4ca4d 100644 --- a/packages/x-charts-pro/src/internals/plugins/useChartProZoom/gestureHooks/usePanOnPressAndDrag.ts +++ b/packages/x-charts-pro/src/internals/plugins/useChartProZoom/gestureHooks/usePanOnPressAndDrag.ts @@ -23,7 +23,8 @@ export const usePanOnPressAndDrag = ( ) => { const drawingArea = useSelector(store, selectorChartDrawingArea); const optionsLookup = useSelector(store, selectorChartZoomOptionsLookup); - const startRef = React.useRef(null); + const isInteracting = React.useRef(false); + const accumulatedChange = React.useRef<{ x: number; y: number }>({ x: 0, y: 0 }); const config = useSelector(store, selectorPanInteractionConfig, 'pressAndDrag' as const); const isPanOnPressAndDragEnabled: boolean = @@ -54,36 +55,40 @@ export const usePanOnPressAndDrag = ( const handlePressAndDragStart = (event: PressAndDragEvent) => { if (!(event.detail.target as SVGElement)?.closest('[data-charts-zoom-slider]')) { - startRef.current = store.state.zoom.zoomData; + isInteracting.current = true; + accumulatedChange.current = { x: 0, y: 0 }; } }; const handlePressAndDragEnd = () => { - startRef.current = null; + isInteracting.current = false; }; - const throttledCallback = rafThrottle( - (event: PressAndDragEvent, zoomData: readonly ZoomData[]) => { - const newZoomData = translateZoom( - zoomData, - { x: event.detail.activeDeltaX, y: -event.detail.activeDeltaY }, + const throttledCallback = rafThrottle(() => { + const x = accumulatedChange.current.x; + const y = accumulatedChange.current.y; + accumulatedChange.current.x = 0; + accumulatedChange.current.y = 0; + setZoomDataCallback((prev) => + translateZoom( + prev, + { x, y: -y }, { width: drawingArea.width, height: drawingArea.height, }, optionsLookup, - ); - - setZoomDataCallback(newZoomData); - }, - ); + ), + ); + }); const handlePressAndDrag = (event: PressAndDragEvent) => { - const zoomData = startRef.current; - if (!zoomData) { + if (!isInteracting.current) { return; } - throttledCallback(event, zoomData); + accumulatedChange.current.x += event.detail.deltaX; + accumulatedChange.current.y += event.detail.deltaY; + throttledCallback(); }; const pressAndDragHandler = instance.addInteractionListener( @@ -114,6 +119,6 @@ export const usePanOnPressAndDrag = ( drawingArea.height, setZoomDataCallback, store, - startRef, + isInteracting, ]); }; diff --git a/packages/x-charts-pro/src/internals/plugins/useChartProZoom/useChartProZoom.ts b/packages/x-charts-pro/src/internals/plugins/useChartProZoom/useChartProZoom.ts index d6174673eb8a6..07d01c45ce323 100644 --- a/packages/x-charts-pro/src/internals/plugins/useChartProZoom/useChartProZoom.ts +++ b/packages/x-charts-pro/src/internals/plugins/useChartProZoom/useChartProZoom.ts @@ -42,10 +42,24 @@ export const useChartProZoom: ChartPlugin = (pluginDat }); }, [store, zoomInteractionConfig]); + // This is debounced. We want to run it only once after the interaction ends. + const removeIsInteracting = React.useMemo( + () => + debounce( + () => + store.set('zoom', { + ...store.state.zoom, + isInteracting: false, + }), + 166, + ), + [store], + ); + // Manage controlled state React.useEffect(() => { if (paramsZoomData === undefined) { - return undefined; + return; } if (process.env.NODE_ENV !== 'production' && !store.state.zoom.isControlled) { @@ -64,47 +78,28 @@ export const useChartProZoom: ChartPlugin = (pluginDat isInteracting: true, zoomData: paramsZoomData, }); - - const timeout = setTimeout(() => { - store.set('zoom', { - ...store.state.zoom, - isInteracting: false, - }); - }, 166); - - return () => { - clearTimeout(timeout); - }; - }, [store, paramsZoomData]); - - // This is debounced. We want to run it only once after the interaction ends. - const removeIsInteracting = React.useMemo( - () => - debounce( - () => - store.set('zoom', { - ...store.state.zoom, - isInteracting: false, - }), - 166, - ), - [store], - ); + removeIsInteracting(); + }, [store, paramsZoomData, removeIsInteracting]); const setZoomDataCallback = React.useCallback( (zoomData: ZoomData[] | ((prev: ZoomData[]) => ZoomData[])) => { const newZoomData = typeof zoomData === 'function' ? zoomData([...store.state.zoom.zoomData]) : zoomData; - onZoomChange?.(newZoomData); + + onZoomChange(newZoomData); if (store.state.zoom.isControlled) { - return; + store.set('zoom', { + ...store.state.zoom, + isInteracting: true, + }); + } else { + store.set('zoom', { + ...store.state.zoom, + isInteracting: true, + zoomData: newZoomData, + }); + removeIsInteracting(); } - removeIsInteracting(); - store.set('zoom', { - ...store.state.zoom, - isInteracting: true, - zoomData: newZoomData, - }); }, [onZoomChange, store, removeIsInteracting], ); diff --git a/packages/x-charts/src/internals/plugins/corePlugins/useChartInteractionListener/useChartInteractionListener.ts b/packages/x-charts/src/internals/plugins/corePlugins/useChartInteractionListener/useChartInteractionListener.ts index 4bbdc0a2c0a57..6df012135960e 100644 --- a/packages/x-charts/src/internals/plugins/corePlugins/useChartInteractionListener/useChartInteractionListener.ts +++ b/packages/x-charts/src/internals/plugins/corePlugins/useChartInteractionListener/useChartInteractionListener.ts @@ -81,13 +81,11 @@ export const useChartInteractionListener: ChartPlugin { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + it('should throttle multiple calls to execute at most once per frame', async () => { + const fn = vi.fn(); + const throttled = rafThrottle(fn); + + throttled('first', 1); + throttled('second', 2); + throttled('third', 3); + + // Function should not be called yet + expect(fn).not.toHaveBeenCalled(); + + vi.advanceTimersToNextFrame(); + + expect(fn).toHaveBeenCalledTimes(1); + expect(fn).toHaveBeenCalledWith('third', 3); + }); + + it('should cancel pending calls when clear is called', async () => { + const fn = vi.fn(); + const throttled = rafThrottle(fn); + + throttled(1); + throttled(2); + throttled(3); + + // Clear before RAF executes + throttled.clear(); + + vi.advanceTimersToNextFrame(); + + // Function should not be called + expect(fn).not.toHaveBeenCalled(); + }); + + it('should allow new calls after clear', async () => { + const fn = vi.fn(); + const throttled = rafThrottle(fn); + + throttled(1); + throttled.clear(); + throttled(2); + + vi.advanceTimersToNextFrame(); + + expect(fn).toHaveBeenCalledTimes(1); + expect(fn).toHaveBeenCalledWith(2); + }); + + it('should execute on every frame when called continuously', async () => { + const fn = vi.fn(); + const throttled = rafThrottle(fn); + + // First frame + throttled(1); + vi.advanceTimersToNextFrame(); + + // Second frame + throttled(2); + vi.advanceTimersToNextFrame(); + + // Third frame + throttled(3); + vi.advanceTimersToNextFrame(); + + // Should execute once per frame + expect(fn).toHaveBeenCalledTimes(3); + expect(fn).toHaveBeenNthCalledWith(1, 1); + expect(fn).toHaveBeenNthCalledWith(2, 2); + expect(fn).toHaveBeenNthCalledWith(3, 3); + }); + + it('should work with functions that return values', async () => { + const fn = vi.fn().mockReturnValue(42); + const throttled = rafThrottle(fn); + + throttled(1); + + vi.advanceTimersToNextFrame(); + + expect(fn).toHaveBeenCalledWith(1); + expect(fn).toHaveReturnedWith(42); + }); + + it('should schedule a new RAF after the previous one completes', async () => { + const fn = vi.fn(); + const throttled = rafThrottle(fn); + const rafSpy = vi.spyOn(window, 'requestAnimationFrame'); + + throttled(1); + expect(rafSpy).toHaveBeenCalledTimes(1); + + vi.advanceTimersToNextFrame(); + expect(fn).toHaveBeenCalledWith(1); + + throttled(2); + expect(rafSpy).toHaveBeenCalledTimes(2); + + vi.advanceTimersToNextFrame(); + expect(fn).toHaveBeenCalledWith(2); + }); + + it('should handle multiple independent throttled functions', async () => { + const fn1 = vi.fn(); + const fn2 = vi.fn(); + const throttled1 = rafThrottle(fn1); + const throttled2 = rafThrottle(fn2); + + throttled1('a'); + throttled2('b'); + + vi.advanceTimersToNextFrame(); + + expect(fn1).toHaveBeenCalledWith('a'); + expect(fn2).toHaveBeenCalledWith('b'); + }); + + it('should properly clear and not affect other throttled functions', async () => { + const fn1 = vi.fn(); + const fn2 = vi.fn(); + const throttled1 = rafThrottle(fn1); + const throttled2 = rafThrottle(fn2); + + throttled1('a'); + throttled2('b'); + throttled1.clear(); + + vi.advanceTimersToNextFrame(); + + expect(fn1).not.toHaveBeenCalled(); + expect(fn2).toHaveBeenCalledWith('b'); + }); + + it('should handle edge case of clear being called multiple times', async () => { + const fn = vi.fn(); + const throttled = rafThrottle(fn); + + throttled(1); + throttled.clear(); + throttled.clear(); // Should not throw + + vi.advanceTimersToNextFrame(); + + expect(fn).not.toHaveBeenCalled(); + }); + + it('should handle edge case of clear being called when nothing is pending', () => { + const fn = vi.fn(); + const throttled = rafThrottle(fn); + + // Should not throw when clearing with nothing pending + expect(() => throttled.clear()).not.toThrow(); + }); +});