diff --git a/lib/index.js b/lib/index.js index 6ef0ea0266a..20d1b40ec86 100644 --- a/lib/index.js +++ b/lib/index.js @@ -21,6 +21,7 @@ Plotly.register([ require('./contour'), require('./scatterternary'), require('./violin'), + require('./waterfall'), require('./pie'), require('./sunburst'), diff --git a/lib/waterfall.js b/lib/waterfall.js new file mode 100644 index 00000000000..6d2bcac2fde --- /dev/null +++ b/lib/waterfall.js @@ -0,0 +1,11 @@ +/** +* Copyright 2012-2019, Plotly, Inc. +* All rights reserved. +* +* This source code is licensed under the MIT license found in the +* LICENSE file in the root directory of this source tree. +*/ + +'use strict'; + +module.exports = require('../src/traces/waterfall'); diff --git a/src/components/drawing/index.js b/src/components/drawing/index.js index f8a0131e4e0..05a56873b1d 100644 --- a/src/components/drawing/index.js +++ b/src/components/drawing/index.js @@ -110,7 +110,9 @@ drawing.hideOutsideRangePoints = function(traceGroups, subplot) { var trace = d[0].trace; var xcalendar = trace.xcalendar; var ycalendar = trace.ycalendar; - var selector = trace.type === 'bar' ? '.bartext' : '.point,.textpoint'; + var selector = trace.type === 'bar' ? '.bartext' : + trace.type === 'waterfall' ? '.bartext,.line' : + '.point,.textpoint'; traceGroups.selectAll(selector).each(function(d) { drawing.hideOutsideRangePoint(d, d3.select(this), xa, ya, xcalendar, ycalendar); diff --git a/src/components/legend/style.js b/src/components/legend/style.js index cd7260bf8d0..a48752efc8f 100644 --- a/src/components/legend/style.js +++ b/src/components/legend/style.js @@ -61,6 +61,7 @@ module.exports = function style(s, gd) { .enter().append('g') .classed('legendpoints', true); }) + .each(styleWaterfalls) .each(styleBars) .each(styleBoxes) .each(stylePies) @@ -241,6 +242,38 @@ module.exports = function style(s, gd) { txt.selectAll('text').call(Drawing.textPointStyle, tMod, gd); } + function styleWaterfalls(d) { + var trace = d[0].trace; + + var ptsData = []; + if(trace.type === 'waterfall' && trace.visible) { + ptsData = d[0].hasTotals ? + [['increasing', 'M-6,-6V6H0Z'], ['totals', 'M6,6H0L-6,-6H-0Z'], ['decreasing', 'M6,6V-6H0Z']] : + [['increasing', 'M-6,-6V6H6Z'], ['decreasing', 'M6,6V-6H-6Z']]; + } + + var pts = d3.select(this).select('g.legendpoints') + .selectAll('path.legendwaterfall') + .data(ptsData); + pts.enter().append('path').classed('legendwaterfall', true) + .attr('transform', 'translate(20,0)') + .style('stroke-miterlimit', 1); + pts.exit().remove(); + + pts.each(function(dd) { + var pt = d3.select(this); + var cont = trace[dd[0]].marker; + + pt.attr('d', dd[1]) + .style('stroke-width', cont.line.width + 'px') + .call(Color.fill, cont.color); + + if(cont.line.width) { + pt.call(Color.stroke, cont.line.color); + } + }); + } + function styleBars(d) { var trace = d[0].trace; var marker = trace.marker || {}; diff --git a/src/plot_api/helpers.js b/src/plot_api/helpers.js index bfbfc28c414..d4f1a2e4f34 100644 --- a/src/plot_api/helpers.js +++ b/src/plot_api/helpers.js @@ -16,10 +16,11 @@ var Registry = require('../registry'); var Lib = require('../lib'); var Plots = require('../plots/plots'); var AxisIds = require('../plots/cartesian/axis_ids'); -var cleanId = AxisIds.cleanId; -var getFromTrace = AxisIds.getFromTrace; var Color = require('../components/color'); +var cleanId = AxisIds.cleanId; +var getFromTrace = AxisIds.getFromTrace; +var traceIs = Registry.traceIs; // clear the promise queue if one of them got rejected exports.clearPromiseQueue = function(gd) { @@ -290,7 +291,7 @@ exports.cleanData = function(data) { // error_y.opacity is obsolete - merge into color if(trace.error_y && 'opacity' in trace.error_y) { var dc = Color.defaults; - var yeColor = trace.error_y.color || (Registry.traceIs(trace, 'bar') ? + var yeColor = trace.error_y.color || (traceIs(trace, 'bar') ? Color.defaultLine : dc[tracei % dc.length]); trace.error_y.color = Color.addOpacity( @@ -302,8 +303,8 @@ exports.cleanData = function(data) { // convert bardir to orientation, and put the data into // the axes it's eventually going to be used with if('bardir' in trace) { - if(trace.bardir === 'h' && (Registry.traceIs(trace, 'bar') || - trace.type.substr(0, 9) === 'histogram')) { + if(trace.bardir === 'h' && (traceIs(trace, 'bar') || + trace.type.substr(0, 9) === 'histogram')) { trace.orientation = 'h'; exports.swapXYData(trace); } @@ -332,11 +333,11 @@ exports.cleanData = function(data) { if(trace.yaxis) trace.yaxis = cleanId(trace.yaxis, 'y'); // scene ids scene1 -> scene - if(Registry.traceIs(trace, 'gl3d') && trace.scene) { + if(traceIs(trace, 'gl3d') && trace.scene) { trace.scene = Plots.subplotsRegistry.gl3d.cleanId(trace.scene); } - if(!Registry.traceIs(trace, 'pie') && !Registry.traceIs(trace, 'bar')) { + if(!traceIs(trace, 'pie') && !traceIs(trace, 'bar') && trace.type !== 'waterfall') { if(Array.isArray(trace.textposition)) { for(i = 0; i < trace.textposition.length; i++) { trace.textposition[i] = cleanTextPosition(trace.textposition[i]); diff --git a/src/plots/cartesian/axes.js b/src/plots/cartesian/axes.js index 1e0fee8609e..50ab0006933 100644 --- a/src/plots/cartesian/axes.js +++ b/src/plots/cartesian/axes.js @@ -2847,14 +2847,16 @@ function hasBarsOrFill(gd, ax) { for(var i = 0; i < fullData.length; i++) { var trace = fullData[i]; - if(trace.visible === true && - (trace.xaxis + trace.yaxis) === subplot && - ( - Registry.traceIs(trace, 'bar') && trace.orientation === {x: 'h', y: 'v'}[axLetter] || - trace.fill && trace.fill.charAt(trace.fill.length - 1) === axLetter - ) - ) { - return true; + if(trace.visible === true && (trace.xaxis + trace.yaxis) === subplot) { + if( + (Registry.traceIs(trace, 'bar') || trace.type === 'waterfall') && + trace.orientation === {x: 'h', y: 'v'}[axLetter] + ) return true; + + if( + trace.fill && + trace.fill.charAt(trace.fill.length - 1) === axLetter + ) return true; } } return false; diff --git a/src/plots/cartesian/constants.js b/src/plots/cartesian/constants.js index 51738431b6f..2b524c3d338 100644 --- a/src/plots/cartesian/constants.js +++ b/src/plots/cartesian/constants.js @@ -65,7 +65,7 @@ module.exports = { traceLayerClasses: [ 'heatmaplayer', 'contourcarpetlayer', 'contourlayer', - 'barlayer', + 'waterfalllayer', 'barlayer', 'carpetlayer', 'violinlayer', 'boxlayer', diff --git a/src/plots/cartesian/index.js b/src/plots/cartesian/index.js index 430e01dc4ea..8ff5a4678cd 100644 --- a/src/plots/cartesian/index.js +++ b/src/plots/cartesian/index.js @@ -261,7 +261,7 @@ function plotOne(gd, plotinfo, cdSubplot, transitionOpts, makeOnCompleteCallback ); // layers that allow `cliponaxis: false` - if(className !== 'scatterlayer' && className !== 'barlayer') { + if(className !== 'scatterlayer' && className !== 'barlayer' && className !== 'waterfalllayer') { Drawing.setClipUrl(sel, plotinfo.layerClipId, gd); } }); @@ -277,7 +277,7 @@ function plotOne(gd, plotinfo, cdSubplot, transitionOpts, makeOnCompleteCallback if(!gd._context.staticPlot) { if(plotinfo._hasClipOnAxisFalse) { plotinfo.clipOnAxisFalseTraces = plotinfo.plot - .selectAll('.scatterlayer, .barlayer') + .selectAll('.scatterlayer, .barlayer, .waterfalllayer') .selectAll('.trace'); } diff --git a/src/traces/bar/arrays_to_calcdata.js b/src/traces/bar/arrays_to_calcdata.js index fcc88ef81d0..a722f3dec04 100644 --- a/src/traces/bar/arrays_to_calcdata.js +++ b/src/traces/bar/arrays_to_calcdata.js @@ -6,12 +6,10 @@ * LICENSE file in the root directory of this source tree. */ - 'use strict'; var mergeArray = require('../../lib').mergeArray; - // arrayOk attributes, merge them into calcdata array module.exports = function arraysToCalcdata(cd, trace) { for(var i = 0; i < cd.length; i++) cd[i].i = i; diff --git a/src/traces/bar/cross_trace_calc.js b/src/traces/bar/cross_trace_calc.js index 916e853eec5..d09f130bb08 100644 --- a/src/traces/bar/cross_trace_calc.js +++ b/src/traces/bar/cross_trace_calc.js @@ -237,6 +237,7 @@ function setOffsetAndWidth(gd, pa, sieve) { var fullLayout = gd._fullLayout; var bargap = fullLayout.bargap; var bargroupgap = fullLayout.bargroupgap || 0; + var minDiff = sieve.minDiff; var calcTraces = sieve.traces; diff --git a/src/traces/bar/defaults.js b/src/traces/bar/defaults.js index b6715411524..e8e83ac35f0 100644 --- a/src/traces/bar/defaults.js +++ b/src/traces/bar/defaults.js @@ -13,17 +13,17 @@ var Color = require('../../components/color'); var Registry = require('../../registry'); var handleXYDefaults = require('../scatter/xy_defaults'); -var handleStyleDefaults = require('../bar/style_defaults'); +var handleStyleDefaults = require('./style_defaults'); var getAxisGroup = require('../../plots/cartesian/axis_ids').getAxisGroup; var attributes = require('./attributes'); +var coerceFont = Lib.coerceFont; + function supplyDefaults(traceIn, traceOut, defaultColor, layout) { function coerce(attr, dflt) { return Lib.coerce(traceIn, traceOut, attributes, attr, dflt); } - var coerceFont = Lib.coerceFont; - var len = handleXYDefaults(traceIn, traceOut, layout, coerce); if(!len) { traceOut.visible = false; @@ -39,34 +39,7 @@ function supplyDefaults(traceIn, traceOut, defaultColor, layout) { coerce('hovertext'); coerce('hovertemplate'); - var textPosition = coerce('textposition'); - - var hasBoth = Array.isArray(textPosition) || textPosition === 'auto'; - var hasInside = hasBoth || textPosition === 'inside'; - var hasOutside = hasBoth || textPosition === 'outside'; - - if(hasInside || hasOutside) { - var textFont = coerceFont(coerce, 'textfont', layout.font); - - // Note that coercing `insidetextfont` is always needed – - // even if `textposition` is `outside` for each trace – since - // an outside label can become an inside one, for example because - // of a bar being stacked on top of it. - var insideTextFontDefault = Lib.extendFlat({}, textFont); - var isTraceTextfontColorSet = traceIn.textfont && traceIn.textfont.color; - var isColorInheritedFromLayoutFont = !isTraceTextfontColorSet; - if(isColorInheritedFromLayoutFont) { - delete insideTextFontDefault.color; - } - coerceFont(coerce, 'insidetextfont', insideTextFontDefault); - - if(hasOutside) coerceFont(coerce, 'outsidetextfont', textFont); - - coerce('constraintext'); - coerce('selected.textfont.color'); - coerce('unselected.textfont.color'); - coerce('cliponaxis'); - } + handleText(traceIn, traceOut, layout, coerce, true); handleStyleDefaults(traceIn, traceOut, coerce, defaultColor, layout); @@ -126,20 +99,55 @@ function crossTraceDefaults(fullData, fullLayout) { return Lib.coerce(traceOut._input, traceOut, attributes, attr); } - for(var i = 0; i < fullData.length; i++) { - traceOut = fullData[i]; + if(fullLayout.barmode === 'group') { + for(var i = 0; i < fullData.length; i++) { + traceOut = fullData[i]; - if(traceOut.type === 'bar') { - traceIn = traceOut._input; - if(fullLayout.barmode === 'group') { + if(traceOut.type === 'bar') { + traceIn = traceOut._input; handleGroupingDefaults(traceIn, traceOut, fullLayout, coerce); } } } } +function handleText(traceIn, traceOut, layout, coerce, moduleHasSelUnselected) { + var textPosition = coerce('textposition'); + var hasBoth = Array.isArray(textPosition) || textPosition === 'auto'; + var hasInside = hasBoth || textPosition === 'inside'; + var hasOutside = hasBoth || textPosition === 'outside'; + + if(hasInside || hasOutside) { + var textFont = coerceFont(coerce, 'textfont', layout.font); + + // Note that coercing `insidetextfont` is always needed – + // even if `textposition` is `outside` for each trace – since + // an outside label can become an inside one, for example because + // of a bar being stacked on top of it. + var insideTextFontDefault = Lib.extendFlat({}, textFont); + var isTraceTextfontColorSet = traceIn.textfont && traceIn.textfont.color; + var isColorInheritedFromLayoutFont = !isTraceTextfontColorSet; + if(isColorInheritedFromLayoutFont) { + delete insideTextFontDefault.color; + } + coerceFont(coerce, 'insidetextfont', insideTextFontDefault); + + if(hasOutside) coerceFont(coerce, 'outsidetextfont', textFont); + + coerce('constraintext'); + + if(moduleHasSelUnselected) { + coerce('selected.textfont.color'); + coerce('unselected.textfont.color'); + } + + coerce('cliponaxis'); + } +} + module.exports = { supplyDefaults: supplyDefaults, crossTraceDefaults: crossTraceDefaults, - handleGroupingDefaults: handleGroupingDefaults + handleGroupingDefaults: handleGroupingDefaults, + handleText: handleText }; diff --git a/src/traces/bar/hover.js b/src/traces/bar/hover.js index 570fab66c8e..841d3ba00ab 100644 --- a/src/traces/bar/hover.js +++ b/src/traces/bar/hover.js @@ -15,6 +15,21 @@ var Color = require('../../components/color'); var fillHoverText = require('../scatter/fill_hover_text'); function hoverPoints(pointData, xval, yval, hovermode) { + var barPointData = hoverOnBars(pointData, xval, yval, hovermode); + + if(barPointData) { + var cd = barPointData.cd; + var trace = cd[0].trace; + var di = cd[barPointData.index]; + + barPointData.color = getTraceColor(trace, di); + Registry.getComponentMethod('errorbars', 'hoverInfo')(di, trace, barPointData); + + return [barPointData]; + } +} + +function hoverOnBars(pointData, xval, yval, hovermode) { var cd = pointData.cd; var trace = cd[0].trace; var t = cd[0].t; @@ -133,12 +148,10 @@ function hoverPoints(pointData, xval, yval, hovermode) { // in case of bars shifted within groups pointData[posLetter + 'Spike'] = pa.c2p(di.p, true); - pointData.color = getTraceColor(trace, di); fillHoverText(di, trace, pointData); - Registry.getComponentMethod('errorbars', 'hoverInfo')(di, trace, pointData); - pointData.hovertemplate = trace.hovertemplate; - return [pointData]; + + return pointData; } function getTraceColor(trace, di) { @@ -152,5 +165,6 @@ function getTraceColor(trace, di) { module.exports = { hoverPoints: hoverPoints, + hoverOnBars: hoverOnBars, getTraceColor: getTraceColor }; diff --git a/src/traces/bar/plot.js b/src/traces/bar/plot.js index c76d8fe0373..abc63612673 100644 --- a/src/traces/bar/plot.js +++ b/src/traces/bar/plot.js @@ -28,16 +28,24 @@ var style = require('./style'); // padding in pixels around text var TEXTPAD = 3; -module.exports = function plot(gd, plotinfo, cdbar, barLayer) { +module.exports = function plot(gd, plotinfo, cdModule, traceLayer) { var xa = plotinfo.xaxis; var ya = plotinfo.yaxis; var fullLayout = gd._fullLayout; - var bartraces = Lib.makeTraceGroups(barLayer, cdbar, 'trace bars').each(function(cd) { + var bartraces = Lib.makeTraceGroups(traceLayer, cdModule, 'trace bars').each(function(cd) { var plotGroup = d3.select(this); var cd0 = cd[0]; var trace = cd0.trace; + var adjustDir; + var adjustPixel = 0; + if(trace.type === 'waterfall' && trace.connector.visible && trace.connector.mode === 'between') { + adjustPixel = trace.connector.line.width / 2; + } + + var isHorizontal = (trace.orientation === 'h'); + if(!plotinfo.isRangePlot) cd0.node3 = plotGroup; var pointGroup = Lib.ensureSingle(plotGroup, 'g', 'points'); @@ -57,7 +65,7 @@ module.exports = function plot(gd, plotinfo, cdbar, barLayer) { // log values go off-screen by plotwidth // so you see them continue if you drag the plot var x0, x1, y0, y1; - if(trace.orientation === 'h') { + if(isHorizontal) { y0 = ya.c2p(di.p0, true); y1 = ya.c2p(di.p1, true); x0 = xa.c2p(di.s0, true); @@ -65,8 +73,7 @@ module.exports = function plot(gd, plotinfo, cdbar, barLayer) { // for selections di.ct = [x1, (y0 + y1) / 2]; - } - else { + } else { x0 = xa.c2p(di.p0, true); x1 = xa.c2p(di.p1, true); y0 = ya.c2p(di.s0, true); @@ -83,14 +90,43 @@ module.exports = function plot(gd, plotinfo, cdbar, barLayer) { return; } - var lw = (di.mlw + 1 || trace.marker.line.width + 1 || + // in waterfall mode `between` we need to adjust bar end points to match the connector width + if(adjustPixel) { + if(isHorizontal) { + adjustDir = (x1 < x0) ? -1 : 1; + x0 -= adjustDir * adjustPixel; + x1 += adjustDir * adjustPixel; + } else { + adjustDir = (y1 < y0) ? -1 : 1; + y0 -= adjustDir * adjustPixel; + y1 += adjustDir * adjustPixel; + } + } + + var lw; + var mc; + var prefix; + + if(trace.type === 'waterfall') { + var cont = trace[di.dir].marker; + lw = cont.line.width; + mc = cont.color; + prefix = 'waterfall'; + } else { + lw = (di.mlw + 1 || trace.marker.line.width + 1 || (di.trace ? di.trace.marker.line.width : 0) + 1) - 1; + mc = di.mc || trace.marker.color; + prefix = 'bar'; + } + var offset = d3.round((lw / 2) % 1, 2); + var bargap = fullLayout[prefix + 'gap']; + var bargroupgap = fullLayout[prefix + 'groupgap']; function roundWithLine(v) { // if there are explicit gaps, don't round, // it can make the gaps look crappy - return (fullLayout.bargap === 0 && fullLayout.bargroupgap === 0) ? + return (bargap === 0 && bargroupgap === 0) ? d3.round(Math.round(v) - offset, 2) : v; } @@ -111,7 +147,8 @@ module.exports = function plot(gd, plotinfo, cdbar, barLayer) { // pixelation. if the bars ARE fully opaque and have // no line, expand to a full pixel to make sure we // can see them - var op = Color.opacity(di.mc || trace.marker.color); + + var op = Color.opacity(mc); var fixpx = (op < 1 || lw > 0.01) ? roundWithLine : expandToVisible; x0 = fixpx(x0, x1); x1 = fixpx(x1, x0); @@ -121,8 +158,7 @@ module.exports = function plot(gd, plotinfo, cdbar, barLayer) { Lib.ensureSingle(bar, 'path') .style('vector-effect', 'non-scaling-stroke') - .attr('d', - 'M' + x0 + ',' + y0 + 'V' + y1 + 'H' + x1 + 'V' + y0 + 'Z') + .attr('d', 'M' + x0 + ',' + y0 + 'V' + y1 + 'H' + x1 + 'V' + y0 + 'Z') .call(Drawing.setClipUrl, plotinfo.layerClipId, gd); appendBarText(gd, bar, cd, i, x0, x1, y0, y1); @@ -143,6 +179,7 @@ module.exports = function plot(gd, plotinfo, cdbar, barLayer) { }; function appendBarText(gd, bar, calcTrace, i, x0, x1, y0, y1) { + var fullLayout = gd._fullLayout; var textPosition; function appendTextNode(bar, text, textFont) { @@ -174,13 +211,14 @@ function appendBarText(gd, bar, calcTrace, i, x0, x1, y0, y1) { return; } - var layoutFont = gd._fullLayout.font; + var layoutFont = fullLayout.font; var barColor = style.getBarColor(calcTrace[i], trace); var insideTextFont = style.getInsideTextFont(trace, i, layoutFont, barColor); var outsideTextFont = style.getOutsideTextFont(trace, i, layoutFont); // compute text position - var barmode = gd._fullLayout.barmode; + var prefix = trace.type === 'waterfall' ? 'waterfall' : 'bar'; + var barmode = fullLayout[prefix + 'mode']; var inStackMode = (barmode === 'stack'); var inRelativeMode = (barmode === 'relative'); var inStackOrRelativeMode = inStackMode || inRelativeMode; @@ -220,14 +258,12 @@ function appendBarText(gd, bar, calcTrace, i, x0, x1, y0, y1) { if(textHasSize && (fitsInside || fitsInsideIfRotated || fitsInsideIfShrunk)) { textPosition = 'inside'; - } - else { + } else { textPosition = 'outside'; textSelection.remove(); textSelection = null; } - } - else textPosition = 'inside'; + } else textPosition = 'inside'; } if(!textSelection) { @@ -251,8 +287,7 @@ function appendBarText(gd, bar, calcTrace, i, x0, x1, y0, y1) { constrained = trace.constraintext === 'both' || trace.constraintext === 'outside'; transform = getTransformToMoveOutsideBar(x0, x1, y0, y1, textBB, orientation, constrained); - } - else { + } else { constrained = trace.constraintext === 'both' || trace.constraintext === 'inside'; transform = getTransformToMoveInsideBar(x0, x1, y0, y1, textBB, orientation, constrained); @@ -280,8 +315,7 @@ function getTransformToMoveInsideBar(x0, x1, y0, y1, textBB, orientation, constr textpad = TEXTPAD; barWidth -= 2 * textpad; barHeight -= 2 * textpad; - } - else textpad = 0; + } else textpad = 0; // compute rotation and scale var rotate, @@ -291,18 +325,15 @@ function getTransformToMoveInsideBar(x0, x1, y0, y1, textBB, orientation, constr // no scale or rotation is required rotate = false; scale = 1; - } - else if(textWidth <= barHeight && textHeight <= barWidth) { + } else if(textWidth <= barHeight && textHeight <= barWidth) { // only rotation is required rotate = true; scale = 1; - } - else if((textWidth < textHeight) === (barWidth < barHeight)) { + } else if((textWidth < textHeight) === (barWidth < barHeight)) { // only scale is required rotate = false; scale = constrained ? Math.min(barWidth / textWidth, barHeight / textHeight) : 1; - } - else { + } else { // both scale and rotation are required rotate = true; scale = constrained ? Math.min(barHeight / textWidth, barWidth / textHeight) : 1; @@ -314,8 +345,7 @@ function getTransformToMoveInsideBar(x0, x1, y0, y1, textBB, orientation, constr if(rotate) { targetWidth = scale * textHeight; targetHeight = scale * textWidth; - } - else { + } else { targetWidth = scale * textWidth; targetHeight = scale * textHeight; } @@ -325,19 +355,16 @@ function getTransformToMoveInsideBar(x0, x1, y0, y1, textBB, orientation, constr // bar end is on the left hand side targetX = x1 + textpad + targetWidth / 2; targetY = (y0 + y1) / 2; - } - else { + } else { targetX = x1 - textpad - targetWidth / 2; targetY = (y0 + y1) / 2; } - } - else { + } else { if(y1 > y0) { // bar end is on the bottom targetX = (x0 + x1) / 2; targetY = y1 - textpad - targetHeight / 2; - } - else { + } else { targetX = (x0 + x1) / 2; targetY = y1 + textpad + targetHeight / 2; } @@ -382,19 +409,16 @@ function getTransformToMoveOutsideBar(x0, x1, y0, y1, textBB, orientation, const // bar end is on the left hand side targetX = x1 - textpad - targetWidth / 2; targetY = (y0 + y1) / 2; - } - else { + } else { targetX = x1 + textpad + targetWidth / 2; targetY = (y0 + y1) / 2; } - } - else { + } else { if(y1 > y0) { // bar end is on the bottom targetX = (x0 + x1) / 2; targetY = y1 + textpad + targetHeight / 2; - } - else { + } else { targetX = (x0 + x1) / 2; targetY = y1 - textpad - targetHeight / 2; } diff --git a/src/traces/bar/style.js b/src/traces/bar/style.js index be948a3877e..2492af523ab 100644 --- a/src/traces/bar/style.js +++ b/src/traces/bar/style.js @@ -6,7 +6,6 @@ * LICENSE file in the root directory of this source tree. */ - 'use strict'; var d3 = require('d3'); @@ -22,7 +21,7 @@ var attributeOutsideTextFont = attributes.outsidetextfont; var helpers = require('./helpers'); function style(gd, cd) { - var s = cd ? cd[0].node3 : d3.select(gd).selectAll('g.trace.bars'); + var s = cd ? cd[0].node3 : d3.select(gd).selectAll('g.barlayer').selectAll('g.trace'); var barcount = s.size(); var fullLayout = gd._fullLayout; @@ -51,12 +50,12 @@ function style(gd, cd) { } function stylePoints(sel, trace, gd) { - var pts = sel.selectAll('path'); - var txs = sel.selectAll('text'); - - Drawing.pointStyle(pts, trace, gd); + Drawing.pointStyle(sel.selectAll('path'), trace, gd); + styleTextPoints(sel, trace, gd); +} - txs.each(function(d) { +function styleTextPoints(sel, trace, gd) { + sel.selectAll('text').each(function(d) { var tx = d3.select(this); var font = determineFont(tx, d, trace, gd); Drawing.font(tx, font); @@ -160,11 +159,15 @@ function getFontValue(attributeDefinition, attributeValue, index, defaultValue) } function getBarColor(cd, trace) { + if(trace.type === 'waterfall') { + return trace[cd.dir].marker.color; + } return cd.mc || trace.marker.color; } module.exports = { style: style, + styleTextPoints: styleTextPoints, styleOnSelect: styleOnSelect, getInsideTextFont: getInsideTextFont, getOutsideTextFont: getOutsideTextFont, diff --git a/src/traces/waterfall/attributes.js b/src/traces/waterfall/attributes.js new file mode 100644 index 00000000000..a4ba4419217 --- /dev/null +++ b/src/traces/waterfall/attributes.js @@ -0,0 +1,126 @@ +/** +* Copyright 2012-2019, 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 barAttrs = require('../bar/attributes'); +var lineAttrs = require('../scatter/attributes').line; +var extendFlat = require('../../lib/extend').extendFlat; + +function directionAttrs(dirTxt) { + return { + marker: { + color: extendFlat({}, barAttrs.marker.color, { + arrayOk: false, + editType: 'style', + description: 'Sets the marker color of all ' + dirTxt + ' values.' + }), + line: { + color: extendFlat({}, barAttrs.marker.line.color, { + arrayOk: false, + editType: 'style', + description: 'Sets the line color of all ' + dirTxt + ' values.' + }), + width: extendFlat({}, barAttrs.marker.line.width, { + arrayOk: false, + editType: 'style', + description: 'Sets the line width of all ' + dirTxt + ' values.' + }), + editType: 'style', + }, + editType: 'style' + }, + editType: 'style' + }; +} + +module.exports = { + measure: { + valType: 'data_array', + dflt: [], + role: 'info', + editType: 'calc', + description: [ + 'An array containing types of values.', + 'By default the values are considered as \'relative\'.', + 'However; it is possible to use \'total\' to compute the sums.', + 'Also \'absolute\' could be applied to reset the computed total', + 'or to declare an initial value where needed.' + ].join(' ') + }, + + base: { + valType: 'number', + dflt: null, + arrayOk: false, + role: 'info', + editType: 'calc', + description: [ + 'Sets where the bar base is drawn (in position axis units).' + ].join(' ') + }, + + x: barAttrs.x, + x0: barAttrs.x0, + dx: barAttrs.dx, + y: barAttrs.y, + y0: barAttrs.y0, + dy: barAttrs.dy, + + hovertext: barAttrs.hovertext, + hovertemplate: barAttrs.hovertemplate, + + text: barAttrs.text, + textposition: barAttrs.textposition, + textfont: barAttrs.textfont, + insidetextfont: barAttrs.insidetextfont, + outsidetextfont: barAttrs.outsidetextfont, + constraintext: barAttrs.constraintext, + + cliponaxis: barAttrs.cliponaxis, + orientation: barAttrs.orientation, + + offset: barAttrs.offset, + width: barAttrs.width, + + increasing: directionAttrs('increasing'), + decreasing: directionAttrs('decreasing'), + totals: directionAttrs('intermediate sums and total'), + + connector: { + line: { + color: lineAttrs.color, + width: lineAttrs.width, + dash: lineAttrs.dash, + editType: 'plot' + }, + mode: { + valType: 'enumerated', + values: ['spanning', 'between'], + dflt: 'between', + role: 'info', + editType: 'plot', + description: [ + 'Sets the shape of connector lines.' + ].join(' ') + }, + visible: { + valType: 'boolean', + dflt: true, + role: 'info', + editType: 'plot', + description: [ + 'Determines if connector lines are drawn. ' + ].join(' ') + }, + editType: 'plot' + }, + + offsetgroup: barAttrs.offsetgroup, + alignmentgroup: barAttrs.offsetgroup +}; diff --git a/src/traces/waterfall/calc.js b/src/traces/waterfall/calc.js new file mode 100644 index 00000000000..d22c1121a86 --- /dev/null +++ b/src/traces/waterfall/calc.js @@ -0,0 +1,101 @@ +/** +* Copyright 2012-2019, 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 Axes = require('../../plots/cartesian/axes'); +var mergeArray = require('../../lib').mergeArray; +var calcSelection = require('../scatter/calc_selection'); +var BADNUM = require('../../constants/numerical').BADNUM; + +function isAbsolute(a) { + return (a === 'a' || a === 'absolute'); +} + +function isTotal(a) { + return (a === 't' || a === 'total'); +} + +module.exports = function calc(gd, trace) { + var xa = Axes.getFromId(gd, trace.xaxis || 'x'); + var ya = Axes.getFromId(gd, trace.yaxis || 'y'); + var size, pos; + + if(trace.orientation === 'h') { + size = xa.makeCalcdata(trace, 'x'); + pos = ya.makeCalcdata(trace, 'y'); + } else { + size = ya.makeCalcdata(trace, 'y'); + pos = xa.makeCalcdata(trace, 'x'); + } + + // create the "calculated data" to plot + var serieslen = Math.min(pos.length, size.length); + var cd = new Array(serieslen); + + // set position and size (as well as for waterfall total size) + var previousSum = 0; + var newSize; + // trace-wide flags + var hasTotals = false; + + for(var i = 0; i < serieslen; i++) { + + var amount = size[i] || 0; + + var connectToNext = false; + if(size[i] !== BADNUM || isTotal(trace.measure[i]) || isAbsolute(trace.measure[i])) { + if(i + 1 < serieslen && (size[i + 1] !== BADNUM || isTotal(trace.measure[i + 1]) || isAbsolute(trace.measure[i + 1]))) { + connectToNext = true; + } + } + + var cdi = cd[i] = { + i: i, + p: pos[i], + s: amount, + rawS: amount, + cNext: connectToNext + }; + + if(isAbsolute(trace.measure[i])) { + previousSum = cdi.s; + + cdi.isSum = true; + cdi.dir = 'totals'; + cdi.s = previousSum; + } else if(isTotal(trace.measure[i])) { + cdi.isSum = true; + cdi.dir = 'totals'; + cdi.s = previousSum; + } else { + // default: relative + cdi.isSum = false; + cdi.dir = cdi.rawS < 0 ? 'decreasing' : 'increasing'; + newSize = cdi.s; + cdi.s = previousSum + newSize; + previousSum += newSize; + } + + if(cdi.dir === 'totals') { + hasTotals = true; + } + + if(trace.ids) { + cdi.id = String(trace.ids[i]); + } + } + + cd[0].hasTotals = hasTotals; + + mergeArray(trace.text, cd, 'tx'); + mergeArray(trace.hovertext, cd, 'htx'); + calcSelection(cd, trace); + + return cd; +}; diff --git a/src/traces/waterfall/cross_trace_calc.js b/src/traces/waterfall/cross_trace_calc.js new file mode 100644 index 00000000000..31d3b25d178 --- /dev/null +++ b/src/traces/waterfall/cross_trace_calc.js @@ -0,0 +1,70 @@ +/** +* Copyright 2012-2019, 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 setGroupPositions = require('../bar/cross_trace_calc').setGroupPositions; + +module.exports = function crossTraceCalc(gd, plotinfo) { + var fullLayout = gd._fullLayout; + var fullData = gd._fullData; + var calcdata = gd.calcdata; + var xa = plotinfo.xaxis; + var ya = plotinfo.yaxis; + var waterfalls = []; + var waterfallsVert = []; + var waterfallsHorz = []; + var cd, i; + + for(i = 0; i < fullData.length; i++) { + var fullTrace = fullData[i]; + + if( + fullTrace.visible === true && + fullTrace.xaxis === xa._id && + fullTrace.yaxis === ya._id && + fullTrace.type === 'waterfall' + ) { + cd = calcdata[i]; + + if(fullTrace.orientation === 'h') { + waterfallsHorz.push(cd); + } else { + waterfallsVert.push(cd); + } + + waterfalls.push(cd); + } + } + + // waterfall version of 'barmode', 'bargap' and 'bargroupgap' + var mockGd = { + _fullLayout: { + _axisMatchGroups: fullLayout._axisMatchGroups, + _alignmentOpts: fullLayout._alignmentOpts, + barmode: fullLayout.waterfallmode, + bargap: fullLayout.waterfallgap, + bargroupgap: fullLayout.waterfallgroupgap + } + }; + + setGroupPositions(mockGd, xa, ya, waterfallsVert); + setGroupPositions(mockGd, ya, xa, waterfallsHorz); + + for(i = 0; i < waterfalls.length; i++) { + cd = waterfalls[i]; + + for(var j = 0; j < cd.length; j++) { + var di = cd[j]; + + if(di.isSum === false) { + di.s0 += (j === 0) ? 0 : cd[j - 1].s; + } + } + } +}; diff --git a/src/traces/waterfall/defaults.js b/src/traces/waterfall/defaults.js new file mode 100644 index 00000000000..cdf53eab8f6 --- /dev/null +++ b/src/traces/waterfall/defaults.js @@ -0,0 +1,89 @@ +/** +* Copyright 2012-2019, Plotly, Inc. +* All rights reserved. +* +* This source code is licensed under the MIT license found in the +* LICENSE file in the root directory of this source tree. +*/ + +'use strict'; + +var Lib = require('../../lib'); + +var handleGroupingDefaults = require('../bar/defaults').handleGroupingDefaults; +var handleText = require('../bar/defaults').handleText; +var handleXYDefaults = require('../scatter/xy_defaults'); +var attributes = require('./attributes'); +var Color = require('../../components/color'); + +var INCREASING_COLOR = '#3D9970'; +var DECREASING_COLOR = '#FF4136'; +var TOTALS_COLOR = '#4499FF'; + +function handleDirection(coerce, direction, defaultColor) { + coerce(direction + '.marker.color', defaultColor); + coerce(direction + '.marker.line.color', Color.defaultLine); + coerce(direction + '.marker.line.width'); +} + +function supplyDefaults(traceIn, traceOut, defaultColor, layout) { + function coerce(attr, dflt) { + return Lib.coerce(traceIn, traceOut, attributes, attr, dflt); + } + + var len = handleXYDefaults(traceIn, traceOut, layout, coerce); + if(!len) { + traceOut.visible = false; + return; + } + + coerce('measure'); + + coerce('orientation', (traceOut.x && !traceOut.y) ? 'h' : 'v'); + coerce('base'); + coerce('offset'); + coerce('width'); + + coerce('text'); + coerce('hovertext'); + coerce('hovertemplate'); + + handleText(traceIn, traceOut, layout, coerce, false); + + handleDirection(coerce, 'increasing', INCREASING_COLOR); + handleDirection(coerce, 'decreasing', DECREASING_COLOR); + handleDirection(coerce, 'totals', TOTALS_COLOR); + + var connectorVisible = coerce('connector.visible'); + if(connectorVisible) { + coerce('connector.mode'); + var connectorLineWidth = coerce('connector.line.width'); + if(connectorLineWidth) { + coerce('connector.line.color'); + coerce('connector.line.dash'); + } + } +} + +function crossTraceDefaults(fullData, fullLayout) { + var traceIn, traceOut; + + function coerce(attr) { + return Lib.coerce(traceOut._input, traceOut, attributes, attr); + } + + if(fullLayout.waterfallmode === 'group') { + for(var i = 0; i < fullData.length; i++) { + traceOut = fullData[i]; + traceIn = traceOut._input; + + handleGroupingDefaults(traceIn, traceOut, fullLayout, coerce); + } + } +} + +module.exports = { + supplyDefaults: supplyDefaults, + crossTraceDefaults: crossTraceDefaults, + handleGroupingDefaults: handleGroupingDefaults +}; diff --git a/src/traces/waterfall/hover.js b/src/traces/waterfall/hover.js new file mode 100644 index 00000000000..923668dabcf --- /dev/null +++ b/src/traces/waterfall/hover.js @@ -0,0 +1,61 @@ +/** +* Copyright 2012-2019, 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 Color = require('../../components/color'); +var hoverOnBars = require('../bar/hover').hoverOnBars; + +var DIRSYMBOL = { + increasing: '▲', + decreasing: '▼' +}; + +module.exports = function hoverPoints(pointData, xval, yval, hovermode) { + var point = hoverOnBars(pointData, xval, yval, hovermode); + if(!point) return; + + var cd = point.cd; + var trace = cd[0].trace; + + // the closest data point + var index = point.index; + var di = cd[index]; + + var sizeLetter = (trace.orientation === 'h') ? 'x' : 'y'; + + var size = (di.isSum) ? di.b + di.s : di.rawS; + + if(!di.isSum) { + // format delta numbers: + if(size > 0) { + point.extraText = size + ' ' + DIRSYMBOL.increasing; + } else if(size < 0) { + point.extraText = '(' + (-size) + ') ' + DIRSYMBOL.decreasing; + } else { + return; + } + // display initial value + point.extraText += '
Initial: ' + (di.b + di.s - size); + } else { + point[sizeLetter + 'LabelVal'] = size; + } + + point.color = getTraceColor(trace, di); + + return [point]; +}; + +function getTraceColor(trace, di) { + var cont = trace[di.dir].marker; + var mc = cont.color; + var mlc = cont.line.color; + var mlw = cont.line.width; + if(Color.opacity(mc)) return mc; + else if(Color.opacity(mlc) && mlw) return mlc; +} diff --git a/src/traces/waterfall/index.js b/src/traces/waterfall/index.js new file mode 100644 index 00000000000..1263189fced --- /dev/null +++ b/src/traces/waterfall/index.js @@ -0,0 +1,40 @@ +/** +* Copyright 2012-2019, 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 Waterfall = {}; + +Waterfall.attributes = require('./attributes'); +Waterfall.layoutAttributes = require('./layout_attributes'); +Waterfall.supplyDefaults = require('./defaults').supplyDefaults; +Waterfall.crossTraceDefaults = require('./defaults').crossTraceDefaults; +Waterfall.supplyLayoutDefaults = require('./layout_defaults'); +Waterfall.calc = require('./calc'); +Waterfall.crossTraceCalc = require('./cross_trace_calc'); +Waterfall.plot = require('./plot'); +Waterfall.style = require('./style').style; +Waterfall.hoverPoints = require('./hover'); +Waterfall.selectPoints = require('../bar/select'); + +Waterfall.moduleType = 'trace'; +Waterfall.name = 'waterfall'; +Waterfall.basePlotModule = require('../../plots/cartesian'); +Waterfall.categories = ['cartesian', 'svg', 'oriented', 'showLegend', 'zoomScale']; +Waterfall.meta = { + description: [ + 'Draws waterfall trace which is useful graph to displays the', + 'contribution of various elements (either positive or negative)', + 'in a bar chart. The data visualized by the span of the bars is', + 'set in `y` if `orientation` is set th *v* (the default) and the', + 'labels are set in `x`.', + 'By setting `orientation` to *h*, the roles are interchanged.' + ].join(' ') +}; + +module.exports = Waterfall; diff --git a/src/traces/waterfall/layout_attributes.js b/src/traces/waterfall/layout_attributes.js new file mode 100644 index 00000000000..7d0a29b897c --- /dev/null +++ b/src/traces/waterfall/layout_attributes.js @@ -0,0 +1,50 @@ +/** +* Copyright 2012-2019, Plotly, Inc. +* All rights reserved. +* +* This source code is licensed under the MIT license found in the +* LICENSE file in the root directory of this source tree. +*/ + +'use strict'; + +module.exports = { + waterfallmode: { + valType: 'enumerated', + values: ['group', 'overlay'], + dflt: 'group', + role: 'info', + editType: 'calc', + description: [ + 'Determines how bars at the same location coordinate', + 'are displayed on the graph.', + 'With *group*, the bars are plotted next to one another', + 'centered around the shared location.', + 'With *overlay*, the bars are plotted over one another,', + 'you might need to an *opacity* to see multiple bars.' + ].join(' ') + }, + waterfallgap: { + valType: 'number', + min: 0, + max: 1, + role: 'style', + editType: 'calc', + description: [ + 'Sets the gap (in plot fraction) between bars of', + 'adjacent location coordinates.' + ].join(' ') + }, + waterfallgroupgap: { + valType: 'number', + min: 0, + max: 1, + dflt: 0, + role: 'style', + editType: 'calc', + description: [ + 'Sets the gap (in plot fraction) between bars of', + 'the same location coordinate.' + ].join(' ') + } +}; diff --git a/src/traces/waterfall/layout_defaults.js b/src/traces/waterfall/layout_defaults.js new file mode 100644 index 00000000000..0179d7317a9 --- /dev/null +++ b/src/traces/waterfall/layout_defaults.js @@ -0,0 +1,35 @@ +/** +* Copyright 2012-2019, Plotly, Inc. +* All rights reserved. +* +* This source code is licensed under the MIT license found in the +* LICENSE file in the root directory of this source tree. +*/ + +'use strict'; + +var Lib = require('../../lib'); +var layoutAttributes = require('./layout_attributes'); + +module.exports = function(layoutIn, layoutOut, fullData) { + var hasTraceType = false; + + function coerce(attr, dflt) { + return Lib.coerce(layoutIn, layoutOut, layoutAttributes, attr, dflt); + } + + for(var i = 0; i < fullData.length; i++) { + var trace = fullData[i]; + + if(trace.visible && trace.type === 'waterfall') { + hasTraceType = true; + break; + } + } + + if(hasTraceType) { + coerce('waterfallmode'); + coerce('waterfallgap', 0.2); + coerce('waterfallgroupgap'); + } +}; diff --git a/src/traces/waterfall/plot.js b/src/traces/waterfall/plot.js new file mode 100644 index 00000000000..a285f0456df --- /dev/null +++ b/src/traces/waterfall/plot.js @@ -0,0 +1,131 @@ +/** +* Copyright 2012-2019, Plotly, Inc. +* All rights reserved. +* +* This source code is licensed under the MIT license found in the +* LICENSE file in the root directory of this source tree. +*/ + +'use strict'; + +var d3 = require('d3'); +var Lib = require('../../lib'); +var Drawing = require('../../components/drawing'); +var barPlot = require('../bar/plot'); + +module.exports = function plot(gd, plotinfo, cdModule, traceLayer) { + barPlot(gd, plotinfo, cdModule, traceLayer); + plotConnectors(gd, plotinfo, cdModule, traceLayer); +}; + +function plotConnectors(gd, plotinfo, cdModule, traceLayer) { + var xa = plotinfo.xaxis; + var ya = plotinfo.yaxis; + + Lib.makeTraceGroups(traceLayer, cdModule, 'trace bars').each(function(cd) { + var plotGroup = d3.select(this); + var cd0 = cd[0]; + var trace = cd0.trace; + + var group = Lib.ensureSingle(plotGroup, 'g', 'lines'); + + if(!trace.connector || !trace.connector.visible) { + group.remove(); + return; + } + + var isHorizontal = (trace.orientation === 'h'); + var mode = trace.connector.mode; + + if(!plotinfo.isRangePlot) cd0.node3 = plotGroup; + + var connectors = group.selectAll('g.line').data(Lib.identity); + + connectors.enter().append('g') + .classed('line', true); + + connectors.exit().remove(); + + var len = connectors.size(); + + connectors.each(function(di, i) { + // don't draw lines between nulls + if(i !== len - 1 && !di.cNext) return; + + var connector = d3.select(this); + var shape = ''; + + var x0, y0; + var x1, y1; + var x2, y2; + var x3, y3; + + var delta = 0; + if(i + 1 < len && Array.isArray(trace.offset)) { + delta -= trace.offset[i + 1] - trace.offset[i]; + } + + if(isHorizontal) { + x0 = xa.c2p(di.s1, true); + y0 = ya.c2p(di.p1, true); + + x1 = xa.c2p(di.s0, true); + y1 = ya.c2p(di.p0, true); + + x2 = xa.c2p(di.s1, true); + y2 = ya.c2p(di.p1, true); + + if(i + 1 < len) { + x3 = xa.c2p(di.s0 + 1 - delta, true); + y3 = ya.c2p(di.p0 + 1 - delta, true); + } + } else { + x0 = xa.c2p(di.p1, true); + y0 = ya.c2p(di.s1, true); + + x1 = xa.c2p(di.p0, true); + y1 = ya.c2p(di.s0, true); + + x2 = xa.c2p(di.p1, true); + y2 = ya.c2p(di.s1, true); + + if(i + 1 < len) { + x3 = xa.c2p(di.p0 + 1 - delta, true); + y3 = ya.c2p(di.s0 + 1 - delta, true); + } + } + + if(mode === 'spanning') { + if(!di.isSum && i > 0) { + if(isHorizontal) { + shape += 'M' + x1 + ',' + y0 + 'V' + y1; + } else { + shape += 'M' + x0 + ',' + y1 + 'H' + x1; + } + } + } + + if(mode !== 'between') { + if(di.isSum || i < len - 1) { + if(isHorizontal) { + shape += 'M' + x2 + ',' + y1 + 'V' + y2; + } else { + shape += 'M' + x1 + ',' + y2 + 'H' + x2; + } + } + } + + if(x3 !== undefined && y3 !== undefined) { + if(isHorizontal) { + shape += 'M' + x2 + ',' + y2 + 'V' + y3; + } else { + shape += 'M' + x2 + ',' + y2 + 'H' + x3; + } + } + + Lib.ensureSingle(connector, 'path') + .attr('d', shape) + .call(Drawing.setClipUrl, plotinfo.layerClipId, gd); + }); + }); +} diff --git a/src/traces/waterfall/style.js b/src/traces/waterfall/style.js new file mode 100644 index 00000000000..12730615d9c --- /dev/null +++ b/src/traces/waterfall/style.js @@ -0,0 +1,54 @@ +/** +* Copyright 2012-2019, Plotly, Inc. +* All rights reserved. +* +* This source code is licensed under the MIT license found in the +* LICENSE file in the root directory of this source tree. +*/ + +'use strict'; + +var d3 = require('d3'); + +var Drawing = require('../../components/drawing'); +var Color = require('../../components/color'); + +var styleTextPoints = require('../bar/style').styleTextPoints; + +function style(gd, cd) { + var s = cd ? cd[0].node3 : d3.select(gd).selectAll('g.waterfalllayer').selectAll('g.trace'); + + s.style('opacity', function(d) { return d[0].trace.opacity; }); + + s.each(function(d) { + var gTrace = d3.select(this); + var trace = d[0].trace; + + gTrace.selectAll('.point > path').each(function(di) { + var cont = trace[di.dir].marker; + + d3.select(this) + .call(Color.fill, cont.color) + .call(Color.stroke, cont.line.color) + .call(Drawing.dashLine, cont.line.dash, cont.line.width) + .style('opacity', trace.selectedpoints && !di.selected ? 0.3 : 1); + }); + + styleTextPoints(gTrace, trace, gd); + + gTrace.selectAll('.lines').each(function() { + var sel = d3.select(this); + var connectorLine = trace.connector.line; + + Drawing.lineGroupStyle(sel.selectAll('path'), + connectorLine.width, + connectorLine.color, + connectorLine.dash + ); + }); + }); +} + +module.exports = { + style: style +}; diff --git a/test/image/baselines/waterfall-grouping-vs-defaults.png b/test/image/baselines/waterfall-grouping-vs-defaults.png new file mode 100644 index 00000000000..7c023291515 Binary files /dev/null and b/test/image/baselines/waterfall-grouping-vs-defaults.png differ diff --git a/test/image/baselines/waterfall-offsetgroups.png b/test/image/baselines/waterfall-offsetgroups.png new file mode 100644 index 00000000000..63dd5bf28ee Binary files /dev/null and b/test/image/baselines/waterfall-offsetgroups.png differ diff --git a/test/image/baselines/waterfall_11.png b/test/image/baselines/waterfall_11.png new file mode 100644 index 00000000000..e0c5465c7a3 Binary files /dev/null and b/test/image/baselines/waterfall_11.png differ diff --git a/test/image/baselines/waterfall_and_bar.png b/test/image/baselines/waterfall_and_bar.png new file mode 100644 index 00000000000..e1dda68661e Binary files /dev/null and b/test/image/baselines/waterfall_and_bar.png differ diff --git a/test/image/baselines/waterfall_and_histogram.png b/test/image/baselines/waterfall_and_histogram.png new file mode 100644 index 00000000000..c0383c04e3c Binary files /dev/null and b/test/image/baselines/waterfall_and_histogram.png differ diff --git a/test/image/baselines/waterfall_attrs.png b/test/image/baselines/waterfall_attrs.png new file mode 100644 index 00000000000..231255df571 Binary files /dev/null and b/test/image/baselines/waterfall_attrs.png differ diff --git a/test/image/baselines/waterfall_cliponaxis-false.png b/test/image/baselines/waterfall_cliponaxis-false.png new file mode 100644 index 00000000000..9b984b005b9 Binary files /dev/null and b/test/image/baselines/waterfall_cliponaxis-false.png differ diff --git a/test/image/baselines/waterfall_custom.png b/test/image/baselines/waterfall_custom.png new file mode 100644 index 00000000000..af16fd97824 Binary files /dev/null and b/test/image/baselines/waterfall_custom.png differ diff --git a/test/image/baselines/waterfall_gap0.png b/test/image/baselines/waterfall_gap0.png new file mode 100644 index 00000000000..7ea902fff82 Binary files /dev/null and b/test/image/baselines/waterfall_gap0.png differ diff --git a/test/image/baselines/waterfall_line.png b/test/image/baselines/waterfall_line.png new file mode 100644 index 00000000000..7e32aaad93a Binary files /dev/null and b/test/image/baselines/waterfall_line.png differ diff --git a/test/image/baselines/waterfall_months.png b/test/image/baselines/waterfall_months.png new file mode 100644 index 00000000000..47a7907c292 Binary files /dev/null and b/test/image/baselines/waterfall_months.png differ diff --git a/test/image/baselines/waterfall_multicategory.png b/test/image/baselines/waterfall_multicategory.png new file mode 100644 index 00000000000..1fc8d5b86de Binary files /dev/null and b/test/image/baselines/waterfall_multicategory.png differ diff --git a/test/image/baselines/waterfall_nonnumeric_sizes.png b/test/image/baselines/waterfall_nonnumeric_sizes.png new file mode 100644 index 00000000000..2ddd259f402 Binary files /dev/null and b/test/image/baselines/waterfall_nonnumeric_sizes.png differ diff --git a/test/image/baselines/waterfall_profit-loss_2018_positive-negative.png b/test/image/baselines/waterfall_profit-loss_2018_positive-negative.png new file mode 100644 index 00000000000..e913e3f156a Binary files /dev/null and b/test/image/baselines/waterfall_profit-loss_2018_positive-negative.png differ diff --git a/test/image/baselines/waterfall_profit-loss_2018vs2019_overlay.png b/test/image/baselines/waterfall_profit-loss_2018vs2019_overlay.png new file mode 100644 index 00000000000..b2155e45d7b Binary files /dev/null and b/test/image/baselines/waterfall_profit-loss_2018vs2019_overlay.png differ diff --git a/test/image/baselines/waterfall_profit-loss_2018vs2019_rectangle.png b/test/image/baselines/waterfall_profit-loss_2018vs2019_rectangle.png new file mode 100644 index 00000000000..7aae25906eb Binary files /dev/null and b/test/image/baselines/waterfall_profit-loss_2018vs2019_rectangle.png differ diff --git a/test/image/mocks/waterfall-grouping-vs-defaults.json b/test/image/mocks/waterfall-grouping-vs-defaults.json new file mode 100644 index 00000000000..41c9d4242a5 --- /dev/null +++ b/test/image/mocks/waterfall-grouping-vs-defaults.json @@ -0,0 +1,61 @@ +{ + "data": [ + { + "type": "waterfall", + "y": [ 1, -2, 1 ], + "yaxis": "y2" + }, + { + "type": "waterfall", + "y": [ 2, -1, 2 ] + }, + { + "type": "waterfall", + "y": [ 1, -3, 0 ] + }, + { + "type": "waterfall", + "y": [ 1, -2, 1 ], + "alignmentgroup": "top", + "hovertext": "alignmentgroup: top", + "xaxis": "x2", + "yaxis": "y2" + }, + { + "type": "waterfall", + "y": [ 2, -1, 2 ], + "hovertext": "alignmentgroup: top
offsetgroup: 1", + "alignmentgroup": "bottom", + "offsetgroup": "1", + "xaxis": "x2" + }, + { + "type": "waterfall", + "y": [ 1, -3, 0 ], + "hovertext": "alignmentgroup: top
offsetgroup: 2", + "alignmentgroup": "bottom", + "offsetgroup": "2", + "xaxis": "x2" + } + ], + "layout": { + "showlegend": false, + "grid": { + "rows": 2, + "columns": 2, + "roworder": "bottom to top" + }, + "colorway": [ "blue", "orange", "green" ], + "margin": { "t": 20 }, + "xaxis": { + "title": { + "text": "no alignmentgroup
no offsetgroup" + } + }, + "xaxis2": { + "title": { + "text": "with alignmentgroup
with offsetgroup" + } + } + } +} diff --git a/test/image/mocks/waterfall-offsetgroups.json b/test/image/mocks/waterfall-offsetgroups.json new file mode 100644 index 00000000000..66eb8e149ad --- /dev/null +++ b/test/image/mocks/waterfall-offsetgroups.json @@ -0,0 +1,88 @@ +{ + "data": [ + { + "type": "waterfall", + "x": [ "A", "B", "C", "D" ], + "y": [ 1, -2, 3, 4 ], + "offsetgroup": 1, + "hovertext": "offsetgroup: 1" + }, + { + "type": "waterfall", + "x": [ "A", "B", "C", "D" ], + "y": [ 2, -3, 1, 5 ], + "offsetgroup": 2, + "hovertext": "offsetgroup: 2" + }, + { + "type": "waterfall", + "x": [ "A", "B", "C", "D" ], + "y": [ 1, -2, 3, 4 ], + "yaxis": "y2", + "offsetgroup": 1, + "hovertext": "offsetgroup: 1" + }, + { + "type": "waterfall", + "x": [ "A", "B", "C", "D" ], + "y": [ 2, -3, 1, 5 ], + "yaxis": "y2", + "offsetgroup": 2, + "hovertext": "offsetgroup: 2" + }, + { + "type": "waterfall", + "x": [ "A", "B", "C", "D" ], + "y": [ 1, -2, 3, 4 ], + "offsetgroup": 1, + "hovertext": "offsetgroup: 1", + "xaxis": "x2" + }, + { + "type": "waterfall", + "x": [ "A", "B", "C", "D" ], + "y": [ 2, -3, 1, 5 ], + "offsetgroup": 2, + "hovertext": "offsetgroup: 2", + "xaxis": "x2" + }, + { + "type": "waterfall", + "x": [ "A", "B", "C", "D" ], + "y": [ 1, -2, 3, 4 ], + "yaxis": "y2", + "offsetgroup": 3, + "hovertext": "offsetgroup: 3", + "xaxis": "x2" + }, + { + "type": "waterfall", + "x": [ "A", "B", "C", "D" ], + "y": [ 2, -3, 1, 5 ], + "yaxis": "y2", + "offsetgroup": 4, + "hovertext": "offsetgroup: 4", + "xaxis": "x2" + } + ], + "layout": { + "showlegend": false, + "grid": { + "rows": 2, + "columns": 2 + }, + "title": { + "text": "Bar offset groups" + }, + "xaxis": { + "title": { + "text": "two distinct offset groups" + } + }, + "xaxis2": { + "title": { + "text": "four distinct offset groups" + } + } + } +} diff --git a/test/image/mocks/waterfall_11.json b/test/image/mocks/waterfall_11.json new file mode 100644 index 00000000000..4b1137c77ec --- /dev/null +++ b/test/image/mocks/waterfall_11.json @@ -0,0 +1,243 @@ +{ + "data": [ + { + "x": [ + "Half Dose", + "Full Dose", + "Double Dose" + ], + "y": [ + "13.23", + "-22.7", + "26.06" + ], + "name": "Orange Juice", + "type": "waterfall" + }, + { + "x": [ + "Half Dose", + "Full Dose", + "Double Dose" + ], + "y": [ + "7.98", + "-16.77", + "26.14" + ], + "name": "Vitamin C", + "type": "waterfall" + }, + { + "x": [ + "Half Dose", + "Full Dose", + "Double Dose" + ], + "y": [ + "1.4102837", + "-1.236752", + "0.8396031" + ], + "name": "Std Dev - OJ", + "visible": false, + "type": "waterfall" + }, + { + "x": [ + "Half Dose", + "Full Dose", + "Double Dose" + ], + "y": [ + "0.868562", + "-0.7954104", + "1.5171757" + ], + "name": "Std Dev - VC", + "visible": false, + "type": "waterfall" + } + ], + "layout": { + "title": "Grouped Waterfall Chart", + "titlefont": { + "color": "", + "family": "", + "size": 16 + }, + "font": { + "family": "Arial, sans-serif", + "size": 12, + "color": "#000" + }, + "showlegend": true, + "autosize": false, + "width": 600, + "height": 440, + "xaxis": { + "title": "Dose (mg)", + "titlefont": { + "color": "", + "family": "", + "size": 16 + }, + "range": [ + -0.5, + 2.5 + ], + "domain": [ + 0, + 1 + ], + "type": "category", + "rangemode": "normal", + "showgrid": true, + "zeroline": false, + "showline": false, + "autotick": true, + "nticks": 0, + "ticks": "", + "showticklabels": true, + "tick0": 0, + "dtick": 1, + "ticklen": 5, + "tickwidth": 1, + "tickcolor": "#000", + "tickangle": 0, + "tickfont": { + "family": "", + "size": 16, + "color": "" + }, + "exponentformat": "e", + "showexponent": "all", + "gridcolor": "rgb(255, 255, 255)", + "gridwidth": 1.9, + "zerolinecolor": "#000", + "zerolinewidth": 1, + "linecolor": "#000", + "linewidth": 0.1, + "anchor": "y", + "position": 0, + "mirror": true, + "overlaying": false, + "autorange": true + }, + "yaxis": { + "title": "Length", + "titlefont": { + "color": "", + "family": "", + "size": 16 + }, + "range": [ + 0, + 29.11281652631579 + ], + "domain": [ + 0, + 1 + ], + "type": "linear", + "rangemode": "normal", + "showgrid": true, + "zeroline": false, + "showline": false, + "autotick": true, + "nticks": 0, + "ticks": "", + "showticklabels": true, + "tick0": 0, + "dtick": 5, + "ticklen": 5, + "tickwidth": 1, + "tickcolor": "#000", + "tickangle": 0, + "tickfont": { + "family": "", + "size": 16, + "color": "" + }, + "exponentformat": "e", + "showexponent": "all", + "gridcolor": "rgb(255, 255, 255)", + "gridwidth": 1.9, + "zerolinecolor": "#000", + "zerolinewidth": 1, + "linecolor": "#000", + "linewidth": 0.1, + "anchor": "x", + "position": 0, + "mirror": true, + "overlaying": false, + "autorange": true + }, + "legend": { + "x": 1.02, + "y": 0.9319072504424065, + "traceorder": "normal", + "font": { + "family": "", + "size": 16, + "color": "rgb(0, 0, 0)" + }, + "bgcolor": "rgba(255, 255, 255, 0)", + "bordercolor": "rgba(0, 0, 0, 0)", + "borderwidth": 1, + "xanchor": "left", + "yanchor": "auto" + }, + "annotations": [ + { + "x": 1.3479735318444994, + "y": 0.9982142857142856, + "xref": "paper", + "yref": "paper", + "text": "Supplement", + "font": { + "family": "", + "size": 18, + "color": "" + }, + "align": "center", + "showarrow": false, + "arrowhead": 1, + "arrowsize": 1, + "arrowwidth": 0, + "arrowcolor": "", + "ax": -10, + "ay": -26.7109375, + "bordercolor": "", + "borderwidth": 1, + "borderpad": 1, + "bgcolor": "rgba(0,0,0,0)", + "opacity": 1, + "xanchor": "auto", + "yanchor": "auto", + "xatype": "category", + "yatype": "linear", + "tag": "", + "ref": "paper" + } + ], + "margin": { + "l": 80, + "r": 0, + "b": 80, + "t": 80, + "pad": 2, + "autoexpand": true + }, + "paper_bgcolor": "#fff", + "plot_bgcolor": "rgb(217, 217, 217)", + "hovermode": "x", + "dragmode": "zoom", + "waterfallmode": "group", + "waterfallgap": 0.2, + "waterfallgroupgap": 0.1, + "boxmode": "overlay", + "separators": ".,", + "hidesources": false + } +} diff --git a/test/image/mocks/waterfall_and_bar.json b/test/image/mocks/waterfall_and_bar.json new file mode 100644 index 00000000000..012d5e6f8de --- /dev/null +++ b/test/image/mocks/waterfall_and_bar.json @@ -0,0 +1,124 @@ +{ + "data": [ + { + "name": "bar", + "type": "bar", + "orientation": "v", + "opacity": 0.5, + "x": [ + "Sales", + "Consulting", + "Maintenance", + "Other revenue", + "Net revenue", + "Purchases", + "Material expenses", + "Personnel expenses", + "Other expenses", + "Operating profit", + "Investment income", + "Financial income", + "Profit before tax", + "Income tax (15%)", + "Profit after tax" + ], + "y": [ + 375, + 128, + 78, + 27, + null, + -327, + -12, + -78, + -12, + null, + 32, + 89, + null, + -45, + null + ] + }, { + "name": "waterfall", + "type": "waterfall", + "orientation": "v", + "opacity": 0.5, + "measure": [ + "relative", + "relative", + "relative", + "relative", + "total", + "relative", + "relative", + "relative", + "relative", + "total", + "relative", + "relative", + "total", + "relative", + "total" + ], + "x": [ + "Sales", + "Consulting", + "Maintenance", + "Other revenue", + "Net revenue", + "Purchases", + "Material expenses", + "Personnel expenses", + "Other expenses", + "Operating profit", + "Investment income", + "Financial income", + "Profit before tax", + "Income tax (15%)", + "Profit after tax" + ], + "y": [ + 375, + 128, + 78, + 27, + null, + -327, + -12, + -78, + -12, + null, + 32, + 89, + null, + -45, + null + ], + "connector": { + "mode": "spanning", + "line": { + "width": 2, + "color": "rgb(0, 0, 0)", + "dash": 0 + } + } + } + ], + "layout": { + "title": { + "text": "waterfall chart with bar chart" + }, + "xaxis": { + "type": "category" + }, + "yaxis": { + "type": "linear" + }, + "height": 600, + "width": 600, + "showlegend": true, + "bargap": 0, + "waterfallgap": 0.4 + } +} diff --git a/test/image/mocks/waterfall_and_histogram.json b/test/image/mocks/waterfall_and_histogram.json new file mode 100644 index 00000000000..0d30bd458f7 --- /dev/null +++ b/test/image/mocks/waterfall_and_histogram.json @@ -0,0 +1,15 @@ +{ + "data": [ + { "x": [1,1,1,1,1,2,2,2,3,3], "type": "histogram" }, + { "x": [1,2,3], "y":[1,-2,3], "type": "waterfall", "connector": {"mode": "spanning"} }, + { "x": [1,2,3], "y":[1,-2,3], "type": "waterfall", "yaxis":"y2", "connector": {"mode": "spanning"}}, + { "x": [1,1,1,1,1,2,2,2,3,3], "type": "histogram", "yaxis":"y2" } + ], + "layout": { + "yaxis": { "domain": [0,0.49] }, + "yaxis2": { "domain": [0.51, 1] }, + "showlegend": false, + "bargap": 0.4, + "waterfallgap": 0 + } +} diff --git a/test/image/mocks/waterfall_attrs.json b/test/image/mocks/waterfall_attrs.json new file mode 100644 index 00000000000..956e0e39dbe --- /dev/null +++ b/test/image/mocks/waterfall_attrs.json @@ -0,0 +1,58 @@ +{ + "data": [ + { + "width": [1, 0.8, 0.6, 0.4], + "text": [1, -2, 3333333333, 4], + "textposition": "outside", + "y": [1, -2, 3, 4], + "x": [1, 2, 3, 4], + "decreasing": { "marker": { "color": "Blue" } }, + "increasing": { "marker": { "color": "Blue" } }, + "totals": { "marker": { "color": "Blue" } }, + "opacity": 0.5, + "connector": { "visible": false }, + "type": "waterfall" + }, { + "width": [0.4, 0.6, 0.8, 1], + "text": ["Three", 2, "inside text", 0], + "textposition": "auto", + "textfont": { "size": [10]}, + "y": [3, -2, 1, -1], + "x": [1, 2, 3, 4], + "decreasing": { "marker": { "color": "Orange" } }, + "increasing": { "marker": { "color": "Orange" } }, + "totals": { "marker": { "color": "Orange" } }, + "opacity": 0.5, + "connector": { "visible": false }, + "type": "waterfall" + }, { + "width": 1, + "text": [-1, 3, -2, -4], + "textposition": "inside", + "y": [-1, 3, -2, -4], + "x": [1, 2, 3, 4], + "decreasing": { "marker": { "color": "Green" } }, + "increasing": { "marker": { "color": "Green" } }, + "totals": { "marker": { "color": "Green" } }, + "opacity": 0.5, + "connector": { "visible": false }, + "type": "waterfall" + }, { + "text": [2, "outside text", -3, -2], + "textposition": "auto", + "y": [2, 0.25, -3, -2], + "x": [1, 2, 3, 4], + "decreasing": { "marker": { "color": "Red" } }, + "increasing": { "marker": { "color": "Red" } }, + "totals": { "marker": { "color": "Red" } }, + "opacity": 0.5, + "connector": { "visible": false }, + "type": "waterfall" + } + ], + "layout": { + "xaxis": { "showgrid": true }, + "height": 800, + "width": 800 + } +} diff --git a/test/image/mocks/waterfall_cliponaxis-false.json b/test/image/mocks/waterfall_cliponaxis-false.json new file mode 100644 index 00000000000..1df4d99d49e --- /dev/null +++ b/test/image/mocks/waterfall_cliponaxis-false.json @@ -0,0 +1,116 @@ +{ + "data": [ + { + "type": "waterfall", + "name": "not clipped", + "x": ["apple", "banana", "clementine"], + "y": [1.8, 0.2, 0.1], + "text": ["apple", "banana", "x"], + "textposition": "outside", + "cliponaxis": false, + "textfont": { + "size": [60, 40, 20] + }, + "increasing": { "marker": {"color": "blue"} }, + "decreasing": { "marker": {"color": "blue"} } + }, + { + "type": "waterfall", + "name": "same but clipped", + "x": ["apple", "banana", "clementine"], + "y": [1.8, 0.2, 0.1], + "text": ["apple", "banana", "clementine"], + "textposition": "outside", + "cliponaxis": true, + "textfont": { + "size": [60, 40, 20] + }, + "xaxis": "x2", + "yaxis": "y2", + "increasing": { "marker": {"color": "orange"} }, + "decreasing": { "marker": {"color": "orange"} } + }, + { + "type": "waterfall", + "name": "should not see text", + "x": ["banana"], + "y": [2], + "text": ["X"], + "textposition": "outside", + "cliponaxis": true, + "textfont": {"size": [20]}, + "increasing": { "marker": {"color": "green"} }, + "decreasing": { "marker": {"color": "green"} } + }, + { + "type": "waterfall", + "name": "should see text", + "x": ["banana"], + "y": [1], + "text": ["banana"], + "textposition": "outside", + "cliponaxis": false, + "textfont": {"size": [20]}, + "xaxis": "x3", + "yaxis": "y3", + "increasing": { "marker": {"color": "red"} }, + "decreasing": { "marker": {"color": "red"} } + } + ], + "layout": { + "waterfallmode": "overlay", + "legend": { + "x": 0.5, + "xanchor": "center", + "y": -0.05, + "yanchor": "top" + }, + "xaxis": { + "showline": true, + "showticklabels": false, + "mirror": true, + "layer": "below traces", + "domain": [0, 0.38] + }, + "xaxis2": { + "anchor": "y2", + "showline": true, + "showticklabels": false, + "mirror": true, + "layer": "below traces", + "domain": [0.42, 0.80] + }, + "xaxis3": { + "anchor": "y3", + "showline": true, + "showticklabels": false, + "mirror": true, + "layer": "below traces", + "domain": [0.84, 1] + }, + "yaxis": { + "showline": true, + "mirror": true, + "layer": "below traces", + "range": [0, 2] + }, + "yaxis2": { + "anchor": "x2", + "showline": true, + "mirror": true, + "layer": "below traces", + "range": [0, 2] + }, + "yaxis3": { + "anchor": "x3", + "showline": true, + "mirror": true, + "layer": "below traces", + "range": [2, 0] + }, + "width": 800, + "height": 400, + "margin": {"t": 40}, + "dragmode": "pan" + } +} diff --git a/test/image/mocks/waterfall_custom.json b/test/image/mocks/waterfall_custom.json new file mode 100644 index 00000000000..d757a79bced --- /dev/null +++ b/test/image/mocks/waterfall_custom.json @@ -0,0 +1,59 @@ +{ + "data": [ + { + "name": "2018", + "type": "waterfall", + "orientation": "v", + "measure": [ + "relative", + "relative", + "total", + "relative", + "relative", + "total" + ], + "width": [1.0, 0.6, 0.5, 0.7, 0.9, 0.8], + "offset": [0.0, 0.1, 0.2, 0.3, 0.4, 0.5], + "x": [ + "Sales", + "Consulting", + "Net revenue", + "Purchases", + "Other expenses", + "Cash" + ], + "y": [ + 60, + 80, + 0, + -40, + -20, + 0 + ], + "base": 1000, + "connector": { + "mode": "between", + "line": { + "width": 5, + "color": "rgb(63, 63, 63)" + } + } + } + ], + "layout": { + "title": { + "text": "Waterfall chart with custom bar widths and offsets" + }, + "xaxis": { + "type": "category" + }, + "yaxis": { + "type": "linear" + }, + "margin": { "l": 150 }, + "height": 800, + "width": 800, + "autosize": true, + "showlegend": true + } +} diff --git a/test/image/mocks/waterfall_gap0.json b/test/image/mocks/waterfall_gap0.json new file mode 100644 index 00000000000..fbd415994e2 --- /dev/null +++ b/test/image/mocks/waterfall_gap0.json @@ -0,0 +1,40 @@ +{ + "data": [ + { + "x": [ + "giraffes", + "orangutans", + "monkeys" + ], + "y": [ + 20, + 14, + 23 + ], + "name": "SF Zoo", + "type": "waterfall" + } + ], + "layout": { + "waterfallgap": 0, + "xaxis": { + "type": "category", + "range": [ + -0.5, + 2.5 + ], + "autorange": true + }, + "yaxis": { + "type": "linear", + "range": [ + 0, + 24.210526315789473 + ], + "autorange": true + }, + "height": 450, + "width": 1100, + "autosize": false + } +} diff --git a/test/image/mocks/waterfall_line.json b/test/image/mocks/waterfall_line.json new file mode 100644 index 00000000000..b5b5d1f42ef --- /dev/null +++ b/test/image/mocks/waterfall_line.json @@ -0,0 +1,42 @@ +{ + "data": [ + { + "x": [ + 0, + 1, + 2, + 3, + 4, + 5 + ], + "y": [ + 1.5, + 1, + 1.3, + 0.7, + 0.8, + 0.9 + ], + "type": "scatter" + }, + { + "x": [ + 0, + 1, + 2, + 3, + 4, + 5 + ], + "y": [ + 1, + 0.5, + 0.7, + -1.2, + 0.3, + 0.4 + ], + "type": "waterfall" + } + ] +} diff --git a/test/image/mocks/waterfall_months.json b/test/image/mocks/waterfall_months.json new file mode 100644 index 00000000000..c5f3e856486 --- /dev/null +++ b/test/image/mocks/waterfall_months.json @@ -0,0 +1,73 @@ +{ + "data": + [{ + "type": "waterfall", + "connector": { + "mode": "between", + "line": { + "width": 2, + "color": "rgb(0, 0, 0)", + "dash": 0 + } + }, + "decreasing": { "marker": { "color": "rgb(255, 0, 0)" } }, + "increasing": { "marker": { "color": "rgb(0, 255, 0)" } }, + "totals": { "marker": { "color": "rgb(0, 0, 63)" } }, + "measure": ["absolute", "relative", "relative", "relative", "total", "relative", "relative", "relative", "total"], + "x": ["Initial", "Jan-Feb", "Mar-Apr", "May-Jun", "Mid-year", "Jul-Aug", "Sep-Oct", "Nov-Dec", "Full year"], + "y": [25, -1.1, -2.1, -3.1, null, -4.1, -5.1, -6.1, null] + }, { + "type": "waterfall", + "connector": { + "mode": "between", + "line": { + "width": 2, + "color": "rgb(63, 63, 63)", + "dash": 2 + } + }, + "decreasing": { "marker": { "color": "rgb(255, 63, 63)" } }, + "increasing": { "marker": { "color": "rgb(63, 255, 63)" } }, + "totals": { "marker": { "color": "rgb(0, 0, 127)" } }, + "measure": ["absolute", "relative", "relative", "relative", "total", "relative", "relative", "relative", "total"], + "x": ["Initial", "Jan-Feb", "Mar-Apr", "May-Jun", "Mid-year", "Jul-Aug", "Sep-Oct", "Nov-Dec", "Full year"], + "y": [4, 1.2, 2.2, 3.2, null, 4.2, 5.2, 6.2, null] + }, { + "type": "waterfall", + "connector": { + "mode": "between", + "line": { + "width": 4, + "color": "rgb(127, 127, 127)", + "dash": 4 + } + }, + "decreasing": { "marker": { "color": "rgb(255, 127, 127)" } }, + "increasing": { "marker": { "color": "rgb(127, 255, 127)" } }, + "totals": { "marker": { "color": "rgb(0, 0, 191)" } }, + "measure": ["absolute", "relative", "relative", "relative", "total", "relative", "relative", "relative", "total"], + "x": ["Initial", "Jan-Feb", "Mar-Apr", "May-Jun", "Mid-year", "Jul-Aug", "Sep-Oct", "Nov-Dec", "Full year"], + "y": [4, 6.3, 5.3, 4.3, null, 3.3, 2.3, 1.3, null] + }, { + "type": "waterfall", + "connector": { + "mode": "between", + "line": { + "width": 4, + "color": "rgb(191, 191, 191)", + "dash": 6 + } + }, + "decreasing": { "marker": { "color": "rgb(255, 191, 191)" } }, + "increasing": { "marker": { "color": "rgb(191, 255, 191)" } }, + "totals": { "marker": { "color": "rgb(0, 0, 255)" } }, + "measure": ["absolute", "relative", "relative", "relative", "total", "relative", "relative", "relative", "total"], + "x": ["Initial", "Jan-Feb", "Mar-Apr", "May-Jun", "Mid-year", "Jul-Aug", "Sep-Oct", "Nov-Dec", "Full year"], + "y": [25, -6.4, -5.4, -4.4, null, -3.4, -2.4, -1.4, null] + }], + "layout": { + "width": 900, + "height": 450, + "showlegend": true + } +} diff --git a/test/image/mocks/waterfall_multicategory.json b/test/image/mocks/waterfall_multicategory.json new file mode 100644 index 00000000000..675625d766c --- /dev/null +++ b/test/image/mocks/waterfall_multicategory.json @@ -0,0 +1,31 @@ +{ + "data": [ + { + "type": "waterfall", + "x": [ + ["2016", "2017", "2017", "2017", "2017", "2018", "2018", "2018", "2018"], + ["initial", "q1", "q2", "q3", "total", "q1", "q2", "q3", "total" ] + ], + "measure": ["absolute", "relative", "relative", "relative", "total", "relative", "relative", "relative", "total"], + "y": [1, 2, 3, -1, null, 1, 2, -4, null], + "base": 1000 + }, + { + "type": "waterfall", + "x": [ + ["2016", "2017", "2017", "2017", "2017", "2018", "2018", "2018", "2018"], + ["initial", "q1", "q2", "q3", "total", "q1", "q2", "q3", "total" ] + ], + "measure": ["absolute", "relative", "relative", "relative", "total", "relative", "relative", "relative", "total"], + "y": [1.1, 2.2, 3.3, -1.1, null, 1.1, 2.2, -4.4, null], + "base": 1000 + } + ], + "layout": { + "xaxis": { + "title": "MULTI-CATEGORY", + "tickfont": {"size": 16}, + "ticks": "outside" + } + } +} diff --git a/test/image/mocks/waterfall_nonnumeric_sizes.json b/test/image/mocks/waterfall_nonnumeric_sizes.json new file mode 100644 index 00000000000..2d3bd73d34a --- /dev/null +++ b/test/image/mocks/waterfall_nonnumeric_sizes.json @@ -0,0 +1,23 @@ +{ + "data": [ + { + "x": [ + "a", + "b", + "c", + "d", + "e", + "f" + ], + "y": [ + 1, + null, + "nonsense", + 14, + 0, + 1 + ], + "type": "waterfall" + } + ] +} diff --git a/test/image/mocks/waterfall_profit-loss_2018_positive-negative.json b/test/image/mocks/waterfall_profit-loss_2018_positive-negative.json new file mode 100644 index 00000000000..f85fb70a990 --- /dev/null +++ b/test/image/mocks/waterfall_profit-loss_2018_positive-negative.json @@ -0,0 +1,84 @@ +{ + "data": [ + { + "name": "2018", + "type": "waterfall", + "orientation": "h", + "measure": [ + "relative", + "relative", + "relative", + "relative", + "total", + "relative", + "relative", + "relative", + "relative", + "total", + "relative", + "relative", + "total", + "relative", + "total" + ], + "y": [ + "Sales", + "Consulting", + "Maintenance", + "Other revenue", + "Net revenue", + "Purchases", + "Material expenses", + "Personnel expenses", + "Other expenses", + "Operating profit", + "Investment income", + "Financial income", + "Profit before tax", + "Income tax (15%)", + "Profit after tax" + ], + "x": [ + 375, + 128, + 78, + 27, + null, + -327, + -12, + -78, + -12, + null, + 32, + 89, + null, + -45, + null + ], + "connector": { + "mode": "between", + "line": { + "width": 4, + "color": "rgb(0, 0, 0)", + "dash": 0 + } + } + } + ], + "layout": { + "title": { + "text": "Profit and loss statement 2018
waterfall chart displaying positive and negative" + }, + "yaxis": { + "type": "category", + "autorange": "reversed" + }, + "xaxis": { + "type": "linear" + }, + "margin": { "l": 150 }, + "height": 800, + "width": 800, + "showlegend": true + } +} diff --git a/test/image/mocks/waterfall_profit-loss_2018vs2019_overlay.json b/test/image/mocks/waterfall_profit-loss_2018vs2019_overlay.json new file mode 100644 index 00000000000..fff5e83282d --- /dev/null +++ b/test/image/mocks/waterfall_profit-loss_2018vs2019_overlay.json @@ -0,0 +1,157 @@ +{ + "data": [ + { + "name": "2018", + "type": "waterfall", + "orientation": "h", + "measure": [ + "relative", + "relative", + "relative", + "relative", + "relative", + "total", + "relative", + "relative", + "relative", + "relative", + "total", + "relative", + "relative", + "total", + "relative", + "total" + ], + "y": [ + "2018/2019", + "Sales", + "Consulting", + "Maintenance", + "Other revenue", + "Net revenue", + "Purchases", + "Material expenses", + "Personnel expenses", + "Other expenses", + "Operating profit", + "Investment income", + "Financial income", + "Profit before tax", + "Income tax (15%)", + "Profit after tax" + ], + "x": [ + 0, + 375, + 128, + 78, + 27, + null, + -327, + -12, + -78, + -12, + null, + 32, + 89, + null, + -45, + null + ], + "opacity": 0.5, + "connector": { + "mode": "spanning", + "line": { + "width": 2, + "color": "rgb(0, 0, 0)", + "dash": 0 + } + } + }, + { + "name": "2019", + "type": "waterfall", + "orientation": "h", + "measure": [ + "relative", + "relative", + "relative", + "relative", + "relative", + "total", + "relative", + "relative", + "relative", + "relative", + "total", + "relative", + "relative", + "total", + "relative", + "total" + ], + "y": [ + "2018/2019", + "Sales", + "Consulting", + "Maintenance", + "Other revenue", + "Net revenue", + "Purchases", + "Material expenses", + "Personnel expenses", + "Other expenses", + "Operating profit", + "Investment income", + "Financial income", + "Profit before tax", + "Income tax (15%)", + "Profit after tax" + ], + "x": [ + 0, + 307, + 102, + 187, + 172, + null, + -302, + -121, + -187, + -121, + null, + 123, + 198, + null, + -53.7, + null + ], + "opacity": 0.5, + "connector": { + "mode": "spanning", + "line": { + "width": 2, + "color": "rgb(0, 0, 0)", + "dash": 0 + } + } + } + ], + "layout": { + "waterfallmode": "overlay", + "title": { + "text": "Profit and loss statement 2018 vs 2019
overlaid waterfall chart" + }, + "yaxis": { + "type": "category", + "autorange": "reversed" + }, + "xaxis": { + "type": "linear" + }, + "margin": { "l": 150 }, + "height": 800, + "width": 800, + "showlegend": true + } +} diff --git a/test/image/mocks/waterfall_profit-loss_2018vs2019_rectangle.json b/test/image/mocks/waterfall_profit-loss_2018vs2019_rectangle.json new file mode 100644 index 00000000000..a33d59b11f0 --- /dev/null +++ b/test/image/mocks/waterfall_profit-loss_2018vs2019_rectangle.json @@ -0,0 +1,155 @@ +{ + "data": [ + { + "name": "2018", + "type": "waterfall", + "orientation": "h", + "opacity": 0.5, + "measure": [ + "relative", + "relative", + "relative", + "relative", + "relative", + "total", + "relative", + "relative", + "relative", + "relative", + "total", + "relative", + "relative", + "total", + "relative", + "total" + ], + "y": [ + "2018/2019", + "Sales", + "Consulting", + "Maintenance", + "Other revenue", + "Net revenue", + "Purchases", + "Material expenses", + "Personnel expenses", + "Other expenses", + "Operating profit", + "Investment income", + "Financial income", + "Profit before tax", + "Income tax (15%)", + "Profit after tax" + ], + "x": [ + 0, + 375, + 128, + 78, + 27, + null, + -327, + -12, + -78, + -12, + null, + 32, + 89, + null, + -45, + null + ], + "connector": { + "mode": "spanning", + "line": { + "width": 2, + "color": "rgb(0, 0, 0)", + "dash": 0 + } + } + }, + { + "name": "2019", + "type": "waterfall", + "orientation": "h", + "measure": [ + "relative", + "relative", + "relative", + "relative", + "relative", + "total", + "relative", + "relative", + "relative", + "relative", + "total", + "relative", + "relative", + "total", + "relative", + "total" + ], + "y": [ + "2018/2019", + "Sales", + "Consulting", + "Maintenance", + "Other revenue", + "Net revenue", + "Purchases", + "Material expenses", + "Personnel expenses", + "Other expenses", + "Operating profit", + "Investment income", + "Financial income", + "Profit before tax", + "Income tax (15%)", + "Profit after tax" + ], + "x": [ + 0, + 307, + 102, + 187, + 172, + null, + -302, + -121, + -187, + -121, + null, + 123, + 198, + null, + -53.7, + null + ], + "connector": { + "mode": "spanning", + "line": { + "width": 2, + "color": "rgb(0, 0, 0)", + "dash": 0 + } + } + } + ], + "layout": { + "title": { + "text": "Profit and loss statement 2018 vs 2019
waterfall chart" + }, + "yaxis": { + "type": "category", + "autorange": "reversed" + }, + "xaxis": { + "type": "linear" + }, + "margin": { "l": 150 }, + "height": 800, + "width": 800, + "showlegend": true + } +} diff --git a/test/jasmine/assets/mock_lists.js b/test/jasmine/assets/mock_lists.js index a52e0c7327b..01e4188f639 100644 --- a/test/jasmine/assets/mock_lists.js +++ b/test/jasmine/assets/mock_lists.js @@ -14,6 +14,7 @@ var svgMockList = [ ['axes_enumerated_ticks', require('@mocks/axes_enumerated_ticks.json')], ['axes_visible-false', require('@mocks/axes_visible-false.json')], ['bar_and_histogram', require('@mocks/bar_and_histogram.json')], + ['waterfall', require('@mocks/waterfall_profit-loss_2018vs2019_rectangle.json')], ['basic_error_bar', require('@mocks/basic_error_bar.json')], ['binding', require('@mocks/binding.json')], ['cheater_smooth', require('@mocks/cheater_smooth.json')], diff --git a/test/jasmine/tests/select_test.js b/test/jasmine/tests/select_test.js index 5f788d6d80a..4da03f92dda 100644 --- a/test/jasmine/tests/select_test.js +++ b/test/jasmine/tests/select_test.js @@ -2121,6 +2121,74 @@ describe('Test select box and lasso per trace:', function() { .then(done); }, LONG_TIMEOUT_INTERVAL); + it('@noCI @flaky should work for waterfall traces', function(done) { + var assertPoints = makeAssertPoints(['curveNumber', 'x', 'y']); + var assertSelectedPoints = makeAssertSelectedPoints(); + var assertRanges = makeAssertRanges(); + var assertLassoPoints = makeAssertLassoPoints(); + + var fig = Lib.extendDeep({}, require('@mocks/waterfall_profit-loss_2018_positive-negative')); + fig.layout.dragmode = 'lasso'; + addInvisible(fig); + + Plotly.plot(gd, fig) + .then(function() { + return _run( + [[400, 300], [200, 400], [400, 500], [600, 400], [500, 350]], + function() { + assertPoints([ + [0, 281, 'Purchases'], + [0, 269, 'Material expenses'], + [0, 191, 'Personnel expenses'], + [0, 179, 'Other expenses'] + ]); + assertSelectedPoints({ + 0: [5, 6, 7, 8] + }); + assertLassoPoints([ + [289.8550724637681, 57.97101449275362, 289.8550724637681, 521.7391304347826, 405.7971014492753], + ['Net revenue', 'Personnel expenses', 'Operating profit', 'Personnel expenses', 'Material expenses'] + ]); + }, + null, LASSOEVENTS, 'waterfall lasso' + ); + }) + .then(function() { + return Plotly.relayout(gd, 'dragmode', 'select'); + }) + .then(function() { + // For some reason we need this to make the following tests pass + // on CI consistently. It appears that a double-click action + // is being confused with a mere click. See + // https://github.com/plotly/plotly.js/pull/2135#discussion_r148897529 + // for more info. + return new Promise(function(resolve) { + setTimeout(resolve, 100); + }); + }) + .then(function() { + return _run( + [[300, 300], [400, 400]], + function() { + assertPoints([ + [0, 281, 'Purchases', 269], + [0, 269, 'Material expenses', 269] + ]); + assertSelectedPoints({ + 0: [5, 6] + }); + assertRanges([ + [173.91304347826087, 289.8550724637681], + ['Net revenue', 'Personnel expenses'] + ]); + }, + null, BOXEVENTS, 'waterfall select' + ); + }) + .catch(failTest) + .then(done); + }); + it('@flaky should work for bar traces', function(done) { var assertPoints = makeAssertPoints(['curveNumber', 'x', 'y']); var assertSelectedPoints = makeAssertSelectedPoints(); diff --git a/test/jasmine/tests/shapes_test.js b/test/jasmine/tests/shapes_test.js index 0c0ef5d0956..d31b20a10d9 100644 --- a/test/jasmine/tests/shapes_test.js +++ b/test/jasmine/tests/shapes_test.js @@ -1037,7 +1037,7 @@ describe('A fixed size shape', function() { }); }); - it('being sized relative to data vertically is getting lower ' + + it('@flaky being sized relative to data vertically is getting lower ' + 'when being dragged to expand the y-axis', function(done) { layout.shapes[0].ysizemode = 'data'; diff --git a/test/jasmine/tests/waterfall_test.js b/test/jasmine/tests/waterfall_test.js new file mode 100644 index 00000000000..db2e748bf95 --- /dev/null +++ b/test/jasmine/tests/waterfall_test.js @@ -0,0 +1,1459 @@ +var Plotly = require('@lib/index'); + +var Waterfall = require('@src/traces/waterfall'); +var Lib = require('@src/lib'); +var Plots = require('@src/plots/plots'); +var Drawing = require('@src/components/drawing'); + +var Axes = require('@src/plots/cartesian/axes'); + +var createGraphDiv = require('../assets/create_graph_div'); +var destroyGraphDiv = require('../assets/destroy_graph_div'); +var failTest = require('../assets/fail_test'); +var supplyAllDefaults = require('../assets/supply_defaults'); +var color = require('../../../src/components/color'); +var rgb = color.rgb; + +var customAssertions = require('../assets/custom_assertions'); +var assertHoverLabelContent = customAssertions.assertHoverLabelContent; +var Fx = require('@src/components/fx'); + +var d3 = require('d3'); + +var WATERFALL_TEXT_SELECTOR = '.bars .bartext'; + +describe('Waterfall.supplyDefaults', function() { + 'use strict'; + + var traceIn, + traceOut; + + var defaultColor = '#444'; + + var supplyDefaults = Waterfall.supplyDefaults; + + beforeEach(function() { + traceOut = {}; + }); + + it('should set visible to false when x and y are empty', function() { + traceIn = {}; + supplyDefaults(traceIn, traceOut, defaultColor, {}); + expect(traceOut.visible).toBe(false); + + traceIn = { + x: [], + y: [] + }; + supplyDefaults(traceIn, traceOut, defaultColor, {}); + expect(traceOut.visible).toBe(false); + }); + + it('should set visible to false when x or y is empty', function() { + traceIn = { + x: [] + }; + supplyDefaults(traceIn, traceOut, defaultColor, {}); + expect(traceOut.visible).toBe(false); + + traceIn = { + x: [], + y: [1, 2, 3] + }; + supplyDefaults(traceIn, traceOut, defaultColor, {}); + expect(traceOut.visible).toBe(false); + + traceIn = { + y: [] + }; + supplyDefaults(traceIn, traceOut, defaultColor, {}); + expect(traceOut.visible).toBe(false); + + traceIn = { + x: [1, 2, 3], + y: [] + }; + supplyDefaults(traceIn, traceOut, defaultColor, {}); + expect(traceOut.visible).toBe(false); + }); + + [{letter: 'y', counter: 'x'}, {letter: 'x', counter: 'y'}].forEach(function(spec) { + var l = spec.letter; + var c = spec.counter; + var c0 = c + '0'; + var dc = 'd' + c; + it('should be visible using ' + c0 + '/' + dc + ' if ' + c + ' is missing completely but ' + l + ' is present', function() { + traceIn = {}; + traceIn[l] = [1, 2]; + supplyDefaults(traceIn, traceOut, defaultColor, {}); + expect(traceOut.visible).toBe(undefined, l); // visible: true gets set above the module level + expect(traceOut._length).toBe(2, l); + expect(traceOut[c0]).toBe(0, c0); + expect(traceOut[dc]).toBe(1, dc); + expect(traceOut.orientation).toBe(l === 'x' ? 'h' : 'v', l); + }); + }); + + it('should not set base, offset or width', function() { + traceIn = { + y: [1, 2, 3] + }; + supplyDefaults(traceIn, traceOut, defaultColor, {}); + expect(traceOut.base).toBeUndefined(); + expect(traceOut.offset).toBeUndefined(); + expect(traceOut.width).toBeUndefined(); + }); + + it('should coerce a non-negative width', function() { + traceIn = { + width: -1, + y: [1, 2, 3] + }; + supplyDefaults(traceIn, traceOut, defaultColor, {}); + expect(traceOut.width).toBeUndefined(); + }); + + it('should coerce textposition to none', function() { + traceIn = { + y: [1, 2, 3] + }; + supplyDefaults(traceIn, traceOut, defaultColor, {}); + expect(traceOut.textposition).toBe('none'); + expect(traceOut.texfont).toBeUndefined(); + expect(traceOut.insidetexfont).toBeUndefined(); + expect(traceOut.outsidetexfont).toBeUndefined(); + expect(traceOut.constraintext).toBeUndefined(); + }); + + it('should default textfont to layout.font except for insidetextfont.color', function() { + traceIn = { + textposition: 'inside', + y: [1, 2, 3] + }; + var layout = { + font: {family: 'arial', color: '#AAA', size: 13} + }; + var layoutFontMinusColor = {family: 'arial', size: 13}; + + supplyDefaults(traceIn, traceOut, defaultColor, layout); + + expect(traceOut.textposition).toBe('inside'); + expect(traceOut.textfont).toEqual(layout.font); + expect(traceOut.textfont).not.toBe(layout.font); + expect(traceOut.insidetextfont).toEqual(layoutFontMinusColor); + expect(traceOut.insidetextfont).not.toBe(layout.font); + expect(traceOut.insidetextfont).not.toBe(traceOut.textfont); + expect(traceOut.outsidetexfont).toBeUndefined(); + expect(traceOut.constraintext).toBe('both'); + }); + + it('should not default insidetextfont.color to layout.font.color', function() { + traceIn = { + textposition: 'inside', + y: [1, 2, 3] + }; + var layout = { + font: {family: 'arial', color: '#AAA', size: 13} + }; + + supplyDefaults(traceIn, traceOut, defaultColor, layout); + + expect(traceOut.insidetextfont.family).toBe('arial'); + expect(traceOut.insidetextfont.color).toBeUndefined(); + expect(traceOut.insidetextfont.size).toBe(13); + }); + + it('should default insidetextfont.color to textfont.color', function() { + traceIn = { + textposition: 'inside', + y: [1, 2, 3], + textfont: {family: 'arial', color: '#09F', size: 20} + }; + + supplyDefaults(traceIn, traceOut, defaultColor, {}); + + expect(traceOut.insidetextfont.family).toBe('arial'); + expect(traceOut.insidetextfont.color).toBe('#09F'); + expect(traceOut.insidetextfont.size).toBe(20); + }); + + it('should inherit layout.calendar', function() { + traceIn = { + x: [1, 2, 3], + y: [1, 2, 3] + }; + supplyDefaults(traceIn, traceOut, defaultColor, {calendar: 'islamic'}); + + // we always fill calendar attributes, because it's hard to tell if + // we're on a date axis at this point. + expect(traceOut.xcalendar).toBe('islamic'); + expect(traceOut.ycalendar).toBe('islamic'); + }); + + it('should take its own calendars', function() { + traceIn = { + x: [1, 2, 3], + y: [1, 2, 3], + xcalendar: 'coptic', + ycalendar: 'ethiopian' + }; + supplyDefaults(traceIn, traceOut, defaultColor, {calendar: 'islamic'}); + + expect(traceOut.xcalendar).toBe('coptic'); + expect(traceOut.ycalendar).toBe('ethiopian'); + }); +}); + +describe('waterfall calc / crossTraceCalc', function() { + 'use strict'; + + it('should fill in calc pt fields (overlay case)', function() { + var gd = mockWaterfallPlot([{ + y: [2, 1, 2] + }, { + y: [3, 1, null, 2, null], + measure: ['absolute', 'relative', 'total', 'relative', 'total'] + }], { + waterfallmode: 'overlay' + }); + + var cd = gd.calcdata; + assertPointField(cd, 'w', [[0.8, 0.8, 0.8], [0.8, 0.8, 0.8, 0.8, 0.8]]); + assertPointField(cd, 'x', [[0, 1, 2], [0, 1, 2, 3, 4]]); + assertPointField(cd, 'y', [[2, 3, 5], [3, 4, 4, 6, 6]]); + assertPointField(cd, 'b', [[0, 0, 0], [0, 0, 0, 0, 0]]); + assertPointField(cd, 's', [[2, 3, 5], [3, 4, 4, 6, 6]]); + assertPointField(cd, 'p', [[0, 1, 2], [0, 1, 2, 3, 4]]); + assertPointField(cd, 'p0', [[-0.4, 0.6, 1.6], [-0.4, 0.6, 1.6, 2.6, 3.6]]); + assertPointField(cd, 'p1', [[0.4, 1.4, 2.4], [0.4, 1.4, 2.4, 3.4, 4.4]]); + assertPointField(cd, 's0', [[0, 2, 3], [0, 3, 0, 4, 0]]); + assertPointField(cd, 's1', [[2, 3, 5], [3, 4, 4, 6, 6]]); + assertPointField(cd, 'isSum', [[false, false, false], [true, false, true, false, true]]); + assertPointField(cd, 'rawS', [[2, 1, 2], [3, 1, 0, 2, 0]]); + assertPointField(cd, 'dir', [['increasing', 'increasing', 'increasing'], ['totals', 'increasing', 'totals', 'increasing', 'totals']]); + assertPointField(cd, 'cNext', [[true, true, false], [true, true, true, true, false]]); + assertPointField(cd, 'hasTotals', [[false, undefined, undefined], [true, undefined, undefined, undefined, undefined]]); + assertTraceField(cd, 't.barwidth', [0.8, 0.8]); + assertTraceField(cd, 't.poffset', [-0.4, -0.4]); + assertTraceField(cd, 't.bargroupwidth', [0.8, 0.8]); + }); + + it('should fill in calc pt fields (group case)', function() { + var gd = mockWaterfallPlot([{ + y: [2, 1, 2] + }, { + y: [3, 1, null, 2, null], + measure: ['absolute', null, 'total', null, 'total'] + }], { + waterfallmode: 'group', + // asumming default waterfallgap is 0.2 + waterfallgroupgap: 0.1 + }); + + var cd = gd.calcdata; + assertPointField(cd, 'w', [[0.36, 0.36, 0.36], [0.36, 0.36, 0.36, 0.36, 0.36]]); + assertPointField(cd, 'x', [[-0.2, 0.8, 1.8], [0.2, 1.2, 2.2, 3.2, 4.2]]); + assertPointField(cd, 'y', [[2, 3, 5], [3, 4, 4, 6, 6]]); + assertPointField(cd, 'b', [[0, 0, 0], [0, 0, 0, 0, 0]]); + assertPointField(cd, 's', [[2, 3, 5], [3, 4, 4, 6, 6]]); + assertPointField(cd, 'p', [[0, 1, 2], [0, 1, 2, 3, 4]]); + assertPointField(cd, 'p0', [[-0.38, 0.62, 1.62], [0.02, 1.02, 2.02, 3.02, 4.02]]); + assertPointField(cd, 'p1', [[-0.02, 0.98, 1.98], [0.38, 1.38, 2.38, 3.38, 4.38]]); + assertPointField(cd, 's0', [[0, 2, 3], [0, 3, 0, 4, 0]]); + assertPointField(cd, 's1', [[2, 3, 5], [3, 4, 4, 6, 6]]); + assertPointField(cd, 'isSum', [[false, false, false], [true, false, true, false, true]]); + assertPointField(cd, 'rawS', [[2, 1, 2], [3, 1, 0, 2, 0]]); + assertPointField(cd, 'dir', [['increasing', 'increasing', 'increasing'], ['totals', 'increasing', 'totals', 'increasing', 'totals']]); + assertPointField(cd, 'cNext', [[true, true, false], [true, true, true, true, false]]); + assertPointField(cd, 'hasTotals', [[false, undefined, undefined], [true, undefined, undefined, undefined, undefined]]); + assertTraceField(cd, 't.barwidth', [0.36, 0.36]); + assertTraceField(cd, 't.poffset', [-0.38, 0.02]); + assertTraceField(cd, 't.bargroupwidth', [0.8, 0.8]); + }); +}); + +describe('Waterfall.calc', function() { + 'use strict'; + + it('should not exclude items with non-numeric x from calcdata (vertical case)', function() { + var gd = mockWaterfallPlot([{ + x: [5, NaN, 15, 20, null, 21], + orientation: 'v', + }]); + + var cd = gd.calcdata; + assertPointField(cd, 'x', [[5, NaN, 15, 20, NaN, 21]]); + }); + + it('should not exclude items with non-numeric y from calcdata (horizontal case)', function() { + var gd = mockWaterfallPlot([{ + orientation: 'h', + y: [20, NaN, 23, 25, null, 26] + }]); + + var cd = gd.calcdata; + assertPointField(cd, 'y', [[20, NaN, 23, 25, NaN, 26]]); + }); + + it('should not exclude items with non-numeric y from calcdata (to plots gaps correctly)', function() { + var gd = mockWaterfallPlot([{ + x: ['a', 'b', 'c', 'd'], + y: [1, null, 'nonsense', 15] + }]); + + var cd = gd.calcdata; + assertPointField(cd, 'x', [[0, 1, 2, 3]]); + assertPointField(cd, 'y', [[1, 1, 1, 16]]); + }); + + it('should not exclude items with non-numeric x from calcdata (to plots gaps correctly)', function() { + var gd = mockWaterfallPlot([{ + x: [1, null, 'nonsense', 15], + y: [1, 2, 10, 30] + }]); + + var cd = gd.calcdata; + assertPointField(cd, 'x', [[1, NaN, NaN, 15]]); + assertPointField(cd, 'y', [[1, 3, 13, 43]]); + }); +}); + +describe('Waterfall.crossTraceCalc', function() { + 'use strict'; + + it('should guard against invalid offset items', function() { + var gd = mockWaterfallPlot([{ + offset: [null, 0, 1], + y: [1, 2, 3] + }, { + offset: [null, 1], + y: [1, 2, 3] + }, { + offset: null, + y: [1] + }], { + waterfallgap: 0.2, + waterfallmode: 'overlay' + }); + + var cd = gd.calcdata; + assertArrayField(cd[0][0], 't.poffset', [-0.4, 0, 1]); + assertArrayField(cd[1][0], 't.poffset', [-0.4, 1, -0.4]); + assertArrayField(cd[2][0], 't.poffset', [-0.4]); + }); + + it('should work with *width* typed arrays', function() { + var w = [0.1, 0.4, 0.7]; + + var gd = mockWaterfallPlot([{ + width: w, + y: [1, 2, 3] + }, { + width: new Float32Array(w), + y: [1, 2, 3] + }]); + + var cd = gd.calcdata; + assertArrayField(cd[0][0], 't.barwidth', w); + assertArrayField(cd[1][0], 't.barwidth', w); + assertPointField(cd, 'x', [ + [-0.2, 0.8, 1.8], + [0.2, 1.2, 2.2] + ]); + }); + + it('should work with *offset* typed arrays', function() { + var o = [0.1, 0.4, 0.7]; + + var gd = mockWaterfallPlot([{ + offset: o, + y: [1, 2, 3] + }, { + offset: new Float32Array(o), + y: [1, 2, 3] + }]); + + var cd = gd.calcdata; + assertArrayField(cd[0][0], 't.poffset', o); + assertArrayField(cd[1][0], 't.poffset', o); + assertPointField(cd, 'x', [ + [0.5, 1.8, 3.1], + [0.5, 1.8, 3.099] + ]); + }); + + it('should guard against invalid width items', function() { + var gd = mockWaterfallPlot([{ + width: [null, 1, 0.8], + y: [1, 2, 3] + }, { + width: [null, 1], + y: [1, 2, 3] + }, { + width: null, + y: [1] + }], { + waterfallgap: 0.2, + waterfallmode: 'overlay' + }); + + var cd = gd.calcdata; + assertArrayField(cd[0][0], 't.barwidth', [0.8, 1, 0.8]); + assertArrayField(cd[1][0], 't.barwidth', [0.8, 1, 0.8]); + assertArrayField(cd[2][0], 't.barwidth', [0.8]); + }); + + it('should guard against invalid width items (group case)', function() { + var gd = mockWaterfallPlot([{ + width: [null, 0.1, 0.2], + y: [1, 2, 3] + }, { + width: [null, 0.1], + y: [1, 2, 3] + }, { + width: null, + y: [1] + }], { + waterfallgap: 0, + waterfallmode: 'group' + }); + + var cd = gd.calcdata; + assertArrayField(cd[0][0], 't.barwidth', [0.33, 0.1, 0.2]); + assertArrayField(cd[1][0], 't.barwidth', [0.33, 0.1, 0.33]); + assertArrayField(cd[2][0], 't.barwidth', [0.33]); + }); + + it('should not group traces that set offset', function() { + var gd = mockWaterfallPlot([{ + y: [1, 2, 3] + }, { + y: [10, 20, 30] + }, { + offset: -1, + y: [-1, -2, -3] + }], { + waterfallgap: 0, + waterfallmode: 'group' + }); + + var cd = gd.calcdata; + assertPointField(cd, 'b', [[0, 0, 0], [0, 0, 0], [0, 0, 0]]); + assertPointField(cd, 's', [[1, 3, 6], [10, 30, 60], [-1, -3, -6]]); + assertPointField(cd, 'x', [[-0.25, 0.75, 1.75], [0.25, 1.25, 2.25], [-0.5, 0.5, 1.5]]); + assertPointField(cd, 'y', [[1, 3, 6], [10, 30, 60], [-1, -3, -6]]); + }); + + it('should draw traces separately in overlay mode', function() { + var gd = mockWaterfallPlot([{ + y: [1, 2, 3] + }, { + y: [10, 20, 30] + }], { + waterfallgap: 0, + waterfallmode: 'overlay' + }); + + var cd = gd.calcdata; + assertPointField(cd, 'b', [[0, 0, 0], [0, 0, 0]]); + assertPointField(cd, 's', [[1, 3, 6], [10, 30, 60]]); + assertPointField(cd, 'x', [[0, 1, 2], [0, 1, 2]]); + assertPointField(cd, 'y', [[1, 3, 6], [10, 30, 60]]); + }); + + it('should expand position axis', function() { + var gd = mockWaterfallPlot([{ + offset: 10, + width: 2, + y: [1.5, 1, 0.5] + }, { + offset: -5, + width: 2, + y: [-0.5, -1, -1.5] + }], { + waterfallgap: 0, + waterfallmode: 'overlay' + }); + + var xa = gd._fullLayout.xaxis; + var ya = gd._fullLayout.yaxis; + expect(Axes.getAutoRange(gd, xa)).toBeCloseToArray([-5, 14], undefined, '(xa.range)'); + expect(Axes.getAutoRange(gd, ya)).toBeCloseToArray([-3.33, 3.33], undefined, '(ya.range)'); + }); + + it('should expand size axis (overlay case)', function() { + var gd = mockWaterfallPlot([{ + base: 7, + y: [3, 2, 1] + }, { + base: 2, + y: [1, 2, 3] + }, { + base: -2, + y: [-3, -2, -1] + }, { + base: -7, + y: [-1, -2, -3] + }], { + waterfallgap: 0, + waterfallmode: 'overlay' + }); + + var xa = gd._fullLayout.xaxis; + var ya = gd._fullLayout.yaxis; + expect(Axes.getAutoRange(gd, xa)).toBeCloseToArray([-0.5, 2.5], undefined, '(xa.range)'); + expect(Axes.getAutoRange(gd, ya)).toBeCloseToArray([-14.44, 14.44], undefined, '(ya.range)'); + }); + + it('should include explicit base in size axis range', function() { + var waterfallmodes = ['group', 'overlay']; + waterfallmodes.forEach(function(waterfallmode) { + var gd = mockWaterfallPlot([ + {y: [3, 4, -5], base: 10} + ], { + waterfallmode: waterfallmode + }); + + var ya = gd._fullLayout.yaxis; + expect(Axes.getAutoRange(gd, ya)).toBeCloseToArray([9.611, 17.388]); + }); + }); + + it('works with log axes (grouped waterfalls)', function() { + var gd = mockWaterfallPlot([ + {y: [1, 10, 1e10, -1]}, + {y: [2, 20, 2e10, -2]} + ], { + yaxis: {type: 'log'}, + waterfallmode: 'group' + }); + + var ya = gd._fullLayout.yaxis; + expect(Axes.getAutoRange(gd, ya)).toBeCloseToArray([-0.572, 10.873], undefined, '(ya.range)'); + }); + + it('should ignore *base* on category axes', function() { + var gd = mockWaterfallPlot([ + {x: ['a', 'b', 'c'], base: 10}, + ]); + + expect(gd._fullLayout.xaxis.type).toBe('category'); + assertPointField(gd.calcdata, 'b', [[0, 0, 0]]); + }); + + it('should ignore *base* on multicategory axes', function() { + var gd = mockWaterfallPlot([ + {x: [['a', 'a', 'b', 'b'], ['1', '2', '1', '2']], base: 10} + ]); + + expect(gd._fullLayout.xaxis.type).toBe('multicategory'); + assertPointField(gd.calcdata, 'b', [[0, 0, 0, 0]]); + }); +}); + +describe('A waterfall plot', function() { + 'use strict'; + + var DARK = '#444'; + var LIGHT = '#fff'; + + var gd; + + beforeEach(function() { + gd = createGraphDiv(); + }); + + afterEach(destroyGraphDiv); + + function getAllTraceNodes(node) { + return node.querySelectorAll('g.points'); + } + + function getAllWaterfallNodes(node) { + return node.querySelectorAll('g.point'); + } + + function assertTextIsInsidePath(textNode, pathNode) { + var textBB = textNode.getBoundingClientRect(); + var pathBB = pathNode.getBoundingClientRect(); + + expect(pathBB.left).not.toBeGreaterThan(textBB.left); + expect(textBB.right).not.toBeGreaterThan(pathBB.right); + expect(pathBB.top).not.toBeGreaterThan(textBB.top); + expect(textBB.bottom).not.toBeGreaterThan(pathBB.bottom); + } + + function assertTextIsAbovePath(textNode, pathNode) { + var textBB = textNode.getBoundingClientRect(); + var pathBB = pathNode.getBoundingClientRect(); + + expect(textBB.bottom).not.toBeGreaterThan(pathBB.top); + } + + function assertTextIsBelowPath(textNode, pathNode) { + var textBB = textNode.getBoundingClientRect(); + var pathBB = pathNode.getBoundingClientRect(); + + expect(pathBB.bottom).not.toBeGreaterThan(textBB.top); + } + + function assertTextIsAfterPath(textNode, pathNode) { + var textBB = textNode.getBoundingClientRect(); + var pathBB = pathNode.getBoundingClientRect(); + + expect(pathBB.right).not.toBeGreaterThan(textBB.left); + } + + function assertTextFont(textNode, expectedFontProps, index) { + expect(textNode.style.fontFamily).toBe(expectedFontProps.family[index]); + expect(textNode.style.fontSize).toBe(expectedFontProps.size[index] + 'px'); + + var actualColorRGB = textNode.style.fill; + var expectedColorRGB = rgb(expectedFontProps.color[index]); + expect(actualColorRGB).toBe(expectedColorRGB); + } + + function assertTextIsBeforePath(textNode, pathNode) { + var textBB = textNode.getBoundingClientRect(); + var pathBB = pathNode.getBoundingClientRect(); + + expect(textBB.right).not.toBeGreaterThan(pathBB.left); + } + + function assertTextFontColors(expFontColors, label) { + return function() { + var selection = d3.selectAll(WATERFALL_TEXT_SELECTOR); + expect(selection.size()).toBe(expFontColors.length); + + selection.each(function(d, i) { + var expFontColor = expFontColors[i]; + var isArray = Array.isArray(expFontColor); + + expect(this.style.fill).toBe(isArray ? rgb(expFontColor[0]) : rgb(expFontColor), + (label || '') + ', fill for element ' + i); + expect(this.style.fillOpacity).toBe(isArray ? expFontColor[1] : '1', + (label || '') + ', fillOpacity for element ' + i); + }); + }; + } + + it('should show texts (inside case)', function(done) { + var data = [{ + y: [10, 20, 30], + type: 'waterfall', + text: ['1', 'Very very very very very long text'], + textposition: 'inside', + }]; + var layout = {}; + + Plotly.plot(gd, data, layout).then(function() { + var traceNodes = getAllTraceNodes(gd); + var waterfallNodes = getAllWaterfallNodes(traceNodes[0]); + var foundTextNodes; + + for(var i = 0; i < waterfallNodes.length; i++) { + var waterfallNode = waterfallNodes[i]; + var pathNode = waterfallNode.querySelector('path'); + var textNode = waterfallNode.querySelector('text'); + if(textNode) { + foundTextNodes = true; + assertTextIsInsidePath(textNode, pathNode); + } + } + + expect(foundTextNodes).toBe(true); + }) + .catch(failTest) + .then(done); + }); + + it('should show texts (horizontal case)', function(done) { + var data = [{ + x: [10, -20, 30], + type: 'waterfall', + text: ['Very very very very very long text', -20], + textposition: 'outside', + }]; + var layout = {}; + + Plotly.plot(gd, data, layout).then(function() { + var traceNodes = getAllTraceNodes(gd); + var waterfallNodes = getAllWaterfallNodes(traceNodes[0]); + var foundTextNodes; + + for(var i = 0; i < waterfallNodes.length; i++) { + var waterfallNode = waterfallNodes[i]; + var pathNode = waterfallNode.querySelector('path'); + var textNode = waterfallNode.querySelector('text'); + if(textNode) { + foundTextNodes = true; + if(data[0].x[i] > 0) assertTextIsAfterPath(textNode, pathNode); + else assertTextIsBeforePath(textNode, pathNode); + } + } + + expect(foundTextNodes).toBe(true); + }) + .catch(failTest) + .then(done); + }); + + var insideTextTestsTrace = { + x: ['giraffes', 'orangutans', 'monkeys', 'elefants', 'spiders', 'snakes'], + y: [20, 14, 23, 10, 59, 15], + text: [20, 14, 23, 10, 59, 15], + type: 'waterfall', + textposition: 'auto', + marker: { + color: ['#ee1', '#eee', '#333', '#9467bd', '#dda', '#922'], + } + }; + + it('should take fill opacities into account when calculating contrasting inside text colors', function(done) { + var trace = { + x: [5, 10], + y: [5, -15], + text: ['Giraffes', 'Zebras'], + type: 'waterfall', + textposition: 'inside', + increasing: { marker: { color: 'rgba(0, 0, 0, 0.2)' } }, + decreasing: { marker: { color: 'rgba(0, 0, 0, 0.8)' } } + }; + + Plotly.plot(gd, [trace]) + .then(assertTextFontColors([DARK, LIGHT])) + .catch(failTest) + .then(done); + }); + + it('should use defined textfont.color for inside text instead of the contrasting default', function(done) { + var data = Lib.extendFlat({}, insideTextTestsTrace, { textfont: { color: '#09f' } }); + + Plotly.plot(gd, [data]) + .then(assertTextFontColors(Lib.repeat('#09f', 6))) + .catch(failTest) + .then(done); + }); + + it('should be able to restyle', function(done) { + var mock = { + data: [ + { + width: [1, 0.8, 0.6, 0.4], + text: [1, 2, 3333333333, 4], + textposition: 'outside', + y: [1, 2, 3, 4], + x: [1, 2, 3, 4], + type: 'waterfall' + }, { + width: [0.4, 0.6, 0.8, 1], + text: ['Three', 2, 'inside text', 0], + textposition: 'auto', + textfont: { size: [10] }, + y: [3, 2, 1, 0], + x: [1, 2, 3, 4], + type: 'waterfall' + }, { + width: 1, + text: [-1, -3, -2, -4], + textposition: 'inside', + y: [-1, -3, -2, -4], + x: [1, 2, 3, 4], + type: 'waterfall' + }, { + text: [0, 'outside text', -3, -2], + textposition: 'auto', + y: [0, -0.25, -3, -2], + x: [1, 2, 3, 4], + type: 'waterfall' + } + ], + layout: { + xaxis: { showgrid: true }, + yaxis: { range: [-6, 6] }, + height: 400, + width: 400, + waterfallmode: 'overlay' + } + }; + + Plotly.plot(gd, mock.data, mock.layout).then(function() { + var cd = gd.calcdata; + assertPointField(cd, 'x', [ + [1, 2, 3, 4], [1, 2, 3, 4], + [1, 2, 3, 4], [1, 2, 3, 4]]); + assertPointField(cd, 'y', [ + [1, 3, 6, 10], [3, 5, 6, 6], + [-1, -4, -6, -10], [0, -0.25, -3.25, -5.25]]); + assertPointField(cd, 's', [ + [1, 3, 6, 10], [3, 5, 6, 6], + [-1, -4, -6, -10], [0, -0.25, -3.25, -5.25]]); + assertPointField(cd, 'p', [ + [1, 2, 3, 4], [1, 2, 3, 4], + [1, 2, 3, 4], [1, 2, 3, 4]]); + assertArrayField(cd[0][0], 't.barwidth', [1, 0.8, 0.6, 0.4]); + assertArrayField(cd[1][0], 't.barwidth', [0.4, 0.6, 0.8, 1]); + expect(cd[2][0].t.barwidth).toBe(1); + expect(cd[3][0].t.barwidth).toBe(0.8); + assertArrayField(cd[0][0], 't.poffset', [-0.5, -0.4, -0.3, -0.2]); + assertArrayField(cd[1][0], 't.poffset', [-0.2, -0.3, -0.4, -0.5]); + expect(cd[2][0].t.poffset).toBe(-0.5); + expect(cd[3][0].t.poffset).toBe(-0.4); + assertTraceField(cd, 't.bargroupwidth', [0.8, 0.8, 0.8, 0.8]); + + return Plotly.restyle(gd, 'offset', 0); + }).then(function() { + var cd = gd.calcdata; + assertPointField(cd, 'x', [ + [1.5, 2.4, 3.3, 4.2], [1.2, 2.3, 3.4, 4.5], + [1.5, 2.5, 3.5, 4.5], [1.4, 2.4, 3.4, 4.4]]); + assertPointField(cd, 'y', [ + [1, 3, 6, 10], [3, 5, 6, 6], + [-1, -4, -6, -10], [0, -0.25, -3.25, -5.25]]); + assertPointField(cd, 's', [ + [1, 3, 6, 10], [3, 5, 6, 6], + [-1, -4, -6, -10], [0, -0.25, -3.25, -5.25] ]); + assertPointField(cd, 'p', [ + [1, 2, 3, 4], [1, 2, 3, 4], + [1, 2, 3, 4], [1, 2, 3, 4]]); + assertArrayField(cd[0][0], 't.barwidth', [1, 0.8, 0.6, 0.4]); + assertArrayField(cd[1][0], 't.barwidth', [0.4, 0.6, 0.8, 1]); + expect(cd[2][0].t.barwidth).toBe(1); + expect(cd[3][0].t.barwidth).toBe(0.8); + expect(cd[0][0].t.poffset).toBe(0); + expect(cd[1][0].t.poffset).toBe(0); + expect(cd[2][0].t.poffset).toBe(0); + expect(cd[3][0].t.poffset).toBe(0); + assertTraceField(cd, 't.bargroupwidth', [0.8, 0.8, 0.8, 0.8]); + + var traceNodes = getAllTraceNodes(gd); + var trace0Waterfall3 = getAllWaterfallNodes(traceNodes[0])[3]; + var path03 = trace0Waterfall3.querySelector('path'); + var text03 = trace0Waterfall3.querySelector('text'); + var trace1Waterfall2 = getAllWaterfallNodes(traceNodes[1])[2]; + var path12 = trace1Waterfall2.querySelector('path'); + var text12 = trace1Waterfall2.querySelector('text'); + var trace2Waterfall0 = getAllWaterfallNodes(traceNodes[2])[0]; + var path20 = trace2Waterfall0.querySelector('path'); + var text20 = trace2Waterfall0.querySelector('text'); + var trace3Waterfall0 = getAllWaterfallNodes(traceNodes[3])[0]; + var path30 = trace3Waterfall0.querySelector('path'); + var text30 = trace3Waterfall0.querySelector('text'); + + expect(text03.textContent).toBe('4'); + expect(text12.textContent).toBe('inside text'); + expect(text20.textContent).toBe('-1'); + expect(text30.textContent).toBe('outside text'); + + assertTextIsAbovePath(text03, path03); // outside + assertTextIsInsidePath(text12, path12); // inside + assertTextIsInsidePath(text20, path20); // inside + assertTextIsBelowPath(text30, path30); // outside + + // clear bounding box cache - somehow when you cache + // text size too early sometimes it changes later... + // we've had this issue before, where we've had to + // redraw annotations to get final sizes, I wish we + // could get some signal that fonts are really ready + // and not start drawing until then (or invalidate + // the bbox cache when that happens?) + // without this change, we get an error at + // assertTextIsInsidePath(text30, path30); + Drawing.savedBBoxes = {}; + + return Plotly.restyle(gd, 'textposition', 'inside'); + }).then(function() { + var cd = gd.calcdata; + assertPointField(cd, 'x', [ + [1.5, 2.4, 3.3, 4.2], [1.2, 2.3, 3.4, 4.5], + [1.5, 2.5, 3.5, 4.5], [1.4, 2.4, 3.4, 4.4]]); + assertPointField(cd, 'y', [ + [1, 3, 6, 10], [3, 5, 6, 6], + [-1, -4, -6, -10], [0, -0.25, -3.25, -5.25]]); + assertPointField(cd, 's', [ + [1, 3, 6, 10], [3, 5, 6, 6], + [-1, -4, -6, -10], [0, -0.25, -3.25, -5.25]]); + assertPointField(cd, 'p', [ + [1, 2, 3, 4], [1, 2, 3, 4], + [1, 2, 3, 4], [1, 2, 3, 4]]); + assertArrayField(cd[0][0], 't.barwidth', [1, 0.8, 0.6, 0.4]); + assertArrayField(cd[1][0], 't.barwidth', [0.4, 0.6, 0.8, 1]); + expect(cd[2][0].t.barwidth).toBe(1); + expect(cd[3][0].t.barwidth).toBe(0.8); + expect(cd[0][0].t.poffset).toBe(0); + expect(cd[1][0].t.poffset).toBe(0); + expect(cd[2][0].t.poffset).toBe(0); + expect(cd[3][0].t.poffset).toBe(0); + assertTraceField(cd, 't.bargroupwidth', [0.8, 0.8, 0.8, 0.8]); + + var traceNodes = getAllTraceNodes(gd); + var trace0Waterfall3 = getAllWaterfallNodes(traceNodes[0])[3]; + var path03 = trace0Waterfall3.querySelector('path'); + var text03 = trace0Waterfall3.querySelector('text'); + var trace1Waterfall2 = getAllWaterfallNodes(traceNodes[1])[2]; + var path12 = trace1Waterfall2.querySelector('path'); + var text12 = trace1Waterfall2.querySelector('text'); + var trace2Waterfall0 = getAllWaterfallNodes(traceNodes[2])[0]; + var path20 = trace2Waterfall0.querySelector('path'); + var text20 = trace2Waterfall0.querySelector('text'); + var trace3Waterfall0 = getAllWaterfallNodes(traceNodes[3])[0]; + var path30 = trace3Waterfall0.querySelector('path'); + var text30 = trace3Waterfall0.querySelector('text'); + + expect(text03.textContent).toBe('4'); + expect(text12.textContent).toBe('inside text'); + expect(text20.textContent).toBe('-1'); + expect(text30.textContent).toBe('outside text'); + + assertTextIsInsidePath(text03, path03); // inside + assertTextIsInsidePath(text12, path12); // inside + assertTextIsInsidePath(text20, path20); // inside + assertTextIsInsidePath(text30, path30); // inside + }) + .catch(failTest) + .then(done); + }); + + it('should be able to add/remove connector nodes on restyle', function(done) { + function _assertNumberOfWaterfallConnectorNodes(cnt) { + var sel = d3.select(gd).select('.waterfalllayer').selectAll('.line'); + expect(sel.size()).toBe(cnt); + } + + Plotly.plot(gd, [{ + type: 'waterfall', + x: ['Initial', 'A', 'B', 'C', 'Total'], + y: [10, 2, 3, 5], + measure: ['absolute', 'relative', 'relative', 'relative', 'total'], + connector: { visible: false } + }]) + .then(function() { + _assertNumberOfWaterfallConnectorNodes(0); + return Plotly.restyle(gd, 'connector.visible', true); + }) + .then(function() { + _assertNumberOfWaterfallConnectorNodes(4); + return Plotly.restyle(gd, 'connector.visible', false); + }) + .then(function() { + _assertNumberOfWaterfallConnectorNodes(0); + return Plotly.restyle(gd, 'connector.visible', true); + }) + .then(function() { + _assertNumberOfWaterfallConnectorNodes(4); + }) + .catch(failTest) + .then(done); + }); + + it('should coerce text-related attributes', function(done) { + var data = [{ + y: [10, 20, 30, 40], + type: 'waterfall', + text: ['T1P1', 'T1P2', 13, 14], + textposition: ['inside', 'outside', 'auto', 'BADVALUE'], + textfont: { + family: ['"comic sans"'], + color: ['red', 'green'], + }, + insidetextfont: { + size: [8, 12, 16], + color: ['black'], + }, + outsidetextfont: { + size: [null, 24, 32] + } + }]; + var layout = { + font: {family: 'arial', color: 'blue', size: 13} + }; + + // Note: insidetextfont.color does NOT inherit from textfont.color + // since insidetextfont.color should be contrasting to bar's fill by default. + var contrastingLightColorVal = color.contrast('black'); + var expected = { + y: [10, 20, 30, 40], + type: 'waterfall', + text: ['T1P1', 'T1P2', '13', '14'], + textposition: ['inside', 'outside', 'none'], + textfont: { + family: ['"comic sans"', 'arial'], + color: ['red', 'green'], + size: [13, 13] + }, + insidetextfont: { + family: ['"comic sans"', 'arial', 'arial'], + color: ['black', 'green', contrastingLightColorVal], + size: [8, 12, 16] + }, + outsidetextfont: { + family: ['"comic sans"', 'arial', 'arial'], + color: ['red', 'green', 'blue'], + size: [13, 24, 32] + } + }; + + Plotly.plot(gd, data, layout).then(function() { + var traceNodes = getAllTraceNodes(gd); + var waterfallNodes = getAllWaterfallNodes(traceNodes[0]); + var pathNodes = [ + waterfallNodes[0].querySelector('path'), + waterfallNodes[1].querySelector('path'), + waterfallNodes[2].querySelector('path'), + waterfallNodes[3].querySelector('path') + ]; + var textNodes = [ + waterfallNodes[0].querySelector('text'), + waterfallNodes[1].querySelector('text'), + waterfallNodes[2].querySelector('text'), + waterfallNodes[3].querySelector('text') + ]; + var i; + + // assert waterfall texts + for(i = 0; i < 3; i++) { + expect(textNodes[i].textContent).toBe(expected.text[i]); + } + + // assert waterfall positions + assertTextIsInsidePath(textNodes[0], pathNodes[0]); // inside + assertTextIsAbovePath(textNodes[1], pathNodes[1]); // outside + assertTextIsInsidePath(textNodes[2], pathNodes[2]); // auto -> inside + expect(textNodes[3]).toBe(null); // BADVALUE -> none + + // assert fonts + assertTextFont(textNodes[0], expected.insidetextfont, 0); + assertTextFont(textNodes[1], expected.outsidetextfont, 1); + assertTextFont(textNodes[2], expected.insidetextfont, 2); + }) + .catch(failTest) + .then(done); + }); + + it('should be able to add/remove text node on restyle', function(done) { + function _assertNumberOfWaterfallTextNodes(cnt) { + var sel = d3.select(gd).select('.waterfalllayer').selectAll('text'); + expect(sel.size()).toBe(cnt); + } + + Plotly.plot(gd, [{ + type: 'waterfall', + x: ['Product A', 'Product B', 'Product C'], + y: [20, 14, 23], + text: [20, 14, 23], + textposition: 'auto' + }]) + .then(function() { + _assertNumberOfWaterfallTextNodes(3); + return Plotly.restyle(gd, 'textposition', 'none'); + }) + .then(function() { + _assertNumberOfWaterfallTextNodes(0); + return Plotly.restyle(gd, 'textposition', 'auto'); + }) + .then(function() { + _assertNumberOfWaterfallTextNodes(3); + return Plotly.restyle(gd, 'text', [[null, 0, '']]); + }) + .then(function() { + // N.B. that '0' should be there! + _assertNumberOfWaterfallTextNodes(1); + return Plotly.restyle(gd, 'text', 'yo!'); + }) + .then(function() { + _assertNumberOfWaterfallTextNodes(3); + }) + .catch(failTest) + .then(done); + }); + + it('should be able to react with new text colors', function(done) { + Plotly.react(gd, [{ + type: 'waterfall', + y: [1, 2, 3], + text: ['A', 'B', 'C'], + textposition: 'inside' + }]) + .then(assertTextFontColors(['rgb(255, 255, 255)', 'rgb(255, 255, 255)', 'rgb(255, 255, 255)'])) + .then(function() { + gd.data[0].insidetextfont = {color: 'red'}; + return Plotly.react(gd, gd.data); + }) + .then(assertTextFontColors(['rgb(255, 0, 0)', 'rgb(255, 0, 0)', 'rgb(255, 0, 0)'])) + .then(function() { + delete gd.data[0].insidetextfont.color; + gd.data[0].textfont = {color: 'blue'}; + return Plotly.react(gd, gd.data); + }) + .then(assertTextFontColors(['rgb(0, 0, 255)', 'rgb(0, 0, 255)', 'rgb(0, 0, 255)'])) + .then(function() { + gd.data[0].textposition = 'outside'; + return Plotly.react(gd, gd.data); + }) + .then(assertTextFontColors(['rgb(0, 0, 255)', 'rgb(0, 0, 255)', 'rgb(0, 0, 255)'])) + .then(function() { + gd.data[0].outsidetextfont = {color: 'red'}; + return Plotly.react(gd, gd.data); + }) + .then(assertTextFontColors(['rgb(255, 0, 0)', 'rgb(255, 0, 0)', 'rgb(255, 0, 0)'])) + .catch(failTest) + .then(done); + }); +}); + +describe('waterfall visibility toggling:', function() { + var gd; + + beforeEach(function() { + gd = createGraphDiv(); + }); + + afterEach(destroyGraphDiv); + + function _assert(msg, xrng, yrng, calls) { + var fullLayout = gd._fullLayout; + expect(fullLayout.xaxis.range).toBeCloseToArray(xrng, 2, msg + ' xrng'); + expect(fullLayout.yaxis.range).toBeCloseToArray(yrng, 2, msg + ' yrng'); + + var crossTraceCalc = gd._fullData[0]._module.crossTraceCalc; + expect(crossTraceCalc).toHaveBeenCalledTimes(calls); + crossTraceCalc.calls.reset(); + } + + it('should update axis range according to visible edits (group case)', function(done) { + Plotly.plot(gd, [ + {type: 'waterfall', x: [1, 2, 3], y: [0.5, 1, 0.5]}, + {type: 'waterfall', x: [1, 2, 3], y: [-0.5, -1, -0.5]} + ]) + .then(function() { + spyOn(gd._fullData[0]._module, 'crossTraceCalc').and.callThrough(); + + _assert('base', [0.5, 3.5], [-2.222, 2.222], 0); + expect(gd._fullLayout.legend.traceorder).toBe('normal'); + return Plotly.restyle(gd, 'visible', false, [1]); + }) + .then(function() { + _assert('visible [true,false]', [0.5, 3.5], [0, 2.105], 1); + return Plotly.restyle(gd, 'visible', false, [0]); + }) + .then(function() { + _assert('both invisible', [0.5, 3.5], [0, 2.105], 0); + return Plotly.restyle(gd, 'visible', 'legendonly'); + }) + .then(function() { + _assert('both legendonly', [0.5, 3.5], [0, 2.105], 0); + expect(gd._fullLayout.legend.traceorder).toBe('normal'); + return Plotly.restyle(gd, 'visible', true, [1]); + }) + .then(function() { + _assert('visible [false,true]', [0.5, 3.5], [-2.105, 0], 1); + return Plotly.restyle(gd, 'visible', true); + }) + .then(function() { + _assert('back to both visible', [0.5, 3.5], [-2.222, 2.222], 1); + }) + .catch(failTest) + .then(done); + }); +}); + +describe('waterfall hover', function() { + 'use strict'; + + var gd; + + afterEach(destroyGraphDiv); + + function getPointData(gd) { + var cd = gd.calcdata; + var subplot = gd._fullLayout._plots.xy; + + return { + index: false, + distance: 20, + cd: cd[0], + trace: cd[0][0].trace, + xa: subplot.xaxis, + ya: subplot.yaxis, + maxHoverDistance: 20 + }; + } + + function _hover(gd, xval, yval, hovermode) { + var pointData = getPointData(gd); + var pts = Waterfall.hoverPoints(pointData, xval, yval, hovermode); + if(!pts) return false; + + var pt = pts[0]; + + return { + style: [pt.index, pt.color, pt.xLabelVal, pt.yLabelVal], + pos: [pt.x0, pt.x1, pt.y0, pt.y1], + text: pt.text + }; + } + + function assertPos(actual, expected) { + var TOL = 5; + + actual.forEach(function(p, i) { + expect(p).toBeWithin(expected[i], TOL); + }); + } + + describe('with orientation *v*', function() { + beforeAll(function(done) { + gd = createGraphDiv(); + + var mock = Lib.extendDeep({}, require('@mocks/waterfall_11.json')); + + Plotly.plot(gd, mock.data, mock.layout) + .catch(failTest) + .then(done); + }); + + it('should return the correct hover point data (case x)', function() { + var out = _hover(gd, 0, 0, 'x'); + + expect(out.style).toEqual([0, '#3D9970', 0, 13.23]); + assertPos(out.pos, [11.87, 106.8, 52.71, 52.71]); + }); + + it('should return the correct hover point data (case closest)', function() { + var out = _hover(gd, -0.2, 12, 'closest'); + + expect(out.style).toEqual([0, '#3D9970', 0, 13.23]); + assertPos(out.pos, [11.87, 59.33, 52.71, 52.71]); + }); + }); + + describe('text labels', function() { + + it('should show \'hovertext\' items when present, \'text\' if not', function(done) { + gd = createGraphDiv(); + + var mock = Lib.extendDeep({}, require('@mocks/text_chart_arrays')); + mock.data.forEach(function(t) { t.type = 'waterfall'; }); + + Plotly.plot(gd, mock).then(function() { + var out = _hover(gd, -0.25, 0.5, 'closest'); + expect(out.text).toEqual('Hover text\nA', 'hover text'); + + return Plotly.restyle(gd, 'hovertext', null); + }) + .then(function() { + var out = _hover(gd, -0.25, 0.5, 'closest'); + expect(out.text).toEqual('Text\nA', 'hover text'); + + return Plotly.restyle(gd, 'text', ['APPLE', 'BANANA', 'ORANGE']); + }) + .then(function() { + var out = _hover(gd, -0.25, 0.5, 'closest'); + expect(out.text).toEqual('APPLE', 'hover text'); + + return Plotly.restyle(gd, 'hovertext', ['apple', 'banana', 'orange']); + }) + .then(function() { + var out = _hover(gd, -0.25, 0.5, 'closest'); + expect(out.text).toEqual('apple', 'hover text'); + }) + .catch(failTest) + .then(done); + }); + + it('should use hovertemplate if specified', function(done) { + gd = createGraphDiv(); + + var mock = Lib.extendDeep({}, require('@mocks/text_chart_arrays')); + mock.data.forEach(function(t) { + t.type = 'waterfall'; + t.hovertemplate = '%{y}'; + }); + + function _hover() { + var evt = { xpx: 125, ypx: 150 }; + Fx.hover('graph', evt, 'xy'); + } + + Plotly.plot(gd, mock) + .then(_hover) + .then(function() { + assertHoverLabelContent({ + nums: ['1', '2', '1.5'], + name: ['', '', ''], + axis: '0' + }); + // return Plotly.restyle(gd, 'text', ['APPLE', 'BANANA', 'ORANGE']); + }) + .catch(failTest) + .then(done); + }); + }); + + describe('with special width/offset combinations', function() { + + beforeEach(function() { + gd = createGraphDiv(); + }); + + it('should return correct hover data (single waterfall, trace width)', function(done) { + Plotly.plot(gd, [{ + type: 'waterfall', + x: [1], + y: [2], + width: 10, + marker: { color: 'red' } + }], { + xaxis: { range: [-200, 200] } + }) + .then(function() { + // all these x, y, hovermode should give the same (the only!) hover label + [ + [0, 0, 'closest'], + [-3.9, 1, 'closest'], + [5.9, 1.9, 'closest'], + [-3.9, -10, 'x'], + [5.9, 19, 'x'] + ].forEach(function(hoverSpec) { + var out = _hover(gd, hoverSpec[0], hoverSpec[1], hoverSpec[2]); + + expect(out.style).toEqual([0, '#3D9970', 1, 2], hoverSpec); + assertPos(out.pos, [264, 278, 14, 14], hoverSpec); + }); + + // then a few that are off the edge so yield nothing + [ + [1, -0.1, 'closest'], + [1, 2.1, 'closest'], + [-4.1, 1, 'closest'], + [6.1, 1, 'closest'], + [-4.1, 1, 'x'], + [6.1, 1, 'x'] + ].forEach(function(hoverSpec) { + var out = _hover(gd, hoverSpec[0], hoverSpec[1], hoverSpec[2]); + + expect(out).toBe(false, hoverSpec); + }); + }) + .catch(failTest) + .then(done); + }); + + it('should return correct hover data (two waterfalls, array width)', function(done) { + Plotly.plot(gd, [{ + type: 'waterfall', + x: [1, 200], + y: [2, 1], + width: [10, 20], + marker: { color: 'red' } + }, { + type: 'waterfall', + x: [1, 200], + y: [1, 2], + width: [20, 10], + marker: { color: 'green' } + }], { + xaxis: { range: [-200, 300] }, + width: 500, + height: 500 + }) + .then(function() { + var out = _hover(gd, -36, 1.5, 'closest'); + + expect(out.style).toEqual([0, '#3D9970', 1, 2]); + assertPos(out.pos, [99, 106, 117.33, 117.33]); + + out = _hover(gd, 164, 0.8, 'closest'); + + expect(out.style).toEqual([1, '#3D9970', 200, 3]); + assertPos(out.pos, [222, 235, 16, 16]); + + out = _hover(gd, 125, 0.8, 'x'); + + expect(out.style).toEqual([1, '#3D9970', 200, 3]); + assertPos(out.pos, [222, 280, 16, 16]); + }) + .catch(failTest) + .then(done); + }); + + it('positions labels correctly w.r.t. narrow waterfalls', function(done) { + Plotly.newPlot(gd, [{ + x: [0, 10, 20], + y: [1, 3, 2], + type: 'waterfall', + width: 1 + }], { + width: 500, + height: 500, + margin: {l: 100, r: 100, t: 100, b: 100} + }) + .then(function() { + // you can still hover over the gap (14) but the label will + // get pushed in to the bar + var out = _hover(gd, 14, 2, 'x'); + assertPos(out.pos, [145, 155, 110, 110]); + + // in closest mode you must be over the bar though + out = _hover(gd, 14, 2, 'closest'); + expect(out).toBe(false); + + // now for a single waterfall trace, closest and compare modes give the same + // positioning of hover labels + out = _hover(gd, 10, 2, 'closest'); + assertPos(out.pos, [145, 155, 110, 110]); + }) + .catch(failTest) + .then(done); + }); + }); +}); + +function mockWaterfallPlot(dataWithoutTraceType, layout) { + var traceTemplate = { type: 'waterfall' }; + + var dataWithTraceType = dataWithoutTraceType.map(function(trace) { + return Lib.extendFlat({}, traceTemplate, trace); + }); + + var gd = { + data: dataWithTraceType, + layout: layout || {}, + calcdata: [], + _context: {locale: 'en', locales: {}} + }; + + supplyAllDefaults(gd); + Plots.doCalcdata(gd); + + return gd; +} + +function assertArrayField(calcData, prop, expectation) { + var values = Lib.nestedProperty(calcData, prop).get(); + if(!Array.isArray(values)) values = [values]; + + expect(values).toBeCloseToArray(expectation, undefined, '(field ' + prop + ')'); +} + +function assertPointField(calcData, prop, expectation) { + var values = []; + + calcData.forEach(function(calcTrace) { + var vals = calcTrace.map(function(pt) { + return Lib.nestedProperty(pt, prop).get(); + }); + + values.push(vals); + }); + + expect(values).toBeCloseTo2DArray(expectation, undefined, '(field ' + prop + ')'); +} + +function assertTraceField(calcData, prop, expectation) { + var values = calcData.map(function(calcTrace) { + return Lib.nestedProperty(calcTrace[0], prop).get(); + }); + + expect(values).toBeCloseToArray(expectation, undefined, '(field ' + prop + ')'); +}