diff --git a/src/plots/gl2d/scene2d.js b/src/plots/gl2d/scene2d.js index 06180291e91..f644fa46cd6 100644 --- a/src/plots/gl2d/scene2d.js +++ b/src/plots/gl2d/scene2d.js @@ -33,6 +33,7 @@ function Scene2D(options, fullLayout) { this.staticPlot = !!options.staticPlot; this.fullLayout = fullLayout; + this.fullData = null; this.updateAxes(fullLayout); this.makeFramework(); @@ -49,6 +50,7 @@ function Scene2D(options, fullLayout) { // trace set this.traces = {}; + this._inputs = {}; // create axes spikes this.spikes = createSpikes(this.glplot); @@ -58,6 +60,9 @@ function Scene2D(options, fullLayout) { outerFill: true }); + // last button state + this.lastButtonState = 0; + // last pick result this.pickResult = null; @@ -332,6 +337,8 @@ proto.destroy = function() { this.container.removeChild(this.svgContainer); this.container.removeChild(this.mouseContainer); + this.fullData = null; + this._inputs = null; this.glplot = null; this.stopped = true; }; @@ -422,6 +429,8 @@ proto.updateTraces = function(fullData, calcData) { var traceIds = Object.keys(this.traces); var i, j, fullTrace; + this.fullData = fullData; + // remove empty traces trace_id_loop: for(i = 0; i < traceIds.length; i++) { @@ -443,7 +452,7 @@ proto.updateTraces = function(fullData, calcData) { // update / create trace objects for(i = 0; i < fullData.length; i++) { fullTrace = fullData[i]; - + this._inputs[fullTrace.uid] = i; var calcTrace = calcData[i], traceObj = this.traces[fullTrace.uid]; @@ -455,6 +464,24 @@ proto.updateTraces = function(fullData, calcData) { } }; +proto.emitPointAction = function(nextSelection, eventType) { + + var curveIndex = this._inputs[nextSelection.trace.uid]; + + this.graphDiv.emit(eventType, { + points: [{ + x: nextSelection.traceCoord[0], + y: nextSelection.traceCoord[1], + curveNumber: curveIndex, + pointNumber: nextSelection.pointIndex, + data: this.fullData[curveIndex]._input, + fullData: this.fullData, + xaxis: this.xaxis, + yaxis: this.yaxis + }] + }); +}; + proto.draw = function() { if(this.stopped) return; @@ -463,8 +490,11 @@ proto.draw = function() { var glplot = this.glplot, camera = this.camera, mouseListener = camera.mouseListener, + mouseUp = this.lastButtonState === 1 && mouseListener.buttons === 0, fullLayout = this.fullLayout; + this.lastButtonState = mouseListener.buttons; + this.cameraChanged(); var x = mouseListener.x * glplot.pixelRatio; @@ -494,8 +524,13 @@ proto.draw = function() { (y / glplot.pixelRatio) - (size.t + (1 - domainY[1]) * size.h) ); + var nextSelection = result && result.object._trace.handlePick(result); + + if(nextSelection && mouseUp) { + this.emitPointAction(nextSelection, 'plotly_click'); + } + if(result && result.object._trace.hoverinfo !== 'skip' && fullLayout.hovermode) { - var nextSelection = result.object._trace.handlePick(result); if(nextSelection && ( !this.lastPickResult || @@ -522,6 +557,10 @@ proto.draw = function() { glplot.pixelRatio ]; + // this needs to happen before the next block that deletes traceCoord data + // also it's important to copy, otherwise data is lost by the time event data is read + this.emitPointAction(nextSelection, 'plotly_hover'); + var hoverinfo = selection.hoverinfo; if(hoverinfo !== 'all') { var parts = hoverinfo.split('+'); @@ -549,6 +588,7 @@ proto.draw = function() { else if(!result && this.lastPickResult) { this.spikes.update({}); this.lastPickResult = null; + this.graphDiv.emit('plotly_unhover'); Fx.loneUnhover(this.svgContainer); } } diff --git a/src/traces/contourgl/convert.js b/src/traces/contourgl/convert.js index 4d2061aa50a..c042e76fc62 100644 --- a/src/traces/contourgl/convert.js +++ b/src/traces/contourgl/convert.js @@ -60,20 +60,24 @@ function Contour(scene, uid) { var proto = Contour.prototype; proto.handlePick = function(pickResult) { - var index = pickResult.pointId, - options = this.heatmapOptions, - shape = options.shape; + var options = this.heatmapOptions, + shape = options.shape, + index = pickResult.pointId, + xIndex = index % shape[0], + yIndex = Math.floor(index / shape[0]), + zIndex = index; return { trace: this, dataCoord: pickResult.dataCoord, traceCoord: [ - options.x[index % shape[0]], - options.y[Math.floor(index / shape[0])], - options.z[index] + options.x[xIndex], + options.y[yIndex], + options.z[zIndex] ], textLabel: this.textLabels[index], name: this.name, + pointIndex: [xIndex, yIndex], hoverinfo: this.hoverinfo }; }; diff --git a/src/traces/heatmapgl/convert.js b/src/traces/heatmapgl/convert.js index 714073c7aae..f8a47b6fa0c 100644 --- a/src/traces/heatmapgl/convert.js +++ b/src/traces/heatmapgl/convert.js @@ -46,19 +46,24 @@ function Heatmap(scene, uid) { var proto = Heatmap.prototype; proto.handlePick = function(pickResult) { - var index = pickResult.pointId, - shape = this.options.shape; + var options = this.options, + shape = options.shape, + index = pickResult.pointId, + xIndex = index % shape[0], + yIndex = Math.floor(index / shape[0]), + zIndex = index; return { trace: this, dataCoord: pickResult.dataCoord, traceCoord: [ - this.options.x[index % shape[0]], - this.options.y[Math.floor(index / shape[0])], - this.options.z[index] + options.x[xIndex], + options.y[yIndex], + options.z[zIndex] ], textLabel: this.textLabels[index], name: this.name, + pointIndex: [xIndex, yIndex], hoverinfo: this.hoverinfo }; }; diff --git a/src/traces/pointcloud/convert.js b/src/traces/pointcloud/convert.js index 2c7374c8a14..efc86dd0f65 100644 --- a/src/traces/pointcloud/convert.js +++ b/src/traces/pointcloud/convert.js @@ -63,6 +63,7 @@ proto.handlePick = function(pickResult) { this.textLabels, color: this.color, name: this.name, + pointIndex: index, hoverinfo: this.hoverinfo }; }; diff --git a/src/traces/scattergl/convert.js b/src/traces/scattergl/convert.js index 95053e5ac5b..fe7a44ebaf3 100644 --- a/src/traces/scattergl/convert.js +++ b/src/traces/scattergl/convert.js @@ -128,6 +128,7 @@ proto.handlePick = function(pickResult) { this.color[index] : this.color, name: this.name, + pointIndex: index, hoverinfo: this.hoverinfo }; }; diff --git a/test/jasmine/assets/hover.js b/test/jasmine/assets/hover.js new file mode 100644 index 00000000000..0efa6fbb13f --- /dev/null +++ b/test/jasmine/assets/hover.js @@ -0,0 +1,5 @@ +var mouseEvent = require('./mouse_event'); + +module.exports = function hover(x, y) { + mouseEvent('mousemove', x, y); +}; diff --git a/test/jasmine/assets/timed_click.js b/test/jasmine/assets/timed_click.js new file mode 100644 index 00000000000..b8806f2bd52 --- /dev/null +++ b/test/jasmine/assets/timed_click.js @@ -0,0 +1,17 @@ +var mouseEvent = require('./mouse_event'); + +module.exports = function click(x, y) { + mouseEvent('mousemove', x, y, {buttons: 0}); + + window.setTimeout(function() { + + mouseEvent('mousedown', x, y, {buttons: 1}); + + window.setTimeout(function() { + + mouseEvent('mouseup', x, y, {buttons: 0}); + + }, 50); + + }, 150); +}; diff --git a/test/jasmine/tests/gl2d_click_test.js b/test/jasmine/tests/gl2d_click_test.js new file mode 100644 index 00000000000..9e75816aeaa --- /dev/null +++ b/test/jasmine/tests/gl2d_click_test.js @@ -0,0 +1,485 @@ +var Plotly = require('@lib/index'); +var Lib = require('@src/lib'); + +var createGraphDiv = require('../assets/create_graph_div'); +var destroyGraphDiv = require('../assets/destroy_graph_div'); +var customMatchers = require('../assets/custom_matchers'); +var hasWebGLSupport = require('../assets/has_webgl_support'); + +// cartesian click events events use the hover data +// from the mousemove events and then simulate +// a click event on mouseup +var click = require('../assets/timed_click'); +var hover = require('../assets/hover'); + +describe('Test hover and click interactions', function() { + + if(!hasWebGLSupport('gl2d_click_test')) return; + + var mock = require('@mocks/gl2d_14.json'); + var mock2 = require('@mocks/gl2d_pointcloud-basic.json'); + var mock3 = { + 'data': [ + { + 'type': 'contourgl', + 'z': [ + [ + 10, + 10.625, + 12.5, + 15.625, + 20 + ], + [ + 5.625, + 6.25, + 8.125, + 11.25, + 15.625 + ], + [ + 2.5, + 3.125, + 5, + 8.125, + 12.5 + ], + [ + 0.625, + 1.25, + 3.125, + 6.25, + 10.625 + ], + [ + 0, + 0.625, + 2.5, + 5.625, + 10 + ] + ], + 'colorscale': 'Jet', +/* 'contours': { + 'start': 2, + 'end': 10, + 'size': 1 + },*/ + 'uid': 'ad5624', + 'zmin': 0, + 'zmax': 20 + } + ], + 'layout': { + 'xaxis': { + 'range': [ + 0, + 4 + ], + 'autorange': true + }, + 'yaxis': { + 'range': [ + 0, + 4 + ], + 'autorange': true + }, + 'height': 450, + 'width': 1000, + 'autosize': true + } + }; + + var mockCopy, gd; + + function check(pt) { + expect(Object.keys(pt)).toEqual([ + 'x', 'y', 'curveNumber', 'pointNumber', 'data', 'fullData', 'xaxis', 'yaxis' + ]); + + expect(pt.x).toEqual(15.772); + expect(pt.y).toEqual(0.387); + expect(pt.curveNumber).toEqual(0); + expect(pt.pointNumber).toEqual(33); + expect(pt.fullData.length).toEqual(1); + expect(typeof pt.data.uid).toEqual('string'); + expect(pt.xaxis.domain.length).toEqual(2); + expect(pt.yaxis.domain.length).toEqual(2); + } + + beforeAll(function() { + jasmine.addMatchers(customMatchers); + }); + + beforeEach(function() { + gd = createGraphDiv(); + mockCopy = Lib.extendDeep({}, mock); + }); + + afterEach(destroyGraphDiv); + + describe('hover event is fired on hover', function() { + var futureData; + + it('in general', function(done) { + + var modifiedMockCopy = Lib.extendDeep({}, mockCopy); + + Plotly.plot(gd, modifiedMockCopy.data, modifiedMockCopy.layout) + + .then(new Promise(function() { + + gd.on('plotly_hover', function(data) { + futureData = data; + }); + + hover(654.7712871743302, 316.97670766680994); + + window.setTimeout(function() { + + expect(futureData.points.length).toEqual(1); + + var pt = futureData.points[0]; + + check(pt); + + done(); + }, 250); + })); + + + }); + + it('even when hoverinfo (== plotly tooltip) is set to none', function(done) { + + var modifiedMockCopy = Lib.extendDeep({}, mockCopy); + modifiedMockCopy.data[0].hoverinfo = 'none'; + + Plotly.plot(gd, modifiedMockCopy.data, modifiedMockCopy.layout) + + .then(new Promise(function() { + + gd.on('plotly_hover', function(data) { + futureData = data; + }); + + hover(654.7712871743302, 316.97670766680994); + + window.setTimeout(function() { + + expect(futureData.points.length).toEqual(1); + + var pt = futureData.points[0]; + + check(pt); + + done(); + }, 250); + })); + + + }); + + it('event happens even on a click interaction', function(done) { + + var modifiedMockCopy = Lib.extendDeep({}, mockCopy); + modifiedMockCopy.data[0].hoverinfo = 'none'; + + Plotly.plot(gd, modifiedMockCopy.data, modifiedMockCopy.layout) + + .then(new Promise(function() { + + gd.on('plotly_hover', function(data) { + futureData = data; + }); + + click(654.7712871743302, 316.97670766680994); + + window.setTimeout(function() { + + expect(futureData.points.length).toEqual(1); + + var pt = futureData.points[0]; + + check(pt); + + done(); + }, 250); + })); + + + }); + + it('unhover happens', function(done) { + + var modifiedMockCopy = Lib.extendDeep({}, mockCopy); + modifiedMockCopy.data[0].hoverinfo = 'none'; + + Plotly.plot(gd, modifiedMockCopy.data, modifiedMockCopy.layout) + + .then(new Promise(function() { + + futureData = undefined; + + gd.on('plotly_unhover', function() { + futureData = 'emitted plotly_unhover'; + }); + + hover(654.7712871743302, 316.97670766680994); + + // fairly realistic simulation of moving with the cursor + window.setTimeout(function() { + + var x = 654, y = 316; // we start here + var canceler = window.setInterval(function() { + hover(x--, y++); // move the cursor + }, 10); + + window.setTimeout(function() { + window.clearInterval(canceler); // stop the mouse at some point + }, 250); + + window.setTimeout(function() { + + expect(futureData).toEqual('emitted plotly_unhover'); + + done(); + + }, 250); + + }, 250); + })); + + }); + + + }); + + describe('hover event is fired for other gl2d plot types', function() { + var futureData; + + it('pointcloud', function(done) { + + var modifiedMockCopy = Lib.extendDeep({}, mock2); + + Plotly.plot(gd, modifiedMockCopy.data, modifiedMockCopy.layout) + + .then(new Promise(function() { + + gd.on('plotly_hover', function(data) { + futureData = data; + }); + + hover(540, 150); + + window.setTimeout(function() { + + expect(futureData.points.length).toEqual(1); + + var pt = futureData.points[0]; + + expect(Object.keys(pt)).toEqual([ + 'x', 'y', 'curveNumber', 'pointNumber', 'data', 'fullData', 'xaxis', 'yaxis' + ]); + + expect(pt.x).toEqual(4.5); + expect(pt.y).toEqual(9); + expect(pt.curveNumber).toEqual(2); + expect(pt.pointNumber).toEqual(1); + expect(pt.fullData.length).toEqual(3); + expect(typeof pt.data.uid).toEqual('string'); + expect(pt.xaxis.domain.length).toEqual(2); + expect(pt.yaxis.domain.length).toEqual(2); + + done(); + }, 350); + })); + + + }); + + it('heatmapgl', function(done) { + + var modifiedMockCopy = Lib.extendDeep({}, mock3); + modifiedMockCopy.data[0].type = 'heatmapgl'; + + Plotly.plot(gd, modifiedMockCopy.data, modifiedMockCopy.layout) + + .then(new Promise(function() { + + gd.on('plotly_hover', function(data) { + futureData = data; + }); + + hover(540, 150); + + window.setTimeout(function() { + + expect(futureData.points.length).toEqual(1); + + var pt = futureData.points[0]; + + expect(Object.keys(pt)).toEqual([ + 'x', 'y', 'curveNumber', 'pointNumber', 'data', 'fullData', 'xaxis', 'yaxis' + ]); + + expect(pt.x).toEqual(2); + expect(pt.y).toEqual(3); + expect(pt.curveNumber).toEqual(0); + expect(pt.pointNumber).toEqual([2, 3]); + expect(pt.fullData.length).toEqual(1); + expect(typeof pt.data.uid).toEqual('string'); + expect(pt.xaxis.domain.length).toEqual(2); + expect(pt.yaxis.domain.length).toEqual(2); + + done(); + }, 350); + })); + + + }); + + it('contourgl', function(done) { + + var modifiedMockCopy = Lib.extendDeep({}, mock3); + + Plotly.plot(gd, modifiedMockCopy.data, modifiedMockCopy.layout) + + .then(new Promise(function() { + + gd.on('plotly_hover', function(data) { + futureData = data; + }); + + hover(540, 150); + + window.setTimeout(function() { + + expect(futureData.points.length).toEqual(1); + + var pt = futureData.points[0]; + + expect(Object.keys(pt)).toEqual([ + 'x', 'y', 'curveNumber', 'pointNumber', 'data', 'fullData', 'xaxis', 'yaxis' + ]); + + expect(pt.x).toEqual(2); + expect(pt.y).toEqual(3); + expect(pt.curveNumber).toEqual(0); + expect(pt.pointNumber).toEqual([2, 3]); + expect(pt.fullData.length).toEqual(1); + expect(typeof pt.data.uid).toEqual('string'); + expect(pt.xaxis.domain.length).toEqual(2); + expect(pt.yaxis.domain.length).toEqual(2); + + done(); + }, 350); + })); + }); + }); + + describe('click event is fired on click', function() { + var futureData; + + it('in general', function(done) { + + var modifiedMockCopy = Lib.extendDeep({}, mockCopy); + + Plotly.plot(gd, modifiedMockCopy.data, modifiedMockCopy.layout) + + .then(new Promise(function() { + + gd.on('plotly_click', function(data) { + futureData = data; + }); + + click(654.7712871743302, 316.97670766680994); + + window.setTimeout(function() { + + var pt = futureData.points[0]; + + check(pt); + + done(); + + }, 350); + })); + + }); + + it('even when hoverinfo (== plotly tooltip) is set to none', function(done) { + + var modifiedMockCopy = Lib.extendDeep({}, mockCopy); + modifiedMockCopy.data[0].hoverinfo = 'none'; + + Plotly.plot(gd, modifiedMockCopy.data, modifiedMockCopy.layout) + + .then(new Promise(function() { + + gd.on('plotly_hover', function(data) { + futureData = data; + }); + + hover(654.7712871743302, 316.97670766680994); + + window.setTimeout(function() { + + expect(futureData.points.length).toEqual(1); + + var pt = futureData.points[0]; + + check(pt); + + done(); + }, 250); + })); + + + }); + + it('unhover happens', function(done) { + + var modifiedMockCopy = Lib.extendDeep({}, mockCopy); + modifiedMockCopy.data[0].hoverinfo = 'none'; + + Plotly.plot(gd, modifiedMockCopy.data, modifiedMockCopy.layout) + + .then(new Promise(function() { + + futureData = undefined; + + gd.on('plotly_unhover', function() { + futureData = 'emitted plotly_unhover'; + }); + + hover(654.7712871743302, 316.97670766680994); + + // fairly realistic simulation of moving with the cursor + window.setTimeout(function() { + + var x = 654, y = 316; // we start here + var canceler = window.setInterval(function() { + hover(x--, y++); // move the cursor + }, 10); + + window.setTimeout(function() { + window.clearInterval(canceler); // stop the mouse at some point + }, 250); + + window.setTimeout(function() { + + expect(futureData).toEqual('emitted plotly_unhover'); + + done(); + + }, 250); + + }, 250); + })); + + }); + + }); +});