Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit dca69f9

Browse files
committedMay 6, 2016
Merge pull request #466 from monfera/30a-emit-relayout-event-on-layout-change
Emit a plotly_relayout' event upon mouseup, wheel
2 parents 67c7e73 + 2a13e92 commit dca69f9

File tree

9 files changed

+509
-184
lines changed

9 files changed

+509
-184
lines changed
 

‎package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@
9393
"fs-extra": "^0.28.0",
9494
"fuse.js": "^2.2.0",
9595
"glob": "^7.0.0",
96-
"jasmine-core": "^2.3.4",
96+
"jasmine-core": "^2.4.1",
9797
"karma": "^0.13.15",
9898
"karma-browserify": "^5.0.1",
9999
"karma-chrome-launcher": "^0.2.1",

‎src/components/modebar/buttons.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,8 @@ function handleCamera3d(gd, ev) {
350350

351351
if(attr === 'resetDefault') scene.setCameraToDefault();
352352
else if(attr === 'resetLastSave') {
353+
// This handler looks in the un-updated fullLayout.scene.camera object to reset the camera
354+
// to the last saved position.
353355
scene.setCamera(fullSceneLayout.camera);
354356
}
355357
}

‎src/plots/gl2d/index.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,9 @@ exports.plot = function plotGl2d(gd) {
5050
// If Scene is not instantiated, create one!
5151
if(scene === undefined) {
5252
scene = new Scene2D({
53-
container: gd.querySelector('.gl-container'),
5453
id: subplotId,
54+
graphDiv: gd,
55+
container: gd.querySelector('.gl-container'),
5556
staticPlot: gd._context.staticPlot,
5657
plotGlPixelRatio: gd._context.plotGlPixelRatio
5758
},

‎src/plots/gl2d/scene2d.js

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ var STATIC_CANVAS, STATIC_CONTEXT;
2727

2828
function Scene2D(options, fullLayout) {
2929
this.container = options.container;
30+
this.graphDiv = options.graphDiv;
3031
this.pixelRatio = options.plotGlPixelRatio || window.devicePixelRatio;
3132
this.id = options.id;
3233
this.staticPlot = !!options.staticPlot;
@@ -268,6 +269,25 @@ proto.updateFx = function(options) {
268269
fullLayout.hovermode = options.hovermode;
269270
};
270271

272+
var relayoutCallback = function(scene) {
273+
274+
var xrange = scene.xaxis.range,
275+
yrange = scene.yaxis.range;
276+
277+
// Update the layout on the DIV
278+
scene.graphDiv.layout.xaxis.range = xrange.slice(0);
279+
scene.graphDiv.layout.yaxis.range = yrange.slice(0);
280+
281+
// Make a meaningful value to be passed on to the possible 'plotly_relayout' subscriber(s)
282+
var update = { // scene.camera has no many useful projection or scale information
283+
lastInputTime: scene.camera.lastInputTime // helps determine which one is the latest input (if async)
284+
};
285+
update[scene.xaxis._name] = xrange.slice();
286+
update[scene.yaxis._name] = yrange.slice();
287+
288+
scene.graphDiv.emit('plotly_relayout', update);
289+
};
290+
271291
proto.cameraChanged = function() {
272292
var camera = this.camera,
273293
xrange = this.xaxis.range,
@@ -285,6 +305,7 @@ proto.cameraChanged = function() {
285305
this.glplotOptions.ticks = nextTicks;
286306
this.glplotOptions.dataBox = camera.dataBox;
287307
this.glplot.update(this.glplotOptions);
308+
relayoutCallback(this);
288309
}
289310
};
290311

‎src/plots/gl3d/scene.js

Lines changed: 53 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,6 @@
77
*/
88

99

10-
/*eslint block-scoped-var: 0*/
11-
/*eslint no-redeclare: 0*/
12-
1310
'use strict';
1411

1512
var createPlot = require('gl-plot3d');
@@ -34,6 +31,8 @@ var STATIC_CANVAS, STATIC_CONTEXT;
3431

3532
function render(scene) {
3633

34+
var trace;
35+
3736
// update size of svg container
3837
var svgContainer = scene.svgContainer;
3938
var clientRect = scene.container.getBoundingClientRect();
@@ -50,7 +49,7 @@ function render(scene) {
5049
var lastPicked = null;
5150
var selection = scene.glplot.selection;
5251
for(var i = 0; i < keys.length; ++i) {
53-
var trace = scene.traces[keys[i]];
52+
trace = scene.traces[keys[i]];
5453
if(trace.handlePick(selection)) {
5554
lastPicked = trace;
5655
}
@@ -68,9 +67,9 @@ function render(scene) {
6867
var oldEventData;
6968

7069
if(lastPicked !== null) {
71-
var pdata = project(scene.glplot.cameraParams, selection.dataCoordinate),
72-
trace = lastPicked.data,
73-
hoverinfo = trace.hoverinfo;
70+
var pdata = project(scene.glplot.cameraParams, selection.dataCoordinate);
71+
trace = lastPicked.data;
72+
var hoverinfo = trace.hoverinfo;
7473

7574
var xVal = formatter('xaxis', selection.traceCoordinate[0]),
7675
yVal = formatter('yaxis', selection.traceCoordinate[1]),
@@ -172,6 +171,16 @@ function initializeGLPlot(scene, fullLayout, canvas, gl) {
172171
showNoWebGlMsg(scene);
173172
}
174173

174+
var relayoutCallback = function(scene) {
175+
var update = {};
176+
update[scene.id] = getLayoutCamera(scene.camera);
177+
scene.saveCamera(scene.graphDiv.layout);
178+
scene.graphDiv.emit('plotly_relayout', update);
179+
};
180+
181+
scene.glplot.canvas.addEventListener('mouseup', relayoutCallback.bind(null, scene));
182+
scene.glplot.canvas.addEventListener('wheel', relayoutCallback.bind(null, scene));
183+
175184
if(!scene.staticMode) {
176185
scene.glplot.canvas.addEventListener('webglcontextlost', function(ev) {
177186
console.log('lost context');
@@ -255,7 +264,7 @@ function Scene(options, fullLayout) {
255264

256265
this.contourLevels = [ [], [], [] ];
257266

258-
if(!initializeGLPlot(this, fullLayout)) return;
267+
if(!initializeGLPlot(this, fullLayout)) return; // todo check the necessity for this line
259268
}
260269

261270
var proto = Scene.prototype;
@@ -283,18 +292,19 @@ proto.recoverContext = function() {
283292
var axisProperties = [ 'xaxis', 'yaxis', 'zaxis' ];
284293

285294
function coordinateBound(axis, coord, d, bounds) {
295+
var x;
286296
for(var i=0; i<coord.length; ++i) {
287297
if(Array.isArray(coord[i])) {
288298
for(var j=0; j<coord[i].length; ++j) {
289-
var x = axis.d2l(coord[i][j]);
299+
x = axis.d2l(coord[i][j]);
290300
if(!isNaN(x) && isFinite(x)) {
291301
bounds[0][d] = Math.min(bounds[0][d], x);
292302
bounds[1][d] = Math.max(bounds[1][d], x);
293303
}
294304
}
295305
}
296306
else {
297-
var x = axis.d2l(coord[i]);
307+
x = axis.d2l(coord[i]);
298308
if(!isNaN(x) && isFinite(x)) {
299309
bounds[0][d] = Math.min(bounds[0][d], x);
300310
bounds[1][d] = Math.max(bounds[1][d], x);
@@ -317,7 +327,7 @@ proto.plot = function(sceneData, fullLayout, layout) {
317327
if(this.glplot.contextLost) return;
318328

319329
var data, trace;
320-
var i, j;
330+
var i, j, axis, axisType;
321331
var fullSceneLayout = fullLayout[this.id];
322332
var sceneLayout = layout[this.id];
323333

@@ -341,7 +351,7 @@ proto.plot = function(sceneData, fullLayout, layout) {
341351

342352
// Update axes functions BEFORE updating traces
343353
for(i = 0; i < 3; ++i) {
344-
var axis = fullSceneLayout[axisProperties[i]];
354+
axis = fullSceneLayout[axisProperties[i]];
345355
setConvert(axis);
346356
}
347357

@@ -354,14 +364,14 @@ proto.plot = function(sceneData, fullLayout, layout) {
354364
[Infinity, Infinity, Infinity],
355365
[-Infinity, -Infinity, -Infinity]
356366
];
357-
for(var i=0; i<sceneData.length; ++i) {
358-
var data = sceneData[i];
367+
for(i=0; i<sceneData.length; ++i) {
368+
data = sceneData[i];
359369
if(data.visible !== true) continue;
360370

361371
computeTraceBounds(this, data, dataBounds);
362372
}
363373
var dataScale = [1,1,1];
364-
for(var j=0; j<3; ++j) {
374+
for(j=0; j<3; ++j) {
365375
if(dataBounds[0][j] > dataBounds[1][j]) {
366376
dataScale[j] = 1.0;
367377
}
@@ -379,7 +389,7 @@ proto.plot = function(sceneData, fullLayout, layout) {
379389
this.dataScale = dataScale;
380390

381391
//Update traces
382-
for(var i = 0; i < sceneData.length; ++i) {
392+
for(i = 0; i < sceneData.length; ++i) {
383393
data = sceneData[i];
384394
if(data.visible!==true) {
385395
continue;
@@ -416,8 +426,8 @@ proto.plot = function(sceneData, fullLayout, layout) {
416426
axisTypeRatios = {};
417427

418428
for(i = 0; i < 3; ++i) {
419-
var axis = fullSceneLayout[axisProperties[i]];
420-
var axisType = axis.type;
429+
axis = fullSceneLayout[axisProperties[i]];
430+
axisType = axis.type;
421431

422432
if(axisType in axisTypeRatios) {
423433
axisTypeRatios[axisType].acc *= dataScale[i];
@@ -471,9 +481,9 @@ proto.plot = function(sceneData, fullLayout, layout) {
471481
var axesScaleRatio = [1, 1, 1];
472482

473483
//Compute axis scale per category
474-
for(var i=0; i<3; ++i) {
475-
var axis = fullSceneLayout[axisProperties[i]];
476-
var axisType = axis.type;
484+
for(i=0; i<3; ++i) {
485+
axis = fullSceneLayout[axisProperties[i]];
486+
axisType = axis.type;
477487
var axisRatio = axisTypeRatios[axisType];
478488
axesScaleRatio[i] = Math.pow(axisRatio.acc, 1.0/axisRatio.count) / dataScale[i];
479489
}
@@ -567,33 +577,35 @@ proto.setCameraToDefault = function setCameraToDefault() {
567577
});
568578
};
569579

570-
// get camera position in plotly coords from 'orbit-camera' coords
571-
proto.getCamera = function getCamera() {
572-
this.glplot.camera.view.recalcMatrix(this.camera.view.lastT());
573-
574-
var up = this.glplot.camera.up;
575-
var center = this.glplot.camera.center;
576-
var eye = this.glplot.camera.eye;
580+
// getOrbitCamera :: plotly_coords -> orbit_camera_coords
581+
// inverse of getLayoutCamera
582+
function getOrbitCamera(camera) {
583+
return [
584+
[camera.eye.x, camera.eye.y, camera.eye.z],
585+
[camera.center.x, camera.center.y, camera.center.z],
586+
[camera.up.x, camera.up.y, camera.up.z]
587+
];
588+
}
577589

590+
// getLayoutCamera :: orbit_camera_coords -> plotly_coords
591+
// inverse of getOrbitCamera
592+
function getLayoutCamera(camera) {
578593
return {
579-
up: {x: up[0], y: up[1], z: up[2]},
580-
center: {x: center[0], y: center[1], z: center[2]},
581-
eye: {x: eye[0], y: eye[1], z: eye[2]}
594+
up: {x: camera.up[0], y: camera.up[1], z: camera.up[2]},
595+
center: {x: camera.center[0], y: camera.center[1], z: camera.center[2]},
596+
eye: {x: camera.eye[0], y: camera.eye[1], z: camera.eye[2]}
582597
};
598+
}
599+
600+
// get camera position in plotly coords from 'orbit-camera' coords
601+
proto.getCamera = function getCamera() {
602+
this.glplot.camera.view.recalcMatrix(this.camera.view.lastT());
603+
return getLayoutCamera(this.glplot.camera);
583604
};
584605

585606
// set camera position with a set of plotly coords
586607
proto.setCamera = function setCamera(cameraData) {
587608

588-
// getOrbitCamera :: plotly_coords -> orbit_camera_coords
589-
function getOrbitCamera(camera) {
590-
return [
591-
[camera.eye.x, camera.eye.y, camera.eye.z],
592-
[camera.center.x, camera.center.y, camera.center.z],
593-
[camera.up.x, camera.up.y, camera.up.z]
594-
];
595-
}
596-
597609
var update = {};
598610

599611
update[this.id] = cameraData;
@@ -612,7 +624,7 @@ proto.saveCamera = function saveCamera(layout) {
612624
function same(x, y, i, j) {
613625
var vectors = ['up', 'center', 'eye'],
614626
components = ['x', 'y', 'z'];
615-
return x[vectors[i]][components[j]] === y[vectors[i]][components[j]];
627+
return y[vectors[i]] && (x[vectors[i]][components[j]] === y[vectors[i]][components[j]]);
616628
}
617629

618630
if(cameraDataLastSave === undefined) hasChanged = true;

‎test/jasmine/karma.ciconf.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ function func(config) {
1414
* exclude them from the CircleCI test bundle.
1515
*
1616
*/
17-
func.defaultConfig.exclude = ['tests/gl_plot_interact_test.js'];
17+
func.defaultConfig.exclude = ['tests/gl_plot_interact_test.js', 'tests/gl_plot_interact_basic_test.js'];
1818

1919
// if true, Karma captures browsers, runs the tests and exits
2020
func.defaultConfig.singleRun = true;
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
'use strict';
2+
3+
var Plotly = require('@lib/index');
4+
var Plots = require('@src/plots/plots');
5+
6+
var createGraphDiv = require('../assets/create_graph_div');
7+
var destroyGraphDiv = require('../assets/destroy_graph_div');
8+
var mouseEvent = require('../assets/mouse_event');
9+
10+
function teardown(gd, done) {
11+
12+
// The teardown function needs information of what to tear down so afterEach can not be used without global vars.
13+
// In addition to removing the plot from the DOM it also destroy possibly present 2D or 3D scenes
14+
15+
// TODO we should figure out something to only rely on public API calls
16+
// In other words, how can users themselves properly destroy the plot through the API?
17+
// This function is left in this file until the above todo is looked into.
18+
var fullLayout = gd._fullLayout;
19+
20+
Plots.getSubplotIds(fullLayout, 'gl3d').forEach(function(sceneId) {
21+
var scene = fullLayout[sceneId]._scene;
22+
if(scene.glplot) scene.destroy();
23+
});
24+
25+
Plots.getSubplotIds(fullLayout, 'gl2d').forEach(function(sceneId) {
26+
var scene2d = fullLayout._plots[sceneId]._scene2d;
27+
if(scene2d.glplot) {
28+
scene2d.stopped = true;
29+
scene2d.destroy();
30+
}
31+
});
32+
33+
destroyGraphDiv();
34+
35+
// A test case can only be called 'done' when the above destroy methods had been performed.
36+
// One way of helping ensure that the destroys are not forgotten is that done() is part of
37+
// the teardown, consequently if a test case omits the teardown by accident, the test will
38+
// visibly hang. If the teardown receives no proper arguments, it'll also visibly fail.
39+
done();
40+
}
41+
42+
describe('Test gl plot interactions', function() {
43+
44+
describe('gl3d plots', function() {
45+
46+
// Expected shape of projection-related data
47+
var cameraStructure = {
48+
up: {x: jasmine.any(Number), y: jasmine.any(Number), z: jasmine.any(Number)},
49+
center: {x: jasmine.any(Number), y: jasmine.any(Number), z: jasmine.any(Number)},
50+
eye: {x: jasmine.any(Number), y: jasmine.any(Number), z: jasmine.any(Number)}
51+
};
52+
53+
function makePlot(mock) {
54+
return Plotly.plot(createGraphDiv(), mock.data, mock.layout);
55+
}
56+
57+
function addEventCallback(graphDiv) {
58+
var relayoutCallback = jasmine.createSpy('relayoutCallback');
59+
graphDiv.on('plotly_relayout', relayoutCallback);
60+
return {graphDiv: graphDiv, relayoutCallback: relayoutCallback};
61+
}
62+
63+
function verifyInteractionEffects(tuple) {
64+
65+
// One 'drag': simulating fairly thoroughly as the mouseup event is also needed here
66+
mouseEvent('mousemove', 400, 200);
67+
mouseEvent('mousedown', 400, 200);
68+
mouseEvent('mousemove', 320, 320, {buttons: 1});
69+
mouseEvent('mouseup', 320, 320);
70+
71+
// Check event emission count
72+
expect(tuple.relayoutCallback).toHaveBeenCalledTimes(1);
73+
74+
// Check structure of event callback value contents
75+
expect(tuple.relayoutCallback).toHaveBeenCalledWith(jasmine.objectContaining({scene: cameraStructure}));
76+
77+
// Check camera contents on the DIV layout
78+
var divCamera = tuple.graphDiv.layout.scene.camera;
79+
80+
expect(divCamera).toEqual(cameraStructure);
81+
82+
return tuple.graphDiv;
83+
}
84+
85+
function testEvents(plot, done) {
86+
plot.then(function(graphDiv) {
87+
var tuple = addEventCallback(graphDiv); // TODO disuse tuple with ES6
88+
verifyInteractionEffects(tuple);
89+
teardown(graphDiv, done);
90+
});
91+
}
92+
93+
it('should respond to drag interactions with mock of unset camera', function(done) {
94+
testEvents(makePlot(require('@mocks/gl3d_scatter3d-connectgaps.json')), done);
95+
});
96+
97+
it('should respond to drag interactions with mock of partially set camera', function(done) {
98+
testEvents(makePlot(require('@mocks/gl3d_errorbars_zx.json')), done);
99+
});
100+
});
101+
});

‎test/jasmine/tests/gl_plot_interact_test.js

Lines changed: 209 additions & 140 deletions
Large diffs are not rendered by default.

‎test/jasmine/tests/plot_interact_test.js

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@ var Lib = require('@src/lib');
66
var createGraphDiv = require('../assets/create_graph_div');
77
var destroyGraphDiv = require('../assets/destroy_graph_div');
88
var customMatchers = require('../assets/custom_matchers');
9+
var mouseEvent = require('../assets/mouse_event');
10+
var selectButton = require('../assets/modebar_button');
911

12+
var MODEBAR_DELAY = 500;
1013

1114
describe('Test plot structure', function() {
1215
'use strict';
@@ -142,6 +145,122 @@ describe('Test plot structure', function() {
142145
});
143146
});
144147

148+
describe('scatter drag', function() {
149+
150+
var mock = require('@mocks/10.json'),
151+
gd, modeBar, relayoutCallback;
152+
153+
beforeEach(function(done) {
154+
gd = createGraphDiv();
155+
156+
Plotly.plot(gd, mock.data, mock.layout).then(function() {
157+
158+
modeBar = gd._fullLayout._modeBar;
159+
relayoutCallback = jasmine.createSpy('relayoutCallback');
160+
161+
gd.on('plotly_relayout', relayoutCallback);
162+
163+
done();
164+
});
165+
});
166+
167+
it('scatter plot should respond to drag interactions', function(done) {
168+
169+
jasmine.addMatchers(customMatchers);
170+
171+
var precision = 5;
172+
173+
var buttonPan = selectButton(modeBar, 'pan2d');
174+
175+
var originalX = [-0.6225,5.5];
176+
var originalY = [-1.6340975059013805,7.166241526218911];
177+
178+
var newX = [-2.0255729166666665,4.096927083333333];
179+
var newY = [-0.3769062155984817,8.42343281652181];
180+
181+
expect(gd.layout.xaxis.range).toBeCloseToArray(originalX, precision);
182+
expect(gd.layout.yaxis.range).toBeCloseToArray(originalY, precision);
183+
184+
// Switch to pan mode
185+
expect(buttonPan.isActive()).toBe(false); // initially, zoom is active
186+
buttonPan.click();
187+
expect(buttonPan.isActive()).toBe(true); // switched on dragmode
188+
189+
// Switching mode must not change visible range
190+
expect(gd.layout.xaxis.range).toBeCloseToArray(originalX, precision);
191+
expect(gd.layout.yaxis.range).toBeCloseToArray(originalY, precision);
192+
193+
setTimeout(function() {
194+
195+
expect(relayoutCallback).toHaveBeenCalledTimes(1);
196+
relayoutCallback.calls.reset();
197+
198+
// Drag scene along the X axis
199+
200+
mouseEvent('mousedown', 110, 150);
201+
mouseEvent('mousemove', 220, 150);
202+
mouseEvent('mouseup', 220, 150);
203+
204+
expect(gd.layout.xaxis.range).toBeCloseToArray(newX, precision);
205+
expect(gd.layout.yaxis.range).toBeCloseToArray(originalY, precision);
206+
207+
// Drag scene back along the X axis (not from the same starting point but same X delta)
208+
209+
mouseEvent('mousedown', 280, 150);
210+
mouseEvent('mousemove', 170, 150);
211+
mouseEvent('mouseup', 170, 150);
212+
213+
expect(gd.layout.xaxis.range).toBeCloseToArray(originalX, precision);
214+
expect(gd.layout.yaxis.range).toBeCloseToArray(originalY, precision);
215+
216+
// Drag scene along the Y axis
217+
218+
mouseEvent('mousedown', 110, 150);
219+
mouseEvent('mousemove', 110, 190);
220+
mouseEvent('mouseup', 110, 190);
221+
222+
expect(gd.layout.xaxis.range).toBeCloseToArray(originalX, precision);
223+
expect(gd.layout.yaxis.range).toBeCloseToArray(newY, precision);
224+
225+
// Drag scene back along the Y axis (not from the same starting point but same Y delta)
226+
227+
mouseEvent('mousedown', 280, 130);
228+
mouseEvent('mousemove', 280, 90);
229+
mouseEvent('mouseup', 280, 90);
230+
231+
expect(gd.layout.xaxis.range).toBeCloseToArray(originalX, precision);
232+
expect(gd.layout.yaxis.range).toBeCloseToArray(originalY, precision);
233+
234+
// Drag scene along both the X and Y axis
235+
236+
mouseEvent('mousedown', 110, 150);
237+
mouseEvent('mousemove', 220, 190);
238+
mouseEvent('mouseup', 220, 190);
239+
240+
expect(gd.layout.xaxis.range).toBeCloseToArray(newX, precision);
241+
expect(gd.layout.yaxis.range).toBeCloseToArray(newY, precision);
242+
243+
// Drag scene back along the X and Y axis (not from the same starting point but same delta vector)
244+
245+
mouseEvent('mousedown', 280, 130);
246+
mouseEvent('mousemove', 170, 90);
247+
mouseEvent('mouseup', 170, 90);
248+
249+
expect(gd.layout.xaxis.range).toBeCloseToArray(originalX, precision);
250+
expect(gd.layout.yaxis.range).toBeCloseToArray(originalY, precision);
251+
252+
setTimeout(function() {
253+
254+
expect(relayoutCallback).toHaveBeenCalledTimes(6); // X and back; Y and back; XY and back
255+
256+
done();
257+
258+
}, MODEBAR_DELAY);
259+
260+
}, MODEBAR_DELAY);
261+
});
262+
});
263+
145264
describe('contour/heatmap traces', function() {
146265
var mock = require('@mocks/connectgaps_2d.json');
147266
var gd;

0 commit comments

Comments
 (0)
Please sign in to comment.