From 4805faa425075c94b0dd22464e3144f2ad2d3097 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= Date: Mon, 25 Nov 2019 16:41:00 -0500 Subject: [PATCH 01/12] add quartilemethod attribute to box traces ... with values 'linear', 'exclusive' and 'inclusive' --- src/traces/box/attributes.js | 24 +++++ src/traces/box/calc.js | 43 +++++--- src/traces/box/defaults.js | 1 + test/image/baselines/box_quartile-methods.png | Bin 0 -> 20501 bytes test/image/mocks/box_quartile-methods.json | 17 ++++ test/jasmine/tests/box_test.js | 92 ++++++++++++++++++ 6 files changed, 165 insertions(+), 12 deletions(-) create mode 100644 test/image/baselines/box_quartile-methods.png create mode 100644 test/image/mocks/box_quartile-methods.json diff --git a/src/traces/box/attributes.js b/src/traces/box/attributes.js index 8b31df30dc9..3167ea3e759 100644 --- a/src/traces/box/attributes.js +++ b/src/traces/box/attributes.js @@ -187,6 +187,30 @@ module.exports = { ].join(' ') }, + quartilemethod: { + valType: 'enumerated', + values: ['linear', 'exclusive', 'inclusive'], + dflt: 'linear', + role: 'info', + editType: 'calc', + description: [ + 'Sets the method used to compute the sample\'s Q1 and Q3 quartiles.', + + 'The *linear* method uses the 25th percentile for Q1 and 75th percentile for Q3', + 'as computed using method #10 listed on http://www.amstat.org/publications/jse/v14n3/langford.html).', + + 'The *exclusive* method uses the median to divide the ordered dataset into two halves', + 'if the sample is odd, it does not includes the median in either half -', + 'Q1 is then the median of the lower half and', + 'Q3 the median of the upper half.', + + 'The *inclusive* method also uses the median to divide the ordered dataset into two halves', + 'but if the sample is odd, it includes the median in both halves -', + 'Q1 is then the median of the lower half and', + 'Q3 the median of the upper half.' + ].join(' ') + }, + width: { valType: 'number', min: 0, diff --git a/src/traces/box/calc.js b/src/traces/box/calc.js index 992c2dc64d7..aa47017ffda 100644 --- a/src/traces/box/calc.js +++ b/src/traces/box/calc.js @@ -77,7 +77,7 @@ module.exports = function calc(gd, trace) { if(ptsPerBin[i].length > 0) { var pts = ptsPerBin[i].sort(sortByVal); var boxVals = pts.map(extractVal); - var bvLen = boxVals.length; + var N = boxVals.length; cdi = {}; cdi.pos = posDistinct[i]; @@ -85,19 +85,38 @@ module.exports = function calc(gd, trace) { // Sort categories by values cdi[posLetter] = cdi.pos; - cdi[valLetter] = cdi.pts.map(function(pt) { return pt.v; }); + cdi[valLetter] = cdi.pts.map(extractVal); cdi.min = boxVals[0]; - cdi.max = boxVals[bvLen - 1]; - cdi.mean = Lib.mean(boxVals, bvLen); - cdi.sd = Lib.stdev(boxVals, bvLen, cdi.mean); + cdi.max = boxVals[N - 1]; + cdi.mean = Lib.mean(boxVals, N); + cdi.sd = Lib.stdev(boxVals, N, cdi.mean); - // first quartile - cdi.q1 = Lib.interp(boxVals, 0.25); - // median + // median cdi.med = Lib.interp(boxVals, 0.5); - // third quartile - cdi.q3 = Lib.interp(boxVals, 0.75); + + var quartilemethod = trace.quartilemethod; + + if((N % 2) && (quartilemethod === 'exclusive' || quartilemethod === 'inclusive')) { + var lower; + var upper; + + if(quartilemethod === 'exclusive') { + // do NOT include the median in either half + lower = boxVals.slice(0, N / 2); + upper = boxVals.slice(N / 2 + 1); + } else if(quartilemethod === 'inclusive') { + // include the median in either half + lower = boxVals.slice(0, N / 2 + 1); + upper = boxVals.slice(N / 2); + } + + cdi.q1 = Lib.interp(lower, 0.5); + cdi.q3 = Lib.interp(upper, 0.5); + } else { + cdi.q1 = Lib.interp(boxVals, 0.25); + cdi.q3 = Lib.interp(boxVals, 0.75); + } // lower and upper fences - last point inside // 1.5 interquartile ranges from quartiles @@ -105,7 +124,7 @@ module.exports = function calc(gd, trace) { cdi.q1, boxVals[Math.min( Lib.findBin(2.5 * cdi.q1 - 1.5 * cdi.q3, boxVals, true) + 1, - bvLen - 1 + N - 1 )] ); cdi.uf = Math.max( @@ -123,7 +142,7 @@ module.exports = function calc(gd, trace) { // lower and upper notches ~95% Confidence Intervals for median var iqr = cdi.q3 - cdi.q1; - var mci = 1.57 * iqr / Math.sqrt(bvLen); + var mci = 1.57 * iqr / Math.sqrt(N); cdi.ln = cdi.med - mci; cdi.un = cdi.med + mci; minLowerNotch = Math.min(minLowerNotch, cdi.ln); diff --git a/src/traces/box/defaults.js b/src/traces/box/defaults.js index 3bba43eb33d..30a951c1b09 100644 --- a/src/traces/box/defaults.js +++ b/src/traces/box/defaults.js @@ -29,6 +29,7 @@ function supplyDefaults(traceIn, traceOut, defaultColor, layout) { coerce('whiskerwidth'); coerce('boxmean'); coerce('width'); + coerce('quartilemethod'); var notched = coerce('notched', traceIn.notchwidth !== undefined); if(notched) coerce('notchwidth'); diff --git a/test/image/baselines/box_quartile-methods.png b/test/image/baselines/box_quartile-methods.png new file mode 100644 index 0000000000000000000000000000000000000000..1c9e6115b1a91ed182018b4dff61131974b067d7 GIT binary patch literal 20501 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 literal 0 HcmV?d00001 diff --git a/test/image/mocks/box_quartile-methods.json b/test/image/mocks/box_quartile-methods.json new file mode 100644 index 00000000000..d2c2b2fefaa --- /dev/null +++ b/test/image/mocks/box_quartile-methods.json @@ -0,0 +1,17 @@ +{ + "data": [{ + "type": "box", + "y": [1, 2, 3, 4, 5], + "name": "linear" + }, { + "type": "box", + "y": [1, 2, 3, 4, 5], + "name": "exclusive", + "quartilemethod": "exclusive" + }, { + "type": "box", + "y": [1, 2, 3, 4, 5], + "name": "inclusive", + "quartilemethod": "inclusive" + }] +} diff --git a/test/jasmine/tests/box_test.js b/test/jasmine/tests/box_test.js index 998f32ee3cb..cd2dea4852c 100644 --- a/test/jasmine/tests/box_test.js +++ b/test/jasmine/tests/box_test.js @@ -1,5 +1,6 @@ var Plotly = require('@lib'); var Lib = require('@src/lib'); +var Plots = require('@src/plots/plots'); var Box = require('@src/traces/box'); @@ -610,3 +611,94 @@ describe('Test box restyle:', function() { .then(done); }); }); + +describe('Test box calc', function() { + var gd; + + function _calc(attrs, layout) { + gd = { + data: [Lib.extendFlat({type: 'box'}, attrs)], + layout: layout || {}, + calcdata: [] + }; + supplyAllDefaults(gd); + Plots.doCalcdata(gd); + return gd.calcdata[0]; + } + + it('should compute q1/q3 depending on *quartilemethod*', function() { + // samples from https://en.wikipedia.org/wiki/Quartile + var specs = { + // N is odd and is spanned by (4n+3) + odd: { + sample: [6, 7, 15, 36, 39, 40, 41, 42, 43, 47, 49], + methods: { + linear: {q1: 20.25, q3: 42.75}, + exclusive: {q1: 15, q3: 43}, + inclusive: {q1: 25.5, q3: 42.5} + } + }, + // N is odd and is spanned by (4n+1) + odd2: { + sample: [6, 15, 36, 39, 40, 42, 43, 47, 49], + methods: { + linear: {q1: 30.75, q3: 44}, + exclusive: {q1: 25.5, q3: 45}, + inclusive: {q1: 36, q3: 43} + } + }, + // N is even + even: { + sample: [7, 15, 36, 39, 40, 41], + methods: { + linear: {q1: 15, q3: 40}, + exclusive: {q1: 15, q3: 40}, + inclusive: {q1: 15, q3: 40} + } + }, + // samples from http://jse.amstat.org/v14n3/langford.html + s4: { + sample: [1, 2, 3, 4], + methods: { + linear: {q1: 1.5, q3: 3.5}, + exclusive: {q1: 1.5, q3: 3.5}, + inclusive: {q1: 1.5, q3: 3.5} + } + }, + s5: { + sample: [1, 2, 3, 4, 5], + methods: { + linear: {q1: 1.75, q3: 4.25}, + exclusive: {q1: 1.5, q3: 4.5}, + inclusive: {q1: 2, q3: 4} + } + }, + s6: { + sample: [1, 2, 3, 4, 5, 6], + methods: { + linear: {q1: 2, q3: 5}, + exclusive: {q1: 2, q3: 5}, + inclusive: {q1: 2, q3: 5} + } + }, + s7: { + sample: [1, 2, 3, 4, 5, 6, 7], + methods: { + linear: {q1: 2.25, q3: 5.75}, + exclusive: {q1: 2, q3: 6}, + inclusive: {q1: 2.5, q3: 5.5} + } + } + }; + + for(var name in specs) { + var spec = specs[name]; + + for(var m in spec.methods) { + var cd = _calc({y: spec.sample, quartilemethod: m}); + expect(cd[0].q1).toBe(spec.methods[m].q1, ['q1', m, name].join(' | ')); + expect(cd[0].q3).toBe(spec.methods[m].q3, ['q3', m, name].join(' | ')); + } + } + }); +}); From 5be9c36479eec556856827ad6857bc96bc81a796 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= Date: Wed, 27 Nov 2019 10:36:54 -0500 Subject: [PATCH 02/12] reorder box attributes + make notched `role: 'info'` --- src/traces/box/attributes.js | 98 ++++++++++++++++++++---------------- 1 file changed, 54 insertions(+), 44 deletions(-) diff --git a/src/traces/box/attributes.js b/src/traces/box/attributes.js index 3167ea3e759..d6678dc231e 100644 --- a/src/traces/box/attributes.js +++ b/src/traces/box/attributes.js @@ -52,6 +52,7 @@ module.exports = { 'See overview for more info.' ].join(' ') }, + name: { valType: 'string', role: 'info', @@ -64,40 +65,10 @@ module.exports = { 'missing and the position axis is categorical' ].join(' ') }, - text: extendFlat({}, scatterAttrs.text, { - description: [ - 'Sets the text elements associated with each sample value.', - 'If a single string, the same string appears over', - 'all the data points.', - 'If an array of string, the items are mapped in order to the', - 'this trace\'s (x,y) coordinates.', - 'To be seen, trace `hoverinfo` must contain a *text* flag.' - ].join(' ') - }), - hovertext: extendFlat({}, scatterAttrs.hovertext, { - description: 'Same as `text`.' - }), - hovertemplate: hovertemplateAttrs({ - description: [ - 'N.B. This only has an effect when hovering on points.' - ].join(' ') - }), - whiskerwidth: { - valType: 'number', - min: 0, - max: 1, - dflt: 0.5, - role: 'style', - editType: 'calc', - description: [ - 'Sets the width of the whiskers relative to', - 'the box\' width.', - 'For example, with 1, the whiskers are as wide as the box(es).' - ].join(' ') - }, + notched: { valType: 'boolean', - role: 'style', + role: 'info', editType: 'calc', description: [ 'Determines whether or not notches are drawn.', @@ -121,6 +92,7 @@ module.exports = { 'For example, with 0, the notches are as wide as the box(es).' ].join(' ') }, + boxpoints: { valType: 'enumerated', values: ['all', 'outliers', 'suspectedoutliers', false], @@ -137,18 +109,6 @@ module.exports = { 'If *false*, only the box(es) are shown with no sample points' ].join(' ') }, - boxmean: { - valType: 'enumerated', - values: [true, 'sd', false], - dflt: false, - role: 'style', - editType: 'calc', - description: [ - 'If *true*, the mean of the box(es)\' underlying distribution is', - 'drawn as a dashed line inside the box(es).', - 'If *sd* the standard deviation is also drawn.' - ].join(' ') - }, jitter: { valType: 'number', min: 0, @@ -175,6 +135,20 @@ module.exports = { 'right (left) for vertical boxes and above (below) for horizontal boxes' ].join(' ') }, + + boxmean: { + valType: 'enumerated', + values: [true, 'sd', false], + dflt: false, + role: 'style', + editType: 'calc', + description: [ + 'If *true*, the mean of the box(es)\' underlying distribution is', + 'drawn as a dashed line inside the box(es).', + 'If *sd* the standard deviation is also drawn.' + ].join(' ') + }, + orientation: { valType: 'enumerated', values: ['v', 'h'], @@ -270,6 +244,7 @@ module.exports = { }, editType: 'plot' }, + line: { color: { valType: 'color', @@ -287,8 +262,23 @@ module.exports = { }, editType: 'plot' }, + fillcolor: scatterAttrs.fillcolor, + whiskerwidth: { + valType: 'number', + min: 0, + max: 1, + dflt: 0.5, + role: 'style', + editType: 'calc', + description: [ + 'Sets the width of the whiskers relative to', + 'the box\' width.', + 'For example, with 1, the whiskers are as wide as the box(es).' + ].join(' ') + }, + offsetgroup: barAttrs.offsetgroup, alignmentgroup: barAttrs.alignmentgroup, @@ -300,6 +290,26 @@ module.exports = { marker: scatterAttrs.unselected.marker, editType: 'style' }, + + text: extendFlat({}, scatterAttrs.text, { + description: [ + 'Sets the text elements associated with each sample value.', + 'If a single string, the same string appears over', + 'all the data points.', + 'If an array of string, the items are mapped in order to the', + 'this trace\'s (x,y) coordinates.', + 'To be seen, trace `hoverinfo` must contain a *text* flag.' + ].join(' ') + }), + hovertext: extendFlat({}, scatterAttrs.hovertext, { + description: 'Same as `text`.' + }), + hovertemplate: hovertemplateAttrs({ + description: [ + 'N.B. This only has an effect when hovering on points.' + ].join(' ') + }), + hoveron: { valType: 'flaglist', flags: ['boxes', 'points'], From 73d845ced42ec7a326f331df5dbf7da420441b3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= Date: Tue, 17 Dec 2019 17:31:53 -0500 Subject: [PATCH 03/12] fix confidence interval formula in notched attr description --- src/traces/box/attributes.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/traces/box/attributes.js b/src/traces/box/attributes.js index d6678dc231e..12f56a11e84 100644 --- a/src/traces/box/attributes.js +++ b/src/traces/box/attributes.js @@ -73,7 +73,7 @@ module.exports = { description: [ 'Determines whether or not notches are drawn.', 'Notches displays a confidence interval around the median.', - 'We compute the confidence interval as median +/- 1.57 / IQR * sqrt(N),', + 'We compute the confidence interval as median +/- 1.57 * IQR / sqrt(N),', 'where IQR is the interquartile range and N is the sample size.', 'If two boxes\' notches do not overlap there is 95% confidence their medians differ.', 'See https://sites.google.com/site/davidsstatistics/home/notched-box-plots for more info.' From feb43883fd130953bea27230d1db1c11fcff7ae5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= Date: Tue, 17 Dec 2019 17:33:55 -0500 Subject: [PATCH 04/12] correctly declare boxpoints & violin points attribute dflt ... and mention how the smart logic work in the attribute description. --- src/traces/box/attributes.js | 6 ++++-- src/traces/violin/attributes.js | 5 ++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/traces/box/attributes.js b/src/traces/box/attributes.js index 12f56a11e84..3ae1668be41 100644 --- a/src/traces/box/attributes.js +++ b/src/traces/box/attributes.js @@ -96,7 +96,6 @@ module.exports = { boxpoints: { valType: 'enumerated', values: ['all', 'outliers', 'suspectedoutliers', false], - dflt: 'outliers', role: 'style', editType: 'calc', description: [ @@ -106,7 +105,10 @@ module.exports = { '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 box(es) are shown with no sample points' + 'If *false*, only the box(es) are shown with no sample points', + 'Defaults to *suspectedoutliers* when `marker.outliercolor` or', + '`marker.line.outliercolor` is set,', + 'otherwise defaults to *outliers*.' ].join(' ') }, jitter: { diff --git a/src/traces/violin/attributes.js b/src/traces/violin/attributes.js index e4d961addad..1014490d732 100644 --- a/src/traces/violin/attributes.js +++ b/src/traces/violin/attributes.js @@ -128,7 +128,10 @@ module.exports = { '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' + 'If *false*, only the violins are shown with no sample points', + 'Defaults to *suspectedoutliers* when `marker.outliercolor` or', + '`marker.line.outliercolor` is set,', + 'otherwise defaults to *outliers*.' ].join(' ') }), jitter: extendFlat({}, boxAttrs.jitter, { From d32f66205193b82e17ab8a8121bd38beafde1c54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= Date: Tue, 17 Dec 2019 17:39:07 -0500 Subject: [PATCH 05/12] introduce new box attributes for q1/median/q3 signature - add attribues q1, median, q3, lowerfence, upperfence, notchspan, mean, sd, dx, dy - add info in the module meta description - add info about improved boxmean, boxpoints and notched dflt logic - add TODO comment for potential outlier bounds attributes --- src/traces/box/attributes.js | 135 +++++++++++++++++++++++++++++++++-- src/traces/box/index.js | 35 ++++++--- 2 files changed, 152 insertions(+), 18 deletions(-) diff --git a/src/traces/box/attributes.js b/src/traces/box/attributes.js index 3ae1668be41..1305ec52b00 100644 --- a/src/traces/box/attributes.js +++ b/src/traces/box/attributes.js @@ -39,7 +39,9 @@ module.exports = { role: 'info', editType: 'calc+clearAxisTypes', description: [ - 'Sets the x coordinate of the box.', + 'Sets the x coordinate for single-box traces', + 'or the starting coordinate for multi-box traces', + 'set using q1/median/q3.', 'See overview for more info.' ].join(' ') }, @@ -48,11 +50,32 @@ module.exports = { role: 'info', editType: 'calc+clearAxisTypes', description: [ - 'Sets the y coordinate of the box.', + 'Sets the y coordinate for single-box traces', + 'or the starting coordinate for multi-box traces', + 'set using q1/median/q3.', 'See overview for more info.' ].join(' ') }, + dx: { + valType: 'number', + role: 'info', + editType: 'calc', + description: [ + 'Sets the x coordinate step for multi-box traces', + 'set using q1/median/q3.' + ].join(' ') + }, + dy: { + valType: 'number', + role: 'info', + editType: 'calc', + description: [ + 'Sets the y coordinate step for multi-box traces', + 'set using q1/median/q3.' + ].join(' ') + }, + name: { valType: 'string', role: 'info', @@ -66,6 +89,58 @@ module.exports = { ].join(' ') }, + q1: { + valType: 'data_array', + role: 'info', + editType: 'calc+clearAxisTypes', + description: [ + 'Sets the Quartile 1 values,', + 'There should be as many items as the number of boxes desired.', + ].join(' ') + }, + median: { + valType: 'data_array', + role: 'info', + editType: 'calc+clearAxisTypes', + description: [ + 'Sets the median values.', + 'There should be as many items as the number of boxes desired.', + ].join(' ') + }, + q3: { + valType: 'data_array', + role: 'info', + editType: 'calc+clearAxisTypes', + description: [ + 'Sets the Quartile 3 values,', + 'There should be as many items as the number of boxes desired.', + ].join(' ') + }, + lowerfence: { + valType: 'data_array', + role: 'info', + editType: 'calc', + description: [ + 'Sets the lower fence values,', + 'There should be as many items as the number of boxes desired.', + 'This attribute has effect only under the q1/median/q3 signature.', + 'If `lowerfence` is not provided but a sample (in `y` or `x`) is set,', + 'we compute the lower as the last sample point below 1.5 times the IQR.' + ].join(' ') + }, + upperfence: { + valType: 'data_array', + role: 'info', + editType: 'calc', + description: [ + 'Sets the upper fence values,', + 'There should be as many items as the number of boxes desired.', + 'This attribute has effect only under the q1/median/q3 signature.', + 'If `upperfence` is not provided but a sample (in `y` or `x`) is set,', + 'we compute the lower as the last sample point above 1.5 times the IQR.' + ].join(' ') + }, + notched: { valType: 'boolean', role: 'info', @@ -76,7 +151,8 @@ module.exports = { 'We compute the confidence interval as median +/- 1.57 * IQR / sqrt(N),', 'where IQR is the interquartile range and N is the sample size.', 'If two boxes\' notches do not overlap there is 95% confidence their medians differ.', - 'See https://sites.google.com/site/davidsstatistics/home/notched-box-plots for more info.' + 'See https://sites.google.com/site/davidsstatistics/home/notched-box-plots for more info.', + 'Defaults to *false* unless `notchwidth` or `notchspan` is set.' ].join(' ') }, notchwidth: { @@ -92,6 +168,24 @@ module.exports = { 'For example, with 0, the notches are as wide as the box(es).' ].join(' ') }, + notchspan: { + valType: 'data_array', + role: 'info', + editType: 'calc', + description: [ + 'Sets the notch span from the boxes\' `median` values,', + 'There should be as many items as the number of boxes desired.', + 'This attribute has effect only under the q1/median/q3 signature.', + 'If `notchspan` is not provided but a sample (in `y` or `x`) is set,', + 'we compute it as 1.57 * IQR / sqrt(N),', + 'where N is the sample size.' + ].join(' ') + }, + + // TODO + // maybe add + // - loweroutlierbound / upperoutlierbound + // - lowersuspectedoutlierbound / uppersuspectedoutlierbound boxpoints: { valType: 'enumerated', @@ -107,8 +201,9 @@ module.exports = { 'If *all*, all sample points are shown', 'If *false*, only the box(es) are shown with no sample points', 'Defaults to *suspectedoutliers* when `marker.outliercolor` or', - '`marker.line.outliercolor` is set,', - 'otherwise defaults to *outliers*.' + '`marker.line.outliercolor` is set.,', + 'Defaults to *all* under the q1/median/q3 signature.', + 'Otherwise defaults to *outliers*.', ].join(' ') }, jitter: { @@ -141,13 +236,39 @@ module.exports = { boxmean: { valType: 'enumerated', values: [true, 'sd', false], - dflt: false, role: 'style', editType: 'calc', description: [ 'If *true*, the mean of the box(es)\' underlying distribution is', 'drawn as a dashed line inside the box(es).', - 'If *sd* the standard deviation is also drawn.' + 'If *sd* the standard deviation is also drawn.', + 'Defaults to *true* when `mean` is set', + 'Defaults to *sd* when `sd` is set', + 'Otherwise defaults to *false*.' + ].join(' ') + }, + mean: { + valType: 'data_array', + role: 'info', + editType: 'calc', + description: [ + 'Sets the mean values,', + 'There should be as many items as the number of boxes desired.', + 'This attribute has effect only under the q1/median/q3 signature.', + 'If `mean` is not provided but a sample (in `y` or `x`) is set,', + 'we compute the mean for each box using the sample values.' + ].join(' ') + }, + sd: { + valType: 'data_array', + role: 'info', + editType: 'calc', + description: [ + 'Sets the standard deviation values,', + 'There should be as many items as the number of boxes desired.', + 'This attribute has effect only under the q1/median/q3 signature.', + 'If `sd` is not provided but a sample (in `y` or `x`) is set,', + 'we compute the standard deviation for each box using the sample values.' ].join(' ') }, diff --git a/src/traces/box/index.js b/src/traces/box/index.js index bbc160fac16..2fdc99e237e 100644 --- a/src/traces/box/index.js +++ b/src/traces/box/index.js @@ -29,18 +29,31 @@ module.exports = { categories: ['cartesian', 'svg', 'symbols', 'oriented', 'box-violin', 'showLegend', 'boxLayout', 'zoomScale'], meta: { description: [ - 'In vertical (horizontal) box plots,', - 'statistics are computed using `y` (`x`) values.', - 'By supplying an `x` (`y`) array, one box per distinct x (y) value', - 'is drawn', - 'If no `x` (`y`) {array} is provided, a single box is drawn.', - 'That box position is then positioned with', - 'with `name` or with `x0` (`y0`) if provided.', 'Each box spans from quartile 1 (Q1) to quartile 3 (Q3).', - 'The second quartile (Q2) is marked by a line inside the box.', - 'By default, the whiskers correspond to the box\' edges', - '+/- 1.5 times the interquartile range (IQR: Q3-Q1),', - 'see *boxpoints* for other options.' + 'The second quartile (Q2, i.e. the median) is marked by a line inside the box.', + 'The fences grow outward from the boxes\' edges,', + 'by default they span +/- 1.5 times the interquartile range (IQR: Q3-Q1),', + 'The sample mean and standard deviation as well as notches and', + 'the sample, outlier and suspected outliers points can be optionally', + 'added to the box plot', + + 'The values and positions corresponding to each boxes can be input', + 'using two signatures.', + + 'The first signature expects users to supply the sample values in the `y`', + 'data array for vertical boxes (`x` for horizontal boxes).', + 'By supplying an `x` (`y`) array, one box per distinct x (y) value is drawn', + 'If no `x` (`y`) {array} is provided, a single box is drawn.', + 'In this case, the box is positioned with the trace `name` or with `x0` (`y0`) if provided.', + + 'The second signature expects users to supply the boxes corresponding Q1, median and Q3', + 'statistics in the `q1`, `median` and `q3` data arrays respectively.', + 'Other box features relying on statistics namely `lowerfence`, `upperfence`, `notchspan`', + 'can be set directly by the users.', + 'To have plotly compute them or to show sample points besides the boxes,', + 'users can set the `y` data array for vertical boxes (`x` for horizontal boxes)', + 'to a 2D array with the outer length corresponding', + 'to the number of boxes in the traces and the inner length corresponding the sample size.' ].join(' ') } }; From bc9d1212b6b7ce4900b5327cbdbab97d43c37b01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= Date: Tue, 17 Dec 2019 17:40:30 -0500 Subject: [PATCH 06/12] add q1/median/q3 defaults + calc logic & tests --- src/plots/cartesian/type_defaults.js | 12 +- src/traces/box/calc.js | 385 +++++++---- src/traces/box/defaults.js | 179 +++++- .../image/baselines/box_precomputed-stats.png | Bin 0 -> 51684 bytes test/image/mocks/box_precomputed-stats.json | 211 ++++++ test/jasmine/tests/box_test.js | 606 ++++++++++++++++++ 6 files changed, 1257 insertions(+), 136 deletions(-) create mode 100644 test/image/baselines/box_precomputed-stats.png create mode 100644 test/image/mocks/box_precomputed-stats.json diff --git a/src/plots/cartesian/type_defaults.js b/src/plots/cartesian/type_defaults.js index 5edf2a37365..9040f905abf 100644 --- a/src/plots/cartesian/type_defaults.js +++ b/src/plots/cartesian/type_defaults.js @@ -40,6 +40,7 @@ function setAutoType(ax, data) { var id = ax._id; var axLetter = id.charAt(0); + var i; // support 3d if(id.indexOf('scene') !== -1) id = axLetter; @@ -50,7 +51,8 @@ function setAutoType(ax, data) { // first check for histograms, as the count direction // should always default to a linear axis if(d0.type === 'histogram' && - axLetter === {v: 'y', h: 'x'}[d0.orientation || 'v']) { + axLetter === {v: 'y', h: 'x'}[d0.orientation || 'v'] + ) { ax.type = 'linear'; return; } @@ -58,7 +60,13 @@ function setAutoType(ax, data) { var calAttr = axLetter + 'calendar'; var calendar = d0[calAttr]; var opts = {noMultiCategory: !traceIs(d0, 'cartesian') || traceIs(d0, 'noMultiCategory')}; - var i; + + // To not confuse 2D x/y used for per-box sample points for multicategory coordinates + if(d0.type === 'box' && d0._hasPreCompStats && + axLetter === {h: 'x', v: 'y'}[d0.orientation || 'v'] + ) { + opts.noMultiCategory = true; + } // check all boxes on this x axis to see // if they're dates, numbers, or categories diff --git a/src/traces/box/calc.js b/src/traces/box/calc.js index aa47017ffda..cd9342e912a 100644 --- a/src/traces/box/calc.js +++ b/src/traces/box/calc.js @@ -10,11 +10,12 @@ var isNumeric = require('fast-isnumeric'); +var Axes = require('../../plots/cartesian/axes'); var Lib = require('../../lib'); + +var BADNUM = require('../../constants/numerical').BADNUM; var _ = Lib._; -var Axes = require('../../plots/cartesian/axes'); -// outlier definition based on http://www.physics.csbsju.edu/stats/box2.html module.exports = function calc(gd, trace) { var fullLayout = gd._fullLayout; var xa = Axes.getFromId(gd, trace.xaxis || 'x'); @@ -24,7 +25,7 @@ module.exports = function calc(gd, trace) { // N.B. violin reuses same Box.calc var numKey = trace.type === 'violin' ? '_numViolins' : '_numBoxes'; - var i; + var i, j; var valAxis, valLetter; var posAxis, posLetter; @@ -40,127 +41,230 @@ module.exports = function calc(gd, trace) { posLetter = 'x'; } - var val = valAxis.makeCalcdata(trace, valLetter); - var pos = getPos(trace, posLetter, posAxis, val, fullLayout[numKey]); - - var dv = Lib.distinctVals(pos); + var posArray = getPos(trace, posLetter, posAxis, fullLayout[numKey]); + var dv = Lib.distinctVals(posArray); var posDistinct = dv.vals; var dPos = dv.minDiff / 2; - var posBins = makeBins(posDistinct, dPos); - - var pLen = posDistinct.length; - var ptsPerBin = initNestedArray(pLen); - - // bin pts info per position bins - for(i = 0; i < trace._length; i++) { - var v = val[i]; - if(!isNumeric(v)) continue; - - var n = Lib.findBin(pos[i], posBins); - if(n >= 0 && n < pLen) { - var pt = {v: v, i: i}; - arraysToCalcdata(pt, trace, i); - ptsPerBin[n].push(pt); - } - } + // item in trace calcdata var cdi; + // array of {v: v, i, i} sample pts + var pts; + // values of the `pts` array of objects + var boxVals; + // length of sample + var N; + // single sample point + var pt; + // single sample value + var v; + + // filter function for outlier pts + // outlier definition based on http://www.physics.csbsju.edu/stats/box2.html var ptFilterFn = (trace.boxpoints || trace.points) === 'all' ? Lib.identity : function(pt) { return (pt.v < cdi.lf || pt.v > cdi.uf); }; - var minLowerNotch = Infinity; - var maxUpperNotch = -Infinity; + if(trace._hasPreCompStats) { + var valArrayRaw = trace[valLetter]; + var d2c = function(k) { return valAxis.d2c((trace[k] || [])[i]); }; + var minVal = Infinity; + var maxVal = -Infinity; - // build calcdata trace items, one item per distinct position - for(i = 0; i < pLen; i++) { - if(ptsPerBin[i].length > 0) { - var pts = ptsPerBin[i].sort(sortByVal); - var boxVals = pts.map(extractVal); - var N = boxVals.length; + for(i = 0; i < trace._length; i++) { + var posi = posArray[i]; + if(!isNumeric(posi)) continue; cdi = {}; - cdi.pos = posDistinct[i]; - cdi.pts = pts; - - // Sort categories by values - cdi[posLetter] = cdi.pos; - cdi[valLetter] = cdi.pts.map(extractVal); - - cdi.min = boxVals[0]; - cdi.max = boxVals[N - 1]; - cdi.mean = Lib.mean(boxVals, N); - cdi.sd = Lib.stdev(boxVals, N, cdi.mean); - - // median - cdi.med = Lib.interp(boxVals, 0.5); - - var quartilemethod = trace.quartilemethod; - - if((N % 2) && (quartilemethod === 'exclusive' || quartilemethod === 'inclusive')) { - var lower; - var upper; - - if(quartilemethod === 'exclusive') { - // do NOT include the median in either half - lower = boxVals.slice(0, N / 2); - upper = boxVals.slice(N / 2 + 1); - } else if(quartilemethod === 'inclusive') { - // include the median in either half - lower = boxVals.slice(0, N / 2 + 1); - upper = boxVals.slice(N / 2); + cdi.pos = cdi[posLetter] = posi; + + cdi.q1 = d2c('q1'); + cdi.med = d2c('median'); + cdi.q3 = d2c('q3'); + + pts = []; + if(valArrayRaw && Lib.isArrayOrTypedArray(valArrayRaw[i])) { + for(j = 0; j < valArrayRaw[i].length; j++) { + v = valAxis.d2c(valArrayRaw[i][j]); + if(v !== BADNUM) { + pt = {v: v, i: [i, j]}; + arraysToCalcdata(pt, trace, [i, j]); + pts.push(pt); + } } - - cdi.q1 = Lib.interp(lower, 0.5); - cdi.q3 = Lib.interp(upper, 0.5); + } + cdi.pts = pts.sort(sortByVal); + boxVals = cdi[valLetter] = pts.map(extractVal); + N = boxVals.length; + + if(cdi.med !== BADNUM && cdi.q1 !== BADNUM && cdi.q3 !== BADNUM && + cdi.med >= cdi.q1 && cdi.q3 >= cdi.med + ) { + var lf = d2c('lowerfence'); + cdi.lf = (lf !== BADNUM && lf <= cdi.q1) ? + lf : + computeLowerFence(cdi, boxVals, N); + + var uf = d2c('upperfence'); + cdi.uf = (uf !== BADNUM && uf >= cdi.q3) ? + uf : + computeUpperFence(cdi, boxVals, N); + + var mean = d2c('mean'); + cdi.mean = (mean !== BADNUM) ? + mean : + (N ? Lib.mean(boxVals, N) : (cdi.q1 + cdi.q3) / 2); + + var sd = d2c('sd'); + cdi.sd = (mean !== BADNUM && sd >= 0) ? + sd : + (N ? Lib.stdev(boxVals, N, cdi.mean) : (cdi.q3 - cdi.q1)); + + cdi.lo = computeLowerOutlierBound(cdi); + cdi.uo = computeUpperOutlierBound(cdi); + + var ns = d2c('notchspan'); + ns = (ns !== BADNUM && ns > 0) ? ns : computeNotchSpan(cdi, N); + cdi.ln = cdi.med - ns; + cdi.un = cdi.med + ns; + + var imin = cdi.lf; + var imax = cdi.uf; + if(trace.boxpoints && boxVals.length) { + imin = Math.min(imin, boxVals[0]); + imax = Math.max(imax, boxVals[N - 1]); + } + if(trace.notched) { + imin = Math.min(imin, cdi.ln); + imax = Math.max(imax, cdi.un); + } + cdi.min = imin; + cdi.max = imax; } else { - cdi.q1 = Lib.interp(boxVals, 0.25); - cdi.q3 = Lib.interp(boxVals, 0.75); + Lib.warn('Invalid input - make sure that q1 <= median <= q3'); + + var v0; + if(cdi.med !== BADNUM) { + v0 = cdi.med; + } else if(cdi.q1 !== BADNUM) { + if(cdi.q3 !== BADNUM) v0 = (cdi.q1 + cdi.q3) / 2; + else v0 = cdi.q1; + } else if(cdi.q3 !== BADNUM) { + v0 = cdi.q3; + } else { + v0 = 0; + } + + // draw box as line segment + cdi.med = v0; + cdi.q1 = cdi.q3 = v0; + cdi.lf = cdi.uf = v0; + cdi.mean = cdi.sd = v0; + cdi.ln = cdi.un = v0; + cdi.min = cdi.max = v0; } - // lower and upper fences - last point inside - // 1.5 interquartile ranges from quartiles - cdi.lf = Math.min( - cdi.q1, - boxVals[Math.min( - Lib.findBin(2.5 * cdi.q1 - 1.5 * cdi.q3, boxVals, true) + 1, - N - 1 - )] - ); - cdi.uf = Math.max( - cdi.q3, - boxVals[Math.max( - Lib.findBin(2.5 * cdi.q3 - 1.5 * cdi.q1, boxVals), - 0 - )] - ); - - // lower and upper outliers - 3 IQR out (don't clip to max/min, - // this is only for discriminating suspected & far outliers) - cdi.lo = 4 * cdi.q1 - 3 * cdi.q3; - cdi.uo = 4 * cdi.q3 - 3 * cdi.q1; - - // lower and upper notches ~95% Confidence Intervals for median - var iqr = cdi.q3 - cdi.q1; - var mci = 1.57 * iqr / Math.sqrt(N); - cdi.ln = cdi.med - mci; - cdi.un = cdi.med + mci; - minLowerNotch = Math.min(minLowerNotch, cdi.ln); - maxUpperNotch = Math.max(maxUpperNotch, cdi.un); + minVal = Math.min(minVal, cdi.min); + maxVal = Math.max(maxVal, cdi.max); cdi.pts2 = pts.filter(ptFilterFn); cd.push(cdi); } + + trace._extremes[valAxis._id] = Axes.findExtremes(valAxis, + [minVal, maxVal], + {padded: true} + ); + } else { + var valArray = valAxis.makeCalcdata(trace, valLetter); + var quartilemethod = trace.quartilemethod; + var posBins = makeBins(posDistinct, dPos); + var pLen = posDistinct.length; + var ptsPerBin = initNestedArray(pLen); + + // bin pts info per position bins + for(i = 0; i < trace._length; i++) { + v = valArray[i]; + if(!isNumeric(v)) continue; + + var n = Lib.findBin(posArray[i], posBins); + if(n >= 0 && n < pLen) { + pt = {v: v, i: i}; + arraysToCalcdata(pt, trace, i); + ptsPerBin[n].push(pt); + } + } + + var minLowerNotch = Infinity; + var maxUpperNotch = -Infinity; + + // build calcdata trace items, one item per distinct position + for(i = 0; i < pLen; i++) { + if(ptsPerBin[i].length > 0) { + cdi = {}; + cdi.pos = cdi[posLetter] = posDistinct[i]; + + pts = cdi.pts = ptsPerBin[i].sort(sortByVal); + boxVals = cdi[valLetter] = pts.map(extractVal); + N = boxVals.length; + + cdi.min = boxVals[0]; + cdi.max = boxVals[N - 1]; + cdi.mean = Lib.mean(boxVals, N); + cdi.sd = Lib.stdev(boxVals, N, cdi.mean); + cdi.med = Lib.interp(boxVals, 0.5); + + if((N % 2) && (quartilemethod === 'exclusive' || quartilemethod === 'inclusive')) { + var lower; + var upper; + + if(quartilemethod === 'exclusive') { + // do NOT include the median in either half + lower = boxVals.slice(0, N / 2); + upper = boxVals.slice(N / 2 + 1); + } else if(quartilemethod === 'inclusive') { + // include the median in either half + lower = boxVals.slice(0, N / 2 + 1); + upper = boxVals.slice(N / 2); + } + + cdi.q1 = Lib.interp(lower, 0.5); + cdi.q3 = Lib.interp(upper, 0.5); + } else { + cdi.q1 = Lib.interp(boxVals, 0.25); + cdi.q3 = Lib.interp(boxVals, 0.75); + } + + // lower and upper fences + cdi.lf = computeLowerFence(cdi, boxVals, N); + cdi.uf = computeUpperFence(cdi, boxVals, N); + + // lower and upper outliers bounds + cdi.lo = computeLowerOutlierBound(cdi); + cdi.uo = computeUpperOutlierBound(cdi); + + // lower and upper notches + var mci = computeNotchSpan(cdi, N); + cdi.ln = cdi.med - mci; + cdi.un = cdi.med + mci; + minLowerNotch = Math.min(minLowerNotch, cdi.ln); + maxUpperNotch = Math.max(maxUpperNotch, cdi.un); + + cdi.pts2 = pts.filter(ptFilterFn); + + cd.push(cdi); + } + } + + trace._extremes[valAxis._id] = Axes.findExtremes(valAxis, + trace.notched ? valArray.concat([minLowerNotch, maxUpperNotch]) : valArray, + {padded: true} + ); } calcSelection(cd, trace); - trace._extremes[valAxis._id] = Axes.findExtremes(valAxis, - trace.notched ? val.concat([minLowerNotch, maxUpperNotch]) : val, - {padded: true} - ); - if(cd.length > 0) { cd[0].t = { num: fullLayout[numKey], @@ -191,14 +295,17 @@ 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, num) { - if(posLetter in trace) { +function getPos(trace, posLetter, posAxis, num) { + var hasPosArray = posLetter in trace; + var hasPos0 = posLetter + '0' in trace; + var hasPosStep = 'd' + posLetter in trace; + + if(hasPosArray || (hasPos0 && hasPosStep)) { return posAxis.makeCalcdata(trace, posLetter); } var pos0; - - if(posLetter + '0' in trace) { + if(hasPos0) { pos0 = trace[posLetter + '0']; } else if('name' in trace && ( posAxis.type === 'category' || ( @@ -218,7 +325,11 @@ function getPos(trace, posLetter, posAxis, val, num) { posAxis.r2c_just_indices(pos0) : posAxis.d2c(pos0, 0, trace[posLetter + 'calendar']); - return val.map(function() { return pos0c; }); + var len = trace._length; + var out = new Array(len); + for(var i = 0; i < len; i++) out[i] = pos0c; + + return out; } function makeBins(x, dx) { @@ -241,15 +352,21 @@ function initNestedArray(len) { return arr; } -function arraysToCalcdata(pt, trace, i) { - var trace2calc = { - text: 'tx', - hovertext: 'htx' - }; +var TRACE_TO_CALC = { + text: 'tx', + hovertext: 'htx' +}; - for(var k in trace2calc) { - if(Array.isArray(trace[k])) { - pt[trace2calc[k]] = trace[k][i]; +function arraysToCalcdata(pt, trace, ptNumber) { + for(var k in TRACE_TO_CALC) { + if(Lib.isArrayOrTypedArray(trace[k])) { + if(Array.isArray(ptNumber)) { + if(Lib.isArrayOrTypedArray(trace[k][ptNumber[0]])) { + pt[TRACE_TO_CALC[k]] = trace[k][ptNumber[0]][ptNumber[1]]; + } + } else { + pt[TRACE_TO_CALC[k]] = trace[k][ptNumber]; + } } } } @@ -272,3 +389,45 @@ function calcSelection(cd, trace) { function sortByVal(a, b) { return a.v - b.v; } function extractVal(o) { return o.v; } + +// last point below 1.5 * IQR +function computeLowerFence(cdi, boxVals, N) { + if(N === 0) return cdi.q1; + return Math.min( + cdi.q1, + boxVals[Math.min( + Lib.findBin(2.5 * cdi.q1 - 1.5 * cdi.q3, boxVals, true) + 1, + N - 1 + )] + ); +} + +// last point above 1.5 * IQR +function computeUpperFence(cdi, boxVals, N) { + if(N === 0) return cdi.q3; + return Math.max( + cdi.q3, + boxVals[Math.max( + Lib.findBin(2.5 * cdi.q3 - 1.5 * cdi.q1, boxVals), + 0 + )] + ); +} + +// 3 IQR below (don't clip to max/min, +// this is only for discriminating suspected & far outliers) +function computeLowerOutlierBound(cdi) { + return 4 * cdi.q1 - 3 * cdi.q3; +} + +// 3 IQR above (don't clip to max/min, +// this is only for discriminating suspected & far outliers) +function computeUpperOutlierBound(cdi) { + return 4 * cdi.q3 - 3 * cdi.q1; +} + +// 95% confidence intervals for median +function computeNotchSpan(cdi, N) { + if(N === 0) return 0; + return 1.57 * (cdi.q3 - cdi.q1) / Math.sqrt(N); +} diff --git a/src/traces/box/defaults.js b/src/traces/box/defaults.js index 30a951c1b09..33f533c6ec8 100644 --- a/src/traces/box/defaults.js +++ b/src/traces/box/defaults.js @@ -22,50 +22,183 @@ function supplyDefaults(traceIn, traceOut, defaultColor, layout) { handleSampleDefaults(traceIn, traceOut, coerce, layout); if(traceOut.visible === false) return; + var hasPreCompStats = traceOut._hasPreCompStats; + + if(hasPreCompStats) { + coerce('lowerfence'); + coerce('upperfence'); + } + coerce('line.color', (traceIn.marker || {}).color || defaultColor); coerce('line.width'); coerce('fillcolor', Color.addOpacity(traceOut.line.color, 0.5)); + var boxmeanDflt = false; + if(hasPreCompStats) { + var mean = coerce('mean'); + var sd = coerce('sd'); + if(mean && mean.length) { + boxmeanDflt = true; + if(sd && sd.length) boxmeanDflt = 'sd'; + } + } + coerce('boxmean', boxmeanDflt); + coerce('whiskerwidth'); - coerce('boxmean'); coerce('width'); coerce('quartilemethod'); - var notched = coerce('notched', traceIn.notchwidth !== undefined); + var notchedDflt = false; + if(hasPreCompStats) { + var notchspan = coerce('notchspan'); + if(notchspan && notchspan.length) { + notchedDflt = true; + } + } else if(Lib.validate(traceIn.notchwidth, attributes.notchwidth)) { + notchedDflt = true; + } + var notched = coerce('notched', notchedDflt); if(notched) coerce('notchwidth'); handlePointsDefaults(traceIn, traceOut, coerce, {prefix: 'box'}); } function handleSampleDefaults(traceIn, traceOut, coerce, layout) { + function getDims(arr) { + var dims = 0; + if(arr && arr.length) { + dims += 1; + if(Lib.isArrayOrTypedArray(arr[0]) && arr[0].length) { + dims += 1; + } + } + return dims; + } + + function valid(astr) { + return Lib.validate(traceIn[astr], attributes[astr]); + } + var y = coerce('y'); var x = coerce('x'); - var hasX = x && x.length; + + var sLen; + if(traceOut.type === 'box') { + var q1 = coerce('q1'); + var median = coerce('median'); + var q3 = coerce('q3'); + + traceOut._hasPreCompStats = ( + q1 && q1.length && + median && median.length && + q3 && q3.length + ); + sLen = Math.min( + Lib.minRowLength(q1), + Lib.minRowLength(median), + Lib.minRowLength(q3) + ); + } + + var yDims = getDims(y); + var xDims = getDims(x); + var yLen = yDims && Lib.minRowLength(y); + var xLen = xDims && Lib.minRowLength(x); var defaultOrientation, len; + if(traceOut._hasPreCompStats) { + switch(String(xDims) + String(yDims)) { + // no x / no y + case '00': + var setInX = valid('x0') || valid('dx'); + var setInY = valid('y0') || valid('dy'); + + if(setInY && !setInX) { + defaultOrientation = 'h'; + } else { + defaultOrientation = 'v'; + } - if(y && y.length) { + len = sLen; + break; + // just x + case '10': + defaultOrientation = 'v'; + len = Math.min(sLen, xLen); + break; + case '20': + defaultOrientation = 'h'; + len = Math.min(sLen, xLen); + break; + // just y + case '01': + defaultOrientation = 'h'; + len = Math.min(sLen, yLen); + break; + case '02': + defaultOrientation = 'v'; + len = Math.min(sLen, yLen); + break; + // both + case '12': + defaultOrientation = 'v'; + len = Math.min(sLen, xLen, yLen); + break; + case '21': + defaultOrientation = 'h'; + len = Math.min(sLen, xLen, yLen); + break; + case '11': + // this one is ill-defined + len = 0; + break; + case '22': + // this one case happen on multi-category axes + defaultOrientation = 'v'; + len = Math.min(sLen, xLen, yLen); + break; + } + } else if(yDims > 0) { defaultOrientation = 'v'; - if(hasX) { - len = Math.min(Lib.minRowLength(x), Lib.minRowLength(y)); + if(xDims > 0) { + len = Math.min(xLen, yLen); } else { - coerce('x0'); - len = Lib.minRowLength(y); + len = Math.min(yLen); } - } else if(hasX) { + } else if(xDims > 0) { defaultOrientation = 'h'; - coerce('y0'); - len = Lib.minRowLength(x); + len = Math.min(xLen); } else { + len = 0; + } + + if(!len) { traceOut.visible = false; return; } traceOut._length = len; + var orientation = coerce('orientation', defaultOrientation); + + // these are just used for positioning, they never define the sample + if(traceOut._hasPreCompStats) { + if(orientation === 'v' && xDims === 0) { + coerce('x0', 0); + coerce('dx', 1); + } else if(orientation === 'h' && yDims === 0) { + coerce('y0', 0); + coerce('dy', 1); + } + } else { + if(orientation === 'v' && xDims === 0) { + coerce('x0'); + } else if(orientation === 'h' && yDims === 0) { + coerce('y0'); + } + } + var handleCalendarDefaults = Registry.getComponentMethod('calendars', 'handleTraceDefaults'); handleCalendarDefaults(traceIn, traceOut, ['x', 'y'], layout); - - coerce('orientation', defaultOrientation); } function handlePointsDefaults(traceIn, traceOut, coerce, opts) { @@ -74,14 +207,18 @@ function handlePointsDefaults(traceIn, traceOut, coerce, opts) { var outlierColorDflt = Lib.coerce2(traceIn, traceOut, attributes, 'marker.outliercolor'); var lineoutliercolor = coerce('marker.line.outliercolor'); - var points = coerce( - prefix + 'points', - (outlierColorDflt || lineoutliercolor) ? 'suspectedoutliers' : undefined - ); + var modeDflt = 'outliers'; + if(traceOut._hasPreCompStats) { + modeDflt = 'all'; + } else if(outlierColorDflt || lineoutliercolor) { + modeDflt = 'suspectedoutliers'; + } + + var mode = coerce(prefix + 'points', modeDflt); - if(points) { - coerce('jitter', points === 'all' ? 0.3 : 0); - coerce('pointpos', points === 'all' ? -1.5 : 0); + if(mode) { + coerce('jitter', mode === 'all' ? 0.3 : 0); + coerce('pointpos', mode === 'all' ? -1.5 : 0); coerce('marker.symbol'); coerce('marker.opacity'); @@ -90,7 +227,7 @@ function handlePointsDefaults(traceIn, traceOut, coerce, opts) { coerce('marker.line.color'); coerce('marker.line.width'); - if(points === 'suspectedoutliers') { + if(mode === 'suspectedoutliers') { coerce('marker.line.outliercolor', traceOut.marker.color); coerce('marker.line.outlierwidth'); } diff --git a/test/image/baselines/box_precomputed-stats.png b/test/image/baselines/box_precomputed-stats.png new file mode 100644 index 0000000000000000000000000000000000000000..dca43f52f820dbe8727e95ab0f166c3e8000d1fa GIT binary patch literal 51684 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 MQzhvl$ye|G55TM0i~s-t literal 0 HcmV?d00001 diff --git a/test/image/mocks/box_precomputed-stats.json b/test/image/mocks/box_precomputed-stats.json new file mode 100644 index 00000000000..a68bec46695 --- /dev/null +++ b/test/image/mocks/box_precomputed-stats.json @@ -0,0 +1,211 @@ +{ + "data": [ + { + "type": "box", + "name": "[V] just q1/median/q3", + "offsetgroup": "1", + "q1": [ 1, 2, 1 ], + "median": [ 2, 3, 2 ], + "q3": [ 3, 4, 3 ] + }, + { + "type": "box", + "name": "[V] q1/median/q3/lowerfence/upperfence", + "offsetgroup": "2", + "q1": [ 1, 2, 1 ], + "median": [ 2, 3, 2 ], + "q3": [ 3, 4, 3 ], + "lowerfence": [ 0, 1, 0 ], + "upperfence": [ 4, 5, 4 ] + }, + { + "type": "box", + "name": "[V] all pre-computed stats", + "offsetgroup": "3", + "q1": [ 1, 2, 1 ], + "median": [ 2, 3, 2 ], + "q3": [ 3, 4, 3 ], + "lowerfence": [ 0, 1, 0 ], + "upperfence": [ 4, 5, 4 ], + "mean": [ 2.2, 2.8, 2.2 ], + "sd": [ 0.4, 0.4, 0.4 ], + "notchspan": [ 0.2, 0.1, 0.2 ] + }, + { + "type": "box", + "name": "[V] set q1/median/q3 computed lowerfence/upperfence", + "offsetgroup": "1", + "q1": [ 1, 2, 1 ], + "median": [ 2, 3, 2 ], + "q3": [ 3, 4, 3 ], + "y": [ + [ 0, 1, 2, 3, 4 ], + [ 1, 2, 3, 4, 5 ], + [ 0, 1, 2, 3, 4 ] + ], + "xaxis": "x2", + "yaxis": "y2" + }, + { + "type": "box", + "name": "[V] set q1/median/q3/lowerfence/upperfence computed mean", + "offsetgroup": "2", + "q1": [ 1, 2, 1 ], + "median": [ 2, 3, 2 ], + "q3": [ 3, 4, 3 ], + "lowerfence": [ -1, 0, -1 ], + "upperfence": [ 5, 6, 5 ], + "boxmean": true, + "y": [ + [ 0, 1, 2, 3, 4 ], + [ 1, 2, 3, 4, 5, 8 ], + [ 0, 1, 2, 3, 4 ] + ], + "xaxis": "x2", + "yaxis": "y2" + }, + { + "type": "box", + "name": "[V] set q1/median/q3 computed lowerfence/upperfence/mean/sd/notches", + "offsetgroup": "3", + "q1": [ 1, 2, 1 ], + "median": [ 2, 3, 2 ], + "q3": [ 3, 4, 3 ], + "y": [ + [ 0, 1, 2, 3, 4 ], + [ 1, 2, 3, 4, 5 ], + [ 0, 1, 2, 3, 4 ] + ], + "boxmean": "sd", + "notched": true, + "xaxis": "x2", + "yaxis": "y2" + }, + { + "type": "box", + "name": "[H] just q1/median/q3", + "offsetgroup": "1", + "y0": 1, + "q1": [ 1, 2, 1 ], + "median": [ 2, 3, 2 ], + "q3": [ 3, 4, 3 ], + "xaxis": "x3", + "yaxis": "y3" + }, + { + "type": "box", + "name": "[H] q1/median/q3/lowerfence/upperfence", + "offsetgroup": "2", + "y0": 1, + "q1": [ 1, 2, 1 ], + "median": [ 2, 3, 2 ], + "q3": [ 3, 4, 3 ], + "lowerfence": [ 0, 1, 0 ], + "upperfence": [ 4, 5, 4 ], + "xaxis": "x3", + "yaxis": "y3" + }, + { + "type": "box", + "name": "[H] all pre-computed stats", + "offsetgroup": "3", + "y0": 1, + "q1": [ 1, 2, 1 ], + "median": [ 2, 3, 2 ], + "q3": [ 3, 4, 3 ], + "lowerfence": [ 0, 1, 0 ], + "upperfence": [ 4, 5, 4 ], + "mean": [ 2.2, 2.8, 2.2 ], + "sd": [ 0.4, 0.4, 0.4 ], + "notchspan": [ 0.2, 0.1, 0.2 ], + "xaxis": "x3", + "yaxis": "y3" + }, + { + "type": "box", + "name": "[H] set q1/median/q3 computed lowerfence/upperfence", + "offsetgroup": "1", + "q1": [ 1, 2, 1 ], + "median": [ 2, 3, 2 ], + "q3": [ 3, 4, 3 ], + "x": [ + [ 0, 1, 2, 3, 4 ], + [ 1, 2, 3, 4, 5 ], + [ 0, 1, 2, 3, 4 ] + ], + "xaxis": "x4", + "yaxis": "y4" + }, + { + "type": "box", + "name": "[H] set q1/median/q3/lowerfence/upperfence computed mean", + "offsetgroup": "2", + "q1": [ 1, 2, 1 ], + "median": [ 2, 3, 2 ], + "q3": [ 3, 4, 3 ], + "lowerfence": [ -1, 0, -1 ], + "upperfence": [ 5, 6, 5 ], + "boxmean": true, + "x": [ + [ 0, 1, 2, 3, 4 ], + [ 1, 2, 3, 4, 5, 8 ], + [ 0, 1, 2, 3, 4 ] + ], + "xaxis": "x4", + "yaxis": "y4" + }, + { + "type": "box", + "name": "[H] set q1/median/q3 computed lowerfence/upperfence/mean/sd/notches", + "offsetgroup": "3", + "q1": [ 1, 2, 1 ], + "median": [ 2, 3, 2 ], + "q3": [ 3, 4, 3 ], + "x": [ + [ 0, 1, 2, 3, 4 ], + [ 1, 2, 3, 4, 5 ], + [ 0, 1, 2, 3, 4 ] + ], + "boxmean": "sd", + "notched": true, + "xaxis": "x4", + "yaxis": "y4" + } + ], + "layout": { + "showlegend": false, + "boxmode": "group", + "yaxis": { + "domain": [ 0.78, 1 ] + }, + "xaxis2": { + "anchor": "y2" + }, + "yaxis2": { + "domain": [ 0.51, 0.72 ], + "anchor": "x2" + }, + "xaxis3": { + "domain": [ 0, 0.5 ], + "anchor": "y3" + }, + "yaxis3": { + "domain": [ 0, 0.48 ], + "anchor": "x3" + }, + "xaxis4": { + "domain": [ 0.5, 1 ], + "anchor": "y4" + }, + "yaxis4": { + "domain": [ 0, 0.48 ], + "anchor": "x4" + }, + "margin": { "l": 20, "t": 40, "b": 20, "r": 20 }, + "title": { + "text": "box traces with pre-computed stats", + "x": 0 + }, + "hovermode": "closest" + } +} diff --git a/test/jasmine/tests/box_test.js b/test/jasmine/tests/box_test.js index cd2dea4852c..e80ab37c4f3 100644 --- a/test/jasmine/tests/box_test.js +++ b/test/jasmine/tests/box_test.js @@ -201,6 +201,304 @@ describe('Test boxes supplyDefaults', function() { expect(gd._fullData[0].alignmentgroup).toBe(undefined, 'alignementgroup'); expect(gd._fullData[0].offsetgroup).toBe(undefined, 'offsetgroup'); }); + + describe('q1/median/q3 API signature', function() { + function _check(msg, t, exp) { + var gd = { data: [Lib.extendFlat({type: 'box'}, t)] }; + supplyAllDefaults(gd); + for(var k in exp) { + var actual = gd._fullData[0][k]; + if(Array.isArray(exp[k])) { + expect(actual).toEqual(exp[k], msg + ' | ' + k); + } else { + expect(actual).toBe(exp[k], msg + ' | ' + k); + } + } + } + + it('should result in correct orientation results', function() { + _check('insufficient (no median)', { + q1: [1], + q3: [3] + }, { + visible: false, + orientation: undefined, + _length: undefined + }); + _check('just q1/median/q3', { + q1: [1], + median: [2], + q3: [3] + }, { + visible: true, + orientation: 'v', + x0: 0, dx: 1, + y0: undefined, dy: undefined, + _length: 1 + }); + _check('with set x', { + x: [0], + q1: [1], + median: [2], + q3: [3] + }, { + visible: true, + orientation: 'v', + _length: 1, + x0: undefined, dx: undefined, + y0: undefined, dy: undefined + }); + _check('with set 2D x', { + x: [[1, 2, 3]], + q1: [1], + median: [2], + q3: [3] + }, { + visible: true, + orientation: 'h', + _length: 1, + x0: undefined, dx: undefined, + y0: 0, dy: 1 + }); + _check('with set y', { + y: [0], + q1: [1], + median: [2], + q3: [3] + }, { + visible: true, + orientation: 'h', + _length: 1, + x0: undefined, dx: undefined, + y0: undefined, dy: undefined + }); + _check('with set 2d y', { + y: [[1, 2, 3]], + q1: [1], + median: [2], + q3: [3] + }, { + visible: true, + orientation: 'v', + _length: 1, + x0: 0, dx: 1, + y0: undefined, dy: undefined + }); + _check('with set x AND 2d y', { + x: [0], + y: [[1, 2, 3]], + q1: [1], + median: [2], + q3: [3] + }, { + visible: true, + orientation: 'v', + _length: 1, + x: [0], + y: [[1, 2, 3]], + x0: undefined, dx: undefined, + y0: undefined, dy: undefined + }); + _check('with set 2d x AND y', { + x: [[1, 2, 3]], + y: [4], + q1: [1], + median: [2], + q3: [3] + }, { + visible: true, + orientation: 'h', + _length: 1, + x: [[1, 2, 3]], + y: [4], + x0: undefined, dx: undefined, + y0: undefined, dy: undefined + }); + _check('with just y0', { + y0: 4, + q1: [1], + median: [2], + q3: [3] + }, { + visible: true, + orientation: 'h', + _length: 1, + x0: undefined, dx: undefined, + y0: 4, dy: 1 + }); + _check('with just dy', { + dy: -0.4, + q1: [1], + median: [2], + q3: [3] + }, { + visible: true, + orientation: 'h', + _length: 1, + x0: undefined, dx: undefined, + y0: 0, dy: -0.4 + }); + _check('with x0/dx AND y0/dy (ignores y0/dy)', { + x0: -1, dx: -0.2, + y0: -10, dy: -0.4, + q1: [1], + median: [2], + q3: [3] + }, { + visible: true, + orientation: 'v', + _length: 1, + x0: -1, dx: -0.2, + y0: undefined, dy: undefined + }); + _check('with x0/dx AND y0/dy AND orientation:h (ignores x0/dx)', { + x0: -1, dx: -0.2, + y0: -10, dy: -0.4, + orientation: 'h', + q1: [1], + median: [2], + q3: [3] + }, { + visible: true, + orientation: 'h', + _length: 1, + x0: undefined, dx: undefined, + y0: -10, dy: -0.4 + }); + }); + + it('should coerce lowerfence and upperfence', function() { + var lf = [-1]; + var uf = [5]; + + _check('insufficient (no median)', { + q1: [1], + q3: [3], + lowerfence: lf, + upperfence: uf + }, { + visible: false, + lowerfence: undefined, + upperfence: undefined + }); + _check('x/y signature', { + x: [0], + y: [1], + lowerfence: lf, + upperfence: uf + }, { + visible: true, + lowerfence: undefined, + upperfence: undefined + }); + _check('base', { + x: [0], + q1: [1], + median: [2], + q3: [3], + lowerfence: lf, + upperfence: uf + }, { + visible: true, + lowerfence: lf, + upperfence: uf + }); + }); + + it('should lead to correct boxmean default', function() { + var mean = [2.2]; + var sd = [0.1]; + + _check('x/y signature', { + x: [0], + y: [1], + }, { + boxmean: false + }); + _check('base', { + x: [0], + q1: [1], + median: [2], + q3: [3] + }, { + boxmean: false + }); + _check('with mean set', { + x: [0], + q1: [1], + median: [2], + q3: [3], + mean: mean + }, { + boxmean: true, + mean: mean + }); + _check('with mean and sd set', { + x: [0], + q1: [1], + median: [2], + q3: [3], + mean: mean, + sd: sd + }, { + boxmean: 'sd', + mean: mean, + sd: sd + }); + }); + + it('should lead to correct notched default', function() { + var ns = [0.05]; + + _check('x/y signature', { + x: [0], + y: [1], + notchwidth: 0.1 + }, { + notched: true, + notchwidth: 0.1 + }); + _check('base', { + x: [0], + q1: [1], + median: [2], + q3: [3], + notchwidth: 0.1 + }, { + notched: false, + notchwidth: undefined + }); + _check('with notchspan set', { + x: [0], + q1: [1], + median: [2], + q3: [3], + notchspan: ns + }, { + notchspan: ns, + notched: true, + notchwidth: 0.25 + }); + }); + + it('should lead to correct boxpoints default', function() { + _check('set default to *all*', { + q1: [1], + median: [2], + q3: [3] + }, { + boxpoints: 'all' + }); + _check('honours valid user input', { + q1: [1], + median: [2], + q3: [3], + boxpoints: 'outliers' + }, { + boxpoints: 'outliers' + }); + }); + }); }); describe('Test box hover:', function() { @@ -701,4 +999,312 @@ describe('Test box calc', function() { } } }); + + describe('with q1/median/q3 API signature inputs', function() { + function minimal(patch) { + return Lib.extendFlat({ + q1: [1, 2], + median: [2, 3], + q3: [3, 4], + }, patch || {}); + } + + function base(patch) { + return Lib.extendFlat(minimal({ + x: [1, 2], + lowerfence: [0, 1], + upperfence: [4, 5] + }), patch || {}); + } + + function _assert(msg, d, keys, exp) { + var actual = keys.map(function(k) { return d[k]; }); + expect(actual).withContext(keys.join(', ') + ' | ' + msg).toEqual(exp); + } + + it('should skip box corresponding to non-numeric positions', function() { + var cd = _calc(base({x: [null, 2]})); + expect(cd.length).toBe(1, 'has length 1'); + _assert('', cd[0], + ['x', 'lf', 'q1', 'med', 'q3', 'uf'], + [2, 1, 2, 3, 4, 5] + ); + }); + + it('should be able to set positions from x0/dx and y0/dy', function() { + var cd = _calc(minimal()); + _assert('blank', cd[0], ['x'], [0]); + _assert('blank', cd[1], ['x'], [1]); + + cd = _calc(minimal({x0: 5, dx: 2})); + _assert('x0/dx cd[0]', cd[0], ['x'], [5]); + _assert('x0/dx cd[1]', cd[1], ['x'], [7]); + + cd = _calc(minimal({y0: 9, dy: -3})); + _assert('y0/dy cd[0]', cd[0], ['y'], [9]); + _assert('y0/dy cd[1]', cd[1], ['y'], [6]); + }); + + it('should warn when q1/median/q3 values are invalid', function() { + spyOn(Lib, 'warn'); + + var cd = _calc(base({q1: [null, 2]})); + _assert('non-numeric q1', cd[0], + ['lf', 'q1', 'med', 'q3', 'uf'], + [2, 2, 2, 2, 2] + ); + cd = _calc(base({q1: [10, 2]})); + _assert('invalid q1', cd[0], + ['lf', 'q1', 'med', 'q3', 'uf'], + [2, 2, 2, 2, 2] + ); + + cd = _calc(base({q3: [3, null]})); + _assert('non-numeric q3', cd[1], + ['lf', 'q1', 'med', 'q3', 'uf'], + [3, 3, 3, 3, 3] + ); + cd = _calc(base({q3: [3, -10]})); + _assert('invalid q3', cd[1], + ['lf', 'q1', 'med', 'q3', 'uf'], + [3, 3, 3, 3, 3] + ); + + cd = _calc(base({median: [null, 3]})); + _assert('non-numeric median', cd[0], + ['lf', 'q1', 'med', 'q3', 'uf'], + [2, 2, 2, 2, 2] + ); + cd = _calc(base({median: [null, 3], q1: [null, 2]})); + _assert('non-numeric median AND q1', cd[0], + ['lf', 'q1', 'med', 'q3', 'uf'], + [3, 3, 3, 3, 3] + ); + cd = _calc(base({median: [null, 3], q3: [null, 4]})); + _assert('non-numeric median AND q3', cd[0], + ['lf', 'q1', 'med', 'q3', 'uf'], + [1, 1, 1, 1, 1] + ); + cd = _calc(base({median: [null, 3], q1: [null, 2], q3: [null, 4]})); + _assert('non-numeric median, q1 and q3', cd[0], + ['lf', 'q1', 'med', 'q3', 'uf'], + [0, 0, 0, 0, 0] + ); + + expect(Lib.warn).toHaveBeenCalledTimes(8); + }); + + it('should set *lf* / *uf*:', function() { + var cd = _calc({q1: [1], median: [2], q3: [3]}); + _assert('to q1/q3 when only q1/median/q3 are set', cd[0], + ['lf', 'uf'], + [1, 3] + ); + + cd = _calc({q1: [1], median: [2], q3: [3], lowerfence: [null], upperfence: [NaN]}); + _assert('to q1/q3 when only lowerfence/upperfence input is non-numeric', cd[0], + ['lf', 'uf'], + [1, 3] + ); + + cd = _calc({q1: [1], median: [2], q3: [3], lowerfence: [1.5], upperfence: [2.5]}); + _assert('to q1/q3 when only lowerfence/upperfence input is invalid', cd[0], + ['lf', 'uf'], + [1, 3] + ); + + cd = _calc({q1: [1], median: [2], q3: [3], lowerfence: [0.5], upperfence: [3.5]}); + _assert('to lowerfence/upperfence when valid', cd[0], + ['lf', 'uf'], + [0.5, 3.5] + ); + + cd = _calc({q1: [1], median: [2], q3: [3], y: [[0, 1, 2, 3, 4]]}); + _assert('to computed value when a sample is set', cd[0], + ['lf', 'uf'], + [0, 4] + ); + }); + + it('should fill *mean* and *sd*', function() { + var cd = _calc({q1: [1], median: [2], q3: [3]}); + _assert('to (q1+q3)/2 and the IQR respectively when *mean* and *sd* are not set', cd[0], + ['mean', 'sd'], + [2, 2] + ); + + cd = _calc({q1: [1], median: [2], q3: [3], mean: [NaN], sd: [-10]}); + _assert('to (q1+q3)/2 and the IQR respectively when *mean* and *sd* are invalid', cd[0], + ['mean', 'sd'], + [2, 2] + ); + + cd = _calc({q1: [1], median: [2], q3: [3], mean: [1.1], sd: [0.1]}); + _assert('to *mean* and *sd* when invalid', cd[0], + ['mean', 'sd'], + [1.1, 0.1] + ); + + cd = _calc({q1: [1], median: [2], q3: [3], x: [[0, 1, 2, 3, 4, 5]]}); + _assert('to computed value when a sample is set', cd[0], + ['mean', 'sd'], + [2.5, 1.707825127659933] + ); + }); + + it('should fill *lo* and *uo*', function() { + var cd = _calc({q1: [1], median: [2], q3: [3]}); + _assert('using q1 and q3', cd[0], + ['lo', 'uo'], + [-5, 9] + ); + }); + + it('should fill *ln* and *un*', function() { + var cd = _calc({q1: [1], median: [2], q3: [3], notchspan: [null]}); + _assert('to *median* value when input is non-numeric', cd[0], + ['ln', 'un'], + [2, 2] + ); + + cd = _calc({q1: [1], median: [2], q3: [3], notchspan: [-10]}); + _assert('to *median* value when input is negative', cd[0], + ['ln', 'un'], + [2, 2] + ); + + cd = _calc({q1: [1], median: [2], q3: [3], notchspan: [0.1]}); + _assert('to *median* -/+ input value when valid', cd[0], + ['ln', 'un'], + [1.9, 2.1] + ); + + cd = _calc({q1: [1], median: [2], q3: [3], y: [[0, 1, 2, 3, 4, 5, 6]]}); + _assert('to computed value when a sample is set', cd[0], + ['ln', 'un'], + [0.8131915547510264, 3.1868084452489738] + ); + }); + + it('should fill in *pts* and *pts2* arrays with sample items', function() { + var cd = _calc({q1: [1], median: [2], q3: [3]}); + _assert('empty case', cd[0], + ['pts', 'pts2'], + [[], []] + ); + + cd = _calc({ + q1: [1], median: [2], q3: [3], + y: [[0, 4, 1, 5, 2, 3]], + boxpoints: 'all' + }); + _assert('with sample + boxpoints:all', cd[0], + ['pts', 'pts2'], + [[ + {v: 0, i: [0, 0]}, {v: 1, i: [0, 2]}, {v: 2, i: [0, 4]}, + {v: 3, i: [0, 5]}, {v: 4, i: [0, 1]}, {v: 5, i: [0, 3]} + ], [ + {v: 0, i: [0, 0]}, {v: 1, i: [0, 2]}, {v: 2, i: [0, 4]}, + {v: 3, i: [0, 5]}, {v: 4, i: [0, 1]}, {v: 5, i: [0, 3]} + ]] + ); + + cd = _calc({ + q1: [1], median: [2], q3: [3], + y: [[0, 4, 1, 5, 2, 3]], + boxpoints: 'outliers' + }); + _assert('with sample + boxpoints:outliers', cd[0], + ['pts', 'pts2'], + [[ + {v: 0, i: [0, 0]}, {v: 1, i: [0, 2]}, {v: 2, i: [0, 4]}, + {v: 3, i: [0, 5]}, {v: 4, i: [0, 1]}, {v: 5, i: [0, 3]} + ], []] + ); + + // same as for boxpoints: 'outliers', suspectedoutliers style logic happens in box/plot.js + cd = _calc({ + q1: [1], median: [2], q3: [3], + y: [[0, 4, 1, 5, 2, 3]], + boxpoints: 'suspectedoutliers' + }); + _assert('with sample + boxpoints:suspectedoutliers', cd[0], + ['pts', 'pts2'], + [[ + {v: 0, i: [0, 0]}, {v: 1, i: [0, 2]}, {v: 2, i: [0, 4]}, + {v: 3, i: [0, 5]}, {v: 4, i: [0, 1]}, {v: 5, i: [0, 3]} + ], []] + ); + + cd = _calc({ + q1: [1], median: [2], q3: [3], + lowerfence: [1], + upperfence: [3], + y: [[0, 4, 1, 5, 2, 3]], + boxpoints: 'outliers' + }); + _assert('with sample + set lowerfence/upperfence + boxpoints:outliers', cd[0], + ['pts', 'pts2'], + [[ + {v: 0, i: [0, 0]}, {v: 1, i: [0, 2]}, {v: 2, i: [0, 4]}, + {v: 3, i: [0, 5]}, {v: 4, i: [0, 1]}, {v: 5, i: [0, 3]} + ], [ + {v: 0, i: [0, 0]}, + {v: 4, i: [0, 1]}, {v: 5, i: [0, 3]} + ]] + ); + }); + + it('should compute correct *min* and *max* values', function() { + var cd = _calc({q1: [1], median: [2], q3: [3]}); + _assert('simple q1/median/q3', cd[0], + ['min', 'max'], + [1, 3] + ); + + cd = _calc({q1: [1], median: [2], q3: [3], x: [[0, 1, 5, 3, 4, 2]]}); + _assert('with sample', cd[0], + ['min', 'max'], + [0, 5] + ); + + cd = _calc({q1: [1], median: [2], q3: [3], notchspan: [2.5]}); + _assert('inverted notches', cd[0], + ['min', 'max'], + [-0.5, 4.5] + ); + }); + + it('should fill 2D per-point text/hovertext values', function() { + var cd = _calc({q1: [1], median: [2], q3: [3]}); + _assert('simple q1/median/q3', cd[0], + ['pts'], + [[]] + ); + + cd = _calc({ + q1: [1], median: [2], q3: [3], + y: [[1, 2, 3]], + text: ['a'], hovertext: ['b'] + }); + _assert('invalid text/hovertext 1D array case', cd[0], + ['pts'], + [[{v: 1, i: [0, 0]}, {v: 2, i: [0, 1]}, {v: 3, i: [0, 2]}]] + ); + + cd = _calc({ + q1: [1], median: [2], q3: [3], + y: [[1, 2, 3]], + text: [['a', 'b', 'c']], hovertext: [['A', 'B', 'C']] + }); + _assert('valid text/hovertext 2D array case', cd[0], + ['pts'], + [[ + {v: 1, i: [0, 0], tx: 'a', htx: 'A'}, + {v: 2, i: [0, 1], tx: 'b', htx: 'B'}, + {v: 3, i: [0, 2], tx: 'c', htx: 'C'} + ]] + ); + }); + }); }); From 80662d40b262a5a15e62fcab4b6898c0f0813600 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= Date: Wed, 18 Dec 2019 12:04:15 -0500 Subject: [PATCH 07/12] fix selectedpoints for q1/median/q3 traces + add hover/select tests --- src/lib/index.js | 4 +- test/jasmine/tests/box_test.js | 91 +++++++++++++++++++++++++++++++ test/jasmine/tests/select_test.js | 49 +++++++++++++++++ 3 files changed, 143 insertions(+), 1 deletion(-) diff --git a/src/lib/index.js b/src/lib/index.js index 17ff52c33db..7f176f07cc6 100644 --- a/src/lib/index.js +++ b/src/lib/index.js @@ -584,7 +584,9 @@ lib.tagSelected = function(calcTrace, trace, ptNumber2cdIndex) { for(var i = 0; i < selectedpoints.length; i++) { var ptIndex = selectedpoints[i]; - if(lib.isIndex(ptIndex)) { + if(lib.isIndex(ptIndex) || + (lib.isArrayOrTypedArray(ptIndex) && lib.isIndex(ptIndex[0]) && lib.isIndex(ptIndex[1])) + ) { var ptNumber = ptIndex2ptNumber ? ptIndex2ptNumber[ptIndex] : ptIndex; var cdIndex = ptNumber2cdIndex ? ptNumber2cdIndex[ptNumber] : ptNumber; diff --git a/test/jasmine/tests/box_test.js b/test/jasmine/tests/box_test.js index e80ab37c4f3..6a9cbdcb60d 100644 --- a/test/jasmine/tests/box_test.js +++ b/test/jasmine/tests/box_test.js @@ -753,6 +753,72 @@ describe('Test box hover:', function() { nums: ['median: 2', 'q1: 1.5', 'q3: 2.5', 'max: 3', 'min: 1'], name: ['', '', '', '', ''], axis: 'trace 0' + }, { + desc: 'q1/median/q3 signature on boxes', + mock: { + data: [{ + type: 'box', + x0: 'A', + q1: [1], + median: [2], + q3: [3] + }], + layout: { + width: 400, + height: 400 + } + }, + pos: [200, 200], + nums: ['median: 2', 'q1: 1', 'q3: 3'], + name: ['', '', ''], + axis: 'A' + }, { + desc: 'q1/median/q3 signature on points', + mock: { + data: [{ + type: 'box', + x0: 'A', + q1: [1], + median: [2], + q3: [3], + y: [[0, 1, 2, 3, 4]], + hoveron: 'points', + pointpos: 0 + }], + layout: { + width: 400, + height: 400, + margin: {l: 0, t: 0, b: 0, r: 0} + } + }, + pos: [200, 200], + nums: '2', + name: '', + axis: 'A' + }, { + desc: 'q1/median/q3 signature on points + hovertemplate', + mock: { + data: [{ + type: 'box', + x0: 'A', + q1: [1], + median: [2], + q3: [3], + y: [[0, 1, 2, 3, 4]], + hoveron: 'points', + pointpos: 0, + hovertemplate: '%{x} | %{y}%{pointNumber[0]} | %{pointNumber[1]}' + }], + layout: { + width: 400, + height: 400, + margin: {l: 0, t: 0, b: 0, r: 0} + } + }, + pos: [200, 200], + nums: 'A | 2', + name: '0 | 2', + axis: 'A' }].forEach(function(specs) { it('should generate correct hover labels ' + specs.desc, function(done) { run(specs).catch(failTest).then(done); @@ -1306,5 +1372,30 @@ describe('Test box calc', function() { ]] ); }); + + it('should tag selected sample points', function() { + var cd = _calc({ + q1: [1], median: [2], q3: [3], + x: [[1, 3, 2]] + }); + _assert('base case', cd[0], + ['pts'], + [[{v: 1, i: [0, 0]}, {v: 2, i: [0, 2]}, {v: 3, i: [0, 1]}]] + ); + + cd = _calc({ + q1: [1], median: [2], q3: [3], + x: [[1, 3, 2]], + selectedpoints: [[0, 1]] + }); + _assert('with set selectedpoints', cd[0], + ['pts'], + [[ + {v: 1, i: [0, 0]}, + {v: 2, i: [0, 2]}, + {v: 3, i: [0, 1], selected: 1} + ]] + ); + }); }); }); diff --git a/test/jasmine/tests/select_test.js b/test/jasmine/tests/select_test.js index 505c6737d46..ecf4f0689e3 100644 --- a/test/jasmine/tests/select_test.js +++ b/test/jasmine/tests/select_test.js @@ -2692,6 +2692,55 @@ describe('Test select box and lasso per trace:', function() { .then(done); }); + it('@flaky should work for box traces (q1/median/q3 case)', function(done) { + var assertPoints = makeAssertPoints(['curveNumber', 'y', 'x']); + var assertSelectedPoints = makeAssertSelectedPoints(); + + var fig = { + data: [{ + type: 'box', + x0: 'A', + q1: [1], + median: [2], + q3: [3], + y: [[0, 1, 2, 3, 4]], + pointpos: 0, + }], + layout: { + width: 500, + height: 500, + dragmode: 'lasso' + } + }; + + Plotly.plot(gd, fig) + .then(function() { + return _run( + [[200, 200], [400, 200], [400, 350], [200, 350], [200, 200]], + function() { + assertPoints([ [0, 1, undefined], [0, 2, undefined] ]); + assertSelectedPoints({ 0: [[0, 1], [0, 2]] }); + }, + null, LASSOEVENTS, 'box lasso' + ); + }) + .then(function() { + return Plotly.relayout(gd, 'dragmode', 'select'); + }) + .then(function() { + return _run( + [[200, 200], [400, 300]], + function() { + assertPoints([ [0, 2, undefined] ]); + assertSelectedPoints({ 0: [[0, 2]] }); + }, + null, BOXEVENTS, 'box select' + ); + }) + .catch(failTest) + .then(done); + }); + it('@flaky should work for violin traces', function(done) { var assertPoints = makeAssertPoints(['curveNumber', 'y', 'x']); var assertSelectedPoints = makeAssertSelectedPoints(); From 7ecf81ddacbd1e79b108ac7458225980c308f741 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= Date: Tue, 24 Dec 2019 11:08:09 -0500 Subject: [PATCH 08/12] add smart orientation dflt logic for multicategory position axes --- src/traces/box/defaults.js | 40 ++++++++-- test/jasmine/tests/box_test.js | 135 +++++++++++++++++++++++++++++++++ 2 files changed, 168 insertions(+), 7 deletions(-) diff --git a/src/traces/box/defaults.js b/src/traces/box/defaults.js index 33f533c6ec8..5dccfd8b1c7 100644 --- a/src/traces/box/defaults.js +++ b/src/traces/box/defaults.js @@ -12,6 +12,7 @@ var Lib = require('../../lib'); var Registry = require('../../registry'); var Color = require('../../components/color'); var handleGroupingDefaults = require('../bar/defaults').handleGroupingDefaults; +var autoType = require('../../plots/cartesian/axis_autotype'); var attributes = require('./attributes'); function supplyDefaults(traceIn, traceOut, defaultColor, layout) { @@ -128,7 +129,7 @@ function handleSampleDefaults(traceIn, traceOut, coerce, layout) { break; case '20': defaultOrientation = 'h'; - len = Math.min(sLen, xLen); + len = Math.min(sLen, x.length); break; // just y case '01': @@ -137,25 +138,50 @@ function handleSampleDefaults(traceIn, traceOut, coerce, layout) { break; case '02': defaultOrientation = 'v'; - len = Math.min(sLen, yLen); + len = Math.min(sLen, y.length); break; // both case '12': defaultOrientation = 'v'; - len = Math.min(sLen, xLen, yLen); + len = Math.min(sLen, xLen, y.length); break; case '21': defaultOrientation = 'h'; - len = Math.min(sLen, xLen, yLen); + len = Math.min(sLen, x.length, yLen); break; case '11': // this one is ill-defined len = 0; break; case '22': - // this one case happen on multi-category axes - defaultOrientation = 'v'; - len = Math.min(sLen, xLen, yLen); + var hasCategories = false; + var i; + for(i = 0; i < x.length; i++) { + if(autoType(x[i]) === 'category') { + hasCategories = true; + break; + } + } + + if(hasCategories) { + defaultOrientation = 'v'; + len = Math.min(sLen, xLen, y.length); + } else { + for(i = 0; i < y.length; i++) { + if(autoType(y[i]) === 'category') { + hasCategories = true; + break; + } + } + + if(hasCategories) { + defaultOrientation = 'h'; + len = Math.min(sLen, x.length, yLen); + } else { + defaultOrientation = 'v'; + len = Math.min(sLen, xLen, y.length); + } + } break; } } else if(yDims > 0) { diff --git a/test/jasmine/tests/box_test.js b/test/jasmine/tests/box_test.js index 6a9cbdcb60d..58ea50c048d 100644 --- a/test/jasmine/tests/box_test.js +++ b/test/jasmine/tests/box_test.js @@ -260,6 +260,30 @@ describe('Test boxes supplyDefaults', function() { x0: undefined, dx: undefined, y0: 0, dy: 1 }); + _check('with set 2D x (sliced to length q1/median/q3)', { + x: [[1, 2, 3], [2, 3, 4]], + q1: [1], + median: [2], + q3: [3] + }, { + visible: true, + orientation: 'h', + _length: 1, + x0: undefined, dx: undefined, + y0: 0, dy: 1 + }); + _check('with set 2D x (sliced to x.length)', { + x: [[1, 2, 3]], + q1: [1, 2], + median: [2, 3], + q3: [3, 4] + }, { + visible: true, + orientation: 'h', + _length: 1, + x0: undefined, dx: undefined, + y0: 0, dy: 1 + }); _check('with set y', { y: [0], q1: [1], @@ -284,6 +308,30 @@ describe('Test boxes supplyDefaults', function() { x0: 0, dx: 1, y0: undefined, dy: undefined }); + _check('with set 2d y (sliced to y.length)', { + y: [[1, 2, 3]], + q1: [1, 2], + median: [2, 3], + q3: [3, 4] + }, { + visible: true, + orientation: 'v', + _length: 1, + x0: 0, dx: 1, + y0: undefined, dy: undefined + }); + _check('with set 2d y (sliced to q1/median/q3 length)', { + y: [[1, 2, 3], [2, 3, 4]], + q1: [1], + median: [2], + q3: [3] + }, { + visible: true, + orientation: 'v', + _length: 1, + x0: 0, dx: 1, + y0: undefined, dy: undefined + }); _check('with set x AND 2d y', { x: [0], y: [[1, 2, 3]], @@ -299,6 +347,21 @@ describe('Test boxes supplyDefaults', function() { x0: undefined, dx: undefined, y0: undefined, dy: undefined }); + _check('with set x AND 2d y (sliced to y.length)', { + x: [0, 1], + y: [[1, 2, 3]], + q1: [1, 2], + median: [2, 3], + q3: [3, 4] + }, { + visible: true, + orientation: 'v', + _length: 1, + x: [0, 1], + y: [[1, 2, 3]], + x0: undefined, dx: undefined, + y0: undefined, dy: undefined + }); _check('with set 2d x AND y', { x: [[1, 2, 3]], y: [4], @@ -314,6 +377,78 @@ describe('Test boxes supplyDefaults', function() { x0: undefined, dx: undefined, y0: undefined, dy: undefined }); + _check('with set 2d x AND y (sliced to x.length)', { + x: [[1, 2, 3]], + y: [4, 5], + q1: [1, 2], + median: [2, 3], + q3: [3, 4] + }, { + visible: true, + orientation: 'h', + _length: 1, + x: [[1, 2, 3]], + y: [4, 5], + x0: undefined, dx: undefined, + y0: undefined, dy: undefined + }); + _check('with set 2d multicategory x AND 2d y', { + x: [ + ['2017', '2017', '2018', '2018'], + ['q1', 'q2', 'q1', 'q2'] + ], + y: [ + [0, 1, 2, 3, 4], + [1, 2, 3, 4, 5], + [2, 3, 4, 5, 6], + [3, 4, 5, 6, 7] + ], + q1: [1, 2, 3, 4], + median: [2, 3, 4, 5], + q3: [3, 4, 5, 6] + }, { + visible: true, + orientation: 'v', + _length: 4 + }); + _check('with set 2d x AND 2d multicategory y', { + y: [ + ['2017', '2017', '2018', '2018'], + ['q1', 'q2', 'q1', 'q2'] + ], + x: [ + [0, 1, 2, 3, 4], + [1, 2, 3, 4, 5], + [2, 3, 4, 5, 6], + [3, 4, 5, 6, 7] + ], + q1: [1, 2, 3, 4], + median: [2, 3, 4, 5], + q3: [3, 4, 5, 6] + }, { + visible: true, + orientation: 'h', + _length: 4 + }); + _check('with set category 2d x AND 2d y (edge case!)', { + x: [ + ['2017', '2017', '2018', '2018'], + ['q1', 'q2', 'q1', 'q2'] + ], + y: [ + ['a', 'b', 'c'], + ['a', 'b', 'c'], + ['a', 'b', 'c'], + ['a', 'b', 'c'] + ], + q1: [1, 2, 3, 4], + median: [2, 3, 4, 5], + q3: [3, 4, 5, 6] + }, { + visible: true, + orientation: 'v', + _length: 4 + }); _check('with just y0', { y0: 4, q1: [1], From c9344edacfdc2cb5a69d2bcc564a766d74f682d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= Date: Thu, 2 Jan 2020 15:34:39 -0500 Subject: [PATCH 09/12] fix typos in some box/violin descriptions --- src/traces/box/attributes.js | 22 +++++++++++----------- src/traces/box/index.js | 4 ++-- src/traces/violin/attributes.js | 2 +- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/traces/box/attributes.js b/src/traces/box/attributes.js index 1305ec52b00..0e18d73f9a1 100644 --- a/src/traces/box/attributes.js +++ b/src/traces/box/attributes.js @@ -94,7 +94,7 @@ module.exports = { role: 'info', editType: 'calc+clearAxisTypes', description: [ - 'Sets the Quartile 1 values,', + 'Sets the Quartile 1 values.', 'There should be as many items as the number of boxes desired.', ].join(' ') }, @@ -112,7 +112,7 @@ module.exports = { role: 'info', editType: 'calc+clearAxisTypes', description: [ - 'Sets the Quartile 3 values,', + 'Sets the Quartile 3 values.', 'There should be as many items as the number of boxes desired.', ].join(' ') }, @@ -121,7 +121,7 @@ module.exports = { role: 'info', editType: 'calc', description: [ - 'Sets the lower fence values,', + 'Sets the lower fence values.', 'There should be as many items as the number of boxes desired.', 'This attribute has effect only under the q1/median/q3 signature.', 'If `lowerfence` is not provided but a sample (in `y` or `x`) is set,', @@ -133,7 +133,7 @@ module.exports = { role: 'info', editType: 'calc', description: [ - 'Sets the upper fence values,', + 'Sets the upper fence values.', 'There should be as many items as the number of boxes desired.', 'This attribute has effect only under the q1/median/q3 signature.', 'If `upperfence` is not provided but a sample (in `y` or `x`) is set,', @@ -173,7 +173,7 @@ module.exports = { role: 'info', editType: 'calc', description: [ - 'Sets the notch span from the boxes\' `median` values,', + 'Sets the notch span from the boxes\' `median` values.', 'There should be as many items as the number of boxes desired.', 'This attribute has effect only under the q1/median/q3 signature.', 'If `notchspan` is not provided but a sample (in `y` or `x`) is set,', @@ -201,7 +201,7 @@ module.exports = { 'If *all*, all sample points are shown', 'If *false*, only the box(es) are shown with no sample points', 'Defaults to *suspectedoutliers* when `marker.outliercolor` or', - '`marker.line.outliercolor` is set.,', + '`marker.line.outliercolor` is set.', 'Defaults to *all* under the q1/median/q3 signature.', 'Otherwise defaults to *outliers*.', ].join(' ') @@ -242,7 +242,7 @@ module.exports = { 'If *true*, the mean of the box(es)\' underlying distribution is', 'drawn as a dashed line inside the box(es).', 'If *sd* the standard deviation is also drawn.', - 'Defaults to *true* when `mean` is set', + 'Defaults to *true* when `mean` is set.', 'Defaults to *sd* when `sd` is set', 'Otherwise defaults to *false*.' ].join(' ') @@ -252,7 +252,7 @@ module.exports = { role: 'info', editType: 'calc', description: [ - 'Sets the mean values,', + 'Sets the mean values.', 'There should be as many items as the number of boxes desired.', 'This attribute has effect only under the q1/median/q3 signature.', 'If `mean` is not provided but a sample (in `y` or `x`) is set,', @@ -264,7 +264,7 @@ module.exports = { role: 'info', editType: 'calc', description: [ - 'Sets the standard deviation values,', + 'Sets the standard deviation values.', 'There should be as many items as the number of boxes desired.', 'This attribute has effect only under the q1/median/q3 signature.', 'If `sd` is not provided but a sample (in `y` or `x`) is set,', @@ -294,10 +294,10 @@ module.exports = { 'Sets the method used to compute the sample\'s Q1 and Q3 quartiles.', 'The *linear* method uses the 25th percentile for Q1 and 75th percentile for Q3', - 'as computed using method #10 listed on http://www.amstat.org/publications/jse/v14n3/langford.html).', + 'as computed using method #10 (listed on http://www.amstat.org/publications/jse/v14n3/langford.html).', 'The *exclusive* method uses the median to divide the ordered dataset into two halves', - 'if the sample is odd, it does not includes the median in either half -', + 'if the sample is odd, it does not include the median in either half -', 'Q1 is then the median of the lower half and', 'Q3 the median of the upper half.', diff --git a/src/traces/box/index.js b/src/traces/box/index.js index 2fdc99e237e..54b0345ddfa 100644 --- a/src/traces/box/index.js +++ b/src/traces/box/index.js @@ -35,14 +35,14 @@ module.exports = { 'by default they span +/- 1.5 times the interquartile range (IQR: Q3-Q1),', 'The sample mean and standard deviation as well as notches and', 'the sample, outlier and suspected outliers points can be optionally', - 'added to the box plot', + 'added to the box plot.', 'The values and positions corresponding to each boxes can be input', 'using two signatures.', 'The first signature expects users to supply the sample values in the `y`', 'data array for vertical boxes (`x` for horizontal boxes).', - 'By supplying an `x` (`y`) array, one box per distinct x (y) value is drawn', + 'By supplying an `x` (`y`) array, one box per distinct `x` (`y`) value is drawn', 'If no `x` (`y`) {array} is provided, a single box is drawn.', 'In this case, the box is positioned with the trace `name` or with `x0` (`y0`) if provided.', diff --git a/src/traces/violin/attributes.js b/src/traces/violin/attributes.js index 1014490d732..24fd6f06cdb 100644 --- a/src/traces/violin/attributes.js +++ b/src/traces/violin/attributes.js @@ -128,7 +128,7 @@ module.exports = { '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', + 'If *false*, only the violins are shown with no sample points.', 'Defaults to *suspectedoutliers* when `marker.outliercolor` or', '`marker.line.outliercolor` is set,', 'otherwise defaults to *outliers*.' From a09806aa53f4447b0e672e430ad7906dd3e49f2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= Date: Thu, 2 Jan 2020 15:52:23 -0500 Subject: [PATCH 10/12] mv quartilemethod str comparisons out of pet-box loop --- src/traces/box/calc.js | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/traces/box/calc.js b/src/traces/box/calc.js index cd9342e912a..281a082005c 100644 --- a/src/traces/box/calc.js +++ b/src/traces/box/calc.js @@ -178,7 +178,6 @@ module.exports = function calc(gd, trace) { ); } else { var valArray = valAxis.makeCalcdata(trace, valLetter); - var quartilemethod = trace.quartilemethod; var posBins = makeBins(posDistinct, dPos); var pLen = posDistinct.length; var ptsPerBin = initNestedArray(pLen); @@ -199,6 +198,10 @@ module.exports = function calc(gd, trace) { var minLowerNotch = Infinity; var maxUpperNotch = -Infinity; + var quartilemethod = trace.quartilemethod; + var usesExclusive = quartilemethod === 'exclusive'; + var usesInclusive = quartilemethod === 'inclusive'; + // build calcdata trace items, one item per distinct position for(i = 0; i < pLen; i++) { if(ptsPerBin[i].length > 0) { @@ -215,15 +218,15 @@ module.exports = function calc(gd, trace) { cdi.sd = Lib.stdev(boxVals, N, cdi.mean); cdi.med = Lib.interp(boxVals, 0.5); - if((N % 2) && (quartilemethod === 'exclusive' || quartilemethod === 'inclusive')) { + if((N % 2) && (usesExclusive || usesInclusive)) { var lower; var upper; - if(quartilemethod === 'exclusive') { + if(usesExclusive) { // do NOT include the median in either half lower = boxVals.slice(0, N / 2); upper = boxVals.slice(N / 2 + 1); - } else if(quartilemethod === 'inclusive') { + } else if(usesInclusive) { // include the median in either half lower = boxVals.slice(0, N / 2 + 1); upper = boxVals.slice(N / 2); From f37f04d2d730521faea2e24031e1a4f25303463c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= Date: Thu, 2 Jan 2020 15:52:51 -0500 Subject: [PATCH 11/12] add numeric strings in box_precomputed-stats mock --- test/image/mocks/box_precomputed-stats.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/test/image/mocks/box_precomputed-stats.json b/test/image/mocks/box_precomputed-stats.json index a68bec46695..02ba946022b 100644 --- a/test/image/mocks/box_precomputed-stats.json +++ b/test/image/mocks/box_precomputed-stats.json @@ -4,9 +4,9 @@ "type": "box", "name": "[V] just q1/median/q3", "offsetgroup": "1", - "q1": [ 1, 2, 1 ], - "median": [ 2, 3, 2 ], - "q3": [ 3, 4, 3 ] + "q1": [ 1, "2", 1 ], + "median": [ 2, 3, "2" ], + "q3": [ "3", 4, 3 ] }, { "type": "box", @@ -15,8 +15,8 @@ "q1": [ 1, 2, 1 ], "median": [ 2, 3, 2 ], "q3": [ 3, 4, 3 ], - "lowerfence": [ 0, 1, 0 ], - "upperfence": [ 4, 5, 4 ] + "lowerfence": [ 0, "1", 0 ], + "upperfence": [ 4, 5, "4" ] }, { "type": "box", @@ -27,9 +27,9 @@ "q3": [ 3, 4, 3 ], "lowerfence": [ 0, 1, 0 ], "upperfence": [ 4, 5, 4 ], - "mean": [ 2.2, 2.8, 2.2 ], - "sd": [ 0.4, 0.4, 0.4 ], - "notchspan": [ 0.2, 0.1, 0.2 ] + "mean": [ 2.2, 2.8, "2.2" ], + "sd": [ "0.4", 0.4, 0.4 ], + "notchspan": [ 0.2, "0.1", 0.2 ] }, { "type": "box", From b2e494cd8cf6c0be45262ab3810083c92b5332ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= Date: Thu, 2 Jan 2020 15:59:15 -0500 Subject: [PATCH 12/12] print q1, median and q3 values in invalid input warn msg --- src/traces/box/calc.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/traces/box/calc.js b/src/traces/box/calc.js index 281a082005c..86d6f2caff9 100644 --- a/src/traces/box/calc.js +++ b/src/traces/box/calc.js @@ -141,7 +141,12 @@ module.exports = function calc(gd, trace) { cdi.min = imin; cdi.max = imax; } else { - Lib.warn('Invalid input - make sure that q1 <= median <= q3'); + Lib.warn([ + 'Invalid input - make sure that q1 <= median <= q3', + 'q1 = ' + cdi.q1, + 'median = ' + cdi.med, + 'q3 = ' + cdi.q3 + ].join('\n')); var v0; if(cdi.med !== BADNUM) {