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); 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..b33ea43a6b66c --- /dev/null +++ b/packages/x-charts-pro/src/internals/plugins/useChartProZoom/gestureHooks/usePanOnDrag.ts @@ -0,0 +1,85 @@ +'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 { translateZoom } from './useZoom.utils'; + +export const usePanOnDrag = ( + { + store, + instance, + svgRef, + }: Pick>[0], 'store' | 'instance' | 'svgRef'>, + 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], + ); + + React.useEffect(() => { + const element = svgRef.current; + + if (element === null || !isPanEnabled) { + return () => {}; + } + + const panHandler = instance.addInteractionListener('drag', (state) => { + if (state.pinching) { + state.cancel(); + return undefined; + } + + if (!state.memo) { + state.memo = store.getSnapshot().zoom.zoomData; + } + + 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 = translateZoom( + state.memo, + { x: movementX, y: movementY }, + { + width: drawingArea.width, + height: drawingArea.height, + }, + optionsLookup, + ); + + setZoomDataCallback(newZoomData); + return state.memo; + }); + + return () => { + panHandler.cleanup(); + }; + }, [ + instance, + svgRef, + isPanEnabled, + optionsLookup, + drawingArea.width, + drawingArea.height, + setZoomDataCallback, + store, + ]); +}; 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 7f75501596e62..32fcde06b54ab 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 @@ -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,9 @@ export function getHorizontalCenterRatio( return (point.x - left) / width; } -export function preventDefault(event: TouchEvent) { - event.preventDefault(); -} - +/** + * 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 }, @@ -153,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( + initialZoomData: readonly ZoomData[], + movement: { x: number; y: number }, + drawingArea: { width: number; height: number }, + optionsLookup: Record, +) { + return initialZoomData.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 new file mode 100644 index 0000000000000..a7705e2a59f35 --- /dev/null +++ b/packages/x-charts-pro/src/internals/plugins/useChartProZoom/gestureHooks/useZoomOnPinch.ts @@ -0,0 +1,80 @@ +'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 './useZoom.utils'; + +export const useZoomOnPinch = ( + { + store, + instance, + svgRef, + }: Pick>[0], 'store' | 'instance' | 'svgRef'>, + 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 zoomHandler = instance.addInteractionListener('pinch', (state) => { + 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 delta is 0, it means the pinch gesture is not valid. + if (state.delta[0] === 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; + }); + }); + + return () => { + zoomHandler.cleanup(); + }; + }, [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 new file mode 100644 index 0000000000000..dfc1e73cb310f --- /dev/null +++ b/packages/x-charts-pro/src/internals/plugins/useChartProZoom/gestureHooks/useZoomOnWheel.ts @@ -0,0 +1,80 @@ +'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 './useZoom.utils'; + +export const useZoomOnWheel = ( + { + store, + instance, + svgRef, + }: Pick>[0], 'store' | 'instance' | 'svgRef'>, + 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 zoomOnWheelHandler = instance.addInteractionListener( + 'wheel', + (state) => { + const point = getSVGPoint(element, state.event); + + if (!instance.isPointInside(point)) { + return; + } + + if (!state.memo) { + state.memo = store.getSnapshot().zoom.zoomData; + } + + const newZoomData = state.memo.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 }; + }); + + setZoomDataCallback(newZoomData); + }, + ); + + return () => { + zoomOnWheelHandler.cleanup(); + }; + }, [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 503a58fbeb19a..a2c4984893dc9 100644 --- a/packages/x-charts-pro/src/internals/plugins/useChartProZoom/useChartProZoom.ts +++ b/packages/x-charts-pro/src/internals/plugins/useChartProZoom/useChartProZoom.ts @@ -4,27 +4,17 @@ import { ChartPlugin, AxisId, DefaultizedZoomOptions, - useSelector, - getSVGPoint, - selectorChartDrawingArea, ZoomData, createZoomLookup, - selectorChartZoomOptionsLookup, } from '@mui/x-charts/internals'; -import { useEventCallback } from '@mui/material/utils'; import { rafThrottle } from '@mui/x-internals/rafThrottle'; import debounce from '@mui/utils/debounce'; +import { useEventCallback } from '@mui/material/utils'; import { UseChartProZoomSignature } from './useChartProZoom.types'; -import { - getDiff, - getHorizontalCenterRatio, - getPinchScaleRatio, - getVerticalCenterRatio, - getWheelScaleRatio, - isSpanValid, - preventDefault, - zoomAtPoint, -} from './useChartProZoom.utils'; +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) { return Object.values(options).map(({ axisId, minStart: start, maxEnd: end }) => ({ @@ -42,9 +32,6 @@ export const useChartProZoom: ChartPlugin = ({ }) => { const { zoomData: paramsZoomData, onZoomChange: onZoomChangeProp } = params; - const drawingArea = useSelector(store, selectorChartDrawingArea); - const optionsLookup = useSelector(store, selectorChartZoomOptionsLookup); - const isZoomEnabled = Object.keys(optionsLookup).length > 0; const onZoomChange = useEventCallback(onZoomChangeProp ?? (() => {})); // Manage controlled state @@ -147,266 +134,13 @@ export const useChartProZoom: ChartPlugin = ({ }, [setZoomDataCallback, removeIsInteracting]); // Add events - const panningEventCacheRef = React.useRef([]); - const zoomEventCacheRef = React.useRef([]); - const eventPrevDiff = React.useRef(0); - - // 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; - 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, - ); - 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, - isPanEnabled, - optionsLookup, - drawingArea.width, - drawingArea.height, - setZoomDataCallback, - store, - ]); - - // Add event for chart zoom in/out - React.useEffect(() => { - const element = svgRef.current; - if (element === null || !isZoomEnabled) { - return () => {}; - } - - const wheelHandler = (event: WheelEvent) => { - if (element === null) { - return; - } - - const point = getSVGPoint(element, event); - - if (!instance.isPointInside(point)) { - return; - } - - event.preventDefault(); - - 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 pluginData = { store, instance, svgRef }; - const { scaleRatio, isZoomIn } = getWheelScaleRatio(event, option.step); - const [newMinRange, newMaxRange] = zoomAtPoint(centerRatio, scaleRatio, zoom, option); + usePanOnDrag(pluginData, setZoomDataCallback); - if (!isSpanValid(newMinRange, newMaxRange, isZoomIn, option)) { - return zoom; - } - - return { axisId: zoom.axisId, start: newMinRange, end: newMaxRange }; - }); - }); - }; - - function pointerDownHandler(event: PointerEvent) { - zoomEventCacheRef.current.push(event); - } - - 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; - }); - } + useZoomOnWheel(pluginData, setZoomDataCallback); - function pointerUpHandler(event: PointerEvent) { - zoomEventCacheRef.current.splice( - zoomEventCacheRef.current.findIndex( - (cachedEvent) => cachedEvent.pointerId === event.pointerId, - ), - 1, - ); - - if (zoomEventCacheRef.current.length < 2) { - eventPrevDiff.current = 0; - } - } - - element.addEventListener('wheel', wheelHandler); - 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('wheel', wheelHandler); - 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, instance, setZoomDataCallback]); + useZoomOnPinch(pluginData, setZoomDataCallback); return { publicAPI: { diff --git a/packages/x-charts/package.json b/packages/x-charts/package.json index bdbb2ac6edbae..4c44eba5f1bc6 100644 --- a/packages/x-charts/package.json +++ b/packages/x-charts/package.json @@ -43,6 +43,7 @@ "@mui/utils": "^7.0.1", "@mui/x-charts-vendor": "workspace:*", "@mui/x-internals": "workspace:*", + "@use-gesture/react": "^10.3.1", "bezier-easing": "^2.1.0", "clsx": "^2.1.1", "prop-types": "^15.8.1", 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/ChartsTooltip/ChartsTooltipContainer.tsx b/packages/x-charts/src/ChartsTooltip/ChartsTooltipContainer.tsx index 66e2f696e614f..5c4883ac54d78 100644 --- a/packages/x-charts/src/ChartsTooltip/ChartsTooltipContainer.tsx +++ b/packages/x-charts/src/ChartsTooltip/ChartsTooltipContainer.tsx @@ -6,7 +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 { useSvgRef } from '../hooks/useSvgRef'; +import { rafThrottle } from '@mui/x-internals/rafThrottle'; import { TriggerOptions, usePointerType } from './utils'; import { ChartsTooltipClasses } from './chartsTooltipClasses'; import { useSelector } from '../internals/store/useSelector'; @@ -16,6 +16,7 @@ import { selectorChartsInteractionAxisTooltip, UseChartCartesianAxisSignature, } from '../internals/plugins/featurePlugins/useChartCartesianAxis'; +import { useChartContext } from '../context/ChartProvider'; export interface ChartsTooltipContainerProps extends Partial { /** @@ -57,41 +58,41 @@ function ChartsTooltipContainer(inProps: ChartsTooltipContainerProps) { name: 'MuiChartsTooltipContainer', }); const { trigger = 'axis', classes, children, ...other } = props; + const { instance } = useChartContext(); - const svgRef = useSvgRef(); const pointerType = usePointerType(); const popperRef: PopperProps['popperRef'] = React.useRef(null); const positionRef = useLazyRef(() => ({ x: 0, y: 0 })); const store = useStore<[UseChartCartesianAxisSignature]>(); - const isOpen = useSelector( + const hasData = useSelector( store, trigger === 'axis' ? selectorChartsInteractionAxisTooltip : selectorChartsInteractionItemIsDefined, ); - const popperOpen = pointerType !== null && isOpen; // tooltipHasData; + const popperOpen = pointerType !== null && hasData; // tooltipHasData; React.useEffect(() => { - const element = svgRef.current; - if (element === null) { - return () => {}; - } + const update = rafThrottle(() => popperRef.current?.update()); - const handleMove = (event: PointerEvent) => { - // eslint-disable-next-line react-compiler/react-compiler - positionRef.current = { x: event.clientX, y: event.clientY }; - popperRef.current?.update(); - }; + const positionHandler = instance.addMultipleInteractionListeners(['move', 'drag'], (state) => { + if (state.interactionType === 'move' && state.dragging && state.moving) { + return; + } - element.addEventListener('pointermove', handleMove); + // eslint-disable-next-line react-compiler/react-compiler + positionRef.current = { x: state.event.clientX, y: state.event.clientY }; + update(); + }); return () => { - element.removeEventListener('pointermove', handleMove); + positionHandler.cleanup(); + update.clear(); }; - }, [svgRef, positionRef]); + }, [positionRef, instance]); const anchorEl = React.useMemo( () => ({ diff --git a/packages/x-charts/src/ChartsTooltip/utils.tsx b/packages/x-charts/src/ChartsTooltip/utils.tsx index dbbeb9a6a46f7..f4161e84fe083 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,33 @@ 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') { - 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 }); + 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 () => { - // Calling `.abort()` removes ALL event listeners - // For more info, see https://kettanaito.com/blog/dont-sleep-on-abort-controller - controller.abort(); + positionHandler.cleanup(); + outHandler.cleanup(); }; - }, [svgRef]); + }, [instance]); return mousePosition; } @@ -60,38 +49,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 removePointerHandler = instance.addInteractionListener('pointerUp', (state) => { + // Only close the tooltip on mobile. + if (state.event.pointerType !== 'mouse') { setPointerType(null); } - }; + }); - const handleEnter = (event: PointerEvent) => { + const setPointerHandler = instance.addInteractionListener('pointerEnter', (state) => { setPointerType({ - height: event.height, - pointerType: event.pointerType as PointerType['pointerType'], + height: Math.max(state.event.height, 24), + pointerType: state.event.pointerType as PointerType['pointerType'], }); - }; - - element.addEventListener('pointerenter', handleEnter); - element.addEventListener('pointerup', handleOut); + }); return () => { - element.removeEventListener('pointerenter', handleEnter); - element.removeEventListener('pointerup', handleOut); + removePointerHandler.cleanup(); + setPointerHandler.cleanup(); }; - }, [svgRef]); + }, [instance]); return pointerType; } 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/corePlugins.ts b/packages/x-charts/src/internals/plugins/corePlugins/corePlugins.ts index 8b876ac4b66a8..9b24a3a12cf08 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 { useChartInteractionListener } from './useChartInteractionListener'; /** * 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, + useChartInteractionListener, +] as const; export type ChartCorePluginSignatures = ConvertPluginsIntoSignatures; 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..cf17dca97703f --- /dev/null +++ b/packages/x-charts/src/internals/plugins/corePlugins/useChartInteractionListener/index.ts @@ -0,0 +1,10 @@ +export { useChartInteractionListener } from './useChartInteractionListener'; +export type { + UseChartInteractionListenerParameters, + UseChartInteractionListenerDefaultizedParameters, + 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 new file mode 100644 index 0000000000000..4b888e723a3c2 --- /dev/null +++ b/packages/x-charts/src/internals/plugins/corePlugins/useChartInteractionListener/useChartInteractionListener.ts @@ -0,0 +1,165 @@ +'use client'; +import * as React from 'react'; +import { + createUseGesture, + dragAction, + pinchAction, + wheelAction, + moveAction, + hoverAction, +} from '@use-gesture/react'; +import { ChartPlugin } from '../../models'; +import { + UseChartInteractionListenerSignature, + AddInteractionListener, + AddMultipleInteractionListeners, + ChartInteraction, + ChartInteractionHandler, +} from './useChartInteractionListener.types'; + +type ListenerRef = Map>>; + +const preventDefault = (event: Event) => event.preventDefault(); + +// 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, +}) => { + const listenersRef = React.useRef(new Map()); + + 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) => { + const result = callback({ ...state, memo: memo.get(callback) }); + if (result) { + memo.set(callback, result); + } + }); + } + + return memo; + }, []); + + useGesture( + { + 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, + drag: { + pointer: { + // We can allow customizing the number of pointers + buttons: 1, + // Disable using `setPointerCapture` when testing, as it doesn't work properly. + capture: process.env.NODE_ENV !== 'test', + }, + }, + pinch: {}, + wheel: { + eventOptions: { + passive: false, + }, + preventDefault: true, + }, + }, + ); + + const addInteractionListener: AddInteractionListener = React.useCallback( + (interaction, callback) => { + let listeners = listenersRef.current.get(interaction); + + if (!listeners) { + listeners = new Set>(); + listeners.add(callback); + listenersRef.current.set(interaction, listeners); + } else { + listeners.add(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; + + // Disable gesture on safari + // https://use-gesture.netlify.app/docs/gestures/#about-the-pinch-gesture + 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]); + + return { + instance: { + addInteractionListener, + addMultipleInteractionListeners, + }, + }; +}; + +useChartInteractionListener.params = {}; + +useChartInteractionListener.getDefaultizedParams = ({ params }) => ({ + ...params, +}); + +useChartInteractionListener.getInitialState = () => { + return {}; +}; 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 new file mode 100644 index 0000000000000..4d043c8abe27b --- /dev/null +++ b/packages/x-charts/src/internals/plugins/corePlugins/useChartInteractionListener/useChartInteractionListener.types.ts @@ -0,0 +1,121 @@ +import { EventTypes, FullGestureState, GestureKey } from '@use-gesture/react'; +import { ChartPluginSignature } from '../../models'; + +export type ChartInteraction = + | 'drag' + | 'dragStart' + | 'dragEnd' + | 'pinch' + | 'pinchStart' + | 'pinchEnd' + | 'wheel' + | 'wheelStart' + | 'wheelEnd' + | 'move' + | 'moveStart' + | 'moveEnd' + | 'hover' + | 'pointerDown' + | 'pointerEnter' + | 'pointerOver' + | 'pointerMove' + | 'pointerLeave' + | 'pointerOut' + | 'pointerUp'; + +export type ChartInteractionHandler< + Memo extends any, + Key extends GestureKey, + EventType = EventTypes[Key], +> = ( + state: Omit, 'event' | 'memo'> & { + event: EventType; + memo: Memo; + interactionType: ChartInteraction; + }, +) => any | void; + +export type InteractionListenerResult = { cleanup: () => void }; + +export type AddInteractionListener = { + ( + interaction: 'drag' | 'dragStart' | 'dragEnd', + callback: ChartInteractionHandler, + ): InteractionListenerResult; + ( + interaction: 'pinch' | 'pinchStart' | 'pinchEnd', + callback: ChartInteractionHandler, + ): InteractionListenerResult; + ( + interaction: 'wheel' | 'wheelStart' | 'wheelEnd', + callback: ChartInteractionHandler, + ): InteractionListenerResult; + ( + interaction: 'move' | 'moveStart' | 'moveEnd', + callback: ChartInteractionHandler, + ): InteractionListenerResult; + ( + interaction: 'hover', + callback: ChartInteractionHandler, + ): InteractionListenerResult; + ( + interaction: + | 'pointerMove' + | 'pointerDown' + | 'pointerEnter' + | 'pointerOver' + | 'pointerLeave' + | 'pointerOut' + | 'pointerUp', + callback: ChartInteractionHandler, + ): InteractionListenerResult; +}; + +type InteractionMap = T extends 'wheel' | 'wheelStart' | 'wheelEnd' + ? WheelEvent + : PointerEvent; + +export type AddMultipleInteractionListeners = { + < + 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: ChartInteractionHandler>, + ): InteractionListenerResult; +}; + +export interface UseChartInteractionListenerParameters {} + +export type UseChartInteractionListenerDefaultizedParameters = + UseChartInteractionListenerParameters & {}; + +export interface UseChartInteractionListenerState {} + +export interface UseChartInteractionListenerInstance { + /** + * Adds an interaction listener to the SVG element. + * + * @param interaction The interaction to listen to. + * @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<{ + params: UseChartInteractionListenerParameters; + defaultizedParams: UseChartInteractionListenerDefaultizedParameters; + state: UseChartInteractionListenerState; + instance: UseChartInteractionListenerInstance; +}>; 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 74d474de4e3bd..89829c613901a 100644 --- a/packages/x-charts/src/internals/plugins/featurePlugins/useChartCartesianAxis/useChartCartesianAxis.ts +++ b/packages/x-charts/src/internals/plugins/featurePlugins/useChartCartesianAxis/useChartCartesianAxis.ts @@ -69,49 +69,43 @@ export const useChartCartesianAxis: ChartPlugin { const element = svgRef.current; - if (!isInteractionEnabled || element === null || params.disableAxisListener) { + if (!isInteractionEnabled || !element || params.disableAxisListener) { return () => {}; } - const handleOut = () => { - instance.cleanInteraction?.(); - }; - - const handleMove = (event: MouseEvent | TouchEvent) => { - const target = 'targetTouches' in event ? event.targetTouches[0] : event; - const svgPoint = getSVGPoint(element, target); - - if (!instance.isPointInside(svgPoint, { targetElement: event.target as SVGElement })) { + // Clean the interaction when the mouse leaves the chart. + const cleanInteractionHandler = instance.addInteractionListener('hover', (state) => { + if (!state.hovering) { instance.cleanInteraction?.(); - return; } + }); + + // 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, + }); - instance.setPointerCoordinate?.(svgPoint); - }; - - const handleDown = (event: PointerEvent) => { - const target = event.currentTarget; - if (!target) { - return; - } + if (!isPointInside) { + instance.cleanInteraction?.(); + return; + } - if ( - 'hasPointerCapture' in target && - (target as HTMLElement).hasPointerCapture(event.pointerId) - ) { - (target as HTMLElement).releasePointerCapture(event.pointerId); - } - }; + instance.setPointerCoordinate?.(svgPoint); + }, + ); - element.addEventListener('pointerdown', handleDown); - element.addEventListener('pointermove', handleMove); - element.addEventListener('pointercancel', handleOut); - element.addEventListener('pointerleave', handleOut); return () => { - element.removeEventListener('pointerdown', handleDown); - element.removeEventListener('pointermove', handleMove); - element.removeEventListener('pointercancel', handleOut); - element.removeEventListener('pointerleave', handleOut); + cleanInteractionHandler.cleanup(); + setInteractionHandler.cleanup(); }; }, [ svgRef, @@ -132,13 +126,15 @@ export const useChartCartesianAxis: ChartPlugin {}; } - const handleMouseClick = (event: MouseEvent) => { - event.preventDefault(); + const axisClickHandler = instance.addInteractionListener('drag', (state) => { + if (!state.tap) { + return; + } let dataIndex: number | null = null; let isXAxis: boolean = false; - const svgPoint = getSVGPoint(element, event); + const svgPoint = getSVGPoint(element, state.event); const xIndex = getAxisIndex(xAxisWithScale[usedXAxis], svgPoint.x); isXAxis = xIndex !== -1; @@ -171,12 +167,11 @@ export const useChartCartesianAxis: ChartPlugin { - element.removeEventListener('click', handleMouseClick); + axisClickHandler.cleanup(); }; }, [ params.onAxisClick, @@ -188,6 +183,7 @@ export const useChartCartesianAxis: ChartPlugin ({ ...xLookup, ...yLookup }), + { + memoizeOptions: { + resultEqualityCheck: isDeepEqual, + }, + }, ); const selectorChartXFilter = createSelector( 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 142d0d4c23cd7..56fabb8ab0c37 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,73 +104,56 @@ 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) { - instance?.cleanInteraction(); - mousePosition.current.isInChart = false; - } - return; + // Clean the interaction when the mouse leaves the chart. + const cleanInteractionHandler = instance.addInteractionListener('hover', (state) => { + if (!state.hovering) { + mousePosition.current.isInChart = false; + instance.cleanInteraction?.(); } - - // 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) { - instance?.cleanInteraction(); - 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, + }); + // Test if it's in the drawing area + if (!isPointInside) { + if (mousePosition.current.isInChart) { + instance?.cleanInteraction(); + mousePosition.current.isInChart = false; + } + return; } - return; - } - mousePosition.current.isInChart = true; - instance.setPointerCoordinate?.(svgPoint); - }; + // 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]; - const handleDown = (event: PointerEvent) => { - const target = event.currentTarget; - if (!target) { - return; - } + if (radiusSquare > maxRadius ** 2) { + if (mousePosition.current.isInChart) { + instance?.cleanInteraction(); + mousePosition.current.isInChart = false; + } + return; + } - if ( - 'hasPointerCapture' in target && - (target as HTMLElement).hasPointerCapture(event.pointerId) - ) { - (target as HTMLElement).releasePointerCapture(event.pointerId); - } - }; + mousePosition.current.isInChart = true; + instance.setPointerCoordinate?.(svgPoint); + }, + ); - 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); + 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 43caa7472f695..977b998c77ffb 100644 --- a/packages/x-charts/src/internals/plugins/featurePlugins/useChartVoronoi/useChartVoronoi.ts +++ b/packages/x-charts/src/internals/plugins/featurePlugins/useChartVoronoi/useChartVoronoi.ts @@ -162,57 +162,47 @@ export const useChartVoronoi: ChartPlugin = ({ return { seriesId: closestSeries.seriesId, dataIndex }; } - const handleMouseLeave = () => { - instance.cleanInteraction?.(); - instance.clearHighlight?.(); - }; - - const handleMouseMove = (event: MouseEvent) => { - const closestPoint = getClosestPoint(event); - - if (closestPoint === 'outside-chart') { + // Clean the interaction when the mouse leaves the chart. + const cleanInteractionHandler = instance.addInteractionListener('hover', (state) => { + if (!state.hovering) { instance.cleanInteraction?.(); instance.clearHighlight?.(); - return; - } - - if (closestPoint === 'outside-voronoi-max-radius' || closestPoint === 'no-point-found') { - instance.removeItemInteraction?.(); - instance.clearHighlight?.(); - return; } + }); - const { seriesId, dataIndex } = closestPoint; + const setInteractionHandler = instance.addMultipleInteractionListeners( + ['move', 'drag'], + (state) => { + const closestPoint = getClosestPoint(state.event); - instance.setItemInteraction?.({ type: 'scatter', seriesId, dataIndex }); - instance.setHighlight?.({ - seriesId, - dataIndex, - }); - }; + if (closestPoint === 'outside-chart') { + instance.cleanInteraction?.(); + instance.clearHighlight?.(); + return; + } - const handleMouseClick = (event: MouseEvent) => { - if (!onItemClick) { - return; - } - const closestPoint = getClosestPoint(event); + if (closestPoint === 'outside-voronoi-max-radius' || closestPoint === 'no-point-found') { + instance.removeItemInteraction?.(); + instance.clearHighlight?.(); + return; + } - if (typeof closestPoint === 'string') { - // No point fond for any reason - return; - } + const { seriesId, dataIndex } = closestPoint; + instance.setItemInteraction?.({ type: 'scatter', seriesId, dataIndex }); + instance.setHighlight?.({ + seriesId, + dataIndex, + }); - const { seriesId, dataIndex } = closestPoint; - onItemClick(event, { type: 'scatter', seriesId, dataIndex }); - }; + if ('tap' in state && state.tap && onItemClick) { + 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); + cleanInteractionHandler.cleanup(); + setInteractionHandler.cleanup(); }; }, [svgRef, yAxis, xAxis, voronoiMaxRadius, onItemClick, disableVoronoi, drawingArea, instance]); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c6b83fedcc8c5..f547a9d084f97 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -738,6 +738,9 @@ importers: '@mui/x-internals': specifier: workspace:* version: link:../x-internals/build + '@use-gesture/react': + specifier: ^10.3.1 + version: 10.3.1(react@19.0.0) bezier-easing: specifier: ^2.1.0 version: 2.1.0 @@ -4382,6 +4385,14 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + '@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.1': resolution: {integrity: sha512-aEUPCckHDcFyxpwFm0AIkbtv6PpUp3xTb9wYGFjtABynXjCYKkWoxX0AOK9NT9XCrdk6mBBUOeHQS+RKdcNO1A==} peerDependencies: @@ -13600,6 +13611,13 @@ snapshots: '@ungap/structured-clone@1.3.0': {} + '@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.1(@swc/helpers@0.5.15)(vite@6.2.1(@types/node@20.17.30)(terser@5.39.0)(tsx@4.19.3)(yaml@2.7.0))': dependencies: '@swc/core': 1.11.11(@swc/helpers@0.5.15)