diff --git a/src/components/drawing/index.js b/src/components/drawing/index.js index 7343d3bb58b..eafda1a4331 100644 --- a/src/components/drawing/index.js +++ b/src/components/drawing/index.js @@ -442,7 +442,7 @@ drawing.singlePointStyle = function(d, sel, trace, fns, gd) { fill: 'none' }); } else { - sel.style('stroke-width', lineWidth + 'px'); + sel.style('stroke-width', (d.isBlank ? 0 : lineWidth) + 'px'); var markerGradient = marker.gradient; diff --git a/src/traces/bar/index.js b/src/traces/bar/index.js index 5560603cee6..aa1a7a8feec 100644 --- a/src/traces/bar/index.js +++ b/src/traces/bar/index.js @@ -28,6 +28,7 @@ module.exports = { name: 'bar', basePlotModule: require('../../plots/cartesian'), categories: ['bar-like', 'cartesian', 'svg', 'bar', 'oriented', 'errorBarsOK', 'showLegend', 'zoomScale'], + animatable: true, 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 11d8ad24f68..0ba64571ec8 100644 --- a/src/traces/bar/plot.js +++ b/src/traces/bar/plot.js @@ -51,7 +51,28 @@ function getXY(di, xa, ya, isHorizontal) { return isHorizontal ? [s, p] : [p, s]; } -function plot(gd, plotinfo, cdModule, traceLayer, opts) { +function transition(selection, opts, makeOnCompleteCallback) { + if(hasTransition(opts)) { + var onComplete; + if(makeOnCompleteCallback) { + onComplete = makeOnCompleteCallback(); + } + return selection + .transition() + .duration(opts.duration) + .ease(opts.easing) + .each('end', function() { onComplete && onComplete(); }) + .each('interrupt', function() { onComplete && onComplete(); }); + } else { + return selection; + } +} + +function hasTransition(transitionOpts) { + return transitionOpts && transitionOpts.duration > 0; +} + +function plot(gd, plotinfo, cdModule, traceLayer, opts, makeOnCompleteCallback) { var xa = plotinfo.xaxis; var ya = plotinfo.yaxis; var fullLayout = gd._fullLayout; @@ -96,7 +117,6 @@ function plot(gd, plotinfo, cdModule, traceLayer, opts) { // 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 xy = getXY(di, xa, ya, isHorizontal); var x0 = xy[0][0]; @@ -118,8 +138,11 @@ function plot(gd, plotinfo, cdModule, traceLayer, opts) { } di.isBlank = isBlank; + if(isBlank && isHorizontal) x1 = x0; + if(isBlank && !isHorizontal) y1 = y0; + // in waterfall mode `between` we need to adjust bar end points to match the connector width - if(adjustPixel) { + if(adjustPixel && !isBlank) { if(isHorizontal) { x0 -= dirSign(x0, x1) * adjustPixel; x1 += dirSign(x0, x1) * adjustPixel; @@ -178,12 +201,18 @@ function plot(gd, plotinfo, cdModule, traceLayer, opts) { y1 = fixpx(y1, y0); } - Lib.ensureSingle(bar, 'path') + var sel = transition(Lib.ensureSingle(bar, 'path'), opts, makeOnCompleteCallback); + sel .style('vector-effect', 'non-scaling-stroke') - .attr('d', isBlank ? 'M0,0Z' : 'M' + x0 + ',' + y0 + 'V' + y1 + 'H' + x1 + 'V' + y0 + 'Z') + .attr('d', 'M' + x0 + ',' + y0 + 'V' + y1 + 'H' + x1 + 'V' + y0 + 'Z') .call(Drawing.setClipUrl, plotinfo.layerClipId, gd); - appendBarText(gd, plotinfo, bar, cd, i, x0, x1, y0, y1, opts); + if(hasTransition(opts)) { + var styleFns = Drawing.makePointStyleFns(trace); + Drawing.singlePointStyle(di, sel, trace, styleFns, gd); + } + + appendBarText(gd, plotinfo, bar, cd, i, x0, x1, y0, y1, opts, makeOnCompleteCallback); if(plotinfo.layerClipId) { Drawing.hideOutsideRangePoint(di, bar.select('text'), xa, ya, trace.xcalendar, trace.ycalendar); @@ -197,10 +226,10 @@ function plot(gd, plotinfo, cdModule, traceLayer, opts) { }); // error bars are on the top - Registry.getComponentMethod('errorbars', 'plot')(gd, bartraces, plotinfo); + Registry.getComponentMethod('errorbars', 'plot')(gd, bartraces, plotinfo, opts); } -function appendBarText(gd, plotinfo, bar, calcTrace, i, x0, x1, y0, y1, opts) { +function appendBarText(gd, plotinfo, bar, calcTrace, i, x0, x1, y0, y1, opts, makeOnCompleteCallback) { var xa = plotinfo.xaxis; var ya = plotinfo.yaxis; @@ -212,7 +241,6 @@ function appendBarText(gd, plotinfo, bar, calcTrace, i, x0, x1, y0, y1, opts) { .text(text) .attr({ 'class': 'bartext bartext-' + textPosition, - transform: '', 'text-anchor': 'middle', // prohibit tex interpretation until we can handle // tex and regular text together @@ -325,9 +353,12 @@ function appendBarText(gd, plotinfo, bar, calcTrace, i, x0, x1, y0, y1, opts) { (textPosition === 'outside') ? outsideTextFont : insideTextFont); + var currentTransform = textSelection.attr('transform'); + textSelection.attr('transform', ''); textBB = Drawing.bBox(textSelection.node()), textWidth = textBB.width, textHeight = textBB.height; + textSelection.attr('transform', currentTransform); if(textWidth <= 0 || textHeight <= 0) { textSelection.remove(); @@ -360,7 +391,7 @@ function appendBarText(gd, plotinfo, bar, calcTrace, i, x0, x1, y0, y1, opts) { })); } - textSelection.attr('transform', transform); + transition(textSelection, opts, makeOnCompleteCallback).attr('transform', transform); } function getRotateFromAngle(angle) { diff --git a/test/image/baselines/animation_bar.png b/test/image/baselines/animation_bar.png new file mode 100644 index 00000000000..262aaed7be1 Binary files /dev/null and b/test/image/baselines/animation_bar.png differ diff --git a/test/image/mocks/animation_bar.json b/test/image/mocks/animation_bar.json new file mode 100644 index 00000000000..60955f86ff7 --- /dev/null +++ b/test/image/mocks/animation_bar.json @@ -0,0 +1,22 @@ +{ + "data": [{ + "type": "bar", + "x": ["A", "B", "C"], + "y": [24, 5, 8], + "error_y": {"array": [3, 2, 1]} + }], + "layout": { + "width": 400, + "height": 400, + "yaxis": {"range": [0, 30]} + }, + "frames": [ + {"data": [{"y": [12, 15, 10]}]}, + {"data": [{"error_y": {"array": [5, 4, 1]}}]}, + {"data": [{"marker": {"color": ["red", "blue", "green"]}}]}, + {"data": [{"width": 0.25}]}, + {"data": [{"marker": {"line": {"width": 10}}}]}, + {"data": [{"marker": {"line": {"color": ["orange", "yellow", "blue"]}}}]}, + {"layout": {"yaxis": {"range": [0, 20]}}} + ] +} diff --git a/test/jasmine/assets/check_transitions.js b/test/jasmine/assets/check_transitions.js new file mode 100644 index 00000000000..35f4df88bb2 --- /dev/null +++ b/test/jasmine/assets/check_transitions.js @@ -0,0 +1,133 @@ +'use strict'; + +var Plotly = require('@lib/index'); +var Lib = require('@src/lib'); +var d3 = require('d3'); +var delay = require('./delay.js'); + +var reNumbers = /([\d\.]+)/gm; + +function promiseSerial(funcs, wait) { + return funcs.reduce(function(promise, func) { + return promise.then(function(result) { + return func().then(Array.prototype.concat.bind(result)).then(delay(wait)); + }); + }, Promise.resolve([])); +} + +function clockTick(currentNow, milliseconds) { + Date.now = function() { + return currentNow + milliseconds; + }; + d3.timer.flush(); +} + +// Using the methodology from http://eng.wealthfront.com/2017/10/26/testing-d3-transitions/ +module.exports = function checkTransition(gd, mock, animateOpts, transitionOpts, tests) { + if(!transitionOpts) { + transitionOpts = { + transition: { + duration: 500, + easing: 'linear' + }, + frame: { + duration: 500 + } + }; + } + // Prepare chain + var now = Date.now; + var startTime; + var currentTime; + var p = [ + function() { + return Plotly.newPlot(gd, mock) + .then(function() { + // Check initial states if present + for(var i = 0; i < tests.length; i++) { + if(tests[i][0] === 0) assert(tests[i]); + } + }); + }, + function() { + // Hijack Date.now + startTime = Date.now(); + currentTime = 0; + clockTick(startTime, 0); + Plotly.animate(gd, animateOpts, transitionOpts); + return Promise.resolve(true); + } + ]; + + var checkTests = tests.map(function(test) { + return function() { + if(test[0] === 0) return Promise.resolve(true); + if(test[0] !== currentTime) { + clockTick(startTime, test[0]); + currentTime = test[0]; + } + return assert(test); + }; + }); + + // Run all tasks + return promiseSerial(p.concat(checkTests)) + .catch(function(err) { + Date.now = now; + return Promise.reject(err); + }) + .then(function() { + Date.now = now; + }); +}; + +// A test array is made of +// [ms since start of transition, selector, (attr|style), name of attribute, array of values to be found] +// Ex.: [0, '.point path', 'style', 'fill', ['rgb(31, 119, 180)', 'rgb(31, 119, 180)', 'rgb(31, 119, 180)']] +function assert(test) { + var msg = 'at ' + test[0] + 'ms, selection ' + test[1] + ' has ' + test[3]; + var cur = []; + d3.selectAll(test[1]).each(function(d, i) { + if(test[2] === 'style') cur[i] = this.style[test[3]]; + if(test[2] === 'attr') cur[i] = d3.select(this).attr(test[3]); + }); + switch(test[3]) { + case 'd': + assertEqual(cur, test[4], round, msg); + break; + case 'transform': + assertCloseTo(cur, test[4], 3, extractNumbers, msg); + break; + default: + assertEqual(cur, test[4], Lib.identity, msg); + } + return Promise.resolve(true); +} + +function assertEqual(A, B, cb, msg) { + var a = cb(A); + var b = cb(B); + expect(a).withContext(msg + ' equal to ' + JSON.stringify(a)).toEqual(b); +} + +function assertCloseTo(A, B, tolerance, cb, msg) { + var a = cb(A).flat(); + var b = cb(B).flat(); + expect(a).withContext(msg + ' equal to ' + JSON.stringify(A)).toBeWithinArray(b, tolerance); +} + +function extractNumbers(array) { + return array.map(function(d) { + return d.match(reNumbers).map(function(n) { + return parseFloat(n); + }); + }); +} + +function round(array) { + return array.map(function(cur) { + return cur.replace(reNumbers, function(match) { + return Math.round(match); + }); + }); +} diff --git a/test/jasmine/tests/bar_test.js b/test/jasmine/tests/bar_test.js index 32d45cffb7f..2b36d728017 100644 --- a/test/jasmine/tests/bar_test.js +++ b/test/jasmine/tests/bar_test.js @@ -26,6 +26,7 @@ var assertClip = customAssertions.assertClip; var assertNodeDisplay = customAssertions.assertNodeDisplay; var assertHoverLabelContent = customAssertions.assertHoverLabelContent; var checkTextTemplate = require('../assets/check_texttemplate'); +var checkTransition = require('../assets/check_transitions'); var Fx = require('@src/components/fx'); var d3 = require('d3'); @@ -2548,3 +2549,138 @@ function assertTraceField(calcData, prop, expectation) { expect(values).toBeCloseToArray(expectation, undefined, '(field ' + prop + ')'); } + +describe('bar tweening', function() { + var gd; + var mock = { + 'data': [{ + 'type': 'bar', + 'x': ['A', 'B', 'C'], + 'text': ['A', 'B', 'C'], + 'textposition': 'inside', + 'y': [24, 5, 8], + 'error_y': {'array': [3, 2, 1]} + }] + }; + var transitionOpts = false; // use default + + beforeEach(function() { + gd = createGraphDiv(); + }); + + afterEach(function() { + Plotly.purge(gd); + destroyGraphDiv(); + }); + + it('for bar fill color', function(done) { + var tests = [ + [0, '.point path', 'style', 'fill', ['rgb(31, 119, 180)', 'rgb(31, 119, 180)', 'rgb(31, 119, 180)']], + [100, '.point path', 'style', 'fill', ['rgb(76, 95, 144)', 'rgb(25, 146, 144)', 'rgb(25, 95, 195)']], + [300, '.point path', 'style', 'fill', ['rgb(165, 48, 72)', 'rgb(12, 201, 72)', 'rgb(12, 48, 225)']], + [500, '.point path', 'style', 'fill', ['rgb(255, 0, 0)', 'rgb(0, 255, 0)', 'rgb(0, 0, 255)']] + ]; + var animateOpts = {'data': [{'marker': {'color': ['rgb(255, 0, 0)', 'rgb(0, 255, 0)', 'rgb(0, 0, 255)']}}]}; + + checkTransition(gd, mock, animateOpts, transitionOpts, tests) + .catch(failTest) + .then(done); + }); + + it('for vertical bar height and text position', function(done) { + var tests = [ + [0, '.point path', 'attr', 'd', ['M18,270V42H162V270Z', 'M198,270V222.5H342V270Z', 'M378,270V194H522V270Z']], + [0, 'text.bartext', 'attr', 'transform', ['translate(90 56)', 'translate(270 236.5)', 'translate(450 208)']], + [100, '.point path', 'attr', 'd', ['M18,270V78.1H162V270Z', 'M198,270V186.4H342V270Z', 'M378,270V186.40000000000003H522V270Z']], + [300, '.point path', 'attr', 'd', ['M18,270V150.3H162V270Z', 'M198,270V114.2H342V270Z', 'M378,270V171.2H522V270Z']], + [300, 'text.bartext', 'attr', 'transform', ['translate(90,164.3)', 'translate(270,128.20000000000002)', 'translate(450,185.2)']], + [500, '.point path', 'attr', 'd', ['M18,270V222.5H162V270Z', 'M198,270V42H342V270Z', 'M378,270V156H522V270Z']], + [600, '.point path', 'attr', 'd', ['M18,270V222.5H162V270Z', 'M198,270V42H342V270Z', 'M378,270V156H522V270Z']], + [600, 'text.bartext', 'attr', 'transform', ['translate(90 236.5)', 'translate(270 56)', 'translate(450 170)']], + ]; + var animateOpts = {data: [{y: [5, 24, 12]}]}; + + checkTransition(gd, mock, animateOpts, transitionOpts, tests) + .catch(failTest) + .then(done); + }); + + it('for vertical bar width', function(done) { + var tests = [ + [0, '.point path', 'attr', 'd', ['M54,270V13.5H486V270Z']], + [250, '.point path', 'attr', 'd', ['M94.5,270V13.5H445.5V270Z']], + [500, '.point path', 'attr', 'd', ['M135,270V13.5H405V270Z']] + ]; + var animateOpts = {data: [{width: 0.5}]}; + + checkTransition(gd, { + data: [{ + type: 'bar', + y: [5] + }]}, + animateOpts, transitionOpts, tests) + .catch(failTest) + .then(done); + }); + + it('for horizontal bar length and text position', function(done) { + var mockCopy = Lib.extendDeep({}, mock); + mockCopy.data[0].orientation = 'h'; + mockCopy.data[0].x = mock.data[0].y.slice(); + mockCopy.data[0].y = mock.data[0].x.slice(); + var tests = [ + [0, '.point path', 'attr', 'd', ['M0,261V189H107V261Z', 'M0,171V99H513V171Z', 'M0,81V9H257V81Z']], + [0, 'text.bartext', 'attr', 'transform', ['translate(100 229)', 'translate(506 139)', 'translate(249 49)']], + [150, '.point path', 'attr', 'd', ['M0,261V189H171V261Z', 'M0,171V99H455V171Z', 'M0,81V9H276V81Z']], + [300, '.point path', 'attr', 'd', ['M0,261V189H235V261Z', 'M0,171V99H398V171Z', 'M0,81V9H295V81Z']], + [300, 'text.bartext', 'attr', 'transform', ['translate(228,229)', 'translate(391,139)', 'translate(287,49)']], + [450, '.point path', 'attr', 'd', ['M0,261V189H299V261Z', 'M0,171V99H340V171Z', 'M0,81V9H314V81Z']], + [600, '.point path', 'attr', 'd', ['M0,261V189H321V261Z', 'M0,171V99H321V171Z', 'M0,81V9H321V81Z']], + [600, 'text.bartext', 'attr', 'transform', ['translate(314 229)', 'translate(314 139)', 'translate(313 49)']] + ]; + var animateOpts = {data: [{x: [15, 15, 15]}]}; + + checkTransition(gd, mockCopy, animateOpts, transitionOpts, tests) + .catch(failTest) + .then(done); + }); + + it('for bar line width and color', function(done) { + var tests = [ + [0, '.point path', 'style', 'stroke', ['', '', '']], + [0, '.point path', 'style', 'stroke-width', ['0px', '0px', '0px']], + [150, '.point path', 'style', 'stroke', ['rgb(77, 0, 0)', 'rgb(0, 77, 0)', 'rgb(0, 0, 77)']], + [150, '.point path', 'style', 'stroke-width', ['6px', '6px', '6px']], + [300, '.point path', 'style', 'stroke', ['rgb(153, 0, 0)', 'rgb(0, 153, 0)', 'rgb(0, 0, 153)']], + [300, '.point path', 'style', 'stroke-width', ['12px', '12px', '12px']], + [450, '.point path', 'style', 'stroke', ['rgb(230, 0, 0)', 'rgb(0, 230, 0)', 'rgb(0, 0, 230)']], + [450, '.point path', 'style', 'stroke-width', ['18px', '18px', '18px']], + [600, '.point path', 'style', 'stroke', ['rgb(255, 0, 0)', 'rgb(0, 255, 0)', 'rgb(0, 0, 255)']], + [600, '.point path', 'style', 'stroke-width', ['20px', '20px', '20px']] + ]; + var animateOpts = {'data': [{'marker': {'line': {'width': 20, 'color': ['rgb(255, 0, 0)', 'rgb(0, 255, 0)', 'rgb(0, 0, 255)']}}}]}; + + checkTransition(gd, mock, animateOpts, transitionOpts, tests) + .catch(failTest) + .then(done); + }); + + it('for error bars', function(done) { + var tests = [ + [0, 'path.yerror', 'attr', 'd', ['M266,13.5h8m-4,0V99m-4,0h8']], + [250, 'path.yerror', 'attr', 'd', ['M266,-18.56h8m-4,0V131.065m-4,0h8']], + [500, 'path.yerror', 'attr', 'd', ['M266,-50.62h8m-4,0V163.13m-4,0h8']] + ]; + var animateOpts = {data: [{error_y: {value: 50}}]}; + + checkTransition(gd, { + data: [{ + type: 'bar', + y: [2], + error_y: {value: 20} + }]}, + animateOpts, transitionOpts, tests) + .catch(failTest) + .then(done); + }); +}); diff --git a/test/jasmine/tests/transition_test.js b/test/jasmine/tests/transition_test.js index 96fdb32857c..9097392ba06 100644 --- a/test/jasmine/tests/transition_test.js +++ b/test/jasmine/tests/transition_test.js @@ -435,7 +435,7 @@ describe('Plotly.react transitions:', function() { addSpies(); var trace = { - type: 'bar', + type: 'violin', y: [1], marker: {line: {width: 1}} }; @@ -444,8 +444,8 @@ describe('Plotly.react transitions:', function() { var layout = {transition: {duration: 10}}; // sanity check that this test actually tests what was intended - var Bar = Registry.modules.bar._module; - if(Bar.animatable || Bar.attributes.marker.line.width.anim !== true) { + var Violin = Registry.modules.violin._module; + if(Violin.animatable || Violin.attributes.marker.line.width.anim !== true) { fail('Test no longer tests its indented code path:' + ' This test is meant to test that Plotly.react with' + ' *anim:true* attributes in *animatable:false* modules' +