diff --git a/packages/vx-brush/package.json b/packages/vx-brush/package.json index a37ad2290..cb67513c5 100644 --- a/packages/vx-brush/package.json +++ b/packages/vx-brush/package.json @@ -33,6 +33,9 @@ "react": "^15.0.0-0 || ^16.0.0-0" }, "dependencies": { + "@vx/drag": "^0.0.192", + "@vx/group": "^0.0.192", + "@vx/shape": "^0.0.192", "classnames": "^2.2.5", "prop-types": "^15.6.1", "recompose": "^0.23.1" diff --git a/packages/vx-brush/src/BaseBrush.tsx b/packages/vx-brush/src/BaseBrush.tsx new file mode 100644 index 000000000..d1697025d --- /dev/null +++ b/packages/vx-brush/src/BaseBrush.tsx @@ -0,0 +1,443 @@ +import React from 'react'; +import { Group } from '@vx/group'; +import { Bar } from '@vx/shape'; +import { Drag } from '@vx/drag'; + +import BrushHandle from './BrushHandle'; +import BrushCorner from './BrushCorner'; +import BrushSelection from './BrushSelection'; +import { GeneralStyleShape, MarginShape, Point, BrushShape, ResizeTriggerAreas } from './types'; + +export type BaseBrushProps = { + brushDirection?: 'horizontal' | 'vertical' | 'both'; + width: number; + height: number; + left: number; + top: number; + inheritedMargin?: MarginShape; + onChange?: Function; + handleSize: number; + resizeTriggerAreas?: ResizeTriggerAreas; + onBrushStart?: Function; + onBrushEnd?: Function; + selectedBoxStyle: GeneralStyleShape; + onMouseLeave?: Function; + onMouseUp?: Function; + onMouseMove?: Function; + onClick?: Function; + clickSensitivity: number; + disableDraggingSelection: boolean; +}; + +export type BaseBrushState = BrushShape & { + activeHandle: any; + isBrushing: boolean; +}; + +export default class BaseBrush extends React.Component { + private mouseUpTime: any = 0; + private mouseDownTime: any = 0; + + state = { + start: { x: 0, y: 0 }, + end: { x: 0, y: 0 }, + extent: { + x0: 0, + x1: 0, + y0: 0, + y1: 0, + }, + bounds: { + x0: 0, + x1: this.props.width, + y0: 0, + y1: this.props.height, + }, + isBrushing: false, + activeHandle: null, + }; + + static defaultProps = { + brushDirection: 'both', + inheritedMargin: { + left: 0, + top: 0, + right: 0, + bottom: 0, + }, + onChange: null, + handleSize: 4, + resizeTriggerAreas: ['left', 'right'], + onBrushStart: null, + onBrushEnd: null, + onMouseLeave: null, + onMouseUp: null, + onMouseMove: null, + onClick: null, + disableDraggingSelection: false, + clickSensitivity: 200, + }; + + shouldComponentUpdate(nextProps: BaseBrushProps) { + // @ts-ignore + if (['width', 'height'].some(prop => this.props[prop] !== nextProps[prop])) { + this.setState(() => ({ + bounds: { + x0: 0, + x1: nextProps.width, + y0: 0, + y1: nextProps.height, + }, + })); + return true; + } + + return false; + } + + getExtent(start: Point, end: Point) { + const { brushDirection, width, height } = this.props; + const x0 = brushDirection === 'vertical' ? 0 : Math.min(start.x, end.x); + const x1 = brushDirection === 'vertical' ? width : Math.max(start.x, end.x); + const y0 = brushDirection === 'horizontal' ? 0 : Math.min(start.y, end.y); + const y1 = brushDirection === 'horizontal' ? height : Math.max(start.y, end.y); + + return { + x0, + x1, + y0, + y1, + }; + } + + handleDragStart = (draw: any) => { + const { onBrushStart, left, top, inheritedMargin } = this.props; + const marginLeft = inheritedMargin && inheritedMargin.left ? inheritedMargin.left : 0; + const marginTop = inheritedMargin && inheritedMargin.top ? inheritedMargin.top : 0; + const start = { + x: draw.x + draw.dx - left - marginLeft, + y: draw.y + draw.dy - top - marginTop, + }; + const end = { ...start }; + + if (onBrushStart) { + onBrushStart(start); + } + + this.update((prevBrush: BaseBrushState) => ({ + ...prevBrush, + start, + end, + extent: { + x0: -1, + x1: -1, + y0: -1, + y1: -1, + }, + isBrushing: true, + })); + }; + + handleDragMove = (draw: any) => { + const { left, top, inheritedMargin } = this.props; + if (!draw.isDragging) return; + const marginLeft = inheritedMargin && inheritedMargin.left ? inheritedMargin.left : 0; + const marginTop = inheritedMargin && inheritedMargin.top ? inheritedMargin.top : 0; + const end = { + x: draw.x + draw.dx - left - marginLeft, + y: draw.y + draw.dy - top - marginTop, + }; + this.update((prevBrush: BaseBrushState) => { + const { start } = prevBrush; + const extent = this.getExtent(start, end); + + return { + ...prevBrush, + end, + extent, + }; + }); + }; + + handleDragEnd = () => { + const { onBrushEnd } = this.props; + this.update((prevBrush: BaseBrushState) => { + const { extent } = prevBrush; + const newState = { + ...prevBrush, + start: { + x: extent.x0, + y: extent.y0, + }, + end: { + x: extent.x1, + y: extent.y1, + }, + isBrushing: false, + }; + if (onBrushEnd) { + onBrushEnd(newState); + } + + return newState; + }); + }; + + width() { + const { extent } = this.state; + const { x0, x1 } = extent; + + return Math.max(Math.max(x0, x1) - Math.min(x0, x1), 0); + } + + height() { + const { extent } = this.state; + const { y1, y0 } = extent; + + return Math.max(y1 - y0, 0); + } + + handles(): { + [index: string]: { + x: number; + y: number; + height: number; + width: number; + }; + } { + const { handleSize } = this.props; + const { extent } = this.state; + const { x0, x1, y0, y1 } = extent; + const offset = handleSize / 2; + const width = this.width(); + const height = this.height(); + + return { + top: { + x: x0 - offset, + y: y0 - offset, + height: handleSize, + width: width + handleSize, + }, + bottom: { + x: x0 - offset, + y: y1 - offset, + height: handleSize, + width: width + handleSize, + }, + right: { + x: x1 - offset, + y: y0 - offset, + height: height + handleSize, + width: handleSize, + }, + left: { + x: x0 - offset, + y: y0 - offset, + height: height + handleSize, + width: handleSize, + }, + }; + } + + corners(): { + [index: string]: { + x: number; + y: number; + }; + } { + const { handleSize } = this.props; + const { extent } = this.state; + const { x0, x1, y0, y1 } = extent; + const offset = handleSize / 2; + + return { + topLeft: { + x: Math.min(x0, x1) - offset, + y: Math.min(y0, y1) - offset, + }, + topRight: { + x: Math.max(x0, x1) - offset, + y: Math.min(y0, y1) - offset, + }, + bottomLeft: { + x: Math.min(x0, x1) - offset, + y: Math.max(y0, y1) - offset, + }, + bottomRight: { + x: Math.max(x0, x1) - offset, + y: Math.max(y0, y1) - offset, + }, + }; + } + + update = (updater: any) => { + const { onChange } = this.props; + this.setState(updater, () => { + if (onChange) { + onChange(this.state); + } + }); + }; + + reset() { + const { width, height } = this.props; + this.update(() => ({ + start: undefined, + end: undefined, + extent: { + x0: undefined, + x1: undefined, + y0: undefined, + y1: undefined, + }, + bounds: { + x0: 0, + x1: width, + y0: 0, + y1: height, + }, + isBrushing: false, + activeHandle: undefined, + })); + } + + render() { + const { start, end } = this.state; + const { + top, + left, + width: stageWidth, + height: stageHeight, + handleSize, + onMouseLeave, + onMouseUp, + onMouseMove, + onBrushEnd, + onClick, + resizeTriggerAreas, + selectedBoxStyle, + disableDraggingSelection, + clickSensitivity, + } = this.props; + + const handles = this.handles(); + const corners = this.corners(); + const width = this.width(); + const height = this.height(); + const resizeTriggerAreaSet = new Set(resizeTriggerAreas); + + return ( + + {/* overlay */} + + {({ dragStart, isDragging, dragMove, dragEnd }) => ( + this.reset()} + onClick={(event: React.MouseEvent) => { + const duration = this.mouseUpTime - this.mouseDownTime; + if (onClick && duration < clickSensitivity) onClick(event); + }} + onMouseDown={(event: React.MouseEvent) => { + this.mouseDownTime = new Date(); + dragStart(event); + }} + onMouseLeave={(event: React.MouseEvent) => { + if (onMouseLeave) onMouseLeave(event); + }} + onMouseMove={(event: React.MouseEvent) => { + if (!isDragging && onMouseMove) onMouseMove(event); + if (isDragging) dragMove(event); + }} + onMouseUp={(event: React.MouseEvent) => { + this.mouseUpTime = new Date(); + if (onMouseUp) onMouseUp(event); + dragEnd(event); + }} + style={{ cursor: 'crosshair' }} + /> + )} + + {/* selection */} + {start && end && ( + + )} + {/* handles */} + {start && + end && + Object.keys(handles) + // @ts-ignore + .filter(handleKey => resizeTriggerAreaSet.has(handleKey)) + .map(handleKey => { + const handle = handles[handleKey]; + + return ( + + ); + })} + {/* corners */} + {start && + end && + Object.keys(corners) + // @ts-ignore + .filter(cornerKey => resizeTriggerAreaSet.has(cornerKey)) + .map(cornerKey => { + const corner = corners[cornerKey]; + + return ( + + ); + })} + + ); + } +} diff --git a/packages/vx-brush/src/Brush.tsx b/packages/vx-brush/src/Brush.tsx new file mode 100644 index 000000000..fdf8aa556 --- /dev/null +++ b/packages/vx-brush/src/Brush.tsx @@ -0,0 +1,214 @@ +import React from 'react'; +import BaseBrush, { BaseBrushState } from './BaseBrush'; +import { GeneralStyleShape, MarginShape, Point, ResizeTriggerAreas } from './types'; +import { scaleInvert, getDomainFromExtent } from './utils'; + +const SAFE_PIXEL = 2; +const DEFAULT_COLOR = 'steelblue'; + +export type BrushProps = { + selectedBoxStyle: GeneralStyleShape; + xScale: Function; + yScale: Function; + height: number; + width: number; + onChange: Function; + onBrushStart: Function; + onBrushEnd: Function; + onMouseMove: Function; + onMouseLeave: Function; + onClick: Function; + margin: MarginShape; + brushDirection: 'vertical' | 'horizontal' | 'both'; + resizeTriggerAreas: ResizeTriggerAreas; + brushRegion: 'xAxis' | 'yAxis' | 'chart'; + yAxisOrientation: 'left' | 'right'; + xAxisOrientation: 'top' | 'bottom'; + disableDraggingSelection: boolean; + handleSize: number; +}; + +class Brush extends React.Component { + private BaseBrush: any = React.createRef(); + + static defaultProps = { + xScale: null, + yScale: null, + onChange: null, + height: 0, + width: 0, + selectedBoxStyle: { + fill: DEFAULT_COLOR, + fillOpacity: 0.2, + stroke: DEFAULT_COLOR, + strokeWidth: 1, + strokeOpacity: 0.8, + }, + margin: { + top: 0, + left: 0, + right: 0, + bottom: 0, + }, + handleSize: 4, + brushDirection: 'horizontal', + resizeTriggerAreas: ['left', 'right'], + brushRegion: 'chart', + yAxisOrientation: 'right', + xAxisOrientation: 'bottom', + onBrushStart: null, + onBrushEnd: null, + disableDraggingSelection: false, + onMouseMove: null, + onMouseLeave: null, + onClick: null, + }; + + reset() { + if (this.BaseBrush) { + this.BaseBrush.reset(); + } + } + + handleChange = (brush: BaseBrushState) => { + const { onChange } = this.props; + if (!onChange) return; + const { x0 } = brush.extent; + if (x0 < 0 || typeof x0 === 'undefined') { + onChange(null); + + return; + } + const domain = this.convertRangeToDomain(brush); + onChange(domain); + }; + + convertRangeToDomain(brush: BaseBrushState) { + const { xScale, yScale } = this.props; + const { x0, x1, y0, y1 } = brush.extent; + + const xDomain = getDomainFromExtent(xScale, x0, x1, SAFE_PIXEL); + const yDomain = getDomainFromExtent(yScale, y0, y1, SAFE_PIXEL); + + const domain = { + x0: xDomain.start, + x1: xDomain.end, + xValues: xDomain.values, + y0: yDomain.start, + y1: yDomain.end, + yValues: yDomain.values, + }; + + return domain; + } + + handleBrushStart = (point: Point) => { + const { x, y } = point; + const { onBrushStart, xScale, yScale } = this.props; + const invertedX = scaleInvert(xScale, x); + const invertedY = scaleInvert(yScale, y); + if (onBrushStart) { + onBrushStart({ + // @ts-ignore + x: xScale.invert ? invertedX : xScale.domain()[invertedX], + // @ts-ignore + y: yScale.invert ? invertedY : yScale.domain()[invertedY], + }); + } + }; + + handleBrushEnd = (brush: BaseBrushState) => { + const { onBrushEnd } = this.props; + if (!onBrushEnd) return; + const { x0 } = brush.extent; + if (x0 < 0) { + onBrushEnd(null); + + return; + } + const domain = this.convertRangeToDomain(brush); + onBrushEnd(domain); + }; + + render() { + const { + xScale, + yScale, + height, + width, + margin, + brushDirection, + resizeTriggerAreas, + brushRegion, + yAxisOrientation, + xAxisOrientation, + selectedBoxStyle, + disableDraggingSelection, + onMouseLeave, + onMouseMove, + onClick, + handleSize, + } = this.props; + if (!xScale || !yScale) return null; + + let brushRegionWidth; + let brushRegionHeight; + let left; + let top; + const marginLeft = margin && margin.left ? margin.left : 0; + const marginTop = margin && margin.top ? margin.top : 0; + const marginRight = margin && margin.right ? margin.right : 0; + const marginBottom = margin && margin.bottom ? margin.bottom : 0; + + if (brushRegion === 'chart') { + left = 0; + top = 0; + brushRegionWidth = width; + brushRegionHeight = height; + } else if (brushRegion === 'yAxis') { + top = 0; + brushRegionHeight = height; + if (yAxisOrientation === 'right') { + left = width; + brushRegionWidth = marginRight; + } else { + left = -marginLeft; + brushRegionWidth = marginLeft; + } + } else { + left = 0; + brushRegionWidth = width; + if (xAxisOrientation === 'bottom') { + top = height; + brushRegionHeight = marginBottom; + } else { + top = -marginTop; + brushRegionHeight = marginTop; + } + } + + return ( + + ); + } +} + +export default Brush; diff --git a/packages/vx-brush/src/BrushCorner.tsx b/packages/vx-brush/src/BrushCorner.tsx new file mode 100644 index 000000000..2d2c209e2 --- /dev/null +++ b/packages/vx-brush/src/BrushCorner.tsx @@ -0,0 +1,190 @@ +/* eslint react/jsx-handler-names: 0 */ +import React, { SVGProps } from 'react'; +import { Drag } from '@vx/drag'; +import { GeneralStyleShape } from './types'; +import { BaseBrushState as BrushState } from './BaseBrush'; + +export type BrushCornerProps = SVGProps & { + stageWidth: number; + stageHeight: number; + brush: BrushState; + updateBrush: Function; + onBrushEnd?: Function; + type: string; + style?: GeneralStyleShape; +}; + +export type BrushCornerState = {}; + +export default class BrushCorner extends React.Component { + static defaultProps = { + style: {}, + }; + + cornerDragMove = (drag: any) => { + const { updateBrush, type } = this.props; + if (!drag.isDragging) return; + updateBrush((prevBrush: BrushState) => { + const { start, end } = prevBrush; + + const xMax = Math.max(start.x, end.x); + const xMin = Math.min(start.x, end.x); + const yMax = Math.max(start.y, end.y); + const yMin = Math.min(start.y, end.y); + + let moveX = 0; + let moveY = 0; + let nextState = {}; + + switch (type) { + case 'topRight': + moveX = xMax + drag.dx; + moveY = yMin + drag.dy; + nextState = { + ...prevBrush, + activeHandle: type, + extent: { + ...prevBrush.extent, + x0: Math.max(Math.min(moveX, start.x), prevBrush.bounds.x0), + x1: Math.min(Math.max(moveX, start.x), prevBrush.bounds.x1), + y0: Math.max(Math.min(moveY, end.y), prevBrush.bounds.y0), + y1: Math.min(Math.max(moveY, end.y), prevBrush.bounds.y1), + }, + }; + break; + case 'topLeft': + moveX = xMin + drag.dx; + moveY = yMin + drag.dy; + nextState = { + ...prevBrush, + activeHandle: type, + extent: { + ...prevBrush.extent, + x0: Math.max(Math.min(moveX, end.x), prevBrush.bounds.x0), + x1: Math.min(Math.max(moveX, end.x), prevBrush.bounds.x1), + y0: Math.max(Math.min(moveY, end.y), prevBrush.bounds.y0), + y1: Math.min(Math.max(moveY, end.y), prevBrush.bounds.y1), + }, + }; + break; + case 'bottomLeft': + moveX = xMin + drag.dx; + moveY = yMax + drag.dy; + nextState = { + ...prevBrush, + activeHandle: type, + extent: { + ...prevBrush.extent, + x0: Math.max(Math.min(moveX, end.x), prevBrush.bounds.x0), + x1: Math.min(Math.max(moveX, end.x), prevBrush.bounds.x1), + y0: Math.max(Math.min(moveY, start.y), prevBrush.bounds.y0), + y1: Math.min(Math.max(moveY, start.y), prevBrush.bounds.y1), + }, + }; + break; + case 'bottomRight': + moveX = xMax + drag.dx; + moveY = yMax + drag.dy; + nextState = { + ...prevBrush, + activeHandle: type, + extent: { + ...prevBrush.extent, + x0: Math.max(Math.min(moveX, start.x), prevBrush.bounds.x0), + x1: Math.min(Math.max(moveX, start.x), prevBrush.bounds.x1), + y0: Math.max(Math.min(moveY, start.y), prevBrush.bounds.y0), + y1: Math.min(Math.max(moveY, start.y), prevBrush.bounds.y1), + }, + }; + break; + default: + break; + } + + return nextState; + }); + }; + + cornerDragEnd = () => { + const { updateBrush, onBrushEnd } = this.props; + updateBrush((prevBrush: BrushState) => { + const { start, end, extent } = prevBrush; + start.x = Math.min(extent.x0, extent.x1); + start.y = Math.min(extent.y0, extent.y0); + end.x = Math.max(extent.x0, extent.x1); + end.y = Math.max(extent.y0, extent.y1); + const nextBrush = { + ...prevBrush, + start, + end, + activeHandle: undefined, + domain: { + x0: Math.min(start.x, end.x), + x1: Math.max(start.x, end.x), + y0: Math.min(start.y, end.y), + y1: Math.max(start.y, end.y), + }, + }; + if (onBrushEnd) { + onBrushEnd(nextBrush); + } + + return nextBrush; + }); + }; + + render() { + const { + type, + brush, + updateBrush, + stageWidth, + stageHeight, + style: styleProp, + onBrushEnd, + ...restProps + } = this.props; + const cursor = type === 'topLeft' || type === 'bottomRight' ? 'nwse-resize' : 'nesw-resize'; + const pointerEvents = brush.activeHandle || brush.isBrushing ? 'none' : 'all'; + const style = { + cursor, + pointerEvents, + ...styleProp, + }; + + return ( + + {({ dragMove, dragEnd, dragStart, isDragging }) => ( + + {isDragging && ( + + )} + + + )} + + ); + } +} diff --git a/packages/vx-brush/src/BrushHandle.tsx b/packages/vx-brush/src/BrushHandle.tsx new file mode 100644 index 000000000..cf68e9316 --- /dev/null +++ b/packages/vx-brush/src/BrushHandle.tsx @@ -0,0 +1,161 @@ +/* eslint react/jsx-handler-names: 0 */ +import React from 'react'; +import { Drag } from '@vx/drag'; +import { DragShape } from './types'; +import { BaseBrushState as BrushState } from './BaseBrush'; + +export type BrushHandleProps = { + stageWidth: number; + stageHeight: number; + brush: BrushState; + updateBrush: Function; + onBrushEnd?: Function; + handle: DragShape; + type: string; +}; + +export default class BrushHandle extends React.Component { + handleDragMove = (drag: any) => { + const { updateBrush, type } = this.props; + if (!drag.isDragging) return; + updateBrush((prevBrush: BrushState) => { + const { start, end } = prevBrush; + let nextState = {}; + let move = 0; + const xMax = Math.max(start.x, end.x); + const xMin = Math.min(start.x, end.x); + const yMax = Math.max(start.y, end.y); + const yMin = Math.min(start.y, end.y); + switch (type) { + case 'right': + move = xMax + drag.dx; + nextState = { + ...prevBrush, + activeHandle: type, + extent: { + ...prevBrush.extent, + x0: Math.max(Math.min(move, start.x), prevBrush.bounds.x0), + x1: Math.min(Math.max(move, start.x), prevBrush.bounds.x1), + }, + }; + break; + case 'left': + move = xMin + drag.dx; + nextState = { + ...prevBrush, + activeHandle: type, + extent: { + ...prevBrush.extent, + x0: Math.min(move, end.x), + x1: Math.max(move, end.x), + }, + }; + break; + case 'bottom': + move = yMax + drag.dy; + nextState = { + ...prevBrush, + activeHandle: type, + extent: { + ...prevBrush.extent, + y0: Math.min(move, start.y), + y1: Math.max(move, start.y), + }, + }; + break; + case 'top': + move = yMin + drag.dy; + nextState = { + ...prevBrush, + activeHandle: type, + extent: { + ...prevBrush.extent, + y0: Math.min(move, end.y), + y1: Math.max(move, end.y), + }, + }; + break; + default: + break; + } + + return nextState; + }); + }; + + handleDragEnd = () => { + const { updateBrush, onBrushEnd } = this.props; + updateBrush((prevBrush: BrushState) => { + const { start, end, extent } = prevBrush; + start.x = Math.min(extent.x0, extent.x1); + start.y = Math.min(extent.y0, extent.y0); + end.x = Math.max(extent.x0, extent.x1); + end.y = Math.max(extent.y0, extent.y1); + const nextBrush = { + ...prevBrush, + start, + end, + activeHandle: undefined, + isBrushing: false, + domain: { + x0: Math.min(start.x, end.x), + x1: Math.max(start.x, end.x), + y0: Math.min(start.y, end.y), + y1: Math.max(start.y, end.y), + }, + }; + if (onBrushEnd) { + onBrushEnd(nextBrush); + } + + return nextBrush; + }); + }; + + render() { + const { stageWidth, stageHeight, brush, type, handle } = this.props; + const { x, y, width, height } = handle; + const cursor = type === 'right' || type === 'left' ? 'ew-resize' : 'ns-resize'; + + return ( + + {({ isDragging, dragStart, dragEnd, dragMove }) => ( + + {handle.isDragging && ( + + )} + + + )} + + ); + } +} diff --git a/packages/vx-brush/src/BrushSelection.tsx b/packages/vx-brush/src/BrushSelection.tsx new file mode 100644 index 000000000..246137702 --- /dev/null +++ b/packages/vx-brush/src/BrushSelection.tsx @@ -0,0 +1,157 @@ +/* eslint react/jsx-handler-names: 0 */ +import React from 'react'; +import { Drag } from '@vx/drag'; +import { BaseBrushState as BrushState } from './BaseBrush'; + +export type BrushSelectionProps = { + width: number; + height: number; + stageWidth: number; + stageHeight: number; + brush: BrushState; + updateBrush: Function; + onBrushEnd?: Function; + disableDraggingSelection: boolean; + onMouseLeave: Function; + onMouseMove: Function; + onMouseUp: Function; + onClick: Function; +}; + +export default class BrushSelection extends React.Component { + static defaultProps = { + onMouseLeave: null, + onMouseUp: null, + onMouseMove: null, + onClick: null, + }; + + selectionDragMove = (drag: any) => { + const { updateBrush } = this.props; + updateBrush((prevBrush: BrushState) => { + const { x: x0, y: y0 } = prevBrush.start; + const { x: x1, y: y1 } = prevBrush.end; + const validDx = + drag.dx > 0 + ? Math.min(drag.dx, prevBrush.bounds.x1 - x1) + : Math.max(drag.dx, prevBrush.bounds.x0 - x0); + + const validDy = + drag.dy > 0 + ? Math.min(drag.dy, prevBrush.bounds.y1 - y1) + : Math.max(drag.dy, prevBrush.bounds.y0 - y0); + + return { + ...prevBrush, + isBrushing: true, + extent: { + ...prevBrush.extent, + x0: x0 + validDx, + x1: x1 + validDx, + y0: y0 + validDy, + y1: y1 + validDy, + }, + }; + }); + }; + + selectionDragEnd = () => { + const { updateBrush, onBrushEnd } = this.props; + updateBrush((prevBrush: BrushState) => { + const nextBrush = { + ...prevBrush, + isBrushing: false, + start: { + ...prevBrush.start, + x: Math.min(prevBrush.extent.x0, prevBrush.extent.x1), + y: Math.min(prevBrush.extent.y0, prevBrush.extent.y1), + }, + end: { + ...prevBrush.end, + x: Math.max(prevBrush.extent.x0, prevBrush.extent.x1), + y: Math.max(prevBrush.extent.y0, prevBrush.extent.y1), + }, + }; + if (onBrushEnd) { + onBrushEnd(nextBrush); + } + + return nextBrush; + }); + }; + + render() { + const { + width, + height, + stageWidth, + stageHeight, + brush, + updateBrush, + disableDraggingSelection, + onBrushEnd, + onMouseLeave, + onMouseMove, + onMouseUp, + onClick, + ...restProps + } = this.props; + + return ( + + {({ isDragging, dragStart, dragEnd, dragMove }) => ( + + {isDragging && ( + + )} + { + if (onMouseLeave) onMouseLeave(event); + }} + onMouseMove={event => { + dragMove(event); + if (onMouseMove) onMouseMove(event); + }} + onMouseUp={event => { + dragEnd(event); + if (onMouseUp) onMouseUp(event); + }} + onClick={event => { + if (onClick) onClick(event); + }} + // @ts-ignore + style={{ + pointerEvents: brush.isBrushing || brush.activeHandle ? 'none' : 'all', + cursor: disableDraggingSelection ? null : 'move', + }} + {...restProps} + /> + + )} + + ); + } +} diff --git a/packages/vx-brush/src/brushes/BoxBrush.jsx b/packages/vx-brush/src/brushes/BoxBrush.jsx deleted file mode 100644 index 9ae80fcb0..000000000 --- a/packages/vx-brush/src/brushes/BoxBrush.jsx +++ /dev/null @@ -1,52 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import cx from 'classnames'; - -const propTypes = { - brush: PropTypes.shape({ - start: PropTypes.shape({ x: PropTypes.number, y: PropTypes.number }), - end: PropTypes.shape({ x: PropTypes.number, y: PropTypes.number }), - isBrushing: PropTypes.bool, - }), - className: PropTypes.string, - fill: PropTypes.string, - stroke: PropTypes.string, - strokeWidth: PropTypes.oneOf([PropTypes.string, PropTypes.number]), -}; - -function BoxBrush({ - brush, - className, - fill = 'rgba(102, 181, 245, 0.1)', - stroke = 'rgba(102, 181, 245, 1)', - strokeWidth = 1, - ...otherProps -}) { - const { start, end, isBrushing } = brush; - if (!start) return null; - if (!end) return null; - const x = end.x > start.x ? start.x : end.x; - const y = end.y > start.y ? start.y : end.y; - const width = Math.abs(start.x - end.x); - const height = Math.abs(start.y - end.y); - return ( - - {isBrushing && ( - - )} - - ); -} - -BoxBrush.propTypes = propTypes; - -export default BoxBrush; diff --git a/packages/vx-brush/src/enhancers/withBrush.js b/packages/vx-brush/src/enhancers/withBrush.js deleted file mode 100644 index 63e6d0603..000000000 --- a/packages/vx-brush/src/enhancers/withBrush.js +++ /dev/null @@ -1,51 +0,0 @@ -import { compose, withState, withHandlers } from 'recompose'; - -export default compose( - withState('brush', 'updateBrush', { - start: undefined, - end: undefined, - domain: undefined, - isBrushing: false, - }), - withHandlers({ - onBrushStart: ({ updateBrush }) => ({ x, y }) => { - updateBrush(prevState => ({ - ...prevState, - start: { x, y }, - isBrushing: true, - end: undefined, - domain: undefined, - })); - }, - onBrushDrag: ({ updateBrush }) => ({ x, y }) => { - updateBrush(prevState => ({ - ...prevState, - end: { x, y }, - domain: undefined, - })); - }, - onBrushEnd: ({ updateBrush }) => ({ x, y }) => { - updateBrush(prevState => { - const { start } = prevState; - return { - ...prevState, - isBrushing: false, - domain: { - x0: Math.min(start.x, x), - x1: Math.max(start.x, x), - y0: Math.min(start.y, y), - y1: Math.max(start.y, y), - }, - }; - }); - }, - onBrushReset: ({ updateBrush }) => (/** event */) => { - updateBrush((/** prevState */) => ({ - start: undefined, - end: undefined, - domain: undefined, - isBrushing: false, - })); - }, - }), -); diff --git a/packages/vx-brush/src/index.js b/packages/vx-brush/src/index.js deleted file mode 100644 index 666908daf..000000000 --- a/packages/vx-brush/src/index.js +++ /dev/null @@ -1,4 +0,0 @@ -export { default as withBrush } from './enhancers/withBrush'; -export { default as BoxBrush } from './brushes/BoxBrush'; -export { default as constrainToRegion } from './utils/constrainToRegion'; -export { default as getCoordsFromEvent } from './utils/getCoordsFromEvent'; diff --git a/packages/vx-brush/src/index.ts b/packages/vx-brush/src/index.ts new file mode 100644 index 000000000..ac268b21e --- /dev/null +++ b/packages/vx-brush/src/index.ts @@ -0,0 +1 @@ +export { default as Brush } from './Brush'; diff --git a/packages/vx-brush/src/types.ts b/packages/vx-brush/src/types.ts new file mode 100644 index 000000000..f58e766af --- /dev/null +++ b/packages/vx-brush/src/types.ts @@ -0,0 +1,50 @@ +export type Point = { + x: number; + y: number; +}; + +export type Bound = { + x0: number; + x1: number; + y0: number; + y1: number; +}; + +export type GeneralStyleShape = { + stroke: string; + strokeWidth: number; + strokeOpacity: number; + fill: string; + fillOpacity: number; +}; + +export type MarginShape = { + top?: number; + left?: number; + right?: number; + bottom?: number; +}; + +export type BrushShape = { + start: Point; + end: Point; + extent: Bound; + bounds: Bound; +}; + +export type DragShape = { + x?: number; + y?: number; + dx?: number; + dy?: number; + isDragging?: boolean; + dragEnd?: Function; + dragMove?: Function; + dragStart?: Function; + width: number; + height: number; +}; + +export type ResizeTriggerAreas = [ + 'left' | 'right' | 'top' | 'bottom' | 'topLeft' | 'topRight' | 'bottomLeft' | 'bottomRight', +]; diff --git a/packages/vx-brush/src/utils.ts b/packages/vx-brush/src/utils.ts new file mode 100644 index 000000000..b0512901b --- /dev/null +++ b/packages/vx-brush/src/utils.ts @@ -0,0 +1,47 @@ +export function scaleInvert(scale: any, value: number) { + // Test if the scale is an ordinalScale or not, + // Since an ordinalScale doesn't support invert function. + if (!scale.invert) { + const [start, end] = scale.range(); + let i = 0; + const width = (scale.step() * (end - start)) / Math.abs(end - start); + if (width > 0) { + while (value > start + width * (i + 1)) { + i += 1; + } + } else { + while (value < start + width * (i + 1)) { + i += 1; + } + } + + return i; + } + + return scale.invert(value); +} + +export function getDomainFromExtent(scale: any, start: number, end: number, tolerentDelta: number) { + let domain; + const invertedStart = scaleInvert(scale, start + (start < end ? -tolerentDelta : tolerentDelta)); + const invertedEnd = scaleInvert(scale, end + (end < start ? -tolerentDelta : tolerentDelta)); + const minValue = Math.min(invertedStart, invertedEnd); + const maxValue = Math.max(invertedStart, invertedEnd); + if (scale.invert) { + domain = { + start: minValue, + end: maxValue, + }; + } else { + const values = []; + const scaleDomain = scale.domain(); + for (let i = minValue; i <= maxValue; i += 1) { + values.push(scaleDomain[i]); + } + domain = { + values, + }; + } + + return domain; +} diff --git a/packages/vx-brush/src/utils/constrainToRegion.js b/packages/vx-brush/src/utils/constrainToRegion.js deleted file mode 100644 index 80107d754..000000000 --- a/packages/vx-brush/src/utils/constrainToRegion.js +++ /dev/null @@ -1,7 +0,0 @@ -export default function constrainToRegion({ region, x, y }) { - const { x0, x1, y0, y1 } = region; - return { - x: x < x0 ? x0 : x > x1 ? x1 : x, - y: y < y0 ? y0 : y > y1 ? y1 : y, - }; -} diff --git a/packages/vx-brush/src/utils/getCoordsFromEvent.js b/packages/vx-brush/src/utils/getCoordsFromEvent.js deleted file mode 100644 index 875019bea..000000000 --- a/packages/vx-brush/src/utils/getCoordsFromEvent.js +++ /dev/null @@ -1,19 +0,0 @@ -export default function getCoordsFromEvent(node, event) { - if (!node) return; - const svg = node.ownerSVGElement || node; - if (svg.createSVGPoint) { - let point = svg.createSVGPoint(); - point.x = event.clientX; - point.y = event.clientY; - point = point.matrixTransform(node.getScreenCTM().inverse()); - return { - x: point.x, - y: point.y, - }; - } - const rect = node.getBoundingClientRect(); - return { - x: event.clientX - rect.left - node.clientLeft, - y: event.clientY - rect.top - node.clientTop, - }; -} diff --git a/packages/vx-brush/test/BoxBrush.test.js b/packages/vx-brush/test/BoxBrush.test.js deleted file mode 100644 index e784aaaa1..000000000 --- a/packages/vx-brush/test/BoxBrush.test.js +++ /dev/null @@ -1,7 +0,0 @@ -import { BoxBrush } from '../src'; - -describe('', () => { - test('it should be defined', () => { - expect(BoxBrush).toBeDefined(); - }); -}); diff --git a/packages/vx-brush/test/Brush.test.tsx b/packages/vx-brush/test/Brush.test.tsx new file mode 100644 index 000000000..4d395a37f --- /dev/null +++ b/packages/vx-brush/test/Brush.test.tsx @@ -0,0 +1,7 @@ +import { Brush } from '../src'; + +describe('', () => { + test('it should be defined', () => { + expect(Brush).toBeDefined(); + }); +}); diff --git a/packages/vx-demo/src/components/gallery.js b/packages/vx-demo/src/components/gallery.js index 125fe2825..cf61408c1 100644 --- a/packages/vx-demo/src/components/gallery.js +++ b/packages/vx-demo/src/components/gallery.js @@ -42,6 +42,7 @@ import Threshold from './tiles/threshold'; import Chord from './tiles/chord'; import Polygons from './tiles/polygons'; import ZoomI from './tiles/zoom-i'; +import BrushChart from './tiles/brush'; const items = [ '#242424', @@ -63,6 +64,23 @@ export default function() { return (
+ + +
+
+ + {({ width, height }) => } + +
+
+
Brush
+
+
{''}
+
+
+
+ +
diff --git a/packages/vx-demo/src/components/tiles/brush.js b/packages/vx-demo/src/components/tiles/brush.js new file mode 100644 index 000000000..9e75485ab --- /dev/null +++ b/packages/vx-demo/src/components/tiles/brush.js @@ -0,0 +1,199 @@ +import React, { useState } from 'react'; +import { Group } from '@vx/group'; +import { AreaClosed, Bar } from '@vx/shape'; +import { AxisLeft, AxisBottom } from '@vx/axis'; +import { curveMonotoneX } from '@vx/curve'; +import { scaleTime, scaleLinear } from '@vx/scale'; +import { appleStock } from '@vx/mock-data'; +import { Brush } from '@vx/brush'; +import { PatternLines } from '@vx/pattern'; + +/** + * Initialize some variables + */ +const stock = appleStock.slice(1200); +const min = (arr, fn) => Math.min(...arr.map(fn)); +const max = (arr, fn) => Math.max(...arr.map(fn)); +const extent = (arr, fn) => [min(arr, fn), max(arr, fn)]; +const axisColor = '#fff'; +const axisBottomTickLabelProps = { + textAnchor: 'middle', + fontFamily: 'Arial', + fontSize: 10, + fill: axisColor, +}; +const axisLeftTickLabelProps = { + dx: '-0.25em', + dy: '0.25em', + fontFamily: 'Arial', + fontSize: 10, + textAnchor: 'end', + fill: axisColor, +}; + +// accessors +const xStock = d => new Date(d.date); +const yStock = d => d.close; + +function AreaChart({ + data, + width, + height, + yMax, + margin, + xScale, + yScale, + axis = false, + top, + left, + children, +}) { + return ( + + + + + + + + {axis && ( + 520 ? 10 : 5} + stroke={axisColor} + tickStroke={axisColor} + tickLabelProps={() => axisBottomTickLabelProps} + /> + )} + {axis && ( + axisLeftTickLabelProps} + /> + )} + xScale(xStock(d))} + y={d => yScale(yStock(d))} + yScale={yScale} + strokeWidth={1} + stroke="url(#gradient)" + fill="url(#gradient)" + curve={curveMonotoneX} + /> + + {children} + + ); +} + +function BrushChart({ + width, + height, + margin = { + top: 50, + left: 50, + bottom: 0, + right: 20, + }, +}) { + const [filteredStock, setFilteredStock] = useState(stock); + + function onBrushChange(domain) { + if (!domain) return; + const { x0, x1, y0, y1 } = domain; + const stockCopy = stock.filter(s => { + const x = xStock(s).getTime(); + const y = yStock(s); + return x > x0 && x < x1 && y > y0 && y < y1; + }); + setFilteredStock(stockCopy); + } + + const brushMargin = { top: 0, bottom: 20, left: 50, right: 20 }; + + // bounds + const xMax = Math.max(width - margin.left - margin.right, 0); + const yMax = Math.max(height * 0.6 - margin.top - margin.bottom, 0); + const xBrushMax = Math.max(width - brushMargin.left - brushMargin.right, 0); + const yBrushMax = Math.max(120 - brushMargin.top - brushMargin.bottom, 0); + + // scales + const xScale = scaleTime({ + range: [0, xMax], + domain: extent(filteredStock, xStock), + }); + const yScale = scaleLinear({ + range: [yMax, 0], + domain: [0, max(filteredStock, yStock) + yMax / 3], + nice: true, + }); + const xBrushScale = scaleTime({ + range: [0, xBrushMax], + domain: extent(stock, xStock), + }); + const yBrushScale = scaleLinear({ + range: [yBrushMax, 0], + domain: [0, max(stock, yStock) + yBrushMax / 3], + nice: true, + }); + + return ( +
+ + + + + + + + +
+ ); +} + +export default BrushChart; diff --git a/packages/vx-demo/src/pages/brush.js b/packages/vx-demo/src/pages/brush.js new file mode 100644 index 000000000..9bc53c5ea --- /dev/null +++ b/packages/vx-demo/src/pages/brush.js @@ -0,0 +1,20 @@ +import React from 'react'; +import Show from '../components/show'; +import BrushChart from '../components/tiles/brush'; + +export default () => { + return ( + + {`import React from 'react';`} + + ); +}; diff --git a/packages/vx-responsive/test/ScaleSVG.test.tsx b/packages/vx-responsive/test/ScaleSVG.test.tsx index 3d36ce1bf..4c9782bcf 100644 --- a/packages/vx-responsive/test/ScaleSVG.test.tsx +++ b/packages/vx-responsive/test/ScaleSVG.test.tsx @@ -9,6 +9,7 @@ describe('', () => { }); test('it should expose its ref via an innerRef prop', () => { + // eslint-disable-next-line require-await return new Promise(done => { const refCallback = (n: SVGSVGElement) => { expect(n.tagName).toEqual('svg'); diff --git a/packages/vx-vx/test/index.test.js b/packages/vx-vx/test/index.test.js index 31dc7bb71..69a53a184 100644 --- a/packages/vx-vx/test/index.test.js +++ b/packages/vx-vx/test/index.test.js @@ -18,10 +18,6 @@ describe('vx', () => { expect(vx.withBoundingRects).toBeDefined(); }); - test('it should export @vx/brush', () => { - expect(vx.withBrush).toBeDefined(); - }); - test('it should export @vx/clip-path', () => { expect(vx.ClipPath).toBeDefined(); });