diff --git a/src/components/fx/layout_attributes.js b/src/components/fx/layout_attributes.js index 4c35c2b4086..2429a5541f5 100644 --- a/src/components/fx/layout_attributes.js +++ b/src/components/fx/layout_attributes.js @@ -18,6 +18,29 @@ fontAttrs.family.dflt = constants.HOVERFONT; fontAttrs.size.dflt = constants.HOVERFONTSIZE; module.exports = { + clickmode: { + valType: 'flaglist', + role: 'info', + flags: ['event', 'select'], + dflt: 'event', + editType: 'plot', + extras: ['none'], + description: [ + 'Determines the mode of single click interactions.', + '*event* is the default value and emits the `plotly_click`', + 'event. In addition this mode emits the `plotly_selected` event', + 'in drag modes *lasso* and *select*, but with no event data attached', + '(kept for compatibility reasons).', + 'The *select* flag enables selecting single', + 'data points via click. This mode also supports persistent selections,', + 'meaning that pressing Shift while clicking, adds to / subtracts from an', + 'existing selection. *select* with `hovermode`: *x* can be confusing, consider', + 'explicitly setting `hovermode`: *closest* when using this feature.', + 'Selection events are sent accordingly as long as *event* flag is set as well.', + 'When the *event* flag is missing, `plotly_click` and `plotly_selected`', + 'events are not fired.' + ].join(' ') + }, dragmode: { valType: 'enumerated', role: 'info', @@ -36,7 +59,16 @@ module.exports = { role: 'info', values: ['x', 'y', 'closest', false], editType: 'modebar', - description: 'Determines the mode of hover interactions.' + description: [ + 'Determines the mode of hover interactions.', + 'If `clickmode` includes the *select* flag,', + '`hovermode` defaults to *closest*.', + 'If `clickmode` lacks the *select* flag,', + 'it defaults to *x* or *y* (depending on the trace\'s', + '`orientation` value) for plots based on', + 'cartesian coordinates. For anything else the default', + 'value is *closest*.', + ].join(' ') }, hoverdistance: { valType: 'integer', diff --git a/src/components/fx/layout_defaults.js b/src/components/fx/layout_defaults.js index 742c6eb1621..5f7372d6edf 100644 --- a/src/components/fx/layout_defaults.js +++ b/src/components/fx/layout_defaults.js @@ -16,15 +16,21 @@ module.exports = function supplyLayoutDefaults(layoutIn, layoutOut, fullData) { return Lib.coerce(layoutIn, layoutOut, layoutAttributes, attr, dflt); } + var clickmode = coerce('clickmode'); + var dragMode = coerce('dragmode'); if(dragMode === 'select') coerce('selectdirection'); var hovermodeDflt; if(layoutOut._has('cartesian')) { - // flag for 'horizontal' plots: - // determines the state of the mode bar 'compare' hovermode button - layoutOut._isHoriz = isHoriz(fullData); - hovermodeDflt = layoutOut._isHoriz ? 'y' : 'x'; + if(clickmode.indexOf('select') > -1) { + hovermodeDflt = 'closest'; + } else { + // flag for 'horizontal' plots: + // determines the state of the mode bar 'compare' hovermode button + layoutOut._isHoriz = isHoriz(fullData); + hovermodeDflt = layoutOut._isHoriz ? 'y' : 'x'; + } } else hovermodeDflt = 'closest'; diff --git a/src/lib/polygon.js b/src/lib/polygon.js index d84583e696e..fb07a677af6 100644 --- a/src/lib/polygon.js +++ b/src/lib/polygon.js @@ -31,8 +31,6 @@ 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, @@ -174,50 +172,6 @@ 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]); - 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 - }; -}; - /** * 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 8d66a4cb4a5..bdd180b17c4 100644 --- a/src/plots/cartesian/dragbox.js +++ b/src/plots/cartesian/dragbox.js @@ -30,6 +30,7 @@ var doTicksSingle = require('./axes').doTicksSingle; var getFromId = require('./axis_ids').getFromId; var prepSelect = require('./select').prepSelect; var clearSelect = require('./select').clearSelect; +var selectOnClick = require('./select').selectOnClick; var scaleZoom = require('./scale_zoom'); var constants = require('./constants'); @@ -148,7 +149,11 @@ function makeDragBox(gd, plotinfo, x, y, w, h, ns, ew) { }; dragOptions.prepFn = function(e, startX, startY) { + var dragModePrev = dragOptions.dragmode; var dragModeNow = gd._fullLayout.dragmode; + if(dragModeNow !== dragModePrev) { + dragOptions.dragmode = dragModeNow; + } recomputeAxisLists(); @@ -178,7 +183,19 @@ function makeDragBox(gd, plotinfo, x, y, w, h, ns, ew) { prepSelect(e, startX, startY, dragOptions, dragModeNow); } else { dragOptions.clickFn = clickFn; - clearAndResetSelect(); + if(isSelectOrLasso(dragModePrev)) { + // TODO Fix potential bug + // Note: clearing / resetting selection state only happens, when user + // triggers at least one interaction in pan/zoom mode. Otherwise, the + // select/lasso outlines are deleted (in plots.js.cleanPlot) but the selection + // cache isn't cleared. So when the user switches back to select/lasso and + // 'adds to a selection' with Shift, the "old", seemingly removed outlines + // are redrawn again because the selection cache still holds their coordinates. + // However, this isn't easily solved, since plots.js would need + // to have a reference to the dragOptions object (which holds the + // selection cache). + clearAndResetSelect(); + } if(!allFixedRanges) { if(dragModeNow === 'zoom') { @@ -207,12 +224,20 @@ function makeDragBox(gd, plotinfo, x, y, w, h, ns, ew) { } function clickFn(numClicks, evt) { + var clickmode = gd._fullLayout.clickmode; + removeZoombox(gd); if(numClicks === 2 && !singleEnd) doubleClick(); if(isMainDrag) { - Fx.click(gd, evt, plotinfo.id); + if(clickmode.indexOf('select') > -1) { + selectOnClick(evt, gd, xaxes, yaxes, plotinfo.id, dragOptions); + } + + if(clickmode.indexOf('event') > -1) { + Fx.click(gd, evt, plotinfo.id); + } } else if(numClicks === 1 && singleEnd) { var ax = ns ? ya0 : xa0, diff --git a/src/plots/cartesian/select.js b/src/plots/cartesian/select.js index 498dfff60d2..fdc869f8690 100644 --- a/src/plots/cartesian/select.js +++ b/src/plots/cartesian/select.js @@ -26,7 +26,6 @@ var MINSELECT = constants.MINSELECT; var filteredPolygon = polygon.filter; var polygonTester = polygon.tester; -var multipolygonTester = polygon.multitester; function getAxId(ax) { return ax._id; } @@ -45,43 +44,13 @@ function prepSelect(e, startX, startY, dragOptions, mode) { var path0 = 'M' + x0 + ',' + y0; var pw = dragOptions.xaxes[0]._length; var ph = dragOptions.yaxes[0]._length; - var xAxisIds = dragOptions.xaxes.map(getAxId); - var yAxisIds = dragOptions.yaxes.map(getAxId); var allAxes = dragOptions.xaxes.concat(dragOptions.yaxes); var subtract = e.altKey; - var filterPoly, testPoly, mergedPolygons, currentPolygon; - var i, cd, trace, searchInfo, eventData; + var filterPoly, selectionTester, mergedPolygons, currentPolygon; + var i, searchInfo, eventData; - var selectingOnSameSubplot = ( - fullLayout._lastSelectedSubplot && - fullLayout._lastSelectedSubplot === plotinfo.id - ); - - if( - selectingOnSameSubplot && - (e.shiftKey || e.altKey) && - (plotinfo.selection && plotinfo.selection.polygons) && - !dragOptions.polygons - ) { - // take over selection polygons from prev mode, if any - dragOptions.polygons = plotinfo.selection.polygons; - dragOptions.mergedPolygons = plotinfo.selection.mergedPolygons; - } else if( - (!e.shiftKey && !e.altKey) || - ((e.shiftKey || e.altKey) && !plotinfo.selection) - ) { - // create new polygons, if shift mode or selecting across different subplots - plotinfo.selection = {}; - plotinfo.selection.polygons = dragOptions.polygons = []; - plotinfo.selection.mergedPolygons = dragOptions.mergedPolygons = []; - } - - // clear selection outline when selecting a different subplot - if(!selectingOnSameSubplot) { - clearSelect(zoomLayer); - fullLayout._lastSelectedSubplot = plotinfo.id; - } + coerceSelectionsCache(e, gd, dragOptions); if(mode === 'lasso') { filterPoly = filteredPolygon([[x0, y0]], constants.BENDPX); @@ -106,52 +75,12 @@ function prepSelect(e, startX, startY, dragOptions, mode) { .attr('d', 'M0,0Z'); - // find the traces to search for selection points - var searchTraces = []; var throttleID = fullLayout._uid + constants.SELECTID; var selection = []; - for(i = 0; i < gd.calcdata.length; i++) { - cd = gd.calcdata[i]; - trace = cd[0].trace; - - if(trace.visible !== true || !trace._module || !trace._module.selectPoints) continue; - - if(dragOptions.subplot) { - if( - trace.subplot === dragOptions.subplot || - trace.geo === dragOptions.subplot - ) { - searchTraces.push({ - _module: trace._module, - cd: cd, - xaxis: dragOptions.xaxes[0], - yaxis: dragOptions.yaxes[0] - }); - } - } else if( - trace.type === 'splom' && - // FIXME: make sure we don't have more than single axis for splom - trace._xaxes[xAxisIds[0]] && trace._yaxes[yAxisIds[0]] - ) { - searchTraces.push({ - _module: trace._module, - cd: cd, - xaxis: dragOptions.xaxes[0], - yaxis: dragOptions.yaxes[0] - }); - } else { - if(xAxisIds.indexOf(trace.xaxis) === -1) continue; - if(yAxisIds.indexOf(trace.yaxis) === -1) continue; - - searchTraces.push({ - _module: trace._module, - cd: cd, - xaxis: getFromId(gd, trace.xaxis), - yaxis: getFromId(gd, trace.yaxis) - }); - } - } + // find the traces to search for selection points + var searchTraces = determineSearchTraces(gd, dragOptions.xaxes, + dragOptions.yaxes, dragOptions.subplot); function axValue(ax) { var index = (ax._id.charAt(0) === 'y') ? 1 : 0; @@ -253,24 +182,19 @@ function prepSelect(e, startX, startY, dragOptions, mode) { } // create outline & tester - if(dragOptions.polygons && dragOptions.polygons.length) { + if(dragOptions.selectionDefs && dragOptions.selectionDefs.length) { mergedPolygons = mergePolygons(dragOptions.mergedPolygons, currentPolygon, subtract); currentPolygon.subtract = subtract; - testPoly = multipolygonTester(dragOptions.polygons.concat([currentPolygon])); + selectionTester = multiTester(dragOptions.selectionDefs.concat([currentPolygon])); } else { mergedPolygons = [currentPolygon]; - testPoly = polygonTester(currentPolygon); + selectionTester = 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'); + drawSelection(mergedPolygons, outlines); + throttle.throttle( throttleID, @@ -282,7 +206,7 @@ function prepSelect(e, startX, startY, dragOptions, mode) { for(i = 0; i < searchTraces.length; i++) { searchInfo = searchTraces[i]; - traceSelection = searchInfo._module.selectPoints(searchInfo, testPoly); + traceSelection = searchInfo._module.selectPoints(searchInfo, selectionTester); traceSelections.push(traceSelection); thisSelection = fillSelectionItem(traceSelection, searchInfo); @@ -304,6 +228,8 @@ function prepSelect(e, startX, startY, dragOptions, mode) { }; dragOptions.clickFn = function(numClicks, evt) { + var clickmode = fullLayout.clickmode; + corners.remove(); throttle.done(throttleID).then(function() { @@ -317,12 +243,23 @@ function prepSelect(e, startX, startY, dragOptions, mode) { } updateSelectedState(gd, searchTraces); + + clearSelectionsCache(dragOptions); + gd.emit('plotly_deselect', null); - } - else { - // TODO: remove in v2 - this was probably never intended to work as it does, - // but in case anyone depends on it we don't want to break it now. - gd.emit('plotly_selected', undefined); + } else { + if(clickmode.indexOf('select') > -1) { + selectOnClick(evt, gd, dragOptions.xaxes, dragOptions.yaxes, + dragOptions.subplot, dragOptions, outlines); + } + + if(clickmode === 'event') { + // TODO: remove in v2 - this was probably never intended to work as it does, + // but in case anyone depends on it we don't want to break it now. + // Note that click-to-select introduced pre v2 also emitts proper + // event data when clickmode is having 'select' in its flag list. + gd.emit('plotly_selected', undefined); + } } Fx.click(gd, evt); @@ -336,10 +273,10 @@ function prepSelect(e, startX, startY, dragOptions, mode) { throttle.clear(throttleID); dragOptions.gd.emit('plotly_selected', eventData); - if(currentPolygon && dragOptions.polygons) { + if(currentPolygon && dragOptions.selectionDefs) { // save last polygons currentPolygon.subtract = subtract; - dragOptions.polygons.push(currentPolygon); + dragOptions.selectionDefs.push(currentPolygon); // we have to keep reference to arrays container dragOptions.mergedPolygons.length = 0; @@ -349,6 +286,380 @@ function prepSelect(e, startX, startY, dragOptions, mode) { }; } +function selectOnClick(evt, gd, xAxes, yAxes, subplot, dragOptions, polygonOutlines) { + var hoverData = gd._hoverdata; + var clickmode = gd._fullLayout.clickmode; + var sendEvents = clickmode.indexOf('event') > -1; + var selection = []; + var searchTraces, searchInfo, currentSelectionDef, selectionTester, traceSelection; + var thisTracesSelection, pointOrBinSelected, subtract, eventData, i; + + if(isHoverDataSet(hoverData)) { + coerceSelectionsCache(evt, gd, dragOptions); + searchTraces = determineSearchTraces(gd, xAxes, yAxes, subplot); + var clickedPtInfo = extractClickedPtInfo(hoverData, searchTraces); + var isBinnedTrace = clickedPtInfo.pointNumbers.length > 0; + + + // Note: potentially costly operation isPointOrBinSelected is + // called as late as possible through the use of an assignment + // in an if condition. + if(isBinnedTrace ? + isOnlyThisBinSelected(searchTraces, clickedPtInfo) : + isOnlyOnePointSelected(searchTraces) && + (pointOrBinSelected = isPointOrBinSelected(clickedPtInfo))) + { + if(polygonOutlines) polygonOutlines.remove(); + for(i = 0; i < searchTraces.length; i++) { + searchInfo = searchTraces[i]; + searchInfo._module.selectPoints(searchInfo, false); + } + + updateSelectedState(gd, searchTraces); + + clearSelectionsCache(dragOptions); + + if(sendEvents) { + gd.emit('plotly_deselect', null); + } + } else { + subtract = evt.shiftKey && + (pointOrBinSelected !== undefined ? + pointOrBinSelected : + isPointOrBinSelected(clickedPtInfo)); + currentSelectionDef = newPointSelectionDef(clickedPtInfo.pointNumber, clickedPtInfo.searchInfo, subtract); + + var allSelectionDefs = dragOptions.selectionDefs.concat([currentSelectionDef]); + selectionTester = multiTester(allSelectionDefs); + + for(i = 0; i < searchTraces.length; i++) { + traceSelection = searchTraces[i]._module.selectPoints(searchTraces[i], selectionTester); + thisTracesSelection = fillSelectionItem(traceSelection, searchTraces[i]); + + if(selection.length) { + for(var j = 0; j < thisTracesSelection.length; j++) { + selection.push(thisTracesSelection[j]); + } + } + else selection = thisTracesSelection; + } + + eventData = {points: selection}; + updateSelectedState(gd, searchTraces, eventData); + + if(currentSelectionDef && dragOptions) { + dragOptions.selectionDefs.push(currentSelectionDef); + } + + if(polygonOutlines) drawSelection(dragOptions.mergedPolygons, polygonOutlines); + + if(sendEvents) { + gd.emit('plotly_selected', eventData); + } + } + } +} + +/** + * Constructs a new point selection definition object. + */ +function newPointSelectionDef(pointNumber, searchInfo, subtract) { + return { + pointNumber: pointNumber, + searchInfo: searchInfo, + subtract: subtract + }; +} + +function isPointSelectionDef(o) { + return 'pointNumber' in o && 'searchInfo' in o; +} + +/* + * Constructs a new point number tester. + */ +function newPointNumTester(pointSelectionDef) { + return { + xmin: 0, + xmax: 0, + ymin: 0, + ymax: 0, + pts: [], + contains: function(pt, omitFirstEdge, pointNumber, searchInfo) { + var idxWantedTrace = pointSelectionDef.searchInfo.cd[0].trace._expandedIndex; + var idxActualTrace = searchInfo.cd[0].trace._expandedIndex; + return idxActualTrace === idxWantedTrace && + pointNumber === pointSelectionDef.pointNumber; + }, + isRect: false, + degenerate: false, + subtract: pointSelectionDef.subtract + }; +} + +/** + * Wraps multiple selection testers. + * + * @param {Array} list - An array of selection testers. + * + * @return a selection tester object with a contains function + * that can be called to evaluate a point against all wrapped + * selection testers that were passed in list. + */ +function multiTester(list) { + var testers = []; + var xmin = isPointSelectionDef(list[0]) ? 0 : list[0][0][0]; + var xmax = xmin; + var ymin = isPointSelectionDef(list[0]) ? 0 : list[0][0][1]; + var ymax = ymin; + + for(var i = 0; i < list.length; i++) { + if(isPointSelectionDef(list[i])) { + testers.push(newPointNumTester(list[i])); + } else { + 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); + } + } + + /** + * Tests if the given point is within this tester. + * + * @param {Array} pt - [0] is the x coordinate, [1] is the y coordinate of the point. + * @param {*} arg - An optional parameter to pass down to wrapped testers. + * @param {number} pointNumber - The point number of the point within the underlying data array. + * @param {number} searchInfo - An object identifying the trace the point is contained in. + * + * @return {boolean} true if point is considered to be selected, false otherwise. + */ + function contains(pt, arg, pointNumber, searchInfo) { + var contained = false; + for(var i = 0; i < testers.length; i++) { + if(testers[i].contains(pt, arg, pointNumber, searchInfo)) { + // if contained by subtract tester - exclude the point + contained = testers[i].subtract === false; + } + } + + return contained; + } + + return { + xmin: xmin, + xmax: xmax, + ymin: ymin, + ymax: ymax, + pts: [], + contains: contains, + isRect: false, + degenerate: false + }; +} + +function coerceSelectionsCache(evt, gd, dragOptions) { + var fullLayout = gd._fullLayout; + var zoomLayer = fullLayout._zoomlayer; + var plotinfo = dragOptions.plotinfo; + + var selectingOnSameSubplot = ( + fullLayout._lastSelectedSubplot && + fullLayout._lastSelectedSubplot === plotinfo.id + ); + var hasModifierKey = evt.shiftKey || evt.altKey; + if(selectingOnSameSubplot && hasModifierKey && + (plotinfo.selection && plotinfo.selection.selectionDefs) && !dragOptions.selectionDefs) { + // take over selection definitions from prev mode, if any + dragOptions.selectionDefs = plotinfo.selection.selectionDefs; + dragOptions.mergedPolygons = plotinfo.selection.mergedPolygons; + } else if(!hasModifierKey || !plotinfo.selection) { + clearSelectionsCache(dragOptions); + } + + // clear selection outline when selecting a different subplot + if(!selectingOnSameSubplot) { + clearSelect(zoomLayer); + fullLayout._lastSelectedSubplot = plotinfo.id; + } +} + +function clearSelectionsCache(dragOptions) { + var plotinfo = dragOptions.plotinfo; + + plotinfo.selection = {}; + plotinfo.selection.selectionDefs = dragOptions.selectionDefs = []; + plotinfo.selection.mergedPolygons = dragOptions.mergedPolygons = []; +} + +function determineSearchTraces(gd, xAxes, yAxes, subplot) { + var searchTraces = []; + var xAxisIds = xAxes.map(getAxId); + var yAxisIds = yAxes.map(getAxId); + var cd, trace, i; + + for(i = 0; i < gd.calcdata.length; i++) { + cd = gd.calcdata[i]; + trace = cd[0].trace; + + if(trace.visible !== true || !trace._module || !trace._module.selectPoints) continue; + + if(subplot && (trace.subplot === subplot || trace.geo === subplot)) { + searchTraces.push(createSearchInfo(trace._module, cd, xAxes[0], yAxes[0])); + } else if( + trace.type === 'splom' && + // FIXME: make sure we don't have more than single axis for splom + trace._xaxes[xAxisIds[0]] && trace._yaxes[yAxisIds[0]] + ) { + searchTraces.push(createSearchInfo(trace._module, cd, xAxes[0], yAxes[0])); + } else { + if(xAxisIds.indexOf(trace.xaxis) === -1) continue; + if(yAxisIds.indexOf(trace.yaxis) === -1) continue; + + searchTraces.push(createSearchInfo(trace._module, cd, + getFromId(gd, trace.xaxis), getFromId(gd, trace.yaxis))); + } + } + + return searchTraces; + + function createSearchInfo(module, calcData, xaxis, yaxis) { + return { + _module: module, + cd: calcData, + xaxis: xaxis, + yaxis: yaxis + }; + } +} + +function drawSelection(polygons, outlines) { + var paths = []; + var i, d; + + for(i = 0; i < polygons.length; i++) { + var ppts = polygons[i]; + paths.push(ppts.join('L') + 'L' + ppts[0]); + } + + d = polygons.length > 0 ? + 'M' + paths.join('M') + 'Z' : + 'M0,0Z'; + outlines.attr('d', d); +} + +function isHoverDataSet(hoverData) { + return hoverData && + Array.isArray(hoverData) && + hoverData[0].hoverOnBox !== true; +} + +function extractClickedPtInfo(hoverData, searchTraces) { + var hoverDatum = hoverData[0]; + var pointNumber = -1; + var pointNumbers = []; + var searchInfo, i; + + for(i = 0; i < searchTraces.length; i++) { + searchInfo = searchTraces[i]; + if(hoverDatum.fullData._expandedIndex === searchInfo.cd[0].trace._expandedIndex) { + + // Special case for box (and violin) + if(hoverDatum.hoverOnBox === true) { + break; + } + + // Hint: in some traces like histogram, one graphical element + // doesn't correspond to one particular data point, but to + // bins of data points. Thus, hoverDatum can have a binNumber + // property instead of pointNumber. + if(hoverDatum.pointNumber !== undefined) { + pointNumber = hoverDatum.pointNumber; + } else if(hoverDatum.binNumber !== undefined) { + pointNumber = hoverDatum.binNumber; + pointNumbers = hoverDatum.pointNumbers; + } + + break; + } + } + + return { + pointNumber: pointNumber, + pointNumbers: pointNumbers, + searchInfo: searchInfo + }; +} + +function isPointOrBinSelected(clickedPtInfo) { + var trace = clickedPtInfo.searchInfo.cd[0].trace; + var ptNum = clickedPtInfo.pointNumber; + var ptNums = clickedPtInfo.pointNumbers; + var ptNumsSet = ptNums.length > 0; + + // When pointsNumbers is set (e.g. histogram's binning), + // it is assumed that when the first point of + // a bin is selected, all others are as well + var ptNumToTest = ptNumsSet ? ptNums[0] : ptNum; + + // TODO potential performance improvement + // Primarily we need this function to determine if a click adds + // or subtracts from a selection. + // In cases `trace.selectedpoints` is a huge array, indexOf + // might be slow. One remedy would be to introduce a hash somewhere. + return trace.selectedpoints ? trace.selectedpoints.indexOf(ptNumToTest) > -1 : false; +} + +function isOnlyThisBinSelected(searchTraces, clickedPtInfo) { + var tracesWithSelectedPts = []; + var searchInfo, trace, isSameTrace, i; + + for(i = 0; i < searchTraces.length; i++) { + searchInfo = searchTraces[i]; + if(searchInfo.cd[0].trace.selectedpoints && searchInfo.cd[0].trace.selectedpoints.length > 0) { + tracesWithSelectedPts.push(searchInfo); + } + } + + if(tracesWithSelectedPts.length === 1) { + isSameTrace = tracesWithSelectedPts[0] === clickedPtInfo.searchInfo; + if(isSameTrace) { + trace = clickedPtInfo.searchInfo.cd[0].trace; + if(trace.selectedpoints.length === clickedPtInfo.pointNumbers.length) { + for(i = 0; i < clickedPtInfo.pointNumbers.length; i++) { + if(trace.selectedpoints.indexOf(clickedPtInfo.pointNumbers[i]) < 0) { + return false; + } + } + return true; + } + } + } + + return false; +} + +function isOnlyOnePointSelected(searchTraces) { + var len = 0; + var searchInfo, trace, i; + + for(i = 0; i < searchTraces.length; i++) { + searchInfo = searchTraces[i]; + trace = searchInfo.cd[0].trace; + if(trace.selectedpoints) { + if(trace.selectedpoints.length > 1) return false; + + len += trace.selectedpoints.length; + if(len > 1) return false; + } + } + + return len === 1; +} + function updateSelectedState(gd, searchTraces, eventData) { var i, j, searchInfo, trace; @@ -471,5 +782,6 @@ function clearSelect(zoomlayer) { module.exports = { prepSelect: prepSelect, - clearSelect: clearSelect + clearSelect: clearSelect, + selectOnClick: selectOnClick }; diff --git a/src/plots/geo/geo.js b/src/plots/geo/geo.js index e6d419503b5..83153e0dfe3 100644 --- a/src/plots/geo/geo.js +++ b/src/plots/geo/geo.js @@ -21,6 +21,7 @@ var Plots = require('../plots'); var Axes = require('../cartesian/axes'); var dragElement = require('../../components/dragelement'); var prepSelect = require('../cartesian/select').prepSelect; +var selectOnClick = require('../cartesian/select').selectOnClick; var createGeoZoom = require('./zoom'); var constants = require('./constants'); @@ -354,6 +355,7 @@ proto.updateFx = function(fullLayout, geoLayout) { var gd = _this.graphDiv; var bgRect = _this.bgRect; var dragMode = fullLayout.dragmode; + var clickMode = fullLayout.clickmode; if(_this.isStatic) return; @@ -376,6 +378,44 @@ proto.updateFx = function(fullLayout, geoLayout) { ]); } + var fillRangeItems; + + if(dragMode === 'select') { + fillRangeItems = function(eventData, poly) { + var ranges = eventData.range = {}; + ranges[_this.id] = [ + invert([poly.xmin, poly.ymin]), + invert([poly.xmax, poly.ymax]) + ]; + }; + } else if(dragMode === 'lasso') { + fillRangeItems = function(eventData, poly, pts) { + var dataPts = eventData.lassoPoints = {}; + dataPts[_this.id] = pts.filtered.map(invert); + }; + } + + // Note: dragOptions is needed to be declared for all dragmodes because + // it's the object that holds persistent selection state. + var dragOptions = { + element: _this.bgRect.node(), + gd: gd, + plotinfo: { + id: _this.id, + xaxis: _this.xaxis, + yaxis: _this.yaxis, + fillRangeItems: fillRangeItems + }, + xaxes: [_this.xaxis], + yaxes: [_this.yaxis], + subplot: _this.id, + clickFn: function(numClicks) { + if(numClicks === 2) { + fullLayout._zoomlayer.selectAll('.select-outline').remove(); + } + } + }; + if(dragMode === 'pan') { bgRect.node().onmousedown = null; bgRect.call(createGeoZoom(_this, geoLayout)); @@ -384,41 +424,6 @@ proto.updateFx = function(fullLayout, geoLayout) { else if(dragMode === 'select' || dragMode === 'lasso') { bgRect.on('.zoom', null); - var fillRangeItems; - - if(dragMode === 'select') { - fillRangeItems = function(eventData, poly) { - var ranges = eventData.range = {}; - ranges[_this.id] = [ - invert([poly.xmin, poly.ymin]), - invert([poly.xmax, poly.ymax]) - ]; - }; - } else if(dragMode === 'lasso') { - fillRangeItems = function(eventData, poly, pts) { - var dataPts = eventData.lassoPoints = {}; - dataPts[_this.id] = pts.filtered.map(invert); - }; - } - - var dragOptions = { - element: _this.bgRect.node(), - gd: gd, - plotinfo: { - xaxis: _this.xaxis, - yaxis: _this.yaxis, - fillRangeItems: fillRangeItems - }, - xaxes: [_this.xaxis], - yaxes: [_this.yaxis], - subplot: _this.id, - clickFn: function(numClicks) { - if(numClicks === 2) { - fullLayout._zoomlayer.selectAll('.select-outline').remove(); - } - } - }; - dragOptions.prepFn = function(e, startX, startY) { prepSelect(e, startX, startY, dragOptions, dragMode); }; @@ -440,15 +445,26 @@ proto.updateFx = function(fullLayout, geoLayout) { }); bgRect.on('mouseout', function() { + if(gd._dragging) return; dragElement.unhover(gd, d3.event); }); bgRect.on('click', function() { - // TODO: like pie and mapbox, this doesn't support right-click - // actually this one is worse, as right-click starts a pan, or leaves - // select in a weird state. - // Also, only tangentially related, we should cancel hover during pan - Fx.click(gd, d3.event); + // For select and lasso the dragElement is handling clicks + if(dragMode !== 'select' && dragMode !== 'lasso') { + if(clickMode.indexOf('select') > -1) { + selectOnClick(d3.event, gd, [_this.xaxis], [_this.yaxis], + _this.id, dragOptions); + } + + if(clickMode.indexOf('event') > -1) { + // TODO: like pie and mapbox, this doesn't support right-click + // actually this one is worse, as right-click starts a pan, or leaves + // select in a weird state. + // Also, only tangentially related, we should cancel hover during pan + Fx.click(gd, d3.event); + } + } }); }; diff --git a/src/plots/mapbox/mapbox.js b/src/plots/mapbox/mapbox.js index c5332d05fd8..77da3871017 100644 --- a/src/plots/mapbox/mapbox.js +++ b/src/plots/mapbox/mapbox.js @@ -15,6 +15,7 @@ var Fx = require('../../components/fx'); var Lib = require('../../lib'); var dragElement = require('../../components/dragelement'); var prepSelect = require('../cartesian/select').prepSelect; +var selectOnClick = require('../cartesian/select').selectOnClick; var constants = require('./constants'); var layoutAttributes = require('./layout_attributes'); var createMapboxLayer = require('./layers'); @@ -176,15 +177,6 @@ proto.createMap = function(calcData, fullLayout, resolve, reject) { Fx.hover(gd, evt, self.id); }); - map.on('click', function(evt) { - // TODO: this does not support right-click. If we want to support it, we - // would likely need to change mapbox to use dragElement instead of straight - // mapbox event binding. Or perhaps better, make a simple wrapper with the - // right mousedown, mousemove, and mouseup handlers just for a left/right click - // pie would use this too. - Fx.click(gd, evt.originalEvent); - }); - function unhover() { Fx.loneUnhover(fullLayout._toppaper); } @@ -221,11 +213,34 @@ proto.createMap = function(calcData, fullLayout, resolve, reject) { gd.emit('plotly_relayout', evtData); } - // define clear select on map creation, to keep one ref per map, + // define event handlers on map creation, to keep one ref per map, // so that map.on / map.off in updateFx works as expected self.clearSelect = function() { gd._fullLayout._zoomlayer.selectAll('.select-outline').remove(); }; + + /** + * Returns a click handler function that is supposed + * to handle clicks in pan mode. + */ + self.onClickInPanFn = function(dragOptions) { + return function(evt) { + var clickMode = gd._fullLayout.clickmode; + + if(clickMode.indexOf('select') > -1) { + selectOnClick(evt.originalEvent, gd, [self.xaxis], [self.yaxis], self.id, dragOptions); + } + + if(clickMode.indexOf('event') > -1) { + // TODO: this does not support right-click. If we want to support it, we + // would likely need to change mapbox to use dragElement instead of straight + // mapbox event binding. Or perhaps better, make a simple wrapper with the + // right mousedown, mousemove, and mouseup handlers just for a left/right click + // pie would use this too. + Fx.click(gd, evt.originalEvent); + } + }; + }; }; proto.updateMap = function(calcData, fullLayout, resolve, reject) { @@ -382,32 +397,50 @@ proto.updateFx = function(fullLayout) { }; } + // Note: dragOptions is needed to be declared for all dragmodes because + // it's the object that holds persistent selection state. + // Merge old dragOptions with new to keep possibly initialized + // persistent selection state. + var oldDragOptions = self.dragOptions; + self.dragOptions = Lib.extendDeep(oldDragOptions || {}, { + element: self.div, + gd: gd, + plotinfo: { + id: self.id, + xaxis: self.xaxis, + yaxis: self.yaxis, + fillRangeItems: fillRangeItems + }, + xaxes: [self.xaxis], + yaxes: [self.yaxis], + subplot: self.id + }); + + // Unregister the old handler before potentially registering + // a new one. Otherwise multiple click handlers might + // be registered resulting in unwanted behavior. + map.off('click', self.onClickInPanHandler); if(dragMode === 'select' || dragMode === 'lasso') { map.dragPan.disable(); map.on('zoomstart', self.clearSelect); - var dragOptions = { - element: self.div, - gd: gd, - plotinfo: { - xaxis: self.xaxis, - yaxis: self.yaxis, - fillRangeItems: fillRangeItems - }, - xaxes: [self.xaxis], - yaxes: [self.yaxis], - subplot: self.id + self.dragOptions.prepFn = function(e, startX, startY) { + prepSelect(e, startX, startY, self.dragOptions, dragMode); }; - dragOptions.prepFn = function(e, startX, startY) { - prepSelect(e, startX, startY, dragOptions, dragMode); - }; - - dragElement.init(dragOptions); + dragElement.init(self.dragOptions); } else { map.dragPan.enable(); map.off('zoomstart', self.clearSelect); self.div.onmousedown = null; + + // TODO: this does not support right-click. If we want to support it, we + // would likely need to change mapbox to use dragElement instead of straight + // mapbox event binding. Or perhaps better, make a simple wrapper with the + // right mousedown, mousemove, and mouseup handlers just for a left/right click + // pie would use this too. + self.onClickInPanHandler = self.onClickInPanFn(self.dragOptions); + map.on('click', self.onClickInPanHandler); } }; diff --git a/src/plots/polar/polar.js b/src/plots/polar/polar.js index e550b52b325..e7df279c845 100644 --- a/src/plots/polar/polar.js +++ b/src/plots/polar/polar.js @@ -25,6 +25,7 @@ var dragElement = require('../../components/dragelement'); var Fx = require('../../components/fx'); var Titles = require('../../components/titles'); var prepSelect = require('../cartesian/select').prepSelect; +var selectOnClick = require('../cartesian/select').selectOnClick; var clearSelect = require('../cartesian/select').clearSelect; var setCursor = require('../../lib/setcursor'); @@ -637,6 +638,7 @@ proto.updateMainDrag = function(fullLayout) { gd: gd, subplot: _this.id, plotinfo: { + id: _this.id, xaxis: _this.xaxis, yaxis: _this.yaxis }, @@ -843,6 +845,31 @@ proto.updateMainDrag = function(fullLayout) { Registry.call('relayout', gd, updateObj); } + function zoomClick(numClicks, evt) { + var clickMode = gd._fullLayout.clickmode; + + dragBox.removeZoombox(gd); + + // TODO double once vs twice logic (autorange vs fixed range) + if(numClicks === 2) { + var updateObj = {}; + for(var k in _this.viewInitial) { + updateObj[_this.id + '.' + k] = _this.viewInitial[k]; + } + + gd.emit('plotly_doubleclick', null); + Registry.call('relayout', gd, updateObj); + } + + if(clickMode.indexOf('select') > -1 && numClicks === 1) { + selectOnClick(evt, gd, [_this.xaxis], [_this.yaxis], _this.id, dragOpts); + } + + if(clickMode.indexOf('event') > -1) { + Fx.click(gd, evt, _this.id); + } + } + dragOpts.prepFn = function(evt, startX, startY) { var dragModeNow = gd._fullLayout.dragmode; @@ -865,6 +892,7 @@ proto.updateMainDrag = function(fullLayout) { } else { dragOpts.moveFn = zoomMove; } + dragOpts.clickFn = zoomClick; dragOpts.doneFn = zoomDone; zoomPrep(evt, startX, startY); break; @@ -875,23 +903,6 @@ proto.updateMainDrag = function(fullLayout) { } }; - dragOpts.clickFn = function(numClicks, evt) { - dragBox.removeZoombox(gd); - - // TODO double once vs twice logic (autorange vs fixed range) - if(numClicks === 2) { - var updateObj = {}; - for(var k in _this.viewInitial) { - updateObj[_this.id + '.' + k] = _this.viewInitial[k]; - } - - gd.emit('plotly_doubleclick', null); - Registry.call('relayout', gd, updateObj); - } - - Fx.click(gd, evt, _this.id); - }; - mainDrag.onmousemove = function(evt) { Fx.hover(gd, evt, _this.id); gd._fullLayout._lasthover = mainDrag; diff --git a/src/plots/ternary/ternary.js b/src/plots/ternary/ternary.js index 436397bc5aa..8d0cfd9cc41 100644 --- a/src/plots/ternary/ternary.js +++ b/src/plots/ternary/ternary.js @@ -25,6 +25,7 @@ var dragElement = require('../../components/dragelement'); var Fx = require('../../components/fx'); var Titles = require('../../components/titles'); var prepSelect = require('../cartesian/select').prepSelect; +var selectOnClick = require('../cartesian/select').selectOnClick; var clearSelect = require('../cartesian/select').clearSelect; var constants = require('../cartesian/constants'); @@ -452,6 +453,7 @@ proto.initInteractions = function() { element: dragger, gd: gd, plotinfo: { + id: _this.id, xaxis: _this.xaxis, yaxis: _this.yaxis }, @@ -462,21 +464,19 @@ proto.initInteractions = function() { dragOptions.xaxes = [_this.xaxis]; dragOptions.yaxes = [_this.yaxis]; var dragModeNow = gd._fullLayout.dragmode; - if(e.shiftKey) { - if(dragModeNow === 'pan') dragModeNow = 'zoom'; - else dragModeNow = 'pan'; - } if(dragModeNow === 'lasso') dragOptions.minDrag = 1; else dragOptions.minDrag = undefined; if(dragModeNow === 'zoom') { dragOptions.moveFn = zoomMove; + dragOptions.clickFn = clickZoomPan; dragOptions.doneFn = zoomDone; zoomPrep(e, startX, startY); } else if(dragModeNow === 'pan') { dragOptions.moveFn = plotDrag; + dragOptions.clickFn = clickZoomPan; dragOptions.doneFn = dragDone; panPrep(); clearSelect(zoomContainer); @@ -484,24 +484,34 @@ proto.initInteractions = function() { else if(dragModeNow === 'select' || dragModeNow === 'lasso') { prepSelect(e, startX, startY, dragOptions, dragModeNow); } - }, - clickFn: function(numClicks, evt) { - removeZoombox(gd); - - if(numClicks === 2) { - var attrs = {}; - attrs[_this.id + '.aaxis.min'] = 0; - attrs[_this.id + '.baxis.min'] = 0; - attrs[_this.id + '.caxis.min'] = 0; - gd.emit('plotly_doubleclick', null); - Registry.call('relayout', gd, attrs); - } - Fx.click(gd, evt, _this.id); } }; var x0, y0, mins0, span0, mins, lum, path0, dimmed, zb, corners; + function clickZoomPan(numClicks, evt) { + var clickMode = gd._fullLayout.clickmode; + + removeZoombox(gd); + + if(numClicks === 2) { + var attrs = {}; + attrs[_this.id + '.aaxis.min'] = 0; + attrs[_this.id + '.baxis.min'] = 0; + attrs[_this.id + '.caxis.min'] = 0; + gd.emit('plotly_doubleclick', null); + Registry.call('relayout', gd, attrs); + } + + if(clickMode.indexOf('select') > -1 && numClicks === 1) { + selectOnClick(evt, gd, [_this.xaxis], [_this.yaxis], _this.id, dragOptions); + } + + if(clickMode.indexOf('event') > -1) { + Fx.click(gd, evt, _this.id); + } + } + function zoomPrep(e, startX, startY) { var dragBBox = dragger.getBoundingClientRect(); x0 = startX - dragBBox.left; diff --git a/src/traces/bar/select.js b/src/traces/bar/select.js index 04ede09356c..4d80b7b4836 100644 --- a/src/traces/bar/select.js +++ b/src/traces/bar/select.js @@ -8,14 +8,14 @@ 'use strict'; -module.exports = function selectPoints(searchInfo, polygon) { +module.exports = function selectPoints(searchInfo, selectionTester) { var cd = searchInfo.cd; var xa = searchInfo.xaxis; var ya = searchInfo.yaxis; var selection = []; var i; - if(polygon === false) { + if(selectionTester === false) { // clear selection for(i = 0; i < cd.length; i++) { cd[i].selected = 0; @@ -24,7 +24,7 @@ module.exports = function selectPoints(searchInfo, polygon) { for(i = 0; i < cd.length; i++) { var di = cd[i]; - if(polygon.contains(di.ct)) { + if(selectionTester.contains(di.ct, false, i, searchInfo)) { selection.push({ pointNumber: i, x: xa.c2d(di.x), diff --git a/src/traces/box/event_data.js b/src/traces/box/event_data.js new file mode 100644 index 00000000000..a12ee8eb67a --- /dev/null +++ b/src/traces/box/event_data.js @@ -0,0 +1,24 @@ +/** +* Copyright 2012-2018, Plotly, Inc. +* All rights reserved. +* +* This source code is licensed under the MIT license found in the +* LICENSE file in the root directory of this source tree. +*/ + +'use strict'; + +module.exports = function eventData(out, pt) { + + // Note: hoverOnBox property is needed for click-to-select + // to ignore when a box was clicked. This is the reason box + // implements this custom eventData function. + if(pt.hoverOnBox) out.hoverOnBox = pt.hoverOnBox; + + if('xVal' in pt) out.x = pt.xVal; + if('yVal' in pt) out.y = pt.yVal; + if(pt.xa) out.xaxis = pt.xa; + if(pt.ya) out.yaxis = pt.ya; + + return out; +}; diff --git a/src/traces/box/hover.js b/src/traces/box/hover.js index 79e24509360..b4729279e1c 100644 --- a/src/traces/box/hover.js +++ b/src/traces/box/hover.js @@ -169,6 +169,10 @@ function hoverOnBoxes(pointData, xval, yval, hovermode) { pointData2[vLetter + 'LabelVal'] = val; pointData2[vLetter + 'Label'] = (t.labels ? t.labels[attr] + ' ' : '') + Axes.hoverLabelText(vAxis, val); + // Note: introduced to be able to distinguish a + // clicked point from a box during click-to-select + pointData2.hoverOnBox = true; + if(attr === 'mean' && ('sd' in di) && trace.boxmean === 'sd') { pointData2[vLetter + 'err'] = di.sd; } diff --git a/src/traces/box/index.js b/src/traces/box/index.js index 3ad049e1701..17931ec782d 100644 --- a/src/traces/box/index.js +++ b/src/traces/box/index.js @@ -20,6 +20,7 @@ Box.plot = require('./plot').plot; Box.style = require('./style').style; Box.styleOnSelect = require('./style').styleOnSelect; Box.hoverPoints = require('./hover').hoverPoints; +Box.eventData = require('./event_data'); Box.selectPoints = require('./select'); Box.moduleType = 'trace'; diff --git a/src/traces/box/select.js b/src/traces/box/select.js index 9ec9ed03e3f..069b9b1896f 100644 --- a/src/traces/box/select.js +++ b/src/traces/box/select.js @@ -8,14 +8,14 @@ 'use strict'; -module.exports = function selectPoints(searchInfo, polygon) { +module.exports = function selectPoints(searchInfo, selectionTester) { var cd = searchInfo.cd; var xa = searchInfo.xaxis; var ya = searchInfo.yaxis; var selection = []; var i, j; - if(polygon === false) { + if(selectionTester === false) { for(i = 0; i < cd.length; i++) { for(j = 0; j < (cd[i].pts || []).length; j++) { // clear selection @@ -29,7 +29,7 @@ module.exports = function selectPoints(searchInfo, polygon) { var x = xa.c2p(pt.x); var y = ya.c2p(pt.y); - if(polygon.contains([x, y])) { + if(selectionTester.contains([x, y], null, pt.i, searchInfo)) { selection.push({ pointNumber: pt.i, x: xa.c2d(pt.x), diff --git a/src/traces/choropleth/select.js b/src/traces/choropleth/select.js index c3a8f332c4d..9052c06a74e 100644 --- a/src/traces/choropleth/select.js +++ b/src/traces/choropleth/select.js @@ -8,7 +8,7 @@ 'use strict'; -module.exports = function selectPoints(searchInfo, polygon) { +module.exports = function selectPoints(searchInfo, selectionTester) { var cd = searchInfo.cd; var xa = searchInfo.xaxis; var ya = searchInfo.yaxis; @@ -16,7 +16,7 @@ module.exports = function selectPoints(searchInfo, polygon) { var i, di, ct, x, y; - if(polygon === false) { + if(selectionTester === false) { for(i = 0; i < cd.length; i++) { cd[i].selected = 0; } @@ -30,7 +30,7 @@ module.exports = function selectPoints(searchInfo, polygon) { x = xa.c2p(ct); y = ya.c2p(ct); - if(polygon.contains([x, y])) { + if(selectionTester.contains([x, y], null, i, searchInfo)) { selection.push({ pointNumber: i, lon: ct[0], diff --git a/src/traces/ohlc/select.js b/src/traces/ohlc/select.js index 29bed35028f..a588e2ac164 100644 --- a/src/traces/ohlc/select.js +++ b/src/traces/ohlc/select.js @@ -8,7 +8,7 @@ 'use strict'; -module.exports = function selectPoints(searchInfo, polygon) { +module.exports = function selectPoints(searchInfo, selectionTester) { var cd = searchInfo.cd; var xa = searchInfo.xaxis; var ya = searchInfo.yaxis; @@ -17,7 +17,7 @@ module.exports = function selectPoints(searchInfo, polygon) { // for (potentially grouped) candlesticks var posOffset = cd[0].t.bPos || 0; - if(polygon === false) { + if(selectionTester === false) { // clear selection for(i = 0; i < cd.length; i++) { cd[i].selected = 0; @@ -26,7 +26,7 @@ module.exports = function selectPoints(searchInfo, polygon) { for(i = 0; i < cd.length; i++) { var di = cd[i]; - if(polygon.contains([xa.c2p(di.pos + posOffset), ya.c2p(di.yc)])) { + if(selectionTester.contains([xa.c2p(di.pos + posOffset), ya.c2p(di.yc)], null, di.i, searchInfo)) { selection.push({ pointNumber: di.i, x: xa.c2d(di.pos), diff --git a/src/traces/scatter/select.js b/src/traces/scatter/select.js index e980a6d7400..79e1a689e41 100644 --- a/src/traces/scatter/select.js +++ b/src/traces/scatter/select.js @@ -11,7 +11,7 @@ var subtypes = require('./subtypes'); -module.exports = function selectPoints(searchInfo, polygon) { +module.exports = function selectPoints(searchInfo, selectionTester) { var cd = searchInfo.cd, xa = searchInfo.xaxis, ya = searchInfo.yaxis, @@ -25,7 +25,7 @@ module.exports = function selectPoints(searchInfo, polygon) { var hasOnlyLines = (!subtypes.hasMarkers(trace) && !subtypes.hasText(trace)); if(hasOnlyLines) return []; - if(polygon === false) { // clear selection + if(selectionTester === false) { // clear selection for(i = 0; i < cd.length; i++) { cd[i].selected = 0; } @@ -36,7 +36,7 @@ module.exports = function selectPoints(searchInfo, polygon) { x = xa.c2p(di.x); y = ya.c2p(di.y); - if((di.i !== null) && polygon.contains([x, y])) { + if((di.i !== null) && selectionTester.contains([x, y], false, i, searchInfo)) { selection.push({ pointNumber: di.i, x: xa.c2d(di.x), diff --git a/src/traces/scattergeo/select.js b/src/traces/scattergeo/select.js index 4c11e6c3196..b6b9fe2b212 100644 --- a/src/traces/scattergeo/select.js +++ b/src/traces/scattergeo/select.js @@ -11,7 +11,7 @@ var subtypes = require('../scatter/subtypes'); var BADNUM = require('../../constants/numerical').BADNUM; -module.exports = function selectPoints(searchInfo, polygon) { +module.exports = function selectPoints(searchInfo, selectionTester) { var cd = searchInfo.cd; var xa = searchInfo.xaxis; var ya = searchInfo.yaxis; @@ -23,7 +23,7 @@ module.exports = function selectPoints(searchInfo, polygon) { var hasOnlyLines = (!subtypes.hasMarkers(trace) && !subtypes.hasText(trace)); if(hasOnlyLines) return []; - if(polygon === false) { + if(selectionTester === false) { for(i = 0; i < cd.length; i++) { cd[i].selected = 0; } @@ -38,7 +38,7 @@ module.exports = function selectPoints(searchInfo, polygon) { x = xa.c2p(lonlat); y = ya.c2p(lonlat); - if(polygon.contains([x, y])) { + if(selectionTester.contains([x, y], null, i, searchInfo)) { selection.push({ pointNumber: i, lon: lonlat[0], diff --git a/src/traces/scattergl/index.js b/src/traces/scattergl/index.js index 0bb6dabe015..d01dd713629 100644 --- a/src/traces/scattergl/index.js +++ b/src/traces/scattergl/index.js @@ -524,6 +524,7 @@ function plot(gd, subplot, cdata) { scene.unselectBatch = null; var dragmode = fullLayout.dragmode; var selectMode = dragmode === 'lasso' || dragmode === 'select'; + var clickSelectEnabled = fullLayout.clickmode.indexOf('select') > -1; for(i = 0; i < cdata.length; i++) { var cd0 = cdata[i][0]; @@ -533,7 +534,7 @@ function plot(gd, subplot, cdata) { var x = stash.x; var y = stash.y; - if(trace.selectedpoints || selectMode) { + if(trace.selectedpoints || selectMode || clickSelectEnabled) { if(!selectMode) selectMode = true; if(!scene.selectBatch) { @@ -822,7 +823,7 @@ function calcHover(pointData, x, y, trace) { } -function selectPoints(searchInfo, polygon) { +function selectPoints(searchInfo, selectionTester) { var cd = searchInfo.cd; var selection = []; var trace = cd[0].trace; @@ -844,10 +845,10 @@ function selectPoints(searchInfo, polygon) { var unels = null; // FIXME: clearing selection does not work here var i; - if(polygon !== false && !polygon.degenerate) { + if(selectionTester !== false && !selectionTester.degenerate) { els = [], unels = []; for(i = 0; i < stash.count; i++) { - if(polygon.contains([stash.xpx[i], stash.ypx[i]])) { + if(selectionTester.contains([stash.xpx[i], stash.ypx[i]], false, i, searchInfo)) { els.push(i); selection.push({ pointNumber: i, diff --git a/src/traces/scattermapbox/select.js b/src/traces/scattermapbox/select.js index dd6f3536903..34bbeedf0e6 100644 --- a/src/traces/scattermapbox/select.js +++ b/src/traces/scattermapbox/select.js @@ -12,7 +12,7 @@ var Lib = require('../../lib'); var subtypes = require('../scatter/subtypes'); var BADNUM = require('../../constants/numerical').BADNUM; -module.exports = function selectPoints(searchInfo, polygon) { +module.exports = function selectPoints(searchInfo, selectionTester) { var cd = searchInfo.cd; var xa = searchInfo.xaxis; var ya = searchInfo.yaxis; @@ -22,7 +22,7 @@ module.exports = function selectPoints(searchInfo, polygon) { if(!subtypes.hasMarkers(trace)) return []; - if(polygon === false) { + if(selectionTester === false) { for(i = 0; i < cd.length; i++) { cd[i].selected = 0; } @@ -35,7 +35,7 @@ module.exports = function selectPoints(searchInfo, polygon) { var lonlat2 = [Lib.modHalf(lonlat[0], 360), lonlat[1]]; var xy = [xa.c2p(lonlat2), ya.c2p(lonlat2)]; - if(polygon.contains(xy)) { + if(selectionTester.contains(xy, null, i, searchInfo)) { selection.push({ pointNumber: i, lon: lonlat[0], diff --git a/src/traces/splom/index.js b/src/traces/splom/index.js index 902646cb17a..cbbade4c81b 100644 --- a/src/traces/splom/index.js +++ b/src/traces/splom/index.js @@ -229,7 +229,9 @@ function plotOne(gd, cd0) { scene.matrix = createMatrix(regl); } - var selectMode = dragmode === 'lasso' || dragmode === 'select' || !!trace.selectedpoints; + var clickSelectEnabled = fullLayout.clickmode.indexOf('select') > -1; + var selectMode = dragmode === 'lasso' || dragmode === 'select' || + !!trace.selectedpoints || clickSelectEnabled; scene.selectBatch = null; scene.unselectBatch = null; @@ -346,7 +348,7 @@ function hoverPoints(pointData, xval, yval) { return [pointData]; } -function selectPoints(searchInfo, polygon) { +function selectPoints(searchInfo, selectionTester) { var cd = searchInfo.cd; var trace = cd[0].trace; var stash = cd[0].t; @@ -375,10 +377,10 @@ function selectPoints(searchInfo, polygon) { // filter out points by visible scatter ones var els = null; var unels = null; - if(polygon !== false && !polygon.degenerate) { + if(selectionTester !== false && !selectionTester.degenerate) { els = [], unels = []; for(i = 0; i < x.length; i++) { - if(polygon.contains([xpx[i], ypx[i]])) { + if(selectionTester.contains([xpx[i], ypx[i]], null, i, searchInfo)) { els.push(i); selection.push({ pointNumber: i, diff --git a/test/jasmine/assets/double_click.js b/test/jasmine/assets/double_click.js index 73d444d0782..222ecd7e5a4 100644 --- a/test/jasmine/assets/double_click.js +++ b/test/jasmine/assets/double_click.js @@ -3,22 +3,25 @@ var getNodeCoords = require('./get_node_coords'); var DBLCLICKDELAY = require('../../../src/constants/interactions').DBLCLICKDELAY; /* - * double click on a point. - * you can either specify x,y as pixels, or + * Double click on a point. + * You can either specify x,y as pixels, or * you can specify node and optionally an edge ('n', 'se', 'w' etc) - * to grab it by an edge or corner (otherwise the middle is used) + * to grab it by an edge or corner (otherwise the middle is used). + * You can also pass options for the underlying click, e.g. + * to specify modifier keys. See `click` function + * for more info. */ -module.exports = function doubleClick(x, y) { +module.exports = function doubleClick(x, y, clickOpts) { if(typeof x === 'object') { var coords = getNodeCoords(x, y); x = coords.x; y = coords.y; } return new Promise(function(resolve) { - click(x, y); + click(x, y, clickOpts); setTimeout(function() { - click(x, y); + click(x, y, clickOpts); setTimeout(function() { resolve(); }, DBLCLICKDELAY / 2); }, DBLCLICKDELAY / 2); }); diff --git a/test/jasmine/tests/hover_label_test.js b/test/jasmine/tests/hover_label_test.js index aa0981c5ac3..437c47392d1 100644 --- a/test/jasmine/tests/hover_label_test.js +++ b/test/jasmine/tests/hover_label_test.js @@ -2454,3 +2454,52 @@ describe('hover distance', function() { }); }); }); + +describe('hovermode defaults to', function() { + var gd; + + beforeEach(function() { + gd = createGraphDiv(); + }); + + afterEach(destroyGraphDiv); + + it('\'closest\' for cartesian plots if clickmode includes \'select\'', function(done) { + Plotly.plot(gd, [{ x: [1, 2, 3], y: [4, 5, 6] }], { clickmode: 'event+select' }) + .then(function() { + expect(gd._fullLayout.hovermode).toBe('closest'); + }) + .catch(failTest) + .then(done); + }); + + it('\'x\' for horizontal cartesian plots if clickmode lacks \'select\'', function(done) { + Plotly.plot(gd, [{ x: [1, 2, 3], y: [4, 5, 6], type: 'bar', orientation: 'h' }], { clickmode: 'event' }) + .then(function() { + expect(gd._fullLayout.hovermode).toBe('y'); + }) + .catch(failTest) + .then(done); + }); + + it('\'y\' for vertical cartesian plots if clickmode lacks \'select\'', function(done) { + Plotly.plot(gd, [{ x: [1, 2, 3], y: [4, 5, 6], type: 'bar', orientation: 'v' }], { clickmode: 'event' }) + .then(function() { + expect(gd._fullLayout.hovermode).toBe('x'); + }) + .catch(failTest) + .then(done); + }); + + it('\'closest\' for a non-cartesian plot', function(done) { + var mock = require('@mocks/polar_scatter.json'); + expect(mock.layout.hovermode).toBeUndefined(); + + Plotly.plot(gd, mock.data, mock.layout) + .then(function() { + expect(gd._fullLayout.hovermode).toBe('closest'); + }) + .catch(failTest) + .then(done); + }); +}); diff --git a/test/jasmine/tests/select_test.js b/test/jasmine/tests/select_test.js index 821c7380e96..5e4341763f6 100644 --- a/test/jasmine/tests/select_test.js +++ b/test/jasmine/tests/select_test.js @@ -2,7 +2,9 @@ var d3 = require('d3'); var Plotly = require('@lib/index'); var Lib = require('@src/lib'); +var click = require('../assets/click'); var doubleClick = require('../assets/double_click'); +var DBLCLICKDELAY = require('../../../src/constants/interactions').DBLCLICKDELAY; var createGraphDiv = require('../assets/create_graph_div'); var destroyGraphDiv = require('../assets/destroy_graph_div'); @@ -52,7 +54,7 @@ function assertSelectionNodes(cornerCnt, outlineCnt, _msg) { } var selectingCnt, selectingData, selectedCnt, selectedData, deselectCnt, doubleClickData; -var selectedPromise, deselectPromise; +var selectedPromise, deselectPromise, clickedPromise; function resetEvents(gd) { selectingCnt = 0; @@ -75,7 +77,13 @@ function resetEvents(gd) { }); gd.on('plotly_selected', function(data) { - assertSelectionNodes(0, 2); + // With click-to-select supported, selection nodes are only + // in the DOM in certain circumstances. + if(data && + gd._fullLayout.dragmode.indexOf('select') > -1 && + gd._fullLayout.dragmode.indexOf('lasso') > -1) { + assertSelectionNodes(0, 2); + } selectedCnt++; selectedData = data; resolve(); @@ -90,6 +98,12 @@ function resetEvents(gd) { resolve(); }); }); + + clickedPromise = new Promise(function(resolve) { + gd.on('plotly_click', function() { + resolve(); + }); + }); } function assertEventCounts(selecting, selected, deselect, msg) { @@ -109,6 +123,659 @@ var BOXEVENTS = [1, 2, 1]; // assumes 5 points in the lasso path var LASSOEVENTS = [4, 2, 1]; +var SELECT_PATH = [[93, 193], [143, 193]]; +var LASSO_PATH = [[316, 171], [318, 239], [335, 243], [328, 169]]; + +describe('Click-to-select', function() { + var mock14Pts = { + '1': { x: 134, y: 116 }, + '7': { x: 270, y: 160 }, + '10': { x: 324, y: 198 }, + '35': { x: 685, y: 341 } + }; + var gd; + + beforeEach(function() { + gd = createGraphDiv(); + }); + + afterEach(destroyGraphDiv); + + function plotMock14(layoutOpts) { + var mock = require('@mocks/14.json'); + var defaultLayoutOpts = { + layout: { + clickmode: 'event+select', + dragmode: 'select', + hovermode: 'closest' + } + }; + var mockCopy = Lib.extendDeep( + {}, + mock, + defaultLayoutOpts, + { layout: layoutOpts }); + + return Plotly.plot(gd, mockCopy.data, mockCopy.layout); + } + + /** + * Executes a click and before resets selection event handlers. + * By default, click is executed with a delay to prevent unwanted double clicks. + * Returns the `selectedPromise` promise for convenience. + */ + function _click(x, y, clickOpts, immediate) { + resetEvents(gd); + + // Too fast subsequent calls of `click` would + // produce an unwanted double click, thus we need + // to delay the click. + if(immediate) { + click(x, y, clickOpts); + } else { + setTimeout(function() { + click(x, y, clickOpts); + }, DBLCLICKDELAY * 1.01); + } + + return selectedPromise; + } + + function _clickPt(coords, clickOpts, immediate) { + expect(coords).toBeDefined('coords needs to be defined'); + expect(coords.x).toBeDefined('coords.x needs to be defined'); + expect(coords.y).toBeDefined('coords.y needs to be defined'); + + return _click(coords.x, coords.y, clickOpts, immediate); + } + + /** + * Convenient helper to execute a click immediately. + */ + function _immediateClickPt(coords, clickOpts) { + return _clickPt(coords, clickOpts, true); + } + + /** + * Asserting selected points. + * + * @param expected can be a point number, an array + * of point numbers (for a single trace) or an array of point number + * arrays in case of multiple traces. undefined in an array of arrays + * is also allowed, e.g. useful when not all traces support selection. + */ + function assertSelectedPoints(expected) { + var expectedPtsPerTrace = toArrayOfArrays(expected); + var expectedPts, traceNum; + + for(traceNum = 0; traceNum < expectedPtsPerTrace.length; traceNum++) { + expectedPts = expectedPtsPerTrace[traceNum]; + expect(gd._fullData[traceNum].selectedpoints).toEqual(expectedPts); + expect(gd.data[traceNum].selectedpoints).toEqual(expectedPts); + } + + function toArrayOfArrays(expected) { + var isArrayInArray, i; + + if(Array.isArray(expected)) { + isArrayInArray = false; + for(i = 0; i < expected.length; i++) { + if(Array.isArray(expected[i])) { + isArrayInArray = true; + break; + } + } + + return isArrayInArray ? expected : [expected]; + } else { + return [[expected]]; + } + } + } + + function assertSelectionCleared() { + gd._fullData.forEach(function(fullDataItem) { + expect(fullDataItem.selectedpoints).toBeUndefined(); + }); + } + + it('selects a single data point when being clicked', function(done) { + plotMock14() + .then(function() { return _immediateClickPt(mock14Pts[7]); }) + .then(function() { assertSelectedPoints(7); }) + .catch(failTest) + .then(done); + }); + + describe('clears entire selection when the last selected data point', function() { + [{ + desc: 'is clicked', + clickOpts: {} + }, { + desc: 'is clicked while add/subtract modifier keys are active', + clickOpts: { shiftKey: true } + }].forEach(function(testData) { + it('@flaky ' + testData.desc, function(done) { + plotMock14() + .then(function() { return _immediateClickPt(mock14Pts[7]); }) + .then(function() { + assertSelectedPoints(7); + _clickPt(mock14Pts[7], testData.clickOpts); + return deselectPromise; + }) + .then(function() { + assertSelectionCleared(); + return _clickPt(mock14Pts[35], testData.clickOpts); + }) + .then(function() { + assertSelectedPoints(35); + }) + .catch(failTest) + .then(done); + }); + }); + }); + + it('@flaky cleanly clears and starts selections although add/subtract mode on', function(done) { + plotMock14() + .then(function() { + return _immediateClickPt(mock14Pts[7]); + }) + .then(function() { + assertSelectedPoints(7); + _clickPt(mock14Pts[7], { shiftKey: true }); + return deselectPromise; + }) + .then(function() { + assertSelectionCleared(); + return _clickPt(mock14Pts[35], { shiftKey: true }); + }) + .then(function() { + assertSelectedPoints(35); + }) + .catch(failTest) + .then(done); + }); + + it('@flaky supports adding to an existing selection', function(done) { + plotMock14() + .then(function() { return _immediateClickPt(mock14Pts[7]); }) + .then(function() { + assertSelectedPoints(7); + return _clickPt(mock14Pts[35], { shiftKey: true }); + }) + .then(function() { assertSelectedPoints([7, 35]); }) + .catch(failTest) + .then(done); + }); + + it('@flaky supports subtracting from an existing selection', function(done) { + plotMock14() + .then(function() { return _immediateClickPt(mock14Pts[7]); }) + .then(function() { + assertSelectedPoints(7); + return _clickPt(mock14Pts[35], { shiftKey: true }); + }) + .then(function() { + assertSelectedPoints([7, 35]); + return _clickPt(mock14Pts[7], { shiftKey: true }); + }) + .then(function() { assertSelectedPoints(35); }) + .catch(failTest) + .then(done); + }); + + it('@flaky can be used interchangeably with lasso/box select', function(done) { + plotMock14() + .then(function() { + return _immediateClickPt(mock14Pts[35]); + }) + .then(function() { + assertSelectedPoints(35); + drag(SELECT_PATH, { shiftKey: true }); + }) + .then(function() { + assertSelectedPoints([0, 1, 35]); + return _immediateClickPt(mock14Pts[7], { shiftKey: true }); + }) + .then(function() { + assertSelectedPoints([0, 1, 7, 35]); + return _clickPt(mock14Pts[1], { shiftKey: true }); + }) + .then(function() { + assertSelectedPoints([0, 7, 35]); + return Plotly.relayout(gd, 'dragmode', 'lasso'); + }) + .then(function() { + assertSelectedPoints([0, 7, 35]); + drag(LASSO_PATH, { shiftKey: true }); + }) + .then(function() { + assertSelectedPoints([0, 7, 10, 35]); + return _clickPt(mock14Pts[10], { shiftKey: true }); + }) + .then(function() { + assertSelectedPoints([0, 7, 35]); + drag([[670, 330], [695, 330], [695, 350], [670, 350]], + { shiftKey: true, altKey: true }); + }) + .then(function() { + assertSelectedPoints([0, 7]); + return _clickPt(mock14Pts[35], { shiftKey: true }); + }) + .then(function() { + assertSelectedPoints([0, 7, 35]); + return _clickPt(mock14Pts[7]); + }) + .then(function() { + assertSelectedPoints([7]); + return doubleClick(650, 100); + }) + .then(function() { + assertSelectionCleared(); + }) + .catch(failTest) + .then(done); + }); + + it('@gl works in a multi-trace plot', function(done) { + Plotly.plot(gd, [ + { + x: [1, 3, 5, 4, 10, 12, 12, 7], + y: [2, 7, 6, 1, 0, 13, 6, 12], + type: 'scatter', + mode: 'markers', + marker: { size: 20 } + }, { + x: [1, 7, 6, 2], + y: [2, 3, 5, 4], + type: 'bar' + }, { + x: [7, 8, 9, 10], + y: [7, 9, 13, 21], + type: 'scattergl', + mode: 'markers', + marker: { size: 20 } + } + ], { + width: 400, + height: 600, + hovermode: 'closest', + dragmode: 'select', + clickmode: 'event+select' + }) + .then(function() { + return _click(136, 369, {}, true); }) + .then(function() { + assertSelectedPoints([[1], [], []]); + return _click(245, 136, { shiftKey: true }); + }) + .then(function() { + assertSelectedPoints([[1], [], [3]]); + return _click(183, 470, { shiftKey: true }); + }) + .then(function() { + assertSelectedPoints([[1], [2], [3]]); + }) + .catch(failTest) + .then(done); + }); + + it('@flaky is supported in pan/zoom mode', function(done) { + plotMock14({ dragmode: 'zoom' }) + .then(function() { + return _immediateClickPt(mock14Pts[35]); + }) + .then(function() { + assertSelectedPoints(35); + return _clickPt(mock14Pts[7], { shiftKey: true }); + }) + .then(function() { + assertSelectedPoints([7, 35]); + return _clickPt(mock14Pts[7], { shiftKey: true }); + }) + .then(function() { + assertSelectedPoints(35); + drag(LASSO_PATH); + }) + .then(function() { + assertSelectedPoints(35); + _clickPt(mock14Pts[35], { shiftKey: true }); + return deselectPromise; + }) + .then(function() { + assertSelectionCleared(); + }) + .catch(failTest) + .then(done); + }); + + it('@flaky retains selected points when switching between pan and zoom mode', function(done) { + plotMock14({ dragmode: 'zoom' }) + .then(function() { + return _immediateClickPt(mock14Pts[35]); + }) + .then(function() { + assertSelectedPoints(35); + return Plotly.relayout(gd, 'dragmode', 'pan'); + }) + .then(function() { + assertSelectedPoints(35); + return _clickPt(mock14Pts[7], { shiftKey: true }); + }) + .then(function() { + assertSelectedPoints([7, 35]); + return Plotly.relayout(gd, 'dragmode', 'zoom'); + }) + .then(function() { + assertSelectedPoints([7, 35]); + return _clickPt(mock14Pts[7], { shiftKey: true }); + }) + .then(function() { + assertSelectedPoints(35); + }) + .catch(failTest) + .then(done); + }); + + it('@gl is supported by scattergl in pan/zoom mode', function(done) { + Plotly.plot(gd, [ + { + x: [7, 8, 9, 10], + y: [7, 9, 13, 21], + type: 'scattergl', + mode: 'markers', + marker: { size: 20 } + } + ], { + width: 400, + height: 600, + hovermode: 'closest', + dragmode: 'zoom', + clickmode: 'event+select' + }) + .then(function() { + return _click(230, 340, {}, true); + }) + .then(function() { + assertSelectedPoints(2); + }) + .catch(failTest) + .then(done); + }); + + it('@flaky deals correctly with histogram\'s binning in the persistent selection case', function(done) { + var mock = require('@mocks/histogram_colorscale.json'); + var firstBinPts = [0]; + var secondBinPts = [1, 2]; + var thirdBinPts = [3, 4, 5]; + + mock.layout.clickmode = 'event+select'; + Plotly.plot(gd, mock.data, mock.layout) + .then(function() { + return clickFirstBinImmediately(); + }) + .then(function() { + assertSelectedPoints(firstBinPts); + return shiftClickSecondBin(); + }) + .then(function() { + assertSelectedPoints([].concat(firstBinPts, secondBinPts)); + return shiftClickThirdBin(); + }) + .then(function() { + assertSelectedPoints([].concat(firstBinPts, secondBinPts, thirdBinPts)); + return clickFirstBin(); + }) + .then(function() { + assertSelectedPoints([].concat(firstBinPts)); + clickFirstBin(); + return deselectPromise; + }) + .then(function() { + assertSelectionCleared(); + }) + .catch(failTest) + .then(done); + + function clickFirstBinImmediately() { return _immediateClickPt({ x: 141, y: 358 }); } + function clickFirstBin() { return _click(141, 358); } + function shiftClickSecondBin() { return _click(239, 330, { shiftKey: true }); } + function shiftClickThirdBin() { return _click(351, 347, { shiftKey: true }); } + }); + + it('@flaky ignores clicks on boxes in a box trace type', function(done) { + var mock = Lib.extendDeep({}, require('@mocks/box_grouped_horz.json')); + + mock.layout.clickmode = 'event+select'; + mock.layout.width = 1100; + mock.layout.height = 450; + + Plotly.plot(gd, mock.data, mock.layout) + .then(function() { + return clickPtImmediately(); + }) + .then(function() { + assertSelectedPoints(2); + clickPt(); + return deselectPromise; + }) + .then(function() { + assertSelectionCleared(); + clickBox(); + return clickedPromise; + }) + .then(function() { + assertSelectionCleared(); + }) + .catch(failTest) + .then(done); + + function clickPtImmediately() { return _immediateClickPt({ x: 610, y: 342 }); } + function clickPt() { return _clickPt({ x: 610, y: 342 }); } + function clickBox() { return _clickPt({ x: 565, y: 329 }); } + }); + + describe('is disabled when clickmode does not include \'select\'', function() { + ['select', 'lasso'] + .forEach(function(dragmode) { + it('@flaky and dragmode is ' + dragmode, function(done) { + plotMock14({ clickmode: 'event', dragmode: dragmode }) + .then(function() { + // Still, the plotly_selected event should be thrown, + // so return promise here + return _immediateClickPt(mock14Pts[1]); + }) + .then(function() { + assertSelectionCleared(); + }) + .catch(failTest) + .then(done); + }); + }); + }); + + describe('is disabled when clickmode does not include \'select\'', function() { + ['pan', 'zoom'] + .forEach(function(dragmode) { + it('@flaky and dragmode is ' + dragmode, function(done) { + plotMock14({ clickmode: 'event', dragmode: dragmode }) + .then(function() { + _immediateClickPt(mock14Pts[1]); + return clickedPromise; + }) + .then(function() { + assertSelectionCleared(); + }) + .catch(failTest) + .then(done); + }); + }); + }); + + describe('is supported by', function() { + // On loading mocks: + // - Note, that `require` function calls are resolved at compile time + // and thus dynamically concatenated mock paths won't work. + // - Some mocks don't specify a width and height, so this needs + // to be set explicitly to ensure click coordinates fit. + + // The non-gl traces: use @flaky CI annotation + [ + testCase('histrogram', require('@mocks/histogram_colorscale.json'), 355, 301, [3, 4, 5]), + testCase('box', require('@mocks/box_grouped_horz.json'), 610, 342, [[2], [], []], + { width: 1100, height: 450 }), + testCase('violin', require('@mocks/violin_grouped.json'), 166, 187, [[3], [], []], + { width: 1100, height: 450 }), + testCase('ohlc', require('@mocks/ohlc_first.json'), 669, 165, [9]), + testCase('candlestick', require('@mocks/finance_style.json'), 331, 162, [[], [5]]), + testCase('choropleth', require('@mocks/geo_choropleth-text.json'), 440, 163, [6]), + testCase('scattergeo', require('@mocks/geo_scattergeo-locations.json'), 285, 240, [1]), + testCase('scatterternary', require('@mocks/ternary_markers.json'), 485, 335, [7]), + + // Note that first trace (carpet) in mock doesn't support selection, + // thus undefined is expected + testCase('scattercarpet', require('@mocks/scattercarpet.json'), 532, 178, + [undefined, [], [], [], [], [], [2]], { width: 1100, height: 450 }), + + // scatterpolar and scatterpolargl do not support pan (the default), + // so set dragmode to zoom + testCase('scatterpolar', require('@mocks/polar_scatter.json'), 130, 290, + [[], [], [], [19], [], []], { dragmode: 'zoom' }), + ] + .forEach(function(testCase) { + it('@flaky trace type ' + testCase.label, function(done) { + _run(testCase, done); + }); + }); + + // The gl traces: use @gl CI annotation + [ + testCase('scatterpolargl', require('@mocks/glpolar_scatter.json'), 130, 290, + [[], [], [], [19], [], []], { dragmode: 'zoom' }), + testCase('splom', require('@mocks/splom_lower.json'), 427, 400, [[], [7], []]) + ] + .forEach(function(testCase) { + it('@gl trace type ' + testCase.label, function(done) { + _run(testCase, done); + }); + }); + + // The mapbox traces: use @noCI annotation cause they are usually too resource-intensive + [ + testCase('scattermapbox', require('@mocks/mapbox_0.json'), 650, 195, [[2], []], {}, + { mapboxAccessToken: require('@build/credentials.json').MAPBOX_ACCESS_TOKEN }) + ] + .forEach(function(testCase) { + it('@noCI trace type ' + testCase.label, function(done) { + _run(testCase, done); + }); + }); + + function _run(testCase, doneFn) { + Plotly.plot(gd, testCase.mock.data, testCase.mock.layout, testCase.mock.config) + .then(function() { + return _immediateClickPt(testCase); + }) + .then(function() { + assertSelectedPoints(testCase.expectedPts); + return Plotly.relayout(gd, 'dragmode', 'lasso'); + }) + .then(function() { + _clickPt(testCase); + return deselectPromise; + }) + .then(function() { + assertSelectionCleared(); + return _clickPt(testCase); + }) + .then(function() { + assertSelectedPoints(testCase.expectedPts); + }) + .catch(failTest) + .then(doneFn); + } + }); + + describe('triggers \'plotly_selected\' before \'plotly_click\'', function() { + [ + testCase('cartesian', require('@mocks/14.json'), 270, 160, [7]), + testCase('geo', require('@mocks/geo_scattergeo-locations.json'), 285, 240, [1]), + testCase('ternary', require('@mocks/ternary_markers.json'), 485, 335, [7]), + testCase('polar', require('@mocks/polar_scatter.json'), 130, 290, + [[], [], [], [19], [], []], { dragmode: 'zoom' }) + ].forEach(function(testCase) { + it('@flaky for base plot ' + testCase.label, function(done) { + _run(testCase, done); + }); + }); + + // The mapbox traces: use @noCI annotation cause they are usually too resource-intensive + [ + testCase('mapbox', require('@mocks/mapbox_0.json'), 650, 195, [[2], []], {}, + { mapboxAccessToken: require('@build/credentials.json').MAPBOX_ACCESS_TOKEN }) + ].forEach(function(testCase) { + it('@noCI for base plot ' + testCase.label, function(done) { + _run(testCase, done); + }); + }); + + function _run(testCase, doneFn) { + Plotly.plot(gd, testCase.mock.data, testCase.mock.layout, testCase.mock.config) + .then(function() { + var clickHandlerCalled = false; + var selectedHandlerCalled = false; + + gd.on('plotly_selected', function() { + expect(clickHandlerCalled).toBe(false); + selectedHandlerCalled = true; + }); + gd.on('plotly_click', function() { + clickHandlerCalled = true; + expect(selectedHandlerCalled).toBe(true); + doneFn(); + }); + + return click(testCase.x, testCase.y); + }) + .catch(failTest) + .then(doneFn); + } + }); + + function testCase(label, mock, x, y, expectedPts, layoutOptions, configOptions) { + var defaultLayoutOpts = { + layout: { + clickmode: 'event+select', + dragmode: 'pan', + hovermode: 'closest' + } + }; + var customLayoutOptions = { + layout: layoutOptions + }; + var customConfigOptions = { + config: configOptions + }; + var mockCopy = Lib.extendDeep( + {}, + mock, + defaultLayoutOpts, + customLayoutOptions, + customConfigOptions); + + return { + label: label, + mock: mockCopy, + layoutOptions: layoutOptions, + x: x, + y: y, + expectedPts: expectedPts, + configOptions: configOptions + }; + } +}); + describe('Test select box and lasso in general:', function() { var mock = require('@mocks/14.json'); var selectPath = [[93, 193], [143, 193]]; @@ -143,6 +810,7 @@ describe('Test select box and lasso in general:', function() { describe('select events', function() { var mockCopy = Lib.extendDeep({}, mock); mockCopy.layout.dragmode = 'select'; + mockCopy.layout.hovermode = 'closest'; mockCopy.data[0].ids = mockCopy.data[0].x .map(function(v) { return 'id-' + v; }); mockCopy.data[0].customdata = mockCopy.data[0].y @@ -293,6 +961,7 @@ describe('Test select box and lasso in general:', function() { describe('lasso events', function() { var mockCopy = Lib.extendDeep({}, mock); mockCopy.layout.dragmode = 'lasso'; + mockCopy.layout.hovermode = 'closest'; addInvisible(mockCopy); var gd; @@ -627,6 +1296,43 @@ describe('Test select box and lasso in general:', function() { .then(done); }); + it('should cleanly clear and restart selections on double click when add/subtract mode on', function(done) { + var gd = createGraphDiv(); + var fig = Lib.extendDeep({}, require('@mocks/0.json')); + + fig.layout.dragmode = 'select'; + Plotly.plot(gd, fig) + .then(function() { + return drag([[350, 100], [400, 400]]); + }) + .then(function() { + _assertSelectedPoints([49, 50, 51, 52, 53, 54, 55, 56, 57]); + + // Note: although Shift has no behavioral effect on clearing a selection + // with a double click, users might hold the Shift key by accident. + // This test ensures selection is cleared as expected although + // the Shift key is held and no selection state is retained in any way. + return doubleClick(500, 200, { shiftKey: true }); + }) + .then(function() { + _assertSelectedPoints(null); + return drag([[450, 100], [500, 400]], { shiftKey: true }); + }) + .then(function() { + _assertSelectedPoints([67, 68, 69, 70, 71, 72, 73, 74]); + }) + .catch(failTest) + .then(done); + + function _assertSelectedPoints(selPts) { + if(selPts) { + expect(gd.data[0].selectedpoints).toEqual(selPts); + } else { + expect('selectedpoints' in gd.data[0]).toBe(false); + } + } + }); + it('@flaky should clear selected points on double click only on pan/lasso modes', function(done) { var gd = createGraphDiv(); var fig = Lib.extendDeep({}, require('@mocks/0.json')); @@ -635,6 +1341,7 @@ describe('Test select box and lasso in general:', function() { fig.layout.xaxis.range = [2, 8]; fig.layout.yaxis.autorange = false; fig.layout.yaxis.range = [0, 3]; + fig.layout.hovermode = 'closest'; function _assert(msg, exp) { expect(gd.layout.xaxis.range) @@ -1394,7 +2101,7 @@ describe('Test select box and lasso per trace:', function() { }) .then(function() { return _run( - [[370, 120], [500, 200]], null, [280, 190], NOEVENTS, 'choropleth pan' + [[370, 120], [500, 200]], null, [200, 180], NOEVENTS, 'choropleth pan' ); }) .catch(failTest) @@ -1857,6 +2564,7 @@ describe('Test select box and lasso per trace:', function() { textposition: 'outside' }], { dragmode: 'select', + hovermode: 'closest', showlegend: false, width: 400, height: 400, @@ -1869,7 +2577,7 @@ describe('Test select box and lasso per trace:', function() { assertSelectedPoints({0: [0], 1: [0]}); assertFillOpacity([1, 0.2, 0.2, 1, 0.2, 0.2]); }, - null, BOXEVENTS, 'selecting first scatter/bar text nodes' + [10, 10], BOXEVENTS, 'selecting first scatter/bar text nodes' ); }) .then(function() {