diff --git a/lib/funnel.js b/lib/funnel.js new file mode 100644 index 00000000000..d541fb8fc17 --- /dev/null +++ b/lib/funnel.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/funnel'); diff --git a/lib/index-finance.js b/lib/index-finance.js index 0032435566d..b1aa2ac0c73 100644 --- a/lib/index-finance.js +++ b/lib/index-finance.js @@ -16,6 +16,7 @@ Plotly.register([ require('./pie'), require('./ohlc'), require('./candlestick'), + require('./funnel'), require('./waterfall') ]); diff --git a/lib/index.js b/lib/index.js index fae7e67f43a..210bc566a93 100644 --- a/lib/index.js +++ b/lib/index.js @@ -21,6 +21,7 @@ Plotly.register([ require('./contour'), require('./scatterternary'), require('./violin'), + require('./funnel'), require('./waterfall'), require('./pie'), diff --git a/src/components/drawing/index.js b/src/components/drawing/index.js index 9e764d93562..55116253f33 100644 --- a/src/components/drawing/index.js +++ b/src/components/drawing/index.js @@ -110,9 +110,7 @@ drawing.hideOutsideRangePoints = function(traceGroups, subplot) { var trace = d[0].trace; var xcalendar = trace.xcalendar; var ycalendar = trace.ycalendar; - var selector = trace.type === 'bar' ? '.bartext' : - trace.type === 'waterfall' ? '.bartext,.line' : - '.point,.textpoint'; + var selector = Registry.traceIs(trace, 'bar-like') ? '.bartext' : '.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 3b3c3c6c0f7..277cab88bc3 100644 --- a/src/components/legend/style.js +++ b/src/components/legend/style.js @@ -84,6 +84,7 @@ module.exports = function style(s, gd) { .classed('legendpoints', true); }) .each(styleWaterfalls) + .each(styleFunnels) .each(styleBars) .each(styleBoxes) .each(stylePies) @@ -306,14 +307,25 @@ module.exports = function style(s, gd) { } function styleBars(d) { + styleBarFamily(d, this); + } + + function styleFunnels(d) { + styleBarFamily(d, this, 'funnel'); + } + + function styleBarFamily(d, lThis, desiredType) { var trace = d[0].trace; var marker = trace.marker || {}; var markerLine = marker.line || {}; - var barpath = d3.select(this).select('g.legendpoints') - .selectAll('path.legendbar') - .data(Registry.traceIs(trace, 'bar') ? [d] : []); - barpath.enter().append('path').classed('legendbar', true) + var isVisible = (!desiredType) ? Registry.traceIs(trace, 'bar') : + (trace.type === desiredType && trace.visible); + + var barpath = d3.select(lThis).select('g.legendpoints') + .selectAll('path.legend' + desiredType) + .data(isVisible ? [d] : []); + barpath.enter().append('path').classed('legend' + desiredType, true) .attr('d', 'M6,6H-6V-6H6Z') .attr('transform', 'translate(20,0)'); barpath.exit().remove(); diff --git a/src/plot_api/helpers.js b/src/plot_api/helpers.js index 72fb27472d4..45bd5b69835 100644 --- a/src/plot_api/helpers.js +++ b/src/plot_api/helpers.js @@ -328,7 +328,7 @@ exports.cleanData = function(data) { trace.scene = Plots.subplotsRegistry.gl3d.cleanId(trace.scene); } - if(!traceIs(trace, 'pie') && !traceIs(trace, 'bar') && trace.type !== 'waterfall') { + if(!traceIs(trace, 'pie') && !traceIs(trace, 'bar-like')) { 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 e56537802dc..cb2a114ca1f 100644 --- a/src/plots/cartesian/axes.js +++ b/src/plots/cartesian/axes.js @@ -2813,7 +2813,7 @@ function hasBarsOrFill(gd, ax) { if(trace.visible === true && (trace.xaxis + trace.yaxis) === subplot) { if( - (Registry.traceIs(trace, 'bar') || trace.type === 'waterfall') && + Registry.traceIs(trace, 'bar-like') && trace.orientation === {x: 'h', y: 'v'}[axLetter] ) return true; diff --git a/src/plots/cartesian/axis_defaults.js b/src/plots/cartesian/axis_defaults.js index 2269152de39..57b05f5b29d 100644 --- a/src/plots/cartesian/axis_defaults.js +++ b/src/plots/cartesian/axis_defaults.js @@ -31,13 +31,18 @@ var setConvert = require('./set_convert'); * noTickson: boolean, this axis doesn't support 'tickson' * data: the plot data, used to manage categories * bgColor: the plot background color, to calculate default gridline colors + * calendar: + * splomStash: + * visibleDflt: boolean + * reverseDflt: boolean + * automargin: boolean */ module.exports = function handleAxisDefaults(containerIn, containerOut, coerce, options, layoutOut) { var letter = options.letter; var font = options.font || {}; var splomStash = options.splomStash || {}; - var visible = coerce('visible', !options.cheateronly); + var visible = coerce('visible', !options.visibleDflt); var axType = containerOut.type; @@ -48,7 +53,9 @@ module.exports = function handleAxisDefaults(containerIn, containerOut, coerce, setConvert(containerOut, layoutOut); - var autoRange = coerce('autorange', !containerOut.isValidRange(containerIn.range)); + var autorangeDflt = !containerOut.isValidRange(containerIn.range); + if(autorangeDflt && options.reverseDflt) autorangeDflt = 'reversed'; + var autoRange = coerce('autorange', autorangeDflt); if(autoRange && (axType === 'linear' || axType === '-')) coerce('rangemode'); coerce('range'); @@ -58,8 +65,6 @@ module.exports = function handleAxisDefaults(containerIn, containerOut, coerce, if(axType !== 'category' && !options.noHover) coerce('hoverformat'); - if(!visible) return containerOut; - var dfltColor = coerce('color'); // if axis.color was provided, use it for fonts too; otherwise, // inherit from global font color in case that was provided. @@ -69,6 +74,9 @@ module.exports = function handleAxisDefaults(containerIn, containerOut, coerce, // try to get default title from splom trace, fallback to graph-wide value var dfltTitle = splomStash.label || layoutOut._dfltTitle[letter]; + handleTickLabelDefaults(containerIn, containerOut, coerce, axType, options, {pass: 1}); + if(!visible) return containerOut; + coerce('title.text', dfltTitle); Lib.coerceFont(coerce, 'title.font', { family: font.family, @@ -77,7 +85,7 @@ module.exports = function handleAxisDefaults(containerIn, containerOut, coerce, }); handleTickValueDefaults(containerIn, containerOut, coerce, axType); - handleTickLabelDefaults(containerIn, containerOut, coerce, axType, options); + handleTickLabelDefaults(containerIn, containerOut, coerce, axType, options, {pass: 2}); handleTickMarkDefaults(containerIn, containerOut, coerce, options); handleLineGridDefaults(containerIn, containerOut, coerce, { dfltColor: dfltColor, diff --git a/src/plots/cartesian/constants.js b/src/plots/cartesian/constants.js index 2b524c3d338..da643b09feb 100644 --- a/src/plots/cartesian/constants.js +++ b/src/plots/cartesian/constants.js @@ -65,7 +65,7 @@ module.exports = { traceLayerClasses: [ 'heatmaplayer', 'contourcarpetlayer', 'contourlayer', - 'waterfalllayer', 'barlayer', + 'funnellayer', 'waterfalllayer', 'barlayer', 'carpetlayer', 'violinlayer', 'boxlayer', @@ -73,6 +73,13 @@ module.exports = { 'scattercarpetlayer', 'scatterlayer' ], + clipOnAxisFalseQuery: [ + '.scatterlayer', + '.barlayer', + '.funnellayer', + '.waterfalllayer' + ], + layerValue2layerClass: { 'above traces': 'above', 'below traces': 'below' diff --git a/src/plots/cartesian/index.js b/src/plots/cartesian/index.js index fa1dd76df26..de802c03c6c 100644 --- a/src/plots/cartesian/index.js +++ b/src/plots/cartesian/index.js @@ -260,7 +260,7 @@ function plotOne(gd, plotinfo, cdSubplot, transitionOpts, makeOnCompleteCallback ); // layers that allow `cliponaxis: false` - if(className !== 'scatterlayer' && className !== 'barlayer' && className !== 'waterfalllayer') { + if(constants.clipOnAxisFalseQuery.indexOf('.' + className) === -1) { Drawing.setClipUrl(sel, plotinfo.layerClipId, gd); } }); @@ -276,7 +276,7 @@ function plotOne(gd, plotinfo, cdSubplot, transitionOpts, makeOnCompleteCallback if(!gd._context.staticPlot) { if(plotinfo._hasClipOnAxisFalse) { plotinfo.clipOnAxisFalseTraces = plotinfo.plot - .selectAll('.scatterlayer, .barlayer, .waterfalllayer') + .selectAll(constants.clipOnAxisFalseQuery.join(',')) .selectAll('.trace'); } diff --git a/src/plots/cartesian/layout_defaults.js b/src/plots/cartesian/layout_defaults.js index 8a6f6235b10..3865a3751fb 100644 --- a/src/plots/cartesian/layout_defaults.js +++ b/src/plots/cartesian/layout_defaults.js @@ -35,8 +35,12 @@ function appendList(cont, k, item) { module.exports = function supplyLayoutDefaults(layoutIn, layoutOut, fullData) { var ax2traces = {}; - var xaCheater = {}; - var xaNonCheater = {}; + var xaMayHide = {}; + var yaMayHide = {}; + var xaMustDisplay = {}; + var yaMustDisplay = {}; + var yaMustForward = {}; + var yaMayBackward = {}; var outerTicks = {}; var noGrids = {}; var i, j; @@ -66,30 +70,46 @@ module.exports = function supplyLayoutDefaults(layoutIn, layoutOut, fullData) { } } + // logic for funnels + if(trace.type === 'funnel') { + if(trace.orientation === 'h') { + if(xaName) xaMayHide[xaName] = true; + if(yaName) yaMayBackward[yaName] = true; + } else { + if(yaName) yaMayHide[yaName] = true; + } + } else { + if(yaName) { + yaMustDisplay[yaName] = true; + yaMustForward[yaName] = true; + } + + if(!traceIs(trace, 'carpet') || (trace.type === 'carpet' && !trace._cheater)) { + if(xaName) xaMustDisplay[xaName] = true; + } + } + // Two things trigger axis visibility: // 1. is not carpet // 2. carpet that's not cheater - if(!traceIs(trace, 'carpet') || (trace.type === 'carpet' && !trace._cheater)) { - if(xaName) xaNonCheater[xaName] = 1; - } // The above check for definitely-not-cheater is not adequate. This // second list tracks which axes *could* be a cheater so that the // full condition triggering hiding is: // *could* be a cheater and *is not definitely visible* if(trace.type === 'carpet' && trace._cheater) { - if(xaName) xaCheater[xaName] = 1; + if(xaName) xaMayHide[xaName] = true; } // check for default formatting tweaks if(traceIs(trace, '2dMap')) { - outerTicks[xaName] = 1; - outerTicks[yaName] = 1; + outerTicks[xaName] = true; + outerTicks[yaName] = true; } if(traceIs(trace, 'oriented')) { var positionAxis = trace.orientation === 'h' ? yaName : xaName; - noGrids[positionAxis] = 1; + noGrids[positionAxis] = true; } } @@ -167,6 +187,13 @@ module.exports = function supplyLayoutDefaults(layoutIn, layoutOut, fullData) { var overlayableAxes = getOverlayableAxes(axLetter, axName); + var visibleDflt = + (axLetter === 'x' && !xaMustDisplay[axName] && xaMayHide[axName]) || + (axLetter === 'y' && !yaMustDisplay[axName] && yaMayHide[axName]); + + var reverseDflt = + (axLetter === 'y' && !yaMustForward[axName] && yaMayBackward[axName]); + var defaultOptions = { letter: axLetter, font: layoutOut.font, @@ -176,7 +203,8 @@ module.exports = function supplyLayoutDefaults(layoutIn, layoutOut, fullData) { bgColor: bgColor, calendar: layoutOut.calendar, automargin: true, - cheateronly: axLetter === 'x' && xaCheater[axName] && !xaNonCheater[axName], + visibleDflt: visibleDflt, + reverseDflt: reverseDflt, splomStash: ((layoutOut._splomAxes || {})[axLetter] || {})[id] }; diff --git a/src/plots/cartesian/tick_label_defaults.js b/src/plots/cartesian/tick_label_defaults.js index 5bbe72765fc..42c58054694 100644 --- a/src/plots/cartesian/tick_label_defaults.js +++ b/src/plots/cartesian/tick_label_defaults.js @@ -13,7 +13,27 @@ var Lib = require('../../lib'); var layoutAttributes = require('./layout_attributes'); var handleArrayContainerDefaults = require('../array_container_defaults'); -module.exports = function handleTickLabelDefaults(containerIn, containerOut, coerce, axType, options) { +module.exports = function handleTickLabelDefaults(containerIn, containerOut, coerce, axType, options, config) { + if(!config || config.pass === 1) { + handlePrefixSuffix(containerIn, containerOut, coerce, axType, options); + } + + if(!config || config.pass === 2) { + handleOtherDefaults(containerIn, containerOut, coerce, axType, options); + } +}; + +function handlePrefixSuffix(containerIn, containerOut, coerce, axType, options) { + var showAttrDflt = getShowAttrDflt(containerIn); + + var tickPrefix = coerce('tickprefix'); + if(tickPrefix) coerce('showtickprefix', showAttrDflt); + + var tickSuffix = coerce('ticksuffix', options.tickSuffixDflt); + if(tickSuffix) coerce('showticksuffix', showAttrDflt); +} + +function handleOtherDefaults(containerIn, containerOut, coerce, axType, options) { var showAttrDflt = getShowAttrDflt(containerIn); var tickPrefix = coerce('tickprefix'); @@ -54,7 +74,7 @@ module.exports = function handleTickLabelDefaults(containerIn, containerOut, coe } } } -}; +} /* * Attributes 'showexponent', 'showtickprefix' and 'showticksuffix' diff --git a/src/traces/bar/attributes.js b/src/traces/bar/attributes.js index 5cf9003da59..08124bf822d 100644 --- a/src/traces/bar/attributes.js +++ b/src/traces/bar/attributes.js @@ -86,6 +86,30 @@ module.exports = { ].join(' ') }, + insidetextanchor: { + valType: 'enumerated', + values: ['end', 'middle', 'start'], + dflt: 'end', + role: 'info', // TODO: or style ? + editType: 'plot', + description: [ + 'Determines if texts are kept at center or start/end points in `textposition` *inside* mode.' + ].join(' ') + }, + + textangle: { + valType: 'angle', + dflt: 'auto', + role: 'info', // TODO: or style ? + editType: 'plot', + description: [ + 'Sets the angle of the tick labels with respect to the bar.', + 'For example, a `tickangle` of -90 draws the tick labels', + 'vertically. With *auto* the texts may automatically be', + 'rotated to fit with the maximum size in bars.' + ].join(' ') + }, + textfont: extendFlat({}, textFontAttrs, { description: 'Sets the font used for `text`.' }), diff --git a/src/traces/bar/cross_trace_calc.js b/src/traces/bar/cross_trace_calc.js index e98728a384d..3fd7e68ad0f 100644 --- a/src/traces/bar/cross_trace_calc.js +++ b/src/traces/bar/cross_trace_calc.js @@ -28,10 +28,11 @@ function crossTraceCalc(gd, plotinfo) { var xa = plotinfo.xaxis; var ya = plotinfo.yaxis; + var fullLayout = gd._fullLayout; var fullTraces = gd._fullData; var calcTraces = gd.calcdata; - var calcTracesHorizontal = []; - var calcTracesVertical = []; + var calcTracesHorz = []; + var calcTracesVert = []; for(var i = 0; i < fullTraces.length; i++) { var fullTrace = fullTraces[i]; @@ -42,30 +43,36 @@ function crossTraceCalc(gd, plotinfo) { fullTrace.yaxis === ya._id ) { if(fullTrace.orientation === 'h') { - calcTracesHorizontal.push(calcTraces[i]); + calcTracesHorz.push(calcTraces[i]); } else { - calcTracesVertical.push(calcTraces[i]); + calcTracesVert.push(calcTraces[i]); } } } - setGroupPositions(gd, xa, ya, calcTracesVertical); - setGroupPositions(gd, ya, xa, calcTracesHorizontal); + var opts = { + mode: fullLayout.barmode, + norm: fullLayout.barnorm, + gap: fullLayout.bargap, + groupgap: fullLayout.bargroupgap + }; + + setGroupPositions(gd, xa, ya, calcTracesVert, opts); + setGroupPositions(gd, ya, xa, calcTracesHorz, opts); } -function setGroupPositions(gd, pa, sa, calcTraces) { +function setGroupPositions(gd, pa, sa, calcTraces, opts) { if(!calcTraces.length) return; - var barmode = gd._fullLayout.barmode; var excluded; var included; var i, calcTrace, fullTrace; - initBase(gd, pa, sa, calcTraces); + initBase(sa, calcTraces); - switch(barmode) { + switch(opts.mode) { case 'overlay': - setGroupPositionsInOverlayMode(gd, pa, sa, calcTraces); + setGroupPositionsInOverlayMode(pa, sa, calcTraces, opts); break; case 'group': @@ -81,10 +88,10 @@ function setGroupPositions(gd, pa, sa, calcTraces) { } if(included.length) { - setGroupPositionsInGroupMode(gd, pa, sa, included); + setGroupPositionsInGroupMode(gd, pa, sa, included, opts); } if(excluded.length) { - setGroupPositionsInOverlayMode(gd, pa, sa, excluded); + setGroupPositionsInOverlayMode(pa, sa, excluded, opts); } break; @@ -102,10 +109,10 @@ function setGroupPositions(gd, pa, sa, calcTraces) { } if(included.length) { - setGroupPositionsInStackOrRelativeMode(gd, pa, sa, included); + setGroupPositionsInStackOrRelativeMode(gd, pa, sa, included, opts); } if(excluded.length) { - setGroupPositionsInOverlayMode(gd, pa, sa, excluded); + setGroupPositionsInOverlayMode(pa, sa, excluded, opts); } break; } @@ -113,13 +120,13 @@ function setGroupPositions(gd, pa, sa, calcTraces) { collectExtents(calcTraces, pa); } -function initBase(gd, pa, sa, calcTraces) { +function initBase(sa, calcTraces) { var i, j; for(i = 0; i < calcTraces.length; i++) { var cd = calcTraces[i]; var trace = cd[0].trace; - var base = trace.base; + var base = (trace.type === 'funnel') ? trace._base : trace.base; var b; // not sure if it really makes sense to have dates for bar size data... @@ -156,75 +163,66 @@ function initBase(gd, pa, sa, calcTraces) { } } -function setGroupPositionsInOverlayMode(gd, pa, sa, calcTraces) { - var barnorm = gd._fullLayout.barnorm; - +function setGroupPositionsInOverlayMode(pa, sa, calcTraces, opts) { // update position axis and set bar offsets and widths for(var i = 0; i < calcTraces.length; i++) { var calcTrace = calcTraces[i]; var sieve = new Sieve([calcTrace], { sepNegVal: false, - overlapNoMerge: !barnorm + overlapNoMerge: !opts.norm }); // set bar offsets and widths, and update position axis - setOffsetAndWidth(gd, pa, sieve); + setOffsetAndWidth(pa, sieve, opts); // set bar bases and sizes, and update size axis // // (note that `setGroupPositionsInOverlayMode` handles the case barnorm // is defined, because this function is also invoked for traces that // can't be grouped or stacked) - if(barnorm) { - sieveBars(gd, sa, sieve); - normalizeBars(gd, sa, sieve); + if(opts.norm) { + sieveBars(sieve); + normalizeBars(sa, sieve, opts); } else { - setBaseAndTop(gd, sa, sieve); + setBaseAndTop(sa, sieve); } } } -function setGroupPositionsInGroupMode(gd, pa, sa, calcTraces) { - var fullLayout = gd._fullLayout; - var barnorm = fullLayout.barnorm; - +function setGroupPositionsInGroupMode(gd, pa, sa, calcTraces, opts) { var sieve = new Sieve(calcTraces, { sepNegVal: false, - overlapNoMerge: !barnorm + overlapNoMerge: !opts.norm }); // set bar offsets and widths, and update position axis - setOffsetAndWidthInGroupMode(gd, pa, sieve); + setOffsetAndWidthInGroupMode(gd, pa, sieve, opts); // relative-stack bars within the same trace that would otherwise // be hidden - unhideBarsWithinTrace(gd, sa, sieve); + unhideBarsWithinTrace(sieve); // set bar bases and sizes, and update size axis - if(barnorm) { - sieveBars(gd, sa, sieve); - normalizeBars(gd, sa, sieve); + if(opts.norm) { + sieveBars(sieve); + normalizeBars(sa, sieve, opts); } else { - setBaseAndTop(gd, sa, sieve); + setBaseAndTop(sa, sieve); } } -function setGroupPositionsInStackOrRelativeMode(gd, pa, sa, calcTraces) { - var fullLayout = gd._fullLayout; - var barmode = fullLayout.barmode; - var barnorm = fullLayout.barnorm; - +function setGroupPositionsInStackOrRelativeMode(gd, pa, sa, calcTraces, opts) { var sieve = new Sieve(calcTraces, { - sepNegVal: barmode === 'relative', - overlapNoMerge: !(barnorm || barmode === 'stack' || barmode === 'relative') + sepNegVal: opts.mode === 'relative', + overlapNoMerge: !(opts.norm || opts.mode === 'stack' || opts.mode === 'relative') }); // set bar offsets and widths, and update position axis - setOffsetAndWidth(gd, pa, sieve); + setOffsetAndWidth(pa, sieve, opts); // set bar bases and sizes, and update size axis - stackBars(gd, sa, sieve); + stackBars(sa, sieve, opts); // flag the outmost bar (for text display purposes) for(var i = 0; i < calcTraces.length; i++) { @@ -242,21 +240,17 @@ function setGroupPositionsInStackOrRelativeMode(gd, pa, sa, calcTraces) { // Note that marking the outmost bars has to be done // before `normalizeBars` changes `bar.b` and `bar.s`. - if(barnorm) normalizeBars(gd, sa, sieve); + if(opts.norm) normalizeBars(sa, sieve, opts); } -function setOffsetAndWidth(gd, pa, sieve) { - var fullLayout = gd._fullLayout; - var bargap = fullLayout.bargap; - var bargroupgap = fullLayout.bargroupgap || 0; - +function setOffsetAndWidth(pa, sieve, opts) { var minDiff = sieve.minDiff; var calcTraces = sieve.traces; // set bar offsets and widths - var barGroupWidth = minDiff * (1 - bargap); + var barGroupWidth = minDiff * (1 - opts.gap); var barWidthPlusGap = barGroupWidth; - var barWidth = barWidthPlusGap * (1 - bargroupgap); + var barWidth = barWidthPlusGap * (1 - (opts.groupgap || 0)); // computer bar group center and bar offset var offsetFromCenter = -barWidth / 2; @@ -279,16 +273,14 @@ function setOffsetAndWidth(gd, pa, sieve) { applyAttributes(sieve); // store the bar center in each calcdata item - setBarCenterAndWidth(gd, pa, sieve); + setBarCenterAndWidth(pa, sieve); // update position axes - updatePositionAxis(gd, pa, sieve); + updatePositionAxis(pa, sieve); } -function setOffsetAndWidthInGroupMode(gd, pa, sieve) { +function setOffsetAndWidthInGroupMode(gd, pa, sieve, opts) { var fullLayout = gd._fullLayout; - var bargap = fullLayout.bargap; - var bargroupgap = fullLayout.bargroupgap || 0; var positions = sieve.positions; var distinctPositions = sieve.distinctPositions; var minDiff = sieve.minDiff; @@ -298,7 +290,7 @@ function setOffsetAndWidthInGroupMode(gd, pa, sieve) { // if there aren't any overlapping positions, // let them have full width even if mode is group var overlap = (positions.length !== distinctPositions.length); - var barGroupWidth = minDiff * (1 - bargap); + var barGroupWidth = minDiff * (1 - opts.gap); var groupId = getAxisGroup(fullLayout, pa._id) + calcTraces[0][0].trace.orientation; var alignmentGroups = fullLayout._alignmentOpts[groupId] || {}; @@ -317,7 +309,7 @@ function setOffsetAndWidthInGroupMode(gd, pa, sieve) { barWidthPlusGap = overlap ? barGroupWidth / nTraces : barGroupWidth; } - var barWidth = barWidthPlusGap * (1 - bargroupgap); + var barWidth = barWidthPlusGap * (1 - (opts.groupgap || 0)); var offsetFromCenter; if(nOffsetGroups) { @@ -342,10 +334,10 @@ function setOffsetAndWidthInGroupMode(gd, pa, sieve) { applyAttributes(sieve); // store the bar center in each calcdata item - setBarCenterAndWidth(gd, pa, sieve); + setBarCenterAndWidth(pa, sieve); // update position axes - updatePositionAxis(gd, pa, sieve, overlap); + updatePositionAxis(pa, sieve, overlap); } function applyAttributes(sieve) { @@ -426,7 +418,7 @@ function applyAttributes(sieve) { } } -function setBarCenterAndWidth(gd, pa, sieve) { +function setBarCenterAndWidth(pa, sieve) { var calcTraces = sieve.traces; var pLetter = getAxisLetter(pa); @@ -448,7 +440,7 @@ function setBarCenterAndWidth(gd, pa, sieve) { } } -function updatePositionAxis(gd, pa, sieve, allowMinDtick) { +function updatePositionAxis(pa, sieve, allowMinDtick) { var calcTraces = sieve.traces; var minDiff = sieve.minDiff; var vpad = minDiff / 2; @@ -493,7 +485,7 @@ function updatePositionAxis(gd, pa, sieve, allowMinDtick) { // store these bar bases and tops in calcdata // and make sure the size axis includes zero, // along with the bases and tops of each bar. -function setBaseAndTop(gd, sa, sieve) { +function setBaseAndTop(sa, sieve) { var calcTraces = sieve.traces; var sLetter = getAxisLetter(sa); @@ -501,61 +493,93 @@ function setBaseAndTop(gd, sa, sieve) { var calcTrace = calcTraces[i]; var fullTrace = calcTrace[0].trace; var pts = []; - var allBarBaseAboveZero = true; + var allBaseAboveZero = true; for(var j = 0; j < calcTrace.length; j++) { var bar = calcTrace[j]; - var barBase = bar.b; - var barTop = barBase + bar.s; + var base = bar.b; + var top = base + bar.s; - bar[sLetter] = barTop; - pts.push(barTop); - if(bar.hasB) pts.push(barBase); + bar[sLetter] = top; + pts.push(top); + if(bar.hasB) pts.push(base); if(!bar.hasB || !(bar.b > 0 && bar.s > 0)) { - allBarBaseAboveZero = false; + allBaseAboveZero = false; } } fullTrace._extremes[sa._id] = Axes.findExtremes(sa, pts, { - tozero: !allBarBaseAboveZero, + tozero: !allBaseAboveZero, padded: true }); } } -function stackBars(gd, sa, sieve) { - var fullLayout = gd._fullLayout; - var barnorm = fullLayout.barnorm; +function stackBars(sa, sieve, opts) { var sLetter = getAxisLetter(sa); var calcTraces = sieve.traces; + var calcTrace; + var fullTrace; + var isFunnel; + var i, j; + var bar; + + for(i = 0; i < calcTraces.length; i++) { + calcTrace = calcTraces[i]; + fullTrace = calcTrace[0].trace; + + if(fullTrace.type === 'funnel') { + for(j = 0; j < calcTrace.length; j++) { + bar = calcTrace[j]; + + if(bar.s !== BADNUM) { + // create base of funnels + sieve.put(bar.p, -0.5 * bar.s); + } + } + } + } + + for(i = 0; i < calcTraces.length; i++) { + calcTrace = calcTraces[i]; + fullTrace = calcTrace[0].trace; + + isFunnel = (fullTrace.type === 'funnel'); - for(var i = 0; i < calcTraces.length; i++) { - var calcTrace = calcTraces[i]; - var fullTrace = calcTrace[0].trace; var pts = []; - for(var j = 0; j < calcTrace.length; j++) { - var bar = calcTrace[j]; + for(j = 0; j < calcTrace.length; j++) { + bar = calcTrace[j]; if(bar.s !== BADNUM) { // stack current bar and get previous sum - var barBase = sieve.put(bar.p, bar.b + bar.s); - var barTop = barBase + bar.b + bar.s; + var value; + if(isFunnel) { + value = bar.s; + } else { + value = bar.s + bar.b; + } - // store the bar base and top in each calcdata item - bar.b = barBase; - bar[sLetter] = barTop; + var base = sieve.put(bar.p, value); + + var top = base + value; - if(!barnorm) { - pts.push(barTop); - if(bar.hasB) pts.push(barBase); + // store the bar base and top in each calcdata item + bar.b = base; + bar[sLetter] = top; + + if(!opts.norm) { + pts.push(top); + if(bar.hasB) { + pts.push(base); + } } } } // if barnorm is set, let normalizeBars update the axis range - if(!barnorm) { + if(!opts.norm) { fullTrace._extremes[sa._id] = Axes.findExtremes(sa, pts, { // N.B. we don't stack base with 'base', // so set tozero:true always! @@ -566,7 +590,7 @@ function stackBars(gd, sa, sieve) { } } -function sieveBars(gd, sa, sieve) { +function sieveBars(sieve) { var calcTraces = sieve.traces; for(var i = 0; i < calcTraces.length; i++) { @@ -582,7 +606,7 @@ function sieveBars(gd, sa, sieve) { } } -function unhideBarsWithinTrace(gd, sa, sieve) { +function unhideBarsWithinTrace(sieve) { var calcTraces = sieve.traces; for(var i = 0; i < calcTraces.length; i++) { @@ -600,12 +624,12 @@ function unhideBarsWithinTrace(gd, sa, sieve) { if(bar.p !== BADNUM) { // stack current bar and get previous sum - var barBase = inTraceSieve.put(bar.p, bar.b + bar.s); + var base = inTraceSieve.put(bar.p, bar.b + bar.s); // if previous sum if non-zero, this means: // multiple bars have same starting point are potentially hidden, // shift them vertically so that all bars are visible by default - if(barBase) bar.b = barBase; + if(base) bar.b = base; } } } @@ -616,14 +640,13 @@ function unhideBarsWithinTrace(gd, sa, sieve) { // // normalizeBars requires that either sieveBars or stackBars has been // previously invoked. -function normalizeBars(gd, sa, sieve) { - var fullLayout = gd._fullLayout; +function normalizeBars(sa, sieve, opts) { var calcTraces = sieve.traces; var sLetter = getAxisLetter(sa); - var sTop = fullLayout.barnorm === 'fraction' ? 1 : 100; + var sTop = opts.norm === 'fraction' ? 1 : 100; var sTiny = sTop / 1e9; // in case of rounding error in sum var sMin = sa.l2c(sa.c2l(0)); - var sMax = fullLayout.barmode === 'stack' ? sTop : sMin; + var sMax = opts.mode === 'stack' ? sTop : sMin; function needsPadding(v) { return ( @@ -636,7 +659,7 @@ function normalizeBars(gd, sa, sieve) { var calcTrace = calcTraces[i]; var fullTrace = calcTrace[0].trace; var pts = []; - var allBarBaseAboveZero = true; + var allBaseAboveZero = true; var padded = false; for(var j = 0; j < calcTrace.length; j++) { @@ -647,26 +670,26 @@ function normalizeBars(gd, sa, sieve) { bar.b *= scale; bar.s *= scale; - var barBase = bar.b; - var barTop = barBase + bar.s; + var base = bar.b; + var top = base + bar.s; - bar[sLetter] = barTop; - pts.push(barTop); - padded = padded || needsPadding(barTop); + bar[sLetter] = top; + pts.push(top); + padded = padded || needsPadding(top); if(bar.hasB) { - pts.push(barBase); - padded = padded || needsPadding(barBase); + pts.push(base); + padded = padded || needsPadding(base); } if(!bar.hasB || !(bar.b > 0 && bar.s > 0)) { - allBarBaseAboveZero = false; + allBaseAboveZero = false; } } } fullTrace._extremes[sa._id] = Axes.findExtremes(sa, pts, { - tozero: !allBarBaseAboveZero, + tozero: !allBaseAboveZero, padded: padded }); } diff --git a/src/traces/bar/defaults.js b/src/traces/bar/defaults.js index e8e83ac35f0..70355cf5f9a 100644 --- a/src/traces/bar/defaults.js +++ b/src/traces/bar/defaults.js @@ -142,6 +142,11 @@ function handleText(traceIn, traceOut, layout, coerce, moduleHasSelUnselected) { } coerce('cliponaxis'); + coerce('textangle'); + } + + if(hasInside) { + coerce('insidetextanchor'); } } diff --git a/src/traces/bar/index.js b/src/traces/bar/index.js index 2da83f64c5b..dd9a8bbf72b 100644 --- a/src/traces/bar/index.js +++ b/src/traces/bar/index.js @@ -28,7 +28,7 @@ Bar.selectPoints = require('./select'); Bar.moduleType = 'trace'; Bar.name = 'bar'; Bar.basePlotModule = require('../../plots/cartesian'); -Bar.categories = ['cartesian', 'svg', 'bar', 'oriented', 'errorBarsOK', 'showLegend', 'zoomScale']; +Bar.categories = ['bar-like', 'cartesian', 'svg', 'bar', 'oriented', 'errorBarsOK', 'showLegend', 'zoomScale']; Bar.meta = { description: [ 'The data visualized by the span of the bars is set in `y`', diff --git a/src/traces/bar/plot.js b/src/traces/bar/plot.js index 41ef3d39eda..4a989631318 100644 --- a/src/traces/bar/plot.js +++ b/src/traces/bar/plot.js @@ -20,26 +20,54 @@ var Drawing = require('../../components/drawing'); var Registry = require('../../registry'); var tickText = require('../../plots/cartesian/axes').tickText; +var style = require('./style'); +var helpers = require('./helpers'); var attributes = require('./attributes'); + var attributeText = attributes.text; var attributeTextPosition = attributes.textposition; -var style = require('./style'); -var helpers = require('./helpers'); // padding in pixels around text var TEXTPAD = 3; -module.exports = function plot(gd, plotinfo, cdModule, traceLayer) { +function dirSign(a, b) { + return (a < b) ? 1 : -1; +} + +function getXY(di, xa, ya, isHorizontal) { + var s = []; + var p = []; + + var sAxis = isHorizontal ? xa : ya; + var pAxis = isHorizontal ? ya : xa; + + s[0] = sAxis.c2p(di.s0, true); + p[0] = pAxis.c2p(di.p0, true); + + s[1] = sAxis.c2p(di.s1, true); + p[1] = pAxis.c2p(di.p1, true); + + return isHorizontal ? [s, p] : [p, s]; +} + +module.exports = function plot(gd, plotinfo, cdModule, traceLayer, opts) { var xa = plotinfo.xaxis; var ya = plotinfo.yaxis; var fullLayout = gd._fullLayout; + if(!opts) { + opts = { + mode: fullLayout.barmode, + norm: fullLayout.barmode, + gap: fullLayout.bargap, + groupgap: fullLayout.bargroupgap + }; + } + 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 trace = cd[0].trace; - var adjustDir; var adjustPixel = 0; if(trace.type === 'waterfall' && trace.connector.visible && trace.connector.mode === 'between') { adjustPixel = trace.connector.line.width / 2; @@ -47,7 +75,7 @@ module.exports = function plot(gd, plotinfo, cdModule, traceLayer) { var isHorizontal = (trace.orientation === 'h'); - if(!plotinfo.isRangePlot) cd0.node3 = plotGroup; + if(!plotinfo.isRangePlot) cd[0].node3 = plotGroup; var pointGroup = Lib.ensureSingle(plotGroup, 'g', 'points'); @@ -65,18 +93,13 @@ module.exports = function plot(gd, plotinfo, cdModule, traceLayer) { // clipped xf/yf (2nd arg true): non-positive // log values go off-screen by plotwidth // so you see them continue if you drag the plot - var x0, x1, y0, y1; - if(isHorizontal) { - y0 = ya.c2p(di.p0, true); - y1 = ya.c2p(di.p1, true); - x0 = xa.c2p(di.s0, true); - x1 = xa.c2p(di.s1, true); - } else { - x0 = xa.c2p(di.p0, true); - x1 = xa.c2p(di.p1, true); - y0 = ya.c2p(di.s0, true); - y1 = ya.c2p(di.s1, true); - } + + var xy = getXY(di, xa, ya, isHorizontal); + + var x0 = xy[0][0]; + var x1 = xy[0][1]; + var y0 = xy[1][0]; + var y1 = xy[1][1]; var isBlank = di.isBlank = ( !isNumeric(x0) || !isNumeric(x1) || @@ -87,19 +110,16 @@ module.exports = function plot(gd, plotinfo, cdModule, traceLayer) { // 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; + x0 -= dirSign(x0, x1) * adjustPixel; + x1 += dirSign(x0, x1) * adjustPixel; } else { - adjustDir = (y1 < y0) ? -1 : 1; - y0 -= adjustDir * adjustPixel; - y1 += adjustDir * adjustPixel; + y0 -= dirSign(y0, y1) * adjustPixel; + y1 += dirSign(y0, y1) * adjustPixel; } } var lw; var mc; - var prefix; if(trace.type === 'waterfall') { if(!isBlank) { @@ -107,22 +127,18 @@ module.exports = function plot(gd, plotinfo, cdModule, traceLayer) { 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 (bargap === 0 && bargroupgap === 0) ? + return (opts.gap === 0 && opts.groupgap === 0) ? d3.round(Math.round(v) - offset, 2) : v; } @@ -157,7 +173,7 @@ module.exports = function plot(gd, plotinfo, cdModule, traceLayer) { .attr('d', isBlank ? 'M0,0Z' : 'M' + x0 + ',' + y0 + 'V' + y1 + 'H' + x1 + 'V' + y0 + 'Z') .call(Drawing.setClipUrl, plotinfo.layerClipId, gd); - appendBarText(gd, plotinfo, bar, cd, i, x0, x1, y0, y1); + appendBarText(gd, plotinfo, bar, cd, i, x0, x1, y0, y1, opts); if(plotinfo.layerClipId) { Drawing.hideOutsideRangePoint(di, bar.select('text'), xa, ya, trace.xcalendar, trace.ycalendar); @@ -166,7 +182,7 @@ module.exports = function plot(gd, plotinfo, cdModule, traceLayer) { // lastly, clip points groups of `cliponaxis !== false` traces // on `plotinfo._hasClipOnAxisFalse === true` subplots - var hasClipOnAxisFalse = cd0.trace.cliponaxis === false; + var hasClipOnAxisFalse = trace.cliponaxis === false; Drawing.setClipUrl(plotGroup, hasClipOnAxisFalse ? null : plotinfo.layerClipId, gd); }); @@ -174,7 +190,7 @@ module.exports = function plot(gd, plotinfo, cdModule, traceLayer) { Registry.getComponentMethod('errorbars', 'plot')(gd, bartraces, plotinfo); }; -function appendBarText(gd, plotinfo, bar, calcTrace, i, x0, x1, y0, y1) { +function appendBarText(gd, plotinfo, bar, calcTrace, i, x0, x1, y0, y1, opts) { var xa = plotinfo.xaxis; var ya = plotinfo.yaxis; @@ -200,21 +216,24 @@ function appendBarText(gd, plotinfo, bar, calcTrace, i, x0, x1, y0, y1) { // get trace attributes var trace = calcTrace[0].trace; - var orientation = trace.orientation; + var isHorizontal = (trace.orientation === 'h'); var text = getText(calcTrace, i, xa, ya); textPosition = getTextPosition(trace, i); // compute text position - var prefix = trace.type === 'waterfall' ? 'waterfall' : 'bar'; - var barmode = fullLayout[prefix + 'mode']; - var inStackOrRelativeMode = barmode === 'stack' || barmode === 'relative'; + var inStackOrRelativeMode = + opts.mode === 'stack' || + opts.mode === 'relative'; var calcBar = calcTrace[i]; var isOutmostBar = !inStackOrRelativeMode || calcBar._outmost; - if(!text || textPosition === 'none' || - (calcBar.isBlank && (textPosition === 'auto' || textPosition === 'inside'))) { + if(!text || + textPosition === 'none' || + (calcBar.isBlank && ( + textPosition === 'auto' || + textPosition === 'inside'))) { bar.select('text').remove(); return; } @@ -227,7 +246,7 @@ function appendBarText(gd, plotinfo, bar, calcTrace, i, x0, x1, y0, y1) { // Special case: don't use the c2p(v, true) value on log size axes, // so that we can get correctly inside text scaling var di = bar.datum(); - if(orientation === 'h') { + if(isHorizontal) { if(xa.type === 'log' && di.s0 <= 0) { if(xa.range[0] < xa.range[1]) { x0 = 0; @@ -271,12 +290,15 @@ function appendBarText(gd, plotinfo, bar, calcTrace, i, x0, x1, y0, y1) { var textHasSize = (textWidth > 0 && textHeight > 0); var fitsInside = (textWidth <= barWidth && textHeight <= barHeight); var fitsInsideIfRotated = (textWidth <= barHeight && textHeight <= barWidth); - var fitsInsideIfShrunk = (orientation === 'h') ? + var fitsInsideIfShrunk = (isHorizontal) ? (barWidth >= textWidth * (barHeight / textHeight)) : (barHeight >= textHeight * (barWidth / textWidth)); - if(textHasSize && - (fitsInside || fitsInsideIfRotated || fitsInsideIfShrunk)) { + if(textHasSize && ( + fitsInside || + fitsInsideIfRotated || + fitsInsideIfShrunk) + ) { textPosition = 'inside'; } else { textPosition = 'outside'; @@ -306,150 +328,149 @@ function appendBarText(gd, plotinfo, bar, calcTrace, i, x0, x1, y0, y1) { // compute text transform var transform, constrained; if(textPosition === 'outside') { - constrained = trace.constraintext === 'both' || trace.constraintext === 'outside'; + constrained = + trace.constraintext === 'both' || + trace.constraintext === 'outside'; + transform = getTransformToMoveOutsideBar(x0, x1, y0, y1, textBB, - orientation, constrained); + isHorizontal, constrained, trace.textangle); } else { - constrained = trace.constraintext === 'both' || trace.constraintext === 'inside'; + constrained = + trace.constraintext === 'both' || + trace.constraintext === 'inside'; + transform = getTransformToMoveInsideBar(x0, x1, y0, y1, textBB, - orientation, constrained); + isHorizontal, constrained, trace.textangle, trace.insidetextanchor); } textSelection.attr('transform', transform); } -function getTransformToMoveInsideBar(x0, x1, y0, y1, textBB, orientation, constrained) { - // compute text and target positions +function getRotationFromAngle(angle) { + return (angle === 'auto') ? 0 : angle; +} + +function getTransformToMoveInsideBar(x0, x1, y0, y1, textBB, isHorizontal, constrained, angle, anchor) { var textWidth = textBB.width; var textHeight = textBB.height; - var textX = (textBB.left + textBB.right) / 2; - var textY = (textBB.top + textBB.bottom) / 2; - var barWidth = Math.abs(x1 - x0); - var barHeight = Math.abs(y1 - y0); - var targetWidth; - var targetHeight; - var targetX; - var targetY; - - // apply text padding - var textpad; - if(barWidth > (2 * TEXTPAD) && barHeight > (2 * TEXTPAD)) { - textpad = TEXTPAD; - barWidth -= 2 * textpad; - barHeight -= 2 * textpad; - } else textpad = 0; - - // compute rotation and scale - var rotate, - scale; + var lx = Math.abs(x1 - x0); + var ly = Math.abs(y1 - y0); + + var textpad = ( + lx > (2 * TEXTPAD) && + ly > (2 * TEXTPAD) + ) ? TEXTPAD : 0; + + lx -= 2 * textpad; + ly -= 2 * textpad; + + var autoRotate = (angle === 'auto'); + var isAutoRotated = false; + if(autoRotate && + !(textWidth <= lx && textHeight <= ly) && + (textWidth > lx || textHeight > ly) && ( + !(textWidth > ly || textHeight > lx) || + ((textWidth < textHeight) !== (lx < ly)) + )) { + isAutoRotated = true; + } - if(textWidth <= barWidth && textHeight <= barHeight) { - // no scale or rotation is required - rotate = false; - scale = 1; - } else if(textWidth <= barHeight && textHeight <= barWidth) { - // only rotation is required - rotate = true; - scale = 1; - } else if((textWidth < textHeight) === (barWidth < barHeight)) { - // only scale is required - rotate = false; - scale = constrained ? Math.min(barWidth / textWidth, barHeight / textHeight) : 1; - } else { - // both scale and rotation are required - rotate = true; - scale = constrained ? Math.min(barHeight / textWidth, barWidth / textHeight) : 1; + if(isAutoRotated) { + // don't rotate yet only swap bar width with height + var tmp = ly; + ly = lx; + lx = tmp; } - if(rotate) rotate = 90; // rotate clockwise + var rotation = getRotationFromAngle(angle); + var absSin = Math.abs(Math.sin(Math.PI / 180 * rotation)); + var absCos = Math.abs(Math.cos(Math.PI / 180 * rotation)); + + // compute and apply text padding + var dx = Math.max(lx * absCos, ly * absSin); + var dy = Math.max(lx * absSin, ly * absCos); + + var scale = (constrained) ? + Math.min(dx / textWidth, dy / textHeight) : + Math.max(absCos, absSin); + + scale = Math.min(1, scale); // compute text and target positions - if(rotate) { - targetWidth = scale * textHeight; - targetHeight = scale * textWidth; - } else { - targetWidth = scale * textWidth; - targetHeight = scale * textHeight; - } + var targetX = (x0 + x1) / 2; + var targetY = (y0 + y1) / 2; - if(orientation === 'h') { - if(x1 < x0) { - // bar end is on the left hand side - targetX = x1 + textpad + targetWidth / 2; - targetY = (y0 + y1) / 2; - } else { - targetX = x1 - textpad - targetWidth / 2; - targetY = (y0 + y1) / 2; - } - } else { - if(y1 > y0) { - // bar end is on the bottom - targetX = (x0 + x1) / 2; - targetY = y1 - textpad - targetHeight / 2; + if(anchor !== 'middle') { // case of 'start' or 'end' + var targetWidth = scale * (isHorizontal !== isAutoRotated ? textHeight : textWidth); + var targetHeight = scale * (isHorizontal !== isAutoRotated ? textWidth : textHeight); + textpad += 0.5 * (targetWidth * absSin + targetHeight * absCos); + + if(isHorizontal) { + textpad *= dirSign(x0, x1); + targetX = (anchor === 'start') ? x0 + textpad : x1 - textpad; } else { - targetX = (x0 + x1) / 2; - targetY = y1 + textpad + targetHeight / 2; + textpad *= dirSign(y0, y1); + targetY = (anchor === 'start') ? y0 + textpad : y1 - textpad; } } - return getTransform(textX, textY, targetX, targetY, scale, rotate); + var textX = (textBB.left + textBB.right) / 2; + var textY = (textBB.top + textBB.bottom) / 2; + + // lastly apply auto rotation + if(isAutoRotated) rotation += 90; + + return getTransform(textX, textY, targetX, targetY, scale, rotation); } -function getTransformToMoveOutsideBar(x0, x1, y0, y1, textBB, orientation, constrained) { - var barWidth = (orientation === 'h') ? - Math.abs(y1 - y0) : - Math.abs(x1 - x0); - var textpad; +function getTransformToMoveOutsideBar(x0, x1, y0, y1, textBB, isHorizontal, constrained, angle) { + var textWidth = textBB.width; + var textHeight = textBB.height; + var lx = Math.abs(x1 - x0); + var ly = Math.abs(y1 - y0); + var textpad; // Keep the padding so the text doesn't sit right against // the bars, but don't factor it into barWidth - if(barWidth > 2 * TEXTPAD) { - textpad = TEXTPAD; + if(isHorizontal) { + textpad = (ly > 2 * TEXTPAD) ? TEXTPAD : 0; + } else { + textpad = (lx > 2 * TEXTPAD) ? TEXTPAD : 0; } // compute rotation and scale var scale = 1; if(constrained) { - scale = (orientation === 'h') ? - Math.min(1, barWidth / textBB.height) : - Math.min(1, barWidth / textBB.width); + scale = (isHorizontal) ? + Math.min(1, ly / textHeight) : + Math.min(1, lx / textWidth); } + var rotation = getRotationFromAngle(angle); + var absSin = Math.abs(Math.sin(Math.PI / 180 * rotation)); + var absCos = Math.abs(Math.cos(Math.PI / 180 * rotation)); + // compute text and target positions - var textX = (textBB.left + textBB.right) / 2; - var textY = (textBB.top + textBB.bottom) / 2; - var targetWidth; - var targetHeight; - var targetX; - var targetY; - - targetWidth = scale * textBB.width; - targetHeight = scale * textBB.height; - - if(orientation === 'h') { - if(x1 < x0) { - // bar end is on the left hand side - targetX = x1 - textpad - targetWidth / 2; - targetY = (y0 + y1) / 2; - } else { - targetX = x1 + textpad + targetWidth / 2; - targetY = (y0 + y1) / 2; - } + var targetWidth = scale * (isHorizontal ? textHeight : textWidth); + var targetHeight = scale * (isHorizontal ? textWidth : textHeight); + textpad += 0.5 * (targetWidth * absSin + targetHeight * absCos); + + var targetX = (x0 + x1) / 2; + var targetY = (y0 + y1) / 2; + + if(isHorizontal) { + targetX = x1 - textpad * dirSign(x1, x0); } else { - if(y1 > y0) { - // bar end is on the bottom - targetX = (x0 + x1) / 2; - targetY = y1 + textpad + targetHeight / 2; - } else { - targetX = (x0 + x1) / 2; - targetY = y1 - textpad - targetHeight / 2; - } + targetY = y1 + textpad * dirSign(y0, y1); } - return getTransform(textX, textY, targetX, targetY, scale, false); + var textX = (textBB.left + textBB.right) / 2; + var textY = (textBB.top + textBB.bottom) / 2; + + return getTransform(textX, textY, targetX, targetY, scale, rotation); } -function getTransform(textX, textY, targetX, targetY, scale, rotate) { +function getTransform(textX, textY, targetX, targetY, scale, rotation) { var transformScale; var transformRotate; var transformTranslate; @@ -460,8 +481,8 @@ function getTransform(textX, textY, targetX, targetY, scale, rotate) { transformScale = ''; } - transformRotate = (rotate) ? - 'rotate(' + rotate + ' ' + textX + ' ' + textY + ') ' : ''; + transformRotate = (rotation) ? + 'rotate(' + rotation + ' ' + textX + ' ' + textY + ') ' : ''; // Note that scaling also affects the center of the text box var translateX = (targetX - scale * textX); @@ -493,10 +514,14 @@ function calcTextinfo(calcTrace, index, xa, ya) { var trace = calcTrace[0].trace; var isHorizontal = (trace.orientation === 'h'); + function formatLabel(u) { + var pAxis = isHorizontal ? ya : xa; + return tickText(pAxis, u, true).text; // TODO: may pass false here to drop the parent category? + } + function formatNumber(v) { var sAxis = isHorizontal ? xa : ya; - var hover = false; - return tickText(sAxis, +v, hover).text; + return tickText(sAxis, +v, true).text; } var textinfo = trace.textinfo; @@ -504,19 +529,16 @@ function calcTextinfo(calcTrace, index, xa, ya) { var parts = textinfo.split('+'); var text = []; + var tx; var hasFlag = function(flag) { return parts.indexOf(flag) !== -1; }; if(hasFlag('label')) { - if(isHorizontal) { - text.push(trace.y[index]); - } else { - text.push(trace.x[index]); - } + text.push(formatLabel(calcTrace[index].p)); } if(hasFlag('text')) { - var tx = Lib.castOption(trace, cdi.i, 'text'); + tx = Lib.castOption(trace, cdi.i, 'text'); if(tx) text.push(tx); } @@ -530,5 +552,36 @@ function calcTextinfo(calcTrace, index, xa, ya) { if(hasFlag('final')) text.push(formatNumber(final)); } + if(trace.type === 'funnel') { + if(hasFlag('value')) text.push(formatNumber(cdi.s)); + + var nPercent = 0; + if(hasFlag('percent initial')) nPercent++; + if(hasFlag('percent previous')) nPercent++; + if(hasFlag('percent total')) nPercent++; + + var hasMultiplePercents = nPercent > 1; + + if(hasFlag('percent initial')) { + tx = formatPercent(cdi.begR); + if(hasMultiplePercents) tx += ' of initial'; + text.push(tx); + } + if(hasFlag('percent previous')) { + tx = formatPercent(cdi.difR); + if(hasMultiplePercents) tx += ' of previous'; + text.push(tx); + } + if(hasFlag('percent total')) { + tx = formatPercent(cdi.sumR); + if(hasMultiplePercents) tx += ' of total'; + text.push(tx); + } + } + return text.join('
'); } + +function formatPercent(ratio) { + return Math.round(100 * ratio) + '%'; +} diff --git a/src/traces/bar/select.js b/src/traces/bar/select.js index 2b918d7fb85..98233511bd0 100644 --- a/src/traces/bar/select.js +++ b/src/traces/bar/select.js @@ -13,6 +13,8 @@ module.exports = function selectPoints(searchInfo, selectionTester) { var xa = searchInfo.xaxis; var ya = searchInfo.yaxis; var trace = cd[0].trace; + var isFunnel = (trace.type === 'funnel'); + var isHorizontal = (trace.orientation === 'h'); var selection = []; var i; @@ -22,13 +24,9 @@ module.exports = function selectPoints(searchInfo, selectionTester) { cd[i].selected = 0; } } else { - var getCentroid = trace.orientation === 'h' ? - function(d) { return [xa.c2p(d.s1, true), (ya.c2p(d.p0, true) + ya.c2p(d.p1, true)) / 2]; } : - function(d) { return [(xa.c2p(d.p0, true) + xa.c2p(d.p1, true)) / 2, ya.c2p(d.s1, true)]; }; - for(i = 0; i < cd.length; i++) { var di = cd[i]; - var ct = 'ct' in di ? di.ct : getCentroid(di); + var ct = 'ct' in di ? di.ct : getCentroid(di, xa, ya, isHorizontal, isFunnel); if(selectionTester.contains(ct, false, i, searchInfo)) { selection.push({ @@ -45,3 +43,20 @@ module.exports = function selectPoints(searchInfo, selectionTester) { return selection; }; + +function getCentroid(d, xa, ya, isHorizontal, isFunnel) { + var x0 = xa.c2p(isHorizontal ? d.s0 : d.p0, true); + var x1 = xa.c2p(isHorizontal ? d.s1 : d.p1, true); + var y0 = ya.c2p(isHorizontal ? d.p0 : d.s0, true); + var y1 = ya.c2p(isHorizontal ? d.p1 : d.s1, true); + + if(isFunnel) { + return [(x0 + x1) / 2, (y0 + y1) / 2]; + } else { + if(isHorizontal) { + return [x1, (y0 + y1) / 2]; + } else { + return [(x0 + x1) / 2, y1]; + } + } +} diff --git a/src/traces/bar/sieve.js b/src/traces/bar/sieve.js index 3593365da6e..541b58dad3b 100644 --- a/src/traces/bar/sieve.js +++ b/src/traces/bar/sieve.js @@ -10,7 +10,7 @@ module.exports = Sieve; -var Lib = require('../../lib'); +var distinctVals = require('../../lib').distinctVals; var BADNUM = require('../../constants/numerical').BADNUM; /** @@ -48,7 +48,7 @@ function Sieve(traces, opts) { } this.positions = positions; - var dv = Lib.distinctVals(positions); + var dv = distinctVals(positions); this.distinctPositions = dv.vals; if(dv.vals.length === 1 && width1 !== Infinity) this.minDiff = width1; else this.minDiff = Math.min(dv.minDiff, width1); diff --git a/src/traces/barpolar/calc.js b/src/traces/barpolar/calc.js index 8474c87596c..0b66e31d7dc 100644 --- a/src/traces/barpolar/calc.js +++ b/src/traces/barpolar/calc.js @@ -96,11 +96,12 @@ function crossTraceCalc(gd, polarLayout, subplotId) { var rAxis = extendFlat({}, polarLayout.radialaxis, {_id: 'x'}); var aAxis = polarLayout.angularaxis; - // 'bargap', 'barmode' are in _fullLayout.polar - // TODO clean up setGroupPositions API instead - var mockGd = {_fullLayout: polarLayout}; - - setGroupPositions(mockGd, aAxis, rAxis, barPolarCd); + setGroupPositions(gd, aAxis, rAxis, barPolarCd, { + mode: polarLayout.barmode, + norm: polarLayout.barnorm, + gap: polarLayout.bargap, + groupgap: polarLayout.bargroupgap + }); } module.exports = { diff --git a/src/traces/funnel/arrays_to_calcdata.js b/src/traces/funnel/arrays_to_calcdata.js new file mode 100644 index 00000000000..a722f3dec04 --- /dev/null +++ b/src/traces/funnel/arrays_to_calcdata.js @@ -0,0 +1,31 @@ +/** +* 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 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; + + mergeArray(trace.text, cd, 'tx'); + mergeArray(trace.hovertext, cd, 'htx'); + + var marker = trace.marker; + if(marker) { + mergeArray(marker.opacity, cd, 'mo'); + mergeArray(marker.color, cd, 'mc'); + + var markerLine = marker.line; + if(markerLine) { + mergeArray(markerLine.color, cd, 'mlc'); + mergeArray(markerLine.width, cd, 'mlw'); + } + } +}; diff --git a/src/traces/funnel/attributes.js b/src/traces/funnel/attributes.js new file mode 100644 index 00000000000..045ea7bca02 --- /dev/null +++ b/src/traces/funnel/attributes.js @@ -0,0 +1,100 @@ +/** +* 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; +var Color = require('../../components/color'); + +module.exports = { + x: barAttrs.x, + x0: barAttrs.x0, + dx: barAttrs.dx, + y: barAttrs.y, + y0: barAttrs.y0, + dy: barAttrs.dy, + + hovertext: barAttrs.hovertext, + hovertemplate: barAttrs.hovertemplate, + + textinfo: { + valType: 'flaglist', + flags: ['label', 'text', 'percent initial', 'percent previous', 'percent total', 'value'], + extras: ['none'], + role: 'info', + editType: 'plot', + arrayOk: false, + description: [ + 'Determines which trace information appear on the graph.', + 'In the case of having multiple funnels, percentages & totals', + 'are computed separately (per trace).' + ].join(' ') + }, + + text: barAttrs.text, + textposition: extendFlat({}, barAttrs.textposition, {dflt: 'auto'}), + insidetextanchor: extendFlat({}, barAttrs.insidetextanchor, {dflt: 'middle'}), + textangle: extendFlat({}, barAttrs.textangle, {dflt: 0}), + textfont: barAttrs.textfont, + insidetextfont: barAttrs.insidetextfont, + outsidetextfont: barAttrs.outsidetextfont, + constraintext: barAttrs.constraintext, + cliponaxis: barAttrs.cliponaxis, + + orientation: extendFlat({}, barAttrs.orientation, { + description: [ + 'Sets the orientation of the funnels.', + 'With *v* (*h*), the value of the each bar spans', + 'along the vertical (horizontal).', + 'By default funnels are tend to be oriented horizontally;', + 'unless only *y* array is presented or orientation is set to *v*.', + 'Also regarding graphs including only \'horizontal\' funnels,', + '*autorange* on the *y-axis* are set to *reversed*.' + ].join(' ') + }), + + offset: extendFlat({}, barAttrs.offset, {arrayOk: false}), + width: extendFlat({}, barAttrs.width, {arrayOk: false}), + + marker: barAttrs.marker, + + connector: { + fillcolor: { + valType: 'color', + role: 'style', + editType: 'style', + description: [ + 'Sets the fill color.' + ].join(' ') + }, + line: { + color: extendFlat({}, lineAttrs.color, {dflt: Color.defaultLine}), + width: extendFlat({}, lineAttrs.width, { + dflt: 0, + editType: 'plot', + }), + dash: lineAttrs.dash, + editType: 'style' + }, + visible: { + valType: 'boolean', + dflt: true, + role: 'info', + editType: 'plot', + description: [ + 'Determines if connector regions and lines are drawn.' + ].join(' ') + }, + editType: 'plot' + }, + + offsetgroup: barAttrs.offsetgroup, + alignmentgroup: barAttrs.alignmentgroup +}; diff --git a/src/traces/funnel/calc.js b/src/traces/funnel/calc.js new file mode 100644 index 00000000000..e26ada2934d --- /dev/null +++ b/src/traces/funnel/calc.js @@ -0,0 +1,92 @@ +/** +* 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 arraysToCalcdata = require('./arrays_to_calcdata'); +var calcSelection = require('../scatter/calc_selection'); +var BADNUM = require('../../constants/numerical').BADNUM; + +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, i, cdi; + + 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); + + // Unlike other bar-like traces funnels do not support base attribute. + // bases for funnels are computed internally in a way that + // the mid-point of each bar are located on the axis line. + trace._base = []; + + // set position and size + for(i = 0; i < serieslen; i++) { + // treat negative values as bad numbers + if(size[i] < 0) size[i] = BADNUM; + + var connectToNext = false; + if(size[i] !== BADNUM) { + if(i + 1 < serieslen && size[i + 1] !== BADNUM) { + connectToNext = true; + } + } + + cdi = cd[i] = { + p: pos[i], + s: size[i], + cNext: connectToNext + }; + + trace._base[i] = -0.5 * cdi.s; + + if(trace.ids) { + cdi.id = String(trace.ids[i]); + } + + // calculate total values + if(i === 0) cd[0].vTotal = 0; + cd[0].vTotal += fixNum(cdi.s); + + // ratio from initial value + cdi.begR = fixNum(cdi.s) / fixNum(cd[0].s); + } + + var prevGoodNum; + for(i = 0; i < serieslen; i++) { + cdi = cd[i]; + if(cdi.s === BADNUM) continue; + + // ratio of total value + cdi.sumR = cdi.s / cd[0].vTotal; + + // ratio of previous (good) value + cdi.difR = (prevGoodNum !== undefined) ? cdi.s / prevGoodNum : 1; + + prevGoodNum = cdi.s; + } + + arraysToCalcdata(cd, trace); + calcSelection(cd, trace); + + return cd; +}; + +function fixNum(a) { + return (a === BADNUM) ? 0 : a; +} diff --git a/src/traces/funnel/cross_trace_calc.js b/src/traces/funnel/cross_trace_calc.js new file mode 100644 index 00000000000..31654d3ae21 --- /dev/null +++ b/src/traces/funnel/cross_trace_calc.js @@ -0,0 +1,69 @@ +/** +* 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 funnels = []; + var funnelsVert = []; + var funnelsHorz = []; + var cd, i; + + for(i = 0; i < fullData.length; i++) { + var fullTrace = fullData[i]; + var isHorizontal = (fullTrace.orientation === 'h'); + + if( + fullTrace.visible === true && + fullTrace.xaxis === xa._id && + fullTrace.yaxis === ya._id && + fullTrace.type === 'funnel' + ) { + cd = calcdata[i]; + + if(isHorizontal) { + funnelsHorz.push(cd); + } else { + funnelsVert.push(cd); + } + + funnels.push(cd); + } + } + + var opts = { + mode: fullLayout.funnelmode, + norm: fullLayout.funnelnorm, + gap: fullLayout.funnelgap, + groupgap: fullLayout.funnelgroupgap + }; + + setGroupPositions(gd, xa, ya, funnelsVert, opts); + setGroupPositions(gd, ya, xa, funnelsHorz, opts); + + for(i = 0; i < funnels.length; i++) { + cd = funnels[i]; + + for(var j = 0; j < cd.length; j++) { + if(j + 1 < cd.length) { + cd[j].nextP0 = cd[j + 1].p0; + cd[j].nextS0 = cd[j + 1].s0; + + cd[j].nextP1 = cd[j + 1].p1; + cd[j].nextS1 = cd[j + 1].s1; + } + } + } +}; diff --git a/src/traces/funnel/defaults.js b/src/traces/funnel/defaults.js new file mode 100644 index 00000000000..da560a1f8c0 --- /dev/null +++ b/src/traces/funnel/defaults.js @@ -0,0 +1,90 @@ +/** +* 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'); + +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('orientation', (traceOut.y && !traceOut.x) ? 'v' : 'h'); + coerce('offset'); + coerce('width'); + + var text = coerce('text'); + + coerce('hovertext'); + coerce('hovertemplate'); + + handleText(traceIn, traceOut, layout, coerce, false); + + // TODO: move this block to bar handleText if/when textinfo implimented for bars/histograms + if(traceOut.textposition !== 'none') { + var defaultTextinfo = + Lib.isArrayOrTypedArray(text) ? 'text+value' : 'value'; + coerce('textinfo', defaultTextinfo); + } + + var markerColor = coerce('marker.color', defaultColor); + coerce('marker.line.color', Color.defaultLine); + coerce('marker.line.width'); + + var connectorVisible = coerce('connector.visible'); + if(connectorVisible) { + coerce('connector.fillcolor', defaultFillColor(markerColor)); + + var connectorLineWidth = coerce('connector.line.width'); + if(connectorLineWidth) { + coerce('connector.line.color'); + coerce('connector.line.dash'); + } + } +} + +function defaultFillColor(markerColor) { + var cBase = Lib.isArrayOrTypedArray(markerColor) ? '#000' : markerColor; + + return Color.addOpacity(cBase, 0.5 * Color.opacity(cBase)); +} + +function crossTraceDefaults(fullData, fullLayout) { + var traceIn, traceOut; + + function coerce(attr) { + return Lib.coerce(traceOut._input, traceOut, attributes, attr); + } + + if(fullLayout.funnelmode === '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 +}; diff --git a/src/traces/funnel/hover.js b/src/traces/funnel/hover.js new file mode 100644 index 00000000000..a31455f0efc --- /dev/null +++ b/src/traces/funnel/hover.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 opacity = require('../../components/color').opacity; +var hoverOnBars = require('../bar/hover').hoverOnBars; + +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; + var isHorizontal = (trace.orientation === 'h'); + + // the closest data point + var index = point.index; + var di = cd[index]; + + var sizeLetter = isHorizontal ? 'x' : 'y'; + + point[sizeLetter + 'LabelVal'] = di.s; + + // display ratio to initial value + point.extraText = [ + formatPercent(di.begR) + ' of initial', + formatPercent(di.difR) + ' of previous', + formatPercent(di.sumR) + ' of total' + ].join('
'); + // TODO: Should we use pieHelpers.formatPieValue instead ? + + point.color = getTraceColor(trace, di); + + return [point]; +}; + +function getTraceColor(trace, di) { + var cont = trace.marker; + var mc = di.mc || cont.color; + var mlc = di.mlc || cont.line.color; + var mlw = di.mlw || cont.line.width; + if(opacity(mc)) return mc; + else if(opacity(mlc) && mlw) return mlc; +} + +function formatPercent(ratio) { + return ((Math.round(1000 * ratio) * 0.1).toFixed(1) + '%').replace('.0%', '%'); +} diff --git a/src/traces/funnel/index.js b/src/traces/funnel/index.js new file mode 100644 index 00000000000..4d6b375273b --- /dev/null +++ b/src/traces/funnel/index.js @@ -0,0 +1,37 @@ +/** +* 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 = { + attributes: require('./attributes'), + layoutAttributes: require('./layout_attributes'), + supplyDefaults: require('./defaults').supplyDefaults, + crossTraceDefaults: require('./defaults').crossTraceDefaults, + supplyLayoutDefaults: require('./layout_defaults'), + calc: require('./calc'), + crossTraceCalc: require('./cross_trace_calc'), + plot: require('./plot'), + style: require('./style').style, + hoverPoints: require('./hover'), + selectPoints: require('../bar/select'), + + moduleType: 'trace', + name: 'funnel', + basePlotModule: require('../../plots/cartesian'), + categories: ['bar-like', 'cartesian', 'svg', 'oriented', 'showLegend', 'zoomScale'], + meta: { + description: [ // TODO: update description + 'Draws funnel trace.', + '"Funnel charts are a type of chart, often used to represent stages in a sales process', + 'and show the amount of potential revenue for each stage. This type of chart can also', + 'be useful in identifying potential problem areas in an organization’s sales processes.', + 'A funnel chart is similar to a stacked percent bar chart." (https://en.wikipedia.org/wiki/Funnel_chart)' + ].join(' ') + } +}; diff --git a/src/traces/funnel/layout_attributes.js b/src/traces/funnel/layout_attributes.js new file mode 100644 index 00000000000..1bbe48533ab --- /dev/null +++ b/src/traces/funnel/layout_attributes.js @@ -0,0 +1,51 @@ +/** +* 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 = { + funnelmode: { + valType: 'enumerated', + values: ['stack', 'group', 'overlay'], + dflt: 'stack', + role: 'info', + editType: 'calc', + description: [ + 'Determines how bars at the same location coordinate', + 'are displayed on the graph.', + 'With *stack*, the bars are stacked on top of one another', + '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(' ') + }, + funnelgap: { + valType: 'number', + min: 0, + max: 1, + role: 'style', + editType: 'calc', + description: [ + 'Sets the gap (in plot fraction) between bars of', + 'adjacent location coordinates.' + ].join(' ') + }, + funnelgroupgap: { + 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/funnel/layout_defaults.js b/src/traces/funnel/layout_defaults.js new file mode 100644 index 00000000000..b0b16b70608 --- /dev/null +++ b/src/traces/funnel/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 === 'funnel') { + hasTraceType = true; + break; + } + } + + if(hasTraceType) { + coerce('funnelmode'); + coerce('funnelgap', 0.2); + coerce('funnelgroupgap'); + } +}; diff --git a/src/traces/funnel/plot.js b/src/traces/funnel/plot.js new file mode 100644 index 00000000000..70523e5b676 --- /dev/null +++ b/src/traces/funnel/plot.js @@ -0,0 +1,156 @@ +/** +* 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) { + var fullLayout = gd._fullLayout; + + plotConnectorRegions(gd, plotinfo, cdModule, traceLayer); + plotConnectorLines(gd, plotinfo, cdModule, traceLayer); + + barPlot(gd, plotinfo, cdModule, traceLayer, { + mode: fullLayout.funnelmode, + norm: fullLayout.funnelmode, + gap: fullLayout.funnelgap, + groupgap: fullLayout.funnelgroupgap + }); +}; + +function plotConnectorRegions(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 trace = cd[0].trace; + + var group = Lib.ensureSingle(plotGroup, 'g', 'regions'); + + if(!trace.connector || !trace.connector.visible) { + group.remove(); + return; + } + + var isHorizontal = (trace.orientation === 'h'); + + var connectors = group.selectAll('g.region').data(Lib.identity); + + connectors.enter().append('g') + .classed('region', 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 xy = getXY(di, xa, ya, isHorizontal); + var x = xy[0]; + var y = xy[1]; + + var shape = ''; + + if(x[3] !== undefined && y[3] !== undefined) { + if(isHorizontal) { + shape += 'M' + x[0] + ',' + y[1] + 'L' + x[2] + ',' + y[2] + 'H' + x[3] + 'L' + x[1] + ',' + y[1] + 'Z'; + } else { + shape += 'M' + x[1] + ',' + y[1] + 'L' + x[2] + ',' + y[3] + 'V' + y[2] + 'L' + x[1] + ',' + y[0] + 'Z'; + } + } + + Lib.ensureSingle(d3.select(this), 'path') + .attr('d', shape) + .call(Drawing.setClipUrl, plotinfo.layerClipId, gd); + }); + }); +} + +function plotConnectorLines(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 trace = cd[0].trace; + + var group = Lib.ensureSingle(plotGroup, 'g', 'lines'); + + if(!trace.connector || !trace.connector.visible || !trace.connector.line.width) { + group.remove(); + return; + } + + var isHorizontal = (trace.orientation === 'h'); + + 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 xy = getXY(di, xa, ya, isHorizontal); + var x = xy[0]; + var y = xy[1]; + + var shape = ''; + + if(x[3] !== undefined && y[3] !== undefined) { + if(isHorizontal) { + shape += 'M' + x[0] + ',' + y[1] + 'L' + x[2] + ',' + y[2]; + shape += 'M' + x[1] + ',' + y[1] + 'L' + x[3] + ',' + y[2]; + } else { + shape += 'M' + x[1] + ',' + y[1] + 'L' + x[2] + ',' + y[3]; + shape += 'M' + x[1] + ',' + y[0] + 'L' + x[2] + ',' + y[2]; + } + } + + if(shape === '') shape = 'M0,0Z'; + + Lib.ensureSingle(d3.select(this), 'path') + .attr('d', shape) + .call(Drawing.setClipUrl, plotinfo.layerClipId, gd); + }); + }); +} + +function getXY(di, xa, ya, isHorizontal) { + var s = []; + var p = []; + + var sAxis = isHorizontal ? xa : ya; + var pAxis = isHorizontal ? ya : xa; + + s[0] = sAxis.c2p(di.s0, true); + p[0] = pAxis.c2p(di.p0, true); + + s[1] = sAxis.c2p(di.s1, true); + p[1] = pAxis.c2p(di.p1, true); + + s[2] = sAxis.c2p(di.nextS0, true); + p[2] = pAxis.c2p(di.nextP0, true); + + s[3] = sAxis.c2p(di.nextS1, true); + p[3] = pAxis.c2p(di.nextP1, true); + + return isHorizontal ? [s, p] : [p, s]; +} diff --git a/src/traces/funnel/style.js b/src/traces/funnel/style.js new file mode 100644 index 00000000000..b63967684c8 --- /dev/null +++ b/src/traces/funnel/style.js @@ -0,0 +1,60 @@ +/** +* 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.funnellayer').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) { + if(!di.isBlank) { + var cont = trace.marker; + + d3.select(this) + .call(Color.fill, di.mc || cont.color) + .call(Color.stroke, di.mlc || cont.line.color) + .call(Drawing.dashLine, cont.line.dash, di.mlw || cont.line.width) + .style('opacity', trace.selectedpoints && !di.selected ? 0.3 : 1); + } + }); + + styleTextPoints(gTrace, trace, gd); + + gTrace.selectAll('.regions').each(function() { + d3.select(this).selectAll('path').style('stroke-width', 0).call(Color.fill, trace.connector.fillcolor); + }); + + gTrace.selectAll('.lines').each(function() { + var cont = trace.connector.line; + + Drawing.lineGroupStyle( + d3.select(this).selectAll('path'), + cont.width, + cont.color, + cont.dash + ); + }); + }); +} + +module.exports = { + style: style +}; diff --git a/src/traces/histogram/index.js b/src/traces/histogram/index.js index 71861c67a46..32455843358 100644 --- a/src/traces/histogram/index.js +++ b/src/traces/histogram/index.js @@ -41,7 +41,7 @@ module.exports = { moduleType: 'trace', name: 'histogram', basePlotModule: require('../../plots/cartesian'), - categories: ['cartesian', 'svg', 'bar', 'histogram', 'oriented', 'errorBarsOK', 'showLegend'], + categories: ['bar-like', 'cartesian', 'svg', 'bar', 'histogram', 'oriented', 'errorBarsOK', 'showLegend'], meta: { description: [ 'The sample data from which statistics are computed is set in `x`', diff --git a/src/traces/waterfall/attributes.js b/src/traces/waterfall/attributes.js index 6e8140c369b..a4b8efacc93 100644 --- a/src/traces/waterfall/attributes.js +++ b/src/traces/waterfall/attributes.js @@ -8,7 +8,6 @@ 'use strict'; -var pieAtts = require('../pie/attributes'); var barAttrs = require('../bar/attributes'); var lineAttrs = require('../scatter/attributes').line; var extendFlat = require('../../lib/extend').extendFlat; @@ -77,16 +76,24 @@ module.exports = { hovertext: barAttrs.hovertext, hovertemplate: barAttrs.hovertemplate, - textinfo: extendFlat({}, pieAtts.textinfo, { - editType: 'plot', + textinfo: { + valType: 'flaglist', flags: ['label', 'text', 'initial', 'delta', 'final'], + extras: ['none'], + role: 'info', + editType: 'plot', + arrayOk: false, description: [ - 'Determines which trace information appear on the graph.' + 'Determines which trace information appear on the graph.', + 'In the case of having multiple waterfalls, totals', + 'are computed separately (per trace).' ].join(' ') - }), + }, text: barAttrs.text, textposition: barAttrs.textposition, + insidetextanchor: barAttrs.insidetextanchor, + textangle: barAttrs.textangle, textfont: barAttrs.textfont, insidetextfont: barAttrs.insidetextfont, outsidetextfont: barAttrs.outsidetextfont, @@ -134,5 +141,5 @@ module.exports = { }, offsetgroup: barAttrs.offsetgroup, - alignmentgroup: barAttrs.offsetgroup + alignmentgroup: barAttrs.alignmentgroup }; diff --git a/src/traces/waterfall/cross_trace_calc.js b/src/traces/waterfall/cross_trace_calc.js index 229b4792215..ccc23291a98 100644 --- a/src/traces/waterfall/cross_trace_calc.js +++ b/src/traces/waterfall/cross_trace_calc.js @@ -42,19 +42,15 @@ module.exports = function crossTraceCalc(gd, plotinfo) { } } - // 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 - } + var opts = { + mode: fullLayout.waterfallmode, + norm: fullLayout.waterfallnorm, + gap: fullLayout.waterfallgap, + groupgap: fullLayout.waterfallgroupgap }; - setGroupPositions(mockGd, xa, ya, waterfallsVert); - setGroupPositions(mockGd, ya, xa, waterfallsHorz); + setGroupPositions(gd, xa, ya, waterfallsVert, opts); + setGroupPositions(gd, ya, xa, waterfallsHorz, opts); for(i = 0; i < waterfalls.length; i++) { cd = waterfalls[i]; diff --git a/src/traces/waterfall/defaults.js b/src/traces/waterfall/defaults.js index f74f848d6a7..7e00d0bca3d 100644 --- a/src/traces/waterfall/defaults.js +++ b/src/traces/waterfall/defaults.js @@ -86,6 +86,5 @@ function crossTraceDefaults(fullData, fullLayout) { module.exports = { supplyDefaults: supplyDefaults, - crossTraceDefaults: crossTraceDefaults, - handleGroupingDefaults: handleGroupingDefaults + crossTraceDefaults: crossTraceDefaults }; diff --git a/src/traces/waterfall/hover.js b/src/traces/waterfall/hover.js index f3bbe18ce83..f542765885e 100644 --- a/src/traces/waterfall/hover.js +++ b/src/traces/waterfall/hover.js @@ -35,8 +35,6 @@ module.exports = function hoverPoints(pointData, xval, yval, hovermode) { var index = point.index; var di = cd[index]; - var sizeLetter = isHorizontal ? 'x' : 'y'; - var size = (di.isSum) ? di.b + di.s : di.rawS; if(!di.isSum) { @@ -50,8 +48,6 @@ module.exports = function hoverPoints(pointData, xval, yval, hovermode) { } // display initial value point.extraText += '
Initial: ' + formatNumber(di.b + di.s - size); - } else { - point[sizeLetter + 'LabelVal'] = formatNumber(size); } point.color = getTraceColor(trace, di); diff --git a/src/traces/waterfall/index.js b/src/traces/waterfall/index.js index 9547c170b40..23da97872e2 100644 --- a/src/traces/waterfall/index.js +++ b/src/traces/waterfall/index.js @@ -24,7 +24,7 @@ module.exports = { moduleType: 'trace', name: 'waterfall', basePlotModule: require('../../plots/cartesian'), - categories: ['cartesian', 'svg', 'oriented', 'showLegend', 'zoomScale'], + categories: ['bar-like', 'cartesian', 'svg', 'oriented', 'showLegend', 'zoomScale'], meta: { description: [ 'Draws waterfall trace which is useful graph to displays the', diff --git a/src/traces/waterfall/plot.js b/src/traces/waterfall/plot.js index 8c604727010..c29e6afbf80 100644 --- a/src/traces/waterfall/plot.js +++ b/src/traces/waterfall/plot.js @@ -14,7 +14,15 @@ var Drawing = require('../../components/drawing'); var barPlot = require('../bar/plot'); module.exports = function plot(gd, plotinfo, cdModule, traceLayer) { - barPlot(gd, plotinfo, cdModule, traceLayer); + var fullLayout = gd._fullLayout; + + barPlot(gd, plotinfo, cdModule, traceLayer, { + mode: fullLayout.waterfallmode, + norm: fullLayout.waterfallmode, + gap: fullLayout.waterfallgap, + groupgap: fullLayout.waterfallgroupgap + }); + plotConnectors(gd, plotinfo, cdModule, traceLayer); }; @@ -24,8 +32,7 @@ function plotConnectors(gd, plotinfo, cdModule, traceLayer) { Lib.makeTraceGroups(traceLayer, cdModule, 'trace bars').each(function(cd) { var plotGroup = d3.select(this); - var cd0 = cd[0]; - var trace = cd0.trace; + var trace = cd[0].trace; var group = Lib.ensureSingle(plotGroup, 'g', 'lines'); @@ -37,8 +44,6 @@ function plotConnectors(gd, plotinfo, cdModule, traceLayer) { 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') @@ -52,50 +57,18 @@ function plotConnectors(gd, plotinfo, cdModule, traceLayer) { // 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; - - 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.nextS0, true); - y3 = ya.c2p(di.nextP0, 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); + var xy = getXY(di, xa, ya, isHorizontal); + var x = xy[0]; + var y = xy[1]; - x2 = xa.c2p(di.p1, true); - y2 = ya.c2p(di.s1, true); - - if(i + 1 < len) { - x3 = xa.c2p(di.nextP0, true); - y3 = ya.c2p(di.nextS0, true); - } - } + var shape = ''; if(mode === 'spanning') { if(!di.isSum && i > 0) { if(isHorizontal) { - shape += 'M' + x1 + ',' + y0 + 'V' + y1; + shape += 'M' + x[0] + ',' + y[1] + 'V' + y[0]; } else { - shape += 'M' + x0 + ',' + y1 + 'H' + x1; + shape += 'M' + x[1] + ',' + y[0] + 'H' + x[0]; } } } @@ -103,24 +76,45 @@ function plotConnectors(gd, plotinfo, cdModule, traceLayer) { if(mode !== 'between') { if(di.isSum || i < len - 1) { if(isHorizontal) { - shape += 'M' + x2 + ',' + y1 + 'V' + y2; + shape += 'M' + x[1] + ',' + y[0] + 'V' + y[1]; } else { - shape += 'M' + x1 + ',' + y2 + 'H' + x2; + shape += 'M' + x[0] + ',' + y[1] + 'H' + x[1]; } } } - if(x3 !== undefined && y3 !== undefined) { + if(x[2] !== undefined && y[2] !== undefined) { if(isHorizontal) { - shape += 'M' + x2 + ',' + y2 + 'V' + y3; + shape += 'M' + x[1] + ',' + y[1] + 'V' + y[2]; } else { - shape += 'M' + x2 + ',' + y2 + 'H' + x3; + shape += 'M' + x[1] + ',' + y[1] + 'H' + x[2]; } } - Lib.ensureSingle(connector, 'path') + if(shape === '') shape = 'M0,0Z'; + + Lib.ensureSingle(d3.select(this), 'path') .attr('d', shape) .call(Drawing.setClipUrl, plotinfo.layerClipId, gd); }); }); } + +function getXY(di, xa, ya, isHorizontal) { + var s = []; + var p = []; + + var sAxis = isHorizontal ? xa : ya; + var pAxis = isHorizontal ? ya : xa; + + s[0] = sAxis.c2p(di.s0, true); + p[0] = pAxis.c2p(di.p0, true); + + s[1] = sAxis.c2p(di.s1, true); + p[1] = pAxis.c2p(di.p1, true); + + s[2] = sAxis.c2p(di.nextS0, true); + p[2] = pAxis.c2p(di.nextP0, true); + + return isHorizontal ? [s, p] : [p, s]; +} diff --git a/src/traces/waterfall/style.js b/src/traces/waterfall/style.js index b893669df4f..2e33d32d9f6 100644 --- a/src/traces/waterfall/style.js +++ b/src/traces/waterfall/style.js @@ -39,13 +39,13 @@ function style(gd, cd) { styleTextPoints(gTrace, trace, gd); gTrace.selectAll('.lines').each(function() { - var sel = d3.select(this); - var connectorLine = trace.connector.line; + var cont = trace.connector.line; - Drawing.lineGroupStyle(sel.selectAll('path'), - connectorLine.width, - connectorLine.color, - connectorLine.dash + Drawing.lineGroupStyle( + d3.select(this).selectAll('path'), + cont.width, + cont.color, + cont.dash ); }); }); diff --git a/test/image/baselines/bar_axis_textangle_outside.png b/test/image/baselines/bar_axis_textangle_outside.png new file mode 100644 index 00000000000..0f5009d6f89 Binary files /dev/null and b/test/image/baselines/bar_axis_textangle_outside.png differ diff --git a/test/image/baselines/blank-bar-outsidetext.png b/test/image/baselines/blank-bar-outsidetext.png index b2a2cd42638..4e30b143dad 100644 Binary files a/test/image/baselines/blank-bar-outsidetext.png and b/test/image/baselines/blank-bar-outsidetext.png differ diff --git a/test/image/baselines/funnel-grouping-vs-defaults.png b/test/image/baselines/funnel-grouping-vs-defaults.png new file mode 100644 index 00000000000..d61c404c113 Binary files /dev/null and b/test/image/baselines/funnel-grouping-vs-defaults.png differ diff --git a/test/image/baselines/funnel-offsetgroups.png b/test/image/baselines/funnel-offsetgroups.png new file mode 100644 index 00000000000..6cee5c5747f Binary files /dev/null and b/test/image/baselines/funnel-offsetgroups.png differ diff --git a/test/image/baselines/funnel_11.png b/test/image/baselines/funnel_11.png new file mode 100644 index 00000000000..03772977a9c Binary files /dev/null and b/test/image/baselines/funnel_11.png differ diff --git a/test/image/baselines/funnel_attrs.png b/test/image/baselines/funnel_attrs.png new file mode 100644 index 00000000000..f70ad2a327f Binary files /dev/null and b/test/image/baselines/funnel_attrs.png differ diff --git a/test/image/baselines/funnel_axis.png b/test/image/baselines/funnel_axis.png new file mode 100644 index 00000000000..5605cbdec31 Binary files /dev/null and b/test/image/baselines/funnel_axis.png differ diff --git a/test/image/baselines/funnel_axis_textangle.png b/test/image/baselines/funnel_axis_textangle.png new file mode 100644 index 00000000000..171e64944b1 Binary files /dev/null and b/test/image/baselines/funnel_axis_textangle.png differ diff --git a/test/image/baselines/funnel_axis_textangle_outside.png b/test/image/baselines/funnel_axis_textangle_outside.png new file mode 100644 index 00000000000..6d9a57dc303 Binary files /dev/null and b/test/image/baselines/funnel_axis_textangle_outside.png differ diff --git a/test/image/baselines/funnel_axis_textangle_start-end.png b/test/image/baselines/funnel_axis_textangle_start-end.png new file mode 100644 index 00000000000..ae7bd9d62ad Binary files /dev/null and b/test/image/baselines/funnel_axis_textangle_start-end.png differ diff --git a/test/image/baselines/funnel_axis_with_other_traces.png b/test/image/baselines/funnel_axis_with_other_traces.png new file mode 100644 index 00000000000..363190083c3 Binary files /dev/null and b/test/image/baselines/funnel_axis_with_other_traces.png differ diff --git a/test/image/baselines/funnel_cliponaxis-false.png b/test/image/baselines/funnel_cliponaxis-false.png new file mode 100644 index 00000000000..573a250e715 Binary files /dev/null and b/test/image/baselines/funnel_cliponaxis-false.png differ diff --git a/test/image/baselines/funnel_custom.png b/test/image/baselines/funnel_custom.png new file mode 100644 index 00000000000..912c369cb4c Binary files /dev/null and b/test/image/baselines/funnel_custom.png differ diff --git a/test/image/baselines/funnel_date-axes.png b/test/image/baselines/funnel_date-axes.png new file mode 100644 index 00000000000..bd241b76168 Binary files /dev/null and b/test/image/baselines/funnel_date-axes.png differ diff --git a/test/image/baselines/funnel_gap0.png b/test/image/baselines/funnel_gap0.png new file mode 100644 index 00000000000..ffef94bd356 Binary files /dev/null and b/test/image/baselines/funnel_gap0.png differ diff --git a/test/image/baselines/funnel_horizontal_group_basic.png b/test/image/baselines/funnel_horizontal_group_basic.png new file mode 100644 index 00000000000..321bb9f0cb0 Binary files /dev/null and b/test/image/baselines/funnel_horizontal_group_basic.png differ diff --git a/test/image/baselines/funnel_horizontal_stack_basic.png b/test/image/baselines/funnel_horizontal_stack_basic.png new file mode 100644 index 00000000000..a964f16b2dd Binary files /dev/null and b/test/image/baselines/funnel_horizontal_stack_basic.png differ diff --git a/test/image/baselines/funnel_horizontal_stack_more.png b/test/image/baselines/funnel_horizontal_stack_more.png new file mode 100644 index 00000000000..299530ee929 Binary files /dev/null and b/test/image/baselines/funnel_horizontal_stack_more.png differ diff --git a/test/image/baselines/funnel_multicategory.png b/test/image/baselines/funnel_multicategory.png new file mode 100644 index 00000000000..3bb854f7959 Binary files /dev/null and b/test/image/baselines/funnel_multicategory.png differ diff --git a/test/image/baselines/funnel_nonnumeric_sizes.png b/test/image/baselines/funnel_nonnumeric_sizes.png new file mode 100644 index 00000000000..d3af58fcbc4 Binary files /dev/null and b/test/image/baselines/funnel_nonnumeric_sizes.png differ diff --git a/test/image/baselines/funnel_vertical_overlay_custom_arrays.png b/test/image/baselines/funnel_vertical_overlay_custom_arrays.png new file mode 100644 index 00000000000..a57b974c331 Binary files /dev/null and b/test/image/baselines/funnel_vertical_overlay_custom_arrays.png differ diff --git a/test/image/baselines/waterfall_profit-loss_2018vs2019_textinfo_base.png b/test/image/baselines/waterfall_profit-loss_2018vs2019_textinfo_base.png index 3986f9cfac9..52260e1f2ef 100644 Binary files a/test/image/baselines/waterfall_profit-loss_2018vs2019_textinfo_base.png and b/test/image/baselines/waterfall_profit-loss_2018vs2019_textinfo_base.png differ diff --git a/test/image/mocks/bar_axis_textangle_outside.json b/test/image/mocks/bar_axis_textangle_outside.json new file mode 100644 index 00000000000..94b8344d28b --- /dev/null +++ b/test/image/mocks/bar_axis_textangle_outside.json @@ -0,0 +1,161 @@ +{ + "data": [ + { + "type": "bar", + "orientation": "v", + "x": [ + "A", + "B", + "C" + ], + "y": [ + 3, + 2, + 1 + ], + "text": [ + "()", + "void main()", + "public static void main()" + ], + "textposition": "outside", + "cliponaxis": false, + "textangle": -15, + "textinfo": "value+percent initial+percent previous+percent total" + }, + { + "type": "bar", + "orientation": "v", + "x": [ + "A", + "B", + "C" + ], + "y": [ + 300, + 200, + 100 + ], + "text": [ + "()", + "void main()", + "public static void main()" + ], + "textposition": "outside", + "cliponaxis": false, + "textangle": 135, + "textinfo": "value+percent initial+percent previous+percent total", + "xaxis": "x2", + "yaxis": "y2" + }, + { + "type": "bar", + "orientation": "h", + "x": [ + 30000, + 20000, + 10000 + ], + "y": [ + "A", + "B", + "C" + ], + "text": [ + "()", + "void main()", + "public static void main()" + ], + "textposition": "outside", + "cliponaxis": false, + "textangle": 30, + "textinfo": "value+percent initial+percent previous+percent total", + "xaxis": "x3", + "yaxis": "y3" + }, + { + "type": "bar", + "orientation": "h", + "x": [ + 1000000, + 2000000, + 3000000 + ], + "y": [ + "A", + "B", + "C" + ], + "text": [ + "()", + "void main()", + "public static void main()" + ], + "textposition": "outside", + "cliponaxis": false, + "textangle": -90, + "textinfo": "value+percent initial+percent previous+percent total", + "xaxis": "x4", + "yaxis": "y4" + } + ], + "layout": { + "width": 800, + "height": 800, + "dragmode": "pan", + "xaxis": { + "domain": [ + 0, + 0.48 + ] + }, + "xaxis2": { + "anchor": "y2", + "domain": [ + 0.52, + 1 + ] + }, + "xaxis3": { + "anchor": "y3", + "domain": [ + 0, + 0.48 + ] + }, + "xaxis4": { + "anchor": "y4", + "domain": [ + 0.52, + 1 + ] + }, + "yaxis": { + "domain": [ + 0, + 0.48 + ] + }, + "yaxis2": { + "anchor": "x2", + "domain": [ + 0.52, + 1 + ] + }, + "yaxis3": { + "anchor": "x3", + "domain": [ + 0.52, + 1 + ] + }, + "yaxis4": { + "anchor": "x4", + "domain": [ + 0, + 0.48 + ] + } + } +} diff --git a/test/image/mocks/blank-bar-outsidetext.json b/test/image/mocks/blank-bar-outsidetext.json index e15c1cfd0cd..b6c04d221e2 100644 --- a/test/image/mocks/blank-bar-outsidetext.json +++ b/test/image/mocks/blank-bar-outsidetext.json @@ -2,28 +2,140 @@ "data": [ { "type": "bar", - "x": [ "textposition:outside" ], - "y": [ 0 ], - "text": [ "Text" ], - "textposition": [ "outside" ] + "x": [ + "textposition:outside" + ], + "y": [ + 0 + ], + "text": [ + "Text" + ], + "textposition": [ + "outside" + ], + "xaxis": "x", + "yaxis": "y" }, { "type": "bar", - "x": [ "textpostion:auto" ], - "y": [ 0 ], - "text": [ "should not see" ], - "textposition": [ "auto" ] + "x": [ + "textpostion:auto" + ], + "y": [ + 0 + ], + "text": [ + "should not see" + ], + "textposition": [ + "auto" + ], + "xaxis": "x", + "yaxis": "y" }, { "type": "bar", - "x": [ "textpostion:inside" ], - "y": [ 0 ], - "text": [ "should not see" ], - "textposition": [ "inside" ] + "x": [ + "textpostion:inside" + ], + "y": [ + 0 + ], + "text": [ + "should not see" + ], + "textposition": [ + "inside" + ], + "xaxis": "x", + "yaxis": "y" + }, + { + "type": "bar", + "orientation": "h", + "y": [ + "textposition:outside" + ], + "x": [ + 0 + ], + "text": [ + "Text" + ], + "textposition": [ + "outside" + ], + "xaxis": "x2", + "yaxis": "y2" + }, + { + "type": "bar", + "orientation": "h", + "y": [ + "textpostion:auto" + ], + "x": [ + 0 + ], + "text": [ + "should not see" + ], + "textposition": [ + "auto" + ], + "xaxis": "x2", + "yaxis": "y2" + }, + { + "type": "bar", + "orientation": "h", + "y": [ + "textpostion:inside" + ], + "x": [ + 0 + ], + "text": [ + "should not see" + ], + "textposition": [ + "inside" + ], + "xaxis": "x2", + "yaxis": "y2" } ], "layout": { "showlegend": false, - "width": 600 + "margin": { "l": 150, "b": 25, "t": 25, "r": 25 }, + "width": 600, + "height": 300, + "xaxis": { + "domain": [ + 0, + 1 + ] + }, + "yaxis": { + "domain": [ + 0, + 0.45 + ] + }, + "xaxis2": { + "anchor": "y2", + "domain": [ + 0, + 1 + ] + }, + "yaxis2": { + "anchor": "x2", + "domain": [ + 0.55, + 1 + ] + } } } diff --git a/test/image/mocks/funnel-grouping-vs-defaults.json b/test/image/mocks/funnel-grouping-vs-defaults.json new file mode 100644 index 00000000000..ff489849e05 --- /dev/null +++ b/test/image/mocks/funnel-grouping-vs-defaults.json @@ -0,0 +1,62 @@ +{ + "data": [ + { + "type": "funnel", "opacity": 0.8, + "y": [ 3, 2, 1 ], + "yaxis": "y2" + }, + { + "type": "funnel", "opacity": 0.8, + "y": [ 5, 3, 2 ] + }, + { + "type": "funnel", "opacity": 0.8, + "y": [ 4, 1, 0 ] + }, + { + "type": "funnel", "opacity": 0.8, + "y": [ 3, 2, 1 ], + "alignmentgroup": "top", + "hovertext": "alignmentgroup: top", + "xaxis": "x2", + "yaxis": "y2" + }, + { + "type": "funnel", "opacity": 0.8, + "y": [ 5, 3, 2 ], + "hovertext": "alignmentgroup: top
offsetgroup: 1", + "alignmentgroup": "bottom", + "offsetgroup": "1", + "xaxis": "x2" + }, + { + "type": "funnel", "opacity": 0.8, + "y": [ 4, 1, 0 ], + "hovertext": "alignmentgroup: top
offsetgroup: 2", + "alignmentgroup": "bottom", + "offsetgroup": "2", + "xaxis": "x2" + } + ], + "layout": { + "funnelmode": "group", + "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/funnel-offsetgroups.json b/test/image/mocks/funnel-offsetgroups.json new file mode 100644 index 00000000000..7b02421c17a --- /dev/null +++ b/test/image/mocks/funnel-offsetgroups.json @@ -0,0 +1,89 @@ +{ + "data": [ + { + "type": "funnel", "opacity": 0.8, "orientation": "v", + "x": [ "A", "B", "C", "D" ], + "y": [ 4, 3, 2, 1 ], + "offsetgroup": 1, + "hovertext": "offsetgroup: 1" + }, + { + "type": "funnel", "opacity": 0.8, "orientation": "v", + "x": [ "A", "B", "C", "D" ], + "y": [ 3, 2, 1, 0 ], + "offsetgroup": 2, + "hovertext": "offsetgroup: 2" + }, + { + "type": "funnel", "opacity": 0.8, "orientation": "v", + "x": [ "A", "B", "C", "D" ], + "y": [ 4, 3, 2, 1 ], + "yaxis": "y2", + "offsetgroup": 1, + "hovertext": "offsetgroup: 1" + }, + { + "type": "funnel", "opacity": 0.8, "orientation": "v", + "x": [ "A", "B", "C", "D" ], + "y": [ 3, 2, 1, 0 ], + "yaxis": "y2", + "offsetgroup": 2, + "hovertext": "offsetgroup: 2" + }, + { + "type": "funnel", "opacity": 0.8, "orientation": "v", + "x": [ "A", "B", "C", "D" ], + "y": [ 4, 3, 2, 1 ], + "offsetgroup": 1, + "hovertext": "offsetgroup: 1", + "xaxis": "x2" + }, + { + "type": "funnel", "opacity": 0.8, "orientation": "v", + "x": [ "A", "B", "C", "D" ], + "y": [ 3, 2, 1, 0 ], + "offsetgroup": 2, + "hovertext": "offsetgroup: 2", + "xaxis": "x2" + }, + { + "type": "funnel", "opacity": 0.8, "orientation": "v", + "x": [ "A", "B", "C", "D" ], + "y": [ 4, 3, 2, 1 ], + "yaxis": "y2", + "offsetgroup": 3, + "hovertext": "offsetgroup: 3", + "xaxis": "x2" + }, + { + "type": "funnel", "opacity": 0.8, "orientation": "v", + "x": [ "A", "B", "C", "D" ], + "y": [ 3, 2, 1, 0 ], + "yaxis": "y2", + "offsetgroup": 4, + "hovertext": "offsetgroup: 4", + "xaxis": "x2" + } + ], + "layout": { + "funnelmode": "group", + "showlegend": false, + "grid": { + "rows": 2, + "columns": 2 + }, + "title": { + "text": "funnel offset groups" + }, + "xaxis": { + "title": { + "text": "two distinct offset groups" + } + }, + "xaxis2": { + "title": { + "text": "four distinct offset groups" + } + } + } +} diff --git a/test/image/mocks/funnel_11.json b/test/image/mocks/funnel_11.json new file mode 100644 index 00000000000..cbfc13660f5 --- /dev/null +++ b/test/image/mocks/funnel_11.json @@ -0,0 +1,249 @@ +{ + "data": [ + { + "x": [ + "Half Dose", + "Full Dose", + "Double Dose" + ], + "y": [ + "13.23", + "22.7", + "26.06" + ], + "name": "Orange Juice", + "marker": { + "color": "rgb(255, 102, 97)" + }, + "type": "funnel", "orientation": "v" + }, + { + "x": [ + "Half Dose", + "Full Dose", + "Double Dose" + ], + "y": [ + "7.98", + "16.77", + "26.14" + ], + "name": "Vitamin C", + "marker": { + "color": "rgb(0, 196, 200)" + }, + "type": "funnel", "orientation": "v" + }, + { + "x": [ + "Half Dose", + "Full Dose", + "Double Dose" + ], + "y": [ + "1.4102837", + "1.236752", + "0.8396031" + ], + "name": "Std Dev - OJ", + "visible": false, + "type": "funnel", "orientation": "v" + }, + { + "x": [ + "Half Dose", + "Full Dose", + "Double Dose" + ], + "y": [ + "0.868562", + "0.7954104", + "1.5171757" + ], + "name": "Std Dev - VC", + "visible": false, + "type": "funnel", "orientation": "v" + } + ], + "layout": { + "title": "Grouped Funnel 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", + "funnelmode": "group", + "funnelgap": 0.2, + "funnelgroupgap": 0, + "boxmode": "overlay", + "separators": ".,", + "hidesources": false + } +} diff --git a/test/image/mocks/funnel_attrs.json b/test/image/mocks/funnel_attrs.json new file mode 100644 index 00000000000..0d4720810fb --- /dev/null +++ b/test/image/mocks/funnel_attrs.json @@ -0,0 +1,48 @@ +{ + "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], + "marker": { "color": "Blue" }, + "opacity": 0.5, + "connector": { "visible": false }, + "type": "funnel", "orientation": "v" + }, { + "width": [0.4, 0.6, 0.8, 1], + "text": ["Three", 2, "inside text", 0], + "textfont": { "size": [10]}, + "y": [3, 2, 1, 1], + "x": [1, 2, 3, 4], + "marker": { "color": "Orange" }, + "opacity": 0.5, + "connector": { "visible": false }, + "type": "funnel", "orientation": "v" + }, { + "width": 1, + "text": [1, 3, 2, 4], + "textposition": "inside", + "y": [1, 3, 2, 4], + "x": [1, 2, 3, 4], + "marker": { "color": "Green" }, + "opacity": 0.5, + "connector": { "visible": false }, + "type": "funnel", "orientation": "v" + }, { + "text": [2, "outside text", 3, 2], + "y": [2, 0.25, 3, 2], + "x": [1, 2, 3, 4], + "marker": { "color": "Red" }, + "opacity": 0.5, + "connector": { "visible": false }, + "type": "funnel", "orientation": "v" + } + ], + "layout": { + "xaxis": { "showgrid": true }, + "height": 800, + "width": 800 + } +} diff --git a/test/image/mocks/funnel_axis.json b/test/image/mocks/funnel_axis.json new file mode 100644 index 00000000000..e35bc6f79bb --- /dev/null +++ b/test/image/mocks/funnel_axis.json @@ -0,0 +1,135 @@ +{ + "data": [ + { + "type": "funnel", + "orientation": "v", + "x": [ + "A", + "B", + "C" + ], + "y": [ + 3, + 2, + 1 + ], + "textinfo": "value+percent initial+percent previous+percent total" + }, + { + "type": "funnel", + "orientation": "v", + "x": [ + "A", + "B", + "C" + ], + "y": [ + 300, + 200, + 100 + ], + "textposition": "inside", + "insidetextanchor": "start", + "textinfo": "value+percent initial+percent previous+percent total", + "xaxis": "x2", + "yaxis": "y2" + }, + { + "type": "funnel", + "orientation": "h", + "x": [ + 30000, + 20000, + 10000 + ], + "y": [ + "A", + "B", + "C" + ], + "textposition": "inside", + "insidetextanchor": "end", + "textinfo": "value+percent initial+percent previous+percent total", + "xaxis": "x3", + "yaxis": "y3" + }, + { + "type": "funnel", + "orientation": "h", + "x": [ + 3000000, + 2000000, + 1000000 + ], + "y": [ + "A", + "B", + "C" + ], + "textposition": "outside", + "cliponaxis": false, + "textinfo": "value+percent initial+percent previous+percent total", + "xaxis": "x4", + "yaxis": "y4" + } + ], + "layout": { + "width": 800, + "height": 800, + "dragmode": "pan", + "xaxis": { + "domain": [ + 0, + 0.48 + ] + }, + "xaxis2": { + "anchor": "y2", + "domain": [ + 0.52, + 1 + ] + }, + "xaxis3": { + "anchor": "y3", + "domain": [ + 0, + 0.48 + ] + }, + "xaxis4": { + "anchor": "y4", + "domain": [ + 0.52, + 1 + ] + }, + "yaxis": { + "domain": [ + 0, + 0.48 + ] + }, + "yaxis2": { + "anchor": "x2", + "domain": [ + 0.52, + 1 + ] + }, + "yaxis3": { + "anchor": "x3", + "domain": [ + 0.52, + 1 + ] + }, + "yaxis4": { + "anchor": "x4", + "domain": [ + 0, + 0.48 + ] + } + } +} diff --git a/test/image/mocks/funnel_axis_textangle.json b/test/image/mocks/funnel_axis_textangle.json new file mode 100644 index 00000000000..4bd1cfe95aa --- /dev/null +++ b/test/image/mocks/funnel_axis_textangle.json @@ -0,0 +1,212 @@ +{ + "data": [ + { + "type": "funnel", + "orientation": "v", + "x": [ + "A", + "B", + "C" + ], + "y": [ + 3, + 2, + 1 + ], + "textposition": "inside", + "textangle": "auto", + "textinfo": "value+percent initial+percent previous+percent total" + }, + { + "type": "funnel", + "orientation": "v", + "x": [ + "A", + "B", + "C" + ], + "y": [ + 3, + 2, + 1 + ], + "textposition": "inside", + "textinfo": "value+percent initial+percent previous+percent total" + }, + { + "type": "funnel", + "orientation": "v", + "x": [ + "A", + "B", + "C" + ], + "y": [ + 300, + 200, + 100 + ], + "textposition": "inside", + "textangle": 45, + "textinfo": "value+percent initial+percent previous+percent total", + "xaxis": "x2", + "yaxis": "y2" + }, + { + "type": "funnel", + "orientation": "v", + "x": [ + "A", + "B", + "C" + ], + "y": [ + 300, + 200, + 100 + ], + "textposition": "inside", + "textangle": -45, + "textinfo": "value+percent initial+percent previous+percent total", + "xaxis": "x2", + "yaxis": "y2" + }, + { + "type": "funnel", + "orientation": "h", + "x": [ + 30000, + 20000, + 10000 + ], + "y": [ + "A", + "B", + "C" + ], + "textposition": "inside", + "textangle": 45, + "textinfo": "value+percent initial+percent previous+percent total", + "xaxis": "x3", + "yaxis": "y3" + }, + { + "type": "funnel", + "orientation": "h", + "x": [ + 30000, + 20000, + 10000 + ], + "y": [ + "A", + "B", + "C" + ], + "textposition": "inside", + "textangle": -45, + "textinfo": "value+percent initial+percent previous+percent total", + "xaxis": "x3", + "yaxis": "y3" + }, + { + "type": "funnel", + "orientation": "h", + "x": [ + 3000000, + 2000000, + 1000000 + ], + "y": [ + "A", + "B", + "C" + ], + "textposition": "inside", + "cliponaxis": false, + "textangle": 90, + "textinfo": "value+percent initial+percent previous+percent total", + "xaxis": "x4", + "yaxis": "y4" + }, + { + "type": "funnel", + "orientation": "h", + "x": [ + 3000000, + 2000000, + 1000000 + ], + "y": [ + "A", + "B", + "C" + ], + "textposition": "inside", + "cliponaxis": false, + "textangle": -90, + "textinfo": "value+percent initial+percent previous+percent total", + "xaxis": "x4", + "yaxis": "y4" + } + ], + "layout": { + "width": 800, + "height": 800, + "dragmode": "pan", + "xaxis": { + "domain": [ + 0, + 0.48 + ] + }, + "xaxis2": { + "anchor": "y2", + "domain": [ + 0.52, + 1 + ] + }, + "xaxis3": { + "anchor": "y3", + "domain": [ + 0, + 0.48 + ] + }, + "xaxis4": { + "anchor": "y4", + "domain": [ + 0.52, + 1 + ] + }, + "yaxis": { + "domain": [ + 0, + 0.48 + ] + }, + "yaxis2": { + "anchor": "x2", + "domain": [ + 0.52, + 1 + ] + }, + "yaxis3": { + "anchor": "x3", + "domain": [ + 0.52, + 1 + ] + }, + "yaxis4": { + "anchor": "x4", + "domain": [ + 0, + 0.48 + ] + } + } +} diff --git a/test/image/mocks/funnel_axis_textangle_outside.json b/test/image/mocks/funnel_axis_textangle_outside.json new file mode 100644 index 00000000000..f9e517b69e7 --- /dev/null +++ b/test/image/mocks/funnel_axis_textangle_outside.json @@ -0,0 +1,141 @@ +{ + "data": [ + { + "type": "funnel", + "orientation": "v", + "x": [ + "A", + "B", + "C" + ], + "y": [ + 3, + 2, + 1 + ], + "textposition": "outside", + "cliponaxis": false, + "textangle": -15, + "textinfo": "value+percent initial+percent previous+percent total" + }, + { + "type": "funnel", + "orientation": "v", + "x": [ + "A", + "B", + "C" + ], + "y": [ + 300, + 200, + 100 + ], + "textposition": "outside", + "cliponaxis": false, + "textangle": 135, + "textinfo": "value+percent initial+percent previous+percent total", + "xaxis": "x2", + "yaxis": "y2" + }, + { + "type": "funnel", + "orientation": "h", + "x": [ + 30000, + 20000, + 10000 + ], + "y": [ + "A", + "B", + "C" + ], + "textposition": "outside", + "cliponaxis": false, + "textangle": 30, + "textinfo": "value+percent initial+percent previous+percent total", + "xaxis": "x3", + "yaxis": "y3" + }, + { + "type": "funnel", + "orientation": "h", + "x": [ + 3000000, + 2000000, + 1000000 + ], + "y": [ + "A", + "B", + "C" + ], + "textposition": "outside", + "cliponaxis": false, + "textangle": -90, + "textinfo": "value+percent initial+percent previous+percent total", + "xaxis": "x4", + "yaxis": "y4" + } + ], + "layout": { + "width": 800, + "height": 800, + "dragmode": "pan", + "xaxis": { + "domain": [ + 0, + 0.48 + ] + }, + "xaxis2": { + "anchor": "y2", + "domain": [ + 0.52, + 1 + ] + }, + "xaxis3": { + "anchor": "y3", + "domain": [ + 0, + 0.48 + ] + }, + "xaxis4": { + "anchor": "y4", + "domain": [ + 0.52, + 1 + ] + }, + "yaxis": { + "domain": [ + 0, + 0.48 + ] + }, + "yaxis2": { + "anchor": "x2", + "domain": [ + 0.52, + 1 + ] + }, + "yaxis3": { + "anchor": "x3", + "domain": [ + 0.52, + 1 + ] + }, + "yaxis4": { + "anchor": "x4", + "domain": [ + 0, + 0.48 + ] + } + } +} diff --git a/test/image/mocks/funnel_axis_textangle_start-end.json b/test/image/mocks/funnel_axis_textangle_start-end.json new file mode 100644 index 00000000000..ccfd783a455 --- /dev/null +++ b/test/image/mocks/funnel_axis_textangle_start-end.json @@ -0,0 +1,220 @@ +{ + "data": [ + { + "type": "funnel", + "orientation": "v", + "x": [ + "A", + "B", + "C" + ], + "y": [ + 3, + 2, + 1 + ], + "insidetextanchor": "start", + "textposition": "inside", + "textangle": "auto", + "textinfo": "value+percent initial+percent previous+percent total" + }, + { + "type": "funnel", + "orientation": "v", + "x": [ + "A", + "B", + "C" + ], + "y": [ + 3, + 2, + 1 + ], + "insidetextanchor": "end", + "textposition": "inside", + "textinfo": "value+percent initial+percent previous+percent total" + }, + { + "type": "funnel", + "orientation": "v", + "x": [ + "A", + "B", + "C" + ], + "y": [ + 300, + 200, + 100 + ], + "insidetextanchor": "start", + "textposition": "inside", + "textangle": 45, + "textinfo": "value+percent initial+percent previous+percent total", + "xaxis": "x2", + "yaxis": "y2" + }, + { + "type": "funnel", + "orientation": "v", + "x": [ + "A", + "B", + "C" + ], + "y": [ + 300, + 200, + 100 + ], + "insidetextanchor": "end", + "textposition": "inside", + "textangle": -45, + "textinfo": "value+percent initial+percent previous+percent total", + "xaxis": "x2", + "yaxis": "y2" + }, + { + "type": "funnel", + "orientation": "h", + "x": [ + 30000, + 20000, + 10000 + ], + "y": [ + "A", + "B", + "C" + ], + "insidetextanchor": "start", + "textposition": "inside", + "textangle": 45, + "textinfo": "value+percent initial+percent previous+percent total", + "xaxis": "x3", + "yaxis": "y3" + }, + { + "type": "funnel", + "orientation": "h", + "x": [ + 30000, + 20000, + 10000 + ], + "y": [ + "A", + "B", + "C" + ], + "insidetextanchor": "end", + "textposition": "inside", + "textangle": -45, + "textinfo": "value+percent initial+percent previous+percent total", + "xaxis": "x3", + "yaxis": "y3" + }, + { + "type": "funnel", + "orientation": "h", + "x": [ + 3000000, + 2000000, + 1000000 + ], + "y": [ + "A", + "B", + "C" + ], + "insidetextanchor": "start", + "textposition": "inside", + "cliponaxis": false, + "textangle": 90, + "textinfo": "value+percent initial+percent previous+percent total", + "xaxis": "x4", + "yaxis": "y4" + }, + { + "type": "funnel", + "orientation": "h", + "x": [ + 3000000, + 2000000, + 1000000 + ], + "y": [ + "A", + "B", + "C" + ], + "insidetextanchor": "end", + "textposition": "inside", + "cliponaxis": false, + "textangle": -90, + "textinfo": "value+percent initial+percent previous+percent total", + "xaxis": "x4", + "yaxis": "y4" + } + ], + "layout": { + "width": 800, + "height": 800, + "dragmode": "pan", + "xaxis": { + "domain": [ + 0, + 0.48 + ] + }, + "xaxis2": { + "anchor": "y2", + "domain": [ + 0.52, + 1 + ] + }, + "xaxis3": { + "anchor": "y3", + "domain": [ + 0, + 0.48 + ] + }, + "xaxis4": { + "anchor": "y4", + "domain": [ + 0.52, + 1 + ] + }, + "yaxis": { + "domain": [ + 0, + 0.48 + ] + }, + "yaxis2": { + "anchor": "x2", + "domain": [ + 0.52, + 1 + ] + }, + "yaxis3": { + "anchor": "x3", + "domain": [ + 0.52, + 1 + ] + }, + "yaxis4": { + "anchor": "x4", + "domain": [ + 0, + 0.48 + ] + } + } +} diff --git a/test/image/mocks/funnel_axis_with_other_traces.json b/test/image/mocks/funnel_axis_with_other_traces.json new file mode 100644 index 00000000000..ee077ff955c --- /dev/null +++ b/test/image/mocks/funnel_axis_with_other_traces.json @@ -0,0 +1,214 @@ +{ + "data": [ + { + "type": "funnel", + "insidetextanchor": "start", + "width": 0.6, + "opacity": 0.8, + "orientation": "v", + "x": [ + 1, + 2, + 3 + ], + "y": [ + 10, + 8, + 6 + ] + }, + { + "type": "histogram", + "x": [ + 1, 1, 1, 2, 2, 3 + ] + }, + { + "type": "funnel", + "insidetextanchor": "start", + "width": 0.6, + "opacity": 0.8, + "orientation": "v", + "x": [ + "A", + "B", + "C" + ], + "y": [ + 5, + 4, + 3 + ], + "xaxis": "x2", + "yaxis": "y2" + }, + { + "type": "bar", + "width": 0.4, + "x": [ + "A", + "B", + "C" + ], + "y": [ + 5, + 4, + 3 + ], + "xaxis": "x2", + "yaxis": "y2" + }, + { + "type": "funnel", + "insidetextanchor": "start", + "width": 0.6, + "opacity": 0.8, + "x": [ + 5, + 4, + 3 + ], + "y": [ + "A", + "B", + "C" + ], + "xaxis": "x3", + "yaxis": "y3" + }, + { + "type": "waterfall", + "width": 0.4, + "orientation": "h", + "x": [ + 3, + -2, + 0 + ], + "y": [ + "A", + "B", + "C" + ], + "measure": [ + "r", + "r", + "t" + ], + "xaxis": "x3", + "yaxis": "y3" + }, + { + "type": "scatter", + "orientation": "h", + "x": [ + 5, + 4, + 3 + ], + "y": [ + "A", + "B", + "C" + ], + "xaxis": "x3", + "yaxis": "y3" + }, + { + "type": "funnel", + "width": 0.6, + "opacity": 0.8, + "x": [ + 5, + 4, + 3 + ], + "y": [ + "A", + "B", + "C" + ], + "xaxis": "x4", + "yaxis": "y4" + }, + { + "type": "funnel", + "width": 0.6, + "opacity": 0.8, + "x": [ + 3, + 2, + 1 + ], + "y": [ + "A", + "B", + "C" + ], + "xaxis": "x4", + "yaxis": "y4" + } + ], + "layout": { + "title": { + "text": "Should not see axis lines by default when there are only funnels!" + }, + "width": 800, + "height": 800, + "dragmode": "pan", + "xaxis": { + "domain": [ + 0, + 0.48 + ] + }, + "xaxis2": { + "anchor": "y2", + "domain": [ + 0.52, + 1 + ] + }, + "xaxis3": { + "anchor": "y3", + "domain": [ + 0, + 0.48 + ] + }, + "xaxis4": { + "anchor": "y4", + "domain": [ + 0.52, + 1 + ] + }, + "yaxis": { + "domain": [ + 0, + 0.48 + ] + }, + "yaxis2": { + "anchor": "x2", + "domain": [ + 0.52, + 1 + ] + }, + "yaxis3": { + "anchor": "x3", + "domain": [ + 0.52, + 1 + ] + }, + "yaxis4": { + "anchor": "x4", + "domain": [ + 0, + 0.48 + ] + } + } +} diff --git a/test/image/mocks/funnel_cliponaxis-false.json b/test/image/mocks/funnel_cliponaxis-false.json new file mode 100644 index 00000000000..f3c4045a629 --- /dev/null +++ b/test/image/mocks/funnel_cliponaxis-false.json @@ -0,0 +1,113 @@ +{ + "data": [ + { + "type": "funnel", "orientation": "v", + "name": "not clipped", + "x": ["apple", "banana", "clementine"], + "y": [3.8, 4, 4.2], + "text": ["apple", "banana", "x"], + "textinfo": "text", + "textposition": "outside", + "cliponaxis": false, + "textfont": { + "size": [60, 40, 20] + } + }, + { + "type": "funnel", "orientation": "v", + "name": "same but clipped", + "x": ["apple", "banana", "clementine"], + "y": [3.8, 4, 4.2], + "text": ["apple", "banana", "clementine"], + "textinfo": "text", + "textposition": "outside", + "cliponaxis": true, + "textfont": { + "size": [60, 40, 20] + }, + "xaxis": "x2", + "yaxis": "y2" + }, + { + "type": "funnel", "orientation": "v", + "name": "should not see text", + "x": ["banana"], + "y": [4], + "text": ["X"], + "textinfo": "text", + "textposition": "outside", + "cliponaxis": true, + "textfont": {"size": [20]}, + "marker": {"opacity": 0.3} + }, + { + "type": "funnel", "orientation": "v", + "name": "should see text", + "x": ["banana"], + "y": [2], + "text": ["banana"], + "textinfo": "text", + "textposition": "outside", + "cliponaxis": false, + "textfont": {"size": [20]}, + "xaxis": "x3", + "yaxis": "y3" + } + ], + "layout": { + "funnelmode": "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/funnel_custom.json b/test/image/mocks/funnel_custom.json new file mode 100644 index 00000000000..72af0a2dbd7 --- /dev/null +++ b/test/image/mocks/funnel_custom.json @@ -0,0 +1,101 @@ +{ + "data": [ + { + "name": "2018", + "type": "funnel", + "orientation": "v", + "x": [ + "A", + "B", + "C", + "D", + "E", + "F" + ], + "y": [ + 120, + 60, + 30, + 15, + 10, + 5 + ], + "opacity": 0.75, + "marker": { + "color": "rgba(0,255,0,0.5)" + }, + "connector": { + "line": { + "color": "rgba(255,191,127,0.75)", + "dash": 4, + "width": 4 + } + } + }, + { + "name": "2018", + "type": "funnel", + "orientation": "v", + "x": [ + "A", + "B", + "C", + "D", + "E", + "F" + ], + "y": [ + 120, + 80, + 40, + 20, + 10, + 5 + ], + "marker": { + "color": [ + "rgb(255, 0, 0)", + "rgb(255, 255, 0)", + "rgb(0, 255, 0)", + "rgb(0, 255, 255)", + "rgb(0, 0, 255)", + "rgb(255, 0, 255)" + ], + "line": { + "width": [ + 7, + 6, + 5, + 4, + 3, + 2 + ], + "color": [ + "rgb(127, 0, 127)", + "rgb(0, 0, 127)", + "rgb(0, 127, 127)", + "rgb(0, 127, 0)", + "rgb(127, 127, 0)", + "rgb(127, 0, 0)" + ] + } + } + } + ], + "layout": { + "title": { + "text": "two stacked funnel charts with custom marker colors and lines
as well as transparent colors and opacity" + }, + "xaxis": { + "type": "category" + }, + "yaxis": { + "type": "linear" + }, + "height": 450, + "width": 800, + "autosize": true, + "showlegend": true, + "funnelmode": "stack" + } +} diff --git a/test/image/mocks/funnel_date-axes.json b/test/image/mocks/funnel_date-axes.json new file mode 100644 index 00000000000..98bbb7ad91e --- /dev/null +++ b/test/image/mocks/funnel_date-axes.json @@ -0,0 +1,28 @@ +{ + "data": [{ + "type": "funnel", + "orientation": "h", + "y": [ + "2010-01-01", + "2010-07-01", + "2011-01-01", + "2012-01-01" + ], + "x": [ + 4, + 3, + 2, + 1 + ], + "connector": { + "line": { + "width": 1 + } + } + }], + "layout": { + "margin": { "l": 100, "r": 20, "t": 20, "b": 20 }, + "width": 500, + "height": 250 + } +} diff --git a/test/image/mocks/funnel_gap0.json b/test/image/mocks/funnel_gap0.json new file mode 100644 index 00000000000..f9038d634c2 --- /dev/null +++ b/test/image/mocks/funnel_gap0.json @@ -0,0 +1,40 @@ +{ + "data": [ + { + "x": [ + "Lead", + "Proposal", + "Finalize" + ], + "y": [ + 20.0020, + 10.001001, + "05.0005" + ], + "type": "funnel", + "orientation": "v", + "textfont": { "size": 18 } + } + ], + "layout": { + "margin": { "l": 20, "r": 20, "t": 20, "b": 50 }, + "funnelgap": 0, + "xaxis": { + "type": "category", + "range": [ + -0.5, + 2.5 + ], + "tickfont": { "size": 24 } + }, + "yaxis": { + "type": "linear", + "tickprefix": "$", + "ticksuffix": " million" + + }, + "height": 260, + "width": 900, + "autosize": false + } +} diff --git a/test/image/mocks/funnel_horizontal_group_basic.json b/test/image/mocks/funnel_horizontal_group_basic.json new file mode 100644 index 00000000000..e41ce17c498 --- /dev/null +++ b/test/image/mocks/funnel_horizontal_group_basic.json @@ -0,0 +1,64 @@ +{ + "data": [ + { + "name": "plotly.js", + "type": "funnel", + "orientation": "h", + "y": [ + "All tickets", + "Pull requests", + "Author: etpinard", + "Label: bug", + "Status: open" + ], + "x": [ + 3756, + 1580, + 663, + 343, + 1 + ], + "textposition": "inside", + "insidetextanchor": "start", + "textinfo": "value+percent initial" + }, + { + "name": "orca", + "type": "funnel", + "orientation": "h", + "y": [ + "All tickets", + "Pull requests", + "Author: etpinard", + "Label: bug", + "Status: open" + ], + "x": [ + 224, + 107, + 31, + 2, + 0 + ], + "textposition": "outside", + "textinfo": "label+value", + "marker": { + "line": { + "color": "rgb(255, 0, 0)", + "dash": 10, + "width": 2 + } + } + } + ], + "layout": { + "title": { + "text": "plotly repos | April 10 2019" + }, + "margin": { "l": 150 }, + "height": 800, + "width": 800, + "funnelmode": "group", + "showlegend": true + } +} diff --git a/test/image/mocks/funnel_horizontal_stack_basic.json b/test/image/mocks/funnel_horizontal_stack_basic.json new file mode 100644 index 00000000000..0f41089d569 --- /dev/null +++ b/test/image/mocks/funnel_horizontal_stack_basic.json @@ -0,0 +1,55 @@ +{ + "data": [ + { + "name": "plotly.js", + "type": "funnel", + "orientation": "h", + "y": [ + "All tickets", + "Pull requests", + "Author: etpinard", + "Label: bug", + "Status: open" + ], + "x": [ + 3756, + 1580, + 663, + 343, + 1 + ], + "textinfo": "value+percent total" + }, + { + "name": "orca", + "type": "funnel", + "orientation": "h", + "y": [ + "All tickets", + "Pull requests", + "Author: etpinard", + "Label: bug", + "Status: open" + ], + "x": [ + 224, + 107, + 31, + 2, + 0 + ], + "textinfo": "value+percent total", + "cliponaxis": false + } + ], + "layout": { + "title": { + "text": "plotly repos | April 10 2019" + }, + "margin": { "l": 150 }, + "height": 800, + "width": 800, + "funnelmode": "stack", + "showlegend": true + } +} diff --git a/test/image/mocks/funnel_horizontal_stack_more.json b/test/image/mocks/funnel_horizontal_stack_more.json new file mode 100644 index 00000000000..83644bea8ed --- /dev/null +++ b/test/image/mocks/funnel_horizontal_stack_more.json @@ -0,0 +1,74 @@ +{ + "data": [ + { + "type": "funnel", + "orientation": "h", + "y": [ + "A", + "B", + "C", + "D" + ], + "x": [ + 120, + 60, + 30, + 20 + ], + "textinfo": "value+percent initial" + }, + { + "type": "funnel", + "orientation": "h", + "y": [ + "A", + "B", + "C", + "D", + "E" + ], + "x": [ + 120, + 60, + 30, + 20, + 10 + ], + "textposition": "inside", + "textinfo": "value+percent previous" + }, + { + "type": "funnel", + "orientation": "h", + "y": [ + "A", + "B", + "C", + "D", + "E", + "F" + ], + "x": [ + 120, + 60, + 30, + 20, + 10, + 5 + ], + "cliponaxis": false, + "textposition": "outside", + "textinfo": "value+percent total" + } + ], + "layout": { + "title": { + "text": "plotly stacked funnel chart with textposition' (auto | inside | outside)
and textinfo: percent (initial | previous | total)" + }, + "margin": { "l": 50 }, + "height": 800, + "width": 800, + "funnelmode": "stack", + "showlegend": true + } +} diff --git a/test/image/mocks/funnel_multicategory.json b/test/image/mocks/funnel_multicategory.json new file mode 100644 index 00000000000..51131250870 --- /dev/null +++ b/test/image/mocks/funnel_multicategory.json @@ -0,0 +1,58 @@ +{ + "data": [ + { + "type": "funnel", "opacity": 0.8, "offset": -0.5, "width": 0.5, "orientation": "v", + "cliponaxis": false, + "textposition": "inside", + "textinfo": "label+value+percent initial", + "text": ["winter", "spring", "summer", "autumn", "winter", "spring", "summer", "autumn"], + "x": [ + ["2017", "2017", "2017", "2017", "2018", "2018", "2018", "2018"], + ["q1", "q2", "q3", "q4", "q1", "q2", "q3", "q4"] + ], + "y": [4000.004, 3000.003, 2000.002, 1000.001, 4000.004, 2000.002, 1000.001, 500.0005] + }, + { + "type": "funnel", "opacity": 0.8, "offset": -0.5, "width": 0.5, "orientation": "v", + "cliponaxis": false, + "textposition": "outside", + "textinfo": "label+text+value+percent initial", + "text": ["winter", "spring", "summer", "autumn", "winter", "spring", "summer", "autumn"], + "x": [ + ["2017", "2017", "2017", "2017", "2018", "2018", "2018", "2018"], + ["q1", "q2", "q3", "q4", "q1", "q2", "q3", "q4"] + ], + "y": [5000.005, 4000.004, 3000.003, 2000.002, 8000.008, 4000.004, 2000.002, 1000.001] + }, + { + "type": "waterfall", "opacity": 0.8, "offset": 0.0, "width": 0.5, + "cliponaxis": false, + "textposition": "inside", + "textinfo": "label+text+final", + "text": ["winter", "spring", "summer", "autumn", "winter", "spring", "summer", "autumn"], + "x": [ + ["2017", "2017", "2017", "2017", "2018", "2018", "2018", "2018"], + ["q1", "q2", "q3", "q4", "q1", "q2", "q3", "q4"] + ], + "y": [5000.005, -4000.004, -3000.003, -2000.002, 8000.008, -4000.004, -2000.002, -1000.001] + } + ], + "layout": { + "hovermode": "closest", + "width": 1800, + "height": 600, + "margin": { "l": 50, "r": 50, "t": 50, "b": 50 }, + "xaxis": { + "title": "MULTI-CATEGORY two stacked funnels with arrayOK text and one waterfall with textinfo", + "tickfont": {"size": 16}, + "ticks": "outside", + "tickprefix": "[", + "ticksuffix": "]" + }, + "yaxis": { + "visible": false, + "tickprefix": "$", + "ticksuffix": " m" + } + } +} diff --git a/test/image/mocks/funnel_nonnumeric_sizes.json b/test/image/mocks/funnel_nonnumeric_sizes.json new file mode 100644 index 00000000000..c477922ac35 --- /dev/null +++ b/test/image/mocks/funnel_nonnumeric_sizes.json @@ -0,0 +1,28 @@ +{ + "data": [ + { + "x": [ + "a", + "b", + "c", + "d", + "e", + "f" + ], + "y": [ + 1, + null, + "nonsense", + 2, + 0, + 1 + ], + "type": "funnel", "orientation": "v" + } + ], + "layout": { + "margin": { "l": 20, "r": 20, "t": 20, "b": 50 }, + "width": 600, + "height": 200 + } +} diff --git a/test/image/mocks/funnel_vertical_overlay_custom_arrays.json b/test/image/mocks/funnel_vertical_overlay_custom_arrays.json new file mode 100644 index 00000000000..26012087c2a --- /dev/null +++ b/test/image/mocks/funnel_vertical_overlay_custom_arrays.json @@ -0,0 +1,113 @@ +{ + "data": [ + { + "name": "plotly.js", + "type": "funnel", + "orientation": "v", + "x": [ + "All tickets", + "Pull requests", + "Author: etpinard", + "Label: bug", + "Status: open" + ], + "y": [ + 3756, + 1580, + 663, + 343, + 1 + ], + "textposition": "outside", + "connector": { + "fillcolor": "rgba(0, 0, 0, 0)", + "line": { + "color": "rgb(127, 127, 127)", + "dash": 10, + "width": 2 + } + }, + "marker": { + "color": [ + "rgb(0, 127, 127)", + "rgb(0, 0, 127)", + "rgb(127, 0, 127)", + "rgb(127, 0, 0)", + "rgb(127, 127, 0)" + ], + "line": { + "color": [ + "rgb(255, 127, 127)", + "rgb(255, 255, 127)", + "rgb(127, 255, 127)", + "rgb(127, 255, 255)", + "rgb(127, 127, 255)" + ], + "dash": 10, + "width": [ + 8, + 6, + 4, + 2, + 0 + ] + } + } + }, + { + "name": "orca", + "type": "funnel", + "orientation": "v", + "x": [ + "All tickets", + "Pull requests", + "Author: etpinard", + "Label: bug", + "Status: open" + ], + "y": [ + 224, + 107, + 31, + 2, + 0 + ], + "textposition": "inside", + "text": [ + "224", + "107", + "31", + "2", + "0" + ], + "connector": { + "line": { + "color": "rgb(0, 0, 0)", + "dash": 0, + "width": 1 + } + }, + "marker": { + "color": "rgb(255, 255, 0)", + "line": { + "color": "rgb(255, 0, 0)", + "dash": 10, + "width": 3 + } + } + } + ], + "layout": { + "title": { + "text": "plotly repos | April 10 2019" + }, + "xaxis": { + "autorange": "reversed" + }, + "margin": { "l": 0 }, + "height": 800, + "width": 800, + "funnelmode": "overlay", + "showlegend": true + } +} diff --git a/test/image/mocks/waterfall_profit-loss_2018vs2019_textinfo_base.json b/test/image/mocks/waterfall_profit-loss_2018vs2019_textinfo_base.json index e9fbf512511..297bb2aec83 100644 --- a/test/image/mocks/waterfall_profit-loss_2018vs2019_textinfo_base.json +++ b/test/image/mocks/waterfall_profit-loss_2018vs2019_textinfo_base.json @@ -75,6 +75,7 @@ ], "base": 0, "textinfo": "text+delta", + "cliponaxis": false, "textposition": "outside" }, { @@ -134,6 +135,7 @@ ], "base": 255, "textinfo": "initial+final", + "cliponaxis": false, "textposition": "outside" } ], @@ -143,10 +145,14 @@ }, "yaxis": { "type": "category", + "tickprefix": " \" ", + "ticksuffix": " \" ", "autorange": "reversed" }, "xaxis": { - "type": "linear" + "type": "linear", + "tickprefix": "$", + "ticksuffix": " m" }, "margin": { "l": 150, "r": 50 }, "height": 1200, diff --git a/test/jasmine/assets/mock_lists.js b/test/jasmine/assets/mock_lists.js index ee8cca65237..24011ff51c3 100644 --- a/test/jasmine/assets/mock_lists.js +++ b/test/jasmine/assets/mock_lists.js @@ -15,6 +15,7 @@ var svgMockList = [ ['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')], + ['funnel', require('@mocks/funnel_horizontal_group_basic.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/funnel_test.js b/test/jasmine/tests/funnel_test.js new file mode 100644 index 00000000000..9b64b6fb290 --- /dev/null +++ b/test/jasmine/tests/funnel_test.js @@ -0,0 +1,1509 @@ +var Plotly = require('@lib/index'); + +var Funnel = require('@src/traces/funnel'); +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 FUNNEL_TEXT_SELECTOR = '.bars .bartext'; + +describe('Funnel.supplyDefaults', function() { + 'use strict'; + + var traceIn, + traceOut; + + var defaultColor = '#444'; + + var supplyDefaults = Funnel.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 auto', function() { + traceIn = { + y: [1, 2, 3] + }; + supplyDefaults(traceIn, traceOut, defaultColor, {}); + expect(traceOut.textposition).toBe('auto'); + expect(traceOut.texfont).toBeUndefined(); + expect(traceOut.insidetexfont).toBeUndefined(); + expect(traceOut.outsidetexfont).toBeUndefined(); + expect(traceOut.constraintext).toBe('both'); // TODO: is this expected for funnel? + }); + + it('should not coerce textinfo when textposition is none', function() { + traceIn = { + y: [1, 2, 3], + textposition: 'none', + textinfo: 'text' + }; + supplyDefaults(traceIn, traceOut, defaultColor, {}); + expect(traceOut.textinfo).toBeUndefined(); + }); + + it('should coerce textinfo when textposition is not none', function() { + traceIn = { + y: [1, 2, 3], + textinfo: 'text' + }; + supplyDefaults(traceIn, traceOut, defaultColor, {}); + expect(traceOut.textinfo).not.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'); + }); + + it('should not include alignementgroup/offsetgroup when funnelmode is not *group*', function() { + var gd = { + data: [{type: 'funnel', y: [1], alignmentgroup: 'a', offsetgroup: '1'}], + layout: {funnelmode: 'group'} + }; + + supplyAllDefaults(gd); + expect(gd._fullData[0].alignmentgroup).toBe('a', 'alignementgroup'); + expect(gd._fullData[0].offsetgroup).toBe('1', 'offsetgroup'); + + gd.layout.funnelmode = 'stack'; + supplyAllDefaults(gd); + expect(gd._fullData[0].alignmentgroup).toBe(undefined, 'alignementgroup'); + expect(gd._fullData[0].offsetgroup).toBe(undefined, 'offsetgroup'); + }); +}); + +describe('funnel calc / crossTraceCalc', function() { + 'use strict'; + + it('should fill in calc pt fields (stack case)', function() { + var gd = mockFunnelPlot([{ + y: [3, 2, 1] + }, { + y: [4, 3, 2] + }, { + y: [null, null, 2] + }], { + funnelmode: 'stack' + }); + + var cd = gd.calcdata; + assertPointField(cd, 'x', [[0, 1, 2], [0, 1, 2], [0, 1, 2]]); + assertPointField(cd, 'y', [[-0.5, -0.5, -1.5], [3.5, 2.5, 0.5], [undefined, undefined, 2.5]]); + assertPointField(cd, 'b', [[-3.5, -2.5, -2.5], [-0.5, -0.5, -1.5], [0, 0, 0.5]]); + assertPointField(cd, 's', [[3, 2, 1], [4, 3, 2], [undefined, undefined, 2]]); + assertPointField(cd, 'p', [[0, 1, 2], [0, 1, 2], [0, 1, 2]]); + assertTraceField(cd, 't.barwidth', [0.8, 0.8, 0.8]); + assertTraceField(cd, 't.poffset', [-0.4, -0.4, -0.4]); + assertTraceField(cd, 't.bargroupwidth', [0.8, 0.8, 0.8]); + }); + + it('should fill in calc pt fields (overlay case)', function() { + var gd = mockFunnelPlot([{ + y: [2, 1, 2] + }, { + y: [3, 1, 2] + }], { + funnelmode: 'overlay' + }); + + var cd = gd.calcdata; + assertPointField(cd, 'x', [[0, 1, 2], [0, 1, 2]]); + assertPointField(cd, 'y', [[1, 0.5, 1], [1.5, 0.5, 1]]); + assertPointField(cd, 'b', [[-1, -0.5, -1], [-1.5, -0.5, -1]]); + assertPointField(cd, 's', [[2, 1, 2], [3, 1, 2]]); + assertPointField(cd, 'p', [[0, 1, 2], [0, 1, 2]]); + 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 = mockFunnelPlot([{ + y: [2, 1, 2] + }, { + y: [3, 1, 2] + }], { + funnelmode: 'group', + // asumming default bargap is 0.2 + funnelgroupgap: 0.1 + }); + + var cd = gd.calcdata; + assertPointField(cd, 'x', [[-0.2, 0.8, 1.8], [0.2, 1.2, 2.2]]); + assertPointField(cd, 'y', [[1, 0.5, 1], [1.5, 0.5, 1]]); + assertPointField(cd, 'b', [[-1, -0.5, -1], [-1.5, -0.5, -1]]); + assertPointField(cd, 's', [[2, 1, 2], [3, 1, 2]]); + assertPointField(cd, 'p', [[0, 1, 2], [0, 1, 2]]); + 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('Funnel.calc', function() { + 'use strict'; + + it('should not exclude items with non-numeric x from calcdata (vertical case)', function() { + var gd = mockFunnelPlot([{ + 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 = mockFunnelPlot([{ + 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 x from calcdata (to plots gaps correctly)', function() { + var gd = mockFunnelPlot([{ + y: ['a', 'b', 'c', 'd'], + x: [1, null, 'nonsense', 15] + }]); + + var cd = gd.calcdata; + assertPointField(cd, 'y', [[0, 1, 2, 3]]); + assertPointField(cd, 'x', [[0.5, NaN, NaN, 7.5]]); + }); + + it('should not exclude items with non-numeric y from calcdata (to plots gaps correctly)', function() { + var gd = mockFunnelPlot([{ + y: [1, null, 'nonsense', 15], + x: [1, 2, 10, 30] + }], {funnelmode: 'group'}); + + var cd = gd.calcdata; + assertPointField(cd, 'y', [[1, NaN, NaN, 15]]); + assertPointField(cd, 'x', [[0.5, 1, 5, 15]]); + }); +}); + +describe('Funnel.crossTraceCalc', function() { + 'use strict'; + + it('should guard against invalid offset items', function() { + var gd = mockFunnelPlot([{ + offset: 0, + y: [1, 2, 3] + }, { + offset: 1, + y: [1, 2] + }, { + offset: null, + y: [1] + }], { + funnelgap: 0.2, + funnelmode: 'overlay' + }); + + var cd = gd.calcdata; + assertArrayField(cd[0][0], 't.poffset', [0]); + assertArrayField(cd[1][0], 't.poffset', [1]); + assertArrayField(cd[2][0], 't.poffset', [-0.4]); + }); + + it('should guard against invalid width items', function() { + var gd = mockFunnelPlot([{ + width: null, + y: [1] + }], { + funnelgap: 0.2, + funnelmode: 'overlay' + }); + + var cd = gd.calcdata; + assertArrayField(cd[0][0], 't.barwidth', [0.8]); + }); + + it('should guard against invalid width items (group case)', function() { + var gd = mockFunnelPlot([{ + width: 0.2, + y: [1, 2, 3] + }, { + width: 0.1, + y: [1, 2] + }, { + width: null, + y: [1] + }], { + funnelgap: 0, + funnelmode: 'group' + }); + + var cd = gd.calcdata; + assertArrayField(cd[0][0], 't.barwidth', [0.2]); + assertArrayField(cd[1][0], 't.barwidth', [0.1]); + assertArrayField(cd[2][0], 't.barwidth', [0.33]); + }); + + it('should stack vertical and horizontal traces separately', function() { + var gd = mockFunnelPlot([{ + y: [3, 2, 1] + }, { + y: [4, 3, 2] + }, { + x: [3, 2, 1] + }, { + x: [4, 3, 2] + }], { + funnelmode: 'stack' + }); + + var cd = gd.calcdata; + assertPointField(cd, 'b', [[-3.5, -2.5, -1.5], [-0.5, -0.5, -0.5], [-3.5, -2.5, -1.5], [-0.5, -0.5, -0.5]]); + assertPointField(cd, 's', [[3, 2, 1], [4, 3, 2], [3, 2, 1], [4, 3, 2]]); + assertPointField(cd, 'x', [[0, 1, 2], [0, 1, 2], [-0.5, -0.5, -0.5], [3.5, 2.5, 1.5]]); + assertPointField(cd, 'y', [[-0.5, -0.5, -0.5], [3.5, 2.5, 1.5], [0, 1, 2], [0, 1, 2]]); + }); + + it('should not group traces that set offset', function() { + var gd = mockFunnelPlot([{ + y: [3, 2, 1] + }, { + y: [2, 1, 0] + }, { + offset: -1, + y: [15, 10, 5] + }], { + funnelgap: 0, + funnelmode: 'group' + }); + + var cd = gd.calcdata; + assertPointField(cd, 'b', [[-1.5, -1, -0.5], [-1, -0.5, 0], [-7.5, -5, -2.5]]); + assertPointField(cd, 's', [[3, 2, 1], [2, 1, 0], [15, 10, 5]]); + 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.5, 1, 0.5], [1, 0.5, 0], [7.5, 5, 2.5]]); + }); + + it('should draw traces separately in overlay mode', function() { + var gd = mockFunnelPlot([{ + y: [1, 2, 3] + }, { + y: [10, 20, 30] + }], { + funnelgap: 0, + funnelmode: 'overlay' + }); + + var cd = gd.calcdata; + assertPointField(cd, 'b', [[-0.5, -1, -1.5], [-5, -10, -15]]); + assertPointField(cd, 's', [[1, 2, 3], [10, 20, 30]]); + assertPointField(cd, 'x', [[0, 1, 2], [0, 1, 2]]); + assertPointField(cd, 'y', [[0.5, 1, 1.5], [5, 10, 15]]); + }); + + it('should expand position axis', function() { + var gd = mockFunnelPlot([{ + offset: 10, + width: 2, + y: [1.5, 1, 0.5] + }, { + offset: -5, + width: 2, + y: [3, 2, 1] + }], { + funnelgap: 0, + funnelmode: '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([-1.666, 1.666], undefined, '(ya.range)'); + }); + + it('should expand size axis (overlay case)', function() { + var gd = mockFunnelPlot([{ + y: [20, 18, 16] + }, { + y: [6, 7, 8] + }], { + funnelgap: 0, + funnelmode: 'overlay' + }); + + expect(gd._fullLayout.barnorm).toBeUndefined(); + + 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([-11.11, 11.11], undefined, '(ya.range)'); + }); + + it('works with log axes (grouped funnels)', function() { + var gd = mockFunnelPlot([ + {y: [1, 10, 1e10]}, + {y: [2, 20, 2e10]} + ], { + yaxis: {type: 'log'}, + funnelmode: 'group' + }); + + var ya = gd._fullLayout.yaxis; + expect(Axes.getAutoRange(gd, ya)).toBeCloseToArray([-0.8733094398675356, 10.572279444203554], undefined, '(ya.range)'); + }); + + it('works with log axes (stacked funnels)', function() { + var gd = mockFunnelPlot([ + {y: [1, 10, 1e10]}, + {y: [2, 20, 2e10]} + ], { + yaxis: {type: 'log'}, + funnelmode: 'stack' + }); + + var ya = gd._fullLayout.yaxis; + expect(Axes.getAutoRange(gd, ya)).toBeCloseToArray([-0.37946429649987423, 10.731646814611235], undefined, '(ya.range)'); + }); +}); + +describe('A funnel 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 getAllFunnelNodes(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(FUNNEL_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: 'funnel', + text: ['1', 'Very very very very very long text'], + textposition: 'inside', + insidetextrotate: 'auto', + }]; + var layout = {}; + + Plotly.plot(gd, data, layout).then(function() { + var traceNodes = getAllTraceNodes(gd); + var funnelNodes = getAllFunnelNodes(traceNodes[0]); + var foundTextNodes; + + for(var i = 0; i < funnelNodes.length; i++) { + var funnelNode = funnelNodes[i]; + var pathNode = funnelNode.querySelector('path'); + var textNode = funnelNode.querySelector('text'); + if(textNode) { + foundTextNodes = true; + assertTextIsInsidePath(textNode, pathNode); + } + } + + expect(foundTextNodes).toBe(true); + }) + .catch(failTest) + .then(done); + }); + + it('should show funnel texts (outside case)', function(done) { + var data = [{ + y: [30, 20, 10], + type: 'funnel', + text: ['1', 'Very very very very very long funnel text'], + textposition: 'outside', + }]; + var layout = {}; + + Plotly.plot(gd, data, layout).then(function() { + var traceNodes = getAllTraceNodes(gd); + var funnelNodes = getAllFunnelNodes(traceNodes[0]); + var foundTextNodes; + + for(var i = 0; i < funnelNodes.length; i++) { + var funnelNode = funnelNodes[i]; + var pathNode = funnelNode.querySelector('path'); + var textNode = funnelNode.querySelector('text'); + if(textNode) { + foundTextNodes = true; + if(data[0].y[i] > 0) assertTextIsAbovePath(textNode, pathNode); + else assertTextIsBelowPath(textNode, pathNode); + } + } + + expect(foundTextNodes).toBe(true); + }) + .catch(failTest) + .then(done); + }); + + it('should show texts (horizontal case)', function(done) { + var data = [{ + x: [30, 20, 10], + type: 'funnel', + 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 funnelNodes = getAllFunnelNodes(traceNodes[0]); + var foundTextNodes; + + for(var i = 0; i < funnelNodes.length; i++) { + var funnelNode = funnelNodes[i]; + var pathNode = funnelNode.querySelector('path'); + var textNode = funnelNode.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: 'funnel', + 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: 'funnel', + textposition: 'inside', + marker: { + color: ['rgba(0, 0, 0, 0.2)', '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' }, orientation: 'v' }); + + 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: [ + { + text: [1, 2, 3333333333, 4], + textposition: 'outside', + textinfo: 'text', + y: [1, 2, 3, 4], + x: [1, 2, 3, 4], + orientation: 'v', + type: 'funnel' + }, { + width: 0.4, + text: ['Three', 2, 'inside text', 0], + textinfo: 'text', + textfont: { size: [10] }, + y: [3, 2, 1, 0], + x: [1, 2, 3, 4], + orientation: 'v', + type: 'funnel' + }, { + width: 1, + text: [4, 3, 2, 1], + textinfo: 'text', + textposition: 'inside', + y: [4, 3, 2, 1], + x: [1, 2, 3, 4], + orientation: 'v', + type: 'funnel' + }, { + text: [0, 'outside text', 3, 2], + textinfo: 'text', + y: [0, 0.25, 3, 2], + x: [1, 2, 3, 4], + orientation: 'v', + type: 'funnel' + } + ], + layout: { + xaxis: { showgrid: true }, + yaxis: { range: [-6, 6] }, + height: 400, + width: 400, + funnelmode: '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', [ + [0.5, 1, 1.5, 2], [1.5, 1, 0.5, 0], + [2, 1.5, 1, 0.5], [0, 0.125, 1.5, 1]]); + assertPointField(cd, 'b', [ + [-0.5, -1, -1.5, -2], [-1.5, -1, -0.5, 0], + [-2, -1.5, -1, -0.5], [0, -0.125, -1.5, -1]]); + assertPointField(cd, 's', [ + [1, 2, 3, 4], [3, 2, 1, 0], + [4, 3, 2, 1], [0, 0.25, 3, 2]]); + 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', [0.8]); + assertArrayField(cd[1][0], 't.barwidth', [0.4]); + expect(cd[2][0].t.barwidth).toBe(1); + expect(cd[3][0].t.barwidth).toBe(0.8); + assertArrayField(cd[0][0], 't.poffset', [-0.4]); + assertArrayField(cd[1][0], 't.poffset', [-0.2]); + 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.4, 2.4, 3.4, 4.4], [1.2, 2.2, 3.2, 4.2], + [1.5, 2.5, 3.5, 4.5], [1.4, 2.4, 3.4, 4.4]]); + assertPointField(cd, 'y', [ + [0.5, 1, 1.5, 2], [1.5, 1, 0.5, 0], + [2, 1.5, 1, 0.5], [0, 0.125, 1.5, 1]]); + assertPointField(cd, 'b', [ + [-0.5, -1, -1.5, -2], [-1.5, -1, -0.5, 0], + [-2, -1.5, -1, -0.5], [0, -0.125, -1.5, -1]]); + assertPointField(cd, 's', [ + [1, 2, 3, 4], [3, 2, 1, 0], + [4, 3, 2, 1], [0, 0.25, 3, 2]]); + 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', [0.8]); + assertArrayField(cd[1][0], 't.barwidth', [0.4]); + 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 trace0Bar3 = getAllFunnelNodes(traceNodes[0])[3]; + var path03 = trace0Bar3.querySelector('path'); + var text03 = trace0Bar3.querySelector('text'); + var trace1Bar2 = getAllFunnelNodes(traceNodes[1])[2]; + var path12 = trace1Bar2.querySelector('path'); + var text12 = trace1Bar2.querySelector('text'); + var trace2Bar0 = getAllFunnelNodes(traceNodes[2])[0]; + var path20 = trace2Bar0.querySelector('path'); + var text20 = trace2Bar0.querySelector('text'); + var trace3Bar0 = getAllFunnelNodes(traceNodes[3])[1]; + var path30 = trace3Bar0.querySelector('path'); + var text30 = trace3Bar0.querySelector('text'); + + expect(text03.textContent).toBe('4'); + expect(text12.textContent).toBe('inside text'); + expect(text20.textContent).toBe('4'); + expect(text30.textContent).toBe('outside text'); + + assertTextIsAbovePath(text03, path03); // outside + assertTextIsInsidePath(text12, path12); // inside + assertTextIsInsidePath(text20, path20); // inside + assertTextIsAbovePath(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.4, 2.4, 3.4, 4.4], [1.2, 2.2, 3.2, 4.2], + [1.5, 2.5, 3.5, 4.5], [1.4, 2.4, 3.4, 4.4]]); + assertPointField(cd, 'y', [ + [0.5, 1, 1.5, 2], [1.5, 1, 0.5, 0], + [2, 1.5, 1, 0.5], [0, 0.125, 1.5, 1]]); + assertPointField(cd, 'b', [ + [-0.5, -1, -1.5, -2], [-1.5, -1, -0.5, 0], + [-2, -1.5, -1, -0.5], [0, -0.125, -1.5, -1]]); + assertPointField(cd, 's', [ + [1, 2, 3, 4], [3, 2, 1, 0], + [4, 3, 2, 1], [0, 0.25, 3, 2]]); + 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', [0.8]); + assertArrayField(cd[1][0], 't.barwidth', [0.4]); + 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 trace0Bar3 = getAllFunnelNodes(traceNodes[0])[3]; + var path03 = trace0Bar3.querySelector('path'); + var text03 = trace0Bar3.querySelector('text'); + var trace1Bar2 = getAllFunnelNodes(traceNodes[1])[2]; + var path12 = trace1Bar2.querySelector('path'); + var text12 = trace1Bar2.querySelector('text'); + var trace2Bar0 = getAllFunnelNodes(traceNodes[2])[0]; + var path20 = trace2Bar0.querySelector('path'); + var text20 = trace2Bar0.querySelector('text'); + var trace3Bar0 = getAllFunnelNodes(traceNodes[3])[1]; + var path30 = trace3Bar0.querySelector('path'); + var text30 = trace3Bar0.querySelector('text'); + + expect(text03.textContent).toBe('4'); + expect(text12.textContent).toBe('inside text'); + expect(text20.textContent).toBe('4'); + 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 line nodes on restyle', function(done) { + function _assertNumberOfFunnelConnectorNodes(cnt) { + var sel = d3.select(gd).select('.funnellayer').selectAll('.line'); + expect(sel.size()).toBe(cnt); + } + + Plotly.plot(gd, [{ + type: 'funnel', + x: ['Initial', 'A', 'B', 'C', 'Total'], + y: [10, 2, 3, 5], + connector: { visible: false, line: { width: 2 } } + }]) + .then(function() { + _assertNumberOfFunnelConnectorNodes(0); + return Plotly.restyle(gd, 'connector.visible', true); + }) + .then(function() { + _assertNumberOfFunnelConnectorNodes(4); + return Plotly.restyle(gd, 'connector.visible', false); + }) + .then(function() { + _assertNumberOfFunnelConnectorNodes(0); + return Plotly.restyle(gd, 'connector.visible', true); + }) + .then(function() { + _assertNumberOfFunnelConnectorNodes(4); + return Plotly.restyle(gd, 'connector.line.width', 0); + }) + .then(function() { + _assertNumberOfFunnelConnectorNodes(0); + return Plotly.restyle(gd, 'connector.line.width', 10); + }) + .then(function() { + _assertNumberOfFunnelConnectorNodes(4); + return Plotly.restyle(gd, 'connector.line.width', 0); + }) + .then(function() { + _assertNumberOfFunnelConnectorNodes(0); + }) + .catch(failTest) + .then(done); + }); + + it('should be able to add/remove connector region nodes on restyle', function(done) { + function _assertNumberOfFunnelConnectorNodes(cnt) { + var sel = d3.select(gd).select('.funnellayer').selectAll('.region'); + expect(sel.size()).toBe(cnt); + } + + Plotly.plot(gd, [{ + type: 'funnel', + x: ['Initial', 'A', 'B', 'C', 'Total'], + y: [10, 2, 3, 5], + connector: { visible: false } + }]) + .then(function() { + _assertNumberOfFunnelConnectorNodes(0); + return Plotly.restyle(gd, 'connector.visible', true); + }) + .then(function() { + _assertNumberOfFunnelConnectorNodes(4); + return Plotly.restyle(gd, 'connector.visible', false); + }) + .then(function() { + _assertNumberOfFunnelConnectorNodes(0); + return Plotly.restyle(gd, 'connector.visible', true); + }) + .then(function() { + _assertNumberOfFunnelConnectorNodes(4); + }) + .catch(failTest) + .then(done); + }); + + it('should be able to deal with blank bars on transform', function(done) { + Plotly.plot(gd, { + data: [{ + type: 'funnel', + x: [1, 2, 3], + xsrc: 'ints', + transforms: [{ + type: 'filter', + target: [1, 2, 3], + targetsrc: 'ints', + operation: '<', + value: 0 + }] + }], + layout: { + funnelmode: 'group' + } + }) + .then(function() { + var traceNodes = getAllTraceNodes(gd); + var funnelNodes = getAllFunnelNodes(traceNodes[0]); + var pathNode = funnelNodes[0].querySelector('path'); + + expect(gd.calcdata[0][0].x).toEqual(NaN); + expect(gd.calcdata[0][0].y).toEqual(NaN); + expect(gd.calcdata[0][0].isBlank).toBe(true); + + expect(pathNode.outerHTML).toEqual(''); + }) + .catch(failTest) + .then(done); + }); + + it('should coerce text-related attributes', function(done) { + var data = [{ + y: [10, 20, 30, 40], + type: 'funnel', + text: ['T1P1', 'T1P2', 13, 14], + textinfo: 'text', + textposition: ['inside', 'outside', 'auto', 'none', '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: 'funnel', + 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 funnelNodes = getAllFunnelNodes(traceNodes[0]); + var pathNodes = [ + funnelNodes[0].querySelector('path'), + funnelNodes[1].querySelector('path'), + funnelNodes[2].querySelector('path'), + funnelNodes[3].querySelector('path') + ]; + var textNodes = [ + funnelNodes[0].querySelector('text'), + funnelNodes[1].querySelector('text'), + funnelNodes[2].querySelector('text'), + funnelNodes[3].querySelector('text') + ]; + var i; + + // assert funnel texts + for(i = 0; i < 3; i++) { + expect(textNodes[i].textContent).toBe(expected.text[i]); + } + + // assert funnel 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 _assertNumberOfFunnelTextNodes(cnt) { + var sel = d3.select(gd).select('.funnellayer').selectAll('text'); + expect(sel.size()).toBe(cnt); + } + + Plotly.plot(gd, [{ + type: 'funnel', + orientation: 'v', + x: ['Product A', 'Product B', 'Product C'], + y: [20, 14, 23], + text: [20, 14, 23], + textinfo: 'none' + }]) + .then(function() { + _assertNumberOfFunnelTextNodes(3); + return Plotly.restyle(gd, 'textposition', 'none'); + }) + .then(function() { + _assertNumberOfFunnelTextNodes(0); + return Plotly.restyle(gd, 'textposition', 'auto'); + }) + .then(function() { + _assertNumberOfFunnelTextNodes(3); + return Plotly.restyle(gd, 'text', [[null, 0, '']]); + }) + .then(function() { + // N.B. that '0' should be there! + _assertNumberOfFunnelTextNodes(1); + return Plotly.restyle(gd, 'text', 'yo!'); + }) + .then(function() { + _assertNumberOfFunnelTextNodes(3); + }) + .catch(failTest) + .then(done); + }); + + it('should be able to react with new text colors', function(done) { + Plotly.react(gd, [{ + type: 'funnel', + 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('funnel 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 = Funnel.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/funnel_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, 'rgb(255, 102, 97)', 0, 13.23]); + assertPos(out.pos, [11.87, 106.8, 76.23, 76.23]); + }); + + it('should return the correct hover point data (case closest)', function() { + var out = _hover(gd, -0.2, 6, 'closest'); + + expect(out.style).toEqual([0, 'rgb(255, 102, 97)', 0, 13.23]); + assertPos(out.pos, [11.87, 59.33, 76.23, 76.23]); + }); + }); + + 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 = 'funnel'; t.orientation = 'v'; }); + mock.layout.funnelmode = 'group'; + + Plotly.plot(gd, mock).then(function() { + var out = _hover(gd, -0.25, 0.25, '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.25, '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.25, '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.25, '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 = 'funnel'; + t.orientation = 'v'; + 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('display percentage from the initial value', function() { + it('should format numbers and add tick prefix & suffix even if axis is not visible', function(done) { + gd = createGraphDiv(); + + Plotly.plot(gd, { + data: [{ + x: ['A', 'B', 'C', 'D', 'E'], + y: [5.5, 4.4, 3.3, 2.2, 1.1], + orientation: 'v', + type: 'funnel' + }], + layout: { + yaxis: { + visible: false, + tickprefix: '$', + ticksuffix: '!' + }, + width: 400, + height: 400 + } + }) + .then(function() { + var evt = { xpx: 200, ypx: 350 }; + Fx.hover('graph', evt, 'xy'); + }) + .then(function() { + assertHoverLabelContent({ + nums: '$1.1!\n20% of initial\n50% of previous\n6.7% of total', + axis: 'E' + }); + }) + .catch(failTest) + .then(done); + }); + }); + }); + + describe('with special width/offset combinations', function() { + beforeEach(function() { + gd = createGraphDiv(); + }); + + it('should return correct hover data (single funnel, trace width)', function(done) { + Plotly.plot(gd, [{ + type: 'funnel', + orientation: 'v', + 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, 0.5, 'closest'], + [5.9, 0.95, 'closest'], + [-3.9, -5, 'x'], + [5.9, 9.5, 'x'] + ].forEach(function(hoverSpec) { + var out = _hover(gd, hoverSpec[0], hoverSpec[1], hoverSpec[2]); + + expect(out.style).toEqual([0, 'red', 1, 2], hoverSpec); + assertPos(out.pos, [264, 278, 14, 14], hoverSpec); + }); + + // then a few that are off the edge so yield nothing + [ + [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('positions labels correctly w.r.t. narrow funnels', function(done) { + Plotly.newPlot(gd, [{ + x: [0, 10, 20], + y: [2, 6, 4], + type: 'funnel', + orientation: 'v', + 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, 15, 15]); + + // in closest mode you must be over the bar though + out = _hover(gd, 14, 2, 'closest'); + expect(out).toBe(false); + + // now for a single bar trace, closest and compare modes give the same + // positioning of hover labels + out = _hover(gd, 10, 2, 'closest'); + assertPos(out.pos, [145, 155, 15, 15]); + }) + .catch(failTest) + .then(done); + }); + }); +}); + +function mockFunnelPlot(dataWithoutTraceType, layout) { + var traceTemplate = { type: 'funnel' }; + + 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 + ')'); +} diff --git a/test/jasmine/tests/sankey_test.js b/test/jasmine/tests/sankey_test.js index 90259123690..cc597b1f311 100644 --- a/test/jasmine/tests/sankey_test.js +++ b/test/jasmine/tests/sankey_test.js @@ -1228,7 +1228,7 @@ describe('sankey tests', function() { .then(done); }); - it('should not output hover/unhover event data when node.hoverinfo is skip', function(done) { + it('@noCI should not output hover/unhover event data when node.hoverinfo is skip', function(done) { var fig = Lib.extendDeep({}, mock); Plotly.plot(gd, fig) diff --git a/test/jasmine/tests/select_test.js b/test/jasmine/tests/select_test.js index b4e99dc05dc..3710b979cd6 100644 --- a/test/jasmine/tests/select_test.js +++ b/test/jasmine/tests/select_test.js @@ -2124,7 +2124,7 @@ describe('Test select box and lasso per trace:', function() { .then(done); }, LONG_TIMEOUT_INTERVAL); - it('@noCI @flaky should work for waterfall traces', function(done) { + it('@noCI should work for waterfall traces', function(done) { var assertPoints = makeAssertPoints(['curveNumber', 'x', 'y']); var assertSelectedPoints = makeAssertSelectedPoints(); var assertRanges = makeAssertRanges(); @@ -2159,23 +2159,13 @@ describe('Test select box and lasso per trace:', function() { .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] + [0, 281, 'Purchases'], + [0, 269, 'Material expenses'] ]); assertSelectedPoints({ 0: [5, 6] @@ -2192,6 +2182,66 @@ describe('Test select box and lasso per trace:', function() { .then(done); }); + it('@noCI should work for funnel traces', function(done) { + var assertPoints = makeAssertPoints(['curveNumber', 'x', 'y']); + var assertSelectedPoints = makeAssertSelectedPoints(); + var assertRanges = makeAssertRanges(); + var assertLassoPoints = makeAssertLassoPoints(); + + var fig = Lib.extendDeep({}, require('@mocks/funnel_horizontal_group_basic')); + 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, 331.5, 'Author: etpinard'], + [1, 53.5, 'Pull requests'], + [1, 15.5, 'Author: etpinard'], + ]); + assertSelectedPoints({ + 0: [2], + 1: [1, 2] + }); + assertLassoPoints([ + [-154.56790123456787, -1700.2469, -154.5679, 1391.1111, 618.2716], + ['Pull requests', 'Author: etpinard', 'Label: bug', 'Author: etpinard', 'Author: etpinard'] + ]); + }, + null, LASSOEVENTS, 'funnel lasso' + ); + }) + .then(function() { + return Plotly.relayout(gd, 'dragmode', 'select'); + }) + .then(function() { + return _run( + [[300, 300], [500, 500]], + function() { + assertPoints([ + [0, 331.5, 'Author: etpinard'], + [1, 53.5, 'Pull requests'], + [1, 15.5, 'Author: etpinard'] + ]); + assertSelectedPoints({ + 0: [2], + 1: [1, 2] + }); + assertRanges([ + [-927.4074, 618.2716], + ['Pull requests', 'Label: bug'] + ]); + }, + null, BOXEVENTS, 'funnel 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/waterfall_test.js b/test/jasmine/tests/waterfall_test.js index 534e33c9143..1fd8ab5f728 100644 --- a/test/jasmine/tests/waterfall_test.js +++ b/test/jasmine/tests/waterfall_test.js @@ -1265,7 +1265,10 @@ describe('waterfall hover', function() { return { style: [pt.index, pt.color, pt.xLabelVal, pt.yLabelVal], pos: [pt.x0, pt.x1, pt.y0, pt.y1], - text: pt.text + text: pt.text, + extraText: pt.extraText, + xLabelVal: pt.xLabelVal, + yLabelVal: pt.yLabelVal }; } @@ -1371,32 +1374,88 @@ describe('waterfall hover', function() { .then(done); }); - describe('round hover precision', function() { - it('should format numbers', function(done) { - gd = createGraphDiv(); - - Plotly.plot(gd, { - data: [{ - x: ['A', 'B', 'C', 'D', 'E'], - y: [0, -1.1, 2.2, -3.3, 4.4], - type: 'waterfall' - }], - layout: {width: 400, height: 400} - }) - .then(function() { - var evt = { xpx: 200, ypx: 350 }; - Fx.hover('graph', evt, 'xy'); - }) - .then(function() { - assertHoverLabelContent({ - nums: '2.2\n4.4 ▲\nInitial: −2.2', - name: '', - axis: 'E' - }); - }) - .catch(failTest) - .then(done); - }); + it('should format numbers - round hover precision', function(done) { + gd = createGraphDiv(); + + Plotly.plot(gd, { + data: [{ + x: ['A', 'B', 'C', 'D', 'E'], + y: [0, -1.1, 2.2, -3.3, 4.4], + type: 'waterfall' + }], + layout: {width: 400, height: 400} + }) + .then(function() { + var evt = { xpx: 200, ypx: 350 }; + Fx.hover('graph', evt, 'xy'); + }) + .then(function() { + assertHoverLabelContent({ + nums: '2.2\n4.4 ▲\nInitial: −2.2', + name: '', + axis: 'E' + }); + }) + .catch(failTest) + .then(done); + }); + + it('hover measure categories with axis prefix and suffix', function(done) { + gd = createGraphDiv(); + + Plotly.plot(gd, { + data: [{ + x: ['A', 'B', 'C', 'D', 'E'], + y: [2.2, -1.1, null, 3.3, null], + measure: ['a', 'r', 't', 'r', 't'], + base: 1000.001, + type: 'waterfall' + }], + layout: { + xaxis: { + tickprefix: '[', + ticksuffix: ']' + }, + yaxis: { + tickprefix: '$', + ticksuffix: 'm' + }, + width: 400, + height: 400 + } + }) + .then(function() { + var out = _hover(gd, 0, 1000.5, 'closest'); + expect(out.yLabelVal).toEqual(1002.201); + expect(out.extraText).toEqual(undefined); + expect(out.style).toEqual([0, '#4499FF', 0, 1002.201]); + }) + .then(function() { + var out = _hover(gd, 1, 1000.5, 'closest'); + expect(out.yLabelVal).toEqual(1001.101); + expect(out.extraText).toEqual('($1.1m) ▼
Initial: $1,002.201m'); + expect(out.style).toEqual([1, '#FF4136', 1, 1001.101]); + }) + .then(function() { + var out = _hover(gd, 2, 1000.5, 'closest'); + expect(out.yLabelVal).toEqual(1001.101); + expect(out.extraText).toEqual(undefined); + expect(out.style).toEqual([2, '#4499FF', 2, 1001.101]); + }) + .then(function() { + var out = _hover(gd, 3, 1000.5, 'closest'); + expect(out.yLabelVal).toEqual(1004.401); + expect(out.extraText).toEqual('$3.3m ▲
Initial: $1,001.101m'); + expect(out.style).toEqual([3, '#3D9970', 3, 1004.401]); + }) + .then(function() { + var out = _hover(gd, 4, 1000.5, 'closest'); + expect(out.yLabelVal).toEqual(1004.401); + expect(out.extraText).toEqual(undefined); + expect(out.style).toEqual([4, '#4499FF', 4, 1004.401]); + }) + .catch(failTest) + .then(done); }); });