Skip to content

Commit

Permalink
[charts] Improve charts interaction for mobile users (mui#13692)
Browse files Browse the repository at this point in the history
Signed-off-by: Jose C Quintas Jr <juniorquintas@gmail.com>
Co-authored-by: Alexandre Fauquette <45398769+alexfauquette@users.noreply.github.com>
  • Loading branch information
2 people authored and DungTiger committed Jul 23, 2024
1 parent 34d6a7b commit 97887e0
Show file tree
Hide file tree
Showing 6 changed files with 86 additions and 44 deletions.
6 changes: 5 additions & 1 deletion packages/x-charts/src/ChartsSurface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,11 @@ export interface ChartsSurfaceProps {
const ChartChartsSurfaceStyles = styled('svg', {
name: 'MuiChartsSurface',
slot: 'Root',
})(() => ({}));
})(() => ({
// This prevents default touch actions when using the svg on mobile devices.
// For example, prevent page scroll & zoom.
touchAction: 'none',
}));

const ChartsSurface = React.forwardRef<SVGSVGElement, ChartsSurfaceProps>(function ChartsSurface(
props: ChartsSurfaceProps,
Expand Down
11 changes: 10 additions & 1 deletion packages/x-charts/src/ChartsTooltip/ChartsTooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -148,8 +148,17 @@ function ChartsTooltip<T extends ChartSeriesType>(props: ChartsTooltipProps<T>)
externalSlotProps: slotProps?.popper,
additionalProps: {
open: popperOpen,
placement: 'right-start' as const,
placement:
mousePosition?.pointerType === 'mouse' ? ('right-start' as const) : ('top' as const),
anchorEl: generateVirtualElement(mousePosition),
modifiers: [
{
name: 'offset',
options: {
offset: [0, mousePosition?.pointerType === 'touch' ? 40 - mousePosition.height : 0],
},
},
],
},
ownerState: {},
});
Expand Down
57 changes: 33 additions & 24 deletions packages/x-charts/src/ChartsTooltip/utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,14 @@ import { AxisInteractionData, ItemInteractionData } from '../context/Interaction
import { ChartSeriesType } from '../models/seriesType/config';
import { useSvgRef } from '../hooks';

export function generateVirtualElement(mousePosition: { x: number; y: number } | null) {
type MousePosition = {
x: number;
y: number;
pointerType: 'mouse' | 'touch' | 'pen';
height: number;
};

export function generateVirtualElement(mousePosition: MousePosition | null) {
if (mousePosition === null) {
return {
getBoundingClientRect: () => ({
Expand All @@ -20,18 +27,20 @@ export function generateVirtualElement(mousePosition: { x: number; y: number } |
};
}
const { x, y } = mousePosition;
const boundingBox = {
width: 0,
height: 0,
x,
y,
top: y,
right: x,
bottom: y,
left: x,
};
return {
getBoundingClientRect: () => ({
width: 0,
height: 0,
x,
y,
top: y,
right: x,
bottom: y,
left: x,
toJSON: () =>
JSON.stringify({ width: 0, height: 0, x, y, top: y, right: x, bottom: y, left: x }),
...boundingBox,
toJSON: () => JSON.stringify(boundingBox),
}),
};
}
Expand All @@ -40,7 +49,7 @@ export function useMouseTracker() {
const svgRef = useSvgRef();

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

React.useEffect(() => {
const element = svgRef.current;
Expand All @@ -52,23 +61,23 @@ export function useMouseTracker() {
setMousePosition(null);
};

const handleMove = (event: MouseEvent | TouchEvent) => {
const target = 'targetTouches' in event ? event.targetTouches[0] : event;
const handleMove = (event: PointerEvent) => {
setMousePosition({
x: target.clientX,
y: target.clientY,
x: event.clientX,
y: event.clientY,
height: event.height,
pointerType: event.pointerType as MousePosition['pointerType'],
});
};

element.addEventListener('mouseout', handleOut);
element.addEventListener('mousemove', handleMove);
element.addEventListener('touchend', handleOut);
element.addEventListener('touchmove', handleMove);
element.addEventListener('pointerdown', handleMove);
element.addEventListener('pointermove', handleMove);
element.addEventListener('pointerup', handleOut);

return () => {
element.removeEventListener('mouseout', handleOut);
element.removeEventListener('mousemove', handleMove);
element.addEventListener('touchend', handleOut);
element.addEventListener('touchmove', handleMove);
element.removeEventListener('pointerdown', handleMove);
element.removeEventListener('pointermove', handleMove);
element.removeEventListener('pointerup', handleOut);
};
}, [svgRef]);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ function ChartsVoronoiHandler(props: ChartsVoronoiHandlerProps) {
| 'outside-voronoi-max-radius'
| 'no-point-found' {
// Get mouse coordinate in global SVG space
const svgPoint = getSVGPoint(svgRef.current!, event);
const svgPoint = getSVGPoint(element, event);

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

element.addEventListener('mouseout', handleMouseOut);
element.addEventListener('mousemove', handleMouseMove);
element.addEventListener('pointerout', handleMouseOut);
element.addEventListener('pointermove', handleMouseMove);
element.addEventListener('click', handleMouseClick);
return () => {
element.removeEventListener('mouseout', handleMouseOut);
element.removeEventListener('mousemove', handleMouseMove);
element.removeEventListener('pointerout', handleMouseOut);
element.removeEventListener('pointermove', handleMouseMove);
element.removeEventListener('click', handleMouseClick);
};
}, [
Expand Down
31 changes: 22 additions & 9 deletions packages/x-charts/src/hooks/useAxisEvents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ export const useAxisEvents = (disableAxisListener: boolean) => {

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

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

element.addEventListener('mouseout', handleOut);
element.addEventListener('mousemove', handleMove);
element.addEventListener('touchend', handleOut);
element.addEventListener('touchmove', handleMove);
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('mouseout', handleOut);
element.removeEventListener('mousemove', handleMove);
element.removeEventListener('touchend', handleOut);
element.removeEventListener('touchmove', handleMove);
element.removeEventListener('pointerdown', handleDown);
element.removeEventListener('pointermove', handleMove);
element.removeEventListener('pointerout', handleOut);
element.removeEventListener('pointercancel', handleOut);
element.removeEventListener('pointerleave', handleOut);
};
}, [
svgRef,
Expand Down
15 changes: 11 additions & 4 deletions packages/x-charts/src/hooks/useInteractionItemProps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,12 @@ export const useInteractionItemProps = (skip?: boolean) => {
return () => ({});
}
const getInteractionItemProps = (data: SeriesItemIdentifier) => {
const onMouseEnter = () => {
const onPointerDown = (event: React.PointerEvent) => {
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
};
const onPointerEnter = () => {
dispatchInteraction({
type: 'enterItem',
data,
Expand All @@ -21,13 +26,15 @@ export const useInteractionItemProps = (skip?: boolean) => {
dataIndex: data.dataIndex,
});
};
const onMouseLeave = () => {
const onPointerLeave = (event: React.PointerEvent) => {
event.currentTarget.releasePointerCapture(event.pointerId);
dispatchInteraction({ type: 'leaveItem', data });
clearHighlighted();
};
return {
onMouseEnter,
onMouseLeave,
onPointerEnter,
onPointerLeave,
onPointerDown,
};
};
return getInteractionItemProps;
Expand Down

0 comments on commit 97887e0

Please sign in to comment.