Skip to content

Commit 97887e0

Browse files
JCQuintasalexfauquette
authored andcommitted
[charts] Improve charts interaction for mobile users (mui#13692)
Signed-off-by: Jose C Quintas Jr <juniorquintas@gmail.com> Co-authored-by: Alexandre Fauquette <45398769+alexfauquette@users.noreply.github.com>
1 parent 34d6a7b commit 97887e0

6 files changed

Lines changed: 86 additions & 44 deletions

File tree

packages/x-charts/src/ChartsSurface.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,11 @@ export interface ChartsSurfaceProps {
3535
const ChartChartsSurfaceStyles = styled('svg', {
3636
name: 'MuiChartsSurface',
3737
slot: 'Root',
38-
})(() => ({}));
38+
})(() => ({
39+
// This prevents default touch actions when using the svg on mobile devices.
40+
// For example, prevent page scroll & zoom.
41+
touchAction: 'none',
42+
}));
3943

4044
const ChartsSurface = React.forwardRef<SVGSVGElement, ChartsSurfaceProps>(function ChartsSurface(
4145
props: ChartsSurfaceProps,

packages/x-charts/src/ChartsTooltip/ChartsTooltip.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,8 +148,17 @@ function ChartsTooltip<T extends ChartSeriesType>(props: ChartsTooltipProps<T>)
148148
externalSlotProps: slotProps?.popper,
149149
additionalProps: {
150150
open: popperOpen,
151-
placement: 'right-start' as const,
151+
placement:
152+
mousePosition?.pointerType === 'mouse' ? ('right-start' as const) : ('top' as const),
152153
anchorEl: generateVirtualElement(mousePosition),
154+
modifiers: [
155+
{
156+
name: 'offset',
157+
options: {
158+
offset: [0, mousePosition?.pointerType === 'touch' ? 40 - mousePosition.height : 0],
159+
},
160+
},
161+
],
153162
},
154163
ownerState: {},
155164
});

packages/x-charts/src/ChartsTooltip/utils.tsx

Lines changed: 33 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,14 @@ import { AxisInteractionData, ItemInteractionData } from '../context/Interaction
33
import { ChartSeriesType } from '../models/seriesType/config';
44
import { useSvgRef } from '../hooks';
55

