Skip to content

Commit 2656167

Browse files
authored
Merge pull request #2140 from plotly/multiselect
Add multiselect
2 parents f49b18e + ab92fd2 commit 2656167

File tree

5 files changed

+253
-43
lines changed

5 files changed

+253
-43
lines changed

Diff for: package.json

+1
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@
9393
"ndarray-fill": "^1.0.2",
9494
"ndarray-homography": "^1.0.0",
9595
"ndarray-ops": "^1.2.2",
96+
"polybooljs": "^1.2.0",
9697
"regl": "^1.3.0",
9798
"right-now": "^1.0.0",
9899
"robust-orientation": "^1.1.3",

Diff for: src/lib/polygon.js

+61-3
Original file line numberDiff line numberDiff line change
@@ -31,14 +31,17 @@ var polygon = module.exports = {};
3131
* returns boolean: is pt inside the polygon (including on its edges)
3232
*/
3333
polygon.tester = function tester(ptsIn) {
34+
if(Array.isArray(ptsIn[0][0])) return polygon.multitester(ptsIn);
35+
3436
var pts = ptsIn.slice(),
3537
xmin = pts[0][0],
3638
xmax = xmin,
3739
ymin = pts[0][1],
38-
ymax = ymin;
40+
ymax = ymin,
41+
i;
3942

4043
pts.push(pts[0]);
41-
for(var i = 1; i < pts.length; i++) {
44+
for(i = 1; i < pts.length; i++) {
4245
xmin = Math.min(xmin, pts[i][0]);
4346
xmax = Math.max(xmax, pts[i][0]);
4447
ymin = Math.min(ymin, pts[i][1]);
@@ -149,14 +152,69 @@ polygon.tester = function tester(ptsIn) {
149152
return crossings % 2 === 1;
150153
}
151154

155+
// detect if poly is degenerate
156+
var degenerate = true;
157+
var lastPt = pts[0];
158+
for(i = 1; i < pts.length; i++) {
159+
if(lastPt[0] !== pts[i][0] || lastPt[1] !== pts[i][1]) {
160+
degenerate = false;
161+
break;
162+
}
163+
}
164+
152165
return {
153166
xmin: xmin,
154167
xmax: xmax,
155168
ymin: ymin,
156169
ymax: ymax,
157170
pts: pts,
158171
contains: isRect ? rectContains : contains,
159-
isRect: isRect
172+
isRect: isRect,
173+
degenerate: degenerate
174+
};
175+
};
176+
177+
/**
178+
* Test multiple polygons
179+
*/
180+
polygon.multitester = function multitester(list) {
181+
var testers = [],
182+
xmin = list[0][0][0],
183+
xmax = xmin,
184+
ymin = list[0][0][1],
185+
ymax = ymin;
186+
187+
for(var i = 0; i < list.length; i++) {
188+
var tester = polygon.tester(list[i]);
189+
tester.subtract = list[i].subtract;
190+
testers.push(tester);
191+
xmin = Math.min(xmin, tester.xmin);
192+
xmax = Math.max(xmax, tester.xmax);
193+
ymin = Math.min(ymin, tester.ymin);
194+
ymax = Math.max(ymax, tester.ymax);
195+
}
196+
197+
function contains(pt, arg) {
198+
var yes = false;
199+
for(var i = 0; i < testers.length; i++) {
200+
if(testers[i].contains(pt, arg)) {
201+
// if contained by subtract polygon - exclude the point
202+
yes = testers[i].subtract === false;
203+
}
204+
}
205+
206+
return yes;
207+
}
208+
209+
return {
210+
xmin: xmin,
211+
xmax: xmax,
212+
ymin: ymin,
213+
ymax: ymax,
214+
pts: [],
215+
contains: contains,
216+
isRect: false,
217+
degenerate: false
160218
};
161219
};
162220

Diff for: src/plots/cartesian/dragbox.js

+6-4
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,10 @@ module.exports = function dragBox(gd, plotinfo, x, y, w, h, ns, ew) {
140140
// to pan (or to zoom if it already is pan) on shift
141141
if(e.shiftKey) {
142142
if(dragModeNow === 'pan') dragModeNow = 'zoom';
143-
else dragModeNow = 'pan';
143+
else if(!isSelectOrLasso(dragModeNow)) dragModeNow = 'pan';
144+
}
145+
else if(e.ctrlKey) {
146+
dragModeNow = 'pan';
144147
}
145148
}
146149
// all other draggers just pan
@@ -526,6 +529,7 @@ module.exports = function dragBox(gd, plotinfo, x, y, w, h, ns, ew) {
526529
}
527530

528531
updateSubplots([x0, y0, pw - dx, ph - dy]);
532+
529533
ticksAndAnnotations(yActive, xActive);
530534
}
531535

@@ -924,9 +928,7 @@ function removeZoombox(gd) {
924928
}
925929

926930
function isSelectOrLasso(dragmode) {
927-
var modes = ['lasso', 'select'];
928-
929-
return modes.indexOf(dragmode) !== -1;
931+
return dragmode === 'lasso' || dragmode === 'select';
930932
}
931933

932934
function xCorners(box, y0) {

Diff for: src/plots/cartesian/select.js

+105-21
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
'use strict';
1111

12+
var polybool = require('polybooljs');
1213
var polygon = require('../../lib/polygon');
1314
var throttle = require('../../lib/throttle');
1415
var color = require('../../components/color');
@@ -19,6 +20,7 @@ var constants = require('./constants');
1920

2021
var filteredPolygon = polygon.filter;
2122
var polygonTester = polygon.tester;
23+
var multipolygonTester = polygon.multitester;
2224
var MINSELECT = constants.MINSELECT;
2325

2426
function getAxId(ax) { return ax._id; }
@@ -39,10 +41,24 @@ module.exports = function prepSelect(e, startX, startY, dragOptions, mode) {
3941
xAxisIds = dragOptions.xaxes.map(getAxId),
4042
yAxisIds = dragOptions.yaxes.map(getAxId),
4143
allAxes = dragOptions.xaxes.concat(dragOptions.yaxes),
42-
pts;
44+
filterPoly, testPoly, mergedPolygons, currentPolygon,
45+
subtract = e.altKey;
46+
47+
48+
// take over selection polygons from prev mode, if any
49+
if((e.shiftKey || e.altKey) && (plotinfo.selection && plotinfo.selection.polygons) && !dragOptions.polygons) {
50+
dragOptions.polygons = plotinfo.selection.polygons;
51+
dragOptions.mergedPolygons = plotinfo.selection.mergedPolygons;
52+
}
53+
// create new polygons, if shift mode
54+
else if((!e.shiftKey && !e.altKey) || ((e.shiftKey || e.altKey) && !plotinfo.selection)) {
55+
plotinfo.selection = {};
56+
plotinfo.selection.polygons = dragOptions.polygons = [];
57+
plotinfo.selection.mergedPolygons = dragOptions.mergedPolygons = [];
58+
}
4359

4460
if(mode === 'lasso') {
45-
pts = filteredPolygon([[x0, y0]], constants.BENDPX);
61+
filterPoly = filteredPolygon([[x0, y0]], constants.BENDPX);
4662
}
4763

4864
var outlines = zoomLayer.selectAll('path.select-outline').data([1, 2]);
@@ -129,20 +145,18 @@ module.exports = function prepSelect(e, startX, startY, dragOptions, mode) {
129145
}
130146
};
131147
} else {
132-
fillRangeItems = function(eventData, poly, pts) {
148+
fillRangeItems = function(eventData, currentPolygon, filterPoly) {
133149
var dataPts = eventData.lassoPoints = {};
134150

135151
for(i = 0; i < allAxes.length; i++) {
136152
var ax = allAxes[i];
137-
dataPts[ax._id] = pts.filtered.map(axValue(ax));
153+
dataPts[ax._id] = filterPoly.filtered.map(axValue(ax));
138154
}
139155
};
140156
}
141157
}
142158

143159
dragOptions.moveFn = function(dx0, dy0) {
144-
var poly;
145-
146160
x1 = Math.max(0, Math.min(pw, dx0 + x0));
147161
y1 = Math.max(0, Math.min(ph, dy0 + y0));
148162

@@ -152,46 +166,79 @@ module.exports = function prepSelect(e, startX, startY, dragOptions, mode) {
152166
if(mode === 'select') {
153167
if(dy < Math.min(dx * 0.6, MINSELECT)) {
154168
// horizontal motion: make a vertical box
155-
poly = polygonTester([[x0, 0], [x0, ph], [x1, ph], [x1, 0]]);
169+
currentPolygon = [[x0, 0], [x0, ph], [x1, ph], [x1, 0]];
170+
currentPolygon.xmin = Math.min(x0, x1);
171+
currentPolygon.xmax = Math.max(x0, x1);
172+
currentPolygon.ymin = Math.min(0, ph);
173+
currentPolygon.ymax = Math.max(0, ph);
156174
// extras to guide users in keeping a straight selection
157-
corners.attr('d', 'M' + poly.xmin + ',' + (y0 - MINSELECT) +
175+
corners.attr('d', 'M' + currentPolygon.xmin + ',' + (y0 - MINSELECT) +
158176
'h-4v' + (2 * MINSELECT) + 'h4Z' +
159-
'M' + (poly.xmax - 1) + ',' + (y0 - MINSELECT) +
177+
'M' + (currentPolygon.xmax - 1) + ',' + (y0 - MINSELECT) +
160178
'h4v' + (2 * MINSELECT) + 'h-4Z');
161179

162180
}
163181
else if(dx < Math.min(dy * 0.6, MINSELECT)) {
164182
// vertical motion: make a horizontal box
165-
poly = polygonTester([[0, y0], [0, y1], [pw, y1], [pw, y0]]);
166-
corners.attr('d', 'M' + (x0 - MINSELECT) + ',' + poly.ymin +
183+
currentPolygon = [[0, y0], [0, y1], [pw, y1], [pw, y0]];
184+
currentPolygon.xmin = Math.min(0, pw);
185+
currentPolygon.xmax = Math.max(0, pw);
186+
currentPolygon.ymin = Math.min(y0, y1);
187+
currentPolygon.ymax = Math.max(y0, y1);
188+
corners.attr('d', 'M' + (x0 - MINSELECT) + ',' + currentPolygon.ymin +
167189
'v-4h' + (2 * MINSELECT) + 'v4Z' +
168-
'M' + (x0 - MINSELECT) + ',' + (poly.ymax - 1) +
190+
'M' + (x0 - MINSELECT) + ',' + (currentPolygon.ymax - 1) +
169191
'v4h' + (2 * MINSELECT) + 'v-4Z');
170192
}
171193
else {
172194
// diagonal motion
173-
poly = polygonTester([[x0, y0], [x0, y1], [x1, y1], [x1, y0]]);
195+
currentPolygon = [[x0, y0], [x0, y1], [x1, y1], [x1, y0]];
196+
currentPolygon.xmin = Math.min(x0, x1);
197+
currentPolygon.xmax = Math.max(x0, x1);
198+
currentPolygon.ymin = Math.min(y0, y1);
199+
currentPolygon.ymax = Math.max(y0, y1);
174200
corners.attr('d', 'M0,0Z');
175201
}
176-
outlines.attr('d', 'M' + poly.xmin + ',' + poly.ymin +
177-
'H' + (poly.xmax - 1) + 'V' + (poly.ymax - 1) +
178-
'H' + poly.xmin + 'Z');
179202
}
180203
else if(mode === 'lasso') {
181-
pts.addPt([x1, y1]);
182-
poly = polygonTester(pts.filtered);
183-
outlines.attr('d', 'M' + pts.filtered.join('L') + 'Z');
204+
filterPoly.addPt([x1, y1]);
205+
currentPolygon = filterPoly.filtered;
184206
}
185207

208+
// create outline & tester
209+
if(dragOptions.polygons && dragOptions.polygons.length) {
210+
mergedPolygons = mergePolygons(dragOptions.mergedPolygons, currentPolygon, subtract);
211+
currentPolygon.subtract = subtract;
212+
testPoly = multipolygonTester(dragOptions.polygons.concat([currentPolygon]));
213+
}
214+
else {
215+
mergedPolygons = [currentPolygon];
216+
testPoly = polygonTester(currentPolygon);
217+
}
218+
219+
// draw selection
220+
var paths = [];
221+
for(i = 0; i < mergedPolygons.length; i++) {
222+
var ppts = mergedPolygons[i];
223+
paths.push(ppts.join('L') + 'L' + ppts[0]);
224+
}
225+
outlines.attr('d', 'M' + paths.join('M') + 'Z');
226+
186227
throttle.throttle(
187228
throttleID,
188229
constants.SELECTDELAY,
189230
function() {
190231
selection = [];
232+
233+
var traceSelections = [], traceSelection;
191234
for(i = 0; i < searchTraces.length; i++) {
192235
searchInfo = searchTraces[i];
236+
237+
traceSelection = searchInfo.selectPoints(searchInfo, testPoly);
238+
traceSelections.push(traceSelection);
239+
193240
var thisSelection = fillSelectionItem(
194-
searchInfo.selectPoints(searchInfo, poly), searchInfo
241+
traceSelection, searchInfo
195242
);
196243
if(selection.length) {
197244
for(var j = 0; j < thisSelection.length; j++) {
@@ -202,14 +249,15 @@ module.exports = function prepSelect(e, startX, startY, dragOptions, mode) {
202249
}
203250

204251
eventData = {points: selection};
205-
fillRangeItems(eventData, poly, pts);
252+
fillRangeItems(eventData, currentPolygon, filterPoly);
206253
dragOptions.gd.emit('plotly_selecting', eventData);
207254
}
208255
);
209256
};
210257

211258
dragOptions.doneFn = function(dragged, numclicks) {
212259
corners.remove();
260+
213261
throttle.done(throttleID).then(function() {
214262
throttle.clear(throttleID);
215263

@@ -226,10 +274,46 @@ module.exports = function prepSelect(e, startX, startY, dragOptions, mode) {
226274
else {
227275
dragOptions.gd.emit('plotly_selected', eventData);
228276
}
277+
278+
if(currentPolygon && dragOptions.polygons) {
279+
// save last polygons
280+
currentPolygon.subtract = subtract;
281+
dragOptions.polygons.push(currentPolygon);
282+
283+
// we have to keep reference to arrays container
284+
dragOptions.mergedPolygons.length = 0;
285+
[].push.apply(dragOptions.mergedPolygons, mergedPolygons);
286+
}
229287
});
230288
};
231289
};
232290

291+
function mergePolygons(list, poly, subtract) {
292+
var res;
293+
294+
if(subtract) {
295+
res = polybool.difference({
296+
regions: list,
297+
inverted: false
298+
}, {
299+
regions: [poly],
300+
inverted: false
301+
});
302+
303+
return res.regions;
304+
}
305+
306+
res = polybool.union({
307+
regions: list,
308+
inverted: false
309+
}, {
310+
regions: [poly],
311+
inverted: false
312+
});
313+
314+
return res.regions;
315+
}
316+
233317
function fillSelectionItem(selection, searchInfo) {
234318
if(Array.isArray(selection)) {
235319
var trace = searchInfo.cd[0].trace;

0 commit comments

Comments
 (0)