Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Create ZoomHandler #1354

Open
wants to merge 7 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 14 additions & 6 deletions .eslintrc
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
"extends": [
"eslint:recommended",
"plugin:react/recommended",
"plugin:react-hooks/recommended",
"plugin:jest/recommended",
"prettier",
"prettier/react"
Expand All @@ -19,8 +20,8 @@
"**/dist/",
"**/es/"
],
"settings":{
"react":{
"settings": {
"react": {
"version": "detect"
}
},
Expand All @@ -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"
}
}
}
1 change: 1 addition & 0 deletions packages/react-vis/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions packages/react-vis/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
179 changes: 179 additions & 0 deletions packages/react-vis/src/plot/selection.js
Original file line number Diff line number Diff line change
@@ -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 (
<rect
className="rv-highlight"
pointerEvents="none"
fill={color}
fillOpacity={opacity}
x={bounds.left}
y={bounds.top}
style={style}
width={Math.max(0, bounds.right - bounds.left)}
height={Math.max(0, bounds.bottom - bounds.top)}
/>
);
}
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};
}
157 changes: 157 additions & 0 deletions packages/react-vis/src/plot/window.js
Original file line number Diff line number Diff line change
@@ -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 (
<rect
cursor="move"
transform={`translate(${marginLeft}, ${marginTop})`}
className="rv-highlight"
{...pixelBounds}
fill={color}
fillOpacity={opacity}
style={style}
onMouseDown={onMouseDown}
onMouseUp={onMouseUp}
/>
);
}
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};
}
Loading