6-
export function generateVirtualElement(mousePosition: { x: number; y: number } | null) {
6+
type MousePosition = {
7+
x: number;
8+
y: number;
9+
pointerType: 'mouse' | 'touch' | 'pen';
10+
height: number;
11+
};
12+
13+
export function generateVirtualElement(mousePosition: MousePosition | null) {
714
if (mousePosition === null) {
815
return {
916
getBoundingClientRect: () => ({
@@ -20,18 +27,20 @@ export function generateVirtualElement(mousePosition: { x: number; y: number } |
2027
};
2128
}
2229
const { x, y } = mousePosition;
30+
const boundingBox = {
31+
width: 0,
32+
height: 0,
33+
x,
34+
y,
35+
top: y,
36+
right: x,
37+
bottom: y,
38+
left: x,
39+
};
2340
return {
2441
getBoundingClientRect: () => ({
25-
width: 0,
26-
height: 0,
27-
x,
28-
y,
29-
top: y,
30-
right: x,
31-
bottom: y,
32-
left: x,
33-
toJSON: () =>
34-
JSON.stringify({ width: 0, height: 0, x, y, top: y, right: x, bottom: y, left: x }),
42+
...boundingBox,
43+
toJSON: () => JSON.stringify(boundingBox),
3544
}),
3645
};
3746
}
@@ -40,7 +49,7 @@ export function useMouseTracker() {
4049
const svgRef = useSvgRef();
4150

4251
// Use a ref to avoid rerendering on every mousemove event.
43-
const [mousePosition, setMousePosition] = React.useState<null | { x: number; y: number }>(null);
52+
const [mousePosition, setMousePosition] = React.useState<MousePosition | null>(null);
4453

4554
React.useEffect(() => {
4655
const element = svgRef.current;
@@ -52,23 +61,23 @@ export function useMouseTracker() {
5261
setMousePosition(null);
5362
};
5463

55-
const handleMove = (event: MouseEvent | TouchEvent) => {
56-
const target = 'targetTouches' in event ? event.targetTouches[0] : event;
64+
const handleMove = (event: PointerEvent) => {
5765
setMousePosition({
58-
x: target.clientX,
59-
y: target.clientY,
66+
x: event.clientX,
67+
y: event.clientY,
68+
height: event.height,
69+
pointerType: event.pointerType as MousePosition['pointerType'],
6070
});
6171
};
6272

63-
element.addEventListener('mouseout', handleOut);
64-
element.addEventListener('mousemove', handleMove);
65-
element.addEventListener('touchend', handleOut);
66-
element.addEventListener('touchmove', handleMove);
73+
element.addEventListener('pointerdown', handleMove);
74+
element.addEventListener('pointermove', handleMove);
75+
element.addEventListener('pointerup', handleOut);
76+
6777
return () => {
68-
element.removeEventListener('mouseout', handleOut);
69-
element.removeEventListener('mousemove', handleMove);
70-
element.addEventListener('touchend', handleOut);
71-
element.addEventListener('touchmove', handleMove);
78+
element.removeEventListener('pointerdown', handleMove);
79+
element.removeEventListener('pointermove', handleMove);
80+
element.removeEventListener('pointerup', handleOut);
7281
};
7382
}, [svgRef]);
7483

packages/x-charts/src/ChartsVoronoiHandler/ChartsVoronoiHandler.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ function ChartsVoronoiHandler(props: ChartsVoronoiHandlerProps) {
9797
| 'outside-voronoi-max-radius'
9898
| 'no-point-found' {
9999
// Get mouse coordinate in global SVG space
100-
const svgPoint = getSVGPoint(svgRef.current!, event);
100+
const svgPoint = getSVGPoint(element, event);
101101

102102
const outsideX = svgPoint.x < left || svgPoint.x > left + width;
103103
const outsideY = svgPoint.y < top || svgPoint.y > top + height;
@@ -180,12 +180,12 @@ function ChartsVoronoiHandler(props: ChartsVoronoiHandlerProps) {
180180
onItemClick(event, { type: 'scatter', seriesId, dataIndex });
181181
};
182182

183-
element.addEventListener('mouseout', handleMouseOut);
184-
element.addEventListener('mousemove', handleMouseMove);
183+
element.addEventListener('pointerout', handleMouseOut);
184+
element.addEventListener('pointermove', handleMouseMove);
185185
element.addEventListener('click', handleMouseClick);
186186
return () => {
187-
element.removeEventListener('mouseout', handleMouseOut);
188-
element.removeEventListener('mousemove', handleMouseMove);
187+
element.removeEventListener('pointerout', handleMouseOut);
188+
element.removeEventListener('pointermove', handleMouseMove);
189189
element.removeEventListener('click', handleMouseClick);
190190
};
191191
}, [

packages/x-charts/src/hooks/useAxisEvents.ts

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ export const useAxisEvents = (disableAxisListener: boolean) => {
102102

103103
const handleMove = (event: MouseEvent | TouchEvent) => {
104104
const target = 'targetTouches' in event ? event.targetTouches[0] : event;
105-
const svgPoint = getSVGPoint(svgRef.current!, target);
105+
const svgPoint = getSVGPoint(element, target);
106106

107107
mousePosition.current = {
108108
x: svgPoint.x,
@@ -121,15 +121,28 @@ export const useAxisEvents = (disableAxisListener: boolean) => {
121121
dispatch({ type: 'updateAxis', data: { x: newStateX, y: newStateY } });
122122
};
123123

124-
element.addEventListener('mouseout', handleOut);
125-
element.addEventListener('mousemove', handleMove);
126-
element.addEventListener('touchend', handleOut);
127-
element.addEventListener('touchmove', handleMove);
124+
const handleDown = (event: PointerEvent) => {
125+
const target = event.currentTarget;
126+
if (!target) {
127+
return;
128+
}
129+
130+
if ((target as HTMLElement).hasPointerCapture(event.pointerId)) {
131+
(target as HTMLElement).releasePointerCapture(event.pointerId);
132+
}
133+
};
134+
135+
element.addEventListener('pointerdown', handleDown);
136+
element.addEventListener('pointermove', handleMove);
137+
element.addEventListener('pointerout', handleOut);
138+
element.addEventListener('pointercancel', handleOut);
139+
element.addEventListener('pointerleave', handleOut);
128140
return () => {
129-
element.removeEventListener('mouseout', handleOut);
130-
element.removeEventListener('mousemove', handleMove);
131-
element.removeEventListener('touchend', handleOut);
132-
element.removeEventListener('touchmove', handleMove);
141+
element.removeEventListener('pointerdown', handleDown);
142+
element.removeEventListener('pointermove', handleMove);
143+
element.removeEventListener('pointerout', handleOut);
144+
element.removeEventListener('pointercancel', handleOut);
145+
element.removeEventListener('pointerleave', handleOut);
133146
};
134147
}, [
135148
svgRef,

packages/x-charts/src/hooks/useInteractionItemProps.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,12 @@ export const useInteractionItemProps = (skip?: boolean) => {
1111
return () => ({});
1212
}
1313
const getInteractionItemProps = (data: SeriesItemIdentifier) => {
14-
const onMouseEnter = () => {
14+
const onPointerDown = (event: React.PointerEvent) => {
15+
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
16+
event.currentTarget.releasePointerCapture(event.pointerId);
17+
}
18+
};
19+
const onPointerEnter = () => {
1520
dispatchInteraction({
1621
type: 'enterItem',
1722
data,
@@ -21,13 +26,15 @@ export const useInteractionItemProps = (skip?: boolean) => {
2126
dataIndex: data.dataIndex,
2227
});
2328
};
24-
const onMouseLeave = () => {
29+
const onPointerLeave = (event: React.PointerEvent) => {
30+
event.currentTarget.releasePointerCapture(event.pointerId);
2531
dispatchInteraction({ type: 'leaveItem', data });
2632
clearHighlighted();
2733
};
2834
return {
29-
onMouseEnter,
30-
onMouseLeave,
35+
onPointerEnter,
36+
onPointerLeave,
37+
onPointerDown,
3138
};
3239
};
3340
return getInteractionItemProps;

0 commit comments

Comments
 (0)