From d11de6cd102d9aa59c4577eb17251594d93b5663 Mon Sep 17 00:00:00 2001 From: Will Date: Sat, 2 Mar 2024 23:52:17 +1300 Subject: [PATCH 01/17] Fix build + note Windows development --- CONTRIBUTING.md | 6 +++++- package.json | 9 ++++++--- pnpm-lock.yaml | 32 +++++++++++++++++++++++--------- 3 files changed, 34 insertions(+), 13 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/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/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: From a9a197a3da7d1033de09fac83e2d7693c8b64ed0 Mon Sep 17 00:00:00 2001 From: Will Date: Sun, 3 Mar 2024 01:08:06 +1300 Subject: [PATCH 02/17] Add touch cursor support --- packages/core/index.d.ts | 5 ++- packages/line/index.d.ts | 4 ++ packages/line/src/Line.js | 9 ++++ packages/line/src/Mesh.js | 41 ++++++++++++++++++ packages/tooltip/src/context.ts | 6 ++- packages/tooltip/src/hooks.ts | 6 ++- packages/voronoi/src/Mesh.tsx | 76 +++++++++++++++++++++++++++++---- 7 files changed, 136 insertions(+), 11 deletions(-) diff --git a/packages/core/index.d.ts b/packages/core/index.d.ts index addeb3d63..7294de40c 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: Pick +): [number, number] export function isCursorInRect( x: number, y: number, diff --git a/packages/line/index.d.ts b/packages/line/index.d.ts index 864ee191b..936854656 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 diff --git a/packages/line/src/Line.js b/packages/line/src/Line.js index 48b6b7e68..99aad8737 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, @@ -241,6 +244,9 @@ const Line = props => { onMouseMove={onMouseMove} onMouseLeave={onMouseLeave} onClick={onClick} + onTouchStart={onTouchStart} + onTouchMove={onTouchMove} + onTouchEnd={onTouchEnd} /> ) } @@ -303,6 +309,9 @@ const Line = props => { onMouseMove={onMouseMove} onMouseLeave={onMouseLeave} onClick={onClick} + onTouchStart={onTouchStart} + onTouchMove={onTouchMove} + onTouchEnd={onTouchEnd} tooltip={tooltip} debug={debugMesh} /> diff --git a/packages/line/src/Mesh.js b/packages/line/src/Mesh.js index b04b5e38a..4b488d4d3 100644 --- a/packages/line/src/Mesh.js +++ b/packages/line/src/Mesh.js @@ -21,6 +21,9 @@ const Mesh = ({ onMouseMove, onMouseLeave, onClick, + onTouchStart, + onTouchMove, + onTouchEnd, tooltip, debug, }) => { @@ -68,6 +71,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) + }, + [onTouchStart] + ) + + const handleTouchMove = useCallback( + (point, event) => { + showTooltipAt( + createElement(tooltip, { point }), + [point.x + margin.left, point.y + margin.top], + 'top' + ) + setCurrent(point) + onTouchMove && onTouchMove(point, event) + }, + [onTouchMove] + ) + + const handleTouchEnd = useCallback( + (point, event) => { + hideTooltip() + setCurrent(null) + onTouchEnd && onTouchEnd(point, event) + }, + [onTouchEnd, hideTooltip, setCurrent] + ) + return ( ) diff --git a/packages/tooltip/src/context.ts b/packages/tooltip/src/context.ts index 75a346019..bff488b49 100644 --- a/packages/tooltip/src/context.ts +++ b/packages/tooltip/src/context.ts @@ -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: Pick, + anchor?: TooltipAnchor + ) => void hideTooltip: () => void } diff --git a/packages/tooltip/src/hooks.ts b/packages/tooltip/src/hooks.ts index 07015942b..8c98fcdb1 100644 --- a/packages/tooltip/src/hooks.ts +++ b/packages/tooltip/src/hooks.ts @@ -24,7 +24,11 @@ export const useTooltipHandlers = (container: MutableRefObject) ) const showTooltipFromEvent: TooltipActionsContextData['showTooltipFromEvent'] = useCallback( - (content: JSX.Element, event: MouseEvent, anchor: TooltipAnchor = 'top') => { + ( + content: JSX.Element, + event: Pick, + anchor: TooltipAnchor = 'top' + ) => { const bounds = container.current.getBoundingClientRect() const offsetWidth = container.current.offsetWidth // In a normal situation mouse enter / mouse leave events diff --git a/packages/voronoi/src/Mesh.tsx b/packages/voronoi/src/Mesh.tsx index 5e049984f..0aa826bc9 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,9 @@ interface MeshProps { onMouseMove?: MouseHandler onMouseLeave?: MouseHandler onClick?: MouseHandler + onTouchStart?: TouchHandler + onTouchMove?: TouchHandler + onTouchEnd?: TouchHandler debug?: boolean } @@ -28,6 +32,9 @@ export const Mesh = ({ onMouseMove, onMouseLeave, onClick, + onTouchStart, + onTouchMove, + onTouchEnd, debug, }: MeshProps) => { const elementRef = useRef(null) @@ -50,7 +57,7 @@ export const Mesh = ({ return undefined }, [debug, voronoi]) - const getIndexAndNodeFromEvent = useCallback( + const getIndexAndNodeFromMouseEvent = useCallback( (event: MouseEvent) => { if (!elementRef.current) { return [null, null] @@ -64,26 +71,40 @@ export const Mesh = ({ [elementRef, delaunay] ) + const getIndexAndNodeFromTouchEvent = useCallback( + (event: TouchEvent) => { + if (!elementRef.current) { + return [null, null] + } + + const [x, y] = getRelativeCursor(elementRef.current, event.touches[0]) + const index = delaunay.find(x, y) + + return [index, index !== undefined ? nodes[index] : null] as [number, Datum | null] + }, + [elementRef, delaunay] + ) + 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 +123,49 @@ 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) + setCurrentIndex(index) + if (node) { + onTouchStart?.(node, event) + } + }, + [getIndexAndNodeFromTouchEvent, setCurrentIndex, onTouchStart] + ) + + const handleTouchMove = useCallback( + (event: TouchEvent) => { + const [index, node] = getIndexAndNodeFromTouchEvent(event) + setCurrentIndex(index) + if (node) { + onTouchMove?.(node, event) + } + }, + [getIndexAndNodeFromTouchEvent, setCurrentIndex, onTouchMove] + ) + + const handleTouchEnd = useCallback( + (event: TouchEvent) => { + setCurrentIndex(null) + if (onTouchEnd) { + let previousNode: Datum | undefined = undefined + if (currentIndex !== null) { + previousNode = nodes[currentIndex] + } + previousNode && onTouchEnd(previousNode, event) + } + }, + [setCurrentIndex, currentIndex, onTouchEnd, nodes] ) return ( @@ -132,6 +189,9 @@ export const Mesh = ({ onMouseEnter={handleMouseEnter} onMouseMove={handleMouseMove} onMouseLeave={handleMouseLeave} + onTouchStart={handleTouchStart} + onTouchMove={handleTouchMove} + onTouchEnd={handleTouchEnd} onClick={handleClick} /> From aab0b4672f95434b8d816595e102930f91233a9a Mon Sep 17 00:00:00 2001 From: Will Date: Sun, 3 Mar 2024 01:18:29 +1300 Subject: [PATCH 03/17] Make touch crosshair optional + off by default --- packages/line/index.d.ts | 1 + packages/line/src/Line.js | 2 ++ packages/line/src/Mesh.js | 2 ++ packages/voronoi/src/Mesh.tsx | 14 +++++++++++--- 4 files changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/line/index.d.ts b/packages/line/index.d.ts index 936854656..e1beccb8b 100644 --- a/packages/line/index.d.ts +++ b/packages/line/index.d.ts @@ -201,6 +201,7 @@ export interface LineProps { enableCrosshair?: boolean crosshairType?: CrosshairType + touchCrosshair?: boolean legends?: LegendProps[] } diff --git a/packages/line/src/Line.js b/packages/line/src/Line.js index 99aad8737..212b013ac 100644 --- a/packages/line/src/Line.js +++ b/packages/line/src/Line.js @@ -113,6 +113,7 @@ const Line = props => { enableCrosshair = true, crosshairType = 'bottom-left', + touchCrosshair = false, role = 'img', } = props @@ -313,6 +314,7 @@ const Line = props => { onTouchMove={onTouchMove} onTouchEnd={onTouchEnd} tooltip={tooltip} + touchCrosshair={touchCrosshair} debug={debugMesh} /> ) diff --git a/packages/line/src/Mesh.js b/packages/line/src/Mesh.js index 4b488d4d3..07746ae2a 100644 --- a/packages/line/src/Mesh.js +++ b/packages/line/src/Mesh.js @@ -26,6 +26,7 @@ const Mesh = ({ onTouchEnd, tooltip, debug, + touchCrosshair, }) => { const { showTooltipAt, hideTooltip } = useTooltip() @@ -118,6 +119,7 @@ const Mesh = ({ onTouchStart={handleTouchStart} onTouchMove={handleTouchMove} onTouchEnd={handleTouchEnd} + touchCrosshair={touchCrosshair} debug={debug} /> ) diff --git a/packages/voronoi/src/Mesh.tsx b/packages/voronoi/src/Mesh.tsx index 0aa826bc9..2ef11dbca 100644 --- a/packages/voronoi/src/Mesh.tsx +++ b/packages/voronoi/src/Mesh.tsx @@ -19,6 +19,7 @@ interface MeshProps { onTouchStart?: TouchHandler onTouchMove?: TouchHandler onTouchEnd?: TouchHandler + touchCrosshair?: boolean debug?: boolean } @@ -35,6 +36,7 @@ export const Mesh = ({ onTouchStart, onTouchMove, onTouchEnd, + touchCrosshair = false, debug, }: MeshProps) => { const elementRef = useRef(null) @@ -135,7 +137,9 @@ export const Mesh = ({ const handleTouchStart = useCallback( (event: TouchEvent) => { const [index, node] = getIndexAndNodeFromTouchEvent(event) - setCurrentIndex(index) + if (touchCrosshair) { + setCurrentIndex(index) + } if (node) { onTouchStart?.(node, event) } @@ -146,7 +150,9 @@ export const Mesh = ({ const handleTouchMove = useCallback( (event: TouchEvent) => { const [index, node] = getIndexAndNodeFromTouchEvent(event) - setCurrentIndex(index) + if (touchCrosshair) { + setCurrentIndex(index) + } if (node) { onTouchMove?.(node, event) } @@ -156,7 +162,9 @@ export const Mesh = ({ const handleTouchEnd = useCallback( (event: TouchEvent) => { - setCurrentIndex(null) + if (touchCrosshair) { + setCurrentIndex(null) + } if (onTouchEnd) { let previousNode: Datum | undefined = undefined if (currentIndex !== null) { From 1dc993ea3d01447f203ff436118301081334d896 Mon Sep 17 00:00:00 2001 From: Will Date: Mon, 4 Mar 2024 20:41:05 +1300 Subject: [PATCH 04/17] `touchCrosshair` > `enableTouchCrosshair` --- packages/line/index.d.ts | 2 +- packages/line/src/Line.js | 4 ++-- packages/line/src/Mesh.js | 4 ++-- packages/voronoi/src/Mesh.tsx | 10 +++++----- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/line/index.d.ts b/packages/line/index.d.ts index e1beccb8b..c7eeec053 100644 --- a/packages/line/index.d.ts +++ b/packages/line/index.d.ts @@ -201,7 +201,7 @@ export interface LineProps { enableCrosshair?: boolean crosshairType?: CrosshairType - touchCrosshair?: boolean + enableTouchCrosshair?: boolean legends?: LegendProps[] } diff --git a/packages/line/src/Line.js b/packages/line/src/Line.js index 212b013ac..36424bd13 100644 --- a/packages/line/src/Line.js +++ b/packages/line/src/Line.js @@ -113,7 +113,7 @@ const Line = props => { enableCrosshair = true, crosshairType = 'bottom-left', - touchCrosshair = false, + enableTouchCrosshair = false, role = 'img', } = props @@ -314,7 +314,7 @@ const Line = props => { onTouchMove={onTouchMove} onTouchEnd={onTouchEnd} tooltip={tooltip} - touchCrosshair={touchCrosshair} + enableTouchCrosshair={enableTouchCrosshair} debug={debugMesh} /> ) diff --git a/packages/line/src/Mesh.js b/packages/line/src/Mesh.js index 07746ae2a..126fddfe5 100644 --- a/packages/line/src/Mesh.js +++ b/packages/line/src/Mesh.js @@ -26,7 +26,7 @@ const Mesh = ({ onTouchEnd, tooltip, debug, - touchCrosshair, + enableTouchCrosshair, }) => { const { showTooltipAt, hideTooltip } = useTooltip() @@ -119,7 +119,7 @@ const Mesh = ({ onTouchStart={handleTouchStart} onTouchMove={handleTouchMove} onTouchEnd={handleTouchEnd} - touchCrosshair={touchCrosshair} + enableTouchCrosshair={enableTouchCrosshair} debug={debug} /> ) diff --git a/packages/voronoi/src/Mesh.tsx b/packages/voronoi/src/Mesh.tsx index 2ef11dbca..aa3afc1d6 100644 --- a/packages/voronoi/src/Mesh.tsx +++ b/packages/voronoi/src/Mesh.tsx @@ -19,7 +19,7 @@ interface MeshProps { onTouchStart?: TouchHandler onTouchMove?: TouchHandler onTouchEnd?: TouchHandler - touchCrosshair?: boolean + enableTouchCrosshair?: boolean debug?: boolean } @@ -36,7 +36,7 @@ export const Mesh = ({ onTouchStart, onTouchMove, onTouchEnd, - touchCrosshair = false, + enableTouchCrosshair = false, debug, }: MeshProps) => { const elementRef = useRef(null) @@ -137,7 +137,7 @@ export const Mesh = ({ const handleTouchStart = useCallback( (event: TouchEvent) => { const [index, node] = getIndexAndNodeFromTouchEvent(event) - if (touchCrosshair) { + if (enableTouchCrosshair) { setCurrentIndex(index) } if (node) { @@ -150,7 +150,7 @@ export const Mesh = ({ const handleTouchMove = useCallback( (event: TouchEvent) => { const [index, node] = getIndexAndNodeFromTouchEvent(event) - if (touchCrosshair) { + if (enableTouchCrosshair) { setCurrentIndex(index) } if (node) { @@ -162,7 +162,7 @@ export const Mesh = ({ const handleTouchEnd = useCallback( (event: TouchEvent) => { - if (touchCrosshair) { + if (enableTouchCrosshair) { setCurrentIndex(null) } if (onTouchEnd) { From f2e5fbb6007e02bbf0a7ea0f4f363916fe7ee138 Mon Sep 17 00:00:00 2001 From: Will Date: Mon, 4 Mar 2024 20:52:29 +1300 Subject: [PATCH 05/17] Add missing hook dependencies --- packages/line/src/Mesh.js | 6 +++--- packages/voronoi/src/Mesh.tsx | 12 ++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/line/src/Mesh.js b/packages/line/src/Mesh.js index 126fddfe5..9e910ca17 100644 --- a/packages/line/src/Mesh.js +++ b/packages/line/src/Mesh.js @@ -53,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( @@ -82,7 +82,7 @@ const Mesh = ({ setCurrent(point) onTouchStart && onTouchStart(point, event) }, - [onTouchStart] + [margin.left, margin.top, onTouchStart, setCurrent, showTooltipAt, tooltip] ) const handleTouchMove = useCallback( @@ -95,7 +95,7 @@ const Mesh = ({ setCurrent(point) onTouchMove && onTouchMove(point, event) }, - [onTouchMove] + [margin.left, margin.top, onTouchMove, setCurrent, showTooltipAt, tooltip] ) const handleTouchEnd = useCallback( diff --git a/packages/voronoi/src/Mesh.tsx b/packages/voronoi/src/Mesh.tsx index aa3afc1d6..6d7e75afe 100644 --- a/packages/voronoi/src/Mesh.tsx +++ b/packages/voronoi/src/Mesh.tsx @@ -70,7 +70,7 @@ export const Mesh = ({ return [index, index !== undefined ? nodes[index] : null] as [number, Datum | null] }, - [elementRef, delaunay] + [delaunay, nodes] ) const getIndexAndNodeFromTouchEvent = useCallback( @@ -79,12 +79,12 @@ export const Mesh = ({ return [null, null] } - const [x, y] = getRelativeCursor(elementRef.current, event.touches[0]) + const [x, y] = getRelativeCursor(elementRef.current, event) const index = delaunay.find(x, y) return [index, index !== undefined ? nodes[index] : null] as [number, Datum | null] }, - [elementRef, delaunay] + [delaunay, nodes] ) const handleMouseEnter = useCallback( @@ -144,7 +144,7 @@ export const Mesh = ({ onTouchStart?.(node, event) } }, - [getIndexAndNodeFromTouchEvent, setCurrentIndex, onTouchStart] + [getIndexAndNodeFromTouchEvent, enableTouchCrosshair, onTouchStart] ) const handleTouchMove = useCallback( @@ -157,7 +157,7 @@ export const Mesh = ({ onTouchMove?.(node, event) } }, - [getIndexAndNodeFromTouchEvent, setCurrentIndex, onTouchMove] + [getIndexAndNodeFromTouchEvent, enableTouchCrosshair, onTouchMove] ) const handleTouchEnd = useCallback( @@ -173,7 +173,7 @@ export const Mesh = ({ previousNode && onTouchEnd(previousNode, event) } }, - [setCurrentIndex, currentIndex, onTouchEnd, nodes] + [enableTouchCrosshair, onTouchEnd, currentIndex, nodes] ) return ( From 4ad5f67ffb61af4f8d5cb29b18fd55c0259a6ab3 Mon Sep 17 00:00:00 2001 From: Will Date: Mon, 4 Mar 2024 20:54:05 +1300 Subject: [PATCH 06/17] Make unions of MouseEvent | TouchEvent --- packages/core/index.d.ts | 2 +- packages/core/src/lib/interactivity/index.js | 2 +- packages/tooltip/src/context.ts | 2 +- packages/tooltip/src/hooks.ts | 11 ++++------- 4 files changed, 7 insertions(+), 10 deletions(-) diff --git a/packages/core/index.d.ts b/packages/core/index.d.ts index 7294de40c..fe171d460 100644 --- a/packages/core/index.d.ts +++ b/packages/core/index.d.ts @@ -518,7 +518,7 @@ export function usePropertyAccessor( export function getRelativeCursor( element: Element, - event: Pick + event: React.MouseEvent | React.TouchEvent ): [number, number] export function isCursorInRect( x: number, diff --git a/packages/core/src/lib/interactivity/index.js b/packages/core/src/lib/interactivity/index.js index 68e2a868e..eda3e9970 100644 --- a/packages/core/src/lib/interactivity/index.js +++ b/packages/core/src/lib/interactivity/index.js @@ -22,7 +22,7 @@ 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 } = 'clientX' in event ? event : event.touches[0] // 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. diff --git a/packages/tooltip/src/context.ts b/packages/tooltip/src/context.ts index bff488b49..20cc14970 100644 --- a/packages/tooltip/src/context.ts +++ b/packages/tooltip/src/context.ts @@ -9,7 +9,7 @@ export interface TooltipActionsContextData { ) => void showTooltipFromEvent: ( content: JSX.Element, - event: Pick, + event: MouseEvent | TouchEvent, anchor?: TooltipAnchor ) => void hideTooltip: () => void diff --git a/packages/tooltip/src/hooks.ts b/packages/tooltip/src/hooks.ts index 8c98fcdb1..70a18fa1b 100644 --- a/packages/tooltip/src/hooks.ts +++ b/packages/tooltip/src/hooks.ts @@ -24,11 +24,7 @@ export const useTooltipHandlers = (container: MutableRefObject) ) const showTooltipFromEvent: TooltipActionsContextData['showTooltipFromEvent'] = useCallback( - ( - content: JSX.Element, - event: Pick, - 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 @@ -39,8 +35,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 } = 'clientX' in event ? event : event.touches[0] + const x = (clientX - bounds.left) * scaling + const y = (clientY - bounds.top) * scaling if (anchor === 'left' || anchor === 'right') { if (x < bounds.width / 2) anchor = 'right' From 5b6bf7169b2fbb163c517a3c5a482703f7946ca0 Mon Sep 17 00:00:00 2001 From: Will Date: Mon, 4 Mar 2024 21:08:09 +1300 Subject: [PATCH 07/17] Added website docs --- packages/line/src/props.js | 2 ++ website/src/data/components/line/props.ts | 33 +++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/packages/line/src/props.js b/packages/line/src/props.js index b2eeaa9d7..e9439f63e 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.isRequired, ...motionPropTypes, ...defsPropTypes, } @@ -202,6 +203,7 @@ export const LineDefaultProps = { ...commonDefaultProps, enablePointLabel: false, useMesh: false, + enableTouchCrosshair: false, animate: true, motionConfig: 'gentle', defs: [], diff --git a/website/src/data/components/line/props.ts b/website/src/data/components/line/props.ts index 1cf545d81..ef4413970 100644 --- a/website/src/data/components/line/props.ts +++ b/website/src/data/components/line/props.ts @@ -438,6 +438,39 @@ const props: ChartProperty[] = [ type: '(point, event) => void', required: false, }, + { + 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: 'onTouchStart', + flavors: ['svg'], + group: 'Interactivity', + help: `onTouchStart handler, requires useMesh.`, + type: '(point, event) => void', + required: false, + }, + { + key: 'onTouchMove', + flavors: ['svg'], + group: 'Interactivity', + help: `onTouchMove handler, requires useMesh.`, + type: '(point, event) => void', + required: false, + }, + { + key: 'onTouchEnd', + flavors: ['svg'], + group: 'Interactivity', + help: `onTouchEnd handler, requires useMesh.`, + type: '(point, event) => void', + required: false, + }, { key: 'tooltip', flavors: ['svg', 'canvas'], From f9f8d01bc19dbab797782da3eca73b1075133743 Mon Sep 17 00:00:00 2001 From: Will Date: Mon, 4 Mar 2024 21:11:37 +1300 Subject: [PATCH 08/17] Update help --- website/src/data/components/line/props.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/website/src/data/components/line/props.ts b/website/src/data/components/line/props.ts index ef4413970..5c057da36 100644 --- a/website/src/data/components/line/props.ts +++ b/website/src/data/components/line/props.ts @@ -442,7 +442,7 @@ const props: ChartProperty[] = [ key: 'enableTouchCrosshair', flavors: ['svg'], group: 'Interactivity', - help: `Enables the crosshair to be dragged around a touch screen`, + help: `Enables the crosshair to be dragged around a touch screen, requires useMesh and slices disabled.`, type: 'boolean', defaultValue: defaults.enableTouchCrosshair, control: { type: 'switch' }, @@ -451,7 +451,7 @@ const props: ChartProperty[] = [ key: 'onTouchStart', flavors: ['svg'], group: 'Interactivity', - help: `onTouchStart handler, requires useMesh.`, + help: `onTouchStart handler, requires useMesh and slices disabled.`, type: '(point, event) => void', required: false, }, @@ -459,7 +459,7 @@ const props: ChartProperty[] = [ key: 'onTouchMove', flavors: ['svg'], group: 'Interactivity', - help: `onTouchMove handler, requires useMesh.`, + help: `onTouchMove handler, requires useMesh and slices disabled.`, type: '(point, event) => void', required: false, }, @@ -467,7 +467,7 @@ const props: ChartProperty[] = [ key: 'onTouchEnd', flavors: ['svg'], group: 'Interactivity', - help: `onTouchEnd handler, requires useMesh.`, + help: `onTouchEnd handler, requires useMesh and slices disabled.`, type: '(point, event) => void', required: false, }, From d6dff733b579fb6c1830224909c202809509954c Mon Sep 17 00:00:00 2001 From: Will Date: Mon, 4 Mar 2024 21:49:29 +1300 Subject: [PATCH 09/17] Add tests --- packages/core/src/lib/interactivity/index.js | 9 ++- packages/line/tests/Line.test.js | 63 +++++++++++++++++++- packages/tooltip/src/hooks.ts | 2 +- packages/voronoi/src/Mesh.tsx | 1 + 4 files changed, 69 insertions(+), 6 deletions(-) diff --git a/packages/core/src/lib/interactivity/index.js b/packages/core/src/lib/interactivity/index.js index eda3e9970..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 } = 'clientX' in event ? event : event.touches[0] + 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/tests/Line.test.js b/packages/line/tests/Line.test.js index f32e58e6d..ec77f0dce 100644 --- a/packages/line/tests/Line.test.js +++ b/packages/line/tests/Line.test.js @@ -173,14 +173,20 @@ 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-testid='slice-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-testid='slice-0']`).simulate('mousemove', { + clientX: 100, + clientY: 100, + }) expect(onMouseMove).toHaveBeenCalledTimes(1) }) @@ -198,3 +204,56 @@ describe('mouse events on slices', () => { 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-testid='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-testid='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-testid='mesh-interceptor']`) + .simulate('touchstart', { + touches: [{ clientX: 50, clientY: 50 }], + }) + .simulate('touchend') + expect(onTouchEnd).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/tooltip/src/hooks.ts b/packages/tooltip/src/hooks.ts index 70a18fa1b..7d8a36da4 100644 --- a/packages/tooltip/src/hooks.ts +++ b/packages/tooltip/src/hooks.ts @@ -35,7 +35,7 @@ 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 { clientX, clientY } = 'clientX' in event ? event : event.touches[0] + const { clientX, clientY } = 'touches' in event ? event.touches[0] : event const x = (clientX - bounds.left) * scaling const y = (clientY - bounds.top) * scaling diff --git a/packages/voronoi/src/Mesh.tsx b/packages/voronoi/src/Mesh.tsx index 6d7e75afe..146145464 100644 --- a/packages/voronoi/src/Mesh.tsx +++ b/packages/voronoi/src/Mesh.tsx @@ -189,6 +189,7 @@ export const Mesh = ({ )} {/* transparent rect to intercept mouse events */} Date: Mon, 4 Mar 2024 21:59:13 +1300 Subject: [PATCH 10/17] Add touch crosshair to storybook --- storybook/stories/line/Line.stories.tsx | 1 + 1 file changed, 1 insertion(+) 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', } From 2f5de47be5d36bdb8c76580539b41e73aeec5e9e Mon Sep 17 00:00:00 2001 From: Will Date: Mon, 4 Mar 2024 22:09:13 +1300 Subject: [PATCH 11/17] Fix make command --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From b2f516bdaa392ff75132541b1be29a916ebe91c3 Mon Sep 17 00:00:00 2001 From: Will Date: Tue, 5 Mar 2024 19:33:56 +1300 Subject: [PATCH 12/17] Add support for touch crosshair on slice based graphs --- packages/line/src/Mesh.js | 3 ++ packages/line/src/Slices.js | 10 ++++ packages/line/src/SlicesItem.js | 61 ++++++++++++++++++++-- packages/line/src/hooks.js | 10 ++-- packages/line/tests/Line.test.js | 87 +++++++++++++++++++++++++++----- packages/tooltip/src/context.ts | 2 +- packages/voronoi/src/Mesh.tsx | 2 +- 7 files changed, 153 insertions(+), 22 deletions(-) diff --git a/packages/line/src/Mesh.js b/packages/line/src/Mesh.js index 9e910ca17..67fd1f21f 100644 --- a/packages/line/src/Mesh.js +++ b/packages/line/src/Mesh.js @@ -135,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/tests/Line.test.js b/packages/line/tests/Line.test.js index ec77f0dce..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,7 +185,7 @@ 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, }) @@ -183,7 +195,7 @@ describe('mouse events on slices', () => { 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, }) @@ -193,14 +205,14 @@ describe('mouse events on slices', () => { 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) }) }) @@ -230,7 +242,7 @@ describe('touch events with useMesh', () => { it('should call onTouchStart', () => { const onTouchStart = jest.fn() const wrapper = mount() - wrapper.find(`[data-testid='mesh-interceptor']`).simulate('touchstart', { + wrapper.find(`[data-ref='mesh-interceptor']`).simulate('touchstart', { touches: [{ clientX: 50, clientY: 50 }], }) expect(onTouchStart).toHaveBeenCalledTimes(1) @@ -239,7 +251,7 @@ describe('touch events with useMesh', () => { it('should call onTouchMove', () => { const onTouchMove = jest.fn() const wrapper = mount() - wrapper.find(`[data-testid='mesh-interceptor']`).simulate('touchmove', { + wrapper.find(`[data-ref='mesh-interceptor']`).simulate('touchmove', { touches: [{ clientX: 50, clientY: 50 }], }) expect(onTouchMove).toHaveBeenCalledTimes(1) @@ -249,7 +261,7 @@ describe('touch events with useMesh', () => { const onTouchEnd = jest.fn() const wrapper = mount() wrapper - .find(`[data-testid='mesh-interceptor']`) + .find(`[data-ref='mesh-interceptor']`) .simulate('touchstart', { touches: [{ clientX: 50, clientY: 50 }], }) @@ -257,3 +269,54 @@ describe('touch events with useMesh', () => { 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 20cc14970..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 { diff --git a/packages/voronoi/src/Mesh.tsx b/packages/voronoi/src/Mesh.tsx index 146145464..9ff8f431a 100644 --- a/packages/voronoi/src/Mesh.tsx +++ b/packages/voronoi/src/Mesh.tsx @@ -189,7 +189,7 @@ export const Mesh = ({ )} {/* transparent rect to intercept mouse events */} Date: Tue, 5 Mar 2024 19:45:39 +1300 Subject: [PATCH 13/17] Update docs --- website/src/data/components/line/props.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/website/src/data/components/line/props.ts b/website/src/data/components/line/props.ts index 5c057da36..e6c26a911 100644 --- a/website/src/data/components/line/props.ts +++ b/website/src/data/components/line/props.ts @@ -451,7 +451,7 @@ const props: ChartProperty[] = [ key: 'onTouchStart', flavors: ['svg'], group: 'Interactivity', - help: `onTouchStart handler, requires useMesh and slices disabled.`, + help: `onTouchStart handler, when a touch gesture is started inside the graph.`, type: '(point, event) => void', required: false, }, @@ -459,7 +459,7 @@ const props: ChartProperty[] = [ key: 'onTouchMove', flavors: ['svg'], group: 'Interactivity', - help: `onTouchMove handler, requires useMesh and slices disabled.`, + 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()).`, type: '(point, event) => void', required: false, }, @@ -467,7 +467,7 @@ const props: ChartProperty[] = [ key: 'onTouchEnd', flavors: ['svg'], group: 'Interactivity', - help: `onTouchEnd handler, requires useMesh and slices disabled.`, + help: `onTouchEnd handler, when a touch gesture that originated from inside the graph ends.`, type: '(point, event) => void', required: false, }, From 57bcea9a3f190cc86fd7a91f0bc8f1b7a3847bdc Mon Sep 17 00:00:00 2001 From: Will Date: Tue, 5 Mar 2024 19:49:46 +1300 Subject: [PATCH 14/17] Tweak defaults --- packages/line/src/props.js | 2 +- website/src/data/components/line/defaults.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/line/src/props.js b/packages/line/src/props.js index e9439f63e..e9215e813 100644 --- a/packages/line/src/props.js +++ b/packages/line/src/props.js @@ -133,7 +133,7 @@ export const LinePropTypes = { enablePointLabel: PropTypes.bool.isRequired, role: PropTypes.string.isRequired, useMesh: PropTypes.bool.isRequired, - enableTouchCrosshair: PropTypes.bool.isRequired, + enableTouchCrosshair: PropTypes.bool, ...motionPropTypes, ...defsPropTypes, } 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', } From d79d6ef0f22fec8e6555f554b00fbdf485dc988b Mon Sep 17 00:00:00 2001 From: Will Date: Tue, 5 Mar 2024 20:05:44 +1300 Subject: [PATCH 15/17] Added note about slices vs useMesh for line isInteractive --- website/src/data/components/line/props.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/website/src/data/components/line/props.ts b/website/src/data/components/line/props.ts index e6c26a911..fc8960bdb 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('\n'), }), { key: 'useMesh', @@ -459,7 +464,10 @@ const props: ChartProperty[] = [ 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()).`, + 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('\n'), type: '(point, event) => void', required: false, }, From 4736ca419c0fce909f820abc5e2c059c42b4b5e5 Mon Sep 17 00:00:00 2001 From: Will Date: Tue, 5 Mar 2024 20:12:20 +1300 Subject: [PATCH 16/17] Fix missing import --- packages/tooltip/src/hooks.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/tooltip/src/hooks.ts b/packages/tooltip/src/hooks.ts index 7d8a36da4..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, From fd757b92b331eada80014dd7cf18b51d9edd7e21 Mon Sep 17 00:00:00 2001 From: Will Date: Tue, 5 Mar 2024 20:25:32 +1300 Subject: [PATCH 17/17] Tweaks comments --- website/src/data/components/line/props.ts | 22 +++++++++---------- .../src/lib/chart-properties/interactivity.ts | 4 +++- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/website/src/data/components/line/props.ts b/website/src/data/components/line/props.ts index fc8960bdb..1b4d8dc58 100644 --- a/website/src/data/components/line/props.ts +++ b/website/src/data/components/line/props.ts @@ -389,7 +389,7 @@ const props: ChartProperty[] = [ '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('\n'), + ].join(' '), }), { key: 'useMesh', @@ -443,15 +443,6 @@ const props: ChartProperty[] = [ type: '(point, event) => void', required: false, }, - { - key: 'enableTouchCrosshair', - flavors: ['svg'], - group: 'Interactivity', - help: `Enables the crosshair to be dragged around a touch screen, requires useMesh and slices disabled.`, - type: 'boolean', - defaultValue: defaults.enableTouchCrosshair, - control: { type: 'switch' }, - }, { key: 'onTouchStart', flavors: ['svg'], @@ -467,7 +458,7 @@ const props: ChartProperty[] = [ 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('\n'), + ].join(' '), type: '(point, event) => void', required: false, }, @@ -541,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,