diff --git a/.eslintrc b/.eslintrc index be727efd6..21d169a1e 100644 --- a/.eslintrc +++ b/.eslintrc @@ -2,6 +2,7 @@ "extends": [ "eslint:recommended", "plugin:react/recommended", + "plugin:react-hooks/recommended", "plugin:jest/recommended", "prettier", "prettier/react" @@ -19,8 +20,8 @@ "**/dist/", "**/es/" ], - "settings":{ - "react":{ + "settings": { + "react": { "version": "detect" } }, @@ -31,12 +32,19 @@ }, "rules": { "consistent-return": 0, - "max-len": [1, 110, 4], - "max-params": ["error", 6], + "max-len": [ + 1, + 110, + 4 + ], + "max-params": [ + "error", + 6 + ], "object-curly-spacing": 0, "babel/object-curly-spacing": 2, - "jest/require-top-level-describe":"error", + "jest/require-top-level-describe": "error", "react/prop-types": "off", "prettier/prettier": "warn" } -} +} \ No newline at end of file diff --git a/packages/react-vis/package.json b/packages/react-vis/package.json index 8abe0a0a9..b1d3b1510 100644 --- a/packages/react-vis/package.json +++ b/packages/react-vis/package.json @@ -72,6 +72,7 @@ "enzyme-adapter-react-16": "^1.15.2", "enzyme-to-json": "^3.5.0", "eslint-plugin-jest": "^23.13.2", + "eslint-plugin-react-hooks": "^4.0.4", "jest": "^25.5.4", "jsdom": "^9.9.1", "node-sass": "^4.9.3", diff --git a/packages/react-vis/src/index.js b/packages/react-vis/src/index.js index 6a0b11d3e..ecceabd52 100644 --- a/packages/react-vis/src/index.js +++ b/packages/react-vis/src/index.js @@ -74,6 +74,8 @@ export Treemap from 'treemap'; export ContentClipPath from './plot/content-clip-path'; +export ZoomHandler from './plot/zoom-handler'; + export { makeHeightFlexible, makeVisFlexible, diff --git a/packages/react-vis/src/plot/highlight.js b/packages/react-vis/src/plot/highlight.js index 9f5dd05ff..05fdec519 100644 --- a/packages/react-vis/src/plot/highlight.js +++ b/packages/react-vis/src/plot/highlight.js @@ -6,10 +6,8 @@ import {getAttributeScale} from 'utils/scales-utils'; import {getCombinedClassName} from 'utils/styling-utils'; function getLocs(evt) { - // const xLoc = evt.type === 'touchstart' ? evt.pageX : evt.offsetX; - // const yLoc = evt.type === 'touchstart' ? evt.pageY : evt.offsetY; - const xLoc = evt.offsetX ?? evt.pageX; - const yLoc = evt.offsetY ?? evt.pageY; + const xLoc = evt.type === 'touchstart' ? evt.pageX : evt.offsetX; + const yLoc = evt.type === 'touchstart' ? evt.pageY : evt.offsetY; return {xLoc, yLoc}; } @@ -22,7 +20,7 @@ class Highlight extends AbstractSeries { startLocY: 0, dragArea: null }; - ref = React.createRef(); + _getDrawArea(xLoc, yLoc) { const {startLocX, startLocY} = this.state; const { @@ -118,69 +116,10 @@ class Highlight extends AbstractSeries { return {}; } - _captureMouse = e => { - console.log('capture', e.type, e.target, e); - - document.addEventListener('mouseup', this.stopBrushing, {capture: true}); - document.addEventListener('mousemove', this.onBrush, {capture: true}); - // document.body.style['pointer-events'] = 'none'; - - if (this.ref.current) { - this.ref.current.addEventListener('mouseleave', this._mouseLeave, { - capture: true - }); - } - - e.preventDefault(); - e.stopPropagation(); - // this.startBrushing(e); - }; - - _releaseMouse = e => { - console.log('release', e.type, e.target, e); - - document.removeEventListener('mouseup', this.stopBrushing, {capture: true}); - document.removeEventListener('mousemove', this.onBrush, {capture: true}); - - if (this.ref.current) { - this.ref.current.removeEventListener('mouseleave', this._mouseLeave, { - capture: true - }); - } - // document.body.style['pointer-events'] = 'auto'; - e.stopPropagation(); - }; - - _mouseLeave = e => { - const {toElement} = e; - if (toElement === document.documentElement) { - this.stopBrushing(e); - return; - } - if (toElement === this.ref.current.parentNode.parentNode) { - this.stopBrushing(e); - return; - } - console.log('didnt really leave', toElement, this.ref.current.parentNode); - // this.ref.current. - }; - - startBrushing = e => { - // e.preventDefault(); - this._captureMouse(e); + startBrushing(e) { const {onBrushStart, onDragStart, drag} = this.props; const {dragArea} = this.state; - const {xLoc, yLoc} = getLocs(e); - console.log( - 'start', - xLoc, - yLoc, - e.type, - e.offsetX, - e.offsetY, - e.pageX, - e.pageY - ); + const {xLoc, yLoc} = getLocs(e.nativeEvent); const startArea = (dragging, resetDrag) => { const emptyBrush = { @@ -214,23 +153,9 @@ class Highlight extends AbstractSeries { onDragStart(e); } } - }; + } - stopBrushing = e => { - if (e.toElement === document.documentElement) { - console.log('is document'); - // return; - } - console.log( - 'stop', - e.type, - e.target, - e.currentTarget, - e.toElement, - document, - e - ); - this._releaseMouse(e); + stopBrushing() { const {brushing, dragging, brushArea} = this.state; // Quickly short-circuit if the user isn't brushing in our component if (!brushing && !dragging) { @@ -258,18 +183,14 @@ class Highlight extends AbstractSeries { if (drag && onDragEnd) { onDragEnd(!isNulled ? this._convertAreaToCoordinates(brushArea) : null); } - }; + } - onBrush = e => { - e.preventDefault(); - e.stopPropagation(); + onBrush(e) { const {onBrush, onDrag, drag} = this.props; const {brushing, dragging} = this.state; - const {xLoc, yLoc} = getLocs(e); - // console.log('brush', xLoc, yLoc); + const {xLoc, yLoc} = getLocs(e.nativeEvent); if (brushing) { const brushArea = this._getDrawArea(xLoc, yLoc); - // console.log('brush area', brushArea); this.setState({brushArea}); if (onBrush) { @@ -284,7 +205,7 @@ class Highlight extends AbstractSeries { onDrag(this._convertAreaToCoordinates(brushArea)); } } - }; + } render() { const { @@ -329,7 +250,6 @@ class Highlight extends AbstractSeries { className={getCombinedClassName(className, 'rv-highlight-container')} > this.startBrushing(e.nativeEvent)} - // onMouseMoveCapture={e => this.onBrush(e)} - // onMouseUpCapture={e => this.stopBrushing(e)} - // onMouseLeave={e => { - // console.log( - // 'mouse leave', - // e.target, - // e.currentTarget, - // getLocs(e.nativeEvent) - // ); - // // this._releaseMouse(e); - // // this.stopBrushing(e); - // }} + onMouseDown={e => this.startBrushing(e)} + onMouseMove={e => this.onBrush(e)} + onMouseUp={e => this.stopBrushing(e)} + onMouseLeave={e => this.stopBrushing(e)} // preventDefault() so that mouse event emulation does not happen - // onTouchEnd={e => { - // e.preventDefault(); - // this.stopBrushing(e); - // }} - // onTouchCancel={e => { - // e.preventDefault(); - // this.stopBrushing(e); - // }} + onTouchEnd={e => { + e.preventDefault(); + this.stopBrushing(e); + }} + onTouchCancel={e => { + e.preventDefault(); + this.stopBrushing(e); + }} onContextMenu={e => e.preventDefault()} onContextMenuCapture={e => e.preventDefault()} /> diff --git a/packages/react-vis/src/plot/xy-plot.js b/packages/react-vis/src/plot/xy-plot.js index ccb503c5a..e8e87478a 100644 --- a/packages/react-vis/src/plot/xy-plot.js +++ b/packages/react-vis/src/plot/xy-plot.js @@ -50,6 +50,8 @@ import { import CanvasWrapper from './series/canvas-wrapper'; +import {Event} from '../utils/events'; + const ATTRIBUTES = [ 'x', 'y', @@ -140,7 +142,14 @@ class XYPlot extends React.Component { const data = getStackedData(children, stackBy); this.state = { scaleMixins: this._getScaleMixins(data, props), - data + data, + events: { + mouseMove: new Event('move'), + mouseDown: new Event('down'), + mouseUp: new Event('up'), + mouseLeave: new Event('leave'), + mouseEnter: new Event('enter') + } }; } @@ -222,7 +231,8 @@ class XYPlot extends React.Component { ...scaleMixins, ...child.props, ...XYPlotValues[index], - ...dataProps + ...dataProps, + events: this.state.events }); }); } @@ -348,6 +358,7 @@ class XYPlot extends React.Component { component.onParentMouseDown(event); } }); + this.state.events.mouseDown.fire(event); }; /** @@ -367,6 +378,7 @@ class XYPlot extends React.Component { component.onParentMouseEnter(event); } }); + this.state.events.mouseEnter.fire(event); }; /** @@ -386,6 +398,7 @@ class XYPlot extends React.Component { component.onParentMouseLeave(event); } }); + this.state.events.mouseLeave.fire(event); }; /** @@ -405,6 +418,7 @@ class XYPlot extends React.Component { component.onParentMouseMove(event); } }); + this.state.events.mouseMove.fire(event); }; /** @@ -424,6 +438,7 @@ class XYPlot extends React.Component { component.onParentMouseUp(event); } }); + this.state.events.mouseUp.fire(event); }; /** diff --git a/packages/react-vis/src/plot/zoom-handler.js b/packages/react-vis/src/plot/zoom-handler.js new file mode 100644 index 000000000..b6b4376cf --- /dev/null +++ b/packages/react-vis/src/plot/zoom-handler.js @@ -0,0 +1,175 @@ +import React, {useEffect, useState, useCallback, useRef} from 'react'; +import {getCombinedClassName} from '../utils/styling-utils'; +import {getAttributeScale} from '../utils/scales-utils'; + +const DEFAULT_STATE = { + brushing: false, + bounds: null, + startPosition: null +}; + +const useStateWithGet = initialState => { + const [state, setState] = useState(initialState); + const ref = useRef(); + ref.current = state; + const get = useCallback(() => ref.current, []); + return [state, setState, get]; +}; + +export default function ZoomHandler(props) { + const { + events: {mouseMove, mouseDown, mouseUp, mouseLeave}, + onZoom, + enableX = true, + enableY = true, + marginLeft = 0, + marginTop = 0, + innerWidth = 0, + innerHeight = 0 + } = props; + + const [state, setState, getState] = useStateWithGet(DEFAULT_STATE); + + const convertArea = useCallback( + area => { + const xScale = getAttributeScale(props, 'x'); + const yScale = getAttributeScale(props, 'y'); + + return { + bottom: yScale.invert(area.bottom), + left: xScale.invert(area.left - marginLeft), + right: xScale.invert(area.right - marginLeft), + top: yScale.invert(area.top) + }; + }, + [marginLeft, props] + ); + + const onMouseMove = useCallback( + e => { + const state = getState(); + if (!state.brushing) { + return; + } + e.stopPropagation(); + e.preventDefault(); + const position = getPosition(e); + + setState(state => { + const bounds = { + left: enableX + ? Math.min(position.x, state.startPosition.x) + : marginLeft, + top: enableY + ? Math.min(position.y, state.startPosition.y) + : marginTop, + right: enableX + ? Math.max(position.x, state.startPosition.x) + : innerWidth + marginLeft, + bottom: enableY + ? Math.max(position.y, state.startPosition.y) + : innerHeight + marginTop + }; + return { + ...state, + bounds + }; + }); + }, + [ + enableX, + enableY, + getState, + innerHeight, + innerWidth, + marginLeft, + marginTop, + setState + ] + ); + + const onMouseDown = useCallback( + e => { + e.stopPropagation(); + e.preventDefault(); + const {x, y} = getPosition(e); + + const bounds = {left: x, top: y, right: x, bottom: y}; + + setState(state => ({ + ...state, + brushing: true, + bounds, + startPosition: {x, y} + })); + }, + [setState] + ); + + const onMouseUp = useCallback( + e => { + const state = getState(); + + if (!state.brushing) { + return setState(DEFAULT_STATE); + } + + e.stopPropagation(); + e.preventDefault(); + + if ( + state.bounds.bottom - state.bounds.top > 5 && + state.bounds.right - state.bounds.left > 5 + ) { + onZoom && onZoom(convertArea(state.bounds)); + } + + setState(DEFAULT_STATE); + }, + [convertArea, getState, onZoom, setState] + ); + + const onMouseLeave = useCallback(() => { + const state = getState(); + if (state.brushing) { + setState(DEFAULT_STATE); + } + }, [getState, setState]); + + useEffect(() => mouseMove.subscribe(onMouseMove), [mouseMove, onMouseMove]); + useEffect(() => mouseDown.subscribe(onMouseDown), [mouseDown, onMouseDown]); + useEffect(() => mouseUp.subscribe(onMouseUp), [mouseUp, onMouseUp]); + useEffect(() => mouseLeave.subscribe(onMouseLeave), [ + mouseLeave, + onMouseLeave + ]); + + if (!state.brushing) { + return null; + } + + const {bounds} = state; + const {opacity, color, className} = props; + + return ( + + + + ); +} +ZoomHandler.requiresSVG = true; + +function getPosition(evt) { + const x = evt.nativeEvent.offsetX ?? evt.nativeEvent.pageX; + const y = evt.nativeEvent.offsetY ?? evt.nativeEvent.pageY; + return {x, y}; +} diff --git a/packages/react-vis/src/utils/events.js b/packages/react-vis/src/utils/events.js new file mode 100644 index 000000000..ceb5bc12f --- /dev/null +++ b/packages/react-vis/src/utils/events.js @@ -0,0 +1,20 @@ +export class Event { + subscribers = []; + + constructor(name) { + this.name = name; + } + + fire(...args) { + this.subscribers.forEach(cb => cb(...args)); + } + + subscribe(callback) { + this.subscribers.push(callback); + return () => this.unsubscribe(callback); + } + + unsubscribe(callback) { + this.subscribers = this.subscribers.filter(x => x !== callback); + } +} diff --git a/packages/website/storybook/misc-story.js b/packages/website/storybook/misc-story.js index 59fecdc3b..18aa79702 100644 --- a/packages/website/storybook/misc-story.js +++ b/packages/website/storybook/misc-story.js @@ -8,10 +8,12 @@ import {withKnobs, boolean, button} from '@storybook/addon-knobs/react'; import {SimpleChartWrapper} from './storybook-utils'; import {generateLinearData} from './storybook-data'; -import {LineSeries, ContentClipPath, Highlight} from 'react-vis'; +import {LineSeries, MarkSeries, ContentClipPath, ZoomHandler} from 'react-vis'; const data = generateLinearData({randomFactor: 10}); +const highlightData = generateLinearData({}); + storiesOf('Misc', module) .addDecorator(withKnobs) .addWithJSX('Clip Content', () => { @@ -33,16 +35,17 @@ storiesOf('Misc', module) const [zoom, setZoom] = useState(); const onZoom = useCallback(area => { console.log('zoom', area); + setZoom(area); }, []); button('Reset Zoom', () => setZoom(null), 'Zoom'); const xDomain = zoom ? [zoom.left, zoom.right] : undefined; - + const yDomain = zoom ? [zoom.bottom, zoom.top] : undefined; return ( - - - + + + ); }); diff --git a/yarn.lock b/yarn.lock index 52b54f597..500c15f32 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6603,6 +6603,11 @@ eslint-plugin-prettier@^3.1.3: dependencies: prettier-linter-helpers "^1.0.0" +eslint-plugin-react-hooks@^4.0.4: + version "4.0.4" + resolved "https://registry.yarnpkg.com/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.0.4.tgz#aed33b4254a41b045818cacb047b81e6df27fa58" + integrity sha1-rtM7QlSkGwRYGMrLBHuB5t8n+lg= + eslint-plugin-react@^6.7.1: version "6.10.3" resolved "https://registry.yarnpkg.com/eslint-plugin-react/-/eslint-plugin-react-6.10.3.tgz#c5435beb06774e12c7db2f6abaddcbf900cd3f78"