diff --git a/package.json b/package.json index a7186b1db76..ed1a21f4a02 100644 --- a/package.json +++ b/package.json @@ -92,6 +92,7 @@ "ndarray-fill": "^1.0.2", "ndarray-homography": "^1.0.0", "ndarray-ops": "^1.2.2", + "poly-bool": "^1.0.0", "regl": "^1.3.0", "right-now": "^1.0.0", "robust-orientation": "^1.1.3", diff --git a/src/lib/polygon.js b/src/lib/polygon.js index d30d1fda104..f358e81dd66 100644 --- a/src/lib/polygon.js +++ b/src/lib/polygon.js @@ -31,6 +31,8 @@ var polygon = module.exports = {}; * returns boolean: is pt inside the polygon (including on its edges) */ polygon.tester = function tester(ptsIn) { + if(Array.isArray(ptsIn[0][0])) return polygon.multitester(ptsIn); + var pts = ptsIn.slice(), xmin = pts[0][0], xmax = xmin, @@ -160,6 +162,43 @@ polygon.tester = function tester(ptsIn) { }; }; +/** + * Test multiple polygons + */ +polygon.multitester = function multitester(list) { + var testers = [], + xmin = list[0][0][0], + xmax = xmin, + ymin = list[0][0][1], + ymax = ymin; + + for(var i = 0; i < list.length; i++) { + var tester = polygon.tester(list[i]); + testers.push(tester); + xmin = Math.min(xmin, tester.xmin); + xmax = Math.max(xmax, tester.xmax); + ymin = Math.min(ymin, tester.ymin); + ymax = Math.max(ymax, tester.ymax); + } + + function contains(pt, arg) { + for(var i = 0; i < testers.length; i++) { + if(testers[i].contains(pt, arg)) return true; + } + return false; + } + + return { + xmin: xmin, + xmax: xmax, + ymin: ymin, + ymax: ymax, + pts: [], + contains: contains, + isRect: false + }; +}; + /** * Test if a segment of a points array is bent or straight * diff --git a/src/plots/cartesian/dragbox.js b/src/plots/cartesian/dragbox.js index 603e88d2f78..ab27daacbb0 100644 --- a/src/plots/cartesian/dragbox.js +++ b/src/plots/cartesian/dragbox.js @@ -140,7 +140,10 @@ module.exports = function dragBox(gd, plotinfo, x, y, w, h, ns, ew) { // to pan (or to zoom if it already is pan) on shift if(e.shiftKey) { if(dragModeNow === 'pan') dragModeNow = 'zoom'; - else dragModeNow = 'pan'; + else if(!isSelectOrLasso(dragModeNow)) dragModeNow = 'pan'; + } + else if(e.ctrlKey) { + dragModeNow = 'pan'; } } // all other draggers just pan @@ -168,6 +171,17 @@ module.exports = function dragBox(gd, plotinfo, x, y, w, h, ns, ew) { else if(isSelectOrLasso(dragModeNow)) { dragOptions.xaxes = xa; dragOptions.yaxes = ya; + // take over selection polygons from prev mode, if any + if(e.shiftKey && plotinfo.selection.polygons && !dragOptions.polygons) { + dragOptions.polygons = plotinfo.selection.polygons; + dragOptions.mergedPolygons = plotinfo.selection.mergedPolygons; + } + // create new polygons, if shift mode + else if(!e.shiftKey || (e.shiftKey && !plotinfo.selection.polygons)) { + plotinfo.selection = {}; + plotinfo.selection.polygons = dragOptions.polygons = []; + dragOptions.mergedPolygons = plotinfo.selection.mergedPolygons = []; + } prepSelect(e, startX, startY, dragOptions, dragModeNow); } } @@ -175,6 +189,11 @@ module.exports = function dragBox(gd, plotinfo, x, y, w, h, ns, ew) { dragElement.init(dragOptions); + // FIXME: this hack highlights selection once we enter select/lasso mode + if(isSelectOrLasso(gd._fullLayout.dragmode) && plotinfo.selection) { + showSelect(zoomlayer, dragOptions); + } + var x0, y0, box, @@ -899,6 +918,29 @@ function clearSelect(zoomlayer) { zoomlayer.selectAll('.select-outline').remove(); } +function showSelect(zoomlayer, dragOptions) { + var outlines = zoomlayer.selectAll('path.select-outline').data([1, 2]), + plotinfo = dragOptions.plotinfo, + xaxis = plotinfo.xaxis, + yaxis = plotinfo.yaxis, + selection = plotinfo.selection, + polygons = selection.mergedPolygons, + xs = xaxis._offset, + ys = yaxis._offset, + paths = []; + + for(var i = 0; i < polygons.length; i++) { + var ppts = polygons[i]; + paths.push(ppts.join('L') + 'L' + ppts[0]); + } + + outlines.enter() + .append('path') + .attr('class', function(d) { return 'select-outline select-outline-' + d; }) + .attr('transform', 'translate(' + xs + ', ' + ys + ')') + .attr('d', 'M' + paths.join('M') + 'Z'); +} + function updateZoombox(zb, corners, box, path0, dimmed, lum) { zb.attr('d', path0 + 'M' + (box.l) + ',' + (box.t) + 'v' + (box.h) + @@ -921,9 +963,7 @@ function removeZoombox(gd) { } function isSelectOrLasso(dragmode) { - var modes = ['lasso', 'select']; - - return modes.indexOf(dragmode) !== -1; + return dragmode === 'lasso' || dragmode === 'select'; } function xCorners(box, y0) { diff --git a/src/plots/cartesian/select.js b/src/plots/cartesian/select.js index cba5decac75..c39ff9e05f9 100644 --- a/src/plots/cartesian/select.js +++ b/src/plots/cartesian/select.js @@ -9,6 +9,7 @@ 'use strict'; +var polybool = require('poly-bool'); var polygon = require('../../lib/polygon'); var color = require('../../components/color'); var appendArrayPointValue = require('../../components/fx/helpers').appendArrayPointValue; @@ -18,12 +19,16 @@ var constants = require('./constants'); var filteredPolygon = polygon.filter; var polygonTester = polygon.tester; +var multipolygonTester = polygon.multitester; var MINSELECT = constants.MINSELECT; function getAxId(ax) { return ax._id; } -module.exports = function prepSelect(e, startX, startY, dragOptions, mode) { - var plot = dragOptions.gd._fullLayout._zoomlayer, + +module.exports = prepSelect; + +function prepSelect(e, startX, startY, dragOptions, mode) { + var zoomlayer = dragOptions.gd._fullLayout._zoomlayer, dragBBox = dragOptions.element.getBoundingClientRect(), xs = dragOptions.plotinfo.xaxis._offset, ys = dragOptions.plotinfo.yaxis._offset, @@ -37,13 +42,13 @@ module.exports = function prepSelect(e, startX, startY, dragOptions, mode) { xAxisIds = dragOptions.xaxes.map(getAxId), yAxisIds = dragOptions.yaxes.map(getAxId), allAxes = dragOptions.xaxes.concat(dragOptions.yaxes), - pts; + filterPoly, testPoly, mergedPolygons, currentPolygon; if(mode === 'lasso') { - pts = filteredPolygon([[x0, y0]], constants.BENDPX); + filterPoly = filteredPolygon([[x0, y0]], constants.BENDPX); } - var outlines = plot.selectAll('path.select-outline').data([1, 2]); + var outlines = zoomlayer.selectAll('path.select-outline').data([1, 2]); outlines.enter() .append('path') @@ -51,7 +56,7 @@ module.exports = function prepSelect(e, startX, startY, dragOptions, mode) { .attr('transform', 'translate(' + xs + ', ' + ys + ')') .attr('d', path0 + 'Z'); - var corners = plot.append('path') + var corners = zoomlayer.append('path') .attr('class', 'zoombox-corners') .style({ fill: color.background, @@ -107,8 +112,7 @@ module.exports = function prepSelect(e, startX, startY, dragOptions, mode) { function ascending(a, b) { return a - b; } dragOptions.moveFn = function(dx0, dy0) { - var poly, - ax; + var ax, i; x1 = Math.max(0, Math.min(pw, dx0 + x0)); y1 = Math.max(0, Math.min(ph, dy0 + y0)); @@ -118,42 +122,57 @@ module.exports = function prepSelect(e, startX, startY, dragOptions, mode) { if(mode === 'select') { if(dy < Math.min(dx * 0.6, MINSELECT)) { // horizontal motion: make a vertical box - poly = polygonTester([[x0, 0], [x0, ph], [x1, ph], [x1, 0]]); + currentPolygon = [[x0, 0], [x0, ph], [x1, ph], [x1, 0]]; // extras to guide users in keeping a straight selection - corners.attr('d', 'M' + poly.xmin + ',' + (y0 - MINSELECT) + + corners.attr('d', 'M' + Math.min(x0, x1) + ',' + (y0 - MINSELECT) + 'h-4v' + (2 * MINSELECT) + 'h4Z' + - 'M' + (poly.xmax - 1) + ',' + (y0 - MINSELECT) + + 'M' + (Math.max(x0, x1) - 1) + ',' + (y0 - MINSELECT) + 'h4v' + (2 * MINSELECT) + 'h-4Z'); } else if(dx < Math.min(dy * 0.6, MINSELECT)) { // vertical motion: make a horizontal box - poly = polygonTester([[0, y0], [0, y1], [pw, y1], [pw, y0]]); - corners.attr('d', 'M' + (x0 - MINSELECT) + ',' + poly.ymin + + currentPolygon = [[0, y0], [0, y1], [pw, y1], [pw, y0]]; + corners.attr('d', 'M' + (x0 - MINSELECT) + ',' + Math.min(y0, y1) + 'v-4h' + (2 * MINSELECT) + 'v4Z' + - 'M' + (x0 - MINSELECT) + ',' + (poly.ymax - 1) + + 'M' + (x0 - MINSELECT) + ',' + (Math.max(y0, y1) - 1) + 'v4h' + (2 * MINSELECT) + 'v-4Z'); } else { // diagonal motion - poly = polygonTester([[x0, y0], [x0, y1], [x1, y1], [x1, y0]]); + currentPolygon = [[x0, y0], [x0, y1], [x1, y1], [x1, y0]]; corners.attr('d', 'M0,0Z'); } - outlines.attr('d', 'M' + poly.xmin + ',' + poly.ymin + - 'H' + (poly.xmax - 1) + 'V' + (poly.ymax - 1) + - 'H' + poly.xmin + 'Z'); } else if(mode === 'lasso') { - pts.addPt([x1, y1]); - poly = polygonTester(pts.filtered); - outlines.attr('d', 'M' + pts.filtered.join('L') + 'Z'); + filterPoly.addPt([x1, y1]); + currentPolygon = filterPoly.filtered; + } + + // create outline & tester + if(dragOptions.polygons.length) { + mergedPolygons = polybool(dragOptions.mergedPolygons, [currentPolygon], 'or'); + testPoly = multipolygonTester(dragOptions.polygons.concat([currentPolygon])); + } + else { + mergedPolygons = [currentPolygon]; + testPoly = polygonTester(currentPolygon); } + // draw selection + var paths = []; + for(i = 0; i < mergedPolygons.length; i++) { + var ppts = mergedPolygons[i]; + paths.push(ppts.join('L') + 'L' + ppts[0]); + } + outlines.attr('d', 'M' + paths.join('M') + 'Z'); + + // select points selection = []; for(i = 0; i < searchTraces.length; i++) { searchInfo = searchTraces[i]; [].push.apply(selection, fillSelectionItem( - searchInfo.selectPoints(searchInfo, poly), searchInfo + searchInfo.selectPoints(searchInfo, testPoly), searchInfo )); } @@ -167,8 +186,8 @@ module.exports = function prepSelect(e, startX, startY, dragOptions, mode) { ax = allAxes[i]; axLetter = ax._id.charAt(0); ranges[ax._id] = [ - ax.p2d(poly[axLetter + 'min']), - ax.p2d(poly[axLetter + 'max'])].sort(ascending); + ax.p2d(testPoly[axLetter + 'min']), + ax.p2d(testPoly[axLetter + 'max'])].sort(ascending); } } else { @@ -176,7 +195,7 @@ module.exports = function prepSelect(e, startX, startY, dragOptions, mode) { for(i = 0; i < allAxes.length; i++) { ax = allAxes[i]; - dataPts[ax._id] = pts.filtered.map(axValue(ax)); + dataPts[ax._id] = filterPoly.filtered.map(axValue(ax)); } } dragOptions.gd.emit('plotly_selecting', eventData); @@ -197,8 +216,14 @@ module.exports = function prepSelect(e, startX, startY, dragOptions, mode) { else { dragOptions.gd.emit('plotly_selected', eventData); } + + // save last polygons + dragOptions.polygons.push(currentPolygon); + + // we have to keep reference to arrays, therefore just replace items + dragOptions.mergedPolygons.splice.apply(dragOptions.mergedPolygons, [0, dragOptions.mergedPolygons.length].concat(mergedPolygons)); }; -}; +} function fillSelectionItem(selection, searchInfo) { if(Array.isArray(selection)) {