diff --git a/lib/index-cartesian.js b/lib/index-cartesian.js index 4d07f5f5f09..5818a717748 100644 --- a/lib/index-cartesian.js +++ b/lib/index-cartesian.js @@ -19,7 +19,8 @@ Plotly.register([ require('./histogram2dcontour'), require('./pie'), require('./contour'), - require('./scatterternary') + require('./scatterternary'), + require('./violin') ]); module.exports = Plotly; diff --git a/lib/index.js b/lib/index.js index b64ed888557..7dac73a1f64 100644 --- a/lib/index.js +++ b/lib/index.js @@ -21,7 +21,7 @@ Plotly.register([ require('./pie'), require('./contour'), require('./scatterternary'), - require('./sankey'), + require('./violin'), require('./scatter3d'), require('./surface'), @@ -34,10 +34,13 @@ Plotly.register([ require('./pointcloud'), require('./heatmapgl'), require('./parcoords'), - require('./table'), require('./scattermapbox'), + require('./sankey'), + + require('./table'), + require('./carpet'), require('./scattercarpet'), require('./contourcarpet'), diff --git a/lib/violin.js b/lib/violin.js new file mode 100644 index 00000000000..d1d0ea51c3f --- /dev/null +++ b/lib/violin.js @@ -0,0 +1,11 @@ +/** +* 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'; + +module.exports = require('../src/traces/violin'); diff --git a/src/components/fx/hover.js b/src/components/fx/hover.js index fc219542914..37d80fbc126 100644 --- a/src/components/fx/hover.js +++ b/src/components/fx/hover.js @@ -381,7 +381,7 @@ function _hover(gd, evt, subplot, noHoverEvent) { // Now find the points. if(trace._module && trace._module.hoverPoints) { - var newPoints = trace._module.hoverPoints(pointData, xval, yval, mode); + var newPoints = trace._module.hoverPoints(pointData, xval, yval, mode, fullLayout._hoverlayer); if(newPoints) { var newPoint; for(var newPointNum = 0; newPointNum < newPoints.length; newPointNum++) { diff --git a/src/components/legend/style.js b/src/components/legend/style.js index c6037e79d07..08199c2a888 100644 --- a/src/components/legend/style.js +++ b/src/components/legend/style.js @@ -197,7 +197,7 @@ module.exports = function style(s, gd) { var trace = d[0].trace, pts = d3.select(this).select('g.legendpoints') .selectAll('path.legendbox') - .data(Registry.traceIs(trace, 'box') && trace.visible ? [d] : []); + .data(Registry.traceIs(trace, 'box-violin') && trace.visible ? [d] : []); pts.enter().append('path').classed('legendbox', true) // if we want the median bar, prepend M6,0H-6 .attr('d', 'M6,6H-6V-6H6Z') diff --git a/src/components/modebar/manage.js b/src/components/modebar/manage.js index 783d1bb7f49..bedee3dcb7d 100644 --- a/src/components/modebar/manage.js +++ b/src/components/modebar/manage.js @@ -179,8 +179,8 @@ function isSelectable(fullData) { if(scatterSubTypes.hasMarkers(trace) || scatterSubTypes.hasText(trace)) { selectable = true; } - } else if(Registry.traceIs(trace, 'box')) { - if(trace.boxpoints === 'all') { + } else if(Registry.traceIs(trace, 'box-violin')) { + if(trace.boxpoints === 'all' || trace.points === 'all') { selectable = true; } } diff --git a/src/lib/geometry2d.js b/src/lib/geometry2d.js index a946ccf5e23..8cb21eae047 100644 --- a/src/lib/geometry2d.js +++ b/src/lib/geometry2d.js @@ -193,3 +193,52 @@ exports.getVisibleSegment = function getVisibleSegment(path, bounds, buffer) { Math.abs(pt0.y - ptTotal.y) < 0.1 }; }; + +/** + * Find point on SVG path corresponding to a given constraint coordinate + * + * @param {SVGPathElement} path + * @param {Number} val : constraint coordinate value + * @param {String} coord : 'x' or 'y' the constraint coordinate + * @param {Object} opts : + * - {Number} pathLength : supply total path length before hand + * - {Number} tolerance + * - {Number} iterationLimit + * @return {SVGPoint} + */ +exports.findPointOnPath = function findPointOnPath(path, val, coord, opts) { + opts = opts || {}; + + var pathLength = opts.pathLength || path.getTotalLength(); + var tolerance = opts.tolerance || 1e-3; + var iterationLimit = opts.iterationLimit || 30; + + // if path starts at a val greater than the path tail (like on vertical violins), + // we must flip the sign of the computed diff. + var mul = path.getPointAtLength(0)[coord] > path.getPointAtLength(pathLength)[coord] ? -1 : 1; + + var i = 0; + var b0 = 0; + var b1 = pathLength; + var mid; + var pt; + var diff; + + while(i < iterationLimit) { + mid = (b0 + b1) / 2; + pt = path.getPointAtLength(mid); + diff = pt[coord] - val; + + if(Math.abs(diff) < tolerance) { + return pt; + } else { + if(mul * diff > 0) { + b1 = mid; + } else { + b0 = mid; + } + i++; + } + } + return pt; +}; diff --git a/src/lib/index.js b/src/lib/index.js index 563541ed5fe..b34099227d6 100644 --- a/src/lib/index.js +++ b/src/lib/index.js @@ -82,6 +82,7 @@ lib.segmentDistance = geom2dModule.segmentDistance; lib.getTextLocation = geom2dModule.getTextLocation; lib.clearLocationCache = geom2dModule.clearLocationCache; lib.getVisibleSegment = geom2dModule.getVisibleSegment; +lib.findPointOnPath = geom2dModule.findPointOnPath; var extendModule = require('./extend'); lib.extendFlat = extendModule.extendFlat; diff --git a/src/plots/cartesian/constants.js b/src/plots/cartesian/constants.js index b44235855a2..6edfe4fcb9f 100644 --- a/src/plots/cartesian/constants.js +++ b/src/plots/cartesian/constants.js @@ -57,18 +57,13 @@ module.exports = { DFLTRANGEX: [-1, 6], DFLTRANGEY: [-1, 4], - // Layers to keep trace types in the right order. - // from back to front: - // 1. heatmaps, 2D histos and contour maps - // 2. bars / 1D histos - // 3. errorbars for bars and scatter - // 4. scatter - // 5. box plots + // Layers to keep trace types in the right order traceLayerClasses: [ 'imagelayer', 'maplayer', 'barlayer', 'carpetlayer', + 'violinlayer', 'boxlayer', 'scatterlayer' ], diff --git a/src/plots/cartesian/type_defaults.js b/src/plots/cartesian/type_defaults.js index a82712763dd..fa68995066f 100644 --- a/src/plots/cartesian/type_defaults.js +++ b/src/plots/cartesian/type_defaults.js @@ -75,7 +75,7 @@ function setAutoType(ax, data) { for(var i = 0; i < data.length; i++) { trace = data[i]; - if(!Registry.traceIs(trace, 'box') || + if(!Registry.traceIs(trace, 'box-violin') || (trace[axLetter + 'axis'] || axLetter) !== id) continue; if(trace[posLetter] !== undefined) boxPositions.push(trace[posLetter][0]); @@ -113,7 +113,7 @@ function getBoxPosLetter(trace) { function isBoxWithoutPositionCoords(trace, axLetter) { var posLetter = getBoxPosLetter(trace), - isBox = Registry.traceIs(trace, 'box'), + isBox = Registry.traceIs(trace, 'box-violin'), isCandlestick = Registry.traceIs(trace._fullInput || {}, 'candlestick'); return ( diff --git a/src/plots/plots.js b/src/plots/plots.js index 03799b4c270..7e0973196f6 100644 --- a/src/plots/plots.js +++ b/src/plots/plots.js @@ -1342,7 +1342,6 @@ plots.purge = function(gd) { delete gd.firstscatter; delete gd._hmlumcount; delete gd._hmpixcount; - delete gd.numboxes; delete gd._transitionData; delete gd._transitioning; delete gd._initialAutoSize; @@ -2159,8 +2158,12 @@ plots.doCalcdata = function(gd, traces) { // firstscatter: fill-to-next on the first trace goes to zero gd.firstscatter = true; - // how many box plots do we have (in case they're grouped) - gd.numboxes = 0; + // how many box/violins plots do we have (in case they're grouped) + fullLayout._numBoxes = 0; + fullLayout._numViolins = 0; + + // initialize violin per-scale-group stats container + fullLayout._violinScaleGroupStats = {}; // for calculating avg luminosity of heatmaps gd._hmpixcount = 0; diff --git a/src/traces/box/calc.js b/src/traces/box/calc.js index 601fd6a47b0..2f79e0c368c 100644 --- a/src/traces/box/calc.js +++ b/src/traces/box/calc.js @@ -15,16 +15,19 @@ 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 fullLayout = gd._fullLayout; var xa = Axes.getFromId(gd, trace.xaxis || 'x'); var ya = Axes.getFromId(gd, trace.yaxis || 'y'); - var orientation = trace.orientation; var cd = []; + // N.B. violin reuses same Box.calc + var numKey = trace.type === 'violin' ? '_numViolins' : '_numBoxes'; + var i; var valAxis, valLetter; var posAxis, posLetter; - if(orientation === 'h') { + if(trace.orientation === 'h') { valAxis = xa; valLetter = 'x'; posAxis = ya; @@ -37,7 +40,7 @@ module.exports = function calc(gd, trace) { } var val = valAxis.makeCalcdata(trace, valLetter); - var pos = getPos(trace, posLetter, posAxis, val, gd.numboxes); + var pos = getPos(trace, posLetter, posAxis, val, fullLayout[numKey]); var dv = Lib.distinctVals(pos); var posDistinct = dv.vals; @@ -115,13 +118,16 @@ module.exports = function calc(gd, trace) { if(cd.length > 0) { cd[0].t = { - boxnum: gd.numboxes, - dPos: dPos + num: fullLayout[numKey], + dPos: dPos, + posLetter: posLetter, + valLetter: valLetter }; - gd.numboxes++; + + fullLayout[numKey]++; return cd; } else { - return [{t: {emptybox: true}}]; + return [{t: {empty: true}}]; } }; @@ -130,7 +136,7 @@ module.exports = function calc(gd, trace) { // 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) { +function getPos(trace, posLetter, posAxis, val, num) { if(posLetter in trace) { return posAxis.makeCalcdata(trace, posLetter); } @@ -150,7 +156,7 @@ function getPos(trace, posLetter, posAxis, val, numboxes) { )) { pos0 = trace.name; } else { - pos0 = numboxes; + pos0 = num; } var pos0c = posAxis.d2c(pos0, 0, trace[posLetter + 'calendar']); diff --git a/src/traces/box/defaults.js b/src/traces/box/defaults.js index 64a926e4af7..9ea8e7964af 100644 --- a/src/traces/box/defaults.js +++ b/src/traces/box/defaults.js @@ -14,11 +14,25 @@ var Color = require('../../components/color'); var attributes = require('./attributes'); -module.exports = function supplyDefaults(traceIn, traceOut, defaultColor, layout) { +function supplyDefaults(traceIn, traceOut, defaultColor, layout) { function coerce(attr, dflt) { return Lib.coerce(traceIn, traceOut, attributes, attr, dflt); } + handleSampleDefaults(traceIn, traceOut, coerce, layout); + if(traceOut.visible === false) return; + + coerce('line.color', (traceIn.marker || {}).color || defaultColor); + coerce('line.width'); + coerce('fillcolor', Color.addOpacity(traceOut.line.color, 0.5)); + + coerce('whiskerwidth'); + coerce('boxmean'); + + handlePointsDefaults(traceIn, traceOut, coerce, {prefix: 'box'}); +} + +function handleSampleDefaults(traceIn, traceOut, coerce, layout) { var y = coerce('y'); var x = coerce('x'); @@ -39,25 +53,22 @@ module.exports = function supplyDefaults(traceIn, traceOut, defaultColor, layout handleCalendarDefaults(traceIn, traceOut, ['x', 'y'], layout); coerce('orientation', defaultOrientation); +} - coerce('line.color', (traceIn.marker || {}).color || defaultColor); - coerce('line.width'); - coerce('fillcolor', Color.addOpacity(traceOut.line.color, 0.5)); - - coerce('whiskerwidth'); - coerce('boxmean'); +function handlePointsDefaults(traceIn, traceOut, coerce, opts) { + var prefix = opts.prefix; var outlierColorDflt = Lib.coerce2(traceIn, traceOut, attributes, 'marker.outliercolor'); var lineoutliercolor = coerce('marker.line.outliercolor'); - var boxpoints = coerce( - 'boxpoints', + var points = coerce( + prefix + 'points', (outlierColorDflt || lineoutliercolor) ? 'suspectedoutliers' : undefined ); - if(boxpoints) { - coerce('jitter', boxpoints === 'all' ? 0.3 : 0); - coerce('pointpos', boxpoints === 'all' ? -1.5 : 0); + if(points) { + coerce('jitter', points === 'all' ? 0.3 : 0); + coerce('pointpos', points === 'all' ? -1.5 : 0); coerce('marker.symbol'); coerce('marker.opacity'); @@ -66,7 +77,7 @@ module.exports = function supplyDefaults(traceIn, traceOut, defaultColor, layout coerce('marker.line.color'); coerce('marker.line.width'); - if(boxpoints === 'suspectedoutliers') { + if(points === 'suspectedoutliers') { coerce('marker.line.outliercolor', traceOut.marker.color); coerce('marker.line.outlierwidth'); } @@ -77,4 +88,10 @@ module.exports = function supplyDefaults(traceIn, traceOut, defaultColor, layout } coerce('hoveron'); +} + +module.exports = { + supplyDefaults: supplyDefaults, + handleSampleDefaults: handleSampleDefaults, + handlePointsDefaults: handlePointsDefaults }; diff --git a/src/traces/box/hover.js b/src/traces/box/hover.js index d3668a44575..17cc4c1f84c 100644 --- a/src/traces/box/hover.js +++ b/src/traces/box/hover.js @@ -14,190 +14,233 @@ 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) { +function hoverPoints(pointData, xval, yval, hovermode) { 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; + closeBoxData = closeBoxData.concat(hoverOnBoxes(pointData, xval, yval, hovermode)); + } - // 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(hoveron.indexOf('points') !== -1) { + closePtData = hoverOnPoints(pointData, xval, yval); + } - 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); + // 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; +} + +function hoverOnBoxes(pointData, xval, yval, hovermode) { + var cd = pointData.cd; + var xa = pointData.xa; + var ya = pointData.ya; + var trace = cd[0].trace; + var t = cd[0].t; + var isViolin = trace.type === 'violin'; + var closeBoxData = []; + + var pLetter, vLetter, pAxis, vAxis, vVal, pVal, dx, dy; + + // closest mode: handicap box plots a little relative to others + // adjust inbox w.r.t. to calculate box size + var boxDelta = (hovermode === 'closest' && !isViolin) ? 2.5 * t.bdPos : t.bdPos; + var shiftPos = function(di) { return di.pos + t.bPos - pVal; }; + var dPos; + + if(isViolin && trace.side !== 'both') { + if(trace.side === 'positive') { + dPos = function(di) { + var pos = shiftPos(di); + return Fx.inbox(pos, pos + boxDelta); }; - dy = function(di) { - return Fx.inbox(di.min - yval, di.max - yval); + } + if(trace.side === 'negative') { + dPos = function(di) { + var pos = shiftPos(di); + return Fx.inbox(pos - boxDelta, pos); }; - posLetter = 'x'; - posAxis = xa; - valLetter = 'y'; - valAxis = ya; } + } else { + dPos = function(di) { + var pos = shiftPos(di); + return Fx.inbox(pos - boxDelta, pos + boxDelta); + }; + } - distfn = Fx.getDistanceFunction(hovermode, dx, dy); - Fx.getClosest(cd, distfn, pointData); + var dVal; - // 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]; + if(isViolin) { + dVal = function(di) { + return Fx.inbox(di.span[0] - vVal, di.span[1] - vVal); + }; + } else { + dVal = function(di) { + return Fx.inbox(di.min - vVal, di.max - vVal); + }; + } - var lc = trace.line.color; - var mc = marker.color; + if(trace.orientation === 'h') { + vVal = xval; + pVal = yval; + dx = dVal; + dy = dPos; + pLetter = 'y'; + pAxis = ya; + vLetter = 'x'; + vAxis = xa; + } else { + vVal = yval; + pVal = xval; + dx = dPos; + dy = dVal; + pLetter = 'x'; + pAxis = xa; + vLetter = 'y'; + vAxis = ya; + } - 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; + var distfn = Fx.getDistanceFunction(hovermode, dx, dy); + Fx.getClosest(cd, distfn, pointData); - pointData[posLetter + '0'] = posAxis.c2p(di.pos + t.bPos - t.bdPos, true); - pointData[posLetter + '1'] = posAxis.c2p(di.pos + t.bPos + t.bdPos, true); + // 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) return []; - Axes.tickText(posAxis, posAxis.c2l(di.pos), 'hover').text; - pointData[posLetter + 'LabelVal'] = di.pos; + var di = cd[pointData.index]; + var lc = trace.line.color; + var mc = (trace.marker || {}).color; - // box plots: each "point" gets many labels - var usedVals = {}; - var attrs = ['med', 'min', 'q1', 'q3', 'max']; - var prefixes = ['median', 'min', 'q1', 'q3', 'max']; + 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; - 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'); - } + pointData[pLetter + '0'] = pAxis.c2p(di.pos + t.bPos - t.bdPos, true); + pointData[pLetter + '1'] = pAxis.c2p(di.pos + t.bPos + t.bdPos, true); - for(i = 0; i < attrs.length; i++) { - var attr = attrs[i]; + Axes.tickText(pAxis, pAxis.c2l(di.pos), 'hover').text; + pointData[pLetter + 'LabelVal'] = di.pos; - if(!(attr in di) || (di[attr] in usedVals)) continue; - usedVals[di[attr]] = true; + // box plots: each "point" gets many labels + var usedVals = {}; + var attrs = ['med', 'min', 'q1', 'q3', 'max']; + var prefixes = ['median', 'min', 'q1', 'q3', 'max']; - // 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); + if(trace.boxmean || (trace.meanline || {}).visible) { + attrs.push('mean'); + prefixes.push(trace.boxmean === 'sd' ? 'mean ± σ' : 'mean'); + } + if(trace.boxpoints || trace.points) { + attrs.push('lf', 'uf'); + prefixes.push('lower fence', 'upper fence'); + } - pointData2[valLetter + '0'] = pointData2[valLetter + '1'] = valPx; - pointData2[valLetter + 'LabelVal'] = val; - pointData2[valLetter + 'Label'] = prefixes[i] + ': ' + Axes.hoverLabelText(valAxis, val); + for(var i = 0; i < attrs.length; i++) { + var attr = attrs[i]; - if(attr === 'mean' && ('sd' in di) && trace.boxmean === 'sd') { - pointData2[valLetter + 'err'] = di.sd; - } - // only keep name on the first item (median) - pointData.name = ''; + if(!(attr in di) || (di[attr] in usedVals)) continue; + usedVals[di[attr]] = true; - closeBoxData.push(pointData2); - } - } - } + // copy out to a new object for each value to label + var val = di[attr]; + var valPx = vAxis.c2p(val, true); + var pointData2 = Lib.extendFlat({}, pointData); - if(hoveron.indexOf('points') !== -1) { - var xPx = xa.c2p(xval); - var yPx = ya.c2p(yval); + pointData2[vLetter + '0'] = pointData2[vLetter + '1'] = valPx; + pointData2[vLetter + 'LabelVal'] = val; + pointData2[vLetter + 'Label'] = prefixes[i] + ': ' + Axes.hoverLabelText(vAxis, val); - 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); + if(attr === 'mean' && ('sd' in di) && trace.boxmean === 'sd') { + pointData2[vLetter + 'err'] = di.sd; + } + // only keep name on the first item (median) + pointData.name = ''; - // show one point per trace - var ijClosest = false; - var pt; + closeBoxData.push(pointData2); + } - for(i = 0; i < cd.length; i++) { - di = cd[i]; + return closeBoxData; +} - for(j = 0; j < (di.pts || []).length; j++) { - pt = di.pts[j]; +function hoverOnPoints(pointData, xval, yval) { + var cd = pointData.cd; + var xa = pointData.xa; + var ya = pointData.ya; + var trace = cd[0].trace; + var xPx = xa.c2p(xval); + var yPx = ya.c2p(yval); + var closePtData; - var newDistance = distfn(pt); - if(newDistance <= pointData.distance) { - pointData.distance = newDistance; - ijClosest = [i, j]; - } + var 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); + }; + var 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); + }; + var distfn = Fx.quadrature(dx, dy); + + // show one point per trace + var ijClosest = false; + var di, pt; + + for(var i = 0; i < cd.length; i++) { + di = cd[i]; + + for(var 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; + if(!ijClosest) return false; + + 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: (trace.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); + + return closePtData; +} + +module.exports = { + hoverPoints: hoverPoints, + hoverOnBoxes: hoverOnBoxes, + hoverOnPoints: hoverOnPoints }; diff --git a/src/traces/box/index.js b/src/traces/box/index.js index 2294107883b..3d094a2034b 100644 --- a/src/traces/box/index.js +++ b/src/traces/box/index.js @@ -12,19 +12,19 @@ var Box = {}; Box.attributes = require('./attributes'); Box.layoutAttributes = require('./layout_attributes'); -Box.supplyDefaults = require('./defaults'); -Box.supplyLayoutDefaults = require('./layout_defaults'); +Box.supplyDefaults = require('./defaults').supplyDefaults; +Box.supplyLayoutDefaults = require('./layout_defaults').supplyLayoutDefaults; Box.calc = require('./calc'); -Box.setPositions = require('./set_positions'); -Box.plot = require('./plot'); +Box.setPositions = require('./set_positions').setPositions; +Box.plot = require('./plot').plot; Box.style = require('./style'); -Box.hoverPoints = require('./hover'); +Box.hoverPoints = require('./hover').hoverPoints; Box.selectPoints = require('./select'); Box.moduleType = 'trace'; Box.name = 'box'; Box.basePlotModule = require('../../plots/cartesian'); -Box.categories = ['cartesian', 'symbols', 'oriented', 'box', 'showLegend']; +Box.categories = ['cartesian', 'symbols', 'oriented', 'box-violin', 'showLegend']; Box.meta = { description: [ 'In vertical (horizontal) box plots,', diff --git a/src/traces/box/layout_defaults.js b/src/traces/box/layout_defaults.js index 3213f703af8..4cd9c2b62e3 100644 --- a/src/traces/box/layout_defaults.js +++ b/src/traces/box/layout_defaults.js @@ -8,25 +8,32 @@ 'use strict'; -var Registry = require('../../registry'); var Lib = require('../../lib'); var layoutAttributes = require('./layout_attributes'); -module.exports = function supplyLayoutDefaults(layoutIn, layoutOut, fullData) { - function coerce(attr, dflt) { - return Lib.coerce(layoutIn, layoutOut, layoutAttributes, attr, dflt); - } - - var hasBoxes; +function _supply(layoutIn, layoutOut, fullData, coerce, traceType) { + var hasTraceType; for(var i = 0; i < fullData.length; i++) { - if(Registry.traceIs(fullData[i], 'box')) { - hasBoxes = true; + if(fullData[i].type === traceType) { + hasTraceType = true; break; } } - if(!hasBoxes) return; + if(!hasTraceType) return; + + coerce(traceType + 'mode'); + coerce(traceType + 'gap'); + coerce(traceType + 'groupgap'); +} + +function supplyLayoutDefaults(layoutIn, layoutOut, fullData) { + function coerce(attr, dflt) { + return Lib.coerce(layoutIn, layoutOut, layoutAttributes, attr, dflt); + } + _supply(layoutIn, layoutOut, fullData, coerce, 'box'); +} - coerce('boxmode'); - coerce('boxgap'); - coerce('boxgroupgap'); +module.exports = { + supplyLayoutDefaults: supplyLayoutDefaults, + _supply: _supply }; diff --git a/src/traces/box/plot.js b/src/traces/box/plot.js index e3fbfd2b90a..e36a863de95 100644 --- a/src/traces/box/plot.js +++ b/src/traces/box/plot.js @@ -33,7 +33,7 @@ function rand() { 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) { +function plot(gd, plotinfo, cdbox) { var fullLayout = gd._fullLayout; var xa = plotinfo.xaxis; var ya = plotinfo.yaxis; @@ -49,22 +49,23 @@ module.exports = function plot(gd, plotinfo, cdbox) { var t = cd0.t; var trace = cd0.trace; var sel = cd0.node3 = d3.select(this); + var numBoxes = fullLayout._numBoxes; - var group = (fullLayout.boxmode === 'group' && gd.numboxes > 1); + var group = (fullLayout.boxmode === 'group' && numBoxes > 1); // box half width - var bdPos = t.dPos * (1 - fullLayout.boxgap) * (1 - fullLayout.boxgroupgap) / (group ? gd.numboxes : 1); + var bdPos = t.dPos * (1 - fullLayout.boxgap) * (1 - fullLayout.boxgroupgap) / (group ? numBoxes : 1); // box center offset - var bPos = group ? 2 * t.dPos * (-0.5 + (t.boxnum + 0.5) / gd.numboxes) * (1 - fullLayout.boxgap) : 0; + var bPos = group ? 2 * t.dPos * (-0.5 + (t.num + 0.5) / numBoxes) * (1 - fullLayout.boxgap) : 0; // whisker width var wdPos = bdPos * trace.whiskerwidth; - if(trace.visible !== true || t.emptybox) { + if(trace.visible !== true || t.empty) { d3.select(this).remove(); return; } - // set axis via orientation var posAxis, valAxis; + if(trace.orientation === 'h') { posAxis = ya; valAxis = xa; @@ -76,171 +77,240 @@ module.exports = function plot(gd, plotinfo, cdbox) { // save the box size and box position for use by hover t.bPos = bPos; t.bdPos = bdPos; - - // repeatable pseudorandom number generator - seed(); + t.wdPos = wdPos; // boxes and whiskers - sel.selectAll('path.box') - .data(Lib.identity) - .enter().append('path') - .style('vector-effect', 'non-scaling-stroke') - .attr('class', 'box') - .each(function(d) { - var posc = posAxis.c2p(d.pos + bPos, true), - pos0 = posAxis.c2p(d.pos + bPos - bdPos, true), - pos1 = posAxis.c2p(d.pos + bPos + bdPos, true), - posw0 = posAxis.c2p(d.pos + bPos - wdPos, true), - posw1 = posAxis.c2p(d.pos + bPos + wdPos, true), - q1 = valAxis.c2p(d.q1, true), - q3 = valAxis.c2p(d.q3, true), - // make sure median isn't identical to either of the - // quartiles, so we can see it - m = Lib.constrain(valAxis.c2p(d.med, true), - 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 - 'M' + q1 + ',' + pos0 + 'V' + pos1 + 'H' + q3 + 'V' + pos0 + 'Z' + // box - 'M' + q1 + ',' + posc + 'H' + lf + 'M' + q3 + ',' + posc + 'H' + uf + // whiskers - ((trace.whiskerwidth === 0) ? '' : // whisker caps - 'M' + lf + ',' + posw0 + 'V' + posw1 + 'M' + uf + ',' + posw0 + 'V' + posw1)); - } else { - d3.select(this).attr('d', - 'M' + pos0 + ',' + m + 'H' + pos1 + // median line - 'M' + pos0 + ',' + q1 + 'H' + pos1 + 'V' + q3 + 'H' + pos0 + 'Z' + // box - 'M' + posc + ',' + q1 + 'V' + lf + 'M' + posc + ',' + q3 + 'V' + uf + // whiskers - ((trace.whiskerwidth === 0) ? '' : // whisker caps - 'M' + posw0 + ',' + lf + 'H' + posw1 + 'M' + posw0 + ',' + uf + 'H' + posw1)); - } - }); + plotBoxAndWhiskers(sel, {pos: posAxis, val: valAxis}, trace, t); // draw points, if desired if(trace.boxpoints) { - sel.selectAll('g.points') - // since box plot points get an extra level of nesting, each - // box needs the trace styling info - .data(function(d) { - d.forEach(function(v) { - v.t = t; - v.trace = trace; - }); - return d; - }) - .enter().append('g') - .attr('class', 'points') - .selectAll('path') - .data(function(d) { - 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) { - if(typicalSpread === 0) { - // edge case of no spread at all: fall back to max jitter - maxJitterFactor = 1; - jitterFactors = new Array(pts.length); - for(i = 0; i < pts.length; i++) { - jitterFactors[i] = 1; - } - } else { - for(i = 0; i < pts.length; i++) { - 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].v < d.lf) pmax = Math.min(pmax, d.lf); - else pmin = Math.max(pmin, d.uf); - } - - var jitterFactor = Math.sqrt(spreadLimit * (i1 - i0) / (pmax - pmin + minSpread)) || 0; - jitterFactor = Lib.constrain(Math.abs(jitterFactor), 0, 1); - - jitterFactors.push(jitterFactor); - maxJitterFactor = Math.max(jitterFactor, maxJitterFactor); - } - } - newJitter = trace.jitter * 2 / maxJitterFactor; - } + plotPoints(sel, {x: xa, y: ya}, trace, t); + } - // fills in 'x' and 'y' in calcdata 'pts' item - for(i = 0; i < pts.length; i++) { - var pt = pts[i]; - var v = pt.v; + // draw mean (and stdev diamond) if desired + if(trace.boxmean) { + plotBoxMean(sel, {pos: posAxis, val: valAxis}, trace, t); + } + }); +} - var jitterOffset = trace.jitter ? - (newJitter * jitterFactors[i] * (rand() - 0.5)) : - 0; +function plotBoxAndWhiskers(sel, axes, trace, t) { + var posAxis = axes.pos; + var valAxis = axes.val; + var bPos = t.bPos; + var wdPos = t.wdPos || 0; + var bPosPxOffset = t.bPosPxOffset || 0; + var whiskerWidth = trace.whiskerwidth || 0; - var posPx = d.pos + bPos + bdPos * (trace.pointpos + jitterOffset); + // to support for one-sided box + var bdPos0; + var bdPos1; + if(Array.isArray(t.bdPos)) { + bdPos0 = t.bdPos[0]; + bdPos1 = t.bdPos[1]; + } else { + bdPos0 = t.bdPos; + bdPos1 = t.bdPos; + } - if(trace.orientation === 'h') { - pt.y = posPx; - pt.x = v; - } else { - pt.x = posPx; - pt.y = v; - } + sel.selectAll('path.box') + .data(Lib.identity) + .enter().append('path') + .style('vector-effect', 'non-scaling-stroke') + .attr('class', 'box') + .each(function(d) { + var pos = d.pos; + var posc = posAxis.c2p(pos + bPos, true) + bPosPxOffset; + var pos0 = posAxis.c2p(pos + bPos - bdPos0, true) + bPosPxOffset; + var pos1 = posAxis.c2p(pos + bPos + bdPos1, true) + bPosPxOffset; + var posw0 = posAxis.c2p(pos + bPos - wdPos, true) + bPosPxOffset; + var posw1 = posAxis.c2p(pos + bPos + wdPos, true) + bPosPxOffset; + var q1 = valAxis.c2p(d.q1, true); + var q3 = valAxis.c2p(d.q3, true); + // make sure median isn't identical to either of the + // quartiles, so we can see it + var m = Lib.constrain( + valAxis.c2p(d.med, true), + Math.min(q1, q3) + 1, Math.max(q1, q3) - 1 + ); + var lf = valAxis.c2p(trace.boxpoints === false ? d.min : d.lf, true); + var uf = valAxis.c2p(trace.boxpoints === false ? d.max : d.uf, true); - // tag suspected outliers - if(trace.boxpoints === 'suspectedoutliers' && v < d.uo && v > d.lo) { - pt.so = true; - } - } + if(trace.orientation === 'h') { + d3.select(this).attr('d', + 'M' + m + ',' + pos0 + 'V' + pos1 + // median line + 'M' + q1 + ',' + pos0 + 'V' + pos1 + 'H' + q3 + 'V' + pos0 + 'Z' + // box + 'M' + q1 + ',' + posc + 'H' + lf + 'M' + q3 + ',' + posc + 'H' + uf + // whiskers + ((whiskerWidth === 0) ? '' : // whisker caps + 'M' + lf + ',' + posw0 + 'V' + posw1 + 'M' + uf + ',' + posw0 + 'V' + posw1)); + } else { + d3.select(this).attr('d', + 'M' + pos0 + ',' + m + 'H' + pos1 + // median line + 'M' + pos0 + ',' + q1 + 'H' + pos1 + 'V' + q3 + 'H' + pos0 + 'Z' + // box + 'M' + posc + ',' + q1 + 'V' + lf + 'M' + posc + ',' + q3 + 'V' + uf + // whiskers + ((whiskerWidth === 0) ? '' : // whisker caps + 'M' + posw0 + ',' + lf + 'H' + posw1 + 'M' + posw0 + ',' + uf + 'H' + posw1)); + } + }); +} - return pts; - }) - .enter().append('path') - .classed('point', true) - .call(Drawing.translatePoints, xa, ya); - } +function plotPoints(sel, axes, trace, t) { + var xa = axes.x; + var ya = axes.y; + var bdPos = t.bdPos; + var bPos = t.bPos; - // draw mean (and stdev diamond) if desired - if(trace.boxmean) { - sel.selectAll('path.mean') - .data(Lib.identity) - .enter().append('path') - .attr('class', 'mean') - .style({ - fill: 'none', - 'vector-effect': 'non-scaling-stroke' - }) - .each(function(d) { - var posc = posAxis.c2p(d.pos + bPos, true), - pos0 = posAxis.c2p(d.pos + bPos - bdPos, true), - pos1 = posAxis.c2p(d.pos + bPos + bdPos, true), - m = valAxis.c2p(d.mean, true), - sl = valAxis.c2p(d.mean - d.sd, true), - sh = valAxis.c2p(d.mean + d.sd, true); - if(trace.orientation === 'h') { - d3.select(this).attr('d', - 'M' + m + ',' + pos0 + 'V' + pos1 + - ((trace.boxmean !== 'sd') ? '' : - 'm0,0L' + sl + ',' + posc + 'L' + m + ',' + pos0 + 'L' + sh + ',' + posc + 'Z')); + // to support violin points + var mode = trace.boxpoints || trace.points; + + // repeatable pseudorandom number generator + seed(); + + sel.selectAll('g.points') + // since box plot points get an extra level of nesting, each + // box needs the trace styling info + .data(function(d) { + d.forEach(function(v) { + v.t = t; + v.trace = trace; + }); + return d; + }) + .enter().append('g') + .attr('class', 'points') + .selectAll('path') + .data(function(d) { + var i; + + var pts = mode === '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) { + if(typicalSpread === 0) { + // edge case of no spread at all: fall back to max jitter + maxJitterFactor = 1; + jitterFactors = new Array(pts.length); + for(i = 0; i < pts.length; i++) { + jitterFactors[i] = 1; } - else { - d3.select(this).attr('d', - 'M' + pos0 + ',' + m + 'H' + pos1 + - ((trace.boxmean !== 'sd') ? '' : - 'm0,0L' + posc + ',' + sl + 'L' + pos0 + ',' + m + 'L' + posc + ',' + sh + 'Z')); + } else { + for(i = 0; i < pts.length; i++) { + 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(mode !== 'all') { + if(pts[i].v < d.lf) pmax = Math.min(pmax, d.lf); + else pmin = Math.max(pmin, d.uf); + } + + var jitterFactor = Math.sqrt(spreadLimit * (i1 - i0) / (pmax - pmin + minSpread)) || 0; + jitterFactor = Lib.constrain(Math.abs(jitterFactor), 0, 1); + + jitterFactors.push(jitterFactor); + maxJitterFactor = Math.max(jitterFactor, maxJitterFactor); } - }); - } - }); + } + newJitter = trace.jitter * 2 / maxJitterFactor; + } + + // 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') { + pt.y = posPx; + pt.x = v; + } else { + pt.x = posPx; + pt.y = v; + } + + // tag suspected outliers + if(mode === 'suspectedoutliers' && v < d.uo && v > d.lo) { + pt.so = true; + } + } + + return pts; + }) + .enter().append('path') + .classed('point', true) + .call(Drawing.translatePoints, xa, ya); +} + +function plotBoxMean(sel, axes, trace, t) { + var posAxis = axes.pos; + var valAxis = axes.val; + var bPos = t.bPos; + var bPosPxOffset = t.bPosPxOffset || 0; + + // to support for one-sided box + var bdPos0; + var bdPos1; + if(Array.isArray(t.bdPos)) { + bdPos0 = t.bdPos[0]; + bdPos1 = t.bdPos[1]; + } else { + bdPos0 = t.bdPos; + bdPos1 = t.bdPos; + } + + sel.selectAll('path.mean') + .data(Lib.identity) + .enter().append('path') + .attr('class', 'mean') + .style({ + fill: 'none', + 'vector-effect': 'non-scaling-stroke' + }) + .each(function(d) { + var posc = posAxis.c2p(d.pos + bPos, true) + bPosPxOffset; + var pos0 = posAxis.c2p(d.pos + bPos - bdPos0, true) + bPosPxOffset; + var pos1 = posAxis.c2p(d.pos + bPos + bdPos1, true) + bPosPxOffset; + var m = valAxis.c2p(d.mean, true); + var sl = valAxis.c2p(d.mean - d.sd, true); + var sh = valAxis.c2p(d.mean + d.sd, true); + + if(trace.orientation === 'h') { + d3.select(this).attr('d', + 'M' + m + ',' + pos0 + 'V' + pos1 + + (trace.boxmean === 'sd' ? + 'm0,0L' + sl + ',' + posc + 'L' + m + ',' + pos0 + 'L' + sh + ',' + posc + 'Z' : + '') + ); + } else { + d3.select(this).attr('d', + 'M' + pos0 + ',' + m + 'H' + pos1 + + (trace.boxmean === 'sd' ? + 'm0,0L' + posc + ',' + sl + 'L' + pos0 + ',' + m + 'L' + posc + ',' + sh + 'Z' : + '') + ); + } + }); +} + +module.exports = { + plot: plot, + plotBoxAndWhiskers: plotBoxAndWhiskers, + plotPoints: plotPoints, + plotBoxMean: plotBoxMean }; diff --git a/src/traces/box/set_positions.js b/src/traces/box/set_positions.js index 30580031d4b..f0a6221b638 100644 --- a/src/traces/box/set_positions.js +++ b/src/traces/box/set_positions.js @@ -8,44 +8,37 @@ 'use strict'; -var Registry = require('../../registry'); var Axes = require('../../plots/cartesian/axes'); var Lib = require('../../lib'); +var orientations = ['v', 'h']; -module.exports = function setPositions(gd, plotinfo) { - var fullLayout = gd._fullLayout, - xa = plotinfo.xaxis, - ya = plotinfo.yaxis, - orientations = ['v', 'h']; - var posAxis, i, j, k; +function setPositions(gd, plotinfo) { + var calcdata = gd.calcdata; + var xa = plotinfo.xaxis; + var ya = plotinfo.yaxis; - for(i = 0; i < orientations.length; ++i) { - var orientation = orientations[i], - boxlist = [], - boxpointlist = [], - minPad = 0, - maxPad = 0, - cd, - t, - trace; - - // set axis via orientation - if(orientation === 'h') posAxis = ya; - else posAxis = xa; + for(var i = 0; i < orientations.length; i++) { + var orientation = orientations[i]; + var posAxis = orientation === 'h' ? ya : xa; + var boxList = []; + var minPad = 0; + var maxPad = 0; // make list of boxes - for(j = 0; j < gd.calcdata.length; ++j) { - cd = gd.calcdata[j]; - t = cd[0].t; - trace = cd[0].trace; + for(var j = 0; j < calcdata.length; j++) { + var cd = calcdata[j]; + var t = cd[0].t; + var trace = cd[0].trace; - if(trace.visible === true && Registry.traceIs(trace, 'box') && - !t.emptybox && + if(trace.visible === true && trace.type === 'box' && + !t.empty && trace.orientation === orientation && trace.xaxis === xa._id && - trace.yaxis === ya._id) { - boxlist.push(j); + trace.yaxis === ya._id + ) { + boxList.push(j); + if(trace.boxpoints !== false) { minPad = Math.max(minPad, trace.jitter - trace.pointpos - 1); maxPad = Math.max(maxPad, trace.jitter + trace.pointpos - 1); @@ -53,40 +46,64 @@ module.exports = function setPositions(gd, plotinfo) { } } - // make list of box points - for(j = 0; j < boxlist.length; j++) { - cd = gd.calcdata[boxlist[j]]; - for(k = 0; k < cd.length; k++) boxpointlist.push(cd[k].pos); - } - if(!boxpointlist.length) continue; - - // box plots - update dPos based on multiple traces - // and then use for posAxis autorange + setPositionOffset('box', gd, boxList, posAxis, [minPad, maxPad]); + } +} - var boxdv = Lib.distinctVals(boxpointlist), - dPos = boxdv.minDiff / 2; +function setPositionOffset(traceType, gd, boxList, posAxis, pad) { + var calcdata = gd.calcdata; + var fullLayout = gd._fullLayout; + var pointList = []; - // if there's no duplication of x points, - // disable 'group' mode by setting numboxes=1 - if(boxpointlist.length === boxdv.vals.length) gd.numboxes = 1; + // N.B. reused in violin + var numKey = traceType === 'violin' ? '_numViolins' : '_numBoxes'; - // check for forced minimum dtick - Axes.minDtick(posAxis, boxdv.minDiff, boxdv.vals[0], true); + var i, j, calcTrace; - // set the width of all boxes - for(i = 0; i < boxlist.length; i++) { - var boxListIndex = boxlist[i]; - gd.calcdata[boxListIndex][0].t.dPos = dPos; + // make list of box points + for(i = 0; i < boxList.length; i++) { + calcTrace = calcdata[boxList[i]]; + for(j = 0; j < calcTrace.length; j++) { + pointList.push(calcTrace[j].pos); } + } + + if(!pointList.length) return; - // autoscale the x axis - including space for points if they're off the side - // TODO: this will overdo it if the outermost boxes don't have - // their points as far out as the other boxes - var padfactor = (1 - fullLayout.boxgap) * (1 - fullLayout.boxgroupgap) * - dPos / gd.numboxes; - Axes.expand(posAxis, boxdv.vals, { - vpadminus: dPos + minPad * padfactor, - vpadplus: dPos + maxPad * padfactor - }); + // box plots - update dPos based on multiple traces + // and then use for posAxis autorange + var boxdv = Lib.distinctVals(pointList); + var dPos = boxdv.minDiff / 2; + + // if there's no duplication of x points, + // disable 'group' mode by setting counter to 1 + if(pointList.length === boxdv.vals.length) { + fullLayout[numKey] = 1; + } + + // check for forced minimum dtick + Axes.minDtick(posAxis, boxdv.minDiff, boxdv.vals[0], true); + + // set the width of all boxes + for(i = 0; i < boxList.length; i++) { + calcTrace = calcdata[boxList[i]]; + calcTrace[0].t.dPos = dPos; } + + var gap = fullLayout[traceType + 'gap']; + var groupgap = fullLayout[traceType + 'groupgap']; + var padfactor = (1 - gap) * (1 - groupgap) * dPos / fullLayout[numKey]; + + // autoscale the x axis - including space for points if they're off the side + // TODO: this will overdo it if the outermost boxes don't have + // their points as far out as the other boxes + Axes.expand(posAxis, boxdv.vals, { + vpadminus: dPos + pad[0] * padfactor, + vpadplus: dPos + pad[1] * padfactor + }); +} + +module.exports = { + setPositions: setPositions, + setPositionOffset: setPositionOffset }; diff --git a/src/traces/contour/hover.js b/src/traces/contour/hover.js index d53393d9ed8..0bf4fdb4884 100644 --- a/src/traces/contour/hover.js +++ b/src/traces/contour/hover.js @@ -11,7 +11,6 @@ var heatmapHoverPoints = require('../heatmap/hover'); - -module.exports = function hoverPoints(pointData, xval, yval, hovermode) { - return heatmapHoverPoints(pointData, xval, yval, hovermode, true); +module.exports = function hoverPoints(pointData, xval, yval, hovermode, hoverLayer) { + return heatmapHoverPoints(pointData, xval, yval, hovermode, hoverLayer, true); }; diff --git a/src/traces/heatmap/hover.js b/src/traces/heatmap/hover.js index 310f92c0b84..f8ccaaf0bd9 100644 --- a/src/traces/heatmap/hover.js +++ b/src/traces/heatmap/hover.js @@ -15,7 +15,7 @@ var Axes = require('../../plots/cartesian/axes'); var MAXDIST = Fx.constants.MAXDIST; -module.exports = function hoverPoints(pointData, xval, yval, hovermode, contour) { +module.exports = function hoverPoints(pointData, xval, yval, hovermode, hoverLayer, contour) { // never let a heatmap override another type as closest point if(pointData.distance < MAXDIST) return; diff --git a/src/traces/histogram2d/hover.js b/src/traces/histogram2d/hover.js index 87972380b61..ccce7d3d712 100644 --- a/src/traces/histogram2d/hover.js +++ b/src/traces/histogram2d/hover.js @@ -12,8 +12,8 @@ var heatmapHover = require('../heatmap/hover'); var hoverLabelText = require('../../plots/cartesian/axes').hoverLabelText; -module.exports = function hoverPoints(pointData, xval, yval, hovermode, contour) { - var pts = heatmapHover(pointData, xval, yval, hovermode, contour); +module.exports = function hoverPoints(pointData, xval, yval, hovermode, hoverLayer, contour) { + var pts = heatmapHover(pointData, xval, yval, hovermode, hoverLayer, contour); if(!pts) return; diff --git a/src/traces/violin/attributes.js b/src/traces/violin/attributes.js new file mode 100644 index 00000000000..6f7540cf7a2 --- /dev/null +++ b/src/traces/violin/attributes.js @@ -0,0 +1,244 @@ +/** +* 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 boxAttrs = require('../box/attributes'); +var extendFlat = require('../../lib/extend').extendFlat; + +module.exports = { + y: boxAttrs.y, + x: boxAttrs.x, + x0: boxAttrs.x0, + y0: boxAttrs.y0, + name: boxAttrs.name, + orientation: extendFlat({}, boxAttrs.orientation, { + description: [ + 'Sets the orientation of the violin(s).', + 'If *v* (*h*), the distribution is visualized along', + 'the vertical (horizontal).' + ].join(' ') + }), + + bandwidth: { + valType: 'number', + min: 0, + role: 'info', + editType: 'calc', + description: [ + 'Sets the bandwidth used to compute the kernel density estimate.', + 'By default, the bandwidth is determined by Silverman\'s rule of thumb.' + ].join(' ') + }, + + scalegroup: { + valType: 'string', + role: 'info', + dflt: '', + editType: 'calc', + description: [ + 'If there are multiple violins that should be sized according to', + 'to some metric (see `scalemode`), link them by providing a non-empty group id here', + 'shared by every trace in the same group.' + ].join(' ') + }, + scalemode: { + valType: 'enumerated', + values: ['width', 'count'], + dflt: 'width', + role: 'info', + editType: 'calc', + description: [ + 'Sets the metric by which the width of each violin is determined.', + '*width* means each violin has the same (max) width', + '*count* means the violins are scaled by the number of sample points making', + 'up each violin.' + ].join('') + }, + + spanmode: { + valType: 'enumerated', + values: ['soft', 'hard', 'manual'], + dflt: 'soft', + role: 'info', + editType: 'calc', + description: [ + 'Sets the method by which the span in data space where the density function will be computed.', + '*soft* means the span goes from the sample\'s minimum value minus two bandwidths', + 'to the sample\'s maximum value plus two bandwidths.', + '*hard* means the span goes from the sample\'s minimum to its maximum value.', + 'For custom span settings, use mode *manual* and fill in the `span` attribute.' + ].join(' ') + }, + span: { + valType: 'info_array', + items: [ + {valType: 'any', editType: 'calc'}, + {valType: 'any', editType: 'calc'} + ], + role: 'info', + editType: 'calc', + description: [ + 'Sets the span in data space for which the density function will be computed.', + 'Has an effect only when `spanmode` is set to *manual*.' + ].join(' ') + }, + + line: { + color: { + valType: 'color', + role: 'style', + editType: 'style', + description: 'Sets the color of line bounding the violin(s).' + }, + width: { + valType: 'number', + role: 'style', + min: 0, + dflt: 2, + editType: 'style', + description: 'Sets the width (in px) of line bounding the violin(s).' + }, + editType: 'plot' + }, + fillcolor: boxAttrs.fillcolor, + + points: extendFlat({}, boxAttrs.boxpoints, { + description: [ + 'If *outliers*, only the sample points lying outside the whiskers', + 'are shown', + 'If *suspectedoutliers*, the outlier points are shown and', + 'points either less than 4*Q1-3*Q3 or greater than 4*Q3-3*Q1', + 'are highlighted (see `outliercolor`)', + 'If *all*, all sample points are shown', + 'If *false*, only the violins are shown with no sample points' + ].join(' ') + }), + jitter: extendFlat({}, boxAttrs.jitter, { + description: [ + 'Sets the amount of jitter in the sample points drawn.', + 'If *0*, the sample points align along the distribution axis.', + 'If *1*, the sample points are drawn in a random jitter of width', + 'equal to the width of the violins.' + ].join(' ') + }), + pointpos: extendFlat({}, boxAttrs.pointpos, { + description: [ + 'Sets the position of the sample points in relation to the violins.', + 'If *0*, the sample points are places over the center of the violins.', + 'Positive (negative) values correspond to positions to the', + 'right (left) for vertical violins and above (below) for horizontal violins.' + ].join(' ') + }), + marker: boxAttrs.marker, + text: boxAttrs.text, + + box: { + visible: { + valType: 'boolean', + dflt: false, + role: 'info', + editType: 'plot', + description: [ + 'Determines if an miniature box plot is drawn inside the violins. ' + ].join(' ') + }, + width: { + valType: 'number', + min: 0, + max: 1, + dflt: 0.25, + role: 'info', + editType: 'plot', + description: [ + 'Sets the width of the inner box plots relative to', + 'the violins\' width.', + 'For example, with 1, the inner box plots are as wide as the violins.' + ].join(' ') + }, + fillcolor: { + valType: 'color', + role: 'style', + editType: 'style', + description: 'Sets the inner box plot fill color.' + }, + line: { + color: { + valType: 'color', + role: 'style', + editType: 'style', + description: 'Sets the inner box plot bounding line color.' + }, + width: { + valType: 'number', + min: 0, + role: 'style', + editType: 'style', + description: 'Sets the inner box plot bounding line width.' + }, + editType: 'style' + }, + editType: 'plot' + }, + + meanline: { + visible: { + valType: 'boolean', + dflt: false, + role: 'info', + editType: 'plot', + description: [ + 'Determines if a line corresponding to the sample\'s mean is shown', + 'inside the violins.', + 'If `box.visible` is turned on, the mean line is drawn inside the inner box.', + 'Otherwise, the mean line is drawn from one side of the violin to other.' + ].join(' ') + }, + color: { + valType: 'color', + role: 'style', + editType: 'style', + description: 'Sets the mean line color.' + }, + width: { + valType: 'number', + min: 0, + role: 'style', + editType: 'style', + description: 'Sets the mean line width.' + }, + editType: 'plot' + }, + + side: { + valType: 'enumerated', + values: ['both', 'positive', 'negative'], + dflt: 'both', + role: 'info', + editType: 'plot', + description: [ + 'Determines on which side of the position value the density function making up', + 'one half of a violin is plotted.', + 'Useful when comparing two violin traces under *overlay* mode, where one trace', + 'has `side` set to *positive* and the other to *negative*.' + ].join(' ') + }, + + hoveron: { + valType: 'flaglist', + flags: ['violins', 'points', 'kde'], + dflt: 'violins+points+kde', + extras: ['all'], + role: 'info', + editType: 'style', + description: [ + 'Do the hover effects highlight individual violins', + 'or sample points or the kernel density estimate or any combination of them?' + ].join(' ') + } +}; diff --git a/src/traces/violin/calc.js b/src/traces/violin/calc.js new file mode 100644 index 00000000000..e4b0332824e --- /dev/null +++ b/src/traces/violin/calc.js @@ -0,0 +1,115 @@ +/** +* 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 Lib = require('../../lib'); +var Axes = require('../../plots/cartesian/axes'); +var boxCalc = require('../box/calc'); +var helpers = require('./helpers'); +var BADNUM = require('../../constants/numerical').BADNUM; + +module.exports = function calc(gd, trace) { + var cd = boxCalc(gd, trace); + + if(cd[0].t.empty) return cd; + + var fullLayout = gd._fullLayout; + var valAxis = Axes.getFromId( + gd, + trace[trace.orientation === 'h' ? 'xaxis' : 'yaxis'] + ); + + var violinScaleGroupStats = fullLayout._violinScaleGroupStats; + var scaleGroup = trace.scalegroup; + var groupStats = violinScaleGroupStats[scaleGroup]; + if(!groupStats) { + groupStats = violinScaleGroupStats[scaleGroup] = { + maxWidth: 0, + maxCount: 0 + }; + } + + for(var i = 0; i < cd.length; i++) { + var cdi = cd[i]; + var vals = cdi.pts.map(helpers.extractVal); + var len = vals.length; + + // sample standard deviation + var ssd = Lib.stdev(vals, len - 1, cdi.mean); + var bandwidthDflt = ruleOfThumbBandwidth(vals, ssd, cdi.q3 - cdi.q1); + var bandwidth = cdi.bandwidth = trace.bandwidth || bandwidthDflt; + var span = cdi.span = calcSpan(trace, cdi, valAxis, bandwidth); + + // step that well covers the bandwidth and is multiple of span distance + var dist = span[1] - span[0]; + var n = Math.ceil(dist / (Math.min(bandwidthDflt, bandwidth) / 3)); + var step = dist / n; + + if(!isFinite(step) || !isFinite(n)) { + Lib.error('Something went wrong with computing the violin span'); + cd[0].t.empty = true; + return cd; + } + + var kde = helpers.makeKDE(cdi, trace, vals); + cdi.density = new Array(n); + + for(var k = 0, t = span[0]; t < (span[1] + step / 2); k++, t += step) { + var v = kde(t); + groupStats.maxWidth = Math.max(groupStats.maxWidth, v); + cdi.density[k] = {v: v, t: t}; + } + + Axes.expand(valAxis, span, {padded: true}); + groupStats.maxCount = Math.max(groupStats.maxCount, vals.length); + } + + return cd; +}; + +// Default to Silveman's rule of thumb: +// - https://stats.stackexchange.com/a/6671 +// - https://en.wikipedia.org/wiki/Kernel_density_estimation#A_rule-of-thumb_bandwidth_estimator +// - https://github.com/statsmodels/statsmodels/blob/master/statsmodels/nonparametric/bandwidths.py +function ruleOfThumbBandwidth(vals, ssd, iqr) { + var a = Math.min(ssd, iqr / 1.349); + return 1.059 * a * Math.pow(vals.length, -0.2); +} + +function calcSpan(trace, cdi, valAxis, bandwidth) { + var spanmode = trace.spanmode; + var spanIn = trace.span || []; + var spanTight = [cdi.min, cdi.max]; + var spanLoose = [cdi.min - 2 * bandwidth, cdi.max + 2 * bandwidth]; + var spanOut; + + function calcSpanItem(index) { + var s = spanIn[index]; + var sc = valAxis.d2c(s, 0, trace[cdi.valLetter + 'calendar']); + return sc === BADNUM ? spanLoose[index] : sc; + } + + if(spanmode === 'soft') { + spanOut = spanLoose; + } else if(spanmode === 'hard') { + spanOut = spanTight; + } else { + spanOut = [calcSpanItem(0), calcSpanItem(1)]; + } + + // to reuse the equal-range-item block + var dummyAx = { + type: 'linear', + range: spanOut + }; + Axes.setConvert(dummyAx); + dummyAx.cleanRange(); + + return spanOut; +} diff --git a/src/traces/violin/defaults.js b/src/traces/violin/defaults.js new file mode 100644 index 00000000000..e47480c1b9b --- /dev/null +++ b/src/traces/violin/defaults.js @@ -0,0 +1,55 @@ +/** +* 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 Lib = require('../../lib'); +var Color = require('../../components/color'); + +var boxDefaults = require('../box/defaults'); +var attributes = require('./attributes'); + +module.exports = function supplyDefaults(traceIn, traceOut, defaultColor, layout) { + function coerce(attr, dflt) { + return Lib.coerce(traceIn, traceOut, attributes, attr, dflt); + } + function coerce2(attr, dflt) { + return Lib.coerce2(traceIn, traceOut, attributes, attr, dflt); + } + + boxDefaults.handleSampleDefaults(traceIn, traceOut, coerce, layout); + if(traceOut.visible === false) return; + + coerce('bandwidth'); + coerce('scalegroup', traceOut.name); + coerce('scalemode'); + coerce('side'); + + var span = coerce('span'); + var spanmodeDflt; + if(Array.isArray(span)) spanmodeDflt = 'manual'; + coerce('spanmode', spanmodeDflt); + + var lineColor = coerce('line.color', (traceIn.marker || {}).color || defaultColor); + var lineWidth = coerce('line.width'); + var fillColor = coerce('fillcolor', Color.addOpacity(traceOut.line.color, 0.5)); + + boxDefaults.handlePointsDefaults(traceIn, traceOut, coerce, {prefix: ''}); + + var boxWidth = coerce2('box.width'); + var boxFillColor = coerce2('box.fillcolor', fillColor); + var boxLineColor = coerce2('box.line.color', lineColor); + var boxLineWidth = coerce2('box.line.width', lineWidth); + var boxVisible = coerce('box.visible', Boolean(boxWidth || boxFillColor || boxLineColor || boxLineWidth)); + if(!boxVisible) delete traceOut.box; + + var meanLineColor = coerce2('meanline.color', lineColor); + var meanLineWidth = coerce2('meanline.width', lineWidth); + var meanLineVisible = coerce('meanline.visible', Boolean(meanLineColor || meanLineWidth)); + if(!meanLineVisible) delete traceOut.meanline; +}; diff --git a/src/traces/violin/helpers.js b/src/traces/violin/helpers.js new file mode 100644 index 00000000000..6e191793049 --- /dev/null +++ b/src/traces/violin/helpers.js @@ -0,0 +1,71 @@ +/** +* 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 Lib = require('../../lib'); + +// Maybe add kernels more down the road, +// but note that the default `spanmode: 'soft'` bounds might have +// to become kernel-dependent +var kernels = { + gaussian: function(v) { + return (1 / Math.sqrt(2 * Math.PI)) * Math.exp(-0.5 * v * v); + } +}; + +exports.makeKDE = function(calcItem, trace, vals) { + var len = vals.length; + var kernel = kernels.gaussian; + var bandwidth = calcItem.bandwidth; + var factor = 1 / (len * bandwidth); + + // don't use Lib.aggNums to skip isNumeric checks + return function(x) { + var sum = 0; + for(var i = 0; i < len; i++) { + sum += kernel((x - vals[i]) / bandwidth); + } + return factor * sum; + }; +}; + +exports.getPositionOnKdePath = function(calcItem, trace, valuePx) { + var posLetter, valLetter; + + if(trace.orientation === 'h') { + posLetter = 'y'; + valLetter = 'x'; + } else { + posLetter = 'x'; + valLetter = 'y'; + } + + var pointOnPath = Lib.findPointOnPath( + calcItem.path, + valuePx, + valLetter, + {pathLength: calcItem.pathLength} + ); + + var posCenterPx = calcItem.posCenterPx; + var posOnPath0 = pointOnPath[posLetter]; + var posOnPath1 = trace.side === 'both' ? + 2 * posCenterPx - posOnPath0 : + posCenterPx; + + return [posOnPath0, posOnPath1]; +}; + +exports.getKdeValue = function(calcItem, trace, valueDist) { + var vals = calcItem.pts.map(exports.extractVal); + var kde = exports.makeKDE(calcItem, trace, vals); + return kde(valueDist) / calcItem.posDensityScale; +}; + +exports.extractVal = function(o) { return o.v; }; diff --git a/src/traces/violin/hover.js b/src/traces/violin/hover.js new file mode 100644 index 00000000000..32ea61ba63a --- /dev/null +++ b/src/traces/violin/hover.js @@ -0,0 +1,99 @@ +/** +* 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 Lib = require('../../lib'); +var Axes = require('../../plots/cartesian/axes'); +var boxHoverPoints = require('../box/hover'); +var helpers = require('./helpers'); + +module.exports = function hoverPoints(pointData, xval, yval, hovermode, hoverLayer) { + var cd = pointData.cd; + var trace = cd[0].trace; + var hoveron = trace.hoveron; + var hasHoveronViolins = hoveron.indexOf('violins') !== -1; + var hasHoveronKDE = hoveron.indexOf('kde') !== -1; + var closeData = []; + var closePtData; + var violinLineAttrs; + + if(hasHoveronViolins || hasHoveronKDE) { + var closeBoxData = boxHoverPoints.hoverOnBoxes(pointData, xval, yval, hovermode); + + if(hasHoveronViolins) { + closeData = closeData.concat(closeBoxData); + } + + if(hasHoveronKDE && closeBoxData.length > 0) { + var xa = pointData.xa; + var ya = pointData.ya; + var pLetter, vLetter, pAxis, vAxis, vVal; + + if(trace.orientation === 'h') { + vVal = xval; + pLetter = 'y'; + pAxis = ya; + vLetter = 'x'; + vAxis = xa; + } else { + vVal = yval; + pLetter = 'x'; + pAxis = xa; + vLetter = 'y'; + vAxis = ya; + } + + var di = cd[pointData.index]; + + if(vVal >= di.span[0] && vVal <= di.span[1]) { + var kdePointData = Lib.extendFlat({}, pointData); + var vValPx = vAxis.c2p(vVal, true); + var kdeVal = helpers.getKdeValue(di, trace, vVal); + var pOnPath = helpers.getPositionOnKdePath(di, trace, vValPx); + var paOffset = pAxis._offset; + var paLength = pAxis._length; + + kdePointData[pLetter + '0'] = pOnPath[0]; + kdePointData[pLetter + '1'] = pOnPath[1]; + kdePointData[vLetter + '0'] = kdePointData[vLetter + '1'] = vValPx; + kdePointData[vLetter + 'Label'] = vLetter + ': ' + Axes.hoverLabelText(vAxis, vVal) + ', kde: ' + kdeVal.toFixed(3); + closeData.push(kdePointData); + + violinLineAttrs = {stroke: pointData.color}; + violinLineAttrs[pLetter + '1'] = Lib.constrain(paOffset + pOnPath[0], paOffset, paOffset + paLength); + violinLineAttrs[pLetter + '2'] = Lib.constrain(paOffset + pOnPath[1], paOffset, paOffset + paLength); + violinLineAttrs[vLetter + '1'] = violinLineAttrs[vLetter + '2'] = vAxis._offset + vValPx; + } + } + } + + if(hoveron.indexOf('points') !== -1) { + closePtData = boxHoverPoints.hoverOnPoints(pointData, xval, yval); + } + + // update violin line (if any) + var violinLine = hoverLayer.selectAll('.violinline-' + trace.uid) + .data(violinLineAttrs ? [0] : []); + violinLine.enter().append('line') + .classed('violinline-' + trace.uid, true) + .attr('stroke-width', 1.5); + violinLine.exit().remove(); + violinLine.attr(violinLineAttrs); + + // same combine logic as box hoverPoints + if(hovermode === 'closest') { + if(closePtData) return [closePtData]; + return closeData; + } + if(closePtData) { + closeData.push(closePtData); + return closeData; + } + return closeData; +}; diff --git a/src/traces/violin/index.js b/src/traces/violin/index.js new file mode 100644 index 00000000000..75212b5335e --- /dev/null +++ b/src/traces/violin/index.js @@ -0,0 +1,38 @@ +/** +* 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'; + +module.exports = { + attributes: require('./attributes'), + layoutAttributes: require('./layout_attributes'), + supplyDefaults: require('./defaults'), + supplyLayoutDefaults: require('./layout_defaults'), + calc: require('./calc'), + setPositions: require('./set_positions'), + plot: require('./plot'), + style: require('./style'), + hoverPoints: require('./hover'), + selectPoints: require('../box/select'), + + moduleType: 'trace', + name: 'violin', + basePlotModule: require('../../plots/cartesian'), + categories: ['cartesian', 'symbols', 'oriented', 'box-violin', 'showLegend'], + meta: { + description: [ + 'In vertical (horizontal) violin plots,', + 'statistics are computed using `y` (`x`) values.', + 'By supplying an `x` (`y`) array, one violin per distinct x (y) value', + 'is drawn', + 'If no `x` (`y`) {array} is provided, a single violin is drawn.', + 'That violin position is then positioned with', + 'with `name` or with `x0` (`y0`) if provided.' + ].join(' ') + } +}; diff --git a/src/traces/violin/layout_attributes.js b/src/traces/violin/layout_attributes.js new file mode 100644 index 00000000000..4b6fd09596e --- /dev/null +++ b/src/traces/violin/layout_attributes.js @@ -0,0 +1,37 @@ +/** +* 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 boxLayoutAttrs = require('../box/layout_attributes'); +var extendFlat = require('../../lib').extendFlat; + +module.exports = { + violinmode: extendFlat({}, boxLayoutAttrs.boxmode, { + description: [ + 'Determines how violins at the same location coordinate', + 'are displayed on the graph.', + 'If *group*, the violins are plotted next to one another', + 'centered around the shared location.', + 'If *overlay*, the violins are plotted over one another,', + 'you might need to set *opacity* to see them multiple violins.' + ].join(' ') + }), + violingap: extendFlat({}, boxLayoutAttrs.boxgap, { + description: [ + 'Sets the gap (in plot fraction) between violins of', + 'adjacent location coordinates.' + ].join(' ') + }), + violingroupgap: extendFlat({}, boxLayoutAttrs.boxgroupgap, { + description: [ + 'Sets the gap (in plot fraction) between violins of', + 'the same location coordinate.' + ].join(' ') + }) +}; diff --git a/src/traces/violin/layout_defaults.js b/src/traces/violin/layout_defaults.js new file mode 100644 index 00000000000..28352b7ec95 --- /dev/null +++ b/src/traces/violin/layout_defaults.js @@ -0,0 +1,20 @@ +/** +* 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 Lib = require('../../lib'); +var layoutAttributes = require('./layout_attributes'); +var boxLayoutDefaults = require('../box/layout_defaults'); + +module.exports = function supplyLayoutDefaults(layoutIn, layoutOut, fullData) { + function coerce(attr, dflt) { + return Lib.coerce(layoutIn, layoutOut, layoutAttributes, attr, dflt); + } + boxLayoutDefaults._supply(layoutIn, layoutOut, fullData, coerce, 'violin'); +}; diff --git a/src/traces/violin/plot.js b/src/traces/violin/plot.js new file mode 100644 index 00000000000..afec439dda0 --- /dev/null +++ b/src/traces/violin/plot.js @@ -0,0 +1,202 @@ +/** +* 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 d3 = require('d3'); +var Lib = require('../../lib'); +var Drawing = require('../../components/drawing'); +var boxPlot = require('../box/plot'); +var linePoints = require('../scatter/line_points'); +var helpers = require('./helpers'); + +module.exports = function plot(gd, plotinfo, cd) { + var fullLayout = gd._fullLayout; + var xa = plotinfo.xaxis; + var ya = plotinfo.yaxis; + + function makePath(pts) { + var segments = linePoints(pts, { + xaxis: xa, + yaxis: ya, + connectGaps: true, + baseTolerance: 0.75, + shape: 'spline', + simplify: true + }); + return Drawing.smoothopen(segments[0], 1); + } + + var traces = plotinfo.plot.select('.violinlayer') + .selectAll('g.trace.violins') + .data(cd) + .enter().append('g') + .attr('class', 'trace violins'); + + traces.each(function(d) { + var cd0 = d[0]; + var t = cd0.t; + var trace = cd0.trace; + var sel = cd0.node3 = d3.select(this); + var numViolins = fullLayout._numViolins; + var group = (fullLayout.violinmode === 'group' && numViolins > 1); + // violin max half width + var bdPos = t.bdPos = t.dPos * (1 - fullLayout.violingap) * (1 - fullLayout.violingroupgap) / (group ? numViolins : 1); + // violin center offset + var bPos = t.bPos = group ? 2 * t.dPos * (-0.5 + (t.num + 0.5) / numViolins) * (1 - fullLayout.violingap) : 0; + + if(trace.visible !== true || t.empty) { + d3.select(this).remove(); + return; + } + + var valAxis = plotinfo[t.valLetter + 'axis']; + var posAxis = plotinfo[t.posLetter + 'axis']; + var hasBothSides = trace.side === 'both'; + var hasPositiveSide = hasBothSides || trace.side === 'positive'; + var hasNegativeSide = hasBothSides || trace.side === 'negative'; + var hasBox = trace.box && trace.box.visible; + var hasMeanLine = trace.meanline && trace.meanline.visible; + var groupStats = fullLayout._violinScaleGroupStats[trace.scalegroup]; + + sel.selectAll('path.violin') + .data(Lib.identity) + .enter().append('path') + .style('vector-effect', 'non-scaling-stroke') + .attr('class', 'violin') + .each(function(d) { + var pathSel = d3.select(this); + var density = d.density; + var len = density.length; + var posCenter = d.pos + bPos; + var posCenterPx = posAxis.c2p(posCenter); + var scale; + + switch(trace.scalemode) { + case 'width': + scale = groupStats.maxWidth / bdPos; + break; + case 'count': + scale = (groupStats.maxWidth / bdPos) * (groupStats.maxCount / d.pts.length); + break; + } + + var pathPos, pathNeg, path; + var i, k, pts, pt; + + if(hasPositiveSide) { + pts = new Array(len); + for(i = 0; i < len; i++) { + pt = pts[i] = {}; + pt[t.posLetter] = posCenter + (density[i].v / scale); + pt[t.valLetter] = density[i].t; + } + pathPos = makePath(pts); + } + + if(hasNegativeSide) { + pts = new Array(len); + for(k = 0, i = len - 1; k < len; k++, i--) { + pt = pts[k] = {}; + pt[t.posLetter] = posCenter - (density[i].v / scale); + pt[t.valLetter] = density[i].t; + } + pathNeg = makePath(pts); + } + + if(hasBothSides) { + path = pathPos + 'L' + pathNeg.substr(1) + 'Z'; + } + else { + var startPt = [posCenterPx, valAxis.c2p(density[0].t)]; + var endPt = [posCenterPx, valAxis.c2p(density[len - 1].t)]; + + if(trace.orientation === 'h') { + startPt.reverse(); + endPt.reverse(); + } + + if(hasPositiveSide) { + path = 'M' + startPt + 'L' + pathPos.substr(1) + 'L' + endPt; + } else { + path = 'M' + endPt + 'L' + pathNeg.substr(1) + 'L' + startPt; + } + } + pathSel.attr('d', path); + + // save a few things used in getPositionOnKdePath, getKdeValue + // on hover and for meanline draw block below + d.posCenterPx = posCenterPx; + d.posDensityScale = scale * bdPos; + d.path = pathSel.node(); + d.pathLength = d.path.getTotalLength() / (hasBothSides ? 2 : 1); + }); + + if(hasBox) { + var boxWidth = trace.box.width; + var boxLineWidth = trace.box.line.width; + var bdPosScaled; + var bPosPxOffset; + + if(hasBothSides) { + bdPosScaled = bdPos * boxWidth; + bPosPxOffset = 0; + } else if(hasPositiveSide) { + bdPosScaled = [0, bdPos * boxWidth / 2]; + bPosPxOffset = -boxLineWidth; + } else { + bdPosScaled = [bdPos * boxWidth / 2, 0]; + bPosPxOffset = boxLineWidth; + } + + // do not draw whiskers on inner boxes + trace.whiskerwidth = 0; + + boxPlot.plotBoxAndWhiskers(sel, {pos: posAxis, val: valAxis}, trace, { + bPos: bPos, + bdPos: bdPosScaled, + bPosPxOffset: bPosPxOffset + }); + + // if both box and meanline are visible, show mean line inside box + if(hasMeanLine) { + boxPlot.plotBoxMean(sel, {pos: posAxis, val: valAxis}, trace, { + bPos: bPos, + bdPos: bdPosScaled, + bPosPxOffset: bPosPxOffset + }); + } + } + else { + if(hasMeanLine) { + sel.selectAll('path.mean') + .data(Lib.identity) + .enter().append('path') + .attr('class', 'mean') + .style({ + fill: 'none', + 'vector-effect': 'non-scaling-stroke' + }) + .each(function(d) { + var v = valAxis.c2p(d.mean, true); + var p = helpers.getPositionOnKdePath(d, trace, v); + + d3.select(this).attr('d', + trace.orientation === 'h' ? + 'M' + v + ',' + p[0] + 'V' + p[1] : + 'M' + p[0] + ',' + v + 'H' + p[1] + ); + }); + } + } + + if(trace.points) { + boxPlot.plotPoints(sel, {x: xa, y: ya}, trace, t); + } + }); +}; diff --git a/src/traces/violin/set_positions.js b/src/traces/violin/set_positions.js new file mode 100644 index 00000000000..b18376d485a --- /dev/null +++ b/src/traces/violin/set_positions.js @@ -0,0 +1,48 @@ +/** +* 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 setPositionOffset = require('../box/set_positions').setPositionOffset; +var orientations = ['v', 'h']; + +module.exports = function setPositions(gd, plotinfo) { + var calcdata = gd.calcdata; + var xa = plotinfo.xaxis; + var ya = plotinfo.yaxis; + + for(var i = 0; i < orientations.length; i++) { + var orientation = orientations[i]; + var posAxis = orientation === 'h' ? ya : xa; + var violinList = []; + var minPad = 0; + var maxPad = 0; + + for(var j = 0; j < calcdata.length; j++) { + var cd = calcdata[j]; + var t = cd[0].t; + var trace = cd[0].trace; + + if(trace.visible === true && trace.type === 'violin' && + !t.empty && + trace.orientation === orientation && + trace.xaxis === xa._id && + trace.yaxis === ya._id + ) { + violinList.push(j); + + if(trace.points !== false) { + minPad = Math.max(minPad, trace.jitter - trace.pointpos - 1); + maxPad = Math.max(maxPad, trace.jitter + trace.pointpos - 1); + } + } + } + + setPositionOffset('violin', gd, violinList, posAxis, [minPad, maxPad]); + } +}; diff --git a/src/traces/violin/style.js b/src/traces/violin/style.js new file mode 100644 index 00000000000..a3f0c2d8db0 --- /dev/null +++ b/src/traces/violin/style.js @@ -0,0 +1,47 @@ +/** +* 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 d3 = require('d3'); +var Drawing = require('../../components/drawing'); +var Color = require('../../components/color'); + +module.exports = function style(gd) { + var traces = d3.select(gd).selectAll('g.trace.violins'); + + traces.style('opacity', function(d) { return d[0].trace.opacity; }) + .each(function(d) { + var trace = d[0].trace; + var sel = d3.select(this); + var box = trace.box || {}; + var boxLine = box.line || {}; + var meanline = trace.meanline || {}; + var meanLineWidth = meanline.width; + + sel.selectAll('path.violin') + .style('stroke-width', trace.line.width + 'px') + .call(Color.stroke, trace.line.color) + .call(Color.fill, trace.fillcolor); + + sel.selectAll('path.box') + .style('stroke-width', boxLine.width + 'px') + .call(Color.stroke, boxLine.color) + .call(Color.fill, box.fillcolor); + + sel.selectAll('g.points path') + .call(Drawing.pointStyle, trace, gd); + + sel.selectAll('path.mean') + .style({ + 'stroke-width': meanLineWidth + 'px', + 'stroke-dasharray': (2 * meanLineWidth) + 'px,' + meanLineWidth + 'px' + }) + .call(Color.stroke, meanline.color); + }); +}; diff --git a/test/image/baselines/violin_box_overlay.png b/test/image/baselines/violin_box_overlay.png new file mode 100644 index 00000000000..bf60201ef43 Binary files /dev/null and b/test/image/baselines/violin_box_overlay.png differ diff --git a/test/image/baselines/violin_grouped.png b/test/image/baselines/violin_grouped.png new file mode 100644 index 00000000000..30de14adb18 Binary files /dev/null and b/test/image/baselines/violin_grouped.png differ diff --git a/test/image/baselines/violin_non-linear.png b/test/image/baselines/violin_non-linear.png new file mode 100644 index 00000000000..3609f3890e7 Binary files /dev/null and b/test/image/baselines/violin_non-linear.png differ diff --git a/test/image/baselines/violin_old-faithful.png b/test/image/baselines/violin_old-faithful.png new file mode 100644 index 00000000000..26d9b81943a Binary files /dev/null and b/test/image/baselines/violin_old-faithful.png differ diff --git a/test/image/baselines/violin_side-by-side.png b/test/image/baselines/violin_side-by-side.png new file mode 100644 index 00000000000..591ff342f6c Binary files /dev/null and b/test/image/baselines/violin_side-by-side.png differ diff --git a/test/image/baselines/violin_style.png b/test/image/baselines/violin_style.png new file mode 100644 index 00000000000..60e47221c55 Binary files /dev/null and b/test/image/baselines/violin_style.png differ diff --git a/test/image/mocks/violin_box_overlay.json b/test/image/mocks/violin_box_overlay.json new file mode 100644 index 00000000000..b12add9e702 --- /dev/null +++ b/test/image/mocks/violin_box_overlay.json @@ -0,0 +1,21 @@ +{ + "data": [{ + "type": "violin", + "x0": "sample 1", + "name": "violin", + "points": "all", + "y": [79, 54, 74, 62, 85, 55, 88, 85, 51, 85, 54, 84, 78, 47, 83, 52, 62, 84, 52, 79, 51, 47, 78, 69, 74, 83, 55, 76, 78, 79, 73, 77, 66, 80, 74, 52, 48, 80, 59, 90, 80, 58, 84, 58, 73, 83, 64, 53, 82, 59, 75, 90, 54, 80, 54, 83, 71, 64, 77, 81, 59, 84, 48, 82, 60, 92, 78, 78, 65, 73, 82, 56, 79, 71, 62, 76, 60, 78, 76, 83, 75, 82, 70, 65, 73, 88, 76, 80, 48, 86, 60, 90, 50, 78, 63, 72, 84, 75, 51, 82, 62, 88, 49, 83, 81, 47, 84, 52, 86, 81, 75, 59, 89, 79, 59, 81, 50, 85, 59, 87, 53, 69, 77, 56, 88, 81, 45, 82, 55, 90, 45, 83, 56, 89, 46, 82, 51, 86, 53, 79, 81, 60, 82, 77, 76, 59, 80, 49, 96, 53, 77, 77, 65, 81, 71, 70, 81, 93, 53, 89, 45, 86, 58, 78, 66, 76, 63, 88, 52, 93, 49, 57, 77, 68, 81, 81, 73, 50, 85, 74, 55, 77, 83, 83, 51, 78, 84, 46, 83, 55, 81, 57, 76, 84, 77, 81, 87, 77, 51, 78, 60, 82, 91, 53, 78, 46, 77, 84, 49, 83, 71, 80, 49, 75, 64, 76, 53, 94, 55, 76, 50, 82, 54, 75, 78, 79, 78, 78, 70, 79, 70, 54, 86, 50, 90, 54, 54, 77, 79, 64, 75, 47, 86, 63, 85, 82, 57, 82, 67, 74, 54, 83, 73, 73, 88, 80, 71, 83, 56, 79, 78, 84, 58, 83, 43, 60, 75, 81, 46, 90, 46, 74 ] + }, { + "type": "box", + "x0": "sample 1", + "name": "box", + "boxpoints": false, + "y": [79, 54, 74, 62, 85, 55, 88, 85, 51, 85, 54, 84, 78, 47, 83, 52, 62, 84, 52, 79, 51, 47, 78, 69, 74, 83, 55, 76, 78, 79, 73, 77, 66, 80, 74, 52, 48, 80, 59, 90, 80, 58, 84, 58, 73, 83, 64, 53, 82, 59, 75, 90, 54, 80, 54, 83, 71, 64, 77, 81, 59, 84, 48, 82, 60, 92, 78, 78, 65, 73, 82, 56, 79, 71, 62, 76, 60, 78, 76, 83, 75, 82, 70, 65, 73, 88, 76, 80, 48, 86, 60, 90, 50, 78, 63, 72, 84, 75, 51, 82, 62, 88, 49, 83, 81, 47, 84, 52, 86, 81, 75, 59, 89, 79, 59, 81, 50, 85, 59, 87, 53, 69, 77, 56, 88, 81, 45, 82, 55, 90, 45, 83, 56, 89, 46, 82, 51, 86, 53, 79, 81, 60, 82, 77, 76, 59, 80, 49, 96, 53, 77, 77, 65, 81, 71, 70, 81, 93, 53, 89, 45, 86, 58, 78, 66, 76, 63, 88, 52, 93, 49, 57, 77, 68, 81, 81, 73, 50, 85, 74, 55, 77, 83, 83, 51, 78, 84, 46, 83, 55, 81, 57, 76, 84, 77, 81, 87, 77, 51, 78, 60, 82, 91, 53, 78, 46, 77, 84, 49, 83, 71, 80, 49, 75, 64, 76, 53, 94, 55, 76, 50, 82, 54, 75, 78, 79, 78, 78, 70, 79, 70, 54, 86, 50, 90, 54, 54, 77, 79, 64, 75, 47, 86, 63, 85, 82, 57, 82, 67, 74, 54, 83, 73, 73, 88, 80, 71, 83, 56, 79, 78, 84, 58, 83, 43, 60, 75, 81, 46, 90, 46, 74 ] + }], + "layout": { + "legend": { + "x": 0.95, + "xanchor": "right" + } + } +} diff --git a/test/image/mocks/violin_grouped.json b/test/image/mocks/violin_grouped.json new file mode 100644 index 00000000000..3662401dc38 --- /dev/null +++ b/test/image/mocks/violin_grouped.json @@ -0,0 +1,76 @@ +{ + "data": [{ + "type": "violin", + "name": "kale", + "y": [ + 0.2, 0.2, 0.6, 1, 0.5, 0.4, + 0.2, 0.7, 0.9, 0.1, 0.5, 0.3 + ], + "x": [ + "day 1", "day 1", "day 1", "day 1", "day 1", "day 1", + "day 2", "day 2", "day 2", "day 2", "day 2", "day 2" + ], + "marker": { + "symbol": "line-ew", + "color": "#3D9970", + "line": { + "color": "#3D9970", + "width": 2 + } + }, + "points": "all", + "jitter": 0, + "span": [0, null] + }, { + "type": "violin", + "name": "radishes", + "y": [ + 0.6, 0.7, 0.3, 0.6, 0, 0.5, + 0.7, 0.9, 0.5, 0.8, 0.7, 0.2 + ], + "x": [ + "day 1", "day 1", "day 1", "day 1", "day 1", "day 1", + "day 2", "day 2", "day 2", "day 2", "day 2", "day 2" + ], + "marker": { + "symbol": "line-ew", + "color": "#FF4136", + "line": { + "color": "#FF4136", + "width": 2 + } + }, + "points": "all", + "jitter": 0, + "span": [0, null] + }, { + "type": "violin", + "name": "carrots", + "y": [ + 0.1, 0.3, 0.1, 0.9, 0.6, 0.6, + 0.9, 1, 0.3, 0.6, 0.8, 0.5 + ], + "x": [ + "day 1", "day 1", "day 1", "day 1", "day 1", "day 1", + "day 2", "day 2", "day 2", "day 2", "day 2", "day 2" + ], + "marker": { + "symbol": "line-ew", + "color": "#FF851B", + "line": { + "color": "#FF851B", + "width": 2 + } + }, + "points": "all", + "jitter": 0, + "span": [0, null] + }], + "layout": { + "yaxis": { + "zeroline": false, + "title": "normalized moisture" + }, + "violinmode": "group" + } +} diff --git a/test/image/mocks/violin_non-linear.json b/test/image/mocks/violin_non-linear.json new file mode 100644 index 00000000000..e299a68dc83 --- /dev/null +++ b/test/image/mocks/violin_non-linear.json @@ -0,0 +1,58 @@ +{ + "data": [{ + "type": "violin", + "x": [ + "1798-01-01", + "1798-04-04", + "1798-05-05", + "1798-05-05", + "1798-07-05", + "1798-07-22", + "1799-01-01" + ], + "orientation": "h", + "xcalendar": "discworld", + "name": "discworld dates", + "box": {"visible": true}, + "xaxis": "x", + "yaxis": "y" + }, { + "type": "violin", + "x": [ + "A", + "B", + "C", + "C", + "C", + "D", + "G" + ], + "orientation": "h", + "name": "categories", + "box": {"visible": true}, + "xaxis": "x2", + "yaxis": "y2" + + }], + "layout": { + "xaxis": { + "anchor": "y" + }, + "xaxis2": { + "anchor": "y2", + "categoryarray": ["A", "B", "C", "D", "E", "F", "G"] + }, + "yaxis": { + "anchor": "x", + "domain": [0, 0.48] + }, + "yaxis2": { + "anchor": "x2", + "domain": [0.52, 1] + }, + "margin": { + "l": 100 + }, + "showlegend": false + } +} diff --git a/test/image/mocks/violin_old-faithful.json b/test/image/mocks/violin_old-faithful.json new file mode 100644 index 00000000000..9c7239b5a44 --- /dev/null +++ b/test/image/mocks/violin_old-faithful.json @@ -0,0 +1,9 @@ +{ + "data": [{ + "type": "violin", + "points": "all", + "name": "Old Faithful", + "meanline": {"visible": true}, + "y": [79, 54, 74, 62, 85, 55, 88, 85, 51, 85, 54, 84, 78, 47, 83, 52, 62, 84, 52, 79, 51, 47, 78, 69, 74, 83, 55, 76, 78, 79, 73, 77, 66, 80, 74, 52, 48, 80, 59, 90, 80, 58, 84, 58, 73, 83, 64, 53, 82, 59, 75, 90, 54, 80, 54, 83, 71, 64, 77, 81, 59, 84, 48, 82, 60, 92, 78, 78, 65, 73, 82, 56, 79, 71, 62, 76, 60, 78, 76, 83, 75, 82, 70, 65, 73, 88, 76, 80, 48, 86, 60, 90, 50, 78, 63, 72, 84, 75, 51, 82, 62, 88, 49, 83, 81, 47, 84, 52, 86, 81, 75, 59, 89, 79, 59, 81, 50, 85, 59, 87, 53, 69, 77, 56, 88, 81, 45, 82, 55, 90, 45, 83, 56, 89, 46, 82, 51, 86, 53, 79, 81, 60, 82, 77, 76, 59, 80, 49, 96, 53, 77, 77, 65, 81, 71, 70, 81, 93, 53, 89, 45, 86, 58, 78, 66, 76, 63, 88, 52, 93, 49, 57, 77, 68, 81, 81, 73, 50, 85, 74, 55, 77, 83, 83, 51, 78, 84, 46, 83, 55, 81, 57, 76, 84, 77, 81, 87, 77, 51, 78, 60, 82, 91, 53, 78, 46, 77, 84, 49, 83, 71, 80, 49, 75, 64, 76, 53, 94, 55, 76, 50, 82, 54, 75, 78, 79, 78, 78, 70, 79, 70, 54, 86, 50, 90, 54, 54, 77, 79, 64, 75, 47, 86, 63, 85, 82, 57, 82, 67, 74, 54, 83, 73, 73, 88, 80, 71, 83, 56, 79, 78, 84, 58, 83, 43, 60, 75, 81, 46, 90, 46, 74 ] + }] +} diff --git a/test/image/mocks/violin_side-by-side.json b/test/image/mocks/violin_side-by-side.json new file mode 100644 index 00000000000..140170adb0a --- /dev/null +++ b/test/image/mocks/violin_side-by-side.json @@ -0,0 +1,559 @@ +{ + "layout": { + "hovermode": "closest", + "width": 400, + "yaxis": { + "showgrid": true + }, + "title": "Total bill distribution
scaled by number of bills per gender", + "legend": { + "tracegroupgap": 0 + }, + "violingap": 0, + "violingroupgap": 0, + "violinmode": "overlay", + "height": 700 + }, + "data": [ + { + "text": "sample length: 32", + "hoveron": "points+kde", + "meanline": { + "visible": true + }, + "legendgroup": "F", + "scalegroup": "F", + "points": "all", + "pointpos": 1, + "box": { + "visible": true + }, + "jitter": 0, + "scalemode": "count", + "marker": { + "line": { + "width": 2, + "color": "#bebada" + }, + "symbol": "line-ns" + }, + "showlegend": false, + "side": "positive", + "type": "violin", + "name": "F", + "span": [ + 0 + ], + "line": { + "color": "#bebada" + }, + "y0": "Thursday", + "x": [ + 10.07, + 34.83, + 10.65, + 12.43, + 24.08, + 13.42, + 12.48, + 29.8, + 14.52, + 11.38, + 20.27, + 11.17, + 12.26, + 18.26, + 8.51, + 10.33, + 14.15, + 13.16, + 17.47, + 27.05, + 16.43, + 8.35, + 18.64, + 11.87, + 19.81, + 43.11, + 13.0, + 12.74, + 13.0, + 16.4, + 16.47, + 18.78 + ], + "orientation": "h" + }, + { + "text": "sample length: 30", + "hoveron": "points+kde", + "meanline": { + "visible": true + }, + "legendgroup": "M", + "scalegroup": "M", + "points": "all", + "pointpos": -0.6, + "box": { + "visible": true + }, + "jitter": 0, + "scalemode": "count", + "marker": { + "line": { + "width": 2, + "color": "#8dd3c7" + }, + "symbol": "line-ns" + }, + "showlegend": false, + "side": "negative", + "type": "violin", + "name": "M", + "span": [ + 0 + ], + "line": { + "color": "#8dd3c7" + }, + "y0": "Thursday", + "x": [ + 27.2, + 22.76, + 17.29, + 19.44, + 16.66, + 32.68, + 15.98, + 13.03, + 18.28, + 24.71, + 21.16, + 11.69, + 14.26, + 15.95, + 8.52, + 22.82, + 19.08, + 16.0, + 34.3, + 41.19, + 9.78, + 7.51, + 28.44, + 15.48, + 16.58, + 7.56, + 10.34, + 13.51, + 18.71, + 20.53 + ], + "orientation": "h" + }, + { + "text": "sample length: 9", + "hoveron": "points+kde", + "meanline": { + "visible": true + }, + "legendgroup": "F", + "scalegroup": "F", + "points": "all", + "pointpos": 0.4, + "box": { + "visible": true + }, + "jitter": 0, + "scalemode": "count", + "marker": { + "line": { + "width": 2, + "color": "#bebada" + }, + "symbol": "line-ns" + }, + "showlegend": false, + "side": "positive", + "type": "violin", + "name": "F", + "span": [ + 0 + ], + "line": { + "color": "#bebada" + }, + "y0": "Friday", + "x": [ + 5.75, + 16.32, + 22.75, + 11.35, + 15.38, + 13.42, + 15.98, + 16.27, + 10.09 + ], + "orientation": "h" + }, + { + "text": "sample length: 10", + "hoveron": "points+kde", + "meanline": { + "visible": true + }, + "legendgroup": "M", + "scalegroup": "M", + "points": "all", + "pointpos": -0.3, + "box": { + "visible": true + }, + "jitter": 0, + "scalemode": "count", + "marker": { + "line": { + "width": 2, + "color": "#8dd3c7" + }, + "symbol": "line-ns" + }, + "showlegend": false, + "side": "negative", + "type": "violin", + "name": "M", + "span": [ + 0 + ], + "line": { + "color": "#8dd3c7" + }, + "y0": "Friday", + "x": [ + 28.97, + 22.49, + 40.17, + 27.28, + 12.03, + 21.01, + 12.46, + 12.16, + 8.58, + 13.42 + ], + "orientation": "h" + }, + { + "text": "sample length: 28", + "hoveron": "points+kde", + "meanline": { + "visible": true + }, + "legendgroup": "F", + "scalegroup": "F", + "points": "all", + "pointpos": 0.55, + "box": { + "visible": true + }, + "jitter": 0, + "scalemode": "count", + "marker": { + "line": { + "width": 2, + "color": "#bebada" + }, + "symbol": "line-ns" + }, + "showlegend": true, + "side": "positive", + "type": "violin", + "name": "F", + "span": [ + 0 + ], + "line": { + "color": "#bebada" + }, + "y0": "Saturday", + "x": [ + 20.29, + 15.77, + 19.65, + 15.06, + 20.69, + 16.93, + 26.41, + 16.45, + 3.07, + 17.07, + 26.86, + 25.28, + 14.73, + 44.3, + 22.42, + 20.92, + 14.31, + 7.25, + 10.59, + 10.63, + 12.76, + 13.27, + 28.17, + 12.9, + 30.14, + 22.12, + 35.83, + 27.18 + ], + "orientation": "h" + }, + { + "text": "sample length: 59", + "hoveron": "points+kde", + "meanline": { + "visible": true + }, + "legendgroup": "M", + "scalegroup": "M", + "points": "all", + "pointpos": -1.1, + "box": { + "visible": true + }, + "jitter": 0, + "scalemode": "count", + "marker": { + "line": { + "width": 2, + "color": "#8dd3c7" + }, + "symbol": "line-ns" + }, + "showlegend": true, + "side": "negative", + "type": "violin", + "name": "M", + "span": [ + 0 + ], + "line": { + "color": "#8dd3c7" + }, + "y0": "Saturday", + "x": [ + 20.65, + 17.92, + 39.42, + 19.82, + 17.81, + 13.37, + 12.69, + 21.7, + 9.55, + 18.35, + 17.78, + 24.06, + 16.31, + 18.69, + 31.27, + 16.04, + 38.01, + 11.24, + 48.27, + 20.29, + 13.81, + 11.02, + 18.29, + 17.59, + 20.08, + 20.23, + 15.01, + 12.02, + 10.51, + 17.92, + 15.36, + 20.49, + 25.21, + 18.24, + 14.0, + 50.81, + 15.81, + 26.59, + 38.73, + 24.27, + 30.06, + 25.89, + 48.33, + 28.15, + 11.59, + 7.74, + 20.45, + 13.28, + 24.01, + 15.69, + 11.61, + 10.77, + 15.53, + 10.07, + 12.6, + 32.83, + 29.03, + 22.67, + 17.82 + ], + "orientation": "h" + }, + { + "text": "sample length: 18", + "hoveron": "points+kde", + "meanline": { + "visible": true + }, + "legendgroup": "F", + "scalegroup": "F", + "points": "all", + "pointpos": 0.45, + "box": { + "visible": true + }, + "jitter": 0, + "scalemode": "count", + "marker": { + "line": { + "width": 2, + "color": "#bebada" + }, + "symbol": "line-ns" + }, + "showlegend": false, + "side": "positive", + "type": "violin", + "name": "F", + "span": [ + 0 + ], + "line": { + "color": "#bebada" + }, + "y0": "Sunday", + "x": [ + 16.99, + 24.59, + 35.26, + 14.83, + 10.33, + 16.97, + 10.29, + 34.81, + 25.71, + 17.31, + 29.85, + 25.0, + 13.39, + 16.21, + 17.51, + 9.6, + 20.9, + 18.15 + ], + "orientation": "h" + }, + { + "text": "sample length: 58", + "hoveron": "points+kde", + "meanline": { + "visible": true + }, + "legendgroup": "M", + "scalegroup": "M", + "points": "all", + "pointpos": -0.9, + "box": { + "visible": true + }, + "jitter": 0, + "scalemode": "count", + "marker": { + "line": { + "width": 2, + "color": "#8dd3c7" + }, + "symbol": "line-ns" + }, + "showlegend": false, + "side": "negative", + "type": "violin", + "name": "M", + "span": [ + 0 + ], + "line": { + "color": "#8dd3c7" + }, + "y0": "Sunday", + "x": [ + 10.34, + 21.01, + 23.68, + 25.29, + 8.77, + 26.88, + 15.04, + 14.78, + 10.27, + 15.42, + 18.43, + 21.58, + 16.29, + 17.46, + 13.94, + 9.68, + 30.4, + 18.29, + 22.23, + 32.4, + 28.55, + 18.04, + 12.54, + 9.94, + 25.56, + 19.49, + 38.07, + 23.95, + 29.93, + 14.07, + 13.13, + 17.26, + 24.55, + 19.77, + 48.17, + 16.49, + 21.5, + 12.66, + 13.81, + 24.52, + 20.76, + 31.71, + 7.25, + 31.85, + 16.82, + 32.9, + 17.89, + 14.48, + 34.63, + 34.65, + 23.33, + 45.35, + 23.17, + 40.55, + 20.69, + 30.46, + 23.1, + 15.69 + ], + "orientation": "h" + } + ] +} diff --git a/test/image/mocks/violin_style.json b/test/image/mocks/violin_style.json new file mode 100644 index 00000000000..b7e5462be33 --- /dev/null +++ b/test/image/mocks/violin_style.json @@ -0,0 +1,507 @@ +{ + "layout": { + "annotations": [ + { + "yref": "paper", + "text": "Orbital Period Estimations", + "xref": "paper", + "showarrow": false, + "font": { + "size": 20 + } + } + ], + "paper_bgcolor": "#d3d3d3", + "yaxis": { + "showline": false, + "showticklabels": false, + "range": [ + -5, + 550 + ], + "zeroline": false, + "visible": false + }, + "showlegend": false, + "violingap": 0, + "xaxis": { + "side": "top" + }, + "plot_bgcolor": "#d3d3d3", + "margin": { + "l": 0, + "r": 0, + "b": 0, + "t": 20 + } + }, + "data": [ + { + "bandwidth": 5, + "points": false, + "y": [ + 269.3, + 874.7739999999999, + 763.0, + 326.03, + 516.22, + 185.84, + 798.5, + 993.3, + 452.8, + 883.0, + 335.1, + 479.1, + 4.230785, + 14.651, + 44.38, + 0.73654, + 261.2, + 4.215, + 38.021, + 123.01, + 116.6884, + 691.9, + 952.7, + 181.4, + 380.8, + 3.2357, + 417.9, + 594.9, + 428.5, + 903.3, + 136.75, + 530.32, + 277.02, + 187.83, + 39.845, + 3.3135, + 305.5, + 4.617033, + 241.25799999999998, + 655.6, + 714.3, + 3.48777, + 5.6, + 237.6, + 3.8728, + 125.94, + 268.94, + 137.48, + 379.63, + 621.99, + 578.2, + 392.6, + 3.698, + 15.76491, + 8.631, + 25.6, + 603.0, + 692.0, + 7.3709, + 2.64385, + 5.36874, + 12.9292, + 66.8, + 3.14942, + 598.3, + 7.2004, + 28.14, + 91.61, + 62.24, + 39.025999999999996, + 256.2, + 4.6938, + 3.6, + 35.37, + 61.1166, + 30.0881, + 1.9377799999999998, + 124.26, + 133.71, + 3.33714, + 2.64561, + 428.5, + 349.7, + 5.7727, + 13.505, + 431.8, + 533.0, + 3.4442, + 311.6, + 62.218, + 526.62, + 829.0, + 15.609000000000002, + 431.88, + 356.0, + 360.2, + 675.0, + 777.0, + 792.6, + 177.11, + 22.09, + 615.0, + 5.3978, + 227.0, + 30.052, + 192.9, + 5.75962, + 16.3567, + 49.747, + 122.72, + 602.0, + 989.2, + 170.455, + 711.0, + 37.91, + 262.709, + 471.6, + 14.182, + 53.832, + 19.382, + 931.0, + 75.523, + 17.24, + 990.0, + 465.1, + 359.9, + 21.21663, + 772.0, + 466.2, + 11.849, + 33.823, + 500.0, + 18.315, + 40.114000000000004, + 90.309, + 29.15, + 85.131, + 591.9, + 380.85, + 22.656, + 53.881, + 738.459, + 528.07, + 423.841, + 17.991, + 385.9, + 387.1, + 912.0, + 466.0, + 16.546, + 51.284, + 274.49, + 326.6, + 18.179000000000002, + 157.54, + 388.0, + 363.2, + 154.46, + 843.6, + 55.0, + 5.6363, + 14.025, + 33.941, + 14.3098, + 696.3, + 407.15, + 4.3123, + 9.6184, + 20.432000000000002, + 34.62, + 51.76, + 197.8, + 264.15, + 963.0, + 1.3283, + 18.357, + 25.648000000000003, + 327.8, + 36.96, + 472.0, + 5.8872, + 226.93, + 342.85, + 890.76, + 43.6, + 3.0239999999999996, + 4.0845, + 430.0, + 700.0, + 4.9437, + 14.07, + 95.415, + 118.96, + 303.0, + 201.83, + 607.06, + 2.817822, + 589.64, + 358.0, + 572.4, + 152.6, + 480.5, + 6.276, + 8.667, + 31.56, + 197.0, + 851.8, + 2.54858, + 188.9, + 379.1, + 51.645, + 346.6, + 3.51, + 418.2, + 3.971, + 5.7361, + 111.4357, + 184.02, + 441.47, + 220.078, + 705.0, + 2.985625, + 788.0, + 58.43, + 2.1375, + 3.4160000000000004, + 256.78, + 49.77, + 325.81, + 143.58, + 13.186, + 46.025, + 507.0, + 361.1, + 498.9, + 647.3, + 8.1256, + 103.49, + 9.494, + 436.9, + 439.3, + 17.054000000000002, + 868.0, + 157.57, + 383.7, + 70.46, + 20.8133, + 4.113775, + 127.58, + 520.0, + 122.1, + 778.1, + 6.495, + 47.84, + 5.8881, + 55.806000000000004, + 199.505, + 48.056000000000004, + 10.8985, + 443.4, + 395.8, + 68.27, + 7.8543, + 30.93, + 5.24, + 835.477, + 324.0, + 263.3, + 937.7, + 83.88799999999999, + 493.7, + 670.0, + 25.826999999999998, + 6.1335, + 63.33, + 344.95, + 559.4, + 4.1547, + 9.6737, + 948.12, + 454.2, + 923.8, + 10.7085, + 883.0, + 974.0, + 3.27, + 258.19, + 12.083, + 59.519, + 459.26, + 464.3, + 11.577, + 27.581999999999997, + 106.72, + 330.0, + 653.22, + 386.3, + 176.3, + 103.95, + 44.236000000000004, + 528.4, + 331.5, + 2.8758911, + 4.072, + 2.391, + 689.0, + 499.4, + 18.596, + 163.9, + 408.6, + 194.3, + 391.9, + 131.05, + 842.0, + 4.6455, + 359.5546, + 104.84, + 521.0, + 12.62, + 248.4, + 352.3, + 643.25, + 9.6386, + 310.55, + 8.428198, + 75.29, + 282.4, + 606.4, + 420.77, + 58.11289, + 6.403, + 225.62, + 538.0, + 323.6, + 297.3, + 406.6, + 110.9, + 71.484, + 14.475999999999999, + 3.0925, + 396.03, + 479.0, + 663.0, + 9.3743, + 962.0, + 956.0, + 634.23, + 6.837999999999999, + 986.0, + 3.097, + 456.46, + 14.275, + 2.21857578, + 17.1, + 24.348000000000003, + 74.72, + 525.8, + 351.5, + 18.201629999999998, + 613.8, + 825.0, + 255.87, + 34.873000000000005, + 279.8, + 610.0, + 161.97, + 123.0, + 875.5, + 3.52474859, + 442.1, + 354.8, + 2.245715, + 373.3, + 951.0, + 7.2825, + 10.866, + 191.99, + 3.93, + 567.0, + 118.45, + 7.126816000000001, + 225.7, + 3.8335, + 672.1, + 456.1, + 572.38, + 26.73, + 141.6, + 192.0, + 501.75, + 745.7, + 8.7836, + 3.3689999999999998, + 345.72, + 57.0, + 16.2, + 6.6738550000000005, + 147.73, + 952.0, + 41.397, + 8.1352, + 32.03, + 431.7, + 124.6, + 511.098, + 111.7, + 5.0505, + 311.288, + 123.0, + 982.0, + 580.0, + 820.3, + 789.0, + 677.8, + 6.957999999999999, + 5.118, + 121.71, + 4.4264, + 2.1451 + ], + "type": "violin", + "name": "Radial Velocity", + "span": [ + 0, + null + ], + "line": { + "color": "#67353E" + }, + "box": { + "fillcolor": "black", + "line": { + "color": "black" + }, + "width": 0.01 + } + }, + { + "bandwidth": 5, + "points": false, + "y": [ + 25.261999999999997, + 66.5419, + 98.2114, + 0.09070629 + ], + "type": "violin", + "name": "Pulsar Timing", + "span": [ + 0, + null + ], + "line": { + "color": "#34ABA2" + }, + "box": { + "fillcolor": "black", + "line": { + "color": "black" + }, + "width": 0.01 + } + } + ] +} diff --git a/test/jasmine/tests/plots_test.js b/test/jasmine/tests/plots_test.js index d529b7363ed..355a7daf07b 100644 --- a/test/jasmine/tests/plots_test.js +++ b/test/jasmine/tests/plots_test.js @@ -435,7 +435,7 @@ describe('Test Plots', function() { var expectedUndefined = [ 'data', 'layout', '_fullData', '_fullLayout', 'calcdata', 'framework', 'empty', 'fid', 'undoqueue', 'undonum', 'autoplay', 'changed', - '_promises', '_redrawTimer', 'firstscatter', 'numboxes', + '_promises', '_redrawTimer', 'firstscatter', '_transitionData', '_transitioning', '_hmpixcount', '_hmlumcount', '_dragging', '_dragged', '_hoverdata', '_snapshotInProgress', '_editing', '_replotPending', '_mouseDownTime', '_legendMouseDownTime' diff --git a/test/jasmine/tests/select_test.js b/test/jasmine/tests/select_test.js index 72f29593e3f..3bf53a8337e 100644 --- a/test/jasmine/tests/select_test.js +++ b/test/jasmine/tests/select_test.js @@ -971,6 +971,58 @@ describe('Test select box and lasso per trace:', function() { .catch(fail) .then(done); }); + + it('should work for violin traces', function(done) { + var assertPoints = makeAssertPoints(['curveNumber', 'y', 'x']); + var assertRanges = makeAssertRanges(); + var assertLassoPoints = makeAssertLassoPoints(); + + var fig = Lib.extendDeep({}, require('@mocks/violin_grouped')); + 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.3, 'day 2'], [0, 0.5, 'day 2'], [0, 0.7, 'day 2'], [0, 0.9, 'day 2'], + [1, 0.5, 'day 2'], [1, 0.7, 'day 2'], [1, 0.7, 'day 2'], [1, 0.8, 'day 2'], + [1, 0.9, 'day 2'], + [2, 0.3, 'day 1'], [2, 0.6, 'day 1'], [2, 0.6, 'day 1'], [2, 0.9, 'day 1'] + ]); + assertLassoPoints([ + ['day 1', 'day 2', 'day 2', 'day 1', 'day 1'], + [1.02, 1.02, 0.27, 0.27, 1.02] + ]); + }, + null, LASSOEVENTS, 'violin lasso' + ); + }) + .then(function() { + return Plotly.relayout(gd, 'dragmode', 'select'); + }) + .then(function() { + return _run( + [[200, 200], [400, 350]], + function() { + assertPoints([ + [0, 0.3, 'day 2'], [0, 0.5, 'day 2'], [0, 0.7, 'day 2'], [0, 0.9, 'day 2'], + [1, 0.5, 'day 2'], [1, 0.7, 'day 2'], [1, 0.7, 'day 2'], [1, 0.8, 'day 2'], + [1, 0.9, 'day 2'], + [2, 0.3, 'day 1'], [2, 0.6, 'day 1'], [2, 0.6, 'day 1'], [2, 0.9, 'day 1'] + ]); + assertRanges([['day 1', 'day 2'], [0.27, 1.02]]); + }, + null, BOXEVENTS, 'violin select' + ); + }) + .catch(fail) + .then(done); + }); }); // to make sure none of the above tests fail with extraneous invisible traces, diff --git a/test/jasmine/tests/violin_test.js b/test/jasmine/tests/violin_test.js new file mode 100644 index 00000000000..755d2b20e19 --- /dev/null +++ b/test/jasmine/tests/violin_test.js @@ -0,0 +1,476 @@ +var Plotly = require('@lib'); +var Lib = require('@src/lib'); +var Plots = require('@src/plots/plots'); + +var Violin = require('@src/traces/violin'); + +var d3 = require('d3'); +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'); + +var customAssertions = require('../assets/custom_assertions'); +var assertHoverLabelContent = customAssertions.assertHoverLabelContent; + +describe('Test violin defaults', function() { + var traceOut; + + function _supply(traceIn, layout) { + traceOut = {}; + Violin.supplyDefaults(traceIn, traceOut, '#444', layout || {}); + } + + it('should set visible to false when x and y are empty', function() { + _supply({ + x: [], + y: [] + }); + + expect(traceOut.visible).toBe(false); + }); + + it('should inherit layout.calendar', function() { + _supply({y: [1, 2, 3]}, {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() { + _supply({ + y: [1, 2, 3], + xcalendar: 'coptic', + ycalendar: 'ethiopian' + }, { + 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 *points* is false', function() { + _supply({ + y: [1, 1, 2], + points: false + }); + + expect(traceOut.points).toBe(false); + expect(traceOut.jitter).toBeUndefined(); + expect(traceOut.pointpos).toBeUndefined(); + expect(traceOut.marker).toBeUndefined(); + expect(traceOut.text).toBeUndefined(); + }); + + it('should default *points* to suspectedoutliers when marker.outliercolor is set & valid', function() { + _supply({ + y: [1, 1, 2], + marker: { outliercolor: 'blue' } + }); + + expect(traceOut.points).toBe('suspectedoutliers'); + }); + + it('should default *points* to suspectedoutliers when marker.line.outliercolor is set & valid', function() { + _supply({ + y: [1, 1, 2], + marker: { line: {outliercolor: 'blue'} } + }); + + expect(traceOut.points).toBe('suspectedoutliers'); + expect(traceOut.marker).toBeDefined(); + expect(traceOut.text).toBeDefined(); + }); + + it('should default *spanmode* to manual when *span* is set to an array', function() { + _supply({ + y: [1, 1, 2], + span: [0, 1] + }); + expect(traceOut.span).toEqual([0, 1]); + expect(traceOut.spanmode).toBe('manual'); + + _supply({ + x: [1, 1, 2], + span: 'not-gonna-work' + }); + expect(traceOut.span).toBeUndefined(); + expect(traceOut.spanmode).toBe('soft'); + }); + + it('should default *.visible attributes when one of their corresponding style attributes is set & valid', function() { + _supply({ + y: [1, 2, 1], + box: { width: 0.1 }, + meanline: { color: 'red' } + }); + expect(traceOut.box.visible).toBe(true); + expect(traceOut.meanline.visible).toBe(true); + + _supply({ + y: [1, 2, 1], + box: { + visible: false, + width: 0.1 + }, + meanline: { + visible: false, + color: 'red' + } + }); + expect(traceOut.box).toBeUndefined(); + expect(traceOut.meanline).toBeUndefined(); + }); + + it('should use violin style settings to default inner style attribute', function() { + _supply({ + y: [1, 2, 1], + fillcolor: 'red', + line: {color: 'blue', width: 10}, + box: {visible: true}, + meanline: {visible: true} + }); + expect(traceOut.box.fillcolor).toBe('red'); + expect(traceOut.box.line.color).toBe('blue'); + expect(traceOut.box.line.width).toBe(10); + expect(traceOut.meanline.color).toBe('blue'); + expect(traceOut.meanline.width).toBe(10); + }); +}); + +describe('Test violin calc:', function() { + var cd, fullLayout; + + function _calc(attrs, layout) { + var gd = { + data: [Lib.extendFlat({type: 'violin'}, attrs)], + layout: layout || {}, + calcdata: [] + }; + Plots.supplyDefaults(gd); + Plots.doCalcdata(gd); + cd = gd.calcdata[0]; + fullLayout = gd._fullLayout; + } + + it('should compute bandwidth and span based on the sample and *spanmode*', function() { + var y = [1, 1, 2, 2, 3]; + + _calc({y: y}); + expect(cd[0].bandwidth).toBeCloseTo(0.64); + expect(cd[0].span).toBeCloseToArray([-0.28, 4.28]); + + _calc({ + y: y, + spanmode: 'hard' + }); + expect(cd[0].span).toBeCloseToArray([1, 3]); + + _calc({ + y: y, + span: [0, 0] + }); + expect(cd[0].span).toBeCloseToArray([-1, 1], 'cleans up invalid range'); + + _calc({ + y: y, + span: [null, 5] + }); + expect(cd[0].span).toBeCloseToArray([-0.28, 5], 'defaults to soft bound'); + + _calc({ + y: y, + span: [0, null] + }); + expect(cd[0].span).toBeCloseToArray([0, 4.28], 'defaults to soft bound'); + }); + + it('should honor set bandwidth in span calculations', function() { + var y = [1, 1, 2, 2, 3]; + var bw = 0.1; + + _calc({ + y: y, + bandwidth: bw + }); + expect(cd[0].bandwidth).toBeCloseTo(0.1); + expect(cd[0].span).toBeCloseToArray([0.8, 3.2]); + + _calc({ + y: y, + bandwidth: bw, + spanmode: 'hard' + }); + expect(cd[0].span).toBeCloseToArray([1, 3]); + + _calc({ + y: y, + bandwidth: bw, + span: [0, 0] + }); + expect(cd[0].span).toBeCloseToArray([-1, 1], 'cleans up invalid range'); + + _calc({ + y: y, + bandwidth: bw, + span: [null, 5] + }); + expect(cd[0].span).toBeCloseToArray([0.8, 5], 'defaults to soft bound'); + + _calc({ + y: y, + bandwidth: bw, + span: [0, null] + }); + expect(cd[0].span).toBeCloseToArray([0, 3.2], 'defaults to soft bound'); + }); + + it('should fill in scale-group stats', function() { + _calc({ + name: 'one', + y: [0, 0, 0, 0, 10, 10, 10, 10] + }); + expect(fullLayout._violinScaleGroupStats.one.maxWidth).toBeCloseTo(0.055); + expect(fullLayout._violinScaleGroupStats.one.maxCount).toBe(8); + }); +}); + +describe('Test violin hover:', function() { + var gd; + + afterEach(destroyGraphDiv); + + function run(specs) { + gd = createGraphDiv(); + + var fig = Lib.extendDeep( + {width: 700, height: 500}, + specs.mock || require('@mocks/violin_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', + 'y: 0.9266848, kde: 0.182' + ], + name: ['radishes', '', '', '', '', ''], + axis: 'day 1' + }, { + desc: 'with mean', + patch: function(fig) { + fig.data.forEach(function(trace) { + trace.meanline = {visible: true}; + }); + return fig; + }, + nums: [ + 'median: 0.55', 'min: 0', 'q1: 0.3', 'q3: 0.6', 'max: 0.7', 'mean: 0.45', + 'y: 0.9266848, kde: 0.182' + ], + name: ['radishes', '', '', '', '', '', ''], + axis: 'day 1' + }, { + desc: 'with overlaid violins', + patch: function(fig) { + fig.layout.violinmode = 'overlay'; + return fig; + }, + nums: [ + 'q3: 0.6', 'median: 0.45', 'q3: 0.6', 'max: 1', 'y: 0.9266848, kde: 0.383', + 'median: 0.55', 'max: 0.7', 'y: 0.9266848, kde: 0.182', + 'median: 0.45', 'q3: 0.6', 'max: 0.9', 'y: 0.9266848, kde: 0.435', + '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.points = 'all'; + trace.hoveron = 'points'; + }); + fig.layout.hovermode = 'closest'; + return fig; + }, + pos: [220, 200], + nums: '(day 1, 0.9)', + name: 'carrots' + }, { + desc: 'hoveron points | hovermode x', + patch: function(fig) { + fig.data.forEach(function(trace) { + trace.points = 'all'; + trace.hoveron = 'points'; + }); + fig.layout.hovermode = 'x'; + return fig; + }, + pos: [220, 200], + nums: '0.9', + name: 'carrots', + axis: 'day 1' + }, { + desc: 'hoveron violins+points | hovermode x (hover on violin only - same result as base)', + patch: function(fig) { + fig.data.forEach(function(trace) { + trace.points = 'all'; + trace.hoveron = 'points+violins'; + }); + 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 violins+points | hovermode x (violin AND closest point)', + patch: function(fig) { + fig.data.forEach(function(trace) { + trace.points = 'all'; + trace.hoveron = 'points+violins'; + trace.pointpos = 0; + }); + fig.layout.hovermode = 'x'; + return fig; + }, + pos: [207, 240], + nums: ['0.7', '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.points = 'all'; + trace.hoveron = 'points'; + trace.text = trace.y.map(function(v) { return 'look:' + v; }); + }); + fig.layout.hovermode = 'closest'; + return fig; + }, + pos: [180, 240], + 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.points = 'all'; + trace.hoveron = 'points'; + trace.text = trace.y.map(function(v) { return 'look:' + v; }); + trace.hoverinfo = 'text'; + }); + fig.layout.hovermode = 'closest'; + return fig; + }, + pos: [180, 240], + nums: 'look:0.7', + name: '' + }, { + desc: 'one-sided violin under hovermode closest', + // hoveron: 'kde+points' + // hovermode: 'closest' + // width: 400 + // height: 700 + mock: require('@mocks/violin_side-by-side.json'), + pos: [250, 300], + nums: '(x: 42.43046, kde: 0.083, Saturday)', + name: '' + }, { + desc: 'one-sided violin under hovermode y', + // hoveron: 'kde+points' + // width: 400 + // height: 700 + mock: require('@mocks/violin_side-by-side.json'), + patch: function(fig) { + fig.layout.hovermode = 'y'; + return fig; + }, + pos: [250, 300], + nums: 'x: 42.43046, kde: 0.083', + name: '', + axis: 'Saturday' + }] + .forEach(function(specs) { + it('should generate correct hover labels ' + specs.desc, function(done) { + run(specs).catch(fail).then(done); + }); + }); + + describe('KDE lines inside violin under *kde* hoveron flag', function() { + var fig; + + beforeEach(function() { + gd = createGraphDiv(); + + fig = Lib.extendDeep({}, require('@mocks/violin_old-faithful.json'), { + layout: {width: 500, height: 500} + }); + fig.data[0].points = false; + }); + + function assertViolinHoverLine(pos) { + var line = d3.select('.hoverlayer').selectAll('line'); + + expect(line.size()).toBe(1, 'only one violin line at a time'); + expect(line.attr('class').indexOf('violinline')).toBe(0, 'correct class name'); + expect([ + line.attr('x1'), line.attr('y1'), + line.attr('x2'), line.attr('y2') + ]).toBeCloseToArray(pos, 'line position'); + } + + it('should show in two-sided base case', function(done) { + Plotly.plot(gd, fig).then(function() { + mouseEvent('mousemove', 250, 250); + assertViolinHoverLine([299.35, 250, 200.65, 250]); + }) + .catch(fail) + .then(done); + }); + + it('should show in one-sided positive case', function(done) { + fig.data[0].side = 'positive'; + + Plotly.plot(gd, fig).then(function() { + mouseEvent('mousemove', 300, 250); + assertViolinHoverLine([299.35, 250, 250, 250]); + }) + .catch(fail) + .then(done); + }); + + it('should show in one-sided negative case', function(done) { + fig.data[0].side = 'negative'; + + Plotly.plot(gd, fig).then(function() { + mouseEvent('mousemove', 200, 250); + assertViolinHoverLine([200.65, 250, 250, 250]); + }) + .catch(fail) + .then(done); + }); + }); +});