diff --git a/package.json b/package.json index 4a70535470e..88bd4002fe4 100644 --- a/package.json +++ b/package.json @@ -83,6 +83,7 @@ "gl-shader": "4.2.0", "gl-spikes2d": "^1.0.1", "gl-surface3d": "^1.3.0", + "has-hover": "^1.0.0", "mapbox-gl": "^0.22.0", "matrix-camera-controller": "^2.1.3", "mouse-change": "^1.4.0", diff --git a/src/components/dragelement/index.js b/src/components/dragelement/index.js index 5106cfaa2c9..6b0d47b3b42 100644 --- a/src/components/dragelement/index.js +++ b/src/components/dragelement/index.js @@ -9,6 +9,9 @@ 'use strict'; +var mouseOffset = require('mouse-event-offset'); +var hasHover = require('has-hover'); + var Plotly = require('../../plotly'); var Lib = require('../../lib'); @@ -61,18 +64,25 @@ dragElement.init = function init(options) { startX, startY, newMouseDownTime, + cursor, dragCover, initialTarget; if(!gd._mouseDownTime) gd._mouseDownTime = 0; + options.element.style.pointerEvents = 'all'; + + options.element.onmousedown = onStart; + options.element.ontouchstart = onStart; + function onStart(e) { // make dragging and dragged into properties of gd // so that others can look at and modify them gd._dragged = false; gd._dragging = true; - startX = e.clientX; - startY = e.clientY; + var offset = pointerOffset(e); + startX = offset[0]; + startY = offset[1]; initialTarget = e.target; newMouseDownTime = (new Date()).getTime(); @@ -88,20 +98,30 @@ dragElement.init = function init(options) { if(options.prepFn) options.prepFn(e, startX, startY); - dragCover = coverSlip(); - - dragCover.onmousemove = onMove; - dragCover.onmouseup = onDone; - dragCover.onmouseout = onDone; + if(hasHover) { + dragCover = coverSlip(); + dragCover.style.cursor = window.getComputedStyle(options.element).cursor; + } + else { + // document acts as a dragcover for mobile, bc we can't create dragcover dynamically + dragCover = document; + cursor = window.getComputedStyle(document.documentElement).cursor; + document.documentElement.style.cursor = window.getComputedStyle(options.element).cursor; + } - dragCover.style.cursor = window.getComputedStyle(options.element).cursor; + dragCover.addEventListener('mousemove', onMove); + dragCover.addEventListener('mouseup', onDone); + dragCover.addEventListener('mouseout', onDone); + dragCover.addEventListener('touchmove', onMove); + dragCover.addEventListener('touchend', onDone); return Lib.pauseEvent(e); } function onMove(e) { - var dx = e.clientX - startX, - dy = e.clientY - startY, + var offset = pointerOffset(e), + dx = offset[0] - startX, + dy = offset[1] - startY, minDrag = options.minDrag || constants.MINDRAG; if(Math.abs(dx) < minDrag) dx = 0; @@ -117,10 +137,19 @@ dragElement.init = function init(options) { } function onDone(e) { - dragCover.onmousemove = null; - dragCover.onmouseup = null; - dragCover.onmouseout = null; - Lib.removeElement(dragCover); + dragCover.removeEventListener('mousemove', onMove); + dragCover.removeEventListener('mouseup', onDone); + dragCover.removeEventListener('mouseout', onDone); + dragCover.removeEventListener('touchmove', onMove); + dragCover.removeEventListener('touchend', onDone); + + if(hasHover) { + Lib.removeElement(dragCover); + } + else if(cursor) { + dragCover.documentElement.style.cursor = cursor; + cursor = null; + } if(!gd._dragging) { gd._dragged = false; @@ -143,12 +172,13 @@ dragElement.init = function init(options) { e2 = new MouseEvent('click', e); } catch(err) { + var offset = pointerOffset(e); e2 = document.createEvent('MouseEvents'); e2.initMouseEvent('click', e.bubbles, e.cancelable, e.view, e.detail, e.screenX, e.screenY, - e.clientX, e.clientY, + offset[0], offset[1], e.ctrlKey, e.altKey, e.shiftKey, e.metaKey, e.button, e.relatedTarget); } @@ -162,9 +192,6 @@ dragElement.init = function init(options) { return Lib.pauseEvent(e); } - - options.element.onmousedown = onStart; - options.element.style.pointerEvents = 'all'; }; function coverSlip() { @@ -191,3 +218,10 @@ function finishDrag(gd) { gd._dragging = false; if(gd._replotPending) Plotly.plot(gd); } + +function pointerOffset(e) { + return mouseOffset( + e.changedTouches ? e.changedTouches[0] : e, + document.body + ); +} diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index c067d798f58..19d1b1bb567 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -12,6 +12,7 @@ var d3 = require('d3'); var isNumeric = require('fast-isnumeric'); +var hasHover = require('has-hover'); var Plotly = require('../plotly'); var Lib = require('../lib'); @@ -423,6 +424,11 @@ function setPlotContext(gd, config) { context.showLink = false; context.displayModeBar = false; } + + // make sure hover-only devices have mode bar visible + if(context.displayModeBar === 'hover' && !hasHover) { + context.displayModeBar = true; + } } function plotPolar(gd, data, layout) { diff --git a/src/plots/gl2d/camera.js b/src/plots/gl2d/camera.js index a043dead109..fdf9d37fbdc 100644 --- a/src/plots/gl2d/camera.js +++ b/src/plots/gl2d/camera.js @@ -11,6 +11,7 @@ var mouseChange = require('mouse-change'); var mouseWheel = require('mouse-wheel'); +var mouseOffset = require('mouse-event-offset'); var cartesianConstants = require('../cartesian/constants'); module.exports = createCamera; @@ -55,7 +56,24 @@ function createCamera(scene) { return false; } - result.mouseListener = mouseChange(element, function(buttons, x, y) { + result.mouseListener = mouseChange(element, handleInteraction); + + // enable simple touch interactions + element.addEventListener('touchstart', function(ev) { + var xy = mouseOffset(ev.changedTouches[0], element); + handleInteraction(0, xy[0], xy[1]); + handleInteraction(1, xy[0], xy[1]); + }); + element.addEventListener('touchmove', function(ev) { + ev.preventDefault(); + var xy = mouseOffset(ev.changedTouches[0], element); + handleInteraction(1, xy[0], xy[1]); + }); + element.addEventListener('touchend', function() { + handleInteraction(0, result.lastPos[0], result.lastPos[1]); + }); + + function handleInteraction(buttons, x, y) { var dataBox = scene.calcDataBox(), viewBox = plot.viewBox; @@ -235,7 +253,7 @@ function createCamera(scene) { result.lastPos[0] = x; result.lastPos[1] = y; - }); + } result.wheelListener = mouseWheel(element, function(dx, dy) { var dataBox = scene.calcDataBox(), diff --git a/src/plots/gl2d/scene2d.js b/src/plots/gl2d/scene2d.js index 6c4da94cbd8..4e43607dd7f 100644 --- a/src/plots/gl2d/scene2d.js +++ b/src/plots/gl2d/scene2d.js @@ -525,11 +525,23 @@ proto.updateTraces = function(fullData, calcData) { }; proto.updateFx = function(dragmode) { + // switch to svg interactions in lasso/select mode if(dragmode === 'lasso' || dragmode === 'select') { this.mouseContainer.style['pointer-events'] = 'none'; } else { this.mouseContainer.style['pointer-events'] = 'auto'; } + + // set proper cursor + if(dragmode === 'pan') { + this.mouseContainer.style.cursor = 'move'; + } + else if(dragmode === 'zoom') { + this.mouseContainer.style.cursor = 'crosshair'; + } + else { + this.mouseContainer.style.cursor = null; + } }; proto.emitPointAction = function(nextSelection, eventType) { diff --git a/src/traces/scattergl/convert.js b/src/traces/scattergl/convert.js index 74a2a1d9ea1..cb37467368c 100644 --- a/src/traces/scattergl/convert.js +++ b/src/traces/scattergl/convert.js @@ -401,7 +401,7 @@ proto.updateFast = function(options) { this.idToIndex = idToIndex; // form selected set - if(selection) { + if(selection && selection.length) { selPositions = new Float64Array(2 * selection.length); for(i = 0, l = selection.length; i < l; i++) { diff --git a/test/jasmine/assets/touch_event.js b/test/jasmine/assets/touch_event.js new file mode 100644 index 00000000000..ea16b1db664 --- /dev/null +++ b/test/jasmine/assets/touch_event.js @@ -0,0 +1,44 @@ +var Lib = require('../../../src/lib'); + +module.exports = function(type, x, y, opts) { + var el = (opts && opts.element) || document.elementFromPoint(x, y), + ev; + + var touchObj = new Touch({ + identifier: Date.now(), + target: el, + clientX: x, + clientY: y, + radiusX: 2.5, + radiusY: 2.5, + rotationAngle: 10, + force: 0.5, + }); + + var fullOpts = { + touches: [touchObj], + targetTouches: [], + changedTouches: [touchObj], + bubbles: true + }; + + if(opts && opts.altKey) { + fullOpts.altKey = opts.altKey; + } + if(opts && opts.ctrlKey) { + fullOpts.ctrlKey = opts.ctrlKey; + } + if(opts && opts.metaKey) { + fullOpts.metaKey = opts.metaKey; + } + if(opts && opts.shiftKey) { + fullOpts.shiftKey = opts.shiftKey; + } + + + ev = new window.TouchEvent(type, Lib.extendFlat({}, fullOpts, opts)); + + el.dispatchEvent(ev); + + return el; +}; diff --git a/test/jasmine/karma.conf.js b/test/jasmine/karma.conf.js index 8f8af91fbf7..74866dffcda 100644 --- a/test/jasmine/karma.conf.js +++ b/test/jasmine/karma.conf.js @@ -182,6 +182,7 @@ func.defaultConfig = { _Chrome: { base: 'Chrome', flags: [ + '--touch-events', '--window-size=' + argv.width + ',' + argv.height, isCI ? '--ignore-gpu-blacklist' : '' ] diff --git a/test/jasmine/tests/select_test.js b/test/jasmine/tests/select_test.js index 37cf778b940..3fb53bda8f8 100644 --- a/test/jasmine/tests/select_test.js +++ b/test/jasmine/tests/select_test.js @@ -7,6 +7,7 @@ var doubleClick = require('../assets/double_click'); var createGraphDiv = require('../assets/create_graph_div'); var destroyGraphDiv = require('../assets/destroy_graph_div'); var mouseEvent = require('../assets/mouse_event'); +var touchEvent = require('../assets/touch_event'); var customMatchers = require('../assets/custom_matchers'); @@ -22,9 +23,23 @@ describe('select box and lasso', function() { afterEach(destroyGraphDiv); - function drag(path) { + function drag(path, options) { var len = path.length; + if(!options) options = {type: 'mouse'}; + + if(options.type === 'touch') { + touchEvent('touchstart', path[0][0], path[0][1]); + + path.slice(1, len).forEach(function(pt) { + touchEvent('touchmove', pt[0], pt[1]); + }); + + touchEvent('touchend', path[len - 1][0], path[len - 1][1]); + + return; + } + mouseEvent('mousemove', path[0][0], path[0][1]); mouseEvent('mousedown', path[0][0], path[0][1]); @@ -299,6 +314,50 @@ describe('select box and lasso', function() { done(); }); }); + + it('should trigger selecting/selected/deselect events for touches', function(done) { + var selectingCnt = 0, + selectingData; + gd.on('plotly_selecting', function(data) { + selectingCnt++; + selectingData = data; + }); + + var selectedCnt = 0, + selectedData; + gd.on('plotly_selected', function(data) { + selectedCnt++; + selectedData = data; + }); + + var doubleClickData; + gd.on('plotly_deselect', function(data) { + doubleClickData = data; + }); + + drag(lassoPath, {type: 'touch'}); + + expect(selectingCnt).toEqual(3, 'with the correct selecting count'); + assertEventData(selectingData.points, [{ + curveNumber: 0, + pointNumber: 10, + x: 0.099, + y: 2.75 + }], 'with the correct selecting points (1)'); + + expect(selectedCnt).toEqual(1, 'with the correct selected count'); + assertEventData(selectedData.points, [{ + curveNumber: 0, + pointNumber: 10, + x: 0.099, + y: 2.75, + }], 'with the correct selected points (2)'); + + doubleClick(250, 200).then(function() { + expect(doubleClickData).toBe(null, 'with the correct deselect data'); + done(); + }); + }); }); it('should skip over non-visible traces', function(done) {