diff --git a/src/components/errorbars/calc.js b/src/components/errorbars/calc.js index 5340cdc6b27..56742248010 100644 --- a/src/components/errorbars/calc.js +++ b/src/components/errorbars/calc.js @@ -43,12 +43,29 @@ function calcOneAxis(calcTrace, trace, axis, coord) { var computeError = makeComputeError(opts); for(var i = 0; i < calcTrace.length; i++) { - var calcPt = calcTrace[i], - calcCoord = calcPt[coord]; + var calcPt = calcTrace[i]; + + var iIn = calcPt.i; + + // for types that don't include `i` in each calcdata point + if(iIn === undefined) iIn = i; + + // for stacked area inserted points + // TODO: errorbars have been tested cursorily with stacked area, + // but not thoroughly. It's not even really clear what you want to do: + // Should it just be calculated based on that trace's size data? + // Should you add errors from below in quadrature? + // And what about normalization, where in principle the errors shrink + // again when you get up to the top end? + // One option would be to forbid errorbars with stacking until we + // decide how to handle these questions. + else if(iIn === null) continue; + + var calcCoord = calcPt[coord]; if(!isNumeric(axis.c2l(calcCoord))) continue; - var errors = computeError(calcCoord, i); + var errors = computeError(calcCoord, iIn); if(isNumeric(errors[0]) && isNumeric(errors[1])) { var shoe = calcPt[coord + 's'] = calcCoord - errors[0], hat = calcPt[coord + 'h'] = calcCoord + errors[1]; diff --git a/src/plots/plots.js b/src/plots/plots.js index 908289fa72c..2063f505e48 100644 --- a/src/plots/plots.js +++ b/src/plots/plots.js @@ -391,6 +391,11 @@ plots.supplyDefaults = function(gd, opts) { // initialize splom grid defaults newFullLayout._splomGridDflt = {}; + // for stacked area traces to share config across traces + newFullLayout._scatterStackOpts = {}; + // for the first scatter trace on each subplot (so it knows tonext->tozero) + newFullLayout._firstScatter = {}; + // for traces to request a default rangeslider on their x axes // eg set `_requestRangeslider.x2 = true` for xaxis2 newFullLayout._requestRangeslider = {}; @@ -938,8 +943,6 @@ plots.supplyDataDefaults = function(dataIn, dataOut, layout, fullLayout) { fullTrace.uid = fullLayout._traceUids[i]; plots.supplyTraceDefaults(trace, fullTrace, colorCnt, fullLayout, i); - fullTrace.uid = fullLayout._traceUids[i]; - fullTrace.index = i; fullTrace._input = trace; fullTrace._expandedIndex = cnt; @@ -1178,6 +1181,14 @@ plots.supplyTraceDefaults = function(traceIn, traceOut, colorIndex, layout, trac plots.supplyTransformDefaults(traceIn, traceOut, layout); } + else if(_module && Registry.traceIs(traceOut, 'alwaysSupplyDefaults')) { + // Some types need *something* from supplyDefaults always, even if + // visible: false. Looking at you scatter: stack options even from + // hidden traces can control other traces in the stack. + // These types should bail out ASAP if visible is false. + // But we don't need any other cross-module attrs ^^ in this case. + _module.supplyDefaults(traceIn, traceOut, defaultColor, layout); + } return traceOut; }; @@ -1556,7 +1567,6 @@ plots.purge = function(gd) { // (and to have a record of them...) delete gd._promises; delete gd._redrawTimer; - delete gd.firstscatter; delete gd._hmlumcount; delete gd._hmpixcount; delete gd._transitionData; @@ -2421,8 +2431,6 @@ plots.doCalcdata = function(gd, traces) { gd.calcdata = calcdata; // extra helper variables - // firstscatter: fill-to-next on the first trace goes to zero - gd.firstscatter = true; // how many box/violins plots do we have (in case they're grouped) fullLayout._numBoxes = 0; diff --git a/src/traces/bar/layout_attributes.js b/src/traces/bar/layout_attributes.js index aa84ce21d55..a78a2107ca7 100644 --- a/src/traces/bar/layout_attributes.js +++ b/src/traces/bar/layout_attributes.js @@ -36,9 +36,9 @@ module.exports = { editType: 'calc', description: [ 'Sets the normalization for bar traces on the graph.', - 'With *fraction*, the value of each bar is divide by the sum of the', - 'values at the location coordinate.', - 'With *percent*, the results form *fraction* are presented in percents.' + 'With *fraction*, the value of each bar is divided by the sum of all', + 'values at that location coordinate.', + '*percent* is the same but multiplied by 100 to show percentages.' ].join(' ') }, bargap: { diff --git a/src/traces/scatter/attributes.js b/src/traces/scatter/attributes.js index d8a17c934e8..c2856da6def 100644 --- a/src/traces/scatter/attributes.js +++ b/src/traces/scatter/attributes.js @@ -72,6 +72,69 @@ module.exports = { 'See `y0` for more info.' ].join(' ') }, + + stackgroup: { + valType: 'string', + role: 'info', + dflt: '', + editType: 'calc', + description: [ + 'Set several scatter traces (on the same subplot) to the same', + 'stackgroup in order to add their y values (or their x values if', + '`orientation` is *h*). If blank or omitted this trace will not be', + 'stacked. Stacking also turns `fill` on by default, using *tonexty*', + '(*tonextx*) if `orientation` is *h* (*v*) and sets the default', + '`mode` to *lines* irrespective of point count.', + 'You can only stack on a numeric (linear or log) axis.' + ].join(' ') + }, + orientation: { + valType: 'enumerated', + role: 'info', + values: ['v', 'h'], + editType: 'calc', + description: [ + 'Only relevant when `stackgroup` is used, and only the first', + '`orientation` found in the `stackgroup` will be used. Sets the', + 'stacking direction. With *v* (*h*), the y (x) values of subsequent', + 'traces are added. Also affects the default value of `fill`.' + ].join(' ') + }, + groupnorm: { + valType: 'enumerated', + values: ['', 'fraction', 'percent'], + dflt: '', + role: 'info', + editType: 'calc', + description: [ + 'Only relevant when `stackgroup` is used, and only the first', + '`groupnorm` found in the `stackgroup` will be used.', + 'Sets the normalization for the sum of this `stackgroup`.', + 'With *fraction*, the value of each trace at each location is', + 'divided by the sum of all trace values at that location.', + '*percent* is the same but multiplied by 100 to show percentages.' + ].join(' ') + }, + stackgaps: { + valType: 'enumerated', + values: ['infer zero', 'interpolate'], + dflt: 'infer zero', + role: 'info', + editType: 'calc', + description: [ + 'Only relevant when `stackgroup` is used, and only the first', + '`stackgaps` found in the `stackgroup` will be used.', + 'Determines how we handle locations at which other traces in this', + 'group have data but this one does not.', + 'With *infer zero* we insert a zero at these locations.', + 'With *interpolate* we linearly interpolate between existing', + 'values, and extrapolate a constant beyond the existing values.' + // TODO - implement interrupt mode + // '*interrupt* omits this trace from the stack at this location by', + // 'dropping abruptly, midway between the existing and missing locations.' + ].join(' ') + }, + text: { valType: 'string', role: 'info', @@ -114,7 +177,8 @@ module.exports = { 'If the provided `mode` includes *text* then the `text` elements', 'appear at the coordinates. Otherwise, the `text` elements', 'appear on hover.', - 'If there are less than ' + constants.PTS_LINESONLY + ' points,', + 'If there are less than ' + constants.PTS_LINESONLY + ' points', + 'and the trace is not stacked', 'then the default is *lines+markers*. Otherwise, *lines*.' ].join(' ') }, @@ -212,11 +276,12 @@ module.exports = { fill: { valType: 'enumerated', values: ['none', 'tozeroy', 'tozerox', 'tonexty', 'tonextx', 'toself', 'tonext'], - dflt: 'none', role: 'style', editType: 'calc', description: [ 'Sets the area to fill with a solid color.', + 'Defaults to *none* unless this trace is stacked, then it gets', + '*tonexty* (*tonextx*) if `orientation` is *v* (*h*)', 'Use with `fillcolor` if not *none*.', '*tozerox* and *tozeroy* fill to x=0 and y=0 respectively.', '*tonextx* and *tonexty* fill between the endpoints of this', diff --git a/src/traces/scatter/calc.js b/src/traces/scatter/calc.js index 37a9068c8e9..2ff8728073c 100644 --- a/src/traces/scatter/calc.js +++ b/src/traces/scatter/calc.js @@ -9,7 +9,7 @@ 'use strict'; var isNumeric = require('fast-isnumeric'); -var isArrayOrTypedArray = require('../../lib').isArrayOrTypedArray; +var Lib = require('../../lib'); var Axes = require('../../plots/cartesian/axes'); var BADNUM = require('../../constants/numerical').BADNUM; @@ -20,23 +20,69 @@ var arraysToCalcdata = require('./arrays_to_calcdata'); var calcSelection = require('./calc_selection'); function calc(gd, trace) { + var fullLayout = gd._fullLayout; var xa = Axes.getFromId(gd, trace.xaxis || 'x'); var ya = Axes.getFromId(gd, trace.yaxis || 'y'); var x = xa.makeCalcdata(trace, 'x'); var y = ya.makeCalcdata(trace, 'y'); var serieslen = trace._length; var cd = new Array(serieslen); + var ids = trace.ids; + var stackGroupOpts = getStackOpts(trace, fullLayout, xa, ya); + var interpolateGaps = false; + var isV, i, j, k, interpolate, vali; - var ppad = calcMarkerSize(trace, serieslen); - calcAxisExpansion(gd, trace, xa, ya, x, y, ppad); + setFirstScatter(fullLayout, trace); - for(var i = 0; i < serieslen; i++) { - cd[i] = (isNumeric(x[i]) && isNumeric(y[i])) ? - {x: x[i], y: y[i]} : - {x: BADNUM, y: BADNUM}; + var xAttr = 'x'; + var yAttr = 'y'; + var posAttr; + if(stackGroupOpts) { + stackGroupOpts.traceIndices.push(trace.index); + isV = stackGroupOpts.orientation === 'v'; + // size, like we use for bar + if(isV) { + yAttr = 's'; + posAttr = 'x'; + } + else { + xAttr = 's'; + posAttr = 'y'; + } + interpolate = stackGroupOpts.stackgaps === 'interpolate'; + } + else { + var ppad = calcMarkerSize(trace, serieslen); + calcAxisExpansion(gd, trace, xa, ya, x, y, ppad); + } + + for(i = 0; i < serieslen; i++) { + var cdi = cd[i] = {}; + var xValid = isNumeric(x[i]); + var yValid = isNumeric(y[i]); + if(xValid && yValid) { + cdi[xAttr] = x[i]; + cdi[yAttr] = y[i]; + } + // if we're stacking we need to hold on to all valid positions + // even with invalid sizes + else if(stackGroupOpts && (isV ? xValid : yValid)) { + cdi[posAttr] = isV ? x[i] : y[i]; + cdi.gap = true; + if(interpolate) { + cdi.s = BADNUM; + interpolateGaps = true; + } + else { + cdi.s = 0; + } + } + else { + cdi[xAttr] = cdi[yAttr] = BADNUM; + } - if(trace.ids) { - cd[i].id = String(trace.ids[i]); + if(ids) { + cdi.id = String(ids[i]); } } @@ -44,12 +90,72 @@ function calc(gd, trace) { calcColorscale(trace); calcSelection(cd, trace); - gd.firstscatter = false; + if(stackGroupOpts) { + // remove bad positions and sort + // note that original indices get added to cd in arraysToCalcdata + i = 0; + while(i < cd.length) { + if(cd[i][posAttr] === BADNUM) { + cd.splice(i, 1); + } + else i++; + } + + Lib.sort(cd, function(a, b) { + return (a[posAttr] - b[posAttr]) || (a.i - b.i); + }); + + if(interpolateGaps) { + // first fill the beginning with constant from the first point + i = 0; + while(i < cd.length - 1 && cd[i].gap) { + i++; + } + vali = cd[i].s; + if(!vali) vali = cd[i].s = 0; // in case of no data AT ALL in this trace - use 0 + for(j = 0; j < i; j++) { + cd[j].s = vali; + } + // then fill the end with constant from the last point + k = cd.length - 1; + while(k > i && cd[k].gap) { + k--; + } + vali = cd[k].s; + for(j = cd.length - 1; j > k; j--) { + cd[j].s = vali; + } + // now interpolate internal gaps linearly + while(i < k) { + i++; + if(cd[i].gap) { + j = i + 1; + while(cd[j].gap) { + j++; + } + var pos0 = cd[i - 1][posAttr]; + var size0 = cd[i - 1].s; + var m = (cd[j].s - size0) / (cd[j][posAttr] - pos0); + while(i < j) { + cd[i].s = size0 + (cd[i][posAttr] - pos0) * m; + i++; + } + } + } + } + } + return cd; } function calcAxisExpansion(gd, trace, xa, ya, x, y, ppad) { var serieslen = trace._length; + var fullLayout = gd._fullLayout; + var xId = xa._id; + var yId = ya._id; + var firstScatter = fullLayout._firstScatter[xId + yId + trace.type] === trace.uid; + var stackOrientation = (getStackOpts(trace, fullLayout, xa, ya) || {}).orientation; + var fill = trace.fill; // cancel minimum tick spacings (only applies to bars and boxes) xa._minDtick = 0; @@ -66,17 +172,20 @@ function calcAxisExpansion(gd, trace, xa, ya, x, y, ppad) { // TODO: text size + var openEnded = serieslen < 2 || (x[0] !== x[serieslen - 1]) || (y[0] !== y[serieslen - 1]); + // include zero (tight) and extremes (padded) if fill to zero // (unless the shape is closed, then it's just filling the shape regardless) - if(((trace.fill === 'tozerox') || - ((trace.fill === 'tonextx') && gd.firstscatter)) && - ((x[0] !== x[serieslen - 1]) || (y[0] !== y[serieslen - 1]))) { + if(openEnded && ( + (fill === 'tozerox') || + ((fill === 'tonextx') && (firstScatter || stackOrientation === 'h')) + )) { xOptions.tozero = true; } // if no error bars, markers or text, or fill to y=0 remove x padding else if(!(trace.error_y || {}).visible && ( - ['tonexty', 'tozeroy'].indexOf(trace.fill) !== -1 || + (fill === 'tonexty' || fill === 'tozeroy') || (!subTypes.hasMarkers(trace) && !subTypes.hasText(trace)) )) { xOptions.padded = false; @@ -86,19 +195,21 @@ function calcAxisExpansion(gd, trace, xa, ya, x, y, ppad) { // now check for y - rather different logic, though still mostly padded both ends // include zero (tight) and extremes (padded) if fill to zero // (unless the shape is closed, then it's just filling the shape regardless) - if(((trace.fill === 'tozeroy') || ((trace.fill === 'tonexty') && gd.firstscatter)) && - ((x[0] !== x[serieslen - 1]) || (y[0] !== y[serieslen - 1]))) { + if(openEnded && ( + (fill === 'tozeroy') || + ((fill === 'tonexty') && (firstScatter || stackOrientation === 'v')) + )) { yOptions.tozero = true; } // tight y: any x fill - else if(['tonextx', 'tozerox'].indexOf(trace.fill) !== -1) { + else if(fill === 'tonextx' || fill === 'tozerox') { yOptions.padded = false; } // N.B. asymmetric splom traces call this with blank {} xa or ya - if(xa._id) trace._extremes[xa._id] = Axes.findExtremes(xa, x, xOptions); - if(ya._id) trace._extremes[ya._id] = Axes.findExtremes(ya, y, yOptions); + if(xId) trace._extremes[xId] = Axes.findExtremes(xa, x, xOptions); + if(yId) trace._extremes[yId] = Axes.findExtremes(ya, y, yOptions); } function calcMarkerSize(trace, serieslen) { @@ -120,7 +231,7 @@ function calcMarkerSize(trace, serieslen) { }; } - if(isArrayOrTypedArray(marker.size)) { + if(Lib.isArrayOrTypedArray(marker.size)) { // I tried auto-type but category and dates dont make much sense. var ax = {type: 'linear'}; Axes.setConvert(ax); @@ -138,8 +249,34 @@ function calcMarkerSize(trace, serieslen) { } } +/** + * mark the first scatter trace for each subplot + * note that scatter and scattergl each get their own first trace + * note also that I'm doing this during calc rather than supplyDefaults + * so I don't need to worry about transforms, but if we ever do + * per-trace calc this will get confused. + */ +function setFirstScatter(fullLayout, trace) { + var subplotAndType = trace.xaxis + trace.yaxis + trace.type; + var firstScatter = fullLayout._firstScatter; + if(!firstScatter[subplotAndType]) firstScatter[subplotAndType] = trace.uid; +} + +function getStackOpts(trace, fullLayout, xa, ya) { + var stackGroup = trace.stackgroup; + if(!stackGroup) return; + var stackOpts = fullLayout._scatterStackOpts[xa._id + ya._id][stackGroup]; + var stackAx = stackOpts.orientation === 'v' ? ya : xa; + // Allow stacking only on numeric axes + // calc is a little late to be figuring this out, but during supplyDefaults + // we don't know the axis type yet + if(stackAx.type === 'linear' || stackAx.type === 'log') return stackOpts; +} + module.exports = { calc: calc, calcMarkerSize: calcMarkerSize, - calcAxisExpansion: calcAxisExpansion + calcAxisExpansion: calcAxisExpansion, + setFirstScatter: setFirstScatter, + getStackOpts: getStackOpts }; diff --git a/src/traces/scatter/cross_trace_calc.js b/src/traces/scatter/cross_trace_calc.js new file mode 100644 index 00000000000..4bff13e0e5f --- /dev/null +++ b/src/traces/scatter/cross_trace_calc.js @@ -0,0 +1,185 @@ +/** +* Copyright 2012-2018, Plotly, Inc. +* All rights reserved. +* +* This source code is licensed under the MIT license found in the +* LICENSE file in the root directory of this source tree. +*/ + + +'use strict'; + +var calc = require('./calc'); + +/* + * Scatter stacking & normalization calculations + * runs per subplot, and can handle multiple stacking groups + */ + +module.exports = function crossTraceCalc(gd, plotinfo) { + var xa = plotinfo.xaxis; + var ya = plotinfo.yaxis; + var subplot = xa._id + ya._id; + + var subplotStackOpts = gd._fullLayout._scatterStackOpts[subplot]; + if(!subplotStackOpts) return; + + var calcTraces = gd.calcdata; + + var i, j, k, i2, cd, cd0, posj, sumj, norm; + var groupOpts, interpolate, groupnorm, posAttr, valAttr; + var hasAnyBlanks; + + function insertBlank(calcTrace, index, position, traceIndex) { + hasAnyBlanks[traceIndex] = true; + var newEntry = { + i: null, + gap: true, + s: 0 + }; + newEntry[posAttr] = position; + calcTrace.splice(index, 0, newEntry); + // Even if we're not interpolating, if one trace has multiple + // values at the same position and this trace only has one value there, + // we just duplicate that one value rather than insert a zero. + // We also make it look like a real point - because it's ambiguous which + // one really is the real one! + if(index && position === calcTrace[index - 1][posAttr]) { + var prevEntry = calcTrace[index - 1]; + newEntry.s = prevEntry.s; + // TODO is it going to cause any problems to have multiple + // calcdata points with the same index? + newEntry.i = prevEntry.i; + newEntry.gap = prevEntry.gap; + } + else if(interpolate) { + newEntry.s = getInterp(calcTrace, index, position); + } + if(!index) { + // t and trace need to stay on the first cd entry + cd[0].t = cd[1].t; + cd[0].trace = cd[1].trace; + delete cd[1].t; + delete cd[1].trace; + } + } + + function getInterp(calcTrace, index, position) { + var pt0 = calcTrace[index - 1]; + var pt1 = calcTrace[index + 1]; + if(!pt1) return pt0.s; + if(!pt0) return pt1.s; + return pt0.s + (pt1.s - pt0.s) * (position - pt0[posAttr]) / (pt1[posAttr] - pt0[posAttr]); + } + + for(var stackGroup in subplotStackOpts) { + groupOpts = subplotStackOpts[stackGroup]; + var indices = groupOpts.traceIndices; + + // can get here with no indices if the stack axis is non-numeric + if(!indices.length) continue; + + interpolate = groupOpts.stackgaps === 'interpolate'; + groupnorm = groupOpts.groupnorm; + if(groupOpts.orientation === 'v') { + posAttr = 'x'; + valAttr = 'y'; + } + else { + posAttr = 'y'; + valAttr = 'x'; + } + hasAnyBlanks = new Array(indices.length); + for(i = 0; i < hasAnyBlanks.length; i++) { + hasAnyBlanks[i] = false; + } + + // Collect the complete set of all positions across ALL traces. + // Start with the first trace, then interleave items from later traces + // as needed. + // Fill in mising items as we go. + cd0 = calcTraces[indices[0]]; + var allPositions = new Array(cd0.length); + for(i = 0; i < cd0.length; i++) { + allPositions[i] = cd0[i][posAttr]; + } + + for(i = 1; i < indices.length; i++) { + cd = calcTraces[indices[i]]; + + for(j = k = 0; j < cd.length; j++) { + posj = cd[j][posAttr]; + for(; posj > allPositions[k] && k < allPositions.length; k++) { + // the current trace is missing a position from some previous trace(s) + insertBlank(cd, j, allPositions[k], i); + j++; + } + if(posj !== allPositions[k]) { + // previous trace(s) are missing a position from the current trace + for(i2 = 0; i2 < i; i2++) { + insertBlank(calcTraces[indices[i2]], k, posj, i2); + } + allPositions.splice(k, 0, posj); + } + k++; + } + for(; k < allPositions.length; k++) { + insertBlank(cd, j, allPositions[k], i); + j++; + } + } + + var serieslen = allPositions.length; + + // stack (and normalize)! + for(j = 0; j < cd0.length; j++) { + sumj = cd0[j][valAttr] = cd0[j].s; + for(i = 1; i < indices.length; i++) { + cd = calcTraces[indices[i]]; + if(cd.length !== serieslen) { + // TODO: verify this never happens and remove + throw new Error('length mismatch!'); + } + cd[0].trace._rawLength = cd[0].trace._length; + cd[0].trace._length = serieslen; + sumj += cd[j].s; + cd[j][valAttr] = sumj; + } + + if(groupnorm) { + norm = ((groupnorm === 'fraction') ? sumj : (sumj / 100)) || 1; + for(i = 0; i < indices.length; i++) { + var cdj = calcTraces[indices[i]][j]; + cdj[valAttr] /= norm; + cdj.sNorm = cdj.s / norm; + } + } + } + + // autorange + for(i = 0; i < indices.length; i++) { + cd = calcTraces[indices[i]]; + var trace = cd[0].trace; + var ppad = calc.calcMarkerSize(trace, trace._rawLength); + var arrayPad = Array.isArray(ppad); + if((ppad && hasAnyBlanks[i]) || arrayPad) { + var ppadRaw = ppad; + ppad = new Array(serieslen); + for(j = 0; j < serieslen; j++) { + ppad[j] = cd[j].gap ? 0 : (arrayPad ? ppadRaw[cd[j].i] : ppadRaw); + } + } + var x = new Array(serieslen); + var y = new Array(serieslen); + for(j = 0; j < serieslen; j++) { + x[j] = cd[j].x; + y[j] = cd[j].y; + } + calc.calcAxisExpansion(gd, trace, xa, ya, x, y, ppad); + + // while we're here (in a loop over all traces in the stack) + // record the orientation, so hover can find it easily + cd[0].t.orientation = groupOpts.orientation; + } + } +}; diff --git a/src/traces/scatter/defaults.js b/src/traces/scatter/defaults.js index ba9fa7c5c2c..955a2a82d5f 100644 --- a/src/traces/scatter/defaults.js +++ b/src/traces/scatter/defaults.js @@ -15,6 +15,7 @@ var attributes = require('./attributes'); var constants = require('./constants'); var subTypes = require('./subtypes'); var handleXYDefaults = require('./xy_defaults'); +var handleStackDefaults = require('./stack_defaults'); var handleMarkerDefaults = require('./marker_defaults'); var handleLineDefaults = require('./line_defaults'); var handleLineShapeDefaults = require('./line_shape_defaults'); @@ -26,14 +27,15 @@ module.exports = function supplyDefaults(traceIn, traceOut, defaultColor, layout return Lib.coerce(traceIn, traceOut, attributes, attr, dflt); } - var len = handleXYDefaults(traceIn, traceOut, layout, coerce), - // TODO: default mode by orphan points... - defaultMode = len < constants.PTS_LINESONLY ? 'lines+markers' : 'lines'; - if(!len) { - traceOut.visible = false; - return; - } + var stackGroupOpts = handleStackDefaults(traceIn, traceOut, layout, coerce); + + var len = handleXYDefaults(traceIn, traceOut, layout, coerce); + if(!len) traceOut.visible = false; + + if(!traceOut.visible) return; + var defaultMode = !stackGroupOpts && (len < constants.PTS_LINESONLY) ? + 'lines+markers' : 'lines'; coerce('text'); coerce('hovertext'); coerce('mode', defaultMode); @@ -61,7 +63,9 @@ module.exports = function supplyDefaults(traceIn, traceOut, defaultColor, layout dfltHoverOn.push('points'); } - coerce('fill'); + // It's possible for this default to be changed by a later trace. + // We handle that case in some hacky code inside handleStackDefaults. + coerce('fill', stackGroupOpts ? stackGroupOpts.fillDflt : 'none'); if(traceOut.fill !== 'none') { handleFillColorDefaults(traceIn, traceOut, defaultColor, coerce); if(!subTypes.hasLines(traceOut)) handleLineShapeDefaults(traceIn, traceOut, coerce); diff --git a/src/traces/scatter/hover.js b/src/traces/scatter/hover.js index f85a1a77094..6efe4ca9053 100644 --- a/src/traces/scatter/hover.js +++ b/src/traces/scatter/hover.js @@ -68,16 +68,30 @@ module.exports = function hoverPoints(pointData, xval, yval, hovermode) { var yc = ya.c2p(di.y, true); var rad = di.mrc || 1; + // now we're done using the whole `calcdata` array, replace the + // index with the original index (in case of inserted point from + // stacked area) + pointData.index = di.i; + + var orientation = cd[0].t.orientation; + // TODO: for scatter and bar, option to show (sub)totals and + // raw data? Currently stacked and/or normalized bars just show + // the normalized individual sizes, so that's what I'm doing here + // for now. + var sizeVal = orientation && (di.sNorm || di.s); + var xLabelVal = (orientation === 'h') ? sizeVal : di.x; + var yLabelVal = (orientation === 'v') ? sizeVal : di.y; + Lib.extendFlat(pointData, { color: getTraceColor(trace, di), x0: xc - rad, x1: xc + rad, - xLabelVal: di.x, + xLabelVal: xLabelVal, y0: yc - rad, y1: yc + rad, - yLabelVal: di.y, + yLabelVal: yLabelVal, spikeDistance: dxy(di) }); diff --git a/src/traces/scatter/index.js b/src/traces/scatter/index.js index 020bc06a511..6df0d85ed7a 100644 --- a/src/traces/scatter/index.js +++ b/src/traces/scatter/index.js @@ -21,6 +21,7 @@ Scatter.attributes = require('./attributes'); Scatter.supplyDefaults = require('./defaults'); Scatter.cleanData = require('./clean_data'); Scatter.calc = require('./calc').calc; +Scatter.crossTraceCalc = require('./cross_trace_calc'); Scatter.arraysToCalcdata = require('./arrays_to_calcdata'); Scatter.plot = require('./plot'); Scatter.colorbar = require('./marker_colorbar'); @@ -33,7 +34,10 @@ Scatter.animatable = true; Scatter.moduleType = 'trace'; Scatter.name = 'scatter'; Scatter.basePlotModule = require('../../plots/cartesian'); -Scatter.categories = ['cartesian', 'svg', 'symbols', 'errorBarsOK', 'showLegend', 'scatter-like', 'zoomScale']; +Scatter.categories = [ + 'cartesian', 'svg', 'symbols', 'errorBarsOK', 'showLegend', 'scatter-like', + 'zoomScale', 'alwaysSupplyDefaults' +]; Scatter.meta = { description: [ 'The scatter trace type encompasses line charts, scatter charts, text charts, and bubble charts.', diff --git a/src/traces/scatter/plot.js b/src/traces/scatter/plot.js index b0e14b2964a..56980cdcf22 100644 --- a/src/traces/scatter/plot.js +++ b/src/traces/scatter/plot.js @@ -386,9 +386,17 @@ function plotOne(gd, idx, plotinfo, cdscatter, cdscatterAll, element, transition function visFilter(d) { + return d.filter(function(v) { return !v.gap && v.vis; }); + } + + function visFilterWithGaps(d) { return d.filter(function(v) { return v.vis; }); } + function gapFilter(d) { + return d.filter(function(v) { return !v.gap; }); + } + function keyFunc(d) { return d.id; } @@ -416,12 +424,24 @@ function plotOne(gd, idx, plotinfo, cdscatter, cdscatterAll, element, transition var markerFilter = hideFilter; var textFilter = hideFilter; - if(showMarkers) { - markerFilter = (trace.marker.maxdisplayed || trace._needsCull) ? visFilter : Lib.identity; - } + if(showMarkers || showText) { + var showFilter = Lib.identity; + // if we're stacking, "infer zero" gap mode gets markers in the + // gap points - because we've inferred a zero there - but other + // modes (currently "interpolate", later "interrupt" hopefully) + // we don't draw generated markers + var stackGroup = trace.stackgroup; + var isInferZero = stackGroup && ( + gd._fullLayout._scatterStackOpts[xa._id + ya._id][stackGroup].stackgaps === 'infer zero'); + if(trace.marker.maxdisplayed || trace._needsCull) { + showFilter = isInferZero ? visFilterWithGaps : visFilter; + } + else if(stackGroup && !isInferZero) { + showFilter = gapFilter; + } - if(showText) { - textFilter = (trace.marker.maxdisplayed || trace._needsCull) ? visFilter : Lib.identity; + if(showMarkers) markerFilter = showFilter; + if(showText) textFilter = showFilter; } // marker points diff --git a/src/traces/scatter/select.js b/src/traces/scatter/select.js index 5d10050b494..e980a6d7400 100644 --- a/src/traces/scatter/select.js +++ b/src/traces/scatter/select.js @@ -36,9 +36,9 @@ module.exports = function selectPoints(searchInfo, polygon) { x = xa.c2p(di.x); y = ya.c2p(di.y); - if(polygon.contains([x, y])) { + if((di.i !== null) && polygon.contains([x, y])) { selection.push({ - pointNumber: i, + pointNumber: di.i, x: xa.c2d(di.x), y: ya.c2d(di.y) }); diff --git a/src/traces/scatter/stack_defaults.js b/src/traces/scatter/stack_defaults.js new file mode 100644 index 00000000000..75cdd99a5dc --- /dev/null +++ b/src/traces/scatter/stack_defaults.js @@ -0,0 +1,101 @@ +/** +* Copyright 2012-2018, Plotly, Inc. +* All rights reserved. +* +* This source code is licensed under the MIT license found in the +* LICENSE file in the root directory of this source tree. +*/ + +'use strict'; + +var perStackAttrs = ['orientation', 'groupnorm', 'stackgaps']; + +module.exports = function handleStackDefaults(traceIn, traceOut, layout, coerce) { + var stackOpts = layout._scatterStackOpts; + + var stackGroup = coerce('stackgroup'); + if(stackGroup) { + // use independent stacking options per subplot + var subplot = traceOut.xaxis + traceOut.yaxis; + var subplotStackOpts = stackOpts[subplot]; + if(!subplotStackOpts) subplotStackOpts = stackOpts[subplot] = {}; + + var groupOpts = subplotStackOpts[stackGroup]; + var firstTrace = false; + if(groupOpts) { + groupOpts.traces.push(traceOut); + } + else { + groupOpts = subplotStackOpts[stackGroup] = { + // keep track of trace indices for use during stacking calculations + // this will be filled in during `calc` and used during `crossTraceCalc` + // so it's OK if we don't recreate it during a non-calc edit + traceIndices: [], + // Hold on to the whole set of prior traces + // First one is most important, so we can clear defaults + // there if we find explicit values only in later traces. + // We're only going to *use* the values stored in groupOpts, + // but for the editor and validate we want things self-consistent + // The full set of traces is used only to fix `fill` default if + // we find `orientation: 'h'` beyond the first trace + traces: [traceOut] + }; + firstTrace = true; + } + // TODO: how is this going to work with groupby transforms? + // in principle it should be OK I guess, as long as explicit group styles + // don't override explicit base-trace styles? + + var dflts = { + orientation: (traceOut.x && !traceOut.y) ? 'h' : 'v' + }; + + for(var i = 0; i < perStackAttrs.length; i++) { + var attr = perStackAttrs[i]; + var attrFound = attr + 'Found'; + if(!groupOpts[attrFound]) { + var traceHasAttr = traceIn[attr] !== undefined; + var isOrientation = attr === 'orientation'; + if(traceHasAttr || firstTrace) { + groupOpts[attr] = coerce(attr, dflts[attr]); + + if(isOrientation) { + groupOpts.fillDflt = groupOpts[attr] === 'h' ? + 'tonextx' : 'tonexty'; + } + + if(traceHasAttr) { + // TODO: this will show a value here even if it's invalid + // in which case it will revert to default. Is that what we + // want, or should we only take the first *valid* value? + groupOpts[attrFound] = true; + + // TODO: should we copy the new value to the first trace instead? + // if we did that, the editor could show controls in the + // first trace always; otherwise it would only show controls + // in a later trace, if that trace happens to already have a value... + // I think it's probably better to ignore the editor for this + // purpose though, as it's probably going to have to use some + // special logic anyway, it will likely want to just + // take the value out of layout._scatterStackOpts, set the + // new value into the first trace, and clear all later traces. + if(!firstTrace) { + delete groupOpts.traces[0][attr]; + + // orientation can affect default fill of previous traces + if(isOrientation) { + for(var j = 0; j < groupOpts.traces.length - 1; j++) { + var trace2 = groupOpts.traces[j]; + if(trace2._input.fill !== trace2.fill) { + trace2.fill = groupOpts.fillDflt; + } + } + } + } + } + } + } + } + return groupOpts; + } +}; diff --git a/src/traces/scattercarpet/attributes.js b/src/traces/scattercarpet/attributes.js index 6b6564d6565..60ca8852fee 100644 --- a/src/traces/scattercarpet/attributes.js +++ b/src/traces/scattercarpet/attributes.js @@ -74,6 +74,7 @@ module.exports = { connectgaps: scatterAttrs.connectgaps, fill: extendFlat({}, scatterAttrs.fill, { values: ['none', 'toself', 'tonext'], + dflt: 'none', description: [ 'Sets the area to fill with a solid color.', 'Use with `fillcolor` if not *none*.', diff --git a/src/traces/scattergl/attributes.js b/src/traces/scattergl/attributes.js index 3d8798115bd..385fb765742 100644 --- a/src/traces/scattergl/attributes.js +++ b/src/traces/scattergl/attributes.js @@ -75,7 +75,7 @@ var attrs = module.exports = overrideAll({ }) }), connectgaps: scatterAttrs.connectgaps, - fill: scatterAttrs.fill, + fill: extendFlat({}, scatterAttrs.fill, {dflt: 'none'}), fillcolor: scatterAttrs.fillcolor, // no hoveron diff --git a/src/traces/scattergl/index.js b/src/traces/scattergl/index.js index befc5e468f5..0bb6dabe015 100644 --- a/src/traces/scattergl/index.js +++ b/src/traces/scattergl/index.js @@ -23,8 +23,10 @@ var findExtremes = require('../../plots/cartesian/autorange').findExtremes; var Color = require('../../components/color'); var subTypes = require('../scatter/subtypes'); -var calcMarkerSize = require('../scatter/calc').calcMarkerSize; -var calcAxisExpansion = require('../scatter/calc').calcAxisExpansion; +var scatterCalc = require('../scatter/calc'); +var calcMarkerSize = scatterCalc.calcMarkerSize; +var calcAxisExpansion = scatterCalc.calcAxisExpansion; +var setFirstScatter = scatterCalc.setFirstScatter; var calcColorscales = require('../scatter/colorscale_calc'); var linkTraces = require('../scatter/link_traces'); var getTraceColor = require('../scatter/get_trace_color'); @@ -89,6 +91,7 @@ function calc(gd, trace) { // Reuse SVG scatter axis expansion routine. // For graphs with very large number of points and array marker.size, // use average marker size instead to speed things up. + setFirstScatter(fullLayout, trace); var ppad; if(count < TOO_MANY_POINTS) { ppad = calcMarkerSize(trace, count); @@ -134,7 +137,6 @@ function calc(gd, trace) { scene.count++; - gd.firstscatter = false; return [{x: false, y: false, t: stash, trace: trace}]; } diff --git a/src/traces/scatterpolar/attributes.js b/src/traces/scatterpolar/attributes.js index e05c8f31da3..fa04df7f593 100644 --- a/src/traces/scatterpolar/attributes.js +++ b/src/traces/scatterpolar/attributes.js @@ -106,6 +106,7 @@ module.exports = { fill: extendFlat({}, scatterAttrs.fill, { values: ['none', 'toself', 'tonext'], + dflt: 'none', description: [ 'Sets the area to fill with a solid color.', 'Use with `fillcolor` if not *none*.', diff --git a/src/traces/scatterternary/attributes.js b/src/traces/scatterternary/attributes.js index e41377fd74e..1dee1c0d70d 100644 --- a/src/traces/scatterternary/attributes.js +++ b/src/traces/scatterternary/attributes.js @@ -103,6 +103,7 @@ module.exports = { cliponaxis: scatterAttrs.cliponaxis, fill: extendFlat({}, scatterAttrs.fill, { values: ['none', 'toself', 'tonext'], + dflt: 'none', description: [ 'Sets the area to fill with a solid color.', 'Use with `fillcolor` if not *none*.', diff --git a/test/image/baselines/stacked_area.png b/test/image/baselines/stacked_area.png new file mode 100644 index 00000000000..2f508049fbc Binary files /dev/null and b/test/image/baselines/stacked_area.png differ diff --git a/test/image/baselines/stacked_area_horz.png b/test/image/baselines/stacked_area_horz.png new file mode 100644 index 00000000000..3c18529b36d Binary files /dev/null and b/test/image/baselines/stacked_area_horz.png differ diff --git a/test/image/baselines/stacked_area_log.png b/test/image/baselines/stacked_area_log.png new file mode 100644 index 00000000000..737318747aa Binary files /dev/null and b/test/image/baselines/stacked_area_log.png differ diff --git a/test/image/mocks/stacked_area.json b/test/image/mocks/stacked_area.json new file mode 100644 index 00000000000..0c2aac3076b --- /dev/null +++ b/test/image/mocks/stacked_area.json @@ -0,0 +1,83 @@ +{ + "data": [ + { + "x": [1, 3, 5, 7], "y": [2, 6, 4, 5], "line": {"color": "red"}, "stackgroup": "a", "name": "bottom", "legendgroup": "b" + }, { + "x": [2, 5, 6], "y": [1, 3, 4], "line": {"color": "green"}, "stackgroup": "a", "name": "middle", "legendgroup": "m" + }, { + "x": [3, 4 ,5], "y": [1, 2, 1], "line": {"color": "blue"}, "stackgroup": "a", "name": "top", "legendgroup": "t" + }, + + { + "x": [1, 3, 5, 7], "y": [2, 6, 4, 5], "line": {"color": "red"}, "stackgroup": "a", "name": "bottom", "legendgroup": "b", + "showlegend": false, "xaxis": "x2", "yaxis": "y2", + "stackgaps": "interpolate" + }, { + "x": [2, 5, 6], "y": [1, 3, 4], "line": {"color": "green"}, "stackgroup": "a", "name": "middle", "legendgroup": "m", + "showlegend": false, "xaxis": "x2", "yaxis": "y2" + }, { + "x": [3, 4 ,5], "y": [1, 2, 1], "line": {"color": "blue"}, "stackgroup": "a", "name": "top", "legendgroup": "t", + "showlegend": false, "xaxis": "x2", "yaxis": "y2" + }, + + { + "x": [1, 3, 5, 7], "y": [2, 6, 4, 5], "line": {"color": "red"}, "stackgroup": "b", "name": "bottom", "legendgroup": "b", + "showlegend": false, "xaxis": "x3", "yaxis": "y3", "mode": "lines+markers", + "groupnorm": "fraction" + }, { + "x": [2, 5, 6], "y": [1, 3, 4], "line": {"color": "green"}, "stackgroup": "b", "name": "middle", "legendgroup": "m", + "showlegend": false, "xaxis": "x3", "yaxis": "y3", "mode": "lines+markers" + }, { + "x": [3, 4 ,5], "y": [1, 2, 1], "line": {"color": "blue"}, "stackgroup": "b", "name": "top", "legendgroup": "t", + "showlegend": false, "xaxis": "x3", "yaxis": "y3", "mode": "lines+markers" + }, + + { + "x": [1, 3, 5, 7], "y": [2, 6, 4, 5], "line": {"color": "red"}, "stackgroup": "a", "name": "bottom", "legendgroup": "b", + "showlegend": false, "xaxis": "x4", "yaxis": "y4", "mode": "lines+markers" + }, { + "x": [2, 5, 6], "y": [1, 3, 4], "line": {"color": "green"}, "stackgroup": "a", "name": "middle", "legendgroup": "m", + "showlegend": false, "xaxis": "x4", "yaxis": "y4", "mode": "lines+markers", + "stackgaps": "interpolate", "groupnorm": "fraction" + }, { + "x": [3, 4 ,5], "y": [1, 2, 1], "line": {"color": "blue"}, "stackgroup": "a", "name": "top", "legendgroup": "t", + "showlegend": false, "xaxis": "x4", "yaxis": "y4", "mode": "lines+markers" + }, + + { + "x": [1, 3, 5, 7], "y": [2, 6, 4, 5], "line": {"color": "red"}, "stackgroup": "a", "name": "bottom", "legendgroup": "b", + "showlegend": false, "xaxis": "x5", "yaxis": "y5" + }, { + "x": [2, 5, 6], "y": [1, 3, 4], "line": {"color": "green"}, "stackgroup": "a", "name": "middle", "legendgroup": "m", + "showlegend": false, "xaxis": "x5", "yaxis": "y5" + }, { + "x": [3, 4 ,5], "y": [1, 2, 1], "line": {"color": "blue"}, "stackgroup": "a", "name": "top", "legendgroup": "t", + "showlegend": false, "xaxis": "x5", "yaxis": "y5", + "groupnorm": "percent" + }, + + { + "x": [1, 3, 5, 7], "y": [2, 6, 4, 5], "line": {"color": "red"}, "stackgroup": "a", "name": "bottom", "legendgroup": "b", + "showlegend": false, "xaxis": "x6", "yaxis": "y6", + "groupnorm": "percent" + }, { + "x": [2, 5, 6], "y": [1, 3, 4], "line": {"color": "green"}, "stackgroup": "a", "name": "middle", "legendgroup": "m", + "showlegend": false, "xaxis": "x6", "yaxis": "y6" + }, { + "x": [3, 4 ,5], "y": [1, 2, 1], "line": {"color": "blue"}, "stackgroup": "a", "name": "top", "legendgroup": "t", + "showlegend": false, "xaxis": "x6", "yaxis": "y6", + "stackgaps": "interpolate" + } + ], + "layout": { + "width": 800, + "height": 800, + "xaxis": {"title": "stackgaps: infer zero"}, + "xaxis2": {"title": "stackgaps: interpolate"}, + "yaxis": {"title": "groupnorm: -"}, + "yaxis3": {"title": "groupnorm: fraction
mode: lines+markers"}, + "yaxis5": {"title": "groupnorm: percent"}, + "legend": {"traceorder": "reversed"}, + "grid": {"columns": 2, "rows": 3, "pattern": "independent", "roworder": "bottom to top"} + } +} diff --git a/test/image/mocks/stacked_area_horz.json b/test/image/mocks/stacked_area_horz.json new file mode 100644 index 00000000000..4d0342996c8 --- /dev/null +++ b/test/image/mocks/stacked_area_horz.json @@ -0,0 +1,83 @@ +{ + "data": [ + { + "y": [1, 3, 5, 7], "x": [2, 6, 4, 5], "line": {"color": "red"}, "stackgroup": "a", "orientation": "h", "name": "bottom", "legendgroup": "b" + }, { + "y": [2, 5, 6], "x": [1, 3, 4], "line": {"color": "green"}, "stackgroup": "a", "name": "middle", "legendgroup": "m" + }, { + "y": [3, 4 ,5], "x": [1, 2, 1], "line": {"color": "blue"}, "stackgroup": "a", "name": "top", "legendgroup": "t" + }, + + { + "y": [1, 3, 5, 7], "x": [2, 6, 4, 5], "line": {"color": "red"}, "stackgroup": "a", "name": "bottom", "legendgroup": "b", + "showlegend": false, "xaxis": "x2", "yaxis": "y2", + "stackgaps": "interpolate" + }, { + "y": [2, 5, 6], "x": [1, 3, 4], "line": {"color": "green"}, "stackgroup": "a", "orientation": "h", "name": "middle", "legendgroup": "m", + "showlegend": false, "xaxis": "x2", "yaxis": "y2" + }, { + "y": [3, 4 ,5], "x": [1, 2, 1], "line": {"color": "blue"}, "stackgroup": "a", "name": "top", "legendgroup": "t", + "showlegend": false, "xaxis": "x2", "yaxis": "y2" + }, + + { + "y": [1, 3, 5, 7], "x": [2, 6, 4, 5], "line": {"color": "red"}, "stackgroup": "b", "name": "bottom", "legendgroup": "b", + "showlegend": false, "xaxis": "x3", "yaxis": "y3", "mode": "lines+markers", + "groupnorm": "fraction" + }, { + "y": [2, 5, 6], "x": [1, 3, 4], "line": {"color": "green"}, "stackgroup": "b", "name": "middle", "legendgroup": "m", + "showlegend": false, "xaxis": "x3", "yaxis": "y3", "mode": "lines+markers" + }, { + "y": [3, 4 ,5], "x": [1, 2, 1], "line": {"color": "blue"}, "stackgroup": "b", "orientation": "h", "name": "top", "legendgroup": "t", + "showlegend": false, "xaxis": "x3", "yaxis": "y3", "mode": "lines+markers" + }, + + { + "y": [1, 3, 5, 7], "x": [2, 6, 4, 5], "line": {"color": "red"}, "stackgroup": "a", "orientation": "h", "name": "bottom", "legendgroup": "b", + "showlegend": false, "xaxis": "x4", "yaxis": "y4", "mode": "lines+markers" + }, { + "y": [2, 5, 6], "x": [1, 3, 4], "line": {"color": "green"}, "stackgroup": "a", "name": "middle", "legendgroup": "m", + "showlegend": false, "xaxis": "x4", "yaxis": "y4", "mode": "lines+markers", + "stackgaps": "interpolate", "groupnorm": "fraction" + }, { + "y": [3, 4 ,5], "x": [1, 2, 1], "line": {"color": "blue"}, "stackgroup": "a", "name": "top", "legendgroup": "t", + "showlegend": false, "xaxis": "x4", "yaxis": "y4", "mode": "lines+markers" + }, + + { + "y": [1, 3, 5, 7], "x": [2, 6, 4, 5], "line": {"color": "red"}, "stackgroup": "a", "name": "bottom", "legendgroup": "b", + "showlegend": false, "xaxis": "x5", "yaxis": "y5" + }, { + "y": [2, 5, 6], "x": [1, 3, 4], "line": {"color": "green"}, "stackgroup": "a", "orientation": "h", "name": "middle", "legendgroup": "m", + "showlegend": false, "xaxis": "x5", "yaxis": "y5" + }, { + "y": [3, 4 ,5], "x": [1, 2, 1], "line": {"color": "blue"}, "stackgroup": "a", "name": "top", "legendgroup": "t", + "showlegend": false, "xaxis": "x5", "yaxis": "y5", + "groupnorm": "percent" + }, + + { + "y": [1, 3, 5, 7], "x": [2, 6, 4, 5], "line": {"color": "red"}, "stackgroup": "a", "name": "bottom", "legendgroup": "b", + "showlegend": false, "xaxis": "x6", "yaxis": "y6", + "groupnorm": "percent" + }, { + "y": [2, 5, 6], "x": [1, 3, 4], "line": {"color": "green"}, "stackgroup": "a", "name": "middle", "legendgroup": "m", + "showlegend": false, "xaxis": "x6", "yaxis": "y6" + }, { + "y": [3, 4 ,5], "x": [1, 2, 1], "line": {"color": "blue"}, "stackgroup": "a", "orientation": "h", "name": "top", "legendgroup": "t", + "showlegend": false, "xaxis": "x6", "yaxis": "y6", + "stackgaps": "interpolate" + } + ], + "layout": { + "width": 800, + "height": 800, + "xaxis": {"title": "stackgaps: infer zero"}, + "xaxis2": {"title": "stackgaps: interpolate"}, + "yaxis": {"title": "groupnorm: -"}, + "yaxis3": {"title": "groupnorm: fraction
mode: lines+markers"}, + "yaxis5": {"title": "groupnorm: percent"}, + "legend": {"traceorder": "reversed"}, + "grid": {"columns": 2, "rows": 3, "pattern": "independent", "roworder": "bottom to top"} + } +} diff --git a/test/image/mocks/stacked_area_log.json b/test/image/mocks/stacked_area_log.json new file mode 100644 index 00000000000..ec0965a01d3 --- /dev/null +++ b/test/image/mocks/stacked_area_log.json @@ -0,0 +1,86 @@ +{ + "data": [ + { + "x": [1, 3, 5, 7], "y": [2, 6, 4, 5], "line": {"color": "red"}, "stackgroup": "a", "name": "bottom", "legendgroup": "b" + }, { + "x": [2, 5, 6], "y": [1, 3, 4], "line": {"color": "green"}, "stackgroup": "a", "name": "middle", "legendgroup": "m" + }, { + "x": [3, 4 ,5], "y": [1, 2, 1], "line": {"color": "blue"}, "stackgroup": "a", "name": "top", "legendgroup": "t" + }, + + { + "x": [1, 3, 5, 7], "y": [2, 6, 4, 5], "line": {"color": "red"}, "stackgroup": "a", "name": "bottom", "legendgroup": "b", + "showlegend": false, "xaxis": "x2", "yaxis": "y2", + "stackgaps": "interpolate" + }, { + "x": [2, 5, 6], "y": [1, 3, 4], "line": {"color": "green"}, "stackgroup": "a", "name": "middle", "legendgroup": "m", + "showlegend": false, "xaxis": "x2", "yaxis": "y2" + }, { + "x": [3, 4 ,5], "y": [1, 2, 1], "line": {"color": "blue"}, "stackgroup": "a", "name": "top", "legendgroup": "t", + "showlegend": false, "xaxis": "x2", "yaxis": "y2" + }, + + { + "x": [1, 3, 5, 7], "y": [2, 6, 4, 5], "line": {"color": "red"}, "stackgroup": "b", "name": "bottom", "legendgroup": "b", + "showlegend": false, "xaxis": "x3", "yaxis": "y3", "mode": "lines+markers", + "groupnorm": "fraction" + }, { + "x": [2, 5, 6], "y": [1, 3, 4], "line": {"color": "green"}, "stackgroup": "b", "name": "middle", "legendgroup": "m", + "showlegend": false, "xaxis": "x3", "yaxis": "y3", "mode": "lines+markers" + }, { + "x": [3, 4 ,5], "y": [1, 2, 1], "line": {"color": "blue"}, "stackgroup": "b", "name": "top", "legendgroup": "t", + "showlegend": false, "xaxis": "x3", "yaxis": "y3", "mode": "lines+markers" + }, + + { + "x": [1, 3, 5, 7], "y": [2, 6, 4, 5], "line": {"color": "red"}, "stackgroup": "a", "name": "bottom", "legendgroup": "b", + "showlegend": false, "xaxis": "x4", "yaxis": "y4", "mode": "lines+markers" + }, { + "x": [2, 5, 6], "y": [1, 3, 4], "line": {"color": "green"}, "stackgroup": "a", "name": "middle", "legendgroup": "m", + "showlegend": false, "xaxis": "x4", "yaxis": "y4", "mode": "lines+markers", + "stackgaps": "interpolate", "groupnorm": "fraction" + }, { + "x": [3, 4 ,5], "y": [1, 2, 1], "line": {"color": "blue"}, "stackgroup": "a", "name": "top", "legendgroup": "t", + "showlegend": false, "xaxis": "x4", "yaxis": "y4", "mode": "lines+markers" + }, + + { + "x": [1, 3, 5, 7], "y": [2, 6, 4, 5], "line": {"color": "red"}, "stackgroup": "a", "name": "bottom", "legendgroup": "b", + "showlegend": false, "xaxis": "x5", "yaxis": "y5" + }, { + "x": [2, 5, 6], "y": [1, 3, 4], "line": {"color": "green"}, "stackgroup": "a", "name": "middle", "legendgroup": "m", + "showlegend": false, "xaxis": "x5", "yaxis": "y5" + }, { + "x": [3, 4 ,5], "y": [1, 2, 1], "line": {"color": "blue"}, "stackgroup": "a", "name": "top", "legendgroup": "t", + "showlegend": false, "xaxis": "x5", "yaxis": "y5", + "groupnorm": "percent" + }, + + { + "x": [1, 3, 5, 7], "y": [2, 6, 4, 5], "line": {"color": "red"}, "stackgroup": "a", "name": "bottom", "legendgroup": "b", + "showlegend": false, "xaxis": "x6", "yaxis": "y6", + "groupnorm": "percent" + }, { + "x": [2, 5, 6], "y": [1, 3, 4], "line": {"color": "green"}, "stackgroup": "a", "name": "middle", "legendgroup": "m", + "showlegend": false, "xaxis": "x6", "yaxis": "y6" + }, { + "x": [3, 4 ,5], "y": [1, 2, 1], "line": {"color": "blue"}, "stackgroup": "a", "name": "top", "legendgroup": "t", + "showlegend": false, "xaxis": "x6", "yaxis": "y6", + "stackgaps": "interpolate" + } + ], + "layout": { + "width": 800, + "height": 800, + "xaxis": {"title": "stackgaps: infer zero"}, + "xaxis2": {"title": "stackgaps: interpolate"}, + "yaxis": {"title": "groupnorm: -", "type": "log"}, + "yaxis2": {"type": "log"}, + "yaxis3": {"title": "groupnorm: fraction
mode: lines+markers", "type": "log"}, + "yaxis4": {"type": "log"}, + "yaxis5": {"title": "groupnorm: percent", "type": "log"}, + "yaxis6": {"type": "log"}, + "legend": {"traceorder": "reversed"}, + "grid": {"columns": 2, "rows": 3, "pattern": "independent", "roworder": "bottom to top"} + } +} diff --git a/test/jasmine/tests/scatter_test.js b/test/jasmine/tests/scatter_test.js index dffed81956d..e1711b395d9 100644 --- a/test/jasmine/tests/scatter_test.js +++ b/test/jasmine/tests/scatter_test.js @@ -832,6 +832,32 @@ describe('end-to-end scatter tests', function() { .then(done); }); + it('correctly autoranges fill tonext traces across multiple subplots', function(done) { + Plotly.newPlot(gd, [ + {y: [3, 4, 5], fill: 'tonexty'}, + {y: [4, 5, 6], fill: 'tonexty'}, + {y: [3, 4, 5], fill: 'tonexty', yaxis: 'y2'}, + {y: [4, 5, 6], fill: 'tonexty', yaxis: 'y2'} + ], {}) + .then(function() { + expect(gd._fullLayout.yaxis.range[0]).toBe(0); + // when we had a single `gd.firstscatter` this one was ~2.73 + // even though the fill was correctly drawn down to zero + expect(gd._fullLayout.yaxis2.range[0]).toBe(0); + }) + .catch(failTest) + .then(done); + }); + + it('correctly autoranges fill tonext traces with only one point', function(done) { + Plotly.newPlot(gd, [{y: [3], fill: 'tonexty'}]) + .then(function() { + expect(gd._fullLayout.yaxis.range[0]).toBe(0); + }) + .catch(failTest) + .then(done); + }); + it('should work with typed arrays', function(done) { function _assert(colors, sizes) { var pts = d3.selectAll('.point'); @@ -978,6 +1004,109 @@ describe('end-to-end scatter tests', function() { }); }); +describe('stacked area', function() { + var gd; + + beforeEach(function() { gd = createGraphDiv(); }); + afterEach(destroyGraphDiv); + var mock = require('@mocks/stacked_area'); + + it('updates ranges correctly when traces are toggled', function(done) { + function checkRanges(ranges, msg) { + for(var axId in ranges) { + var axName = axId.charAt(0) + 'axis' + axId.slice(1); + expect(gd._fullLayout[axName].range) + .toBeCloseToArray(ranges[axId], 0.1, msg + ' - ' + axId); + } + } + Plotly.newPlot(gd, Lib.extendDeep({}, mock)) + .then(function() { + // initial ranges, as in the baseline image + var xr = [1, 7]; + checkRanges({ + x: xr, x2: xr, x3: xr, x4: xr, x5: xr, x6: xr, + y: [0, 8.42], y2: [0, 10.53], + // TODO: for normalized data, perhaps we want to + // remove padding from the top (like we do from the zero) + // when data stay within the normalization limit? + // (y3&4 are more padded because they have markers) + y3: [0, 1.08], y4: [0, 1.08], y5: [0, 105.26], y6: [0, 105.26] + }, 'base case'); + + return Plotly.restyle(gd, 'visible', 'legendonly', [0, 3, 6, 9, 12, 15]); + }) + .then(function() { + var xr = [2, 6]; + checkRanges({ + x: xr, x2: xr, x3: xr, x4: xr, x5: xr, x6: xr, + y: [0, 4.21], y2: [0, 5.26], + y3: [0, 1.08], y4: [0, 1.08], y5: [0, 105.26], y6: [0, 105.26] + }, 'bottom trace legendonly'); + + return Plotly.restyle(gd, 'visible', false, [0, 3, 6, 9, 12, 15]); + }) + .then(function() { + var xr = [2, 6]; + checkRanges({ + x: xr, x2: xr, x3: xr, x4: xr, x5: xr, x6: xr, + y: [0, 4.21], y2: [0, 5.26], + y3: [0, 1.08], y4: [0, 1.08], y5: [0, 105.26], y6: [0, 105.26] + }, 'bottom trace visible: false'); + + return Plotly.restyle(gd, 'visible', false, [1, 4, 7, 10, 13, 16]); + }) + .then(function() { + var xr = [3, 5]; + checkRanges({ + x: xr, x2: xr, x3: xr, x4: xr, x5: xr, x6: xr, + y: [0, 2.11], y2: [0, 2.11], + y3: [0, 1.08], y4: [0, 1.08], y5: [0, 105.26], y6: [0, 105.26] + }, 'only top trace showing'); + + return Plotly.restyle(gd, 'visible', true, [0, 3, 6, 9, 12, 15]); + }) + .then(function() { + var xr = [1, 7]; + checkRanges({ + x: xr, x2: xr, x3: xr, x4: xr, x5: xr, x6: xr, + y: [0, 7.37], y2: [0, 7.37], + y3: [0, 1.08], y4: [0, 1.08], y5: [0, 105.26], y6: [0, 105.26] + }, 'top and bottom showing'); + }) + .catch(failTest) + .then(done); + }); + + it('does not stack on date axes', function(done) { + Plotly.newPlot(gd, [ + {y: ['2016-01-01', '2017-01-01'], stackgroup: 'a'}, + {y: ['2016-01-01', '2017-01-01'], stackgroup: 'a'} + ]) + .then(function() { + expect(gd.layout.yaxis.range.map(function(v) { return v.slice(0, 4); })) + // if we had stacked, this would go into the 2060s since we'd be + // adding milliseconds since 1970 + .toEqual(['2015', '2017']); + }) + .catch(failTest) + .then(done); + }); + + it('does not stack on category axes', function(done) { + Plotly.newPlot(gd, [ + {y: ['a', 'b'], stackgroup: 'a'}, + {y: ['b', 'c'], stackgroup: 'a'} + ]) + .then(function() { + // if we had stacked, we'd calculate a new category 3 + // and autorange to ~[-0.2, 3.2] + expect(gd.layout.yaxis.range).toBeCloseToArray([-0.1, 2.1], 1); + }) + .catch(failTest) + .then(done); + }); +}); + describe('scatter hoverPoints', function() { afterEach(destroyGraphDiv);