From b03ccf265d77804a7e319676a54e3793967d7df0 Mon Sep 17 00:00:00 2001 From: Jose Quintas Date: Thu, 6 Mar 2025 19:15:43 +0100 Subject: [PATCH 01/48] add plugin start --- .../plugins/corePlugins/corePlugins.ts | 10 ++- .../corePlugins/useChartInteraction/index.ts | 8 ++ .../useChartInteraction.ts | 29 +++++++ .../useChartInteraction.types.ts | 75 +++++++++++++++++++ 4 files changed, 120 insertions(+), 2 deletions(-) create mode 100644 packages/x-charts/src/internals/plugins/corePlugins/useChartInteraction/index.ts create mode 100644 packages/x-charts/src/internals/plugins/corePlugins/useChartInteraction/useChartInteraction.ts create mode 100644 packages/x-charts/src/internals/plugins/corePlugins/useChartInteraction/useChartInteraction.types.ts diff --git a/packages/x-charts/src/internals/plugins/corePlugins/corePlugins.ts b/packages/x-charts/src/internals/plugins/corePlugins/corePlugins.ts index 8b876ac4b66a8..c95c59f3e6487 100644 --- a/packages/x-charts/src/internals/plugins/corePlugins/corePlugins.ts +++ b/packages/x-charts/src/internals/plugins/corePlugins/corePlugins.ts @@ -1,13 +1,19 @@ import { ConvertPluginsIntoSignatures } from '../models/helpers'; -import { useChartDimensions } from './useChartDimensions/useChartDimensions'; +import { useChartDimensions } from './useChartDimensions'; import { useChartId, UseChartIdParameters } from './useChartId'; import { useChartSeries } from './useChartSeries'; +import { useChartInteraction } from './useChartInteraction'; /** * Internal plugins that create the tools used by the other plugins. * These plugins are used by the Charts components. */ -export const CHART_CORE_PLUGINS = [useChartId, useChartDimensions, useChartSeries] as const; +export const CHART_CORE_PLUGINS = [ + useChartId, + useChartDimensions, + useChartSeries, + useChartInteraction, +] as const; export type ChartCorePluginSignatures = ConvertPluginsIntoSignatures; diff --git a/packages/x-charts/src/internals/plugins/corePlugins/useChartInteraction/index.ts b/packages/x-charts/src/internals/plugins/corePlugins/useChartInteraction/index.ts new file mode 100644 index 0000000000000..e5a7c24af73a2 --- /dev/null +++ b/packages/x-charts/src/internals/plugins/corePlugins/useChartInteraction/index.ts @@ -0,0 +1,8 @@ +export { useChartInteraction } from './useChartInteraction'; +export type { + UseChartInteractionParameters, + UseChartInteractionDefaultizedParameters, + UseChartInteractionState, + UseChartInteractionInstance, + UseChartInteractionSignature, +} from './useChartInteraction.types'; diff --git a/packages/x-charts/src/internals/plugins/corePlugins/useChartInteraction/useChartInteraction.ts b/packages/x-charts/src/internals/plugins/corePlugins/useChartInteraction/useChartInteraction.ts new file mode 100644 index 0000000000000..23eaeaa6a67e0 --- /dev/null +++ b/packages/x-charts/src/internals/plugins/corePlugins/useChartInteraction/useChartInteraction.ts @@ -0,0 +1,29 @@ +'use client'; +import * as React from 'react'; +import { ChartPlugin } from '../../models'; +import { UseChartInteractionSignature, AddInteractionListener } from './useChartInteraction.types'; + +export const useChartInteraction: ChartPlugin = ({ svgRef }) => { + const addInteractionListener: AddInteractionListener = React.useCallback( + (interaction, callback, options) => { + svgRef.current?.addEventListener(interaction, callback, options); + }, + [svgRef], + ); + + return { + instance: { + addInteractionListener, + }, + }; +}; + +useChartInteraction.params = {}; + +useChartInteraction.getDefaultizedParams = ({ params }) => ({ + ...params, +}); + +useChartInteraction.getInitialState = () => { + return {}; +}; diff --git a/packages/x-charts/src/internals/plugins/corePlugins/useChartInteraction/useChartInteraction.types.ts b/packages/x-charts/src/internals/plugins/corePlugins/useChartInteraction/useChartInteraction.types.ts new file mode 100644 index 0000000000000..27829fc7404ef --- /dev/null +++ b/packages/x-charts/src/internals/plugins/corePlugins/useChartInteraction/useChartInteraction.types.ts @@ -0,0 +1,75 @@ +import { ChartPluginSignature } from '../../models'; + +export type ChartInteraction = + | 'pan' + | 'panStart' + | 'panEnd' + | 'pinch' + | 'pinchStart' + | 'pinchEnd' + | 'scroll' + | 'scrollStart' + | 'scrollEnd' + | 'move' + | 'moveStart' + | 'moveEnd'; + +export type ChartInteractionOptions = { + capture?: boolean; + once?: boolean; + passive?: boolean; + signal: AbortSignal; +}; + +export type AddInteractionListener = ( + interaction: ChartInteraction, + // TODO: Custom Event type/data + callback: () => void, + options: ChartInteractionOptions, +) => void; + +export interface UseChartInteractionParameters {} + +export type UseChartInteractionDefaultizedParameters = UseChartInteractionParameters & {}; + +export interface UseChartInteractionState {} + +export interface UseChartInteractionInstance { + /** + * Adds an interaction listener to the SVG element. + * + * An interrupt signal is required to stop the interaction listener. + * We don't provide a way to remove the listener. + * + * @example + * ```tsx + * const { instance } = useChartInteraction(); + * + * useEffect(() => { + * const abortController = new AbortController(); + * + * instance.addInteractionListener( + * 'move', + * () => console.log('Move'), + * { signal: abortController.signal } + * ); + * + * return () => { + * abortController.abort(); + * }; + * }, []); + * ``` + * + * @param interaction The interaction to listen to. + * @param callback The callback to call when the interaction occurs. + * @param options The options to use when adding the event listener. + */ + addInteractionListener: AddInteractionListener; +} + +export type UseChartInteractionSignature = ChartPluginSignature<{ + params: UseChartInteractionParameters; + defaultizedParams: UseChartInteractionDefaultizedParameters; + state: UseChartInteractionState; + instance: UseChartInteractionInstance; +}>; From 6d80993a4fdc5264e91ae271a1fe5bbf9a9778d4 Mon Sep 17 00:00:00 2001 From: Jose Quintas Date: Fri, 7 Mar 2025 19:19:29 +0100 Subject: [PATCH 02/48] add chart interaction using @use-gesture/react --- packages/x-charts/package.json | 1 + .../useChartInteraction.ts | 66 ++++++++++++++++-- .../useChartInteraction.types.ts | 67 +++++++------------ pnpm-lock.yaml | 18 +++++ 4 files changed, 107 insertions(+), 45 deletions(-) diff --git a/packages/x-charts/package.json b/packages/x-charts/package.json index 060fc1a56b000..e19e3ab2f3483 100644 --- a/packages/x-charts/package.json +++ b/packages/x-charts/package.json @@ -45,6 +45,7 @@ "@mui/x-internals": "workspace:*", "@react-spring/rafz": "^9.7.5", "@react-spring/web": "^9.7.5", + "@use-gesture/react": "^10.3.1", "clsx": "^2.1.1", "prop-types": "^15.8.1", "react-is": "^18.3.1 || ^19.0.0", diff --git a/packages/x-charts/src/internals/plugins/corePlugins/useChartInteraction/useChartInteraction.ts b/packages/x-charts/src/internals/plugins/corePlugins/useChartInteraction/useChartInteraction.ts index 23eaeaa6a67e0..84ba9917a1cf5 100644 --- a/packages/x-charts/src/internals/plugins/corePlugins/useChartInteraction/useChartInteraction.ts +++ b/packages/x-charts/src/internals/plugins/corePlugins/useChartInteraction/useChartInteraction.ts @@ -1,16 +1,74 @@ 'use client'; import * as React from 'react'; +import { Handler, useGesture } from '@use-gesture/react'; import { ChartPlugin } from '../../models'; -import { UseChartInteractionSignature, AddInteractionListener } from './useChartInteraction.types'; +import { + UseChartInteractionSignature, + AddInteractionListener, + ChartInteraction, +} from './useChartInteraction.types'; + +type ListenerRef = Map>>; export const useChartInteraction: ChartPlugin = ({ svgRef }) => { + const listenersRef = React.useRef(new Map()); + + const retriggerEvent = React.useCallback((interaction: ChartInteraction, state: any) => { + const listeners = listenersRef.current.get(interaction); + if (listeners) { + listeners.forEach((callback) => callback(state)); + } + }, []); + + useGesture( + { + onDrag: (state) => retriggerEvent('drag', state), + onDragStart: (state) => retriggerEvent('dragStart', state), + onDragEnd: (state) => retriggerEvent('dragEnd', state), + onPinch: (state) => retriggerEvent('pinch', state), + onPinchStart: (state) => retriggerEvent('pinchStart', state), + onPinchEnd: (state) => retriggerEvent('pinchEnd', state), + onWheel: (state) => retriggerEvent('wheel', state), + onWheelStart: (state) => retriggerEvent('wheelStart', state), + onWheelEnd: (state) => retriggerEvent('wheelEnd', state), + onMove: (state) => retriggerEvent('move', state), + onMoveStart: (state) => retriggerEvent('moveStart', state), + onMoveEnd: (state) => retriggerEvent('moveEnd', state), + }, + { + target: svgRef, + }, + ); + const addInteractionListener: AddInteractionListener = React.useCallback( - (interaction, callback, options) => { - svgRef.current?.addEventListener(interaction, callback, options); + (interaction, callback) => { + const listeners = listenersRef.current.get(interaction); + + if (!listeners) { + const newSet = new Set>(); + newSet.add(callback); + listenersRef.current.set(interaction, newSet); + } else { + listenersRef.current.set(interaction, listeners.add(callback)); + } + + return () => { + if (listeners) { + listeners.delete(callback); + } + }; }, - [svgRef], + [], ); + React.useEffect(() => { + const ref = listenersRef.current; + + return () => { + ref.clear(); + }; + }, [svgRef]); + return { instance: { addInteractionListener, diff --git a/packages/x-charts/src/internals/plugins/corePlugins/useChartInteraction/useChartInteraction.types.ts b/packages/x-charts/src/internals/plugins/corePlugins/useChartInteraction/useChartInteraction.types.ts index 27829fc7404ef..66115b445479c 100644 --- a/packages/x-charts/src/internals/plugins/corePlugins/useChartInteraction/useChartInteraction.types.ts +++ b/packages/x-charts/src/internals/plugins/corePlugins/useChartInteraction/useChartInteraction.types.ts @@ -1,33 +1,41 @@ +import { Handler, WebKitGestureEvent } from '@use-gesture/react'; import { ChartPluginSignature } from '../../models'; export type ChartInteraction = - | 'pan' - | 'panStart' - | 'panEnd' + | 'drag' + | 'dragStart' + | 'dragEnd' | 'pinch' | 'pinchStart' | 'pinchEnd' - | 'scroll' - | 'scrollStart' - | 'scrollEnd' + | 'wheel' + | 'wheelStart' + | 'wheelEnd' | 'move' | 'moveStart' | 'moveEnd'; -export type ChartInteractionOptions = { - capture?: boolean; - once?: boolean; - passive?: boolean; - signal: AbortSignal; +export type RemoveInteractionListener = () => void; + +export type AddInteractionListener = { + ( + interaction: 'drag' | 'dragStart' | 'dragEnd', + callback: Handler<'drag', PointerEvent | MouseEvent | TouchEvent | KeyboardEvent>, + ): RemoveInteractionListener; + ( + interaction: 'pinch' | 'pinchStart' | 'pinchEnd', + callback: Handler<'pinch', PointerEvent | TouchEvent | WheelEvent | WebKitGestureEvent>, + ): RemoveInteractionListener; + ( + interaction: 'wheel' | 'wheelStart' | 'wheelEnd', + callback: Handler<'wheel', WheelEvent>, + ): RemoveInteractionListener; + ( + interaction: 'move' | 'moveStart' | 'moveEnd', + callback: Handler<'move', PointerEvent>, + ): RemoveInteractionListener; }; -export type AddInteractionListener = ( - interaction: ChartInteraction, - // TODO: Custom Event type/data - callback: () => void, - options: ChartInteractionOptions, -) => void; - export interface UseChartInteractionParameters {} export type UseChartInteractionDefaultizedParameters = UseChartInteractionParameters & {}; @@ -38,31 +46,8 @@ export interface UseChartInteractionInstance { /** * Adds an interaction listener to the SVG element. * - * An interrupt signal is required to stop the interaction listener. - * We don't provide a way to remove the listener. - * - * @example - * ```tsx - * const { instance } = useChartInteraction(); - * - * useEffect(() => { - * const abortController = new AbortController(); - * - * instance.addInteractionListener( - * 'move', - * () => console.log('Move'), - * { signal: abortController.signal } - * ); - * - * return () => { - * abortController.abort(); - * }; - * }, []); - * ``` - * * @param interaction The interaction to listen to. * @param callback The callback to call when the interaction occurs. - * @param options The options to use when adding the event listener. */ addInteractionListener: AddInteractionListener; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 05713c02df56f..f46f8af3ffa4c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -738,6 +738,9 @@ importers: '@react-spring/web': specifier: ^9.7.5 version: 9.7.5(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@use-gesture/react': + specifier: ^10.3.1 + version: 10.3.1(react@19.0.0) clsx: specifier: ^2.1.1 version: 2.1.1 @@ -4445,6 +4448,14 @@ packages: '@ungap/structured-clone@1.2.1': resolution: {integrity: sha512-fEzPV3hSkSMltkw152tJKNARhOupqbH96MZWyRjNaYZOMIzbrTeQDG+MTc6Mr2pgzFQzFxAfmhGDNP5QK++2ZA==} + '@use-gesture/core@10.3.1': + resolution: {integrity: sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==} + + '@use-gesture/react@10.3.1': + resolution: {integrity: sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g==} + peerDependencies: + react: '>= 16.8.0' + '@vitejs/plugin-react-swc@3.8.0': resolution: {integrity: sha512-T4sHPvS+DIqDP51ifPqa9XIRAz/kIvIi8oXcnOZZgHmMotgmmdxe/DD5tMFlt5nuIRzT0/QuiwmKlH0503Aapw==} peerDependencies: @@ -13677,6 +13688,13 @@ snapshots: '@ungap/structured-clone@1.2.1': {} + '@use-gesture/core@10.3.1': {} + + '@use-gesture/react@10.3.1(react@19.0.0)': + dependencies: + '@use-gesture/core': 10.3.1 + react: 19.0.0 + '@vitejs/plugin-react-swc@3.8.0(@swc/helpers@0.5.15)(vite@5.4.11(@types/node@20.17.22)(terser@5.37.0))': dependencies: '@swc/core': 1.10.16(@swc/helpers@0.5.15) From ba8b61a117fc4592fc5a2477c2c3663e1ba4618f Mon Sep 17 00:00:00 2001 From: Jose Quintas Date: Fri, 7 Mar 2025 20:08:12 +0100 Subject: [PATCH 03/48] replace tooltip logic --- packages/x-charts/src/ChartsTooltip/utils.tsx | 73 +++++-------- .../useChartInteraction.ts | 1 + .../useChartInteraction.types.ts | 6 +- .../useChartCartesianAxis.ts | 100 ++++++++---------- 4 files changed, 77 insertions(+), 103 deletions(-) diff --git a/packages/x-charts/src/ChartsTooltip/utils.tsx b/packages/x-charts/src/ChartsTooltip/utils.tsx index dbbeb9a6a46f7..fefb6770d9bd8 100644 --- a/packages/x-charts/src/ChartsTooltip/utils.tsx +++ b/packages/x-charts/src/ChartsTooltip/utils.tsx @@ -1,6 +1,6 @@ 'use client'; import * as React from 'react'; -import { useSvgRef } from '../hooks'; +import { useChartContext } from '../context/ChartProvider'; type MousePosition = { x: number; @@ -15,44 +15,29 @@ export type UseMouseTrackerReturnValue = null | MousePosition; * @deprecated We recommend using vanilla JS to let popper track mouse position. */ export function useMouseTracker(): UseMouseTrackerReturnValue { - const svgRef = useSvgRef(); + const { instance } = useChartContext(); // Use a ref to avoid rerendering on every mousemove event. const [mousePosition, setMousePosition] = React.useState(null); React.useEffect(() => { - const element = svgRef.current; - if (element === null) { - return () => {}; - } - - const controller = new AbortController(); - - const handleOut = (event: PointerEvent) => { - if (event.pointerType !== 'mouse') { + const hover = instance.addInteractionListener('hover', (state) => { + if (state.hovering) { + setMousePosition({ + x: state.event.clientX, + y: state.event.clientY, + height: state.event.height, + pointerType: state.event.pointerType as MousePosition['pointerType'], + }); + } else if (state.event.pointerType !== 'mouse') { setMousePosition(null); } - }; - - const handleMove = (event: PointerEvent) => { - setMousePosition({ - x: event.clientX, - y: event.clientY, - height: event.height, - pointerType: event.pointerType as MousePosition['pointerType'], - }); - }; - - element.addEventListener('pointerdown', handleMove, { signal: controller.signal }); - element.addEventListener('pointermove', handleMove, { signal: controller.signal }); - element.addEventListener('pointerup', handleOut, { signal: controller.signal }); + }); return () => { - // Calling `.abort()` removes ALL event listeners - // For more info, see https://kettanaito.com/blog/dont-sleep-on-abort-controller - controller.abort(); + hover(); }; - }, [svgRef]); + }, [instance]); return mousePosition; } @@ -60,38 +45,30 @@ export function useMouseTracker(): UseMouseTrackerReturnValue { type PointerType = Pick; export function usePointerType(): null | PointerType { - const svgRef = useSvgRef(); + const { instance } = useChartContext(); // Use a ref to avoid rerendering on every mousemove event. const [pointerType, setPointerType] = React.useState(null); React.useEffect(() => { - const element = svgRef.current; - if (element === null) { - return () => {}; - } - - const handleOut = (event: PointerEvent) => { - if (event.pointerType !== 'mouse') { + const moveEnd = instance.addInteractionListener('moveEnd', (state) => { + if (state.event.pointerType !== 'mouse') { setPointerType(null); } - }; + }); - const handleEnter = (event: PointerEvent) => { + const moveStart = instance.addInteractionListener('moveStart', (state) => { setPointerType({ - height: event.height, - pointerType: event.pointerType as PointerType['pointerType'], + height: state.event.height, + pointerType: state.event.pointerType as PointerType['pointerType'], }); - }; - - element.addEventListener('pointerenter', handleEnter); - element.addEventListener('pointerup', handleOut); + }); return () => { - element.removeEventListener('pointerenter', handleEnter); - element.removeEventListener('pointerup', handleOut); + moveEnd(); + moveStart(); }; - }, [svgRef]); + }, [instance]); return pointerType; } diff --git a/packages/x-charts/src/internals/plugins/corePlugins/useChartInteraction/useChartInteraction.ts b/packages/x-charts/src/internals/plugins/corePlugins/useChartInteraction/useChartInteraction.ts index 84ba9917a1cf5..7aed10d4f217d 100644 --- a/packages/x-charts/src/internals/plugins/corePlugins/useChartInteraction/useChartInteraction.ts +++ b/packages/x-charts/src/internals/plugins/corePlugins/useChartInteraction/useChartInteraction.ts @@ -34,6 +34,7 @@ export const useChartInteraction: ChartPlugin = ({ onMove: (state) => retriggerEvent('move', state), onMoveStart: (state) => retriggerEvent('moveStart', state), onMoveEnd: (state) => retriggerEvent('moveEnd', state), + onHover: (state) => retriggerEvent('hover', state), }, { target: svgRef, diff --git a/packages/x-charts/src/internals/plugins/corePlugins/useChartInteraction/useChartInteraction.types.ts b/packages/x-charts/src/internals/plugins/corePlugins/useChartInteraction/useChartInteraction.types.ts index 66115b445479c..009d3775b332b 100644 --- a/packages/x-charts/src/internals/plugins/corePlugins/useChartInteraction/useChartInteraction.types.ts +++ b/packages/x-charts/src/internals/plugins/corePlugins/useChartInteraction/useChartInteraction.types.ts @@ -13,14 +13,15 @@ export type ChartInteraction = | 'wheelEnd' | 'move' | 'moveStart' - | 'moveEnd'; + | 'moveEnd' + | 'hover'; export type RemoveInteractionListener = () => void; export type AddInteractionListener = { ( interaction: 'drag' | 'dragStart' | 'dragEnd', - callback: Handler<'drag', PointerEvent | MouseEvent | TouchEvent | KeyboardEvent>, + callback: Handler<'drag', PointerEvent | MouseEvent | TouchEvent>, ): RemoveInteractionListener; ( interaction: 'pinch' | 'pinchStart' | 'pinchEnd', @@ -34,6 +35,7 @@ export type AddInteractionListener = { interaction: 'move' | 'moveStart' | 'moveEnd', callback: Handler<'move', PointerEvent>, ): RemoveInteractionListener; + (interaction: 'hover', callback: Handler<'hover', PointerEvent>): RemoveInteractionListener; }; export interface UseChartInteractionParameters {} diff --git a/packages/x-charts/src/internals/plugins/featurePlugins/useChartCartesianAxis/useChartCartesianAxis.ts b/packages/x-charts/src/internals/plugins/featurePlugins/useChartCartesianAxis/useChartCartesianAxis.ts index 41c4c563625a4..ea83e45b89f1d 100644 --- a/packages/x-charts/src/internals/plugins/featurePlugins/useChartCartesianAxis/useChartCartesianAxis.ts +++ b/packages/x-charts/src/internals/plugins/featurePlugins/useChartCartesianAxis/useChartCartesianAxis.ts @@ -80,67 +80,61 @@ export const useChartCartesianAxis: ChartPlugin { const element = svgRef.current; - if (!isInteractionEnabled || element === null || params.disableAxisListener) { + if (!isInteractionEnabled || !element || params.disableAxisListener) { return () => {}; } - const handleOut = () => { - mousePosition.current = { - isInChart: false, - x: -1, - y: -1, - }; + const hover = instance.addInteractionListener('hover', (state) => { + if (!state.hovering) { + mousePosition.current = { + isInChart: false, + x: -1, + y: -1, + }; - instance.cleanInteraction?.(); - }; - - const handleMove = (event: MouseEvent | TouchEvent) => { - const target = 'targetTouches' in event ? event.targetTouches[0] : event; - const svgPoint = getSVGPoint(element, target); - - mousePosition.current.x = svgPoint.x; - mousePosition.current.y = svgPoint.y; - - if (!instance.isPointInside(svgPoint, { targetElement: event.target as SVGElement })) { - if (mousePosition.current.isInChart) { - store.update((prev) => ({ - ...prev, - interaction: { item: null, axis: { x: null, y: null } }, - })); - mousePosition.current.isInChart = false; - } - return; - } - mousePosition.current.isInChart = true; - - instance.setAxisInteraction?.({ - x: getAxisValue(xAxisWithScale[usedXAxis], svgPoint.x), - y: getAxisValue(yAxisWithScale[usedYAxis], svgPoint.y), - }); - }; - - const handleDown = (event: PointerEvent) => { - const target = event.currentTarget; - if (!target) { - return; + instance.cleanInteraction?.(); } + }); + + const [move, drag] = ['move', 'drag'].map((interaction) => + instance.addInteractionListener( + // We force `as drag` because it is the more complete interaction + // We check the `targetTouches` to handle touch events + interaction as 'drag', + (state) => { + const target = + 'targetTouches' in state.event ? state.event.targetTouches[0] : state.event; + const svgPoint = getSVGPoint(element, target); + + mousePosition.current.x = svgPoint.x; + mousePosition.current.y = svgPoint.y; + + if ( + !instance.isPointInside(svgPoint, { targetElement: state.event.target as SVGElement }) + ) { + if (mousePosition.current.isInChart) { + store.update((prev) => ({ + ...prev, + interaction: { item: null, axis: { x: null, y: null } }, + })); + mousePosition.current.isInChart = false; + } + return; + } + mousePosition.current.isInChart = true; - if ((target as HTMLElement).hasPointerCapture(event.pointerId)) { - (target as HTMLElement).releasePointerCapture(event.pointerId); - } - }; + instance.setAxisInteraction?.({ + x: getAxisValue(xAxisWithScale[usedXAxis], svgPoint.x), + y: getAxisValue(yAxisWithScale[usedYAxis], svgPoint.y), + }); + }, + ), + ); - element.addEventListener('pointerdown', handleDown); - element.addEventListener('pointermove', handleMove); - element.addEventListener('pointerout', handleOut); - element.addEventListener('pointercancel', handleOut); - element.addEventListener('pointerleave', handleOut); return () => { - element.removeEventListener('pointerdown', handleDown); - element.removeEventListener('pointermove', handleMove); - element.removeEventListener('pointerout', handleOut); - element.removeEventListener('pointercancel', handleOut); - element.removeEventListener('pointerleave', handleOut); + hover(); + move(); + drag(); }; }, [ svgRef, From db75765950676ef006f758024fc31b71f75a2974 Mon Sep 17 00:00:00 2001 From: Jose Quintas Date: Fri, 7 Mar 2025 20:30:45 +0100 Subject: [PATCH 04/48] rename vars --- packages/x-charts/src/ChartsTooltip/utils.tsx | 12 ++--- .../useChartInteraction.types.ts | 6 +-- .../useChartCartesianAxis.ts | 17 ++++--- .../useChartVoronoi/useChartVoronoi.ts | 51 ++++++++++--------- 4 files changed, 44 insertions(+), 42 deletions(-) diff --git a/packages/x-charts/src/ChartsTooltip/utils.tsx b/packages/x-charts/src/ChartsTooltip/utils.tsx index fefb6770d9bd8..a76ef60cfde6a 100644 --- a/packages/x-charts/src/ChartsTooltip/utils.tsx +++ b/packages/x-charts/src/ChartsTooltip/utils.tsx @@ -21,7 +21,7 @@ export function useMouseTracker(): UseMouseTrackerReturnValue { const [mousePosition, setMousePosition] = React.useState(null); React.useEffect(() => { - const hover = instance.addInteractionListener('hover', (state) => { + const removeOnHover = instance.addInteractionListener('hover', (state) => { if (state.hovering) { setMousePosition({ x: state.event.clientX, @@ -35,7 +35,7 @@ export function useMouseTracker(): UseMouseTrackerReturnValue { }); return () => { - hover(); + removeOnHover(); }; }, [instance]); @@ -51,13 +51,13 @@ export function usePointerType(): null | PointerType { const [pointerType, setPointerType] = React.useState(null); React.useEffect(() => { - const moveEnd = instance.addInteractionListener('moveEnd', (state) => { + const removeOnMoveEnd = instance.addInteractionListener('moveEnd', (state) => { if (state.event.pointerType !== 'mouse') { setPointerType(null); } }); - const moveStart = instance.addInteractionListener('moveStart', (state) => { + const removeOnMoveStart = instance.addInteractionListener('moveStart', (state) => { setPointerType({ height: state.event.height, pointerType: state.event.pointerType as PointerType['pointerType'], @@ -65,8 +65,8 @@ export function usePointerType(): null | PointerType { }); return () => { - moveEnd(); - moveStart(); + removeOnMoveEnd(); + removeOnMoveStart(); }; }, [instance]); diff --git a/packages/x-charts/src/internals/plugins/corePlugins/useChartInteraction/useChartInteraction.types.ts b/packages/x-charts/src/internals/plugins/corePlugins/useChartInteraction/useChartInteraction.types.ts index 009d3775b332b..947331eab3025 100644 --- a/packages/x-charts/src/internals/plugins/corePlugins/useChartInteraction/useChartInteraction.types.ts +++ b/packages/x-charts/src/internals/plugins/corePlugins/useChartInteraction/useChartInteraction.types.ts @@ -1,4 +1,4 @@ -import { Handler, WebKitGestureEvent } from '@use-gesture/react'; +import { Handler } from '@use-gesture/react'; import { ChartPluginSignature } from '../../models'; export type ChartInteraction = @@ -21,11 +21,11 @@ export type RemoveInteractionListener = () => void; export type AddInteractionListener = { ( interaction: 'drag' | 'dragStart' | 'dragEnd', - callback: Handler<'drag', PointerEvent | MouseEvent | TouchEvent>, + callback: Handler<'drag', PointerEvent>, ): RemoveInteractionListener; ( interaction: 'pinch' | 'pinchStart' | 'pinchEnd', - callback: Handler<'pinch', PointerEvent | TouchEvent | WheelEvent | WebKitGestureEvent>, + callback: Handler<'pinch', PointerEvent>, ): RemoveInteractionListener; ( interaction: 'wheel' | 'wheelStart' | 'wheelEnd', diff --git a/packages/x-charts/src/internals/plugins/featurePlugins/useChartCartesianAxis/useChartCartesianAxis.ts b/packages/x-charts/src/internals/plugins/featurePlugins/useChartCartesianAxis/useChartCartesianAxis.ts index ea83e45b89f1d..a00abe6b9d603 100644 --- a/packages/x-charts/src/internals/plugins/featurePlugins/useChartCartesianAxis/useChartCartesianAxis.ts +++ b/packages/x-charts/src/internals/plugins/featurePlugins/useChartCartesianAxis/useChartCartesianAxis.ts @@ -84,7 +84,7 @@ export const useChartCartesianAxis: ChartPlugin {}; } - const hover = instance.addInteractionListener('hover', (state) => { + const removeOnHover = instance.addInteractionListener('hover', (state) => { if (!state.hovering) { mousePosition.current = { isInChart: false, @@ -96,14 +96,15 @@ export const useChartCartesianAxis: ChartPlugin + const [removeOnMove, removeOnDrag] = ['move', 'drag'].map((interaction) => instance.addInteractionListener( - // We force `as drag` because it is the more complete interaction - // We check the `targetTouches` to handle touch events + // We force `as drag` to fix typing interaction as 'drag', (state) => { const target = - 'targetTouches' in state.event ? state.event.targetTouches[0] : state.event; + 'targetTouches' in state.event + ? (state.event as any as TouchEvent).targetTouches[0] + : state.event; const svgPoint = getSVGPoint(element, target); mousePosition.current.x = svgPoint.x; @@ -132,9 +133,9 @@ export const useChartCartesianAxis: ChartPlugin { - hover(); - move(); - drag(); + removeOnHover(); + removeOnMove(); + removeOnDrag(); }; }, [ svgRef, diff --git a/packages/x-charts/src/internals/plugins/featurePlugins/useChartVoronoi/useChartVoronoi.ts b/packages/x-charts/src/internals/plugins/featurePlugins/useChartVoronoi/useChartVoronoi.ts index 43caa7472f695..5bfda2b0234d2 100644 --- a/packages/x-charts/src/internals/plugins/featurePlugins/useChartVoronoi/useChartVoronoi.ts +++ b/packages/x-charts/src/internals/plugins/featurePlugins/useChartVoronoi/useChartVoronoi.ts @@ -162,13 +162,15 @@ export const useChartVoronoi: ChartPlugin = ({ return { seriesId: closestSeries.seriesId, dataIndex }; } - const handleMouseLeave = () => { - instance.cleanInteraction?.(); - instance.clearHighlight?.(); - }; + const removeOnHover = instance.addInteractionListener('hover', (state) => { + if (!state.hovering) { + instance.cleanInteraction?.(); + instance.clearHighlight?.(); + } + }); - const handleMouseMove = (event: MouseEvent) => { - const closestPoint = getClosestPoint(event); + const removeOnMove = instance.addInteractionListener('move', (state) => { + const closestPoint = getClosestPoint(state.event); if (closestPoint === 'outside-chart') { instance.cleanInteraction?.(); @@ -189,30 +191,29 @@ export const useChartVoronoi: ChartPlugin = ({ seriesId, dataIndex, }); - }; + }); - const handleMouseClick = (event: MouseEvent) => { - if (!onItemClick) { - return; - } - const closestPoint = getClosestPoint(event); + const removeOnDrag = instance.addInteractionListener('drag', (state) => { + if (state.tap) { + if (!onItemClick) { + return; + } + const closestPoint = getClosestPoint(state.event); - if (typeof closestPoint === 'string') { - // No point fond for any reason - return; - } + if (typeof closestPoint === 'string') { + // No point fond for any reason + return; + } - const { seriesId, dataIndex } = closestPoint; - onItemClick(event, { type: 'scatter', seriesId, dataIndex }); - }; + const { seriesId, dataIndex } = closestPoint; + onItemClick(state.event, { type: 'scatter', seriesId, dataIndex }); + } + }); - element.addEventListener('pointerleave', handleMouseLeave); - element.addEventListener('pointermove', handleMouseMove); - element.addEventListener('click', handleMouseClick); return () => { - element.removeEventListener('pointerleave', handleMouseLeave); - element.removeEventListener('pointermove', handleMouseMove); - element.removeEventListener('click', handleMouseClick); + removeOnHover(); + removeOnMove(); + removeOnDrag(); }; }, [svgRef, yAxis, xAxis, voronoiMaxRadius, onItemClick, disableVoronoi, drawingArea, instance]); From 04f4a3ad86ae14872b9352469579b02e6c769a22 Mon Sep 17 00:00:00 2001 From: Jose Quintas Date: Fri, 7 Mar 2025 20:38:13 +0100 Subject: [PATCH 05/48] update axis event click handler --- .../ChartsTooltip/ChartsTooltipContainer.tsx | 21 +++++++------------ .../useChartCartesianAxis.ts | 16 +++++++------- 2 files changed, 16 insertions(+), 21 deletions(-) diff --git a/packages/x-charts/src/ChartsTooltip/ChartsTooltipContainer.tsx b/packages/x-charts/src/ChartsTooltip/ChartsTooltipContainer.tsx index 88d59982b42a3..90156615d62f0 100644 --- a/packages/x-charts/src/ChartsTooltip/ChartsTooltipContainer.tsx +++ b/packages/x-charts/src/ChartsTooltip/ChartsTooltipContainer.tsx @@ -6,7 +6,6 @@ import useLazyRef from '@mui/utils/useLazyRef'; import { styled, useThemeProps } from '@mui/material/styles'; import Popper, { PopperPlacementType, PopperProps } from '@mui/material/Popper'; import NoSsr from '@mui/material/NoSsr'; -import { useSvgRef } from '../hooks/useSvgRef'; import { AxisDefaultized } from '../models/axis'; import { TriggerOptions, usePointerType } from './utils'; import { ChartsTooltipClasses } from './chartsTooltipClasses'; @@ -18,6 +17,7 @@ import { selectorChartsInteractionXAxisIsDefined, selectorChartsInteractionYAxisIsDefined, } from '../internals/plugins/featurePlugins/useChartInteraction'; +import { useChartContext } from '../context/ChartProvider'; export interface ChartsTooltipContainerProps extends Partial { /** @@ -61,8 +61,8 @@ function ChartsTooltipContainer(inProps: ChartsTooltipContainerProps) { name: 'MuiChartsTooltipContainer', }); const { trigger = 'axis', classes, children, ...other } = props; + const { instance } = useChartContext(); - const svgRef = useSvgRef(); const pointerType = usePointerType(); const xAxis = useXAxis(); @@ -83,23 +83,16 @@ function ChartsTooltipContainer(inProps: ChartsTooltipContainerProps) { const popperOpen = pointerType !== null && isOpen; // tooltipHasData; React.useEffect(() => { - const element = svgRef.current; - if (element === null) { - return () => {}; - } - - const handleMove = (event: PointerEvent) => { + const removeOnMove = instance.addInteractionListener('move', (state) => { // eslint-disable-next-line react-compiler/react-compiler - positionRef.current = { x: event.clientX, y: event.clientY }; + positionRef.current = { x: state.event.clientX, y: state.event.clientY }; popperRef.current?.update(); - }; - - element.addEventListener('pointermove', handleMove); + }); return () => { - element.removeEventListener('pointermove', handleMove); + removeOnMove(); }; - }, [svgRef, positionRef]); + }, [positionRef, instance]); const anchorEl = React.useMemo( () => ({ diff --git a/packages/x-charts/src/internals/plugins/featurePlugins/useChartCartesianAxis/useChartCartesianAxis.ts b/packages/x-charts/src/internals/plugins/featurePlugins/useChartCartesianAxis/useChartCartesianAxis.ts index a00abe6b9d603..bfffabbe9ca76 100644 --- a/packages/x-charts/src/internals/plugins/featurePlugins/useChartCartesianAxis/useChartCartesianAxis.ts +++ b/packages/x-charts/src/internals/plugins/featurePlugins/useChartCartesianAxis/useChartCartesianAxis.ts @@ -155,13 +155,15 @@ export const useChartCartesianAxis: ChartPlugin {}; } - const handleMouseClick = (event: MouseEvent) => { - event.preventDefault(); + const removeOnDrag = instance.addInteractionListener('drag', (state) => { + if (!state.tap) { + return; + } let dataIndex: number | null = null; let isXAxis: boolean = false; if (interactionAxis.x === null && interactionAxis.y === null) { - const svgPoint = getSVGPoint(element, event); + const svgPoint = getSVGPoint(element, state.event); const xIndex = getAxisValue(xAxisWithScale[usedXAxis], svgPoint.x)?.index ?? null; isXAxis = xIndex !== null && xIndex !== -1; @@ -202,12 +204,11 @@ export const useChartCartesianAxis: ChartPlugin { - element.removeEventListener('click', handleMouseClick); + removeOnDrag(); }; }, [ params.onAxisClick, @@ -221,6 +222,7 @@ export const useChartCartesianAxis: ChartPlugin Date: Sun, 9 Mar 2025 22:26:16 +0100 Subject: [PATCH 06/48] improve --- .../useChartCartesianAxis/useChartCartesianAxis.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/x-charts/src/internals/plugins/featurePlugins/useChartCartesianAxis/useChartCartesianAxis.ts b/packages/x-charts/src/internals/plugins/featurePlugins/useChartCartesianAxis/useChartCartesianAxis.ts index bfffabbe9ca76..29f3689ce5043 100644 --- a/packages/x-charts/src/internals/plugins/featurePlugins/useChartCartesianAxis/useChartCartesianAxis.ts +++ b/packages/x-charts/src/internals/plugins/featurePlugins/useChartCartesianAxis/useChartCartesianAxis.ts @@ -151,7 +151,9 @@ export const useChartCartesianAxis: ChartPlugin { const element = svgRef.current; - if (element === null || !params.onAxisClick) { + const onAxisClick = params.onAxisClick; + + if (element === null || !onAxisClick) { return () => {}; } @@ -204,7 +206,7 @@ export const useChartCartesianAxis: ChartPlugin { @@ -218,7 +220,6 @@ export const useChartCartesianAxis: ChartPlugin Date: Sun, 9 Mar 2025 23:17:13 +0100 Subject: [PATCH 07/48] rename --- .../plugins/corePlugins/corePlugins.ts | 4 ++-- .../corePlugins/useChartInteraction/index.ts | 8 -------- .../useChartInteractionListener/index.ts | 8 ++++++++ .../useChartInteractionListener.ts} | 14 ++++++++------ .../useChartInteractionListener.types.ts} | 19 ++++++++++--------- 5 files changed, 28 insertions(+), 25 deletions(-) delete mode 100644 packages/x-charts/src/internals/plugins/corePlugins/useChartInteraction/index.ts create mode 100644 packages/x-charts/src/internals/plugins/corePlugins/useChartInteractionListener/index.ts rename packages/x-charts/src/internals/plugins/corePlugins/{useChartInteraction/useChartInteraction.ts => useChartInteractionListener/useChartInteractionListener.ts} (85%) rename packages/x-charts/src/internals/plugins/corePlugins/{useChartInteraction/useChartInteraction.types.ts => useChartInteractionListener/useChartInteractionListener.types.ts} (69%) diff --git a/packages/x-charts/src/internals/plugins/corePlugins/corePlugins.ts b/packages/x-charts/src/internals/plugins/corePlugins/corePlugins.ts index c95c59f3e6487..9b24a3a12cf08 100644 --- a/packages/x-charts/src/internals/plugins/corePlugins/corePlugins.ts +++ b/packages/x-charts/src/internals/plugins/corePlugins/corePlugins.ts @@ -2,7 +2,7 @@ import { ConvertPluginsIntoSignatures } from '../models/helpers'; import { useChartDimensions } from './useChartDimensions'; import { useChartId, UseChartIdParameters } from './useChartId'; import { useChartSeries } from './useChartSeries'; -import { useChartInteraction } from './useChartInteraction'; +import { useChartInteractionListener } from './useChartInteractionListener'; /** * Internal plugins that create the tools used by the other plugins. @@ -12,7 +12,7 @@ export const CHART_CORE_PLUGINS = [ useChartId, useChartDimensions, useChartSeries, - useChartInteraction, + useChartInteractionListener, ] as const; export type ChartCorePluginSignatures = ConvertPluginsIntoSignatures; diff --git a/packages/x-charts/src/internals/plugins/corePlugins/useChartInteraction/index.ts b/packages/x-charts/src/internals/plugins/corePlugins/useChartInteraction/index.ts deleted file mode 100644 index e5a7c24af73a2..0000000000000 --- a/packages/x-charts/src/internals/plugins/corePlugins/useChartInteraction/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -export { useChartInteraction } from './useChartInteraction'; -export type { - UseChartInteractionParameters, - UseChartInteractionDefaultizedParameters, - UseChartInteractionState, - UseChartInteractionInstance, - UseChartInteractionSignature, -} from './useChartInteraction.types'; diff --git a/packages/x-charts/src/internals/plugins/corePlugins/useChartInteractionListener/index.ts b/packages/x-charts/src/internals/plugins/corePlugins/useChartInteractionListener/index.ts new file mode 100644 index 0000000000000..b53470853f913 --- /dev/null +++ b/packages/x-charts/src/internals/plugins/corePlugins/useChartInteractionListener/index.ts @@ -0,0 +1,8 @@ +export { useChartInteractionListener } from './useChartInteractionListener'; +export type { + UseChartInteractionListenerParameters, + UseChartInteractionListenerDefaultizedParameters, + UseChartInteractionListenerState, + UseChartInteractionListenerInstance, + UseChartInteractionListenerSignature, +} from './useChartInteractionListener.types'; diff --git a/packages/x-charts/src/internals/plugins/corePlugins/useChartInteraction/useChartInteraction.ts b/packages/x-charts/src/internals/plugins/corePlugins/useChartInteractionListener/useChartInteractionListener.ts similarity index 85% rename from packages/x-charts/src/internals/plugins/corePlugins/useChartInteraction/useChartInteraction.ts rename to packages/x-charts/src/internals/plugins/corePlugins/useChartInteractionListener/useChartInteractionListener.ts index 7aed10d4f217d..836e04837c2fd 100644 --- a/packages/x-charts/src/internals/plugins/corePlugins/useChartInteraction/useChartInteraction.ts +++ b/packages/x-charts/src/internals/plugins/corePlugins/useChartInteractionListener/useChartInteractionListener.ts @@ -3,14 +3,16 @@ import * as React from 'react'; import { Handler, useGesture } from '@use-gesture/react'; import { ChartPlugin } from '../../models'; import { - UseChartInteractionSignature, + UseChartInteractionListenerSignature, AddInteractionListener, ChartInteraction, -} from './useChartInteraction.types'; +} from './useChartInteractionListener.types'; type ListenerRef = Map>>; -export const useChartInteraction: ChartPlugin = ({ svgRef }) => { +export const useChartInteractionListener: ChartPlugin = ({ + svgRef, +}) => { const listenersRef = React.useRef(new Map()); const retriggerEvent = React.useCallback((interaction: ChartInteraction, state: any) => { @@ -77,12 +79,12 @@ export const useChartInteraction: ChartPlugin = ({ }; }; -useChartInteraction.params = {}; +useChartInteractionListener.params = {}; -useChartInteraction.getDefaultizedParams = ({ params }) => ({ +useChartInteractionListener.getDefaultizedParams = ({ params }) => ({ ...params, }); -useChartInteraction.getInitialState = () => { +useChartInteractionListener.getInitialState = () => { return {}; }; diff --git a/packages/x-charts/src/internals/plugins/corePlugins/useChartInteraction/useChartInteraction.types.ts b/packages/x-charts/src/internals/plugins/corePlugins/useChartInteractionListener/useChartInteractionListener.types.ts similarity index 69% rename from packages/x-charts/src/internals/plugins/corePlugins/useChartInteraction/useChartInteraction.types.ts rename to packages/x-charts/src/internals/plugins/corePlugins/useChartInteractionListener/useChartInteractionListener.types.ts index 947331eab3025..920c4daadd927 100644 --- a/packages/x-charts/src/internals/plugins/corePlugins/useChartInteraction/useChartInteraction.types.ts +++ b/packages/x-charts/src/internals/plugins/corePlugins/useChartInteractionListener/useChartInteractionListener.types.ts @@ -38,13 +38,14 @@ export type AddInteractionListener = { (interaction: 'hover', callback: Handler<'hover', PointerEvent>): RemoveInteractionListener; }; -export interface UseChartInteractionParameters {} +export interface UseChartInteractionListenerParameters {} -export type UseChartInteractionDefaultizedParameters = UseChartInteractionParameters & {}; +export type UseChartInteractionListenerDefaultizedParameters = + UseChartInteractionListenerParameters & {}; -export interface UseChartInteractionState {} +export interface UseChartInteractionListenerState {} -export interface UseChartInteractionInstance { +export interface UseChartInteractionListenerInstance { /** * Adds an interaction listener to the SVG element. * @@ -54,9 +55,9 @@ export interface UseChartInteractionInstance { addInteractionListener: AddInteractionListener; } -export type UseChartInteractionSignature = ChartPluginSignature<{ - params: UseChartInteractionParameters; - defaultizedParams: UseChartInteractionDefaultizedParameters; - state: UseChartInteractionState; - instance: UseChartInteractionInstance; +export type UseChartInteractionListenerSignature = ChartPluginSignature<{ + params: UseChartInteractionListenerParameters; + defaultizedParams: UseChartInteractionListenerDefaultizedParameters; + state: UseChartInteractionListenerState; + instance: UseChartInteractionListenerInstance; }>; From b4f0d242b3ce9a85badf6225984df57b74bdf473 Mon Sep 17 00:00:00 2001 From: Jose Quintas Date: Mon, 10 Mar 2025 12:39:44 +0100 Subject: [PATCH 08/48] prevent useInteractionItemProps re-renders --- .../src/hooks/useInteractionItemProps.ts | 58 ++++++++++++------- 1 file changed, 36 insertions(+), 22 deletions(-) diff --git a/packages/x-charts/src/hooks/useInteractionItemProps.ts b/packages/x-charts/src/hooks/useInteractionItemProps.ts index feb1fafda8540..e14fe9f6cbab0 100644 --- a/packages/x-charts/src/hooks/useInteractionItemProps.ts +++ b/packages/x-charts/src/hooks/useInteractionItemProps.ts @@ -5,39 +5,53 @@ import { useChartContext } from '../context/ChartProvider'; import { UseChartHighlightSignature } from '../internals/plugins/featurePlugins/useChartHighlight'; import { UseChartInteractionSignature } from '../internals/plugins/featurePlugins/useChartInteraction'; +const onPointerDown = (event: React.PointerEvent) => { + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } +}; + export const useInteractionItemProps = (skip?: boolean) => { const { instance } = useChartContext<[UseChartInteractionSignature, UseChartHighlightSignature]>(); + const dataRef = React.useRef(null); - if (skip) { - return () => ({}); - } - const getInteractionItemProps = (data: SeriesItemIdentifier) => { - const onPointerDown = (event: React.PointerEvent) => { - if (event.currentTarget.hasPointerCapture(event.pointerId)) { - event.currentTarget.releasePointerCapture(event.pointerId); + const onPointerEnter = React.useMemo(() => { + return () => { + if (!dataRef.current) { + return; } - }; - const onPointerEnter = () => { - instance.setItemInteraction(data); + instance.setItemInteraction(dataRef.current); instance.setHighlight({ - seriesId: data.seriesId, - dataIndex: data.dataIndex, + seriesId: dataRef.current.seriesId, + dataIndex: dataRef.current.dataIndex, }); }; - const onPointerLeave = (event: React.PointerEvent) => { - if (event.currentTarget.hasPointerCapture(event.pointerId)) { - event.currentTarget.releasePointerCapture(event.pointerId); - } + }, [instance]); - instance.removeItemInteraction(data); + const onPointerLeave = React.useMemo(() => { + return () => { + if (!dataRef.current) { + return; + } + instance.removeItemInteraction(dataRef.current); instance.clearHighlight(); }; - return { - onPointerEnter, - onPointerLeave, - onPointerDown, + }, [instance]); + + const getInteractionItemProps = React.useMemo(() => { + if (skip) { + return () => ({}); + } + return (data: SeriesItemIdentifier) => { + dataRef.current = data; + return { + onPointerEnter, + onPointerLeave, + onPointerDown, + }; }; - }; + }, [skip, onPointerEnter, onPointerLeave]); + return getInteractionItemProps; }; From 48b461eb8586376d9eac4dec695faae36479908f Mon Sep 17 00:00:00 2001 From: Jose Quintas Date: Mon, 10 Mar 2025 12:58:17 +0100 Subject: [PATCH 09/48] Fix perf related to react spring --- .../src/LineChart/CircleMarkElement.tsx | 18 ++++++++++++++---- .../x-charts/src/LineChart/MarkElement.tsx | 18 ++++++++++++++---- 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/packages/x-charts/src/LineChart/CircleMarkElement.tsx b/packages/x-charts/src/LineChart/CircleMarkElement.tsx index 1fb7765aa6db6..dda253c0dc9c4 100644 --- a/packages/x-charts/src/LineChart/CircleMarkElement.tsx +++ b/packages/x-charts/src/LineChart/CircleMarkElement.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import PropTypes from 'prop-types'; import { useTheme } from '@mui/material/styles'; -import { animated, useSpring } from '@react-spring/web'; +import { animated, useSpringValue } from '@react-spring/web'; import { useInteractionItemProps } from '../hooks/useInteractionItemProps'; import { useItemHighlighted } from '../hooks/useItemHighlighted'; import { MarkElementOwnerState, useUtilityClasses } from './markElementClasses'; @@ -57,7 +57,17 @@ function CircleMarkElement(props: CircleMarkElementProps) { const store = useStore(); const xAxisIdentifier = useSelector(store, selectorChartsInteractionXAxis); - const position = useSpring({ to: { x, y }, immediate: skipAnimation }); + const cx = useSpringValue(x, { immediate: skipAnimation }); + const cy = useSpringValue(y, { immediate: skipAnimation }); + + React.useEffect(() => { + cx.start(x); + }, [cx, x]); + + React.useEffect(() => { + cy.start(y); + }, [cy, y]); + const ownerState = { id, classes: innerClasses, @@ -71,8 +81,8 @@ function CircleMarkElement(props: CircleMarkElementProps) { { + cx.start(x); + }, [cx, x]); + + React.useEffect(() => { + cy.start(y); + }, [cy, y]); + const ownerState = { id, classes: innerClasses, @@ -85,8 +95,8 @@ function MarkElement(props: MarkElementProps) { `translate(${pX}px, ${pY}px)`), - transformOrigin: to([position.x, position.y], (pX, pY) => `${pX}px ${pY}px`), + transform: to([cx, cy], (pX, pY) => `translate(${pX}px, ${pY}px)`), + transformOrigin: to([cx, cy], (pX, pY) => `${pX}px ${pY}px`), }} ownerState={ownerState} // @ts-expect-error From 7988ff0030cfdcd0902feed90d041da245a429fd Mon Sep 17 00:00:00 2001 From: Jose Quintas Date: Wed, 12 Mar 2025 10:31:18 +0100 Subject: [PATCH 10/48] fix missing --- packages/x-charts/src/hooks/useInteractionItemProps.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/x-charts/src/hooks/useInteractionItemProps.ts b/packages/x-charts/src/hooks/useInteractionItemProps.ts index 652f144f09667..fde3deca799a4 100644 --- a/packages/x-charts/src/hooks/useInteractionItemProps.ts +++ b/packages/x-charts/src/hooks/useInteractionItemProps.ts @@ -21,7 +21,6 @@ export const useInteractionItemProps = ( } => { const { instance } = useChartContext<[UseChartInteractionSignature, UseChartHighlightSignature]>(); - const dataRef = React.useRef(null); const onPointerEnter = React.useCallback(() => { instance.setItemInteraction({ From 29923faef8d991d72b71eac6c300566b949d0ebd Mon Sep 17 00:00:00 2001 From: Jose Quintas Date: Wed, 12 Mar 2025 13:17:50 +0100 Subject: [PATCH 11/48] add mouse position tracker back --- .../useChartCartesianAxis.ts | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/packages/x-charts/src/internals/plugins/featurePlugins/useChartCartesianAxis/useChartCartesianAxis.ts b/packages/x-charts/src/internals/plugins/featurePlugins/useChartCartesianAxis/useChartCartesianAxis.ts index c46b81f6bc34b..ba98263568658 100644 --- a/packages/x-charts/src/internals/plugins/featurePlugins/useChartCartesianAxis/useChartCartesianAxis.ts +++ b/packages/x-charts/src/internals/plugins/featurePlugins/useChartCartesianAxis/useChartCartesianAxis.ts @@ -71,6 +71,10 @@ export const useChartCartesianAxis: ChartPlugin { const element = svgRef.current; if (!isInteractionEnabled || !element || params.disableAxisListener) { @@ -98,13 +102,17 @@ export const useChartCartesianAxis: ChartPlugin ({ - ...prev, - interaction: { item: null, axis: { x: null, y: null } }, - })); - return; + if (mousePosition.current.isInChart) { + store.update((prev) => ({ + ...prev, + interaction: { item: null, axis: { x: null, y: null } }, + })); + mousePosition.current.isInChart = false; + } } + mousePosition.current.isInChart = true; + instance.setAxisInteraction?.({ x: getAxisValue(xAxisWithScale[usedXAxis], svgPoint.x), y: getAxisValue(yAxisWithScale[usedYAxis], svgPoint.y), From 6abb966ccde009e27eaf10ccb510c6041656f7c4 Mon Sep 17 00:00:00 2001 From: Jose Quintas Date: Wed, 12 Mar 2025 13:23:11 +0100 Subject: [PATCH 12/48] migrate polar axis --- .../useChartCartesianAxis.ts | 1 + .../useChartPolarAxis/useChartPolarAxis.ts | 132 ++++++++---------- 2 files changed, 59 insertions(+), 74 deletions(-) diff --git a/packages/x-charts/src/internals/plugins/featurePlugins/useChartCartesianAxis/useChartCartesianAxis.ts b/packages/x-charts/src/internals/plugins/featurePlugins/useChartCartesianAxis/useChartCartesianAxis.ts index ba98263568658..9f2f9765a227d 100644 --- a/packages/x-charts/src/internals/plugins/featurePlugins/useChartCartesianAxis/useChartCartesianAxis.ts +++ b/packages/x-charts/src/internals/plugins/featurePlugins/useChartCartesianAxis/useChartCartesianAxis.ts @@ -83,6 +83,7 @@ export const useChartCartesianAxis: ChartPlugin { if (!state.hovering) { + mousePosition.current.isInChart = false; instance.cleanInteraction?.(); } }); diff --git a/packages/x-charts/src/internals/plugins/featurePlugins/useChartPolarAxis/useChartPolarAxis.ts b/packages/x-charts/src/internals/plugins/featurePlugins/useChartPolarAxis/useChartPolarAxis.ts index 512a2650ff656..0f740d9bc2063 100644 --- a/packages/x-charts/src/internals/plugins/featurePlugins/useChartPolarAxis/useChartPolarAxis.ts +++ b/packages/x-charts/src/internals/plugins/featurePlugins/useChartPolarAxis/useChartPolarAxis.ts @@ -96,8 +96,6 @@ export const useChartPolarAxis: ChartPlugin> = ( // Use a ref to avoid rerendering on every mousemove event. const mousePosition = React.useRef({ isInChart: false, - x: -1, - y: -1, }); React.useEffect(() => { @@ -106,81 +104,67 @@ export const useChartPolarAxis: ChartPlugin> = ( return () => {}; } - const handleOut = () => { - mousePosition.current = { - isInChart: false, - x: -1, - y: -1, - }; - - instance.cleanInteraction?.(); - }; - - const handleMove = (event: MouseEvent | TouchEvent) => { - const target = 'targetTouches' in event ? event.targetTouches[0] : event; - const svgPoint = getSVGPoint(element, target); - - mousePosition.current.x = svgPoint.x; - mousePosition.current.y = svgPoint.y; - - // Test if it's in the drawing area - if (!instance.isPointInside(svgPoint, { targetElement: event.target as SVGElement })) { - if (mousePosition.current.isInChart) { - store.update((prev) => ({ - ...prev, - interaction: { item: null, axis: { x: null, y: null } }, - })); - mousePosition.current.isInChart = false; - } - return; + const removeOnHover = instance.addInteractionListener('hover', (state) => { + if (!state.hovering) { + mousePosition.current.isInChart = false; + instance.cleanInteraction?.(); } + }); + + const [removeOnMove, removeOnDrag] = ['move', 'drag'].map((interaction) => + instance.addInteractionListener( + // We force `as drag` to fix typing + interaction as 'drag', + (state) => { + const target = + 'targetTouches' in state.event + ? (state.event as any as TouchEvent).targetTouches[0] + : state.event; + const svgPoint = getSVGPoint(element, target); + + const isPointInside = instance.isPointInside(svgPoint, { + targetElement: state.event.target as SVGElement, + }); + if (!isPointInside) { + if (mousePosition.current.isInChart) { + store.update((prev) => ({ + ...prev, + interaction: { item: null, axis: { x: null, y: null } }, + })); + mousePosition.current.isInChart = false; + } + } + + // Test if it's in the radar circle + const radiusSquare = (center.cx - svgPoint.x) ** 2 + (center.cy - svgPoint.y) ** 2; + const maxRadius = radiusAxisWithScale[usedRadiusAxisId].scale.range()[1]; + + if (radiusSquare > maxRadius ** 2) { + if (mousePosition.current.isInChart) { + store.update((prev) => ({ + ...prev, + interaction: { item: null, axis: { x: null, y: null } }, + })); + mousePosition.current.isInChart = false; + } + return; + } + + mousePosition.current.isInChart = true; + const angle = svg2rotation(svgPoint.x, svgPoint.y); + + instance.setAxisInteraction?.({ + x: getAxisValue(rotationAxisWithScale[usedRotationAxisId], angle), + y: null, + }); + }, + ), + ); - // Test if it's in the radar circle - const radiusSquare = (center.cx - svgPoint.x) ** 2 + (center.cy - svgPoint.y) ** 2; - const maxRadius = radiusAxisWithScale[usedRadiusAxisId].scale.range()[1]; - - if (radiusSquare > maxRadius ** 2) { - if (mousePosition.current.isInChart) { - store.update((prev) => ({ - ...prev, - interaction: { item: null, axis: { x: null, y: null } }, - })); - mousePosition.current.isInChart = false; - } - return; - } - - mousePosition.current.isInChart = true; - const angle = svg2rotation(svgPoint.x, svgPoint.y); - - instance.setAxisInteraction?.({ - x: getAxisValue(rotationAxisWithScale[usedRotationAxisId], angle), - y: null, - }); - }; - - const handleDown = (event: PointerEvent) => { - const target = event.currentTarget; - if (!target) { - return; - } - - if ((target as HTMLElement).hasPointerCapture(event.pointerId)) { - (target as HTMLElement).releasePointerCapture(event.pointerId); - } - }; - - element.addEventListener('pointerdown', handleDown); - element.addEventListener('pointermove', handleMove); - element.addEventListener('pointerout', handleOut); - element.addEventListener('pointercancel', handleOut); - element.addEventListener('pointerleave', handleOut); return () => { - element.removeEventListener('pointerdown', handleDown); - element.removeEventListener('pointermove', handleMove); - element.removeEventListener('pointerout', handleOut); - element.removeEventListener('pointercancel', handleOut); - element.removeEventListener('pointerleave', handleOut); + removeOnHover(); + removeOnMove(); + removeOnDrag(); }; }, [ svgRef, From 7ebb091ed0a79fe16ab07cfbcf0968db06c02c02 Mon Sep 17 00:00:00 2001 From: Jose Quintas Date: Wed, 12 Mar 2025 17:04:49 +0100 Subject: [PATCH 13/48] add wheel listeners --- .../useChartProZoom/useChartProZoom.ts | 38 ++++++++++++++----- .../useChartInteractionListener.ts | 4 ++ 2 files changed, 33 insertions(+), 9 deletions(-) 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 13e48dd68aad1..6af9cc9b3a248 100644 --- a/packages/x-charts-pro/src/internals/plugins/useChartProZoom/useChartProZoom.ts +++ b/packages/x-charts-pro/src/internals/plugins/useChartProZoom/useChartProZoom.ts @@ -255,18 +255,21 @@ export const useChartProZoom: ChartPlugin = ({ return () => {}; } - const wheelHandler = (event: WheelEvent) => { + const removeOnWheel = instance.addInteractionListener('wheel', (state) => { if (element === null) { return; } - const point = getSVGPoint(element, event); + const point = getSVGPoint(element, state.event); if (!instance.isPointInside(point)) { return; } - event.preventDefault(); + if (!state.last) { + state.event.preventDefault(); + } + if (interactionTimeoutRef.current) { clearTimeout(interactionTimeoutRef.current); } @@ -288,8 +291,11 @@ export const useChartProZoom: ChartPlugin = ({ option.axisDirection === 'x' ? getHorizontalCenterRatio(point, drawingArea) : getVerticalCenterRatio(point, drawingArea); + console.log(drawingArea); + + console.log(option.axisDirection, centerRatio); - const { scaleRatio, isZoomIn } = getWheelScaleRatio(event, option.step); + const { scaleRatio, isZoomIn } = getWheelScaleRatio(state.event, option.step); const [newMinRange, newMaxRange] = zoomAtPoint(centerRatio, scaleRatio, zoom, option); if (!isSpanValid(newMinRange, newMaxRange, isZoomIn, option)) { @@ -299,7 +305,26 @@ export const useChartProZoom: ChartPlugin = ({ return { axisId: zoom.axisId, start: newMinRange, end: newMaxRange }; }); }); + }); + + return () => { + removeOnWheel(); }; + }, [ + svgRef, + drawingArea, + isZoomEnabled, + optionsLookup, + setIsInteracting, + instance, + setZoomDataCallback, + ]); + + React.useEffect(() => { + const element = svgRef.current; + if (element === null || !isZoomEnabled) { + return () => {}; + } function pointerDownHandler(event: PointerEvent) { zoomEventCacheRef.current.push(event); @@ -378,7 +403,6 @@ export const useChartProZoom: ChartPlugin = ({ } } - element.addEventListener('wheel', wheelHandler); element.addEventListener('pointerdown', pointerDownHandler); element.addEventListener('pointermove', pointerMoveHandler); element.addEventListener('pointerup', pointerUpHandler); @@ -391,7 +415,6 @@ export const useChartProZoom: ChartPlugin = ({ element.addEventListener('touchmove', preventDefault); return () => { - element.removeEventListener('wheel', wheelHandler); element.removeEventListener('pointerdown', pointerDownHandler); element.removeEventListener('pointermove', pointerMoveHandler); element.removeEventListener('pointerup', pointerUpHandler); @@ -400,9 +423,6 @@ export const useChartProZoom: ChartPlugin = ({ element.removeEventListener('pointerleave', pointerUpHandler); element.removeEventListener('touchstart', preventDefault); element.removeEventListener('touchmove', preventDefault); - if (interactionTimeoutRef.current) { - clearTimeout(interactionTimeoutRef.current); - } }; }, [ svgRef, 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 836e04837c2fd..f88dcae300d9f 100644 --- a/packages/x-charts/src/internals/plugins/corePlugins/useChartInteractionListener/useChartInteractionListener.ts +++ b/packages/x-charts/src/internals/plugins/corePlugins/useChartInteractionListener/useChartInteractionListener.ts @@ -10,6 +10,7 @@ import { type ListenerRef = Map>>; +// TODO: use import { createUseGesture, dragAction, pinchAction } from '@use-gesture/react' export const useChartInteractionListener: ChartPlugin = ({ svgRef, }) => { @@ -40,6 +41,9 @@ export const useChartInteractionListener: ChartPlugin Date: Wed, 12 Mar 2025 17:21:34 +0100 Subject: [PATCH 14/48] log --- .../plugins/useChartProZoom/useChartProZoom.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) 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 6af9cc9b3a248..2f383db75a8fb 100644 --- a/packages/x-charts-pro/src/internals/plugins/useChartProZoom/useChartProZoom.ts +++ b/packages/x-charts-pro/src/internals/plugins/useChartProZoom/useChartProZoom.ts @@ -255,10 +255,13 @@ export const useChartProZoom: ChartPlugin = ({ return () => {}; } + console.log('ran'); + const removeOnWheel = instance.addInteractionListener('wheel', (state) => { if (element === null) { return; } + console.log(drawingArea.width); const point = getSVGPoint(element, state.event); @@ -286,12 +289,12 @@ export const useChartProZoom: ChartPlugin = ({ if (!option) { return zoom; } - + console.log(drawingArea.width); const centerRatio = option.axisDirection === 'x' ? getHorizontalCenterRatio(point, drawingArea) : getVerticalCenterRatio(point, drawingArea); - console.log(drawingArea); + console.log(drawingArea.width); console.log(option.axisDirection, centerRatio); @@ -309,10 +312,17 @@ export const useChartProZoom: ChartPlugin = ({ return () => { removeOnWheel(); + if (interactionTimeoutRef.current) { + clearTimeout(interactionTimeoutRef.current); + } }; }, [ svgRef, drawingArea, + drawingArea.left, + drawingArea.width, + drawingArea.top, + drawingArea.height, isZoomEnabled, optionsLookup, setIsInteracting, From 6a8ad6944a56e73bc67b0b5f45562246832c37eb Mon Sep 17 00:00:00 2001 From: Jose Quintas Date: Wed, 12 Mar 2025 17:34:58 +0100 Subject: [PATCH 15/48] fix listeners bug --- .../plugins/useChartProZoom/useChartProZoom.ts | 11 ----------- .../useChartInteractionListener.ts | 14 ++++++-------- 2 files changed, 6 insertions(+), 19 deletions(-) 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 2f383db75a8fb..6c04c2e060a5e 100644 --- a/packages/x-charts-pro/src/internals/plugins/useChartProZoom/useChartProZoom.ts +++ b/packages/x-charts-pro/src/internals/plugins/useChartProZoom/useChartProZoom.ts @@ -255,13 +255,10 @@ export const useChartProZoom: ChartPlugin = ({ return () => {}; } - console.log('ran'); - const removeOnWheel = instance.addInteractionListener('wheel', (state) => { if (element === null) { return; } - console.log(drawingArea.width); const point = getSVGPoint(element, state.event); @@ -289,14 +286,10 @@ export const useChartProZoom: ChartPlugin = ({ if (!option) { return zoom; } - console.log(drawingArea.width); const centerRatio = option.axisDirection === 'x' ? getHorizontalCenterRatio(point, drawingArea) : getVerticalCenterRatio(point, drawingArea); - console.log(drawingArea.width); - - console.log(option.axisDirection, centerRatio); const { scaleRatio, isZoomIn } = getWheelScaleRatio(state.event, option.step); const [newMinRange, newMaxRange] = zoomAtPoint(centerRatio, scaleRatio, zoom, option); @@ -319,10 +312,6 @@ export const useChartProZoom: ChartPlugin = ({ }, [ svgRef, drawingArea, - drawingArea.left, - drawingArea.width, - drawingArea.top, - drawingArea.height, isZoomEnabled, optionsLookup, setIsInteracting, 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 f88dcae300d9f..b3343765fe525 100644 --- a/packages/x-charts/src/internals/plugins/corePlugins/useChartInteractionListener/useChartInteractionListener.ts +++ b/packages/x-charts/src/internals/plugins/corePlugins/useChartInteractionListener/useChartInteractionListener.ts @@ -49,20 +49,18 @@ export const useChartInteractionListener: ChartPlugin { - const listeners = listenersRef.current.get(interaction); + let listeners = listenersRef.current.get(interaction); if (!listeners) { - const newSet = new Set>(); - newSet.add(callback); - listenersRef.current.set(interaction, newSet); + listeners = new Set>(); + listeners.add(callback); + listenersRef.current.set(interaction, listeners); } else { - listenersRef.current.set(interaction, listeners.add(callback)); + listeners.add(callback); } return () => { - if (listeners) { - listeners.delete(callback); - } + listeners.delete(callback); }; }, [], From 989c284385bbee685a65e7e43c26af5172d10107 Mon Sep 17 00:00:00 2001 From: Jose Quintas Date: Wed, 12 Mar 2025 18:26:59 +0100 Subject: [PATCH 16/48] clear animation on drag --- .../plugins/useChartProZoom/useChartProZoom.ts | 17 +++++++++++++---- .../useChartInteractionListener.ts | 6 ++++++ 2 files changed, 19 insertions(+), 4 deletions(-) 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 6c04c2e060a5e..63dd7ba38f813 100644 --- a/packages/x-charts-pro/src/internals/plugins/useChartProZoom/useChartProZoom.ts +++ b/packages/x-charts-pro/src/internals/plugins/useChartProZoom/useChartProZoom.ts @@ -205,6 +205,9 @@ export const useChartProZoom: ChartPlugin = ({ event.preventDefault(); } isDraggingRef.current = true; + if (interactionTimeoutRef.current) { + clearTimeout(interactionTimeoutRef.current); + } setIsInteracting(true); touchStartRef.current = { x: point.x, @@ -273,12 +276,15 @@ export const useChartProZoom: ChartPlugin = ({ if (interactionTimeoutRef.current) { clearTimeout(interactionTimeoutRef.current); } - setIsInteracting(true); // Debounce transition to `isInteractive=false`. // Useful because wheel events don't have an "end" event. - interactionTimeoutRef.current = window.setTimeout(() => { - setIsInteracting(false); - }, 166); + if (!isDraggingRef.current) { + setIsInteracting(true); + + interactionTimeoutRef.current = window.setTimeout(() => { + setIsInteracting(false); + }, 166); + } setZoomDataCallback((prevZoomData) => { return prevZoomData.map((zoom) => { @@ -327,6 +333,9 @@ export const useChartProZoom: ChartPlugin = ({ function pointerDownHandler(event: PointerEvent) { zoomEventCacheRef.current.push(event); + if (interactionTimeoutRef.current) { + clearTimeout(interactionTimeoutRef.current); + } setIsInteracting(true); } 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 b3343765fe525..5c598519594f1 100644 --- a/packages/x-charts/src/internals/plugins/corePlugins/useChartInteractionListener/useChartInteractionListener.ts +++ b/packages/x-charts/src/internals/plugins/corePlugins/useChartInteractionListener/useChartInteractionListener.ts @@ -44,6 +44,12 @@ export const useChartInteractionListener: ChartPlugin Date: Thu, 13 Mar 2025 13:56:56 +0100 Subject: [PATCH 17/48] move zoom wheel setup to its own hook --- .../gestureHooks/useZoomOnWheel.ts | 112 ++++++++++++++++++ .../useChartProZoom/useChartProZoom.ts | 82 ++----------- 2 files changed, 122 insertions(+), 72 deletions(-) create mode 100644 packages/x-charts-pro/src/internals/plugins/useChartProZoom/gestureHooks/useZoomOnWheel.ts diff --git a/packages/x-charts-pro/src/internals/plugins/useChartProZoom/gestureHooks/useZoomOnWheel.ts b/packages/x-charts-pro/src/internals/plugins/useChartProZoom/gestureHooks/useZoomOnWheel.ts new file mode 100644 index 0000000000000..b27bb9adea3f4 --- /dev/null +++ b/packages/x-charts-pro/src/internals/plugins/useChartProZoom/gestureHooks/useZoomOnWheel.ts @@ -0,0 +1,112 @@ +'use client'; +import * as React from 'react'; +import { + ChartPlugin, + useSelector, + getSVGPoint, + selectorChartDrawingArea, + ZoomData, + selectorChartZoomOptionsLookup, +} from '@mui/x-charts/internals'; +import { UseChartProZoomSignature } from '../useChartProZoom.types'; +import { + getHorizontalCenterRatio, + getVerticalCenterRatio, + getWheelScaleRatio, + isSpanValid, + zoomAtPoint, +} from '../useChartProZoom.utils'; + +export const useZoomOnWheel = ( + { + store, + instance, + svgRef, + }: Pick>[0], 'store' | 'instance' | 'svgRef'>, + interactionTimeoutRef: React.RefObject, + isDraggingRef: React.RefObject, + setIsInteracting: React.Dispatch, + setZoomDataCallback: React.Dispatch ZoomData[])>, +) => { + const drawingArea = useSelector(store, selectorChartDrawingArea); + const optionsLookup = useSelector(store, selectorChartZoomOptionsLookup); + const isZoomEnabled = Object.keys(optionsLookup).length > 0; + + // Add event for chart zoom in/out + React.useEffect(() => { + const element = svgRef.current; + if (element === null || !isZoomEnabled) { + return () => {}; + } + + const removeOnWheel = instance.addInteractionListener('wheel', (state) => { + if (element === null) { + return; + } + + const point = getSVGPoint(element, state.event); + + if (!instance.isPointInside(point)) { + return; + } + + if (!state.last) { + state.event.preventDefault(); + } + + if (interactionTimeoutRef.current) { + clearTimeout(interactionTimeoutRef.current); + } + // Debounce transition to `isInteractive=false`. + // Useful because wheel events don't have an "end" event. + if (!isDraggingRef.current) { + setIsInteracting(true); + + // Ref is passed in. + // eslint-disable-next-line react-compiler/react-compiler + interactionTimeoutRef.current = window.setTimeout(() => { + setIsInteracting(false); + }, 166); + } + + setZoomDataCallback((prevZoomData) => { + return prevZoomData.map((zoom) => { + const option = optionsLookup[zoom.axisId]; + if (!option) { + return zoom; + } + const centerRatio = + option.axisDirection === 'x' + ? getHorizontalCenterRatio(point, drawingArea) + : getVerticalCenterRatio(point, drawingArea); + + const { scaleRatio, isZoomIn } = getWheelScaleRatio(state.event, option.step); + const [newMinRange, newMaxRange] = zoomAtPoint(centerRatio, scaleRatio, zoom, option); + + if (!isSpanValid(newMinRange, newMaxRange, isZoomIn, option)) { + return zoom; + } + + return { axisId: zoom.axisId, start: newMinRange, end: newMaxRange }; + }); + }); + }); + + return () => { + removeOnWheel(); + if (interactionTimeoutRef.current) { + clearTimeout(interactionTimeoutRef.current); + } + }; + }, [ + svgRef, + drawingArea, + isZoomEnabled, + optionsLookup, + setIsInteracting, + instance, + setZoomDataCallback, + interactionTimeoutRef, + isDraggingRef, + ]); +}; 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 63dd7ba38f813..d0a4709d6ab4a 100644 --- a/packages/x-charts-pro/src/internals/plugins/useChartProZoom/useChartProZoom.ts +++ b/packages/x-charts-pro/src/internals/plugins/useChartProZoom/useChartProZoom.ts @@ -18,11 +18,11 @@ import { getHorizontalCenterRatio, getPinchScaleRatio, getVerticalCenterRatio, - getWheelScaleRatio, isSpanValid, preventDefault, zoomAtPoint, } from './useChartProZoom.utils'; +import { useZoomOnWheel } from './gestureHooks/useZoomOnWheel'; // It is helpful to avoid the need to provide the possibly auto-generated id for each axis. function initializeZoomData(options: Record) { @@ -251,79 +251,17 @@ export const useChartProZoom: ChartPlugin = ({ store, ]); - // Add event for chart zoom in/out - React.useEffect(() => { - const element = svgRef.current; - if (element === null || !isZoomEnabled) { - return () => {}; - } - - const removeOnWheel = instance.addInteractionListener('wheel', (state) => { - if (element === null) { - return; - } - - const point = getSVGPoint(element, state.event); - - if (!instance.isPointInside(point)) { - return; - } - - if (!state.last) { - state.event.preventDefault(); - } - - if (interactionTimeoutRef.current) { - clearTimeout(interactionTimeoutRef.current); - } - // Debounce transition to `isInteractive=false`. - // Useful because wheel events don't have an "end" event. - if (!isDraggingRef.current) { - setIsInteracting(true); - - interactionTimeoutRef.current = window.setTimeout(() => { - setIsInteracting(false); - }, 166); - } - - setZoomDataCallback((prevZoomData) => { - return prevZoomData.map((zoom) => { - const option = optionsLookup[zoom.axisId]; - if (!option) { - return zoom; - } - const centerRatio = - option.axisDirection === 'x' - ? getHorizontalCenterRatio(point, drawingArea) - : getVerticalCenterRatio(point, drawingArea); - - const { scaleRatio, isZoomIn } = getWheelScaleRatio(state.event, option.step); - const [newMinRange, newMaxRange] = zoomAtPoint(centerRatio, scaleRatio, zoom, option); - - if (!isSpanValid(newMinRange, newMaxRange, isZoomIn, option)) { - return zoom; - } - - return { axisId: zoom.axisId, start: newMinRange, end: newMaxRange }; - }); - }); - }); - - return () => { - removeOnWheel(); - if (interactionTimeoutRef.current) { - clearTimeout(interactionTimeoutRef.current); - } - }; - }, [ - svgRef, - drawingArea, - isZoomEnabled, - optionsLookup, + useZoomOnWheel( + { + store, + instance, + svgRef, + }, + interactionTimeoutRef, + isDraggingRef, setIsInteracting, - instance, setZoomDataCallback, - ]); + ); React.useEffect(() => { const element = svgRef.current; From 1cb800aa15ab6225df991e9692391918756d33d8 Mon Sep 17 00:00:00 2001 From: Jose Quintas Date: Thu, 13 Mar 2025 23:17:26 +0100 Subject: [PATCH 18/48] zoom on pinch --- .../gestureHooks/useZoomOnPinch.ts | 108 ++++++++++++++ .../useChartProZoom/useChartProZoom.ts | 137 +----------------- .../useChartProZoom/useChartProZoom.utils.ts | 33 ----- .../useChartInteractionListener.ts | 18 +++ 4 files changed, 130 insertions(+), 166 deletions(-) create mode 100644 packages/x-charts-pro/src/internals/plugins/useChartProZoom/gestureHooks/useZoomOnPinch.ts diff --git a/packages/x-charts-pro/src/internals/plugins/useChartProZoom/gestureHooks/useZoomOnPinch.ts b/packages/x-charts-pro/src/internals/plugins/useChartProZoom/gestureHooks/useZoomOnPinch.ts new file mode 100644 index 0000000000000..c867833f83d31 --- /dev/null +++ b/packages/x-charts-pro/src/internals/plugins/useChartProZoom/gestureHooks/useZoomOnPinch.ts @@ -0,0 +1,108 @@ +'use client'; +import * as React from 'react'; +import { + ChartPlugin, + useSelector, + getSVGPoint, + selectorChartDrawingArea, + ZoomData, + selectorChartZoomOptionsLookup, +} from '@mui/x-charts/internals'; +import { UseChartProZoomSignature } from '../useChartProZoom.types'; +import { + getHorizontalCenterRatio, + getVerticalCenterRatio, + isSpanValid, + zoomAtPoint, +} from '../useChartProZoom.utils'; + +export const useZoomOnPinch = ( + { + store, + instance, + svgRef, + }: Pick>[0], 'store' | 'instance' | 'svgRef'>, + interactionTimeoutRef: React.RefObject, + setIsInteracting: React.Dispatch, + setZoomDataCallback: React.Dispatch ZoomData[])>, +) => { + const drawingArea = useSelector(store, selectorChartDrawingArea); + const optionsLookup = useSelector(store, selectorChartZoomOptionsLookup); + const isZoomEnabled = Object.keys(optionsLookup).length > 0; + + // Zoom on pinch + React.useEffect(() => { + const element = svgRef.current; + if (element === null || !isZoomEnabled) { + return () => {}; + } + + const removePinchStart = instance.addInteractionListener('pinchStart', () => { + if (interactionTimeoutRef.current) { + clearTimeout(interactionTimeoutRef.current); + } + setIsInteracting(true); + }); + + const removePinch = instance.addInteractionListener('pinch', (state) => { + if (element === null) { + return; + } + + setZoomDataCallback((prevZoomData) => { + const newZoomData = prevZoomData.map((zoom) => { + const option = optionsLookup[zoom.axisId]; + if (!option) { + return zoom; + } + + const scaledStep = option.step / 1000; + const isZoomIn = state.direction[0] > 0; + const scaleRatio = 1 + (isZoomIn ? scaledStep : -scaledStep); + + // If the scale ratio is 0, it means the pinch gesture is not valid. + if (scaleRatio === 0) { + return zoom; + } + + const point = getSVGPoint(element, { + clientX: state.origin[0], + clientY: state.origin[1], + }); + + const centerRatio = + option.axisDirection === 'x' + ? getHorizontalCenterRatio(point, drawingArea) + : getVerticalCenterRatio(point, drawingArea); + + const [newMinRange, newMaxRange] = zoomAtPoint(centerRatio, scaleRatio, zoom, option); + + if (!isSpanValid(newMinRange, newMaxRange, isZoomIn, option)) { + return zoom; + } + return { axisId: zoom.axisId, start: newMinRange, end: newMaxRange }; + }); + return newZoomData; + }); + }); + + const removePinchEnd = instance.addInteractionListener('pinchEnd', () => { + setIsInteracting(false); + }); + + return () => { + removePinchStart(); + removePinch(); + removePinchEnd(); + }; + }, [ + svgRef, + drawingArea, + isZoomEnabled, + optionsLookup, + setIsInteracting, + instance, + setZoomDataCallback, + interactionTimeoutRef, + ]); +}; 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 d0a4709d6ab4a..3af62dfa5ed96 100644 --- a/packages/x-charts-pro/src/internals/plugins/useChartProZoom/useChartProZoom.ts +++ b/packages/x-charts-pro/src/internals/plugins/useChartProZoom/useChartProZoom.ts @@ -13,16 +13,8 @@ import { selectorChartZoomOptionsLookup, } from '@mui/x-charts/internals'; import { UseChartProZoomSignature } from './useChartProZoom.types'; -import { - getDiff, - getHorizontalCenterRatio, - getPinchScaleRatio, - getVerticalCenterRatio, - isSpanValid, - preventDefault, - zoomAtPoint, -} from './useChartProZoom.utils'; import { useZoomOnWheel } from './gestureHooks/useZoomOnWheel'; +import { useZoomOnPinch } from './gestureHooks/useZoomOnPinch'; // It is helpful to avoid the need to provide the possibly auto-generated id for each axis. function initializeZoomData(options: Record) { @@ -43,7 +35,7 @@ export const useChartProZoom: ChartPlugin = ({ const drawingArea = useSelector(store, selectorChartDrawingArea); const optionsLookup = useSelector(store, selectorChartZoomOptionsLookup); - const isZoomEnabled = Object.keys(optionsLookup).length > 0; + const pluginData = { store, instance, svgRef }; // Manage controlled state @@ -125,8 +117,6 @@ export const useChartProZoom: ChartPlugin = ({ // Add events const panningEventCacheRef = React.useRef([]); - const zoomEventCacheRef = React.useRef([]); - const eventPrevDiff = React.useRef(0); const interactionTimeoutRef = React.useRef(undefined); // Add event for chart panning @@ -252,133 +242,14 @@ export const useChartProZoom: ChartPlugin = ({ ]); useZoomOnWheel( - { - store, - instance, - svgRef, - }, + pluginData, interactionTimeoutRef, isDraggingRef, setIsInteracting, setZoomDataCallback, ); - React.useEffect(() => { - const element = svgRef.current; - if (element === null || !isZoomEnabled) { - return () => {}; - } - - function pointerDownHandler(event: PointerEvent) { - zoomEventCacheRef.current.push(event); - if (interactionTimeoutRef.current) { - clearTimeout(interactionTimeoutRef.current); - } - setIsInteracting(true); - } - - function pointerMoveHandler(event: PointerEvent) { - if (element === null) { - return; - } - - const index = zoomEventCacheRef.current.findIndex( - (cachedEv) => cachedEv.pointerId === event.pointerId, - ); - zoomEventCacheRef.current[index] = event; - - // Not a pinch gesture - if (zoomEventCacheRef.current.length !== 2) { - return; - } - - const firstEvent = zoomEventCacheRef.current[0]; - const curDiff = getDiff(zoomEventCacheRef.current); - - setZoomDataCallback((prevZoomData) => { - const newZoomData = prevZoomData.map((zoom) => { - const option = optionsLookup[zoom.axisId]; - if (!option) { - return zoom; - } - - const { scaleRatio, isZoomIn } = getPinchScaleRatio( - curDiff, - eventPrevDiff.current, - option.step, - ); - - // If the scale ratio is 0, it means the pinch gesture is not valid. - if (scaleRatio === 0) { - return zoom; - } - - const point = getSVGPoint(element, firstEvent); - - const centerRatio = - option.axisDirection === 'x' - ? getHorizontalCenterRatio(point, drawingArea) - : getVerticalCenterRatio(point, drawingArea); - - const [newMinRange, newMaxRange] = zoomAtPoint(centerRatio, scaleRatio, zoom, option); - - if (!isSpanValid(newMinRange, newMaxRange, isZoomIn, option)) { - return zoom; - } - return { axisId: zoom.axisId, start: newMinRange, end: newMaxRange }; - }); - eventPrevDiff.current = curDiff; - return newZoomData; - }); - } - - function pointerUpHandler(event: PointerEvent) { - zoomEventCacheRef.current.splice( - zoomEventCacheRef.current.findIndex( - (cachedEvent) => cachedEvent.pointerId === event.pointerId, - ), - 1, - ); - - if (zoomEventCacheRef.current.length < 2) { - eventPrevDiff.current = 0; - } - - if (event.type === 'pointerup' || event.type === 'pointercancel') { - setIsInteracting(false); - } - } - - element.addEventListener('pointerdown', pointerDownHandler); - element.addEventListener('pointermove', pointerMoveHandler); - element.addEventListener('pointerup', pointerUpHandler); - element.addEventListener('pointercancel', pointerUpHandler); - element.addEventListener('pointerout', pointerUpHandler); - element.addEventListener('pointerleave', pointerUpHandler); - - // Prevent zooming the entire page on touch devices - element.addEventListener('touchstart', preventDefault); - element.addEventListener('touchmove', preventDefault); - - return () => { - element.removeEventListener('pointerdown', pointerDownHandler); - element.removeEventListener('pointermove', pointerMoveHandler); - element.removeEventListener('pointerup', pointerUpHandler); - element.removeEventListener('pointercancel', pointerUpHandler); - element.removeEventListener('pointerout', pointerUpHandler); - element.removeEventListener('pointerleave', pointerUpHandler); - element.removeEventListener('touchstart', preventDefault); - element.removeEventListener('touchmove', preventDefault); - }; - }, [ - svgRef, - drawingArea, - isZoomEnabled, - optionsLookup, - setIsInteracting, - instance, - setZoomDataCallback, - ]); + useZoomOnPinch(pluginData, interactionTimeoutRef, setIsInteracting, setZoomDataCallback); return { publicAPI: { diff --git a/packages/x-charts-pro/src/internals/plugins/useChartProZoom/useChartProZoom.utils.ts b/packages/x-charts-pro/src/internals/plugins/useChartProZoom/useChartProZoom.utils.ts index 7f75501596e62..a6595690dca45 100644 --- a/packages/x-charts-pro/src/internals/plugins/useChartProZoom/useChartProZoom.utils.ts +++ b/packages/x-charts-pro/src/internals/plugins/useChartProZoom/useChartProZoom.utils.ts @@ -102,35 +102,6 @@ export function getWheelScaleRatio(event: WheelEvent, step: number) { return { scaleRatio, isZoomIn }; } -/** - * Get the scale ratio and if it's a zoom in or out from a pinch gesture. - */ -export function getPinchScaleRatio(curDiff: number, prevDiff: number, step: number) { - const scaledStep = step / 1000; - let scaleRatio: number = 0; - let isZoomIn: boolean = false; - - const hasMoved = prevDiff > 0; - - if (hasMoved && curDiff > prevDiff) { - // The distance between the two pointers has increased - scaleRatio = 1 + scaledStep; - isZoomIn = true; - } - if (hasMoved && curDiff < prevDiff) { - // The distance between the two pointers has decreased - scaleRatio = 1 - scaledStep; - isZoomIn = false; - } - - return { scaleRatio, isZoomIn }; -} - -export function getDiff(eventCache: PointerEvent[]) { - const [firstEvent, secondEvent] = eventCache; - return Math.hypot(firstEvent.pageX - secondEvent.pageX, firstEvent.pageY - secondEvent.pageY); -} - /** * Get the ratio of the point in the horizontal center of the area. */ @@ -142,10 +113,6 @@ export function getHorizontalCenterRatio( return (point.x - left) / width; } -export function preventDefault(event: TouchEvent) { - event.preventDefault(); -} - export function getVerticalCenterRatio( point: { x: number; y: number }, area: { top: number; height: number }, 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 5c598519594f1..0987a68c722f3 100644 --- a/packages/x-charts/src/internals/plugins/corePlugins/useChartInteractionListener/useChartInteractionListener.ts +++ b/packages/x-charts/src/internals/plugins/corePlugins/useChartInteractionListener/useChartInteractionListener.ts @@ -10,6 +10,8 @@ import { type ListenerRef = Map>>; +const preventDefault = (event: Event) => event.preventDefault(); + // TODO: use import { createUseGesture, dragAction, pinchAction } from '@use-gesture/react' export const useChartInteractionListener: ChartPlugin = ({ svgRef, @@ -50,6 +52,13 @@ export const useChartInteractionListener: ChartPlugin { const ref = listenersRef.current; + const svg = svgRef.current; + + // Disable gesture on safari + svg?.addEventListener('gesturestart', preventDefault); + svg?.addEventListener('gesturechange', preventDefault); + svg?.addEventListener('gestureend', preventDefault); return () => { ref.clear(); + svg?.removeEventListener('gesturestart', preventDefault); + svg?.removeEventListener('gesturechange', preventDefault); + svg?.removeEventListener('gestureend', preventDefault); }; }, [svgRef]); From a01917899e7db8c6d4cf7f493d128adecced63a1 Mon Sep 17 00:00:00 2001 From: Jose Quintas Date: Fri, 14 Mar 2025 11:13:11 +0100 Subject: [PATCH 19/48] move pan on drag --- .../gestureHooks/usePanOnDrag.ts | 146 ++++++++++++++++++ .../gestureHooks/useZoomOnWheel.ts | 4 +- .../useChartProZoom/useChartProZoom.ts | 138 +---------------- 3 files changed, 150 insertions(+), 138 deletions(-) create mode 100644 packages/x-charts-pro/src/internals/plugins/useChartProZoom/gestureHooks/usePanOnDrag.ts 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 new file mode 100644 index 0000000000000..8675092397f18 --- /dev/null +++ b/packages/x-charts-pro/src/internals/plugins/useChartProZoom/gestureHooks/usePanOnDrag.ts @@ -0,0 +1,146 @@ +'use client'; +import * as React from 'react'; +import { + ChartPlugin, + useSelector, + getSVGPoint, + selectorChartDrawingArea, + ZoomData, + selectorChartZoomOptionsLookup, +} from '@mui/x-charts/internals'; +import { UseChartProZoomSignature } from '../useChartProZoom.types'; + +export const usePanOnDrag = ( + { + store, + instance, + svgRef, + }: Pick>[0], 'store' | 'instance' | 'svgRef'>, + interactionTimeoutRef: React.RefObject, + setIsInteracting: React.Dispatch, + setZoomDataCallback: React.Dispatch ZoomData[])>, +) => { + const drawingArea = useSelector(store, selectorChartDrawingArea); + const optionsLookup = useSelector(store, selectorChartZoomOptionsLookup); + + // Add event for chart panning + const isPanEnabled = React.useMemo( + () => Object.values(optionsLookup).some((v) => v.panning) || false, + [optionsLookup], + ); + + const panningEventCacheRef = React.useRef([]); + const isDraggingRef = React.useRef(false); + const touchStartRef = React.useRef<{ + x: number; + y: number; + zoomData: readonly ZoomData[]; + } | null>(null); + + React.useEffect(() => { + const element = svgRef.current; + if (element === null || !isPanEnabled) { + return () => {}; + } + const removeOnDrag = instance.addInteractionListener('drag', (state) => { + if (element === null || !isDraggingRef.current || panningEventCacheRef.current.length > 1) { + return; + } + if (touchStartRef.current == null) { + return; + } + const point = getSVGPoint(element, state.event); + const movementX = point.x - touchStartRef.current.x; + const movementY = (point.y - touchStartRef.current.y) * -1; + const newZoomData = touchStartRef.current.zoomData.map((zoom) => { + const options = optionsLookup[zoom.axisId]; + if (!options || !options.panning) { + return zoom; + } + const min = zoom.start; + const max = zoom.end; + const span = max - min; + const MIN_PERCENT = options.minStart; + const MAX_PERCENT = options.maxEnd; + const movement = options.axisDirection === 'x' ? movementX : movementY; + const dimension = options.axisDirection === 'x' ? drawingArea.width : drawingArea.height; + let newMinPercent = min - (movement / dimension) * span; + let newMaxPercent = max - (movement / dimension) * span; + if (newMinPercent < MIN_PERCENT) { + newMinPercent = MIN_PERCENT; + newMaxPercent = newMinPercent + span; + } + if (newMaxPercent > MAX_PERCENT) { + newMaxPercent = MAX_PERCENT; + newMinPercent = newMaxPercent - span; + } + if ( + newMinPercent < MIN_PERCENT || + newMaxPercent > MAX_PERCENT || + span < options.minSpan || + span > options.maxSpan + ) { + return zoom; + } + return { + ...zoom, + start: newMinPercent, + end: newMaxPercent, + }; + }); + setZoomDataCallback(newZoomData); + }); + + const removeOnDragStart = instance.addInteractionListener('dragStart', (state) => { + panningEventCacheRef.current.push(state.event); + const point = getSVGPoint(element, state.event); + if (!instance.isPointInside(point)) { + return; + } + // If there is only one pointer, prevent selecting text + if (panningEventCacheRef.current.length === 1) { + state.event.preventDefault(); + } + isDraggingRef.current = true; + if (interactionTimeoutRef.current) { + clearTimeout(interactionTimeoutRef.current); + } + setIsInteracting(true); + touchStartRef.current = { + x: point.x, + y: point.y, + zoomData: store.getSnapshot().zoom.zoomData, + }; + }); + + const removeOnDragEnd = instance.addInteractionListener('dragEnd', (state) => { + panningEventCacheRef.current.splice( + panningEventCacheRef.current.findIndex( + (cachedEvent) => cachedEvent.pointerId === state.event.pointerId, + ), + 1, + ); + setIsInteracting(false); + isDraggingRef.current = false; + touchStartRef.current = null; + }); + + return () => { + removeOnDragStart(); + removeOnDrag(); + removeOnDragEnd(); + }; + }, [ + instance, + svgRef, + isDraggingRef, + setIsInteracting, + isPanEnabled, + optionsLookup, + drawingArea.width, + drawingArea.height, + setZoomDataCallback, + store, + interactionTimeoutRef, + ]); +}; diff --git a/packages/x-charts-pro/src/internals/plugins/useChartProZoom/gestureHooks/useZoomOnWheel.ts b/packages/x-charts-pro/src/internals/plugins/useChartProZoom/gestureHooks/useZoomOnWheel.ts index b27bb9adea3f4..7095767005de0 100644 --- a/packages/x-charts-pro/src/internals/plugins/useChartProZoom/gestureHooks/useZoomOnWheel.ts +++ b/packages/x-charts-pro/src/internals/plugins/useChartProZoom/gestureHooks/useZoomOnWheel.ts @@ -24,7 +24,6 @@ export const useZoomOnWheel = ( svgRef, }: Pick>[0], 'store' | 'instance' | 'svgRef'>, interactionTimeoutRef: React.RefObject, - isDraggingRef: React.RefObject, setIsInteracting: React.Dispatch, setZoomDataCallback: React.Dispatch ZoomData[])>, ) => { @@ -59,7 +58,7 @@ export const useZoomOnWheel = ( } // Debounce transition to `isInteractive=false`. // Useful because wheel events don't have an "end" event. - if (!isDraggingRef.current) { + if (!state.dragging) { setIsInteracting(true); // Ref is passed in. @@ -107,6 +106,5 @@ export const useZoomOnWheel = ( instance, setZoomDataCallback, interactionTimeoutRef, - isDraggingRef, ]); }; 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 3af62dfa5ed96..5cda41ed30643 100644 --- a/packages/x-charts-pro/src/internals/plugins/useChartProZoom/useChartProZoom.ts +++ b/packages/x-charts-pro/src/internals/plugins/useChartProZoom/useChartProZoom.ts @@ -5,16 +5,13 @@ import { ChartPlugin, AxisId, DefaultizedZoomOptions, - useSelector, - getSVGPoint, - selectorChartDrawingArea, ZoomData, createZoomLookup, - selectorChartZoomOptionsLookup, } from '@mui/x-charts/internals'; import { UseChartProZoomSignature } from './useChartProZoom.types'; import { useZoomOnWheel } from './gestureHooks/useZoomOnWheel'; import { useZoomOnPinch } from './gestureHooks/useZoomOnPinch'; +import { usePanOnDrag } from './gestureHooks/usePanOnDrag'; // It is helpful to avoid the need to provide the possibly auto-generated id for each axis. function initializeZoomData(options: Record) { @@ -33,8 +30,6 @@ export const useChartProZoom: ChartPlugin = ({ }) => { const { zoomData: paramsZoomData, onZoomChange } = params; - const drawingArea = useSelector(store, selectorChartDrawingArea); - const optionsLookup = useSelector(store, selectorChartZoomOptionsLookup); const pluginData = { store, instance, svgRef }; // Manage controlled state @@ -116,138 +111,11 @@ export const useChartProZoom: ChartPlugin = ({ ); // Add events - const panningEventCacheRef = React.useRef([]); const interactionTimeoutRef = React.useRef(undefined); - // Add event for chart panning - const isPanEnabled = React.useMemo( - () => Object.values(optionsLookup).some((v) => v.panning) || false, - [optionsLookup], - ); - - const isDraggingRef = React.useRef(false); - const touchStartRef = React.useRef<{ - x: number; - y: number; - zoomData: readonly ZoomData[]; - } | null>(null); - React.useEffect(() => { - const element = svgRef.current; - if (element === null || !isPanEnabled) { - return () => {}; - } - const handlePan = (event: PointerEvent) => { - if (element === null || !isDraggingRef.current || panningEventCacheRef.current.length > 1) { - return; - } - if (touchStartRef.current == null) { - return; - } - const point = getSVGPoint(element, event); - const movementX = point.x - touchStartRef.current.x; - const movementY = (point.y - touchStartRef.current.y) * -1; - const newZoomData = touchStartRef.current.zoomData.map((zoom) => { - const options = optionsLookup[zoom.axisId]; - if (!options || !options.panning) { - return zoom; - } - const min = zoom.start; - const max = zoom.end; - const span = max - min; - const MIN_PERCENT = options.minStart; - const MAX_PERCENT = options.maxEnd; - const movement = options.axisDirection === 'x' ? movementX : movementY; - const dimension = options.axisDirection === 'x' ? drawingArea.width : drawingArea.height; - let newMinPercent = min - (movement / dimension) * span; - let newMaxPercent = max - (movement / dimension) * span; - if (newMinPercent < MIN_PERCENT) { - newMinPercent = MIN_PERCENT; - newMaxPercent = newMinPercent + span; - } - if (newMaxPercent > MAX_PERCENT) { - newMaxPercent = MAX_PERCENT; - newMinPercent = newMaxPercent - span; - } - if ( - newMinPercent < MIN_PERCENT || - newMaxPercent > MAX_PERCENT || - span < options.minSpan || - span > options.maxSpan - ) { - return zoom; - } - return { - ...zoom, - start: newMinPercent, - end: newMaxPercent, - }; - }); - setZoomDataCallback(newZoomData); - }; - const handleDown = (event: PointerEvent) => { - panningEventCacheRef.current.push(event); - const point = getSVGPoint(element, event); - if (!instance.isPointInside(point)) { - return; - } - // If there is only one pointer, prevent selecting text - if (panningEventCacheRef.current.length === 1) { - event.preventDefault(); - } - isDraggingRef.current = true; - if (interactionTimeoutRef.current) { - clearTimeout(interactionTimeoutRef.current); - } - setIsInteracting(true); - touchStartRef.current = { - x: point.x, - y: point.y, - zoomData: store.getSnapshot().zoom.zoomData, - }; - }; - const handleUp = (event: PointerEvent) => { - panningEventCacheRef.current.splice( - panningEventCacheRef.current.findIndex( - (cachedEvent) => cachedEvent.pointerId === event.pointerId, - ), - 1, - ); - setIsInteracting(false); - isDraggingRef.current = false; - touchStartRef.current = null; - }; - element.addEventListener('pointerdown', handleDown); - document.addEventListener('pointermove', handlePan); - document.addEventListener('pointerup', handleUp); - document.addEventListener('pointercancel', handleUp); - document.addEventListener('pointerleave', handleUp); - return () => { - element.removeEventListener('pointerdown', handleDown); - document.removeEventListener('pointermove', handlePan); - document.removeEventListener('pointerup', handleUp); - document.removeEventListener('pointercancel', handleUp); - document.removeEventListener('pointerleave', handleUp); - }; - }, [ - instance, - svgRef, - isDraggingRef, - setIsInteracting, - isPanEnabled, - optionsLookup, - drawingArea.width, - drawingArea.height, - setZoomDataCallback, - store, - ]); + usePanOnDrag(pluginData, interactionTimeoutRef, setIsInteracting, setZoomDataCallback); - useZoomOnWheel( - pluginData, - interactionTimeoutRef, - isDraggingRef, - setIsInteracting, - setZoomDataCallback, - ); + useZoomOnWheel(pluginData, interactionTimeoutRef, setIsInteracting, setZoomDataCallback); useZoomOnPinch(pluginData, interactionTimeoutRef, setIsInteracting, setZoomDataCallback); From 3c180864133b8f3874bc143155581e9e0fcc27fd Mon Sep 17 00:00:00 2001 From: Jose Quintas Date: Fri, 14 Mar 2025 11:18:01 +0100 Subject: [PATCH 20/48] cleanup cache logic --- .../gestureHooks/usePanOnDrag.ts | 23 ++++--------------- 1 file changed, 4 insertions(+), 19 deletions(-) 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 8675092397f18..e0f58a96d6098 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 @@ -29,8 +29,6 @@ export const usePanOnDrag = ( [optionsLookup], ); - const panningEventCacheRef = React.useRef([]); - const isDraggingRef = React.useRef(false); const touchStartRef = React.useRef<{ x: number; y: number; @@ -42,8 +40,9 @@ export const usePanOnDrag = ( if (element === null || !isPanEnabled) { return () => {}; } + const removeOnDrag = instance.addInteractionListener('drag', (state) => { - if (element === null || !isDraggingRef.current || panningEventCacheRef.current.length > 1) { + if (element === null) { return; } if (touchStartRef.current == null) { @@ -92,16 +91,11 @@ export const usePanOnDrag = ( }); const removeOnDragStart = instance.addInteractionListener('dragStart', (state) => { - panningEventCacheRef.current.push(state.event); const point = getSVGPoint(element, state.event); if (!instance.isPointInside(point)) { return; } - // If there is only one pointer, prevent selecting text - if (panningEventCacheRef.current.length === 1) { - state.event.preventDefault(); - } - isDraggingRef.current = true; + if (interactionTimeoutRef.current) { clearTimeout(interactionTimeoutRef.current); } @@ -113,16 +107,8 @@ export const usePanOnDrag = ( }; }); - const removeOnDragEnd = instance.addInteractionListener('dragEnd', (state) => { - panningEventCacheRef.current.splice( - panningEventCacheRef.current.findIndex( - (cachedEvent) => cachedEvent.pointerId === state.event.pointerId, - ), - 1, - ); + const removeOnDragEnd = instance.addInteractionListener('dragEnd', () => { setIsInteracting(false); - isDraggingRef.current = false; - touchStartRef.current = null; }); return () => { @@ -133,7 +119,6 @@ export const usePanOnDrag = ( }, [ instance, svgRef, - isDraggingRef, setIsInteracting, isPanEnabled, optionsLookup, From 61bf65775c370dc20e2825e3e715f6950ea64d35 Mon Sep 17 00:00:00 2001 From: Jose Quintas Date: Fri, 14 Mar 2025 11:23:49 +0100 Subject: [PATCH 21/48] fix mobile pinch --- .../gestureHooks/usePanOnDrag.ts | 23 ++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) 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 e0f58a96d6098..0ba93f735b683 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 @@ -42,12 +42,10 @@ export const usePanOnDrag = ( } const removeOnDrag = instance.addInteractionListener('drag', (state) => { - if (element === null) { - return; - } - if (touchStartRef.current == null) { - return; + if (state.pinching || element === null || touchStartRef.current === null) { + return state.cancel(); } + const point = getSVGPoint(element, state.event); const movementX = point.x - touchStartRef.current.x; const movementY = (point.y - touchStartRef.current.y) * -1; @@ -88,12 +86,14 @@ export const usePanOnDrag = ( }; }); setZoomDataCallback(newZoomData); + return undefined; }); const removeOnDragStart = instance.addInteractionListener('dragStart', (state) => { const point = getSVGPoint(element, state.event); - if (!instance.isPointInside(point)) { - return; + + if (state.pinching || !instance.isPointInside(point)) { + return state.cancel(); } if (interactionTimeoutRef.current) { @@ -105,10 +105,17 @@ export const usePanOnDrag = ( y: point.y, zoomData: store.getSnapshot().zoom.zoomData, }; + + return undefined; }); - const removeOnDragEnd = instance.addInteractionListener('dragEnd', () => { + const removeOnDragEnd = instance.addInteractionListener('dragEnd', (state) => { + if (state.pinching) { + return state.cancel(); + } + setIsInteracting(false); + return undefined; }); return () => { From 9595fac10737aa555b9956f59d9650c2029f02ec Mon Sep 17 00:00:00 2001 From: Jose Quintas Date: Fri, 14 Mar 2025 11:30:17 +0100 Subject: [PATCH 22/48] reorder --- .../src/internals/plugins/useChartProZoom/useChartProZoom.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) 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 5cda41ed30643..9817c3f08df22 100644 --- a/packages/x-charts-pro/src/internals/plugins/useChartProZoom/useChartProZoom.ts +++ b/packages/x-charts-pro/src/internals/plugins/useChartProZoom/useChartProZoom.ts @@ -30,10 +30,7 @@ export const useChartProZoom: ChartPlugin = ({ }) => { const { zoomData: paramsZoomData, onZoomChange } = params; - const pluginData = { store, instance, svgRef }; - // Manage controlled state - useEnhancedEffect(() => { if (paramsZoomData === undefined) { return undefined; @@ -112,6 +109,7 @@ export const useChartProZoom: ChartPlugin = ({ // Add events const interactionTimeoutRef = React.useRef(undefined); + const pluginData = { store, instance, svgRef }; usePanOnDrag(pluginData, interactionTimeoutRef, setIsInteracting, setZoomDataCallback); From 223c9b16ba71fcb5b92e97fff0d495a9aeeb54c0 Mon Sep 17 00:00:00 2001 From: Jose Quintas Date: Fri, 14 Mar 2025 16:30:52 +0100 Subject: [PATCH 23/48] fix mobile interaction --- .../gestureHooks/usePanOnDrag.ts | 17 +++---- .../ChartsTooltip/ChartsTooltipContainer.tsx | 11 ++-- packages/x-charts/src/ChartsTooltip/utils.tsx | 33 +++++++----- .../useChartInteractionListener.ts | 1 + .../useChartVoronoi/useChartVoronoi.ts | 50 +++++++------------ 5 files changed, 55 insertions(+), 57 deletions(-) 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 0ba93f735b683..05bbd5b79d534 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 @@ -42,10 +42,14 @@ export const usePanOnDrag = ( } const removeOnDrag = instance.addInteractionListener('drag', (state) => { - if (state.pinching || element === null || touchStartRef.current === null) { + if (state.pinching) { return state.cancel(); } + if (element === null || touchStartRef.current === null) { + return undefined; + } + const point = getSVGPoint(element, state.event); const movementX = point.x - touchStartRef.current.x; const movementY = (point.y - touchStartRef.current.y) * -1; @@ -85,6 +89,7 @@ export const usePanOnDrag = ( end: newMaxPercent, }; }); + // TODO: fix max update issue setZoomDataCallback(newZoomData); return undefined; }); @@ -92,10 +97,6 @@ export const usePanOnDrag = ( const removeOnDragStart = instance.addInteractionListener('dragStart', (state) => { const point = getSVGPoint(element, state.event); - if (state.pinching || !instance.isPointInside(point)) { - return state.cancel(); - } - if (interactionTimeoutRef.current) { clearTimeout(interactionTimeoutRef.current); } @@ -109,11 +110,7 @@ export const usePanOnDrag = ( return undefined; }); - const removeOnDragEnd = instance.addInteractionListener('dragEnd', (state) => { - if (state.pinching) { - return state.cancel(); - } - + const removeOnDragEnd = instance.addInteractionListener('dragEnd', () => { setIsInteracting(false); return undefined; }); diff --git a/packages/x-charts/src/ChartsTooltip/ChartsTooltipContainer.tsx b/packages/x-charts/src/ChartsTooltip/ChartsTooltipContainer.tsx index 90156615d62f0..74b2939eba198 100644 --- a/packages/x-charts/src/ChartsTooltip/ChartsTooltipContainer.tsx +++ b/packages/x-charts/src/ChartsTooltip/ChartsTooltipContainer.tsx @@ -83,14 +83,17 @@ function ChartsTooltipContainer(inProps: ChartsTooltipContainerProps) { const popperOpen = pointerType !== null && isOpen; // tooltipHasData; React.useEffect(() => { - const removeOnMove = instance.addInteractionListener('move', (state) => { - // eslint-disable-next-line react-compiler/react-compiler - positionRef.current = { x: state.event.clientX, y: state.event.clientY }; - popperRef.current?.update(); + const [removeOnMove, removeOnDrag] = ['move', 'drag'].map((eventName) => { + return instance.addInteractionListener(eventName as 'drag', (state) => { + // eslint-disable-next-line react-compiler/react-compiler + positionRef.current = { x: state.event.clientX, y: state.event.clientY }; + popperRef.current?.update(); + }); }); return () => { removeOnMove(); + removeOnDrag(); }; }, [positionRef, instance]); diff --git a/packages/x-charts/src/ChartsTooltip/utils.tsx b/packages/x-charts/src/ChartsTooltip/utils.tsx index a76ef60cfde6a..7c18555248708 100644 --- a/packages/x-charts/src/ChartsTooltip/utils.tsx +++ b/packages/x-charts/src/ChartsTooltip/utils.tsx @@ -29,13 +29,20 @@ export function useMouseTracker(): UseMouseTrackerReturnValue { height: state.event.height, pointerType: state.event.pointerType as MousePosition['pointerType'], }); - } else if (state.event.pointerType !== 'mouse') { - setMousePosition(null); } }); + const removeOnDrag = instance.addInteractionListener('drag', (state) => { + setMousePosition({ + x: state.event.clientX, + y: state.event.clientY, + height: state.event.height, + pointerType: state.event.pointerType as MousePosition['pointerType'], + }); + }); return () => { removeOnHover(); + removeOnDrag(); }; }, [instance]); @@ -47,26 +54,28 @@ type PointerType = Pick; export function usePointerType(): null | PointerType { const { instance } = useChartContext(); - // Use a ref to avoid rerendering on every mousemove event. const [pointerType, setPointerType] = React.useState(null); React.useEffect(() => { - const removeOnMoveEnd = instance.addInteractionListener('moveEnd', (state) => { - if (state.event.pointerType !== 'mouse') { - setPointerType(null); - } + const removeOnDragEnd = instance.addInteractionListener('dragEnd', () => { + // TODO: We can check and only close when it is not a tap with `!state.tap` + // This would allow users to click/tap on the chart to display the tooltip. + setPointerType(null); }); - const removeOnMoveStart = instance.addInteractionListener('moveStart', (state) => { - setPointerType({ - height: state.event.height, - pointerType: state.event.pointerType as PointerType['pointerType'], + const [removeOnMoveStart, removeOnDragStart] = ['moveStart', 'dragStart'].map((interaction) => { + return instance.addInteractionListener(interaction as 'dragStart', (state) => { + setPointerType({ + height: state.event.height, + pointerType: state.event.pointerType as PointerType['pointerType'], + }); }); }); return () => { - removeOnMoveEnd(); removeOnMoveStart(); + removeOnDragEnd(); + removeOnDragStart(); }; }, [instance]); 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 0987a68c722f3..d89427c69e050 100644 --- a/packages/x-charts/src/internals/plugins/corePlugins/useChartInteractionListener/useChartInteractionListener.ts +++ b/packages/x-charts/src/internals/plugins/corePlugins/useChartInteractionListener/useChartInteractionListener.ts @@ -51,6 +51,7 @@ export const useChartInteractionListener: ChartPlugin = ({ } }); - const removeOnMove = instance.addInteractionListener('move', (state) => { - const closestPoint = getClosestPoint(state.event); - - if (closestPoint === 'outside-chart') { - instance.cleanInteraction?.(); - instance.clearHighlight?.(); - return; - } - - if (closestPoint === 'outside-voronoi-max-radius' || closestPoint === 'no-point-found') { - instance.removeItemInteraction?.(); - instance.clearHighlight?.(); - return; - } - - const { seriesId, dataIndex } = closestPoint; - - instance.setItemInteraction?.({ type: 'scatter', seriesId, dataIndex }); - instance.setHighlight?.({ - seriesId, - dataIndex, - }); - }); + const [removeOnMove, removeOnDrag] = ['move', 'drag'].map((eventName) => { + return instance.addInteractionListener(eventName as 'drag', (state) => { + const closestPoint = getClosestPoint(state.event); - const removeOnDrag = instance.addInteractionListener('drag', (state) => { - if (state.tap) { - if (!onItemClick) { + if (closestPoint === 'outside-chart') { + instance.cleanInteraction?.(); + instance.clearHighlight?.(); return; } - const closestPoint = getClosestPoint(state.event); - if (typeof closestPoint === 'string') { - // No point fond for any reason + if (closestPoint === 'outside-voronoi-max-radius' || closestPoint === 'no-point-found') { + instance.removeItemInteraction?.(); + instance.clearHighlight?.(); return; } const { seriesId, dataIndex } = closestPoint; - onItemClick(state.event, { type: 'scatter', seriesId, dataIndex }); - } + instance.setItemInteraction?.({ type: 'scatter', seriesId, dataIndex }); + instance.setHighlight?.({ + seriesId, + dataIndex, + }); + + if (state.tap && onItemClick) { + onItemClick(state.event, { type: 'scatter', seriesId, dataIndex }); + } + }); }); return () => { From 568f7da827fdc7232e41c4cda0161774a290be81 Mon Sep 17 00:00:00 2001 From: Jose Quintas Date: Fri, 14 Mar 2025 18:12:13 +0100 Subject: [PATCH 24/48] allow config multiple listeners at once --- .../gestureHooks/usePanOnDrag.ts | 13 ++- .../gestureHooks/useZoomOnPinch.ts | 12 +-- .../gestureHooks/useZoomOnWheel.ts | 4 +- .../ChartsTooltip/ChartsTooltipContainer.tsx | 13 +-- packages/x-charts/src/ChartsTooltip/utils.tsx | 28 +++--- .../useChartInteractionListener.ts | 19 +++- .../useChartInteractionListener.types.ts | 38 ++++++-- .../useChartCartesianAxis.ts | 68 +++++++------- .../useChartPolarAxis/useChartPolarAxis.ts | 94 +++++++++---------- .../useChartVoronoi/useChartVoronoi.ts | 18 ++-- 10 files changed, 172 insertions(+), 135 deletions(-) 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 05bbd5b79d534..713535e7d9197 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 @@ -41,7 +41,7 @@ export const usePanOnDrag = ( return () => {}; } - const removeOnDrag = instance.addInteractionListener('drag', (state) => { + const panHandler = instance.addInteractionListener('drag', (state) => { if (state.pinching) { return state.cancel(); } @@ -94,7 +94,7 @@ export const usePanOnDrag = ( return undefined; }); - const removeOnDragStart = instance.addInteractionListener('dragStart', (state) => { + const panStartHandler = instance.addInteractionListener('dragStart', (state) => { const point = getSVGPoint(element, state.event); if (interactionTimeoutRef.current) { @@ -110,15 +110,14 @@ export const usePanOnDrag = ( return undefined; }); - const removeOnDragEnd = instance.addInteractionListener('dragEnd', () => { + const panEndHandler = instance.addInteractionListener('dragEnd', () => { setIsInteracting(false); - return undefined; }); return () => { - removeOnDragStart(); - removeOnDrag(); - removeOnDragEnd(); + panHandler.cleanup(); + panStartHandler.cleanup(); + panEndHandler.cleanup(); }; }, [ instance, diff --git a/packages/x-charts-pro/src/internals/plugins/useChartProZoom/gestureHooks/useZoomOnPinch.ts b/packages/x-charts-pro/src/internals/plugins/useChartProZoom/gestureHooks/useZoomOnPinch.ts index c867833f83d31..c31ad7fcc197e 100644 --- a/packages/x-charts-pro/src/internals/plugins/useChartProZoom/gestureHooks/useZoomOnPinch.ts +++ b/packages/x-charts-pro/src/internals/plugins/useChartProZoom/gestureHooks/useZoomOnPinch.ts @@ -37,14 +37,14 @@ export const useZoomOnPinch = ( return () => {}; } - const removePinchStart = instance.addInteractionListener('pinchStart', () => { + const zoomStartHandler = instance.addInteractionListener('pinchStart', () => { if (interactionTimeoutRef.current) { clearTimeout(interactionTimeoutRef.current); } setIsInteracting(true); }); - const removePinch = instance.addInteractionListener('pinch', (state) => { + const zoomHandler = instance.addInteractionListener('pinch', (state) => { if (element === null) { return; } @@ -86,14 +86,14 @@ export const useZoomOnPinch = ( }); }); - const removePinchEnd = instance.addInteractionListener('pinchEnd', () => { + const zoomEndHandler = instance.addInteractionListener('pinchEnd', () => { setIsInteracting(false); }); return () => { - removePinchStart(); - removePinch(); - removePinchEnd(); + zoomStartHandler.cleanup(); + zoomHandler.cleanup(); + zoomEndHandler.cleanup(); }; }, [ svgRef, diff --git a/packages/x-charts-pro/src/internals/plugins/useChartProZoom/gestureHooks/useZoomOnWheel.ts b/packages/x-charts-pro/src/internals/plugins/useChartProZoom/gestureHooks/useZoomOnWheel.ts index 7095767005de0..1423d4690c6fb 100644 --- a/packages/x-charts-pro/src/internals/plugins/useChartProZoom/gestureHooks/useZoomOnWheel.ts +++ b/packages/x-charts-pro/src/internals/plugins/useChartProZoom/gestureHooks/useZoomOnWheel.ts @@ -38,7 +38,7 @@ export const useZoomOnWheel = ( return () => {}; } - const removeOnWheel = instance.addInteractionListener('wheel', (state) => { + const zoomOnWheelHandler = instance.addInteractionListener('wheel', (state) => { if (element === null) { return; } @@ -92,7 +92,7 @@ export const useZoomOnWheel = ( }); return () => { - removeOnWheel(); + zoomOnWheelHandler.cleanup(); if (interactionTimeoutRef.current) { clearTimeout(interactionTimeoutRef.current); } diff --git a/packages/x-charts/src/ChartsTooltip/ChartsTooltipContainer.tsx b/packages/x-charts/src/ChartsTooltip/ChartsTooltipContainer.tsx index 74b2939eba198..c0f889adcfddd 100644 --- a/packages/x-charts/src/ChartsTooltip/ChartsTooltipContainer.tsx +++ b/packages/x-charts/src/ChartsTooltip/ChartsTooltipContainer.tsx @@ -83,17 +83,14 @@ function ChartsTooltipContainer(inProps: ChartsTooltipContainerProps) { const popperOpen = pointerType !== null && isOpen; // tooltipHasData; React.useEffect(() => { - const [removeOnMove, removeOnDrag] = ['move', 'drag'].map((eventName) => { - return instance.addInteractionListener(eventName as 'drag', (state) => { - // eslint-disable-next-line react-compiler/react-compiler - positionRef.current = { x: state.event.clientX, y: state.event.clientY }; - popperRef.current?.update(); - }); + const positionHandler = instance.addMultipleInteractionListeners(['move', 'drag'], (state) => { + // eslint-disable-next-line react-compiler/react-compiler + positionRef.current = { x: state.event.clientX, y: state.event.clientY }; + popperRef.current?.update(); }); return () => { - removeOnMove(); - removeOnDrag(); + positionHandler.cleanup(); }; }, [positionRef, instance]); diff --git a/packages/x-charts/src/ChartsTooltip/utils.tsx b/packages/x-charts/src/ChartsTooltip/utils.tsx index 7c18555248708..10ab3213fcaf6 100644 --- a/packages/x-charts/src/ChartsTooltip/utils.tsx +++ b/packages/x-charts/src/ChartsTooltip/utils.tsx @@ -21,7 +21,8 @@ export function useMouseTracker(): UseMouseTrackerReturnValue { const [mousePosition, setMousePosition] = React.useState(null); React.useEffect(() => { - const removeOnHover = instance.addInteractionListener('hover', (state) => { + // Hover event is triggered when the mouse is moved over/out the chart. + const positionOnHoverHandler = instance.addInteractionListener('hover', (state) => { if (state.hovering) { setMousePosition({ x: state.event.clientX, @@ -31,7 +32,9 @@ export function useMouseTracker(): UseMouseTrackerReturnValue { }); } }); - const removeOnDrag = instance.addInteractionListener('drag', (state) => { + + // Drag event is triggered by mobile touch or mouse drag. + const positionOnDragHandler = instance.addInteractionListener('drag', (state) => { setMousePosition({ x: state.event.clientX, y: state.event.clientY, @@ -41,8 +44,8 @@ export function useMouseTracker(): UseMouseTrackerReturnValue { }); return () => { - removeOnHover(); - removeOnDrag(); + positionOnHoverHandler.cleanup(); + positionOnDragHandler.cleanup(); }; }, [instance]); @@ -57,25 +60,26 @@ export function usePointerType(): null | PointerType { const [pointerType, setPointerType] = React.useState(null); React.useEffect(() => { - const removeOnDragEnd = instance.addInteractionListener('dragEnd', () => { + const removePointerHandler = instance.addInteractionListener('dragEnd', () => { // TODO: We can check and only close when it is not a tap with `!state.tap` // This would allow users to click/tap on the chart to display the tooltip. setPointerType(null); }); - const [removeOnMoveStart, removeOnDragStart] = ['moveStart', 'dragStart'].map((interaction) => { - return instance.addInteractionListener(interaction as 'dragStart', (state) => { + // Move is mouse, Drag is both mouse and touch. + const setPointerHandler = instance.addMultipleInteractionListeners( + ['moveStart', 'dragStart'], + (state) => { setPointerType({ height: state.event.height, pointerType: state.event.pointerType as PointerType['pointerType'], }); - }); - }); + }, + ); return () => { - removeOnMoveStart(); - removeOnDragEnd(); - removeOnDragStart(); + removePointerHandler.cleanup(); + setPointerHandler.cleanup(); }; }, [instance]); 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 d89427c69e050..a6528f8165583 100644 --- a/packages/x-charts/src/internals/plugins/corePlugins/useChartInteractionListener/useChartInteractionListener.ts +++ b/packages/x-charts/src/internals/plugins/corePlugins/useChartInteractionListener/useChartInteractionListener.ts @@ -5,6 +5,7 @@ import { ChartPlugin } from '../../models'; import { UseChartInteractionListenerSignature, AddInteractionListener, + AddMultipleInteractionListeners, ChartInteraction, } from './useChartInteractionListener.types'; @@ -75,13 +76,26 @@ export const useChartInteractionListener: ChartPlugin { - listeners.delete(callback); + return { + cleanup: () => listeners.delete(callback), }; }, [], ); + const addMultipleInteractionListeners: AddMultipleInteractionListeners = React.useCallback( + (interactions, callback) => { + const cleanups = interactions.map((interaction) => + // @ts-expect-error Overriding the type because the type of the callback is not inferred + addInteractionListener(interaction, callback), + ); + return { + cleanup: () => cleanups.forEach((cleanup) => cleanup.cleanup()), + }; + }, + [addInteractionListener], + ); + React.useEffect(() => { const ref = listenersRef.current; const svg = svgRef.current; @@ -102,6 +116,7 @@ export const useChartInteractionListener: ChartPlugin void; +export type InteractionListenerResult = { cleanup: () => void }; export type AddInteractionListener = { ( interaction: 'drag' | 'dragStart' | 'dragEnd', callback: Handler<'drag', PointerEvent>, - ): RemoveInteractionListener; + ): InteractionListenerResult; ( interaction: 'pinch' | 'pinchStart' | 'pinchEnd', callback: Handler<'pinch', PointerEvent>, - ): RemoveInteractionListener; + ): InteractionListenerResult; ( interaction: 'wheel' | 'wheelStart' | 'wheelEnd', callback: Handler<'wheel', WheelEvent>, - ): RemoveInteractionListener; + ): InteractionListenerResult; ( interaction: 'move' | 'moveStart' | 'moveEnd', callback: Handler<'move', PointerEvent>, - ): RemoveInteractionListener; - (interaction: 'hover', callback: Handler<'hover', PointerEvent>): RemoveInteractionListener; + ): InteractionListenerResult; + (interaction: 'hover', callback: Handler<'hover', PointerEvent>): InteractionListenerResult; +}; + +type InteractionMap = T extends 'wheel' | 'wheelStart' | 'wheelEnd' + ? WheelEvent + : PointerEvent; + +export type AddMultipleInteractionListeners = { + < + T extends ChartInteraction[], + K = T, + I extends ChartInteraction[] = T, + D extends GestureKey = K extends (infer J)[] ? (J extends GestureKey ? J : never) : never, + E extends ChartInteraction = I extends (infer J)[] ? J : never, + >( + interactions: T, + callback: Handler>, + ): InteractionListenerResult; }; export interface UseChartInteractionListenerParameters {} @@ -53,6 +70,13 @@ export interface UseChartInteractionListenerInstance { * @param callback The callback to call when the interaction occurs. */ addInteractionListener: AddInteractionListener; + /** + * Adds multiple interaction listeners to the SVG element. + * + * @param interactions The interactions to listen to. + * @param callback The callback to call when the interaction occurs. + */ + addMultipleInteractionListeners: AddMultipleInteractionListeners; } export type UseChartInteractionListenerSignature = ChartPluginSignature<{ diff --git a/packages/x-charts/src/internals/plugins/featurePlugins/useChartCartesianAxis/useChartCartesianAxis.ts b/packages/x-charts/src/internals/plugins/featurePlugins/useChartCartesianAxis/useChartCartesianAxis.ts index 9f2f9765a227d..24404e1f13020 100644 --- a/packages/x-charts/src/internals/plugins/featurePlugins/useChartCartesianAxis/useChartCartesianAxis.ts +++ b/packages/x-charts/src/internals/plugins/featurePlugins/useChartCartesianAxis/useChartCartesianAxis.ts @@ -81,51 +81,49 @@ export const useChartCartesianAxis: ChartPlugin {}; } - const removeOnHover = instance.addInteractionListener('hover', (state) => { + // Clean the interaction when the mouse leaves the chart. + const cleanInteractionHandler = instance.addInteractionListener('hover', (state) => { if (!state.hovering) { mousePosition.current.isInChart = false; instance.cleanInteraction?.(); } }); - const [removeOnMove, removeOnDrag] = ['move', 'drag'].map((interaction) => - instance.addInteractionListener( - // We force `as drag` to fix typing - interaction as 'drag', - (state) => { - const target = - 'targetTouches' in state.event - ? (state.event as any as TouchEvent).targetTouches[0] - : state.event; - const svgPoint = getSVGPoint(element, target); - - const isPointInside = instance.isPointInside(svgPoint, { - targetElement: state.event.target as SVGElement, - }); - if (!isPointInside) { - if (mousePosition.current.isInChart) { - store.update((prev) => ({ - ...prev, - interaction: { item: null, axis: { x: null, y: null } }, - })); - mousePosition.current.isInChart = false; - } + // Move is mouse, Drag is both mouse and touch. + const setInteractionHandler = instance.addMultipleInteractionListeners( + ['move', 'drag'], + (state) => { + const target = + 'targetTouches' in state.event + ? (state.event as any as TouchEvent).targetTouches[0] + : state.event; + const svgPoint = getSVGPoint(element, target); + + const isPointInside = instance.isPointInside(svgPoint, { + targetElement: state.event.target as SVGElement, + }); + if (!isPointInside) { + if (mousePosition.current.isInChart) { + store.update((prev) => ({ + ...prev, + interaction: { item: null, axis: { x: null, y: null } }, + })); + mousePosition.current.isInChart = false; } + } - mousePosition.current.isInChart = true; + mousePosition.current.isInChart = true; - instance.setAxisInteraction?.({ - x: getAxisValue(xAxisWithScale[usedXAxis], svgPoint.x), - y: getAxisValue(yAxisWithScale[usedYAxis], svgPoint.y), - }); - }, - ), + instance.setAxisInteraction?.({ + x: getAxisValue(xAxisWithScale[usedXAxis], svgPoint.x), + y: getAxisValue(yAxisWithScale[usedYAxis], svgPoint.y), + }); + }, ); return () => { - removeOnHover(); - removeOnMove(); - removeOnDrag(); + cleanInteractionHandler.cleanup(); + setInteractionHandler.cleanup(); }; }, [ svgRef, @@ -160,7 +158,7 @@ export const useChartCartesianAxis: ChartPlugin {}; } - const removeOnDrag = instance.addInteractionListener('drag', (state) => { + const axisClickHandler = instance.addInteractionListener('drag', (state) => { if (!state.tap) { return; } @@ -214,7 +212,7 @@ export const useChartCartesianAxis: ChartPlugin { - removeOnDrag(); + axisClickHandler.cleanup(); }; }, [ params.onAxisClick, diff --git a/packages/x-charts/src/internals/plugins/featurePlugins/useChartPolarAxis/useChartPolarAxis.ts b/packages/x-charts/src/internals/plugins/featurePlugins/useChartPolarAxis/useChartPolarAxis.ts index 0f740d9bc2063..58fbf07276d32 100644 --- a/packages/x-charts/src/internals/plugins/featurePlugins/useChartPolarAxis/useChartPolarAxis.ts +++ b/packages/x-charts/src/internals/plugins/featurePlugins/useChartPolarAxis/useChartPolarAxis.ts @@ -104,67 +104,65 @@ export const useChartPolarAxis: ChartPlugin> = ( return () => {}; } - const removeOnHover = instance.addInteractionListener('hover', (state) => { + // Clean the interaction when the mouse leaves the chart. + const cleanInteractionHandler = instance.addInteractionListener('hover', (state) => { if (!state.hovering) { mousePosition.current.isInChart = false; instance.cleanInteraction?.(); } }); - const [removeOnMove, removeOnDrag] = ['move', 'drag'].map((interaction) => - instance.addInteractionListener( - // We force `as drag` to fix typing - interaction as 'drag', - (state) => { - const target = - 'targetTouches' in state.event - ? (state.event as any as TouchEvent).targetTouches[0] - : state.event; - const svgPoint = getSVGPoint(element, target); - - const isPointInside = instance.isPointInside(svgPoint, { - targetElement: state.event.target as SVGElement, - }); - if (!isPointInside) { - if (mousePosition.current.isInChart) { - store.update((prev) => ({ - ...prev, - interaction: { item: null, axis: { x: null, y: null } }, - })); - mousePosition.current.isInChart = false; - } + // Move is mouse, Drag is both mouse and touch. + const setInteractionHandler = instance.addMultipleInteractionListeners( + ['move', 'drag'], + (state) => { + const target = + 'targetTouches' in state.event + ? (state.event as any as TouchEvent).targetTouches[0] + : state.event; + const svgPoint = getSVGPoint(element, target); + + const isPointInside = instance.isPointInside(svgPoint, { + targetElement: state.event.target as SVGElement, + }); + if (!isPointInside) { + if (mousePosition.current.isInChart) { + store.update((prev) => ({ + ...prev, + interaction: { item: null, axis: { x: null, y: null } }, + })); + mousePosition.current.isInChart = false; } - - // Test if it's in the radar circle - const radiusSquare = (center.cx - svgPoint.x) ** 2 + (center.cy - svgPoint.y) ** 2; - const maxRadius = radiusAxisWithScale[usedRadiusAxisId].scale.range()[1]; - - if (radiusSquare > maxRadius ** 2) { - if (mousePosition.current.isInChart) { - store.update((prev) => ({ - ...prev, - interaction: { item: null, axis: { x: null, y: null } }, - })); - mousePosition.current.isInChart = false; - } - return; + } + + // Test if it's in the radar circle + const radiusSquare = (center.cx - svgPoint.x) ** 2 + (center.cy - svgPoint.y) ** 2; + const maxRadius = radiusAxisWithScale[usedRadiusAxisId].scale.range()[1]; + + if (radiusSquare > maxRadius ** 2) { + if (mousePosition.current.isInChart) { + store.update((prev) => ({ + ...prev, + interaction: { item: null, axis: { x: null, y: null } }, + })); + mousePosition.current.isInChart = false; } + return; + } - mousePosition.current.isInChart = true; - const angle = svg2rotation(svgPoint.x, svgPoint.y); + mousePosition.current.isInChart = true; + const angle = svg2rotation(svgPoint.x, svgPoint.y); - instance.setAxisInteraction?.({ - x: getAxisValue(rotationAxisWithScale[usedRotationAxisId], angle), - y: null, - }); - }, - ), + instance.setAxisInteraction?.({ + x: getAxisValue(rotationAxisWithScale[usedRotationAxisId], angle), + y: null, + }); + }, ); return () => { - removeOnHover(); - removeOnMove(); - removeOnDrag(); + cleanInteractionHandler.cleanup(); + setInteractionHandler.cleanup(); }; }, [ svgRef, diff --git a/packages/x-charts/src/internals/plugins/featurePlugins/useChartVoronoi/useChartVoronoi.ts b/packages/x-charts/src/internals/plugins/featurePlugins/useChartVoronoi/useChartVoronoi.ts index 8e37931e0f738..2b15b47ad3296 100644 --- a/packages/x-charts/src/internals/plugins/featurePlugins/useChartVoronoi/useChartVoronoi.ts +++ b/packages/x-charts/src/internals/plugins/featurePlugins/useChartVoronoi/useChartVoronoi.ts @@ -162,15 +162,17 @@ export const useChartVoronoi: ChartPlugin = ({ return { seriesId: closestSeries.seriesId, dataIndex }; } - const removeOnHover = instance.addInteractionListener('hover', (state) => { + // Clean the interaction when the mouse leaves the chart. + const cleanInteractionHandler = instance.addInteractionListener('hover', (state) => { if (!state.hovering) { instance.cleanInteraction?.(); instance.clearHighlight?.(); } }); - const [removeOnMove, removeOnDrag] = ['move', 'drag'].map((eventName) => { - return instance.addInteractionListener(eventName as 'drag', (state) => { + const setInteractionHandler = instance.addMultipleInteractionListeners( + ['move', 'drag'], + (state) => { const closestPoint = getClosestPoint(state.event); if (closestPoint === 'outside-chart') { @@ -192,16 +194,16 @@ export const useChartVoronoi: ChartPlugin = ({ dataIndex, }); + // @ts-expect-error tap doesn't exist on move. if (state.tap && onItemClick) { onItemClick(state.event, { type: 'scatter', seriesId, dataIndex }); } - }); - }); + }, + ); return () => { - removeOnHover(); - removeOnMove(); - removeOnDrag(); + cleanInteractionHandler.cleanup(); + setInteractionHandler.cleanup(); }; }, [svgRef, yAxis, xAxis, voronoiMaxRadius, onItemClick, disableVoronoi, drawingArea, instance]); From c89dabdf8baa86e7cdba7843ef1f86887976557f Mon Sep 17 00:00:00 2001 From: Jose Quintas Date: Sun, 16 Mar 2025 17:49:06 +0100 Subject: [PATCH 25/48] tree shake --- .../useChartInteractionListener.ts | 21 +++++++++++++++++-- test/karma.conf.js | 6 ------ test/karma.tests.js | 2 +- 3 files changed, 20 insertions(+), 9 deletions(-) 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 a6528f8165583..3dc4f47b76632 100644 --- a/packages/x-charts/src/internals/plugins/corePlugins/useChartInteractionListener/useChartInteractionListener.ts +++ b/packages/x-charts/src/internals/plugins/corePlugins/useChartInteractionListener/useChartInteractionListener.ts @@ -1,6 +1,14 @@ 'use client'; import * as React from 'react'; -import { Handler, useGesture } from '@use-gesture/react'; +import { + createUseGesture, + dragAction, + pinchAction, + wheelAction, + moveAction, + hoverAction, + Handler, +} from '@use-gesture/react'; import { ChartPlugin } from '../../models'; import { UseChartInteractionListenerSignature, @@ -13,7 +21,16 @@ type ListenerRef = Map>>; const preventDefault = (event: Event) => event.preventDefault(); -// TODO: use import { createUseGesture, dragAction, pinchAction } from '@use-gesture/react' +// Create our own useGesture hook with the actions we need +// Better for tree shaking +const useGesture = createUseGesture([ + dragAction, + pinchAction, + wheelAction, + moveAction, + hoverAction, +]); + export const useChartInteractionListener: ChartPlugin = ({ svgRef, }) => { diff --git a/test/karma.conf.js b/test/karma.conf.js index 7b4516a71b7e6..44c66cd56519d 100644 --- a/test/karma.conf.js +++ b/test/karma.conf.js @@ -28,12 +28,6 @@ module.exports = function setKarmaConfig(config) { served: true, included: true, }, - { - pattern: 'test/karma.datagrid.tests.js', - watched: true, - served: true, - included: true, - }, ], plugins: (process.env.PARALLEL === 'true' ? ['karma-parallel'] : []).concat([ 'karma-mocha', diff --git a/test/karma.tests.js b/test/karma.tests.js index 2d2401669a76e..0cb3844c161a5 100644 --- a/test/karma.tests.js +++ b/test/karma.tests.js @@ -21,5 +21,5 @@ afterEach(function beforeEachHook() { const packagesContext = require.context('../packages', true, /\.test\.tsx$/); packagesContext .keys() - .filter((key) => !key.includes('x-data-grid')) + .filter((key) => key.includes('x-charts')) .forEach(packagesContext); From 01899c7db43b15663b82320ec8ec9c7a10691bf6 Mon Sep 17 00:00:00 2001 From: Jose Quintas Date: Sun, 16 Mar 2025 18:07:51 +0100 Subject: [PATCH 26/48] remove el check --- .../plugins/useChartProZoom/gestureHooks/useZoomOnPinch.ts | 4 ---- .../plugins/useChartProZoom/gestureHooks/useZoomOnWheel.ts | 4 ---- .../useChartInteractionListener.ts | 2 +- 3 files changed, 1 insertion(+), 9 deletions(-) diff --git a/packages/x-charts-pro/src/internals/plugins/useChartProZoom/gestureHooks/useZoomOnPinch.ts b/packages/x-charts-pro/src/internals/plugins/useChartProZoom/gestureHooks/useZoomOnPinch.ts index c31ad7fcc197e..ab03051f4ef0f 100644 --- a/packages/x-charts-pro/src/internals/plugins/useChartProZoom/gestureHooks/useZoomOnPinch.ts +++ b/packages/x-charts-pro/src/internals/plugins/useChartProZoom/gestureHooks/useZoomOnPinch.ts @@ -45,10 +45,6 @@ export const useZoomOnPinch = ( }); const zoomHandler = instance.addInteractionListener('pinch', (state) => { - if (element === null) { - return; - } - setZoomDataCallback((prevZoomData) => { const newZoomData = prevZoomData.map((zoom) => { const option = optionsLookup[zoom.axisId]; diff --git a/packages/x-charts-pro/src/internals/plugins/useChartProZoom/gestureHooks/useZoomOnWheel.ts b/packages/x-charts-pro/src/internals/plugins/useChartProZoom/gestureHooks/useZoomOnWheel.ts index 1423d4690c6fb..ca30f45f966ff 100644 --- a/packages/x-charts-pro/src/internals/plugins/useChartProZoom/gestureHooks/useZoomOnWheel.ts +++ b/packages/x-charts-pro/src/internals/plugins/useChartProZoom/gestureHooks/useZoomOnWheel.ts @@ -39,10 +39,6 @@ export const useZoomOnWheel = ( } const zoomOnWheelHandler = instance.addInteractionListener('wheel', (state) => { - if (element === null) { - return; - } - const point = getSVGPoint(element, state.event); if (!instance.isPointInside(point)) { 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 3dc4f47b76632..6df5332a4bddf 100644 --- a/packages/x-charts/src/internals/plugins/corePlugins/useChartInteractionListener/useChartInteractionListener.ts +++ b/packages/x-charts/src/internals/plugins/corePlugins/useChartInteractionListener/useChartInteractionListener.ts @@ -68,6 +68,7 @@ export const useChartInteractionListener: ChartPlugin Date: Sun, 16 Mar 2025 19:19:54 +0100 Subject: [PATCH 27/48] use memo for storing data --- .../gestureHooks/usePanOnDrag.ts | 45 +++++++++---------- .../useChartInteractionListener.ts | 17 +++++-- .../useChartInteractionListener.types.ts | 39 +++++++++++----- 3 files changed, 60 insertions(+), 41 deletions(-) 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 713535e7d9197..1de9b8788960e 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 @@ -29,31 +29,33 @@ export const usePanOnDrag = ( [optionsLookup], ); - const touchStartRef = React.useRef<{ - x: number; - y: number; - zoomData: readonly ZoomData[]; - } | null>(null); - React.useEffect(() => { const element = svgRef.current; if (element === null || !isPanEnabled) { return () => {}; } - const panHandler = instance.addInteractionListener('drag', (state) => { + const panHandler = instance.addInteractionListener('drag', (state) => { if (state.pinching) { - return state.cancel(); + state.cancel(); + return undefined; } - if (element === null || touchStartRef.current === null) { - return undefined; + if (!state.memo) { + state.memo = store.getSnapshot().zoom.zoomData; } - const point = getSVGPoint(element, state.event); - const movementX = point.x - touchStartRef.current.x; - const movementY = (point.y - touchStartRef.current.y) * -1; - const newZoomData = touchStartRef.current.zoomData.map((zoom) => { + const point = getSVGPoint(element, { + clientX: state.xy[0], + clientY: state.xy[1], + }); + const originalPoint = getSVGPoint(element, { + clientX: state.initial[0], + clientY: state.initial[1], + }); + const movementX = point.x - originalPoint.x; + const movementY = (point.y - originalPoint.y) * -1; + const newZoomData = state.memo.map((zoom) => { const options = optionsLookup[zoom.axisId]; if (!options || !options.panning) { return zoom; @@ -91,27 +93,20 @@ export const usePanOnDrag = ( }); // TODO: fix max update issue setZoomDataCallback(newZoomData); - return undefined; + return state.memo; }); const panStartHandler = instance.addInteractionListener('dragStart', (state) => { - const point = getSVGPoint(element, state.event); - if (interactionTimeoutRef.current) { clearTimeout(interactionTimeoutRef.current); } setIsInteracting(true); - touchStartRef.current = { - x: point.x, - y: point.y, - zoomData: store.getSnapshot().zoom.zoomData, - }; - - return undefined; + return state.memo; }); - const panEndHandler = instance.addInteractionListener('dragEnd', () => { + const panEndHandler = instance.addInteractionListener('dragEnd', (state) => { setIsInteracting(false); + return state.memo; }); return () => { 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 6df5332a4bddf..e7cad47bc0a07 100644 --- a/packages/x-charts/src/internals/plugins/corePlugins/useChartInteractionListener/useChartInteractionListener.ts +++ b/packages/x-charts/src/internals/plugins/corePlugins/useChartInteractionListener/useChartInteractionListener.ts @@ -7,7 +7,6 @@ import { wheelAction, moveAction, hoverAction, - Handler, } from '@use-gesture/react'; import { ChartPlugin } from '../../models'; import { @@ -15,9 +14,10 @@ import { AddInteractionListener, AddMultipleInteractionListeners, ChartInteraction, + ChartInteractionHandler, } from './useChartInteractionListener.types'; -type ListenerRef = Map>>; +type ListenerRef = Map>>; const preventDefault = (event: Event) => event.preventDefault(); @@ -38,9 +38,18 @@ export const useChartInteractionListener: ChartPlugin { const listeners = listenersRef.current.get(interaction); + const memo = !state.memo ? new Map() : state.memo; + if (listeners) { - listeners.forEach((callback) => callback(state)); + listeners.forEach((callback) => { + const result = callback({ ...state, memo: memo.get(callback) }); + if (result) { + memo.set(callback, result); + } + }); } + + return memo; }, []); useGesture( @@ -86,7 +95,7 @@ export const useChartInteractionListener: ChartPlugin>(); + listeners = new Set>(); listeners.add(callback); listenersRef.current.set(interaction, listeners); } else { diff --git a/packages/x-charts/src/internals/plugins/corePlugins/useChartInteractionListener/useChartInteractionListener.types.ts b/packages/x-charts/src/internals/plugins/corePlugins/useChartInteractionListener/useChartInteractionListener.types.ts index fdb4fd9a56254..bedb9ce6d70b3 100644 --- a/packages/x-charts/src/internals/plugins/corePlugins/useChartInteractionListener/useChartInteractionListener.types.ts +++ b/packages/x-charts/src/internals/plugins/corePlugins/useChartInteractionListener/useChartInteractionListener.types.ts @@ -1,4 +1,4 @@ -import { GestureKey, Handler } from '@use-gesture/react'; +import { EventTypes, FullGestureState, GestureKey } from '@use-gesture/react'; import { ChartPluginSignature } from '../../models'; export type ChartInteraction = @@ -16,26 +16,40 @@ export type ChartInteraction = | 'moveEnd' | 'hover'; +export type ChartInteractionHandler< + Memo extends any, + Key extends GestureKey, + EventType = EventTypes[Key], +> = ( + state: Omit, 'event' | 'memo'> & { + event: EventType; + memo: Memo; + }, +) => any | void; + export type InteractionListenerResult = { cleanup: () => void }; export type AddInteractionListener = { - ( + ( interaction: 'drag' | 'dragStart' | 'dragEnd', - callback: Handler<'drag', PointerEvent>, + callback: ChartInteractionHandler, ): InteractionListenerResult; - ( + ( interaction: 'pinch' | 'pinchStart' | 'pinchEnd', - callback: Handler<'pinch', PointerEvent>, + callback: ChartInteractionHandler, ): InteractionListenerResult; - ( + ( interaction: 'wheel' | 'wheelStart' | 'wheelEnd', - callback: Handler<'wheel', WheelEvent>, + callback: ChartInteractionHandler, ): InteractionListenerResult; - ( + ( interaction: 'move' | 'moveStart' | 'moveEnd', - callback: Handler<'move', PointerEvent>, + callback: ChartInteractionHandler, + ): InteractionListenerResult; + ( + interaction: 'hover', + callback: ChartInteractionHandler, ): InteractionListenerResult; - (interaction: 'hover', callback: Handler<'hover', PointerEvent>): InteractionListenerResult; }; type InteractionMap = T extends 'wheel' | 'wheelStart' | 'wheelEnd' @@ -44,14 +58,15 @@ type InteractionMap = T extends 'wheel' | 'wheelStar export type AddMultipleInteractionListeners = { < - T extends ChartInteraction[], + Memo extends any = {}, + T extends ChartInteraction[] = any, K = T, I extends ChartInteraction[] = T, D extends GestureKey = K extends (infer J)[] ? (J extends GestureKey ? J : never) : never, E extends ChartInteraction = I extends (infer J)[] ? J : never, >( interactions: T, - callback: Handler>, + callback: ChartInteractionHandler>, ): InteractionListenerResult; }; From b93cbd145e862ce581012f8f41ee9e8e2ca64205 Mon Sep 17 00:00:00 2001 From: Jose Quintas Date: Sun, 16 Mar 2025 19:26:36 +0100 Subject: [PATCH 28/48] remove comment --- .../plugins/useChartProZoom/gestureHooks/usePanOnDrag.ts | 1 - 1 file changed, 1 deletion(-) 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 1de9b8788960e..4f691457470ce 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 @@ -91,7 +91,6 @@ export const usePanOnDrag = ( end: newMaxPercent, }; }); - // TODO: fix max update issue setZoomDataCallback(newZoomData); return state.memo; }); From 73f39f37382ce11760095737333fa5cfb19634c7 Mon Sep 17 00:00:00 2001 From: Jose Quintas Date: Mon, 17 Mar 2025 10:32:56 +0100 Subject: [PATCH 29/48] cleanup drag --- .../plugins/useChartProZoom/gestureHooks/usePanOnDrag.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) 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 4f691457470ce..6c87796228912 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 @@ -95,17 +95,15 @@ export const usePanOnDrag = ( return state.memo; }); - const panStartHandler = instance.addInteractionListener('dragStart', (state) => { + const panStartHandler = instance.addInteractionListener('dragStart', () => { if (interactionTimeoutRef.current) { clearTimeout(interactionTimeoutRef.current); } setIsInteracting(true); - return state.memo; }); - const panEndHandler = instance.addInteractionListener('dragEnd', (state) => { + const panEndHandler = instance.addInteractionListener('dragEnd', () => { setIsInteracting(false); - return state.memo; }); return () => { From 8dbaf63b2a5481236c712d61bf371e81ce9f171e Mon Sep 17 00:00:00 2001 From: Jose Quintas Date: Mon, 17 Mar 2025 11:07:57 +0100 Subject: [PATCH 30/48] fix pinch mobile --- .../plugins/useChartProZoom/gestureHooks/useZoomOnPinch.ts | 4 ++-- .../useChartInteractionListener.ts | 7 +------ 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/packages/x-charts-pro/src/internals/plugins/useChartProZoom/gestureHooks/useZoomOnPinch.ts b/packages/x-charts-pro/src/internals/plugins/useChartProZoom/gestureHooks/useZoomOnPinch.ts index ab03051f4ef0f..af19049ef4c4f 100644 --- a/packages/x-charts-pro/src/internals/plugins/useChartProZoom/gestureHooks/useZoomOnPinch.ts +++ b/packages/x-charts-pro/src/internals/plugins/useChartProZoom/gestureHooks/useZoomOnPinch.ts @@ -56,8 +56,8 @@ export const useZoomOnPinch = ( const isZoomIn = state.direction[0] > 0; const scaleRatio = 1 + (isZoomIn ? scaledStep : -scaledStep); - // If the scale ratio is 0, it means the pinch gesture is not valid. - if (scaleRatio === 0) { + // If the delta is 0, it means the pinch gesture is not valid. + if (state.delta[0] === 0) { return zoom; } 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 e7cad47bc0a07..86feaaff23e00 100644 --- a/packages/x-charts/src/internals/plugins/corePlugins/useChartInteractionListener/useChartInteractionListener.ts +++ b/packages/x-charts/src/internals/plugins/corePlugins/useChartInteractionListener/useChartInteractionListener.ts @@ -81,12 +81,7 @@ export const useChartInteractionListener: ChartPlugin Date: Mon, 17 Mar 2025 13:26:12 +0100 Subject: [PATCH 31/48] prevent scroll on mobile --- packages/x-charts/src/ChartsSurface/ChartsSurface.tsx | 3 ++- .../useChartInteractionListener/useChartInteractionListener.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/x-charts/src/ChartsSurface/ChartsSurface.tsx b/packages/x-charts/src/ChartsSurface/ChartsSurface.tsx index 2c2530437919c..dbc699dc37683 100644 --- a/packages/x-charts/src/ChartsSurface/ChartsSurface.tsx +++ b/packages/x-charts/src/ChartsSurface/ChartsSurface.tsx @@ -34,7 +34,8 @@ const ChartsSurfaceStyles = styled('svg', { overflow: 'hidden', // This prevents default touch actions when using the svg on mobile devices. // For example, prevent page scroll & zoom. - touchAction: 'none', + touchAction: 'pan-y', + userSelect: 'none', })); /** 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 86feaaff23e00..12be8108c610c 100644 --- a/packages/x-charts/src/internals/plugins/corePlugins/useChartInteractionListener/useChartInteractionListener.ts +++ b/packages/x-charts/src/internals/plugins/corePlugins/useChartInteractionListener/useChartInteractionListener.ts @@ -79,7 +79,7 @@ export const useChartInteractionListener: ChartPlugin Date: Mon, 17 Mar 2025 15:57:19 +0100 Subject: [PATCH 32/48] Revert "tree shake" This reverts commit c89dabdf8baa86e7cdba7843ef1f86887976557f. --- test/karma.conf.js | 6 ++++++ test/karma.tests.js | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/test/karma.conf.js b/test/karma.conf.js index 44c66cd56519d..7b4516a71b7e6 100644 --- a/test/karma.conf.js +++ b/test/karma.conf.js @@ -28,6 +28,12 @@ module.exports = function setKarmaConfig(config) { served: true, included: true, }, + { + pattern: 'test/karma.datagrid.tests.js', + watched: true, + served: true, + included: true, + }, ], plugins: (process.env.PARALLEL === 'true' ? ['karma-parallel'] : []).concat([ 'karma-mocha', diff --git a/test/karma.tests.js b/test/karma.tests.js index 0cb3844c161a5..2d2401669a76e 100644 --- a/test/karma.tests.js +++ b/test/karma.tests.js @@ -21,5 +21,5 @@ afterEach(function beforeEachHook() { const packagesContext = require.context('../packages', true, /\.test\.tsx$/); packagesContext .keys() - .filter((key) => key.includes('x-charts')) + .filter((key) => !key.includes('x-data-grid')) .forEach(packagesContext); From c6be367d17b29c0629a0006831d9dfab3408c752 Mon Sep 17 00:00:00 2001 From: Jose Quintas Date: Mon, 17 Mar 2025 19:08:01 +0100 Subject: [PATCH 33/48] remove preventscroll option as it messes tapping --- .../ChartsTooltip/ChartsTooltipContainer.tsx | 4 ++-- packages/x-charts/src/ChartsTooltip/utils.tsx | 21 ++++++++++++------- .../useChartInteractionListener.ts | 1 - 3 files changed, 16 insertions(+), 10 deletions(-) diff --git a/packages/x-charts/src/ChartsTooltip/ChartsTooltipContainer.tsx b/packages/x-charts/src/ChartsTooltip/ChartsTooltipContainer.tsx index c0f889adcfddd..feb9a0833aecc 100644 --- a/packages/x-charts/src/ChartsTooltip/ChartsTooltipContainer.tsx +++ b/packages/x-charts/src/ChartsTooltip/ChartsTooltipContainer.tsx @@ -70,7 +70,7 @@ function ChartsTooltipContainer(inProps: ChartsTooltipContainerProps) { const positionRef = useLazyRef(() => ({ x: 0, y: 0 })); const store = useStore(); - const isOpen = useSelector( + const hasData = useSelector( store, // eslint-disable-next-line no-nested-ternary trigger === 'axis' @@ -80,7 +80,7 @@ function ChartsTooltipContainer(inProps: ChartsTooltipContainerProps) { : selectorChartsInteractionItemIsDefined, ); - const popperOpen = pointerType !== null && isOpen; // tooltipHasData; + const popperOpen = pointerType !== null && hasData; // tooltipHasData; React.useEffect(() => { const positionHandler = instance.addMultipleInteractionListeners(['move', 'drag'], (state) => { diff --git a/packages/x-charts/src/ChartsTooltip/utils.tsx b/packages/x-charts/src/ChartsTooltip/utils.tsx index 10ab3213fcaf6..fe7c6ac4552ef 100644 --- a/packages/x-charts/src/ChartsTooltip/utils.tsx +++ b/packages/x-charts/src/ChartsTooltip/utils.tsx @@ -60,20 +60,27 @@ export function usePointerType(): null | PointerType { const [pointerType, setPointerType] = React.useState(null); React.useEffect(() => { - const removePointerHandler = instance.addInteractionListener('dragEnd', () => { + const removePointerHandler = instance.addInteractionListener('dragEnd', (state) => { // TODO: We can check and only close when it is not a tap with `!state.tap` // This would allow users to click/tap on the chart to display the tooltip. - setPointerType(null); + + // Only close the tooltip on mobile, which doesn't trigger a hover event. + if (!state.hovering) { + setPointerType(null); + } }); // Move is mouse, Drag is both mouse and touch. const setPointerHandler = instance.addMultipleInteractionListeners( - ['moveStart', 'dragStart'], + ['move', 'drag'], (state) => { - setPointerType({ - height: state.event.height, - pointerType: state.event.pointerType as PointerType['pointerType'], - }); + // @ts-expect-error tap doesn't exist on move. + if (!state.first && !state.tap) { + setPointerType({ + height: state.event.height, + pointerType: state.event.pointerType as PointerType['pointerType'], + }); + } }, ); 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 12be8108c610c..ac30023b66144 100644 --- a/packages/x-charts/src/internals/plugins/corePlugins/useChartInteractionListener/useChartInteractionListener.ts +++ b/packages/x-charts/src/internals/plugins/corePlugins/useChartInteractionListener/useChartInteractionListener.ts @@ -79,7 +79,6 @@ export const useChartInteractionListener: ChartPlugin Date: Wed, 19 Mar 2025 15:43:45 +0100 Subject: [PATCH 34/48] add bar tests --- .../src/BarChartPro/BarChartPro.zoom.test.tsx | 212 ++++++++++++++++++ packages/x-charts/src/internals/domUtils.ts | 17 +- .../useChartInteractionListener.ts | 2 + .../useChartCartesianAxis/zoom.types.ts | 15 +- packages/x-charts/tsconfig.json | 2 +- 5 files changed, 240 insertions(+), 8 deletions(-) create mode 100644 packages/x-charts-pro/src/BarChartPro/BarChartPro.zoom.test.tsx diff --git a/packages/x-charts-pro/src/BarChartPro/BarChartPro.zoom.test.tsx b/packages/x-charts-pro/src/BarChartPro/BarChartPro.zoom.test.tsx new file mode 100644 index 0000000000000..37487ebebc8ef --- /dev/null +++ b/packages/x-charts-pro/src/BarChartPro/BarChartPro.zoom.test.tsx @@ -0,0 +1,212 @@ +import * as React from 'react'; +import { expect } from 'chai'; +import { createRenderer, screen, fireEvent } from '@mui/internal-test-utils'; +import { describeSkipIf, isJSDOM, testSkipIf } from 'test/utils/skipIf'; +import { BarChartPro } from './BarChartPro'; + +describeSkipIf(isJSDOM)(' - Zoom', () => { + const { render } = createRenderer(); + + const barChartProps = { + series: [ + { + data: [10, 20, 30, 40], + }, + ], + xAxis: [ + { + scaleType: 'band', + data: ['A', 'B', 'C', 'D'], + zoom: true, + height: 30, + id: 'x', + }, + ], + yAxis: [{ position: 'none' }], + width: 100, + height: 120, + margin: 0, + slotProps: { tooltip: { trigger: 'none' } }, + } as const; + + const options = { + wrapper: ({ children }: { children?: React.ReactNode }) => ( +
{children}
+ ), + }; + + // eslint-disable-next-line mocha/no-top-level-hooks + beforeEach(() => { + // TODO: Remove beforeEach/afterEach after vitest becomes our main runner + if (window?.document?.body?.style) { + window.document.body.style.margin = '0'; + } + }); + + // eslint-disable-next-line mocha/no-top-level-hooks + afterEach(() => { + if (window?.document?.body?.style) { + window.document.body.style.margin = '8px'; + } + }); + + it('should zoom on wheel', async () => { + const { user } = render(, options); + + expect(screen.queryByText('A')).not.to.equal(null); + expect(screen.queryByText('B')).not.to.equal(null); + expect(screen.queryByText('C')).not.to.equal(null); + expect(screen.queryByText('D')).not.to.equal(null); + + const svg = document.querySelector('svg')!; + + await user.pointer([ + { + target: svg, + coords: { x: 50, y: 50 }, + }, + ]); + + // scroll, we scroll exactly in the center of the svg + // And we do it 200 times which is the lowest number to trigger a zoom where both A and D are not visible + for (let i = 0; i < 200; i += 1) { + fireEvent.wheel(svg, { deltaY: -1, clientX: 50, clientY: 50 }); + } + + expect(screen.queryByText('A')).to.equal(null); + expect(screen.queryByText('B')).not.to.equal(null); + expect(screen.queryByText('C')).not.to.equal(null); + expect(screen.queryByText('D')).to.equal(null); + + // scroll back + for (let i = 0; i < 200; i += 1) { + fireEvent.wheel(svg, { deltaY: 1, clientX: 50, clientY: 50 }); + } + + expect(screen.queryByText('A')).not.to.equal(null); + expect(screen.queryByText('B')).not.to.equal(null); + expect(screen.queryByText('C')).not.to.equal(null); + expect(screen.queryByText('D')).not.to.equal(null); + }); + + ['MouseLeft', 'TouchA'].forEach((pointerName) => { + it(`should pan on ${pointerName} drag`, async () => { + const { user } = render( + , + options, + ); + + expect(screen.queryByText('A')).to.equal(null); + expect(screen.queryByText('B')).to.equal(null); + expect(screen.queryByText('C')).to.equal(null); + expect(screen.queryByText('D')).not.to.equal(null); + + const svg = document.querySelector('svg')!; + + // we drag one position so C should be visible + await user.pointer([ + { + keys: `[${pointerName}>]`, + target: svg, + coords: { x: 5, y: 20 }, + }, + { + pointerName: pointerName === 'MouseLeft' ? undefined : pointerName, + target: svg, + coords: { x: 100, y: 20 }, + }, + { + keys: `[/${pointerName}]`, + target: svg, + coords: { x: 100, y: 20 }, + }, + ]); + + expect(screen.queryByText('A')).to.equal(null); + expect(screen.queryByText('B')).to.equal(null); + expect(screen.queryByText('C')).not.to.equal(null); + expect(screen.queryByText('D')).to.equal(null); + + // we drag all the way to the left so A should be visible + await user.pointer([ + { + keys: `[${pointerName}>]`, + target: svg, + coords: { x: 5, y: 20 }, + }, + { + pointerName: pointerName === 'MouseLeft' ? undefined : pointerName, + target: svg, + coords: { x: 300, y: 20 }, + }, + { + keys: `[/${pointerName}]`, + target: svg, + coords: { x: 300, y: 20 }, + }, + ]); + + expect(screen.queryByText('A')).not.to.equal(null); + expect(screen.queryByText('B')).to.equal(null); + expect(screen.queryByText('C')).to.equal(null); + expect(screen.queryByText('D')).to.equal(null); + }); + }); + + // Technically it should work, but it's not working in the test environment + // https://github.com/pmndrs/use-gesture/discussions/430 + testSkipIf(true)('should zoom on pinch', async () => { + const { user } = render(, options); + + expect(screen.queryByText('A')).not.to.equal(null); + expect(screen.queryByText('B')).not.to.equal(null); + expect(screen.queryByText('C')).not.to.equal(null); + expect(screen.queryByText('D')).not.to.equal(null); + + const svg = document.querySelector('svg')!; + + await user.pointer({ + keys: '[TouchA]', + target: svg, + coords: { x: 50, y: 50 }, + }); + + await user.pointer([ + { + keys: '[TouchA>]', + target: svg, + coords: { x: 55, y: 45 }, + }, + { + keys: '[TouchB>]', + target: svg, + coords: { x: 45, y: 55 }, + }, + { + pointerName: 'TouchA', + target: svg, + coords: { x: 75, y: 25 }, + }, + { + pointerName: 'TouchB', + target: svg, + coords: { x: 25, y: 75 }, + }, + { + keys: '[/TouchA]', + target: svg, + coords: { x: 75, y: 25 }, + }, + { + keys: '[/TouchB]', + target: svg, + coords: { x: 25, y: 75 }, + }, + ]); + + expect(screen.queryByText('A')?.textContent).to.equal(null); + expect(screen.queryByText('B')).not.to.equal(null); + expect(screen.queryByText('C')).not.to.equal(null); + expect(screen.queryByText('D')).to.equal(null); + }); +}); diff --git a/packages/x-charts/src/internals/domUtils.ts b/packages/x-charts/src/internals/domUtils.ts index 47903a4dedb14..415d6fd8a66d8 100644 --- a/packages/x-charts/src/internals/domUtils.ts +++ b/packages/x-charts/src/internals/domUtils.ts @@ -148,13 +148,18 @@ export const getStringSize = (text: string | number, style: React.CSSProperties stringCache.cacheCount += 1; } - if (domCleanTimeout) { - clearTimeout(domCleanTimeout); - } - domCleanTimeout = setTimeout(() => { - // Limit node cleaning to once per render cycle + if (process.env.NODE_ENV === 'test') { + // In test environment, we clean the measurement span immediately measurementSpan.textContent = ''; - }, 0); + } else { + if (domCleanTimeout) { + clearTimeout(domCleanTimeout); + } + domCleanTimeout = setTimeout(() => { + // Limit node cleaning to once per render cycle + measurementSpan.textContent = ''; + }, 0); + } return result; } catch { 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 ac30023b66144..0beddd25e2e35 100644 --- a/packages/x-charts/src/internals/plugins/corePlugins/useChartInteractionListener/useChartInteractionListener.ts +++ b/packages/x-charts/src/internals/plugins/corePlugins/useChartInteractionListener/useChartInteractionListener.ts @@ -78,6 +78,8 @@ export const useChartInteractionListener: ChartPlugin Date: Wed, 19 Mar 2025 16:52:25 +0100 Subject: [PATCH 35/48] add further tests --- .../src/BarChartPro/BarChartPro.zoom.test.tsx | 4 +- .../LineChartPro/LineChartPro.zoom.test.tsx | 155 +++++++++++++ .../ScatterChartPro.zoom.test.tsx | 205 ++++++++++++++++++ 3 files changed, 362 insertions(+), 2 deletions(-) create mode 100644 packages/x-charts-pro/src/LineChartPro/LineChartPro.zoom.test.tsx create mode 100644 packages/x-charts-pro/src/ScatterChartPro/ScatterChartPro.zoom.test.tsx diff --git a/packages/x-charts-pro/src/BarChartPro/BarChartPro.zoom.test.tsx b/packages/x-charts-pro/src/BarChartPro/BarChartPro.zoom.test.tsx index 37487ebebc8ef..0f11b029f1eae 100644 --- a/packages/x-charts-pro/src/BarChartPro/BarChartPro.zoom.test.tsx +++ b/packages/x-charts-pro/src/BarChartPro/BarChartPro.zoom.test.tsx @@ -24,14 +24,14 @@ describeSkipIf(isJSDOM)(' - Zoom', () => { ], yAxis: [{ position: 'none' }], width: 100, - height: 120, + height: 130, margin: 0, slotProps: { tooltip: { trigger: 'none' } }, } as const; const options = { wrapper: ({ children }: { children?: React.ReactNode }) => ( -
{children}
+
{children}
), }; diff --git a/packages/x-charts-pro/src/LineChartPro/LineChartPro.zoom.test.tsx b/packages/x-charts-pro/src/LineChartPro/LineChartPro.zoom.test.tsx new file mode 100644 index 0000000000000..4945852ae637d --- /dev/null +++ b/packages/x-charts-pro/src/LineChartPro/LineChartPro.zoom.test.tsx @@ -0,0 +1,155 @@ +import * as React from 'react'; +import { expect } from 'chai'; +import { createRenderer, screen, fireEvent } from '@mui/internal-test-utils'; +import { describeSkipIf, isJSDOM } from 'test/utils/skipIf'; +import { LineChartPro } from './LineChartPro'; + +describeSkipIf(isJSDOM)(' - Zoom', () => { + const { render } = createRenderer(); + + const lineChartProps = { + series: [ + { + data: [10, 20, 30, 40], + }, + ], + xAxis: [ + { + scaleType: 'point', + data: ['A', 'B', 'C', 'D'], + zoom: true, + height: 30, + id: 'x', + }, + ], + yAxis: [{ position: 'none' }], + width: 100, + height: 130, + margin: 5, + slotProps: { tooltip: { trigger: 'none' } }, + } as const; + + const options = { + wrapper: ({ children }: { children?: React.ReactNode }) => ( +
{children}
+ ), + }; + + // eslint-disable-next-line mocha/no-top-level-hooks + beforeEach(() => { + // TODO: Remove beforeEach/afterEach after vitest becomes our main runner + if (window?.document?.body?.style) { + window.document.body.style.margin = '0'; + } + }); + + // eslint-disable-next-line mocha/no-top-level-hooks + afterEach(() => { + if (window?.document?.body?.style) { + window.document.body.style.margin = '8px'; + } + }); + + it('should zoom on wheel', async () => { + const { user } = render(, options); + + expect(screen.queryByText('A')).not.to.equal(null); + expect(screen.queryByText('B')).not.to.equal(null); + expect(screen.queryByText('C')).not.to.equal(null); + expect(screen.queryByText('D')).not.to.equal(null); + + const svg = document.querySelector('svg')!; + + await user.pointer([ + { + target: svg, + coords: { x: 50, y: 50 }, + }, + ]); + + // scroll, we scroll exactly in the center of the svg + // And we do it 200 times which is the lowest number to trigger a zoom where both A and D are not visible + for (let i = 0; i < 200; i += 1) { + fireEvent.wheel(svg, { deltaY: -1, clientX: 50, clientY: 50 }); + } + + expect(screen.queryByText('A')).to.equal(null); + expect(screen.queryByText('B')).not.to.equal(null); + expect(screen.queryByText('C')).not.to.equal(null); + expect(screen.queryByText('D')).to.equal(null); + + // scroll back + for (let i = 0; i < 200; i += 1) { + fireEvent.wheel(svg, { deltaY: 1, clientX: 50, clientY: 50 }); + } + + expect(screen.queryByText('A')).not.to.equal(null); + expect(screen.queryByText('B')).not.to.equal(null); + expect(screen.queryByText('C')).not.to.equal(null); + expect(screen.queryByText('D')).not.to.equal(null); + }); + + ['MouseLeft', 'TouchA'].forEach((pointerName) => { + it(`should pan on ${pointerName} drag`, async () => { + const { user } = render( + , + options, + ); + + expect(screen.queryByText('A')).to.equal(null); + expect(screen.queryByText('B')).to.equal(null); + expect(screen.queryByText('C')).to.equal(null); + expect(screen.queryByText('D')).not.to.equal(null); + + const svg = document.querySelector('svg')!; + + // we drag one position so C should be visible + await user.pointer([ + { + keys: `[${pointerName}>]`, + target: svg, + coords: { x: 5, y: 20 }, + }, + { + pointerName: pointerName === 'MouseLeft' ? undefined : pointerName, + target: svg, + coords: { x: 100, y: 20 }, + }, + { + keys: `[/${pointerName}]`, + target: svg, + coords: { x: 100, y: 20 }, + }, + ]); + + expect(screen.queryByText('A')).to.equal(null); + expect(screen.queryByText('B')).to.equal(null); + expect(screen.queryByText('C')).not.to.equal(null); + expect(screen.queryByText('D')).to.equal(null); + + // we drag all the way to the left so A should be visible + await user.pointer([ + { + keys: `[${pointerName}>]`, + target: svg, + coords: { x: 5, y: 20 }, + }, + { + pointerName: pointerName === 'MouseLeft' ? undefined : pointerName, + target: svg, + coords: { x: 300, y: 20 }, + }, + { + keys: `[/${pointerName}]`, + target: svg, + coords: { x: 300, y: 20 }, + }, + ]); + + expect(screen.queryByText('A')).not.to.equal(null); + expect(screen.queryByText('B')).to.equal(null); + expect(screen.queryByText('C')).to.equal(null); + expect(screen.queryByText('D')).to.equal(null); + }); + }); +}); diff --git a/packages/x-charts-pro/src/ScatterChartPro/ScatterChartPro.zoom.test.tsx b/packages/x-charts-pro/src/ScatterChartPro/ScatterChartPro.zoom.test.tsx new file mode 100644 index 0000000000000..dce81b77755aa --- /dev/null +++ b/packages/x-charts-pro/src/ScatterChartPro/ScatterChartPro.zoom.test.tsx @@ -0,0 +1,205 @@ +import * as React from 'react'; +import { expect } from 'chai'; +import { createRenderer, screen, fireEvent } from '@mui/internal-test-utils'; +import { describeSkipIf, isJSDOM } from 'test/utils/skipIf'; +import { ScatterChartPro } from './ScatterChartPro'; + +describeSkipIf(isJSDOM)(' - Zoom', () => { + const { render } = createRenderer(); + + const scatterChartProps = { + series: [ + { + data: [ + { + x: 1, + y: 10, + }, + { + x: 2, + y: 20, + }, + { + x: 1, + y: 30, + }, + { + x: 3, + y: 30, + }, + { + x: 3, + y: 10, + }, + ], + }, + ], + xAxis: [ + { + zoom: true, + height: 30, + id: 'x', + }, + ], + yAxis: [ + { + zoom: true, + width: 30, + id: 'y', + position: 'right', + }, + ], + width: 130, + height: 130, + margin: 10, + slotProps: { tooltip: { trigger: 'none' } }, + } as const; + + const options = { + wrapper: ({ children }: { children?: React.ReactNode }) => ( +
{children}
+ ), + }; + + // eslint-disable-next-line mocha/no-top-level-hooks + beforeEach(() => { + // TODO: Remove beforeEach/afterEach after vitest becomes our main runner + if (window?.document?.body?.style) { + window.document.body.style.margin = '0'; + } + }); + + // eslint-disable-next-line mocha/no-top-level-hooks + afterEach(() => { + if (window?.document?.body?.style) { + window.document.body.style.margin = '8px'; + } + }); + + it('should zoom on wheel', async () => { + const { user } = render(, options); + + expect(screen.queryByText('1')).not.to.equal(null); + expect(screen.queryByText('2')).not.to.equal(null); + expect(screen.queryByText('3')).not.to.equal(null); + expect(screen.queryByText('10')).not.to.equal(null); + expect(screen.queryByText('20')).not.to.equal(null); + expect(screen.queryByText('30')).not.to.equal(null); + + const svg = document.querySelector('svg')!; + + await user.pointer([ + { + target: svg, + coords: { x: 50, y: 50 }, + }, + ]); + + // scroll, we scroll exactly in the center of the svg + // This will leave only x2 and y20 visible + for (let i = 0; i < 200; i += 1) { + fireEvent.wheel(svg, { deltaY: -1, clientX: 50, clientY: 50 }); + } + + expect(screen.queryByText('1')).to.equal(null); + expect(screen.queryByText('2')).not.to.equal(null); + expect(screen.queryByText('3')).to.equal(null); + expect(screen.queryByText('10')).to.equal(null); + expect(screen.queryByText('20')).not.to.equal(null); + expect(screen.queryByText('30')).to.equal(null); + + // scroll back + for (let i = 0; i < 200; i += 1) { + fireEvent.wheel(svg, { deltaY: 1, clientX: 50, clientY: 50 }); + } + + expect(screen.queryByText('1')).not.to.equal(null); + expect(screen.queryByText('2')).not.to.equal(null); + expect(screen.queryByText('3')).not.to.equal(null); + expect(screen.queryByText('10')).not.to.equal(null); + expect(screen.queryByText('20')).not.to.equal(null); + expect(screen.queryByText('30')).not.to.equal(null); + }); + + ['MouseLeft', 'TouchA'].forEach((pointerName) => { + it.only(`should pan on ${pointerName} drag`, async () => { + const { user } = render( + , + options, + ); + + expect(screen.queryByText('1.0')).to.equal(null); + expect(screen.queryByText('2.6')).not.to.equal(null); + expect(screen.queryByText('2.8')).not.to.equal(null); + expect(screen.queryByText('3.0')).not.to.equal(null); + expect(screen.queryByText('10')).to.equal(null); + expect(screen.queryByText('26')).not.to.equal(null); + expect(screen.queryByText('28')).not.to.equal(null); + expect(screen.queryByText('30')).not.to.equal(null); + + const svg = document.querySelector('svg')!; + + // we drag one position + await user.pointer([ + { + keys: `[${pointerName}>]`, + target: svg, + coords: { x: 5, y: 100 }, + }, + { + pointerName: pointerName === 'MouseLeft' ? undefined : pointerName, + target: svg, + coords: { x: 100, y: 5 }, + }, + { + keys: `[/${pointerName}]`, + target: svg, + coords: { x: 100, y: 5 }, + }, + ]); + + expect(screen.queryByText('1.0')).to.equal(null); + expect(screen.queryByText('2.0')).not.to.equal(null); + expect(screen.queryByText('2.2')).not.to.equal(null); + expect(screen.queryByText('2.4')).not.to.equal(null); + expect(screen.queryByText('10')).to.equal(null); + expect(screen.queryByText('20')).not.to.equal(null); + expect(screen.queryByText('22')).not.to.equal(null); + expect(screen.queryByText('24')).not.to.equal(null); + + // we drag all the way to the left so 1 should be visible + await user.pointer([ + { + keys: `[${pointerName}>]`, + target: svg, + coords: { x: 5, y: 100 }, + }, + { + pointerName: pointerName === 'MouseLeft' ? undefined : pointerName, + target: svg, + coords: { x: 300, y: -200 }, + }, + { + keys: `[/${pointerName}]`, + target: svg, + coords: { x: 300, y: -200 }, + }, + ]); + + expect(screen.queryByText('2.0')).to.equal(null); + expect(screen.queryByText('1.0')).not.to.equal(null); + expect(screen.queryByText('1.2')).not.to.equal(null); + expect(screen.queryByText('1.4')).not.to.equal(null); + expect(screen.queryByText('20')).to.equal(null); + expect(screen.queryByText('10')).not.to.equal(null); + expect(screen.queryByText('12')).not.to.equal(null); + expect(screen.queryByText('14')).not.to.equal(null); + }); + }); +}); From 290e7f0fdb2ac9d41771edc7d7f91efb93686c0a Mon Sep 17 00:00:00 2001 From: Jose Quintas Date: Wed, 19 Mar 2025 17:22:18 +0100 Subject: [PATCH 36/48] remove only --- .../src/ScatterChartPro/ScatterChartPro.zoom.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 dce81b77755aa..a03f90a59a799 100644 --- a/packages/x-charts-pro/src/ScatterChartPro/ScatterChartPro.zoom.test.tsx +++ b/packages/x-charts-pro/src/ScatterChartPro/ScatterChartPro.zoom.test.tsx @@ -122,7 +122,7 @@ describeSkipIf(isJSDOM)(' - Zoom', () => { }); ['MouseLeft', 'TouchA'].forEach((pointerName) => { - it.only(`should pan on ${pointerName} drag`, async () => { + it(`should pan on ${pointerName} drag`, async () => { const { user } = render( Date: Wed, 19 Mar 2025 23:34:54 +0100 Subject: [PATCH 37/48] fix wheel update depth issue --- .../gestureHooks/useZoomOnWheel.ts | 28 +++++++++++++++---- packages/x-charts/src/ChartsTooltip/utils.tsx | 22 ++++----------- 2 files changed, 28 insertions(+), 22 deletions(-) diff --git a/packages/x-charts-pro/src/internals/plugins/useChartProZoom/gestureHooks/useZoomOnWheel.ts b/packages/x-charts-pro/src/internals/plugins/useChartProZoom/gestureHooks/useZoomOnWheel.ts index ca30f45f966ff..31c9d3238dd02 100644 --- a/packages/x-charts-pro/src/internals/plugins/useChartProZoom/gestureHooks/useZoomOnWheel.ts +++ b/packages/x-charts-pro/src/internals/plugins/useChartProZoom/gestureHooks/useZoomOnWheel.ts @@ -38,22 +38,17 @@ export const useZoomOnWheel = ( return () => {}; } - const zoomOnWheelHandler = instance.addInteractionListener('wheel', (state) => { + const wheelStartHandler = instance.addInteractionListener('wheel', (state) => { const point = getSVGPoint(element, state.event); if (!instance.isPointInside(point)) { return; } - if (!state.last) { - state.event.preventDefault(); - } - if (interactionTimeoutRef.current) { clearTimeout(interactionTimeoutRef.current); } // Debounce transition to `isInteractive=false`. - // Useful because wheel events don't have an "end" event. if (!state.dragging) { setIsInteracting(true); @@ -63,6 +58,25 @@ export const useZoomOnWheel = ( setIsInteracting(false); }, 166); } + }); + + const wheelEndHandler = instance.addInteractionListener('wheelEnd', () => { + if (interactionTimeoutRef.current) { + clearTimeout(interactionTimeoutRef.current); + } + setIsInteracting(false); + }); + + const zoomOnWheelHandler = instance.addInteractionListener('wheel', (state) => { + const point = getSVGPoint(element, state.event); + + if (!instance.isPointInside(point)) { + return; + } + + if (!state.last) { + state.event.preventDefault(); + } setZoomDataCallback((prevZoomData) => { return prevZoomData.map((zoom) => { @@ -89,6 +103,8 @@ export const useZoomOnWheel = ( return () => { zoomOnWheelHandler.cleanup(); + wheelStartHandler.cleanup(); + wheelEndHandler.cleanup(); if (interactionTimeoutRef.current) { clearTimeout(interactionTimeoutRef.current); } diff --git a/packages/x-charts/src/ChartsTooltip/utils.tsx b/packages/x-charts/src/ChartsTooltip/utils.tsx index fe7c6ac4552ef..c85864f945d5b 100644 --- a/packages/x-charts/src/ChartsTooltip/utils.tsx +++ b/packages/x-charts/src/ChartsTooltip/utils.tsx @@ -21,30 +21,20 @@ export function useMouseTracker(): UseMouseTrackerReturnValue { const [mousePosition, setMousePosition] = React.useState(null); React.useEffect(() => { - // Hover event is triggered when the mouse is moved over/out the chart. - const positionOnHoverHandler = instance.addInteractionListener('hover', (state) => { - if (state.hovering) { + // Drag event is triggered by mobile touch or mouse drag. + const positionOnDragHandler = instance.addMultipleInteractionListeners( + ['move', 'drag'], + (state) => { setMousePosition({ x: state.event.clientX, y: state.event.clientY, height: state.event.height, pointerType: state.event.pointerType as MousePosition['pointerType'], }); - } - }); - - // Drag event is triggered by mobile touch or mouse drag. - const positionOnDragHandler = instance.addInteractionListener('drag', (state) => { - setMousePosition({ - x: state.event.clientX, - y: state.event.clientY, - height: state.event.height, - pointerType: state.event.pointerType as MousePosition['pointerType'], - }); - }); + }, + ); return () => { - positionOnHoverHandler.cleanup(); positionOnDragHandler.cleanup(); }; }, [instance]); From c5f6c8e7609add114e9f7882180b5ab48ed1bee5 Mon Sep 17 00:00:00 2001 From: Jose Quintas Date: Wed, 19 Mar 2025 23:50:12 +0100 Subject: [PATCH 38/48] remove timeout on wheel need --- .../gestureHooks/usePanOnDrag.ts | 5 ----- .../gestureHooks/useZoomOnPinch.ts | 5 ----- .../gestureHooks/useZoomOnWheel.ts | 22 +++---------------- .../useChartProZoom/useChartProZoom.ts | 7 +++--- 4 files changed, 6 insertions(+), 33 deletions(-) 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 6c87796228912..c60adc8f0e1bf 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 @@ -16,7 +16,6 @@ export const usePanOnDrag = ( instance, svgRef, }: Pick>[0], 'store' | 'instance' | 'svgRef'>, - interactionTimeoutRef: React.RefObject, setIsInteracting: React.Dispatch, setZoomDataCallback: React.Dispatch ZoomData[])>, ) => { @@ -96,9 +95,6 @@ export const usePanOnDrag = ( }); const panStartHandler = instance.addInteractionListener('dragStart', () => { - if (interactionTimeoutRef.current) { - clearTimeout(interactionTimeoutRef.current); - } setIsInteracting(true); }); @@ -121,6 +117,5 @@ export const usePanOnDrag = ( drawingArea.height, setZoomDataCallback, store, - interactionTimeoutRef, ]); }; diff --git a/packages/x-charts-pro/src/internals/plugins/useChartProZoom/gestureHooks/useZoomOnPinch.ts b/packages/x-charts-pro/src/internals/plugins/useChartProZoom/gestureHooks/useZoomOnPinch.ts index af19049ef4c4f..87e93a1858e8b 100644 --- a/packages/x-charts-pro/src/internals/plugins/useChartProZoom/gestureHooks/useZoomOnPinch.ts +++ b/packages/x-charts-pro/src/internals/plugins/useChartProZoom/gestureHooks/useZoomOnPinch.ts @@ -22,7 +22,6 @@ export const useZoomOnPinch = ( instance, svgRef, }: Pick>[0], 'store' | 'instance' | 'svgRef'>, - interactionTimeoutRef: React.RefObject, setIsInteracting: React.Dispatch, setZoomDataCallback: React.Dispatch ZoomData[])>, ) => { @@ -38,9 +37,6 @@ export const useZoomOnPinch = ( } const zoomStartHandler = instance.addInteractionListener('pinchStart', () => { - if (interactionTimeoutRef.current) { - clearTimeout(interactionTimeoutRef.current); - } setIsInteracting(true); }); @@ -99,6 +95,5 @@ export const useZoomOnPinch = ( setIsInteracting, instance, setZoomDataCallback, - interactionTimeoutRef, ]); }; diff --git a/packages/x-charts-pro/src/internals/plugins/useChartProZoom/gestureHooks/useZoomOnWheel.ts b/packages/x-charts-pro/src/internals/plugins/useChartProZoom/gestureHooks/useZoomOnWheel.ts index 31c9d3238dd02..19b703e772362 100644 --- a/packages/x-charts-pro/src/internals/plugins/useChartProZoom/gestureHooks/useZoomOnWheel.ts +++ b/packages/x-charts-pro/src/internals/plugins/useChartProZoom/gestureHooks/useZoomOnWheel.ts @@ -23,7 +23,6 @@ export const useZoomOnWheel = ( instance, svgRef, }: Pick>[0], 'store' | 'instance' | 'svgRef'>, - interactionTimeoutRef: React.RefObject, setIsInteracting: React.Dispatch, setZoomDataCallback: React.Dispatch ZoomData[])>, ) => { @@ -45,26 +44,15 @@ export const useZoomOnWheel = ( return; } - if (interactionTimeoutRef.current) { - clearTimeout(interactionTimeoutRef.current); - } - // Debounce transition to `isInteractive=false`. if (!state.dragging) { setIsInteracting(true); - - // Ref is passed in. - // eslint-disable-next-line react-compiler/react-compiler - interactionTimeoutRef.current = window.setTimeout(() => { - setIsInteracting(false); - }, 166); } }); - const wheelEndHandler = instance.addInteractionListener('wheelEnd', () => { - if (interactionTimeoutRef.current) { - clearTimeout(interactionTimeoutRef.current); + const wheelEndHandler = instance.addInteractionListener('wheelEnd', (state) => { + if (!state.dragging && !state.pinching) { + setIsInteracting(false); } - setIsInteracting(false); }); const zoomOnWheelHandler = instance.addInteractionListener('wheel', (state) => { @@ -105,9 +93,6 @@ export const useZoomOnWheel = ( zoomOnWheelHandler.cleanup(); wheelStartHandler.cleanup(); wheelEndHandler.cleanup(); - if (interactionTimeoutRef.current) { - clearTimeout(interactionTimeoutRef.current); - } }; }, [ svgRef, @@ -117,6 +102,5 @@ export const useZoomOnWheel = ( setIsInteracting, instance, setZoomDataCallback, - interactionTimeoutRef, ]); }; 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 9817c3f08df22..37603ba6ceb85 100644 --- a/packages/x-charts-pro/src/internals/plugins/useChartProZoom/useChartProZoom.ts +++ b/packages/x-charts-pro/src/internals/plugins/useChartProZoom/useChartProZoom.ts @@ -108,14 +108,13 @@ export const useChartProZoom: ChartPlugin = ({ ); // Add events - const interactionTimeoutRef = React.useRef(undefined); const pluginData = { store, instance, svgRef }; - usePanOnDrag(pluginData, interactionTimeoutRef, setIsInteracting, setZoomDataCallback); + usePanOnDrag(pluginData, setIsInteracting, setZoomDataCallback); - useZoomOnWheel(pluginData, interactionTimeoutRef, setIsInteracting, setZoomDataCallback); + useZoomOnWheel(pluginData, setIsInteracting, setZoomDataCallback); - useZoomOnPinch(pluginData, interactionTimeoutRef, setIsInteracting, setZoomDataCallback); + useZoomOnPinch(pluginData, setIsInteracting, setZoomDataCallback); return { publicAPI: { From cc9e738a1c8f2380ce164acf784f6e44f0a387ae Mon Sep 17 00:00:00 2001 From: Jose Quintas Date: Thu, 20 Mar 2025 10:18:24 +0100 Subject: [PATCH 39/48] add docs --- .../useChartInteractionListener/useChartInteractionListener.ts | 1 + 1 file changed, 1 insertion(+) 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 0beddd25e2e35..e357c54d9b388 100644 --- a/packages/x-charts/src/internals/plugins/corePlugins/useChartInteractionListener/useChartInteractionListener.ts +++ b/packages/x-charts/src/internals/plugins/corePlugins/useChartInteractionListener/useChartInteractionListener.ts @@ -123,6 +123,7 @@ export const useChartInteractionListener: ChartPlugin Date: Thu, 20 Mar 2025 10:19:19 +0100 Subject: [PATCH 40/48] rework tap --- .../plugins/featurePlugins/useChartVoronoi/useChartVoronoi.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/x-charts/src/internals/plugins/featurePlugins/useChartVoronoi/useChartVoronoi.ts b/packages/x-charts/src/internals/plugins/featurePlugins/useChartVoronoi/useChartVoronoi.ts index 2b15b47ad3296..977b998c77ffb 100644 --- a/packages/x-charts/src/internals/plugins/featurePlugins/useChartVoronoi/useChartVoronoi.ts +++ b/packages/x-charts/src/internals/plugins/featurePlugins/useChartVoronoi/useChartVoronoi.ts @@ -194,8 +194,7 @@ export const useChartVoronoi: ChartPlugin = ({ dataIndex, }); - // @ts-expect-error tap doesn't exist on move. - if (state.tap && onItemClick) { + if ('tap' in state && state.tap && onItemClick) { onItemClick(state.event, { type: 'scatter', seriesId, dataIndex }); } }, From fd88fbe68280800324946af9cf5193ec7f18924e Mon Sep 17 00:00:00 2001 From: Jose Quintas Date: Fri, 21 Mar 2025 19:00:59 +0100 Subject: [PATCH 41/48] allow pointermove --- packages/x-charts/src/ChartsTooltip/utils.tsx | 19 ++++++++----------- .../useChartInteractionListener.ts | 1 + .../useChartInteractionListener.types.ts | 7 ++++++- 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/packages/x-charts/src/ChartsTooltip/utils.tsx b/packages/x-charts/src/ChartsTooltip/utils.tsx index c85864f945d5b..7d355ae0fe321 100644 --- a/packages/x-charts/src/ChartsTooltip/utils.tsx +++ b/packages/x-charts/src/ChartsTooltip/utils.tsx @@ -22,17 +22,14 @@ export function useMouseTracker(): UseMouseTrackerReturnValue { React.useEffect(() => { // Drag event is triggered by mobile touch or mouse drag. - const positionOnDragHandler = instance.addMultipleInteractionListeners( - ['move', 'drag'], - (state) => { - setMousePosition({ - x: state.event.clientX, - y: state.event.clientY, - height: state.event.height, - pointerType: state.event.pointerType as MousePosition['pointerType'], - }); - }, - ); + const positionOnDragHandler = instance.addInteractionListener('pointerMove', (state) => { + setMousePosition({ + x: state.event.clientX, + y: state.event.clientY, + height: state.event.height, + pointerType: state.event.pointerType as MousePosition['pointerType'], + }); + }); return () => { positionOnDragHandler.cleanup(); 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 e357c54d9b388..2d6277bb14b3f 100644 --- a/packages/x-charts/src/internals/plugins/corePlugins/useChartInteractionListener/useChartInteractionListener.ts +++ b/packages/x-charts/src/internals/plugins/corePlugins/useChartInteractionListener/useChartInteractionListener.ts @@ -67,6 +67,7 @@ export const useChartInteractionListener: ChartPlugin retriggerEvent('moveStart', state), onMoveEnd: (state) => retriggerEvent('moveEnd', state), onHover: (state) => retriggerEvent('hover', state), + onPointerMove: (state) => retriggerEvent('pointerMove', state), }, { target: svgRef, diff --git a/packages/x-charts/src/internals/plugins/corePlugins/useChartInteractionListener/useChartInteractionListener.types.ts b/packages/x-charts/src/internals/plugins/corePlugins/useChartInteractionListener/useChartInteractionListener.types.ts index bedb9ce6d70b3..bc82514bc7981 100644 --- a/packages/x-charts/src/internals/plugins/corePlugins/useChartInteractionListener/useChartInteractionListener.types.ts +++ b/packages/x-charts/src/internals/plugins/corePlugins/useChartInteractionListener/useChartInteractionListener.types.ts @@ -14,7 +14,8 @@ export type ChartInteraction = | 'move' | 'moveStart' | 'moveEnd' - | 'hover'; + | 'hover' + | 'pointerMove'; export type ChartInteractionHandler< Memo extends any, @@ -50,6 +51,10 @@ export type AddInteractionListener = { interaction: 'hover', callback: ChartInteractionHandler, ): InteractionListenerResult; + ( + interaction: 'pointerMove', + callback: ChartInteractionHandler, + ): InteractionListenerResult; }; type InteractionMap = T extends 'wheel' | 'wheelStart' | 'wheelEnd' From 02c1a296998c6c70d69e5e86193bf821e086c406 Mon Sep 17 00:00:00 2001 From: Jose Quintas Date: Mon, 24 Mar 2025 13:48:51 +0100 Subject: [PATCH 42/48] move utils --- .../gestureHooks/usePanOnDrag.ts | 47 ++++------------- .../useZoom.utils.ts} | 50 +++++++++++++++++++ .../gestureHooks/useZoomOnPinch.ts | 2 +- .../gestureHooks/useZoomOnWheel.ts | 2 +- 4 files changed, 63 insertions(+), 38 deletions(-) rename packages/x-charts-pro/src/internals/plugins/useChartProZoom/{useChartProZoom.utils.ts => gestureHooks/useZoom.utils.ts} (69%) 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 c60adc8f0e1bf..e7afb69652bde 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 @@ -9,6 +9,7 @@ import { selectorChartZoomOptionsLookup, } from '@mui/x-charts/internals'; import { UseChartProZoomSignature } from '../useChartProZoom.types'; +import { translateZoom } from './useZoom.utils'; export const usePanOnDrag = ( { @@ -54,42 +55,16 @@ export const usePanOnDrag = ( }); const movementX = point.x - originalPoint.x; const movementY = (point.y - originalPoint.y) * -1; - const newZoomData = state.memo.map((zoom) => { - const options = optionsLookup[zoom.axisId]; - if (!options || !options.panning) { - return zoom; - } - const min = zoom.start; - const max = zoom.end; - const span = max - min; - const MIN_PERCENT = options.minStart; - const MAX_PERCENT = options.maxEnd; - const movement = options.axisDirection === 'x' ? movementX : movementY; - const dimension = options.axisDirection === 'x' ? drawingArea.width : drawingArea.height; - let newMinPercent = min - (movement / dimension) * span; - let newMaxPercent = max - (movement / dimension) * span; - if (newMinPercent < MIN_PERCENT) { - newMinPercent = MIN_PERCENT; - newMaxPercent = newMinPercent + span; - } - if (newMaxPercent > MAX_PERCENT) { - newMaxPercent = MAX_PERCENT; - newMinPercent = newMaxPercent - span; - } - if ( - newMinPercent < MIN_PERCENT || - newMaxPercent > MAX_PERCENT || - span < options.minSpan || - span > options.maxSpan - ) { - return zoom; - } - return { - ...zoom, - start: newMinPercent, - end: newMaxPercent, - }; - }); + const newZoomData = translateZoom( + state.memo, + { x: movementX, y: movementY }, + { + width: drawingArea.width, + height: drawingArea.height, + }, + optionsLookup, + ); + setZoomDataCallback(newZoomData); return state.memo; }); diff --git a/packages/x-charts-pro/src/internals/plugins/useChartProZoom/useChartProZoom.utils.ts b/packages/x-charts-pro/src/internals/plugins/useChartProZoom/gestureHooks/useZoom.utils.ts similarity index 69% rename from packages/x-charts-pro/src/internals/plugins/useChartProZoom/useChartProZoom.utils.ts rename to packages/x-charts-pro/src/internals/plugins/useChartProZoom/gestureHooks/useZoom.utils.ts index a6595690dca45..b69a2b9f87ea1 100644 --- a/packages/x-charts-pro/src/internals/plugins/useChartProZoom/useChartProZoom.utils.ts +++ b/packages/x-charts-pro/src/internals/plugins/useChartProZoom/gestureHooks/useZoom.utils.ts @@ -113,6 +113,9 @@ export function getHorizontalCenterRatio( return (point.x - left) / width; } +/** + * Get the ratio of the point in the vertical center of the area. + */ export function getVerticalCenterRatio( point: { x: number; y: number }, area: { top: number; height: number }, @@ -120,3 +123,50 @@ export function getVerticalCenterRatio( const { top, height } = area; return ((point.y - top) / height) * -1 + 1; } + +/** + * Translate the zoom data by a given movement. + */ +export function translateZoom( + currentZoom: readonly ZoomData[], + movement: { x: number; y: number }, + drawingArea: { width: number; height: number }, + optionsLookup: Record, +) { + return currentZoom.map((zoom) => { + const options = optionsLookup[zoom.axisId]; + if (!options || !options.panning) { + return zoom; + } + const min = zoom.start; + const max = zoom.end; + const span = max - min; + const MIN_PERCENT = options.minStart; + const MAX_PERCENT = options.maxEnd; + const displacement = options.axisDirection === 'x' ? movement.x : movement.y; + const dimension = options.axisDirection === 'x' ? drawingArea.width : drawingArea.height; + let newMinPercent = min - (displacement / dimension) * span; + let newMaxPercent = max - (displacement / dimension) * span; + if (newMinPercent < MIN_PERCENT) { + newMinPercent = MIN_PERCENT; + newMaxPercent = newMinPercent + span; + } + if (newMaxPercent > MAX_PERCENT) { + newMaxPercent = MAX_PERCENT; + newMinPercent = newMaxPercent - span; + } + if ( + newMinPercent < MIN_PERCENT || + newMaxPercent > MAX_PERCENT || + span < options.minSpan || + span > options.maxSpan + ) { + return zoom; + } + return { + ...zoom, + start: newMinPercent, + end: newMaxPercent, + }; + }); +} diff --git a/packages/x-charts-pro/src/internals/plugins/useChartProZoom/gestureHooks/useZoomOnPinch.ts b/packages/x-charts-pro/src/internals/plugins/useChartProZoom/gestureHooks/useZoomOnPinch.ts index 87e93a1858e8b..dc8f8c1fa6fa8 100644 --- a/packages/x-charts-pro/src/internals/plugins/useChartProZoom/gestureHooks/useZoomOnPinch.ts +++ b/packages/x-charts-pro/src/internals/plugins/useChartProZoom/gestureHooks/useZoomOnPinch.ts @@ -14,7 +14,7 @@ import { getVerticalCenterRatio, isSpanValid, zoomAtPoint, -} from '../useChartProZoom.utils'; +} from './useZoom.utils'; export const useZoomOnPinch = ( { diff --git a/packages/x-charts-pro/src/internals/plugins/useChartProZoom/gestureHooks/useZoomOnWheel.ts b/packages/x-charts-pro/src/internals/plugins/useChartProZoom/gestureHooks/useZoomOnWheel.ts index 19b703e772362..c652f516816b7 100644 --- a/packages/x-charts-pro/src/internals/plugins/useChartProZoom/gestureHooks/useZoomOnWheel.ts +++ b/packages/x-charts-pro/src/internals/plugins/useChartProZoom/gestureHooks/useZoomOnWheel.ts @@ -15,7 +15,7 @@ import { getWheelScaleRatio, isSpanValid, zoomAtPoint, -} from '../useChartProZoom.utils'; +} from './useZoom.utils'; export const useZoomOnWheel = ( { From 1cccafa09d8f41b4a4200ba9168dc0d9345135c8 Mon Sep 17 00:00:00 2001 From: Jose Quintas Date: Tue, 25 Mar 2025 14:02:01 +0100 Subject: [PATCH 43/48] use native events --- packages/x-charts/src/ChartsTooltip/utils.tsx | 53 +++++++++---------- .../useChartInteractionListener.ts | 38 +++++++------ .../useChartInteractionListener.types.ts | 18 ++++++- 3 files changed, 63 insertions(+), 46 deletions(-) diff --git a/packages/x-charts/src/ChartsTooltip/utils.tsx b/packages/x-charts/src/ChartsTooltip/utils.tsx index 7d355ae0fe321..f4161e84fe083 100644 --- a/packages/x-charts/src/ChartsTooltip/utils.tsx +++ b/packages/x-charts/src/ChartsTooltip/utils.tsx @@ -21,18 +21,25 @@ export function useMouseTracker(): UseMouseTrackerReturnValue { const [mousePosition, setMousePosition] = React.useState(null); React.useEffect(() => { - // Drag event is triggered by mobile touch or mouse drag. - const positionOnDragHandler = instance.addInteractionListener('pointerMove', (state) => { - setMousePosition({ - x: state.event.clientX, - y: state.event.clientY, - height: state.event.height, - pointerType: state.event.pointerType as MousePosition['pointerType'], - }); + const outHandler = instance.addInteractionListener('pointerOut', () => { + setMousePosition(null); }); + const positionHandler = instance.addMultipleInteractionListeners( + ['pointerMove', 'pointerDown'], + (state) => { + setMousePosition({ + x: state.event.clientX, + y: state.event.clientY, + height: state.event.height, + pointerType: state.event.pointerType as MousePosition['pointerType'], + }); + }, + ); + return () => { - positionOnDragHandler.cleanup(); + positionHandler.cleanup(); + outHandler.cleanup(); }; }, [instance]); @@ -47,29 +54,19 @@ export function usePointerType(): null | PointerType { const [pointerType, setPointerType] = React.useState(null); React.useEffect(() => { - const removePointerHandler = instance.addInteractionListener('dragEnd', (state) => { - // TODO: We can check and only close when it is not a tap with `!state.tap` - // This would allow users to click/tap on the chart to display the tooltip. - - // Only close the tooltip on mobile, which doesn't trigger a hover event. - if (!state.hovering) { + const removePointerHandler = instance.addInteractionListener('pointerUp', (state) => { + // Only close the tooltip on mobile. + if (state.event.pointerType !== 'mouse') { setPointerType(null); } }); - // Move is mouse, Drag is both mouse and touch. - const setPointerHandler = instance.addMultipleInteractionListeners( - ['move', 'drag'], - (state) => { - // @ts-expect-error tap doesn't exist on move. - if (!state.first && !state.tap) { - setPointerType({ - height: state.event.height, - pointerType: state.event.pointerType as PointerType['pointerType'], - }); - } - }, - ); + const setPointerHandler = instance.addInteractionListener('pointerEnter', (state) => { + setPointerType({ + height: Math.max(state.event.height, 24), + pointerType: state.event.pointerType as PointerType['pointerType'], + }); + }); return () => { removePointerHandler.cleanup(); 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 2d6277bb14b3f..194894d053a0e 100644 --- a/packages/x-charts/src/internals/plugins/corePlugins/useChartInteractionListener/useChartInteractionListener.ts +++ b/packages/x-charts/src/internals/plugins/corePlugins/useChartInteractionListener/useChartInteractionListener.ts @@ -36,9 +36,10 @@ export const useChartInteractionListener: ChartPlugin { const listenersRef = React.useRef(new Map()); - const retriggerEvent = React.useCallback((interaction: ChartInteraction, state: any) => { + const forwardEvent = React.useCallback((interaction: ChartInteraction, state: any) => { const listeners = listenersRef.current.get(interaction); const memo = !state.memo ? new Map() : state.memo; + state.interactionType = interaction; if (listeners) { listeners.forEach((callback) => { @@ -54,20 +55,26 @@ export const useChartInteractionListener: ChartPlugin retriggerEvent('drag', state), - onDragStart: (state) => retriggerEvent('dragStart', state), - onDragEnd: (state) => retriggerEvent('dragEnd', state), - onPinch: (state) => retriggerEvent('pinch', state), - onPinchStart: (state) => retriggerEvent('pinchStart', state), - onPinchEnd: (state) => retriggerEvent('pinchEnd', state), - onWheel: (state) => retriggerEvent('wheel', state), - onWheelStart: (state) => retriggerEvent('wheelStart', state), - onWheelEnd: (state) => retriggerEvent('wheelEnd', state), - onMove: (state) => retriggerEvent('move', state), - onMoveStart: (state) => retriggerEvent('moveStart', state), - onMoveEnd: (state) => retriggerEvent('moveEnd', state), - onHover: (state) => retriggerEvent('hover', state), - onPointerMove: (state) => retriggerEvent('pointerMove', state), + onDrag: (state) => forwardEvent('drag', state), + onDragStart: (state) => forwardEvent('dragStart', state), + onDragEnd: (state) => forwardEvent('dragEnd', state), + onPinch: (state) => forwardEvent('pinch', state), + onPinchStart: (state) => forwardEvent('pinchStart', state), + onPinchEnd: (state) => forwardEvent('pinchEnd', state), + onWheel: (state) => forwardEvent('wheel', state), + onWheelStart: (state) => forwardEvent('wheelStart', state), + onWheelEnd: (state) => forwardEvent('wheelEnd', state), + onMove: (state) => forwardEvent('move', state), + onMoveStart: (state) => forwardEvent('moveStart', state), + onMoveEnd: (state) => forwardEvent('moveEnd', state), + onHover: (state) => forwardEvent('hover', state), + onPointerDown: (state) => forwardEvent('pointerDown', state), + onPointerEnter: (state) => forwardEvent('pointerEnter', state), + onPointerOver: (state) => forwardEvent('pointerOver', state), + onPointerMove: (state) => forwardEvent('pointerMove', state), + onPointerLeave: (state) => forwardEvent('pointerLeave', state), + onPointerOut: (state) => forwardEvent('pointerOut', state), + onPointerUp: (state) => forwardEvent('pointerUp', state), }, { target: svgRef, @@ -78,7 +85,6 @@ export const useChartInteractionListener: ChartPlugin, 'event' | 'memo'> & { event: EventType; memo: Memo; + interactionType: ChartInteraction; }, ) => any | void; @@ -52,7 +59,14 @@ export type AddInteractionListener = { callback: ChartInteractionHandler, ): InteractionListenerResult; ( - interaction: 'pointerMove', + interaction: + | 'pointerMove' + | 'pointerDown' + | 'pointerEnter' + | 'pointerOver' + | 'pointerLeave' + | 'pointerOut' + | 'pointerUp', callback: ChartInteractionHandler, ): InteractionListenerResult; }; From c55a5e3674785ae39bce4a622bca86bfc0573780 Mon Sep 17 00:00:00 2001 From: Jose Quintas Date: Tue, 25 Mar 2025 14:44:14 +0100 Subject: [PATCH 44/48] pan --- .../zoom-and-pan/ExternalZoomManagement.tsx | 2 +- .../charts/zoom-and-pan/ZoomControlled.tsx | 4 +-- .../gestureHooks/usePanOnDrag.ts | 25 +++++++++++++++++++ .../gestureHooks/useZoom.utils.ts | 4 +-- 4 files changed, 30 insertions(+), 5 deletions(-) diff --git a/docs/data/charts/zoom-and-pan/ExternalZoomManagement.tsx b/docs/data/charts/zoom-and-pan/ExternalZoomManagement.tsx index bee7d79fb2d1d..18a284cfe8123 100644 --- a/docs/data/charts/zoom-and-pan/ExternalZoomManagement.tsx +++ b/docs/data/charts/zoom-and-pan/ExternalZoomManagement.tsx @@ -25,7 +25,7 @@ export default function ExternalZoomManagement() { {...chartProps} initialZoom={initialZoomData} apiRef={apiRef} - onZoomChange={(newZoomData) => setZoomData(newZoomData)} + onZoomChange={setZoomData} xAxis={[ { zoom: true, diff --git a/docs/data/charts/zoom-and-pan/ZoomControlled.tsx b/docs/data/charts/zoom-and-pan/ZoomControlled.tsx index 961ea7cbca000..4256c2080c48b 100644 --- a/docs/data/charts/zoom-and-pan/ZoomControlled.tsx +++ b/docs/data/charts/zoom-and-pan/ZoomControlled.tsx @@ -38,13 +38,13 @@ export default function ZoomControlled() { setZoomData(newZoomData)} + onZoomChange={setZoomData} zoomData={zoomData} xAxis={lineAxis} /> setZoomData(newZoomData)} + onZoomChange={setZoomData} zoomData={zoomData} xAxis={barAxis} /> 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 e7afb69652bde..6adf7156c43dc 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,6 +23,8 @@ export const usePanOnDrag = ( const drawingArea = useSelector(store, selectorChartDrawingArea); const optionsLookup = useSelector(store, selectorChartZoomOptionsLookup); + const ref = React.useRef({}); + // Add event for chart panning const isPanEnabled = React.useMemo( () => Object.values(optionsLookup).some((v) => v.panning) || false, @@ -31,6 +33,29 @@ export const usePanOnDrag = ( React.useEffect(() => { const element = svgRef.current; + + console.log('instance', instance === ref.current.instance); + console.log('svgRef', svgRef === ref.current.svgRef); + console.log('setIsInteracting', setIsInteracting === ref.current.setIsInteracting); + console.log('isPanEnabled', isPanEnabled === ref.current.isPanEnabled); + console.log('optionsLookup', optionsLookup === ref.current.optionsLookup); + console.log('drawingArea.width', drawingArea.width === ref.current.width); + console.log('drawingArea.height', drawingArea.height === ref.current.height); + console.log('setZoomDataCallback', setZoomDataCallback === ref.current.setZoomDataCallback); + console.log('store', store === ref.current.store); + + ref.current = { + instance, + svgRef, + setIsInteracting, + isPanEnabled, + optionsLookup, + width: drawingArea.width, + height: drawingArea.height, + setZoomDataCallback, + store, + }; + if (element === null || !isPanEnabled) { return () => {}; } diff --git a/packages/x-charts-pro/src/internals/plugins/useChartProZoom/gestureHooks/useZoom.utils.ts b/packages/x-charts-pro/src/internals/plugins/useChartProZoom/gestureHooks/useZoom.utils.ts index b69a2b9f87ea1..32fcde06b54ab 100644 --- a/packages/x-charts-pro/src/internals/plugins/useChartProZoom/gestureHooks/useZoom.utils.ts +++ b/packages/x-charts-pro/src/internals/plugins/useChartProZoom/gestureHooks/useZoom.utils.ts @@ -128,12 +128,12 @@ export function getVerticalCenterRatio( * Translate the zoom data by a given movement. */ export function translateZoom( - currentZoom: readonly ZoomData[], + initialZoomData: readonly ZoomData[], movement: { x: number; y: number }, drawingArea: { width: number; height: number }, optionsLookup: Record, ) { - return currentZoom.map((zoom) => { + return initialZoomData.map((zoom) => { const options = optionsLookup[zoom.axisId]; if (!options || !options.panning) { return zoom; From 220efd1ff15e10f17546fea131141a0e6a7d3e95 Mon Sep 17 00:00:00 2001 From: Jose Quintas Date: Tue, 25 Mar 2025 19:13:47 +0100 Subject: [PATCH 45/48] ensure zoom is called on RAF --- .../gestureHooks/usePanOnDrag.ts | 24 ---------- .../gestureHooks/useZoomOnWheel.ts | 28 ++++++----- .../useChartProZoom/useChartProZoom.ts | 47 +++++++++++-------- .../ChartsTooltip/ChartsTooltipContainer.tsx | 10 +++- packages/x-charts/src/internals/index.ts | 1 + .../useChartInteractionListener/index.ts | 2 + .../useChartInteractionListener.ts | 9 ++-- .../useChartCartesianAxis.ts | 26 ++-------- .../useChartPolarAxis/useChartPolarAxis.ts | 19 ++------ packages/x-internals/src/rafThrottle/index.ts | 1 + .../src/rafThrottle/rafThrottle.ts | 45 ++++++++++++++++++ 11 files changed, 117 insertions(+), 95 deletions(-) create mode 100644 packages/x-internals/src/rafThrottle/index.ts create mode 100644 packages/x-internals/src/rafThrottle/rafThrottle.ts 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 6adf7156c43dc..318a8527e7943 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,8 +23,6 @@ export const usePanOnDrag = ( const drawingArea = useSelector(store, selectorChartDrawingArea); const optionsLookup = useSelector(store, selectorChartZoomOptionsLookup); - const ref = React.useRef({}); - // Add event for chart panning const isPanEnabled = React.useMemo( () => Object.values(optionsLookup).some((v) => v.panning) || false, @@ -34,28 +32,6 @@ export const usePanOnDrag = ( React.useEffect(() => { const element = svgRef.current; - console.log('instance', instance === ref.current.instance); - console.log('svgRef', svgRef === ref.current.svgRef); - console.log('setIsInteracting', setIsInteracting === ref.current.setIsInteracting); - console.log('isPanEnabled', isPanEnabled === ref.current.isPanEnabled); - console.log('optionsLookup', optionsLookup === ref.current.optionsLookup); - console.log('drawingArea.width', drawingArea.width === ref.current.width); - console.log('drawingArea.height', drawingArea.height === ref.current.height); - console.log('setZoomDataCallback', setZoomDataCallback === ref.current.setZoomDataCallback); - console.log('store', store === ref.current.store); - - ref.current = { - instance, - svgRef, - setIsInteracting, - isPanEnabled, - optionsLookup, - width: drawingArea.width, - height: drawingArea.height, - setZoomDataCallback, - store, - }; - if (element === null || !isPanEnabled) { return () => {}; } diff --git a/packages/x-charts-pro/src/internals/plugins/useChartProZoom/gestureHooks/useZoomOnWheel.ts b/packages/x-charts-pro/src/internals/plugins/useChartProZoom/gestureHooks/useZoomOnWheel.ts index c652f516816b7..e2f3c648118c5 100644 --- a/packages/x-charts-pro/src/internals/plugins/useChartProZoom/gestureHooks/useZoomOnWheel.ts +++ b/packages/x-charts-pro/src/internals/plugins/useChartProZoom/gestureHooks/useZoomOnWheel.ts @@ -55,19 +55,20 @@ export const useZoomOnWheel = ( } }); - const zoomOnWheelHandler = instance.addInteractionListener('wheel', (state) => { - const point = getSVGPoint(element, state.event); + const zoomOnWheelHandler = instance.addInteractionListener( + 'wheel', + (state) => { + const point = getSVGPoint(element, state.event); - if (!instance.isPointInside(point)) { - return; - } + if (!instance.isPointInside(point)) { + return; + } - if (!state.last) { - state.event.preventDefault(); - } + if (!state.memo) { + state.memo = store.getSnapshot().zoom.zoomData; + } - setZoomDataCallback((prevZoomData) => { - return prevZoomData.map((zoom) => { + const newZoomData = state.memo.map((zoom) => { const option = optionsLookup[zoom.axisId]; if (!option) { return zoom; @@ -86,8 +87,10 @@ export const useZoomOnWheel = ( return { axisId: zoom.axisId, start: newMinRange, end: newMaxRange }; }); - }); - }); + + setZoomDataCallback(newZoomData); + }, + ); return () => { zoomOnWheelHandler.cleanup(); @@ -102,5 +105,6 @@ export const useZoomOnWheel = ( setIsInteracting, instance, setZoomDataCallback, + store, ]); }; 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 37603ba6ceb85..79a8e396c474b 100644 --- a/packages/x-charts-pro/src/internals/plugins/useChartProZoom/useChartProZoom.ts +++ b/packages/x-charts-pro/src/internals/plugins/useChartProZoom/useChartProZoom.ts @@ -8,6 +8,7 @@ import { ZoomData, createZoomLookup, } from '@mui/x-charts/internals'; +import { rafThrottle } from '@mui/x-internals/rafThrottle'; import { UseChartProZoomSignature } from './useChartProZoom.types'; import { useZoomOnWheel } from './gestureHooks/useZoomOnWheel'; import { useZoomOnPinch } from './gestureHooks/useZoomOnPinch'; @@ -83,30 +84,36 @@ export const useChartProZoom: ChartPlugin = ({ [store], ); - const setZoomDataCallback = React.useCallback( - (zoomData: ZoomData[] | ((prev: ZoomData[]) => ZoomData[])) => { - store.update((prevState) => { - const newZoomData = - typeof zoomData === 'function' ? zoomData([...prevState.zoom.zoomData]) : zoomData; - onZoomChange?.(newZoomData); - - if (prevState.zoom.isControlled) { - return prevState; - } - - return { - ...prevState, - zoom: { - ...prevState.zoom, - zoomData: newZoomData, - }, - }; - }); - }, + const setZoomDataCallback = React.useMemo( + () => + rafThrottle((zoomData: ZoomData[] | ((prev: ZoomData[]) => ZoomData[])) => { + store.update((prevState) => { + const newZoomData = + typeof zoomData === 'function' ? zoomData([...prevState.zoom.zoomData]) : zoomData; + onZoomChange?.(newZoomData); + if (prevState.zoom.isControlled) { + return prevState; + } + + return { + ...prevState, + zoom: { + ...prevState.zoom, + zoomData: newZoomData, + }, + }; + }); + }), [onZoomChange, store], ); + React.useEffect(() => { + return () => { + setZoomDataCallback.clear(); + }; + }, [setZoomDataCallback]); + // Add events const pluginData = { store, instance, svgRef }; diff --git a/packages/x-charts/src/ChartsTooltip/ChartsTooltipContainer.tsx b/packages/x-charts/src/ChartsTooltip/ChartsTooltipContainer.tsx index 01878a6d4b6ec..cde0b0c2309fc 100644 --- a/packages/x-charts/src/ChartsTooltip/ChartsTooltipContainer.tsx +++ b/packages/x-charts/src/ChartsTooltip/ChartsTooltipContainer.tsx @@ -6,6 +6,7 @@ import useLazyRef from '@mui/utils/useLazyRef'; import { styled, useThemeProps } from '@mui/material/styles'; import Popper, { PopperPlacementType, PopperProps } from '@mui/material/Popper'; import NoSsr from '@mui/material/NoSsr'; +import { rafThrottle } from '@mui/x-internals/rafThrottle'; import { AxisDefaultized } from '../models/axis'; import { TriggerOptions, usePointerType } from './utils'; import { ChartsTooltipClasses } from './chartsTooltipClasses'; @@ -84,14 +85,21 @@ function ChartsTooltipContainer(inProps: ChartsTooltipContainerProps) { const popperOpen = pointerType !== null && hasData; // tooltipHasData; React.useEffect(() => { + const update = rafThrottle(() => popperRef.current?.update()); + const positionHandler = instance.addMultipleInteractionListeners(['move', 'drag'], (state) => { + if (state.interactionType === 'move' && state.dragging && state.moving) { + return; + } + // eslint-disable-next-line react-compiler/react-compiler positionRef.current = { x: state.event.clientX, y: state.event.clientY }; - popperRef.current?.update(); + update(); }); return () => { positionHandler.cleanup(); + update.clear(); }; }, [positionRef, instance]); diff --git a/packages/x-charts/src/internals/index.ts b/packages/x-charts/src/internals/index.ts index 66c9228ffaa11..58ef07578a860 100644 --- a/packages/x-charts/src/internals/index.ts +++ b/packages/x-charts/src/internals/index.ts @@ -18,6 +18,7 @@ export * from './createSeriesSelectorOfType'; export * from './plugins/corePlugins/useChartId'; export * from './plugins/corePlugins/useChartSeries'; export * from './plugins/corePlugins/useChartDimensions'; +export * from './plugins/corePlugins/useChartInteractionListener'; export * from './plugins/featurePlugins/useChartZAxis'; export * from './plugins/featurePlugins/useChartCartesianAxis'; export * from './plugins/featurePlugins/useChartPolarAxis'; diff --git a/packages/x-charts/src/internals/plugins/corePlugins/useChartInteractionListener/index.ts b/packages/x-charts/src/internals/plugins/corePlugins/useChartInteractionListener/index.ts index b53470853f913..cf17dca97703f 100644 --- a/packages/x-charts/src/internals/plugins/corePlugins/useChartInteractionListener/index.ts +++ b/packages/x-charts/src/internals/plugins/corePlugins/useChartInteractionListener/index.ts @@ -5,4 +5,6 @@ export type { UseChartInteractionListenerState, UseChartInteractionListenerInstance, UseChartInteractionListenerSignature, + ChartInteraction, + ChartInteractionHandler, } from './useChartInteractionListener.types'; 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 194894d053a0e..4b888e723a3c2 100644 --- a/packages/x-charts/src/internals/plugins/corePlugins/useChartInteractionListener/useChartInteractionListener.ts +++ b/packages/x-charts/src/internals/plugins/corePlugins/useChartInteractionListener/useChartInteractionListener.ts @@ -78,9 +78,6 @@ export const useChartInteractionListener: ChartPlugin { const element = svgRef.current; if (!isInteractionEnabled || !element || params.disableAxisListener) { @@ -80,7 +76,6 @@ export const useChartCartesianAxis: ChartPlugin { if (!state.hovering) { - mousePosition.current.isInChart = false; instance.cleanInteraction?.(); } }); @@ -98,22 +93,13 @@ export const useChartCartesianAxis: ChartPlugin ({ - ...prev, - interaction: { item: null, axis: { x: null, y: null } }, - })); - mousePosition.current.isInChart = false; - } + instance.cleanInteraction?.(); + return; } - mousePosition.current.isInChart = true; - - instance.setAxisInteraction?.({ - x: getAxisValue(xAxisWithScale[usedXAxis], svgPoint.x), - y: getAxisValue(yAxisWithScale[usedYAxis], svgPoint.y), - }); + instance.setPointerCoordinate?.(svgPoint); }, ); @@ -147,10 +133,8 @@ export const useChartCartesianAxis: ChartPlugin> = ( const isPointInside = instance.isPointInside(svgPoint, { targetElement: state.event.target as SVGElement, }); + // Test if it's in the drawing area if (!isPointInside) { if (mousePosition.current.isInChart) { - store.update((prev) => ({ - ...prev, - interaction: { item: null, axis: { x: null, y: null } }, - })); + instance?.cleanInteraction(); mousePosition.current.isInChart = false; } + return; } // Test if it's in the radar circle @@ -140,22 +139,14 @@ export const useChartPolarAxis: ChartPlugin> = ( if (radiusSquare > maxRadius ** 2) { if (mousePosition.current.isInChart) { - store.update((prev) => ({ - ...prev, - interaction: { item: null, axis: { x: null, y: null } }, - })); + instance?.cleanInteraction(); mousePosition.current.isInChart = false; } return; } mousePosition.current.isInChart = true; - const angle = svg2rotation(svgPoint.x, svgPoint.y); - - instance.setAxisInteraction?.({ - x: getAxisValue(rotationAxisWithScale[usedRotationAxisId], angle), - y: null, - }); + instance.setPointerCoordinate?.(svgPoint); }, ); diff --git a/packages/x-internals/src/rafThrottle/index.ts b/packages/x-internals/src/rafThrottle/index.ts new file mode 100644 index 0000000000000..b93c52f02d69e --- /dev/null +++ b/packages/x-internals/src/rafThrottle/index.ts @@ -0,0 +1 @@ +export * from './rafThrottle'; diff --git a/packages/x-internals/src/rafThrottle/rafThrottle.ts b/packages/x-internals/src/rafThrottle/rafThrottle.ts new file mode 100644 index 0000000000000..7884392588487 --- /dev/null +++ b/packages/x-internals/src/rafThrottle/rafThrottle.ts @@ -0,0 +1,45 @@ +export interface Cancelable { + clear(): void; +} + +/** + * Creates a throttled function that only invokes `fn` at most once per animation frame. + * + * @example + * ```ts + * const throttled = rafThrottle((value: number) => console.log(value)); + * window.addEventListener('scroll', (e) => throttled(e.target.scrollTop)); + * ``` + * + * @param fn Callback function + * @return The `requestAnimationFrame` throttled function + */ +export function rafThrottle any>(fn: T): T & Cancelable { + let isRunning: boolean = false; + let lastArgs: Parameters; + let rafRef: ReturnType | null; + + const later = () => { + isRunning = false; + fn(...lastArgs); + }; + + function throttled(...args: Parameters) { + lastArgs = args; + if (isRunning) { + return; + } + isRunning = true; + rafRef = requestAnimationFrame(later); + } + + throttled.clear = () => { + if (rafRef) { + cancelAnimationFrame(rafRef); + rafRef = null; + } + isRunning = false; + }; + + return throttled as T & Cancelable; +} From 7fb1449087944ca0b659bc9a4dc6e7869da7e460 Mon Sep 17 00:00:00 2001 From: Jose Quintas Date: Tue, 25 Mar 2025 22:54:50 +0100 Subject: [PATCH 46/48] Add debouce and automatic interacting --- .../gestureHooks/usePanOnDrag.ts | 12 ------- .../gestureHooks/useZoomOnPinch.ts | 21 +---------- .../gestureHooks/useZoomOnWheel.ts | 32 +---------------- .../useChartProZoom/useChartProZoom.ts | 35 +++++++++++++------ 4 files changed, 27 insertions(+), 73 deletions(-) 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 318a8527e7943..b33ea43a6b66c 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 @@ -17,7 +17,6 @@ export const usePanOnDrag = ( instance, svgRef, }: Pick>[0], 'store' | 'instance' | 'svgRef'>, - setIsInteracting: React.Dispatch, setZoomDataCallback: React.Dispatch ZoomData[])>, ) => { const drawingArea = useSelector(store, selectorChartDrawingArea); @@ -70,23 +69,12 @@ export const usePanOnDrag = ( return state.memo; }); - const panStartHandler = instance.addInteractionListener('dragStart', () => { - setIsInteracting(true); - }); - - const panEndHandler = instance.addInteractionListener('dragEnd', () => { - setIsInteracting(false); - }); - return () => { panHandler.cleanup(); - panStartHandler.cleanup(); - panEndHandler.cleanup(); }; }, [ instance, svgRef, - setIsInteracting, isPanEnabled, optionsLookup, drawingArea.width, diff --git a/packages/x-charts-pro/src/internals/plugins/useChartProZoom/gestureHooks/useZoomOnPinch.ts b/packages/x-charts-pro/src/internals/plugins/useChartProZoom/gestureHooks/useZoomOnPinch.ts index dc8f8c1fa6fa8..a7705e2a59f35 100644 --- a/packages/x-charts-pro/src/internals/plugins/useChartProZoom/gestureHooks/useZoomOnPinch.ts +++ b/packages/x-charts-pro/src/internals/plugins/useChartProZoom/gestureHooks/useZoomOnPinch.ts @@ -22,7 +22,6 @@ export const useZoomOnPinch = ( instance, svgRef, }: Pick>[0], 'store' | 'instance' | 'svgRef'>, - setIsInteracting: React.Dispatch, setZoomDataCallback: React.Dispatch ZoomData[])>, ) => { const drawingArea = useSelector(store, selectorChartDrawingArea); @@ -36,10 +35,6 @@ export const useZoomOnPinch = ( return () => {}; } - const zoomStartHandler = instance.addInteractionListener('pinchStart', () => { - setIsInteracting(true); - }); - const zoomHandler = instance.addInteractionListener('pinch', (state) => { setZoomDataCallback((prevZoomData) => { const newZoomData = prevZoomData.map((zoom) => { @@ -78,22 +73,8 @@ export const useZoomOnPinch = ( }); }); - const zoomEndHandler = instance.addInteractionListener('pinchEnd', () => { - setIsInteracting(false); - }); - return () => { - zoomStartHandler.cleanup(); zoomHandler.cleanup(); - zoomEndHandler.cleanup(); }; - }, [ - svgRef, - drawingArea, - isZoomEnabled, - optionsLookup, - setIsInteracting, - instance, - setZoomDataCallback, - ]); + }, [svgRef, drawingArea, isZoomEnabled, optionsLookup, instance, setZoomDataCallback]); }; diff --git a/packages/x-charts-pro/src/internals/plugins/useChartProZoom/gestureHooks/useZoomOnWheel.ts b/packages/x-charts-pro/src/internals/plugins/useChartProZoom/gestureHooks/useZoomOnWheel.ts index e2f3c648118c5..dfc1e73cb310f 100644 --- a/packages/x-charts-pro/src/internals/plugins/useChartProZoom/gestureHooks/useZoomOnWheel.ts +++ b/packages/x-charts-pro/src/internals/plugins/useChartProZoom/gestureHooks/useZoomOnWheel.ts @@ -23,7 +23,6 @@ export const useZoomOnWheel = ( instance, svgRef, }: Pick>[0], 'store' | 'instance' | 'svgRef'>, - setIsInteracting: React.Dispatch, setZoomDataCallback: React.Dispatch ZoomData[])>, ) => { const drawingArea = useSelector(store, selectorChartDrawingArea); @@ -37,24 +36,6 @@ export const useZoomOnWheel = ( return () => {}; } - const wheelStartHandler = instance.addInteractionListener('wheel', (state) => { - const point = getSVGPoint(element, state.event); - - if (!instance.isPointInside(point)) { - return; - } - - if (!state.dragging) { - setIsInteracting(true); - } - }); - - const wheelEndHandler = instance.addInteractionListener('wheelEnd', (state) => { - if (!state.dragging && !state.pinching) { - setIsInteracting(false); - } - }); - const zoomOnWheelHandler = instance.addInteractionListener( 'wheel', (state) => { @@ -94,17 +75,6 @@ export const useZoomOnWheel = ( return () => { zoomOnWheelHandler.cleanup(); - wheelStartHandler.cleanup(); - wheelEndHandler.cleanup(); }; - }, [ - svgRef, - drawingArea, - isZoomEnabled, - optionsLookup, - setIsInteracting, - instance, - setZoomDataCallback, - store, - ]); + }, [svgRef, drawingArea, isZoomEnabled, optionsLookup, instance, setZoomDataCallback, store]); }; 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 79a8e396c474b..e8d0af1646d61 100644 --- a/packages/x-charts-pro/src/internals/plugins/useChartProZoom/useChartProZoom.ts +++ b/packages/x-charts-pro/src/internals/plugins/useChartProZoom/useChartProZoom.ts @@ -9,6 +9,7 @@ import { createZoomLookup, } from '@mui/x-charts/internals'; import { rafThrottle } from '@mui/x-internals/rafThrottle'; +import debounce from '@mui/utils/debounce'; import { UseChartProZoomSignature } from './useChartProZoom.types'; import { useZoomOnWheel } from './gestureHooks/useZoomOnWheel'; import { useZoomOnPinch } from './gestureHooks/useZoomOnPinch'; @@ -76,14 +77,25 @@ export const useChartProZoom: ChartPlugin = ({ }; }, [store, paramsZoomData]); - // Add instance methods - const setIsInteracting = React.useCallback( - (isInteracting: boolean) => { - store.update((prev) => ({ ...prev, zoom: { ...prev.zoom, isInteracting } })); - }, + // This is debounced. We want to run it only once after the interaction ends. + const removeIsInteracting = React.useMemo( + () => + debounce(() => + store.update((prevState) => { + return { + ...prevState, + zoom: { + ...prevState.zoom, + isInteracting: false, + }, + }; + }), + ), [store], ); + // This is throttled. We want to run it at most once per frame. + // By joining the two, we ensure that interacting and zooming are in sync. const setZoomDataCallback = React.useMemo( () => rafThrottle((zoomData: ZoomData[] | ((prev: ZoomData[]) => ZoomData[])) => { @@ -95,33 +107,36 @@ export const useChartProZoom: ChartPlugin = ({ return prevState; } + removeIsInteracting(); return { ...prevState, zoom: { ...prevState.zoom, + isInteracting: true, zoomData: newZoomData, }, }; }); }), - [onZoomChange, store], + [onZoomChange, store, removeIsInteracting], ); React.useEffect(() => { return () => { setZoomDataCallback.clear(); + removeIsInteracting.clear(); }; - }, [setZoomDataCallback]); + }, [setZoomDataCallback, removeIsInteracting]); // Add events const pluginData = { store, instance, svgRef }; - usePanOnDrag(pluginData, setIsInteracting, setZoomDataCallback); + usePanOnDrag(pluginData, setZoomDataCallback); - useZoomOnWheel(pluginData, setIsInteracting, setZoomDataCallback); + useZoomOnWheel(pluginData, setZoomDataCallback); - useZoomOnPinch(pluginData, setIsInteracting, setZoomDataCallback); + useZoomOnPinch(pluginData, setZoomDataCallback); return { publicAPI: { From fe3242499c6d81f6c4154852b943ae336123604b Mon Sep 17 00:00:00 2001 From: Jose Quintas Date: Mon, 7 Apr 2025 23:15:08 +0200 Subject: [PATCH 47/48] fix type --- docs/data/charts/zoom-and-pan/ExternalZoomManagement.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/data/charts/zoom-and-pan/ExternalZoomManagement.tsx b/docs/data/charts/zoom-and-pan/ExternalZoomManagement.tsx index 18a284cfe8123..88b6eca8a0bf7 100644 --- a/docs/data/charts/zoom-and-pan/ExternalZoomManagement.tsx +++ b/docs/data/charts/zoom-and-pan/ExternalZoomManagement.tsx @@ -14,7 +14,7 @@ const initialZoomData: ZoomData[] = [ }, ]; export default function ExternalZoomManagement() { - const apiRef = React.useRef(undefined) as React.MutableRefObject< + const apiRef = React.useRef(undefined) as React.RefObject< ChartPublicAPI<[any]> | undefined >; const [zoomData, setZoomData] = React.useState(initialZoomData); From 64ae3a47e28f218e99c6dfe67eb5806243a6e39b Mon Sep 17 00:00:00 2001 From: Jose Quintas Date: Mon, 7 Apr 2025 23:30:49 +0200 Subject: [PATCH 48/48] fix mobile pan too fast issue --- .../useChartCartesianAxisRendering.selectors.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/x-charts/src/internals/plugins/featurePlugins/useChartCartesianAxis/useChartCartesianAxisRendering.selectors.ts b/packages/x-charts/src/internals/plugins/featurePlugins/useChartCartesianAxis/useChartCartesianAxisRendering.selectors.ts index 4d5a81327c400..2d3effb6dcf92 100644 --- a/packages/x-charts/src/internals/plugins/featurePlugins/useChartCartesianAxis/useChartCartesianAxisRendering.selectors.ts +++ b/packages/x-charts/src/internals/plugins/featurePlugins/useChartCartesianAxis/useChartCartesianAxisRendering.selectors.ts @@ -1,3 +1,4 @@ +import { isDeepEqual } from '@mui/x-internals/isDeepEqual'; import { selectorChartDrawingArea } from '../../corePlugins/useChartDimensions'; import { selectorChartSeriesConfig, @@ -53,6 +54,11 @@ const selectorChartYZoomOptionsLookup = createSelector( export const selectorChartZoomOptionsLookup = createSelector( [selectorChartXZoomOptionsLookup, selectorChartYZoomOptionsLookup], (xLookup, yLookup) => ({ ...xLookup, ...yLookup }), + { + memoizeOptions: { + resultEqualityCheck: isDeepEqual, + }, + }, ); const selectorChartXFilter = createSelector(