diff --git a/src/components/fx/helpers.js b/src/components/fx/helpers.js index 9ce5acd160b..22caa8847d0 100644 --- a/src/components/fx/helpers.js +++ b/src/components/fx/helpers.js @@ -34,7 +34,7 @@ exports.p2c = function p2c(axArray, v) { }; exports.getDistanceFunction = function getDistanceFunction(mode, dx, dy, dxy) { - if(mode === 'closest') return dxy || quadrature(dx, dy); + if(mode === 'closest') return dxy || exports.quadrature(dx, dy); return mode === 'x' ? dx : dy; }; @@ -77,13 +77,13 @@ exports.inbox = function inbox(v0, v1) { return Infinity; }; -function quadrature(dx, dy) { +exports.quadrature = function quadrature(dx, dy) { return function(di) { var x = dx(di), y = dy(di); return Math.sqrt(x * x + y * y); }; -} +}; /** Appends values inside array attributes corresponding to given point number * diff --git a/src/components/fx/hover.js b/src/components/fx/hover.js index bc5866198bd..cc366193eb3 100644 --- a/src/components/fx/hover.js +++ b/src/components/fx/hover.js @@ -1065,36 +1065,14 @@ function cleanPoint(d, hovermode) { // and convert the x and y label values into objects // formatted as text, with font info - var logOffScale; if(d.xLabelVal !== undefined) { - logOffScale = (d.xa.type === 'log' && d.xLabelVal <= 0); - var xLabelObj = Axes.tickText(d.xa, - d.xa.c2l(logOffScale ? -d.xLabelVal : d.xLabelVal), 'hover'); - if(logOffScale) { - if(d.xLabelVal === 0) d.xLabel = '0'; - else d.xLabel = '-' + xLabelObj.text; - } - // TODO: should we do something special if the axis calendar and - // the data calendar are different? Somehow display both dates with - // their system names? Right now it will just display in the axis calendar - // but users could add the other one as text. - else d.xLabel = xLabelObj.text; + d.xLabel = ('xLabel' in d) ? d.xLabel : Axes.hoverLabelText(d.xa, d.xLabelVal); d.xVal = d.xa.c2d(d.xLabelVal); } - if(d.yLabelVal !== undefined) { - logOffScale = (d.ya.type === 'log' && d.yLabelVal <= 0); - var yLabelObj = Axes.tickText(d.ya, - d.ya.c2l(logOffScale ? -d.yLabelVal : d.yLabelVal), 'hover'); - if(logOffScale) { - if(d.yLabelVal === 0) d.yLabel = '0'; - else d.yLabel = '-' + yLabelObj.text; - } - // TODO: see above TODO - else d.yLabel = yLabelObj.text; + d.yLabel = ('yLabel' in d) ? d.yLabel : Axes.hoverLabelText(d.ya, d.yLabelVal); d.yVal = d.ya.c2d(d.yLabelVal); } - if(d.zLabelVal !== undefined) d.zLabel = String(d.zLabelVal); // for box means and error bars, add the range to the label diff --git a/src/components/fx/index.js b/src/components/fx/index.js index 34d7f711855..dd69c0315e3 100644 --- a/src/components/fx/index.js +++ b/src/components/fx/index.js @@ -35,6 +35,7 @@ module.exports = { getDistanceFunction: helpers.getDistanceFunction, getClosest: helpers.getClosest, inbox: helpers.inbox, + quadrature: helpers.quadrature, appendArrayPointValue: helpers.appendArrayPointValue, castHoverOption: castHoverOption, diff --git a/src/components/modebar/manage.js b/src/components/modebar/manage.js index 8feee485140..783d1bb7f49 100644 --- a/src/components/modebar/manage.js +++ b/src/components/modebar/manage.js @@ -179,6 +179,10 @@ function isSelectable(fullData) { if(scatterSubTypes.hasMarkers(trace) || scatterSubTypes.hasText(trace)) { selectable = true; } + } else if(Registry.traceIs(trace, 'box')) { + if(trace.boxpoints === 'all') { + selectable = true; + } } // assume that in general if the trace module has selectPoints, // then it's selectable. Scatter is an exception to this because it must diff --git a/src/plots/cartesian/axes.js b/src/plots/cartesian/axes.js index 64506c23eb9..e2e7a01e1b7 100644 --- a/src/plots/cartesian/axes.js +++ b/src/plots/cartesian/axes.js @@ -1216,6 +1216,21 @@ axes.tickText = function(ax, x, hover) { return out; }; +axes.hoverLabelText = function(ax, val) { + var logOffScale = (ax.type === 'log' && val <= 0); + var tx = axes.tickText(ax, ax.c2l(logOffScale ? -val : val), 'hover').text; + + if(logOffScale) { + return val === 0 ? '0' : '-' + tx; + } + + // TODO: should we do something special if the axis calendar and + // the data calendar are different? Somehow display both dates with + // their system names? Right now it will just display in the axis calendar + // but users could add the other one as text. + return tx; +}; + function tickTextObj(ax, x, text) { var tf = ax.tickfont || {}; diff --git a/src/traces/bar/select.js b/src/traces/bar/select.js index 309016c72be..34a4e23d310 100644 --- a/src/traces/bar/select.js +++ b/src/traces/bar/select.js @@ -12,6 +12,8 @@ var DESELECTDIM = require('../../constants/interactions').DESELECTDIM; module.exports = function selectPoints(searchInfo, polygon) { var cd = searchInfo.cd; + var xa = searchInfo.xaxis; + var ya = searchInfo.yaxis; var selection = []; var trace = cd[0].trace; var node3 = cd[0].node3; @@ -31,8 +33,8 @@ module.exports = function selectPoints(searchInfo, polygon) { if(polygon.contains(di.ct)) { selection.push({ pointNumber: i, - x: di.x, - y: di.y + x: xa.c2d(di.x), + y: ya.c2d(di.y) }); di.dim = 0; } else { diff --git a/src/traces/box/attributes.js b/src/traces/box/attributes.js index d029069cb2a..baa4e056210 100644 --- a/src/traces/box/attributes.js +++ b/src/traces/box/attributes.js @@ -12,9 +12,8 @@ var scatterAttrs = require('../scatter/attributes'); var colorAttrs = require('../../components/color/attributes'); var extendFlat = require('../../lib/extend').extendFlat; -var scatterMarkerAttrs = scatterAttrs.marker, - scatterMarkerLineAttrs = scatterMarkerAttrs.line; - +var scatterMarkerAttrs = scatterAttrs.marker; +var scatterMarkerLineAttrs = scatterMarkerAttrs.line; module.exports = { y: { @@ -63,6 +62,16 @@ module.exports = { 'missing and the position axis is categorical' ].join(' ') }, + text: extendFlat({}, scatterAttrs.text, { + description: [ + 'Sets the text elements associated with each sample value.', + 'If a single string, the same string appears over', + 'all the data points.', + 'If an array of string, the items are mapped in order to the', + 'this trace\'s (x,y) coordinates.', + 'To be seen, trace `hoverinfo` must contain a *text* flag.' + ].join(' ') + }), whiskerwidth: { valType: 'number', min: 0, @@ -159,9 +168,11 @@ module.exports = { {arrayOk: false, editType: 'style'}), line: { color: extendFlat({}, scatterMarkerLineAttrs.color, - {arrayOk: false, dflt: colorAttrs.defaultLine, editType: 'style'}), + {arrayOk: false, dflt: colorAttrs.defaultLine, editType: 'style'} + ), width: extendFlat({}, scatterMarkerLineAttrs.width, - {arrayOk: false, dflt: 0, editType: 'style'}), + {arrayOk: false, dflt: 0, editType: 'style'} + ), outliercolor: { valType: 'color', role: 'style', @@ -202,5 +213,16 @@ module.exports = { }, editType: 'plot' }, - fillcolor: scatterAttrs.fillcolor + fillcolor: scatterAttrs.fillcolor, + hoveron: { + valType: 'flaglist', + flags: ['boxes', 'points'], + dflt: 'boxes+points', + role: 'info', + editType: 'style', + description: [ + 'Do the hover effects highlight individual boxes ', + 'or sample points or both?' + ].join(' ') + } }; diff --git a/src/traces/box/calc.js b/src/traces/box/calc.js index d6a7ca28c14..601fd6a47b0 100644 --- a/src/traces/box/calc.js +++ b/src/traces/box/calc.js @@ -13,17 +13,17 @@ var isNumeric = require('fast-isnumeric'); var Lib = require('../../lib'); var Axes = require('../../plots/cartesian/axes'); - // outlier definition based on http://www.physics.csbsju.edu/stats/box2.html module.exports = function calc(gd, trace) { - var xa = Axes.getFromId(gd, trace.xaxis || 'x'), - ya = Axes.getFromId(gd, trace.yaxis || 'y'), - orientation = trace.orientation, - cd = [], - valAxis, valLetter, val, valBinned, - posAxis, posLetter, pos, posDistinct, dPos; - - // Set value (val) and position (pos) keys via orientation + var xa = Axes.getFromId(gd, trace.xaxis || 'x'); + var ya = Axes.getFromId(gd, trace.yaxis || 'y'); + var orientation = trace.orientation; + var cd = []; + + var i; + var valAxis, valLetter; + var posAxis, posLetter; + if(orientation === 'h') { valAxis = xa; valLetter = 'x'; @@ -36,112 +36,159 @@ module.exports = function calc(gd, trace) { posLetter = 'x'; } - val = valAxis.makeCalcdata(trace, valLetter); // get val - - // size autorange based on all source points - // position happens afterward when we know all the pos - Axes.expand(valAxis, val, {padded: true}); - - // In vertical (horizontal) box plots: - // if no x (y) data, use x0 (y0), or name - // so if you want one box - // per trace, set x0 (y0) to the x (y) value or category for this trace - // (or set x (y) to a constant array matching y (x)) - function getPos(gd, trace, posLetter, posAxis, val) { - var pos0; - if(posLetter in trace) pos = posAxis.makeCalcdata(trace, posLetter); - else { - if(posLetter + '0' in trace) pos0 = trace[posLetter + '0']; - else if('name' in trace && ( - posAxis.type === 'category' || - (isNumeric(trace.name) && - ['linear', 'log'].indexOf(posAxis.type) !== -1) || - (Lib.isDateTime(trace.name) && - posAxis.type === 'date') - )) { - pos0 = trace.name; - } - else pos0 = gd.numboxes; - pos0 = posAxis.d2c(pos0, 0, trace[posLetter + 'calendar']); - pos = val.map(function() { return pos0; }); - } - return pos; - } - - pos = getPos(gd, trace, posLetter, posAxis, val); + var val = valAxis.makeCalcdata(trace, valLetter); + var pos = getPos(trace, posLetter, posAxis, val, gd.numboxes); - // get distinct positions and min difference var dv = Lib.distinctVals(pos); - posDistinct = dv.vals; - dPos = dv.minDiff / 2; - - function binVal(cd, val, pos, posDistinct, dPos) { - var posDistinctLength = posDistinct.length, - valLength = val.length, - valBinned = [], - bins = [], - i, p, n, v; - - // store distinct pos in cd, find bins, init. valBinned - for(i = 0; i < posDistinctLength; ++i) { - p = posDistinct[i]; - cd[i] = {pos: p}; - bins[i] = p - dPos; - valBinned[i] = []; + var posDistinct = dv.vals; + var dPos = dv.minDiff / 2; + var posBins = makeBins(posDistinct, dPos); + + var vLen = val.length; + var pLen = posDistinct.length; + var ptsPerBin = initNestedArray(pLen); + + // bin pts info per position bins + for(i = 0; i < vLen; i++) { + var v = val[i]; + if(!isNumeric(v)) continue; + + var n = Lib.findBin(pos[i], posBins); + if(n >= 0 && n < pLen) { + var pt = {v: v, i: i}; + arraysToCalcdata(pt, trace, i); + ptsPerBin[n].push(pt); } - bins.push(posDistinct[posDistinctLength - 1] + dPos); - - // bin the values - for(i = 0; i < valLength; ++i) { - v = val[i]; - if(!isNumeric(v)) continue; - n = Lib.findBin(pos[i], bins); - if(n >= 0 && n < valLength) valBinned[n].push(v); - } - - return valBinned; } - valBinned = binVal(cd, val, pos, posDistinct, dPos); - - // sort the bins and calculate the stats - function calculateStats(cd, valBinned) { - var v, l, cdi, i; - - for(i = 0; i < valBinned.length; ++i) { - v = valBinned[i].sort(Lib.sorterAsc); - l = v.length; - cdi = cd[i]; - - cdi.val = v; // put all values into calcdata - cdi.min = v[0]; - cdi.max = v[l - 1]; - cdi.mean = Lib.mean(v, l); - cdi.sd = Lib.stdev(v, l, cdi.mean); - cdi.q1 = Lib.interp(v, 0.25); // first quartile - cdi.med = Lib.interp(v, 0.5); // median - cdi.q3 = Lib.interp(v, 0.75); // third quartile + // build calcdata trace items, one item per distinct position + for(i = 0; i < pLen; i++) { + if(ptsPerBin[i].length > 0) { + var pts = ptsPerBin[i].sort(sortByVal); + var boxVals = pts.map(extractVal); + var bvLen = boxVals.length; + + var cdi = { + pos: posDistinct[i], + pts: pts + }; + + cdi.min = boxVals[0]; + cdi.max = boxVals[bvLen - 1]; + cdi.mean = Lib.mean(boxVals, bvLen); + cdi.sd = Lib.stdev(boxVals, bvLen, cdi.mean); + + // first quartile + cdi.q1 = Lib.interp(boxVals, 0.25); + // median + cdi.med = Lib.interp(boxVals, 0.5); + // third quartile + cdi.q3 = Lib.interp(boxVals, 0.75); + // lower and upper fences - last point inside // 1.5 interquartile ranges from quartiles - cdi.lf = Math.min(cdi.q1, v[ - Math.min(Lib.findBin(2.5 * cdi.q1 - 1.5 * cdi.q3, v, true) + 1, l - 1)]); - cdi.uf = Math.max(cdi.q3, v[ - Math.max(Lib.findBin(2.5 * cdi.q3 - 1.5 * cdi.q1, v), 0)]); + cdi.lf = Math.min( + cdi.q1, + boxVals[Math.min( + Lib.findBin(2.5 * cdi.q1 - 1.5 * cdi.q3, boxVals, true) + 1, + bvLen - 1 + )] + ); + cdi.uf = Math.max( + cdi.q3, + boxVals[Math.max( + Lib.findBin(2.5 * cdi.q3 - 1.5 * cdi.q1, boxVals), + 0 + )] + ); + // lower and upper outliers - 3 IQR out (don't clip to max/min, // this is only for discriminating suspected & far outliers) cdi.lo = 4 * cdi.q1 - 3 * cdi.q3; cdi.uo = 4 * cdi.q3 - 3 * cdi.q1; + + cd.push(cdi); } } - calculateStats(cd, valBinned); - - // remove empty bins - cd = cd.filter(function(cdi) { return cdi.val && cdi.val.length; }); - if(!cd.length) return [{t: {emptybox: true}}]; + Axes.expand(valAxis, val, {padded: true}); - // add numboxes and dPos to cd - cd[0].t = {boxnum: gd.numboxes, dPos: dPos}; - gd.numboxes++; - return cd; + if(cd.length > 0) { + cd[0].t = { + boxnum: gd.numboxes, + dPos: dPos + }; + gd.numboxes++; + return cd; + } else { + return [{t: {emptybox: true}}]; + } }; + +// In vertical (horizontal) box plots: +// if no x (y) data, use x0 (y0), or name +// so if you want one box +// per trace, set x0 (y0) to the x (y) value or category for this trace +// (or set x (y) to a constant array matching y (x)) +function getPos(trace, posLetter, posAxis, val, numboxes) { + if(posLetter in trace) { + return posAxis.makeCalcdata(trace, posLetter); + } + + var pos0; + + if(posLetter + '0' in trace) { + pos0 = trace[posLetter + '0']; + } else if('name' in trace && ( + posAxis.type === 'category' || ( + isNumeric(trace.name) && + ['linear', 'log'].indexOf(posAxis.type) !== -1 + ) || ( + Lib.isDateTime(trace.name) && + posAxis.type === 'date' + ) + )) { + pos0 = trace.name; + } else { + pos0 = numboxes; + } + + var pos0c = posAxis.d2c(pos0, 0, trace[posLetter + 'calendar']); + return val.map(function() { return pos0c; }); +} + +function makeBins(x, dx) { + var len = x.length; + var bins = new Array(len + 1); + + for(var i = 0; i < len; i++) { + bins[i] = x[i] - dx; + } + bins[len] = x[len - 1] + dx; + + return bins; +} + +function initNestedArray(len) { + var arr = new Array(len); + for(var i = 0; i < len; i++) { + arr[i] = []; + } + return arr; +} + +function arraysToCalcdata(pt, trace, i) { + var trace2calc = { + text: 'tx' + }; + + for(var k in trace2calc) { + if(Array.isArray(trace[k])) { + pt[trace2calc[k]] = trace[k][i]; + } + } +} + +function sortByVal(a, b) { return a.v - b.v; } + +function extractVal(o) { return o.v; } diff --git a/src/traces/box/defaults.js b/src/traces/box/defaults.js index e913a66d912..64a926e4af7 100644 --- a/src/traces/box/defaults.js +++ b/src/traces/box/defaults.js @@ -19,9 +19,10 @@ module.exports = function supplyDefaults(traceIn, traceOut, defaultColor, layout return Lib.coerce(traceIn, traceOut, attributes, attr, dflt); } - var y = coerce('y'), - x = coerce('x'), - defaultOrientation; + var y = coerce('y'); + var x = coerce('x'); + + var defaultOrientation; if(y && y.length) { defaultOrientation = 'v'; @@ -40,17 +41,19 @@ module.exports = function supplyDefaults(traceIn, traceOut, defaultColor, layout coerce('orientation', defaultOrientation); coerce('line.color', (traceIn.marker || {}).color || defaultColor); - coerce('line.width', 2); + coerce('line.width'); coerce('fillcolor', Color.addOpacity(traceOut.line.color, 0.5)); coerce('whiskerwidth'); coerce('boxmean'); - var outlierColorDflt = Lib.coerce2(traceIn, traceOut, attributes, 'marker.outliercolor'), - lineoutliercolor = coerce('marker.line.outliercolor'), - boxpoints = outlierColorDflt || - lineoutliercolor ? coerce('boxpoints', 'suspectedoutliers') : - coerce('boxpoints'); + var outlierColorDflt = Lib.coerce2(traceIn, traceOut, attributes, 'marker.outliercolor'); + var lineoutliercolor = coerce('marker.line.outliercolor'); + + var boxpoints = coerce( + 'boxpoints', + (outlierColorDflt || lineoutliercolor) ? 'suspectedoutliers' : undefined + ); if(boxpoints) { coerce('jitter', boxpoints === 'all' ? 0.3 : 0); @@ -67,5 +70,11 @@ module.exports = function supplyDefaults(traceIn, traceOut, defaultColor, layout coerce('marker.line.outliercolor', traceOut.marker.color); coerce('marker.line.outlierwidth'); } + + coerce('text'); + } else { + delete traceOut.marker; } + + coerce('hoveron'); }; diff --git a/src/traces/box/hover.js b/src/traces/box/hover.js index c565e9d0d47..d3668a44575 100644 --- a/src/traces/box/hover.js +++ b/src/traces/box/hover.js @@ -12,96 +12,192 @@ var Axes = require('../../plots/cartesian/axes'); var Lib = require('../../lib'); var Fx = require('../../components/fx'); var Color = require('../../components/color'); +var fillHoverText = require('../scatter/fill_hover_text'); module.exports = function hoverPoints(pointData, xval, yval, hovermode) { - // closest mode: handicap box plots a little relative to others - var cd = pointData.cd, - trace = cd[0].trace, - t = cd[0].t, - xa = pointData.xa, - ya = pointData.ya, - closeData = [], - dx, dy, distfn, boxDelta, - posLetter, posAxis, - val, valLetter, valAxis; - - // adjust inbox w.r.t. to calculate box size - boxDelta = (hovermode === 'closest') ? 2.5 * t.bdPos : t.bdPos; - - if(trace.orientation === 'h') { - dx = function(di) { - return Fx.inbox(di.min - xval, di.max - xval); - }; - dy = function(di) { - var pos = di.pos + t.bPos - yval; - return Fx.inbox(pos - boxDelta, pos + boxDelta); - }; - posLetter = 'y'; - posAxis = ya; - valLetter = 'x'; - valAxis = xa; - } else { - dx = function(di) { - var pos = di.pos + t.bPos - xval; - return Fx.inbox(pos - boxDelta, pos + boxDelta); - }; - dy = function(di) { - return Fx.inbox(di.min - yval, di.max - yval); - }; - posLetter = 'x'; - posAxis = xa; - valLetter = 'y'; - valAxis = ya; - } + var cd = pointData.cd; + var xa = pointData.xa; + var ya = pointData.ya; + + var trace = cd[0].trace; + var hoveron = trace.hoveron; + var marker = trace.marker || {}; + + // output hover points components + var closeBoxData = []; + var closePtData; + // x/y/effective distance functions + var dx, dy, distfn; + // orientation-specific fields + var posLetter, valLetter, posAxis, valAxis; + // calcdata item + var di; + // loop indices + var i, j; + + if(hoveron.indexOf('boxes') !== -1) { + var t = cd[0].t; + + // closest mode: handicap box plots a little relative to others + // adjust inbox w.r.t. to calculate box size + var boxDelta = (hovermode === 'closest') ? 2.5 * t.bdPos : t.bdPos; + + if(trace.orientation === 'h') { + dx = function(di) { + return Fx.inbox(di.min - xval, di.max - xval); + }; + dy = function(di) { + var pos = di.pos + t.bPos - yval; + return Fx.inbox(pos - boxDelta, pos + boxDelta); + }; + posLetter = 'y'; + posAxis = ya; + valLetter = 'x'; + valAxis = xa; + } else { + dx = function(di) { + var pos = di.pos + t.bPos - xval; + return Fx.inbox(pos - boxDelta, pos + boxDelta); + }; + dy = function(di) { + return Fx.inbox(di.min - yval, di.max - yval); + }; + posLetter = 'x'; + posAxis = xa; + valLetter = 'y'; + valAxis = ya; + } + + distfn = Fx.getDistanceFunction(hovermode, dx, dy); + Fx.getClosest(cd, distfn, pointData); + + // skip the rest (for this trace) if we didn't find a close point + // and create the item(s) in closedata for this point + if(pointData.index !== false) { + di = cd[pointData.index]; - distfn = Fx.getDistanceFunction(hovermode, dx, dy); - Fx.getClosest(cd, distfn, pointData); + var lc = trace.line.color; + var mc = marker.color; - // skip the rest (for this trace) if we didn't find a close point - if(pointData.index === false) return; + if(Color.opacity(lc) && trace.line.width) pointData.color = lc; + else if(Color.opacity(mc) && trace.boxpoints) pointData.color = mc; + else pointData.color = trace.fillcolor; - // create the item(s) in closedata for this point + pointData[posLetter + '0'] = posAxis.c2p(di.pos + t.bPos - t.bdPos, true); + pointData[posLetter + '1'] = posAxis.c2p(di.pos + t.bPos + t.bdPos, true); - // the closest data point - var di = cd[pointData.index], - lc = trace.line.color, - mc = (trace.marker || {}).color; - if(Color.opacity(lc) && trace.line.width) pointData.color = lc; - else if(Color.opacity(mc) && trace.boxpoints) pointData.color = mc; - else pointData.color = trace.fillcolor; + Axes.tickText(posAxis, posAxis.c2l(di.pos), 'hover').text; + pointData[posLetter + 'LabelVal'] = di.pos; - pointData[posLetter + '0'] = posAxis.c2p(di.pos + t.bPos - t.bdPos, true); - pointData[posLetter + '1'] = posAxis.c2p(di.pos + t.bPos + t.bdPos, true); + // box plots: each "point" gets many labels + var usedVals = {}; + var attrs = ['med', 'min', 'q1', 'q3', 'max']; + var prefixes = ['median', 'min', 'q1', 'q3', 'max']; - Axes.tickText(posAxis, posAxis.c2l(di.pos), 'hover').text; - pointData[posLetter + 'LabelVal'] = di.pos; + if(trace.boxmean) { + attrs.push('mean'); + prefixes.push(trace.boxmean === 'sd' ? 'mean ± σ' : 'mean'); + } + if(trace.boxpoints) { + attrs.push('lf', 'uf'); + prefixes.push('lower fence', 'upper fence'); + } - // box plots: each "point" gets many labels - var usedVals = {}, - attrs = ['med', 'min', 'q1', 'q3', 'max'], - attr, - pointData2; - if(trace.boxmean) attrs.push('mean'); - if(trace.boxpoints) [].push.apply(attrs, ['lf', 'uf']); + for(i = 0; i < attrs.length; i++) { + var attr = attrs[i]; - for(var i = 0; i < attrs.length; i++) { - attr = attrs[i]; + if(!(attr in di) || (di[attr] in usedVals)) continue; + usedVals[di[attr]] = true; - if(!(attr in di) || (di[attr] in usedVals)) continue; - usedVals[di[attr]] = true; + // copy out to a new object for each value to label + var val = di[attr]; + var valPx = valAxis.c2p(val, true); + var pointData2 = Lib.extendFlat({}, pointData); - // copy out to a new object for each value to label - val = valAxis.c2p(di[attr], true); - pointData2 = Lib.extendFlat({}, pointData); - pointData2[valLetter + '0'] = pointData2[valLetter + '1'] = val; - pointData2[valLetter + 'LabelVal'] = di[attr]; - pointData2.attr = attr; + pointData2[valLetter + '0'] = pointData2[valLetter + '1'] = valPx; + pointData2[valLetter + 'LabelVal'] = val; + pointData2[valLetter + 'Label'] = prefixes[i] + ': ' + Axes.hoverLabelText(valAxis, val); - if(attr === 'mean' && ('sd' in di) && trace.boxmean === 'sd') { - pointData2[valLetter + 'err'] = di.sd; + if(attr === 'mean' && ('sd' in di) && trace.boxmean === 'sd') { + pointData2[valLetter + 'err'] = di.sd; + } + // only keep name on the first item (median) + pointData.name = ''; + + closeBoxData.push(pointData2); + } } - pointData.name = ''; // only keep name on the first item (median) - closeData.push(pointData2); } - return closeData; + + if(hoveron.indexOf('points') !== -1) { + var xPx = xa.c2p(xval); + var yPx = ya.c2p(yval); + + dx = function(di) { + var rad = Math.max(3, di.mrc || 0); + return Math.max(Math.abs(xa.c2p(di.x) - xPx) - rad, 1 - 3 / rad); + }; + dy = function(di) { + var rad = Math.max(3, di.mrc || 0); + return Math.max(Math.abs(ya.c2p(di.y) - yPx) - rad, 1 - 3 / rad); + }; + distfn = Fx.quadrature(dx, dy); + + // show one point per trace + var ijClosest = false; + var pt; + + for(i = 0; i < cd.length; i++) { + di = cd[i]; + + for(j = 0; j < (di.pts || []).length; j++) { + pt = di.pts[j]; + + var newDistance = distfn(pt); + if(newDistance <= pointData.distance) { + pointData.distance = newDistance; + ijClosest = [i, j]; + } + } + } + + if(ijClosest) { + di = cd[ijClosest[0]]; + pt = di.pts[ijClosest[1]]; + + var xc = xa.c2p(pt.x, true); + var yc = ya.c2p(pt.y, true); + var rad = pt.mrc || 1; + + closePtData = Lib.extendFlat({}, pointData, { + // corresponds to index in x/y input data array + index: pt.i, + color: marker.color, + name: trace.name, + x0: xc - rad, + x1: xc + rad, + xLabelVal: pt.x, + y0: yc - rad, + y1: yc + rad, + yLabelVal: pt.y + }); + fillHoverText(pt, trace, closePtData); + } + } + + // In closest mode, show only one point or stats for one box, and points have priority + // If there's a point in range and hoveron has points, show the best single point only. + // If hoveron has boxes and there's no point in range (or hoveron doesn't have points), show the box stats. + if(hovermode === 'closest') { + if(closePtData) return [closePtData]; + return closeBoxData; + } + + // Otherwise in compare mode, allow a point AND the box stats to be labeled + // If there are multiple boxes in range (ie boxmode = 'overlay') we'll see stats for all of them. + if(closePtData) { + closeBoxData.push(closePtData); + return closeBoxData; + } + return closeBoxData; }; diff --git a/src/traces/box/index.js b/src/traces/box/index.js index 82ed9d23097..2294107883b 100644 --- a/src/traces/box/index.js +++ b/src/traces/box/index.js @@ -19,6 +19,7 @@ Box.setPositions = require('./set_positions'); Box.plot = require('./plot'); Box.style = require('./style'); Box.hoverPoints = require('./hover'); +Box.selectPoints = require('./select'); Box.moduleType = 'trace'; Box.name = 'box'; diff --git a/src/traces/box/plot.js b/src/traces/box/plot.js index cc959616974..e3fbfd2b90a 100644 --- a/src/traces/box/plot.js +++ b/src/traces/box/plot.js @@ -13,7 +13,6 @@ var d3 = require('d3'); var Lib = require('../../lib'); var Drawing = require('../../components/drawing'); - // repeatable pseudorandom generator var randSeed = 2000000000; @@ -31,15 +30,13 @@ function rand() { } // constants for dynamic jitter (ie less jitter for sparser points) -var JITTERCOUNT = 5, // points either side of this to include - JITTERSPREAD = 0.01; // fraction of IQR to count as "dense" - +var JITTERCOUNT = 5; // points either side of this to include +var JITTERSPREAD = 0.01; // fraction of IQR to count as "dense" module.exports = function plot(gd, plotinfo, cdbox) { - var fullLayout = gd._fullLayout, - xa = plotinfo.xaxis, - ya = plotinfo.yaxis, - posAxis, valAxis; + var fullLayout = gd._fullLayout; + var xa = plotinfo.xaxis; + var ya = plotinfo.yaxis; var boxtraces = plotinfo.plot.select('.boxlayer') .selectAll('g.trace.boxes') @@ -48,21 +45,26 @@ module.exports = function plot(gd, plotinfo, cdbox) { .attr('class', 'trace boxes'); boxtraces.each(function(d) { - var t = d[0].t, - trace = d[0].trace, - group = (fullLayout.boxmode === 'group' && gd.numboxes > 1), - // box half width - bdPos = t.dPos * (1 - fullLayout.boxgap) * (1 - fullLayout.boxgroupgap) / (group ? gd.numboxes : 1), - // box center offset - bPos = group ? 2 * t.dPos * (-0.5 + (t.boxnum + 0.5) / gd.numboxes) * (1 - fullLayout.boxgap) : 0, - // whisker width - wdPos = bdPos * trace.whiskerwidth; + var cd0 = d[0]; + var t = cd0.t; + var trace = cd0.trace; + var sel = cd0.node3 = d3.select(this); + + var group = (fullLayout.boxmode === 'group' && gd.numboxes > 1); + // box half width + var bdPos = t.dPos * (1 - fullLayout.boxgap) * (1 - fullLayout.boxgroupgap) / (group ? gd.numboxes : 1); + // box center offset + var bPos = group ? 2 * t.dPos * (-0.5 + (t.boxnum + 0.5) / gd.numboxes) * (1 - fullLayout.boxgap) : 0; + // whisker width + var wdPos = bdPos * trace.whiskerwidth; + if(trace.visible !== true || t.emptybox) { d3.select(this).remove(); return; } // set axis via orientation + var posAxis, valAxis; if(trace.orientation === 'h') { posAxis = ya; valAxis = xa; @@ -79,7 +81,7 @@ module.exports = function plot(gd, plotinfo, cdbox) { seed(); // boxes and whiskers - d3.select(this).selectAll('path.box') + sel.selectAll('path.box') .data(Lib.identity) .enter().append('path') .style('vector-effect', 'non-scaling-stroke') @@ -98,6 +100,7 @@ module.exports = function plot(gd, plotinfo, cdbox) { Math.min(q1, q3) + 1, Math.max(q1, q3) - 1), lf = valAxis.c2p(trace.boxpoints === false ? d.min : d.lf, true), uf = valAxis.c2p(trace.boxpoints === false ? d.max : d.uf, true); + if(trace.orientation === 'h') { d3.select(this).attr('d', 'M' + m + ',' + pos0 + 'V' + pos1 + // median line @@ -117,7 +120,7 @@ module.exports = function plot(gd, plotinfo, cdbox) { // draw points, if desired if(trace.boxpoints) { - d3.select(this).selectAll('g.points') + sel.selectAll('g.points') // since box plot points get an extra level of nesting, each // box needs the trace styling info .data(function(d) { @@ -131,20 +134,19 @@ module.exports = function plot(gd, plotinfo, cdbox) { .attr('class', 'points') .selectAll('path') .data(function(d) { - var pts = (trace.boxpoints === 'all') ? d.val : - d.val.filter(function(v) { return (v < d.lf || v > d.uf); }), - // normally use IQR, but if this is 0 or too small, use max-min - typicalSpread = Math.max((d.max - d.min) / 10, d.q3 - d.q1), - minSpread = typicalSpread * 1e-9, - spreadLimit = typicalSpread * JITTERSPREAD, - jitterFactors = [], - maxJitterFactor = 0, - i, - i0, i1, - pmin, - pmax, - jitterFactor, - newJitter; + var i; + + var pts = trace.boxpoints === 'all' ? + d.pts : + d.pts.filter(function(pt) { return (pt.v < d.lf || pt.v > d.uf); }); + + // normally use IQR, but if this is 0 or too small, use max-min + var typicalSpread = Math.max((d.max - d.min) / 10, d.q3 - d.q1); + var minSpread = typicalSpread * 1e-9; + var spreadLimit = typicalSpread * JITTERSPREAD; + var jitterFactors = []; + var maxJitterFactor = 0; + var newJitter; // dynamic jitter if(trace.jitter) { @@ -155,20 +157,19 @@ module.exports = function plot(gd, plotinfo, cdbox) { for(i = 0; i < pts.length; i++) { jitterFactors[i] = 1; } - } - else { + } else { for(i = 0; i < pts.length; i++) { - i0 = Math.max(0, i - JITTERCOUNT); - pmin = pts[i0]; - i1 = Math.min(pts.length - 1, i + JITTERCOUNT); - pmax = pts[i1]; + var i0 = Math.max(0, i - JITTERCOUNT); + var pmin = pts[i0].v; + var i1 = Math.min(pts.length - 1, i + JITTERCOUNT); + var pmax = pts[i1].v; if(trace.boxpoints !== 'all') { - if(pts[i] < d.lf) pmax = Math.min(pmax, d.lf); + if(pts[i].v < d.lf) pmax = Math.min(pmax, d.lf); else pmin = Math.max(pmin, d.uf); } - jitterFactor = Math.sqrt(spreadLimit * (i1 - i0) / (pmax - pmin + minSpread)) || 0; + var jitterFactor = Math.sqrt(spreadLimit * (i1 - i0) / (pmax - pmin + minSpread)) || 0; jitterFactor = Lib.constrain(Math.abs(jitterFactor), 0, 1); jitterFactors.push(jitterFactor); @@ -178,39 +179,41 @@ module.exports = function plot(gd, plotinfo, cdbox) { newJitter = trace.jitter * 2 / maxJitterFactor; } - return pts.map(function(v, i) { - var posOffset = trace.pointpos, - p; - if(trace.jitter) { - posOffset += newJitter * jitterFactors[i] * (rand() - 0.5); - } + // fills in 'x' and 'y' in calcdata 'pts' item + for(i = 0; i < pts.length; i++) { + var pt = pts[i]; + var v = pt.v; + + var jitterOffset = trace.jitter ? + (newJitter * jitterFactors[i] * (rand() - 0.5)) : + 0; + + var posPx = d.pos + bPos + bdPos * (trace.pointpos + jitterOffset); if(trace.orientation === 'h') { - p = { - y: d.pos + posOffset * bdPos + bPos, - x: v - }; + pt.y = posPx; + pt.x = v; } else { - p = { - x: d.pos + posOffset * bdPos + bPos, - y: v - }; + pt.x = posPx; + pt.y = v; } // tag suspected outliers if(trace.boxpoints === 'suspectedoutliers' && v < d.uo && v > d.lo) { - p.so = true; + pt.so = true; } - return p; - }); + } + + return pts; }) .enter().append('path') .classed('point', true) .call(Drawing.translatePoints, xa, ya); } + // draw mean (and stdev diamond) if desired if(trace.boxmean) { - d3.select(this).selectAll('path.mean') + sel.selectAll('path.mean') .data(Lib.identity) .enter().append('path') .attr('class', 'mean') diff --git a/src/traces/box/select.js b/src/traces/box/select.js new file mode 100644 index 00000000000..755246dd20a --- /dev/null +++ b/src/traces/box/select.js @@ -0,0 +1,57 @@ +/** +* Copyright 2012-2017, 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'; + +var DESELECTDIM = require('../../constants/interactions').DESELECTDIM; + +module.exports = function selectPoints(searchInfo, polygon) { + var cd = searchInfo.cd; + var xa = searchInfo.xaxis; + var ya = searchInfo.yaxis; + var trace = cd[0].trace; + var node3 = cd[0].node3; + var selection = []; + var i, j; + + if(trace.visible !== true) return []; + + if(polygon === false) { + for(i = 0; i < cd.length; i++) { + for(j = 0; j < (cd[i].pts || []).length; j++) { + // clear selection + cd[i].pts[j].dim = 0; + } + } + } else { + for(i = 0; i < cd.length; i++) { + for(j = 0; j < (cd[i].pts || []).length; j++) { + var pt = cd[i].pts[j]; + var x = xa.c2p(pt.x); + var y = ya.c2p(pt.y); + + if(polygon.contains([x, y])) { + selection.push({ + pointNumber: pt.i, + x: xa.c2d(pt.x), + y: ya.c2d(pt.y) + }); + pt.dim = 0; + } else { + pt.dim = 1; + } + } + } + } + + node3.selectAll('.point').style('opacity', function(d) { + return d.dim ? DESELECTDIM : 1; + }); + + return selection; +}; diff --git a/src/traces/scatter/select.js b/src/traces/scatter/select.js index a529a4c21e6..1663848439e 100644 --- a/src/traces/scatter/select.js +++ b/src/traces/scatter/select.js @@ -42,8 +42,8 @@ module.exports = function selectPoints(searchInfo, polygon) { if(polygon.contains([x, y])) { selection.push({ pointNumber: i, - x: di.x, - y: di.y + x: xa.c2d(di.x), + y: ya.c2d(di.y) }); di.dim = 0; } diff --git a/src/traces/scattercarpet/select.js b/src/traces/scattercarpet/select.js index 5682b0e1669..ff40c61a271 100644 --- a/src/traces/scattercarpet/select.js +++ b/src/traces/scattercarpet/select.js @@ -24,7 +24,6 @@ module.exports = function selectPoints(searchInfo, polygon) { cdi = cd[pt.pointNumber]; pt.a = cdi.a; pt.b = cdi.b; - pt.c = cdi.c; delete pt.x; delete pt.y; } diff --git a/src/traces/scattergl/select.js b/src/traces/scattergl/select.js index e4187fb9eda..22ff3716767 100644 --- a/src/traces/scattergl/select.js +++ b/src/traces/scattergl/select.js @@ -41,8 +41,8 @@ module.exports = function selectPoints(searchInfo, polygon) { if(polygon.contains([x, y])) { selection.push({ pointNumber: i, - x: di.x, - y: di.y + x: xa.c2d(di.x), + y: ya.c2d(di.y) }); di.dim = 0; } diff --git a/test/jasmine/tests/box_test.js b/test/jasmine/tests/box_test.js index c292fc356c9..0c56796937f 100644 --- a/test/jasmine/tests/box_test.js +++ b/test/jasmine/tests/box_test.js @@ -1,112 +1,318 @@ +var Plotly = require('@lib'); +var Lib = require('@src/lib'); + var Box = require('@src/traces/box'); +var createGraphDiv = require('../assets/create_graph_div'); +var destroyGraphDiv = require('../assets/destroy_graph_div'); +var fail = require('../assets/fail_test'); +var mouseEvent = require('../assets/mouse_event'); -describe('Test boxes', function() { - 'use strict'; +var customAssertions = require('../assets/custom_assertions'); +var assertHoverLabelContent = customAssertions.assertHoverLabelContent; - describe('supplyDefaults', function() { - var traceIn, - traceOut; +describe('Test boxes supplyDefaults', function() { + var traceIn; + var traceOut; + var defaultColor = '#444'; + var supplyDefaults = Box.supplyDefaults; - var defaultColor = '#444'; + beforeEach(function() { + traceOut = {}; + }); - var supplyDefaults = Box.supplyDefaults; + it('should set visible to false when x and y are empty', function() { + traceIn = {}; + supplyDefaults(traceIn, traceOut, defaultColor); + expect(traceOut.visible).toBe(false); - beforeEach(function() { - traceOut = {}; - }); + traceIn = { + x: [], + y: [] + }; + supplyDefaults(traceIn, traceOut, defaultColor, {}); + expect(traceOut.visible).toBe(false); + }); - it('should set visible to false when x and y are empty', function() { - traceIn = {}; - supplyDefaults(traceIn, traceOut, defaultColor); - expect(traceOut.visible).toBe(false); - - traceIn = { - x: [], - y: [] - }; - supplyDefaults(traceIn, traceOut, defaultColor, {}); - expect(traceOut.visible).toBe(false); - }); + it('should set visible to false when x or y is empty', function() { + traceIn = { + x: [] + }; + supplyDefaults(traceIn, traceOut, defaultColor, {}); + expect(traceOut.visible).toBe(false); - it('should set visible to false when x or y is empty', function() { - traceIn = { - x: [] - }; - supplyDefaults(traceIn, traceOut, defaultColor, {}); - expect(traceOut.visible).toBe(false); - - traceIn = { - x: [], - y: [1, 2, 3] - }; - supplyDefaults(traceIn, traceOut, defaultColor, {}); - expect(traceOut.visible).toBe(false); - - traceIn = { - y: [] - }; - supplyDefaults(traceIn, traceOut, defaultColor, {}); - expect(traceOut.visible).toBe(false); - - traceIn = { - x: [1, 2, 3], - y: [] - }; - supplyDefaults(traceIn, traceOut, defaultColor, {}); - expect(traceOut.visible).toBe(false); - }); + traceIn = { + x: [], + y: [1, 2, 3] + }; + supplyDefaults(traceIn, traceOut, defaultColor, {}); + expect(traceOut.visible).toBe(false); - it('should set orientation to v by default', function() { - traceIn = { - y: [1, 2, 3] - }; - supplyDefaults(traceIn, traceOut, defaultColor, {}); - expect(traceOut.orientation).toBe('v'); - - traceIn = { - x: [1, 1, 1], - y: [1, 2, 3] - }; - supplyDefaults(traceIn, traceOut, defaultColor, {}); - expect(traceOut.orientation).toBe('v'); - }); + traceIn = { + y: [] + }; + supplyDefaults(traceIn, traceOut, defaultColor, {}); + expect(traceOut.visible).toBe(false); - it('should set orientation to h when only x is supplied', function() { - traceIn = { - x: [1, 2, 3] - }; - supplyDefaults(traceIn, traceOut, defaultColor, {}); - expect(traceOut.orientation).toBe('h'); + traceIn = { + x: [1, 2, 3], + y: [] + }; + supplyDefaults(traceIn, traceOut, defaultColor, {}); + expect(traceOut.visible).toBe(false); + }); - }); + it('should set orientation to v by default', function() { + traceIn = { + y: [1, 2, 3] + }; + supplyDefaults(traceIn, traceOut, defaultColor, {}); + expect(traceOut.orientation).toBe('v'); - it('should inherit layout.calendar', function() { - traceIn = { - y: [1, 2, 3] - }; - supplyDefaults(traceIn, traceOut, defaultColor, {calendar: 'islamic'}); + traceIn = { + x: [1, 1, 1], + y: [1, 2, 3] + }; + supplyDefaults(traceIn, traceOut, defaultColor, {}); + expect(traceOut.orientation).toBe('v'); + }); - // we always fill calendar attributes, because it's hard to tell if - // we're on a date axis at this point. - expect(traceOut.xcalendar).toBe('islamic'); - expect(traceOut.ycalendar).toBe('islamic'); - }); + it('should set orientation to h when only x is supplied', function() { + traceIn = { + x: [1, 2, 3] + }; + supplyDefaults(traceIn, traceOut, defaultColor, {}); + expect(traceOut.orientation).toBe('h'); - it('should take its own calendars', function() { - traceIn = { - y: [1, 2, 3], - xcalendar: 'coptic', - ycalendar: 'ethiopian' - }; - supplyDefaults(traceIn, traceOut, defaultColor, {calendar: 'islamic'}); - - // we always fill calendar attributes, because it's hard to tell if - // we're on a date axis at this point. - expect(traceOut.xcalendar).toBe('coptic'); - expect(traceOut.ycalendar).toBe('ethiopian'); - }); + }); + + it('should inherit layout.calendar', function() { + traceIn = { + y: [1, 2, 3] + }; + supplyDefaults(traceIn, traceOut, defaultColor, {calendar: 'islamic'}); + // we always fill calendar attributes, because it's hard to tell if + // we're on a date axis at this point. + expect(traceOut.xcalendar).toBe('islamic'); + expect(traceOut.ycalendar).toBe('islamic'); }); + it('should take its own calendars', function() { + traceIn = { + y: [1, 2, 3], + xcalendar: 'coptic', + ycalendar: 'ethiopian' + }; + supplyDefaults(traceIn, traceOut, defaultColor, {calendar: 'islamic'}); + + // we always fill calendar attributes, because it's hard to tell if + // we're on a date axis at this point. + expect(traceOut.xcalendar).toBe('coptic'); + expect(traceOut.ycalendar).toBe('ethiopian'); + }); + + it('should not coerce point attributes when boxpoints is false', function() { + traceIn = { + y: [1, 1, 2], + boxpoints: false + }; + supplyDefaults(traceIn, traceOut, defaultColor, {}); + + expect(traceOut.boxpoints).toBe(false); + expect(traceOut.jitter).toBeUndefined(); + expect(traceOut.pointpos).toBeUndefined(); + expect(traceOut.marker).toBeUndefined(); + expect(traceOut.text).toBeUndefined(); + }); + + it('should default boxpoints to suspectedoutliers when marker.outliercolor is set & valid', function() { + traceIn = { + y: [1, 1, 2], + marker: { + outliercolor: 'blue' + } + }; + supplyDefaults(traceIn, traceOut, defaultColor, {}); + expect(traceOut.boxpoints).toBe('suspectedoutliers'); + }); + + it('should default boxpoints to suspectedoutliers when marker.line.outliercolor is set & valid', function() { + traceIn = { + y: [1, 1, 2], + marker: { + line: {outliercolor: 'blue'} + } + }; + supplyDefaults(traceIn, traceOut, defaultColor, {}); + expect(traceOut.boxpoints).toBe('suspectedoutliers'); + expect(traceOut.marker).toBeDefined(); + expect(traceOut.text).toBeDefined(); + }); +}); + +describe('Test box hover:', function() { + var gd; + + afterEach(destroyGraphDiv); + + function run(specs) { + gd = createGraphDiv(); + + var fig = Lib.extendDeep( + {width: 700, height: 500}, + specs.mock || require('@mocks/box_grouped.json') + ); + + if(specs.patch) { + fig = specs.patch(fig); + } + + var pos = specs.pos || [200, 200]; + + return Plotly.plot(gd, fig).then(function() { + mouseEvent('mousemove', pos[0], pos[1]); + assertHoverLabelContent(specs); + }); + } + + [{ + desc: 'base', + nums: ['median: 0.55', 'min: 0', 'q1: 0.3', 'q3: 0.6', 'max: 0.7'], + name: ['radishes', '', '', '', ''], + axis: 'day 1' + }, { + desc: 'with mean', + patch: function(fig) { + fig.data.forEach(function(trace) { + trace.boxmean = true; + }); + return fig; + }, + nums: ['median: 0.55', 'min: 0', 'q1: 0.3', 'q3: 0.6', 'max: 0.7', 'mean: 0.45'], + name: ['radishes', '', '', '', '', ''], + axis: 'day 1' + }, { + desc: 'with sd', + patch: function(fig) { + fig.data.forEach(function(trace) { + trace.boxmean = 'sd'; + }); + return fig; + }, + nums: [ + 'median: 0.55', 'min: 0', 'q1: 0.3', 'q3: 0.6', 'max: 0.7', + 'mean ± σ: 0.45 ± 0.2362908' + ], + name: ['radishes', '', '', '', '', ''], + axis: 'day 1' + }, { + desc: 'with boxpoints fences', + mock: require('@mocks/boxplots_outliercolordflt.json'), + pos: [350, 200], + nums: [ + 'median: 8.15', 'min: 0.75', 'q1: 6.8', + 'q3: 10.25', 'max: 23.25', 'lower fence: 5.25', 'upper fence: 12' + ], + name: ['', '', '', '', '', '', ''], + axis: 'trace 0' + }, { + desc: 'with overlaid boxes', + patch: function(fig) { + fig.layout.boxmode = 'overlay'; + return fig; + }, + nums: [ + 'q1: 0.3', 'median: 0.45', 'q3: 0.6', 'max: 1', 'median: 0.55', 'min: 0.2', + 'q3: 0.6', 'max: 0.7', 'median: 0.45', 'min: 0.1', 'q3: 0.6', 'max: 0.9' + ], + name: [ + '', 'kale', '', '', 'radishes', '', + '', '', 'carrots', '', '', '' + ], + axis: 'day 1' + }, { + desc: 'hoveron points | hovermode closest', + patch: function(fig) { + fig.data.forEach(function(trace) { + trace.boxpoints = 'all'; + trace.hoveron = 'points'; + }); + fig.layout.hovermode = 'closest'; + return fig; + }, + nums: '(day 1, 0.7)', + name: 'radishes' + }, { + desc: 'hoveron points | hovermode x', + patch: function(fig) { + fig.data.forEach(function(trace) { + trace.boxpoints = 'all'; + trace.hoveron = 'points'; + }); + fig.layout.hovermode = 'x'; + return fig; + }, + nums: '0.7', + name: 'radishes', + axis: 'day 1' + }, { + desc: 'hoveron boxes+points | hovermode x (hover on box only - same result as base)', + patch: function(fig) { + fig.data.forEach(function(trace) { + trace.boxpoints = 'all'; + trace.hoveron = 'points+boxes'; + }); + fig.layout.hovermode = 'x'; + return fig; + }, + nums: ['median: 0.55', 'min: 0', 'q1: 0.3', 'q3: 0.6', 'max: 0.7'], + name: ['radishes', '', '', '', ''], + axis: 'day 1' + }, { + desc: 'hoveron boxes+points | hovermode x (box AND closest point)', + patch: function(fig) { + fig.data.forEach(function(trace) { + trace.boxpoints = 'all'; + trace.hoveron = 'points+boxes'; + trace.pointpos = 0; + }); + fig.layout.hovermode = 'x'; + return fig; + }, + nums: ['0.6', 'median: 0.55', 'min: 0', 'q1: 0.3', 'q3: 0.6', 'max: 0.7'], + name: ['radishes', 'radishes', '', '', '', ''], + axis: 'day 1' + }, { + desc: 'text items on hover', + patch: function(fig) { + fig.data.forEach(function(trace) { + trace.boxpoints = 'all'; + trace.hoveron = 'points'; + trace.text = trace.y.map(function(v) { return 'look:' + v; }); + }); + fig.layout.hovermode = 'closest'; + return fig; + }, + nums: '(day 1, 0.7)\nlook:0.7', + name: 'radishes' + }, { + desc: 'only text items on hover', + patch: function(fig) { + fig.data.forEach(function(trace) { + trace.boxpoints = 'all'; + trace.hoveron = 'points'; + trace.text = trace.y.map(function(v) { return 'look:' + v; }); + trace.hoverinfo = 'text'; + }); + fig.layout.hovermode = 'closest'; + return fig; + }, + nums: 'look:0.7', + name: '' + }].forEach(function(specs) { + it('should generate correct hover labels ' + specs.desc, function(done) { + run(specs).catch(fail).then(done); + }); + }); }); diff --git a/test/jasmine/tests/modebar_test.js b/test/jasmine/tests/modebar_test.js index 3246769b229..1b37fd74b25 100644 --- a/test/jasmine/tests/modebar_test.js +++ b/test/jasmine/tests/modebar_test.js @@ -154,7 +154,6 @@ describe('ModeBar', function() { }); describe('manageModeBar', function() { - function getButtons(list) { for(var i = 0; i < list.length; i++) { for(var j = 0; j < list[i].length; j++) { @@ -174,9 +173,9 @@ describe('ModeBar', function() { }); expect(modeBar.hasButtons(buttons)).toBe(true, 'modeBar.hasButtons'); - expect(countGroups(modeBar)).toEqual(expectedGroupCount, 'correct group count'); - expect(countButtons(modeBar)).toEqual(expectedButtonCount, 'correct button count'); - expect(countLogo(modeBar)).toEqual(1, 'correct logo count'); + expect(countGroups(modeBar)).toBe(expectedGroupCount, 'correct group count'); + expect(countButtons(modeBar)).toBe(expectedButtonCount, 'correct button count'); + expect(countLogo(modeBar)).toBe(1, 'correct logo count'); } it('creates mode bar (unselectable cartesian version)', function() { @@ -197,7 +196,7 @@ describe('ModeBar', function() { checkButtons(modeBar, buttons, 1); }); - it('creates mode bar (selectable cartesian version)', function() { + it('creates mode bar (selectable scatter version)', function() { var buttons = getButtons([ ['toImage', 'sendDataToCloud'], ['zoom2d', 'pan2d', 'select2d', 'lasso2d'], @@ -221,6 +220,30 @@ describe('ModeBar', function() { checkButtons(modeBar, buttons, 1); }); + it('creates mode bar (selectable box version)', function() { + var buttons = getButtons([ + ['toImage', 'sendDataToCloud'], + ['zoom2d', 'pan2d', 'select2d', 'lasso2d'], + ['zoomIn2d', 'zoomOut2d', 'autoScale2d', 'resetScale2d'], + ['toggleSpikelines', 'hoverClosestCartesian', 'hoverCompareCartesian'] + ]); + + var gd = getMockGraphInfo(); + gd._fullLayout._basePlotModules = [{ name: 'cartesian' }]; + gd._fullLayout.xaxis = {fixedrange: false}; + gd._fullData = [{ + type: 'box', + visible: true, + boxpoints: 'all', + _module: {selectPoints: true} + }]; + + manageModeBar(gd); + var modeBar = gd._fullLayout._modeBar; + + checkButtons(modeBar, buttons, 1); + }); + it('creates mode bar (cartesian fixed-axes version)', function() { var buttons = getButtons([ ['toImage', 'sendDataToCloud'], diff --git a/test/jasmine/tests/select_test.js b/test/jasmine/tests/select_test.js index 06f946bcb9b..4840f878ba8 100644 --- a/test/jasmine/tests/select_test.js +++ b/test/jasmine/tests/select_test.js @@ -809,6 +809,63 @@ describe('Test select box and lasso per trace:', function() { .then(done); }); + it('should work for date/category traces', function(done) { + var assertPoints = makeAssertPoints(['curveNumber', 'x', 'y']); + + var fig = { + data: [{ + x: ['2017-01-01', '2017-02-01', '2017-03-01'], + y: ['a', 'b', 'c'] + }, { + type: 'bar', + x: ['2017-01-01', '2017-02-02', '2017-03-01'], + y: ['a', 'b', 'c'] + }], + layout: { + dragmode: 'lasso', + width: 400, + height: 400 + } + }; + addInvisible(fig); + + var x0 = 100; + var y0 = 100; + var x1 = 250; + var y1 = 250; + + Plotly.plot(gd, fig) + .then(function() { + return _run( + [[x0, y0], [x1, y0], [x1, y1], [x0, y1], [x0, y0]], + function() { + assertPoints([ + [0, '2017-02-01', 'b'], + [1, '2017-02-02', 'b'] + ]); + }, + null, LASSOEVENTS, 'date/category lasso' + ); + }) + .then(function() { + return Plotly.relayout(gd, 'dragmode', 'select'); + }) + .then(function() { + return _run( + [[x0, y0], [x1, y1]], + function() { + assertPoints([ + [0, '2017-02-01', 'b'], + [1, '2017-02-02', 'b'] + ]); + }, + null, BOXEVENTS, 'date/category select' + ); + }) + .catch(fail) + .then(done); + }); + it('should work for histogram traces', function(done) { var assertPoints = makeAssertPoints(['curveNumber', 'x', 'y']); var assertRanges = makeAssertRanges(); @@ -848,7 +905,60 @@ describe('Test select box and lasso per trace:', function() { ]); assertRanges([[1.66, 3.59], [0.69, 2.17]]); }, - null, BOXEVENTS, 'bar select' + null, BOXEVENTS, 'histogram select' + ); + }) + .catch(fail) + .then(done); + }); + + it('should work for box traces', function(done) { + var assertPoints = makeAssertPoints(['curveNumber', 'y', 'x']); + var assertRanges = makeAssertRanges(); + var assertLassoPoints = makeAssertLassoPoints(); + + var fig = Lib.extendDeep({}, require('@mocks/box_grouped')); + fig.data.forEach(function(trace) { + trace.boxpoints = 'all'; + }); + fig.layout.dragmode = 'lasso'; + fig.layout.width = 600; + fig.layout.height = 500; + addInvisible(fig); + + Plotly.plot(gd, fig) + .then(function() { + return _run( + [[200, 200], [400, 200], [400, 350], [200, 350], [200, 200]], + function() { + assertPoints([ + [0, 0.2, 'day 2'], [0, 0.3, 'day 2'], [0, 0.5, 'day 2'], [0, 0.7, 'day 2'], + [1, 0.2, 'day 2'], [1, 0.5, 'day 2'], [1, 0.7, 'day 2'], [1, 0.7, 'day 2'], + [2, 0.3, 'day 1'], [2, 0.6, 'day 1'], [2, 0.6, 'day 1'] + ]); + assertLassoPoints([ + ['day 1', 'day 2', 'day 2', 'day 1', 'day 1'], + [0.71, 0.71, 0.1875, 0.1875, 0.71] + ]); + }, + null, LASSOEVENTS, 'box lasso' + ); + }) + .then(function() { + return Plotly.relayout(gd, 'dragmode', 'select'); + }) + .then(function() { + return _run( + [[200, 200], [400, 350]], + function() { + assertPoints([ + [0, 0.2, 'day 2'], [0, 0.3, 'day 2'], [0, 0.5, 'day 2'], [0, 0.7, 'day 2'], + [1, 0.2, 'day 2'], [1, 0.5, 'day 2'], [1, 0.7, 'day 2'], [1, 0.7, 'day 2'], + [2, 0.3, 'day 1'], [2, 0.6, 'day 1'], [2, 0.6, 'day 1'] + ]); + assertRanges([['day 1', 'day 2'], [0.1875, 0.71]]); + }, + null, BOXEVENTS, 'box select' ); }) .catch(fail)