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

Inertial Panning #63

Open
wants to merge 5 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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ dist
.DS_Store
.nyc_output
coverage
storybook-static
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ MapInteraction doesn't require any props. It will control its own internal state
// contents appear at actual size, greater than 1 is zoomed, and between 0 and 1 is shrunken.
scale: PropTypes.number,
// The distance in pixels to translate the contents by.
translation: PropTypes.shape({ x: PropTypes.number, y: PropTypes.number }),
translation: PropTypes.shape({ x: PropTypes.number, y: PropTypes.number }),
}),

defaultValue: PropTypes.shape({
Expand All @@ -107,6 +107,12 @@ MapInteraction doesn't require any props. It will control its own internal state
// changed via zooming, in order to keep the focal point beneath the cursor. This prop does not change the behavior of the `translation` prop.
disablePan: PropTypes.bool,

// Stops the inertia or momentum of MapInteract after panning.
disableInertialPanning: PropTypes.bool,

// Determines how fast the velocity is reduced from inertial panning. Must be between 0 and 1. Numbers closer to 0 will stop faster.
frictionCoef: PropTypes.number,

// Apply a limit to the translation in any direction in pixel values. The default is unbounded.
translationBounds: PropTypes.shape({
xMin: PropTypes.number, xMax: PropTypes.number, yMin: PropTypes.number, yMax: PropTypes.number
Expand Down
2 changes: 2 additions & 0 deletions example/src/App.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ class App extends Component {
scale={scale}
translation={translation}
onChange={({ scale, translation }) => this.setState({ scale, translation })}
onStoppedMoving={() => console.log("stopped moving")}
disableInertialPanning={false}
defaultScale={1}
defaultTranslation={{ x: 0, y: 0 }}
minScale={0.05}
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "react-map-interaction",
"version": "2.0.0",
"version": "2.0.1",
"description": "'Add map like zooming and dragging to any element'",
"main": "dist/react-map-interaction.js",
"scripts": {
Expand Down
186 changes: 171 additions & 15 deletions src/MapInteraction.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,12 @@ export class MapInteractionControlled extends Component {
translation: translationShape.isRequired,
}).isRequired,
onChange: PropTypes.func.isRequired,
onStoppedMoving: PropTypes.func,

disableZoom: PropTypes.bool,
disablePan: PropTypes.bool,
disableInertialPanning: PropTypes.bool,
frictionCoef: PropTypes.number,
translationBounds: PropTypes.shape({
xMin: PropTypes.number, xMax: PropTypes.number, yMin: PropTypes.number, yMax: PropTypes.number
}),
Expand All @@ -45,7 +48,8 @@ export class MapInteractionControlled extends Component {
btnClass: PropTypes.string,
plusBtnClass: PropTypes.string,
minusBtnClass: PropTypes.string,
controlsClass: PropTypes.string
controlsClass: PropTypes.string,
wheelBehavior: PropTypes.oneOf(['zoom', 'x-scroll', 'y-scroll'])
};
}

Expand All @@ -56,7 +60,10 @@ export class MapInteractionControlled extends Component {
showControls: false,
translationBounds: {},
disableZoom: false,
disablePan: false
disablePan: false,
disableInertialPanning: false,
frictionCoef: 0.85,
wheelBehavior: 'zoom'
};
}

Expand All @@ -69,6 +76,21 @@ export class MapInteractionControlled extends Component {

this.startPointerInfo = undefined;

this.prevDragThrottled = false;
this.prevDragPos = {
time: undefined,
pos: {
x: 0,
y: 0
}
};
this.dragVelocity = {
x: 0,
y: 0,
}
this.inertialPanningLastRaf = undefined;
this.inertialPanningRafID = undefined;

this.onMouseDown = this.onMouseDown.bind(this);
this.onTouchStart = this.onTouchStart.bind(this);

Expand All @@ -79,6 +101,8 @@ export class MapInteractionControlled extends Component {
this.onTouchEnd = this.onTouchEnd.bind(this);

this.onWheel = this.onWheel.bind(this);

this.inertialPanning = this.inertialPanning.bind(this);
}

componentDidMount() {
Expand Down Expand Up @@ -134,19 +158,31 @@ export class MapInteractionControlled extends Component {
onMouseDown(e) {
e.preventDefault();
this.setPointerState([e]);
this.stopInertialPanning();
}

onTouchStart(e) {
e.preventDefault();
this.setPointerState(e.touches);
this.stopInertialPanning();
}

onMouseUp(e) {
this.setPointerState();
this.triggerInertialPanning();

if (this.props.disableInertialPanning) {
this.props.onStoppedMoving();
}
}

onTouchEnd(e) {
this.setPointerState(e.touches);
this.triggerInertialPanning();

if (this.props.disableInertialPanning) {
this.props.onStoppedMoving();
}
}

onMouseMove(e) {
Expand Down Expand Up @@ -187,6 +223,21 @@ export class MapInteractionControlled extends Component {

const shouldPreventTouchEndDefault = Math.abs(dragX) > 1 || Math.abs(dragY) > 1;

// Track drag positions every 150ms for inertial panning velocity calculations.
if (!this.props.disableInertialPanning && !this.prevDragThrottled) {
this.prevDragThrottled = true;

this.prevDragPos = {
time: performance.now(),
pos: {
x: newTranslation.x,
y: newTranslation.y
}
};

setTimeout(() => this.prevDragThrottled = false, 150)
}

this.setState({
shouldPreventTouchEndDefault
}, () => {
Expand All @@ -198,24 +249,46 @@ export class MapInteractionControlled extends Component {
}

onWheel(e) {
if (this.props.disableZoom) {
return;
}

e.preventDefault();
e.stopPropagation();

const scaleChange = 2 ** (e.deltaY * 0.002);
if (this.props.wheelBehavior === 'zoom') {
if (this.props.disableZoom) {
return;
}

const newScale = clamp(
this.props.minScale,
this.props.value.scale + (1 - scaleChange),
this.props.maxScale
);
const scaleChange = 2 ** (e.deltaY * 0.002);

const newScale = clamp(
this.props.minScale,
this.props.value.scale + (1 - scaleChange),
this.props.maxScale
);

const mousePos = this.clientPosToTranslatedPos({ x: e.clientX, y: e.clientY });
const mousePos = this.clientPosToTranslatedPos({ x: e.clientX, y: e.clientY });

this.scaleFromPoint(newScale, mousePos);
this.scaleFromPoint(newScale, mousePos);
} else {
const translation = this.props.value.translation;

const scollChange = e.deltaY;

const newTranslation = {
x: translation.x + (this.props.wheelBehavior === 'x-scroll' ? (1 - scollChange) : 0),
y: translation.y + (this.props.wheelBehavior === 'y-scroll' ? (1 - scollChange) : 0)
};

this.props.onChange({
scale: this.props.value.scale,
translation: this.clampTranslation(newTranslation)
})
}
}

onStoppedMoving() {
if (typeof this.props.onStoppedMoving !== "undefined") {
this.props.onStoppedMoving();
}
}

setPointerState(pointers) {
Expand Down Expand Up @@ -245,6 +318,85 @@ export class MapInteractionControlled extends Component {
};
}

calculateDragVelocity(timestamp, pos) {
const timeDiff = timestamp - this.prevDragPos.time;
const clampedPrevDragPos = this.clampTranslation(this.prevDragPos.pos);
const vX = (pos.x - clampedPrevDragPos.x) / timeDiff;
const vY = (pos.y - clampedPrevDragPos.y) / timeDiff;

this.dragVelocity = {x: vX, y: vY};

}

stopInertialPanning() {
if (!this.disablePan && !this.props.disableInertialPanning) {
window.cancelAnimationFrame(this.inertialPanningRafID);
}
}

triggerInertialPanning() {
if (!this.disablePan && !this.props.disableInertialPanning) {

this.calculateDragVelocity(performance.now(), this.props.value.translation);

// Clear previous drag position to avoid triggering again with the wrong values.
this.prevDragPos = {
time: undefined,
pos: {
x: 0,
y: 0
}
};

this.prevDragThrottled = false;
this.inertialPanningLastRaf = undefined;

this.inertialPanning();
}
}

inertialPanning(timestamp) {
if (!this.disablePan && !this.props.disableInertialPanning) {
if (typeof timestamp === "undefined") {
timestamp = performance.now();
}

// Stop running when velocity drops to a negligible number.
if (Math.abs(this.dragVelocity.x) > 0.01 || Math.abs(this.dragVelocity.y) > 0.01) {
// Elapsed time between rafs in ms.
let elapsedTime = 1;

if (typeof this.inertialPanningLastRaf != "undefined") {
elapsedTime = timestamp - this.inertialPanningLastRaf;
}

const translation = this.props.value.translation;

const newTranslation = {
x: translation.x + this.dragVelocity.x*elapsedTime,
y: translation.y + this.dragVelocity.y*elapsedTime
}

this.dragVelocity = {
x: this.dragVelocity.x * this.props.frictionCoef,
y: this.dragVelocity.y * this.props.frictionCoef
};

this.props.onChange({
scale: this.props.value.scale,
translation: this.clampTranslation(newTranslation)
});

this.inertialPanningLastRaf = timestamp;
this.inertialPanningRafID = window.requestAnimationFrame(this.inertialPanning);
} else {
this.dragVelocity = {x: 0, y: 0};
this.inertialPanningLastRaf = undefined;
this.onStoppedMoving();
}
}
}

translatedOrigin(translation = this.props.value.translation) {
const clientOffset = this.getContainerBoundingClientRect();
return {
Expand Down Expand Up @@ -443,7 +595,10 @@ class MapInteractionController extends Component {
}),
disableZoom: PropTypes.bool,
disablePan: PropTypes.bool,
disableInertialPanning: PropTypes.bool,
frictionCoef: PropTypes.number,
onChange: PropTypes.func,
onStoppedMoving: PropTypes.func,
translationBounds: PropTypes.shape({
xMin: PropTypes.number, xMax: PropTypes.number, yMin: PropTypes.number, yMax: PropTypes.number
}),
Expand All @@ -455,7 +610,8 @@ class MapInteractionController extends Component {
btnClass: PropTypes.string,
plusBtnClass: PropTypes.string,
minusBtnClass: PropTypes.string,
controlsClass: PropTypes.string
controlsClass: PropTypes.string,
wheelBehavior: PropTypes.oneOf(['zoom', 'x-scroll', 'y-scroll']),
};
}

Expand Down
43 changes: 43 additions & 0 deletions stories/index.stories.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,12 @@ storiesOf('MapInteractionCSS', module)
onChange={(value) => {
this.setState({ value });
}}
translationBounds={{
xMin: -250,
xMax: 250,
yMin: -250,
yMax: 250
}}
showControls
>
<img src={gridImg} style={{ pointerEvents: 'none' }} alt="" />
Expand Down Expand Up @@ -166,3 +172,40 @@ storiesOf('MapInteractionCSS', module)
</div>
)
})
.add('Wheel Scroll Y', () => {
class Controller extends Component {
constructor(props) {
super(props);
this.state = {
value: {
scale: 1, translation: { x: 0, y: 0 }
}
};
}

render() {
return (
<div style={{ width: 500, height: 500, border: BLUE_BORDER }}>
<MapInteractionCSS
value={this.state.value}
onChange={(value) => {
this.setState({ value });
}}
translationBounds={{
xMin: -250,
xMax: 250,
yMin: -250,
yMax: 250
}}
showControls
scrollBehavior={'y-scroll'}
>
<img src={gridImg} style={{ pointerEvents: 'none' }} alt="" />
</MapInteractionCSS>
</div>
);
}
}

return <Controller />;
})