From be502385bc3f27f9d79408ec4b4eee7dd57f89a4 Mon Sep 17 00:00:00 2001 From: David Manthey Date: Fri, 27 Jan 2017 10:02:11 -0500 Subject: [PATCH 1/4] Handle basic touch interactions. This uses hammerjs as an optional dependency. It is only loaded if the touch support is detected in the browser OR the alwaysTouch option is set. By default, it handles pan and rotate events, where rotate events include zoom (pinch) actions. If hammerjs is not available, touch actions are not supported. Actions are configured through our general action mechanism, so that they could require different gestures, though I suspect anything different will require some work. There is currently no touch support for selecting a rectangular region. Since 1-touch is pan and 2-touch is zoom/rotate, this would either require using 3-touch or some sort of tapping, I would think. This still needs tests. --- external.config.js | 3 +- package.json | 4 +- src/action.js | 1 + src/main.styl | 1 + src/mapInteractor.js | 278 ++++++++++++++++++++++++++++++++++++++++--- webpack.config.js | 4 +- 6 files changed, 269 insertions(+), 22 deletions(-) diff --git a/external.config.js b/external.config.js index 93de2eb858..2bde999cce 100644 --- a/external.config.js +++ b/external.config.js @@ -14,7 +14,8 @@ module.exports = { }, resolve: { alias: { - d3: 'd3/d3.js' + d3: 'd3/d3.js', + hammerjs: 'hammerjs/hammer.js' } }, plugins: [ diff --git a/package.json b/package.json index ed02ffed52..8ea346459f 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,8 @@ "url": "https://github.com/OpenGeoscience/geojs" }, "optionalDependencies": { - "d3": "^3.5.16" + "d3": "^3.5.16", + "hammerjs": "^2.0.8" }, "devDependencies": { "blanket": "~1.2", @@ -71,6 +72,7 @@ "style-loader": "^0.13.1", "stylus": "^0.54.5", "stylus-loader": "^2.4.0", + "touch-emulator": "^1.0.0", "url-loader": "^0.5.7", "vgl": "0.3.10", "webpack": "^1.12.14", diff --git a/src/action.js b/src/action.js index e65e832a8f..af8b34efc8 100644 --- a/src/action.js +++ b/src/action.js @@ -11,6 +11,7 @@ var geo_action = { select: 'geo_action_select', unzoomselect: 'geo_action_unzoomselect', zoom: 'geo_action_zoom', + zoomrotate: 'geo_action_zoom_rotate', zoomselect: 'geo_action_zoomselect', // annotation actions diff --git a/src/main.styl b/src/main.styl index c82e194b94..5699d95c71 100644 --- a/src/main.styl +++ b/src/main.styl @@ -2,6 +2,7 @@ .geojs-map position relative + user-select none .geo-attribution position absolute diff --git a/src/mapInteractor.js b/src/mapInteractor.js index 5f98eabe80..d3b64b2d91 100644 --- a/src/mapInteractor.js +++ b/src/mapInteractor.js @@ -35,6 +35,7 @@ var mapInteractor = function (args) { m_mouse, m_keyHandler, m_boundKeys, + m_touchHandler, m_state, m_queue, $node, @@ -140,6 +141,16 @@ var mapInteractor = function (args) { selectionRectangle: geo_event.unzoomselect, owner: 'geo.mapInteractor', name: 'drag unzoom' + }, { + action: geo_action.pan, + input: 'pan', + owner: 'geo.mapInteractor', + name: 'touch pan' + }, { + action: geo_action.zoomrotate, + input: 'rotate', + owner: 'geo.mapInteractor', + name: 'touch zoom and rotate' }], click: { @@ -192,11 +203,35 @@ var mapInteractor = function (args) { * (which would propably require detecting keyup events). */ focusHighlight: true }, - + /* Set alwaysTouch to false to only add touch support on devices that + * report touch support. Set to true to add touch support on all + * devices. */ + alwaysTouch: false, wheelScaleX: 1, wheelScaleY: 1, zoomScale: 1, rotateWheelScale: 6 * Math.PI / 180, + /* The minimum angle of rotation (in radians) before the + * geo_action.zoomrotate action will allow rotation. Set to 0 to always + * include rotation. */ + zoomrotateMinimumRotation: 5.0 * Math.PI / 180, + /* The minimum angle of rotation (in radians) before the + * geo_action.zoomrotate action will reverse the rotation direction. + * This helps reduce chatter when zooms and pans are combined with + * rotations. */ + zoomrotateReverseRotation: 2.0 * Math.PI / 180, + /* The minimum zoom factor change (increasing or descreasing) before the + * geo_action.zoomrotate action will allow zoom. Set to 0 to always + * include zoom. */ + zoomrotateMinimumZoom: 0.05, + /* The minimum number of pixels before the geo_action.zoomrotate action + * will allow panning. Set to 0 to always include panning. */ + zoomrotateMinimumPan: 5, + /* The touch pan delay prevents a touch pan event from immediately + * following a rotate (including zoom) event. No touch pan event is + * processed within this number of milliseconds of a non-pan touch + * event. */ + touchPanDelay: 50, momentum: { enabled: true, maxSpeed: 2.5, @@ -566,6 +601,90 @@ var mapInteractor = function (args) { } }; + /** + * Check if this browser has touch support. + * Copied from https://github.com/hammerjs/touchemulator under the MIT + * license. + * + * @returns {boolean}: true if there is touch support. + */ + this.hasTouchSupport = function () { + return ('ontouchstart' in window) || // touch events + (window.Modernizr && window.Modernizr.touch) || // modernizr + (navigator.msMaxTouchPoints || navigator.maxTouchPoints) > 1; // pointer events + }; + + /** + * Handle touch events. + * + * @param {object} evt: the touch event. + */ + this._handleTouch = function (evt) { + var endIfBound = false; + if (evt.pointerType === 'mouse' && m_touchHandler.touchSupport) { + endIfBound = true; + } + if (evt.type === 'hammer.input') { + if (m_touchHandler.lastEventType === 'pan' && evt.pointers.length !== 1) { + endIfBound = true; + m_touchHandler.lastEventType = null; + } else { + return; + } + } + var evtType = /^(.*)(start|end|move)$/.exec(evt.type); + if (!evtType || evtType.length !== 3) { + endIfBound = true; + } + if (endIfBound) { + if (m_state.boundDocumentHandlers && m_touchHandler.lastEvent) { + m_this._handleMouseUpDocument(m_touchHandler.lastEvent); + } + return; + } + evt.which = evtType[1]; + var time = (new Date()).valueOf(); + if (evt.which === 'pan' && m_touchHandler.lastEventType !== 'pan' && + time - m_touchHandler.lastTime < m_options.touchPanDelay) { + return; + } + m_touchHandler.lastTime = time; + m_touchHandler.lastEventType = evt.which; + m_touchHandler.lastEvent = evt; + /* convert touch events to have page locations */ + var offset = $node.offset(); + if (evt.pageX === undefined && evt.center !== undefined && evt.center.x !== undefined) { + evt.pageX = evt.center.x + offset.left; + evt.pageY = evt.center.y + offset.top; + } + /* start events should occur *before* the triggering delta. By using the + * mouse handlers, we get all of the action properties we expect (and + * actions can be changed or defined as we see fit). */ + if (evtType[2] === 'start') { + m_this._handleMouseDown(evt); + m_this._setClickMaybe(false); + if (m_state.boundDocumentHandlers) { + $(document).on('mousemove.geojs', m_this._handleMouseUpDocument); + } + } + /* start and move events both trigger a movement */ + if (evtType[2] === 'start' || evtType[2] === 'move') { + if (m_state.boundDocumentHandlers) { + m_this._handleMouseMoveDocument(evt); + } else { + m_this._handleMouseMove(evt); + } + } + if (evtType[2] === 'end' || evtType[2] === 'cancel') { + if (m_state.boundDocumentHandlers) { + m_this._handleMouseUpDocument(evt); + } else { + m_this._handleMouseUp(evt); + } + m_touchHandler.lastEvent = null; + } + }; + //////////////////////////////////////////////////////////////////////////// /** * Connects events to a map. If the map is not set, then this does nothing. @@ -587,6 +706,14 @@ var mapInteractor = function (args) { m_this._handleMouseWheel = throttled_wheel(); m_callZoom = debounced_zoom(); + // catalog what inputs we are using + util.adjustActions(m_options.actions); + var usedInputs = {}; + ['right', 'pan', 'rotate'].forEach(function (input) { + usedInputs[input] = m_options.actions.some(function (action) { + return action.input[input]; + }); + }); // add event handlers $node.on('wheel.geojs', m_this._handleMouseWheel); $node.on('mousemove.geojs', m_this._handleMouseMove); @@ -594,13 +721,11 @@ var mapInteractor = function (args) { $node.on('mouseup.geojs', m_this._handleMouseUp); // Disable dragging images and such $node.on('dragstart', function () { return false; }); - util.adjustActions(m_options.actions); - if (m_options.actions.some(function (action) { - return action.input.right; - })) { + if (usedInputs.right) { $node.on('contextmenu.geojs', function () { return false; }); } + // bind keyboard events if (m_options.keyboard && m_options.keyboard.actions) { m_keyHandler = Mousetrap($node[0]); var bound = []; @@ -622,6 +747,32 @@ var mapInteractor = function (args) { $node.toggleClass('highlight-focus', m_boundKeys && m_boundKeys.length && m_options.keyboard.focusHighlight); + // bind touch events + if ((m_this.hasTouchSupport() || m_options.alwaysTouch) && + (usedInputs.pan || usedInputs.rotate) && + __webpack_modules__[require.resolveWeak('hammerjs')]) { // eslint-disable-line + var Hammer = require('hammerjs'); + var recog = [], + touchEvents = ['hammer.input']; + if (usedInputs.rotate) { + recog.push([Hammer.Rotate, {enable: true}]); + touchEvents = touchEvents.concat(['rotatestart', 'rotateend', 'rotatemove']); + } + if (usedInputs.pan) { + recog.push([Hammer.Pan, {direction: Hammer.DIRECTION_ALL}]); + touchEvents = touchEvents.concat(['panstart', 'panend', 'panmove']); + } + var hammerParams = {recognizers: recog}; + m_touchHandler = { + manager: new Hammer.Manager($node[0], hammerParams), + touchSupport: m_this.hasTouchSupport(), + lastTime: 0 + }; + touchEvents.forEach(function (touchEvent) { + m_touchHandler.manager.on(touchEvent, m_this._handleTouch); + }); + } + return m_this; }; @@ -639,6 +790,10 @@ var mapInteractor = function (args) { m_boundKeys = null; m_keyHandler = null; } + if (m_touchHandler) { + m_touchHandler.manager.destroy(); + m_touchHandler = null; + } if ($node) { $node.off('.geojs'); $node = null; @@ -726,12 +881,22 @@ var mapInteractor = function (args) { */ //////////////////////////////////////////////////////////////////////////// this._getMouseButton = function (evt) { - if (evt.which === 1) { - m_mouse.buttons.left = evt.type !== 'mouseup'; - } else if (evt.which === 3) { - m_mouse.buttons.right = evt.type !== 'mouseup'; - } else if (evt.which === 2) { - m_mouse.buttons.middle = evt.type !== 'mouseup'; + for (var prop in m_mouse.buttons) { + if (m_mouse.buttons.hasOwnProperty(prop)) { + m_mouse.buttons[prop] = false; + } + } + if (evt.type !== 'mouseup') { + switch (evt.which) { + case 1: m_mouse.buttons.left = true; break; + case 2: m_mouse.buttons.middle = true; break; + case 3: m_mouse.buttons.right = true; break; + default: + if (evt.which) { + m_mouse.buttons[evt.which] = true; + } + break; + } } }; @@ -878,12 +1043,12 @@ var mapInteractor = function (args) { } } actionRecord = actionMatch(m_mouse.buttons, m_mouse.modifiers, - m_options.actions); + m_options.actions); action = (actionRecord || {}).action; + var map = m_this.map(); // cancel transitions and momentum on click - m_this.map().transitionCancel( - '_handleMouseDown' + (action ? '.' + action : '')); + map.transitionCancel('_handleMouseDown' + (action ? '.' + action : '')); m_this.cancel(geo_action.momentum); m_mouse.velocity = { @@ -902,6 +1067,9 @@ var mapInteractor = function (args) { action: action, actionRecord: actionRecord, origin: $.extend(true, {}, m_mouse), + initialZoom: map.zoom(), + initialRotation: map.rotation(), + initialEventRotation: evt.rotation, delta: {x: 0, y: 0} }; @@ -909,20 +1077,20 @@ var mapInteractor = function (args) { // Make sure the old selection layer is gone. if (m_selectionLayer) { m_selectionLayer.clear(); - m_this.map().deleteLayer(m_selectionLayer); + map.deleteLayer(m_selectionLayer); m_selectionLayer = null; } - m_selectionLayer = m_this.map().createLayer( + m_selectionLayer = map.createLayer( 'feature', {features: [quadFeature.capabilities.color]}); m_selectionQuad = m_selectionLayer.createFeature( - 'quad', {gcs: m_this.map().gcs()}); + 'quad', {gcs: map.gcs()}); m_selectionQuad.style({ opacity: 0.25, color: {r: 0.3, g: 0.3, b: 0.3} }); - m_this.map().geoTrigger(geo_event.brushstart, m_this._getSelection()); + map.geoTrigger(geo_event.brushstart, m_this._getSelection()); } - m_this.map().geoTrigger(geo_event.actiondown, { + map.geoTrigger(geo_event.actiondown, { state: m_this.state(), mouse: m_this.mouse(), event: evt}); // bind temporary handlers to document @@ -975,6 +1143,76 @@ var mapInteractor = function (args) { m_this.map().geoTrigger(geo_event.mousemove, m_this.mouse()); }; + /** + * Handle the zoomrotate action. + * + * @param {object} evt: the mouse event that triggered this. + */ + this._handleZoomrotate = function (evt) { + /* Only zoom if we have once execeed the initial zoom threshold. */ + var deltaZoom = Math.log2(evt.scale); + if (!m_state.zoomrotateAllowZoom && deltaZoom && + Math.abs(deltaZoom) >= m_options.zoomrotateMinimumZoom) { + if (m_options.zoomrotateMinimumZoom) { + m_state.initialZoom -= deltaZoom; + } + m_state.zoomrotateAllowZoom = true; + } + if (m_state.zoomrotateAllowZoom && deltaZoom) { + var zoom = m_state.initialZoom + deltaZoom; + m_this.map().zoom(zoom, m_state.origin); + } + /* Only rotate if we have once execeed the initial rotation threshold. The + * first time this happens (if the threshold is greater than zero), set the + * start of rotation to the current position, so that there is no sudden + * jump. */ + var deltaTheta = (evt.rotation - m_state.initialEventRotation) * Math.PI / 180; + if (!m_state.zoomrotateAllowRotation && deltaTheta && + Math.abs(deltaTheta) >= m_options.zoomrotateMinimumRotation) { + if (m_options.zoomrotateMinimumRotation) { + m_state.initialEventRotation = evt.rotation; + deltaTheta = 0; + } + m_state.zoomrotateAllowRotation = true; + } + if (m_state.zoomrotateAllowRotation) { + var theta = m_state.initialRotation + deltaTheta; + deltaTheta = theta - m_this.map().rotation(); + /* If we reverse direction, don't rotate until some threshold is + * exceeded. This helps prevent rotation bouncing while panning. */ + if (deltaTheta && (deltaTheta * (m_state.lastRotationDelta || 0) >= 0 || Math.abs(deltaTheta) >= m_options.zoomrotateReverseRotation)) { + m_this.map().rotation(theta, m_state.origin); + m_state.lastRotationDelta = deltaTheta; + } + } + /* Only pan if we have once exceed the initial pan threshold. */ + var panOrigin = m_state.origin.page; + if (m_state.initialEventGeo) { + var offset = $node.offset(); + panOrigin = m_this.map().gcsToDisplay(m_state.initialEventGeo); + panOrigin.x += offset.left; + panOrigin.y += offset.top; + } + var x = evt.pageX, deltaX = x - panOrigin.x, + y = evt.pageY, deltaY = y - panOrigin.y, + deltaPan2 = deltaX * deltaX + deltaY * deltaY; + if (!m_state.zoomrotateAllowPan && deltaPan2 && + deltaPan2 >= m_options.zoomrotateMinimumPan * m_options.zoomrotateMinimumPan) { + if (m_options.zoomrotateMinimumPan) { + // m_state.origin = m_this.mouse(); + deltaX = deltaY = 0; + m_state.initialEventGeo = m_this.mouse().geo; + } else { + m_state.initialEventGeo = m_state.origin.geo; + } + m_state.zoomrotateAllowPan = true; + } + if (m_state.zoomrotateAllowPan && (deltaX || deltaY)) { + // m_state.origin = m_this.mouse(); + m_this.map().pan({x: deltaX, y: deltaY}); + } + }; + //////////////////////////////////////////////////////////////////////////// /** * Handle mouse move event on the document (temporary bindings) @@ -1033,6 +1271,8 @@ var mapInteractor = function (args) { cx = m_mouse.map.x - m_this.map().size().width / 2; cy = m_mouse.map.y - m_this.map().size().height / 2; m_this.map().rotation(m_state.origin.rotation + Math.atan2(cy, cx)); + } else if (m_state.action === geo_action.zoomrotate) { + m_this._handleZoomrotate(evt); } else if (m_state.actionRecord.selectionRectangle) { // Get the bounds of the current selection selectionObj = m_this._getSelection(); diff --git a/webpack.config.js b/webpack.config.js index f87e87e647..259828bcd6 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -37,11 +37,13 @@ module.exports = { proj4: 'proj4/lib', vgl: 'vgl/vgl.js', d3: 'd3/d3.js', + hammerjs: 'hammerjs/hammer.js', mousetrap: 'mousetrap/mousetrap.js' } }, externals: { - d3: 'd3' + d3: 'd3', + hammerjs: 'hammerjs' }, plugins: [ define_plugin, From bf879b2c44b9fbcaec7b7adb1ba06322c6b329a2 Mon Sep 17 00:00:00 2001 From: David Manthey Date: Tue, 7 Mar 2017 16:02:37 -0500 Subject: [PATCH 2/4] Add mapInteractor coverage tests. --- src/mapInteractor.js | 39 +++++++---- tests/cases/mapInteractor.js | 127 +++++++++++++++++++++++++++++++++++ 2 files changed, 153 insertions(+), 13 deletions(-) diff --git a/src/mapInteractor.js b/src/mapInteractor.js index d3b64b2d91..821d6e2c3e 100644 --- a/src/mapInteractor.js +++ b/src/mapInteractor.js @@ -219,7 +219,7 @@ var mapInteractor = function (args) { * geo_action.zoomrotate action will reverse the rotation direction. * This helps reduce chatter when zooms and pans are combined with * rotations. */ - zoomrotateReverseRotation: 2.0 * Math.PI / 180, + zoomrotateReverseRotation: 4.0 * Math.PI / 180, /* The minimum zoom factor change (increasing or descreasing) before the * geo_action.zoomrotate action will allow zoom. Set to 0 to always * include zoom. */ @@ -762,7 +762,7 @@ var mapInteractor = function (args) { recog.push([Hammer.Pan, {direction: Hammer.DIRECTION_ALL}]); touchEvents = touchEvents.concat(['panstart', 'panend', 'panmove']); } - var hammerParams = {recognizers: recog}; + var hammerParams = {recognizers: recog, preventDefault: true}; m_touchHandler = { manager: new Hammer.Manager($node[0], hammerParams), touchSupport: m_this.hasTouchSupport(), @@ -778,7 +778,8 @@ var mapInteractor = function (args) { //////////////////////////////////////////////////////////////////////////// /** - * Disonnects events to a map. If the map is not set, then this does nothing. + * Disiconnects events to a map. If the map is not set, then this does + * nothing. * @returns {geo.mapInteractor} */ //////////////////////////////////////////////////////////////////////////// @@ -1149,10 +1150,10 @@ var mapInteractor = function (args) { * @param {object} evt: the mouse event that triggered this. */ this._handleZoomrotate = function (evt) { - /* Only zoom if we have once execeed the initial zoom threshold. */ + /* Only zoom if we have once exceeded the initial zoom threshold. */ var deltaZoom = Math.log2(evt.scale); if (!m_state.zoomrotateAllowZoom && deltaZoom && - Math.abs(deltaZoom) >= m_options.zoomrotateMinimumZoom) { + Math.abs(deltaZoom) >= Math.log2(1 + m_options.zoomrotateMinimumZoom)) { if (m_options.zoomrotateMinimumZoom) { m_state.initialZoom -= deltaZoom; } @@ -1162,7 +1163,7 @@ var mapInteractor = function (args) { var zoom = m_state.initialZoom + deltaZoom; m_this.map().zoom(zoom, m_state.origin); } - /* Only rotate if we have once execeed the initial rotation threshold. The + /* Only rotate if we have once exceeded the initial rotation threshold. The * first time this happens (if the threshold is greater than zero), set the * start of rotation to the current position, so that there is no sudden * jump. */ @@ -1177,7 +1178,10 @@ var mapInteractor = function (args) { } if (m_state.zoomrotateAllowRotation) { var theta = m_state.initialRotation + deltaTheta; - deltaTheta = theta - m_this.map().rotation(); + /* Compute the delta in the range of [-PI, PI). This is involed to work + * around modulo returning a signed value. */ + deltaTheta = ((theta - m_this.map().rotation()) % (Math.PI * 2) + + Math.PI * 3) % (Math.PI * 2) - Math.PI; /* If we reverse direction, don't rotate until some threshold is * exceeded. This helps prevent rotation bouncing while panning. */ if (deltaTheta && (deltaTheta * (m_state.lastRotationDelta || 0) >= 0 || Math.abs(deltaTheta) >= m_options.zoomrotateReverseRotation)) { @@ -1199,7 +1203,6 @@ var mapInteractor = function (args) { if (!m_state.zoomrotateAllowPan && deltaPan2 && deltaPan2 >= m_options.zoomrotateMinimumPan * m_options.zoomrotateMinimumPan) { if (m_options.zoomrotateMinimumPan) { - // m_state.origin = m_this.mouse(); deltaX = deltaY = 0; m_state.initialEventGeo = m_this.mouse().geo; } else { @@ -1208,7 +1211,6 @@ var mapInteractor = function (args) { m_state.zoomrotateAllowPan = true; } if (m_state.zoomrotateAllowPan && (deltaX || deltaY)) { - // m_state.origin = m_this.mouse(); m_this.map().pan({x: deltaX, y: deltaY}); } }; @@ -1372,7 +1374,7 @@ var mapInteractor = function (args) { //////////////////////////////////////////////////////////////////////////// /** - * Based on the screen coodinates of a selection, zoom or unzoom and + * Based on the screen coordinates of a selection, zoom or unzoom and * recenter. * * @private @@ -1731,7 +1733,7 @@ var mapInteractor = function (args) { if (action) { // if we were moving because of momentum or a transition, cancel it and - // recompute where the mouse action is occuring. + // recompute where the mouse action is occurring. var recompute = m_this.map().transitionCancel('wheel.' + action); recompute |= m_this.cancel(geo_action.momentum, true); if (recompute) { @@ -2024,7 +2026,7 @@ var mapInteractor = function (args) { if (type === 'keyboard' && m_keyHandler) { /* Mousetrap passes through the keys we send, but not an event object, - * so we construct an artifical event object as the keys, and use that. + * so we construct an artificial event object as the keys, and use that. */ var keys = { shiftKey: options.shift || options.shiftKey || false, @@ -2069,6 +2071,13 @@ var mapInteractor = function (args) { ctrlKey: options.modifiers.indexOf('ctrl') >= 0, metaKey: options.modifiers.indexOf('meta') >= 0, shiftKey: options.modifiers.indexOf('shift') >= 0, + + center: options.center, + rotation: options.touch ? options.rotation || 0 : options.rotation, + scale: options.touch ? options.scale || 1 : options.scale, + pointers: options.pointers, + pointerType: options.pointerType, + originalEvent: { deltaX: options.wheelDelta.x, deltaY: options.wheelDelta.y, @@ -2079,7 +2088,11 @@ var mapInteractor = function (args) { } } ); - $node.trigger(evt); + if (options.touch && m_touchHandler) { + m_this._handleTouch(evt); + } else { + $node.trigger(evt); + } if (type.indexOf('.geojs') >= 0) { $(document).trigger(evt); } diff --git a/tests/cases/mapInteractor.js b/tests/cases/mapInteractor.js index 066e474d9e..0ca066d6b2 100644 --- a/tests/cases/mapInteractor.js +++ b/tests/cases/mapInteractor.js @@ -1691,4 +1691,131 @@ describe('mapInteractor', function () { interactor.simulateEvent('keyboard', {keys: '2'}); expect(triggered).toBe(0); }); + + it('Test touch interactions', function () { + var map = mockedMap('#mapNode1'), + interactor = geo.mapInteractor({map: map}); + + expect(interactor.hasTouchSupport()).toBe(true); + + // check the pan event was called + interactor.simulateEvent( + 'panstart', {touch: true, center: {x: 20, y: 20}}); + interactor.simulateEvent( + 'panmove', {touch: true, center: {x: 30, y: 20}}); + interactor.simulateEvent( + 'panend', {touch: true, center: {x: 40, y: 20}}); + expect(map.info.pan).toBe(2); + expect(map.info.panArgs.x).toBe(10); + expect(map.info.panArgs.y).toBe(0); + + // A two-pointer event will end the action + interactor.simulateEvent( + 'panstart', {touch: true, center: {x: 20, y: 20}}); + interactor.simulateEvent( + 'hammer.input', {touch: true, center: {x: 30, y: 20}, pointers: [1]}); + interactor.simulateEvent( + 'panmove', {touch: true, center: {x: 30, y: 20}}); + expect(map.info.pan).toBe(4); + interactor.simulateEvent( + 'hammer.input', {touch: true, center: {x: 40, y: 20}, pointers: [1, 2]}); + interactor.simulateEvent( + 'panmove', {touch: true, center: {x: 50, y: 20}}); + interactor.simulateEvent( + 'panend', {touch: true, center: {x: 60, y: 20}}); + expect(map.info.pan).toBe(4); + expect(map.info.panArgs.x).toBe(10); + expect(map.info.panArgs.y).toBe(0); + + // check the two-fingered pan event was called + interactor.simulateEvent( + 'rotatestart', {touch: true, center: {x: 20, y: 20}}); + // first movement exceeds the threshold, but doesn't register + interactor.simulateEvent( + 'rotatemove', {touch: true, center: {x: 31, y: 21}}); + // second movement will result in a pan + interactor.simulateEvent( + 'rotatemove', {touch: true, center: {x: 42, y: 22}}); + interactor.simulateEvent( + 'rotateend', {touch: true, center: {x: 53, y: 23}}); + expect(map.info.pan).toBe(5); + expect(map.info.panArgs.x).toBe(11); + expect(map.info.panArgs.y).toBe(1); + + // a spurious event will end the action + interactor.simulateEvent( + 'rotatestart', {touch: true, center: {x: 20, y: 20}}); + interactor.simulateEvent( + 'rotatemove', {touch: true, center: {x: 30, y: 20}}); + interactor.simulateEvent( + 'spurious', {touch: true, center: {x: 30, y: 20}}); + interactor.simulateEvent( + 'rotatemove', {touch: true, center: {x: 40, y: 20}}); + interactor.simulateEvent( + 'rotateend', {touch: true, center: {x: 50, y: 20}}); + expect(map.info.pan).toBe(5); + + // a mouse move will end the action + interactor.simulateEvent( + 'rotatestart', {touch: true, center: {x: 20, y: 20}}); + interactor.simulateEvent( + 'rotatemove', {touch: true, center: {x: 30, y: 20}}); + interactor.simulateEvent( + 'rotatemove', {touch: true, center: {x: 30, y: 20}, pointerType: 'mouse'}); + interactor.simulateEvent( + 'rotatemove', {touch: true, center: {x: 40, y: 20}}); + interactor.simulateEvent( + 'rotateend', {touch: true, center: {x: 50, y: 20}}); + expect(map.info.pan).toBe(5); + + // a zero-threshold will result in a faster pan + interactor.options({zoomrotateMinimumPan: 0}); + interactor.simulateEvent( + 'rotatestart', {touch: true, center: {x: 20, y: 20}}); + // first movement will result in a pan + interactor.simulateEvent( + 'rotatemove', {touch: true, center: {x: 32, y: 22}}); + expect(map.info.pan).toBe(6); + expect(map.info.panArgs.x).toBe(12); + expect(map.info.panArgs.y).toBe(2); + // second movement will result in a pan + interactor.simulateEvent( + 'rotatemove', {touch: true, center: {x: 44, y: 23}}); + interactor.simulateEvent( + 'rotateend', {touch: true, center: {x: 53, y: 23}}); + expect(map.info.pan).toBe(7); + expect(map.info.panArgs.x).toBe(12); + expect(map.info.panArgs.y).toBe(1); + + // check the two-fingered rotate event was called + interactor.simulateEvent( + 'rotatestart', {touch: true, center: {x: 20, y: 20}, rotation: 30}); + // first movement exceeds the threshold, but doesn't register + interactor.simulateEvent( + 'rotatemove', {touch: true, center: {x: 20, y: 20}, rotation: 35}); + // second movement will result in a rotation + interactor.simulateEvent( + 'rotatemove', {touch: true, center: {x: 20, y: 20}, rotation: 40}); + interactor.simulateEvent( + 'rotateend', {touch: true, center: {x: 20, y: 20}, rotation: 45}); + expect(map.info.rotation).toBe(1); + expect(map.info.rotationArgs).toBe(0.1 + 5 * Math.PI / 180); + + // check the two-fingered scale event was called + interactor.simulateEvent( + 'rotatestart', {touch: true, center: {x: 20, y: 20}, scale: 1}); + // first movement exceeds the threshold, but doesn't change the zoom + interactor.simulateEvent( + 'rotatemove', {touch: true, center: {x: 20, y: 20}, scale: 1.1}); + expect(map.info.zoom).toBe(1); + expect(map.info.zoomArgs).toBe(2); + // second movement will result in a zoom + interactor.simulateEvent( + 'rotatemove', {touch: true, center: {x: 20, y: 20}, scale: 1.2}); + expect(map.info.zoom).toBe(2); + expect(map.info.zoomArgs).toBe(2 + Math.log2(1.2) - Math.log2(1.1)); + interactor.simulateEvent( + 'rotateend', {touch: true, center: {x: 20, y: 20}, scale: 1.3}); + expect(map.info.zoom).toBe(2); + }); }); From 85a5573b0391c1ce3271a20d7f8b0050be9b5312 Mon Sep 17 00:00:00 2001 From: David Manthey Date: Tue, 14 Mar 2017 16:03:18 -0400 Subject: [PATCH 3/4] Include hammer.js in the built external libraries. --- external.config.js | 2 ++ src/vendor.js | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/external.config.js b/external.config.js index 2bde999cce..3d1aaccf9a 100644 --- a/external.config.js +++ b/external.config.js @@ -28,6 +28,8 @@ module.exports = { module: { loaders: [{ test: require.resolve('d3'), loader: 'expose?d3' + }, { + test: require.resolve('hammerjs'), loader: 'expose?hammerjs' }] } }; diff --git a/src/vendor.js b/src/vendor.js index 99db4b7f47..4ce7ec015b 100644 --- a/src/vendor.js +++ b/src/vendor.js @@ -5,7 +5,8 @@ * @license BSD-3-Clause */ var globals = { - d3: require('d3') + d3: require('d3'), + hammerjs: require('hammerjs') }; module.exports = globals; From 00c94e83064cd6c7bdab16a653276d878a61d1c2 Mon Sep 17 00:00:00 2001 From: David Manthey Date: Wed, 15 Mar 2017 14:57:38 -0400 Subject: [PATCH 4/4] Break a long line. --- src/mapInteractor.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/mapInteractor.js b/src/mapInteractor.js index 821d6e2c3e..4a1f44a900 100644 --- a/src/mapInteractor.js +++ b/src/mapInteractor.js @@ -1184,7 +1184,8 @@ var mapInteractor = function (args) { Math.PI * 3) % (Math.PI * 2) - Math.PI; /* If we reverse direction, don't rotate until some threshold is * exceeded. This helps prevent rotation bouncing while panning. */ - if (deltaTheta && (deltaTheta * (m_state.lastRotationDelta || 0) >= 0 || Math.abs(deltaTheta) >= m_options.zoomrotateReverseRotation)) { + if (deltaTheta && (deltaTheta * (m_state.lastRotationDelta || 0) >= 0 || + Math.abs(deltaTheta) >= m_options.zoomrotateReverseRotation)) { m_this.map().rotation(theta, m_state.origin); m_state.lastRotationDelta = deltaTheta; }