From a90a6ccedc65770989c7c9d7c14025dd2b1f10e1 Mon Sep 17 00:00:00 2001 From: Will Bradley Date: Tue, 5 Mar 2024 22:05:38 +1300 Subject: [PATCH] feat(line): add support for touch events + crosshair (#2524) * Fix build + note Windows development * Add touch cursor support * Make touch crosshair optional + off by default * `touchCrosshair` > `enableTouchCrosshair` * Add missing hook dependencies * Make unions of MouseEvent | TouchEvent * Added website docs * Update help * Add tests * Add touch crosshair to storybook * Fix make command * Add support for touch crosshair on slice based graphs * Update docs * Tweak defaults * Added note about slices vs useMesh for line isInteractive * Fix missing import * Tweaks comments --- CONTRIBUTING.md | 6 +- Makefile | 2 +- package.json | 9 +- packages/core/index.d.ts | 5 +- packages/core/src/lib/interactivity/index.js | 9 +- packages/line/index.d.ts | 5 + packages/line/src/Line.js | 11 ++ packages/line/src/Mesh.js | 48 +++++- packages/line/src/Slices.js | 10 ++ packages/line/src/SlicesItem.js | 61 +++++++- packages/line/src/hooks.js | 10 +- packages/line/src/props.js | 2 + packages/line/tests/Line.test.js | 140 ++++++++++++++++-- packages/tooltip/src/context.ts | 8 +- packages/tooltip/src/hooks.ts | 17 ++- packages/voronoi/src/Mesh.tsx | 87 +++++++++-- pnpm-lock.yaml | 32 ++-- storybook/stories/line/Line.stories.tsx | 1 + website/src/data/components/line/defaults.ts | 1 + website/src/data/components/line/props.ts | 41 +++++ .../src/lib/chart-properties/interactivity.ts | 4 +- 21 files changed, 457 insertions(+), 52 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 762a78192..ee4ab4f04 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -28,7 +28,11 @@ the various packages, please execute the following: make init ``` -> please note that it will take a while as this project uses a lot of dependencies… +> please note that it will take a while as this project uses a lot of dependencies…' + +### Windows + +If you want to build this project on Windows, it is recommended to use either WSL 2, or Git bash + `choco install make`. ## Development diff --git a/Makefile b/Makefile index dc3b63752..cd1ed93c1 100644 --- a/Makefile +++ b/Makefile @@ -78,7 +78,7 @@ fmt-check: ##@0 global check if files were all formatted using prettier test: ##@0 global run all checks/tests (packages, website) @$(MAKE) fmt-check - @$(MAKE) lint + @$(MAKE) pkgs-lint @$(MAKE) pkgs-test vercel-build: ##@0 global Build the website and storybook to vercel diff --git a/package.json b/package.json index 0525039db..534f63ea5 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,9 @@ "keywords": [], "devDependencies": { "@babel/core": "^7.21.5", + "@babel/preset-env": "^7.21.5", + "@babel/preset-react": "^7.18.6", + "@babel/preset-typescript": "^7.21.5", "@ekino/config": "^0.3.0", "@rollup/plugin-babel": "^6.0.3", "@rollup/plugin-node-resolve": "^15.0.2", @@ -27,6 +30,7 @@ "@types/lodash": "^4.14.170", "@types/react": "^18.2.0", "@types/react-dom": "^18.2.0", + "@types/react-test-renderer": "^18.0.0", "@typescript-eslint/eslint-plugin": "^5.59.1", "@typescript-eslint/parser": "^5.59.1", "@wojtekmaj/enzyme-adapter-react-17": "0.6.6", @@ -35,6 +39,7 @@ "babel-loader": "^8.2.3", "chalk": "^5.2.0", "chalk-template": "^1.0.0", + "cypress": "^12.11.0", "enzyme": "^3.11.0", "eslint": "^8.39.0", "eslint-config-prettier": "^8.8.0", @@ -57,7 +62,6 @@ "react": "^18.0.2", "react-dom": "^18.0.2", "react-test-renderer": "^18.2.0", - "@types/react-test-renderer": "^18.0.0", "resize-observer-polyfill": "^1.5.1", "rollup": "^3.21.0", "rollup-plugin-cleanup": "^3.2.1", @@ -65,8 +69,7 @@ "rollup-plugin-strip-banner": "^3.0.0", "rollup-plugin-visualizer": "^5.5.2", "serve": "^13.0.2", - "typescript": "^4.9.5", - "cypress": "^12.11.0" + "typescript": "^4.9.5" }, "resolutions": { "@types/react": "^18.2.0", diff --git a/packages/core/index.d.ts b/packages/core/index.d.ts index addeb3d63..fe171d460 100644 --- a/packages/core/index.d.ts +++ b/packages/core/index.d.ts @@ -516,7 +516,10 @@ export function usePropertyAccessor( accessor: PropertyAccessor ): (datum: Datum) => Value -export function getRelativeCursor(element: Element, event: React.MouseEvent): [number, number] +export function getRelativeCursor( + element: Element, + event: React.MouseEvent | React.TouchEvent +): [number, number] export function isCursorInRect( x: number, y: number, diff --git a/packages/core/src/lib/interactivity/index.js b/packages/core/src/lib/interactivity/index.js index 68e2a868e..6e585aa27 100644 --- a/packages/core/src/lib/interactivity/index.js +++ b/packages/core/src/lib/interactivity/index.js @@ -22,7 +22,8 @@ export * from './detect' * give us the scaling factor to calculate the proper mouse position. */ export const getRelativeCursor = (el, event) => { - const { clientX, clientY } = event + const { clientX, clientY } = 'touches' in event ? event.touches[0] : event + // Get the dimensions of the element, in case it has // been scaled using a transform for example, we get // the scaled dimensions, not the original ones. @@ -36,8 +37,10 @@ export const getRelativeCursor = (el, event) => { } else { // Other elements. originalBox = { - width: el.offsetWidth, - height: el.offsetHeight, + // These should be here, except when we are running in jsdom. + // https://github.com/jsdom/jsdom/issues/135 + width: el.offsetWidth || 0, + height: el.offsetHeight || 0, } } diff --git a/packages/line/index.d.ts b/packages/line/index.d.ts index 864ee191b..c7eeec053 100644 --- a/packages/line/index.d.ts +++ b/packages/line/index.d.ts @@ -99,6 +99,7 @@ export interface Point { export type AccessorFunc = (datum: Point['data']) => string export type PointMouseHandler = (point: Point, event: React.MouseEvent) => void +export type PointTouchHandler = (point: Point, event: React.TouchEvent) => void export interface PointTooltipProps { point: Point @@ -185,6 +186,9 @@ export interface LineProps { onMouseMove?: PointMouseHandler onMouseLeave?: PointMouseHandler onClick?: PointMouseHandler + onTouchStart?: PointTouchHandler + onTouchMove?: PointTouchHandler + onTouchEnd?: PointTouchHandler debugMesh?: boolean @@ -197,6 +201,7 @@ export interface LineProps { enableCrosshair?: boolean crosshairType?: CrosshairType + enableTouchCrosshair?: boolean legends?: LegendProps[] } diff --git a/packages/line/src/Line.js b/packages/line/src/Line.js index 48b6b7e68..36424bd13 100644 --- a/packages/line/src/Line.js +++ b/packages/line/src/Line.js @@ -101,6 +101,9 @@ const Line = props => { onMouseMove, onMouseLeave, onClick, + onTouchStart, + onTouchMove, + onTouchEnd, tooltip = PointTooltip, @@ -110,6 +113,7 @@ const Line = props => { enableCrosshair = true, crosshairType = 'bottom-left', + enableTouchCrosshair = false, role = 'img', } = props @@ -241,6 +245,9 @@ const Line = props => { onMouseMove={onMouseMove} onMouseLeave={onMouseLeave} onClick={onClick} + onTouchStart={onTouchStart} + onTouchMove={onTouchMove} + onTouchEnd={onTouchEnd} /> ) } @@ -303,7 +310,11 @@ const Line = props => { onMouseMove={onMouseMove} onMouseLeave={onMouseLeave} onClick={onClick} + onTouchStart={onTouchStart} + onTouchMove={onTouchMove} + onTouchEnd={onTouchEnd} tooltip={tooltip} + enableTouchCrosshair={enableTouchCrosshair} debug={debugMesh} /> ) diff --git a/packages/line/src/Mesh.js b/packages/line/src/Mesh.js index b04b5e38a..67fd1f21f 100644 --- a/packages/line/src/Mesh.js +++ b/packages/line/src/Mesh.js @@ -21,8 +21,12 @@ const Mesh = ({ onMouseMove, onMouseLeave, onClick, + onTouchStart, + onTouchMove, + onTouchEnd, tooltip, debug, + enableTouchCrosshair, }) => { const { showTooltipAt, hideTooltip } = useTooltip() @@ -49,7 +53,7 @@ const Mesh = ({ setCurrent(point) onMouseMove && onMouseMove(point, event) }, - [setCurrent, showTooltipAt, tooltip, onMouseMove] + [showTooltipAt, tooltip, margin.left, margin.top, setCurrent, onMouseMove] ) const handleMouseLeave = useCallback( @@ -68,6 +72,41 @@ const Mesh = ({ [onClick] ) + const handleTouchStart = useCallback( + (point, event) => { + showTooltipAt( + createElement(tooltip, { point }), + [point.x + margin.left, point.y + margin.top], + 'top' + ) + setCurrent(point) + onTouchStart && onTouchStart(point, event) + }, + [margin.left, margin.top, onTouchStart, setCurrent, showTooltipAt, tooltip] + ) + + const handleTouchMove = useCallback( + (point, event) => { + showTooltipAt( + createElement(tooltip, { point }), + [point.x + margin.left, point.y + margin.top], + 'top' + ) + setCurrent(point) + onTouchMove && onTouchMove(point, event) + }, + [margin.left, margin.top, onTouchMove, setCurrent, showTooltipAt, tooltip] + ) + + const handleTouchEnd = useCallback( + (point, event) => { + hideTooltip() + setCurrent(null) + onTouchEnd && onTouchEnd(point, event) + }, + [onTouchEnd, hideTooltip, setCurrent] + ) + return ( ) @@ -92,6 +135,9 @@ Mesh.propTypes = { onMouseMove: PropTypes.func, onMouseLeave: PropTypes.func, onClick: PropTypes.func, + onTouchStart: PropTypes.func, + onTouchMove: PropTypes.func, + onTouchEnd: PropTypes.func, tooltip: PropTypes.oneOfType([PropTypes.func, PropTypes.object]).isRequired, debug: PropTypes.bool.isRequired, } diff --git a/packages/line/src/Slices.js b/packages/line/src/Slices.js index a2afd140f..33a4bb3ea 100644 --- a/packages/line/src/Slices.js +++ b/packages/line/src/Slices.js @@ -22,11 +22,15 @@ const Slices = ({ onMouseMove, onMouseLeave, onClick, + onTouchStart, + onTouchMove, + onTouchEnd, }) => { return slices.map(slice => ( )) } @@ -64,6 +71,9 @@ Slices.propTypes = { onMouseMove: PropTypes.func, onMouseLeave: PropTypes.func, onClick: PropTypes.func, + onTouchStart: PropTypes.func, + onTouchMove: PropTypes.func, + onTouchEnd: PropTypes.func, } export default memo(Slices) diff --git a/packages/line/src/SlicesItem.js b/packages/line/src/SlicesItem.js index d102e9bbf..bf6a2d044 100644 --- a/packages/line/src/SlicesItem.js +++ b/packages/line/src/SlicesItem.js @@ -12,6 +12,7 @@ import { useTooltip } from '@nivo/tooltip' const SlicesItem = ({ slice, + slices, axis, debug, tooltip, @@ -21,6 +22,9 @@ const SlicesItem = ({ onMouseMove, onMouseLeave, onClick, + onTouchStart, + onTouchMove, + onTouchEnd, }) => { const { showTooltipFromEvent, hideTooltip } = useTooltip() @@ -30,7 +34,7 @@ const SlicesItem = ({ setCurrent(slice) onMouseEnter && onMouseEnter(slice, event) }, - [showTooltipFromEvent, tooltip, slice, onMouseEnter] + [showTooltipFromEvent, tooltip, slice, axis, setCurrent, onMouseEnter] ) const handleMouseMove = useCallback( @@ -38,7 +42,7 @@ const SlicesItem = ({ showTooltipFromEvent(createElement(tooltip, { slice, axis }), event, 'right') onMouseMove && onMouseMove(slice, event) }, - [showTooltipFromEvent, tooltip, slice, onMouseMove] + [showTooltipFromEvent, tooltip, slice, axis, onMouseMove] ) const handleMouseLeave = useCallback( @@ -47,7 +51,7 @@ const SlicesItem = ({ setCurrent(null) onMouseLeave && onMouseLeave(slice, event) }, - [hideTooltip, slice, onMouseLeave] + [hideTooltip, setCurrent, onMouseLeave, slice] ) const handleClick = useCallback( @@ -57,6 +61,51 @@ const SlicesItem = ({ [slice, onClick] ) + const handeOnTouchStart = useCallback( + event => { + showTooltipFromEvent(createElement(tooltip, { slice, axis }), event, 'right') + setCurrent(slice) + onTouchStart && onTouchStart(slice, event) + }, + [axis, onTouchStart, setCurrent, showTooltipFromEvent, slice, tooltip] + ) + + const handeOnTouchMove = useCallback( + event => { + // This event will be locked to the element that was touched originally + // We find the element that is currently being "hovered over" by getting the element at the touch point + const touchPoint = event.touches[0] + const touchingElement = document.elementFromPoint( + touchPoint.clientX, + touchPoint.clientY + ) + // Is this a nivo ref? + const touchingSliceId = touchingElement?.getAttribute('data-ref') + if (touchingSliceId) { + // Is this a slice for this graph? + const slice = slices.find(slice => slice.id === touchingSliceId) + if (slice) { + showTooltipFromEvent(createElement(tooltip, { slice, axis }), event, 'right') + setCurrent(slice) + } + } + + // Note here, this will pass the original slice, not the one we found + // But this can be found with document.elementFromPoint() + onTouchMove && onTouchMove(slice, event) + }, + [axis, onTouchMove, setCurrent, showTooltipFromEvent, slice, slices, tooltip] + ) + + const handleOnTouchEnd = useCallback( + event => { + hideTooltip() + setCurrent(null) + onTouchEnd && onTouchEnd(slice, event) + }, + [hideTooltip, setCurrent, onTouchEnd, slice] + ) + return ( ) } SlicesItem.propTypes = { slice: PropTypes.object.isRequired, + slices: PropTypes.arrayOf(PropTypes.object).isRequired, axis: PropTypes.oneOf(['x', 'y']).isRequired, debug: PropTypes.bool.isRequired, height: PropTypes.number.isRequired, diff --git a/packages/line/src/hooks.js b/packages/line/src/hooks.js index 93e6d0fa3..22280bebe 100644 --- a/packages/line/src/hooks.js +++ b/packages/line/src/hooks.js @@ -6,7 +6,7 @@ * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ -import { useCallback, useMemo, useState } from 'react' +import { useCallback, useMemo, useState, useId } from 'react' import { area, line } from 'd3-shape' import { curveFromProp, useTheme, useValueFormatter } from '@nivo/core' import { useOrdinalColorScale, useInheritedColor } from '@nivo/colors' @@ -67,7 +67,7 @@ const usePoints = ({ series, getPointColor, getPointBorderColor, formatX, format }, [series, getPointColor, getPointBorderColor, formatX, formatY]) } -export const useSlices = ({ enableSlices, points, width, height }) => { +export const useSlices = ({ componentId, enableSlices, points, width, height }) => { return useMemo(() => { if (enableSlices === false) return [] @@ -93,7 +93,7 @@ export const useSlices = ({ enableSlices, points, width, height }) => { else sliceWidth = x - x0 + (nextSlice[0] - x) / 2 return { - id: x, + id: `slice${componentId}${x}`, x0, x, y0: 0, @@ -136,7 +136,7 @@ export const useSlices = ({ enableSlices, points, width, height }) => { } }) } - }, [enableSlices, points]) + }, [componentId, enableSlices, height, points, width]) } export const useLine = ({ @@ -154,6 +154,7 @@ export const useLine = ({ pointBorderColor = LineDefaultProps.pointBorderColor, enableSlices = LineDefaultProps.enableSlicesTooltip, }) => { + const componentId = useId() const formatX = useValueFormatter(xFormat) const formatY = useValueFormatter(yFormat) const getColor = useOrdinalColorScale(colors, 'id') @@ -212,6 +213,7 @@ export const useLine = ({ }) const slices = useSlices({ + componentId, enableSlices, points, width, diff --git a/packages/line/src/props.js b/packages/line/src/props.js index b2eeaa9d7..e9215e813 100644 --- a/packages/line/src/props.js +++ b/packages/line/src/props.js @@ -133,6 +133,7 @@ export const LinePropTypes = { enablePointLabel: PropTypes.bool.isRequired, role: PropTypes.string.isRequired, useMesh: PropTypes.bool.isRequired, + enableTouchCrosshair: PropTypes.bool, ...motionPropTypes, ...defsPropTypes, } @@ -202,6 +203,7 @@ export const LineDefaultProps = { ...commonDefaultProps, enablePointLabel: false, useMesh: false, + enableTouchCrosshair: false, animate: true, motionConfig: 'gentle', defs: [], diff --git a/packages/line/tests/Line.test.js b/packages/line/tests/Line.test.js index f32e58e6d..4e729aaa1 100644 --- a/packages/line/tests/Line.test.js +++ b/packages/line/tests/Line.test.js @@ -4,6 +4,18 @@ import Line from '../src/Line' import SlicesItem from '../src/SlicesItem' import renderer from 'react-test-renderer' +// Handle useId mocks +let id = 0 +beforeEach(() => { + id = 0 +}) +const generateId = () => ++id + +jest.mock('react', () => ({ + ...jest.requireActual('react'), + useId: () => `:r${generateId()}:`, +})) + it('should render a basic line chart', () => { const data = [ { @@ -71,11 +83,11 @@ it('should create slice for each x value', () => { const slices = wrapper.find(SlicesItem) expect(slices).toHaveLength(5) - expect(slices.at(0).prop('slice').id).toBe(0) - expect(slices.at(1).prop('slice').id).toBe(125) - expect(slices.at(2).prop('slice').id).toBe(250) - expect(slices.at(3).prop('slice').id).toBe(375) - expect(slices.at(4).prop('slice').id).toBe(500) + expect(slices.at(0).prop('slice').x).toBe(0) + expect(slices.at(1).prop('slice').x).toBe(125) + expect(slices.at(2).prop('slice').x).toBe(250) + expect(slices.at(3).prop('slice').x).toBe(375) + expect(slices.at(4).prop('slice').x).toBe(500) }) it('should have left and bottom axis by default', () => { @@ -173,28 +185,138 @@ describe('mouse events on slices', () => { it('should call onMouseEnter', () => { const onMouseEnter = jest.fn() const wrapper = mount() - wrapper.find(`[data-testid='slice-0']`).simulate('mouseenter') + wrapper.find(`[data-ref='slice:r1:0']`).simulate('mouseenter', { + clientX: 100, + clientY: 100, + }) expect(onMouseEnter).toHaveBeenCalledTimes(1) }) it('should call onMouseMove', () => { const onMouseMove = jest.fn() const wrapper = mount() - wrapper.find(`[data-testid='slice-0']`).simulate('mousemove') + wrapper.find(`[data-ref='slice:r1:0']`).simulate('mousemove', { + clientX: 100, + clientY: 100, + }) expect(onMouseMove).toHaveBeenCalledTimes(1) }) it('should call onMouseLeave', () => { const onMouseLeave = jest.fn() const wrapper = mount() - wrapper.find(`[data-testid='slice-0']`).simulate('mouseleave') + wrapper.find(`[data-ref='slice:r1:0']`).simulate('mouseleave') expect(onMouseLeave).toHaveBeenCalledTimes(1) }) it('should call onClick', () => { const onClick = jest.fn() const wrapper = mount() - wrapper.find(`[data-testid='slice-0']`).simulate('click') + wrapper.find(`[data-ref='slice:r1:0']`).simulate('click') expect(onClick).toHaveBeenCalledTimes(1) }) }) + +describe('touch events with useMesh', () => { + const data = [ + { + id: 'A', + data: [ + { x: 0, y: 3 }, + { x: 1, y: 7 }, + { x: 2, y: 11 }, + { x: 3, y: 9 }, + { x: 4, y: 8 }, + ], + }, + ] + const baseProps = { + width: 500, + height: 300, + data: data, + animate: false, + useMesh: true, + enableTouchCrosshair: true, + } + + it('should call onTouchStart', () => { + const onTouchStart = jest.fn() + const wrapper = mount() + wrapper.find(`[data-ref='mesh-interceptor']`).simulate('touchstart', { + touches: [{ clientX: 50, clientY: 50 }], + }) + expect(onTouchStart).toHaveBeenCalledTimes(1) + }) + + it('should call onTouchMove', () => { + const onTouchMove = jest.fn() + const wrapper = mount() + wrapper.find(`[data-ref='mesh-interceptor']`).simulate('touchmove', { + touches: [{ clientX: 50, clientY: 50 }], + }) + expect(onTouchMove).toHaveBeenCalledTimes(1) + }) + + it('should call onTouchEnd', () => { + const onTouchEnd = jest.fn() + const wrapper = mount() + wrapper + .find(`[data-ref='mesh-interceptor']`) + .simulate('touchstart', { + touches: [{ clientX: 50, clientY: 50 }], + }) + .simulate('touchend') + expect(onTouchEnd).toHaveBeenCalledTimes(1) + }) +}) + +describe('touch events with slices', () => { + const data = [ + { + id: 'A', + data: [ + { x: 0, y: 3 }, + { x: 1, y: 7 }, + { x: 2, y: 11 }, + { x: 3, y: 9 }, + { x: 4, y: 8 }, + ], + }, + ] + const baseProps = { + width: 500, + height: 300, + data: data, + animate: false, + enableSlices: 'x', + } + + it('should call onTouchStart', () => { + const onTouchStart = jest.fn() + const wrapper = mount() + wrapper.find(`[data-ref='slice:r1:0']`).simulate('touchstart') + expect(onTouchStart).toHaveBeenCalledTimes(1) + }) + + it('should call onTouchMove', () => { + const onTouchMove = jest.fn() + // Enzyme doesn't support this, so we mock it + document.elementFromPoint = jest.fn(() => { + const rect = document.createElement('rect') + rect.setAttribute('data-ref', 'slice:r1:1') + return rect + }) + const wrapper = mount() + wrapper.find(`[data-ref='slice:r1:0']`).simulate('touchmove', { + touches: [{ clientX: 50, clientY: 50 }], + }) + expect(onTouchMove).toHaveBeenCalledTimes(1) + }) + + it('should call onTouchEnd', () => { + const onTouchEnd = jest.fn() + const wrapper = mount() + wrapper.find(`[data-ref='slice:r1:0']`).simulate('touchend') + expect(onTouchEnd).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/tooltip/src/context.ts b/packages/tooltip/src/context.ts index 75a346019..21c4aff61 100644 --- a/packages/tooltip/src/context.ts +++ b/packages/tooltip/src/context.ts @@ -1,4 +1,4 @@ -import { createContext, MouseEvent } from 'react' +import { createContext, MouseEvent, TouchEvent } from 'react' import { TooltipAnchor } from './types' export interface TooltipActionsContextData { @@ -7,7 +7,11 @@ export interface TooltipActionsContextData { position: [number, number], anchor?: TooltipAnchor ) => void - showTooltipFromEvent: (content: JSX.Element, event: MouseEvent, anchor?: TooltipAnchor) => void + showTooltipFromEvent: ( + content: JSX.Element, + event: MouseEvent | TouchEvent, + anchor?: TooltipAnchor + ) => void hideTooltip: () => void } diff --git a/packages/tooltip/src/hooks.ts b/packages/tooltip/src/hooks.ts index 07015942b..6c16c7066 100644 --- a/packages/tooltip/src/hooks.ts +++ b/packages/tooltip/src/hooks.ts @@ -1,4 +1,12 @@ -import { useState, useContext, useCallback, MutableRefObject, MouseEvent, useMemo } from 'react' +import { + useState, + useContext, + useCallback, + MutableRefObject, + MouseEvent, + TouchEvent, + useMemo, +} from 'react' import { TooltipActionsContext, TooltipActionsContextData, @@ -24,7 +32,7 @@ export const useTooltipHandlers = (container: MutableRefObject) ) const showTooltipFromEvent: TooltipActionsContextData['showTooltipFromEvent'] = useCallback( - (content: JSX.Element, event: MouseEvent, anchor: TooltipAnchor = 'top') => { + (content: JSX.Element, event: MouseEvent | TouchEvent, anchor: TooltipAnchor = 'top') => { const bounds = container.current.getBoundingClientRect() const offsetWidth = container.current.offsetWidth // In a normal situation mouse enter / mouse leave events @@ -35,8 +43,9 @@ export const useTooltipHandlers = (container: MutableRefObject) // width give us the scaling factor to calculate // ok mouse position const scaling = offsetWidth === bounds.width ? 1 : offsetWidth / bounds.width - const x = (event.clientX - bounds.left) * scaling - const y = (event.clientY - bounds.top) * scaling + const { clientX, clientY } = 'touches' in event ? event.touches[0] : event + const x = (clientX - bounds.left) * scaling + const y = (clientY - bounds.top) * scaling if (anchor === 'left' || anchor === 'right') { if (x < bounds.width / 2) anchor = 'right' diff --git a/packages/voronoi/src/Mesh.tsx b/packages/voronoi/src/Mesh.tsx index 5e049984f..9ff8f431a 100644 --- a/packages/voronoi/src/Mesh.tsx +++ b/packages/voronoi/src/Mesh.tsx @@ -1,9 +1,10 @@ -import { useRef, useState, useCallback, useMemo, MouseEvent } from 'react' +import { useRef, useState, useCallback, useMemo, MouseEvent, TouchEvent } from 'react' import { getRelativeCursor } from '@nivo/core' import { useVoronoiMesh } from './hooks' import { XYAccessor } from './computeMesh' type MouseHandler = (datum: Datum, event: MouseEvent) => void +type TouchHandler = (datum: Datum, event: TouchEvent) => void interface MeshProps { nodes: Datum[] @@ -15,6 +16,10 @@ interface MeshProps { onMouseMove?: MouseHandler onMouseLeave?: MouseHandler onClick?: MouseHandler + onTouchStart?: TouchHandler + onTouchMove?: TouchHandler + onTouchEnd?: TouchHandler + enableTouchCrosshair?: boolean debug?: boolean } @@ -28,6 +33,10 @@ export const Mesh = ({ onMouseMove, onMouseLeave, onClick, + onTouchStart, + onTouchMove, + onTouchEnd, + enableTouchCrosshair = false, debug, }: MeshProps) => { const elementRef = useRef(null) @@ -50,7 +59,7 @@ export const Mesh = ({ return undefined }, [debug, voronoi]) - const getIndexAndNodeFromEvent = useCallback( + const getIndexAndNodeFromMouseEvent = useCallback( (event: MouseEvent) => { if (!elementRef.current) { return [null, null] @@ -61,29 +70,43 @@ export const Mesh = ({ return [index, index !== undefined ? nodes[index] : null] as [number, Datum | null] }, - [elementRef, delaunay] + [delaunay, nodes] + ) + + const getIndexAndNodeFromTouchEvent = useCallback( + (event: TouchEvent) => { + if (!elementRef.current) { + return [null, null] + } + + const [x, y] = getRelativeCursor(elementRef.current, event) + const index = delaunay.find(x, y) + + return [index, index !== undefined ? nodes[index] : null] as [number, Datum | null] + }, + [delaunay, nodes] ) const handleMouseEnter = useCallback( (event: MouseEvent) => { - const [index, node] = getIndexAndNodeFromEvent(event) + const [index, node] = getIndexAndNodeFromMouseEvent(event) setCurrentIndex(index) if (node) { onMouseEnter?.(node, event) } }, - [getIndexAndNodeFromEvent, setCurrentIndex, onMouseEnter] + [getIndexAndNodeFromMouseEvent, setCurrentIndex, onMouseEnter] ) const handleMouseMove = useCallback( (event: MouseEvent) => { - const [index, node] = getIndexAndNodeFromEvent(event) + const [index, node] = getIndexAndNodeFromMouseEvent(event) setCurrentIndex(index) if (node) { onMouseMove?.(node, event) } }, - [getIndexAndNodeFromEvent, setCurrentIndex, onMouseMove] + [getIndexAndNodeFromMouseEvent, setCurrentIndex, onMouseMove] ) const handleMouseLeave = useCallback( @@ -102,13 +125,55 @@ export const Mesh = ({ const handleClick = useCallback( (event: MouseEvent) => { - const [index, node] = getIndexAndNodeFromEvent(event) + const [index, node] = getIndexAndNodeFromMouseEvent(event) setCurrentIndex(index) if (node) { onClick?.(node, event) } }, - [getIndexAndNodeFromEvent, setCurrentIndex, onClick] + [getIndexAndNodeFromMouseEvent, setCurrentIndex, onClick] + ) + + const handleTouchStart = useCallback( + (event: TouchEvent) => { + const [index, node] = getIndexAndNodeFromTouchEvent(event) + if (enableTouchCrosshair) { + setCurrentIndex(index) + } + if (node) { + onTouchStart?.(node, event) + } + }, + [getIndexAndNodeFromTouchEvent, enableTouchCrosshair, onTouchStart] + ) + + const handleTouchMove = useCallback( + (event: TouchEvent) => { + const [index, node] = getIndexAndNodeFromTouchEvent(event) + if (enableTouchCrosshair) { + setCurrentIndex(index) + } + if (node) { + onTouchMove?.(node, event) + } + }, + [getIndexAndNodeFromTouchEvent, enableTouchCrosshair, onTouchMove] + ) + + const handleTouchEnd = useCallback( + (event: TouchEvent) => { + if (enableTouchCrosshair) { + setCurrentIndex(null) + } + if (onTouchEnd) { + let previousNode: Datum | undefined = undefined + if (currentIndex !== null) { + previousNode = nodes[currentIndex] + } + previousNode && onTouchEnd(previousNode, event) + } + }, + [enableTouchCrosshair, onTouchEnd, currentIndex, nodes] ) return ( @@ -124,6 +189,7 @@ export const Mesh = ({ )} {/* transparent rect to intercept mouse events */} ({ onMouseEnter={handleMouseEnter} onMouseMove={handleMouseMove} onMouseLeave={handleMouseLeave} + onTouchStart={handleTouchStart} + onTouchMove={handleTouchMove} + onTouchEnd={handleTouchEnd} onClick={handleClick} /> diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4c997c15d..62c70b610 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,6 +18,15 @@ importers: '@babel/core': specifier: ^7.21.5 version: 7.21.5 + '@babel/preset-env': + specifier: ^7.21.5 + version: 7.21.5(@babel/core@7.21.5) + '@babel/preset-react': + specifier: ^7.18.6 + version: 7.18.6(@babel/core@7.21.5) + '@babel/preset-typescript': + specifier: ^7.21.5 + version: 7.21.5(@babel/core@7.21.5) '@ekino/config': specifier: ^0.3.0 version: 0.3.0 @@ -2335,6 +2344,7 @@ packages: /@babel/plugin-proposal-class-static-block@7.21.0(@babel/core@7.21.5): resolution: {integrity: sha512-XP5G9MWNUskFuP30IfFSEFB0Z6HzLIUcjYM4bYOPHXl7eiJ9HFv8tWj6TXTN5QODiEhDZAeI4hLok2iHFFV4hw==} engines: {node: '>=6.9.0'} + deprecated: This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-class-static-block instead. peerDependencies: '@babel/core': ^7.12.0 dependencies: @@ -2362,6 +2372,7 @@ packages: /@babel/plugin-proposal-dynamic-import@7.18.6(@babel/core@7.21.5): resolution: {integrity: sha512-1auuwmK+Rz13SJj36R+jqFPMJWyKEDd7lLSdOj4oJK0UTgGueSAtkrCvz9ewmgyU/P941Rv2fQwZJN8s6QruXw==} engines: {node: '>=6.9.0'} + deprecated: This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-dynamic-import instead. peerDependencies: '@babel/core': ^7.0.0-0 dependencies: @@ -2382,6 +2393,7 @@ packages: /@babel/plugin-proposal-json-strings@7.18.6(@babel/core@7.21.5): resolution: {integrity: sha512-lr1peyn9kOdbYc0xr0OdHTZ5FMqS6Di+H0Fz2I/JwMzGmzJETNeOFq2pBySw6X/KFL5EWDjlJuMsUGRFb8fQgQ==} engines: {node: '>=6.9.0'} + deprecated: This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-json-strings instead. peerDependencies: '@babel/core': ^7.0.0-0 dependencies: @@ -2402,6 +2414,7 @@ packages: /@babel/plugin-proposal-nullish-coalescing-operator@7.18.6(@babel/core@7.21.5): resolution: {integrity: sha512-wQxQzxYeJqHcfppzBDnm1yAY0jSRkUXR2z8RePZYrKwMKgMlE8+Z6LUno+bd6LvbGh8Gltvy74+9pIYkr+XkKA==} engines: {node: '>=6.9.0'} + deprecated: This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-nullish-coalescing-operator instead. peerDependencies: '@babel/core': ^7.0.0-0 dependencies: @@ -2412,6 +2425,7 @@ packages: /@babel/plugin-proposal-numeric-separator@7.18.6(@babel/core@7.21.5): resolution: {integrity: sha512-ozlZFogPqoLm8WBr5Z8UckIoE4YQ5KESVcNudyXOR8uqIkliTEgJ3RoketfG6pmzLdeZF0H/wjE9/cCEitBl7Q==} engines: {node: '>=6.9.0'} + deprecated: This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-numeric-separator instead. peerDependencies: '@babel/core': ^7.0.0-0 dependencies: @@ -11429,7 +11443,6 @@ packages: dependencies: ms: 2.1.3 supports-color: 5.5.0 - dev: true /debug@3.2.7(supports-color@8.1.1): resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} @@ -11441,6 +11454,7 @@ packages: dependencies: ms: 2.1.3 supports-color: 8.1.1 + dev: true /debug@4.3.4(supports-color@5.5.0): resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==} @@ -11720,7 +11734,7 @@ packages: '@types/tmp': 0.0.33 application-config-path: 0.1.0 command-exists: 1.2.9 - debug: 3.2.7(supports-color@8.1.1) + debug: 3.2.7(supports-color@5.5.0) eol: 0.9.1 get-port: 3.2.0 glob: 7.2.3 @@ -12479,7 +12493,7 @@ packages: /eslint-import-resolver-node@0.3.6: resolution: {integrity: sha512-0En0w03NRVMn9Uiyn8YRPDKvWjxCWkslUEhGNTdGx15RvPJYQ+lbOlqrlNI2vEAs4pDYK4f/HN2TbDmk5TP0iw==} dependencies: - debug: 3.2.7(supports-color@8.1.1) + debug: 3.2.7(supports-color@5.5.0) resolve: 1.22.2 transitivePeerDependencies: - supports-color @@ -12488,7 +12502,7 @@ packages: /eslint-import-resolver-node@0.3.7: resolution: {integrity: sha512-gozW2blMLJCeFpBwugLTGyvVjNoeo1knonXAcatC6bjPBZitotxdWf7Gimr25N4c0AAOo4eOUfaG82IJPDpqCA==} dependencies: - debug: 3.2.7(supports-color@8.1.1) + debug: 3.2.7(supports-color@5.5.0) is-core-module: 2.12.0 resolve: 1.22.2 transitivePeerDependencies: @@ -12514,7 +12528,7 @@ packages: optional: true dependencies: '@typescript-eslint/parser': 5.59.1(eslint@8.39.0)(typescript@4.9.5) - debug: 3.2.7(supports-color@8.1.1) + debug: 3.2.7(supports-color@5.5.0) eslint-import-resolver-node: 0.3.6 find-up: 2.1.0 pkg-dir: 2.0.0 @@ -12544,7 +12558,7 @@ packages: optional: true dependencies: '@typescript-eslint/parser': 5.59.1(eslint@7.32.0)(typescript@4.9.5) - debug: 3.2.7(supports-color@8.1.1) + debug: 3.2.7(supports-color@5.5.0) eslint: 7.32.0 eslint-import-resolver-node: 0.3.7 transitivePeerDependencies: @@ -12622,7 +12636,7 @@ packages: array-includes: 3.1.6 array.prototype.flat: 1.3.1 array.prototype.flatmap: 1.3.1 - debug: 3.2.7(supports-color@8.1.1) + debug: 3.2.7(supports-color@5.5.0) doctrine: 2.1.0 eslint: 7.32.0 eslint-import-resolver-node: 0.3.7 @@ -13205,7 +13219,7 @@ packages: resolution: {integrity: sha512-/l77JHcOUrDUX8V67E287VEUQT0lbm71gdGVoodnlWBziarYKgMcpqT7xvh/HM8Jv52phw8Bd8tY+a7QjOr7Yg==} engines: {node: '>=6.0.0'} dependencies: - debug: 3.2.7(supports-color@8.1.1) + debug: 3.2.7(supports-color@5.5.0) es6-promise: 4.2.8 raw-body: 2.4.3 transitivePeerDependencies: @@ -18861,7 +18875,7 @@ packages: engines: {node: '>= 4.4.x'} hasBin: true dependencies: - debug: 3.2.7(supports-color@8.1.1) + debug: 3.2.7(supports-color@5.5.0) iconv-lite: 0.4.24 sax: 1.2.4 transitivePeerDependencies: diff --git a/storybook/stories/line/Line.stories.tsx b/storybook/stories/line/Line.stories.tsx index 7a392de97..41fbedefe 100644 --- a/storybook/stories/line/Line.stories.tsx +++ b/storybook/stories/line/Line.stories.tsx @@ -34,6 +34,7 @@ const commonProperties = { margin: { top: 20, right: 20, bottom: 60, left: 80 }, data, animate: true, + enableTouchCrosshair: true, enableSlices: 'x', } diff --git a/website/src/data/components/line/defaults.ts b/website/src/data/components/line/defaults.ts index 1160d1fe9..6a1594042 100644 --- a/website/src/data/components/line/defaults.ts +++ b/website/src/data/components/line/defaults.ts @@ -92,5 +92,6 @@ export default { debugSlices: false, enableCrosshair: true, + enableTouchCrosshair: true, crosshairType: 'bottom-left', } diff --git a/website/src/data/components/line/props.ts b/website/src/data/components/line/props.ts index 1cf545d81..1b4d8dc58 100644 --- a/website/src/data/components/line/props.ts +++ b/website/src/data/components/line/props.ts @@ -385,6 +385,11 @@ const props: ChartProperty[] = [ isInteractive({ flavors: ['svg', 'canvas'], defaultValue: defaults.isInteractive, + help: [ + 'Enable/disable interactivity.', + 'Using `enableSlices` will enable a crosshair on the `x` or `y` axis, that will move between the nearest slice to the mouse/touch point, and will show a tooltip of all data points for that slice.', + 'Using `useMesh` will use a voronoi mesh to detect the closest point to the mouse cursor/touch point, which is useful for very dense datasets, as it can become difficult to hover a specific point, however, it will only return one data point.', + ].join(' '), }), { key: 'useMesh', @@ -438,6 +443,33 @@ const props: ChartProperty[] = [ type: '(point, event) => void', required: false, }, + { + key: 'onTouchStart', + flavors: ['svg'], + group: 'Interactivity', + help: `onTouchStart handler, when a touch gesture is started inside the graph.`, + type: '(point, event) => void', + required: false, + }, + { + key: 'onTouchMove', + flavors: ['svg'], + group: 'Interactivity', + help: [ + 'onTouchMove handler, when a touch gesture that originated from inside the graph is moved.', + 'Note, when using slices, this will return the originally touched slice, not the slice currently being hovered over (use document.elementFromPoint()).', + ].join(' '), + type: '(point, event) => void', + required: false, + }, + { + key: 'onTouchEnd', + flavors: ['svg'], + group: 'Interactivity', + help: `onTouchEnd handler, when a touch gesture that originated from inside the graph ends.`, + type: '(point, event) => void', + required: false, + }, { key: 'tooltip', flavors: ['svg', 'canvas'], @@ -500,6 +532,15 @@ const props: ChartProperty[] = [ control: { type: 'switch' }, defaultValue: defaults.enableCrosshair, }, + { + key: 'enableTouchCrosshair', + flavors: ['svg'], + group: 'Interactivity', + help: `Enables the crosshair to be dragged around a touch screen.`, + type: 'boolean', + defaultValue: defaults.enableTouchCrosshair, + control: { type: 'switch' }, + }, { key: 'crosshairType', flavors: ['svg'], diff --git a/website/src/lib/chart-properties/interactivity.ts b/website/src/lib/chart-properties/interactivity.ts index 01e472fe7..40ccc2fa8 100644 --- a/website/src/lib/chart-properties/interactivity.ts +++ b/website/src/lib/chart-properties/interactivity.ts @@ -3,14 +3,16 @@ import { ChartProperty, Flavor } from '../../types' export const isInteractive = ({ flavors, defaultValue, + help, }: { flavors: Flavor[] defaultValue: boolean + help?: string }): ChartProperty => ({ key: 'isInteractive', group: 'Interactivity', type: 'boolean', - help: 'Enable/disable interactivity.', + help: help ?? 'Enable/disable interactivity.', required: false, defaultValue, flavors,