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);
});
});