diff --git a/src/plot_api/helpers.js b/src/plot_api/helpers.js index 4d432cea9d6..d813dfc70f4 100644 --- a/src/plot_api/helpers.js +++ b/src/plot_api/helpers.js @@ -298,7 +298,7 @@ exports.cleanData = function(data, existingData) { trace.scene = Plots.subplotsRegistry.gl3d.cleanId(trace.scene); } - if(!Registry.traceIs(trace, 'pie')) { + if(!Registry.traceIs(trace, 'pie') && !Registry.traceIs(trace, 'bar')) { if(Array.isArray(trace.textposition)) { trace.textposition = trace.textposition.map(cleanTextPosition); } diff --git a/src/traces/bar/attributes.js b/src/traces/bar/attributes.js index 26436e87656..bba0d30611f 100644 --- a/src/traces/bar/attributes.js +++ b/src/traces/bar/attributes.js @@ -12,8 +12,15 @@ var scatterAttrs = require('../scatter/attributes'); var colorAttributes = require('../../components/colorscale/color_attributes'); var errorBarAttrs = require('../../components/errorbars/attributes'); var colorbarAttrs = require('../../components/colorbar/attributes'); +var fontAttrs = require('../../plots/font_attributes'); var extendFlat = require('../../lib/extend').extendFlat; +var extendDeep = require('../../lib/extend').extendDeep; + +var textFontAttrs = extendDeep({}, fontAttrs); +textFontAttrs.family.arrayOk = true; +textFontAttrs.size.arrayOk = true; +textFontAttrs.color.arrayOk = true; var scatterMarkerAttrs = scatterAttrs.marker; var scatterMarkerLineAttrs = scatterMarkerAttrs.line; @@ -40,8 +47,38 @@ module.exports = { y: scatterAttrs.y, y0: scatterAttrs.y0, dy: scatterAttrs.dy, + text: scatterAttrs.text, + textposition: { + valType: 'enumerated', + role: 'info', + values: ['inside', 'outside', 'auto', 'none'], + dflt: 'none', + arrayOk: true, + description: [ + 'Specifies the location of the `text`.', + '*inside* positions `text` inside, next to the bar end', + '(rotated and scaled if needed).', + '*outside* positions `text` outside, next to the bar end', + '(scaled if needed).', + '*auto* positions `text` inside or outside', + 'so that `text` size is maximized.' + ].join(' ') + }, + + textfont: extendFlat({}, textFontAttrs, { + description: 'Sets the font used for `text`.' + }), + + insidetextfont: extendFlat({}, textFontAttrs, { + description: 'Sets the font used for `text` lying inside the bar.' + }), + + outsidetextfont: extendFlat({}, textFontAttrs, { + description: 'Sets the font used for `text` lying outside the bar.' + }), + orientation: { valType: 'enumerated', role: 'info', diff --git a/src/traces/bar/defaults.js b/src/traces/bar/defaults.js index 6e094021140..e907b40ad06 100644 --- a/src/traces/bar/defaults.js +++ b/src/traces/bar/defaults.js @@ -23,6 +23,8 @@ module.exports = function supplyDefaults(traceIn, traceOut, defaultColor, layout return Lib.coerce(traceIn, traceOut, attributes, attr, dflt); } + var coerceFont = Lib.coerceFont; + var len = handleXYDefaults(traceIn, traceOut, coerce); if(!len) { traceOut.visible = false; @@ -33,8 +35,20 @@ module.exports = function supplyDefaults(traceIn, traceOut, defaultColor, layout coerce('base'); coerce('offset'); coerce('width'); + coerce('text'); + var textPosition = coerce('textposition'); + + var hasBoth = Array.isArray(textPosition) || textPosition === 'auto', + hasInside = hasBoth || textPosition === 'inside', + hasOutside = hasBoth || textPosition === 'outside'; + if(hasInside || hasOutside) { + var textFont = coerceFont(coerce, 'textfont', layout.font); + if(hasInside) coerceFont(coerce, 'insidetextfont', textFont); + if(hasOutside) coerceFont(coerce, 'outsidetextfont', textFont); + } + handleStyleDefaults(traceIn, traceOut, coerce, defaultColor, layout); // override defaultColor for error bars with defaultLine diff --git a/src/traces/bar/plot.js b/src/traces/bar/plot.js index 4e0cb53ac29..db68a618f50 100644 --- a/src/traces/bar/plot.js +++ b/src/traces/bar/plot.js @@ -11,13 +11,26 @@ var d3 = require('d3'); var isNumeric = require('fast-isnumeric'); +var tinycolor = require('tinycolor2'); var Lib = require('../../lib'); +var svgTextUtils = require('../../lib/svg_text_utils'); + var Color = require('../../components/color'); +var Drawing = require('../../components/drawing'); var ErrorBars = require('../../components/errorbars'); var arraysToCalcdata = require('./arrays_to_calcdata'); +var attributes = require('./attributes'), + attributeText = attributes.text, + attributeTextPosition = attributes.textposition, + attributeTextFont = attributes.textfont, + attributeInsideTextFont = attributes.insidetextfont, + attributeOutsideTextFont = attributes.outsidetextfont; + +// padding in pixels around text +var TEXTPAD = 3; module.exports = function plot(gd, plotinfo, cdbar) { var xa = plotinfo.xaxis, @@ -42,9 +55,9 @@ module.exports = function plot(gd, plotinfo, cdbar) { arraysToCalcdata(d); - d3.select(this).selectAll('path') + d3.select(this).selectAll('g.point') .data(Lib.identity) - .enter().append('path') + .enter().append('g').classed('point', true) .each(function(di, i) { // now display the bar // clipped xf/yf (2nd arg true): non-positive @@ -75,15 +88,18 @@ module.exports = function plot(gd, plotinfo, cdbar) { d3.select(this).remove(); return; } + var lw = (di.mlw + 1 || trace.marker.line.width + 1 || (di.trace ? di.trace.marker.line.width : 0) + 1) - 1, offset = d3.round((lw / 2) % 1, 2); + function roundWithLine(v) { // if there are explicit gaps, don't round, // it can make the gaps look crappy return (fullLayout.bargap === 0 && fullLayout.bargroupgap === 0) ? d3.round(Math.round(v) - offset, 2) : v; } + function expandToVisible(v, vc) { // if it's not in danger of disappearing entirely, // round more precisely @@ -93,6 +109,7 @@ module.exports = function plot(gd, plotinfo, cdbar) { // its neighbor (v > vc ? Math.ceil(v) : Math.floor(v)); } + if(!gd._context.staticPlot) { // if bars are not fully opaque or they have a line // around them, round to integer pixels, mainly for @@ -108,8 +125,14 @@ module.exports = function plot(gd, plotinfo, cdbar) { y0 = fixpx(y0, y1); y1 = fixpx(y1, y0); } - d3.select(this).attr('d', + + // append bar path and text + var bar = d3.select(this); + + bar.append('path').attr('d', 'M' + x0 + ',' + y0 + 'V' + y1 + 'H' + x1 + 'V' + y0 + 'Z'); + + appendBarText(gd, bar, d, i, x0, x1, y0, y1); }); }); @@ -117,3 +140,384 @@ module.exports = function plot(gd, plotinfo, cdbar) { bartraces.call(ErrorBars.plot, plotinfo); }; + +function appendBarText(gd, bar, calcTrace, i, x0, x1, y0, y1) { + function appendTextNode(bar, text, textFont) { + var textSelection = bar.append('text') + // prohibit tex interpretation until we can handle + // tex and regular text together + .attr('data-notex', 1) + .text(text) + .attr({ + 'class': 'bartext', + transform: '', + 'data-bb': '', + 'text-anchor': 'middle', + x: 0, + y: 0 + }) + .call(Drawing.font, textFont); + + textSelection.call(svgTextUtils.convertToTspans); + textSelection.selectAll('tspan.line').attr({x: 0, y: 0}); + + return textSelection; + } + + // get trace attributes + var trace = calcTrace[0].trace, + orientation = trace.orientation; + + var text = getText(trace, i); + if(!text) return; + + var textPosition = getTextPosition(trace, i); + if(textPosition === 'none') return; + + var textFont = getTextFont(trace, i, gd._fullLayout.font), + insideTextFont = getInsideTextFont(trace, i, textFont), + outsideTextFont = getOutsideTextFont(trace, i, textFont); + + // compute text position + var barmode = gd._fullLayout.barmode, + inStackMode = (barmode === 'stack'), + inRelativeMode = (barmode === 'relative'), + inStackOrRelativeMode = inStackMode || inRelativeMode, + + calcBar = calcTrace[i], + isOutmostBar = !inStackOrRelativeMode || calcBar._outmost, + + barWidth = Math.abs(x1 - x0) - 2 * TEXTPAD, // padding excluded + barHeight = Math.abs(y1 - y0) - 2 * TEXTPAD, // padding excluded + + textSelection, + textBB, + textWidth, + textHeight; + + if(textPosition === 'outside') { + if(!isOutmostBar) textPosition = 'inside'; + } + + if(textPosition === 'auto') { + if(isOutmostBar) { + // draw text using insideTextFont and check if it fits inside bar + textSelection = appendTextNode(bar, text, insideTextFont); + + textBB = Drawing.bBox(textSelection.node()), + textWidth = textBB.width, + textHeight = textBB.height; + + var textHasSize = (textWidth > 0 && textHeight > 0), + fitsInside = + (textWidth <= barWidth && textHeight <= barHeight), + fitsInsideIfRotated = + (textWidth <= barHeight && textHeight <= barWidth), + fitsInsideIfShrunk = (orientation === 'h') ? + (barWidth >= textWidth * (barHeight / textHeight)) : + (barHeight >= textHeight * (barWidth / textWidth)); + if(textHasSize && + (fitsInside || fitsInsideIfRotated || fitsInsideIfShrunk)) { + textPosition = 'inside'; + } + else { + textPosition = 'outside'; + textSelection.remove(); + textSelection = null; + } + } + else textPosition = 'inside'; + } + + if(!textSelection) { + textSelection = appendTextNode(bar, text, + (textPosition === 'outside') ? + outsideTextFont : insideTextFont); + + textBB = Drawing.bBox(textSelection.node()), + textWidth = textBB.width, + textHeight = textBB.height; + + if(textWidth <= 0 || textHeight <= 0) { + textSelection.remove(); + return; + } + } + + // compute text transform + var transform; + if(textPosition === 'outside') { + transform = getTransformToMoveOutsideBar(x0, x1, y0, y1, textBB, + orientation); + } + else { + transform = getTransformToMoveInsideBar(x0, x1, y0, y1, textBB, + orientation); + } + + textSelection.attr('transform', transform); +} + +function getTransformToMoveInsideBar(x0, x1, y0, y1, textBB, orientation) { + // compute text and target positions + var textWidth = textBB.width, + textHeight = textBB.height, + textX = (textBB.left + textBB.right) / 2, + textY = (textBB.top + textBB.bottom) / 2, + barWidth = Math.abs(x1 - x0), + barHeight = Math.abs(y1 - y0), + targetWidth, + targetHeight, + targetX, + 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; + + 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 = Math.min(barWidth / textWidth, barHeight / textHeight); + } + else { + // both scale and rotation are required + rotate = true; + scale = Math.min(barHeight / textWidth, barWidth / textHeight); + } + + if(rotate) rotate = 90; // rotate clockwise + + // compute text and target positions + if(rotate) { + targetWidth = scale * textHeight; + targetHeight = scale * textWidth; + } + else { + targetWidth = scale * textWidth; + targetHeight = scale * textHeight; + } + + 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; + } + else { + targetX = (x0 + x1) / 2; + targetY = y1 + textpad + targetHeight / 2; + } + } + + return getTransform(textX, textY, targetX, targetY, scale, rotate); +} + +function getTransformToMoveOutsideBar(x0, x1, y0, y1, textBB, orientation) { + var barWidth = (orientation === 'h') ? + Math.abs(y1 - y0) : + Math.abs(x1 - x0), + textpad; + + // apply text padding if possible + if(barWidth > 2 * TEXTPAD) { + textpad = TEXTPAD; + barWidth -= 2 * textpad; + } + + // compute rotation and scale + var rotate = false, + scale = (orientation === 'h') ? + Math.min(1, barWidth / textBB.height) : + Math.min(1, barWidth / textBB.width); + + // compute text and target positions + var textX = (textBB.left + textBB.right) / 2, + textY = (textBB.top + textBB.bottom) / 2, + targetWidth, + targetHeight, + targetX, + targetY; + if(rotate) { + targetWidth = scale * textBB.height; + targetHeight = scale * textBB.width; + } + else { + 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; + } + } + 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; + } + } + + return getTransform(textX, textY, targetX, targetY, scale, rotate); +} + +function getTransform(textX, textY, targetX, targetY, scale, rotate) { + var transformScale, + transformRotate, + transformTranslate; + + if(scale < 1) transformScale = 'scale(' + scale + ') '; + else { + scale = 1; + transformScale = ''; + } + + transformRotate = (rotate) ? + 'rotate(' + rotate + ' ' + textX + ' ' + textY + ') ' : ''; + + // Note that scaling also affects the center of the text box + var translateX = (targetX - scale * textX), + translateY = (targetY - scale * textY); + transformTranslate = 'translate(' + translateX + ' ' + translateY + ')'; + + return transformTranslate + transformScale + transformRotate; +} + +function getText(trace, index) { + var value = getValue(trace.text, index); + return coerceString(attributeText, value); +} + +function getTextPosition(trace, index) { + var value = getValue(trace.textposition, index); + return coerceEnumerated(attributeTextPosition, value); +} + +function getTextFont(trace, index, defaultValue) { + return getFontValue( + attributeTextFont, trace.textfont, index, defaultValue); +} + +function getInsideTextFont(trace, index, defaultValue) { + return getFontValue( + attributeInsideTextFont, trace.insidetextfont, index, defaultValue); +} + +function getOutsideTextFont(trace, index, defaultValue) { + return getFontValue( + attributeOutsideTextFont, trace.outsidetextfont, index, defaultValue); +} + +function getFontValue(attributeDefinition, attributeValue, index, defaultValue) { + attributeValue = attributeValue || {}; + + var familyValue = getValue(attributeValue.family, index), + sizeValue = getValue(attributeValue.size, index), + colorValue = getValue(attributeValue.color, index); + + return { + family: coerceString( + attributeDefinition.family, familyValue, defaultValue.family), + size: coerceNumber( + attributeDefinition.size, sizeValue, defaultValue.size), + color: coerceColor( + attributeDefinition.color, colorValue, defaultValue.color) + }; +} + +function getValue(arrayOrScalar, index) { + var value; + if(!Array.isArray(arrayOrScalar)) value = arrayOrScalar; + else if(index < arrayOrScalar.length) value = arrayOrScalar[index]; + return value; +} + +function coerceString(attributeDefinition, value, defaultValue) { + if(typeof value === 'string') { + if(value || !attributeDefinition.noBlank) return value; + } + else if(typeof value === 'number') { + if(!attributeDefinition.strict) return String(value); + } + + return (defaultValue !== undefined) ? + defaultValue : + attributeDefinition.dflt; +} + +function coerceEnumerated(attributeDefinition, value, defaultValue) { + if(attributeDefinition.coerceNumber) value = +value; + + if(attributeDefinition.values.indexOf(value) !== -1) return value; + + return (defaultValue !== undefined) ? + defaultValue : + attributeDefinition.dflt; +} + +function coerceNumber(attributeDefinition, value, defaultValue) { + if(isNumeric(value)) { + value = +value; + + var min = attributeDefinition.min, + max = attributeDefinition.max, + isOutOfBounds = (min !== undefined && value < min) || + (max !== undefined && value > max); + + if(!isOutOfBounds) return value; + } + + return (defaultValue !== undefined) ? + defaultValue : + attributeDefinition.dflt; +} + +function coerceColor(attributeDefinition, value, defaultValue) { + if(tinycolor(value).isValid()) return value; + + return (defaultValue !== undefined) ? + defaultValue : + attributeDefinition.dflt; +} diff --git a/src/traces/bar/set_positions.js b/src/traces/bar/set_positions.js index c34adab1b02..7ad3b655c1e 100644 --- a/src/traces/bar/set_positions.js +++ b/src/traces/bar/set_positions.js @@ -179,6 +179,24 @@ function setGroupPositionsInStackOrRelativeMode(gd, pa, sa, calcTraces) { // set bar bases and sizes, and update size axis stackBars(gd, sa, sieve); + + // flag the outmost bar (for text display purposes) + for(var i = 0; i < calcTraces.length; i++) { + var calcTrace = calcTraces[i]; + + for(var j = 0; j < calcTrace.length; j++) { + var bar = calcTrace[j]; + + if(!isNumeric(bar.s)) continue; + + var isOutmostBar = ((bar.b + bar.s) === sieve.get(bar.p, bar.s)); + if(isOutmostBar) bar._outmost = true; + } + } + + // Note that marking the outmost bars has to be done + // before `normalizeBars` changes `bar.b` and `bar.s`. + if(barnorm) normalizeBars(gd, sa, sieve); } @@ -506,12 +524,7 @@ function stackBars(gd, sa, sieve) { } // if barnorm is set, let normalizeBars update the axis range - if(barnorm) { - normalizeBars(gd, sa, sieve); - } - else { - Axes.expand(sa, [sMin, sMax], {tozero: true, padded: true}); - } + if(!barnorm) Axes.expand(sa, [sMin, sMax], {tozero: true, padded: true}); } diff --git a/test/image/baselines/bar_attrs_group_norm.png b/test/image/baselines/bar_attrs_group_norm.png index 53a17a1353a..330d6607ce7 100644 Binary files a/test/image/baselines/bar_attrs_group_norm.png and b/test/image/baselines/bar_attrs_group_norm.png differ diff --git a/test/image/baselines/bar_attrs_relative.png b/test/image/baselines/bar_attrs_relative.png index fe44f4c1eb7..a2dee28d1ee 100644 Binary files a/test/image/baselines/bar_attrs_relative.png and b/test/image/baselines/bar_attrs_relative.png differ diff --git a/test/image/mocks/bar_attrs_group_norm.json b/test/image/mocks/bar_attrs_group_norm.json index 0f6c54b2886..0c4e252975f 100644 --- a/test/image/mocks/bar_attrs_group_norm.json +++ b/test/image/mocks/bar_attrs_group_norm.json @@ -1,10 +1,15 @@ { "data":[ { + "text":"50%", + "textposition":"inside", + "outsidetextfont": {"size":32}, "base":[100,40,25,10], "x":[-50,10,25,40], "type":"bar" }, { + "text":"50%", + "textposition":"outside", "base":[0,60,75,90], "x":[50,-10,-25,-40], "type":"bar" diff --git a/test/image/mocks/bar_attrs_relative.json b/test/image/mocks/bar_attrs_relative.json index a7e15e0ff96..962fdfbc141 100644 --- a/test/image/mocks/bar_attrs_relative.json +++ b/test/image/mocks/bar_attrs_relative.json @@ -2,27 +2,37 @@ "data":[ { "width":[1,0.8,0.6,0.4], + "text":[1,2,3333333333,4], + "textposition":"outside", "y":[1,2,3,4], "x":[1,2,3,4], "type":"bar" }, { "width":[0.4,0.6,0.8,1], + "text":["Three",2,"inside text",0], + "textposition":"auto", + "textfont":{"size":[10]}, "y":[3,2,1,0], "x":[1,2,3,4], "type":"bar" }, { "width":1, + "text":[-1,-3,-2,-4], + "textposition":"inside", "y":[-1,-3,-2,-4], "x":[1,2,3,4], "type":"bar" }, { - "y":[0,-1,-3,-2], + "text":[0,"outside text",-3,-2], + "textposition":"auto", + "y":[0,-0.25,-3,-2], "x":[1,2,3,4], "type":"bar" } ], "layout":{ "xaxis": {"showgrid":true}, + "yaxis": {"range":[-6,6]}, "height":400, "width":400, "barmode":"relative", diff --git a/test/jasmine/tests/bar_test.js b/test/jasmine/tests/bar_test.js index a67ee339cf7..75f8bc01926 100644 --- a/test/jasmine/tests/bar_test.js +++ b/test/jasmine/tests/bar_test.js @@ -1,17 +1,17 @@ var Plotly = require('@lib/index'); -var Plots = require('@src/plots/plots'); + +var Bar = require('@src/traces/bar'); var Lib = require('@src/lib'); +var Plots = require('@src/plots/plots'); var PlotlyInternal = require('@src/plotly'); var Axes = PlotlyInternal.Axes; -var Bar = require('@src/traces/bar'); - var createGraphDiv = require('../assets/create_graph_div'); var destroyGraphDiv = require('../assets/destroy_graph_div'); var customMatchers = require('../assets/custom_matchers'); -describe('bar supplyDefaults', function() { +describe('Bar.supplyDefaults', function() { 'use strict'; var traceIn, @@ -84,6 +84,38 @@ describe('bar supplyDefaults', function() { supplyDefaults(traceIn, traceOut, defaultColor); expect(traceOut.width).toBeUndefined(); }); + + it('should coerce textposition to none', function() { + traceIn = { + y: [1, 2, 3] + }; + supplyDefaults(traceIn, traceOut, defaultColor); + expect(traceOut.textposition).toBe('none'); + expect(traceOut.texfont).toBeUndefined(); + expect(traceOut.insidetexfont).toBeUndefined(); + expect(traceOut.outsidetexfont).toBeUndefined(); + }); + + it('should default textfont to layout.font', function() { + traceIn = { + textposition: 'inside', + y: [1, 2, 3] + }; + + var layout = { + font: {family: 'arial', color: '#AAA', 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(layout.font); + expect(traceOut.insidetextfont).not.toBe(layout.font); + expect(traceOut.insidetextfont).not.toBe(traceOut.textfont); + expect(traceOut.outsidetexfont).toBeUndefined(); + }); }); describe('heatmap calc / setPositions', function() { @@ -633,6 +665,201 @@ describe('A bar plot', function() { afterEach(destroyGraphDiv); + function getAllTraceNodes(node) { + return node.querySelectorAll('g.points'); + } + + function getAllBarNodes(node) { + return node.querySelectorAll('g.point'); + } + + function assertTextIsInsidePath(textNode, pathNode) { + var textBB = textNode.getBoundingClientRect(), + 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(), + pathBB = pathNode.getBoundingClientRect(); + + expect(textBB.bottom).not.toBeGreaterThan(pathBB.top); + } + + function assertTextIsBelowPath(textNode, pathNode) { + var textBB = textNode.getBoundingClientRect(), + pathBB = pathNode.getBoundingClientRect(); + + expect(pathBB.bottom).not.toBeGreaterThan(textBB.top); + } + + function assertTextIsAfterPath(textNode, pathNode) { + var textBB = textNode.getBoundingClientRect(), + pathBB = pathNode.getBoundingClientRect(); + + expect(pathBB.right).not.toBeGreaterThan(textBB.left); + } + + var colorMap = { + 'rgb(0, 0, 0)': 'black', + 'rgb(255, 0, 0)': 'red', + 'rgb(0, 128, 0)': 'green', + 'rgb(0, 0, 255)': 'blue' + }; + function assertTextFont(textNode, textFont, index) { + expect(textNode.style.fontFamily).toBe(textFont.family[index]); + expect(textNode.style.fontSize).toBe(textFont.size[index] + 'px'); + + var color = textNode.style.fill; + if(!colorMap[color]) colorMap[color] = color; + expect(colorMap[color]).toBe(textFont.color[index]); + } + + function assertTextIsBeforePath(textNode, pathNode) { + var textBB = textNode.getBoundingClientRect(), + pathBB = pathNode.getBoundingClientRect(); + + expect(textBB.right).not.toBeGreaterThan(pathBB.left); + } + + it('should show bar texts (inside case)', function(done) { + var gd = createGraphDiv(), + data = [{ + y: [10, 20, 30], + type: 'bar', + text: ['1', 'Very very very very very long bar text'], + textposition: 'inside', + }], + layout = { + }; + + Plotly.plot(gd, data, layout).then(function() { + var traceNodes = getAllTraceNodes(gd), + barNodes = getAllBarNodes(traceNodes[0]), + foundTextNodes; + + for(var i = 0; i < barNodes.length; i++) { + var barNode = barNodes[i], + pathNode = barNode.querySelector('path'), + textNode = barNode.querySelector('text'); + if(textNode) { + foundTextNodes = true; + assertTextIsInsidePath(textNode, pathNode); + } + } + + expect(foundTextNodes).toBe(true); + + done(); + }); + }); + + it('should show bar texts (outside case)', function(done) { + var gd = createGraphDiv(), + data = [{ + y: [10, -20, 30], + type: 'bar', + text: ['1', 'Very very very very very long bar text'], + textposition: 'outside', + }], + layout = { + barmode: 'relative' + }; + + Plotly.plot(gd, data, layout).then(function() { + var traceNodes = getAllTraceNodes(gd), + barNodes = getAllBarNodes(traceNodes[0]), + foundTextNodes; + + for(var i = 0; i < barNodes.length; i++) { + var barNode = barNodes[i], + pathNode = barNode.querySelector('path'), + textNode = barNode.querySelector('text'); + if(textNode) { + foundTextNodes = true; + if(data[0].y[i] > 0) assertTextIsAbovePath(textNode, pathNode); + else assertTextIsBelowPath(textNode, pathNode); + } + } + + expect(foundTextNodes).toBe(true); + + done(); + }); + }); + + it('should show bar texts (horizontal case)', function(done) { + var gd = createGraphDiv(), + data = [{ + x: [10, -20, 30], + type: 'bar', + text: ['Very very very very very long bar text', -20], + textposition: 'outside', + }], + layout = { + }; + + Plotly.plot(gd, data, layout).then(function() { + var traceNodes = getAllTraceNodes(gd), + barNodes = getAllBarNodes(traceNodes[0]), + foundTextNodes; + + for(var i = 0; i < barNodes.length; i++) { + var barNode = barNodes[i], + pathNode = barNode.querySelector('path'), + textNode = barNode.querySelector('text'); + if(textNode) { + foundTextNodes = true; + if(data[0].x[i] > 0) assertTextIsAfterPath(textNode, pathNode); + else assertTextIsBeforePath(textNode, pathNode); + } + } + + expect(foundTextNodes).toBe(true); + + done(); + }); + }); + + it('should show bar texts (barnorm case)', function(done) { + var gd = createGraphDiv(), + data = [{ + x: [100, -100, 100], + type: 'bar', + text: [100, -100, 100], + textposition: 'outside', + }], + layout = { + barmode: 'relative', + barnorm: 'percent' + }; + + Plotly.plot(gd, data, layout).then(function() { + var traceNodes = getAllTraceNodes(gd), + barNodes = getAllBarNodes(traceNodes[0]), + foundTextNodes; + + for(var i = 0; i < barNodes.length; i++) { + var barNode = barNodes[i], + pathNode = barNode.querySelector('path'), + textNode = barNode.querySelector('text'); + if(textNode) { + foundTextNodes = true; + if(data[0].x[i] > 0) assertTextIsAfterPath(textNode, pathNode); + else assertTextIsBeforePath(textNode, pathNode); + } + } + + expect(foundTextNodes).toBe(true); + + done(); + }); + }); + it('should be able to restyle', function(done) { var gd = createGraphDiv(), mock = Lib.extendDeep({}, require('@mocks/bar_attrs_relative')); @@ -644,13 +871,13 @@ describe('A bar plot', function() { [1, 2, 3, 4], [1, 2, 3, 4]]); assertPointField(cd, 'y', [ [1, 2, 3, 4], [4, 4, 4, 4], - [-1, -3, -2, -4], [4, -4, -5, -6]]); + [-1, -3, -2, -4], [4, -3.25, -5, -6]]); assertPointField(cd, 'b', [ [0, 0, 0, 0], [1, 2, 3, 4], [0, 0, 0, 0], [4, -3, -2, -4]]); assertPointField(cd, 's', [ [1, 2, 3, 4], [3, 2, 1, 0], - [-1, -3, -2, -4], [0, -1, -3, -2]]); + [-1, -3, -2, -4], [0, -0.25, -3, -2]]); assertPointField(cd, 'p', [ [1, 2, 3, 4], [1, 2, 3, 4], [1, 2, 3, 4], [1, 2, 3, 4]]); @@ -672,13 +899,13 @@ describe('A bar plot', function() { [1.5, 2.5, 3.5, 4.5], [1.4, 2.4, 3.4, 4.4]]); assertPointField(cd, 'y', [ [1, 2, 3, 4], [4, 4, 4, 4], - [-1, -3, -2, -4], [4, -4, -5, -6]]); + [-1, -3, -2, -4], [4, -3.25, -5, -6]]); assertPointField(cd, 'b', [ [0, 0, 0, 0], [1, 2, 3, 4], [0, 0, 0, 0], [4, -3, -2, -4]]); assertPointField(cd, 's', [ [1, 2, 3, 4], [3, 2, 1, 0], - [-1, -3, -2, -4], [0, -1, -3, -2]]); + [-1, -3, -2, -4], [0, -0.25, -3, -2]]); assertPointField(cd, 'p', [ [1, 2, 3, 4], [1, 2, 3, 4], [1, 2, 3, 4], [1, 2, 3, 4]]); @@ -692,6 +919,164 @@ describe('A bar plot', function() { expect(cd[3][0].t.poffset).toBe(0); assertTraceField(cd, 't.bargroupwidth', [0.8, 0.8, 0.8, 0.8]); + var traceNodes = getAllTraceNodes(gd), + trace0Bar3 = getAllBarNodes(traceNodes[0])[3], + path03 = trace0Bar3.querySelector('path'), + text03 = trace0Bar3.querySelector('text'), + trace1Bar2 = getAllBarNodes(traceNodes[1])[2], + path12 = trace1Bar2.querySelector('path'), + text12 = trace1Bar2.querySelector('text'), + trace2Bar0 = getAllBarNodes(traceNodes[2])[0], + path20 = trace2Bar0.querySelector('path'), + text20 = trace2Bar0.querySelector('text'), + trace3Bar0 = getAllBarNodes(traceNodes[3])[0], + path30 = trace3Bar0.querySelector('path'), + text30 = trace3Bar0.querySelector('text'); + + expect(text03.textContent).toBe('4'); + expect(text12.textContent).toBe('inside text'); + expect(text20.textContent).toBe('-1'); + expect(text30.textContent).toBe('outside text'); + + assertTextIsAbovePath(text03, path03); // outside + assertTextIsInsidePath(text12, path12); // inside + assertTextIsInsidePath(text20, path20); // inside + assertTextIsBelowPath(text30, path30); // outside + + return Plotly.restyle(gd, 'textposition', 'inside'); + }).then(function() { + var cd = gd.calcdata; + assertPointField(cd, 'x', [ + [1.5, 2.4, 3.3, 4.2], [1.2, 2.3, 3.4, 4.5], + [1.5, 2.5, 3.5, 4.5], [1.4, 2.4, 3.4, 4.4]]); + assertPointField(cd, 'y', [ + [1, 2, 3, 4], [4, 4, 4, 4], + [-1, -3, -2, -4], [4, -3.25, -5, -6]]); + assertPointField(cd, 'b', [ + [0, 0, 0, 0], [1, 2, 3, 4], + [0, 0, 0, 0], [4, -3, -2, -4]]); + assertPointField(cd, 's', [ + [1, 2, 3, 4], [3, 2, 1, 0], + [-1, -3, -2, -4], [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', [1, 0.8, 0.6, 0.4]); + assertArrayField(cd[1][0], 't.barwidth', [0.4, 0.6, 0.8, 1]); + expect(cd[2][0].t.barwidth).toBe(1); + expect(cd[3][0].t.barwidth).toBe(0.8); + expect(cd[0][0].t.poffset).toBe(0); + expect(cd[1][0].t.poffset).toBe(0); + expect(cd[2][0].t.poffset).toBe(0); + expect(cd[3][0].t.poffset).toBe(0); + assertTraceField(cd, 't.bargroupwidth', [0.8, 0.8, 0.8, 0.8]); + + var traceNodes = getAllTraceNodes(gd), + trace0Bar3 = getAllBarNodes(traceNodes[0])[3], + path03 = trace0Bar3.querySelector('path'), + text03 = trace0Bar3.querySelector('text'), + trace1Bar2 = getAllBarNodes(traceNodes[1])[2], + path12 = trace1Bar2.querySelector('path'), + text12 = trace1Bar2.querySelector('text'), + trace2Bar0 = getAllBarNodes(traceNodes[2])[0], + path20 = trace2Bar0.querySelector('path'), + text20 = trace2Bar0.querySelector('text'), + trace3Bar0 = getAllBarNodes(traceNodes[3])[0], + path30 = trace3Bar0.querySelector('path'), + text30 = trace3Bar0.querySelector('text'); + + expect(text03.textContent).toBe('4'); + expect(text12.textContent).toBe('inside text'); + expect(text20.textContent).toBe('-1'); + expect(text30.textContent).toBe('outside text'); + + assertTextIsInsidePath(text03, path03); // inside + assertTextIsInsidePath(text12, path12); // inside + assertTextIsInsidePath(text20, path20); // inside + assertTextIsInsidePath(text30, path30); // inside + + done(); + }); + }); + + it('should coerce text-related attributes', function(done) { + var gd = createGraphDiv(), + data = [{ + y: [10, 20, 30, 40], + type: 'bar', + text: ['T1P1', 'T1P2', 13, 14], + textposition: ['inside', 'outside', 'auto', 'BADVALUE'], + textfont: { + family: ['"comic sans"'], + color: ['red', 'green'], + }, + insidetextfont: { + size: [8, 12, 16], + color: ['black'], + }, + outsidetextfont: { + size: [null, 24, 32] + } + }], + layout = { + font: {family: 'arial', color: 'blue', size: 13} + }; + + var expected = { + y: [10, 20, 30, 40], + type: 'bar', + 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', 'blue'], + 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), + barNodes = getAllBarNodes(traceNodes[0]), + pathNodes = [ + barNodes[0].querySelector('path'), + barNodes[1].querySelector('path'), + barNodes[2].querySelector('path'), + barNodes[3].querySelector('path') + ], + textNodes = [ + barNodes[0].querySelector('text'), + barNodes[1].querySelector('text'), + barNodes[2].querySelector('text'), + barNodes[3].querySelector('text') + ], + i; + + // assert bar texts + for(i = 0; i < 3; i++) { + expect(textNodes[i].textContent).toBe(expected.text[i]); + } + + // assert bar 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); + done(); }); });