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..2c4c549af 100644 --- a/packages/react-vis/src/index.js +++ b/packages/react-vis/src/index.js @@ -74,6 +74,9 @@ export Treemap from 'treemap'; export ContentClipPath from './plot/content-clip-path'; +export Selection from './plot/selection'; +export Window from './plot/window'; + export { makeHeightFlexible, makeVisFlexible, diff --git a/packages/react-vis/src/plot/selection.js b/packages/react-vis/src/plot/selection.js new file mode 100644 index 000000000..456db7cb8 --- /dev/null +++ b/packages/react-vis/src/plot/selection.js @@ -0,0 +1,179 @@ +import React, {useEffect, useState, useCallback, useRef} from 'react'; +import {getAttributeScale} from '../utils/scales-utils'; + +const DEFAULT_STATE = { + brushing: false, + bounds: null, + startPosition: null +}; + +export default function Selection(props) { + const { + events: {mouseMove, mouseDown, mouseUp, mouseLeave}, + onSelecting, + onSelected, + enableX = true, + enableY = true, + marginLeft = 0, + marginTop = 0, + innerWidth = 0, + innerHeight = 0, + xDomain, + yDomain + } = props; + + const [state, setState] = useState(DEFAULT_STATE); + // The 'state' is being assigned to the 'ref' so that the `useCallback`s can + // reference the value without directly depending on it. + // This is important for performance reasons, as directly depending on the state, + // will cause the event handlers to be added and removed for each move of the mouse. + // The lifecycle of the callbacks isn't affected by the value of the 'state', so + // there is no harm in using the `stateRef` to get the latest value of the `state` + const stateRef = useRef(); + stateRef.current = state; + + const convertArea = useCallback( + area => { + const xScale = getAttributeScale(props, 'x'); + const yScale = getAttributeScale(props, 'y'); + + // If the axis isn't enabled, then use the domain to ensure + // that the entire space is selected. + return { + left: enableX ? xScale.invert(area.left - marginLeft) : xDomain[0], + top: enableY ? yScale.invert(area.top - marginTop) : yDomain[1], + right: enableX ? xScale.invert(area.right - marginLeft) : yDomain[1], + bottom: enableY ? yScale.invert(area.bottom - marginTop) : yDomain[0] + }; + }, + [enableX, enableY, marginLeft, marginTop, props, xDomain, yDomain] + ); + + const onMouseMove = useCallback( + e => { + // Get the current value of 'state' + const state = stateRef.current; + if (!state.brushing) { + return; + } + e.stopPropagation(); + e.preventDefault(); + const position = getPosition(e); + + 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 + }; + + onSelecting && onSelecting(convertArea(bounds)); + + setState({ + ...state, + bounds + }); + }, + [ + convertArea, + enableX, + enableY, + innerHeight, + innerWidth, + marginLeft, + marginTop, + onSelecting + ] + ); + + 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} + })); + }, []); + + const onMouseUp = useCallback( + e => { + // Get the current value of 'state' + const state = stateRef.current; + + 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 + ) { + onSelected && onSelected(convertArea(state.bounds)); + } else { + onSelected && onSelected(null); + } + + setState(DEFAULT_STATE); + }, + [convertArea, onSelected] + ); + + const onMouseLeave = useCallback(() => { + const state = stateRef.current; + if (state.brushing) { + setState(DEFAULT_STATE); + } + }, []); + + 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 = 0.2, color, style} = props; + + return ( + + ); +} +Selection.requiresSVG = true; + +function getPosition(event) { + event = event.nativeEvent ?? event; + const x = event.type === 'touchstart' ? event.pageX : event.offsetX; + const y = event.type === 'touchstart' ? event.pageY : event.offsetY; + return {x, y}; +} diff --git a/packages/react-vis/src/plot/window.js b/packages/react-vis/src/plot/window.js new file mode 100644 index 000000000..ff5838165 --- /dev/null +++ b/packages/react-vis/src/plot/window.js @@ -0,0 +1,157 @@ +import React, {useMemo, useCallback, useRef, useState, useEffect} from 'react'; +import {getAttributeScale} from '../utils/scales-utils'; + +const DEFAULT_STATE = { + dragging: false, + startPosition: null, + offset: null, + + bounds: null +}; + +export default function Window(props) { + const { + yDomain, + xDomain, + left = xDomain[0], + top = yDomain[1], + right = xDomain[1], + bottom = yDomain[0], + onMoving, + onMoveComplete, + enableX = true, + enableY = true, + events: {mouseMove, mouseLeave} + } = props; + + const xScale = useMemo(() => getAttributeScale(props, 'x'), [props]); + const yScale = useMemo(() => getAttributeScale(props, 'y'), [props]); + + const [state, setState] = useState(DEFAULT_STATE); + const stateRef = useRef(); + stateRef.current = state; + + const pixelBounds = useMemo(() => { + return { + x: xScale(left) + (state.offset?.x ?? 0), + y: yScale(top) + (state.offset?.y ?? 0), + width: xScale(right) - xScale(left), + height: yScale(bottom) - yScale(top) + }; + }, [ + bottom, + left, + right, + state.offset?.x, + state.offset?.y, + top, + xScale, + yScale + ]); + + const onMouseDown = useCallback(e => { + e.stopPropagation(); + e.preventDefault(); + + setState({ + dragging: true, + startPosition: getPosition(e), + offset: {x: 0, y: 0} + }); + }, []); + + const onMouseMove = useCallback( + e => { + const {dragging, startPosition} = stateRef.current; + if (!dragging) { + return; + } + e.stopPropagation(); + e.preventDefault(); + + const position = getPosition(e); + const pixelOffset = { + x: enableX ? position.x - startPosition.x : 0, + y: enableY ? position.y - startPosition.y : 0 + }; + + const bounds = { + left: xScale.invert(xScale(left) + pixelOffset.x), + top: yScale.invert(yScale(top) + pixelOffset.y), + right: xScale.invert(xScale(right) + pixelOffset.x), + bottom: yScale.invert(yScale(bottom) + pixelOffset.y) + }; + + onMoving && onMoving(bounds); + + setState(state => ({ + ...state, + offset: pixelOffset, + bounds + })); + }, + [bottom, enableX, enableY, left, onMoving, right, top, xScale, yScale] + ); + + const onMouseUp = useCallback( + e => { + const {dragging} = stateRef.current; + if (!dragging) { + return; + } + e.stopPropagation(); + e.preventDefault(); + + const state = stateRef.current; + + setState(DEFAULT_STATE); + onMoveComplete && onMoveComplete(state.bounds); + }, + [onMoveComplete] + ); + + const onPlotMouseLeave = useCallback(() => { + const state = stateRef.current; + if (state.dragging) { + setState(DEFAULT_STATE); + } + }, []); + + useEffect(() => mouseMove.subscribe(onMouseMove), [mouseMove, onMouseMove]); + useEffect(() => mouseLeave.subscribe(onPlotMouseLeave), [ + mouseLeave, + onPlotMouseLeave + ]); + + if ( + [pixelBounds.x, pixelBounds.y, pixelBounds.width, pixelBounds.height].some( + isNaN + ) + ) { + return null; + } + + const {color, opacity = 0.2, style, marginLeft, marginTop} = props; + + return ( + + ); +} +Window.requiresSVG = true; + +function getPosition(event) { + event = event.nativeEvent ?? event; + const x = event.type === 'touchstart' ? event.pageX : event.offsetX; + const y = event.type === 'touchstart' ? event.pageY : event.offsetY; + return {x, y}; +} 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/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/react-vis/tests/plot/zoom-handler.test.js b/packages/react-vis/tests/plot/zoom-handler.test.js new file mode 100644 index 000000000..6b9adef14 --- /dev/null +++ b/packages/react-vis/tests/plot/zoom-handler.test.js @@ -0,0 +1,91 @@ +import React from 'react'; + +import {mount} from 'enzyme'; +import Selection from '../../src/plot/selection'; +import XYPlot from '../../src/plot/xy-plot'; + +describe('zoom-handler', () => { + it('should zoom', () => { + const onSelected = jest.fn(); + const wrapper = mount( + + + + ); + + const svg = wrapper.find('svg'); + svg.simulate('mousedown', mouseEvent(100, 110)); + svg.simulate('mouseMove', mouseEvent(150, 160)); + svg.simulate('mouseUp', mouseEvent(150, 160)); + + expect(onSelected).toBeCalledWith({ + left: 4.8, + top: 12, + right: 8.8, + bottom: 8 + }); + }); + + it('should render selection', () => { + const onSelected = jest.fn(); + const wrapper = mount( + + + + ); + + const svg = wrapper.find('svg'); + svg.simulate('mousedown', mouseEvent(100, 100)); + svg.simulate('mouseMove', mouseEvent(150, 150)); + + const selection = wrapper.find('rect'); + + expect(selection.props()).toMatchObject({ + x: 100, + y: 100, + width: 50, + height: 50 + }); + }); + + it('should clear selection if plot mouseleave', () => { + const onSelected = jest.fn(); + const wrapper = mount( + + + + ); + + const svg = wrapper.find('svg'); + svg.simulate('mousedown', mouseEvent(100, 100)); + svg.simulate('mousemove', mouseEvent(150, 150)); + + expect(wrapper.find('rect')).toHaveLength(1); + + svg.simulate('mouseleave'); + expect(wrapper.find('rect')).toHaveLength(0); + expect(onSelected).not.toBeCalled(); + }); +}); + +function mouseEvent(x, y) { + return {nativeEvent: {offsetX: x, offsetY: y}}; +} diff --git a/packages/react-vis/tests/utils/events.test.js b/packages/react-vis/tests/utils/events.test.js new file mode 100644 index 000000000..cc3616405 --- /dev/null +++ b/packages/react-vis/tests/utils/events.test.js @@ -0,0 +1,27 @@ +import {Event} from '../../src/utils/events'; + +describe('events', () => { + it('should subscribe', () => { + const event = new Event(); + const handler = jest.fn(); + event.subscribe(handler); + expect(event.subscribers).toHaveLength(1); + + event.fire('foo'); + expect(handler).toHaveBeenCalledWith('foo'); + }); + + it('should unsubscribe', () => { + const event = new Event(); + const handler = jest.fn(); + const unsubscribe = event.subscribe(handler); + expect(event.subscribers).toHaveLength(1); + + unsubscribe(); + expect(event.subscribers).toHaveLength(0); + + event.fire('foo'); + + expect(handler).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/website/.babelrc b/packages/website/.babelrc new file mode 100644 index 000000000..9e26dfeeb --- /dev/null +++ b/packages/website/.babelrc @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/packages/website/.storybook/main.js b/packages/website/.storybook/main.js new file mode 100644 index 000000000..a8fbec58d --- /dev/null +++ b/packages/website/.storybook/main.js @@ -0,0 +1,28 @@ +/* eslint-env node */ +const {join} = require('path'); +const isProd = process.env.NODE_ENV === 'production'; + +// This custom webpack config allows storybook to use the source files from 'react-vis'. +// Changes can be made and instantly be reflected in storybook to allow quick development +module.exports = { + webpackFinal: config => { + if (isProd) { return config;} + + const reactVisPath = join(__dirname, '../../react-vis/src') + + // Add an alias to 'react-vis' so that it looks in its src directory. + config.resolve.alias['react-vis'] =reactVisPath + config.resolve.modules.push(reactVisPath) + + const jsRule = config.module.rules.find(rule => rule.test.test('test.js')); + // Add the react-vis/src folder to the list of files to compile + jsRule.include.push(reactVisPath) + + const babelLoader = jsRule.use.find(x => x.loader === 'babel-loader'); + if (babelLoader) { + babelLoader.options.rootMode = 'upward' + } + + return config; + } +} diff --git a/packages/website/.storybook/storybook.css b/packages/website/.storybook/storybook.css index 7cc411c30..f75cb06fc 100644 --- a/packages/website/.storybook/storybook.css +++ b/packages/website/.storybook/storybook.css @@ -1,3 +1,8 @@ -html, body, #root { - height: 100%; -} +html, +body, +#root { + height: 100%; + box-sizing: border-box; + position: relative; + margin: 0 +} \ No newline at end of file diff --git a/packages/website/storybook/misc-story.js b/packages/website/storybook/misc-story.js index ba14d3c88..6ab65e811 100644 --- a/packages/website/storybook/misc-story.js +++ b/packages/website/storybook/misc-story.js @@ -1,17 +1,28 @@ /* eslint-env node */ -import React from 'react'; +import React, {useState, useCallback} from 'react'; import {storiesOf} from '@storybook/react'; -import {withKnobs, boolean} from '@storybook/addon-knobs/react'; +import {withKnobs, boolean, button} from '@storybook/addon-knobs/react'; import {SimpleChartWrapper} from './storybook-utils'; import {generateLinearData} from './storybook-data'; -import {LineSeries, ContentClipPath} from 'react-vis'; +import {LineSeries, ContentClipPath, Selection, Window} from 'react-vis'; const data = generateLinearData({randomFactor: 10}); +const highlightData = generateLinearData({}); +const yDomainHighlightData = [ + highlightData.reduce( + (min, cur) => Math.floor(Math.min(min, cur.y)), + Number.MAX_SAFE_INTEGER + ), + highlightData.reduce( + (max, cur) => Math.ceil(Math.max(max, cur.y)), + Number.MIN_SAFE_INTEGER + ) +]; storiesOf('Misc', module) .addDecorator(withKnobs) .addWithJSX('Clip Content', () => { @@ -28,4 +39,49 @@ storiesOf('Misc', module) ); + }) + .addWithJSX('Zoom', () => { + const [zoom, setZoom] = useState(); + const onSelected = useCallback(area => { + setZoom(area); + }, []); + + button('Reset Zoom', () => setZoom(null), 'Zoom'); + + const xDomain = zoom ? [zoom.left, zoom.right] : undefined; + + return ( + + + + + ); + }) + .addWithJSX('Window', () => { + const [zoom, setZoom] = useState({left: 5, right: 10}); + const onMoveComplete = useCallback(area => { + setZoom(area); + }, []); + + const xDomain = [zoom.left, zoom.right]; + + return ( +
+
+ + + + +
+
+ + + + +
+
+ ); }); diff --git a/packages/website/storybook/storybook-utils.js b/packages/website/storybook/storybook-utils.js index bfd996591..b2372df2b 100644 --- a/packages/website/storybook/storybook-utils.js +++ b/packages/website/storybook/storybook-utils.js @@ -39,43 +39,51 @@ export const LINEAR_PALETTE = ['#EF5D28', '#FF9833']; export function SimpleChartWrapper(props) { return ( - {({height, width}) => ( - - {props.noXAxis - ? null - : boolean('X Axis', true, 'General chart options') && } - {props.noYAxis - ? null - : boolean('Y Axis', true, 'General chart options') && } - {props.noVerticalGridLines - ? null - : boolean('vertical gridlines', true, 'General chart options') && ( - - )} - {props.noHorizontalGridLines - ? null - : boolean( - 'horizontal gridlines', - true, - 'General chart options' - ) && } - {props.children} - - )} + {({height, width}) => { + if (!width || !height) { + return null; + } + return ( + + {props.noXAxis + ? null + : boolean('X Axis', true, 'General chart options') && } + {props.noYAxis + ? null + : boolean('Y Axis', true, 'General chart options') && } + {props.noVerticalGridLines + ? null + : boolean( + 'vertical gridlines', + true, + 'General chart options' + ) && } + {props.noHorizontalGridLines + ? null + : boolean( + 'horizontal gridlines', + true, + 'General chart options' + ) && } + {props.children} + + ); + }} ); } diff --git a/packages/website/webpack.config.js b/packages/website/webpack.config.js index 833884df2..6c3420dcb 100644 --- a/packages/website/webpack.config.js +++ b/packages/website/webpack.config.js @@ -16,7 +16,8 @@ module.exports = { }, resolve: { alias: { - react: resolve(__dirname, './node_modules/react') + react: resolve(__dirname, './node_modules/react'), + 'react-vis': resolve('../react-vis/src') } } }; 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"