├── .babelrc
├── .gitignore
├── .mocharc.json
├── .nvmrc
├── .storybook
├── addons.js
└── config.js
├── LICENSE
├── README.md
├── changelog.md
├── copy-to-example.sh
├── example.gif
├── example
├── .env
├── .gitignore
├── README.md
├── package-lock.json
├── package.json
├── public
│ ├── favicon.ico
│ ├── grid.png
│ ├── index.html
│ └── manifest.json
└── src
│ ├── App.css
│ ├── App.js
│ ├── index.css
│ ├── index.js
│ ├── logo.svg
│ └── registerServiceWorker.js
├── mochaTestSetup.js
├── package-lock.json
├── package.json
├── src
├── Controls.jsx
├── Controls.test.jsx
├── MapInteraction.jsx
├── MapInteraction.test.jsx
├── MapInteractionCSS.jsx
├── TestUtil.js
├── UseCases.test.jsx
├── geometry.js
├── geometry.test.js
├── index.js
└── makePassiveEventOption.js
├── stories
├── grid.png
└── index.stories.js
└── webpack.config.js
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": [
3 | "@babel/preset-react",
4 | ["@babel/preset-env", {
5 | "targets": {
6 | "browsers": ["ie >= 9"]
7 | }
8 | }]
9 | ]
10 | }
11 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | dist
3 | .DS_Store
4 | .nyc_output
5 | coverage
6 |
--------------------------------------------------------------------------------
/.mocharc.json:
--------------------------------------------------------------------------------
1 | {
2 | "require": ["@babel/register", "./mochaTestSetup.js"],
3 | "spec": "./src/*.test.*",
4 | "watch-files": ["./src/*"]
5 | }
6 |
--------------------------------------------------------------------------------
/.nvmrc:
--------------------------------------------------------------------------------
1 | 12.22.2
2 |
--------------------------------------------------------------------------------
/.storybook/addons.js:
--------------------------------------------------------------------------------
1 | import '@storybook/addon-actions/register';
2 | import '@storybook/addon-links/register';
3 |
--------------------------------------------------------------------------------
/.storybook/config.js:
--------------------------------------------------------------------------------
1 | import { configure } from '@storybook/react';
2 |
3 | // automatically import all files ending in *.stories.js
4 | const req = require.context('../stories', true, /\.stories\.js$/);
5 | function loadStories() {
6 | req.keys().forEach(filename => req(filename));
7 | }
8 |
9 | configure(loadStories, module);
10 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) Strateos, Inc.
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # react-map-interaction
2 |
3 | Add map like zooming and panning to any React element. This works on both touch devices (pinch to zoom, drag to pan) as well as with a mouse or trackpad (wheel scroll to zoom, mouse drag to pan).
4 |
5 | 
6 |
7 | ## Install
8 | ```bash
9 | npm install --save react-map-interaction
10 | ```
11 |
12 | ## Usage
13 |
14 | ### Basic
15 | ```js
16 | import { MapInteractionCSS } from 'react-map-interaction';
17 |
18 | // This component uses CSS to scale your content.
19 | // Just pass in content as children and it will take care of the rest.
20 | const ThingMap = () => {
21 | return (
22 |
23 |
24 |
25 | );
26 | }
27 | ```
28 |
29 | ### Usage without CSS
30 | ```js
31 | import { MapInteraction } from 'react-map-interaction';
32 |
33 | // Use MapInteraction if you want to determine how to use the resulting translation.
34 | const NotUsingCSS = () => {
35 | return (
36 |
37 | {
38 | ({ translation, scale }) => { /* Use the passed values to scale content on your own. */ }
39 | }
40 |
41 | );
42 | }
43 | ```
44 |
45 | ### Controlled
46 | ```js
47 | import { MapInteractionCSS } from 'react-map-interaction';
48 |
49 | // If you want to have control over the scale and translation,
50 | // then use the `scale`, `translation`, and `onChange` props.
51 | class Controlled extends Component {
52 | constructor(props) {
53 | super(props);
54 | this.state = {
55 | value: {
56 | scale: 1,
57 | translation: { x: 0, y: 0 }
58 | }
59 | };
60 | }
61 |
62 | render() {
63 | const { scale, translation } = this.state;
64 | return (
65 | this.setState({ value })}
68 | >
69 |
70 |
71 | );
72 | }
73 | }
74 | ```
75 |
76 | ### Controlled vs Uncontrolled
77 | Similar to React's ` ` component, you can either control the state of MapInteraction
78 | yourself, or let it handle that for you. It is not recommended, however, that you change
79 | this mode of control during the lifecycle of a component. Once you have started controlling
80 | the state, keep controlling it under you unmount MapInteraction (likewise with uncontrolled).
81 | If you pass `value` prop, we assume you are controlling the state via a `onChange` prop.
82 |
83 | ### Click and drag handlers on child elements
84 | This component lets you decide how to respond to click/drag events on the children that you render inside of the map. To know if an element was clicked or dragged, you can attach onClick or onTouchEnd events and then check the `e.defaultPrevented` attribute. MapInteraction will set `defaultPrevented` to `true` if the touchend/mouseup event happened after a drag, and false if it was a click. See `index.stories.js` for an example.
85 |
86 | ## Prop Types for MapInteractionCSS (all optional)
87 | MapInteraction doesn't require any props. It will control its own internal state, and pass values to its children. If you need to control the scale and translation then you can pass those values as props and listen to the onChange event to receive updates.
88 | ```js
89 | {
90 | value: PropTypes.shape({
91 | // The scale applied to the dimensions of the contents. A scale of 1 means the
92 | // contents appear at actual size, greater than 1 is zoomed, and between 0 and 1 is shrunken.
93 | scale: PropTypes.number,
94 | // The distance in pixels to translate the contents by.
95 | translation: PropTypes.shape({ x: PropTypes.number, y: PropTypes.number }),
96 | }),
97 |
98 | defaultValue: PropTypes.shape({
99 | scale: PropTypes.number,
100 | translation: PropTypes.shape({ x: PropTypes.number, y: PropTypes.number }),
101 | }),
102 |
103 | // Stops user from being able to zoom, but will still adhere to props.scale
104 | disableZoom: PropTypes.bool,
105 |
106 | // Stops user from being able to pan. Note that translation can still be
107 | // changed via zooming, in order to keep the focal point beneath the cursor. This prop does not change the behavior of the `translation` prop.
108 | disablePan: PropTypes.bool,
109 |
110 | // Apply a limit to the translation in any direction in pixel values. The default is unbounded.
111 | translationBounds: PropTypes.shape({
112 | xMin: PropTypes.number, xMax: PropTypes.number, yMin: PropTypes.number, yMax: PropTypes.number
113 | }),
114 |
115 | // Called with an object { scale, translation }
116 | onChange: PropTypes.func,
117 |
118 | // The min and max of the scale of the zoom. Must be > 0.
119 | minScale: PropTypes.number,
120 | maxScale: PropTypes.number,
121 |
122 | // When 'showControls' is 'true', plus/minus buttons are rendered
123 | // that let the user control the zoom factor
124 | showControls: PropTypes.bool,
125 |
126 | // Content to render in each of the control buttons (only when 'showControls' is 'true')
127 | plusBtnContents: PropTypes.node,
128 | minusBtnContents: PropTypes.node,
129 |
130 | // Class applied to the controls wrapper (only when 'showControls' is 'true')
131 | controlsClass: PropTypes.string,
132 |
133 | // Class applied to the plus/minus buttons (only when 'showControls' is 'true')
134 | btnClass: PropTypes.string,
135 |
136 | // Classes applied to each button separately (only when 'showControls' is 'true')
137 | plusBtnClass: PropTypes.string,
138 | minusBtnClass: PropTypes.string,
139 | };
140 | ```
141 |
142 | ## Prop Types for MapInteraction (all optional)
143 | ```js
144 | {
145 | // Function called with an object { translation, scale }
146 | // translation: { x: number, y: number }, The current origin of the content
147 | // scale: number, The current multiplier mapping original coordinates to current coordinates
148 | children: PropTypes.func,
149 |
150 | // The rest of the prop types are the same as MapInteractionCSS
151 | ...MapInteractionCSS.propTypes,
152 | }
153 | ```
154 |
155 | ## Development
156 | Please feel free to file issues or put up a PR.
157 | Note the node version in .nvmrc file.
158 |
159 | ```
160 | $ yarn install
161 | ```
162 |
163 | ```
164 | $ yarn test
165 | ```
166 |
167 | ```
168 | $ yarn run storybook
169 | ```
170 |
--------------------------------------------------------------------------------
/changelog.md:
--------------------------------------------------------------------------------
1 | # Unreleased
2 |
3 | Upgrade Node to 12.22.2.
4 |
5 | Add support for React 17.
6 |
7 | # 2.0.0
8 |
9 | BREAKING: To make compatible with React 17, we got rid of componentWillReceiveProps usage. In doing so,
10 | we also took the time to simplify the API to MapInteraction to just require `value` and `onChange` when
11 | you want to control the component, instead of `scale`, `translation`, and `onChange`. The minimum React
12 | peer dependency is now 16.3.
13 |
14 | See #39
15 |
16 | # 1.3.1
17 |
18 | ### Fix issue of contents changing translation when dragging outside of container.
19 | This bug can be reproduced by a) Perform a normal drag inside of the container, then b) Drag somewhere outside of the container, which should have no impact on the translation of the contents, however you will see that the contents will change translation.
20 |
21 | ### Fix #20.
22 |
23 | # Versions 1.3.0 and earlier do not yet have entries in the changelog.
24 |
--------------------------------------------------------------------------------
/copy-to-example.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | # This is a hack to work around limitations in create-react-app
4 | # E.g.: https://github.com/facebook/create-react-app/issues/3883
5 | # We completely copy over the dist of this library into the ./example folder's node_modules
6 |
7 | mkdir -p example/node_modules/react-map-interaction/dist
8 | cp dist/react-map-interaction.js example/node_modules/react-map-interaction/dist
9 | cp package.json example/node_modules/react-map-interaction/
10 |
--------------------------------------------------------------------------------
/example.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/strateos/react-map-interaction/7e699e8a7abdfda3eb112e8a0b4184c28738388f/example.gif
--------------------------------------------------------------------------------
/example/.env:
--------------------------------------------------------------------------------
1 | SKIP_PREFLIGHT_CHECK=true
--------------------------------------------------------------------------------
/example/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/ignore-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 |
6 | # testing
7 | /coverage
8 |
9 | # production
10 | /build
11 |
12 | # misc
13 | .DS_Store
14 | .env.local
15 | .env.development.local
16 | .env.test.local
17 | .env.production.local
18 |
19 | npm-debug.log*
20 | yarn-debug.log*
21 | yarn-error.log*
22 |
--------------------------------------------------------------------------------
/example/README.md:
--------------------------------------------------------------------------------
1 | Example application to demo react-map-interaction.
2 |
3 | ## Developing
4 | * cd to the root directory of this repo and `$ npm link`
5 | * `$ npm link react-map-interaction` in this dir
6 |
7 |
8 | This project was bootstrapped with [Create React App](https://github.com/facebookincubator/create-react-app).
9 |
--------------------------------------------------------------------------------
/example/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "example",
3 | "version": "0.1.0",
4 | "private": true,
5 | "devDependencies": {
6 | "react-scripts": "^2.1.3"
7 | },
8 | "dependencies": {
9 | "react": "^17.0.0",
10 | "react-dom": "^17.0.0"
11 | },
12 | "scripts": {
13 | "start": "react-scripts start",
14 | "build": "react-scripts build",
15 | "test": "react-scripts test --env=jsdom",
16 | "eject": "react-scripts eject"
17 | },
18 | "browserslist": [
19 | ">0.2%",
20 | "not dead",
21 | "not ie <= 11",
22 | "not op_mini all"
23 | ]
24 | }
25 |
--------------------------------------------------------------------------------
/example/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/strateos/react-map-interaction/7e699e8a7abdfda3eb112e8a0b4184c28738388f/example/public/favicon.ico
--------------------------------------------------------------------------------
/example/public/grid.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/strateos/react-map-interaction/7e699e8a7abdfda3eb112e8a0b4184c28738388f/example/public/grid.png
--------------------------------------------------------------------------------
/example/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
11 |
12 |
13 |
22 | React App
23 |
24 |
25 |
26 | You need to enable JavaScript to run this app.
27 |
28 |
29 |
39 |
40 |
41 |
--------------------------------------------------------------------------------
/example/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "React App",
3 | "name": "Create React App Sample",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | }
10 | ],
11 | "start_url": "./index.html",
12 | "display": "standalone",
13 | "theme_color": "#000000",
14 | "background_color": "#ffffff"
15 | }
16 |
--------------------------------------------------------------------------------
/example/src/App.css:
--------------------------------------------------------------------------------
1 | .App {
2 | text-align: center;
3 | }
4 |
5 | .App-logo {
6 | animation: App-logo-spin infinite 20s linear;
7 | height: 80px;
8 | }
9 |
10 | .App-header {
11 | background-color: #222;
12 | height: 150px;
13 | padding: 20px;
14 | color: white;
15 | }
16 |
17 | .App-title {
18 | font-size: 1.5em;
19 | }
20 |
21 | .App-intro {
22 | font-size: large;
23 | }
24 |
25 | @keyframes App-logo-spin {
26 | from { transform: rotate(0deg); }
27 | to { transform: rotate(360deg); }
28 | }
29 |
--------------------------------------------------------------------------------
/example/src/App.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 |
3 | // See ../copy-to-example.sh
4 | import { MapInteractionCSS } from 'react-map-interaction';
5 |
6 | class App extends Component {
7 | constructor(props) {
8 | super(props);
9 | this.state = {
10 | scale: 1,
11 | translation: { x: 0, y: 0 }
12 | };
13 | }
14 |
15 | render() {
16 | // set container node at an origin other than client 0,0 to make sure we handle this case
17 | const offset = 20;
18 |
19 | const style = {
20 | position: 'absolute',
21 | top: offset,
22 | left: offset,
23 | width: `calc(80vw - ${offset}px)`,
24 | height: `calc(50vh - ${offset}px)`,
25 | border: '1px solid blue'
26 | }
27 |
28 | const { scale, translation } = this.state;
29 | return (
30 |
31 |
this.setState({ scale, translation })}
35 | defaultScale={1}
36 | defaultTranslation={{ x: 0, y: 0 }}
37 | minScale={0.05}
38 | maxScale={5}
39 | showControls
40 | >
41 |
42 |
43 | console.log('Click')}
45 | onTouchEnd={() => console.log('TouchEnd')}
46 | onTouchStart={() => console.log('TouchStart')}
47 | >
48 | Touch/Click Test
49 |
50 |
51 |
52 |
53 |
54 |
55 | );
56 | }
57 | }
58 |
59 | export default App;
60 |
--------------------------------------------------------------------------------
/example/src/index.css:
--------------------------------------------------------------------------------
1 | body {
2 | margin: 0;
3 | padding: 0;
4 | font-family: sans-serif;
5 | }
6 |
--------------------------------------------------------------------------------
/example/src/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import './index.css';
4 | import App from './App';
5 | import registerServiceWorker from './registerServiceWorker';
6 |
7 | ReactDOM.render( , document.getElementById('root'));
8 | registerServiceWorker();
9 |
--------------------------------------------------------------------------------
/example/src/logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/example/src/registerServiceWorker.js:
--------------------------------------------------------------------------------
1 | // In production, we register a service worker to serve assets from local cache.
2 |
3 | // This lets the app load faster on subsequent visits in production, and gives
4 | // it offline capabilities. However, it also means that developers (and users)
5 | // will only see deployed updates on the "N+1" visit to a page, since previously
6 | // cached resources are updated in the background.
7 |
8 | // To learn more about the benefits of this model, read https://goo.gl/KwvDNy.
9 | // This link also includes instructions on opting out of this behavior.
10 |
11 | const isLocalhost = Boolean(
12 | window.location.hostname === 'localhost' ||
13 | // [::1] is the IPv6 localhost address.
14 | window.location.hostname === '[::1]' ||
15 | // 127.0.0.1/8 is considered localhost for IPv4.
16 | window.location.hostname.match(
17 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
18 | )
19 | );
20 |
21 | export default function register() {
22 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
23 | // The URL constructor is available in all browsers that support SW.
24 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location);
25 | if (publicUrl.origin !== window.location.origin) {
26 | // Our service worker won't work if PUBLIC_URL is on a different origin
27 | // from what our page is served on. This might happen if a CDN is used to
28 | // serve assets; see https://github.com/facebookincubator/create-react-app/issues/2374
29 | return;
30 | }
31 |
32 | window.addEventListener('load', () => {
33 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
34 |
35 | if (isLocalhost) {
36 | // This is running on localhost. Lets check if a service worker still exists or not.
37 | checkValidServiceWorker(swUrl);
38 | } else {
39 | // Is not local host. Just register service worker
40 | registerValidSW(swUrl);
41 | }
42 | });
43 | }
44 | }
45 |
46 | function registerValidSW(swUrl) {
47 | navigator.serviceWorker
48 | .register(swUrl)
49 | .then(registration => {
50 | registration.onupdatefound = () => {
51 | const installingWorker = registration.installing;
52 | installingWorker.onstatechange = () => {
53 | if (installingWorker.state === 'installed') {
54 | if (navigator.serviceWorker.controller) {
55 | // At this point, the old content will have been purged and
56 | // the fresh content will have been added to the cache.
57 | // It's the perfect time to display a "New content is
58 | // available; please refresh." message in your web app.
59 | console.log('New content is available; please refresh.');
60 | } else {
61 | // At this point, everything has been precached.
62 | // It's the perfect time to display a
63 | // "Content is cached for offline use." message.
64 | console.log('Content is cached for offline use.');
65 | }
66 | }
67 | };
68 | };
69 | })
70 | .catch(error => {
71 | console.error('Error during service worker registration:', error);
72 | });
73 | }
74 |
75 | function checkValidServiceWorker(swUrl) {
76 | // Check if the service worker can be found. If it can't reload the page.
77 | fetch(swUrl)
78 | .then(response => {
79 | // Ensure service worker exists, and that we really are getting a JS file.
80 | if (
81 | response.status === 404 ||
82 | response.headers.get('content-type').indexOf('javascript') === -1
83 | ) {
84 | // No service worker found. Probably a different app. Reload the page.
85 | navigator.serviceWorker.ready.then(registration => {
86 | registration.unregister().then(() => {
87 | window.location.reload();
88 | });
89 | });
90 | } else {
91 | // Service worker found. Proceed as normal.
92 | registerValidSW(swUrl);
93 | }
94 | })
95 | .catch(() => {
96 | console.log(
97 | 'No internet connection found. App is running in offline mode.'
98 | );
99 | });
100 | }
101 |
102 | export function unregister() {
103 | if ('serviceWorker' in navigator) {
104 | navigator.serviceWorker.ready.then(registration => {
105 | registration.unregister();
106 | });
107 | }
108 | }
109 |
--------------------------------------------------------------------------------
/mochaTestSetup.js:
--------------------------------------------------------------------------------
1 | // https://enzymejs.github.io/enzyme/docs/installation/react-16.html
2 |
3 | // setup file
4 | import { configure } from 'enzyme';
5 | import Adapter from 'enzyme-adapter-react-16';
6 |
7 | configure({ adapter: new Adapter() });
8 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-map-interaction",
3 | "version": "2.1.0",
4 | "description": "'Add map like zooming and dragging to any element'",
5 | "main": "dist/react-map-interaction.js",
6 | "scripts": {
7 | "test": "./node_modules/mocha/bin/mocha",
8 | "test:watch": "./node_modules/mocha/bin/mocha --watch",
9 | "test:cover": "nyc ./node_modules/mocha/bin/mocha",
10 | "dist": "webpack --config webpack.config.js --mode production",
11 | "dist:dev": "npm run dist && ./copy-to-example.sh",
12 | "prepare": "npm run dist",
13 | "storybook": "start-storybook -p 6006",
14 | "build-storybook": "build-storybook"
15 | },
16 | "keywords": [
17 | "react",
18 | "zoom",
19 | "pan",
20 | "pinch",
21 | "data visualization",
22 | "map"
23 | ],
24 | "author": "Transcriptic",
25 | "private": false,
26 | "license": "MIT",
27 | "peerDependencies": {
28 | "react": ">=16.3.0",
29 | "prop-types": ">=15.0.0"
30 | },
31 | "devDependencies": {
32 | "@babel/core": "^7.3.3",
33 | "@babel/preset-env": "^7.3.1",
34 | "@babel/preset-react": "^7.0.0",
35 | "@babel/register": "^7.8.6",
36 | "@storybook/addon-actions": "^5.1.11",
37 | "@storybook/addon-links": "^5.1.11",
38 | "@storybook/addons": "^5.1.11",
39 | "@storybook/react": "^5.1.11",
40 | "babel-loader": "^8.0.5",
41 | "chai": "^4.2.0",
42 | "enzyme": "^3.11.0",
43 | "enzyme-adapter-react-16": "^1.15.2",
44 | "jsdom": "16.2.1",
45 | "jsdom-global": "3.0.2",
46 | "mocha": "^7.1.0",
47 | "nyc": "^15.0.0",
48 | "sinon": "^9.0.0",
49 | "webpack": "^4.29.5",
50 | "webpack-cli": "^3.2.3"
51 | },
52 | "repository": {
53 | "type": "git",
54 | "url": "https://github.com/transcriptic/react-map-interaction"
55 | },
56 | "dependencies": {}
57 | }
58 |
--------------------------------------------------------------------------------
/src/Controls.jsx:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import PropTypes from 'prop-types';
3 |
4 | class Controls extends Component {
5 | render() {
6 | const {
7 | plusBtnContents,
8 | minusBtnContents,
9 | btnClass,
10 | plusBtnClass,
11 | minusBtnClass,
12 | controlsClass,
13 | scale,
14 | minScale,
15 | maxScale,
16 | onClickPlus,
17 | onClickMinus,
18 | disableZoom
19 | } = this.props;
20 |
21 | const btnStyle = { width: 30, paddingTop: 5, marginBottom: 5 };
22 | const controlsStyle = controlsClass ? undefined : { position: 'absolute', right: 10, top: 10 };
23 |
24 | function plusHandler(e) {
25 | e.preventDefault();
26 | e.target.blur();
27 | if (disableZoom) return;
28 | onClickPlus();
29 | }
30 |
31 | function minusHandler(e) {
32 | e.preventDefault();
33 | e.target.blur();
34 | if (disableZoom) return;
35 | onClickMinus();
36 | }
37 |
38 | return (
39 |
40 |
41 | { this.plusNode = node; }}
43 | onClick={plusHandler}
44 | onTouchEnd={plusHandler}
45 | className={[
46 | btnClass ? btnClass : '',
47 | plusBtnClass ? plusBtnClass : '',
48 | ].join(' ')}
49 | type="button"
50 | style={(btnClass || plusBtnClass) ? undefined : btnStyle}
51 | disabled={disableZoom || scale >= maxScale}
52 | >
53 | {plusBtnContents}
54 |
55 |
56 |
57 | { this.minusNode = node; }}
59 | onClick={minusHandler}
60 | onTouchEnd={minusHandler}
61 | className={[
62 | btnClass ? btnClass : '',
63 | minusBtnClass ? minusBtnClass : '',
64 | ].join(' ')}
65 | type="button"
66 | style={(btnClass || minusBtnClass) ? undefined : btnStyle}
67 | disabled={disableZoom || scale <= minScale}
68 | >
69 | {minusBtnContents}
70 |
71 |
72 |
73 | );
74 | }
75 | }
76 |
77 | Controls.propTypes = {
78 | onClickPlus: PropTypes.func.isRequired,
79 | onClickMinus: PropTypes.func.isRequired,
80 | plusBtnContents: PropTypes.node,
81 | minusBtnContents: PropTypes.node,
82 | btnClass: PropTypes.string,
83 | plusBtnClass: PropTypes.string,
84 | minusBtnClass: PropTypes.string,
85 | controlsClass: PropTypes.string,
86 | scale: PropTypes.number,
87 | minScale: PropTypes.number,
88 | maxScale: PropTypes.number,
89 | disableZoom: PropTypes.bool
90 | };
91 |
92 | Controls.defaultProps = {
93 | plusBtnContents: '+',
94 | minusBtnContents: '-',
95 | disableZoom: false
96 | };
97 |
98 | export default Controls;
99 |
--------------------------------------------------------------------------------
/src/Controls.test.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { shallow, mount } from 'enzyme';
3 | import { expect } from 'chai';
4 | import sinon from 'sinon';
5 | import Controls from './Controls';
6 |
7 | const jsdom = require('jsdom-global');
8 |
9 | describe("Controls", () => {
10 | let wrapper;
11 | afterEach(() => {
12 | if (wrapper) wrapper.unmount();
13 | });
14 |
15 | it("renders shallow base case", () => {
16 | wrapper = shallow( {}} onClickMinus={() => {}}/>);
17 | expect(wrapper);
18 | });
19 |
20 | it("renders plus/minus buttons", () => {
21 | wrapper = shallow(
22 | {}}
24 | onClickMinus={() => {}}
25 | plusBtnClass="plus-button-klass"
26 | minusBtnClass="minus-button-klass"
27 | />
28 | );
29 | expect(wrapper.find('button').length).to.equal(2);
30 | expect(wrapper.find('button.plus-button-klass').length).to.equal(1);
31 | expect(wrapper.find('button.minus-button-klass').length).to.equal(1);
32 | });
33 |
34 | it("renders button labels by default", () => {
35 | wrapper = shallow(
36 | {}}
38 | onClickMinus={() => {}}
39 | plusBtnClass="plus-button-klass"
40 | minusBtnClass="minus-button-klass"
41 | />
42 | );
43 | expect(wrapper.find('button.plus-button-klass').text()).to.equal("+");
44 | expect(wrapper.find('button.minus-button-klass').text()).to.equal("-");
45 | })
46 |
47 | describe("full dom tests", () => {
48 | let cleanupDom;
49 | beforeEach(() => {
50 | cleanupDom = jsdom();
51 | })
52 | let wrapper;
53 | afterEach(() => {
54 | if (wrapper) wrapper.unmount();
55 | cleanupDom();
56 | });
57 |
58 | it("alerts on click events", () => {
59 | const plusCallback = sinon.fake();
60 | const minusCallback = sinon.fake();
61 |
62 | // required to mount otherwise .simulate doesn't invoke a synthetic event
63 | wrapper = mount(
64 |
70 | );
71 |
72 | const plusBtn = wrapper.find('button.plus-button-klass').first()
73 | const minusBtn = wrapper.find('button.minus-button-klass').first();
74 | plusBtn.simulate('click');
75 | minusBtn.simulate('click');
76 | minusBtn.simulate('click');
77 | expect(plusCallback.callCount).to.equal(1);
78 | expect(minusCallback.callCount).to.equal(2);
79 | });
80 | });
81 | });
82 |
--------------------------------------------------------------------------------
/src/MapInteraction.jsx:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import PropTypes from 'prop-types';
3 |
4 | import Controls from './Controls';
5 |
6 | import { clamp, distance, midpoint, touchPt, touchDistance } from './geometry';
7 | import makePassiveEventOption from './makePassiveEventOption';
8 |
9 | // The amount that a value of a dimension will change given a new scale
10 | const coordChange = (coordinate, scaleRatio) => {
11 | return (scaleRatio * coordinate) - coordinate;
12 | };
13 |
14 | const translationShape = PropTypes.shape({ x: PropTypes.number, y: PropTypes.number });
15 |
16 | /*
17 | This contains logic for providing a map-like interaction to any DOM node.
18 | It allows a user to pinch, zoom, translate, etc, as they would an interactive map.
19 | It renders its children with the current state of the translation and does not do any scaling
20 | or translating on its own. This works on both desktop, and mobile.
21 | */
22 | export class MapInteractionControlled extends Component {
23 | static get propTypes() {
24 | return {
25 | // The content that will be transformed
26 | children: PropTypes.func,
27 |
28 | // This is a controlled component
29 | value: PropTypes.shape({
30 | scale: PropTypes.number.isRequired,
31 | translation: translationShape.isRequired,
32 | }).isRequired,
33 | onChange: PropTypes.func.isRequired,
34 |
35 | disableZoom: PropTypes.bool,
36 | disablePan: PropTypes.bool,
37 | translationBounds: PropTypes.shape({
38 | xMin: PropTypes.number, xMax: PropTypes.number, yMin: PropTypes.number, yMax: PropTypes.number
39 | }),
40 | minScale: PropTypes.number,
41 | maxScale: PropTypes.number,
42 | showControls: PropTypes.bool,
43 | plusBtnContents: PropTypes.node,
44 | minusBtnContents: PropTypes.node,
45 | btnClass: PropTypes.string,
46 | plusBtnClass: PropTypes.string,
47 | minusBtnClass: PropTypes.string,
48 | controlsClass: PropTypes.string
49 | };
50 | }
51 |
52 | static get defaultProps() {
53 | return {
54 | minScale: 0.05,
55 | maxScale: 3,
56 | showControls: false,
57 | translationBounds: {},
58 | disableZoom: false,
59 | disablePan: false
60 | };
61 | }
62 |
63 | constructor(props) {
64 | super(props);
65 |
66 | this.state = {
67 | shouldPreventTouchEndDefault: false
68 | };
69 |
70 | this.startPointerInfo = undefined;
71 |
72 | this.onMouseDown = this.onMouseDown.bind(this);
73 | this.onTouchStart = this.onTouchStart.bind(this);
74 |
75 | this.onMouseMove = this.onMouseMove.bind(this);
76 | this.onTouchMove = this.onTouchMove.bind(this);
77 |
78 | this.onMouseUp = this.onMouseUp.bind(this);
79 | this.onTouchEnd = this.onTouchEnd.bind(this);
80 |
81 | this.onWheel = this.onWheel.bind(this);
82 | }
83 |
84 | componentDidMount() {
85 | const passiveOption = makePassiveEventOption(false);
86 |
87 | this.getContainerNode().addEventListener('wheel', this.onWheel, passiveOption);
88 |
89 | /*
90 | Setup events for the gesture lifecycle: start, move, end touch
91 | */
92 |
93 | // start gesture
94 | this.getContainerNode().addEventListener('touchstart', this.onTouchStart, passiveOption);
95 | this.getContainerNode().addEventListener('mousedown', this.onMouseDown, passiveOption);
96 |
97 | // move gesture
98 | window.addEventListener('touchmove', this.onTouchMove, passiveOption);
99 | window.addEventListener('mousemove', this.onMouseMove, passiveOption);
100 |
101 | // end gesture
102 | const touchAndMouseEndOptions = { capture: true, ...passiveOption };
103 | window.addEventListener('touchend', this.onTouchEnd, touchAndMouseEndOptions);
104 | window.addEventListener('mouseup', this.onMouseUp, touchAndMouseEndOptions);
105 |
106 | }
107 |
108 | componentWillUnmount() {
109 | this.getContainerNode().removeEventListener('wheel', this.onWheel);
110 |
111 | // Remove touch events
112 | this.getContainerNode().removeEventListener('touchstart', this.onTouchStart);
113 | window.removeEventListener('touchmove', this.onTouchMove);
114 | window.removeEventListener('touchend', this.onTouchEnd);
115 |
116 | // Remove mouse events
117 | this.getContainerNode().removeEventListener('mousedown', this.onMouseDown);
118 | window.removeEventListener('mousemove', this.onMouseMove);
119 | window.removeEventListener('mouseup', this.onMouseUp);
120 | }
121 |
122 | /*
123 | Event handlers
124 |
125 | All touch/mouse handlers preventDefault because we add
126 | both touch and mouse handlers in the same session to support devicse
127 | with both touch screen and mouse inputs. The browser may fire both
128 | a touch and mouse event for a *single* user action, so we have to ensure
129 | that only one handler is used by canceling the event in the first handler.
130 |
131 | https://developer.mozilla.org/en-US/docs/Web/API/Touch_events/Supporting_both_TouchEvent_and_MouseEvent
132 | */
133 |
134 | onMouseDown(e) {
135 | e.preventDefault();
136 | this.setPointerState([e]);
137 | }
138 |
139 | onTouchStart(e) {
140 | e.preventDefault();
141 | this.setPointerState(e.touches);
142 | }
143 |
144 | onMouseUp(e) {
145 | this.setPointerState();
146 | }
147 |
148 | onTouchEnd(e) {
149 | this.setPointerState(e.touches);
150 | }
151 |
152 | onMouseMove(e) {
153 | if (!this.startPointerInfo || this.props.disablePan) {
154 | return;
155 | }
156 | e.preventDefault();
157 | this.onDrag(e);
158 | }
159 |
160 | onTouchMove(e) {
161 | if (!this.startPointerInfo) {
162 | return;
163 | }
164 |
165 | e.preventDefault();
166 |
167 | const { disablePan, disableZoom } = this.props;
168 |
169 | const isPinchAction = e.touches.length == 2 && this.startPointerInfo.pointers.length > 1;
170 | if (isPinchAction && !disableZoom) {
171 | this.scaleFromMultiTouch(e);
172 | } else if ((e.touches.length === 1) && this.startPointerInfo && !disablePan) {
173 | this.onDrag(e.touches[0]);
174 | }
175 | }
176 |
177 | // handles both touch and mouse drags
178 | onDrag(pointer) {
179 | const { translation, pointers } = this.startPointerInfo;
180 | const startPointer = pointers[0];
181 | const dragX = pointer.clientX - startPointer.clientX;
182 | const dragY = pointer.clientY - startPointer.clientY;
183 | const newTranslation = {
184 | x: translation.x + dragX,
185 | y: translation.y + dragY
186 | };
187 |
188 | const shouldPreventTouchEndDefault = Math.abs(dragX) > 1 || Math.abs(dragY) > 1;
189 |
190 | this.setState({
191 | shouldPreventTouchEndDefault
192 | }, () => {
193 | this.props.onChange({
194 | scale: this.props.value.scale,
195 | translation: this.clampTranslation(newTranslation)
196 | });
197 | });
198 | }
199 |
200 | onWheel(e) {
201 | if (this.props.disableZoom) {
202 | return;
203 | }
204 |
205 | e.preventDefault();
206 | e.stopPropagation();
207 |
208 | const scaleChange = 2 ** (e.deltaY * 0.002);
209 |
210 | const newScale = clamp(
211 | this.props.minScale,
212 | this.props.value.scale + (1 - scaleChange),
213 | this.props.maxScale
214 | );
215 |
216 | const mousePos = this.clientPosToTranslatedPos({ x: e.clientX, y: e.clientY });
217 |
218 | this.scaleFromPoint(newScale, mousePos);
219 | }
220 |
221 | setPointerState(pointers) {
222 | if (!pointers || pointers.length === 0) {
223 | this.startPointerInfo = undefined;
224 | return;
225 | }
226 |
227 | this.startPointerInfo = {
228 | pointers,
229 | scale: this.props.value.scale,
230 | translation: this.props.value.translation,
231 | }
232 | }
233 |
234 | clampTranslation(desiredTranslation, props = this.props) {
235 | const { x, y } = desiredTranslation;
236 | let { xMax, xMin, yMax, yMin } = props.translationBounds;
237 | xMin = xMin != undefined ? xMin : -Infinity;
238 | yMin = yMin != undefined ? yMin : -Infinity;
239 | xMax = xMax != undefined ? xMax : Infinity;
240 | yMax = yMax != undefined ? yMax : Infinity;
241 |
242 | return {
243 | x: clamp(xMin, x, xMax),
244 | y: clamp(yMin, y, yMax)
245 | };
246 | }
247 |
248 | translatedOrigin(translation = this.props.value.translation) {
249 | const clientOffset = this.getContainerBoundingClientRect();
250 | return {
251 | x: clientOffset.left + translation.x,
252 | y: clientOffset.top + translation.y
253 | };
254 | }
255 |
256 | // From a given screen point return it as a point
257 | // in the coordinate system of the given translation
258 | clientPosToTranslatedPos({ x, y }, translation = this.props.value.translation) {
259 | const origin = this.translatedOrigin(translation);
260 | return {
261 | x: x - origin.x,
262 | y: y - origin.y
263 | };
264 | }
265 |
266 | scaleFromPoint(newScale, focalPt) {
267 | const { translation, scale } = this.props.value;
268 | const scaleRatio = newScale / (scale != 0 ? scale : 1);
269 |
270 | const focalPtDelta = {
271 | x: coordChange(focalPt.x, scaleRatio),
272 | y: coordChange(focalPt.y, scaleRatio)
273 | };
274 |
275 | const newTranslation = {
276 | x: translation.x - focalPtDelta.x,
277 | y: translation.y - focalPtDelta.y
278 | };
279 | this.props.onChange({
280 | scale: newScale,
281 | translation: this.clampTranslation(newTranslation)
282 | })
283 | }
284 |
285 | // Given the start touches and new e.touches, scale and translate
286 | // such that the initial midpoint remains as the new midpoint. This is
287 | // to achieve the effect of keeping the content that was directly
288 | // in the middle of the two fingers as the focal point throughout the zoom.
289 | scaleFromMultiTouch(e) {
290 | const startTouches = this.startPointerInfo.pointers;
291 | const newTouches = e.touches;
292 |
293 | // calculate new scale
294 | const dist0 = touchDistance(startTouches[0], startTouches[1]);
295 | const dist1 = touchDistance(newTouches[0], newTouches[1]);
296 | const scaleChange = dist1 / dist0;
297 |
298 | const startScale = this.startPointerInfo.scale;
299 | const targetScale = startScale + ((scaleChange - 1) * startScale);
300 | const newScale = clamp(this.props.minScale, targetScale, this.props.maxScale);
301 |
302 | // calculate mid points
303 | const startMidpoint = midpoint(touchPt(startTouches[0]), touchPt(startTouches[1]))
304 | const newMidPoint = midpoint(touchPt(newTouches[0]), touchPt(newTouches[1]));
305 |
306 | // The amount we need to translate by in order for
307 | // the mid point to stay in the middle (before thinking about scaling factor)
308 | const dragDelta = {
309 | x: newMidPoint.x - startMidpoint.x,
310 | y: newMidPoint.y - startMidpoint.y
311 | };
312 |
313 | const scaleRatio = newScale / startScale;
314 |
315 | // The point originally in the middle of the fingers on the initial zoom start
316 | const focalPt = this.clientPosToTranslatedPos(startMidpoint, this.startPointerInfo.translation);
317 |
318 | // The amount that the middle point has changed from this scaling
319 | const focalPtDelta = {
320 | x: coordChange(focalPt.x, scaleRatio),
321 | y: coordChange(focalPt.y, scaleRatio)
322 | };
323 |
324 | // Translation is the original translation, plus the amount we dragged,
325 | // minus what the scaling will do to the focal point. Subtracting the
326 | // scaling factor keeps the midpoint in the middle of the touch points.
327 | const newTranslation = {
328 | x: this.startPointerInfo.translation.x - focalPtDelta.x + dragDelta.x,
329 | y: this.startPointerInfo.translation.y - focalPtDelta.y + dragDelta.y
330 | };
331 |
332 | this.props.onChange({
333 | scale: newScale,
334 | translation: this.clampTranslation(newTranslation)
335 | });
336 | }
337 |
338 | discreteScaleStepSize() {
339 | const { minScale, maxScale } = this.props;
340 | const delta = Math.abs(maxScale - minScale);
341 | return delta / 10;
342 | }
343 |
344 | // Scale using the center of the content as a focal point
345 | changeScale(delta) {
346 | const targetScale = this.props.value.scale + delta;
347 | const { minScale, maxScale } = this.props;
348 | const scale = clamp(minScale, targetScale, maxScale);
349 |
350 | const rect = this.getContainerBoundingClientRect();
351 | const x = rect.left + (rect.width / 2);
352 | const y = rect.top + (rect.height / 2);
353 |
354 | const focalPoint = this.clientPosToTranslatedPos({ x, y });
355 | this.scaleFromPoint(scale, focalPoint);
356 | }
357 |
358 | // Done like this so it is mockable
359 | getContainerNode() { return this.containerNode }
360 | getContainerBoundingClientRect() {
361 | return this.getContainerNode().getBoundingClientRect();
362 | }
363 |
364 | renderControls() {
365 | const step = this.discreteScaleStepSize();
366 | return (
367 | this.changeScale(step)}
369 | onClickMinus={() => this.changeScale(-step)}
370 | plusBtnContents={this.props.plusBtnContents}
371 | minusBtnContents={this.props.minusBtnContents}
372 | btnClass={this.props.btnClass}
373 | plusBtnClass={this.props.plusBtnClass}
374 | minusBtnClass={this.props.minusBtnClass}
375 | controlsClass={this.props.controlsClass}
376 | scale={this.props.value.scale}
377 | minScale={this.props.minScale}
378 | maxScale={this.props.maxScale}
379 | disableZoom={this.props.disableZoom}
380 | />
381 | );
382 | }
383 |
384 | render() {
385 | const { showControls, children } = this.props;
386 | const scale = this.props.value.scale;
387 | // Defensively clamp the translation. This should not be necessary if we properly set state elsewhere.
388 | const translation = this.clampTranslation(this.props.value.translation);
389 |
390 | /*
391 | This is a little trick to allow the following ux: We want the parent of this
392 | component to decide if elements inside the map are clickable. Normally, you wouldn't
393 | want to trigger a click event when the user *drags* on an element (only if they click
394 | and then release w/o dragging at all). However we don't want to assume this
395 | behavior here, so we call `preventDefault` and then let the parent check
396 | `e.defaultPrevented`. That value being true means that we are signalling that
397 | a drag event ended, not a click.
398 | */
399 | const handleEventCapture = (e) => {
400 | if (this.state.shouldPreventTouchEndDefault) {
401 | e.preventDefault();
402 | this.setState({ shouldPreventTouchEndDefault: false });
403 | }
404 | }
405 |
406 | return (
407 | {
409 | this.containerNode = node;
410 | }}
411 | style={{
412 | height: '100%',
413 | width: '100%',
414 | position: 'relative', // for absolutely positioned children
415 | touchAction: 'none'
416 | }}
417 | onClickCapture={handleEventCapture}
418 | onTouchEndCapture={handleEventCapture}
419 | >
420 | {(children || undefined) && children({ translation, scale })}
421 | {(showControls || undefined) && this.renderControls()}
422 |
423 | );
424 | }
425 | }
426 |
427 | /*
428 | Main entry point component.
429 | Determines if it's parent is controlling (eg it manages state) or leaving us uncontrolled
430 | (eg we manage our own internal state)
431 | */
432 | class MapInteractionController extends Component {
433 | static get propTypes() {
434 | return {
435 | children: PropTypes.func,
436 | value: PropTypes.shape({
437 | scale: PropTypes.number.isRequired,
438 | translation: translationShape.isRequired,
439 | }),
440 | defaultValue: PropTypes.shape({
441 | scale: PropTypes.number.isRequired,
442 | translation: translationShape.isRequired,
443 | }),
444 | disableZoom: PropTypes.bool,
445 | disablePan: PropTypes.bool,
446 | onChange: PropTypes.func,
447 | translationBounds: PropTypes.shape({
448 | xMin: PropTypes.number, xMax: PropTypes.number, yMin: PropTypes.number, yMax: PropTypes.number
449 | }),
450 | minScale: PropTypes.number,
451 | maxScale: PropTypes.number,
452 | showControls: PropTypes.bool,
453 | plusBtnContents: PropTypes.node,
454 | minusBtnContents: PropTypes.node,
455 | btnClass: PropTypes.string,
456 | plusBtnClass: PropTypes.string,
457 | minusBtnClass: PropTypes.string,
458 | controlsClass: PropTypes.string
459 | };
460 | }
461 |
462 | constructor(props) {
463 | super(props);
464 |
465 | const controlled = MapInteractionController.isControlled(props);
466 | if (controlled) {
467 | this.state = {
468 | lastKnownValueFromProps: props.value
469 | };
470 | } else {
471 | // Set the necessary state for controlling map interaction ourselves
472 | this.state = {
473 | value: props.defaultValue || {
474 | scale: 1,
475 | translation: { x: 0, y: 0 }
476 | },
477 | lastKnownValueFromProps: undefined
478 | };
479 | }
480 | }
481 |
482 | /*
483 | Handle the parent switchg form controlled to uncontrolled or vice versa.
484 | This is at most a best-effort attempt. It is not gauranteed by our API
485 | but it will do its best to maintain the state such that if the parent
486 | accidentally switches between controlled/uncontrolled there won't be
487 | any jankiness or jumpiness.
488 |
489 | This tries to mimick how the React component behaves.
490 | */
491 | static getDerivedStateFromProps(props, state) {
492 | const nowControlled = MapInteractionController.isControlled(props);
493 | const wasControlled = state.lastKnownValueFromProps && MapInteractionController.isControlled({ value: state.lastKnownValueFromProps })
494 |
495 | /*
496 | State transitions:
497 | uncontrolled --> controlled (unset internal state, set last props from parent)
498 | controlled --> uncontrolled (set internal state to last props from parent)
499 | controlled --> controlled (update last props from parent)
500 | uncontrolled --> uncontrolled (do nothing)
501 |
502 | Note that the second two (no change in control) will also happen on the
503 | initial render because we set lastKnownValueFromProps in the constructor.
504 | */
505 | if (!wasControlled && nowControlled) {
506 | return {
507 | value: undefined,
508 | lastKnownValueFromProps: props.value
509 | };
510 | } else if (wasControlled && !nowControlled) {
511 | return {
512 | value: state.lastKnownValueFromProps,
513 | lastKnownValueFromProps: undefined
514 | };
515 | } else if (wasControlled && nowControlled) {
516 | return { lastKnownValueFromProps: props.value };
517 | } else if (!wasControlled && !nowControlled) {
518 | return null;
519 | }
520 | }
521 |
522 | static isControlled(props) {
523 | // Similar to React's API, setting a value declares
524 | // that you want to control this component.
525 | return props.value != undefined;
526 | }
527 |
528 | // The subset of this component's props that need to be passed
529 | // down to the core RMI component
530 | innerProps() {
531 | const { value, defaultValue, onChange, ...innerProps } = this.props;
532 | return innerProps;
533 | }
534 |
535 | getValue() {
536 | const controlled = MapInteractionController.isControlled(this.props);
537 | return controlled ? this.props.value : this.state.value;
538 | }
539 |
540 | render() {
541 | const { onChange, children } = this.props;
542 | const controlled = MapInteractionController.isControlled(this.props);
543 | const value = controlled ? this.props.value : this.state.value;
544 | return (
545 | {
547 | controlled ? onChange(value) : this.setState({ value });
548 | }}
549 | value={value}
550 | {...this.innerProps()}
551 | >
552 | {children}
553 |
554 | );
555 | }
556 | }
557 |
558 | export default MapInteractionController;
559 |
--------------------------------------------------------------------------------
/src/MapInteraction.test.jsx:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import { mount, shallow } from 'enzyme';
3 | import { expect } from 'chai';
4 | import sinon from 'sinon';
5 | const jsdom = require('jsdom-global');
6 |
7 | import MapInteraction, { MapInteractionControlled } from './MapInteraction';
8 | import { mockContainerRef } from './TestUtil.js';
9 |
10 | describe("MapInteraction", () => {
11 | let cleanupDom;
12 | beforeEach(() => {
13 | cleanupDom = jsdom();
14 | })
15 |
16 | let refStub;
17 | let wrapper;
18 | afterEach(() => {
19 | if (wrapper) wrapper.unmount();
20 | if (refStub) refStub.restore();
21 | cleanupDom();
22 | });
23 |
24 | it("full mount - calls children with params", () => {
25 | const childrenCallback = sinon.fake();
26 | wrapper = mount(
27 |
28 | {childrenCallback}
29 |
30 | );
31 | const argsList = childrenCallback.args[0];
32 | expect(argsList.length).to.equal(1);
33 |
34 | const { translation, scale } = argsList[0];
35 | expect(!isNaN(scale)).to.equal(true);
36 | expect(!isNaN(translation.x)).to.equal(true);
37 | expect(!isNaN(translation.y)).to.equal(true);
38 | });
39 |
40 | it("full mount, controlled", () => {
41 | const childrenCallback = sinon.fake();
42 | wrapper = mount(
43 | {}}
49 | >
50 | {childrenCallback}
51 |
52 | );
53 | const { translation, scale } = childrenCallback.args[0][0];
54 | expect(translation).to.deep.equal({ x: 100, y: 105 });
55 | expect(scale).to.equal(3);
56 | });
57 |
58 | it("scales from point when fully controlled", () => {
59 | refStub = mockContainerRef();
60 | const changeCb = sinon.fake();
61 | wrapper = mount(
62 |
69 | );
70 | const instance = wrapper.find(MapInteractionControlled).instance();
71 | instance.changeScale(-1);
72 | const argsList = changeCb.args;
73 | expect(argsList.length).to.equal(1);
74 | expect(argsList[0][0].scale).to.equal(2);
75 | });
76 |
77 | it("scale from point state change when uncontrolled", () => {
78 | refStub = mockContainerRef();
79 | wrapper = mount(
80 |
86 | );
87 | expect(wrapper.state().value.scale).to.equal(3);
88 | const instance = wrapper.find(MapInteractionControlled).instance();
89 | instance.changeScale(-1);
90 | expect(wrapper.state().value.scale).to.equal(2);
91 | });
92 |
93 | it("fully controlled with changeScale called", () => {
94 | class Controller extends Component {
95 | constructor(props) {
96 | super(props);
97 | this.state = { value: { scale: 1, translation: { x: 0, y: 0 } }};
98 | }
99 |
100 | render() {
101 | return (
102 | {
105 | const promise = new Promise((resolve) => {
106 | this.setState({ value: params }, resolve);
107 | });
108 | this.props.onSetState(promise);
109 | }}
110 | />
111 | );
112 | }
113 | }
114 |
115 | let setStatePromise;
116 |
117 | refStub = mockContainerRef();
118 | wrapper = mount( { setStatePromise = p }} />);
119 | const controller = wrapper.find(Controller);
120 | const rmi = wrapper.find(MapInteraction);
121 | const rmiInner = rmi.find(MapInteractionControlled);
122 |
123 | // initial state
124 | expect(controller.state().value.scale).to.equal(1);
125 | expect(rmi.props().value.scale).to.equal(1);
126 | expect(rmiInner.props().value.scale).to.equal(1);
127 |
128 | rmiInner.instance().changeScale(1);
129 |
130 | return setStatePromise.then(() => {
131 | wrapper.update();
132 | const controller = wrapper.find(Controller);
133 | const rmi = wrapper.find(MapInteraction);
134 | const rmiInner = rmi.find(MapInteractionControlled);
135 |
136 | expect(controller.state().value.scale).to.equal(2);
137 | expect(rmi.props().value.scale).to.equal(2);
138 | expect(rmiInner.props().value.scale).to.equal(2);
139 | });
140 | });
141 |
142 | // This is an unhappy path. The caller of RMI should not switch from
143 | // controlled to uncontrolled. We just want to make sure we dont blow up.
144 | // The caller should be able to switch from controlled-uncontrolled-controlled
145 | // and have the component work back in a fully controlled state, but
146 | // it wont work while in the intermediary uncontrolled state.
147 | it("parent switches from controlled to uncontrolled", () => {
148 | class Controller extends Component {
149 | constructor(props) {
150 | super(props);
151 | this.state = { value: { scale: 1, translation: { x: 0, y: 0 } } };
152 | }
153 |
154 | takeControl(callback) {
155 | this.setState({
156 | value: this.ref.getValue()
157 | }, callback);
158 | }
159 |
160 | render() {
161 | return (
162 | { this.ref = node; }}
164 | value={this.state.value}
165 | onChange={(value) => {
166 | this.setState({ value });
167 | }}
168 | />
169 | );
170 | }
171 | }
172 |
173 | refStub = mockContainerRef();
174 | wrapper = mount( );
175 | const getComponents = () => {
176 | wrapper.update();
177 | const controller = wrapper.find(Controller);
178 | const rmi = wrapper.find(MapInteraction);
179 | const rmiInner = rmi.find(MapInteractionControlled);
180 | return { controller, rmi, rmiInner };
181 | }
182 |
183 | let { controller, rmi, rmiInner } = getComponents();
184 | // Check initial state
185 | expect(controller.state().value).to.deep.equal({ scale: 1, translation: { x: 0, y: 0 } });
186 | expect(rmi.props().value).to.deep.equal({ scale: 1, translation: { x: 0, y: 0 } });
187 | expect(rmiInner.props().value).to.deep.equal({ scale: 1, translation: { x: 0, y: 0 } });
188 |
189 | // switch to uncontrolled and check that the map interaction has source of truth
190 | const promiseToUncontrolled = new Promise((resolve) => {
191 | controller.instance().setState({ value: undefined }, resolve);
192 | }).then(() => {
193 | let { controller, rmi, rmiInner } = getComponents();
194 | expect(controller.state().value).to.equal(undefined);
195 | expect(rmi.state().value).to.deep.equal({ scale: 1, translation: { x: 0, y: 0 } });
196 | expect(rmi.props().value).to.equal(undefined);
197 | expect(rmiInner.props().value).to.deep.equal({ scale: 1, translation: { x: 0, y: 0 } });
198 | });
199 |
200 | // switch back to controlled and check that the controller now has the source of truth
201 | const promiseToControlled = promiseToUncontrolled.then(() => {
202 | return new Promise((resolve) => {
203 | controller.instance().takeControl(resolve);
204 | });
205 | });
206 | promiseToControlled.then(() => {
207 | let { controller, rmi, rmiInner } = getComponents();
208 | expect(controller.state().value).to.deep.equal({ scale: 1, translation: { x: 0, y: 0 } });
209 | expect(rmi.props().value).to.deep.equal({ scale: 1, translation: { x: 0, y: 0 } });
210 | expect(rmi.state().value).to.equal(undefined);
211 | expect(rmiInner.props().value).to.deep.equal({ scale: 1, translation: { x: 0, y: 0 } });
212 | });
213 |
214 | // switch back to uncontrolled one more time
215 | const promiseToUncontrolled2 = promiseToControlled.then(() => {
216 | new Promise((resolve) => {
217 | controller.instance().setState({ value: undefined }, resolve);
218 | });
219 | })
220 | return promiseToUncontrolled2.then(() => {
221 | let { controller, rmi, rmiInner } = getComponents();
222 | expect(controller.state().value).to.equal(undefined);
223 | expect(rmi.state().value).to.deep.equal({ scale: 1, translation: { x: 0, y: 0 } });
224 | expect(rmi.props().value).to.equal(undefined);
225 | expect(rmiInner.props().value).to.deep.equal({ scale: 1, translation: { x: 0, y: 0 } });
226 | });
227 | });
228 | });
229 |
--------------------------------------------------------------------------------
/src/MapInteractionCSS.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import MapInteraction from './MapInteraction';
3 |
4 | /*
5 | This component provides a map like interaction to any content that you place in it. It will let
6 | the user zoom and pan the children by scaling and translating props.children using css.
7 | */
8 | const MapInteractionCSS = (props) => {
9 | return (
10 |
11 | {
12 | ({ translation, scale }) => {
13 | // Translate first and then scale. Otherwise, the scale would affect the translation.
14 | const transform = `translate(${translation.x}px, ${translation.y}px) scale(${scale})`;
15 | return (
16 |
30 |
37 | {props.children}
38 |
39 |
40 | );
41 | }
42 | }
43 |
44 | );
45 | };
46 |
47 | export default MapInteractionCSS;
48 |
--------------------------------------------------------------------------------
/src/TestUtil.js:
--------------------------------------------------------------------------------
1 | import sinon from 'sinon';
2 |
3 | import {MapInteractionControlled} from './MapInteraction';
4 |
5 | // mock the containerNode ref since it wont get set in a shallow render
6 | // this is required if your test needs to simulate dom events
7 | function mockContainerRef() {
8 | return sinon.stub(MapInteractionControlled.prototype, 'getContainerNode')
9 | .callsFake(() => {
10 | return {
11 | addEventListener: function() {},
12 | removeEventListener: function() {},
13 | getBoundingClientRect: function() {
14 | return { left: 0, width: 200, top: 0, height: 200 };
15 | }
16 | }
17 | });
18 | };
19 |
20 | // Just mock client rect. Useful for if you need the native
21 | // event listeners but still need to mock the client rect, which
22 | // jsdom mocks but with 0s as default values.
23 | function mockClientRect() {
24 | return sinon.stub(MapInteractionControlled.prototype, 'getContainerBoundingClientRect')
25 | .callsFake(() => {
26 | return { left: 0, width: 200, top: 0, height: 200 };
27 | });
28 | }
29 |
30 | export { mockContainerRef, mockClientRect };
31 |
--------------------------------------------------------------------------------
/src/UseCases.test.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { mount } from 'enzyme';
3 | import { expect } from 'chai';
4 | const jsdom = require('jsdom-global');
5 |
6 | import MapInteractionCSS from './MapInteractionCSS';
7 | import { mockContainerRef, mockClientRect } from './TestUtil.js';
8 |
9 | /*
10 | Utils
11 | */
12 |
13 | // Triggers mouse events
14 | // https://ghostinspector.com/blog/simulate-drag-and-drop-javascript-casperjs/
15 | // Would be great to use the newer MouseEvent constructor, but
16 | // jsom doesnt yet support it https://github.com/jsdom/jsdom/issues/1911
17 | function fireMouseEvent(type, elem, centerX, centerY) {
18 | const evt = document.createEvent('MouseEvents');
19 | evt.initMouseEvent(type, true, true, window, 1, 1, 1, centerX, centerY, false, false, false, false, 0, elem);
20 | elem.dispatchEvent(evt);
21 | };
22 |
23 | // Gets the css `transform` value from the .child (which is assumed
24 | // to be the child element passed to RMI).
25 | // @param enzymeWrapper The enzyme wrapper of MapInteractionCSS
26 | function getTransformString(enzymeWrapper) {
27 | const child = enzymeWrapper.getDOMNode().querySelector(".child");
28 | const parent = child.parentElement;
29 | return parent.style.transform;
30 | }
31 |
32 | // Extracts the translation css value from the `transform` css string
33 | // `transform: translate(10px, 30px) scale(2)` --> "10px, 30px"
34 | // `transform: translate(0px, 10px) blah(foo)` --> "0px, 10px"
35 | // @param enzymeWrapper The enzyme wrapper of MapInteractionCSS
36 | function getTranslation(enzymeWrapper) {
37 | const transformString = getTransformString(enzymeWrapper);
38 | const translateRegex = new RegExp(/translate\((.*?)\)/);
39 | const translateMatch = translateRegex.exec(transformString);
40 | return translateMatch[1];
41 | }
42 |
43 | // Extracts the scale css value from the transform css value
44 | // `transform: blah(foo) scale(2)` --> 2
45 | // `transform: translate(0px 10px) scale(3)` --> 3
46 | // @param enzymeWrapper The enzyme wrapper of MapInteractionCSS
47 | function getScale(enzymeWrapper) {
48 | const transformString = getTransformString(enzymeWrapper);
49 | const scaleRegex = new RegExp(/scale\((.*?)\)/);
50 | const scaleMatch = scaleRegex.exec(transformString);
51 | return parseFloat(scaleMatch[1]);
52 | }
53 |
54 | // Given the value extracted in `getTranslation` return
55 | // the constituent coordinates x,y
56 | // `0px 10px` --> { x: 0, y: 10 }
57 | // ` 0px 10.5px ` --> { x: 0, y: 10 }
58 | // @param translationString The css value for `translate`
59 | function coordsFromTranslationString(translationString) {
60 | const [x, y] = translationString
61 | .trim()
62 | .split(" ")
63 | .filter(s => !!s)
64 | .map(s => s.split("px")[0])
65 | .map(parseFloat);
66 | return { x, y };
67 | }
68 |
69 | // @param enzymeWrapper The wrapper from mounting MapInteractionCSS with a .child element
70 | function checkTransform(enzymeWrapper, scale, translation) {
71 | const translationString = getTranslation(enzymeWrapper);
72 | expect(translationString).to.deep.equal(`${translation.x}px, ${translation.y}px`);
73 | expect(getScale(enzymeWrapper)).to.equal(scale);
74 | }
75 |
76 | function makeWheelEvent(deltaY = 1) {
77 | // For some reason we need to manually attach
78 | // event params to the event instead of using the constructor
79 | // jsdom... https://github.com/jsdom/jsdom/issues/1434
80 | const evt = new Event("wheel", { bubbles: true });
81 | evt.deltaY = deltaY;
82 | evt.deltaX = 0;
83 | evt.clientX = 50;
84 | evt.clientY = 50;
85 | return evt;
86 | }
87 |
88 | // Utility for mounting an RMI instance and getting back some useful
89 | // handles on the wrapper and sub nodes
90 | // Note that it creates an uncontrolled instance
91 | function makeDefaultWrapper(scale = 1, translation = { x: 0, y: 0 }) {
92 | const wrapper = mount(
93 |
96 | hello
97 |
98 | );
99 | const child = wrapper.getDOMNode().querySelector(".child");
100 | return { wrapper, child };
101 | }
102 |
103 | /*
104 | Use case tests are designed to test the highest level
105 | boundary of the component. This serves two purposes, a) as documentation
106 | for your top level functionality, and b) to allow easier refactoring of
107 | internals without having to change the tests.
108 |
109 | These tests would be even better done via something like Selenium
110 | or https://www.cypress.io/ or phantomjs which exercise a real browser.
111 | */
112 | describe("Use case testing", () => {
113 | let cleanupDom;
114 | beforeEach(() => {
115 | cleanupDom = jsdom();
116 | })
117 |
118 | let refStub;
119 | let wrapper;
120 | let rectStub;
121 | afterEach(() => {
122 | if (wrapper) {
123 | wrapper.unmount();
124 | wrapper = undefined;
125 | };
126 | if (refStub) refStub.restore();
127 | if (rectStub) rectStub.restore();
128 | cleanupDom();
129 | });
130 |
131 | it("applies default translation and scale to the childs parent node", () => {
132 | wrapper = makeDefaultWrapper().wrapper;
133 | checkTransform(
134 | wrapper,
135 | 1,
136 | { x: 0, y: 0 }
137 | );
138 | });
139 |
140 | it("applies custom translation and scale to the childs parent node", () => {
141 | const scale = 2;
142 | const translation = { x: 50, y: 100 };
143 | wrapper = makeDefaultWrapper(scale, translation).wrapper;
144 |
145 | checkTransform(
146 | wrapper,
147 | scale,
148 | translation
149 | );
150 | });
151 |
152 | it("single pointer drag changes translation", () => {
153 | const nodes = makeDefaultWrapper();
154 | wrapper = nodes.wrapper;
155 | const child = nodes.child;
156 |
157 | // check default transform on init
158 | checkTransform(
159 | wrapper,
160 | 1,
161 | { x: 0, y: 0 }
162 | );
163 |
164 | fireMouseEvent('mousedown', child, 10, 10);
165 | fireMouseEvent('mousemove', window, 30, 60);
166 |
167 | checkTransform(
168 | wrapper,
169 | 1,
170 | { x: 20, y: 50 }
171 | );
172 | });
173 |
174 | it("positive wheel event decreases scale and adjusts translation", () => {
175 | const initialScale = 1;
176 | const initialTranslation = { x: 10, y: 10 };
177 | const nodes = makeDefaultWrapper(initialScale, initialTranslation);
178 | wrapper = nodes.wrapper;
179 |
180 | const evt = makeWheelEvent(100);
181 | nodes.child.dispatchEvent(evt);
182 |
183 | // Positive change in wheel decreases the scale
184 | const newScale = getScale(wrapper);
185 | const newTranslation = getTranslation(wrapper);
186 | expect(newScale).to.be.lessThan(initialScale);
187 |
188 | // Since the scale has gone down, the component will have
189 | // increased the translation to keep the focal point beneath the cursor
190 | // TODO Test for exactness, not inequality
191 | const { x: newX, y: newY } = coordsFromTranslationString(newTranslation);
192 | expect(newX).to.be.greaterThan(initialTranslation.x);
193 | expect(newY).to.be.greaterThan(initialTranslation.y);
194 | });
195 |
196 | it("negative wheel event increases scale and adjusts translation", () => {
197 | const initialScale = 1;
198 | const initialTranslation = { x: 10, y: 10 };
199 | const nodes = makeDefaultWrapper(initialScale, initialTranslation);
200 | wrapper = nodes.wrapper;
201 |
202 | const evt = makeWheelEvent(-100);
203 | nodes.child.dispatchEvent(evt);
204 |
205 | const newScale = getScale(wrapper);
206 | const newTranslation = getTranslation(wrapper);
207 |
208 | // Negative change in wheel decreases the scale
209 | expect(newScale).to.be.greaterThan(initialScale);
210 |
211 | // Since the scale has gone up, the component will have
212 | // decreased the translation to keep the focal point beneath the cursor
213 | // TODO Test for exactness, not inequality
214 | const { x: newX, y: newY } = coordsFromTranslationString(newTranslation);
215 | expect(newX).to.be.lessThan(initialTranslation.x);
216 | expect(newY).to.be.lessThan(initialTranslation.y);
217 | });
218 |
219 | it("allows clicking a plus button to increase scale", () => {
220 | refStub = mockContainerRef();
221 | const initialScale = 1;
222 | const initialTranslation = { x: 0, y: 0 };
223 |
224 | wrapper = mount(
225 |
231 |
232 |
233 | );
234 |
235 | const plusButton = wrapper.find("button.plus-button");
236 | plusButton.simulate('click');
237 |
238 | const newScale = getScale(wrapper);
239 | const newTranslation = getTranslation(wrapper);
240 |
241 | // The plus button increments scale
242 | expect(newScale).to.be.greaterThan(initialScale);
243 |
244 | // Scaling using the controls will use the center of
245 | // the content as the focal point.
246 | // TODO calculate exact scale,translation
247 | const { x: newX, y: newY } = coordsFromTranslationString(newTranslation);
248 | expect(newX).to.be.lessThan(initialTranslation.x);
249 | expect(newY).to.be.lessThan(initialTranslation.y);
250 | });
251 |
252 | it("handles single touch drag", () => {
253 | const nodes = makeDefaultWrapper();
254 | wrapper = nodes.wrapper;
255 |
256 | // manually simulate a touchstart event
257 | const evt = new Event('touchstart', { bubbles: true });
258 | evt.touches = [{ clientX: 0, clientY: 0 }];
259 | const evt2 = new Event('touchmove', { bubbles: true });
260 | evt2.touches = [{ clientX: 30, clientY: 0 }];
261 | const evt3 = new Event('touchend', { bubbles: true });
262 | evt3.touches = [{ clientX: 30, clientY: 0 }];
263 |
264 | nodes.child.dispatchEvent(evt);
265 | window.dispatchEvent(evt2);
266 | window.dispatchEvent(evt3);
267 |
268 | checkTransform(
269 | wrapper,
270 | 1,
271 | { x: 30, y: 0 }
272 | );
273 | });
274 |
275 | // Touch down and immediate touch up is a no-op
276 | it("two touches down then up wont change scale or translation", () => {
277 | rectStub = mockClientRect(); // Need client rect
278 | const initialScale = 2;
279 | const initialTranslation = { x: 10, y: 10 };
280 | const nodes = makeDefaultWrapper(initialScale, initialTranslation);
281 | wrapper = nodes.wrapper;
282 |
283 | const evt = new Event('touchstart', { bubbles: true });
284 | evt.touches = [{ clientX: 10, clientY: 10 }, { clientX: 100, clientY: 10 }];
285 | const evt2 = new Event('touchend', { bubbles: true });
286 | evt2.touches = [];
287 |
288 | nodes.child.dispatchEvent(evt);
289 | window.dispatchEvent(evt2);
290 |
291 | checkTransform(
292 | wrapper,
293 | initialScale,
294 | initialTranslation
295 | );
296 | });
297 |
298 | // This is the common case of two finger zoom, standard pinch to zoom with both
299 | // fingers moving way from one another.
300 | it("handles two finger zoom in with change in both dimensions, both fingers move", () => {
301 | rectStub = mockClientRect(); // Need getBoundingClientRect
302 |
303 | const initialScale = 2;
304 | const initialTranslation = { x: 10, y: 10 };
305 | const nodes = makeDefaultWrapper(initialScale, initialTranslation);
306 | wrapper = nodes.wrapper;
307 |
308 | const touchDeltaX = 50;
309 | const touchDeltaY = 50;
310 |
311 | // Trigger touches down, move, then up
312 | const evt = new Event('touchstart', { bubbles: true });
313 | evt.touches = [{ clientX: 10, clientY: 10 }, { clientX: 100, clientY: 100 }];
314 | const ev2 = new Event('touchmove');
315 | ev2.touches = [{ clientX: 10 - touchDeltaX, clientY: 10 - touchDeltaY }, { clientX: 100 + touchDeltaX, clientY: 100 + touchDeltaY }];
316 | const evt3 = new Event('touchend', { bubbles: true });
317 | evt3.touches = [];
318 |
319 | nodes.child.dispatchEvent(evt);
320 | window.dispatchEvent(ev2);
321 | window.dispatchEvent(evt3);
322 |
323 | const newScale = getScale(wrapper);
324 | const newTranslation = getTranslation(wrapper);
325 |
326 | expect(newScale).to.be.greaterThan(initialScale);
327 |
328 | // Since the scale has gone up, the component will have
329 | // decreased the translation to keep the focal point beneath the cursor
330 | // TODO Test for exactness, not inequality
331 | const { x: newX, y: newY } = coordsFromTranslationString(newTranslation);
332 | expect(newX).to.be.lessThan(initialTranslation.x);
333 | expect(newY).to.be.lessThan(initialTranslation.y);
334 | });
335 |
336 | // Two finger zoom, but one finger remains stationary and the other only
337 | // moves along the x-axis.
338 | it("handles two finger zoom in with change in only one dimension, one finger move", () => {
339 | // simulate two finger pinch and zoom out (fingers together)
340 | // demonstrating that the scale decreases and translation offsets
341 | rectStub = mockClientRect(); // Need client rect
342 | const initialScale = 1;
343 | const initialTranslation = { x: 0, y: 0 };
344 | const nodes = makeDefaultWrapper(initialScale, initialTranslation);
345 | wrapper = nodes.wrapper;
346 |
347 | const touchDeltaX = 50;
348 |
349 | // Trigger touches down, move, then up
350 | const evt = new Event('touchstart', { bubbles: true });
351 | evt.touches = [{ clientX: 10, clientY: 10 }, { clientX: 100, clientY: 10 }];
352 | const ev2 = new Event('touchmove');
353 | ev2.touches = [{ clientX: 10, clientY: 10 }, { clientX: 100 + touchDeltaX, clientY: 10 }];
354 | const evt3 = new Event('touchend', { bubbles: true });
355 | evt3.touches = [];
356 |
357 | nodes.child.dispatchEvent(evt);
358 | window.dispatchEvent(ev2);
359 | window.dispatchEvent(evt3);
360 |
361 | const newScale = getScale(wrapper);
362 | const newTranslation = getTranslation(wrapper);
363 |
364 | expect(newScale).to.be.greaterThan(initialScale);
365 |
366 | // Since the scale has gone up, the component will have
367 | // decreased the translation to keep the focal point beneath the cursor
368 | // TODO Test for exactness, not inequality
369 | const { x: newX, y: newY } = coordsFromTranslationString(newTranslation);
370 | expect(newX).to.be.lessThan(initialTranslation.x);
371 | expect(newY).to.be.lessThan(initialTranslation.y);
372 | });
373 |
374 | it("handles two finger zoom in with change in both dimensions, one finger move", () => {
375 | // simulate two finger pinch and zoom out (fingers together)
376 | // demonstrating that the scale decreases and translation offsets
377 | rectStub = mockClientRect(); // Need client rect
378 |
379 | const initialScale = 2;
380 | const initialTranslation = { x: 0, y: 0 };
381 | const nodes = makeDefaultWrapper(initialScale, initialTranslation);
382 | wrapper = nodes.wrapper;
383 |
384 | const touchDeltaX = 50;
385 | const touchDeltaY = 50;
386 |
387 | // Trigger touches down, move, then up
388 | const evt = new Event('touchstart', { bubbles: true });
389 | evt.touches = [{ clientX: 10, clientY: 10 }, { clientX: 100, clientY: 100 }];
390 | const ev2 = new Event('touchmove');
391 | ev2.touches = [{ clientX: 10, clientY: 10 }, { clientX: 100 + touchDeltaX, clientY: 100 + touchDeltaY }];
392 | const evt3 = new Event('touchend', { bubbles: true });
393 | evt3.touches = [];
394 |
395 | nodes.child.dispatchEvent(evt);
396 | window.dispatchEvent(ev2);
397 | window.dispatchEvent(evt3);
398 |
399 | const newScale = getScale(wrapper);
400 | const newTranslation = getTranslation(wrapper);
401 |
402 | expect(newScale).to.be.greaterThan(initialScale);
403 |
404 | // Since the scale has gone up, the component will have
405 | // decreased the translation to keep the focal point beneath the cursor
406 | // TODO Test for exactness, not inequality
407 | const { x: newX, y: newY } = coordsFromTranslationString(newTranslation);
408 | expect(newX).to.be.lessThan(initialTranslation.x);
409 | expect(newY).to.be.lessThan(initialTranslation.y);
410 | });
411 | });
412 |
--------------------------------------------------------------------------------
/src/geometry.js:
--------------------------------------------------------------------------------
1 | function clamp(min, value, max) {
2 | return Math.max(min, Math.min(value, max));
3 | }
4 |
5 | function distance(p1, p2) {
6 | const dx = p1.x - p2.x;
7 | const dy = p1.y - p2.y;
8 | return Math.sqrt(Math.pow(dx, 2) + Math.pow(dy, 2));
9 | }
10 |
11 | function midpoint(p1, p2) {
12 | return {
13 | x: (p1.x + p2.x) / 2,
14 | y: (p1.y + p2.y) / 2
15 | };
16 | }
17 |
18 | function touchPt(touch) {
19 | return { x: touch.clientX, y: touch.clientY };
20 | }
21 |
22 | function touchDistance(t0, t1) {
23 | const p0 = touchPt(t0);
24 | const p1 = touchPt(t1);
25 | return distance(p0, p1);
26 | }
27 |
28 | export {
29 | clamp,
30 | distance,
31 | midpoint,
32 | touchPt,
33 | touchDistance
34 | };
35 |
--------------------------------------------------------------------------------
/src/geometry.test.js:
--------------------------------------------------------------------------------
1 | import { expect } from 'chai';
2 | import { clamp, distance } from './geometry';
3 |
4 | describe("geometry", () => {
5 | describe("#clamp", () => {
6 | it("returns the value if within the range otherwise the closest bound", () => {
7 | expect(clamp(0, 5, 4)).to.equal(4);
8 | expect(clamp(0, -1, 4)).to.equal(0);
9 | expect(clamp(0, 0, 4)).to.equal(0);
10 | expect(clamp(0, 4, 4)).to.equal(4);
11 | });
12 |
13 | it('returns NaN on bad inputs', () => {
14 | expect(isNaN(clamp(undefined, 0, 10))).to.equal(true);
15 | expect(isNaN(clamp(0, "hello", 10))).to.equal(true);
16 | expect(isNaN(clamp(0, 1, {}))).to.equal(true);
17 | });
18 | });
19 |
20 | describe("#distance", () => {
21 | it("computes distance between two 2D points", () => {
22 | expect(distance({ x: 0, y: 0 }, { x: 0, y: 0 })).to.equal(0);
23 | expect(distance({ x: 0, y: 1 }, { x: 0, y: 0 })).to.be.greaterThan(0);
24 | });
25 | });
26 | });
27 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import MapInteractionCSS from './MapInteractionCSS';
2 | import MapInteraction from './MapInteraction';
3 |
4 | export { MapInteractionCSS, MapInteraction };
5 | export default MapInteraction;
6 |
--------------------------------------------------------------------------------
/src/makePassiveEventOption.js:
--------------------------------------------------------------------------------
1 | // We want to make event listeners non-passive, and to do so have to check
2 | // that browsers support EventListenerOptions in the first place.
3 | // https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#Safely_detecting_option_support
4 | let passiveSupported = false;
5 | try {
6 | const options = {
7 | get passive() {
8 | passiveSupported = true;
9 | }
10 | };
11 | window.addEventListener("test", options, options);
12 | window.removeEventListener("test", options, options);
13 | } catch {
14 | passiveSupported = false;
15 | }
16 |
17 | function makePassiveEventOption(passive) {
18 | return passiveSupported ? { passive } : passive;
19 | }
20 |
21 | export default makePassiveEventOption;
22 |
--------------------------------------------------------------------------------
/stories/grid.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/strateos/react-map-interaction/7e699e8a7abdfda3eb112e8a0b4184c28738388f/stories/grid.png
--------------------------------------------------------------------------------
/stories/index.stories.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import { storiesOf } from '@storybook/react';
3 | import { action } from '@storybook/addon-actions';
4 |
5 | import { MapInteractionCSS } from '../src';
6 | import gridImg from './grid.png';
7 |
8 | const BLUE_BORDER = '1px solid blue';
9 |
10 | storiesOf('MapInteractionCSS', module)
11 | .add('Basic uncontrolled', () => {
12 | return (
13 |
14 |
15 |
16 |
17 |
18 | )
19 | })
20 | .add('Basic controlled', () => {
21 | class Controller extends Component {
22 | constructor(props) {
23 | super(props);
24 | this.state = {
25 | value: {
26 | scale: 1, translation: { x: 0, y: 0 }
27 | }
28 | };
29 | }
30 |
31 | render() {
32 | return (
33 |
34 |
{
37 | this.setState({ value });
38 | }}
39 | showControls
40 | >
41 |
42 |
43 |
44 | );
45 | }
46 | }
47 |
48 | return ;
49 | })
50 | .add('Flip controlled to uncontrolled', () => {
51 | class Controller extends Component {
52 | constructor(props) {
53 | super(props);
54 | this.state = {
55 | value: {
56 | scale: 1,
57 | translation: { x: 0, y: 0 }
58 | },
59 | controlled: true
60 | };
61 | }
62 |
63 | render() {
64 | const { controlled, scale, translation } = this.state;
65 |
66 | return (
67 |
68 |
this.setState({ value }) : undefined}
71 | showControls
72 | >
73 |
74 |
75 |
{
77 | this.setState((state) => {
78 | return { controlled: !state.controlled };
79 | });
80 | }}
81 | >
82 | flip
83 |
84 |
85 | );
86 | }
87 | }
88 |
89 | return ;
90 | })
91 | .add('Button inside', () => {
92 | return (
93 |
94 |
95 | {
97 | if (e.defaultPrevented) {
98 | action('Drag!')();
99 | } else {
100 | action("Click!")();
101 | }
102 | }}
103 | onTouchEnd={(e) => {
104 | if (e.defaultPrevented) {
105 | action('Drag!')();
106 | } else {
107 | action("Click!")();
108 | }
109 | }}
110 | >
111 | click me
112 |
113 |
114 |
115 | );
116 | })
117 | .add('2 on screen', () => {
118 | return (
119 |
120 |
121 |
122 | click me
123 |
124 |
125 |
126 |
127 |
128 | click me
129 |
130 |
131 |
132 | );
133 | })
134 | .add('Click element outside', () => {
135 | return (
136 |
137 |
138 |
139 |
140 |
141 |
142 |
143 |
click me
144 |
145 | )
146 | })
147 | .add('Text input outside', () => {
148 | return (
149 |
150 |
151 |
152 |
153 |
154 |
155 |
156 |
157 |
158 | )
159 | })
160 | .add('Controls', () => {
161 | return (
162 |
163 |
164 |
165 |
166 |
167 | )
168 | })
169 |
--------------------------------------------------------------------------------
/webpack.config.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 |
3 | const SRC_PATH = path.join(__dirname, 'src');
4 | const ENTRY_PATH = path.join(__dirname, 'src/index.js');
5 | const DEST_PATH = path.join(__dirname, 'dist');
6 |
7 | module.exports = {
8 | entry: ENTRY_PATH,
9 | output: {
10 | filename: 'react-map-interaction.js',
11 | path: DEST_PATH,
12 | library: 'ReactMapInteraction',
13 | libraryTarget: 'umd',
14 | globalObject: 'this',
15 | },
16 | module: {
17 | rules: [
18 | {
19 | test: /\.(js|jsx)$/,
20 | include: SRC_PATH,
21 | use: 'babel-loader'
22 | }
23 | ]
24 | },
25 | resolve: {
26 | extensions: [".js", ".jsx"]
27 | },
28 | externals: {
29 | react: {
30 | commonjs: "react",
31 | commonjs2: "react",
32 | amd: "React",
33 | root: "React"
34 | },
35 | "prop-types": {
36 | commonjs: "prop-types",
37 | commonjs2: "prop-types",
38 | "commonj2s": "prop-types",
39 | amd: "prop-types",
40 | root: "PropTypes"
41 | }
42 | },
43 | optimization: {
44 | minimize: false
45 | }
46 | };
47 |
--------------------------------------------------------------------------------