diff --git a/package.json b/package.json index dc95cc8eec8..52ba2bb838c 100644 --- a/package.json +++ b/package.json @@ -92,7 +92,7 @@ "fs-extra": "^0.28.0", "fuse.js": "^2.2.0", "glob": "^7.0.0", - "jasmine-core": "^2.3.4", + "jasmine-core": "^2.4.1", "karma": "^0.13.15", "karma-browserify": "^5.0.1", "karma-chrome-launcher": "^0.2.1", diff --git a/src/components/modebar/buttons.js b/src/components/modebar/buttons.js index b545faff14b..7f9cd5736f3 100644 --- a/src/components/modebar/buttons.js +++ b/src/components/modebar/buttons.js @@ -350,6 +350,8 @@ function handleCamera3d(gd, ev) { if(attr === 'resetDefault') scene.setCameraToDefault(); else if(attr === 'resetLastSave') { + // This handler looks in the un-updated fullLayout.scene.camera object to reset the camera + // to the last saved position. scene.setCamera(fullSceneLayout.camera); } } diff --git a/src/plots/gl2d/index.js b/src/plots/gl2d/index.js index d446972fe7f..61524d3a60b 100644 --- a/src/plots/gl2d/index.js +++ b/src/plots/gl2d/index.js @@ -50,8 +50,9 @@ exports.plot = function plotGl2d(gd) { // If Scene is not instantiated, create one! if(scene === undefined) { scene = new Scene2D({ - container: gd.querySelector('.gl-container'), id: subplotId, + graphDiv: gd, + container: gd.querySelector('.gl-container'), staticPlot: gd._context.staticPlot, plotGlPixelRatio: gd._context.plotGlPixelRatio }, diff --git a/src/plots/gl2d/scene2d.js b/src/plots/gl2d/scene2d.js index cc0ed3df658..138efa71085 100644 --- a/src/plots/gl2d/scene2d.js +++ b/src/plots/gl2d/scene2d.js @@ -27,6 +27,7 @@ var STATIC_CANVAS, STATIC_CONTEXT; function Scene2D(options, fullLayout) { this.container = options.container; + this.graphDiv = options.graphDiv; this.pixelRatio = options.plotGlPixelRatio || window.devicePixelRatio; this.id = options.id; this.staticPlot = !!options.staticPlot; @@ -268,6 +269,25 @@ proto.updateFx = function(options) { fullLayout.hovermode = options.hovermode; }; +var relayoutCallback = function(scene) { + + var xrange = scene.xaxis.range, + yrange = scene.yaxis.range; + + // Update the layout on the DIV + scene.graphDiv.layout.xaxis.range = xrange.slice(0); + scene.graphDiv.layout.yaxis.range = yrange.slice(0); + + // Make a meaningful value to be passed on to the possible 'plotly_relayout' subscriber(s) + var update = { // scene.camera has no many useful projection or scale information + lastInputTime: scene.camera.lastInputTime // helps determine which one is the latest input (if async) + }; + update[scene.xaxis._name] = xrange.slice(); + update[scene.yaxis._name] = yrange.slice(); + + scene.graphDiv.emit('plotly_relayout', update); +}; + proto.cameraChanged = function() { var camera = this.camera, xrange = this.xaxis.range, @@ -285,6 +305,7 @@ proto.cameraChanged = function() { this.glplotOptions.ticks = nextTicks; this.glplotOptions.dataBox = camera.dataBox; this.glplot.update(this.glplotOptions); + relayoutCallback(this); } }; diff --git a/src/plots/gl3d/scene.js b/src/plots/gl3d/scene.js index bf054ecefb9..5feee3b78a5 100644 --- a/src/plots/gl3d/scene.js +++ b/src/plots/gl3d/scene.js @@ -7,9 +7,6 @@ */ -/*eslint block-scoped-var: 0*/ -/*eslint no-redeclare: 0*/ - 'use strict'; var createPlot = require('gl-plot3d'); @@ -34,6 +31,8 @@ var STATIC_CANVAS, STATIC_CONTEXT; function render(scene) { + var trace; + // update size of svg container var svgContainer = scene.svgContainer; var clientRect = scene.container.getBoundingClientRect(); @@ -50,7 +49,7 @@ function render(scene) { var lastPicked = null; var selection = scene.glplot.selection; for(var i = 0; i < keys.length; ++i) { - var trace = scene.traces[keys[i]]; + trace = scene.traces[keys[i]]; if(trace.handlePick(selection)) { lastPicked = trace; } @@ -68,9 +67,9 @@ function render(scene) { var oldEventData; if(lastPicked !== null) { - var pdata = project(scene.glplot.cameraParams, selection.dataCoordinate), - trace = lastPicked.data, - hoverinfo = trace.hoverinfo; + var pdata = project(scene.glplot.cameraParams, selection.dataCoordinate); + trace = lastPicked.data; + var hoverinfo = trace.hoverinfo; var xVal = formatter('xaxis', selection.traceCoordinate[0]), yVal = formatter('yaxis', selection.traceCoordinate[1]), @@ -172,6 +171,16 @@ function initializeGLPlot(scene, fullLayout, canvas, gl) { showNoWebGlMsg(scene); } + var relayoutCallback = function(scene) { + var update = {}; + update[scene.id] = getLayoutCamera(scene.camera); + scene.saveCamera(scene.graphDiv.layout); + scene.graphDiv.emit('plotly_relayout', update); + }; + + scene.glplot.canvas.addEventListener('mouseup', relayoutCallback.bind(null, scene)); + scene.glplot.canvas.addEventListener('wheel', relayoutCallback.bind(null, scene)); + if(!scene.staticMode) { scene.glplot.canvas.addEventListener('webglcontextlost', function(ev) { console.log('lost context'); @@ -255,7 +264,7 @@ function Scene(options, fullLayout) { this.contourLevels = [ [], [], [] ]; - if(!initializeGLPlot(this, fullLayout)) return; + if(!initializeGLPlot(this, fullLayout)) return; // todo check the necessity for this line } var proto = Scene.prototype; @@ -283,10 +292,11 @@ proto.recoverContext = function() { var axisProperties = [ 'xaxis', 'yaxis', 'zaxis' ]; function coordinateBound(axis, coord, d, bounds) { + var x; for(var i=0; i dataBounds[1][j]) { dataScale[j] = 1.0; } @@ -379,7 +389,7 @@ proto.plot = function(sceneData, fullLayout, layout) { this.dataScale = dataScale; //Update traces - for(var i = 0; i < sceneData.length; ++i) { + for(i = 0; i < sceneData.length; ++i) { data = sceneData[i]; if(data.visible!==true) { continue; @@ -416,8 +426,8 @@ proto.plot = function(sceneData, fullLayout, layout) { axisTypeRatios = {}; for(i = 0; i < 3; ++i) { - var axis = fullSceneLayout[axisProperties[i]]; - var axisType = axis.type; + axis = fullSceneLayout[axisProperties[i]]; + axisType = axis.type; if(axisType in axisTypeRatios) { axisTypeRatios[axisType].acc *= dataScale[i]; @@ -471,9 +481,9 @@ proto.plot = function(sceneData, fullLayout, layout) { var axesScaleRatio = [1, 1, 1]; //Compute axis scale per category - for(var i=0; i<3; ++i) { - var axis = fullSceneLayout[axisProperties[i]]; - var axisType = axis.type; + for(i=0; i<3; ++i) { + axis = fullSceneLayout[axisProperties[i]]; + axisType = axis.type; var axisRatio = axisTypeRatios[axisType]; axesScaleRatio[i] = Math.pow(axisRatio.acc, 1.0/axisRatio.count) / dataScale[i]; } @@ -567,33 +577,35 @@ proto.setCameraToDefault = function setCameraToDefault() { }); }; -// get camera position in plotly coords from 'orbit-camera' coords -proto.getCamera = function getCamera() { - this.glplot.camera.view.recalcMatrix(this.camera.view.lastT()); - - var up = this.glplot.camera.up; - var center = this.glplot.camera.center; - var eye = this.glplot.camera.eye; +// getOrbitCamera :: plotly_coords -> orbit_camera_coords +// inverse of getLayoutCamera +function getOrbitCamera(camera) { + return [ + [camera.eye.x, camera.eye.y, camera.eye.z], + [camera.center.x, camera.center.y, camera.center.z], + [camera.up.x, camera.up.y, camera.up.z] + ]; +} +// getLayoutCamera :: orbit_camera_coords -> plotly_coords +// inverse of getOrbitCamera +function getLayoutCamera(camera) { return { - up: {x: up[0], y: up[1], z: up[2]}, - center: {x: center[0], y: center[1], z: center[2]}, - eye: {x: eye[0], y: eye[1], z: eye[2]} + up: {x: camera.up[0], y: camera.up[1], z: camera.up[2]}, + center: {x: camera.center[0], y: camera.center[1], z: camera.center[2]}, + eye: {x: camera.eye[0], y: camera.eye[1], z: camera.eye[2]} }; +} + +// get camera position in plotly coords from 'orbit-camera' coords +proto.getCamera = function getCamera() { + this.glplot.camera.view.recalcMatrix(this.camera.view.lastT()); + return getLayoutCamera(this.glplot.camera); }; // set camera position with a set of plotly coords proto.setCamera = function setCamera(cameraData) { - // getOrbitCamera :: plotly_coords -> orbit_camera_coords - function getOrbitCamera(camera) { - return [ - [camera.eye.x, camera.eye.y, camera.eye.z], - [camera.center.x, camera.center.y, camera.center.z], - [camera.up.x, camera.up.y, camera.up.z] - ]; - } - var update = {}; update[this.id] = cameraData; @@ -612,7 +624,7 @@ proto.saveCamera = function saveCamera(layout) { function same(x, y, i, j) { var vectors = ['up', 'center', 'eye'], components = ['x', 'y', 'z']; - return x[vectors[i]][components[j]] === y[vectors[i]][components[j]]; + return y[vectors[i]] && (x[vectors[i]][components[j]] === y[vectors[i]][components[j]]); } if(cameraDataLastSave === undefined) hasChanged = true; diff --git a/test/jasmine/karma.ciconf.js b/test/jasmine/karma.ciconf.js index 3e044c77df8..050d332f735 100644 --- a/test/jasmine/karma.ciconf.js +++ b/test/jasmine/karma.ciconf.js @@ -14,7 +14,7 @@ function func(config) { * exclude them from the CircleCI test bundle. * */ - func.defaultConfig.exclude = ['tests/gl_plot_interact_test.js']; + func.defaultConfig.exclude = ['tests/gl_plot_interact_test.js', 'tests/gl_plot_interact_basic_test.js']; // if true, Karma captures browsers, runs the tests and exits func.defaultConfig.singleRun = true; diff --git a/test/jasmine/tests/gl_plot_interact_basic_test.js b/test/jasmine/tests/gl_plot_interact_basic_test.js new file mode 100644 index 00000000000..b72ffeb7b11 --- /dev/null +++ b/test/jasmine/tests/gl_plot_interact_basic_test.js @@ -0,0 +1,101 @@ +'use strict'; + +var Plotly = require('@lib/index'); +var Plots = require('@src/plots/plots'); + +var createGraphDiv = require('../assets/create_graph_div'); +var destroyGraphDiv = require('../assets/destroy_graph_div'); +var mouseEvent = require('../assets/mouse_event'); + +function teardown(gd, done) { + + // The teardown function needs information of what to tear down so afterEach can not be used without global vars. + // In addition to removing the plot from the DOM it also destroy possibly present 2D or 3D scenes + + // TODO we should figure out something to only rely on public API calls + // In other words, how can users themselves properly destroy the plot through the API? + // This function is left in this file until the above todo is looked into. + var fullLayout = gd._fullLayout; + + Plots.getSubplotIds(fullLayout, 'gl3d').forEach(function(sceneId) { + var scene = fullLayout[sceneId]._scene; + if(scene.glplot) scene.destroy(); + }); + + Plots.getSubplotIds(fullLayout, 'gl2d').forEach(function(sceneId) { + var scene2d = fullLayout._plots[sceneId]._scene2d; + if(scene2d.glplot) { + scene2d.stopped = true; + scene2d.destroy(); + } + }); + + destroyGraphDiv(); + + // A test case can only be called 'done' when the above destroy methods had been performed. + // One way of helping ensure that the destroys are not forgotten is that done() is part of + // the teardown, consequently if a test case omits the teardown by accident, the test will + // visibly hang. If the teardown receives no proper arguments, it'll also visibly fail. + done(); +} + +describe('Test gl plot interactions', function() { + + describe('gl3d plots', function() { + + // Expected shape of projection-related data + var cameraStructure = { + up: {x: jasmine.any(Number), y: jasmine.any(Number), z: jasmine.any(Number)}, + center: {x: jasmine.any(Number), y: jasmine.any(Number), z: jasmine.any(Number)}, + eye: {x: jasmine.any(Number), y: jasmine.any(Number), z: jasmine.any(Number)} + }; + + function makePlot(mock) { + return Plotly.plot(createGraphDiv(), mock.data, mock.layout); + } + + function addEventCallback(graphDiv) { + var relayoutCallback = jasmine.createSpy('relayoutCallback'); + graphDiv.on('plotly_relayout', relayoutCallback); + return {graphDiv: graphDiv, relayoutCallback: relayoutCallback}; + } + + function verifyInteractionEffects(tuple) { + + // One 'drag': simulating fairly thoroughly as the mouseup event is also needed here + mouseEvent('mousemove', 400, 200); + mouseEvent('mousedown', 400, 200); + mouseEvent('mousemove', 320, 320, {buttons: 1}); + mouseEvent('mouseup', 320, 320); + + // Check event emission count + expect(tuple.relayoutCallback).toHaveBeenCalledTimes(1); + + // Check structure of event callback value contents + expect(tuple.relayoutCallback).toHaveBeenCalledWith(jasmine.objectContaining({scene: cameraStructure})); + + // Check camera contents on the DIV layout + var divCamera = tuple.graphDiv.layout.scene.camera; + + expect(divCamera).toEqual(cameraStructure); + + return tuple.graphDiv; + } + + function testEvents(plot, done) { + plot.then(function(graphDiv) { + var tuple = addEventCallback(graphDiv); // TODO disuse tuple with ES6 + verifyInteractionEffects(tuple); + teardown(graphDiv, done); + }); + } + + it('should respond to drag interactions with mock of unset camera', function(done) { + testEvents(makePlot(require('@mocks/gl3d_scatter3d-connectgaps.json')), done); + }); + + it('should respond to drag interactions with mock of partially set camera', function(done) { + testEvents(makePlot(require('@mocks/gl3d_errorbars_zx.json')), done); + }); + }); +}); diff --git a/test/jasmine/tests/gl_plot_interact_test.js b/test/jasmine/tests/gl_plot_interact_test.js index f59a84f3875..5ed14da4bc0 100644 --- a/test/jasmine/tests/gl_plot_interact_test.js +++ b/test/jasmine/tests/gl_plot_interact_test.js @@ -212,12 +212,19 @@ describe('Test gl plot interactions', function() { }); describe('gl2d plots', function() { - var mock = require('@mocks/gl2d_10.json'); + var mock = require('@mocks/gl2d_10.json'), + modeBar, relayoutCallback; beforeEach(function(done) { gd = createGraphDiv(); Plotly.plot(gd, mock.data, mock.layout).then(function() { + + modeBar = gd._fullLayout._modeBar; + relayoutCallback = jasmine.createSpy('relayoutCallback'); + + gd.on('plotly_relayout', relayoutCallback); + delay(done); }); }); @@ -226,9 +233,102 @@ describe('Test gl plot interactions', function() { var nodes = d3.selectAll('canvas'); expect(nodes[0].length).toEqual(1); }); + + it('should respond to drag interactions', function(done) { + + jasmine.addMatchers(customMatchers); + + var precision = 5; + + var buttonPan = selectButton(modeBar, 'pan2d'); + + var originalX = [-0.022068095838587643, 5.022068095838588]; + var originalY = [-0.21331533513634046, 5.851205650049042]; + + var newX = [-0.23224043715846995,4.811895754518705]; + var newY = [-1.2962655110623016,4.768255474123081]; + + expect(gd.layout.xaxis.range).toBeCloseToArray(originalX, precision); + expect(gd.layout.yaxis.range).toBeCloseToArray(originalY, precision); + + // Switch to pan mode + expect(buttonPan.isActive()).toBe(false); // initially, zoom is active + buttonPan.click(); + expect(buttonPan.isActive()).toBe(true); // switched on dragmode + + // Switching mode must not change visible range + expect(gd.layout.xaxis.range).toBeCloseToArray(originalX, precision); + expect(gd.layout.yaxis.range).toBeCloseToArray(originalY, precision); + + setTimeout(function() { + + mouseEvent('mousemove', 200, 200); + + relayoutCallback.calls.reset(); + + // Drag scene along the X axis + + mouseEvent('mousemove', 220, 200, {buttons: 1}); + + expect(gd.layout.xaxis.range).toBeCloseToArray(newX, precision); + expect(gd.layout.yaxis.range).toBeCloseToArray(originalY, precision); + + // Drag scene back along the X axis + + mouseEvent('mousemove', 200, 200, {buttons: 1}); + + expect(gd.layout.xaxis.range).toBeCloseToArray(originalX, precision); + expect(gd.layout.yaxis.range).toBeCloseToArray(originalY, precision); + + // Drag scene along the Y axis + + mouseEvent('mousemove', 200, 150, {buttons: 1}); + + expect(gd.layout.xaxis.range).toBeCloseToArray(originalX, precision); + expect(gd.layout.yaxis.range).toBeCloseToArray(newY, precision); + + // Drag scene back along the Y axis + + mouseEvent('mousemove', 200, 200, {buttons: 1}); + + expect(gd.layout.xaxis.range).toBeCloseToArray(originalX, precision); + expect(gd.layout.yaxis.range).toBeCloseToArray(originalY, precision); + + // Drag scene along both the X and Y axis + + mouseEvent('mousemove', 220, 150, {buttons: 1}); + + expect(gd.layout.xaxis.range).toBeCloseToArray(newX, precision); + expect(gd.layout.yaxis.range).toBeCloseToArray(newY, precision); + + // Drag scene back along the X and Y axis + + mouseEvent('mousemove', 200, 200, {buttons: 1}); + + expect(gd.layout.xaxis.range).toBeCloseToArray(originalX, precision); + expect(gd.layout.yaxis.range).toBeCloseToArray(originalY, precision); + + setTimeout(function() { + + // callback count expectation: X and back; Y and back; XY and back + expect(relayoutCallback).toHaveBeenCalledTimes(6); + + // a callback value structure and contents check + expect(relayoutCallback).toHaveBeenCalledWith(jasmine.objectContaining({ + lastInputTime: jasmine.any(Number), + xaxis: [jasmine.any(Number), jasmine.any(Number)], + yaxis: [jasmine.any(Number), jasmine.any(Number)] + })); + + done(); + + }, MODEBAR_DELAY); + + }, MODEBAR_DELAY); + }); }); - describe('gl3d modebar click handlers', function() { + describe('gl3d event handlers', function() { var modeBar, relayoutCallback; beforeEach(function(done) { @@ -265,145 +365,138 @@ describe('Test gl plot interactions', function() { }); } - describe('button zoom3d', function() { - it('should updates the scene dragmode and dragmode button', function() { - var buttonTurntable = selectButton(modeBar, 'tableRotation'), - buttonZoom3d = selectButton(modeBar, 'zoom3d'); - - assertScenes(gd._fullLayout, 'dragmode', 'turntable'); - expect(buttonTurntable.isActive()).toBe(true); - expect(buttonZoom3d.isActive()).toBe(false); - - buttonZoom3d.click(); - assertScenes(gd.layout, 'dragmode', 'zoom'); - expect(gd.layout.dragmode).toBe(undefined); - expect(gd._fullLayout.dragmode).toBe('zoom'); - expect(buttonTurntable.isActive()).toBe(false); - expect(buttonZoom3d.isActive()).toBe(true); - - buttonTurntable.click(); - assertScenes(gd._fullLayout, 'dragmode', 'turntable'); - expect(buttonTurntable.isActive()).toBe(true); - expect(buttonZoom3d.isActive()).toBe(false); + describe('modebar click handlers', function() { + + describe('button zoom3d', function() { + it('should updates the scene dragmode and dragmode button', function() { + var buttonTurntable = selectButton(modeBar, 'tableRotation'), + buttonZoom3d = selectButton(modeBar, 'zoom3d'); + + assertScenes(gd._fullLayout, 'dragmode', 'turntable'); + expect(buttonTurntable.isActive()).toBe(true); + expect(buttonZoom3d.isActive()).toBe(false); + + buttonZoom3d.click(); + assertScenes(gd.layout, 'dragmode', 'zoom'); + expect(gd.layout.dragmode).toBe(undefined); + expect(gd._fullLayout.dragmode).toBe('zoom'); + expect(buttonTurntable.isActive()).toBe(false); + expect(buttonZoom3d.isActive()).toBe(true); + + buttonTurntable.click(); + assertScenes(gd._fullLayout, 'dragmode', 'turntable'); + expect(buttonTurntable.isActive()).toBe(true); + expect(buttonZoom3d.isActive()).toBe(false); + }); }); - }); - describe('button pan3d', function() { - it('should updates the scene dragmode and dragmode button', function() { - var buttonTurntable = selectButton(modeBar, 'tableRotation'), - buttonPan3d = selectButton(modeBar, 'pan3d'); - - assertScenes(gd._fullLayout, 'dragmode', 'turntable'); - expect(buttonTurntable.isActive()).toBe(true); - expect(buttonPan3d.isActive()).toBe(false); - - buttonPan3d.click(); - assertScenes(gd.layout, 'dragmode', 'pan'); - expect(gd.layout.dragmode).toBe(undefined); - expect(gd._fullLayout.dragmode).toBe('zoom'); - expect(buttonTurntable.isActive()).toBe(false); - expect(buttonPan3d.isActive()).toBe(true); - - buttonTurntable.click(); - assertScenes(gd._fullLayout, 'dragmode', 'turntable'); - expect(buttonTurntable.isActive()).toBe(true); - expect(buttonPan3d.isActive()).toBe(false); + describe('button pan3d', function() { + it('should updates the scene dragmode and dragmode button', function() { + var buttonTurntable = selectButton(modeBar, 'tableRotation'), + buttonPan3d = selectButton(modeBar, 'pan3d'); + + assertScenes(gd._fullLayout, 'dragmode', 'turntable'); + expect(buttonTurntable.isActive()).toBe(true); + expect(buttonPan3d.isActive()).toBe(false); + + buttonPan3d.click(); + assertScenes(gd.layout, 'dragmode', 'pan'); + expect(gd.layout.dragmode).toBe(undefined); + expect(gd._fullLayout.dragmode).toBe('zoom'); + expect(buttonTurntable.isActive()).toBe(false); + expect(buttonPan3d.isActive()).toBe(true); + + buttonTurntable.click(); + assertScenes(gd._fullLayout, 'dragmode', 'turntable'); + expect(buttonTurntable.isActive()).toBe(true); + expect(buttonPan3d.isActive()).toBe(false); + }); }); - }); - describe('button orbitRotation', function() { - it('should updates the scene dragmode and dragmode button', function() { - var buttonTurntable = selectButton(modeBar, 'tableRotation'), - buttonOrbit = selectButton(modeBar, 'orbitRotation'); - - assertScenes(gd._fullLayout, 'dragmode', 'turntable'); - expect(buttonTurntable.isActive()).toBe(true); - expect(buttonOrbit.isActive()).toBe(false); - - buttonOrbit.click(); - assertScenes(gd.layout, 'dragmode', 'orbit'); - expect(gd.layout.dragmode).toBe(undefined); - expect(gd._fullLayout.dragmode).toBe('zoom'); - expect(buttonTurntable.isActive()).toBe(false); - expect(buttonOrbit.isActive()).toBe(true); - - buttonTurntable.click(); - assertScenes(gd._fullLayout, 'dragmode', 'turntable'); - expect(buttonTurntable.isActive()).toBe(true); - expect(buttonOrbit.isActive()).toBe(false); + describe('button orbitRotation', function() { + it('should updates the scene dragmode and dragmode button', function() { + var buttonTurntable = selectButton(modeBar, 'tableRotation'), + buttonOrbit = selectButton(modeBar, 'orbitRotation'); + + assertScenes(gd._fullLayout, 'dragmode', 'turntable'); + expect(buttonTurntable.isActive()).toBe(true); + expect(buttonOrbit.isActive()).toBe(false); + + buttonOrbit.click(); + assertScenes(gd.layout, 'dragmode', 'orbit'); + expect(gd.layout.dragmode).toBe(undefined); + expect(gd._fullLayout.dragmode).toBe('zoom'); + expect(buttonTurntable.isActive()).toBe(false); + expect(buttonOrbit.isActive()).toBe(true); + + buttonTurntable.click(); + assertScenes(gd._fullLayout, 'dragmode', 'turntable'); + expect(buttonTurntable.isActive()).toBe(true); + expect(buttonOrbit.isActive()).toBe(false); + }); + }); + + describe('button hoverClosest3d', function() { + it('should update the scene hovermode and spikes', function() { + var buttonHover = selectButton(modeBar, 'hoverClosest3d'); + + assertScenes(gd._fullLayout, 'hovermode', 'closest'); + expect(buttonHover.isActive()).toBe(true); + + buttonHover.click(); + assertScenes(gd._fullLayout, 'hovermode', false); + assertScenes(gd._fullLayout, 'xaxis.showspikes', false); + assertScenes(gd._fullLayout, 'yaxis.showspikes', false); + assertScenes(gd._fullLayout, 'zaxis.showspikes', false); + expect(buttonHover.isActive()).toBe(false); + + buttonHover.click(); + assertScenes(gd._fullLayout, 'hovermode', 'closest'); + assertScenes(gd._fullLayout, 'xaxis.showspikes', true); + assertScenes(gd._fullLayout, 'yaxis.showspikes', true); + assertScenes(gd._fullLayout, 'zaxis.showspikes', true); + expect(buttonHover.isActive()).toBe(true); + }); }); }); - describe('buttons resetCameraDefault3d and resetCameraLastSave3d', function() { + describe('drag and wheel interactions', function() { it('should update the scene camera', function(done) { var sceneLayout = gd._fullLayout.scene, sceneLayout2 = gd._fullLayout.scene2, - scene = sceneLayout._scene, - scene2 = sceneLayout2._scene; + sceneTarget = gd.querySelector('.svg-container .gl-container #scene canvas'), + sceneTarget2 = gd.querySelector('.svg-container .gl-container #scene2 canvas'); expect(sceneLayout.camera.eye) .toEqual({x: 0.1, y: 0.1, z: 1}); expect(sceneLayout2.camera.eye) .toEqual({x: 2.5, y: 2.5, z: 2.5}); - selectButton(modeBar, 'resetCameraDefault3d').click(); + // Wheel scene 1 + sceneTarget.dispatchEvent(new WheelEvent('wheel', {deltaY: 1})); + + // Wheel scene 2 + sceneTarget2.dispatchEvent(new WheelEvent('wheel', {deltaY: 1})); setTimeout(function() { - expect(relayoutCallback).toHaveBeenCalledTimes(2); // initiator: resetCameraDefault3d; 2 scenes - expect(relayoutCallback).toHaveBeenCalledWith({ - scene: { - eye: { x: 1.25, y: 1.25, z: 1.25 }, - center: { x: 0, y: 0, z: 0 }, - up: { x: 0, y: 0, z: 1 } - } - }); - expect(relayoutCallback).toHaveBeenCalledWith({ - scene2: { - center: { x: 0, y: 0, z: 0 }, - eye: { x: 1.25, y: 1.25, z: 1.25 }, - up: { x: 0, y: 0, z: 1 } - } - }); + expect(relayoutCallback).toHaveBeenCalledTimes(2); + relayoutCallback.calls.reset(); - expect(sceneLayout.camera.eye) - .toEqual({x: 0.1, y: 0.1, z: 1}, 'does not change the layout objects'); - expect(scene.camera.eye) - .toBeCloseToArray([1.25, 1.25, 1.25], 4); - expect(sceneLayout2.camera.eye) - .toEqual({x: 2.5, y: 2.5, z: 2.5}, 'does not change the layout objects'); - expect(scene2.camera.eye) - .toBeCloseToArray([1.25, 1.25, 1.25], 4); + // Drag scene 1 + sceneTarget.dispatchEvent(new MouseEvent('mousedown', {x: 0, y: 0})); + sceneTarget.dispatchEvent(new MouseEvent('mousemove', { x: 100, y: 100})); + sceneTarget.dispatchEvent(new MouseEvent('mouseup', { x: 100, y: 100})); - selectButton(modeBar, 'resetCameraLastSave3d').click(); + // Drag scene 2 + sceneTarget2.dispatchEvent(new MouseEvent('mousedown', {x: 0, y: 0 })); + sceneTarget2.dispatchEvent(new MouseEvent('mousemove', {x: 100, y: 100})); + sceneTarget2.dispatchEvent(new MouseEvent('mouseup', {x: 100, y: 100})); setTimeout(function() { - expect(relayoutCallback).toHaveBeenCalledTimes(2); // initiator: resetCameraLastSave3d; 2 scenes - expect(relayoutCallback).toHaveBeenCalledWith({ - scene: { - center: { x: 0, y: 0, z: 0 }, - eye: { x: 0.1, y: 0.1, z: 1 }, - up: { x: 0, y: 0, z: 1 } - } - }); - expect(relayoutCallback).toHaveBeenCalledWith({ - scene2: { - center: { x: 0, y: 0, z: 0 }, - eye: { x: 2.5, y: 2.5, z: 2.5 }, - up: { x: 0, y: 0, z: 1 } - } - }); - - expect(sceneLayout.camera.eye) - .toEqual({x: 0.1, y: 0.1, z: 1}, 'does not change the layout objects'); - expect(scene.camera.eye) - .toBeCloseToArray([ 0.1, 0.1, 1], 4); - expect(sceneLayout2.camera.eye) - .toEqual({x: 2.5, y: 2.5, z: 2.5}, 'does not change the layout objects'); - expect(scene2.camera.eye) - .toBeCloseToArray([2.5, 2.5, 2.5], 4); + expect(relayoutCallback).toHaveBeenCalledTimes(2); done(); @@ -412,30 +505,6 @@ describe('Test gl plot interactions', function() { }, MODEBAR_DELAY); }); }); - - describe('button hoverClosest3d', function() { - it('should update the scene hovermode and spikes', function() { - var buttonHover = selectButton(modeBar, 'hoverClosest3d'); - - assertScenes(gd._fullLayout, 'hovermode', 'closest'); - expect(buttonHover.isActive()).toBe(true); - - buttonHover.click(); - assertScenes(gd._fullLayout, 'hovermode', false); - assertScenes(gd._fullLayout, 'xaxis.showspikes', false); - assertScenes(gd._fullLayout, 'yaxis.showspikes', false); - assertScenes(gd._fullLayout, 'zaxis.showspikes', false); - expect(buttonHover.isActive()).toBe(false); - - buttonHover.click(); - assertScenes(gd._fullLayout, 'hovermode', 'closest'); - assertScenes(gd._fullLayout, 'xaxis.showspikes', true); - assertScenes(gd._fullLayout, 'yaxis.showspikes', true); - assertScenes(gd._fullLayout, 'zaxis.showspikes', true); - expect(buttonHover.isActive()).toBe(true); - }); - }); - }); describe('Plots.cleanPlot', function() { diff --git a/test/jasmine/tests/plot_interact_test.js b/test/jasmine/tests/plot_interact_test.js index 8748b9d9bb2..58eaa47077f 100644 --- a/test/jasmine/tests/plot_interact_test.js +++ b/test/jasmine/tests/plot_interact_test.js @@ -6,7 +6,10 @@ 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 mouseEvent = require('../assets/mouse_event'); +var selectButton = require('../assets/modebar_button'); +var MODEBAR_DELAY = 500; describe('Test plot structure', function() { 'use strict'; @@ -142,6 +145,122 @@ describe('Test plot structure', function() { }); }); + describe('scatter drag', function() { + + var mock = require('@mocks/10.json'), + gd, modeBar, relayoutCallback; + + beforeEach(function(done) { + gd = createGraphDiv(); + + Plotly.plot(gd, mock.data, mock.layout).then(function() { + + modeBar = gd._fullLayout._modeBar; + relayoutCallback = jasmine.createSpy('relayoutCallback'); + + gd.on('plotly_relayout', relayoutCallback); + + done(); + }); + }); + + it('scatter plot should respond to drag interactions', function(done) { + + jasmine.addMatchers(customMatchers); + + var precision = 5; + + var buttonPan = selectButton(modeBar, 'pan2d'); + + var originalX = [-0.6225,5.5]; + var originalY = [-1.6340975059013805,7.166241526218911]; + + var newX = [-2.0255729166666665,4.096927083333333]; + var newY = [-0.3769062155984817,8.42343281652181]; + + expect(gd.layout.xaxis.range).toBeCloseToArray(originalX, precision); + expect(gd.layout.yaxis.range).toBeCloseToArray(originalY, precision); + + // Switch to pan mode + expect(buttonPan.isActive()).toBe(false); // initially, zoom is active + buttonPan.click(); + expect(buttonPan.isActive()).toBe(true); // switched on dragmode + + // Switching mode must not change visible range + expect(gd.layout.xaxis.range).toBeCloseToArray(originalX, precision); + expect(gd.layout.yaxis.range).toBeCloseToArray(originalY, precision); + + setTimeout(function() { + + expect(relayoutCallback).toHaveBeenCalledTimes(1); + relayoutCallback.calls.reset(); + + // Drag scene along the X axis + + mouseEvent('mousedown', 110, 150); + mouseEvent('mousemove', 220, 150); + mouseEvent('mouseup', 220, 150); + + expect(gd.layout.xaxis.range).toBeCloseToArray(newX, precision); + expect(gd.layout.yaxis.range).toBeCloseToArray(originalY, precision); + + // Drag scene back along the X axis (not from the same starting point but same X delta) + + mouseEvent('mousedown', 280, 150); + mouseEvent('mousemove', 170, 150); + mouseEvent('mouseup', 170, 150); + + expect(gd.layout.xaxis.range).toBeCloseToArray(originalX, precision); + expect(gd.layout.yaxis.range).toBeCloseToArray(originalY, precision); + + // Drag scene along the Y axis + + mouseEvent('mousedown', 110, 150); + mouseEvent('mousemove', 110, 190); + mouseEvent('mouseup', 110, 190); + + expect(gd.layout.xaxis.range).toBeCloseToArray(originalX, precision); + expect(gd.layout.yaxis.range).toBeCloseToArray(newY, precision); + + // Drag scene back along the Y axis (not from the same starting point but same Y delta) + + mouseEvent('mousedown', 280, 130); + mouseEvent('mousemove', 280, 90); + mouseEvent('mouseup', 280, 90); + + expect(gd.layout.xaxis.range).toBeCloseToArray(originalX, precision); + expect(gd.layout.yaxis.range).toBeCloseToArray(originalY, precision); + + // Drag scene along both the X and Y axis + + mouseEvent('mousedown', 110, 150); + mouseEvent('mousemove', 220, 190); + mouseEvent('mouseup', 220, 190); + + expect(gd.layout.xaxis.range).toBeCloseToArray(newX, precision); + expect(gd.layout.yaxis.range).toBeCloseToArray(newY, precision); + + // Drag scene back along the X and Y axis (not from the same starting point but same delta vector) + + mouseEvent('mousedown', 280, 130); + mouseEvent('mousemove', 170, 90); + mouseEvent('mouseup', 170, 90); + + expect(gd.layout.xaxis.range).toBeCloseToArray(originalX, precision); + expect(gd.layout.yaxis.range).toBeCloseToArray(originalY, precision); + + setTimeout(function() { + + expect(relayoutCallback).toHaveBeenCalledTimes(6); // X and back; Y and back; XY and back + + done(); + + }, MODEBAR_DELAY); + + }, MODEBAR_DELAY); + }); + }); + describe('contour/heatmap traces', function() { var mock = require('@mocks/connectgaps_2d.json'); var gd;