Skip to content

Commit 294e5f6

Browse files
committedJun 30, 2017
implement select/lasso dragmode for scattermapbox
- create dragElement on mapbox subplot <div> when dragmode is set to select or lasso, - undo dragElement onmousedown handle for other dragmode values - keep ref to scattermapbox/plot instance in calcdata to call its update method directly in select - must keep track of trace with dimmed pts to go through arrayOk logic in scatter/convert
1 parent 1f4eabb commit 294e5f6

File tree

8 files changed

+354
-12
lines changed

8 files changed

+354
-12
lines changed
 

‎src/plot_api/subroutines.js

+11-5
Original file line numberDiff line numberDiff line change
@@ -378,21 +378,27 @@ exports.doTicksRelayout = function(gd) {
378378

379379
exports.doModeBar = function(gd) {
380380
var fullLayout = gd._fullLayout;
381-
var subplotIds, scene, i;
381+
var subplotIds, subplotObj, i;
382382

383383
ModeBar.manage(gd);
384384
initInteractions(gd);
385385

386386
subplotIds = Plots.getSubplotIds(fullLayout, 'gl3d');
387387
for(i = 0; i < subplotIds.length; i++) {
388-
scene = fullLayout[subplotIds[i]]._scene;
389-
scene.updateFx(fullLayout.dragmode, fullLayout.hovermode);
388+
subplotObj = fullLayout[subplotIds[i]]._scene;
389+
subplotObj.updateFx(fullLayout.dragmode, fullLayout.hovermode);
390390
}
391391

392392
subplotIds = Plots.getSubplotIds(fullLayout, 'gl2d');
393393
for(i = 0; i < subplotIds.length; i++) {
394-
scene = fullLayout._plots[subplotIds[i]]._scene2d;
395-
scene.updateFx(fullLayout.dragmode);
394+
subplotObj = fullLayout._plots[subplotIds[i]]._scene2d;
395+
subplotObj.updateFx(fullLayout.dragmode);
396+
}
397+
398+
subplotIds = Plots.getSubplotIds(fullLayout, 'mapbox');
399+
for(i = 0; i < subplotIds.length; i++) {
400+
subplotObj = fullLayout[subplotIds[i]]._subplot;
401+
subplotObj.updateFx(fullLayout);
396402
}
397403

398404
return Plots.previousPromises(gd);

‎src/plots/mapbox/mapbox.js

+78-4
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ var mapboxgl = require('mapbox-gl');
1313

1414
var Fx = require('../../components/fx');
1515
var Lib = require('../../lib');
16+
var dragElement = require('../../components/dragelement');
17+
var prepSelect = require('../cartesian/select');
1618
var constants = require('./constants');
1719
var layoutAttributes = require('./layout_attributes');
1820
var createMapboxLayer = require('./layers');
@@ -86,9 +88,9 @@ proto.plot = function(calcData, fullLayout, promises) {
8688
};
8789

8890
proto.createMap = function(calcData, fullLayout, resolve, reject) {
89-
var self = this,
90-
gd = self.gd,
91-
opts = self.opts;
91+
var self = this;
92+
var gd = self.gd;
93+
var opts = self.opts;
9294

9395
// store style id and URL or object
9496
var styleObj = self.styleObj = getStyleObj(opts.style);
@@ -107,7 +109,9 @@ proto.createMap = function(calcData, fullLayout, resolve, reject) {
107109
pitch: opts.pitch,
108110

109111
interactive: !self.isStatic,
110-
preserveDrawingBuffer: self.isStatic
112+
preserveDrawingBuffer: self.isStatic,
113+
114+
boxZoom: false
111115
});
112116

113117
// clear navigation container
@@ -128,6 +132,8 @@ proto.createMap = function(calcData, fullLayout, resolve, reject) {
128132
self.resolveOnRender(resolve);
129133
});
130134

135+
if(self.isStatic) return;
136+
131137
// keep track of pan / zoom in user layout and emit relayout event
132138
map.on('moveend', function(eventData) {
133139
if(!self.map) return;
@@ -261,6 +267,7 @@ proto.updateLayout = function(fullLayout) {
261267

262268
this.updateLayers();
263269
this.updateFramework(fullLayout);
270+
this.updateFx(fullLayout);
264271
this.map.resize();
265272
};
266273

@@ -314,6 +321,73 @@ proto.createFramework = function(fullLayout) {
314321
self.updateFramework(fullLayout);
315322
};
316323

324+
proto.updateFx = function(fullLayout) {
325+
var self = this;
326+
var map = self.map;
327+
var gd = self.gd;
328+
329+
if(self.isStatic) return;
330+
331+
function clearSelect() {
332+
fullLayout._zoomlayer.selectAll('.select-outline').remove();
333+
}
334+
335+
function invert(pxpy) {
336+
var obj = self.map.unproject(pxpy);
337+
return [obj.lng, obj.lat];
338+
}
339+
340+
var dragMode = fullLayout.dragmode;
341+
var fillRangeItems;
342+
343+
if(dragMode === 'select') {
344+
fillRangeItems = function(eventData, poly) {
345+
var ranges = eventData.range = {};
346+
ranges[self.id] = [
347+
invert([poly.xmin, poly.ymin]),
348+
invert([poly.xmax, poly.ymax])
349+
];
350+
};
351+
} else {
352+
fillRangeItems = function(eventData, poly, pts) {
353+
var dataPts = eventData.lassoPoints = {};
354+
dataPts[self.id] = pts.filtered.map(invert);
355+
};
356+
}
357+
358+
if(dragMode === 'select' || dragMode === 'lasso') {
359+
map.dragPan.disable();
360+
361+
var dragOptions = {
362+
element: self.div,
363+
gd: gd,
364+
plotinfo: {
365+
xaxis: self.xaxis,
366+
yaxis: self.yaxis,
367+
fillRangeItems: fillRangeItems
368+
},
369+
xaxes: [self.xaxis],
370+
yaxes: [self.yaxis],
371+
subplot: self.id
372+
};
373+
374+
dragOptions.prepFn = function(e, startX, startY) {
375+
prepSelect(e, startX, startY, dragOptions, dragMode);
376+
};
377+
378+
dragOptions.doneFn = function(dragged, numClicks) {
379+
if(numClicks === 2) clearSelect();
380+
};
381+
382+
dragElement.init(dragOptions);
383+
} else {
384+
map.dragPan.enable();
385+
self.div.onmousedown = null;
386+
}
387+
388+
clearSelect();
389+
};
390+
317391
proto.updateFramework = function(fullLayout) {
318392
var domain = fullLayout[this.id].domain,
319393
size = fullLayout._size;

‎src/traces/scattermapbox/convert.js

+6-1
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ var convertTextOpts = require('../../plots/mapbox/convert_text_opts');
2323
var COLOR_PROP = 'circle-color';
2424
var SIZE_PROP = 'circle-radius';
2525
var OPACITY_PROP = 'circle-opacity';
26+
var DESELECTDIM = 0.2;
2627

2728
module.exports = function convert(calcTrace) {
2829
var trace = calcTrace[0].trace;
@@ -185,7 +186,7 @@ function makeCircleGeoJSON(calcTrace, hash) {
185186
}
186187

187188
function combineOpacities(d, mo) {
188-
return trace.opacity * mo;
189+
return trace.opacity * mo * (d.dim ? DESELECTDIM : 1);
189190
}
190191

191192
var opacityFn;
@@ -194,6 +195,10 @@ function makeCircleGeoJSON(calcTrace, hash) {
194195
var mo = isNumeric(d.mo) ? +Lib.constrain(d.mo, 0, 1) : 0;
195196
return combineOpacities(d, mo);
196197
};
198+
} else if(trace._hasDimmedPts) {
199+
opacityFn = function(d) {
200+
return combineOpacities(d, marker.opacity);
201+
};
197202
}
198203

199204
// Translate vals in trace arrayOk containers

‎src/traces/scattermapbox/index.js

+2-1
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,10 @@ ScatterMapbox.attributes = require('./attributes');
1515
ScatterMapbox.supplyDefaults = require('./defaults');
1616
ScatterMapbox.colorbar = require('../scatter/colorbar');
1717
ScatterMapbox.calc = require('../scattergeo/calc');
18+
ScatterMapbox.plot = require('./plot');
1819
ScatterMapbox.hoverPoints = require('./hover');
1920
ScatterMapbox.eventData = require('./event_data');
20-
ScatterMapbox.plot = require('./plot');
21+
ScatterMapbox.selectPoints = require('./select');
2122

2223
ScatterMapbox.moduleType = 'trace';
2324
ScatterMapbox.name = 'scattermapbox';

‎src/traces/scattermapbox/plot.js

+3
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,9 @@ proto.update = function update(calcTrace) {
9292
mapbox.setSourceData(this.idSourceSymbol, opts.symbol.geojson);
9393
mapbox.setOptions(this.idLayerSymbol, 'setPaintProperty', opts.symbol.paint);
9494
}
95+
96+
// link ref for quick update during selections
97+
calcTrace[0].trace._glTrace = this;
9598
};
9699

97100
proto.dispose = function dispose() {

‎src/traces/scattermapbox/select.js

+57
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
/**
2+
* Copyright 2012-2017, Plotly, Inc.
3+
* All rights reserved.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
10+
'use strict';
11+
12+
var subtypes = require('../scatter/subtypes');
13+
14+
module.exports = function selectPoints(searchInfo, polygon) {
15+
var cd = searchInfo.cd;
16+
var xa = searchInfo.xaxis;
17+
var ya = searchInfo.yaxis;
18+
var selection = [];
19+
var trace = cd[0].trace;
20+
21+
var di, lonlat, x, y, i;
22+
23+
// flag used in ./convert.js
24+
// to not insert data-driven 'circle-opacity' when we don't need to
25+
trace._hasDimmedPts = false;
26+
27+
if(trace.visible !== true || !subtypes.hasMarkers(trace)) return;
28+
29+
if(polygon === false) {
30+
for(i = 0; i < cd.length; i++) {
31+
cd[i].dim = 0;
32+
}
33+
} else {
34+
for(i = 0; i < cd.length; i++) {
35+
di = cd[i];
36+
lonlat = di.lonlat;
37+
x = xa.c2p(lonlat);
38+
y = ya.c2p(lonlat);
39+
40+
if(polygon.contains([x, y])) {
41+
trace._hasDimmedPts = true;
42+
selection.push({
43+
pointNumber: i,
44+
lon: lonlat[0],
45+
lat: lonlat[1]
46+
});
47+
di.dim = 0;
48+
} else {
49+
di.dim = 1;
50+
}
51+
}
52+
}
53+
54+
trace._glTrace.update(cd);
55+
56+
return selection;
57+
};

‎test/jasmine/tests/scattermapbox_test.js

+65-1
Original file line numberDiff line numberDiff line change
@@ -114,14 +114,26 @@ describe('scattermapbox convert', function() {
114114
jasmine.addMatchers(customMatchers);
115115
});
116116

117-
function _convert(trace) {
117+
function _convert(trace, selected) {
118118
var gd = { data: [trace] };
119119
Plots.supplyDefaults(gd);
120120

121121
var fullTrace = gd._fullData[0];
122122
Plots.doCalcdata(gd, fullTrace);
123123

124124
var calcTrace = gd.calcdata[0];
125+
126+
if(selected) {
127+
var hasDimmedPts = false;
128+
129+
selected.forEach(function(v, i) {
130+
if(v) hasDimmedPts = true;
131+
calcTrace[i].dim = v;
132+
});
133+
134+
fullTrace._hasDimmedPts = hasDimmedPts;
135+
}
136+
125137
return convert(calcTrace);
126138
}
127139

@@ -208,6 +220,58 @@ describe('scattermapbox convert', function() {
208220
], 'geojson feature properties');
209221
});
210222

223+
it('should fill circle-opacity correctly during selections', function() {
224+
var _base = {
225+
type: 'scattermapbox',
226+
mode: 'markers',
227+
lon: [-10, 30, 20],
228+
lat: [45, 90, 180],
229+
marker: {symbol: 'circle'}
230+
};
231+
232+
var specs = [{
233+
patch: {},
234+
selected: [0, 1, 1],
235+
expected: {stops: [[0, 1], [1, 0.2]], props: [0, 1, 1]}
236+
}, {
237+
patch: {opacity: 0.5},
238+
selected: [0, 1, 1],
239+
expected: {stops: [[0, 0.5], [1, 0.1]], props: [0, 1, 1]}
240+
}, {
241+
patch: {
242+
marker: {opacity: 0.6}
243+
},
244+
selected: [1, 0, 1],
245+
expected: {stops: [[0, 0.12], [1, 0.6]], props: [0, 1, 0]}
246+
}, {
247+
patch: {
248+
marker: {opacity: [0.5, 1, 0.6]}
249+
},
250+
selected: [1, 0, 1],
251+
expected: {stops: [[0, 0.1], [1, 1], [2, 0.12]], props: [0, 1, 2]}
252+
}, {
253+
patch: {
254+
marker: {opacity: [2, null, -0.6]}
255+
},
256+
selected: [1, 1, 1],
257+
expected: {stops: [[0, 0.2], [1, 0]], props: [0, 1, 1]}
258+
}];
259+
260+
specs.forEach(function(s, i) {
261+
var msg0 = '- case ' + i + ' ';
262+
var opts = _convert(Lib.extendDeep({}, _base, s.patch), s.selected);
263+
264+
expect(opts.circle.paint['circle-opacity'].stops)
265+
.toEqual(s.expected.stops, msg0 + 'stops');
266+
267+
var props = opts.circle.geojson.features.map(function(f) {
268+
return f.properties['circle-opacity'];
269+
});
270+
271+
expect(props).toEqual(s.expected.props, msg0 + 'props');
272+
});
273+
});
274+
211275
it('should generate correct output for fill + markers + lines traces', function() {
212276
var opts = _convert(Lib.extendFlat({}, base, {
213277
mode: 'markers+lines',

‎test/jasmine/tests/select_test.js

+132
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ var fail = require('../assets/fail_test');
1010
var mouseEvent = require('../assets/mouse_event');
1111
var customMatchers = require('../assets/custom_matchers');
1212

13+
var LONG_TIMEOUT_INTERVAL = 5 * jasmine.DEFAULT_TIMEOUT_INTERVAL;
14+
1315

1416
describe('select box and lasso', function() {
1517
var mock = require('@mocks/14.json');
@@ -465,4 +467,134 @@ describe('select box and lasso', function() {
465467
.catch(fail)
466468
.then(done);
467469
});
470+
471+
it('@noCI should work on scattermapbox traces', function(done) {
472+
var fig = Lib.extendDeep({}, require('@mocks/mapbox_bubbles-text'));
473+
var gd = createGraphDiv();
474+
var eventData;
475+
476+
fig.layout.dragmode = 'select';
477+
fig.config = {
478+
mapboxAccessToken: require('@build/credentials.json').MAPBOX_ACCESS_TOKEN
479+
};
480+
481+
function assertPoints(expected) {
482+
var pts = eventData.points || [];
483+
484+
expect(pts.length).toBe(expected.length, 'selected points length');
485+
486+
pts.forEach(function(p, i) {
487+
var e = expected[i];
488+
expect(p.lon).toBe(e.lon, 'selected pt lon val');
489+
expect(p.lat).toBe(e.lat, 'selected pt lat val');
490+
});
491+
}
492+
493+
function assertRanges(expected) {
494+
var ranges = (eventData.range || {}).mapbox || [];
495+
expect(ranges).toBeCloseTo2DArray(expected, 1, 'select box range (in lon/lat)');
496+
}
497+
498+
function assertLassoPoints(expected) {
499+
var lassoPoints = (eventData.lassoPoints || {}).mapbox || [];
500+
expect(lassoPoints).toBeCloseTo2DArray(expected, 1, 'lasso points (in lon/lat)');
501+
}
502+
503+
Plotly.plot(gd, fig).then(function() {
504+
var selectingCnt = 0;
505+
var selectedCnt = 0;
506+
var deselectCnt = 0;
507+
508+
gd.once('plotly_selecting', function() {
509+
assertSelectionNodes(1, 2);
510+
selectingCnt++;
511+
});
512+
513+
gd.once('plotly_selected', function(d) {
514+
assertSelectionNodes(0, 2);
515+
selectedCnt++;
516+
eventData = d;
517+
});
518+
519+
gd.once('plotly_deselect', function() {
520+
deselectCnt++;
521+
assertSelectionNodes(0, 0);
522+
});
523+
524+
drag([[370, 120], [500, 200]]);
525+
assertPoints([{lon: 30, lat: 30}]);
526+
assertRanges([[21.99, 34.55], [38.14, 25.98]]);
527+
528+
return doubleClick(250, 200).then(function() {
529+
expect(selectingCnt).toBe(1, 'plotly_selecting call count');
530+
expect(selectedCnt).toBe(1, 'plotly_selected call count');
531+
expect(deselectCnt).toBe(1, 'plotly_deselect call count');
532+
});
533+
})
534+
.then(function() {
535+
return Plotly.relayout(gd, 'dragmode', 'lasso');
536+
})
537+
.then(function() {
538+
var selectingCnt = 0;
539+
var selectedCnt = 0;
540+
var deselectCnt = 0;
541+
542+
gd.once('plotly_selecting', function() {
543+
assertSelectionNodes(1, 2);
544+
selectingCnt++;
545+
});
546+
547+
gd.once('plotly_selected', function(d) {
548+
assertSelectionNodes(0, 2);
549+
selectedCnt++;
550+
eventData = d;
551+
});
552+
553+
gd.once('plotly_deselect', function() {
554+
deselectCnt++;
555+
assertSelectionNodes(0, 0);
556+
});
557+
558+
drag([[300, 200], [300, 300], [400, 300], [400, 200], [300, 200]]);
559+
assertPoints([{lon: 20, lat: 20}]);
560+
assertLassoPoints([
561+
[13.28, 25.97], [13.28, 14.33], [25.71, 14.33], [25.71, 25.97], [13.28, 25.97]
562+
]);
563+
564+
return doubleClick(250, 200).then(function() {
565+
expect(selectingCnt).toBe(1, 'plotly_selecting call count');
566+
expect(selectedCnt).toBe(1, 'plotly_selected call count');
567+
expect(deselectCnt).toBe(1, 'plotly_deselect call count');
568+
});
569+
})
570+
.then(function() {
571+
// make selection handlers don't get called in 'pan' dragmode
572+
return Plotly.relayout(gd, 'dragmode', 'pan');
573+
})
574+
.then(function() {
575+
var selectingCnt = 0;
576+
var selectedCnt = 0;
577+
var deselectCnt = 0;
578+
579+
gd.once('plotly_selecting', function() {
580+
selectingCnt++;
581+
});
582+
583+
gd.once('plotly_selected', function() {
584+
selectedCnt++;
585+
});
586+
587+
gd.once('plotly_deselect', function() {
588+
deselectCnt++;
589+
});
590+
591+
return doubleClick(250, 200).then(function() {
592+
expect(selectingCnt).toBe(0, 'plotly_selecting call count');
593+
expect(selectedCnt).toBe(0, 'plotly_selected call count');
594+
expect(deselectCnt).toBe(0, 'plotly_deselect call count');
595+
});
596+
})
597+
.catch(fail)
598+
.then(done);
599+
}, LONG_TIMEOUT_INTERVAL);
468600
});

0 commit comments

Comments
 (0)
Please sign in to comment.