├── .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 | ![example zooming map](./example.gif) 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 | 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 | 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 | 55 |
56 |
57 | 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 | 84 |
85 | ); 86 | } 87 | } 88 | 89 | return ; 90 | }) 91 | .add('Button inside', () => { 92 | return ( 93 |
94 | 95 | 113 | 114 |
115 | ); 116 | }) 117 | .add('2 on screen', () => { 118 | return ( 119 |
120 |
121 | 122 | 123 | 124 |
125 |
126 |
127 | 128 | 129 | 130 |
131 |
132 | ); 133 | }) 134 | .add('Click element outside', () => { 135 | return ( 136 |
137 |
138 | 139 | 140 | 141 |
142 |
143 | 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 | --------------------------------------------------------------------------------