diff --git a/package.json b/package.json index 49556953bed..303f6c4993e 100644 --- a/package.json +++ b/package.json @@ -93,6 +93,7 @@ "ndarray-fill": "^1.0.2", "ndarray-homography": "^1.0.0", "ndarray-ops": "^1.2.2", + "polybooljs": "^1.2.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..b28dd0d8aff 100644 --- a/src/lib/polygon.js +++ b/src/lib/polygon.js @@ -31,14 +31,17 @@ 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, ymin = pts[0][1], - ymax = ymin; + ymax = ymin, + i; pts.push(pts[0]); - for(var i = 1; i < pts.length; i++) { + for(i = 1; i < pts.length; i++) { xmin = Math.min(xmin, pts[i][0]); xmax = Math.max(xmax, pts[i][0]); ymin = Math.min(ymin, pts[i][1]); @@ -149,6 +152,16 @@ polygon.tester = function tester(ptsIn) { return crossings % 2 === 1; } + // detect if poly is degenerate + var degenerate = true; + var lastPt = pts[0]; + for(i = 1; i < pts.length; i++) { + if(lastPt[0] !== pts[i][0] || lastPt[1] !== pts[i][1]) { + degenerate = false; + break; + } + } + return { xmin: xmin, xmax: xmax, @@ -156,7 +169,52 @@ polygon.tester = function tester(ptsIn) { ymax: ymax, pts: pts, contains: isRect ? rectContains : contains, - isRect: isRect + isRect: isRect, + degenerate: degenerate + }; +}; + +/** + * 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]); + tester.subtract = list[i].subtract; + 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) { + var yes = false; + for(var i = 0; i < testers.length; i++) { + if(testers[i].contains(pt, arg)) { + // if contained by subtract polygon - exclude the point + yes = testers[i].subtract === false; + } + } + + return yes; + } + + return { + xmin: xmin, + xmax: xmax, + ymin: ymin, + ymax: ymax, + pts: [], + contains: contains, + isRect: false, + degenerate: false }; }; diff --git a/src/plots/cartesian/dragbox.js b/src/plots/cartesian/dragbox.js index 4bf2881191e..4a81f4a625f 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 @@ -526,6 +529,7 @@ module.exports = function dragBox(gd, plotinfo, x, y, w, h, ns, ew) { } updateSubplots([x0, y0, pw - dx, ph - dy]); + ticksAndAnnotations(yActive, xActive); } @@ -924,9 +928,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 98afbcd5e84..0f1a1f88d3e 100644 --- a/src/plots/cartesian/select.js +++ b/src/plots/cartesian/select.js @@ -9,6 +9,7 @@ 'use strict'; +var polybool = require('polybooljs'); var polygon = require('../../lib/polygon'); var throttle = require('../../lib/throttle'); var color = require('../../components/color'); @@ -19,6 +20,7 @@ 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; } @@ -39,10 +41,24 @@ 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, + subtract = e.altKey; + + + // take over selection polygons from prev mode, if any + if((e.shiftKey || e.altKey) && (plotinfo.selection && 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.altKey) || ((e.shiftKey || e.altKey) && !plotinfo.selection)) { + plotinfo.selection = {}; + plotinfo.selection.polygons = dragOptions.polygons = []; + plotinfo.selection.mergedPolygons = dragOptions.mergedPolygons = []; + } if(mode === 'lasso') { - pts = filteredPolygon([[x0, y0]], constants.BENDPX); + filterPoly = filteredPolygon([[x0, y0]], constants.BENDPX); } var outlines = zoomLayer.selectAll('path.select-outline').data([1, 2]); @@ -129,20 +145,18 @@ module.exports = function prepSelect(e, startX, startY, dragOptions, mode) { } }; } else { - fillRangeItems = function(eventData, poly, pts) { + fillRangeItems = function(eventData, currentPolygon, filterPoly) { var dataPts = eventData.lassoPoints = {}; for(i = 0; i < allAxes.length; i++) { var ax = allAxes[i]; - dataPts[ax._id] = pts.filtered.map(axValue(ax)); + dataPts[ax._id] = filterPoly.filtered.map(axValue(ax)); } }; } } dragOptions.moveFn = function(dx0, dy0) { - var poly; - x1 = Math.max(0, Math.min(pw, dx0 + x0)); y1 = Math.max(0, Math.min(ph, dy0 + y0)); @@ -152,46 +166,79 @@ 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]]; + currentPolygon.xmin = Math.min(x0, x1); + currentPolygon.xmax = Math.max(x0, x1); + currentPolygon.ymin = Math.min(0, ph); + currentPolygon.ymax = Math.max(0, ph); // extras to guide users in keeping a straight selection - corners.attr('d', 'M' + poly.xmin + ',' + (y0 - MINSELECT) + + corners.attr('d', 'M' + currentPolygon.xmin + ',' + (y0 - MINSELECT) + 'h-4v' + (2 * MINSELECT) + 'h4Z' + - 'M' + (poly.xmax - 1) + ',' + (y0 - MINSELECT) + + 'M' + (currentPolygon.xmax - 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]]; + currentPolygon.xmin = Math.min(0, pw); + currentPolygon.xmax = Math.max(0, pw); + currentPolygon.ymin = Math.min(y0, y1); + currentPolygon.ymax = Math.max(y0, y1); + corners.attr('d', 'M' + (x0 - MINSELECT) + ',' + currentPolygon.ymin + 'v-4h' + (2 * MINSELECT) + 'v4Z' + - 'M' + (x0 - MINSELECT) + ',' + (poly.ymax - 1) + + 'M' + (x0 - MINSELECT) + ',' + (currentPolygon.ymax - 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]]; + currentPolygon.xmin = Math.min(x0, x1); + currentPolygon.xmax = Math.max(x0, x1); + currentPolygon.ymin = Math.min(y0, y1); + currentPolygon.ymax = Math.max(y0, y1); 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 && dragOptions.polygons.length) { + mergedPolygons = mergePolygons(dragOptions.mergedPolygons, currentPolygon, subtract); + currentPolygon.subtract = subtract; + 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'); + throttle.throttle( throttleID, constants.SELECTDELAY, function() { selection = []; + + var traceSelections = [], traceSelection; for(i = 0; i < searchTraces.length; i++) { searchInfo = searchTraces[i]; + + traceSelection = searchInfo.selectPoints(searchInfo, testPoly); + traceSelections.push(traceSelection); + var thisSelection = fillSelectionItem( - searchInfo.selectPoints(searchInfo, poly), searchInfo + traceSelection, searchInfo ); if(selection.length) { for(var j = 0; j < thisSelection.length; j++) { @@ -202,7 +249,7 @@ module.exports = function prepSelect(e, startX, startY, dragOptions, mode) { } eventData = {points: selection}; - fillRangeItems(eventData, poly, pts); + fillRangeItems(eventData, currentPolygon, filterPoly); dragOptions.gd.emit('plotly_selecting', eventData); } ); @@ -210,6 +257,7 @@ module.exports = function prepSelect(e, startX, startY, dragOptions, mode) { dragOptions.doneFn = function(dragged, numclicks) { corners.remove(); + throttle.done(throttleID).then(function() { throttle.clear(throttleID); @@ -226,10 +274,46 @@ module.exports = function prepSelect(e, startX, startY, dragOptions, mode) { else { dragOptions.gd.emit('plotly_selected', eventData); } + + if(currentPolygon && dragOptions.polygons) { + // save last polygons + currentPolygon.subtract = subtract; + dragOptions.polygons.push(currentPolygon); + + // we have to keep reference to arrays container + dragOptions.mergedPolygons.length = 0; + [].push.apply(dragOptions.mergedPolygons, mergedPolygons); + } }); }; }; +function mergePolygons(list, poly, subtract) { + var res; + + if(subtract) { + res = polybool.difference({ + regions: list, + inverted: false + }, { + regions: [poly], + inverted: false + }); + + return res.regions; + } + + res = polybool.union({ + regions: list, + inverted: false + }, { + regions: [poly], + inverted: false + }); + + return res.regions; +} + function fillSelectionItem(selection, searchInfo) { if(Array.isArray(selection)) { var trace = searchInfo.cd[0].trace; diff --git a/test/jasmine/tests/select_test.js b/test/jasmine/tests/select_test.js index 3bf53a8337e..db16fe098a6 100644 --- a/test/jasmine/tests/select_test.js +++ b/test/jasmine/tests/select_test.js @@ -20,26 +20,26 @@ function drag(path, options) { Lib.clearThrottle(); if(options.type === 'touch') { - touchEvent('touchstart', path[0][0], path[0][1]); + touchEvent('touchstart', path[0][0], path[0][1], options); path.slice(1, len).forEach(function(pt) { Lib.clearThrottle(); - touchEvent('touchmove', pt[0], pt[1]); + touchEvent('touchmove', pt[0], pt[1], options); }); - touchEvent('touchend', path[len - 1][0], path[len - 1][1]); + touchEvent('touchend', path[len - 1][0], path[len - 1][1], options); return; } - mouseEvent('mousemove', path[0][0], path[0][1]); - mouseEvent('mousedown', path[0][0], path[0][1]); + mouseEvent('mousemove', path[0][0], path[0][1], options); + mouseEvent('mousedown', path[0][0], path[0][1], options); path.slice(1, len).forEach(function(pt) { Lib.clearThrottle(); - mouseEvent('mousemove', pt[0], pt[1]); + mouseEvent('mousemove', pt[0], pt[1], options); }); - mouseEvent('mouseup', path[len - 1][0], path[len - 1][1]); + mouseEvent('mouseup', path[len - 1][0], path[len - 1][1], options); } function assertSelectionNodes(cornerCnt, outlineCnt) { @@ -157,6 +157,43 @@ describe('Test select box and lasso in general:', function() { drag(selectPath); + selectedPromise.then(function() { + expect(selectedCnt).toBe(1, 'with the correct selected count'); + assertEventData(selectedData.points, [{ + curveNumber: 0, + pointNumber: 0, + x: 0.002, + y: 16.25, + id: 'id-0.002', + customdata: 'customdata-16.25' + }, { + curveNumber: 0, + pointNumber: 1, + x: 0.004, + y: 12.5, + id: 'id-0.004', + customdata: 'customdata-12.5' + }], 'with the correct selected points (2)'); + assertRange(selectedData.range, { + x: [0.002000, 0.0046236], + y: [0.10209191961595454, 24.512223978291406] + }, 'with the correct selected range'); + + return doubleClick(250, 200); + }) + .then(deselectPromise) + .then(function() { + expect(doubleClickData).toBe(null, 'with the correct deselect data'); + }) + .catch(fail) + .then(done); + }); + + it('should handle add/sub selection', function(done) { + resetEvents(gd); + + drag(selectPath); + selectedPromise.then(function() { expect(selectingCnt).toBe(1, 'with the correct selecting count'); assertEventData(selectingData.points, [{ @@ -178,8 +215,14 @@ describe('Test select box and lasso in general:', function() { x: [0.002000, 0.0046236], y: [0.10209191961595454, 24.512223978291406] }, 'with the correct selecting range'); - expect(selectedCnt).toBe(1, 'with the correct selected count'); - assertEventData(selectedData.points, [{ + }) + .then(function() { + // add selection + drag([[193, 193], [213, 193]], {shiftKey: true}); + }) + .then(function() { + expect(selectingCnt).toBe(2, 'with the correct selecting count'); + assertEventData(selectingData.points, [{ curveNumber: 0, pointNumber: 0, x: 0.002, @@ -193,15 +236,37 @@ describe('Test select box and lasso in general:', function() { y: 12.5, id: 'id-0.004', customdata: 'customdata-12.5' - }], 'with the correct selected points (2)'); - assertRange(selectedData.range, { - x: [0.002000, 0.0046236], - y: [0.10209191961595454, 24.512223978291406] - }, 'with the correct selected range'); + }, { + curveNumber: 0, + pointNumber: 4, + x: 0.013, + y: 6.875, + id: 'id-0.013', + customdata: 'customdata-6.875' + }], 'with the correct selecting points (1)'); + }) + .then(function() { + // sub selection + drag([[219, 143], [219, 183]], {altKey: true}); + }).then(function() { + assertEventData(selectingData.points, [{ + curveNumber: 0, + pointNumber: 0, + x: 0.002, + y: 16.25, + id: 'id-0.002', + customdata: 'customdata-16.25' + }, { + curveNumber: 0, + pointNumber: 1, + x: 0.004, + y: 12.5, + id: 'id-0.004', + customdata: 'customdata-12.5' + }], 'with the correct selecting points (1)'); return doubleClick(250, 200); }) - .then(deselectPromise) .then(function() { expect(doubleClickData).toBe(null, 'with the correct deselect data'); })