diff --git a/src/traces/box/attributes.js b/src/traces/box/attributes.js index 215aa421145..e2f60b39ba1 100644 --- a/src/traces/box/attributes.js +++ b/src/traces/box/attributes.js @@ -171,6 +171,20 @@ module.exports = { 'the vertical (horizontal).' ].join(' ') }, + + width: { + valType: 'number', + min: 0, + role: 'info', + dflt: 0, + editType: 'calc', + description: [ + 'Sets the width of the box in data coordinate', + 'If *0* (default value) the width is automatically selected based on the positions', + 'of other box traces in the same subplot.' + ].join(' ') + }, + marker: { outliercolor: { valType: 'color', @@ -244,7 +258,6 @@ module.exports = { marker: scatterAttrs.unselected.marker, editType: 'style' }, - hoveron: { valType: 'flaglist', flags: ['boxes', 'points'], diff --git a/src/traces/box/cross_trace_calc.js b/src/traces/box/cross_trace_calc.js index 9a4aadd8d56..e1b629b8b85 100644 --- a/src/traces/box/cross_trace_calc.js +++ b/src/traces/box/cross_trace_calc.js @@ -13,6 +13,11 @@ var Lib = require('../../lib'); var orientations = ['v', 'h']; + +function getPosition(di) { + return di.pos; +} + function crossTraceCalc(gd, plotinfo) { var calcdata = gd.calcdata; var xa = plotinfo.xaxis; @@ -90,22 +95,31 @@ function setPositionOffset(traceType, gd, boxList, posAxis, pad) { 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 - var extremes = Axes.findExtremes(posAxis, boxdv.vals, { - vpadminus: dPos + pad[0] * padfactor, - vpadplus: dPos + pad[1] * padfactor - }); - + // Find maximum trace width + // we baseline this at dPos for(i = 0; i < boxList.length; i++) { calcTrace = calcdata[boxList[i]]; - // set the width of all boxes - calcTrace[0].t.dPos = dPos; - // link extremes to all boxes + // set the width of this box + // override dPos with trace.width if present + var thisDPos = calcTrace[0].t.dPos = (calcTrace[0].trace.width / 2) || dPos; + var positions = calcTrace.map(getPosition); + // 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 trace = calcTrace[0].trace; // for ease of use + var widthMultiplier = (trace.width) ? trace.width : 1; + var alt_vpadminus = Math.max(Math.abs(calcTrace[0].trace.pointpos) * padfactor * widthMultiplier * fullLayout[numKey], 0); + var alt_vpadplus = Math.max(Math.abs(calcTrace[0].trace.pointpos) * padfactor * widthMultiplier * fullLayout[numKey], 0); + + var side = calcTrace[0].trace.side; + var vpadminus = (side === 'positive') ? alt_vpadminus : (thisDPos + pad[0] * padfactor); + var vpadplus = (side === 'negative') ? alt_vpadplus : (thisDPos + pad[1] * padfactor); + var extremes = Axes.findExtremes(posAxis, positions, { + vpadminus: vpadminus, + vpadplus: vpadplus + }); calcTrace[0].trace._extremes[posAxis._id] = extremes; } - } module.exports = { diff --git a/src/traces/box/defaults.js b/src/traces/box/defaults.js index dc75d7f1266..cdd15dcd035 100644 --- a/src/traces/box/defaults.js +++ b/src/traces/box/defaults.js @@ -28,6 +28,7 @@ function supplyDefaults(traceIn, traceOut, defaultColor, layout) { coerce('whiskerwidth'); coerce('boxmean'); + coerce('width'); var notched = coerce('notched', traceIn.notchwidth !== undefined); if(notched) coerce('notchwidth'); diff --git a/src/traces/box/layout_attributes.js b/src/traces/box/layout_attributes.js index 2b75d7f4877..38b3658d264 100644 --- a/src/traces/box/layout_attributes.js +++ b/src/traces/box/layout_attributes.js @@ -22,7 +22,8 @@ module.exports = { 'If *group*, the boxes are plotted next to one another', 'centered around the shared location.', 'If *overlay*, the boxes are plotted over one another,', - 'you might need to set *opacity* to see them multiple boxes.' + 'you might need to set *opacity* to see them multiple boxes.', + 'Has no effect on traces that have *width* set.' ].join(' ') }, boxgap: { @@ -34,7 +35,8 @@ module.exports = { editType: 'calc', description: [ 'Sets the gap (in plot fraction) between boxes of', - 'adjacent location coordinates.' + 'adjacent location coordinates.', + 'Has no effect on traces that have *width* set.' ].join(' ') }, boxgroupgap: { @@ -46,7 +48,8 @@ module.exports = { editType: 'calc', description: [ 'Sets the gap (in plot fraction) between boxes of', - 'the same location coordinate.' + 'the same location coordinate.', + 'Has no effect on traces that have *width* set.' ].join(' ') } }; diff --git a/src/traces/box/plot.js b/src/traces/box/plot.js index 236bfe2b7ea..c362db07ada 100644 --- a/src/traces/box/plot.js +++ b/src/traces/box/plot.js @@ -22,8 +22,9 @@ function plot(gd, plotinfo, cdbox, boxLayer) { var xa = plotinfo.xaxis; var ya = plotinfo.yaxis; var numBoxes = fullLayout._numBoxes; - var groupFraction = (1 - fullLayout.boxgap); var group = (fullLayout.boxmode === 'group' && numBoxes > 1); + var groupFraction = (1 - fullLayout.boxgap); + var groupGapFraction = 1 - fullLayout.boxgroupgap; Lib.makeTraceGroups(boxLayer, cdbox, 'trace boxes').each(function(cd) { var plotGroup = d3.select(this); @@ -31,10 +32,22 @@ function plot(gd, plotinfo, cdbox, boxLayer) { var t = cd0.t; var trace = cd0.trace; if(!plotinfo.isRangePlot) cd0.node3 = plotGroup; - // box half width - var bdPos = t.dPos * groupFraction * (1 - fullLayout.boxgroupgap) / (group ? numBoxes : 1); + + // position coordinate delta + var dPos = t.dPos; + // box half width; + var bdPos; // box center offset - var bPos = group ? 2 * t.dPos * (-0.5 + (t.num + 0.5) / numBoxes) * groupFraction : 0; + var bPos; + + if(trace.width) { + bdPos = dPos; + bPos = 0; + } else { + bdPos = dPos * groupFraction * groupGapFraction / (group ? numBoxes : 1); + bPos = group ? 2 * dPos * (-0.5 + (t.num + 0.5) / numBoxes) * groupFraction : 0; + } + // whisker width var wdPos = bdPos * trace.whiskerwidth; diff --git a/src/traces/violin/attributes.js b/src/traces/violin/attributes.js index 965b8fecbb7..bdf6e3af3dc 100644 --- a/src/traces/violin/attributes.js +++ b/src/traces/violin/attributes.js @@ -135,6 +135,15 @@ module.exports = { 'right (left) for vertical violins and above (below) for horizontal violins.' ].join(' ') }), + + width: extendFlat({}, boxAttrs.width, { + description: [ + 'Sets the width of the violin in data coordinates.', + 'If *0* (default value) the width is automatically selected based on the positions', + 'of other violin traces in the same subplot.', + ].join(' ') + }), + marker: boxAttrs.marker, text: boxAttrs.text, diff --git a/src/traces/violin/calc.js b/src/traces/violin/calc.js index 1994f4235c3..52a72e57462 100644 --- a/src/traces/violin/calc.js +++ b/src/traces/violin/calc.js @@ -25,18 +25,10 @@ module.exports = function calc(gd, trace) { 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 - }; - } - var spanMin = Infinity; var spanMax = -Infinity; + var maxKDE = 0; + var maxCount = 0; for(var i = 0; i < cd.length; i++) { var cdi = cd[i]; @@ -61,12 +53,11 @@ module.exports = function calc(gd, trace) { 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}; + maxKDE = Math.max(maxKDE, v); } - groupStats.maxCount = Math.max(groupStats.maxCount, vals.length); - + maxCount = Math.max(maxCount, vals.length); spanMin = Math.min(spanMin, span[0]); spanMax = Math.max(spanMax, span[1]); } @@ -74,6 +65,24 @@ module.exports = function calc(gd, trace) { var extremes = Axes.findExtremes(valAxis, [spanMin, spanMax], {padded: true}); trace._extremes[valAxis._id] = extremes; + if(trace.width) { + cd[0].t.maxKDE = maxKDE; + } else { + var violinScaleGroupStats = fullLayout._violinScaleGroupStats; + var scaleGroup = trace.scalegroup; + var groupStats = violinScaleGroupStats[scaleGroup]; + + if(groupStats) { + groupStats.maxKDE = Math.max(groupStats.maxKDE, maxKDE); + groupStats.maxCount = Math.max(groupStats.maxCount, maxCount); + } else { + violinScaleGroupStats[scaleGroup] = { + maxKDE: maxKDE, + maxCount: maxCount + }; + } + } + cd[0].t.labels.kde = Lib._(gd, 'kde:'); return cd; diff --git a/src/traces/violin/defaults.js b/src/traces/violin/defaults.js index 869e7b08320..3b394518d87 100644 --- a/src/traces/violin/defaults.js +++ b/src/traces/violin/defaults.js @@ -26,10 +26,14 @@ module.exports = function supplyDefaults(traceIn, traceOut, defaultColor, layout if(traceOut.visible === false) return; coerce('bandwidth'); - coerce('scalegroup', traceOut.name); - coerce('scalemode'); coerce('side'); + var width = coerce('width'); + if(!width) { + coerce('scalegroup', traceOut.name); + coerce('scalemode'); + } + var span = coerce('span'); var spanmodeDflt; if(Array.isArray(span)) spanmodeDflt = 'manual'; diff --git a/src/traces/violin/layout_attributes.js b/src/traces/violin/layout_attributes.js index b02e309c660..ce6f1f2651a 100644 --- a/src/traces/violin/layout_attributes.js +++ b/src/traces/violin/layout_attributes.js @@ -19,19 +19,22 @@ module.exports = { '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.' + 'you might need to set *opacity* to see them multiple violins.', + 'Has no effect on traces that have *width* set.' ].join(' ') }), violingap: extendFlat({}, boxLayoutAttrs.boxgap, { description: [ 'Sets the gap (in plot fraction) between violins of', - 'adjacent location coordinates.' + 'adjacent location coordinates.', + 'Has no effect on traces that have *width* set.' ].join(' ') }), violingroupgap: extendFlat({}, boxLayoutAttrs.boxgroupgap, { description: [ 'Sets the gap (in plot fraction) between violins of', - 'the same location coordinate.' + 'the same location coordinate.', + 'Has no effect on traces that have *width* set.' ].join(' ') }) }; diff --git a/src/traces/violin/plot.js b/src/traces/violin/plot.js index 43b53a8046e..de046a5a9b2 100644 --- a/src/traces/violin/plot.js +++ b/src/traces/violin/plot.js @@ -20,6 +20,10 @@ module.exports = function plot(gd, plotinfo, cdViolins, violinLayer) { var fullLayout = gd._fullLayout; var xa = plotinfo.xaxis; var ya = plotinfo.yaxis; + var numViolins = fullLayout._numViolins; + var group = (fullLayout.violinmode === 'group' && numViolins > 1); + var groupFraction = 1 - fullLayout.violingap; + var groupGapFraction = 1 - fullLayout.violingroupgap; function makePath(pts) { var segments = linePoints(pts, { @@ -39,16 +43,30 @@ module.exports = function plot(gd, plotinfo, cdViolins, violinLayer) { var t = cd0.t; var trace = cd0.trace; if(!plotinfo.isRangePlot) cd0.node3 = plotGroup; - var numViolins = fullLayout._numViolins; - var group = (fullLayout.violinmode === 'group' && numViolins > 1); - var groupFraction = 1 - fullLayout.violingap; + + // position coordinate delta + var dPos = t.dPos; // violin max half width - var bdPos = t.bdPos = t.dPos * groupFraction * (1 - fullLayout.violingroupgap) / (group ? numViolins : 1); + var bdPos; // violin center offset - var bPos = t.bPos = group ? 2 * t.dPos * (-0.5 + (t.num + 0.5) / numViolins) * groupFraction : 0; + var bPos; // half-width within which to accept hover for this violin // always split the distance to the closest violin - t.wHover = t.dPos * (group ? groupFraction / numViolins : 1); + var wHover; + + if(trace.width) { + bdPos = dPos; + bPos = 0; + wHover = dPos; + } else { + bdPos = dPos * groupFraction * groupGapFraction / (group ? numViolins : 1); + bPos = group ? 2 * dPos * (-0.5 + (t.num + 0.5) / numViolins) * groupFraction : 0; + wHover = dPos * (group ? groupFraction / numViolins : 1); + } + + t.bdPos = bdPos; + t.bPos = bPos; + t.wHover = wHover; if(trace.visible !== true || t.empty) { plotGroup.remove(); @@ -60,7 +78,6 @@ module.exports = function plot(gd, plotinfo, cdViolins, violinLayer) { var hasBothSides = trace.side === 'both'; var hasPositiveSide = hasBothSides || trace.side === 'positive'; var hasNegativeSide = hasBothSides || trace.side === 'negative'; - var groupStats = fullLayout._violinScaleGroupStats[trace.scalegroup]; var violins = plotGroup.selectAll('path.violin').data(Lib.identity); @@ -76,15 +93,15 @@ module.exports = function plot(gd, plotinfo, cdViolins, violinLayer) { 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 scale; + if(trace.width) { + scale = t.maxKDE / bdPos; + } else { + var groupStats = fullLayout._violinScaleGroupStats[trace.scalegroup]; + scale = trace.scalemode === 'count' ? + (groupStats.maxKDE / bdPos) * (groupStats.maxCount / d.pts.length) : + groupStats.maxKDE / bdPos; } var pathPos, pathNeg, path; diff --git a/test/image/baselines/violin_box_multiple_widths.png b/test/image/baselines/violin_box_multiple_widths.png new file mode 100644 index 00000000000..d84c0ec8dc3 Binary files /dev/null and b/test/image/baselines/violin_box_multiple_widths.png differ diff --git a/test/image/mocks/violin_box_multiple_widths.json b/test/image/mocks/violin_box_multiple_widths.json new file mode 100644 index 00000000000..f8bb39d0f81 --- /dev/null +++ b/test/image/mocks/violin_box_multiple_widths.json @@ -0,0 +1,44 @@ +{ + "data": [{ + "type": "violin", + "width": 0.4, + "name": "width: 0.4", + "x": [0, 5, 7, 8], + "side": "positive", + "line": { + "color": "black" + }, + "fillcolor": "#8dd3c7", + "opacity": 0.6, + "y0": 0.0 + }, { + "type": "violin", + "name": "auto", + "x": [0, 5, 7, 8], + "side": "positive", + "line": { + "color": "black" + }, + "fillcolor": "#d3c78d", + "opacity": 0.6, + "y0": 0.1 + }, { + "type": "box", + "width": 0.6, + "name": "width: 0.6", + "x": [0, 5, 7, 8], + "side": "positive", + "line": { + "color": "black" + }, + "fillcolor": "#c78dd3", + "opacity": 0.6, + "y0": 0.2 + }], + "layout": { + "title": "Joyplot - Violin with multiple widths", + "legend": {"x": 0}, + "xaxis": {"zeroline": false}, + "yaxis": {"dtick": 0.1, "gridcolor": "black"} + } +} diff --git a/test/jasmine/tests/violin_test.js b/test/jasmine/tests/violin_test.js index c925b096acb..643f7548e5d 100644 --- a/test/jasmine/tests/violin_test.js +++ b/test/jasmine/tests/violin_test.js @@ -142,6 +142,23 @@ describe('Test violin defaults', function() { expect(traceOut.meanline.color).toBe('blue'); expect(traceOut.meanline.width).toBe(10); }); + + it('should not coerce *scalegroup* and *scalemode* when *width* is set', function() { + _supply({ + y: [1, 2, 1], + width: 1 + }); + expect(traceOut.scalemode).toBeUndefined(); + expect(traceOut.scalegroup).toBeUndefined(); + + _supply({ + y: [1, 2, 1], + // width=0 is ignored during calc + width: 0 + }); + expect(traceOut.scalemode).toBe('width'); + expect(traceOut.scalegroup).toBe(''); + }); }); describe('Test violin calc:', function() { @@ -236,7 +253,7 @@ describe('Test violin calc:', function() { name: 'one', y: [0, 0, 0, 0, 10, 10, 10, 10] }); - expect(fullLayout._violinScaleGroupStats.one.maxWidth).toBeCloseTo(0.055); + expect(fullLayout._violinScaleGroupStats.one.maxKDE).toBeCloseTo(0.055); expect(fullLayout._violinScaleGroupStats.one.maxCount).toBe(8); }); @@ -533,7 +550,7 @@ describe('Test violin hover:', function() { Plotly.plot(gd, fig).then(function() { mouseEvent('mousemove', 300, 250); - assertViolinHoverLine([299.35, 250, 250, 250]); + assertViolinHoverLine([178.67823028564453, 250, 80, 250]); }) .catch(failTest) .then(done); @@ -544,7 +561,7 @@ describe('Test violin hover:', function() { Plotly.plot(gd, fig).then(function() { mouseEvent('mousemove', 200, 250); - assertViolinHoverLine([200.65, 250, 250, 250]); + assertViolinHoverLine([321.3217315673828, 250, 420, 250]); }) .catch(failTest) .then(done);