From f3fee88ffd01754ab3cc69302f94c8bf48035cfd Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Wed, 22 Jun 2016 14:27:18 -0400 Subject: [PATCH 01/82] Reuse SVG DOM elements for scatter traces --- src/components/errorbars/plot.js | 17 +- src/plots/cartesian/index.js | 81 +++--- src/plots/plots.js | 9 +- src/traces/scatter/link_traces.js | 39 +++ src/traces/scatter/plot.js | 389 ++++++++++++++++++--------- test/image/mocks/ternary_simple.json | 3 +- test/jasmine/tests/calcdata_test.js | 14 +- test/jasmine/tests/cartesian_test.js | 99 ++++++- 8 files changed, 471 insertions(+), 180 deletions(-) create mode 100644 src/traces/scatter/link_traces.js diff --git a/src/components/errorbars/plot.js b/src/components/errorbars/plot.js index 3f44ed58df1..eb66769760e 100644 --- a/src/components/errorbars/plot.js +++ b/src/components/errorbars/plot.js @@ -34,15 +34,24 @@ module.exports = function plot(traces, plotinfo) { trace.marker.maxdisplayed > 0 ); + var keyFunc; + + if(trace.key) { + keyFunc = function(d) { return d.key; }; + } + if(!yObj.visible && !xObj.visible) return; - var errorbars = d3.select(this).selectAll('g.errorbar') - .data(Lib.identity); + var selection = d3.select(this).selectAll('g.errorbar'); - errorbars.enter().append('g') + var join = selection.data(Lib.identity, keyFunc); + + join.enter().append('g') .classed('errorbar', true); - errorbars.each(function(d) { + join.exit().remove(); + + join.each(function(d) { var errorbar = d3.select(this); var coords = errorCoords(d, xa, ya); diff --git a/src/plots/cartesian/index.js b/src/plots/cartesian/index.js index 56f827f0ec6..7ced4299776 100644 --- a/src/plots/cartesian/index.js +++ b/src/plots/cartesian/index.js @@ -25,61 +25,74 @@ exports.attrRegex = constants.attrRegex; exports.attributes = require('./attributes'); -exports.plot = function(gd) { +exports.plot = function(gd, traces) { + var cdSubplot, cd, trace, i, j, k, isFullReplot; + var fullLayout = gd._fullLayout, subplots = Plots.getSubplotIds(fullLayout, 'cartesian'), calcdata = gd.calcdata, modules = fullLayout._modules; - function getCdSubplot(calcdata, subplot) { - var cdSubplot = []; - - for(var i = 0; i < calcdata.length; i++) { - var cd = calcdata[i]; - var trace = cd[0].trace; - - if(trace.xaxis + trace.yaxis === subplot) { - cdSubplot.push(cd); - } + if(!Array.isArray(traces)) { + // If traces is not provided, then it's a complete replot and missing + // traces are removed + isFullReplot = true; + traces = []; + for(i = 0; i < calcdata.length; i++) { + traces.push(i); } - - return cdSubplot; + } else { + // If traces are explicitly specified, then it's a partial replot and + // traces are not removed. + isFullReplot = false; } - function getCdModule(cdSubplot, _module) { - var cdModule = []; + for(i = 0; i < subplots.length; i++) { + var subplot = subplots[i], + subplotInfo = fullLayout._plots[subplot]; + + // Get all calcdata for this subplot: + cdSubplot = []; + for(j = 0; j < calcdata.length; j++) { + cd = calcdata[j]; + trace = cd[0].trace; - for(var i = 0; i < cdSubplot.length; i++) { - var cd = cdSubplot[i]; - var trace = cd[0].trace; + // Skip trace if whitelist provided and it's not whitelisted: + // if (Array.isArray(traces) && traces.indexOf(i) === -1) continue; - if((trace._module === _module) && (trace.visible === true)) { - cdModule.push(cd); + if(trace.xaxis + trace.yaxis === subplot && traces.indexOf(trace.index) !== -1) { + cdSubplot.push(cd); } } - return cdModule; - } - - for(var i = 0; i < subplots.length; i++) { - var subplot = subplots[i], - subplotInfo = fullLayout._plots[subplot], - cdSubplot = getCdSubplot(calcdata, subplot); - // remove old traces, then redraw everything - // TODO: use enter/exit appropriately in the plot functions - // so we don't need this - should sometimes be a big speedup - if(subplotInfo.plot) subplotInfo.plot.selectAll('g.trace').remove(); + // TODO: scatterlayer is manually excluded from this since it knows how + // to update instead of fully removing and redrawing every time. The + // remaining plot traces should also be able to do this. Once implemented, + // we won't need this - which should sometimes be a big speedup. + if(subplotInfo.plot) { + subplotInfo.plot.selectAll('g:not(.scatterlayer)').selectAll('g.trace').remove(); + } - for(var j = 0; j < modules.length; j++) { + // Plot all traces for each module at once: + for(j = 0; j < modules.length; j++) { var _module = modules[j]; // skip over non-cartesian trace modules if(_module.basePlotModule.name !== 'cartesian') continue; // plot all traces of this type on this subplot at once - var cdModule = getCdModule(cdSubplot, _module); - _module.plot(gd, subplotInfo, cdModule); + var cdModule = []; + for(k = 0; k < cdSubplot.length; k++) { + cd = cdSubplot[k]; + trace = cd[0].trace; + + if((trace._module === _module) && (trace.visible === true)) { + cdModule.push(cd); + } + } + + _module.plot(gd, subplotInfo, cdModule, isFullReplot); } } }; diff --git a/src/plots/plots.js b/src/plots/plots.js index 4b814bfd85c..4a8def06d1b 100644 --- a/src/plots/plots.js +++ b/src/plots/plots.js @@ -582,12 +582,17 @@ plots.cleanPlot = function(newFullData, newFullLayout, oldFullData, oldFullLayou if(oldUid === newTrace.uid) continue oldLoop; } - // clean old heatmap and contour traces + // clean old heatmap, contour, and scatter traces + // + // Note: This is also how scatter traces (cartesian and scatterternary) get + // removed since otherwise the scatter module is not called (and so the join + // doesn't register the removal) if scatter traces disappear entirely. if(hasPaper) { oldFullLayout._paper.selectAll( '.hm' + oldUid + ',.contour' + oldUid + - ',#clip' + oldUid + ',#clip' + oldUid + + ',.trace' + oldUid ).remove(); } diff --git a/src/traces/scatter/link_traces.js b/src/traces/scatter/link_traces.js new file mode 100644 index 00000000000..801d02b0d64 --- /dev/null +++ b/src/traces/scatter/link_traces.js @@ -0,0 +1,39 @@ +/** +* Copyright 2012-2016, 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 = function linkTraces(gd, plotinfo, cdscatter) { + var cd, trace; + var prevtrace = null; + + for(var i = 0; i < cdscatter.length; ++i) { + cd = cdscatter[i]; + trace = cd[0].trace; + + // Note: The check which ensures all cdscatter here are for the same axis and + // are either cartesian or scatterternary has been removed. This code assumes + // the passed scattertraces have been filtered to the proper plot types and + // the proper subplots. + if(trace.visible === true) { + trace._nexttrace = null; + + if(['tonextx', 'tonexty', 'tonext'].indexOf(trace.fill) !== -1) { + trace._prevtrace = prevtrace; + + if(prevtrace) { + prevtrace._nexttrace = trace; + } + } + + prevtrace = trace; + } else { + trace._prevtrace = trace._nexttrace = null; + } + } +}; diff --git a/src/traces/scatter/plot.js b/src/traces/scatter/plot.js index d2388f40c22..8ec17ff2183 100644 --- a/src/traces/scatter/plot.js +++ b/src/traces/scatter/plot.js @@ -15,80 +15,161 @@ var Lib = require('../../lib'); var Drawing = require('../../components/drawing'); var ErrorBars = require('../../components/errorbars'); -var polygonTester = require('../../lib/polygon').tester; - var subTypes = require('./subtypes'); var arraysToCalcdata = require('./arrays_to_calcdata'); var linePoints = require('./line_points'); +var linkTraces = require('./link_traces'); +var polygonTester = require('../../lib/polygon').tester; +module.exports = function plot(gd, plotinfo, cdscatter, isFullReplot) { + var i, uids, selection, join; -module.exports = function plot(gd, plotinfo, cdscatter) { - selectMarkers(gd, plotinfo, cdscatter); + var scatterlayer = plotinfo.plot.select('g.scatterlayer'); - var xa = plotinfo.x(), - ya = plotinfo.y(); + selection = scatterlayer.selectAll('g.trace'); - // make the container for scatter plots - // (so error bars can find them along with bars) - var scattertraces = plotinfo.plot.select('.scatterlayer') - .selectAll('g.trace.scatter') - .data(cdscatter); + join = selection.data(cdscatter, function(d) {return d[0].trace.uid;}); - scattertraces.enter().append('g') - .attr('class', 'trace scatter') + // Append new traces: + join.enter().append('g') + .attr('class', function(d) { + return 'trace scatter trace' + d[0].trace.uid; + }) .style('stroke-miterlimit', 2); - // error bars are at the bottom - scattertraces.call(ErrorBars.plot, plotinfo); + // After the elements are created but before they've been draw, we have to perform + // this extra step of linking the traces. This allows appending of fill layers so that + // the z-order of fill layers is correct. + linkTraces(gd, plotinfo, cdscatter); - // BUILD LINES AND FILLS - var prevpath = '', - prevPolygons = [], - ownFillEl3, ownFillDir, tonext, nexttonext; + createFills(gd, scatterlayer); - scattertraces.each(function(d) { - var trace = d[0].trace, - line = trace.line, - tr = d3.select(this); - if(trace.visible !== true) return; + // Sort the traces, once created, so that the ordering is preserved even when traces + // are shown and hidden. This is needed since we're not just wiping everything out + // and recreating on every update. + for(i = 0, uids = []; i < cdscatter.length; i++) { + uids[i] = cdscatter[i][0].trace.uid; + } - ownFillDir = trace.fill.charAt(trace.fill.length - 1); - if(ownFillDir !== 'x' && ownFillDir !== 'y') ownFillDir = ''; + scatterlayer.selectAll('g.trace').sort(function(a, b) { + var idx1 = uids.indexOf(a[0].trace.uid); + var idx2 = uids.indexOf(b[0].trace.uid); + return idx1 > idx2 ? 1 : -1; + }); - d[0].node3 = tr; // store node for tweaking by selectPoints + // Must run the selection again since otherwise enters/updates get grouped together + // and these get executed out of order. Except we need them in order! + scatterlayer.selectAll('g.trace').each(function(d, i) { + plotOne(gd, i, plotinfo, d, cdscatter, this); + }); - arraysToCalcdata(d); + if(isFullReplot) { + join.exit().remove(); + } + + // remove paths that didn't get used + scatterlayer.selectAll('path:not([d])').remove(); +}; - if(!subTypes.hasLines(trace) && trace.fill === 'none') return; +function createFills(gd, scatterlayer) { + var trace; - var thispath, - thisrevpath, - // fullpath is all paths for this curve, joined together straight - // across gaps, for filling - fullpath = '', - // revpath is fullpath reversed, for fill-to-next - revpath = '', - // functions for converting a point array to a path - pathfn, revpathbase, revpathfn; + scatterlayer.selectAll('g.trace').each(function(d) { + var tr = d3.select(this); - // make the fill-to-zero path now, so it shows behind the line - // fill to next puts the fill associated with one trace - // grouped with the previous - if(trace.fill.substr(0, 6) === 'tozero' || trace.fill === 'toself' || - (trace.fill.substr(0, 2) === 'to' && !prevpath)) { - ownFillEl3 = tr.append('path') - .classed('js-fill', true); - } - else ownFillEl3 = null; + // Loop only over the traces being redrawn: + trace = d[0].trace; // make the fill-to-next path now for the NEXT trace, so it shows // behind both lines. - // nexttonext was created last time, but give it - // this curve's data for fill color - if(nexttonext) tonext = nexttonext.datum(d); + if(trace._nexttrace) { + trace._nextFill = tr.select('.js-fill.js-tonext'); + if(!trace._nextFill.size()) { + + // If there is an existing tozero fill, we must insert this *after* that fill: + var loc = ':first-child'; + if(tr.select('.js-fill.js-tozero').size()) { + loc += ' + *'; + } - // now make a new nexttonext for next time - nexttonext = tr.append('path').classed('js-fill', true); + trace._nextFill = tr.insert('path', loc).attr('class', 'js-fill js-tonext'); + } + } else { + tr.selectAll('.js-fill.js-tonext').remove(); + trace._nextFill = null; + } + + if(trace.fill.substr(0, 6) === 'tozero' || trace.fill === 'toself' || + (trace.fill.substr(0, 2) === 'to' && !trace._prevtrace)) { + trace._ownFill = tr.select('.js-fill.js-tozero'); + if(!trace._ownFill.size()) { + trace._ownFill = tr.insert('path', ':first-child').attr('class', 'js-fill js-tozero'); + } + } else { + tr.selectAll('.js-fill.js-tozero').remove(); + trace._ownFill = null; + } + }); +} + +function plotOne(gd, idx, plotinfo, cdscatter, cdscatterAll, element) { + var join, i; + + // Since this has been reorganized and we're executing this on individual traces, + // we need to pass it the full list of cdscatter as well as this trace's index (idx) + // since it does an internal n^2 loop over comparisons with other traces: + selectMarkers(gd, idx, plotinfo, cdscatter, cdscatterAll); + + var xa = plotinfo.x(), + ya = plotinfo.y(); + + var trace = cdscatter[0].trace, + line = trace.line, + tr = d3.select(element); + + // (so error bars can find them along with bars) + // error bars are at the bottom + tr.call(ErrorBars.plot, plotinfo); + + if(trace.visible !== true) return; + + // BUILD LINES AND FILLS + var ownFillEl3, tonext; + var ownFillDir = trace.fill.charAt(trace.fill.length - 1); + if(ownFillDir !== 'x' && ownFillDir !== 'y') ownFillDir = ''; + + // store node for tweaking by selectPoints + cdscatter[0].node3 = tr; + + arraysToCalcdata(cdscatter); + + var prevpath = ''; + var prevPolygons = []; + var prevtrace = trace._prevtrace; + + if(prevtrace) { + prevpath = prevtrace._revpath || ''; + tonext = prevtrace._nextFill; + prevPolygons = prevtrace._polygons; + } + + var thispath, + thisrevpath, + // fullpath is all paths for this curve, joined together straight + // across gaps, for filling + fullpath = '', + // revpath is fullpath reversed, for fill-to-next + revpath = '', + // functions for converting a point array to a path + pathfn, revpathbase, revpathfn; + + ownFillEl3 = trace._ownFill; + + if(subTypes.hasLines(trace) || trace.fill !== 'none') { + if(tonext) { + // This tells .style which trace to use for fill information: + tonext.datum(cdscatter); + } if(['hv', 'vh', 'hvh', 'vhv'].indexOf(line.shape) !== -1) { pathfn = Drawing.steps(line.shape); @@ -120,7 +201,7 @@ module.exports = function plot(gd, plotinfo, cdscatter) { return revpathbase(pts.reverse()); }; - var segments = linePoints(d, { + var segments = linePoints(cdscatter, { xaxis: xa, yaxis: ya, connectGaps: trace.connectgaps, @@ -132,9 +213,7 @@ module.exports = function plot(gd, plotinfo, cdscatter) { // polygons for hover on fill // TODO: can we skip this if hoveron!=fills? That would mean we // need to redraw when you change hoveron... - var thisPolygons = trace._polygons = new Array(segments.length), - i; - + var thisPolygons = trace._polygons = new Array(segments.length); for(i = 0; i < segments.length; i++) { trace._polygons[i] = polygonTester(segments[i]); } @@ -144,8 +223,12 @@ module.exports = function plot(gd, plotinfo, cdscatter) { lastSegment = segments[segments.length - 1], pt1 = lastSegment[lastSegment.length - 1]; - for(i = 0; i < segments.length; i++) { - var pts = segments[i]; + var lineJoin = tr.selectAll('.js-line').data(segments); + + lineJoin.enter() + .append('path').classed('js-line', true); + + lineJoin.each(function(pts) { thispath = pathfn(pts); thisrevpath = revpathfn(pts); if(!fullpath) { @@ -161,9 +244,14 @@ module.exports = function plot(gd, plotinfo, cdscatter) { revpath = thisrevpath + 'Z' + revpath; } if(subTypes.hasLines(trace) && pts.length > 1) { - tr.append('path').classed('js-line', true).attr('d', thispath); + d3.select(this) + .attr('d', thispath) + .datum(cdscatter); } - } + }); + + lineJoin.exit().remove(); + if(ownFillEl3) { if(pt0 && pt1) { if(ownFillDir) { @@ -177,9 +265,10 @@ module.exports = function plot(gd, plotinfo, cdscatter) { // fill to zero: full trace path, plus extension of // the endpoints to the appropriate axis ownFillEl3.attr('d', fullpath + 'L' + pt1 + 'L' + pt0 + 'Z'); + } else { + // fill to self: just join the path to itself + ownFillEl3.attr('d', fullpath + 'Z'); } - // fill to self: just join the path to itself - else ownFillEl3.attr('d', fullpath + 'Z'); } } else if(trace.fill.substr(0, 6) === 'tonext' && fullpath && prevpath) { @@ -201,88 +290,128 @@ module.exports = function plot(gd, plotinfo, cdscatter) { } trace._polygons = trace._polygons.concat(prevPolygons); } - prevpath = revpath; - prevPolygons = thisPolygons; + trace._revpath = revpath; + trace._prevPolygons = thisPolygons; } - }); + } - // remove paths that didn't get used - scattertraces.selectAll('path:not([d])').remove(); function visFilter(d) { return d.filter(function(v) { return v.vis; }); } - scattertraces.append('g') - .attr('class', 'points') - .each(function(d) { - var trace = d[0].trace, - s = d3.select(this), - showMarkers = subTypes.hasMarkers(trace), - showText = subTypes.hasText(trace); - - if((!showMarkers && !showText) || trace.visible !== true) s.remove(); - else { - if(showMarkers) { - s.selectAll('path.point') - .data(trace.marker.maxdisplayed ? visFilter : Lib.identity) - .enter().append('path') - .classed('point', true) - .call(Drawing.translatePoints, xa, ya); - } - if(showText) { - s.selectAll('g') - .data(trace.marker.maxdisplayed ? visFilter : Lib.identity) - // each text needs to go in its own 'g' in case - // it gets converted to mathjax - .enter().append('g') - .append('text') - .call(Drawing.translatePoints, xa, ya); - } + function keyFunc(d) { + return d.key; + } + + // Returns a function if the trace is keyed, otherwise returns undefined + function getKeyFunc(trace) { + if(trace.key) { + return keyFunc; + } + } + + function makePoints(d) { + var join, selection; + var trace = d[0].trace, + s = d3.select(this), + showMarkers = subTypes.hasMarkers(trace), + showText = subTypes.hasText(trace); + + if((!showMarkers && !showText) || trace.visible !== true) s.remove(); + else { + if(showMarkers) { + selection = s.selectAll('path.point'); + + join = selection + .data(trace.marker.maxdisplayed ? visFilter : Lib.identity, getKeyFunc(trace)); + + join.enter().append('path') + .classed('point', true) + .call(Drawing.pointStyle, trace) + .call(Drawing.translatePoints, xa, ya); + + selection + .call(Drawing.translatePoints, xa, ya) + .call(Drawing.pointStyle, trace); + + join.exit().remove(); } - }); -}; + if(showText) { + selection = s.selectAll('g'); + + join = selection + .data(trace.marker.maxdisplayed ? visFilter : Lib.identity); + + // each text needs to go in its own 'g' in case + // it gets converted to mathjax + join.enter().append('g') + .append('text') + .call(Drawing.translatePoints, xa, ya); + + selection + .call(Drawing.translatePoints, xa, ya); + + join.exit().remove(); + } + } + } -function selectMarkers(gd, plotinfo, cdscatter) { + // NB: selectAll is evaluated on instantiation: + var pointSelection = tr.selectAll('.points'); + + // Join with new data + join = pointSelection.data([cdscatter]); + + // Transition existing, but don't defer this to an async .transition since + // there's no timing involved: + pointSelection.each(makePoints); + + join.enter().append('g') + .classed('points', true) + .each(makePoints); + + join.exit().remove(); +} + +function selectMarkers(gd, idx, plotinfo, cdscatter, cdscatterAll) { var xa = plotinfo.x(), ya = plotinfo.y(), xr = d3.extent(xa.range.map(xa.l2c)), yr = d3.extent(ya.range.map(ya.l2c)); - cdscatter.forEach(function(d, i) { - var trace = d[0].trace; - if(!subTypes.hasMarkers(trace)) return; - // if marker.maxdisplayed is used, select a maximum of - // mnum markers to show, from the set that are in the viewport - var mnum = trace.marker.maxdisplayed; - - // TODO: remove some as we get away from the viewport? - if(mnum === 0) return; - - var cd = d.filter(function(v) { - return v.x >= xr[0] && v.x <= xr[1] && v.y >= yr[0] && v.y <= yr[1]; - }), - inc = Math.ceil(cd.length / mnum), - tnum = 0; - cdscatter.forEach(function(cdj, j) { - var tracei = cdj[0].trace; - if(subTypes.hasMarkers(tracei) && - tracei.marker.maxdisplayed > 0 && j < i) { - tnum++; - } - }); + var trace = cdscatter[0].trace; + if(!subTypes.hasMarkers(trace)) return; + // if marker.maxdisplayed is used, select a maximum of + // mnum markers to show, from the set that are in the viewport + var mnum = trace.marker.maxdisplayed; + + // TODO: remove some as we get away from the viewport? + if(mnum === 0) return; + + var cd = cdscatter.filter(function(v) { + return v.x >= xr[0] && v.x <= xr[1] && v.y >= yr[0] && v.y <= yr[1]; + }), + inc = Math.ceil(cd.length / mnum), + tnum = 0; + cdscatterAll.forEach(function(cdj, j) { + var tracei = cdj[0].trace; + if(subTypes.hasMarkers(tracei) && + tracei.marker.maxdisplayed > 0 && j < idx) { + tnum++; + } + }); - // if multiple traces use maxdisplayed, stagger which markers we - // display this formula offsets successive traces by 1/3 of the - // increment, adding an extra small amount after each triplet so - // it's not quite periodic - var i0 = Math.round(tnum * inc / 3 + Math.floor(tnum / 3) * inc / 7.1); - - // for error bars: save in cd which markers to show - // so we don't have to repeat this - d.forEach(function(v) { delete v.vis; }); - cd.forEach(function(v, i) { - if(Math.round((i + i0) % inc) === 0) v.vis = true; - }); + // if multiple traces use maxdisplayed, stagger which markers we + // display this formula offsets successive traces by 1/3 of the + // increment, adding an extra small amount after each triplet so + // it's not quite periodic + var i0 = Math.round(tnum * inc / 3 + Math.floor(tnum / 3) * inc / 7.1); + + // for error bars: save in cd which markers to show + // so we don't have to repeat this + cdscatter.forEach(function(v) { delete v.vis; }); + cd.forEach(function(v, i) { + if(Math.round((i + i0) % inc) === 0) v.vis = true; }); } diff --git a/test/image/mocks/ternary_simple.json b/test/image/mocks/ternary_simple.json index ea1d78ff2a3..62bc4574a66 100644 --- a/test/image/mocks/ternary_simple.json +++ b/test/image/mocks/ternary_simple.json @@ -16,8 +16,7 @@ 1, 2.12345 ], - "type": "scatterternary", - "uid": "412fa8" + "type": "scatterternary" } ], "layout": { diff --git a/test/jasmine/tests/calcdata_test.js b/test/jasmine/tests/calcdata_test.js index fb9c3049994..931b151bed0 100644 --- a/test/jasmine/tests/calcdata_test.js +++ b/test/jasmine/tests/calcdata_test.js @@ -18,15 +18,15 @@ describe('calculated data and points', function() { it('should exclude null and undefined points when false', function() { Plotly.plot(gd, [{ x: [1, 2, 3, undefined, 5], y: [1, null, 3, 4, 5]}], {}); - expect(gd.calcdata[0][1]).toEqual({ x: false, y: false}); - expect(gd.calcdata[0][3]).toEqual({ x: false, y: false}); + expect(gd.calcdata[0][1]).toEqual(jasmine.objectContaining({x: false, y: false})); + expect(gd.calcdata[0][3]).toEqual(jasmine.objectContaining({x: false, y: false})); }); it('should exclude null and undefined points as categories when false', function() { Plotly.plot(gd, [{ x: [1, 2, 3, undefined, 5], y: [1, null, 3, 4, 5] }], { xaxis: { type: 'category' }}); - expect(gd.calcdata[0][1]).toEqual({ x: false, y: false}); - expect(gd.calcdata[0][3]).toEqual({ x: false, y: false}); + expect(gd.calcdata[0][1]).toEqual(jasmine.objectContaining({x: false, y: false})); + expect(gd.calcdata[0][3]).toEqual(jasmine.objectContaining({x: false, y: false})); }); }); @@ -180,9 +180,9 @@ describe('calculated data and points', function() { }}); expect(gd.calcdata[0][0]).toEqual(jasmine.objectContaining({x: 4, y: 15})); - expect(gd.calcdata[0][1]).toEqual({ x: false, y: false}); + expect(gd.calcdata[0][1]).toEqual(jasmine.objectContaining({x: false, y: false})); expect(gd.calcdata[0][2]).toEqual(jasmine.objectContaining({x: 3, y: 12})); - expect(gd.calcdata[0][3]).toEqual({ x: false, y: false}); + expect(gd.calcdata[0][3]).toEqual(jasmine.objectContaining({x: false, y: false})); expect(gd.calcdata[0][4]).toEqual(jasmine.objectContaining({x: 2, y: 14})); }); @@ -257,7 +257,7 @@ describe('calculated data and points', function() { }}); expect(gd.calcdata[0][0]).toEqual(jasmine.objectContaining({x: 6, y: 15})); - expect(gd.calcdata[0][1]).toEqual({x: false, y: false}); + expect(gd.calcdata[0][1]).toEqual(jasmine.objectContaining({x: false, y: false})); expect(gd.calcdata[0][2]).toEqual(jasmine.objectContaining({x: 5, y: 12})); expect(gd.calcdata[0][3]).toEqual(jasmine.objectContaining({x: 0, y: 13})); expect(gd.calcdata[0][4]).toEqual(jasmine.objectContaining({x: 3, y: 14})); diff --git a/test/jasmine/tests/cartesian_test.js b/test/jasmine/tests/cartesian_test.js index 7fbe66fbef5..9bc024d6acb 100644 --- a/test/jasmine/tests/cartesian_test.js +++ b/test/jasmine/tests/cartesian_test.js @@ -7,7 +7,6 @@ var createGraphDiv = require('../assets/create_graph_div'); var destroyGraphDiv = require('../assets/destroy_graph_div'); var mouseEvent = require('../assets/mouse_event'); - describe('zoom box element', function() { var mock = require('@mocks/14.json'); @@ -50,6 +49,104 @@ describe('zoom box element', function() { }); }); +describe('restyle', function() { + describe('scatter traces', function() { + var gd; + + beforeEach(function() { + gd = createGraphDiv(); + }); + + afterEach(destroyGraphDiv); + + it('reuses SVG fills', function(done) { + var fills, firstToZero, secondToZero, firstToNext, secondToNext; + var mock = Lib.extendDeep({}, require('@mocks/basic_area.json')); + + Plotly.plot(gd, mock.data, mock.layout).then(function() { + // Assert there are two fills: + fills = d3.selectAll('g.trace.scatter .js-fill')[0]; + + // First is tozero, second is tonext: + expect(d3.selectAll('g.trace.scatter .js-fill').size()).toEqual(2); + expect(fills[0].classList.contains('js-tozero')).toBe(true); + expect(fills[0].classList.contains('js-tonext')).toBe(false); + expect(fills[1].classList.contains('js-tozero')).toBe(false); + expect(fills[1].classList.contains('js-tonext')).toBe(true); + + firstToZero = fills[0]; + firstToNext = fills[1]; + }).then(function() { + return Plotly.restyle(gd, {visible: [false]}, [1]); + }).then(function() { + // Trace 1 hidden leaves only trace zero's tozero fill: + expect(d3.selectAll('g.trace.scatter .js-fill').size()).toEqual(1); + expect(fills[0].classList.contains('js-tozero')).toBe(true); + expect(fills[0].classList.contains('js-tonext')).toBe(false); + }).then(function() { + return Plotly.restyle(gd, {visible: [true]}, [1]); + }).then(function() { + // Reshow means two fills again AND order is preserved: + fills = d3.selectAll('g.trace.scatter .js-fill')[0]; + + // First is tozero, second is tonext: + expect(d3.selectAll('g.trace.scatter .js-fill').size()).toEqual(2); + expect(fills[0].classList.contains('js-tozero')).toBe(true); + expect(fills[0].classList.contains('js-tonext')).toBe(false); + expect(fills[1].classList.contains('js-tozero')).toBe(false); + expect(fills[1].classList.contains('js-tonext')).toBe(true); + + secondToZero = fills[0]; + secondToNext = fills[1]; + + // The identity of the first is retained: + expect(firstToZero).toBe(secondToZero); + + // The second has been recreated so is different: + expect(firstToNext).not.toBe(secondToNext); + }).then(done); + }); + + it('reuses SVG lines', function(done) { + var lines, firstLine1, secondLine1, firstLine2, secondLine2; + var mock = Lib.extendDeep({}, require('@mocks/basic_line.json')); + + Plotly.plot(gd, mock.data, mock.layout).then(function() { + lines = d3.selectAll('g.scatter.trace .js-line'); + + firstLine1 = lines[0][0]; + firstLine2 = lines[0][1]; + + // One line for each trace: + expect(lines.size()).toEqual(2); + }).then(function() { + return Plotly.restyle(gd, {visible: [false]}, [0]); + }).then(function() { + lines = d3.selectAll('g.scatter.trace .js-line'); + + // Only one line now and it's equal to the second trace's line from above: + expect(lines.size()).toEqual(1); + expect(lines[0][0]).toBe(firstLine2); + }).then(function() { + return Plotly.restyle(gd, {visible: [true]}, [0]); + }).then(function() { + lines = d3.selectAll('g.scatter.trace .js-line'); + secondLine1 = lines[0][0]; + secondLine2 = lines[0][1]; + + // Two lines once again: + expect(lines.size()).toEqual(2); + + // First line has been removed and recreated: + expect(firstLine1).not.toBe(secondLine1); + + // Second line was persisted: + expect(firstLine2).toBe(secondLine2); + }).then(done); + }); + }); +}); + describe('relayout', function() { describe('axis category attributes', function() { From f50c2956639fd5f1ec7ff47351d9d3995ea178dd Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Thu, 30 Jun 2016 13:39:44 -0400 Subject: [PATCH 02/82] Implement plotly .animate() and keyframe API --- animate-api.md | 133 +++++++++++ src/core.js | 6 + src/lib/merge_keyframes.js | 31 +++ src/plot_api/plot_api.js | 258 ++++++++++++++++++++- src/plots/plots.js | 62 +++++ src/traces/scatter/plot.js | 6 +- test/jasmine/tests/frame_api_test.js | 205 ++++++++++++++++ test/jasmine/tests/keyframe_computation.js | 48 ++++ 8 files changed, 746 insertions(+), 3 deletions(-) create mode 100644 animate-api.md create mode 100644 src/lib/merge_keyframes.js create mode 100644 test/jasmine/tests/frame_api_test.js create mode 100644 test/jasmine/tests/keyframe_computation.js diff --git a/animate-api.md b/animate-api.md new file mode 100644 index 00000000000..8b65e1aee14 --- /dev/null +++ b/animate-api.md @@ -0,0 +1,133 @@ +## Top-level Plotly API methods + +#### `Plotly.transition(gd, data, layout[, traceIndices[, config]])` +Transition (eased or abruptly if desired) to a new set of data. Knows nothing about the larger state of transitions and frames; identically a 'transition the plot to look like X over Y ms' command. + +**Parameters**: +- `data`: an *array* of *objects* containing trace data, e.g. `[{x: [1, 2, 3], 'lines.color': 'red'}, {y: [7,8]}]`, mapped to traces. +- `layout`: layout properties to which to transition, probably mostly just axis ranges +- `traceIndices`: a mapping between the items of `data` and the trace indices, e.g. `[0, 2]`. If omitted, is inferred from semantics like for `restyle`—which means maybe affecting all traces? +- `config`: object containing transition configuration, including: + - `duration`: duration in ms of transition + - `ease`: d3 easing function, e.g. `elastic-in-out` + - `delay`: delay until animation; not so useful, just very very easy to pass to d3 + - `cascade`: transition points in sequence for a nice visual effect. Maybe just leave out. Kind of a common visual effect for eye candy purposes. Very easy. Can leave out if it leads to weird corner cases. See: http://rickyreusser.com/animation-experiments/#object-constancy + +**Returns**: promise that resolves when animation begins or rejects if config is invalid. + +**Events**: +- `plotly_starttransition` +- `plotly_endtransition` + +
+ +#### `Plotly.animate(gd, frame[, config])` +Transition to a keyframe. Animation sequence is: + +1. Compute the requested frame +2. Separate animatable and non-animatable properties into separate objects +3. Mark exactly what needs to happen. This includes transitions vs. non-animatable properties, whether the axis needs to be redrawn (`needsRelayout`?), and any other optimizations that seem relevant. Since for some cases very simple updates may be coming through at up to 60fps, cutting out work here could be fairly important. + +**Parameters**: +- `frame`: name of the frame to which to animate +- `config`: see `.transition`. + +**Returns**: promise that resolves when animation begins or rejects if config is invalid. + +**Events**: +- `plotly_startanimation` +- `plotly_endanimation` + +
+ +#### `Plotly.addFrames(gd, frames[, frameIndices])` +Add or overwrite frames. New frames are appended to current frame list. + +**Parameters** +- `frames`: an array of objects containing any of `name`, `data`, `layout` and `traceIndices` fields as specified above. If no name is provided, a unique name (e.g. `frame 7`) will be assigned. If the frame already exists, then its definition is overwritten. +- `frameIndices`: optional array of indices at which to insert the given frames. If indices are omitted or a specific index is falsey, then frame is appended. + +**Returns**: Promise that resolves on completion. (In this case, that's synchronously and mainly for the sake of API consistency.) + +
+ +#### `Plotly.deleteFrames(gd, frameIndices)` +Remove frames by frame index. + +**Parameters**: +- `frameIndices`: an array of integer indices of the frames to be removed. + +**Returns**: Promise that resolves on completion (which here means synchronously). + +
+ +## Frame definition + +Frames are defined similarly to mirror the input format, *not* that of `Plotly.restyle`. The easiest way to explain seems to be via an example that touches all features: + +```json +{ + "data": [{ + "x": [1, 2, 3], + "y": [4, 5, 6], + "identifiers": ["China", "Pakistan", "Australia"], + "lines": { + "color": "red" + } + }, { + "x": [1, 2, 3], + "y": [3, 8, 9], + "markers": { + "color": "red" + } + }], + "layout": { + "slider": { + "visible": true, + "plotly_method": "animate", + "args": ["$value", {"duration": 500}] + }, + "slider2": { + "visible": true, + "plotly_method": "animate", + "args": ["$value", {"duration": 500}] + } + }, + "frames": [ + { + "name": "base", + "y": [4, 5, 7], + "identifiers": ["China", "Pakistan", "Australia"], + }, { + "name": "1960", + "data": [{ + "y": [1, 2, 3], + "identifiers": ["China", "Pakistan", "Australia"], + }], + "layout": { + "xaxis": {"range": [7, 3]}, + "yaxis": {"range": [0, 5]} + }, + "baseFrame": "base", + "traceIndices": [0] + }, { + "name": "1965", + "data": [{ + "y": [5, 3, 2], + "identifiers": ["China", "Pakistan", "Australia"], + }], + "layout": { + "xaxis": {"range": [7, 3]}, + "yaxis": {"range": [0, 5]} + }, + "baseFrame": "base", + "traceIndices": [0] + } + ] +} +``` + +Notes on JSON: +- `identifiers` is used as a d3 `key` argument. +- `baseFrame` is merged… recursively? non-recursively? We'll see. Not a crucial implementation choice. +- `frames` seems maybe best stored at top level. Or maybe best on the object. If on the object, `Plotly.plot` would have to be variadic (probably), accepting `Plotly.plot(gd, data, layout[, frames], config)`. That's backward-compatible but a bit ugly. If not on the object, then it would have to be shoved into `layout` (except how, because it's really awkward place in `layout`. diff --git a/src/core.js b/src/core.js index bae6c1accad..a195b64f2b4 100644 --- a/src/core.js +++ b/src/core.js @@ -28,6 +28,12 @@ exports.prependTraces = Plotly.prependTraces; exports.addTraces = Plotly.addTraces; exports.deleteTraces = Plotly.deleteTraces; exports.moveTraces = Plotly.moveTraces; +exports.animate = Plotly.animate; +exports.addFrames = Plotly.addFrames; +exports.deleteFrames = Plotly.deleteFrames; +exports.renameFrame = Plotly.renameFrame; +exports.transition = Plotly.transition; +exports.animate = Plotly.animate; exports.purge = Plotly.purge; exports.setPlotConfig = require('./plot_api/set_plot_config'); exports.register = Plotly.register; diff --git a/src/lib/merge_keyframes.js b/src/lib/merge_keyframes.js new file mode 100644 index 00000000000..5ec15d8e39a --- /dev/null +++ b/src/lib/merge_keyframes.js @@ -0,0 +1,31 @@ +/** +* Copyright 2012-2016, 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 extend = require('./extend'); + +/* + * Merge two keyframe specifications, returning in a third object that + * can be used for plotting. + * + * @param {object} target + * An object with data, layout, and trace data + * @param {object} source + * An object with data, layout, and trace data + * + * Returns: a third object with the merged content + */ +module.exports = function mergeKeyframes(target, source) { + var result; + + result = extend.extendDeep({}, target); + result = extend.extendDeep(result, source); + + return result; +}; diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index 371f834584e..f671858a875 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -839,14 +839,18 @@ Plotly.newPlot = function(gd, data, layout, config) { return Plotly.plot(gd, data, layout, config); }; -function doCalcdata(gd) { +function doCalcdata(gd, traces) { var axList = Plotly.Axes.list(gd), fullData = gd._fullData, fullLayout = gd._fullLayout; var i, trace, module, cd; - var calcdata = gd.calcdata = new Array(fullData.length); + // XXX: Is this correct? Needs a closer look so that *some* traces can be recomputed without + // *all* needing doCalcdata: + var calcdata = new Array(fullData.length); + var oldCalcdata = gd.calcdata.slice(0); + gd.calcdata = calcdata; // extra helper variables // firstscatter: fill-to-next on the first trace goes to zero @@ -870,6 +874,13 @@ function doCalcdata(gd) { } for(i = 0; i < fullData.length; i++) { + // If traces were specified and this trace was not included, then transfer it over from + // the old calcdata: + if(Array.isArray(traces) && traces.indexOf(i) === -1) { + calcdata[i] = oldCalcdata[i]; + continue; + } + trace = fullData[i]; module = trace._module; cd = []; @@ -2476,6 +2487,249 @@ Plotly.relayout = function relayout(gd, astr, val) { }); }; +/** + * Transition to a set of new data and layout properties + * + * @param {string id or DOM element} gd + * the id or DOM element of the graph container div + */ +Plotly.transition = function(gd /*, data, layout, traces, transitionConfig*/) { + gd = getGraphDiv(gd); + + /*var fullLayout = gd._fullLayout; + + transitionConfig = Lib.extendFlat({ + ease: 'cubic-in-out', + duration: 500, + delay: 0 + }, transitionConfig || {}); + + // Create a single transition to be passed around: + if(transitionConfig.duration > 0) { + gd._currentTransition = d3.transition() + .duration(transitionConfig.duration) + .delay(transitionConfig.delay) + .ease(transitionConfig.ease); + } else { + gd._currentTransition = null; + } + + // Select which traces will be updated: + if(isNumeric(traces)) traces = [traces]; + else if(!Array.isArray(traces) || !traces.length) { + traces = gd._fullData.map(function(v, i) {return i;}); + } + + var transitioningTraces = []; + + function prepareAnimations() { + for(i = 0; i < traces.length; i++) { + var traceIdx = traces[i]; + var trace = gd._fullData[traceIdx]; + var module = trace._module; + + if(!module.animatable) { + continue; + } + + transitioningTraces.push(traceIdx); + + newTraceData = newData[i]; + curData = gd.data[traces[i]]; + + for(var ai in newTraceData) { + var value = newTraceData[ai]; + Lib.nestedProperty(curData, ai).set(value); + } + + var traceIdx = traces[i]; + if(gd.data[traceIdx].marker && gd.data[traceIdx].marker.size) { + gd._fullData[traceIdx].marker.size = gd.data[traceIdx].marker.size; + } + if(gd.data[traceIdx].error_y && gd.data[traceIdx].error_y.array) { + gd._fullData[traceIdx].error_y.array = gd.data[traceIdx].error_y.array; + } + if(gd.data[traceIdx].error_x && gd.data[traceIdx].error_x.array) { + gd._fullData[traceIdx].error_x.array = gd.data[traceIdx].error_x.array; + } + gd._fullData[traceIdx].x = gd.data[traceIdx].x; + gd._fullData[traceIdx].y = gd.data[traceIdx].y; + gd._fullData[traceIdx].z = gd.data[traceIdx].z; + gd._fullData[traceIdx].key = gd.data[traceIdx].key; + } + + doCalcdata(gd, transitioningTraces); + + ErrorBars.calc(gd); + } + + function doAnimations() { + var a, i, j; + var basePlotModules = fullLayout._basePlotModules; + for(j = 0; j < basePlotModules.length; j++) { + basePlotModules[j].plot(gd, transitioningTraces, transitionOpts); + } + + if(layout) { + for(j = 0; j < basePlotModules.length; j++) { + if(basePlotModules[j].transitionAxes) { + basePlotModules[j].transitionAxes(gd, layout, transitionOpts); + } + } + } + + if(!transitionOpts.leadingEdgeRestyle) { + return new Promise(function(resolve, reject) { + completion = resolve; + completionTimeout = setTimeout(resolve, transitionOpts.duration); + }); + } + }*/ +}; + +/** + * Animate to a keyframe + * + * @param {string} name + * name of the keyframe to create + * @param {object} transitionConfig + * configuration for transition + */ +Plotly.animate = function(gd, name /*, transitionConfig*/) { + gd = getGraphDiv(gd); + + var _frames = gd._frameData._frames; + + if(!_frames[name]) { + Lib.warn('animateToFrame failure: keyframe does not exist', name); + return Promise.reject(); + } + + return Promise.resolve(); +}; + +/** + * Create new keyframes + * + * @param {array of objects} frameList + * list of frame definitions, in which each object includes any of: + * - name: {string} name of keyframe to add + * - data: {array of objects} trace data + * - layout {object} layout definition + * - traces {array} trace indices + * - baseFrame {string} name of keyframe from which this keyframe gets defaults + */ +Plotly.addFrames = function(gd, frameList, indices) { + gd = getGraphDiv(gd); + + var i, frame, j, idx; + var _frames = gd._frameData._frames; + var _hash = gd._frameData._frameHash; + + + if(!Array.isArray(frameList)) { + Lib.warn('addFrames failure: frameList must be an Array of frame definitions', frameList); + return Promise.reject(); + } + + // Create a sorted list of insertions since we run into lots of problems if these + // aren't in ascending order of index: + // + // Strictly for sorting. Make sure this is guaranteed to never collide with any + // already-exisisting indices: + var bigIndex = _frames.length + frameList.length * 2; + + var insertions = []; + for(i = frameList.length - 1; i >= 0; i--) { + insertions.push({ + frame: frameList[i], + index: (indices && indices[i] !== undefined) ? indices[i] : bigIndex + i + }); + } + + // Sort this, taking note that undefined insertions end up at the end: + insertions.sort(function(a, b) { + if(a.index > b.index) return -1; + if(a.index < b.index) return 1; + return 0; + }); + + var ops = []; + var revops = []; + var frameCount = _frames.length; + + for(i = insertions.length - 1; i >= 0; i--) { + //frame = frameList[i]; + frame = insertions[i].frame; + + if(!frame.name) { + while(_frames[(frame.name = 'frame ' + gd._frameData._counter++)]); + } + + if(_hash[frame.name]) { + // If frame is present, overwrite its definition: + for(j = 0; j < _frames.length; j++) { + if(_frames[j].name === frame.name) break; + } + ops.push({type: 'replace', index: j, value: frame}); + revops.unshift({type: 'replace', index: j, value: _frames[j]}); + } else { + // Otherwise insert it at the end of the list: + idx = Math.min(insertions[i].index, frameCount); + + ops.push({type: 'insert', index: idx, value: frame}); + revops.unshift({type: 'delete', index: idx}); + frameCount++; + } + } + + Plots.modifyFrames(gd, ops); + + var undoFunc = Plots.modifyFrames, + redoFunc = Plots.modifyFrames, + undoArgs = [gd, revops], + redoArgs = [gd, ops]; + + if(Queue) Queue.add(gd, undoFunc, undoArgs, redoFunc, redoArgs); + + return Promise.resolve(); +}; + +/** + * Delete keyframes + * + * @param {array of integers} frameList + * list of integer indices of frames to be deleted + */ +Plotly.deleteFrames = function(gd, frameList) { + gd = getGraphDiv(gd); + + var i, idx; + var _frames = gd._frameData._frames; + var ops = []; + var revops = []; + + frameList = frameList.splice(0); + frameList.sort(); + + for(i = frameList.length - 1; i >= 0; i--) { + idx = frameList[i]; + ops.push({type: 'delete', index: idx}); + revops.unshift({type: 'insert', index: idx, value: _frames[idx]}); + } + + Plots.modifyFrames(gd, ops); + + var undoFunc = Plots.modifyFrames, + redoFunc = Plots.modifyFrames, + undoArgs = [gd, revops], + redoArgs = [gd, ops]; + + if(Queue) Queue.add(gd, undoFunc, undoArgs, redoFunc, redoArgs); + + return Promise.resolve(); +}; + /** * Purge a graph container div back to its initial pre-Plotly.plot state * diff --git a/src/plots/plots.js b/src/plots/plots.js index 4a8def06d1b..dfb3c5f7b43 100644 --- a/src/plots/plots.js +++ b/src/plots/plots.js @@ -452,6 +452,10 @@ plots.sendDataToCloud = function(gd) { // gd._fullLayout._basePlotModules // is a list of all the plot modules required to draw the plot. // +// gd._frameData +// object containing frame definitions (_frameData._frames) and +// associated metadata. +// plots.supplyDefaults = function(gd) { var oldFullLayout = gd._fullLayout || {}, newFullLayout = gd._fullLayout = {}, @@ -540,6 +544,23 @@ plots.supplyDefaults = function(gd) { (gd.calcdata[i][0] || {}).trace = trace; } } + + // Set up the default keyframe if it doesn't exist: + if(!gd._frameData) { + gd._frameData = {}; + } + + if(!gd._frameData._frames) { + gd._frameData._frames = []; + } + + if(!gd._frameData._frameHash) { + gd._frameData._frameHash = {}; + } + + if(!gd._frameData._counter) { + gd._frameData._counter = 0; + } }; // helper function to be bound to fullLayout to check @@ -843,6 +864,7 @@ plots.purge = function(gd) { delete gd.numboxes; delete gd._hoverTimer; delete gd._lastHoverTime; + delete gd._frameData; // remove all event listeners if(gd.removeAllListeners) gd.removeAllListeners(); @@ -1107,3 +1129,43 @@ plots.graphJson = function(gd, dataonly, mode, output, useDefaults) { return (output === 'object') ? obj : JSON.stringify(obj); }; + +/** + * Modify a keyframe using a list of operations: + * + * @param {array of objects} operations + * Sequence of operations to be performed on the keyframes + */ +plots.modifyFrames = function(gd, operations) { + var i, op; + var _frames = gd._frameData._frames; + var _hash = gd._frameData._frameHash; + + for(i = 0; i < operations.length; i++) { + op = operations[i]; + + switch(op.type) { + case 'rename': + var frame = _frames[op.index]; + delete _hash[frame.name]; + _hash[op.name] = frame; + break; + case 'replace': + frame = op.value; + _frames[op.index] = _hash[frame.name] = frame; + break; + case 'insert': + frame = op.value; + _hash[frame.name] = frame; + _frames.splice(op.index, 0, frame); + break; + case 'delete': + frame = _frames[op.index]; + delete _hash[frame.name]; + _frames.splice(op.index, 1); + break; + } + } + + return Promise.resolve(); +}; diff --git a/src/traces/scatter/plot.js b/src/traces/scatter/plot.js index 8ec17ff2183..f6984cb78d3 100644 --- a/src/traces/scatter/plot.js +++ b/src/traces/scatter/plot.js @@ -21,11 +21,15 @@ var linePoints = require('./line_points'); var linkTraces = require('./link_traces'); var polygonTester = require('../../lib/polygon').tester; -module.exports = function plot(gd, plotinfo, cdscatter, isFullReplot) { +module.exports = function plot(gd, plotinfo, cdscatter, transitionConfig) { var i, uids, selection, join; var scatterlayer = plotinfo.plot.select('g.scatterlayer'); + // If transition config is provided, then it is only a partial replot and traces not + // updated are removed. + var isFullReplot = !transitionConfig; + selection = scatterlayer.selectAll('g.trace'); join = selection.data(cdscatter, function(d) {return d[0].trace.uid;}); diff --git a/test/jasmine/tests/frame_api_test.js b/test/jasmine/tests/frame_api_test.js new file mode 100644 index 00000000000..37dc9821b8c --- /dev/null +++ b/test/jasmine/tests/frame_api_test.js @@ -0,0 +1,205 @@ +var Plotly = require('@lib/index'); + +var createGraphDiv = require('../assets/create_graph_div'); +var destroyGraphDiv = require('../assets/destroy_graph_div'); + +// A helper function so that failed tests don't simply stall: +function fail(done) { + return function(err) { + console.error(err.toString()); + expect(err.toString()).toBe(true); + done(); + }; +} + +describe('Test frame api', function() { + 'use strict'; + + var gd, mock, f, h; + + beforeEach(function(done) { + mock = [{x: [1, 2, 3], y: [2, 1, 3]}, {x: [1, 2, 3], y: [6, 4, 5]}]; + gd = createGraphDiv(); + Plotly.plot(gd, mock).then(function() { + f = gd._frameData._frames; + h = gd._frameData._frameHash; + }).then(done); + }); + + afterEach(destroyGraphDiv); + + describe('gd initialization', function() { + it('creates an empty list for frames', function() { + expect(gd._frameData._frames).toEqual([]); + }); + + it('creates an empty lookup table for frames', function() { + expect(gd._frameData._frameHash).toEqual({}); + }); + + it('initializes a name counter to zero', function() { + expect(gd._frameData._counter).toEqual(0); + }); + }); + + describe('addFrames', function() { + it('names an unnamed frame', function(done) { + Plotly.addFrames(gd, [{}]).then(function() { + expect(Object.keys(h)).toEqual(['frame 0']); + }).then(done, fail(done)); + }); + + it('creates multiple unnamed frames at the same time', function(done) { + Plotly.addFrames(gd, [{}, {}]).then(function() { + expect(f).toEqual([{name: 'frame 0'}, {name: 'frame 1'}]); + }).then(done, fail(done)); + }); + + it('creates multiple unnamed frames in series', function(done) { + Plotly.addFrames(gd, [{}]).then( + Plotly.addFrames(gd, [{}]) + ).then(function() { + expect(f).toEqual([{name: 'frame 0'}, {name: 'frame 1'}]); + }).then(done, fail(done)); + }); + + it('inserts frames at specific indices', function(done) { + var i; + var frames = []; + for(i = 0; i < 10; i++) { + frames.push({name: 'frame' + i}); + } + + Plotly.addFrames(gd, frames).then(function() { + return Plotly.addFrames(gd, [{name: 'inserted1'}, {name: 'inserted2'}, {name: 'inserted3'}], [5, 7, undefined]); + }).then(function() { + expect(f[5]).toEqual({name: 'inserted1'}); + expect(f[7]).toEqual({name: 'inserted2'}); + expect(f[12]).toEqual({name: 'inserted3'}); + + return Plotly.Queue.undo(gd); + }).then(function() { + for(i = 0; i < 10; i++) { + expect(f[i]).toEqual({name: 'frame' + i}); + } + }).then(done, fail(done)); + }); + + it('inserts frames at specific indices (reversed)', function(done) { + var i; + var frames = []; + for(i = 0; i < 10; i++) { + frames.push({name: 'frame' + i}); + } + + Plotly.addFrames(gd, frames).then(function() { + return Plotly.addFrames(gd, [{name: 'inserted3'}, {name: 'inserted2'}, {name: 'inserted1'}], [undefined, 7, 5]); + }).then(function() { + expect(f[5]).toEqual({name: 'inserted1'}); + expect(f[7]).toEqual({name: 'inserted2'}); + expect(f[12]).toEqual({name: 'inserted3'}); + + return Plotly.Queue.undo(gd); + }).then(function() { + for(i = 0; i < 10; i++) { + expect(f[i]).toEqual({name: 'frame' + i}); + } + }).then(done, fail(done)); + }); + + it('implements undo/redo', function(done) { + Plotly.addFrames(gd, [{name: 'frame 0'}, {name: 'frame 1'}]).then(function() { + expect(f).toEqual([{name: 'frame 0'}, {name: 'frame 1'}]); + expect(h).toEqual({'frame 0': {name: 'frame 0'}, 'frame 1': {name: 'frame 1'}}); + + return Plotly.Queue.undo(gd); + }).then(function() { + expect(f).toEqual([]); + expect(h).toEqual({}); + + return Plotly.Queue.redo(gd); + }).then(function() { + expect(f).toEqual([{name: 'frame 0'}, {name: 'frame 1'}]); + expect(h).toEqual({'frame 0': {name: 'frame 0'}, 'frame 1': {name: 'frame 1'}}); + }).then(done, fail(done)); + }); + + it('overwrites frames', function(done) { + // The whole shebang. This hits insertion + replacements + deletion + undo + redo: + Plotly.addFrames(gd, [{name: 'test1', x: 'y'}, {name: 'test2'}]).then(function() { + expect(f).toEqual([{name: 'test1', x: 'y'}, {name: 'test2'}]); + expect(Object.keys(h)).toEqual(['test1', 'test2']); + + return Plotly.addFrames(gd, [{name: 'test1'}, {name: 'test3'}]); + }).then(function() { + expect(f).toEqual([{name: 'test1'}, {name: 'test2'}, {name: 'test3'}]); + expect(Object.keys(h)).toEqual(['test1', 'test2', 'test3']); + + return Plotly.Queue.undo(gd); + }).then(function() { + expect(f).toEqual([{name: 'test1', x: 'y'}, {name: 'test2'}]); + expect(Object.keys(h)).toEqual(['test1', 'test2']); + + return Plotly.Queue.redo(gd); + }).then(function() { + expect(f).toEqual([{name: 'test1'}, {name: 'test2'}, {name: 'test3'}]); + expect(Object.keys(h)).toEqual(['test1', 'test2', 'test3']); + }).then(done, fail(done)); + }); + }); + + describe('deleteFrames', function() { + it('deletes a frame', function(done) { + Plotly.addFrames(gd, [{name: 'frame1'}]).then(function() { + expect(f).toEqual([{name: 'frame1'}]); + expect(Object.keys(h)).toEqual(['frame1']); + + return Plotly.deleteFrames(gd, [0]); + }).then(function() { + expect(f).toEqual([]); + expect(Object.keys(h)).toEqual([]); + + return Plotly.Queue.undo(gd); + }).then(function() { + expect(f).toEqual([{name: 'frame1'}]); + + return Plotly.Queue.redo(gd); + }).then(function() { + expect(f).toEqual([]); + expect(Object.keys(h)).toEqual([]); + }).then(done, fail(done)); + }); + + it('deletes multiple frames', function(done) { + var i; + var frames = []; + for(i = 0; i < 10; i++) { + frames.push({name: 'frame' + i}); + } + + Plotly.addFrames(gd, frames).then(function() { + return Plotly.deleteFrames(gd, [2, 8, 4, 6]); + }).then(function() { + var expected = ['frame0', 'frame1', 'frame3', 'frame5', 'frame7', 'frame9']; + expect(f.length).toEqual(expected.length); + for(i = 0; i < expected.length; i++) { + expect(f[i].name).toEqual(expected[i]); + } + + return Plotly.Queue.undo(gd); + }).then(function() { + for(i = 0; i < 10; i++) { + expect(f[i]).toEqual({name: 'frame' + i}); + } + + return Plotly.Queue.redo(gd); + }).then(function() { + var expected = ['frame0', 'frame1', 'frame3', 'frame5', 'frame7', 'frame9']; + expect(f.length).toEqual(expected.length); + for(i = 0; i < expected.length; i++) { + expect(f[i].name).toEqual(expected[i]); + } + }).then(done, fail(done)); + }); + }); +}); diff --git a/test/jasmine/tests/keyframe_computation.js b/test/jasmine/tests/keyframe_computation.js new file mode 100644 index 00000000000..429b52f81cd --- /dev/null +++ b/test/jasmine/tests/keyframe_computation.js @@ -0,0 +1,48 @@ +var mergeKeyframes = require('@src/lib/merge_keyframes'); + +describe('Test mergeKeyframes', function() { + 'use strict'; + + it('returns a new object', function() { + var f1 = {}; + var f2 = {}; + var result = mergeKeyframes(f1, f2); + expect(result).toEqual({}); + expect(result).not.toBe(f1); + expect(result).not.toBe(f2); + }); + + it('overrides properties of target with those of source', function() { + var tar = {xaxis: {range: [0, 1]}}; + var src = {xaxis: {range: [3, 4]}}; + var out = mergeKeyframes(tar, src); + + expect(out).toEqual({xaxis: {range: [3, 4]}}); + }); + + it('merges dotted properties', function() { + var tar = {}; + var src = {'xaxis.range': [0, 1]}; + var out = mergeKeyframes(tar, src); + + expect(out).toEqual({'xaxis.range': [0, 1]}); + }); + + describe('assimilating dotted properties', function() { + it('xaxis.range', function() { + var tar = {xaxis: {range: [0, 1]}}; + var src = {'xaxis.range': [3, 4]}; + var out = mergeKeyframes(tar, src); + + expect(out).toEqual({xaxis: {range: [3, 4]}}); + }); + + it('xaxis.range.0', function() { + var tar = {xaxis: {range: [0, 1]}}; + var src = {'xaxis.range.0': 3}; + var out = mergeKeyframes(tar, src); + + expect(out).toEqual({xaxis: {range: [3, 1]}}); + }); + }); +}); From 3054cb58d0ecad6c7fe50f434fd8057c4242f350 Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Wed, 22 Jun 2016 14:27:18 -0400 Subject: [PATCH 03/82] Reuse SVG DOM elements for scatter traces --- src/components/errorbars/plot.js | 17 +- src/plots/cartesian/index.js | 81 +++--- src/plots/plots.js | 9 +- src/traces/scatter/link_traces.js | 39 +++ src/traces/scatter/plot.js | 389 ++++++++++++++++++--------- test/image/mocks/ternary_simple.json | 3 +- test/jasmine/tests/calcdata_test.js | 14 +- test/jasmine/tests/cartesian_test.js | 99 ++++++- 8 files changed, 471 insertions(+), 180 deletions(-) create mode 100644 src/traces/scatter/link_traces.js diff --git a/src/components/errorbars/plot.js b/src/components/errorbars/plot.js index 3f44ed58df1..eb66769760e 100644 --- a/src/components/errorbars/plot.js +++ b/src/components/errorbars/plot.js @@ -34,15 +34,24 @@ module.exports = function plot(traces, plotinfo) { trace.marker.maxdisplayed > 0 ); + var keyFunc; + + if(trace.key) { + keyFunc = function(d) { return d.key; }; + } + if(!yObj.visible && !xObj.visible) return; - var errorbars = d3.select(this).selectAll('g.errorbar') - .data(Lib.identity); + var selection = d3.select(this).selectAll('g.errorbar'); - errorbars.enter().append('g') + var join = selection.data(Lib.identity, keyFunc); + + join.enter().append('g') .classed('errorbar', true); - errorbars.each(function(d) { + join.exit().remove(); + + join.each(function(d) { var errorbar = d3.select(this); var coords = errorCoords(d, xa, ya); diff --git a/src/plots/cartesian/index.js b/src/plots/cartesian/index.js index 56f827f0ec6..7ced4299776 100644 --- a/src/plots/cartesian/index.js +++ b/src/plots/cartesian/index.js @@ -25,61 +25,74 @@ exports.attrRegex = constants.attrRegex; exports.attributes = require('./attributes'); -exports.plot = function(gd) { +exports.plot = function(gd, traces) { + var cdSubplot, cd, trace, i, j, k, isFullReplot; + var fullLayout = gd._fullLayout, subplots = Plots.getSubplotIds(fullLayout, 'cartesian'), calcdata = gd.calcdata, modules = fullLayout._modules; - function getCdSubplot(calcdata, subplot) { - var cdSubplot = []; - - for(var i = 0; i < calcdata.length; i++) { - var cd = calcdata[i]; - var trace = cd[0].trace; - - if(trace.xaxis + trace.yaxis === subplot) { - cdSubplot.push(cd); - } + if(!Array.isArray(traces)) { + // If traces is not provided, then it's a complete replot and missing + // traces are removed + isFullReplot = true; + traces = []; + for(i = 0; i < calcdata.length; i++) { + traces.push(i); } - - return cdSubplot; + } else { + // If traces are explicitly specified, then it's a partial replot and + // traces are not removed. + isFullReplot = false; } - function getCdModule(cdSubplot, _module) { - var cdModule = []; + for(i = 0; i < subplots.length; i++) { + var subplot = subplots[i], + subplotInfo = fullLayout._plots[subplot]; + + // Get all calcdata for this subplot: + cdSubplot = []; + for(j = 0; j < calcdata.length; j++) { + cd = calcdata[j]; + trace = cd[0].trace; - for(var i = 0; i < cdSubplot.length; i++) { - var cd = cdSubplot[i]; - var trace = cd[0].trace; + // Skip trace if whitelist provided and it's not whitelisted: + // if (Array.isArray(traces) && traces.indexOf(i) === -1) continue; - if((trace._module === _module) && (trace.visible === true)) { - cdModule.push(cd); + if(trace.xaxis + trace.yaxis === subplot && traces.indexOf(trace.index) !== -1) { + cdSubplot.push(cd); } } - return cdModule; - } - - for(var i = 0; i < subplots.length; i++) { - var subplot = subplots[i], - subplotInfo = fullLayout._plots[subplot], - cdSubplot = getCdSubplot(calcdata, subplot); - // remove old traces, then redraw everything - // TODO: use enter/exit appropriately in the plot functions - // so we don't need this - should sometimes be a big speedup - if(subplotInfo.plot) subplotInfo.plot.selectAll('g.trace').remove(); + // TODO: scatterlayer is manually excluded from this since it knows how + // to update instead of fully removing and redrawing every time. The + // remaining plot traces should also be able to do this. Once implemented, + // we won't need this - which should sometimes be a big speedup. + if(subplotInfo.plot) { + subplotInfo.plot.selectAll('g:not(.scatterlayer)').selectAll('g.trace').remove(); + } - for(var j = 0; j < modules.length; j++) { + // Plot all traces for each module at once: + for(j = 0; j < modules.length; j++) { var _module = modules[j]; // skip over non-cartesian trace modules if(_module.basePlotModule.name !== 'cartesian') continue; // plot all traces of this type on this subplot at once - var cdModule = getCdModule(cdSubplot, _module); - _module.plot(gd, subplotInfo, cdModule); + var cdModule = []; + for(k = 0; k < cdSubplot.length; k++) { + cd = cdSubplot[k]; + trace = cd[0].trace; + + if((trace._module === _module) && (trace.visible === true)) { + cdModule.push(cd); + } + } + + _module.plot(gd, subplotInfo, cdModule, isFullReplot); } } }; diff --git a/src/plots/plots.js b/src/plots/plots.js index 4b814bfd85c..4a8def06d1b 100644 --- a/src/plots/plots.js +++ b/src/plots/plots.js @@ -582,12 +582,17 @@ plots.cleanPlot = function(newFullData, newFullLayout, oldFullData, oldFullLayou if(oldUid === newTrace.uid) continue oldLoop; } - // clean old heatmap and contour traces + // clean old heatmap, contour, and scatter traces + // + // Note: This is also how scatter traces (cartesian and scatterternary) get + // removed since otherwise the scatter module is not called (and so the join + // doesn't register the removal) if scatter traces disappear entirely. if(hasPaper) { oldFullLayout._paper.selectAll( '.hm' + oldUid + ',.contour' + oldUid + - ',#clip' + oldUid + ',#clip' + oldUid + + ',.trace' + oldUid ).remove(); } diff --git a/src/traces/scatter/link_traces.js b/src/traces/scatter/link_traces.js new file mode 100644 index 00000000000..801d02b0d64 --- /dev/null +++ b/src/traces/scatter/link_traces.js @@ -0,0 +1,39 @@ +/** +* Copyright 2012-2016, 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 = function linkTraces(gd, plotinfo, cdscatter) { + var cd, trace; + var prevtrace = null; + + for(var i = 0; i < cdscatter.length; ++i) { + cd = cdscatter[i]; + trace = cd[0].trace; + + // Note: The check which ensures all cdscatter here are for the same axis and + // are either cartesian or scatterternary has been removed. This code assumes + // the passed scattertraces have been filtered to the proper plot types and + // the proper subplots. + if(trace.visible === true) { + trace._nexttrace = null; + + if(['tonextx', 'tonexty', 'tonext'].indexOf(trace.fill) !== -1) { + trace._prevtrace = prevtrace; + + if(prevtrace) { + prevtrace._nexttrace = trace; + } + } + + prevtrace = trace; + } else { + trace._prevtrace = trace._nexttrace = null; + } + } +}; diff --git a/src/traces/scatter/plot.js b/src/traces/scatter/plot.js index d2388f40c22..8ec17ff2183 100644 --- a/src/traces/scatter/plot.js +++ b/src/traces/scatter/plot.js @@ -15,80 +15,161 @@ var Lib = require('../../lib'); var Drawing = require('../../components/drawing'); var ErrorBars = require('../../components/errorbars'); -var polygonTester = require('../../lib/polygon').tester; - var subTypes = require('./subtypes'); var arraysToCalcdata = require('./arrays_to_calcdata'); var linePoints = require('./line_points'); +var linkTraces = require('./link_traces'); +var polygonTester = require('../../lib/polygon').tester; +module.exports = function plot(gd, plotinfo, cdscatter, isFullReplot) { + var i, uids, selection, join; -module.exports = function plot(gd, plotinfo, cdscatter) { - selectMarkers(gd, plotinfo, cdscatter); + var scatterlayer = plotinfo.plot.select('g.scatterlayer'); - var xa = plotinfo.x(), - ya = plotinfo.y(); + selection = scatterlayer.selectAll('g.trace'); - // make the container for scatter plots - // (so error bars can find them along with bars) - var scattertraces = plotinfo.plot.select('.scatterlayer') - .selectAll('g.trace.scatter') - .data(cdscatter); + join = selection.data(cdscatter, function(d) {return d[0].trace.uid;}); - scattertraces.enter().append('g') - .attr('class', 'trace scatter') + // Append new traces: + join.enter().append('g') + .attr('class', function(d) { + return 'trace scatter trace' + d[0].trace.uid; + }) .style('stroke-miterlimit', 2); - // error bars are at the bottom - scattertraces.call(ErrorBars.plot, plotinfo); + // After the elements are created but before they've been draw, we have to perform + // this extra step of linking the traces. This allows appending of fill layers so that + // the z-order of fill layers is correct. + linkTraces(gd, plotinfo, cdscatter); - // BUILD LINES AND FILLS - var prevpath = '', - prevPolygons = [], - ownFillEl3, ownFillDir, tonext, nexttonext; + createFills(gd, scatterlayer); - scattertraces.each(function(d) { - var trace = d[0].trace, - line = trace.line, - tr = d3.select(this); - if(trace.visible !== true) return; + // Sort the traces, once created, so that the ordering is preserved even when traces + // are shown and hidden. This is needed since we're not just wiping everything out + // and recreating on every update. + for(i = 0, uids = []; i < cdscatter.length; i++) { + uids[i] = cdscatter[i][0].trace.uid; + } - ownFillDir = trace.fill.charAt(trace.fill.length - 1); - if(ownFillDir !== 'x' && ownFillDir !== 'y') ownFillDir = ''; + scatterlayer.selectAll('g.trace').sort(function(a, b) { + var idx1 = uids.indexOf(a[0].trace.uid); + var idx2 = uids.indexOf(b[0].trace.uid); + return idx1 > idx2 ? 1 : -1; + }); - d[0].node3 = tr; // store node for tweaking by selectPoints + // Must run the selection again since otherwise enters/updates get grouped together + // and these get executed out of order. Except we need them in order! + scatterlayer.selectAll('g.trace').each(function(d, i) { + plotOne(gd, i, plotinfo, d, cdscatter, this); + }); - arraysToCalcdata(d); + if(isFullReplot) { + join.exit().remove(); + } + + // remove paths that didn't get used + scatterlayer.selectAll('path:not([d])').remove(); +}; - if(!subTypes.hasLines(trace) && trace.fill === 'none') return; +function createFills(gd, scatterlayer) { + var trace; - var thispath, - thisrevpath, - // fullpath is all paths for this curve, joined together straight - // across gaps, for filling - fullpath = '', - // revpath is fullpath reversed, for fill-to-next - revpath = '', - // functions for converting a point array to a path - pathfn, revpathbase, revpathfn; + scatterlayer.selectAll('g.trace').each(function(d) { + var tr = d3.select(this); - // make the fill-to-zero path now, so it shows behind the line - // fill to next puts the fill associated with one trace - // grouped with the previous - if(trace.fill.substr(0, 6) === 'tozero' || trace.fill === 'toself' || - (trace.fill.substr(0, 2) === 'to' && !prevpath)) { - ownFillEl3 = tr.append('path') - .classed('js-fill', true); - } - else ownFillEl3 = null; + // Loop only over the traces being redrawn: + trace = d[0].trace; // make the fill-to-next path now for the NEXT trace, so it shows // behind both lines. - // nexttonext was created last time, but give it - // this curve's data for fill color - if(nexttonext) tonext = nexttonext.datum(d); + if(trace._nexttrace) { + trace._nextFill = tr.select('.js-fill.js-tonext'); + if(!trace._nextFill.size()) { + + // If there is an existing tozero fill, we must insert this *after* that fill: + var loc = ':first-child'; + if(tr.select('.js-fill.js-tozero').size()) { + loc += ' + *'; + } - // now make a new nexttonext for next time - nexttonext = tr.append('path').classed('js-fill', true); + trace._nextFill = tr.insert('path', loc).attr('class', 'js-fill js-tonext'); + } + } else { + tr.selectAll('.js-fill.js-tonext').remove(); + trace._nextFill = null; + } + + if(trace.fill.substr(0, 6) === 'tozero' || trace.fill === 'toself' || + (trace.fill.substr(0, 2) === 'to' && !trace._prevtrace)) { + trace._ownFill = tr.select('.js-fill.js-tozero'); + if(!trace._ownFill.size()) { + trace._ownFill = tr.insert('path', ':first-child').attr('class', 'js-fill js-tozero'); + } + } else { + tr.selectAll('.js-fill.js-tozero').remove(); + trace._ownFill = null; + } + }); +} + +function plotOne(gd, idx, plotinfo, cdscatter, cdscatterAll, element) { + var join, i; + + // Since this has been reorganized and we're executing this on individual traces, + // we need to pass it the full list of cdscatter as well as this trace's index (idx) + // since it does an internal n^2 loop over comparisons with other traces: + selectMarkers(gd, idx, plotinfo, cdscatter, cdscatterAll); + + var xa = plotinfo.x(), + ya = plotinfo.y(); + + var trace = cdscatter[0].trace, + line = trace.line, + tr = d3.select(element); + + // (so error bars can find them along with bars) + // error bars are at the bottom + tr.call(ErrorBars.plot, plotinfo); + + if(trace.visible !== true) return; + + // BUILD LINES AND FILLS + var ownFillEl3, tonext; + var ownFillDir = trace.fill.charAt(trace.fill.length - 1); + if(ownFillDir !== 'x' && ownFillDir !== 'y') ownFillDir = ''; + + // store node for tweaking by selectPoints + cdscatter[0].node3 = tr; + + arraysToCalcdata(cdscatter); + + var prevpath = ''; + var prevPolygons = []; + var prevtrace = trace._prevtrace; + + if(prevtrace) { + prevpath = prevtrace._revpath || ''; + tonext = prevtrace._nextFill; + prevPolygons = prevtrace._polygons; + } + + var thispath, + thisrevpath, + // fullpath is all paths for this curve, joined together straight + // across gaps, for filling + fullpath = '', + // revpath is fullpath reversed, for fill-to-next + revpath = '', + // functions for converting a point array to a path + pathfn, revpathbase, revpathfn; + + ownFillEl3 = trace._ownFill; + + if(subTypes.hasLines(trace) || trace.fill !== 'none') { + if(tonext) { + // This tells .style which trace to use for fill information: + tonext.datum(cdscatter); + } if(['hv', 'vh', 'hvh', 'vhv'].indexOf(line.shape) !== -1) { pathfn = Drawing.steps(line.shape); @@ -120,7 +201,7 @@ module.exports = function plot(gd, plotinfo, cdscatter) { return revpathbase(pts.reverse()); }; - var segments = linePoints(d, { + var segments = linePoints(cdscatter, { xaxis: xa, yaxis: ya, connectGaps: trace.connectgaps, @@ -132,9 +213,7 @@ module.exports = function plot(gd, plotinfo, cdscatter) { // polygons for hover on fill // TODO: can we skip this if hoveron!=fills? That would mean we // need to redraw when you change hoveron... - var thisPolygons = trace._polygons = new Array(segments.length), - i; - + var thisPolygons = trace._polygons = new Array(segments.length); for(i = 0; i < segments.length; i++) { trace._polygons[i] = polygonTester(segments[i]); } @@ -144,8 +223,12 @@ module.exports = function plot(gd, plotinfo, cdscatter) { lastSegment = segments[segments.length - 1], pt1 = lastSegment[lastSegment.length - 1]; - for(i = 0; i < segments.length; i++) { - var pts = segments[i]; + var lineJoin = tr.selectAll('.js-line').data(segments); + + lineJoin.enter() + .append('path').classed('js-line', true); + + lineJoin.each(function(pts) { thispath = pathfn(pts); thisrevpath = revpathfn(pts); if(!fullpath) { @@ -161,9 +244,14 @@ module.exports = function plot(gd, plotinfo, cdscatter) { revpath = thisrevpath + 'Z' + revpath; } if(subTypes.hasLines(trace) && pts.length > 1) { - tr.append('path').classed('js-line', true).attr('d', thispath); + d3.select(this) + .attr('d', thispath) + .datum(cdscatter); } - } + }); + + lineJoin.exit().remove(); + if(ownFillEl3) { if(pt0 && pt1) { if(ownFillDir) { @@ -177,9 +265,10 @@ module.exports = function plot(gd, plotinfo, cdscatter) { // fill to zero: full trace path, plus extension of // the endpoints to the appropriate axis ownFillEl3.attr('d', fullpath + 'L' + pt1 + 'L' + pt0 + 'Z'); + } else { + // fill to self: just join the path to itself + ownFillEl3.attr('d', fullpath + 'Z'); } - // fill to self: just join the path to itself - else ownFillEl3.attr('d', fullpath + 'Z'); } } else if(trace.fill.substr(0, 6) === 'tonext' && fullpath && prevpath) { @@ -201,88 +290,128 @@ module.exports = function plot(gd, plotinfo, cdscatter) { } trace._polygons = trace._polygons.concat(prevPolygons); } - prevpath = revpath; - prevPolygons = thisPolygons; + trace._revpath = revpath; + trace._prevPolygons = thisPolygons; } - }); + } - // remove paths that didn't get used - scattertraces.selectAll('path:not([d])').remove(); function visFilter(d) { return d.filter(function(v) { return v.vis; }); } - scattertraces.append('g') - .attr('class', 'points') - .each(function(d) { - var trace = d[0].trace, - s = d3.select(this), - showMarkers = subTypes.hasMarkers(trace), - showText = subTypes.hasText(trace); - - if((!showMarkers && !showText) || trace.visible !== true) s.remove(); - else { - if(showMarkers) { - s.selectAll('path.point') - .data(trace.marker.maxdisplayed ? visFilter : Lib.identity) - .enter().append('path') - .classed('point', true) - .call(Drawing.translatePoints, xa, ya); - } - if(showText) { - s.selectAll('g') - .data(trace.marker.maxdisplayed ? visFilter : Lib.identity) - // each text needs to go in its own 'g' in case - // it gets converted to mathjax - .enter().append('g') - .append('text') - .call(Drawing.translatePoints, xa, ya); - } + function keyFunc(d) { + return d.key; + } + + // Returns a function if the trace is keyed, otherwise returns undefined + function getKeyFunc(trace) { + if(trace.key) { + return keyFunc; + } + } + + function makePoints(d) { + var join, selection; + var trace = d[0].trace, + s = d3.select(this), + showMarkers = subTypes.hasMarkers(trace), + showText = subTypes.hasText(trace); + + if((!showMarkers && !showText) || trace.visible !== true) s.remove(); + else { + if(showMarkers) { + selection = s.selectAll('path.point'); + + join = selection + .data(trace.marker.maxdisplayed ? visFilter : Lib.identity, getKeyFunc(trace)); + + join.enter().append('path') + .classed('point', true) + .call(Drawing.pointStyle, trace) + .call(Drawing.translatePoints, xa, ya); + + selection + .call(Drawing.translatePoints, xa, ya) + .call(Drawing.pointStyle, trace); + + join.exit().remove(); } - }); -}; + if(showText) { + selection = s.selectAll('g'); + + join = selection + .data(trace.marker.maxdisplayed ? visFilter : Lib.identity); + + // each text needs to go in its own 'g' in case + // it gets converted to mathjax + join.enter().append('g') + .append('text') + .call(Drawing.translatePoints, xa, ya); + + selection + .call(Drawing.translatePoints, xa, ya); + + join.exit().remove(); + } + } + } -function selectMarkers(gd, plotinfo, cdscatter) { + // NB: selectAll is evaluated on instantiation: + var pointSelection = tr.selectAll('.points'); + + // Join with new data + join = pointSelection.data([cdscatter]); + + // Transition existing, but don't defer this to an async .transition since + // there's no timing involved: + pointSelection.each(makePoints); + + join.enter().append('g') + .classed('points', true) + .each(makePoints); + + join.exit().remove(); +} + +function selectMarkers(gd, idx, plotinfo, cdscatter, cdscatterAll) { var xa = plotinfo.x(), ya = plotinfo.y(), xr = d3.extent(xa.range.map(xa.l2c)), yr = d3.extent(ya.range.map(ya.l2c)); - cdscatter.forEach(function(d, i) { - var trace = d[0].trace; - if(!subTypes.hasMarkers(trace)) return; - // if marker.maxdisplayed is used, select a maximum of - // mnum markers to show, from the set that are in the viewport - var mnum = trace.marker.maxdisplayed; - - // TODO: remove some as we get away from the viewport? - if(mnum === 0) return; - - var cd = d.filter(function(v) { - return v.x >= xr[0] && v.x <= xr[1] && v.y >= yr[0] && v.y <= yr[1]; - }), - inc = Math.ceil(cd.length / mnum), - tnum = 0; - cdscatter.forEach(function(cdj, j) { - var tracei = cdj[0].trace; - if(subTypes.hasMarkers(tracei) && - tracei.marker.maxdisplayed > 0 && j < i) { - tnum++; - } - }); + var trace = cdscatter[0].trace; + if(!subTypes.hasMarkers(trace)) return; + // if marker.maxdisplayed is used, select a maximum of + // mnum markers to show, from the set that are in the viewport + var mnum = trace.marker.maxdisplayed; + + // TODO: remove some as we get away from the viewport? + if(mnum === 0) return; + + var cd = cdscatter.filter(function(v) { + return v.x >= xr[0] && v.x <= xr[1] && v.y >= yr[0] && v.y <= yr[1]; + }), + inc = Math.ceil(cd.length / mnum), + tnum = 0; + cdscatterAll.forEach(function(cdj, j) { + var tracei = cdj[0].trace; + if(subTypes.hasMarkers(tracei) && + tracei.marker.maxdisplayed > 0 && j < idx) { + tnum++; + } + }); - // if multiple traces use maxdisplayed, stagger which markers we - // display this formula offsets successive traces by 1/3 of the - // increment, adding an extra small amount after each triplet so - // it's not quite periodic - var i0 = Math.round(tnum * inc / 3 + Math.floor(tnum / 3) * inc / 7.1); - - // for error bars: save in cd which markers to show - // so we don't have to repeat this - d.forEach(function(v) { delete v.vis; }); - cd.forEach(function(v, i) { - if(Math.round((i + i0) % inc) === 0) v.vis = true; - }); + // if multiple traces use maxdisplayed, stagger which markers we + // display this formula offsets successive traces by 1/3 of the + // increment, adding an extra small amount after each triplet so + // it's not quite periodic + var i0 = Math.round(tnum * inc / 3 + Math.floor(tnum / 3) * inc / 7.1); + + // for error bars: save in cd which markers to show + // so we don't have to repeat this + cdscatter.forEach(function(v) { delete v.vis; }); + cd.forEach(function(v, i) { + if(Math.round((i + i0) % inc) === 0) v.vis = true; }); } diff --git a/test/image/mocks/ternary_simple.json b/test/image/mocks/ternary_simple.json index ea1d78ff2a3..62bc4574a66 100644 --- a/test/image/mocks/ternary_simple.json +++ b/test/image/mocks/ternary_simple.json @@ -16,8 +16,7 @@ 1, 2.12345 ], - "type": "scatterternary", - "uid": "412fa8" + "type": "scatterternary" } ], "layout": { diff --git a/test/jasmine/tests/calcdata_test.js b/test/jasmine/tests/calcdata_test.js index fb9c3049994..931b151bed0 100644 --- a/test/jasmine/tests/calcdata_test.js +++ b/test/jasmine/tests/calcdata_test.js @@ -18,15 +18,15 @@ describe('calculated data and points', function() { it('should exclude null and undefined points when false', function() { Plotly.plot(gd, [{ x: [1, 2, 3, undefined, 5], y: [1, null, 3, 4, 5]}], {}); - expect(gd.calcdata[0][1]).toEqual({ x: false, y: false}); - expect(gd.calcdata[0][3]).toEqual({ x: false, y: false}); + expect(gd.calcdata[0][1]).toEqual(jasmine.objectContaining({x: false, y: false})); + expect(gd.calcdata[0][3]).toEqual(jasmine.objectContaining({x: false, y: false})); }); it('should exclude null and undefined points as categories when false', function() { Plotly.plot(gd, [{ x: [1, 2, 3, undefined, 5], y: [1, null, 3, 4, 5] }], { xaxis: { type: 'category' }}); - expect(gd.calcdata[0][1]).toEqual({ x: false, y: false}); - expect(gd.calcdata[0][3]).toEqual({ x: false, y: false}); + expect(gd.calcdata[0][1]).toEqual(jasmine.objectContaining({x: false, y: false})); + expect(gd.calcdata[0][3]).toEqual(jasmine.objectContaining({x: false, y: false})); }); }); @@ -180,9 +180,9 @@ describe('calculated data and points', function() { }}); expect(gd.calcdata[0][0]).toEqual(jasmine.objectContaining({x: 4, y: 15})); - expect(gd.calcdata[0][1]).toEqual({ x: false, y: false}); + expect(gd.calcdata[0][1]).toEqual(jasmine.objectContaining({x: false, y: false})); expect(gd.calcdata[0][2]).toEqual(jasmine.objectContaining({x: 3, y: 12})); - expect(gd.calcdata[0][3]).toEqual({ x: false, y: false}); + expect(gd.calcdata[0][3]).toEqual(jasmine.objectContaining({x: false, y: false})); expect(gd.calcdata[0][4]).toEqual(jasmine.objectContaining({x: 2, y: 14})); }); @@ -257,7 +257,7 @@ describe('calculated data and points', function() { }}); expect(gd.calcdata[0][0]).toEqual(jasmine.objectContaining({x: 6, y: 15})); - expect(gd.calcdata[0][1]).toEqual({x: false, y: false}); + expect(gd.calcdata[0][1]).toEqual(jasmine.objectContaining({x: false, y: false})); expect(gd.calcdata[0][2]).toEqual(jasmine.objectContaining({x: 5, y: 12})); expect(gd.calcdata[0][3]).toEqual(jasmine.objectContaining({x: 0, y: 13})); expect(gd.calcdata[0][4]).toEqual(jasmine.objectContaining({x: 3, y: 14})); diff --git a/test/jasmine/tests/cartesian_test.js b/test/jasmine/tests/cartesian_test.js index 7fbe66fbef5..9bc024d6acb 100644 --- a/test/jasmine/tests/cartesian_test.js +++ b/test/jasmine/tests/cartesian_test.js @@ -7,7 +7,6 @@ var createGraphDiv = require('../assets/create_graph_div'); var destroyGraphDiv = require('../assets/destroy_graph_div'); var mouseEvent = require('../assets/mouse_event'); - describe('zoom box element', function() { var mock = require('@mocks/14.json'); @@ -50,6 +49,104 @@ describe('zoom box element', function() { }); }); +describe('restyle', function() { + describe('scatter traces', function() { + var gd; + + beforeEach(function() { + gd = createGraphDiv(); + }); + + afterEach(destroyGraphDiv); + + it('reuses SVG fills', function(done) { + var fills, firstToZero, secondToZero, firstToNext, secondToNext; + var mock = Lib.extendDeep({}, require('@mocks/basic_area.json')); + + Plotly.plot(gd, mock.data, mock.layout).then(function() { + // Assert there are two fills: + fills = d3.selectAll('g.trace.scatter .js-fill')[0]; + + // First is tozero, second is tonext: + expect(d3.selectAll('g.trace.scatter .js-fill').size()).toEqual(2); + expect(fills[0].classList.contains('js-tozero')).toBe(true); + expect(fills[0].classList.contains('js-tonext')).toBe(false); + expect(fills[1].classList.contains('js-tozero')).toBe(false); + expect(fills[1].classList.contains('js-tonext')).toBe(true); + + firstToZero = fills[0]; + firstToNext = fills[1]; + }).then(function() { + return Plotly.restyle(gd, {visible: [false]}, [1]); + }).then(function() { + // Trace 1 hidden leaves only trace zero's tozero fill: + expect(d3.selectAll('g.trace.scatter .js-fill').size()).toEqual(1); + expect(fills[0].classList.contains('js-tozero')).toBe(true); + expect(fills[0].classList.contains('js-tonext')).toBe(false); + }).then(function() { + return Plotly.restyle(gd, {visible: [true]}, [1]); + }).then(function() { + // Reshow means two fills again AND order is preserved: + fills = d3.selectAll('g.trace.scatter .js-fill')[0]; + + // First is tozero, second is tonext: + expect(d3.selectAll('g.trace.scatter .js-fill').size()).toEqual(2); + expect(fills[0].classList.contains('js-tozero')).toBe(true); + expect(fills[0].classList.contains('js-tonext')).toBe(false); + expect(fills[1].classList.contains('js-tozero')).toBe(false); + expect(fills[1].classList.contains('js-tonext')).toBe(true); + + secondToZero = fills[0]; + secondToNext = fills[1]; + + // The identity of the first is retained: + expect(firstToZero).toBe(secondToZero); + + // The second has been recreated so is different: + expect(firstToNext).not.toBe(secondToNext); + }).then(done); + }); + + it('reuses SVG lines', function(done) { + var lines, firstLine1, secondLine1, firstLine2, secondLine2; + var mock = Lib.extendDeep({}, require('@mocks/basic_line.json')); + + Plotly.plot(gd, mock.data, mock.layout).then(function() { + lines = d3.selectAll('g.scatter.trace .js-line'); + + firstLine1 = lines[0][0]; + firstLine2 = lines[0][1]; + + // One line for each trace: + expect(lines.size()).toEqual(2); + }).then(function() { + return Plotly.restyle(gd, {visible: [false]}, [0]); + }).then(function() { + lines = d3.selectAll('g.scatter.trace .js-line'); + + // Only one line now and it's equal to the second trace's line from above: + expect(lines.size()).toEqual(1); + expect(lines[0][0]).toBe(firstLine2); + }).then(function() { + return Plotly.restyle(gd, {visible: [true]}, [0]); + }).then(function() { + lines = d3.selectAll('g.scatter.trace .js-line'); + secondLine1 = lines[0][0]; + secondLine2 = lines[0][1]; + + // Two lines once again: + expect(lines.size()).toEqual(2); + + // First line has been removed and recreated: + expect(firstLine1).not.toBe(secondLine1); + + // Second line was persisted: + expect(firstLine2).toBe(secondLine2); + }).then(done); + }); + }); +}); + describe('relayout', function() { describe('axis category attributes', function() { From fb04b65efe9d81e63ff10f41f87661e2fecef505 Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Thu, 30 Jun 2016 13:39:44 -0400 Subject: [PATCH 04/82] Implement plotly .animate() and keyframe API --- animate-api.md | 133 +++++++++++ src/core.js | 6 + src/lib/merge_keyframes.js | 31 +++ src/plot_api/plot_api.js | 258 ++++++++++++++++++++- src/plots/plots.js | 62 +++++ src/traces/scatter/plot.js | 6 +- test/jasmine/tests/frame_api_test.js | 205 ++++++++++++++++ test/jasmine/tests/keyframe_computation.js | 48 ++++ 8 files changed, 746 insertions(+), 3 deletions(-) create mode 100644 animate-api.md create mode 100644 src/lib/merge_keyframes.js create mode 100644 test/jasmine/tests/frame_api_test.js create mode 100644 test/jasmine/tests/keyframe_computation.js diff --git a/animate-api.md b/animate-api.md new file mode 100644 index 00000000000..8b65e1aee14 --- /dev/null +++ b/animate-api.md @@ -0,0 +1,133 @@ +## Top-level Plotly API methods + +#### `Plotly.transition(gd, data, layout[, traceIndices[, config]])` +Transition (eased or abruptly if desired) to a new set of data. Knows nothing about the larger state of transitions and frames; identically a 'transition the plot to look like X over Y ms' command. + +**Parameters**: +- `data`: an *array* of *objects* containing trace data, e.g. `[{x: [1, 2, 3], 'lines.color': 'red'}, {y: [7,8]}]`, mapped to traces. +- `layout`: layout properties to which to transition, probably mostly just axis ranges +- `traceIndices`: a mapping between the items of `data` and the trace indices, e.g. `[0, 2]`. If omitted, is inferred from semantics like for `restyle`—which means maybe affecting all traces? +- `config`: object containing transition configuration, including: + - `duration`: duration in ms of transition + - `ease`: d3 easing function, e.g. `elastic-in-out` + - `delay`: delay until animation; not so useful, just very very easy to pass to d3 + - `cascade`: transition points in sequence for a nice visual effect. Maybe just leave out. Kind of a common visual effect for eye candy purposes. Very easy. Can leave out if it leads to weird corner cases. See: http://rickyreusser.com/animation-experiments/#object-constancy + +**Returns**: promise that resolves when animation begins or rejects if config is invalid. + +**Events**: +- `plotly_starttransition` +- `plotly_endtransition` + +
+ +#### `Plotly.animate(gd, frame[, config])` +Transition to a keyframe. Animation sequence is: + +1. Compute the requested frame +2. Separate animatable and non-animatable properties into separate objects +3. Mark exactly what needs to happen. This includes transitions vs. non-animatable properties, whether the axis needs to be redrawn (`needsRelayout`?), and any other optimizations that seem relevant. Since for some cases very simple updates may be coming through at up to 60fps, cutting out work here could be fairly important. + +**Parameters**: +- `frame`: name of the frame to which to animate +- `config`: see `.transition`. + +**Returns**: promise that resolves when animation begins or rejects if config is invalid. + +**Events**: +- `plotly_startanimation` +- `plotly_endanimation` + +
+ +#### `Plotly.addFrames(gd, frames[, frameIndices])` +Add or overwrite frames. New frames are appended to current frame list. + +**Parameters** +- `frames`: an array of objects containing any of `name`, `data`, `layout` and `traceIndices` fields as specified above. If no name is provided, a unique name (e.g. `frame 7`) will be assigned. If the frame already exists, then its definition is overwritten. +- `frameIndices`: optional array of indices at which to insert the given frames. If indices are omitted or a specific index is falsey, then frame is appended. + +**Returns**: Promise that resolves on completion. (In this case, that's synchronously and mainly for the sake of API consistency.) + +
+ +#### `Plotly.deleteFrames(gd, frameIndices)` +Remove frames by frame index. + +**Parameters**: +- `frameIndices`: an array of integer indices of the frames to be removed. + +**Returns**: Promise that resolves on completion (which here means synchronously). + +
+ +## Frame definition + +Frames are defined similarly to mirror the input format, *not* that of `Plotly.restyle`. The easiest way to explain seems to be via an example that touches all features: + +```json +{ + "data": [{ + "x": [1, 2, 3], + "y": [4, 5, 6], + "identifiers": ["China", "Pakistan", "Australia"], + "lines": { + "color": "red" + } + }, { + "x": [1, 2, 3], + "y": [3, 8, 9], + "markers": { + "color": "red" + } + }], + "layout": { + "slider": { + "visible": true, + "plotly_method": "animate", + "args": ["$value", {"duration": 500}] + }, + "slider2": { + "visible": true, + "plotly_method": "animate", + "args": ["$value", {"duration": 500}] + } + }, + "frames": [ + { + "name": "base", + "y": [4, 5, 7], + "identifiers": ["China", "Pakistan", "Australia"], + }, { + "name": "1960", + "data": [{ + "y": [1, 2, 3], + "identifiers": ["China", "Pakistan", "Australia"], + }], + "layout": { + "xaxis": {"range": [7, 3]}, + "yaxis": {"range": [0, 5]} + }, + "baseFrame": "base", + "traceIndices": [0] + }, { + "name": "1965", + "data": [{ + "y": [5, 3, 2], + "identifiers": ["China", "Pakistan", "Australia"], + }], + "layout": { + "xaxis": {"range": [7, 3]}, + "yaxis": {"range": [0, 5]} + }, + "baseFrame": "base", + "traceIndices": [0] + } + ] +} +``` + +Notes on JSON: +- `identifiers` is used as a d3 `key` argument. +- `baseFrame` is merged… recursively? non-recursively? We'll see. Not a crucial implementation choice. +- `frames` seems maybe best stored at top level. Or maybe best on the object. If on the object, `Plotly.plot` would have to be variadic (probably), accepting `Plotly.plot(gd, data, layout[, frames], config)`. That's backward-compatible but a bit ugly. If not on the object, then it would have to be shoved into `layout` (except how, because it's really awkward place in `layout`. diff --git a/src/core.js b/src/core.js index a103eebb821..e1dd1a3fda3 100644 --- a/src/core.js +++ b/src/core.js @@ -28,6 +28,12 @@ exports.prependTraces = Plotly.prependTraces; exports.addTraces = Plotly.addTraces; exports.deleteTraces = Plotly.deleteTraces; exports.moveTraces = Plotly.moveTraces; +exports.animate = Plotly.animate; +exports.addFrames = Plotly.addFrames; +exports.deleteFrames = Plotly.deleteFrames; +exports.renameFrame = Plotly.renameFrame; +exports.transition = Plotly.transition; +exports.animate = Plotly.animate; exports.purge = Plotly.purge; exports.setPlotConfig = require('./plot_api/set_plot_config'); exports.register = Plotly.register; diff --git a/src/lib/merge_keyframes.js b/src/lib/merge_keyframes.js new file mode 100644 index 00000000000..5ec15d8e39a --- /dev/null +++ b/src/lib/merge_keyframes.js @@ -0,0 +1,31 @@ +/** +* Copyright 2012-2016, 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 extend = require('./extend'); + +/* + * Merge two keyframe specifications, returning in a third object that + * can be used for plotting. + * + * @param {object} target + * An object with data, layout, and trace data + * @param {object} source + * An object with data, layout, and trace data + * + * Returns: a third object with the merged content + */ +module.exports = function mergeKeyframes(target, source) { + var result; + + result = extend.extendDeep({}, target); + result = extend.extendDeep(result, source); + + return result; +}; diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index 371f834584e..f671858a875 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -839,14 +839,18 @@ Plotly.newPlot = function(gd, data, layout, config) { return Plotly.plot(gd, data, layout, config); }; -function doCalcdata(gd) { +function doCalcdata(gd, traces) { var axList = Plotly.Axes.list(gd), fullData = gd._fullData, fullLayout = gd._fullLayout; var i, trace, module, cd; - var calcdata = gd.calcdata = new Array(fullData.length); + // XXX: Is this correct? Needs a closer look so that *some* traces can be recomputed without + // *all* needing doCalcdata: + var calcdata = new Array(fullData.length); + var oldCalcdata = gd.calcdata.slice(0); + gd.calcdata = calcdata; // extra helper variables // firstscatter: fill-to-next on the first trace goes to zero @@ -870,6 +874,13 @@ function doCalcdata(gd) { } for(i = 0; i < fullData.length; i++) { + // If traces were specified and this trace was not included, then transfer it over from + // the old calcdata: + if(Array.isArray(traces) && traces.indexOf(i) === -1) { + calcdata[i] = oldCalcdata[i]; + continue; + } + trace = fullData[i]; module = trace._module; cd = []; @@ -2476,6 +2487,249 @@ Plotly.relayout = function relayout(gd, astr, val) { }); }; +/** + * Transition to a set of new data and layout properties + * + * @param {string id or DOM element} gd + * the id or DOM element of the graph container div + */ +Plotly.transition = function(gd /*, data, layout, traces, transitionConfig*/) { + gd = getGraphDiv(gd); + + /*var fullLayout = gd._fullLayout; + + transitionConfig = Lib.extendFlat({ + ease: 'cubic-in-out', + duration: 500, + delay: 0 + }, transitionConfig || {}); + + // Create a single transition to be passed around: + if(transitionConfig.duration > 0) { + gd._currentTransition = d3.transition() + .duration(transitionConfig.duration) + .delay(transitionConfig.delay) + .ease(transitionConfig.ease); + } else { + gd._currentTransition = null; + } + + // Select which traces will be updated: + if(isNumeric(traces)) traces = [traces]; + else if(!Array.isArray(traces) || !traces.length) { + traces = gd._fullData.map(function(v, i) {return i;}); + } + + var transitioningTraces = []; + + function prepareAnimations() { + for(i = 0; i < traces.length; i++) { + var traceIdx = traces[i]; + var trace = gd._fullData[traceIdx]; + var module = trace._module; + + if(!module.animatable) { + continue; + } + + transitioningTraces.push(traceIdx); + + newTraceData = newData[i]; + curData = gd.data[traces[i]]; + + for(var ai in newTraceData) { + var value = newTraceData[ai]; + Lib.nestedProperty(curData, ai).set(value); + } + + var traceIdx = traces[i]; + if(gd.data[traceIdx].marker && gd.data[traceIdx].marker.size) { + gd._fullData[traceIdx].marker.size = gd.data[traceIdx].marker.size; + } + if(gd.data[traceIdx].error_y && gd.data[traceIdx].error_y.array) { + gd._fullData[traceIdx].error_y.array = gd.data[traceIdx].error_y.array; + } + if(gd.data[traceIdx].error_x && gd.data[traceIdx].error_x.array) { + gd._fullData[traceIdx].error_x.array = gd.data[traceIdx].error_x.array; + } + gd._fullData[traceIdx].x = gd.data[traceIdx].x; + gd._fullData[traceIdx].y = gd.data[traceIdx].y; + gd._fullData[traceIdx].z = gd.data[traceIdx].z; + gd._fullData[traceIdx].key = gd.data[traceIdx].key; + } + + doCalcdata(gd, transitioningTraces); + + ErrorBars.calc(gd); + } + + function doAnimations() { + var a, i, j; + var basePlotModules = fullLayout._basePlotModules; + for(j = 0; j < basePlotModules.length; j++) { + basePlotModules[j].plot(gd, transitioningTraces, transitionOpts); + } + + if(layout) { + for(j = 0; j < basePlotModules.length; j++) { + if(basePlotModules[j].transitionAxes) { + basePlotModules[j].transitionAxes(gd, layout, transitionOpts); + } + } + } + + if(!transitionOpts.leadingEdgeRestyle) { + return new Promise(function(resolve, reject) { + completion = resolve; + completionTimeout = setTimeout(resolve, transitionOpts.duration); + }); + } + }*/ +}; + +/** + * Animate to a keyframe + * + * @param {string} name + * name of the keyframe to create + * @param {object} transitionConfig + * configuration for transition + */ +Plotly.animate = function(gd, name /*, transitionConfig*/) { + gd = getGraphDiv(gd); + + var _frames = gd._frameData._frames; + + if(!_frames[name]) { + Lib.warn('animateToFrame failure: keyframe does not exist', name); + return Promise.reject(); + } + + return Promise.resolve(); +}; + +/** + * Create new keyframes + * + * @param {array of objects} frameList + * list of frame definitions, in which each object includes any of: + * - name: {string} name of keyframe to add + * - data: {array of objects} trace data + * - layout {object} layout definition + * - traces {array} trace indices + * - baseFrame {string} name of keyframe from which this keyframe gets defaults + */ +Plotly.addFrames = function(gd, frameList, indices) { + gd = getGraphDiv(gd); + + var i, frame, j, idx; + var _frames = gd._frameData._frames; + var _hash = gd._frameData._frameHash; + + + if(!Array.isArray(frameList)) { + Lib.warn('addFrames failure: frameList must be an Array of frame definitions', frameList); + return Promise.reject(); + } + + // Create a sorted list of insertions since we run into lots of problems if these + // aren't in ascending order of index: + // + // Strictly for sorting. Make sure this is guaranteed to never collide with any + // already-exisisting indices: + var bigIndex = _frames.length + frameList.length * 2; + + var insertions = []; + for(i = frameList.length - 1; i >= 0; i--) { + insertions.push({ + frame: frameList[i], + index: (indices && indices[i] !== undefined) ? indices[i] : bigIndex + i + }); + } + + // Sort this, taking note that undefined insertions end up at the end: + insertions.sort(function(a, b) { + if(a.index > b.index) return -1; + if(a.index < b.index) return 1; + return 0; + }); + + var ops = []; + var revops = []; + var frameCount = _frames.length; + + for(i = insertions.length - 1; i >= 0; i--) { + //frame = frameList[i]; + frame = insertions[i].frame; + + if(!frame.name) { + while(_frames[(frame.name = 'frame ' + gd._frameData._counter++)]); + } + + if(_hash[frame.name]) { + // If frame is present, overwrite its definition: + for(j = 0; j < _frames.length; j++) { + if(_frames[j].name === frame.name) break; + } + ops.push({type: 'replace', index: j, value: frame}); + revops.unshift({type: 'replace', index: j, value: _frames[j]}); + } else { + // Otherwise insert it at the end of the list: + idx = Math.min(insertions[i].index, frameCount); + + ops.push({type: 'insert', index: idx, value: frame}); + revops.unshift({type: 'delete', index: idx}); + frameCount++; + } + } + + Plots.modifyFrames(gd, ops); + + var undoFunc = Plots.modifyFrames, + redoFunc = Plots.modifyFrames, + undoArgs = [gd, revops], + redoArgs = [gd, ops]; + + if(Queue) Queue.add(gd, undoFunc, undoArgs, redoFunc, redoArgs); + + return Promise.resolve(); +}; + +/** + * Delete keyframes + * + * @param {array of integers} frameList + * list of integer indices of frames to be deleted + */ +Plotly.deleteFrames = function(gd, frameList) { + gd = getGraphDiv(gd); + + var i, idx; + var _frames = gd._frameData._frames; + var ops = []; + var revops = []; + + frameList = frameList.splice(0); + frameList.sort(); + + for(i = frameList.length - 1; i >= 0; i--) { + idx = frameList[i]; + ops.push({type: 'delete', index: idx}); + revops.unshift({type: 'insert', index: idx, value: _frames[idx]}); + } + + Plots.modifyFrames(gd, ops); + + var undoFunc = Plots.modifyFrames, + redoFunc = Plots.modifyFrames, + undoArgs = [gd, revops], + redoArgs = [gd, ops]; + + if(Queue) Queue.add(gd, undoFunc, undoArgs, redoFunc, redoArgs); + + return Promise.resolve(); +}; + /** * Purge a graph container div back to its initial pre-Plotly.plot state * diff --git a/src/plots/plots.js b/src/plots/plots.js index 4a8def06d1b..dfb3c5f7b43 100644 --- a/src/plots/plots.js +++ b/src/plots/plots.js @@ -452,6 +452,10 @@ plots.sendDataToCloud = function(gd) { // gd._fullLayout._basePlotModules // is a list of all the plot modules required to draw the plot. // +// gd._frameData +// object containing frame definitions (_frameData._frames) and +// associated metadata. +// plots.supplyDefaults = function(gd) { var oldFullLayout = gd._fullLayout || {}, newFullLayout = gd._fullLayout = {}, @@ -540,6 +544,23 @@ plots.supplyDefaults = function(gd) { (gd.calcdata[i][0] || {}).trace = trace; } } + + // Set up the default keyframe if it doesn't exist: + if(!gd._frameData) { + gd._frameData = {}; + } + + if(!gd._frameData._frames) { + gd._frameData._frames = []; + } + + if(!gd._frameData._frameHash) { + gd._frameData._frameHash = {}; + } + + if(!gd._frameData._counter) { + gd._frameData._counter = 0; + } }; // helper function to be bound to fullLayout to check @@ -843,6 +864,7 @@ plots.purge = function(gd) { delete gd.numboxes; delete gd._hoverTimer; delete gd._lastHoverTime; + delete gd._frameData; // remove all event listeners if(gd.removeAllListeners) gd.removeAllListeners(); @@ -1107,3 +1129,43 @@ plots.graphJson = function(gd, dataonly, mode, output, useDefaults) { return (output === 'object') ? obj : JSON.stringify(obj); }; + +/** + * Modify a keyframe using a list of operations: + * + * @param {array of objects} operations + * Sequence of operations to be performed on the keyframes + */ +plots.modifyFrames = function(gd, operations) { + var i, op; + var _frames = gd._frameData._frames; + var _hash = gd._frameData._frameHash; + + for(i = 0; i < operations.length; i++) { + op = operations[i]; + + switch(op.type) { + case 'rename': + var frame = _frames[op.index]; + delete _hash[frame.name]; + _hash[op.name] = frame; + break; + case 'replace': + frame = op.value; + _frames[op.index] = _hash[frame.name] = frame; + break; + case 'insert': + frame = op.value; + _hash[frame.name] = frame; + _frames.splice(op.index, 0, frame); + break; + case 'delete': + frame = _frames[op.index]; + delete _hash[frame.name]; + _frames.splice(op.index, 1); + break; + } + } + + return Promise.resolve(); +}; diff --git a/src/traces/scatter/plot.js b/src/traces/scatter/plot.js index 8ec17ff2183..f6984cb78d3 100644 --- a/src/traces/scatter/plot.js +++ b/src/traces/scatter/plot.js @@ -21,11 +21,15 @@ var linePoints = require('./line_points'); var linkTraces = require('./link_traces'); var polygonTester = require('../../lib/polygon').tester; -module.exports = function plot(gd, plotinfo, cdscatter, isFullReplot) { +module.exports = function plot(gd, plotinfo, cdscatter, transitionConfig) { var i, uids, selection, join; var scatterlayer = plotinfo.plot.select('g.scatterlayer'); + // If transition config is provided, then it is only a partial replot and traces not + // updated are removed. + var isFullReplot = !transitionConfig; + selection = scatterlayer.selectAll('g.trace'); join = selection.data(cdscatter, function(d) {return d[0].trace.uid;}); diff --git a/test/jasmine/tests/frame_api_test.js b/test/jasmine/tests/frame_api_test.js new file mode 100644 index 00000000000..37dc9821b8c --- /dev/null +++ b/test/jasmine/tests/frame_api_test.js @@ -0,0 +1,205 @@ +var Plotly = require('@lib/index'); + +var createGraphDiv = require('../assets/create_graph_div'); +var destroyGraphDiv = require('../assets/destroy_graph_div'); + +// A helper function so that failed tests don't simply stall: +function fail(done) { + return function(err) { + console.error(err.toString()); + expect(err.toString()).toBe(true); + done(); + }; +} + +describe('Test frame api', function() { + 'use strict'; + + var gd, mock, f, h; + + beforeEach(function(done) { + mock = [{x: [1, 2, 3], y: [2, 1, 3]}, {x: [1, 2, 3], y: [6, 4, 5]}]; + gd = createGraphDiv(); + Plotly.plot(gd, mock).then(function() { + f = gd._frameData._frames; + h = gd._frameData._frameHash; + }).then(done); + }); + + afterEach(destroyGraphDiv); + + describe('gd initialization', function() { + it('creates an empty list for frames', function() { + expect(gd._frameData._frames).toEqual([]); + }); + + it('creates an empty lookup table for frames', function() { + expect(gd._frameData._frameHash).toEqual({}); + }); + + it('initializes a name counter to zero', function() { + expect(gd._frameData._counter).toEqual(0); + }); + }); + + describe('addFrames', function() { + it('names an unnamed frame', function(done) { + Plotly.addFrames(gd, [{}]).then(function() { + expect(Object.keys(h)).toEqual(['frame 0']); + }).then(done, fail(done)); + }); + + it('creates multiple unnamed frames at the same time', function(done) { + Plotly.addFrames(gd, [{}, {}]).then(function() { + expect(f).toEqual([{name: 'frame 0'}, {name: 'frame 1'}]); + }).then(done, fail(done)); + }); + + it('creates multiple unnamed frames in series', function(done) { + Plotly.addFrames(gd, [{}]).then( + Plotly.addFrames(gd, [{}]) + ).then(function() { + expect(f).toEqual([{name: 'frame 0'}, {name: 'frame 1'}]); + }).then(done, fail(done)); + }); + + it('inserts frames at specific indices', function(done) { + var i; + var frames = []; + for(i = 0; i < 10; i++) { + frames.push({name: 'frame' + i}); + } + + Plotly.addFrames(gd, frames).then(function() { + return Plotly.addFrames(gd, [{name: 'inserted1'}, {name: 'inserted2'}, {name: 'inserted3'}], [5, 7, undefined]); + }).then(function() { + expect(f[5]).toEqual({name: 'inserted1'}); + expect(f[7]).toEqual({name: 'inserted2'}); + expect(f[12]).toEqual({name: 'inserted3'}); + + return Plotly.Queue.undo(gd); + }).then(function() { + for(i = 0; i < 10; i++) { + expect(f[i]).toEqual({name: 'frame' + i}); + } + }).then(done, fail(done)); + }); + + it('inserts frames at specific indices (reversed)', function(done) { + var i; + var frames = []; + for(i = 0; i < 10; i++) { + frames.push({name: 'frame' + i}); + } + + Plotly.addFrames(gd, frames).then(function() { + return Plotly.addFrames(gd, [{name: 'inserted3'}, {name: 'inserted2'}, {name: 'inserted1'}], [undefined, 7, 5]); + }).then(function() { + expect(f[5]).toEqual({name: 'inserted1'}); + expect(f[7]).toEqual({name: 'inserted2'}); + expect(f[12]).toEqual({name: 'inserted3'}); + + return Plotly.Queue.undo(gd); + }).then(function() { + for(i = 0; i < 10; i++) { + expect(f[i]).toEqual({name: 'frame' + i}); + } + }).then(done, fail(done)); + }); + + it('implements undo/redo', function(done) { + Plotly.addFrames(gd, [{name: 'frame 0'}, {name: 'frame 1'}]).then(function() { + expect(f).toEqual([{name: 'frame 0'}, {name: 'frame 1'}]); + expect(h).toEqual({'frame 0': {name: 'frame 0'}, 'frame 1': {name: 'frame 1'}}); + + return Plotly.Queue.undo(gd); + }).then(function() { + expect(f).toEqual([]); + expect(h).toEqual({}); + + return Plotly.Queue.redo(gd); + }).then(function() { + expect(f).toEqual([{name: 'frame 0'}, {name: 'frame 1'}]); + expect(h).toEqual({'frame 0': {name: 'frame 0'}, 'frame 1': {name: 'frame 1'}}); + }).then(done, fail(done)); + }); + + it('overwrites frames', function(done) { + // The whole shebang. This hits insertion + replacements + deletion + undo + redo: + Plotly.addFrames(gd, [{name: 'test1', x: 'y'}, {name: 'test2'}]).then(function() { + expect(f).toEqual([{name: 'test1', x: 'y'}, {name: 'test2'}]); + expect(Object.keys(h)).toEqual(['test1', 'test2']); + + return Plotly.addFrames(gd, [{name: 'test1'}, {name: 'test3'}]); + }).then(function() { + expect(f).toEqual([{name: 'test1'}, {name: 'test2'}, {name: 'test3'}]); + expect(Object.keys(h)).toEqual(['test1', 'test2', 'test3']); + + return Plotly.Queue.undo(gd); + }).then(function() { + expect(f).toEqual([{name: 'test1', x: 'y'}, {name: 'test2'}]); + expect(Object.keys(h)).toEqual(['test1', 'test2']); + + return Plotly.Queue.redo(gd); + }).then(function() { + expect(f).toEqual([{name: 'test1'}, {name: 'test2'}, {name: 'test3'}]); + expect(Object.keys(h)).toEqual(['test1', 'test2', 'test3']); + }).then(done, fail(done)); + }); + }); + + describe('deleteFrames', function() { + it('deletes a frame', function(done) { + Plotly.addFrames(gd, [{name: 'frame1'}]).then(function() { + expect(f).toEqual([{name: 'frame1'}]); + expect(Object.keys(h)).toEqual(['frame1']); + + return Plotly.deleteFrames(gd, [0]); + }).then(function() { + expect(f).toEqual([]); + expect(Object.keys(h)).toEqual([]); + + return Plotly.Queue.undo(gd); + }).then(function() { + expect(f).toEqual([{name: 'frame1'}]); + + return Plotly.Queue.redo(gd); + }).then(function() { + expect(f).toEqual([]); + expect(Object.keys(h)).toEqual([]); + }).then(done, fail(done)); + }); + + it('deletes multiple frames', function(done) { + var i; + var frames = []; + for(i = 0; i < 10; i++) { + frames.push({name: 'frame' + i}); + } + + Plotly.addFrames(gd, frames).then(function() { + return Plotly.deleteFrames(gd, [2, 8, 4, 6]); + }).then(function() { + var expected = ['frame0', 'frame1', 'frame3', 'frame5', 'frame7', 'frame9']; + expect(f.length).toEqual(expected.length); + for(i = 0; i < expected.length; i++) { + expect(f[i].name).toEqual(expected[i]); + } + + return Plotly.Queue.undo(gd); + }).then(function() { + for(i = 0; i < 10; i++) { + expect(f[i]).toEqual({name: 'frame' + i}); + } + + return Plotly.Queue.redo(gd); + }).then(function() { + var expected = ['frame0', 'frame1', 'frame3', 'frame5', 'frame7', 'frame9']; + expect(f.length).toEqual(expected.length); + for(i = 0; i < expected.length; i++) { + expect(f[i].name).toEqual(expected[i]); + } + }).then(done, fail(done)); + }); + }); +}); diff --git a/test/jasmine/tests/keyframe_computation.js b/test/jasmine/tests/keyframe_computation.js new file mode 100644 index 00000000000..429b52f81cd --- /dev/null +++ b/test/jasmine/tests/keyframe_computation.js @@ -0,0 +1,48 @@ +var mergeKeyframes = require('@src/lib/merge_keyframes'); + +describe('Test mergeKeyframes', function() { + 'use strict'; + + it('returns a new object', function() { + var f1 = {}; + var f2 = {}; + var result = mergeKeyframes(f1, f2); + expect(result).toEqual({}); + expect(result).not.toBe(f1); + expect(result).not.toBe(f2); + }); + + it('overrides properties of target with those of source', function() { + var tar = {xaxis: {range: [0, 1]}}; + var src = {xaxis: {range: [3, 4]}}; + var out = mergeKeyframes(tar, src); + + expect(out).toEqual({xaxis: {range: [3, 4]}}); + }); + + it('merges dotted properties', function() { + var tar = {}; + var src = {'xaxis.range': [0, 1]}; + var out = mergeKeyframes(tar, src); + + expect(out).toEqual({'xaxis.range': [0, 1]}); + }); + + describe('assimilating dotted properties', function() { + it('xaxis.range', function() { + var tar = {xaxis: {range: [0, 1]}}; + var src = {'xaxis.range': [3, 4]}; + var out = mergeKeyframes(tar, src); + + expect(out).toEqual({xaxis: {range: [3, 4]}}); + }); + + it('xaxis.range.0', function() { + var tar = {xaxis: {range: [0, 1]}}; + var src = {'xaxis.range.0': 3}; + var out = mergeKeyframes(tar, src); + + expect(out).toEqual({xaxis: {range: [3, 1]}}); + }); + }); +}); From 3e18a9867a34cdb5a0bbbce2126c0ac48227c694 Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Wed, 6 Jul 2016 12:02:21 -0400 Subject: [PATCH 05/82] Clean up frame API tests and corner cases --- src/plot_api/plot_api.js | 21 +++---- src/plots/plots.js | 22 +++++-- test/jasmine/assets/fail_test.js | 6 +- test/jasmine/tests/frame_api_test.js | 91 ++++++++++++++-------------- 4 files changed, 75 insertions(+), 65 deletions(-) diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index f671858a875..ed42ea7d4ee 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -849,7 +849,7 @@ function doCalcdata(gd, traces) { // XXX: Is this correct? Needs a closer look so that *some* traces can be recomputed without // *all* needing doCalcdata: var calcdata = new Array(fullData.length); - var oldCalcdata = gd.calcdata.slice(0); + var oldCalcdata = (gd.calcdata || []).slice(0); gd.calcdata = calcdata; // extra helper variables @@ -2643,7 +2643,7 @@ Plotly.addFrames = function(gd, frameList, indices) { for(i = frameList.length - 1; i >= 0; i--) { insertions.push({ frame: frameList[i], - index: (indices && indices[i] !== undefined) ? indices[i] : bigIndex + i + index: (indices && indices[i] !== undefined && indices[i] !== null) ? indices[i] : bigIndex + i }); } @@ -2659,11 +2659,12 @@ Plotly.addFrames = function(gd, frameList, indices) { var frameCount = _frames.length; for(i = insertions.length - 1; i >= 0; i--) { - //frame = frameList[i]; frame = insertions[i].frame; if(!frame.name) { - while(_frames[(frame.name = 'frame ' + gd._frameData._counter++)]); + // Repeatedly assign a default name, incrementing the counter each time until + // we get a name that's not in the hashed lookup table: + while(_hash[(frame.name = 'frame ' + gd._frameData._counter++)]); } if(_hash[frame.name]) { @@ -2675,7 +2676,7 @@ Plotly.addFrames = function(gd, frameList, indices) { revops.unshift({type: 'replace', index: j, value: _frames[j]}); } else { // Otherwise insert it at the end of the list: - idx = Math.min(insertions[i].index, frameCount); + idx = Math.max(0, Math.min(insertions[i].index, frameCount)); ops.push({type: 'insert', index: idx, value: frame}); revops.unshift({type: 'delete', index: idx}); @@ -2683,8 +2684,6 @@ Plotly.addFrames = function(gd, frameList, indices) { } } - Plots.modifyFrames(gd, ops); - var undoFunc = Plots.modifyFrames, redoFunc = Plots.modifyFrames, undoArgs = [gd, revops], @@ -2692,7 +2691,7 @@ Plotly.addFrames = function(gd, frameList, indices) { if(Queue) Queue.add(gd, undoFunc, undoArgs, redoFunc, redoArgs); - return Promise.resolve(); + return Plots.modifyFrames(gd, ops); }; /** @@ -2709,7 +2708,7 @@ Plotly.deleteFrames = function(gd, frameList) { var ops = []; var revops = []; - frameList = frameList.splice(0); + frameList = frameList.slice(0); frameList.sort(); for(i = frameList.length - 1; i >= 0; i--) { @@ -2718,8 +2717,6 @@ Plotly.deleteFrames = function(gd, frameList) { revops.unshift({type: 'insert', index: idx, value: _frames[idx]}); } - Plots.modifyFrames(gd, ops); - var undoFunc = Plots.modifyFrames, redoFunc = Plots.modifyFrames, undoArgs = [gd, revops], @@ -2727,7 +2724,7 @@ Plotly.deleteFrames = function(gd, frameList) { if(Queue) Queue.add(gd, undoFunc, undoArgs, redoFunc, redoArgs); - return Promise.resolve(); + return Plots.modifyFrames(gd, ops); }; /** diff --git a/src/plots/plots.js b/src/plots/plots.js index dfb3c5f7b43..0373af1497c 100644 --- a/src/plots/plots.js +++ b/src/plots/plots.js @@ -1137,7 +1137,7 @@ plots.graphJson = function(gd, dataonly, mode, output, useDefaults) { * Sequence of operations to be performed on the keyframes */ plots.modifyFrames = function(gd, operations) { - var i, op; + var i, op, frame; var _frames = gd._frameData._frames; var _hash = gd._frameData._frameHash; @@ -1145,14 +1145,26 @@ plots.modifyFrames = function(gd, operations) { op = operations[i]; switch(op.type) { - case 'rename': - var frame = _frames[op.index]; + // No reason this couldn't exist, but is currently unused/untested: + /*case 'rename': + frame = _frames[op.index]; delete _hash[frame.name]; _hash[op.name] = frame; - break; + frame.name = op.name; + break;*/ case 'replace': frame = op.value; - _frames[op.index] = _hash[frame.name] = frame; + var oldName = _frames[op.index].name; + var newName = frame.name; + _frames[op.index] = _hash[newName] = frame; + + if(newName !== oldName) { + // If name has changed in addition to replacement, then update + // the lookup table: + delete _hash[oldName]; + _hash[newName] = frame; + } + break; case 'insert': frame = op.value; diff --git a/test/jasmine/assets/fail_test.js b/test/jasmine/assets/fail_test.js index 12b591a35f7..468a7640c59 100644 --- a/test/jasmine/assets/fail_test.js +++ b/test/jasmine/assets/fail_test.js @@ -18,5 +18,9 @@ * See ./with_setup_teardown.js for a different example. */ module.exports = function failTest(error) { - expect(error).toBeUndefined(); + if(error === undefined) { + expect(error).not.toBeUndefined(); + } else { + expect(error).toBeUndefined(); + } }; diff --git a/test/jasmine/tests/frame_api_test.js b/test/jasmine/tests/frame_api_test.js index 37dc9821b8c..f35cc6310a9 100644 --- a/test/jasmine/tests/frame_api_test.js +++ b/test/jasmine/tests/frame_api_test.js @@ -2,15 +2,7 @@ var Plotly = require('@lib/index'); var createGraphDiv = require('../assets/create_graph_div'); var destroyGraphDiv = require('../assets/destroy_graph_div'); - -// A helper function so that failed tests don't simply stall: -function fail(done) { - return function(err) { - console.error(err.toString()); - expect(err.toString()).toBe(true); - done(); - }; -} +var fail = require('../assets/fail_test'); describe('Test frame api', function() { 'use strict'; @@ -34,25 +26,21 @@ describe('Test frame api', function() { }); it('creates an empty lookup table for frames', function() { - expect(gd._frameData._frameHash).toEqual({}); - }); - - it('initializes a name counter to zero', function() { expect(gd._frameData._counter).toEqual(0); }); }); - describe('addFrames', function() { + describe('#addFrames', function() { it('names an unnamed frame', function(done) { Plotly.addFrames(gd, [{}]).then(function() { expect(Object.keys(h)).toEqual(['frame 0']); - }).then(done, fail(done)); + }).catch(fail).then(done); }); it('creates multiple unnamed frames at the same time', function(done) { Plotly.addFrames(gd, [{}, {}]).then(function() { expect(f).toEqual([{name: 'frame 0'}, {name: 'frame 1'}]); - }).then(done, fail(done)); + }).catch(fail).then(done); }); it('creates multiple unnamed frames in series', function(done) { @@ -60,7 +48,17 @@ describe('Test frame api', function() { Plotly.addFrames(gd, [{}]) ).then(function() { expect(f).toEqual([{name: 'frame 0'}, {name: 'frame 1'}]); - }).then(done, fail(done)); + }).catch(fail).then(done); + }); + + it('avoids name collisions', function(done) { + Plotly.addFrames(gd, [{name: 'frame 0'}, {name: 'frame 2'}]).then(function() { + expect(f).toEqual([{name: 'frame 0'}, {name: 'frame 2'}]); + + return Plotly.addFrames(gd, [{}, {name: 'foobar'}, {}]); + }).then(function() { + expect(f).toEqual([{name: 'frame 0'}, {name: 'frame 2'}, {name: 'frame 1'}, {name: 'foobar'}, {name: 'frame 3'}]); + }).catch(fail).then(done); }); it('inserts frames at specific indices', function(done) { @@ -70,7 +68,13 @@ describe('Test frame api', function() { frames.push({name: 'frame' + i}); } - Plotly.addFrames(gd, frames).then(function() { + function validate() { + for(i = 0; i < 10; i++) { + expect(f[i]).toEqual({name: 'frame' + i}); + } + } + + Plotly.addFrames(gd, frames).then(validate).then(function() { return Plotly.addFrames(gd, [{name: 'inserted1'}, {name: 'inserted2'}, {name: 'inserted3'}], [5, 7, undefined]); }).then(function() { expect(f[5]).toEqual({name: 'inserted1'}); @@ -78,11 +82,7 @@ describe('Test frame api', function() { expect(f[12]).toEqual({name: 'inserted3'}); return Plotly.Queue.undo(gd); - }).then(function() { - for(i = 0; i < 10; i++) { - expect(f[i]).toEqual({name: 'frame' + i}); - } - }).then(done, fail(done)); + }).then(validate).catch(fail).then(done); }); it('inserts frames at specific indices (reversed)', function(done) { @@ -92,7 +92,13 @@ describe('Test frame api', function() { frames.push({name: 'frame' + i}); } - Plotly.addFrames(gd, frames).then(function() { + function validate() { + for(i = 0; i < 10; i++) { + expect(f[i]).toEqual({name: 'frame' + i}); + } + } + + Plotly.addFrames(gd, frames).then(validate).then(function() { return Plotly.addFrames(gd, [{name: 'inserted3'}, {name: 'inserted2'}, {name: 'inserted1'}], [undefined, 7, 5]); }).then(function() { expect(f[5]).toEqual({name: 'inserted1'}); @@ -100,28 +106,23 @@ describe('Test frame api', function() { expect(f[12]).toEqual({name: 'inserted3'}); return Plotly.Queue.undo(gd); - }).then(function() { - for(i = 0; i < 10; i++) { - expect(f[i]).toEqual({name: 'frame' + i}); - } - }).then(done, fail(done)); + }).then(validate).catch(fail).then(done); }); it('implements undo/redo', function(done) { - Plotly.addFrames(gd, [{name: 'frame 0'}, {name: 'frame 1'}]).then(function() { + function validate() { expect(f).toEqual([{name: 'frame 0'}, {name: 'frame 1'}]); expect(h).toEqual({'frame 0': {name: 'frame 0'}, 'frame 1': {name: 'frame 1'}}); + } + Plotly.addFrames(gd, [{name: 'frame 0'}, {name: 'frame 1'}]).then(validate).then(function() { return Plotly.Queue.undo(gd); }).then(function() { expect(f).toEqual([]); expect(h).toEqual({}); return Plotly.Queue.redo(gd); - }).then(function() { - expect(f).toEqual([{name: 'frame 0'}, {name: 'frame 1'}]); - expect(h).toEqual({'frame 0': {name: 'frame 0'}, 'frame 1': {name: 'frame 1'}}); - }).then(done, fail(done)); + }).then(validate).catch(fail).then(done); }); it('overwrites frames', function(done) { @@ -144,11 +145,11 @@ describe('Test frame api', function() { }).then(function() { expect(f).toEqual([{name: 'test1'}, {name: 'test2'}, {name: 'test3'}]); expect(Object.keys(h)).toEqual(['test1', 'test2', 'test3']); - }).then(done, fail(done)); + }).catch(fail).then(done); }); }); - describe('deleteFrames', function() { + describe('#deleteFrames', function() { it('deletes a frame', function(done) { Plotly.addFrames(gd, [{name: 'frame1'}]).then(function() { expect(f).toEqual([{name: 'frame1'}]); @@ -167,7 +168,7 @@ describe('Test frame api', function() { }).then(function() { expect(f).toEqual([]); expect(Object.keys(h)).toEqual([]); - }).then(done, fail(done)); + }).catch(fail).then(done); }); it('deletes multiple frames', function(done) { @@ -177,15 +178,17 @@ describe('Test frame api', function() { frames.push({name: 'frame' + i}); } - Plotly.addFrames(gd, frames).then(function() { - return Plotly.deleteFrames(gd, [2, 8, 4, 6]); - }).then(function() { + function validate() { var expected = ['frame0', 'frame1', 'frame3', 'frame5', 'frame7', 'frame9']; expect(f.length).toEqual(expected.length); for(i = 0; i < expected.length; i++) { expect(f[i].name).toEqual(expected[i]); } + } + Plotly.addFrames(gd, frames).then(function() { + return Plotly.deleteFrames(gd, [2, 8, 4, 6]); + }).then(validate).then(function() { return Plotly.Queue.undo(gd); }).then(function() { for(i = 0; i < 10; i++) { @@ -193,13 +196,7 @@ describe('Test frame api', function() { } return Plotly.Queue.redo(gd); - }).then(function() { - var expected = ['frame0', 'frame1', 'frame3', 'frame5', 'frame7', 'frame9']; - expect(f.length).toEqual(expected.length); - for(i = 0; i < expected.length; i++) { - expect(f[i].name).toEqual(expected[i]); - } - }).then(done, fail(done)); + }).then(validate).catch(fail).then(done); }); }); }); From 01b9287713eaa5b7377cfdc641c63aa717cf4305 Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Wed, 6 Jul 2016 14:33:02 -0400 Subject: [PATCH 06/82] Implement Lib.expandObjectPaths --- src/lib/extend.js | 21 +++++-- src/lib/index.js | 34 ++++++++++ .../{merge_keyframes.js => merge_frames.js} | 2 +- test/jasmine/tests/extend_test.js | 21 +++++++ test/jasmine/tests/lib_test.js | 63 +++++++++++++++++++ ...ame_computation.js => merge_frame_test.js} | 14 ++--- 6 files changed, 141 insertions(+), 14 deletions(-) rename src/lib/{merge_keyframes.js => merge_frames.js} (92%) rename test/jasmine/tests/{keyframe_computation.js => merge_frame_test.js} (77%) diff --git a/src/lib/extend.js b/src/lib/extend.js index def384503d2..a3e851d6ca0 100644 --- a/src/lib/extend.js +++ b/src/lib/extend.js @@ -13,15 +13,19 @@ var isPlainObject = require('./is_plain_object.js'); var isArray = Array.isArray; exports.extendFlat = function() { - return _extend(arguments, false, false); + return _extend(arguments, false, false, false); }; exports.extendDeep = function() { - return _extend(arguments, true, false); + return _extend(arguments, true, false, false); }; exports.extendDeepAll = function() { - return _extend(arguments, true, true); + return _extend(arguments, true, true, false); +}; + +exports.extendDeepNoArrays = function() { + return _extend(arguments, true, false, true); }; /* @@ -41,7 +45,7 @@ exports.extendDeepAll = function() { * Warning: this might result in infinite loops. * */ -function _extend(inputs, isDeep, keepAllKeys) { +function _extend(inputs, isDeep, keepAllKeys, noArrayCopies) { var target = inputs[0], length = inputs.length; @@ -54,8 +58,13 @@ function _extend(inputs, isDeep, keepAllKeys) { src = target[key]; copy = input[key]; + // Stop early and just transfer the array if array copies are disallowed: + if(noArrayCopies && isArray(copy)) { + target[key] = copy; + } + // recurse if we're merging plain objects or arrays - if(isDeep && copy && (isPlainObject(copy) || (copyIsArray = isArray(copy)))) { + else if(isDeep && copy && (isPlainObject(copy) || (copyIsArray = isArray(copy)))) { if(copyIsArray) { copyIsArray = false; clone = src && isArray(src) ? src : []; @@ -64,7 +73,7 @@ function _extend(inputs, isDeep, keepAllKeys) { } // never move original objects, clone them - target[key] = _extend([clone, copy], isDeep, keepAllKeys); + target[key] = _extend([clone, copy], isDeep, keepAllKeys, noArrayCopies); } // don't bring in undefined values, except for extendDeepAll diff --git a/src/lib/index.js b/src/lib/index.js index 32f3f811a67..bed7348900f 100644 --- a/src/lib/index.js +++ b/src/lib/index.js @@ -57,6 +57,7 @@ var extendModule = require('./extend'); lib.extendFlat = extendModule.extendFlat; lib.extendDeep = extendModule.extendDeep; lib.extendDeepAll = extendModule.extendDeepAll; +lib.extendDeepNoArrays = extendModule.extendDeepNoArrays; var loggersModule = require('./loggers'); lib.log = loggersModule.log; @@ -541,6 +542,39 @@ lib.objectFromPath = function(path, value) { return obj; }; +/** + * Iterate through an object in-place, converting dotted properties to objects. + * + * @example + * lib.expandObjectPaths('nested.test[2].path', 'value'); + * // returns { nested: { test: [null, null, { path: 'value' }]} + * + */ +// Store this to avoid recompiling regex on every prop since this may happen many +// many times for animations. +// TODO: Premature optimization? Remove? +var dottedPropertyRegex = /^([^\.]*)\../; + +lib.expandObjectPaths = function(data) { + var match, key, prop, datum; + if(typeof data === 'object' && !Array.isArray(data)) { + for(key in data) { + if(data.hasOwnProperty(key)) { + if((match = key.match(dottedPropertyRegex))) { + datum = data[key]; + prop = match[1]; + + delete data[key]; + + data[prop] = lib.extendDeepNoArrays(data[prop] || {}, lib.objectFromPath(key, lib.expandObjectPaths(datum))[prop]); + } else { + data[key] = lib.expandObjectPaths(data[key]); + } + } + } + } + return data; +}; /** * Converts value to string separated by the provided separators. diff --git a/src/lib/merge_keyframes.js b/src/lib/merge_frames.js similarity index 92% rename from src/lib/merge_keyframes.js rename to src/lib/merge_frames.js index 5ec15d8e39a..6d374c82758 100644 --- a/src/lib/merge_keyframes.js +++ b/src/lib/merge_frames.js @@ -21,7 +21,7 @@ var extend = require('./extend'); * * Returns: a third object with the merged content */ -module.exports = function mergeKeyframes(target, source) { +module.exports = function mergeFrames(target, source) { var result; result = extend.extendDeep({}, target); diff --git a/test/jasmine/tests/extend_test.js b/test/jasmine/tests/extend_test.js index e74e29c3edc..619f984e1c1 100644 --- a/test/jasmine/tests/extend_test.js +++ b/test/jasmine/tests/extend_test.js @@ -2,6 +2,7 @@ var extendModule = require('@src/lib/extend.js'); var extendFlat = extendModule.extendFlat; var extendDeep = extendModule.extendDeep; var extendDeepAll = extendModule.extendDeepAll; +var extendDeepNoArrays = extendModule.extendDeepNoArrays; var str = 'me a test', integer = 10, @@ -418,3 +419,23 @@ describe('extendDeepAll', function() { expect(ori.arr[2]).toBe(undefined); }); }); + +describe('extendDeepNoArrays', function() { + 'use strict'; + + it('does not copy arrays', function() { + var src = {foo: {bar: [1, 2, 3], baz: [5, 4, 3]}}; + var tar = {foo: {bar: [4, 5, 6], bop: [8, 2, 1]}}; + var ext = extendDeepNoArrays(tar, src); + + expect(ext).not.toBe(src); + expect(ext).toBe(tar); + + expect(ext.foo).not.toBe(src.foo); + expect(ext.foo).toBe(tar.foo); + + expect(ext.foo.bar).toBe(src.foo.bar); + expect(ext.foo.baz).toBe(src.foo.baz); + expect(ext.foo.bop).toBe(tar.foo.bop); + }); +}); diff --git a/test/jasmine/tests/lib_test.js b/test/jasmine/tests/lib_test.js index b736d31a73e..f1d98121f3e 100644 --- a/test/jasmine/tests/lib_test.js +++ b/test/jasmine/tests/lib_test.js @@ -475,6 +475,69 @@ describe('Test lib.js:', function() { }); }); + describe('expandObjectPaths', function() { + it('returns the original object', function() { + var x = {}; + expect(Lib.expandObjectPaths(x)).toBe(x); + }); + + it('unpacks top-level paths', function() { + var input = {'marker.color': 'red', 'marker.size': [1, 2, 3]}; + var expected = {marker: {color: 'red', size: [1, 2, 3]}}; + expect(Lib.expandObjectPaths(input)).toEqual(expected); + }); + + it('unpacks recursively', function() { + var input = {'marker.color': {'red.certainty': 'definitely'}}; + var expected = {marker: {color: {red: {certainty: 'definitely'}}}}; + expect(Lib.expandObjectPaths(input)).toEqual(expected); + }); + + it('unpacks deep paths', function() { + var input = {'foo.bar.baz': 'red'}; + var expected = {foo: {bar: {baz: 'red'}}}; + expect(Lib.expandObjectPaths(input)).toEqual(expected); + }); + + it('unpacks non-top-level deep paths', function() { + var input = {color: {'foo.bar.baz': 'red'}}; + var expected = {color: {foo: {bar: {baz: 'red'}}}}; + expect(Lib.expandObjectPaths(input)).toEqual(expected); + }); + + it('merges dotted properties into objects', function() { + var input = {marker: {color: 'red'}, 'marker.size': 8}; + var expected = {marker: {color: 'red', size: 8}}; + expect(Lib.expandObjectPaths(input)).toEqual(expected); + }); + + it('merges objects into dotted properties', function() { + var input = {'marker.size': 8, marker: {color: 'red'}}; + var expected = {marker: {color: 'red', size: 8}}; + expect(Lib.expandObjectPaths(input)).toEqual(expected); + }); + + it('retains the identity of nested objects', function() { + var input = {marker: {size: 8}}; + var origNested = input.marker; + var expanded = Lib.expandObjectPaths(input); + var newNested = expanded.marker; + + expect(input).toBe(expanded); + expect(origNested).toBe(newNested); + }); + + it('retains the identity of nested arrays', function() { + var input = {'marker.size': [1, 2, 3]}; + var origArray = input['marker.size']; + var expanded = Lib.expandObjectPaths(input); + var newArray = expanded.marker.size; + + expect(input).toBe(expanded); + expect(origArray).toBe(newArray); + }); + }); + describe('coerce', function() { var coerce = Lib.coerce, out; diff --git a/test/jasmine/tests/keyframe_computation.js b/test/jasmine/tests/merge_frame_test.js similarity index 77% rename from test/jasmine/tests/keyframe_computation.js rename to test/jasmine/tests/merge_frame_test.js index 429b52f81cd..76796135dd2 100644 --- a/test/jasmine/tests/keyframe_computation.js +++ b/test/jasmine/tests/merge_frame_test.js @@ -1,12 +1,12 @@ -var mergeKeyframes = require('@src/lib/merge_keyframes'); +var mergeFrames = require('@src/lib/merge_frames'); -describe('Test mergeKeyframes', function() { +describe('Test mergeFrames', function() { 'use strict'; it('returns a new object', function() { var f1 = {}; var f2 = {}; - var result = mergeKeyframes(f1, f2); + var result = mergeFrames(f1, f2); expect(result).toEqual({}); expect(result).not.toBe(f1); expect(result).not.toBe(f2); @@ -15,7 +15,7 @@ describe('Test mergeKeyframes', function() { it('overrides properties of target with those of source', function() { var tar = {xaxis: {range: [0, 1]}}; var src = {xaxis: {range: [3, 4]}}; - var out = mergeKeyframes(tar, src); + var out = mergeFrames(tar, src); expect(out).toEqual({xaxis: {range: [3, 4]}}); }); @@ -23,7 +23,7 @@ describe('Test mergeKeyframes', function() { it('merges dotted properties', function() { var tar = {}; var src = {'xaxis.range': [0, 1]}; - var out = mergeKeyframes(tar, src); + var out = mergeFrames(tar, src); expect(out).toEqual({'xaxis.range': [0, 1]}); }); @@ -32,7 +32,7 @@ describe('Test mergeKeyframes', function() { it('xaxis.range', function() { var tar = {xaxis: {range: [0, 1]}}; var src = {'xaxis.range': [3, 4]}; - var out = mergeKeyframes(tar, src); + var out = mergeFrames(tar, src); expect(out).toEqual({xaxis: {range: [3, 4]}}); }); @@ -40,7 +40,7 @@ describe('Test mergeKeyframes', function() { it('xaxis.range.0', function() { var tar = {xaxis: {range: [0, 1]}}; var src = {'xaxis.range.0': 3}; - var out = mergeKeyframes(tar, src); + var out = mergeFrames(tar, src); expect(out).toEqual({xaxis: {range: [3, 1]}}); }); From 42467ec25e5640bf846619520486d1f349b7a545 Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Wed, 6 Jul 2016 16:46:49 -0400 Subject: [PATCH 07/82] Frame merging logic --- src/lib/index.js | 6 +- src/lib/merge_frames.js | 31 ---- src/plots/plots.js | 83 ++++++++++ test/jasmine/tests/compute_frame_test.js | 185 +++++++++++++++++++++++ test/jasmine/tests/merge_frame_test.js | 48 ------ 5 files changed, 271 insertions(+), 82 deletions(-) delete mode 100644 src/lib/merge_frames.js create mode 100644 test/jasmine/tests/compute_frame_test.js delete mode 100644 test/jasmine/tests/merge_frame_test.js diff --git a/src/lib/index.js b/src/lib/index.js index bed7348900f..7b1427b3728 100644 --- a/src/lib/index.js +++ b/src/lib/index.js @@ -546,10 +546,10 @@ lib.objectFromPath = function(path, value) { * Iterate through an object in-place, converting dotted properties to objects. * * @example - * lib.expandObjectPaths('nested.test[2].path', 'value'); - * // returns { nested: { test: [null, null, { path: 'value' }]} - * + * lib.expandObjectPaths({'nested.test.path': 'value'}); + * // returns { nested: { test: {path: 'value'}}} */ + // Store this to avoid recompiling regex on every prop since this may happen many // many times for animations. // TODO: Premature optimization? Remove? diff --git a/src/lib/merge_frames.js b/src/lib/merge_frames.js deleted file mode 100644 index 6d374c82758..00000000000 --- a/src/lib/merge_frames.js +++ /dev/null @@ -1,31 +0,0 @@ -/** -* Copyright 2012-2016, 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 extend = require('./extend'); - -/* - * Merge two keyframe specifications, returning in a third object that - * can be used for plotting. - * - * @param {object} target - * An object with data, layout, and trace data - * @param {object} source - * An object with data, layout, and trace data - * - * Returns: a third object with the merged content - */ -module.exports = function mergeFrames(target, source) { - var result; - - result = extend.extendDeep({}, target); - result = extend.extendDeep(result, source); - - return result; -}; diff --git a/src/plots/plots.js b/src/plots/plots.js index 0373af1497c..0086a38b723 100644 --- a/src/plots/plots.js +++ b/src/plots/plots.js @@ -1181,3 +1181,86 @@ plots.modifyFrames = function(gd, operations) { return Promise.resolve(); }; + +/* + * Compute a keyframe. Merge a keyframe into its base frame(s) and + * expand properties. + * + * @param {object} frame + * The keyframe to be computed + * + * Returns: a third object with the merged content + */ +plots.computeFrame = function(gd, frameName) { + var i, traceIndices, traceIndex, expandedObj, destIndex; + var _hash = gd._frameData._frameHash; + + var framePtr = _hash[frameName]; + + // Return false if the name is invalid: + if(!framePtr) { + return false; + } + + var frameStack = [framePtr]; + var frameNameStack = [framePtr.name]; + + // Follow frame pointers: + while((framePtr = gd._frameData._frameHash[framePtr.baseFrame])) { + // Avoid infinite loops: + if(frameNameStack.indexOf(framePtr.name) !== -1) break; + + frameStack.push(framePtr); + frameNameStack.push(framePtr.name); + } + + // A new object for the merged result: + var result = {}; + + // Merge, starting with the last and ending with the desired frame: + while((framePtr = frameStack.pop())) { + if(framePtr.layout) { + expandedObj = Lib.expandObjectPaths(framePtr.layout); + result.layout = Lib.extendDeepNoArrays(result.layout || {}, expandedObj); + } + + if(framePtr.data) { + if(!result.data) { + result.data = []; + } + traceIndices = framePtr.traceIndices; + + if(!traceIndices) { + // If not defined, assume serial order starting at zero + traceIndices = []; + for(i = 0; i < framePtr.data.length; i++) { + traceIndices[i] = i; + } + } + + if(!result.traceIndices) { + result.traceIndices = []; + } + + for(i = 0; i < framePtr.data.length; i++) { + // Loop through this frames data, find out where it should go, + // and merge it! + traceIndex = traceIndices[i]; + if(traceIndex === undefined || traceIndex === null) { + continue; + } + + destIndex = result.traceIndices.indexOf(traceIndex); + if(destIndex === -1) { + destIndex = result.data.length; + result.traceIndices[destIndex] = traceIndex; + } + + expandedObj = Lib.expandObjectPaths(framePtr.data[i]); + result.data[destIndex] = Lib.extendDeepNoArrays(result.data[destIndex] || {}, expandedObj); + } + } + } + + return result; +}; diff --git a/test/jasmine/tests/compute_frame_test.js b/test/jasmine/tests/compute_frame_test.js new file mode 100644 index 00000000000..cb2972d3af8 --- /dev/null +++ b/test/jasmine/tests/compute_frame_test.js @@ -0,0 +1,185 @@ +var Plotly = require('@lib/index'); + +var createGraphDiv = require('../assets/create_graph_div'); +var destroyGraphDiv = require('../assets/destroy_graph_div'); +var computeFrame = require('@src/plots/plots').computeFrame; + +describe('Test mergeFrames', function() { + 'use strict'; + + var gd, mock; + + beforeEach(function(done) { + mock = [{x: [1, 2, 3], y: [2, 1, 3]}, {x: [1, 2, 3], y: [6, 4, 5]}]; + gd = createGraphDiv(); + Plotly.plot(gd, mock).then(done); + }); + + afterEach(destroyGraphDiv); + + describe('computing a single frame', function() { + var frame1; + + beforeEach(function(done) { + frame1 = { + name: 'frame1', + data: [{'marker.size': 8, marker: {color: 'red'}}] + }; + + Plotly.addFrames(gd, [frame1]).then(done); + }); + + it('returns false if the frame does not exist', function() { + expect(computeFrame(gd, 'frame8')).toBe(false); + }); + + it('returns a new object', function() { + expect(computeFrame(gd, 'frame1')).not.toBe(frame1); + }); + + it('computes a single frame', function() { + var computed = computeFrame(gd, 'frame1'); + var expected = {data: [{marker: {size: 8, color: 'red'}}], traceIndices: [0]}; + expect(computed).toEqual(expected); + }); + }); + + describe('circularly defined frames', function() { + var frames, results; + + beforeEach(function(done) { + frames = [ + {name: 'frame0', baseFrame: 'frame1', data: [{'marker.size': 0}]}, + {name: 'frame1', baseFrame: 'frame2', data: [{'marker.size': 1}]}, + {name: 'frame2', baseFrame: 'frame0', data: [{'marker.size': 2}]} + ]; + + results = [ + {traceIndices: [0], data: [{marker: {size: 0}}]}, + {traceIndices: [0], data: [{marker: {size: 1}}]}, + {traceIndices: [0], data: [{marker: {size: 2}}]} + ]; + + Plotly.addFrames(gd, frames).then(done); + }); + + function doTest(i) { + it('avoid infinite recursion (starting point = ' + i + ')', function() { + var result = computeFrame(gd, 'frame' + i); + expect(result).toEqual(results[i]); + }); + } + + for(var ii = 0; ii < 3; ii++) { + doTest(ii); + } + }); + + describe('computing trace data', function() { + var frames; + + beforeEach(function(done) { + frames = [{ + name: 'frame0', + data: [{'marker.size': 0}], + traceIndices: [2] + }, { + name: 'frame1', + data: [{'marker.size': 1}], + traceIndices: [8] + }, { + name: 'frame2', + data: [{'marker.size': 2}], + traceIndices: [2] + }, { + name: 'frame3', + data: [{'marker.size': 3}, {'marker.size': 4}], + traceIndices: [2, 8] + }, { + name: 'frame4', + data: [ + {'marker.size': 5}, + {'marker.size': 6}, + {'marker.size': 7} + ] + }]; + + Plotly.addFrames(gd, frames).then(done); + }); + + it('merges orthogonal traces', function() { + frames[0].baseFrame = frames[1].name; + var result = computeFrame(gd, 'frame0'); + expect(result).toEqual({ + traceIndices: [8, 2], + data: [ + {marker: {size: 1}}, + {marker: {size: 0}} + ] + }); + }); + + it('merges overlapping traces', function() { + frames[0].baseFrame = frames[2].name; + var result = computeFrame(gd, 'frame0'); + expect(result).toEqual({ + traceIndices: [2], + data: [{marker: {size: 0}}] + }); + }); + + it('merges partially overlapping traces', function() { + frames[0].baseFrame = frames[1].name; + frames[1].baseFrame = frames[2].name; + frames[2].baseFrame = frames[3].name; + var result = computeFrame(gd, 'frame0'); + expect(result).toEqual({ + traceIndices: [2, 8], + data: [ + {marker: {size: 0}}, + {marker: {size: 1}} + ] + }); + }); + + it('assumes serial order without traceIndices specified', function() { + frames[4].baseFrame = frames[3].name; + var result = computeFrame(gd, 'frame4'); + expect(result).toEqual({ + traceIndices: [2, 8, 0, 1], + data: [ + {marker: {size: 7}}, + {marker: {size: 4}}, + {marker: {size: 5}}, + {marker: {size: 6}} + ] + }); + }); + }); + + describe('computing trace layout', function() { + var frames; + + beforeEach(function(done) { + frames = [{ + name: 'frame0', + layout: {'margin.l': 40} + }, { + name: 'frame1', + layout: {'margin.l': 80} + }]; + + Plotly.addFrames(gd, frames).then(done); + }); + + it('merges layouts', function() { + frames[0].baseFrame = frames[1].name; + var result = computeFrame(gd, 'frame0'); + + expect(result).toEqual({ + layout: {margin: {l: 40}} + }); + }); + + }); +}); diff --git a/test/jasmine/tests/merge_frame_test.js b/test/jasmine/tests/merge_frame_test.js deleted file mode 100644 index 76796135dd2..00000000000 --- a/test/jasmine/tests/merge_frame_test.js +++ /dev/null @@ -1,48 +0,0 @@ -var mergeFrames = require('@src/lib/merge_frames'); - -describe('Test mergeFrames', function() { - 'use strict'; - - it('returns a new object', function() { - var f1 = {}; - var f2 = {}; - var result = mergeFrames(f1, f2); - expect(result).toEqual({}); - expect(result).not.toBe(f1); - expect(result).not.toBe(f2); - }); - - it('overrides properties of target with those of source', function() { - var tar = {xaxis: {range: [0, 1]}}; - var src = {xaxis: {range: [3, 4]}}; - var out = mergeFrames(tar, src); - - expect(out).toEqual({xaxis: {range: [3, 4]}}); - }); - - it('merges dotted properties', function() { - var tar = {}; - var src = {'xaxis.range': [0, 1]}; - var out = mergeFrames(tar, src); - - expect(out).toEqual({'xaxis.range': [0, 1]}); - }); - - describe('assimilating dotted properties', function() { - it('xaxis.range', function() { - var tar = {xaxis: {range: [0, 1]}}; - var src = {'xaxis.range': [3, 4]}; - var out = mergeFrames(tar, src); - - expect(out).toEqual({xaxis: {range: [3, 4]}}); - }); - - it('xaxis.range.0', function() { - var tar = {xaxis: {range: [0, 1]}}; - var src = {'xaxis.range.0': 3}; - var out = mergeFrames(tar, src); - - expect(out).toEqual({xaxis: {range: [3, 1]}}); - }); - }); -}); From 054221356ab314b1acb4b81e052838b4335fe17f Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Thu, 7 Jul 2016 13:00:03 -0400 Subject: [PATCH 08/82] Clean up object identity sloppiness in frame computation --- src/plots/plots.js | 8 ++- test/jasmine/tests/compute_frame_test.js | 85 +++++++++++++++++++----- 2 files changed, 73 insertions(+), 20 deletions(-) diff --git a/src/plots/plots.js b/src/plots/plots.js index 0086a38b723..81b36f62b6c 100644 --- a/src/plots/plots.js +++ b/src/plots/plots.js @@ -1192,7 +1192,7 @@ plots.modifyFrames = function(gd, operations) { * Returns: a third object with the merged content */ plots.computeFrame = function(gd, frameName) { - var i, traceIndices, traceIndex, expandedObj, destIndex; + var i, traceIndices, traceIndex, expandedObj, destIndex, copy; var _hash = gd._frameData._frameHash; var framePtr = _hash[frameName]; @@ -1220,7 +1220,8 @@ plots.computeFrame = function(gd, frameName) { // Merge, starting with the last and ending with the desired frame: while((framePtr = frameStack.pop())) { if(framePtr.layout) { - expandedObj = Lib.expandObjectPaths(framePtr.layout); + copy = Lib.extendDeepNoArrays({}, framePtr.layout); + expandedObj = Lib.expandObjectPaths(copy); result.layout = Lib.extendDeepNoArrays(result.layout || {}, expandedObj); } @@ -1256,7 +1257,8 @@ plots.computeFrame = function(gd, frameName) { result.traceIndices[destIndex] = traceIndex; } - expandedObj = Lib.expandObjectPaths(framePtr.data[i]); + copy = Lib.extendDeepNoArrays({}, framePtr.data[i]); + expandedObj = Lib.expandObjectPaths(copy); result.data[destIndex] = Lib.extendDeepNoArrays(result.data[destIndex] || {}, expandedObj); } } diff --git a/test/jasmine/tests/compute_frame_test.js b/test/jasmine/tests/compute_frame_test.js index cb2972d3af8..e2d6b026c96 100644 --- a/test/jasmine/tests/compute_frame_test.js +++ b/test/jasmine/tests/compute_frame_test.js @@ -1,9 +1,14 @@ var Plotly = require('@lib/index'); +var Lib = require('@src/lib'); var createGraphDiv = require('../assets/create_graph_div'); var destroyGraphDiv = require('../assets/destroy_graph_div'); var computeFrame = require('@src/plots/plots').computeFrame; +function clone(obj) { + return Lib.extendDeep({}, obj); +} + describe('Test mergeFrames', function() { 'use strict'; @@ -18,15 +23,20 @@ describe('Test mergeFrames', function() { afterEach(destroyGraphDiv); describe('computing a single frame', function() { - var frame1; + var frame1, input; beforeEach(function(done) { frame1 = { name: 'frame1', - data: [{'marker.size': 8, marker: {color: 'red'}}] + data: [{ + x: [1, 2, 3], + 'marker.size': 8, + marker: {color: 'red'} + }] }; - Plotly.addFrames(gd, [frame1]).then(done); + input = clone(frame1); + Plotly.addFrames(gd, [input]).then(done); }); it('returns false if the frame does not exist', function() { @@ -34,14 +44,31 @@ describe('Test mergeFrames', function() { }); it('returns a new object', function() { - expect(computeFrame(gd, 'frame1')).not.toBe(frame1); + var result = computeFrame(gd, 'frame1'); + expect(result).not.toBe(input); + }); + + it('copies objects', function() { + var result = computeFrame(gd, 'frame1'); + expect(result.data).not.toBe(input.data); + expect(result.data[0].marker).not.toBe(input.data[0].marker); + }); + + it('does NOT copy arrays', function() { + var result = computeFrame(gd, 'frame1'); + expect(result.data[0].x).toBe(input.data[0].x); }); it('computes a single frame', function() { var computed = computeFrame(gd, 'frame1'); - var expected = {data: [{marker: {size: 8, color: 'red'}}], traceIndices: [0]}; + var expected = {data: [{x: [1, 2, 3], marker: {size: 8, color: 'red'}}], traceIndices: [0]}; expect(computed).toEqual(expected); }); + + it('leaves the frame unaffected', function() { + computeFrame(gd, 'frame1'); + expect(gd._frameData._frameHash.frame1).toEqual(frame1); + }); }); describe('circularly defined frames', function() { @@ -78,7 +105,7 @@ describe('Test mergeFrames', function() { describe('computing trace data', function() { var frames; - beforeEach(function(done) { + beforeEach(function() { frames = [{ name: 'frame0', data: [{'marker.size': 0}], @@ -103,49 +130,65 @@ describe('Test mergeFrames', function() { {'marker.size': 7} ] }]; - - Plotly.addFrames(gd, frames).then(done); }); it('merges orthogonal traces', function() { frames[0].baseFrame = frames[1].name; - var result = computeFrame(gd, 'frame0'); - expect(result).toEqual({ + + // This technically returns a promise, but it's not actually asynchronous so + // that we'll just keep this synchronous: + Plotly.addFrames(gd, frames.map(clone)); + + expect(computeFrame(gd, 'frame0')).toEqual({ traceIndices: [8, 2], data: [ {marker: {size: 1}}, {marker: {size: 0}} ] }); + + // Verify that the frames are untouched (by value, at least, but they should + // also be unmodified by identity too) by the computation: + expect(gd._frameData._frames).toEqual(frames); }); it('merges overlapping traces', function() { frames[0].baseFrame = frames[2].name; - var result = computeFrame(gd, 'frame0'); - expect(result).toEqual({ + + Plotly.addFrames(gd, frames.map(clone)); + + expect(computeFrame(gd, 'frame0')).toEqual({ traceIndices: [2], data: [{marker: {size: 0}}] }); + + expect(gd._frameData._frames).toEqual(frames); }); it('merges partially overlapping traces', function() { frames[0].baseFrame = frames[1].name; frames[1].baseFrame = frames[2].name; frames[2].baseFrame = frames[3].name; - var result = computeFrame(gd, 'frame0'); - expect(result).toEqual({ + + Plotly.addFrames(gd, frames.map(clone)); + + expect(computeFrame(gd, 'frame0')).toEqual({ traceIndices: [2, 8], data: [ {marker: {size: 0}}, {marker: {size: 1}} ] }); + + expect(gd._frameData._frames).toEqual(frames); }); it('assumes serial order without traceIndices specified', function() { frames[4].baseFrame = frames[3].name; - var result = computeFrame(gd, 'frame4'); - expect(result).toEqual({ + + Plotly.addFrames(gd, frames.map(clone)); + + expect(computeFrame(gd, 'frame4')).toEqual({ traceIndices: [2, 8, 0, 1], data: [ {marker: {size: 7}}, @@ -154,11 +197,13 @@ describe('Test mergeFrames', function() { {marker: {size: 6}} ] }); + + expect(gd._frameData._frames).toEqual(frames); }); }); describe('computing trace layout', function() { - var frames; + var frames, frameCopies; beforeEach(function(done) { frames = [{ @@ -169,6 +214,8 @@ describe('Test mergeFrames', function() { layout: {'margin.l': 80} }]; + frameCopies = frames.map(clone); + Plotly.addFrames(gd, frames).then(done); }); @@ -181,5 +228,9 @@ describe('Test mergeFrames', function() { }); }); + it('leaves the frame unaffected', function() { + computeFrame(gd, 'frame0'); + expect(gd._frameData._frames).toEqual(frameCopies); + }); }); }); From cdfec13bdc54037158e8945cfc84c8343176f944 Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Thu, 7 Jul 2016 13:08:52 -0400 Subject: [PATCH 09/82] Remove dependence of frame logic on gd --- src/lib/index.js | 86 ++++++++++++++++++++++++++++++++++++++++++++++ src/plots/plots.js | 80 +++--------------------------------------- 2 files changed, 90 insertions(+), 76 deletions(-) diff --git a/src/lib/index.js b/src/lib/index.js index 7b1427b3728..962d138000d 100644 --- a/src/lib/index.js +++ b/src/lib/index.js @@ -619,3 +619,89 @@ lib.numSeparate = function(value, separators) { return x1 + x2; }; + +/* + * Compute a keyframe. Merge a keyframe into its base frame(s) and + * expand properties. + * + * @param {object} frameLookup + * An object containing frames keyed by name (i.e. gd._frameData._frameHash) + * @param {string} frame + * The name of the keyframe to be computed + * + * Returns: a new object with the merged content + */ +lib.computeFrame = function(frameLookup, frameName) { + var i, traceIndices, traceIndex, expandedObj, destIndex, copy; + + var framePtr = frameLookup[frameName]; + + // Return false if the name is invalid: + if(!framePtr) { + return false; + } + + var frameStack = [framePtr]; + var frameNameStack = [framePtr.name]; + + // Follow frame pointers: + while((framePtr = frameLookup[framePtr.baseFrame])) { + // Avoid infinite loops: + if(frameNameStack.indexOf(framePtr.name) !== -1) break; + + frameStack.push(framePtr); + frameNameStack.push(framePtr.name); + } + + // A new object for the merged result: + var result = {}; + + // Merge, starting with the last and ending with the desired frame: + while((framePtr = frameStack.pop())) { + if(framePtr.layout) { + copy = lib.extendDeepNoArrays({}, framePtr.layout); + expandedObj = lib.expandObjectPaths(copy); + result.layout = lib.extendDeepNoArrays(result.layout || {}, expandedObj); + } + + if(framePtr.data) { + if(!result.data) { + result.data = []; + } + traceIndices = framePtr.traceIndices; + + if(!traceIndices) { + // If not defined, assume serial order starting at zero + traceIndices = []; + for(i = 0; i < framePtr.data.length; i++) { + traceIndices[i] = i; + } + } + + if(!result.traceIndices) { + result.traceIndices = []; + } + + for(i = 0; i < framePtr.data.length; i++) { + // Loop through this frames data, find out where it should go, + // and merge it! + traceIndex = traceIndices[i]; + if(traceIndex === undefined || traceIndex === null) { + continue; + } + + destIndex = result.traceIndices.indexOf(traceIndex); + if(destIndex === -1) { + destIndex = result.data.length; + result.traceIndices[destIndex] = traceIndex; + } + + copy = lib.extendDeepNoArrays({}, framePtr.data[i]); + expandedObj = lib.expandObjectPaths(copy); + result.data[destIndex] = lib.extendDeepNoArrays(result.data[destIndex] || {}, expandedObj); + } + } + } + + return result; +}; diff --git a/src/plots/plots.js b/src/plots/plots.js index 81b36f62b6c..6bcb36747c4 100644 --- a/src/plots/plots.js +++ b/src/plots/plots.js @@ -1186,83 +1186,11 @@ plots.modifyFrames = function(gd, operations) { * Compute a keyframe. Merge a keyframe into its base frame(s) and * expand properties. * - * @param {object} frame - * The keyframe to be computed + * @param {string} frame + * The name of the keyframe to be computed * - * Returns: a third object with the merged content + * Returns: a new object with the merged content */ plots.computeFrame = function(gd, frameName) { - var i, traceIndices, traceIndex, expandedObj, destIndex, copy; - var _hash = gd._frameData._frameHash; - - var framePtr = _hash[frameName]; - - // Return false if the name is invalid: - if(!framePtr) { - return false; - } - - var frameStack = [framePtr]; - var frameNameStack = [framePtr.name]; - - // Follow frame pointers: - while((framePtr = gd._frameData._frameHash[framePtr.baseFrame])) { - // Avoid infinite loops: - if(frameNameStack.indexOf(framePtr.name) !== -1) break; - - frameStack.push(framePtr); - frameNameStack.push(framePtr.name); - } - - // A new object for the merged result: - var result = {}; - - // Merge, starting with the last and ending with the desired frame: - while((framePtr = frameStack.pop())) { - if(framePtr.layout) { - copy = Lib.extendDeepNoArrays({}, framePtr.layout); - expandedObj = Lib.expandObjectPaths(copy); - result.layout = Lib.extendDeepNoArrays(result.layout || {}, expandedObj); - } - - if(framePtr.data) { - if(!result.data) { - result.data = []; - } - traceIndices = framePtr.traceIndices; - - if(!traceIndices) { - // If not defined, assume serial order starting at zero - traceIndices = []; - for(i = 0; i < framePtr.data.length; i++) { - traceIndices[i] = i; - } - } - - if(!result.traceIndices) { - result.traceIndices = []; - } - - for(i = 0; i < framePtr.data.length; i++) { - // Loop through this frames data, find out where it should go, - // and merge it! - traceIndex = traceIndices[i]; - if(traceIndex === undefined || traceIndex === null) { - continue; - } - - destIndex = result.traceIndices.indexOf(traceIndex); - if(destIndex === -1) { - destIndex = result.data.length; - result.traceIndices[destIndex] = traceIndex; - } - - copy = Lib.extendDeepNoArrays({}, framePtr.data[i]); - expandedObj = Lib.expandObjectPaths(copy); - result.data[destIndex] = Lib.extendDeepNoArrays(result.data[destIndex] || {}, expandedObj); - } - } - } - - return result; + return Lib.computeFrame(gd._frameData._frameHash, frameName); }; From c7be054fa54ab099cc1aa5c31812d7781b1a2e2c Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Thu, 7 Jul 2016 14:12:32 -0400 Subject: [PATCH 10/82] Really simple .animate function --- src/plot_api/plot_api.js | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index ed42ea7d4ee..6cf94042828 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -2595,17 +2595,22 @@ Plotly.transition = function(gd /*, data, layout, traces, transitionConfig*/) { * @param {object} transitionConfig * configuration for transition */ -Plotly.animate = function(gd, name /*, transitionConfig*/) { +Plotly.animate = function(gd, frameName, transitionConfig) { gd = getGraphDiv(gd); - var _frames = gd._frameData._frames; - - if(!_frames[name]) { - Lib.warn('animateToFrame failure: keyframe does not exist', name); + if(!gd._frameData._frameHash[frameName]) { + Lib.warn('animateToFrame failure: keyframe does not exist', frameName); return Promise.reject(); } - return Promise.resolve(); + var computedFrame = Plots.computeFrame(gd, frameName); + + return Plotly.transition(gd, + computedFrame.data, + computedFrame.layout, + computedFrame.traceIndices, + transitionConfig + ); }; /** From e4363a2c0212e3552c43dba5a335ad78891135e5 Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Thu, 7 Jul 2016 15:26:42 -0400 Subject: [PATCH 11/82] Add .animate tests --- src/plot_api/plot_api.js | 4 ++- test/image/mocks/animation.json | 46 ++++++++++++++++++++++++ test/jasmine/tests/animate_test.js | 56 ++++++++++++++++++++++++++++++ 3 files changed, 105 insertions(+), 1 deletion(-) create mode 100644 test/image/mocks/animation.json create mode 100644 test/jasmine/tests/animate_test.js diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index 6cf94042828..6279af17814 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -2493,9 +2493,11 @@ Plotly.relayout = function relayout(gd, astr, val) { * @param {string id or DOM element} gd * the id or DOM element of the graph container div */ -Plotly.transition = function(gd /*, data, layout, traces, transitionConfig*/) { +Plotly.transition = function(gd, data, layout, traces, transitionConfig) { gd = getGraphDiv(gd); + return Promise.resolve(); + /*var fullLayout = gd._fullLayout; transitionConfig = Lib.extendFlat({ diff --git a/test/image/mocks/animation.json b/test/image/mocks/animation.json new file mode 100644 index 00000000000..b6184c148b1 --- /dev/null +++ b/test/image/mocks/animation.json @@ -0,0 +1,46 @@ +{ + "data": [ + { + "x": [0, 1, 2], + "y": [0, 2, 8], + "type": "scatter" + }, + { + "x": [0, 1, 2], + "y": [4, 2, 3], + "type": "scatter" + } + ], + "layout": { + "title": "Animation test", + "showlegend": true, + "autosize": false, + "width": 800, + "height": 440, + "xaxis": { + "range": [0, 2], + "domain": [0, 1], + }, + "yaxis": { + "range": [0, 10], + "domain": [0, 1], + } + }, + "frames": [{ + "name": 'frame0', + "data": [ + {"y": [0, 2, 8]}, + {"y": [4, 2, 3]} + ], + "traceIndices": [0, 1], + "layout": { } + }, { + "name": 'frame1', + "data": [ + {"y": [1, 3, 9]}, + {"y": [5, 3, 4]} + ], + "traceIndices": [0, 1], + "layout": { } + }] +} diff --git a/test/jasmine/tests/animate_test.js b/test/jasmine/tests/animate_test.js new file mode 100644 index 00000000000..fe2f99c2119 --- /dev/null +++ b/test/jasmine/tests/animate_test.js @@ -0,0 +1,56 @@ +var Plotly = require('@lib/index'); +var PlotlyInternal = require('@src/plotly'); +var Lib = require('@src/lib'); + +var createGraphDiv = require('../assets/create_graph_div'); +var destroyGraphDiv = require('../assets/destroy_graph_div'); +var fail = require('../assets/fail_test'); + +describe('Test animate API', function() { + 'use strict'; + + var gd; + + beforeEach(function(done) { + gd = createGraphDiv(); + + var mock = require('@mocks/animation'); + var mockCopy = Lib.extendDeep({}, mock); + + spyOn(PlotlyInternal, 'transition').and.callFake(function() { + return Promise.resolve(); + }); + + Plotly.plot(gd, mockCopy.data, mockCopy.layout).then(function() { + Plotly.addFrames(gd, mockCopy.frames); + }).then(done); + }); + + afterEach(function() { + destroyGraphDiv(); + }); + + it('rejects if the frame is not found', function(done) { + Plotly.animate(gd, 'foobar').then(fail).then(done, done); + }); + + it('animates to a frame', function(done) { + Plotly.animate(gd, 'frame0').then(function() { + expect(PlotlyInternal.transition).toHaveBeenCalled(); + + var args = PlotlyInternal.transition.calls.mostRecent().args; + + // was called with gd, data, layout, traceIndices, transitionConfig: + expect(args.length).toEqual(5); + + // data has two traces: + expect(args[1].length).toEqual(2); + + // layout + expect(args[2]).toEqual({}); + + // traces are [0, 1]: + expect(args[3]).toEqual([0, 1]); + }).catch(fail).then(done); + }); +}); From 2fd9c26452492a567f481f5c815b8fc904024b7f Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Thu, 7 Jul 2016 17:53:04 -0400 Subject: [PATCH 12/82] Start adding back transition behavior --- src/plot_api/plot_api.js | 133 ++++++++----- src/plots/cartesian/index.js | 13 +- src/plots/cartesian/transition_axes.js | 266 +++++++++++++++++++++++++ src/traces/scatter/index.js | 1 + test/image/mocks/animation.json | 49 ++++- 5 files changed, 400 insertions(+), 62 deletions(-) create mode 100644 src/plots/cartesian/transition_axes.js diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index 6279af17814..290d3d8f247 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -2493,12 +2493,11 @@ Plotly.relayout = function relayout(gd, astr, val) { * @param {string id or DOM element} gd * the id or DOM element of the graph container div */ -Plotly.transition = function(gd, data, layout, traces, transitionConfig) { +Plotly.transition = function(gd, data, layout, traceIndices, transitionConfig) { gd = getGraphDiv(gd); - return Promise.resolve(); - - /*var fullLayout = gd._fullLayout; + var i, value, traceIdx; + var fullLayout = gd._fullLayout; transitionConfig = Lib.extendFlat({ ease: 'cubic-in-out', @@ -2517,16 +2516,16 @@ Plotly.transition = function(gd, data, layout, traces, transitionConfig) { } // Select which traces will be updated: - if(isNumeric(traces)) traces = [traces]; - else if(!Array.isArray(traces) || !traces.length) { - traces = gd._fullData.map(function(v, i) {return i;}); + if(isNumeric(traceIndices)) traceIndices = [traceIndices]; + else if(!Array.isArray(traceIndices) || !traceIndices.length) { + traceIndices = gd._fullData.map(function(v, i) {return i;}); } - var transitioningTraces = []; + var animatedTraces = []; - function prepareAnimations() { - for(i = 0; i < traces.length; i++) { - var traceIdx = traces[i]; + function prepareTransitions() { + for(i = 0; i < traceIndices.length; i++) { + var traceIdx = traceIndices[i]; var trace = gd._fullData[traceIdx]; var module = trace._module; @@ -2534,59 +2533,95 @@ Plotly.transition = function(gd, data, layout, traces, transitionConfig) { continue; } - transitioningTraces.push(traceIdx); + animatedTraces.push(traceIdx); - newTraceData = newData[i]; - curData = gd.data[traces[i]]; - - for(var ai in newTraceData) { - var value = newTraceData[ai]; - Lib.nestedProperty(curData, ai).set(value); - } - - var traceIdx = traces[i]; - if(gd.data[traceIdx].marker && gd.data[traceIdx].marker.size) { - gd._fullData[traceIdx].marker.size = gd.data[traceIdx].marker.size; - } - if(gd.data[traceIdx].error_y && gd.data[traceIdx].error_y.array) { - gd._fullData[traceIdx].error_y.array = gd.data[traceIdx].error_y.array; - } - if(gd.data[traceIdx].error_x && gd.data[traceIdx].error_x.array) { - gd._fullData[traceIdx].error_x.array = gd.data[traceIdx].error_x.array; - } - gd._fullData[traceIdx].x = gd.data[traceIdx].x; - gd._fullData[traceIdx].y = gd.data[traceIdx].y; - gd._fullData[traceIdx].z = gd.data[traceIdx].z; - gd._fullData[traceIdx].key = gd.data[traceIdx].key; + Lib.extendDeepNoArrays(gd.data[traceIndices[i]], data[i]); } - doCalcdata(gd, transitioningTraces); + Plots.supplyDefaults(gd) + + // doCalcdata(gd, animatedTraces); + doCalcdata(gd); ErrorBars.calc(gd); } - function doAnimations() { - var a, i, j; + var restyleList = []; + var relayoutList = []; + var completionTimeout = null; + var completion = null; + + function executeTransitions () { + var j; var basePlotModules = fullLayout._basePlotModules; - for(j = 0; j < basePlotModules.length; j++) { - basePlotModules[j].plot(gd, transitioningTraces, transitionOpts); + for (j = 0; j < basePlotModules.length; j++) { + basePlotModules[j].plot(gd, animatedTraces, transitionConfig); } - if(layout) { - for(j = 0; j < basePlotModules.length; j++) { - if(basePlotModules[j].transitionAxes) { - basePlotModules[j].transitionAxes(gd, layout, transitionOpts); + if (layout) { + for (j = 0; j < basePlotModules.length; j++) { + if (basePlotModules[j].transitionAxes) { + var newLayout = Lib.extendDeep({}, gd._fullLayout, layout); + basePlotModules[j].transitionAxes(gd, newLayout, transitionConfig); } } } - if(!transitionOpts.leadingEdgeRestyle) { - return new Promise(function(resolve, reject) { - completion = resolve; - completionTimeout = setTimeout(resolve, transitionOpts.duration); - }); + return new Promise(function(resolve, reject) { + completion = resolve; + completionTimeout = setTimeout(resolve, transitionConfig.duration); + }); + } + + function interruptPreviousTransitions () { + var ret; + clearTimeout(completionTimeout); + + if (completion) { + completion(); + } + + if (gd._animationInterrupt) { + ret = gd._animationInterrupt(); + gd._animationInterrupt = null; } - }*/ + return ret; + } + + for (i = 0; i < traceIndices.length; i++) { + var traceIdx = traceIndices[i]; + var contFull = gd._fullData[traceIdx]; + var module = contFull._module; + + if (!module.animatable) { + var thisTrace = [traceIdx]; + var thisUpdate = {}; + + for (var ai in data[i]) { + thisUpdate[ai] = [data[i][ai]]; + } + + restyleList.push((function (md, data, traces) { + return function () { + return Plotly.restyle(gd, data, traces); + } + }(module, thisUpdate, [traceIdx]))); + } + } + + var seq = [Plots.previousPromises, interruptPreviousTransitions, prepareTransitions, executeTransitions]; + seq = seq.concat(restyleList); + + var plotDone = Lib.syncOrAsync(seq, gd); + + if(!plotDone || !plotDone.then) plotDone = Promise.resolve(); + + return plotDone.then(function() { + gd.emit('plotly_beginanimate', []); + return gd; + }); + + return Promise.resolve(); }; /** diff --git a/src/plots/cartesian/index.js b/src/plots/cartesian/index.js index 7ced4299776..5f113a12b7a 100644 --- a/src/plots/cartesian/index.js +++ b/src/plots/cartesian/index.js @@ -25,8 +25,10 @@ exports.attrRegex = constants.attrRegex; exports.attributes = require('./attributes'); -exports.plot = function(gd, traces) { - var cdSubplot, cd, trace, i, j, k, isFullReplot; +exports.transitionAxes = require('./transition_axes'); + +exports.plot = function(gd, traces, transitionOpts) { + var cdSubplot, cd, trace, i, j, k; var fullLayout = gd._fullLayout, subplots = Plots.getSubplotIds(fullLayout, 'cartesian'), @@ -36,15 +38,10 @@ exports.plot = function(gd, traces) { if(!Array.isArray(traces)) { // If traces is not provided, then it's a complete replot and missing // traces are removed - isFullReplot = true; traces = []; for(i = 0; i < calcdata.length; i++) { traces.push(i); } - } else { - // If traces are explicitly specified, then it's a partial replot and - // traces are not removed. - isFullReplot = false; } for(i = 0; i < subplots.length; i++) { @@ -92,7 +89,7 @@ exports.plot = function(gd, traces) { } } - _module.plot(gd, subplotInfo, cdModule, isFullReplot); + _module.plot(gd, subplotInfo, cdModule, transitionOpts); } } }; diff --git a/src/plots/cartesian/transition_axes.js b/src/plots/cartesian/transition_axes.js new file mode 100644 index 00000000000..99eefe5c699 --- /dev/null +++ b/src/plots/cartesian/transition_axes.js @@ -0,0 +1,266 @@ +/** +* Copyright 2012-2016, 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 isNumeric = require('fast-isnumeric'); + +var Plotly = require('../../plotly'); +var Lib = require('../../lib'); +var svgTextUtils = require('../../lib/svg_text_utils'); +var Titles = require('../../components/titles'); +var Color = require('../../components/color'); +var Drawing = require('../../components/drawing'); +var Axes = require('./axes'); + +var axisRegex = /((x|y)([2-9]|[1-9][0-9]+)?)axis$/; + +module.exports = function transitionAxes(gd, newLayout, transitionConfig) { + var fullLayout = gd._fullLayout; + var axes = []; + + function computeUpdates (layout) { + var ai, attrList, match, to, axis, update, i; + var updates = {}; + + for (ai in layout) { + var attrList = ai.split('.'); + var match = attrList[0].match(axisRegex); + if (match) { + var axisName = match[1]; + axis = fullLayout[axisName + 'axis']; + update = {}; + + if (Array.isArray(layout[ai])) { + update.to = layout[ai].slice(0); + } else { + if (Array.isArray(layout[ai].range)) { + update.to = layout[ai].range.slice(0); + } + } + if (!update.to) continue; + + update.axis = axis; + update.length = axis._length; + + axes.push(axisName); + + updates[axisName] = update; + } + } + + return updates; + } + + function computeAffectedSubplots (fullLayout, updatedAxisIds) { + var plotName; + var plotinfos = fullLayout._plots; + var affectedSubplots = []; + + for (plotName in plotinfos) { + var plotinfo = plotinfos[plotName]; + + if (affectedSubplots.indexOf(plotinfo) !== -1) continue; + + var x = plotinfo.xaxis._id; + var y = plotinfo.yaxis._id; + + if (updatedAxisIds.indexOf(x) !== -1 || updatedAxisIds.indexOf(y) !== -1) { + affectedSubplots.push(plotinfo); + } + } + + return affectedSubplots; + } + + var updates = computeUpdates(newLayout); + var updatedAxisIds = Object.keys(updates); + var affectedSubplots = computeAffectedSubplots(fullLayout, updatedAxisIds); + var easeFn = d3.ease(transitionConfig.ease); + + function ticksAndAnnotations(xa, ya) { + var activeAxIds = [], + i; + + activeAxIds = [xa._id, ya._id]; + + for(i = 0; i < activeAxIds.length; i++) { + Axes.doTicks(gd, activeAxIds[i], true); + } + + function redrawObjs(objArray, module) { + var obji; + for(i = 0; i < objArray.length; i++) { + obji = objArray[i]; + if((activeAxIds.indexOf(obji.xref) !== -1) || + (activeAxIds.indexOf(obji.yref) !== -1)) { + module.draw(gd, i); + } + } + } + + redrawObjs(fullLayout.annotations || [], Plotly.Annotations); + redrawObjs(fullLayout.shapes || [], Plotly.Shapes); + redrawObjs(fullLayout.images || [], Plotly.Images); + } + + function unsetSubplotTransform (subplot) { + var xa2 = subplot.x(); + var ya2 = subplot.y(); + + var viewBox = [0, 0, xa2._length, ya2._length]; + + var xScaleFactor = xa2._length / viewBox[2], + yScaleFactor = ya2._length / viewBox[3]; + + var clipDx = viewBox[0], + clipDy = viewBox[1]; + + var fracDx = (viewBox[0] / viewBox[2] * xa2._length), + fracDy = (viewBox[1] / viewBox[3] * ya2._length); + + var plotDx = xa2._offset - fracDx, + plotDy = ya2._offset - fracDy; + + fullLayout._defs.selectAll('#' + subplot.clipId) + .call(Lib.setTranslate, clipDx, clipDy) + .call(Lib.setScale, 1 / xScaleFactor, 1 / yScaleFactor); + + subplot.plot + .call(Lib.setTranslate, plotDx, plotDy) + .call(Lib.setScale, xScaleFactor, yScaleFactor); + + } + + function updateSubplot (subplot, progress) { + var axis, r0, r1; + var xUpdate = updates[subplot.xaxis._id]; + var yUpdate = updates[subplot.yaxis._id]; + + var viewBox = []; + + if (xUpdate) { + axis = xUpdate.axis; + r0 = axis._r; + r1 = xUpdate.to; + viewBox[0] = (r0[0] * (1 - progress) + progress * r1[0] - r0[0]) / (r0[1] - r0[0]) * subplot.xaxis._length; + var dx1 = r0[1] - r0[0]; + var dx2 = r1[1] - r1[0]; + + axis.range[0] = r0[0] * (1 - progress) + progress * r1[0]; + axis.range[1] = r0[1] * (1 - progress) + progress * r1[1]; + + viewBox[2] = subplot.xaxis._length * ((1 - progress) + progress * dx2 / dx1); + } else { + viewBox[0] = 0; + viewBox[2] = subplot.xaxis._length; + } + + if (yUpdate) { + axis = yUpdate.axis; + r0 = axis._r; + r1 = yUpdate.to; + viewBox[1] = (r0[1] * (1 - progress) + progress * r1[1] - r0[1]) / (r0[0] - r0[1]) * subplot.yaxis._length; + var dy1 = r0[1] - r0[0]; + var dy2 = r1[1] - r1[0]; + + axis.range[0] = r0[0] * (1 - progress) + progress * r1[0]; + axis.range[1] = r0[1] * (1 - progress) + progress * r1[1]; + + viewBox[3] = subplot.yaxis._length * ((1 - progress) + progress * dy2 / dy1); + } else { + viewBox[1] = 0; + viewBox[3] = subplot.yaxis._length; + } + + ticksAndAnnotations(subplot.x(), subplot.y()); + + + var xa2 = subplot.x(); + var ya2 = subplot.y(); + + var editX = !!xUpdate; + var editY = !!yUpdate; + + var xScaleFactor = editX ? xa2._length / viewBox[2] : 1, + yScaleFactor = editY ? ya2._length / viewBox[3] : 1; + + var clipDx = editX ? viewBox[0] : 0, + clipDy = editY ? viewBox[1] : 0; + + var fracDx = editX ? (viewBox[0] / viewBox[2] * xa2._length) : 0, + fracDy = editY ? (viewBox[1] / viewBox[3] * ya2._length) : 0; + + var plotDx = xa2._offset - fracDx, + plotDy = ya2._offset - fracDy; + + fullLayout._defs.selectAll('#' + subplot.clipId) + .call(Lib.setTranslate, clipDx, clipDy) + .call(Lib.setScale, 1 / xScaleFactor, 1 / yScaleFactor); + + subplot.plot + .call(Lib.setTranslate, plotDx, plotDy) + .call(Lib.setScale, xScaleFactor, yScaleFactor); + } + + // transitionTail - finish a drag event with a redraw + function transitionTail() { + var attrs = {}; + // revert to the previous axis settings, then apply the new ones + // through relayout - this lets relayout manage undo/redo + for(var i = 0; i < updatedAxisIds.length; i++) { + var axi = updates[updatedAxisIds[i]].axis; + if(axi._r[0] !== axi.range[0]) attrs[axi._name + '.range[0]'] = axi.range[0]; + if(axi._r[1] !== axi.range[1]) attrs[axi._name + '.range[1]'] = axi.range[1]; + + axi.range = axi._r.slice(); + } + + for (var i = 0; i < affectedSubplots.length; i++) { + unsetSubplotTransform(affectedSubplots[i]); + } + + Plotly.relayout(gd, attrs); + } + + return new Promise(function (resolve, reject) { + var t1, t2, raf; + + gd._animationInterrupt = function () { + reject(); + cancelAnimationFrame(raf); + raf = null; + transitionTail(); + }; + + function doFrame () { + t2 = Date.now(); + + var tInterp = Math.min(1, (t2 - t1) / transitionConfig.duration); + var progress = easeFn(tInterp); + + for (var i = 0; i < affectedSubplots.length; i++) { + updateSubplot(affectedSubplots[i], progress); + } + + if (t2 - t1 > transitionConfig.duration) { + raf = cancelAnimationFrame(doFrame); + transitionTail(); + resolve(); + } else { + raf = requestAnimationFrame(doFrame); + resolve(); + } + } + + t1 = Date.now(); + raf = requestAnimationFrame(doFrame); + }); +} diff --git a/src/traces/scatter/index.js b/src/traces/scatter/index.js index 3b576a561d0..5c21f7c6710 100644 --- a/src/traces/scatter/index.js +++ b/src/traces/scatter/index.js @@ -29,6 +29,7 @@ Scatter.colorbar = require('./colorbar'); Scatter.style = require('./style'); Scatter.hoverPoints = require('./hover'); Scatter.selectPoints = require('./select'); +Scatter.animatable = true; Scatter.moduleType = 'trace'; Scatter.name = 'scatter'; diff --git a/test/image/mocks/animation.json b/test/image/mocks/animation.json index b6184c148b1..5a1fe57193a 100644 --- a/test/image/mocks/animation.json +++ b/test/image/mocks/animation.json @@ -15,8 +15,6 @@ "title": "Animation test", "showlegend": true, "autosize": false, - "width": 800, - "height": 440, "xaxis": { "range": [0, 2], "domain": [0, 1], @@ -27,20 +25,61 @@ } }, "frames": [{ - "name": 'frame0', + "name": "base", "data": [ {"y": [0, 2, 8]}, {"y": [4, 2, 3]} ], + "layout": { + "xaxis": { + "range": [0, 2] + }, + "yaxis": { + "range": [0, 10] + } + } + }, { + "name": 'frame0', + "data": [ + {"y": [0.5, 1.5, 7.5]}, + {"y": [4.25, 2.25, 3.05]} + ], + "baseFrame": "base", "traceIndices": [0, 1], "layout": { } }, { "name": 'frame1', "data": [ - {"y": [1, 3, 9]}, - {"y": [5, 3, 4]} + {"y": [2.1, 1, 7]}, + {"y": [4.5, 2.5, 3.1]} + ], + "baseFrame": "base", + "traceIndices": [0, 1], + "layout": { } + }, { + "name": 'frame2', + "data": [ + {"y": [3.5, 0.5, 6]}, + {"y": [5.7, 2.7, 3.9]} ], + "baseFrame": "base", "traceIndices": [0, 1], "layout": { } + }, { + "name": 'frame3', + "data": [ + {"y": [5.1, 0.25, 5]}, + {"y": [7, 2.9, 6]} + ], + "baseFrame": "base", + "traceIndices": [0, 1], + "layout": { + "xaxis": { + "range": [-1, 4] + }, + "yaxis": { + "range": [-5, 15] + } + } }] } From b4a553ba76ca330c6dee0085b96524046f554b80 Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Fri, 8 Jul 2016 10:16:43 -0400 Subject: [PATCH 13/82] Fix animation tests; lint --- src/plot_api/plot_api.js | 40 +++++++++--------- src/plots/cartesian/transition_axes.js | 56 ++++++++++++-------------- test/jasmine/tests/animate_test.js | 5 ++- 3 files changed, 48 insertions(+), 53 deletions(-) diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index 290d3d8f247..dff036b496a 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -2496,7 +2496,7 @@ Plotly.relayout = function relayout(gd, astr, val) { Plotly.transition = function(gd, data, layout, traceIndices, transitionConfig) { gd = getGraphDiv(gd); - var i, value, traceIdx; + var i, traceIdx; var fullLayout = gd._fullLayout; transitionConfig = Lib.extendFlat({ @@ -2538,7 +2538,7 @@ Plotly.transition = function(gd, data, layout, traceIndices, transitionConfig) { Lib.extendDeepNoArrays(gd.data[traceIndices[i]], data[i]); } - Plots.supplyDefaults(gd) + Plots.supplyDefaults(gd); // doCalcdata(gd, animatedTraces); doCalcdata(gd); @@ -2547,64 +2547,62 @@ Plotly.transition = function(gd, data, layout, traceIndices, transitionConfig) { } var restyleList = []; - var relayoutList = []; var completionTimeout = null; var completion = null; - function executeTransitions () { + function executeTransitions() { var j; var basePlotModules = fullLayout._basePlotModules; - for (j = 0; j < basePlotModules.length; j++) { + for(j = 0; j < basePlotModules.length; j++) { basePlotModules[j].plot(gd, animatedTraces, transitionConfig); } - if (layout) { - for (j = 0; j < basePlotModules.length; j++) { - if (basePlotModules[j].transitionAxes) { + if(layout) { + for(j = 0; j < basePlotModules.length; j++) { + if(basePlotModules[j].transitionAxes) { var newLayout = Lib.extendDeep({}, gd._fullLayout, layout); basePlotModules[j].transitionAxes(gd, newLayout, transitionConfig); } } } - return new Promise(function(resolve, reject) { + return new Promise(function(resolve) { completion = resolve; completionTimeout = setTimeout(resolve, transitionConfig.duration); }); } - function interruptPreviousTransitions () { + function interruptPreviousTransitions() { var ret; clearTimeout(completionTimeout); - if (completion) { + if(completion) { completion(); } - if (gd._animationInterrupt) { + if(gd._animationInterrupt) { ret = gd._animationInterrupt(); gd._animationInterrupt = null; } return ret; } - for (i = 0; i < traceIndices.length; i++) { - var traceIdx = traceIndices[i]; + for(i = 0; i < traceIndices.length; i++) { + traceIdx = traceIndices[i]; var contFull = gd._fullData[traceIdx]; var module = contFull._module; - if (!module.animatable) { - var thisTrace = [traceIdx]; + if(!module.animatable) { var thisUpdate = {}; - for (var ai in data[i]) { + for(var ai in data[i]) { thisUpdate[ai] = [data[i][ai]]; } - restyleList.push((function (md, data, traces) { - return function () { + restyleList.push((function(md, data, traces) { + return function() { return Plotly.restyle(gd, data, traces); - } + }; }(module, thisUpdate, [traceIdx]))); } } @@ -2620,8 +2618,6 @@ Plotly.transition = function(gd, data, layout, traceIndices, transitionConfig) { gd.emit('plotly_beginanimate', []); return gd; }); - - return Promise.resolve(); }; /** diff --git a/src/plots/cartesian/transition_axes.js b/src/plots/cartesian/transition_axes.js index 99eefe5c699..ffe6e30af19 100644 --- a/src/plots/cartesian/transition_axes.js +++ b/src/plots/cartesian/transition_axes.js @@ -10,14 +10,9 @@ 'use strict'; var d3 = require('d3'); -var isNumeric = require('fast-isnumeric'); var Plotly = require('../../plotly'); var Lib = require('../../lib'); -var svgTextUtils = require('../../lib/svg_text_utils'); -var Titles = require('../../components/titles'); -var Color = require('../../components/color'); -var Drawing = require('../../components/drawing'); var Axes = require('./axes'); var axisRegex = /((x|y)([2-9]|[1-9][0-9]+)?)axis$/; @@ -26,26 +21,26 @@ module.exports = function transitionAxes(gd, newLayout, transitionConfig) { var fullLayout = gd._fullLayout; var axes = []; - function computeUpdates (layout) { - var ai, attrList, match, to, axis, update, i; + function computeUpdates(layout) { + var ai, attrList, match, axis, update; var updates = {}; - for (ai in layout) { - var attrList = ai.split('.'); - var match = attrList[0].match(axisRegex); - if (match) { + for(ai in layout) { + attrList = ai.split('.'); + match = attrList[0].match(axisRegex); + if(match) { var axisName = match[1]; axis = fullLayout[axisName + 'axis']; update = {}; - if (Array.isArray(layout[ai])) { + if(Array.isArray(layout[ai])) { update.to = layout[ai].slice(0); } else { - if (Array.isArray(layout[ai].range)) { + if(Array.isArray(layout[ai].range)) { update.to = layout[ai].range.slice(0); } } - if (!update.to) continue; + if(!update.to) continue; update.axis = axis; update.length = axis._length; @@ -59,20 +54,20 @@ module.exports = function transitionAxes(gd, newLayout, transitionConfig) { return updates; } - function computeAffectedSubplots (fullLayout, updatedAxisIds) { + function computeAffectedSubplots(fullLayout, updatedAxisIds) { var plotName; var plotinfos = fullLayout._plots; var affectedSubplots = []; - for (plotName in plotinfos) { + for(plotName in plotinfos) { var plotinfo = plotinfos[plotName]; - if (affectedSubplots.indexOf(plotinfo) !== -1) continue; + if(affectedSubplots.indexOf(plotinfo) !== -1) continue; var x = plotinfo.xaxis._id; var y = plotinfo.yaxis._id; - if (updatedAxisIds.indexOf(x) !== -1 || updatedAxisIds.indexOf(y) !== -1) { + if(updatedAxisIds.indexOf(x) !== -1 || updatedAxisIds.indexOf(y) !== -1) { affectedSubplots.push(plotinfo); } } @@ -111,7 +106,7 @@ module.exports = function transitionAxes(gd, newLayout, transitionConfig) { redrawObjs(fullLayout.images || [], Plotly.Images); } - function unsetSubplotTransform (subplot) { + function unsetSubplotTransform(subplot) { var xa2 = subplot.x(); var ya2 = subplot.y(); @@ -139,14 +134,14 @@ module.exports = function transitionAxes(gd, newLayout, transitionConfig) { } - function updateSubplot (subplot, progress) { + function updateSubplot(subplot, progress) { var axis, r0, r1; var xUpdate = updates[subplot.xaxis._id]; var yUpdate = updates[subplot.yaxis._id]; var viewBox = []; - if (xUpdate) { + if(xUpdate) { axis = xUpdate.axis; r0 = axis._r; r1 = xUpdate.to; @@ -163,7 +158,7 @@ module.exports = function transitionAxes(gd, newLayout, transitionConfig) { viewBox[2] = subplot.xaxis._length; } - if (yUpdate) { + if(yUpdate) { axis = yUpdate.axis; r0 = axis._r; r1 = yUpdate.to; @@ -212,10 +207,11 @@ module.exports = function transitionAxes(gd, newLayout, transitionConfig) { // transitionTail - finish a drag event with a redraw function transitionTail() { + var i; var attrs = {}; // revert to the previous axis settings, then apply the new ones // through relayout - this lets relayout manage undo/redo - for(var i = 0; i < updatedAxisIds.length; i++) { + for(i = 0; i < updatedAxisIds.length; i++) { var axi = updates[updatedAxisIds[i]].axis; if(axi._r[0] !== axi.range[0]) attrs[axi._name + '.range[0]'] = axi.range[0]; if(axi._r[1] !== axi.range[1]) attrs[axi._name + '.range[1]'] = axi.range[1]; @@ -223,34 +219,34 @@ module.exports = function transitionAxes(gd, newLayout, transitionConfig) { axi.range = axi._r.slice(); } - for (var i = 0; i < affectedSubplots.length; i++) { + for(i = 0; i < affectedSubplots.length; i++) { unsetSubplotTransform(affectedSubplots[i]); } Plotly.relayout(gd, attrs); } - return new Promise(function (resolve, reject) { + return new Promise(function(resolve, reject) { var t1, t2, raf; - gd._animationInterrupt = function () { + gd._animationInterrupt = function() { reject(); cancelAnimationFrame(raf); raf = null; transitionTail(); }; - function doFrame () { + function doFrame() { t2 = Date.now(); var tInterp = Math.min(1, (t2 - t1) / transitionConfig.duration); var progress = easeFn(tInterp); - for (var i = 0; i < affectedSubplots.length; i++) { + for(var i = 0; i < affectedSubplots.length; i++) { updateSubplot(affectedSubplots[i], progress); } - if (t2 - t1 > transitionConfig.duration) { + if(t2 - t1 > transitionConfig.duration) { raf = cancelAnimationFrame(doFrame); transitionTail(); resolve(); @@ -263,4 +259,4 @@ module.exports = function transitionAxes(gd, newLayout, transitionConfig) { t1 = Date.now(); raf = requestAnimationFrame(doFrame); }); -} +}; diff --git a/test/jasmine/tests/animate_test.js b/test/jasmine/tests/animate_test.js index fe2f99c2119..ad0fca74ece 100644 --- a/test/jasmine/tests/animate_test.js +++ b/test/jasmine/tests/animate_test.js @@ -47,7 +47,10 @@ describe('Test animate API', function() { expect(args[1].length).toEqual(2); // layout - expect(args[2]).toEqual({}); + expect(args[2]).toEqual({ + xaxis: {range: [0, 2]}, + yaxis: {range: [0, 10]} + }); // traces are [0, 1]: expect(args[3]).toEqual([0, 1]); From aec44c6ecbc67ac56cb8f22aefd9fa98967bee38 Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Fri, 8 Jul 2016 10:32:35 -0400 Subject: [PATCH 14/82] Animation cleanup --- src/plot_api/plot_api.js | 22 ++++++++++++++-------- src/plots/cartesian/transition_axes.js | 4 ++-- src/plots/plots.js | 8 ++++++++ test/image/mocks/animation.json | 8 ++++---- 4 files changed, 28 insertions(+), 14 deletions(-) diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index dff036b496a..c236f4ac2d7 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -2540,7 +2540,10 @@ Plotly.transition = function(gd, data, layout, traceIndices, transitionConfig) { Plots.supplyDefaults(gd); + // TODO: Add logic that computes animatedTraces to avoid unnecessary work while + // still handling things like box plots that are interrelated. // doCalcdata(gd, animatedTraces); + doCalcdata(gd); ErrorBars.calc(gd); @@ -2548,7 +2551,7 @@ Plotly.transition = function(gd, data, layout, traceIndices, transitionConfig) { var restyleList = []; var completionTimeout = null; - var completion = null; + var resolveTransitionCallback = null; function executeTransitions() { var j; @@ -2567,22 +2570,25 @@ Plotly.transition = function(gd, data, layout, traceIndices, transitionConfig) { } return new Promise(function(resolve) { - completion = resolve; + resolveTransitionCallback = resolve; completionTimeout = setTimeout(resolve, transitionConfig.duration); }); } function interruptPreviousTransitions() { - var ret; + var ret, interrupt; clearTimeout(completionTimeout); - if(completion) { - completion(); + if(resolveTransitionCallback) { + resolveTransitionCallback(); + } + + while(gd._frameData._layoutInterrupts.length) { + (gd._frameData._layoutInterrupts.pop())(); } - if(gd._animationInterrupt) { - ret = gd._animationInterrupt(); - gd._animationInterrupt = null; + while(gd._frameData._styleInterrupts.length) { + (gd._frameData._styleInterrupts.pop())(); } return ret; } diff --git a/src/plots/cartesian/transition_axes.js b/src/plots/cartesian/transition_axes.js index ffe6e30af19..3698f24665d 100644 --- a/src/plots/cartesian/transition_axes.js +++ b/src/plots/cartesian/transition_axes.js @@ -229,12 +229,12 @@ module.exports = function transitionAxes(gd, newLayout, transitionConfig) { return new Promise(function(resolve, reject) { var t1, t2, raf; - gd._animationInterrupt = function() { + gd._frameData._layoutInterrupts.push(function() { reject(); cancelAnimationFrame(raf); raf = null; transitionTail(); - }; + }); function doFrame() { t2 = Date.now(); diff --git a/src/plots/plots.js b/src/plots/plots.js index 6bcb36747c4..d6468536c11 100644 --- a/src/plots/plots.js +++ b/src/plots/plots.js @@ -561,6 +561,14 @@ plots.supplyDefaults = function(gd) { if(!gd._frameData._counter) { gd._frameData._counter = 0; } + + if(!gd._frameData._layoutInterrupts) { + gd._frameData._layoutInterrupts = []; + } + + if(!gd._frameData._styleInterrupts) { + gd._frameData._styleInterrupts = []; + } }; // helper function to be bound to fullLayout to check diff --git a/test/image/mocks/animation.json b/test/image/mocks/animation.json index 5a1fe57193a..dfd8972d01b 100644 --- a/test/image/mocks/animation.json +++ b/test/image/mocks/animation.json @@ -39,7 +39,7 @@ } } }, { - "name": 'frame0', + "name": "frame0", "data": [ {"y": [0.5, 1.5, 7.5]}, {"y": [4.25, 2.25, 3.05]} @@ -48,7 +48,7 @@ "traceIndices": [0, 1], "layout": { } }, { - "name": 'frame1', + "name": "frame1", "data": [ {"y": [2.1, 1, 7]}, {"y": [4.5, 2.5, 3.1]} @@ -57,7 +57,7 @@ "traceIndices": [0, 1], "layout": { } }, { - "name": 'frame2', + "name": "frame2", "data": [ {"y": [3.5, 0.5, 6]}, {"y": [5.7, 2.7, 3.9]} @@ -66,7 +66,7 @@ "traceIndices": [0, 1], "layout": { } }, { - "name": 'frame3', + "name": "frame3", "data": [ {"y": [5.1, 0.25, 5]}, {"y": [7, 2.9, 6]} From e031c27898e342b0d6bb2753e33f194d4ad5f3f5 Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Fri, 8 Jul 2016 17:29:36 -0400 Subject: [PATCH 15/82] Avoid transitioning axes if no change --- src/plot_api/plot_api.js | 10 ++++++++-- src/plots/cartesian/transition_axes.js | 17 ++++++++++++++--- src/plots/plots.js | 9 +++++++++ 3 files changed, 31 insertions(+), 5 deletions(-) diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index c236f4ac2d7..6e9df411310 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -2502,7 +2502,7 @@ Plotly.transition = function(gd, data, layout, traceIndices, transitionConfig) { transitionConfig = Lib.extendFlat({ ease: 'cubic-in-out', duration: 500, - delay: 0 + delay: 0, }, transitionConfig || {}); // Create a single transition to be passed around: @@ -2560,15 +2560,21 @@ Plotly.transition = function(gd, data, layout, traceIndices, transitionConfig) { basePlotModules[j].plot(gd, animatedTraces, transitionConfig); } + var hasAxisTransition = false; + if(layout) { for(j = 0; j < basePlotModules.length; j++) { if(basePlotModules[j].transitionAxes) { var newLayout = Lib.extendDeep({}, gd._fullLayout, layout); - basePlotModules[j].transitionAxes(gd, newLayout, transitionConfig); + hasAxisTransition = hasAxisTransition || basePlotModules[j].transitionAxes(gd, newLayout, transitionConfig); } } } + if (!hasAxisTransition) { + return false; + } + return new Promise(function(resolve) { resolveTransitionCallback = resolve; completionTimeout = setTimeout(resolve, transitionConfig.duration); diff --git a/src/plots/cartesian/transition_axes.js b/src/plots/cartesian/transition_axes.js index 3698f24665d..89f1a17dbdf 100644 --- a/src/plots/cartesian/transition_axes.js +++ b/src/plots/cartesian/transition_axes.js @@ -54,7 +54,7 @@ module.exports = function transitionAxes(gd, newLayout, transitionConfig) { return updates; } - function computeAffectedSubplots(fullLayout, updatedAxisIds) { + function computeAffectedSubplots(fullLayout, updatedAxisIds, updates) { var plotName; var plotinfos = fullLayout._plots; var affectedSubplots = []; @@ -66,6 +66,12 @@ module.exports = function transitionAxes(gd, newLayout, transitionConfig) { var x = plotinfo.xaxis._id; var y = plotinfo.yaxis._id; + var fromX = plotinfo.xaxis.range; + var fromY = plotinfo.yaxis.range; + var toX = updates[x].to; + var toY = updates[y].to; + + if (fromX[0] === toX[0] && fromX[1] === toX[1] && fromY[0] === toY[0] && fromY[1] === toY[1]) continue; if(updatedAxisIds.indexOf(x) !== -1 || updatedAxisIds.indexOf(y) !== -1) { affectedSubplots.push(plotinfo); @@ -77,8 +83,11 @@ module.exports = function transitionAxes(gd, newLayout, transitionConfig) { var updates = computeUpdates(newLayout); var updatedAxisIds = Object.keys(updates); - var affectedSubplots = computeAffectedSubplots(fullLayout, updatedAxisIds); - var easeFn = d3.ease(transitionConfig.ease); + var affectedSubplots = computeAffectedSubplots(fullLayout, updatedAxisIds, updates); + + if (!affectedSubplots.length) { + return false; + } function ticksAndAnnotations(xa, ya) { var activeAxIds = [], @@ -226,6 +235,8 @@ module.exports = function transitionAxes(gd, newLayout, transitionConfig) { Plotly.relayout(gd, attrs); } + var easeFn = d3.ease(transitionConfig.ease); + return new Promise(function(resolve, reject) { var t1, t2, raf; diff --git a/src/plots/plots.js b/src/plots/plots.js index d6468536c11..b8e2169987b 100644 --- a/src/plots/plots.js +++ b/src/plots/plots.js @@ -526,6 +526,15 @@ plots.supplyDefaults = function(gd) { // relink functions and _ attributes to promote consistency between plots relinkPrivateKeys(newFullLayout, oldFullLayout); + // XXX: This is a hack that should be refactored by more generally removing the + // need for relinkPrivateKeys + var subplots = plots.getSubplotIds(newFullLayout, 'cartesian'); + for(i = 0; i < subplots.length; i++) { + var subplot = newFullLayout._plots[subplots[i]]; + subplot.xaxis = newFullLayout[subplot.xaxis._name]; + subplot.yaxis = newFullLayout[subplot.yaxis._name]; + } + plots.doAutoMargin(gd); // can't quite figure out how to get rid of this... each axis needs From bd949e6bd67e47102c8a92e91fabb2c8ed1c0fa2 Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Mon, 11 Jul 2016 14:29:38 -0400 Subject: [PATCH 16/82] Transfer animation code over to new PR --- src/components/drawing/index.js | 54 ++++++++++++-- src/components/errorbars/plot.js | 99 +++++++++++++++++++++----- src/plot_api/plot_api.js | 27 ++++--- src/plots/cartesian/transition_axes.js | 17 +++-- src/traces/scatter/attributes.js | 4 ++ src/traces/scatter/calc.js | 4 ++ src/traces/scatter/defaults.js | 1 + src/traces/scatter/plot.js | 66 +++++++++++------ 8 files changed, 213 insertions(+), 59 deletions(-) diff --git a/src/components/drawing/index.js b/src/components/drawing/index.js index b6c4b999fe3..34b4bf50af9 100644 --- a/src/components/drawing/index.js +++ b/src/components/drawing/index.js @@ -46,16 +46,62 @@ drawing.setRect = function(s, x, y, w, h) { s.call(drawing.setPosition, x, y).call(drawing.setSize, w, h); }; -drawing.translatePoints = function(s, xa, ya) { - s.each(function(d) { +drawing.translatePoints = function(s, xa, ya, trace, transitionConfig, joinDirection) { + var size; + + var hasTransition = transitionConfig && (transitionConfig || {}).duration > 0; + + if(hasTransition) { + size = s.size(); + } + + s.each(function(d, i) { // put xp and yp into d if pixel scaling is already done var x = d.xp || xa.c2p(d.x), y = d.yp || ya.c2p(d.y), p = d3.select(this); if(isNumeric(x) && isNumeric(y)) { // for multiline text this works better - if(this.nodeName === 'text') p.attr('x', x).attr('y', y); - else p.attr('transform', 'translate(' + x + ',' + y + ')'); + if(this.nodeName === 'text') { + p.attr('x', x).attr('y', y); + } else { + if(hasTransition) { + var trans; + if(!joinDirection) { + trans = p.transition() + .delay(transitionConfig.delay + transitionConfig.cascade / size * i) + .duration(transitionConfig.duration) + .ease(transitionConfig.ease) + .attr('transform', 'translate(' + x + ',' + y + ')'); + + if(trace) { + trans.call(drawing.pointStyle, trace); + } + } else if(joinDirection === -1) { + trans = p.style('opacity', 1) + .transition() + .duration(transitionConfig.duration) + .ease(transitionConfig.ease) + .style('opacity', 0) + .remove(); + } else if(joinDirection === 1) { + trans = p.attr('transform', 'translate(' + x + ',' + y + ')'); + + if(trace) { + trans.call(drawing.pointStyle, trace); + } + + trans.style('opacity', 0) + .transition() + .duration(transitionConfig.duration) + .ease(transitionConfig.ease) + .style('opacity', 1); + } + + } else { + p.attr('transform', 'translate(' + x + ',' + y + ')'); + } + } } else p.remove(); }); diff --git a/src/components/errorbars/plot.js b/src/components/errorbars/plot.js index eb66769760e..f8fb96d9d3b 100644 --- a/src/components/errorbars/plot.js +++ b/src/components/errorbars/plot.js @@ -14,12 +14,17 @@ var isNumeric = require('fast-isnumeric'); var Lib = require('../../lib'); var subTypes = require('../../traces/scatter/subtypes'); +var styleError = require('./style'); -module.exports = function plot(traces, plotinfo) { +module.exports = function plot(traces, plotinfo, transitionConfig) { + var isNew; var xa = plotinfo.x(), ya = plotinfo.y(); + transitionConfig = transitionConfig || {}; + var hasAnimation = isNumeric(transitionConfig.duration) && transitionConfig.duration > 0; + traces.each(function(d) { var trace = d[0].trace, // || {} is in case the trace (specifically scatterternary) @@ -29,29 +34,38 @@ module.exports = function plot(traces, plotinfo) { xObj = trace.error_x || {}, yObj = trace.error_y || {}; + var keyFunc; + + if(trace.identifier) { + keyFunc = function(d) {return d.identifier;}; + } + var sparse = ( subTypes.hasMarkers(trace) && trace.marker.maxdisplayed > 0 ); - var keyFunc; - - if(trace.key) { - keyFunc = function(d) { return d.key; }; - } - if(!yObj.visible && !xObj.visible) return; - var selection = d3.select(this).selectAll('g.errorbar'); - var join = selection.data(Lib.identity, keyFunc); + var errorbars = d3.select(this).selectAll('g.errorbar') + .data(Lib.identity, keyFunc); - join.enter().append('g') + errorbars.enter().append('g') .classed('errorbar', true); - join.exit().remove(); + if(hasAnimation) { + errorbars.exit() + .style('opacity', 1) + .transition() + .duration(transitionConfig.duration) + .style('opacity', 0) + .remove(); + } else { + errorbars.exit().remove(); + } - join.each(function(d) { + errorbars.each(function(d) { var errorbar = d3.select(this); var coords = errorCoords(d, xa, ya); @@ -68,14 +82,37 @@ module.exports = function plot(traces, plotinfo) { coords.yh + 'h' + (2 * yw) + // hat 'm-' + yw + ',0V' + coords.ys; // bar + if(!coords.noYS) path += 'm-' + yw + ',0h' + (2 * yw); // shoe - errorbar.append('path') - .classed('yerror', true) - .attr('d', path); + var yerror = errorbar.select('path.yerror'); + + isNew = !yerror.size(); + + if(isNew) { + yerror = errorbar.append('path') + .classed('yerror', true); + + if(hasAnimation) { + yerror = yerror.style('opacity', 0); + } + } else if(hasAnimation) { + yerror = yerror.transition() + .duration(transitionConfig.duration) + .ease(transitionConfig.ease) + .delay(transitionConfig.delay); + } + + yerror.attr('d', path); + + if(isNew && hasAnimation) { + yerror = yerror.transition() + .duration(transitionConfig.duration) + .style('opacity', 1); + } } - if(xObj.visible && isNumeric(coords.y) && + if(xObj.visible && isNumeric(coords.x) && isNumeric(coords.xh) && isNumeric(coords.xs)) { var xw = (xObj.copy_ystyle ? yObj : xObj).width; @@ -86,11 +123,35 @@ module.exports = function plot(traces, plotinfo) { if(!coords.noXS) path += 'm0,-' + xw + 'v' + (2 * xw); // shoe - errorbar.append('path') - .classed('xerror', true) - .attr('d', path); + var xerror = errorbar.select('path.xerror'); + + isNew = !xerror.size(); + + if(isNew) { + xerror = errorbar.append('path') + .classed('xerror', true); + + if(hasAnimation) { + xerror = xerror.style('opacity', 0); + } + } else if(hasAnimation) { + xerror = xerror.transition() + .duration(transitionConfig.duration) + .ease(transitionConfig.ease) + .delay(transitionConfig.delay); + } + + xerror.attr('d', path); + + if(isNew && hasAnimation) { + xerror = xerror.transition() + .duration(transitionConfig.duration) + .style('opacity', 1); + } } }); + + d3.select(this).call(styleError); }); }; diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index 6e9df411310..0e05c36ef4e 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -2503,6 +2503,7 @@ Plotly.transition = function(gd, data, layout, traceIndices, transitionConfig) { ease: 'cubic-in-out', duration: 500, delay: 0, + cascade: 0 }, transitionConfig || {}); // Create a single transition to be passed around: @@ -2515,13 +2516,19 @@ Plotly.transition = function(gd, data, layout, traceIndices, transitionConfig) { gd._currentTransition = null; } + var dataLength = Array.isArray(data) ? data.length : 0; + // Select which traces will be updated: if(isNumeric(traceIndices)) traceIndices = [traceIndices]; else if(!Array.isArray(traceIndices) || !traceIndices.length) { traceIndices = gd._fullData.map(function(v, i) {return i;}); } - var animatedTraces = []; + if(traceIndices.length > dataLength) { + traceIndices = traceIndices.slice(0, dataLength); + } + + var transitionedTraces = []; function prepareTransitions() { for(i = 0; i < traceIndices.length; i++) { @@ -2533,16 +2540,16 @@ Plotly.transition = function(gd, data, layout, traceIndices, transitionConfig) { continue; } - animatedTraces.push(traceIdx); + transitionedTraces.push(traceIdx); Lib.extendDeepNoArrays(gd.data[traceIndices[i]], data[i]); } Plots.supplyDefaults(gd); - // TODO: Add logic that computes animatedTraces to avoid unnecessary work while + // TODO: Add logic that computes transitionedTraces to avoid unnecessary work while // still handling things like box plots that are interrelated. - // doCalcdata(gd, animatedTraces); + // doCalcdata(gd, transitionedTraces); doCalcdata(gd); @@ -2554,10 +2561,14 @@ Plotly.transition = function(gd, data, layout, traceIndices, transitionConfig) { var resolveTransitionCallback = null; function executeTransitions() { + var hasTraceTransition = false; var j; var basePlotModules = fullLayout._basePlotModules; for(j = 0; j < basePlotModules.length; j++) { - basePlotModules[j].plot(gd, animatedTraces, transitionConfig); + if(basePlotModules[j].animatable) { + hasTraceTransition = true; + } + basePlotModules[j].plot(gd, transitionedTraces, transitionConfig); } var hasAxisTransition = false; @@ -2565,13 +2576,13 @@ Plotly.transition = function(gd, data, layout, traceIndices, transitionConfig) { if(layout) { for(j = 0; j < basePlotModules.length; j++) { if(basePlotModules[j].transitionAxes) { - var newLayout = Lib.extendDeep({}, gd._fullLayout, layout); + var newLayout = Lib.expandObjectPaths(layout); hasAxisTransition = hasAxisTransition || basePlotModules[j].transitionAxes(gd, newLayout, transitionConfig); } } } - if (!hasAxisTransition) { + if(!hasAxisTransition && !hasTraceTransition) { return false; } @@ -2582,7 +2593,6 @@ Plotly.transition = function(gd, data, layout, traceIndices, transitionConfig) { } function interruptPreviousTransitions() { - var ret, interrupt; clearTimeout(completionTimeout); if(resolveTransitionCallback) { @@ -2596,7 +2606,6 @@ Plotly.transition = function(gd, data, layout, traceIndices, transitionConfig) { while(gd._frameData._styleInterrupts.length) { (gd._frameData._styleInterrupts.pop())(); } - return ret; } for(i = 0; i < traceIndices.length; i++) { diff --git a/src/plots/cartesian/transition_axes.js b/src/plots/cartesian/transition_axes.js index 89f1a17dbdf..0eec069ac08 100644 --- a/src/plots/cartesian/transition_axes.js +++ b/src/plots/cartesian/transition_axes.js @@ -58,6 +58,7 @@ module.exports = function transitionAxes(gd, newLayout, transitionConfig) { var plotName; var plotinfos = fullLayout._plots; var affectedSubplots = []; + var toX, toY; for(plotName in plotinfos) { var plotinfo = plotinfos[plotName]; @@ -68,10 +69,18 @@ module.exports = function transitionAxes(gd, newLayout, transitionConfig) { var y = plotinfo.yaxis._id; var fromX = plotinfo.xaxis.range; var fromY = plotinfo.yaxis.range; - var toX = updates[x].to; - var toY = updates[y].to; + if(updates[x]) { + toX = updates[x].to; + } else { + toX = fromX; + } + if(updates[y]) { + toY = updates[y].to; + } else { + toY = fromY; + } - if (fromX[0] === toX[0] && fromX[1] === toX[1] && fromY[0] === toY[0] && fromY[1] === toY[1]) continue; + if(fromX[0] === toX[0] && fromX[1] === toX[1] && fromY[0] === toY[0] && fromY[1] === toY[1]) continue; if(updatedAxisIds.indexOf(x) !== -1 || updatedAxisIds.indexOf(y) !== -1) { affectedSubplots.push(plotinfo); @@ -85,7 +94,7 @@ module.exports = function transitionAxes(gd, newLayout, transitionConfig) { var updatedAxisIds = Object.keys(updates); var affectedSubplots = computeAffectedSubplots(fullLayout, updatedAxisIds, updates); - if (!affectedSubplots.length) { + if(!affectedSubplots.length) { return false; } diff --git a/src/traces/scatter/attributes.js b/src/traces/scatter/attributes.js index 28db0d1343e..1f23210d81b 100644 --- a/src/traces/scatter/attributes.js +++ b/src/traces/scatter/attributes.js @@ -65,6 +65,10 @@ module.exports = { 'See `y0` for more info.' ].join(' ') }, + identifier: { + valType: 'data_array', + description: 'A list of keys for object constancy of data points during animation' + }, text: { valType: 'string', role: 'info', diff --git a/src/traces/scatter/calc.js b/src/traces/scatter/calc.js index 3ac952cce2d..61f3a6300cd 100644 --- a/src/traces/scatter/calc.js +++ b/src/traces/scatter/calc.js @@ -115,6 +115,10 @@ module.exports = function calc(gd, trace) { for(i = 0; i < serieslen; i++) { cd[i] = (isNumeric(x[i]) && isNumeric(y[i])) ? {x: x[i], y: y[i]} : {x: false, y: false}; + + if(trace.identifier && trace.identifier[i] !== undefined) { + cd[i].identifier = trace.identifier[i]; + } } // this has migrated up from arraysToCalcdata as we have a reference to 's' here diff --git a/src/traces/scatter/defaults.js b/src/traces/scatter/defaults.js index e8582802450..7efa194e810 100644 --- a/src/traces/scatter/defaults.js +++ b/src/traces/scatter/defaults.js @@ -38,6 +38,7 @@ module.exports = function supplyDefaults(traceIn, traceOut, defaultColor, layout coerce('text'); coerce('mode', defaultMode); + coerce('identifier'); if(subTypes.hasLines(traceOut)) { handleLineDefaults(traceIn, traceOut, defaultColor, layout, coerce); diff --git a/src/traces/scatter/plot.js b/src/traces/scatter/plot.js index f6984cb78d3..e7c99ee87db 100644 --- a/src/traces/scatter/plot.js +++ b/src/traces/scatter/plot.js @@ -64,7 +64,7 @@ module.exports = function plot(gd, plotinfo, cdscatter, transitionConfig) { // Must run the selection again since otherwise enters/updates get grouped together // and these get executed out of order. Except we need them in order! scatterlayer.selectAll('g.trace').each(function(d, i) { - plotOne(gd, i, plotinfo, d, cdscatter, this); + plotOne(gd, i, plotinfo, d, cdscatter, this, transitionConfig); }); if(isFullReplot) { @@ -116,7 +116,7 @@ function createFills(gd, scatterlayer) { }); } -function plotOne(gd, idx, plotinfo, cdscatter, cdscatterAll, element) { +function plotOne(gd, idx, plotinfo, cdscatter, cdscatterAll, element, transitionConfig) { var join, i; // Since this has been reorganized and we're executing this on individual traces, @@ -124,6 +124,19 @@ function plotOne(gd, idx, plotinfo, cdscatter, cdscatterAll, element) { // since it does an internal n^2 loop over comparisons with other traces: selectMarkers(gd, idx, plotinfo, cdscatter, cdscatterAll); + var hasTransition = !!transitionConfig && transitionConfig.duration > 0; + + function transition(selection) { + if(hasTransition) { + return selection.transition() + .duration(transitionConfig.duration) + .delay(transitionConfig.delay) + .ease(transitionConfig.ease); + } else { + return selection; + } + } + var xa = plotinfo.x(), ya = plotinfo.y(); @@ -133,7 +146,7 @@ function plotOne(gd, idx, plotinfo, cdscatter, cdscatterAll, element) { // (so error bars can find them along with bars) // error bars are at the bottom - tr.call(ErrorBars.plot, plotinfo); + tr.call(ErrorBars.plot, plotinfo, transitionConfig); if(trace.visible !== true) return; @@ -227,12 +240,11 @@ function plotOne(gd, idx, plotinfo, cdscatter, cdscatterAll, element) { lastSegment = segments[segments.length - 1], pt1 = lastSegment[lastSegment.length - 1]; - var lineJoin = tr.selectAll('.js-line').data(segments); - - lineJoin.enter() - .append('path').classed('js-line', true); + //var lineJoin = tr.selectAll('.js-line').data(segments); + //lineJoin.enter().append('path').classed('js-line', true); - lineJoin.each(function(pts) { + for(i = 0; i < segments.length; i++) { + var pts = segments[i]; thispath = pathfn(pts); thisrevpath = revpathfn(pts); if(!fullpath) { @@ -248,13 +260,16 @@ function plotOne(gd, idx, plotinfo, cdscatter, cdscatterAll, element) { revpath = thisrevpath + 'Z' + revpath; } if(subTypes.hasLines(trace) && pts.length > 1) { - d3.select(this) - .attr('d', thispath) - .datum(cdscatter); + var lineJoin = tr.selectAll('.js-line').data([cdscatter]); + + lineJoin.enter() + .append('path').classed('js-line', true).attr('d', thispath); + + transition(lineJoin).attr('d', thispath); } - }); + } - lineJoin.exit().remove(); + //lineJoin.exit().remove(); if(ownFillEl3) { if(pt0 && pt1) { @@ -268,10 +283,10 @@ function plotOne(gd, idx, plotinfo, cdscatter, cdscatterAll, element) { // fill to zero: full trace path, plus extension of // the endpoints to the appropriate axis - ownFillEl3.attr('d', fullpath + 'L' + pt1 + 'L' + pt0 + 'Z'); + transition(ownFillEl3).attr('d', fullpath + 'L' + pt1 + 'L' + pt0 + 'Z'); } else { // fill to self: just join the path to itself - ownFillEl3.attr('d', fullpath + 'Z'); + transition(ownFillEl3).attr('d', fullpath + 'Z'); } } } @@ -282,7 +297,7 @@ function plotOne(gd, idx, plotinfo, cdscatter, cdscatterAll, element) { // contours, we just add the two paths closed on themselves. // This makes strange results if one path is *not* entirely // inside the other, but then that is a strange usage. - tonext.attr('d', fullpath + 'Z' + prevpath + 'Z'); + transition(tonext).attr('d', fullpath + 'Z' + prevpath + 'Z'); } else { // tonextx/y: for now just connect endpoints with lines. This is @@ -290,7 +305,7 @@ function plotOne(gd, idx, plotinfo, cdscatter, cdscatterAll, element) { // y/x, but if they *aren't*, we should ideally do more complicated // things depending on whether the new endpoint projects onto the // existing curve or off the end of it - tonext.attr('d', fullpath + 'L' + prevpath.substr(1) + 'Z'); + transition(tonext).attr('d', fullpath + 'L' + prevpath.substr(1) + 'Z'); } trace._polygons = trace._polygons.concat(prevPolygons); } @@ -305,12 +320,12 @@ function plotOne(gd, idx, plotinfo, cdscatter, cdscatterAll, element) { } function keyFunc(d) { - return d.key; + return d.identifier; } // Returns a function if the trace is keyed, otherwise returns undefined function getKeyFunc(trace) { - if(trace.key) { + if(trace.identifier) { return keyFunc; } } @@ -333,13 +348,18 @@ function plotOne(gd, idx, plotinfo, cdscatter, cdscatterAll, element) { join.enter().append('path') .classed('point', true) .call(Drawing.pointStyle, trace) - .call(Drawing.translatePoints, xa, ya); + .call(Drawing.translatePoints, xa, ya, trace, transitionConfig, 1); - selection - .call(Drawing.translatePoints, xa, ya) + join.transition() + .call(Drawing.translatePoints, xa, ya, trace, transitionConfig, 0) .call(Drawing.pointStyle, trace); - join.exit().remove(); + if(hasTransition) { + join.exit() + .call(Drawing.translatePoints, xa, ya, trace, transitionConfig, -1); + } else { + join.exit().remove(); + } } if(showText) { selection = s.selectAll('g'); From 9132eb6d49e8ede251cd217c89f3c0cc76bff3c7 Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Mon, 11 Jul 2016 15:40:59 -0400 Subject: [PATCH 17/82] Fix bad json --- test/image/mocks/animation.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/image/mocks/animation.json b/test/image/mocks/animation.json index dfd8972d01b..34cb8e737cb 100644 --- a/test/image/mocks/animation.json +++ b/test/image/mocks/animation.json @@ -17,11 +17,11 @@ "autosize": false, "xaxis": { "range": [0, 2], - "domain": [0, 1], + "domain": [0, 1] }, "yaxis": { "range": [0, 10], - "domain": [0, 1], + "domain": [0, 1] } }, "frames": [{ From a4e828286b6a8db8d51f020e2f6b5675dfd04c7d Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Tue, 12 Jul 2016 13:19:06 -0400 Subject: [PATCH 18/82] Fix scatter line issue --- src/traces/scatter/plot.js | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/traces/scatter/plot.js b/src/traces/scatter/plot.js index e7c99ee87db..3578bd53dcf 100644 --- a/src/traces/scatter/plot.js +++ b/src/traces/scatter/plot.js @@ -259,16 +259,16 @@ function plotOne(gd, idx, plotinfo, cdscatter, cdscatterAll, element, transition fullpath += 'Z' + thispath; revpath = thisrevpath + 'Z' + revpath; } - if(subTypes.hasLines(trace) && pts.length > 1) { - var lineJoin = tr.selectAll('.js-line').data([cdscatter]); + } - lineJoin.enter() - .append('path').classed('js-line', true).attr('d', thispath); + if(subTypes.hasLines(trace) && pts.length > 1) { + var lineJoin = tr.selectAll('.js-line').data([cdscatter]); - transition(lineJoin).attr('d', thispath); - } - } + lineJoin.enter() + .append('path').classed('js-line', true).attr('d', fullpath); + transition(lineJoin).attr('d', fullpath); + } //lineJoin.exit().remove(); if(ownFillEl3) { From 86ca5d628f3da07d6e1a48152b65e582cbec310e Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Thu, 14 Jul 2016 11:02:54 -0400 Subject: [PATCH 19/82] Expand the trace update to all interdependent traces #717 --- src/plots/cartesian/index.js | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/src/plots/cartesian/index.js b/src/plots/cartesian/index.js index 5f113a12b7a..619e8e455ce 100644 --- a/src/plots/cartesian/index.js +++ b/src/plots/cartesian/index.js @@ -50,6 +50,7 @@ exports.plot = function(gd, traces, transitionOpts) { // Get all calcdata for this subplot: cdSubplot = []; + var pcd; for(j = 0; j < calcdata.length; j++) { cd = calcdata[j]; trace = cd[0].trace; @@ -57,8 +58,25 @@ exports.plot = function(gd, traces, transitionOpts) { // Skip trace if whitelist provided and it's not whitelisted: // if (Array.isArray(traces) && traces.indexOf(i) === -1) continue; - if(trace.xaxis + trace.yaxis === subplot && traces.indexOf(trace.index) !== -1) { - cdSubplot.push(cd); + if(trace.xaxis + trace.yaxis === subplot) { + // Okay, so example: traces 0, 1, and 2 have fill = tonext. You animate + // traces 0 and 2. Trace 1 also needs to be updated, otherwise its fill + // is outdated. So this retroactively adds the previous trace if the + // traces are interdependent. + if (['tonextx', 'tonexty', 'tonext'].indexOf(trace.fill) !== -1) { + if (cdSubplot.indexOf(pcd) === -1) { + cdSubplot.push(pcd); + } + } + + // If this trace is specifically requested, add it to the list: + if (traces.indexOf(trace.index) !== -1) { + cdSubplot.push(cd); + } + + // Track the previous trace on this subplot for the retroactive-add step + // above: + pcd = cd; } } From dd995a992c373dc128087e08c6857945283b7c0d Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Thu, 14 Jul 2016 11:04:59 -0400 Subject: [PATCH 20/82] Fix lint issues --- src/plots/cartesian/index.js | 6 +++--- src/traces/scatter/plot.js | 3 ++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/plots/cartesian/index.js b/src/plots/cartesian/index.js index 619e8e455ce..2c5968786f0 100644 --- a/src/plots/cartesian/index.js +++ b/src/plots/cartesian/index.js @@ -63,14 +63,14 @@ exports.plot = function(gd, traces, transitionOpts) { // traces 0 and 2. Trace 1 also needs to be updated, otherwise its fill // is outdated. So this retroactively adds the previous trace if the // traces are interdependent. - if (['tonextx', 'tonexty', 'tonext'].indexOf(trace.fill) !== -1) { - if (cdSubplot.indexOf(pcd) === -1) { + if(['tonextx', 'tonexty', 'tonext'].indexOf(trace.fill) !== -1) { + if(cdSubplot.indexOf(pcd) === -1) { cdSubplot.push(pcd); } } // If this trace is specifically requested, add it to the list: - if (traces.indexOf(trace.index) !== -1) { + if(traces.indexOf(trace.index) !== -1) { cdSubplot.push(cd); } diff --git a/src/traces/scatter/plot.js b/src/traces/scatter/plot.js index 3578bd53dcf..54fb2282fb2 100644 --- a/src/traces/scatter/plot.js +++ b/src/traces/scatter/plot.js @@ -236,6 +236,7 @@ function plotOne(gd, idx, plotinfo, cdscatter, cdscatterAll, element, transition } if(segments.length) { + var pts; var pt0 = segments[0][0], lastSegment = segments[segments.length - 1], pt1 = lastSegment[lastSegment.length - 1]; @@ -244,7 +245,7 @@ function plotOne(gd, idx, plotinfo, cdscatter, cdscatterAll, element, transition //lineJoin.enter().append('path').classed('js-line', true); for(i = 0; i < segments.length; i++) { - var pts = segments[i]; + pts = segments[i]; thispath = pathfn(pts); thisrevpath = revpathfn(pts); if(!fullpath) { From cef8bd508681abc7b038cb314167eed704639ca6 Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Thu, 14 Jul 2016 11:55:44 -0400 Subject: [PATCH 21/82] Reorder path fill drawing --- src/traces/scatter/plot.js | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/traces/scatter/plot.js b/src/traces/scatter/plot.js index 54fb2282fb2..f0c6745aabf 100644 --- a/src/traces/scatter/plot.js +++ b/src/traces/scatter/plot.js @@ -160,12 +160,12 @@ function plotOne(gd, idx, plotinfo, cdscatter, cdscatterAll, element, transition arraysToCalcdata(cdscatter); - var prevpath = ''; + var prevRevpath = ''; var prevPolygons = []; var prevtrace = trace._prevtrace; if(prevtrace) { - prevpath = prevtrace._revpath || ''; + prevRevpath = prevtrace._prevRevpath || ''; tonext = prevtrace._nextFill; prevPolygons = prevtrace._polygons; } @@ -284,21 +284,24 @@ function plotOne(gd, idx, plotinfo, cdscatter, cdscatterAll, element, transition // fill to zero: full trace path, plus extension of // the endpoints to the appropriate axis - transition(ownFillEl3).attr('d', fullpath + 'L' + pt1 + 'L' + pt0 + 'Z'); + // For the sake of animations, wrap the points around so that + // the points on the axes are the first two points. Otherwise + // animations get a little crazy if the number of points changes. + transition(ownFillEl3).attr('d', 'M' + pt1 + 'L' + pt0 + 'L' + fullpath.substr(1)); } else { // fill to self: just join the path to itself transition(ownFillEl3).attr('d', fullpath + 'Z'); } } } - else if(trace.fill.substr(0, 6) === 'tonext' && fullpath && prevpath) { + else if(trace.fill.substr(0, 6) === 'tonext' && fullpath && prevRevpath) { // fill to next: full trace path, plus the previous path reversed if(trace.fill === 'tonext') { // tonext: for use by concentric shapes, like manually constructed // contours, we just add the two paths closed on themselves. // This makes strange results if one path is *not* entirely // inside the other, but then that is a strange usage. - transition(tonext).attr('d', fullpath + 'Z' + prevpath + 'Z'); + transition(tonext).attr('d', fullpath + 'Z' + prevRevpath + 'Z'); } else { // tonextx/y: for now just connect endpoints with lines. This is @@ -306,11 +309,11 @@ function plotOne(gd, idx, plotinfo, cdscatter, cdscatterAll, element, transition // y/x, but if they *aren't*, we should ideally do more complicated // things depending on whether the new endpoint projects onto the // existing curve or off the end of it - transition(tonext).attr('d', fullpath + 'L' + prevpath.substr(1) + 'Z'); + transition(tonext).attr('d', fullpath + 'L' + prevRevpath.substr(1) + 'Z'); } trace._polygons = trace._polygons.concat(prevPolygons); } - trace._revpath = revpath; + trace._prevRevpath = revpath; trace._prevPolygons = thisPolygons; } } From 2fb95a7f1c341973adee4aa13d18062fe681e181 Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Thu, 14 Jul 2016 13:12:41 -0400 Subject: [PATCH 22/82] Add config to disable line simplification --- src/traces/scatter/attributes.js | 10 ++++++++++ src/traces/scatter/line_defaults.js | 1 + src/traces/scatter/line_points.js | 5 +++++ src/traces/scatter/plot.js | 3 ++- 4 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/traces/scatter/attributes.js b/src/traces/scatter/attributes.js index 1f23210d81b..2cc6cb00a68 100644 --- a/src/traces/scatter/attributes.js +++ b/src/traces/scatter/attributes.js @@ -156,6 +156,16 @@ module.exports = { 'Sets the style of the lines. Set to a dash string type', 'or a dash length in px.' ].join(' ') + }, + simplify: { + valType: 'boolean', + dflt: true, + role: 'info', + description: [ + 'Simplifies lines by removing nearly-collinear points. When transitioning', + 'lines, it may be desirable to disable this so that the number of points', + 'along the resulting SVG path is unaffected.' + ].join(' ') } }, connectgaps: { diff --git a/src/traces/scatter/line_defaults.js b/src/traces/scatter/line_defaults.js index 92aae25b149..ce031db2180 100644 --- a/src/traces/scatter/line_defaults.js +++ b/src/traces/scatter/line_defaults.js @@ -31,4 +31,5 @@ module.exports = function lineDefaults(traceIn, traceOut, defaultColor, layout, coerce('line.width'); coerce('line.dash'); + coerce('line.simplify'); }; diff --git a/src/traces/scatter/line_points.js b/src/traces/scatter/line_points.js index 60d7e3c77ea..390242a1fd7 100644 --- a/src/traces/scatter/line_points.js +++ b/src/traces/scatter/line_points.js @@ -15,6 +15,7 @@ var Axes = require('../../plots/cartesian/axes'); module.exports = function linePoints(d, opts) { var xa = opts.xaxis, ya = opts.yaxis, + simplify = opts.simplify, connectGaps = opts.connectGaps, baseTolerance = opts.baseTolerance, linear = opts.linear, @@ -48,6 +49,10 @@ module.exports = function linePoints(d, opts) { clusterMaxDeviation, thisDeviation; + if(!simplify) { + baseTolerance = minTolerance = -1; + } + // turn one calcdata point into pixel coordinates function getPt(index) { var x = xa.c2p(d[index].x), diff --git a/src/traces/scatter/plot.js b/src/traces/scatter/plot.js index f0c6745aabf..b17c710ea36 100644 --- a/src/traces/scatter/plot.js +++ b/src/traces/scatter/plot.js @@ -223,7 +223,8 @@ function plotOne(gd, idx, plotinfo, cdscatter, cdscatterAll, element, transition yaxis: ya, connectGaps: trace.connectgaps, baseTolerance: Math.max(line.width || 1, 3) / 4, - linear: line.shape === 'linear' + linear: line.shape === 'linear', + simplify: line.simplify }); // since we already have the pixel segments here, use them to make From bf2aeec7f88339f058585232e0bb766315c1254e Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Mon, 18 Jul 2016 15:52:32 -0400 Subject: [PATCH 23/82] Error bar styling tweaks --- src/components/errorbars/plot.js | 58 ++++++++++++++------------------ src/plot_api/plot_api.js | 11 +++++- 2 files changed, 35 insertions(+), 34 deletions(-) diff --git a/src/components/errorbars/plot.js b/src/components/errorbars/plot.js index f8fb96d9d3b..9ee75f4b7aa 100644 --- a/src/components/errorbars/plot.js +++ b/src/components/errorbars/plot.js @@ -37,7 +37,10 @@ module.exports = function plot(traces, plotinfo, transitionConfig) { var keyFunc; if(trace.identifier) { - keyFunc = function(d) {return d.identifier;}; + keyFunc = function(d) { + console.log('d:', d); + return d.identifier; + }; } var sparse = ( @@ -47,13 +50,9 @@ module.exports = function plot(traces, plotinfo, transitionConfig) { if(!yObj.visible && !xObj.visible) return; - var errorbars = d3.select(this).selectAll('g.errorbar') .data(Lib.identity, keyFunc); - errorbars.enter().append('g') - .classed('errorbar', true); - if(hasAnimation) { errorbars.exit() .style('opacity', 1) @@ -65,6 +64,17 @@ module.exports = function plot(traces, plotinfo, transitionConfig) { errorbars.exit().remove(); } + errorbars.style('opacity', 1); + + var enter = errorbars.enter().append('g') + .classed('errorbar', true); + + if (hasAnimation) { + enter.style('opacity', 0).transition() + .duration(transitionConfig.duration) + .style('opacity', 1); + } + errorbars.each(function(d) { var errorbar = d3.select(this); var coords = errorCoords(d, xa, ya); @@ -92,24 +102,15 @@ module.exports = function plot(traces, plotinfo, transitionConfig) { if(isNew) { yerror = errorbar.append('path') .classed('yerror', true); - - if(hasAnimation) { - yerror = yerror.style('opacity', 0); - } } else if(hasAnimation) { - yerror = yerror.transition() - .duration(transitionConfig.duration) - .ease(transitionConfig.ease) - .delay(transitionConfig.delay); + yerror = yerror + .transition() + .duration(transitionConfig.duration) + .ease(transitionConfig.ease) + .delay(transitionConfig.delay); } yerror.attr('d', path); - - if(isNew && hasAnimation) { - yerror = yerror.transition() - .duration(transitionConfig.duration) - .style('opacity', 1); - } } if(xObj.visible && isNumeric(coords.x) && @@ -130,24 +131,15 @@ module.exports = function plot(traces, plotinfo, transitionConfig) { if(isNew) { xerror = errorbar.append('path') .classed('xerror', true); - - if(hasAnimation) { - xerror = xerror.style('opacity', 0); - } } else if(hasAnimation) { - xerror = xerror.transition() - .duration(transitionConfig.duration) - .ease(transitionConfig.ease) - .delay(transitionConfig.delay); + xerror = xerror + .transition() + .duration(transitionConfig.duration) + .ease(transitionConfig.ease) + .delay(transitionConfig.delay); } xerror.attr('d', path); - - if(isNew && hasAnimation) { - xerror = xerror.transition() - .duration(transitionConfig.duration) - .style('opacity', 1); - } } }); diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index 0e05c36ef4e..da8a1be1b2e 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -2542,7 +2542,16 @@ Plotly.transition = function(gd, data, layout, traceIndices, transitionConfig) { transitionedTraces.push(traceIdx); - Lib.extendDeepNoArrays(gd.data[traceIndices[i]], data[i]); + // This is a multi-step process. First clone w/o arrays so that + // we're not modifying the original: + var update = Lib.extendDeepNoArrays({}, data[i]); + + // Then expand object paths since we don't obey object-overwrite + // semantics here: + update = Lib.expandObjectPaths(update); + + // Finally apply the update (without copying arrays, of course): + Lib.extendDeepNoArrays(gd.data[traceIndices[i]], update); } Plots.supplyDefaults(gd); From bf079e9af222edc7e622141bd5f4df57073fcc1c Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Mon, 18 Jul 2016 17:13:25 -0400 Subject: [PATCH 24/82] Cut losses; make error bars basically work --- src/components/errorbars/plot.js | 24 ++++++------------------ 1 file changed, 6 insertions(+), 18 deletions(-) diff --git a/src/components/errorbars/plot.js b/src/components/errorbars/plot.js index 9ee75f4b7aa..25d6912c834 100644 --- a/src/components/errorbars/plot.js +++ b/src/components/errorbars/plot.js @@ -37,10 +37,7 @@ module.exports = function plot(traces, plotinfo, transitionConfig) { var keyFunc; if(trace.identifier) { - keyFunc = function(d) { - console.log('d:', d); - return d.identifier; - }; + keyFunc = function(d) {return d.identifier;}; } var sparse = ( @@ -51,18 +48,9 @@ module.exports = function plot(traces, plotinfo, transitionConfig) { if(!yObj.visible && !xObj.visible) return; var errorbars = d3.select(this).selectAll('g.errorbar') - .data(Lib.identity, keyFunc); - - if(hasAnimation) { - errorbars.exit() - .style('opacity', 1) - .transition() - .duration(transitionConfig.duration) - .style('opacity', 0) - .remove(); - } else { - errorbars.exit().remove(); - } + .data(d, keyFunc); + + errorbars.exit().remove(); errorbars.style('opacity', 1); @@ -71,8 +59,8 @@ module.exports = function plot(traces, plotinfo, transitionConfig) { if (hasAnimation) { enter.style('opacity', 0).transition() - .duration(transitionConfig.duration) - .style('opacity', 1); + .duration(transitionConfig.duration) + .style('opacity', 1); } errorbars.each(function(d) { From 8717877daa6913d96d8097bedc20eccd6236adda Mon Sep 17 00:00:00 2001 From: Brandon Nielsen Date: Tue, 12 Jul 2016 15:05:43 -0500 Subject: [PATCH 25/82] Fix for issue 702. - Graph divs now keep a reference to their 'parent' document in the `_document' property. - Styles are now injected to the parent document on the first call to Plotly.plot. - Anything that draws over a document for interactivity purposes (eg. dragcovers), now uses the _document reference to ensure it is drawn in the correct document. - Notifiers now require a graph div as the first argument so that notifications are rendered in the correct place --- build/plotcss.js | 8 +- src/components/dragelement/index.js | 10 +- src/components/modebar/buttons.js | 8 +- src/components/rangeslider/create_slider.js | 34 ++--- src/css/helpers.js | 38 +++++ src/css/plotcss_injector.js | 52 +++++++ src/lib/index.js | 24 ---- src/lib/notifier.js | 5 +- src/plot_api/plot_api.js | 18 ++- src/plotly.js | 3 - src/plots/cartesian/dragbox.js | 2 +- src/plots/cartesian/set_convert.js | 1 + src/plots/plots.js | 4 + src/plots/ternary/ternary.js | 2 +- src/traces/heatmap/calc.js | 2 +- tasks/util/pull_css.js | 8 +- test/jasmine/tests/dragelement_test.js | 1 + test/jasmine/tests/plot_css_test.js | 152 ++++++++++++++++++++ test/jasmine/tests/plot_interact_test.js | 141 ++++++++++++++++++ 19 files changed, 437 insertions(+), 76 deletions(-) create mode 100644 src/css/helpers.js create mode 100644 src/css/plotcss_injector.js create mode 100644 test/jasmine/tests/plot_css_test.js diff --git a/build/plotcss.js b/build/plotcss.js index 169edfce295..556adf30e3b 100644 --- a/build/plotcss.js +++ b/build/plotcss.js @@ -1,6 +1,5 @@ 'use strict'; -var Plotly = require('../src/plotly'); var rules = { "X,X div": "font-family:'Open Sans', verdana, arial, sans-serif;margin:0;padding:0;", "X input,X button": "font-family:'Open Sans', verdana, arial, sans-serif;", @@ -54,9 +53,4 @@ var rules = { "Y .notifier-close:hover": "color:#444;text-decoration:none;cursor:pointer;" }; -for(var selector in rules) { - var fullSelector = selector.replace(/^,/,' ,') - .replace(/X/g, '.js-plotly-plot .plotly') - .replace(/Y/g, '.plotly-notifier'); - Plotly.Lib.addStyleRule(fullSelector, rules[selector]); -} +module.exports = rules; diff --git a/src/components/dragelement/index.js b/src/components/dragelement/index.js index a57a0038248..44a7687fa62 100644 --- a/src/components/dragelement/index.js +++ b/src/components/dragelement/index.js @@ -86,7 +86,7 @@ dragElement.init = function init(options) { if(options.prepFn) options.prepFn(e, startX, startY); - dragCover = coverSlip(); + dragCover = coverSlip(gd); dragCover.onmousemove = onMove; dragCover.onmouseup = onDone; @@ -139,7 +139,7 @@ dragElement.init = function init(options) { if(options.doneFn) options.doneFn(gd._dragged, numClicks); if(!gd._dragged) { - var e2 = document.createEvent('MouseEvents'); + var e2 = gd._document.createEvent('MouseEvents'); e2.initEvent('click', true, true); initialTarget.dispatchEvent(e2); } @@ -159,8 +159,8 @@ dragElement.init = function init(options) { options.element.style.pointerEvents = 'all'; }; -function coverSlip() { - var cover = document.createElement('div'); +function coverSlip(gd) { + var cover = gd._document.createElement('div'); cover.className = 'dragcover'; var cStyle = cover.style; @@ -172,7 +172,7 @@ function coverSlip() { cStyle.zIndex = 999999999; cStyle.background = 'none'; - document.body.appendChild(cover); + gd._document.body.appendChild(cover); return cover; } diff --git a/src/components/modebar/buttons.js b/src/components/modebar/buttons.js index 23b6ae8a72c..a43279afdef 100644 --- a/src/components/modebar/buttons.js +++ b/src/components/modebar/buttons.js @@ -50,19 +50,19 @@ modeBarButtons.toImage = { click: function(gd) { var format = 'png'; - Lib.notifier('Taking snapshot - this may take a few seconds', 'long'); + Lib.notifier(gd, 'Taking snapshot - this may take a few seconds', 'long'); if(Lib.isIE()) { - Lib.notifier('IE only supports svg. Changing format to svg.', 'long'); + Lib.notifier(gd, 'IE only supports svg. Changing format to svg.', 'long'); format = 'svg'; } downloadImage(gd, {'format': format}) .then(function(filename) { - Lib.notifier('Snapshot succeeded - ' + filename, 'long'); + Lib.notifier(gd, 'Snapshot succeeded - ' + filename, 'long'); }) .catch(function() { - Lib.notifier('Sorry there was a problem downloading your snapshot!', 'long'); + Lib.notifier(gd, 'Sorry there was a problem downloading your snapshot!', 'long'); }); } }; diff --git a/src/components/rangeslider/create_slider.js b/src/components/rangeslider/create_slider.js index 83caa2ad7eb..00a9b3d06b1 100644 --- a/src/components/rangeslider/create_slider.js +++ b/src/components/rangeslider/create_slider.js @@ -33,7 +33,7 @@ module.exports = function createSlider(gd) { var minStart = 0, maxStart = width; - var slider = document.createElementNS(svgNS, 'g'); + var slider = gd._document.createElementNS(svgNS, 'g'); helpers.setAttributes(slider, { 'class': 'range-slider', 'data-min': minStart, @@ -43,7 +43,7 @@ module.exports = function createSlider(gd) { }); - var sliderBg = document.createElementNS(svgNS, 'rect'), + var sliderBg = gd._document.createElementNS(svgNS, 'rect'), borderCorrect = options.borderwidth % 2 === 0 ? options.borderwidth : options.borderwidth - 1; helpers.setAttributes(sliderBg, { 'fill': options.bgcolor, @@ -56,7 +56,7 @@ module.exports = function createSlider(gd) { }); - var maskMin = document.createElementNS(svgNS, 'rect'); + var maskMin = gd._document.createElementNS(svgNS, 'rect'); helpers.setAttributes(maskMin, { 'x': 0, 'width': minStart, @@ -65,7 +65,7 @@ module.exports = function createSlider(gd) { }); - var maskMax = document.createElementNS(svgNS, 'rect'); + var maskMax = gd._document.createElementNS(svgNS, 'rect'); helpers.setAttributes(maskMax, { 'x': maxStart, 'width': width - maxStart, @@ -74,9 +74,9 @@ module.exports = function createSlider(gd) { }); - var grabberMin = document.createElementNS(svgNS, 'g'), - grabAreaMin = document.createElementNS(svgNS, 'rect'), - handleMin = document.createElementNS(svgNS, 'rect'); + var grabberMin = gd._document.createElementNS(svgNS, 'g'), + grabAreaMin = gd._document.createElementNS(svgNS, 'rect'), + handleMin = gd._document.createElementNS(svgNS, 'rect'); helpers.setAttributes(grabberMin, { 'transform': 'translate(' + (minStart - handleWidth - 1) + ')' }); helpers.setAttributes(grabAreaMin, { 'width': 10, @@ -97,9 +97,9 @@ module.exports = function createSlider(gd) { helpers.appendChildren(grabberMin, [handleMin, grabAreaMin]); - var grabberMax = document.createElementNS(svgNS, 'g'), - grabAreaMax = document.createElementNS(svgNS, 'rect'), - handleMax = document.createElementNS(svgNS, 'rect'); + var grabberMax = gd._document.createElementNS(svgNS, 'g'), + grabAreaMax = gd._document.createElementNS(svgNS, 'rect'), + handleMax = gd._document.createElementNS(svgNS, 'rect'); helpers.setAttributes(grabberMax, { 'transform': 'translate(' + maxStart + ')' }); helpers.setAttributes(grabAreaMax, { 'width': 10, @@ -120,7 +120,7 @@ module.exports = function createSlider(gd) { helpers.appendChildren(grabberMax, [handleMax, grabAreaMax]); - var slideBox = document.createElementNS(svgNS, 'rect'); + var slideBox = gd._document.createElementNS(svgNS, 'rect'); helpers.setAttributes(slideBox, { 'x': minStart, 'width': maxStart - minStart, @@ -137,8 +137,8 @@ module.exports = function createSlider(gd) { minVal = slider.getAttribute('data-min'), maxVal = slider.getAttribute('data-max'); - window.addEventListener('mousemove', mouseMove); - window.addEventListener('mouseup', mouseUp); + gd._document.defaultView.addEventListener('mousemove', mouseMove); + gd._document.defaultView.addEventListener('mouseup', mouseUp); function mouseMove(e) { var delta = +e.clientX - startX, @@ -189,8 +189,8 @@ module.exports = function createSlider(gd) { } function mouseUp() { - window.removeEventListener('mousemove', mouseMove); - window.removeEventListener('mouseup', mouseUp); + gd._document.defaultView.removeEventListener('mousemove', mouseMove); + gd._document.defaultView.removeEventListener('mouseup', mouseUp); slider.style.cursor = 'auto'; } }); @@ -222,8 +222,8 @@ module.exports = function createSlider(gd) { function setDataRange(dataMin, dataMax) { - if(window.requestAnimationFrame) { - window.requestAnimationFrame(function() { + if(gd._document.defaultView.requestAnimationFrame) { + gd._document.defaultView.requestAnimationFrame(function() { Plotly.relayout(gd, 'xaxis.range', [dataMin, dataMax]); }); } else { diff --git a/src/css/helpers.js b/src/css/helpers.js new file mode 100644 index 00000000000..ebf4c6470a0 --- /dev/null +++ b/src/css/helpers.js @@ -0,0 +1,38 @@ +/** +* Copyright 2012-2016, 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'; + +// expands a plotcss selector +exports.buildFullSelector = function buildFullSelector(selector) { + var fullSelector = selector.replace(/,/, ', ') + .replace(/:after/g, '::after') + .replace(/:before/g, '::before') + .replace(/X/g, '.js-plotly-plot .plotly') + .replace(/Y/g, '.plotly-notifier'); + + return fullSelector; +}; + +// Gets all the rules currently attached to the document +exports.getAllRuleSelectors = function getAllRuleSelectors(sourceDocument) { + var allSelectors = []; + + for(var i = 0; i < sourceDocument.styleSheets.length; i++) { + var styleSheet = sourceDocument.styleSheets[i]; + + for(var j = 0; j < styleSheet.cssRules.length; j++) { + var cssRule = styleSheet.cssRules[j]; + + allSelectors.push(cssRule.selectorText); + } + } + + return allSelectors; +}; diff --git a/src/css/plotcss_injector.js b/src/css/plotcss_injector.js new file mode 100644 index 00000000000..4f4e54051ec --- /dev/null +++ b/src/css/plotcss_injector.js @@ -0,0 +1,52 @@ +/** +* Copyright 2012-2016, 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 helpers = require('./helpers'); +var lib = require('../lib'); +var plotcss = require('../../build/plotcss'); + +// Inject styling information into the document containing the graph div +module.exports = function injectStyles(gd) { + // If the graph div has already been styled, bail + if(gd._plotCSSLoaded) return; + + var targetSelectors = helpers.getAllRuleSelectors(gd._document); + var targetStyleSheet = null; + + if(gd._document.getElementsByTagName('style').length === 0) { + var style = gd._document.createElement('style'); + // WebKit hack :( + style.appendChild(gd._document.createTextNode('')); + gd._document.head.appendChild(style); + targetStyleSheet = style.sheet; + } + else { + // Just grab the first style element to append to + targetStyleSheet = gd._document.getElementsByTagName('style')[0].sheet; + } + + for(var selector in plotcss) { + var fullSelector = helpers.buildFullSelector(selector); + + // Don't duplicate selectors + if(targetSelectors.indexOf(fullSelector) === -1) { + if(targetStyleSheet.insertRule) { + targetStyleSheet.insertRule(fullSelector + '{' + plotcss[selector] + '}', 0); + } + else if(targetStyleSheet.addRule) { + targetStyleSheet.addRule(fullSelector, plotcss[selector], 0); + } + else lib.warn('injectStyles failed'); + } + } + + gd._plotCSSLoaded = true; +}; diff --git a/src/lib/index.js b/src/lib/index.js index 0a3d8bf7854..7a46be4e59c 100644 --- a/src/lib/index.js +++ b/src/lib/index.js @@ -388,30 +388,6 @@ lib.removeElement = function(el) { if(elParent) elParent.removeChild(el); }; -/** - * for dynamically adding style rules - * makes one stylesheet that contains all rules added - * by all calls to this function - */ -lib.addStyleRule = function(selector, styleString) { - if(!lib.styleSheet) { - var style = document.createElement('style'); - // WebKit hack :( - style.appendChild(document.createTextNode('')); - document.head.appendChild(style); - lib.styleSheet = style.sheet; - } - var styleSheet = lib.styleSheet; - - if(styleSheet.insertRule) { - styleSheet.insertRule(selector + '{' + styleString + '}', 0); - } - else if(styleSheet.addRule) { - styleSheet.addRule(selector, styleString, 0); - } - else lib.warn('addStyleRule failed'); -}; - lib.getTranslate = function(element) { var re = /.*\btranslate\((\d*\.?\d*)[^\d]*(\d*\.?\d*)[^\d].*/, diff --git a/src/lib/notifier.js b/src/lib/notifier.js index a1bfbfcc14f..ae6a741783f 100644 --- a/src/lib/notifier.js +++ b/src/lib/notifier.js @@ -16,12 +16,13 @@ var NOTEDATA = []; /** * notifier + * @param {object} gd figure Object * @param {String} text The person's user name * @param {Number} [delay=1000] The delay time in milliseconds * or 'long' which provides 2000 ms delay time. * @return {undefined} this function does not return a value */ -module.exports = function(text, displayLength) { +module.exports = function(gd, text, displayLength) { if(NOTEDATA.indexOf(text) !== -1) return; NOTEDATA.push(text); @@ -30,7 +31,7 @@ module.exports = function(text, displayLength) { if(isNumeric(displayLength)) ts = displayLength; else if(displayLength === 'long') ts = 3000; - var notifierContainer = d3.select('body') + var notifierContainer = d3.select(gd._document.body) .selectAll('.plotly-notifier') .data([0]); notifierContainer.enter() diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index 3cf9366e167..24f981a5abf 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -18,6 +18,8 @@ var Lib = require('../lib'); var Events = require('../lib/events'); var Queue = require('../lib/queue'); +var injectStyles = require('../css/plotcss_injector'); + var Plots = require('../plots/plots'); var Fx = require('../plots/cartesian/graph_interact'); @@ -54,6 +56,14 @@ Plotly.plot = function(gd, data, layout, config) { gd = getGraphDiv(gd); + // Get the document the graph div lives in, so we can make sure things like + // drag covers are attached to the correct document + gd._document = gd.ownerDocument || window.document; + + // Inject the plot styles into the document where we're plotting, bails if + // already styled + injectStyles(gd); + // Events.init is idempotent and bails early if gd has already been init'd Events.init(gd); @@ -2541,12 +2551,12 @@ function plotAutoSize(gd, aobj) { // embedded in an iframe - just take the full iframe size // if we get to this point, with no aspect ratio restrictions if(gd._context.fillFrame) { - newWidth = window.innerWidth; - newHeight = window.innerHeight; + newWidth = gd._document.defaultView.innerWidth; + newHeight = gd._document.defaultView.innerHeight; // somehow we get a few extra px height sometimes... // just hide it - document.body.style.overflow = 'hidden'; + gd._document.body.style.overflow = 'hidden'; } else if(isNumeric(context.frameMargins) && context.frameMargins > 0) { var reservedMargins = calculateReservedMargins(gd._boundingBoxMargins), @@ -2563,7 +2573,7 @@ function plotAutoSize(gd, aobj) { // provide height and width for the container div, // specify size in layout, or take the defaults, // but don't enforce any ratio restrictions - computedStyle = window.getComputedStyle(gd); + computedStyle = gd._document.defaultView.getComputedStyle(gd); newHeight = parseFloat(computedStyle.height) || fullLayout.height; newWidth = parseFloat(computedStyle.width) || fullLayout.width; } diff --git a/src/plotly.js b/src/plotly.js index 5ceb2019839..491c0ed5a77 100644 --- a/src/plotly.js +++ b/src/plotly.js @@ -26,9 +26,6 @@ exports.Lib = require('./lib'); exports.util = require('./lib/svg_text_utils'); exports.Queue = require('./lib/queue'); -// plot css -require('../build/plotcss'); - // configuration exports.MathJaxConfig = require('./fonts/mathjax_config'); exports.defaultConfig = require('./plot_api/plot_config'); diff --git a/src/plots/cartesian/dragbox.js b/src/plots/cartesian/dragbox.js index ff6f7bc52bf..8e99adb1ab0 100644 --- a/src/plots/cartesian/dragbox.js +++ b/src/plots/cartesian/dragbox.js @@ -305,7 +305,7 @@ module.exports = function dragBox(gd, plotinfo, x, y, w, h, ns, ew) { dragTail(zoomMode); if(SHOWZOOMOUTTIP && gd.data && gd._context.showTips) { - Lib.notifier('Double-click to
zoom back out', 'long'); + Lib.notifier(gd, 'Double-click to
zoom back out', 'long'); SHOWZOOMOUTTIP = false; } } diff --git a/src/plots/cartesian/set_convert.js b/src/plots/cartesian/set_convert.js index 565c4ce53b3..5f34cde15e9 100644 --- a/src/plots/cartesian/set_convert.js +++ b/src/plots/cartesian/set_convert.js @@ -114,6 +114,7 @@ module.exports = function setConvert(ax) { if(!isFinite(ax._m) || !isFinite(ax._b)) { Lib.notifier( + ax._gd, 'Something went wrong with axis scaling', 'long'); ax._gd._replotting = false; diff --git a/src/plots/plots.js b/src/plots/plots.js index 4b814bfd85c..307c4d38547 100644 --- a/src/plots/plots.js +++ b/src/plots/plots.js @@ -809,6 +809,10 @@ plots.purge = function(gd) { // remove modebar if(fullLayout._modeBar) fullLayout._modeBar.destroy(); + // styling + delete gd._document; + delete gd._plotCSSLoaded; + // data and layout delete gd.data; delete gd.layout; diff --git a/src/plots/ternary/ternary.js b/src/plots/ternary/ternary.js index 1fd6d7094f7..9a93f376205 100644 --- a/src/plots/ternary/ternary.js +++ b/src/plots/ternary/ternary.js @@ -570,7 +570,7 @@ proto.initInteractions = function() { Plotly.relayout(gd, attrs); if(SHOWZOOMOUTTIP && gd.data && gd._context.showTips) { - Lib.notifier('Double-click to
zoom back out', 'long'); + Lib.notifier(gd, 'Double-click to
zoom back out', 'long'); SHOWZOOMOUTTIP = false; } } diff --git a/src/traces/heatmap/calc.js b/src/traces/heatmap/calc.js index e8611ccc82e..828c3288db9 100644 --- a/src/traces/heatmap/calc.js +++ b/src/traces/heatmap/calc.js @@ -73,7 +73,7 @@ module.exports = function calc(gd, trace) { function noZsmooth(msg) { zsmooth = trace._input.zsmooth = trace.zsmooth = false; - Lib.notifier('cannot fast-zsmooth: ' + msg); + Lib.notifier(gd, 'cannot fast-zsmooth: ' + msg); } // check whether we really can smooth (ie all boxes are about the same size) diff --git a/tasks/util/pull_css.js b/tasks/util/pull_css.js index 1f3cb6def53..ff5fefea671 100644 --- a/tasks/util/pull_css.js +++ b/tasks/util/pull_css.js @@ -38,15 +38,9 @@ module.exports = function pullCSS(data, pathOut) { var outStr = [ '\'use strict\';', '', - 'var Plotly = require(\'../src/plotly\');', 'var rules = ' + rulesStr + ';', '', - 'for(var selector in rules) {', - ' var fullSelector = selector.replace(/^,/,\' ,\')', - ' .replace(/X/g, \'.js-plotly-plot .plotly\')', - ' .replace(/Y/g, \'.plotly-notifier\');', - ' Plotly.Lib.addStyleRule(fullSelector, rules[selector]);', - '}', + 'module.exports = rules;', '' ].join('\n'); diff --git a/test/jasmine/tests/dragelement_test.js b/test/jasmine/tests/dragelement_test.js index 924f7f3bcaf..ad6abd29eb1 100644 --- a/test/jasmine/tests/dragelement_test.js +++ b/test/jasmine/tests/dragelement_test.js @@ -15,6 +15,7 @@ describe('dragElement', function() { this.element = document.createElement('div'); this.gd.className = 'js-plotly-plot'; + this.gd._document = document; this.gd._fullLayout = { _hoverlayer: d3.select(this.hoverlayer) }; diff --git a/test/jasmine/tests/plot_css_test.js b/test/jasmine/tests/plot_css_test.js new file mode 100644 index 00000000000..204d420f100 --- /dev/null +++ b/test/jasmine/tests/plot_css_test.js @@ -0,0 +1,152 @@ +var Plotly = require('@lib/index'); + +var createGraphDiv = require('../assets/create_graph_div'); +var destroyGraphDiv = require('../assets/destroy_graph_div'); + +describe('css injection', function() { + var helpers = require('@src/css/helpers'); + var plotcss = require('@build/plotcss'); + + // create a graph div in a child window + function createGraphDivInChildWindow() { + var childWindow = window.open('about:blank', 'popoutWindow', ''); + + var gd = childWindow.document.createElement('div'); + gd.id = 'graph'; + childWindow.document.body.appendChild(gd); + + // force the graph to be at position 0,0 no matter what + gd.style.position = 'fixed'; + gd.style.left = 0; + gd.style.top = 0; + + return gd; + } + + // the most basic of basic plots + function plot(target) { + Plotly.plot(target, [{ + x: [1, 2, 3, 4, 5], + y: [1, 2, 4, 8, 16] + }], { + margin: { + t: 0 + } + }); + } + + // deletes all rules defined in plotcss + function deletePlotCSSRules(sourceDocument) { + for(var selector in plotcss) { + var fullSelector = helpers.buildFullSelector(selector); + + for(var i = 0; i < sourceDocument.styleSheets.length; i++) { + var styleSheet = sourceDocument.styleSheets[i]; + var selectors = []; + + for(var j = 0; j < styleSheet.cssRules.length; j++) { + var cssRule = styleSheet.cssRules[j]; + + selectors.push(cssRule.selectorText); + } + + var selectorIndex = selectors.indexOf(fullSelector); + + if(selectorIndex !== -1) { + styleSheet.deleteRule(selectorIndex); + break; + } + } + } + } + + it('inserts styles on initial plot', function() { + deletePlotCSSRules(document); // clear the rules + + // fix scope errors + var selector = null; + var fullSelector = null; + + // make sure the rules are cleared + var allSelectors = helpers.getAllRuleSelectors(document); + + for(selector in plotcss) { + fullSelector = helpers.buildFullSelector(selector); + + expect(allSelectors.indexOf(fullSelector)).toEqual(-1); + } + + // plot + var gd = createGraphDiv(); + plot(gd); + + // check for styles + allSelectors = helpers.getAllRuleSelectors(document); + + for(selector in plotcss) { + fullSelector = helpers.buildFullSelector(selector); + + expect(allSelectors.indexOf(fullSelector)).not.toEqual(-1); + } + + // clean up + destroyGraphDiv(); + }); + + it('inserts styles in a child window document', function() { + var gd = createGraphDivInChildWindow(); + var childWindow = gd.ownerDocument.defaultView; + + // plot + plot(gd); + + // check for styles + var allSelectors = helpers.getAllRuleSelectors(gd.ownerDocument); + + for(var selector in plotcss) { + var fullSelector = helpers.buildFullSelector(selector); + + expect(allSelectors.indexOf(fullSelector)).not.toEqual(-1); + } + + // clean up + childWindow.close(); + }); + + it('does not insert duplicate styles', function() { + deletePlotCSSRules(document); // clear the rules + + // fix scope errors + var selector = null; + var fullSelector = null; + + // make sure the rules are cleared + var allSelectors = helpers.getAllRuleSelectors(document); + + for(selector in plotcss) { + fullSelector = helpers.buildFullSelector(selector); + + expect(allSelectors.indexOf(fullSelector)).toEqual(-1); + } + + // plot + var gd = createGraphDiv(); + plot(gd); + plot(gd); // plot again so injectStyles gets called again + + // check for styles + allSelectors = helpers.getAllRuleSelectors(document); + + for(selector in plotcss) { + fullSelector = helpers.buildFullSelector(selector); + + var firstIndex = allSelectors.indexOf(fullSelector); + + // there should be no occurences after the initial one + expect(allSelectors.indexOf(fullSelector, firstIndex + 1)).toEqual(-1); + } + + // clean up + destroyGraphDiv(); + }); +}); diff --git a/test/jasmine/tests/plot_interact_test.js b/test/jasmine/tests/plot_interact_test.js index aa9df30fc62..68ffdc4ee95 100644 --- a/test/jasmine/tests/plot_interact_test.js +++ b/test/jasmine/tests/plot_interact_test.js @@ -625,3 +625,144 @@ describe('plot svg clip paths', function() { }); }); }); + +describe('css injection', function() { + var helpers = require('../../../src/css/helpers'); + var plotcss = require('../../../build/plotcss') + + // create a graph div in a child window + function createGraphDivInChildWindow() { + var childWindow = window.open('about:blank', 'popoutWindow', ''); + + var gd = childWindow.document.createElement('div'); + gd.id = 'graph'; + childWindow.document.body.appendChild(gd); + + // force the graph to be at position 0,0 no matter what + gd.style.position = 'fixed'; + gd.style.left = 0; + gd.style.top = 0; + + return gd; + } + + // the most basic of basic plots + function plot(target) { + Plotly.plot(target, [{ + x: [1, 2, 3, 4, 5], + y: [1, 2, 4, 8, 16] + }], { + margin: { + t: 0 + } + }); + } + + // deletes all rules defined in plotcss + function deletePlotCSSRules(sourceDocument) { + for(var selector in plotcss) { + var ruleDeleted = false; + var fullSelector = helpers.buildFullSelector(selector); + + for(var i = 0; i < sourceDocument.styleSheets.length; i++) { + var styleSheet = sourceDocument.styleSheets[i]; + var selectors = [] + + for(var j = 0; j < styleSheet.cssRules.length; j++) { + var cssRule = styleSheet.cssRules[j]; + + selectors.push(cssRule.selectorText); + } + + var selectorIndex = selectors.indexOf(fullSelector); + + if(selectorIndex !== -1) { + styleSheet.deleteRule(selectorIndex); + break; + } + } + } + } + + it('inserts styles on initial plot', function() { + deletePlotCSSRules(document); // clear the rules + + // make sure the rules are clared + var allSelectors = helpers.getAllRuleSelectors(document); + + for(var selector in plotcss) { + var fullSelector = helpers.buildFullSelector(selector); + + expect(allSelectors.indexOf(fullSelector)).toEqual(-1); + } + + // plot + var gd = createGraphDiv(); + plot(gd); + + // check for styles + allSelectors = helpers.getAllRuleSelectors(document); + + for(var selector in plotcss) { + var fullSelector = helpers.buildFullSelector(selector); + + expect(allSelectors.indexOf(fullSelector)).not.toEqual(-1); + } + + // clean up + destroyGraphDiv(); + }); + + it('inserts styles in a child window document', function() { + var gd = createGraphDivInChildWindow(); + var childWindow = gd.ownerDocument.defaultView; + + // plot + plot(gd); + + // check for styles + allSelectors = helpers.getAllRuleSelectors(gd.ownerDocument); + + for(var selector in plotcss) { + var fullSelector = helpers.buildFullSelector(selector); + + expect(allSelectors.indexOf(fullSelector)).not.toEqual(-1); + } + + // clean up + childWindow.close(); + }); + + it('does not insert duplicate styles', function() { + deletePlotCSSRules(document); // clear the rules + + // make sure the rules are clared + var allSelectors = helpers.getAllRuleSelectors(document); + + for(var selector in plotcss) { + var fullSelector = helpers.buildFullSelector(selector); + + expect(allSelectors.indexOf(fullSelector)).toEqual(-1); + } + + // plot + var gd = createGraphDiv(); + plot(gd); + plot(gd); // plot again so injectStyles gets called again + + // check for styles + allSelectors = helpers.getAllRuleSelectors(document); + + for(var selector in plotcss) { + var fullSelector = helpers.buildFullSelector(selector); + + var firstIndex = allSelectors.indexOf(fullSelector); + + // there should be no occurences after the initial one + expect(allSelectors.indexOf(fullSelector, firstIndex + 1)).toEqual(-1); + } + + // clean up + destroyGraphDiv(); + }); +}); From d5a88342d2f0b2cfa729b29c4fad37c670c9c525 Mon Sep 17 00:00:00 2001 From: Brandon Nielsen Date: Thu, 21 Jul 2016 18:11:40 -0500 Subject: [PATCH 26/82] Move injectStyles and helpers to lib/plotcss_utils This keeps the css directory free of Javascript. The helpers have been moved inside the plotcss_utils.js file instead of a separate helpers file. The plot API and tests have been updated accordingly. Removed duplicate tests in plot_interact_tests.js --- src/css/helpers.js | 38 ----- src/lib/index.js | 3 + .../plotcss_utils.js} | 37 ++++- src/plot_api/plot_api.js | 4 +- test/jasmine/tests/plot_css_test.js | 24 +-- test/jasmine/tests/plot_interact_test.js | 141 ------------------ 6 files changed, 48 insertions(+), 199 deletions(-) delete mode 100644 src/css/helpers.js rename src/{css/plotcss_injector.js => lib/plotcss_utils.js} (57%) diff --git a/src/css/helpers.js b/src/css/helpers.js deleted file mode 100644 index ebf4c6470a0..00000000000 --- a/src/css/helpers.js +++ /dev/null @@ -1,38 +0,0 @@ -/** -* Copyright 2012-2016, 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'; - -// expands a plotcss selector -exports.buildFullSelector = function buildFullSelector(selector) { - var fullSelector = selector.replace(/,/, ', ') - .replace(/:after/g, '::after') - .replace(/:before/g, '::before') - .replace(/X/g, '.js-plotly-plot .plotly') - .replace(/Y/g, '.plotly-notifier'); - - return fullSelector; -}; - -// Gets all the rules currently attached to the document -exports.getAllRuleSelectors = function getAllRuleSelectors(sourceDocument) { - var allSelectors = []; - - for(var i = 0; i < sourceDocument.styleSheets.length; i++) { - var styleSheet = sourceDocument.styleSheets[i]; - - for(var j = 0; j < styleSheet.cssRules.length; j++) { - var cssRule = styleSheet.cssRules[j]; - - allSelectors.push(cssRule.selectorText); - } - } - - return allSelectors; -}; diff --git a/src/lib/index.js b/src/lib/index.js index 7a46be4e59c..985220842ee 100644 --- a/src/lib/index.js +++ b/src/lib/index.js @@ -64,6 +64,9 @@ lib.log = loggersModule.log; lib.warn = loggersModule.warn; lib.error = loggersModule.error; +var cssModule = require('./plotcss_utils'); +lib.injectStyles = cssModule.injectStyles; + lib.notifier = require('./notifier'); /** diff --git a/src/css/plotcss_injector.js b/src/lib/plotcss_utils.js similarity index 57% rename from src/css/plotcss_injector.js rename to src/lib/plotcss_utils.js index 4f4e54051ec..4780aadbb25 100644 --- a/src/css/plotcss_injector.js +++ b/src/lib/plotcss_utils.js @@ -9,16 +9,15 @@ 'use strict'; -var helpers = require('./helpers'); -var lib = require('../lib'); +var lib = require('./index'); var plotcss = require('../../build/plotcss'); // Inject styling information into the document containing the graph div -module.exports = function injectStyles(gd) { +exports.injectStyles = function injectStyles(gd) { // If the graph div has already been styled, bail if(gd._plotCSSLoaded) return; - var targetSelectors = helpers.getAllRuleSelectors(gd._document); + var targetSelectors = module.exports.getAllRuleSelectors(gd._document); var targetStyleSheet = null; if(gd._document.getElementsByTagName('style').length === 0) { @@ -34,7 +33,7 @@ module.exports = function injectStyles(gd) { } for(var selector in plotcss) { - var fullSelector = helpers.buildFullSelector(selector); + var fullSelector = module.exports.buildFullSelector(selector); // Don't duplicate selectors if(targetSelectors.indexOf(fullSelector) === -1) { @@ -50,3 +49,31 @@ module.exports = function injectStyles(gd) { gd._plotCSSLoaded = true; }; + +// expands a plotcss selector +exports.buildFullSelector = function buildFullSelector(selector) { + var fullSelector = selector.replace(/,/, ', ') + .replace(/:after/g, '::after') + .replace(/:before/g, '::before') + .replace(/X/g, '.js-plotly-plot .plotly') + .replace(/Y/g, '.plotly-notifier'); + + return fullSelector; +}; + +// Gets all the rules currently attached to the document +exports.getAllRuleSelectors = function getAllRuleSelectors(sourceDocument) { + var allSelectors = []; + + for(var i = 0; i < sourceDocument.styleSheets.length; i++) { + var styleSheet = sourceDocument.styleSheets[i]; + + for(var j = 0; j < styleSheet.cssRules.length; j++) { + var cssRule = styleSheet.cssRules[j]; + + allSelectors.push(cssRule.selectorText); + } + } + + return allSelectors; +}; diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index 24f981a5abf..66ccda4a344 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -18,8 +18,6 @@ var Lib = require('../lib'); var Events = require('../lib/events'); var Queue = require('../lib/queue'); -var injectStyles = require('../css/plotcss_injector'); - var Plots = require('../plots/plots'); var Fx = require('../plots/cartesian/graph_interact'); @@ -62,7 +60,7 @@ Plotly.plot = function(gd, data, layout, config) { // Inject the plot styles into the document where we're plotting, bails if // already styled - injectStyles(gd); + Lib.injectStyles(gd); // Events.init is idempotent and bails early if gd has already been init'd Events.init(gd); diff --git a/test/jasmine/tests/plot_css_test.js b/test/jasmine/tests/plot_css_test.js index 204d420f100..830a606c39f 100644 --- a/test/jasmine/tests/plot_css_test.js +++ b/test/jasmine/tests/plot_css_test.js @@ -4,7 +4,7 @@ var createGraphDiv = require('../assets/create_graph_div'); var destroyGraphDiv = require('../assets/destroy_graph_div'); describe('css injection', function() { - var helpers = require('@src/css/helpers'); + var plotcss_utils = require('@src/lib/plotcss_utils'); var plotcss = require('@build/plotcss'); // create a graph div in a child window @@ -38,7 +38,7 @@ describe('css injection', function() { // deletes all rules defined in plotcss function deletePlotCSSRules(sourceDocument) { for(var selector in plotcss) { - var fullSelector = helpers.buildFullSelector(selector); + var fullSelector = plotcss_utils.buildFullSelector(selector); for(var i = 0; i < sourceDocument.styleSheets.length; i++) { var styleSheet = sourceDocument.styleSheets[i]; @@ -68,10 +68,10 @@ describe('css injection', function() { var fullSelector = null; // make sure the rules are cleared - var allSelectors = helpers.getAllRuleSelectors(document); + var allSelectors = plotcss_utils.getAllRuleSelectors(document); for(selector in plotcss) { - fullSelector = helpers.buildFullSelector(selector); + fullSelector = plotcss_utils.buildFullSelector(selector); expect(allSelectors.indexOf(fullSelector)).toEqual(-1); } @@ -81,10 +81,10 @@ describe('css injection', function() { plot(gd); // check for styles - allSelectors = helpers.getAllRuleSelectors(document); + allSelectors = plotcss_utils.getAllRuleSelectors(document); for(selector in plotcss) { - fullSelector = helpers.buildFullSelector(selector); + fullSelector = plotcss_utils.buildFullSelector(selector); expect(allSelectors.indexOf(fullSelector)).not.toEqual(-1); } @@ -101,10 +101,10 @@ describe('css injection', function() { plot(gd); // check for styles - var allSelectors = helpers.getAllRuleSelectors(gd.ownerDocument); + var allSelectors = plotcss_utils.getAllRuleSelectors(gd.ownerDocument); for(var selector in plotcss) { - var fullSelector = helpers.buildFullSelector(selector); + var fullSelector = plotcss_utils.buildFullSelector(selector); expect(allSelectors.indexOf(fullSelector)).not.toEqual(-1); } @@ -121,10 +121,10 @@ describe('css injection', function() { var fullSelector = null; // make sure the rules are cleared - var allSelectors = helpers.getAllRuleSelectors(document); + var allSelectors = plotcss_utils.getAllRuleSelectors(document); for(selector in plotcss) { - fullSelector = helpers.buildFullSelector(selector); + fullSelector = plotcss_utils.buildFullSelector(selector); expect(allSelectors.indexOf(fullSelector)).toEqual(-1); } @@ -135,10 +135,10 @@ describe('css injection', function() { plot(gd); // plot again so injectStyles gets called again // check for styles - allSelectors = helpers.getAllRuleSelectors(document); + allSelectors = plotcss_utils.getAllRuleSelectors(document); for(selector in plotcss) { - fullSelector = helpers.buildFullSelector(selector); + fullSelector = plotcss_utils.buildFullSelector(selector); var firstIndex = allSelectors.indexOf(fullSelector); diff --git a/test/jasmine/tests/plot_interact_test.js b/test/jasmine/tests/plot_interact_test.js index 68ffdc4ee95..aa9df30fc62 100644 --- a/test/jasmine/tests/plot_interact_test.js +++ b/test/jasmine/tests/plot_interact_test.js @@ -625,144 +625,3 @@ describe('plot svg clip paths', function() { }); }); }); - -describe('css injection', function() { - var helpers = require('../../../src/css/helpers'); - var plotcss = require('../../../build/plotcss') - - // create a graph div in a child window - function createGraphDivInChildWindow() { - var childWindow = window.open('about:blank', 'popoutWindow', ''); - - var gd = childWindow.document.createElement('div'); - gd.id = 'graph'; - childWindow.document.body.appendChild(gd); - - // force the graph to be at position 0,0 no matter what - gd.style.position = 'fixed'; - gd.style.left = 0; - gd.style.top = 0; - - return gd; - } - - // the most basic of basic plots - function plot(target) { - Plotly.plot(target, [{ - x: [1, 2, 3, 4, 5], - y: [1, 2, 4, 8, 16] - }], { - margin: { - t: 0 - } - }); - } - - // deletes all rules defined in plotcss - function deletePlotCSSRules(sourceDocument) { - for(var selector in plotcss) { - var ruleDeleted = false; - var fullSelector = helpers.buildFullSelector(selector); - - for(var i = 0; i < sourceDocument.styleSheets.length; i++) { - var styleSheet = sourceDocument.styleSheets[i]; - var selectors = [] - - for(var j = 0; j < styleSheet.cssRules.length; j++) { - var cssRule = styleSheet.cssRules[j]; - - selectors.push(cssRule.selectorText); - } - - var selectorIndex = selectors.indexOf(fullSelector); - - if(selectorIndex !== -1) { - styleSheet.deleteRule(selectorIndex); - break; - } - } - } - } - - it('inserts styles on initial plot', function() { - deletePlotCSSRules(document); // clear the rules - - // make sure the rules are clared - var allSelectors = helpers.getAllRuleSelectors(document); - - for(var selector in plotcss) { - var fullSelector = helpers.buildFullSelector(selector); - - expect(allSelectors.indexOf(fullSelector)).toEqual(-1); - } - - // plot - var gd = createGraphDiv(); - plot(gd); - - // check for styles - allSelectors = helpers.getAllRuleSelectors(document); - - for(var selector in plotcss) { - var fullSelector = helpers.buildFullSelector(selector); - - expect(allSelectors.indexOf(fullSelector)).not.toEqual(-1); - } - - // clean up - destroyGraphDiv(); - }); - - it('inserts styles in a child window document', function() { - var gd = createGraphDivInChildWindow(); - var childWindow = gd.ownerDocument.defaultView; - - // plot - plot(gd); - - // check for styles - allSelectors = helpers.getAllRuleSelectors(gd.ownerDocument); - - for(var selector in plotcss) { - var fullSelector = helpers.buildFullSelector(selector); - - expect(allSelectors.indexOf(fullSelector)).not.toEqual(-1); - } - - // clean up - childWindow.close(); - }); - - it('does not insert duplicate styles', function() { - deletePlotCSSRules(document); // clear the rules - - // make sure the rules are clared - var allSelectors = helpers.getAllRuleSelectors(document); - - for(var selector in plotcss) { - var fullSelector = helpers.buildFullSelector(selector); - - expect(allSelectors.indexOf(fullSelector)).toEqual(-1); - } - - // plot - var gd = createGraphDiv(); - plot(gd); - plot(gd); // plot again so injectStyles gets called again - - // check for styles - allSelectors = helpers.getAllRuleSelectors(document); - - for(var selector in plotcss) { - var fullSelector = helpers.buildFullSelector(selector); - - var firstIndex = allSelectors.indexOf(fullSelector); - - // there should be no occurences after the initial one - expect(allSelectors.indexOf(fullSelector, firstIndex + 1)).toEqual(-1); - } - - // clean up - destroyGraphDiv(); - }); -}); From f218e09adbdf5c56615fa9b116985154b071c936 Mon Sep 17 00:00:00 2001 From: Brandon Nielsen Date: Fri, 22 Jul 2016 13:14:12 -0500 Subject: [PATCH 27/82] Handle case where cssRules are undefined. It's possible for a stylesheet's cssRules property to be undefined. In that case, break out of the rule aggregating loop. Don't explicitly refer to module.exports. --- src/lib/plotcss_utils.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/lib/plotcss_utils.js b/src/lib/plotcss_utils.js index 4780aadbb25..58f7383e8f6 100644 --- a/src/lib/plotcss_utils.js +++ b/src/lib/plotcss_utils.js @@ -17,7 +17,7 @@ exports.injectStyles = function injectStyles(gd) { // If the graph div has already been styled, bail if(gd._plotCSSLoaded) return; - var targetSelectors = module.exports.getAllRuleSelectors(gd._document); + var targetSelectors = exports.getAllRuleSelectors(gd._document); var targetStyleSheet = null; if(gd._document.getElementsByTagName('style').length === 0) { @@ -33,7 +33,7 @@ exports.injectStyles = function injectStyles(gd) { } for(var selector in plotcss) { - var fullSelector = module.exports.buildFullSelector(selector); + var fullSelector = exports.buildFullSelector(selector); // Don't duplicate selectors if(targetSelectors.indexOf(fullSelector) === -1) { @@ -68,6 +68,8 @@ exports.getAllRuleSelectors = function getAllRuleSelectors(sourceDocument) { for(var i = 0; i < sourceDocument.styleSheets.length; i++) { var styleSheet = sourceDocument.styleSheets[i]; + if(!styleSheet.cssRules) continue; // It's possible for rules to be undefined + for(var j = 0; j < styleSheet.cssRules.length; j++) { var cssRule = styleSheet.cssRules[j]; From 8f4e11e01e6a2794c5fe97802018ce74a099e0f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= Date: Mon, 25 Jul 2016 16:08:33 -0400 Subject: [PATCH 28/82] clear promise queue on restyle and relayout (not after Plotly.plot) --- src/plot_api/plot_api.js | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index 9dc8c7b1e57..812b845e1a3 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -326,10 +326,6 @@ Plotly.plot = function(gd, data, layout, config) { // so that the caller doesn't care which route we took return Promise.all(gd._promises).then(function() { return gd; - }, function() { - // clear the promise queue if one of them got rejected - Lib.log('Clearing previous rejected promises from queue.'); - gd._promises = []; }); }; @@ -355,6 +351,12 @@ function getGraphDiv(gd) { return gd; // otherwise assume that gd is a DOM element } +// clear the promise queue if one of them got rejected +function clearPromiseQueue(gd) { + Lib.log('Clearing previous rejected promises from queue.'); + gd._promises = []; +} + function opaqueSetBackground(gd, bgColor) { gd._fullLayout._paperdiv.style('background', 'white'); Plotly.defaultConfig.setBackground(gd, bgColor); @@ -1536,6 +1538,7 @@ Plotly.moveTraces = function moveTraces(gd, currentIndices, newIndices) { // style files that want to specify cyclical default values). Plotly.restyle = function restyle(gd, astr, val, traces) { gd = getGraphDiv(gd); + clearPromiseQueue(gd); var i, fullLayout = gd._fullLayout, aobj = {}; @@ -2076,6 +2079,7 @@ function swapXYData(trace) { // allows setting multiple attributes simultaneously Plotly.relayout = function relayout(gd, astr, val) { gd = getGraphDiv(gd); + clearPromiseQueue(gd); if(gd.framework && gd.framework.isPolar) { return Promise.resolve(gd); From 30a3fbe213f13521c929d115f8b08099c0045434 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= Date: Mon, 25 Jul 2016 16:09:55 -0400 Subject: [PATCH 29/82] fix mapbox access token tests --- test/jasmine/tests/mapbox_test.js | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/test/jasmine/tests/mapbox_test.js b/test/jasmine/tests/mapbox_test.js index f8acf99dd69..1adad1f9f10 100644 --- a/test/jasmine/tests/mapbox_test.js +++ b/test/jasmine/tests/mapbox_test.js @@ -180,6 +180,8 @@ describe('mapbox credentials', function() { }); it('should throw error if token is invalid', function(done) { + var cnt = 0; + Plotly.plot(gd, [{ type: 'scattermapbox', lon: [10, 20, 30], @@ -187,11 +189,17 @@ describe('mapbox credentials', function() { }], {}, { mapboxAccessToken: dummyToken }).catch(function(err) { + cnt++; expect(err).toEqual(new Error(constants.mapOnErrorMsg)); - }).then(done); + }).then(function() { + expect(cnt).toEqual(1); + done(); + }); }); it('should use access token in mapbox layout options if present', function(done) { + var cnt = 0; + Plotly.plot(gd, [{ type: 'scattermapbox', lon: [10, 20, 30], @@ -202,7 +210,10 @@ describe('mapbox credentials', function() { } }, { mapboxAccessToken: dummyToken + }).catch(function() { + cnt++; }).then(function() { + expect(cnt).toEqual(0); expect(gd._fullLayout.mapbox.accesstoken).toEqual(MAPBOX_ACCESS_TOKEN); done(); }); @@ -493,21 +504,19 @@ describe('mapbox plots', function() { }); it('should be able to update the access token', function(done) { - var promise = Plotly.relayout(gd, 'mapbox.accesstoken', 'wont-work'); - - promise.catch(function(err) { + Plotly.relayout(gd, 'mapbox.accesstoken', 'wont-work').catch(function(err) { expect(gd._fullLayout.mapbox.accesstoken).toEqual('wont-work'); expect(err).toEqual(new Error(constants.mapOnErrorMsg)); - }); + expect(gd._promises.length).toEqual(1); - promise.then(function() { return Plotly.relayout(gd, 'mapbox.accesstoken', MAPBOX_ACCESS_TOKEN); }).then(function() { expect(gd._fullLayout.mapbox.accesstoken).toEqual(MAPBOX_ACCESS_TOKEN); - }).then(done); + expect(gd._promises.length).toEqual(0); + done(); + }); }); - it('should be able to update traces', function(done) { function assertDataPts(lengths) { var lines = getGeoJsonData(gd, 'lines'), From 79e6e4b9a2f9c9272ad2fdf77c93546f43e4ad6b Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Tue, 26 Jul 2016 13:40:30 -0400 Subject: [PATCH 30/82] Add 'in' to filter transform --- src/traces/scatter/select.js | 3 ++- test/jasmine/assets/transforms/filter.js | 9 ++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/traces/scatter/select.js b/src/traces/scatter/select.js index fbe0cd63a6f..03f5ed8f39a 100644 --- a/src/traces/scatter/select.js +++ b/src/traces/scatter/select.js @@ -45,7 +45,8 @@ module.exports = function selectPoints(searchInfo, polygon) { curveNumber: curveNumber, pointNumber: i, x: di.x, - y: di.y + y: di.y, + identifier: di.identifier }); di.dim = 0; } diff --git a/test/jasmine/assets/transforms/filter.js b/test/jasmine/assets/transforms/filter.js index 95215fc50cf..9f004c46e61 100644 --- a/test/jasmine/assets/transforms/filter.js +++ b/test/jasmine/assets/transforms/filter.js @@ -16,16 +16,17 @@ exports.name = 'filter'; exports.attributes = { operation: { valType: 'enumerated', - values: ['=', '<', '>'], + values: ['=', '<', '>', 'in'], dflt: '=' }, value: { - valType: 'number', + valType: 'any', + arrayOk: true, dflt: 0 }, filtersrc: { valType: 'enumerated', - values: ['x', 'y'], + values: ['x', 'y', 'identifier'], dflt: 'x' } }; @@ -129,6 +130,8 @@ function getFilterFunc(opts) { return function(v) { return v < value; }; case '>': return function(v) { return v > value; }; + case 'in': + return function(v) { return value.indexOf(v) !== -1 }; } } From feb76360933a039b0c5c572be7048d443fa139b3 Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Tue, 26 Jul 2016 15:59:50 -0400 Subject: [PATCH 31/82] Clean up scatter trace lines --- src/plots/cartesian/transition_axes.js | 17 ++++++- src/traces/scatter/plot.js | 65 +++++++++++++++----------- 2 files changed, 53 insertions(+), 29 deletions(-) diff --git a/src/plots/cartesian/transition_axes.js b/src/plots/cartesian/transition_axes.js index 0eec069ac08..96aa8b8eca7 100644 --- a/src/plots/cartesian/transition_axes.js +++ b/src/plots/cartesian/transition_axes.js @@ -148,7 +148,13 @@ module.exports = function transitionAxes(gd, newLayout, transitionConfig) { subplot.plot .call(Lib.setTranslate, plotDx, plotDy) - .call(Lib.setScale, xScaleFactor, yScaleFactor); + .call(Lib.setScale, xScaleFactor, yScaleFactor) + + // This is specifically directed at scatter traces, applying an inverse + // scale to individual points to counteract the scale of the trace + // as a whole: + .selectAll('.points').selectAll('.point') + .call(Lib.setPointGroupScale, 1 / xScaleFactor, 1 / yScaleFactor); } @@ -220,7 +226,14 @@ module.exports = function transitionAxes(gd, newLayout, transitionConfig) { subplot.plot .call(Lib.setTranslate, plotDx, plotDy) - .call(Lib.setScale, xScaleFactor, yScaleFactor); + .call(Lib.setScale, xScaleFactor, yScaleFactor) + + // This is specifically directed at scatter traces, applying an inverse + // scale to individual points to counteract the scale of the trace + // as a whole: + .selectAll('.points').selectAll('.point') + .call(Lib.setPointGroupScale, 1 / xScaleFactor, 1 / yScaleFactor); + } // transitionTail - finish a drag event with a redraw diff --git a/src/traces/scatter/plot.js b/src/traces/scatter/plot.js index 9ad22ae3a2c..82fb7fe5fa2 100644 --- a/src/traces/scatter/plot.js +++ b/src/traces/scatter/plot.js @@ -103,8 +103,8 @@ function createFills(gd, scatterlayer) { trace._nextFill = null; } - if(trace.fill.substr(0, 6) === 'tozero' || trace.fill === 'toself' || - (trace.fill.substr(0, 2) === 'to' && !trace._prevtrace)) { + if(trace.fill && (trace.fill.substr(0, 6) === 'tozero' || trace.fill === 'toself' || + (trace.fill.substr(0, 2) === 'to' && !trace._prevtrace))) { trace._ownFill = tr.select('.js-fill.js-tozero'); if(!trace._ownFill.size()) { trace._ownFill = tr.insert('path', ':first-child').attr('class', 'js-fill js-tozero'); @@ -237,42 +237,53 @@ function plotOne(gd, idx, plotinfo, cdscatter, cdscatterAll, element, transition } if(segments.length) { - var pts; var pt0 = segments[0][0], lastSegment = segments[segments.length - 1], pt1 = lastSegment[lastSegment.length - 1]; + } - //var lineJoin = tr.selectAll('.js-line').data(segments); - //lineJoin.enter().append('path').classed('js-line', true); + var lineSegments = segments.filter(function (s) { + return s.length > 1; + }); - for(i = 0; i < segments.length; i++) { - pts = segments[i]; - thispath = pathfn(pts); - thisrevpath = revpathfn(pts); - if(!fullpath) { - fullpath = thispath; - revpath = thisrevpath; - } - else if(ownFillDir) { - fullpath += 'L' + thispath.substr(1); - revpath = thisrevpath + ('L' + revpath.substr(1)); - } - else { - fullpath += 'Z' + thispath; - revpath = thisrevpath + 'Z' + revpath; - } + var lineJoin = tr.selectAll('.js-line').data(lineSegments); + + var lineEnter = lineJoin.enter().append('path') + .classed('js-line', true) + .style('vector-effect', 'non-scaling-stroke') + .call(Drawing.lineGroupStyle) + + lineJoin.each(function(pts) { + thispath = pathfn(pts); + thisrevpath = revpathfn(pts); + if(!fullpath) { + fullpath = thispath; + revpath = thisrevpath; + } + else if(ownFillDir) { + fullpath += 'L' + thispath.substr(1); + revpath = thisrevpath + ('L' + revpath.substr(1)); + } + else { + fullpath += 'Z' + thispath; + revpath = thisrevpath + 'Z' + revpath; } if(subTypes.hasLines(trace) && pts.length > 1) { - var lineJoin = tr.selectAll('.js-line').data([cdscatter]); + var el = d3.select(this); + el.datum(cdscatter); + transition(el).attr('d', thispath) + .call(Drawing.lineGroupStyle); - lineJoin.enter() - .append('path').classed('js-line', true).style('vector-effect', 'non-scaling-stroke').attr('d', fullpath); - - transition(lineJoin).attr('d', fullpath); } - //lineJoin.exit().remove(); + }); + + transition(lineJoin.exit()) + .style('opacity', 0) + .remove(); + + if(segments.length) { if(ownFillEl3) { if(pt0 && pt1) { if(ownFillDir) { From ff3755706f9b0b25bdfbd5ca370809a71c9e7293 Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Tue, 26 Jul 2016 16:24:06 -0400 Subject: [PATCH 32/82] Fix lint errors --- src/traces/scatter/plot.js | 14 ++++++++------ test/jasmine/assets/transforms/filter.js | 2 +- test/jasmine/tests/plots_test.js | 2 +- test/jasmine/tests/transforms_test.js | 14 +++++++------- test/jasmine/tests/validate_test.js | 4 ++-- 5 files changed, 19 insertions(+), 17 deletions(-) diff --git a/src/traces/scatter/plot.js b/src/traces/scatter/plot.js index 82fb7fe5fa2..4c22d728fc5 100644 --- a/src/traces/scatter/plot.js +++ b/src/traces/scatter/plot.js @@ -236,22 +236,24 @@ function plotOne(gd, idx, plotinfo, cdscatter, cdscatterAll, element, transition trace._polygons[i] = polygonTester(segments[i]); } + var pt0, lastSegment, pt1; + if(segments.length) { - var pt0 = segments[0][0], - lastSegment = segments[segments.length - 1], - pt1 = lastSegment[lastSegment.length - 1]; + pt0 = segments[0][0]; + lastSegment = segments[segments.length - 1]; + pt1 = lastSegment[lastSegment.length - 1]; } - var lineSegments = segments.filter(function (s) { + var lineSegments = segments.filter(function(s) { return s.length > 1; }); var lineJoin = tr.selectAll('.js-line').data(lineSegments); - var lineEnter = lineJoin.enter().append('path') + lineJoin.enter().append('path') .classed('js-line', true) .style('vector-effect', 'non-scaling-stroke') - .call(Drawing.lineGroupStyle) + .call(Drawing.lineGroupStyle); lineJoin.each(function(pts) { thispath = pathfn(pts); diff --git a/test/jasmine/assets/transforms/filter.js b/test/jasmine/assets/transforms/filter.js index 9f004c46e61..5dedd82a5bf 100644 --- a/test/jasmine/assets/transforms/filter.js +++ b/test/jasmine/assets/transforms/filter.js @@ -131,7 +131,7 @@ function getFilterFunc(opts) { case '>': return function(v) { return v > value; }; case 'in': - return function(v) { return value.indexOf(v) !== -1 }; + return function(v) { return value.indexOf(v) !== -1; }; } } diff --git a/test/jasmine/tests/plots_test.js b/test/jasmine/tests/plots_test.js index aa9f52d228f..cd410a58066 100644 --- a/test/jasmine/tests/plots_test.js +++ b/test/jasmine/tests/plots_test.js @@ -25,7 +25,7 @@ describe('Test Plots', function() { xaxis: { c2p: function() {} }, yaxis: { _m: 20 }, scene: { _scene: {} }, - annotations: [{ _min: 10, }, { _max: 20 }], + annotations: [{ _min: 10 }, { _max: 20 }], someFunc: function() {} }; diff --git a/test/jasmine/tests/transforms_test.js b/test/jasmine/tests/transforms_test.js index d95eb1319bd..5b832a9436c 100644 --- a/test/jasmine/tests/transforms_test.js +++ b/test/jasmine/tests/transforms_test.js @@ -66,7 +66,7 @@ describe('one-to-one transforms:', function() { it('supplyDataDefaults should apply the transform while', function() { var dataIn = [{ x: [-2, -2, 1, 2, 3], - y: [1, 2, 2, 3, 1], + y: [1, 2, 2, 3, 1] }, { x: [-2, -1, -2, 0, 1, 2, 3], y: [1, 2, 3, 1, 2, 3, 1], @@ -288,12 +288,12 @@ describe('one-to-many transforms:', function() { it('supplyDataDefaults should apply the transform while', function() { var dummyTrace0 = { x: [-2, -2, 1, 2, 3], - y: [1, 2, 2, 3, 1], + y: [1, 2, 2, 3, 1] }; var dummyTrace1 = { x: [-1, 2, 3], - y: [2, 3, 1], + y: [2, 3, 1] }; var dataIn = [ @@ -493,12 +493,12 @@ describe('multiple transforms:', function() { it('supplyDataDefaults should apply the transform while', function() { var dummyTrace0 = { x: [-2, -2, 1, 2, 3], - y: [1, 2, 2, 3, 1], + y: [1, 2, 2, 3, 1] }; var dummyTrace1 = { x: [-1, 2, 3], - y: [2, 3, 1], + y: [2, 3, 1] }; var dataIn = [ @@ -718,12 +718,12 @@ describe('multiple traces with transforms:', function() { it('supplyDataDefaults should apply the transform while', function() { var dummyTrace0 = { x: [-2, -2, 1, 2, 3], - y: [1, 2, 2, 3, 1], + y: [1, 2, 2, 3, 1] }; var dummyTrace1 = { x: [-1, 2, 3], - y: [2, 3, 1], + y: [2, 3, 1] }; var dataIn = [ diff --git a/test/jasmine/tests/validate_test.js b/test/jasmine/tests/validate_test.js index 5ce33eb738f..461d87c951b 100644 --- a/test/jasmine/tests/validate_test.js +++ b/test/jasmine/tests/validate_test.js @@ -258,7 +258,7 @@ describe('Plotly.validate', function() { it('should work with attributes in registered transforms', function() { var base = { x: [-2, -1, -2, 0, 1, 2, 3], - y: [1, 2, 3, 1, 2, 3, 1], + y: [1, 2, 3, 1, 2, 3, 1] }; var out = Plotly.validate([ @@ -286,7 +286,7 @@ describe('Plotly.validate', function() { transforms: [{ type: 'no gonna work' }] - }), + }) ], { title: 'my transformed graph' }); From 1086023444032cfbf9b55a19d325c9ac1bcbbd20 Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Tue, 26 Jul 2016 16:49:32 -0400 Subject: [PATCH 33/82] Fix frame API tests and set queueLength as needed --- test/jasmine/tests/frame_api_test.js | 31 ++++++++++++++++------------ 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/test/jasmine/tests/frame_api_test.js b/test/jasmine/tests/frame_api_test.js index f35cc6310a9..b105d63f1fb 100644 --- a/test/jasmine/tests/frame_api_test.js +++ b/test/jasmine/tests/frame_api_test.js @@ -15,10 +15,15 @@ describe('Test frame api', function() { Plotly.plot(gd, mock).then(function() { f = gd._frameData._frames; h = gd._frameData._frameHash; + }).then(function() { + Plotly.setPlotConfig({ queueLength: 10 }); }).then(done); }); - afterEach(destroyGraphDiv); + afterEach(function() { + destroyGraphDiv(); + Plotly.setPlotConfig({queueLength: 0}); + }); describe('gd initialization', function() { it('creates an empty list for frames', function() { @@ -69,17 +74,17 @@ describe('Test frame api', function() { } function validate() { - for(i = 0; i < 10; i++) { - expect(f[i]).toEqual({name: 'frame' + i}); + for(i = 0; i < f.length; i++) { + expect(f[i].name).toEqual('frame' + i); } } Plotly.addFrames(gd, frames).then(validate).then(function() { - return Plotly.addFrames(gd, [{name: 'inserted1'}, {name: 'inserted2'}, {name: 'inserted3'}], [5, 7, undefined]); + return Plotly.addFrames(gd, [{name: 'frame5', x: [1]}, {name: 'frame7', x: [2]}, {name: 'frame10', x: [3]}], [5, 7, undefined]); }).then(function() { - expect(f[5]).toEqual({name: 'inserted1'}); - expect(f[7]).toEqual({name: 'inserted2'}); - expect(f[12]).toEqual({name: 'inserted3'}); + expect(f[5]).toEqual({name: 'frame5', x: [1]}); + expect(f[7]).toEqual({name: 'frame7', x: [2]}); + expect(f[10]).toEqual({name: 'frame10', x: [3]}); return Plotly.Queue.undo(gd); }).then(validate).catch(fail).then(done); @@ -93,17 +98,17 @@ describe('Test frame api', function() { } function validate() { - for(i = 0; i < 10; i++) { - expect(f[i]).toEqual({name: 'frame' + i}); + for(i = 0; i < f.length; i++) { + expect(f[i].name).toEqual('frame' + i); } } Plotly.addFrames(gd, frames).then(validate).then(function() { - return Plotly.addFrames(gd, [{name: 'inserted3'}, {name: 'inserted2'}, {name: 'inserted1'}], [undefined, 7, 5]); + return Plotly.addFrames(gd, [{name: 'frame10', x: [3]}, {name: 'frame7', x: [2]}, {name: 'frame5', x: [1]}], [undefined, 7, 5]); }).then(function() { - expect(f[5]).toEqual({name: 'inserted1'}); - expect(f[7]).toEqual({name: 'inserted2'}); - expect(f[12]).toEqual({name: 'inserted3'}); + expect(f[5]).toEqual({name: 'frame5', x: [1]}); + expect(f[7]).toEqual({name: 'frame7', x: [2]}); + expect(f[10]).toEqual({name: 'frame10', x: [3]}); return Plotly.Queue.undo(gd); }).then(validate).catch(fail).then(done); From 977afdd469a5b91dba70e4f387b4fa92f9b8ef15 Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Wed, 27 Jul 2016 10:31:49 -0400 Subject: [PATCH 34/82] Add missing scattergeo attribute --- src/traces/scattergeo/attributes.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/traces/scattergeo/attributes.js b/src/traces/scattergeo/attributes.js index 7ee7155fc1c..fc6e153856f 100644 --- a/src/traces/scattergeo/attributes.js +++ b/src/traces/scattergeo/attributes.js @@ -59,7 +59,8 @@ module.exports = { line: { color: scatterLineAttrs.color, width: scatterLineAttrs.width, - dash: scatterLineAttrs.dash + dash: scatterLineAttrs.dash, + simplify: scatterLineAttrs.simplify }, marker: extendFlat({}, { symbol: scatterMarkerAttrs.symbol, From 246f8159f3708c219d302dde65a600dda934c048 Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Wed, 27 Jul 2016 10:33:06 -0400 Subject: [PATCH 35/82] Add missing scatterternay attribute --- src/traces/scatterternary/attributes.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/traces/scatterternary/attributes.js b/src/traces/scatterternary/attributes.js index fb28327b348..b782eb3a112 100644 --- a/src/traces/scatterternary/attributes.js +++ b/src/traces/scatterternary/attributes.js @@ -76,6 +76,7 @@ module.exports = { color: scatterLineAttrs.color, width: scatterLineAttrs.width, dash: scatterLineAttrs.dash, + simplify: scatterLineAttrs.simplify, shape: extendFlat({}, scatterLineAttrs.shape, {values: ['linear', 'spline']}), smoothing: scatterLineAttrs.smoothing From db8a1c513e1d0335a0b842e34adb045b53c0b1e1 Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Wed, 27 Jul 2016 10:46:21 -0400 Subject: [PATCH 36/82] Remove animation mock --- test/image/mocks/animation.json | 85 ----------------------------- test/jasmine/tests/animate_test.js | 88 +++++++++++++++++++++++++++++- 2 files changed, 87 insertions(+), 86 deletions(-) delete mode 100644 test/image/mocks/animation.json diff --git a/test/image/mocks/animation.json b/test/image/mocks/animation.json deleted file mode 100644 index 34cb8e737cb..00000000000 --- a/test/image/mocks/animation.json +++ /dev/null @@ -1,85 +0,0 @@ -{ - "data": [ - { - "x": [0, 1, 2], - "y": [0, 2, 8], - "type": "scatter" - }, - { - "x": [0, 1, 2], - "y": [4, 2, 3], - "type": "scatter" - } - ], - "layout": { - "title": "Animation test", - "showlegend": true, - "autosize": false, - "xaxis": { - "range": [0, 2], - "domain": [0, 1] - }, - "yaxis": { - "range": [0, 10], - "domain": [0, 1] - } - }, - "frames": [{ - "name": "base", - "data": [ - {"y": [0, 2, 8]}, - {"y": [4, 2, 3]} - ], - "layout": { - "xaxis": { - "range": [0, 2] - }, - "yaxis": { - "range": [0, 10] - } - } - }, { - "name": "frame0", - "data": [ - {"y": [0.5, 1.5, 7.5]}, - {"y": [4.25, 2.25, 3.05]} - ], - "baseFrame": "base", - "traceIndices": [0, 1], - "layout": { } - }, { - "name": "frame1", - "data": [ - {"y": [2.1, 1, 7]}, - {"y": [4.5, 2.5, 3.1]} - ], - "baseFrame": "base", - "traceIndices": [0, 1], - "layout": { } - }, { - "name": "frame2", - "data": [ - {"y": [3.5, 0.5, 6]}, - {"y": [5.7, 2.7, 3.9]} - ], - "baseFrame": "base", - "traceIndices": [0, 1], - "layout": { } - }, { - "name": "frame3", - "data": [ - {"y": [5.1, 0.25, 5]}, - {"y": [7, 2.9, 6]} - ], - "baseFrame": "base", - "traceIndices": [0, 1], - "layout": { - "xaxis": { - "range": [-1, 4] - }, - "yaxis": { - "range": [-5, 15] - } - } - }] -} diff --git a/test/jasmine/tests/animate_test.js b/test/jasmine/tests/animate_test.js index ad0fca74ece..feb8784a588 100644 --- a/test/jasmine/tests/animate_test.js +++ b/test/jasmine/tests/animate_test.js @@ -6,6 +6,92 @@ var createGraphDiv = require('../assets/create_graph_div'); var destroyGraphDiv = require('../assets/destroy_graph_div'); var fail = require('../assets/fail_test'); +var mock = { + "data": [ + { + "x": [0, 1, 2], + "y": [0, 2, 8], + "type": "scatter" + }, + { + "x": [0, 1, 2], + "y": [4, 2, 3], + "type": "scatter" + } + ], + "layout": { + "title": "Animation test", + "showlegend": true, + "autosize": false, + "xaxis": { + "range": [0, 2], + "domain": [0, 1] + }, + "yaxis": { + "range": [0, 10], + "domain": [0, 1] + } + }, + "frames": [{ + "name": "base", + "data": [ + {"y": [0, 2, 8]}, + {"y": [4, 2, 3]} + ], + "layout": { + "xaxis": { + "range": [0, 2] + }, + "yaxis": { + "range": [0, 10] + } + } + }, { + "name": "frame0", + "data": [ + {"y": [0.5, 1.5, 7.5]}, + {"y": [4.25, 2.25, 3.05]} + ], + "baseFrame": "base", + "traceIndices": [0, 1], + "layout": { } + }, { + "name": "frame1", + "data": [ + {"y": [2.1, 1, 7]}, + {"y": [4.5, 2.5, 3.1]} + ], + "baseFrame": "base", + "traceIndices": [0, 1], + "layout": { } + }, { + "name": "frame2", + "data": [ + {"y": [3.5, 0.5, 6]}, + {"y": [5.7, 2.7, 3.9]} + ], + "baseFrame": "base", + "traceIndices": [0, 1], + "layout": { } + }, { + "name": "frame3", + "data": [ + {"y": [5.1, 0.25, 5]}, + {"y": [7, 2.9, 6]} + ], + "baseFrame": "base", + "traceIndices": [0, 1], + "layout": { + "xaxis": { + "range": [-1, 4] + }, + "yaxis": { + "range": [-5, 15] + } + } + }] +}; + describe('Test animate API', function() { 'use strict'; @@ -14,7 +100,7 @@ describe('Test animate API', function() { beforeEach(function(done) { gd = createGraphDiv(); - var mock = require('@mocks/animation'); + //var mock = require('@mocks/animation'); var mockCopy = Lib.extendDeep({}, mock); spyOn(PlotlyInternal, 'transition').and.callFake(function() { From bc7fe746b72983bdfc92d4bd7fbfd99c07a0fd35 Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Wed, 27 Jul 2016 11:34:42 -0400 Subject: [PATCH 37/82] Catch degenerate trace-fill-linking case --- src/plots/cartesian/index.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/plots/cartesian/index.js b/src/plots/cartesian/index.js index 2c5968786f0..63aa425bf1a 100644 --- a/src/plots/cartesian/index.js +++ b/src/plots/cartesian/index.js @@ -63,10 +63,10 @@ exports.plot = function(gd, traces, transitionOpts) { // traces 0 and 2. Trace 1 also needs to be updated, otherwise its fill // is outdated. So this retroactively adds the previous trace if the // traces are interdependent. - if(['tonextx', 'tonexty', 'tonext'].indexOf(trace.fill) !== -1) { - if(cdSubplot.indexOf(pcd) === -1) { - cdSubplot.push(pcd); - } + if(pcd && + ['tonextx', 'tonexty', 'tonext'].indexOf(trace.fill) !== -1 && + cdSubplot.indexOf(pcd) === -1) { + cdSubplot.push(pcd); } // If this trace is specifically requested, add it to the list: From 637616619b7be5773d77cb3f6885b3c29895fd60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= Date: Thu, 28 Jul 2016 14:11:21 -0400 Subject: [PATCH 38/82] lint: flatten range selector suite --- test/jasmine/tests/range_selector_test.js | 731 +++++++++++----------- 1 file changed, 366 insertions(+), 365 deletions(-) diff --git a/test/jasmine/tests/range_selector_test.js b/test/jasmine/tests/range_selector_test.js index 4d56997b005..c52239522e4 100644 --- a/test/jasmine/tests/range_selector_test.js +++ b/test/jasmine/tests/range_selector_test.js @@ -12,474 +12,475 @@ var getRectCenter = require('../assets/get_rect_center'); var mouseEvent = require('../assets/mouse_event'); -describe('[range selector suite]', function() { +describe('range selector defaults:', function() { 'use strict'; - describe('defaults:', function() { - var supplyLayoutDefaults = RangeSelector.supplyLayoutDefaults; + var supplyLayoutDefaults = RangeSelector.supplyLayoutDefaults; - function supply(containerIn, containerOut) { - containerOut.domain = [0, 1]; + function supply(containerIn, containerOut) { + containerOut.domain = [0, 1]; - var layout = { - yaxis: { domain: [0, 1] } - }; + var layout = { + yaxis: { domain: [0, 1] } + }; - var counterAxes = ['yaxis']; + var counterAxes = ['yaxis']; - supplyLayoutDefaults(containerIn, containerOut, layout, counterAxes); - } + supplyLayoutDefaults(containerIn, containerOut, layout, counterAxes); + } - it('should set \'visible\' to false when no buttons are present', function() { - var containerIn = {}; - var containerOut = {}; + it('should set \'visible\' to false when no buttons are present', function() { + var containerIn = {}; + var containerOut = {}; - supply(containerIn, containerOut); + supply(containerIn, containerOut); - expect(containerOut.rangeselector) - .toEqual({ - visible: false, - buttons: [] - }); - }); + expect(containerOut.rangeselector) + .toEqual({ + visible: false, + buttons: [] + }); + }); - it('should coerce an empty button object', function() { - var containerIn = { - rangeselector: { - buttons: [{}] - } - }; - var containerOut = {}; - - supply(containerIn, containerOut); - - expect(containerIn.rangeselector.buttons).toEqual([{}]); - expect(containerOut.rangeselector.buttons).toEqual([{ - step: 'month', - stepmode: 'backward', - count: 1 - }]); - }); + it('should coerce an empty button object', function() { + var containerIn = { + rangeselector: { + buttons: [{}] + } + }; + var containerOut = {}; - it('should coerce all buttons present', function() { - var containerIn = { - rangeselector: { - buttons: [{ - step: 'year', - count: 10 - }, { - count: 6 - }] - } - }; - var containerOut = {}; - - supply(containerIn, containerOut, {}, []); - - expect(containerOut.rangeselector.visible).toBe(true); - expect(containerOut.rangeselector.buttons).toEqual([ - { step: 'year', stepmode: 'backward', count: 10 }, - { step: 'month', stepmode: 'backward', count: 6 } - ]); - }); + supply(containerIn, containerOut); - it('should not coerce \'stepmode\' and \'count\', for \'step\' all buttons', function() { - var containerIn = { - rangeselector: { - buttons: [{ - step: 'all', - label: 'full range' - }] - } - }; - var containerOut = {}; - - supply(containerIn, containerOut, {}, []); - - expect(containerOut.rangeselector.buttons).toEqual([{ - step: 'all', - label: 'full range' - }]); - }); + expect(containerIn.rangeselector.buttons).toEqual([{}]); + expect(containerOut.rangeselector.buttons).toEqual([{ + step: 'month', + stepmode: 'backward', + count: 1 + }]); + }); - it('should use axis and counter axis to determine \'x\' and \'y\' defaults (case 1 y)', function() { - var containerIn = { - rangeselector: { buttons: [{}] } - }; - var containerOut = { - _id: 'x', - domain: [0, 0.5] - }; - var layout = { - xaxis: containerIn, - yaxis: { - anchor: 'x', - domain: [0, 0.45] - } - }; - var counterAxes = ['yaxis']; - - supplyLayoutDefaults(containerIn, containerOut, layout, counterAxes); - - expect(containerOut.rangeselector.x).toEqual(0); - expect(containerOut.rangeselector.y).toBeCloseTo(0.47); - }); + it('should coerce all buttons present', function() { + var containerIn = { + rangeselector: { + buttons: [{ + step: 'year', + count: 10 + }, { + count: 6 + }] + } + }; + var containerOut = {}; - it('should use axis and counter axis to determine \'x\' and \'y\' defaults (case multi y)', function() { - var containerIn = { - rangeselector: { buttons: [{}] } - }; - var containerOut = { - _id: 'x', - domain: [0.5, 1] - }; - var layout = { - xaxis: containerIn, - yaxis: { - anchor: 'x', - domain: [0, 0.25] - }, - yaxis2: { - anchor: 'x', - domain: [0.3, 0.55] - }, - yaxis3: { - anchor: 'x', - domain: [0.6, 0.85] - } - }; - var counterAxes = ['yaxis', 'yaxis2', 'yaxis3']; - - supplyLayoutDefaults(containerIn, containerOut, layout, counterAxes); - - expect(containerOut.rangeselector.x).toEqual(0.5); - expect(containerOut.rangeselector.y).toBeCloseTo(0.87); - }); + supply(containerIn, containerOut, {}, []); + + expect(containerOut.rangeselector.visible).toBe(true); + expect(containerOut.rangeselector.buttons).toEqual([ + { step: 'year', stepmode: 'backward', count: 10 }, + { step: 'month', stepmode: 'backward', count: 6 } + ]); }); - describe('getUpdateObject:', function() { - var axisLayout = { - _name: 'xaxis', - range: [ - (new Date(1948, 0, 1)).getTime(), - (new Date(2015, 10, 30)).getTime() - ] + it('should not coerce \'stepmode\' and \'count\', for \'step\' all buttons', function() { + var containerIn = { + rangeselector: { + buttons: [{ + step: 'all', + label: 'full range' + }] + } }; + var containerOut = {}; - function assertRanges(update, range0, range1) { - expect(update['xaxis.range[0]']).toEqual(range0.getTime()); - expect(update['xaxis.range[1]']).toEqual(range1.getTime()); - } + supply(containerIn, containerOut, {}, []); - it('should return update object (1 month backward case)', function() { - var buttonLayout = { - step: 'month', - stepmode: 'backward', - count: 1 - }; + expect(containerOut.rangeselector.buttons).toEqual([{ + step: 'all', + label: 'full range' + }]); + }); - var update = getUpdateObject(axisLayout, buttonLayout); + it('should use axis and counter axis to determine \'x\' and \'y\' defaults (case 1 y)', function() { + var containerIn = { + rangeselector: { buttons: [{}] } + }; + var containerOut = { + _id: 'x', + domain: [0, 0.5] + }; + var layout = { + xaxis: containerIn, + yaxis: { + anchor: 'x', + domain: [0, 0.45] + } + }; + var counterAxes = ['yaxis']; - assertRanges(update, new Date(2015, 9, 30), new Date(2015, 10, 30)); - }); + supplyLayoutDefaults(containerIn, containerOut, layout, counterAxes); - it('should return update object (3 months backward case)', function() { - var buttonLayout = { - step: 'month', - stepmode: 'backward', - count: 3 - }; + expect(containerOut.rangeselector.x).toEqual(0); + expect(containerOut.rangeselector.y).toBeCloseTo(0.47); + }); - var update = getUpdateObject(axisLayout, buttonLayout); + it('should use axis and counter axis to determine \'x\' and \'y\' defaults (case multi y)', function() { + var containerIn = { + rangeselector: { buttons: [{}] } + }; + var containerOut = { + _id: 'x', + domain: [0.5, 1] + }; + var layout = { + xaxis: containerIn, + yaxis: { + anchor: 'x', + domain: [0, 0.25] + }, + yaxis2: { + anchor: 'x', + domain: [0.3, 0.55] + }, + yaxis3: { + anchor: 'x', + domain: [0.6, 0.85] + } + }; + var counterAxes = ['yaxis', 'yaxis2', 'yaxis3']; - assertRanges(update, new Date(2015, 7, 30), new Date(2015, 10, 30)); - }); + supplyLayoutDefaults(containerIn, containerOut, layout, counterAxes); - it('should return update object (6 months backward case)', function() { - var buttonLayout = { - step: 'month', - stepmode: 'backward', - count: 6 - }; + expect(containerOut.rangeselector.x).toEqual(0.5); + expect(containerOut.rangeselector.y).toBeCloseTo(0.87); + }); +}); - var update = getUpdateObject(axisLayout, buttonLayout); +describe('range selector getUpdateObject:', function() { + 'use strict'; - assertRanges(update, new Date(2015, 4, 30), new Date(2015, 10, 30)); - }); + var axisLayout = { + _name: 'xaxis', + range: [ + (new Date(1948, 0, 1)).getTime(), + (new Date(2015, 10, 30)).getTime() + ] + }; + + function assertRanges(update, range0, range1) { + expect(update['xaxis.range[0]']).toEqual(range0.getTime()); + expect(update['xaxis.range[1]']).toEqual(range1.getTime()); + } + + it('should return update object (1 month backward case)', function() { + var buttonLayout = { + step: 'month', + stepmode: 'backward', + count: 1 + }; - it('should return update object (5 months to-date case)', function() { - var buttonLayout = { - step: 'month', - stepmode: 'todate', - count: 5 - }; + var update = getUpdateObject(axisLayout, buttonLayout); - var update = getUpdateObject(axisLayout, buttonLayout); + assertRanges(update, new Date(2015, 9, 30), new Date(2015, 10, 30)); + }); - assertRanges(update, new Date(2015, 6, 1), new Date(2015, 10, 30)); - }); + it('should return update object (3 months backward case)', function() { + var buttonLayout = { + step: 'month', + stepmode: 'backward', + count: 3 + }; - it('should return update object (1 year to-date case)', function() { - var buttonLayout = { - step: 'year', - stepmode: 'todate', - count: 1 - }; + var update = getUpdateObject(axisLayout, buttonLayout); - var update = getUpdateObject(axisLayout, buttonLayout); + assertRanges(update, new Date(2015, 7, 30), new Date(2015, 10, 30)); + }); - assertRanges(update, new Date(2015, 0, 1), new Date(2015, 10, 30)); - }); + it('should return update object (6 months backward case)', function() { + var buttonLayout = { + step: 'month', + stepmode: 'backward', + count: 6 + }; - it('should return update object (10 year to-date case)', function() { - var buttonLayout = { - step: 'year', - stepmode: 'todate', - count: 10 - }; + var update = getUpdateObject(axisLayout, buttonLayout); - var update = getUpdateObject(axisLayout, buttonLayout); + assertRanges(update, new Date(2015, 4, 30), new Date(2015, 10, 30)); + }); - assertRanges(update, new Date(2006, 0, 1), new Date(2015, 10, 30)); - }); + it('should return update object (5 months to-date case)', function() { + var buttonLayout = { + step: 'month', + stepmode: 'todate', + count: 5 + }; - it('should return update object (1 year backward case)', function() { - var buttonLayout = { - step: 'year', - stepmode: 'backward', - count: 1 - }; + var update = getUpdateObject(axisLayout, buttonLayout); - var update = getUpdateObject(axisLayout, buttonLayout); + assertRanges(update, new Date(2015, 6, 1), new Date(2015, 10, 30)); + }); - assertRanges(update, new Date(2014, 10, 30), new Date(2015, 10, 30)); - }); + it('should return update object (1 year to-date case)', function() { + var buttonLayout = { + step: 'year', + stepmode: 'todate', + count: 1 + }; - it('should return update object (reset case)', function() { - var buttonLayout = { - step: 'all' - }; + var update = getUpdateObject(axisLayout, buttonLayout); - var update = getUpdateObject(axisLayout, buttonLayout); + assertRanges(update, new Date(2015, 0, 1), new Date(2015, 10, 30)); + }); - expect(update).toEqual({ 'xaxis.autorange': true }); - }); + it('should return update object (10 year to-date case)', function() { + var buttonLayout = { + step: 'year', + stepmode: 'todate', + count: 10 + }; - it('should return update object (10 day backward case)', function() { - var buttonLayout = { - step: 'day', - stepmode: 'backward', - count: 10 - }; + var update = getUpdateObject(axisLayout, buttonLayout); - var update = getUpdateObject(axisLayout, buttonLayout); + assertRanges(update, new Date(2006, 0, 1), new Date(2015, 10, 30)); + }); - assertRanges(update, new Date(2015, 10, 20), new Date(2015, 10, 30)); - }); + it('should return update object (1 year backward case)', function() { + var buttonLayout = { + step: 'year', + stepmode: 'backward', + count: 1 + }; - it('should return update object (5 hour backward case)', function() { - var buttonLayout = { - step: 'hour', - stepmode: 'backward', - count: 5 - }; + var update = getUpdateObject(axisLayout, buttonLayout); - var update = getUpdateObject(axisLayout, buttonLayout); + assertRanges(update, new Date(2014, 10, 30), new Date(2015, 10, 30)); + }); - assertRanges(update, new Date(2015, 10, 29, 19), new Date(2015, 10, 30)); - }); + it('should return update object (reset case)', function() { + var buttonLayout = { + step: 'all' + }; - it('should return update object (15 minute backward case)', function() { - var buttonLayout = { - step: 'minute', - stepmode: 'backward', - count: 15 - }; + var update = getUpdateObject(axisLayout, buttonLayout); - var update = getUpdateObject(axisLayout, buttonLayout); + expect(update).toEqual({ 'xaxis.autorange': true }); + }); - assertRanges(update, new Date(2015, 10, 29, 23, 45), new Date(2015, 10, 30)); - }); + it('should return update object (10 day backward case)', function() { + var buttonLayout = { + step: 'day', + stepmode: 'backward', + count: 10 + }; - it('should return update object (10 second backward case)', function() { - var buttonLayout = { - step: 'second', - stepmode: 'backward', - count: 10 - }; + var update = getUpdateObject(axisLayout, buttonLayout); - var update = getUpdateObject(axisLayout, buttonLayout); + assertRanges(update, new Date(2015, 10, 20), new Date(2015, 10, 30)); + }); - assertRanges(update, new Date(2015, 10, 29, 23, 59, 50), new Date(2015, 10, 30)); - }); + it('should return update object (5 hour backward case)', function() { + var buttonLayout = { + step: 'hour', + stepmode: 'backward', + count: 5 + }; - it('should return update object (12 hour to-date case)', function() { - var buttonLayout = { - step: 'hour', - stepmode: 'todate', - count: 12 - }; + var update = getUpdateObject(axisLayout, buttonLayout); - axisLayout.range[1] = new Date(2015, 10, 30, 12).getTime(); + assertRanges(update, new Date(2015, 10, 29, 19), new Date(2015, 10, 30)); + }); - var update = getUpdateObject(axisLayout, buttonLayout); + it('should return update object (15 minute backward case)', function() { + var buttonLayout = { + step: 'minute', + stepmode: 'backward', + count: 15 + }; - assertRanges(update, new Date(2015, 10, 30, 1), new Date(2015, 10, 30, 12)); - }); + var update = getUpdateObject(axisLayout, buttonLayout); - it('should return update object (15 minute backward case)', function() { - var buttonLayout = { - step: 'minute', - stepmode: 'todate', - count: 20 - }; + assertRanges(update, new Date(2015, 10, 29, 23, 45), new Date(2015, 10, 30)); + }); - axisLayout.range[1] = new Date(2015, 10, 30, 12, 20).getTime(); + it('should return update object (10 second backward case)', function() { + var buttonLayout = { + step: 'second', + stepmode: 'backward', + count: 10 + }; - var update = getUpdateObject(axisLayout, buttonLayout); + var update = getUpdateObject(axisLayout, buttonLayout); - assertRanges(update, new Date(2015, 10, 30, 12, 1), new Date(2015, 10, 30, 12, 20)); - }); + assertRanges(update, new Date(2015, 10, 29, 23, 59, 50), new Date(2015, 10, 30)); + }); - it('should return update object (2 second to-date case)', function() { - var buttonLayout = { - step: 'second', - stepmode: 'todate', - count: 2 - }; + it('should return update object (12 hour to-date case)', function() { + var buttonLayout = { + step: 'hour', + stepmode: 'todate', + count: 12 + }; - axisLayout.range[1] = new Date(2015, 10, 30, 12, 20, 2).getTime(); + axisLayout.range[1] = new Date(2015, 10, 30, 12).getTime(); - var update = getUpdateObject(axisLayout, buttonLayout); + var update = getUpdateObject(axisLayout, buttonLayout); - assertRanges(update, new Date(2015, 10, 30, 12, 20, 1), new Date(2015, 10, 30, 12, 20, 2)); - }); + assertRanges(update, new Date(2015, 10, 30, 1), new Date(2015, 10, 30, 12)); + }); - it('should return update object with correct axis names', function() { - var axisLayout = { - _name: 'xaxis5', - range: [ - (new Date(1948, 0, 1)).getTime(), - (new Date(2015, 10, 30)).getTime() - ] - }; - - var buttonLayout = { - step: 'month', - stepmode: 'backward', - count: 1 - }; - - var update = getUpdateObject(axisLayout, buttonLayout); - - expect(update).toEqual({ - 'xaxis5.range[0]': new Date(2015, 9, 30).getTime(), - 'xaxis5.range[1]': new Date(2015, 10, 30).getTime() - }); + it('should return update object (15 minute backward case)', function() { + var buttonLayout = { + step: 'minute', + stepmode: 'todate', + count: 20 + }; - }); + axisLayout.range[1] = new Date(2015, 10, 30, 12, 20).getTime(); + + var update = getUpdateObject(axisLayout, buttonLayout); + + assertRanges(update, new Date(2015, 10, 30, 12, 1), new Date(2015, 10, 30, 12, 20)); }); - describe('interactions:', function() { - var mock = require('@mocks/range_selector.json'); + it('should return update object (2 second to-date case)', function() { + var buttonLayout = { + step: 'second', + stepmode: 'todate', + count: 2 + }; + + axisLayout.range[1] = new Date(2015, 10, 30, 12, 20, 2).getTime(); + + var update = getUpdateObject(axisLayout, buttonLayout); + + assertRanges(update, new Date(2015, 10, 30, 12, 20, 1), new Date(2015, 10, 30, 12, 20, 2)); + }); - var gd, mockCopy; + it('should return update object with correct axis names', function() { + var axisLayout = { + _name: 'xaxis5', + range: [ + (new Date(1948, 0, 1)).getTime(), + (new Date(2015, 10, 30)).getTime() + ] + }; - beforeEach(function(done) { - gd = createGraphDiv(); - mockCopy = Lib.extendDeep({}, mock); + var buttonLayout = { + step: 'month', + stepmode: 'backward', + count: 1 + }; - Plotly.plot(gd, mockCopy.data, mockCopy.layout).then(done); + var update = getUpdateObject(axisLayout, buttonLayout); + + expect(update).toEqual({ + 'xaxis5.range[0]': new Date(2015, 9, 30).getTime(), + 'xaxis5.range[1]': new Date(2015, 10, 30).getTime() }); - afterEach(destroyGraphDiv); + }); +}); - function assertNodeCount(query, cnt) { - expect(d3.selectAll(query).size()).toEqual(cnt); - } +describe('range selector interactions:', function() { + 'use strict'; - function checkActiveButton(activeIndex) { - d3.selectAll('.button').each(function(d, i) { - expect(d.isActive).toBe(activeIndex === i); - }); - } + var mock = require('@mocks/range_selector.json'); + + var gd, mockCopy; + + beforeEach(function(done) { + gd = createGraphDiv(); + mockCopy = Lib.extendDeep({}, mock); - it('should display the correct nodes', function() { - assertNodeCount('.rangeselector', 1); - assertNodeCount('.button', mockCopy.layout.xaxis.rangeselector.buttons.length); + Plotly.plot(gd, mockCopy.data, mockCopy.layout).then(done); + }); + + afterEach(destroyGraphDiv); + + function assertNodeCount(query, cnt) { + expect(d3.selectAll(query).size()).toEqual(cnt); + } + + function checkActiveButton(activeIndex) { + d3.selectAll('.button').each(function(d, i) { + expect(d.isActive).toBe(activeIndex === i); }); + } - it('should be able to be removed by `relayout`', function(done) { - Plotly.relayout(gd, 'xaxis.rangeselector.visible', false).then(function() { - assertNodeCount('.rangeselector', 0); - assertNodeCount('.button', 0); - done(); - }); + it('should display the correct nodes', function() { + assertNodeCount('.rangeselector', 1); + assertNodeCount('.button', mockCopy.layout.xaxis.rangeselector.buttons.length); + }); + it('should be able to be removed by `relayout`', function(done) { + Plotly.relayout(gd, 'xaxis.rangeselector.visible', false).then(function() { + assertNodeCount('.rangeselector', 0); + assertNodeCount('.button', 0); + done(); }); - it('should update range and active button when clicked', function() { - var range0 = gd.layout.xaxis.range[0]; - var buttons = d3.selectAll('.button').select('rect'); + }); + + it('should update range and active button when clicked', function() { + var range0 = gd.layout.xaxis.range[0]; + var buttons = d3.selectAll('.button').select('rect'); - checkActiveButton(buttons.size() - 1); + checkActiveButton(buttons.size() - 1); - var pos0 = getRectCenter(buttons[0][0]); - var posReset = getRectCenter(buttons[0][buttons.size() - 1]); + var pos0 = getRectCenter(buttons[0][0]); + var posReset = getRectCenter(buttons[0][buttons.size() - 1]); - mouseEvent('click', pos0[0], pos0[1]); - expect(gd.layout.xaxis.range[0]).toBeGreaterThan(range0); + mouseEvent('click', pos0[0], pos0[1]); + expect(gd.layout.xaxis.range[0]).toBeGreaterThan(range0); - checkActiveButton(0); + checkActiveButton(0); - mouseEvent('click', posReset[0], posReset[1]); - expect(gd.layout.xaxis.range[0]).toEqual(range0); + mouseEvent('click', posReset[0], posReset[1]); + expect(gd.layout.xaxis.range[0]).toEqual(range0); - checkActiveButton(buttons.size() - 1); - }); + checkActiveButton(buttons.size() - 1); + }); - it('should change color on mouse over', function() { - var button = d3.select('.button').select('rect'); - var pos = getRectCenter(button.node()); + it('should change color on mouse over', function() { + var button = d3.select('.button').select('rect'); + var pos = getRectCenter(button.node()); - var fillColor = Color.rgb(gd._fullLayout.xaxis.rangeselector.bgcolor); - var activeColor = Color.rgb(constants.activeColor); + var fillColor = Color.rgb(gd._fullLayout.xaxis.rangeselector.bgcolor); + var activeColor = Color.rgb(constants.activeColor); - expect(button.style('fill')).toEqual(fillColor); + expect(button.style('fill')).toEqual(fillColor); - mouseEvent('mouseover', pos[0], pos[1]); - expect(button.style('fill')).toEqual(activeColor); + mouseEvent('mouseover', pos[0], pos[1]); + expect(button.style('fill')).toEqual(activeColor); - mouseEvent('mouseout', pos[0], pos[1]); - expect(button.style('fill')).toEqual(fillColor); - }); + mouseEvent('mouseout', pos[0], pos[1]); + expect(button.style('fill')).toEqual(fillColor); + }); - it('should update is active relayout calls', function(done) { - var buttons = d3.selectAll('.button').select('rect'); + it('should update is active relayout calls', function(done) { + var buttons = d3.selectAll('.button').select('rect'); - // 'all' should be active at first - checkActiveButton(buttons.size() - 1); + // 'all' should be active at first + checkActiveButton(buttons.size() - 1); - var update = { - 'xaxis.range[0]': (new Date(2015, 9, 30)).getTime(), - 'xaxis.range[1]': (new Date(2015, 10, 30)).getTime() - }; + var update = { + 'xaxis.range[0]': (new Date(2015, 9, 30)).getTime(), + 'xaxis.range[1]': (new Date(2015, 10, 30)).getTime() + }; - Plotly.relayout(gd, update).then(function() { + Plotly.relayout(gd, update).then(function() { - // '1m' should be active after the relayout - checkActiveButton(0); + // '1m' should be active after the relayout + checkActiveButton(0); - return Plotly.relayout(gd, 'xaxis.autorange', true); - }).then(function() { + return Plotly.relayout(gd, 'xaxis.autorange', true); + }).then(function() { - // 'all' should be after an autoscale - checkActiveButton(buttons.size() - 1); + // 'all' should be after an autoscale + checkActiveButton(buttons.size() - 1); - done(); - }); + done(); }); - }); }); From 10a655d906cf719f9c632832d729cdde97ce72a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= Date: Thu, 28 Jul 2016 14:22:04 -0400 Subject: [PATCH 39/82] rangeselector: skip non-object buttons items - making relayout(gd, 'xaxis.rangeselector.buttons[i]', 'remove'); clear to existing button --- src/components/rangeselector/defaults.js | 2 ++ test/jasmine/tests/range_selector_test.js | 37 ++++++++++++++++++++++- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/src/components/rangeselector/defaults.js b/src/components/rangeselector/defaults.js index b2c02e846fe..108b888c8eb 100644 --- a/src/components/rangeselector/defaults.js +++ b/src/components/rangeselector/defaults.js @@ -57,6 +57,8 @@ function buttonsDefaults(containerIn, containerOut) { buttonIn = buttonsIn[i]; buttonOut = {}; + if(!Lib.isPlainObject(buttonIn)) continue; + var step = coerce('step'); if(step !== 'all') { coerce('stepmode'); diff --git a/test/jasmine/tests/range_selector_test.js b/test/jasmine/tests/range_selector_test.js index c52239522e4..c48a3e36f0f 100644 --- a/test/jasmine/tests/range_selector_test.js +++ b/test/jasmine/tests/range_selector_test.js @@ -60,6 +60,26 @@ describe('range selector defaults:', function() { }]); }); + it('should skip over non-object buttons', function() { + var containerIn = { + rangeselector: { + buttons: [{ + label: 'button 0' + }, null, { + label: 'button 2' + }, 'remove', { + label: 'button 4' + }] + } + }; + var containerOut = {}; + + supply(containerIn, containerOut); + + expect(containerIn.rangeselector.buttons.length).toEqual(5); + expect(containerOut.rangeselector.buttons.length).toEqual(3); + }); + it('should coerce all buttons present', function() { var containerIn = { rangeselector: { @@ -421,6 +441,22 @@ describe('range selector interactions:', function() { }); + it('should be able to remove button(s) on `relayout`', function(done) { + var len = mockCopy.layout.xaxis.rangeselector.buttons.length; + + assertNodeCount('.button', len); + + Plotly.relayout(gd, 'xaxis.rangeselector.buttons[0]', null).then(function() { + assertNodeCount('.button', len - 1); + + return Plotly.relayout(gd, 'xaxis.rangeselector.buttons[1]', 'remove'); + }).then(function() { + assertNodeCount('.button', len - 2); + + done(); + }); + }); + it('should update range and active button when clicked', function() { var range0 = gd.layout.xaxis.range[0]; var buttons = d3.selectAll('.button').select('rect'); @@ -482,5 +518,4 @@ describe('range selector interactions:', function() { done(); }); }); - }); From 5904af264f0cf3c950628a35fd0ed577a7586514 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= Date: Thu, 28 Jul 2016 16:07:23 -0400 Subject: [PATCH 40/82] Update README.md --- test/image/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/image/README.md b/test/image/README.md index b3a4d371caa..d8641559841 100644 --- a/test/image/README.md +++ b/test/image/README.md @@ -40,8 +40,8 @@ as listed on [hub.docker.com](https://hub.docker.com/r/plotly/testbed/tags/) and ### Step 2: Run the image tests -The image testing docker container allows plotly.js developers to ([A](#a-run-image-comparison-tests) run image -comparison tests, ([B](#b-run-image-export-tests) run image export tests and ([C](#c-generate-or-update-existing-baseline-image)) generate baseline +The image testing docker container allows plotly.js developers to ([A](#a-run-image-comparison-tests)) run image +comparison tests, ([B](#b-run-image-export-tests)) run image export tests and ([C](#c-generate-or-update-existing-baseline-image)) generate baseline images. **IMPORTANT:** the image tests scripts do **not** bundle the source files before From c09cbb3f2d53fe30126e1c233a5a8ea11dd79d35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= Date: Thu, 28 Jul 2016 18:20:18 -0400 Subject: [PATCH 41/82] isPlainObject: by pass prototype check in nw.js environments - to make sure that plotly.js is compatible with async request handles (e.g. lash) - side effect: isPlainObject(new Constructor()) now returns true in nw.js --- src/lib/is_plain_object.js | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/lib/is_plain_object.js b/src/lib/is_plain_object.js index ced058e1bb5..1f0748e8e27 100644 --- a/src/lib/is_plain_object.js +++ b/src/lib/is_plain_object.js @@ -11,6 +11,15 @@ // more info: http://stackoverflow.com/questions/18531624/isplainobject-thing module.exports = function isPlainObject(obj) { + + // We need to be a little less strict in the `imagetest` container because + // of how async image requests are handled. + // + // N.B. isPlainObject(new Constructor()) will return true in `imagetest` + if(window && window.process && window.process.versions) { + return Object.prototype.toString.call(obj) === '[object Object]'; + } + return ( Object.prototype.toString.call(obj) === '[object Object]' && Object.getPrototypeOf(obj) === Object.prototype From e1f6818acb1d9873187afe00de1130155bade3f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20T=C3=A9treault-Pinard?= Date: Thu, 28 Jul 2016 18:21:34 -0400 Subject: [PATCH 42/82] enfore isPlainObject in src files --- src/components/colorbar/has_colorbar.js | 7 +++---- src/components/colorscale/has_colorscale.js | 4 ++-- src/components/rangeslider/defaults.js | 3 +-- src/plots/mapbox/layers.js | 5 +---- src/traces/scatter/subtypes.js | 6 ++++-- 5 files changed, 11 insertions(+), 14 deletions(-) diff --git a/src/components/colorbar/has_colorbar.js b/src/components/colorbar/has_colorbar.js index e7c750933a7..40990086b43 100644 --- a/src/components/colorbar/has_colorbar.js +++ b/src/components/colorbar/has_colorbar.js @@ -9,10 +9,9 @@ 'use strict'; +var Lib = require('../../lib'); + module.exports = function hasColorbar(container) { - return ( - typeof container.colorbar === 'object' && - container.colorbar !== null - ); + return Lib.isPlainObject(container.colorbar); }; diff --git a/src/components/colorscale/has_colorscale.js b/src/components/colorscale/has_colorscale.js index 9e28d51f5de..5cbce08634d 100644 --- a/src/components/colorscale/has_colorscale.js +++ b/src/components/colorscale/has_colorscale.js @@ -33,12 +33,12 @@ module.exports = function hasColorscale(trace, containerStr) { } return ( - (typeof container === 'object' && container !== null) && ( + Lib.isPlainObject(container) && ( isArrayWithOneNumber || container.showscale === true || (isNumeric(container.cmin) && isNumeric(container.cmax)) || isValidScale(container.colorscale) || - (typeof container.colorbar === 'object' && container.colorbar !== null) + Lib.isPlainObject(container.colorbar) ) ); }; diff --git a/src/components/rangeslider/defaults.js b/src/components/rangeslider/defaults.js index 0095c7b243e..c06502b7068 100644 --- a/src/components/rangeslider/defaults.js +++ b/src/components/rangeslider/defaults.js @@ -13,10 +13,9 @@ var attributes = require('./attributes'); module.exports = function supplyLayoutDefaults(layoutIn, layoutOut, axName, counterAxes) { - if(!layoutIn[axName].rangeslider) return; - var containerIn = typeof layoutIn[axName].rangeslider === 'object' ? + var containerIn = Lib.isPlainObject(layoutIn[axName].rangeslider) ? layoutIn[axName].rangeslider : {}, containerOut = layoutOut[axName].rangeslider = {}; diff --git a/src/plots/mapbox/layers.js b/src/plots/mapbox/layers.js index 8fa4890d384..c5de5901a5c 100644 --- a/src/plots/mapbox/layers.js +++ b/src/plots/mapbox/layers.js @@ -131,11 +131,8 @@ proto.dispose = function dispose() { function isVisible(opts) { var source = opts.source; - // For some weird reason Lib.isPlainObject fails - // to detect `source` as a plain object in nw.js 0.12. - return ( - typeof source === 'object' || + Lib.isPlainObject(source) || (typeof source === 'string' && source.length > 0) ); } diff --git a/src/traces/scatter/subtypes.js b/src/traces/scatter/subtypes.js index 56814679824..b79b420a4f3 100644 --- a/src/traces/scatter/subtypes.js +++ b/src/traces/scatter/subtypes.js @@ -9,6 +9,8 @@ 'use strict'; +var Lib = require('../../lib'); + module.exports = { hasLines: function(trace) { return trace.visible && trace.mode && @@ -26,7 +28,7 @@ module.exports = { }, isBubble: function(trace) { - return (typeof trace.marker === 'object' && - Array.isArray(trace.marker.size)); + return Lib.isPlainObject(trace.marker) && + Array.isArray(trace.marker.size); } }; From 9e2f251245cfbec2c55550fa3183d06d0cda7aff Mon Sep 17 00:00:00 2001 From: Jody McIntyre Date: Thu, 28 Jul 2016 20:44:10 -0400 Subject: [PATCH 43/82] HTML encode attributes in s and s I don't believe this is necessary for security, but it makes our code more obviously secure. --- src/lib/svg_text_utils.js | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/lib/svg_text_utils.js b/src/lib/svg_text_utils.js index 922c4b0213e..fe995cc74f7 100644 --- a/src/lib/svg_text_utils.js +++ b/src/lib/svg_text_utils.js @@ -242,6 +242,15 @@ util.plainText = function(_str) { return (_str || '').replace(STRIP_TAGS, ' '); }; +function encodeForHTML(_str) { + return (_str || '').replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') + .replace(/\//g, '/'); +} + function convertToSVG(_str) { var htmlEntitiesDecoded = Plotly.util.html_entity_decode(_str); var result = htmlEntitiesDecoded @@ -270,15 +279,14 @@ function convertToSVG(_str) { // remove quotes, leading '=', replace '&' with '&' var href = extra.substr(4) .replace(/["']/g, '') - .replace(/=/, '') - .replace(/&/g, '&'); + .replace(/=/, ''); // check protocol var dummyAnchor = document.createElement('a'); dummyAnchor.href = href; if(PROTOCOLS.indexOf(dummyAnchor.protocol) === -1) return ''; - return ''; + return ''; } } else if(tag === 'br') return '
'; @@ -302,7 +310,7 @@ function convertToSVG(_str) { // most of the svg css users will care about is just like html, // but font color is different. Let our users ignore this. extraStyle = extraStyle[1].replace(/(^|;)\s*color:/, '$1 fill:'); - style = (style ? style + ';' : '') + extraStyle; + style = (style ? style + ';' : '') + encodeForHTML(extraStyle); } return tspanStart + (style ? ' style="' + style + '"' : '') + '>'; From 4e2761c14d65e27c9952b6e98f7963da5dd660c5 Mon Sep 17 00:00:00 2001 From: Jody McIntyre Date: Thu, 28 Jul 2016 20:45:24 -0400 Subject: [PATCH 44/82] Add tests of generation --- test/jasmine/tests/svg_text_utils_test.js | 50 +++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/test/jasmine/tests/svg_text_utils_test.js b/test/jasmine/tests/svg_text_utils_test.js index 6d11560a105..4ebbea59eb3 100644 --- a/test/jasmine/tests/svg_text_utils_test.js +++ b/test/jasmine/tests/svg_text_utils_test.js @@ -25,6 +25,11 @@ describe('svg+text utils', function() { expect(a.attr('xlink:show')).toBe(href === null ? null : 'new'); } + function assertTspanStyle(node, style) { + var tspan = node.select('tspan'); + expect(tspan.attr('style')).toBe(style); + } + function assertAnchorAttrs(node) { var a = node.select('a'); @@ -134,5 +139,50 @@ describe('svg+text utils', function() { assertAnchorLink(node, 'https://abc.com/myFeature.jsp?name=abc&pwd=def'); }); }); + + it('allow basic spans', function() { + var node = mockTextSVGElement( + 'text' + ); + + expect(node.text()).toEqual('text'); + assertTspanStyle(node, null); + }); + + it('ignore unquoted styles in spans', function() { + var node = mockTextSVGElement( + 'text' + ); + + expect(node.text()).toEqual('text'); + assertTspanStyle(node, null); + }); + + it('allow quoted styles in spans', function() { + var node = mockTextSVGElement( + 'text' + ); + + expect(node.text()).toEqual('text'); + assertTspanStyle(node, 'quoted: yeah;'); + }); + + it('ignore extra stuff after span styles', function() { + var node = mockTextSVGElement( + 'text' + ); + + expect(node.text()).toEqual('text'); + assertTspanStyle(node, 'quoted: yeah;'); + }); + + it('escapes HTML entities in span styles', function() { + var node = mockTextSVGElement( + 'text' + ); + + expect(node.text()).toEqual('text'); + assertTspanStyle(node, 'quoted: yeah&\';;'); + }); }); }); From 763485c7577ef2bcff156da46e3d71989d12fe3f Mon Sep 17 00:00:00 2001 From: Jody McIntyre Date: Thu, 28 Jul 2016 20:45:35 -0400 Subject: [PATCH 45/82] Add test that relative links work --- test/jasmine/tests/svg_text_utils_test.js | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/test/jasmine/tests/svg_text_utils_test.js b/test/jasmine/tests/svg_text_utils_test.js index 4ebbea59eb3..be4601743c8 100644 --- a/test/jasmine/tests/svg_text_utils_test.js +++ b/test/jasmine/tests/svg_text_utils_test.js @@ -80,6 +80,16 @@ describe('svg+text utils', function() { assertAnchorLink(node, null); }); + it('whitelist relative hrefs (interpreted as http)', function() { + var node = mockTextSVGElement( + '
mylink' + ); + + expect(node.text()).toEqual('mylink'); + assertAnchorAttrs(node); + assertAnchorLink(node, '/mylink'); + }); + it('whitelist http hrefs', function() { var node = mockTextSVGElement( 'bl.ocks.org' From 0aab643b649d6e6ff117c44fc7d7f3b0f7ff54a6 Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Fri, 29 Jul 2016 11:09:03 -0400 Subject: [PATCH 46/82] Reuse SVG DOM elements for scatter traces Conflicts: src/traces/scatter/plot.js --- src/components/errorbars/plot.js | 17 +- src/plots/cartesian/index.js | 81 +++--- src/plots/plots.js | 9 +- src/traces/scatter/link_traces.js | 39 +++ src/traces/scatter/plot.js | 392 ++++++++++++++++++--------- test/image/mocks/ternary_simple.json | 3 +- test/jasmine/tests/calcdata_test.js | 14 +- test/jasmine/tests/cartesian_test.js | 99 ++++++- 8 files changed, 471 insertions(+), 183 deletions(-) create mode 100644 src/traces/scatter/link_traces.js diff --git a/src/components/errorbars/plot.js b/src/components/errorbars/plot.js index 3f44ed58df1..eb66769760e 100644 --- a/src/components/errorbars/plot.js +++ b/src/components/errorbars/plot.js @@ -34,15 +34,24 @@ module.exports = function plot(traces, plotinfo) { trace.marker.maxdisplayed > 0 ); + var keyFunc; + + if(trace.key) { + keyFunc = function(d) { return d.key; }; + } + if(!yObj.visible && !xObj.visible) return; - var errorbars = d3.select(this).selectAll('g.errorbar') - .data(Lib.identity); + var selection = d3.select(this).selectAll('g.errorbar'); - errorbars.enter().append('g') + var join = selection.data(Lib.identity, keyFunc); + + join.enter().append('g') .classed('errorbar', true); - errorbars.each(function(d) { + join.exit().remove(); + + join.each(function(d) { var errorbar = d3.select(this); var coords = errorCoords(d, xa, ya); diff --git a/src/plots/cartesian/index.js b/src/plots/cartesian/index.js index 56f827f0ec6..7ced4299776 100644 --- a/src/plots/cartesian/index.js +++ b/src/plots/cartesian/index.js @@ -25,61 +25,74 @@ exports.attrRegex = constants.attrRegex; exports.attributes = require('./attributes'); -exports.plot = function(gd) { +exports.plot = function(gd, traces) { + var cdSubplot, cd, trace, i, j, k, isFullReplot; + var fullLayout = gd._fullLayout, subplots = Plots.getSubplotIds(fullLayout, 'cartesian'), calcdata = gd.calcdata, modules = fullLayout._modules; - function getCdSubplot(calcdata, subplot) { - var cdSubplot = []; - - for(var i = 0; i < calcdata.length; i++) { - var cd = calcdata[i]; - var trace = cd[0].trace; - - if(trace.xaxis + trace.yaxis === subplot) { - cdSubplot.push(cd); - } + if(!Array.isArray(traces)) { + // If traces is not provided, then it's a complete replot and missing + // traces are removed + isFullReplot = true; + traces = []; + for(i = 0; i < calcdata.length; i++) { + traces.push(i); } - - return cdSubplot; + } else { + // If traces are explicitly specified, then it's a partial replot and + // traces are not removed. + isFullReplot = false; } - function getCdModule(cdSubplot, _module) { - var cdModule = []; + for(i = 0; i < subplots.length; i++) { + var subplot = subplots[i], + subplotInfo = fullLayout._plots[subplot]; + + // Get all calcdata for this subplot: + cdSubplot = []; + for(j = 0; j < calcdata.length; j++) { + cd = calcdata[j]; + trace = cd[0].trace; - for(var i = 0; i < cdSubplot.length; i++) { - var cd = cdSubplot[i]; - var trace = cd[0].trace; + // Skip trace if whitelist provided and it's not whitelisted: + // if (Array.isArray(traces) && traces.indexOf(i) === -1) continue; - if((trace._module === _module) && (trace.visible === true)) { - cdModule.push(cd); + if(trace.xaxis + trace.yaxis === subplot && traces.indexOf(trace.index) !== -1) { + cdSubplot.push(cd); } } - return cdModule; - } - - for(var i = 0; i < subplots.length; i++) { - var subplot = subplots[i], - subplotInfo = fullLayout._plots[subplot], - cdSubplot = getCdSubplot(calcdata, subplot); - // remove old traces, then redraw everything - // TODO: use enter/exit appropriately in the plot functions - // so we don't need this - should sometimes be a big speedup - if(subplotInfo.plot) subplotInfo.plot.selectAll('g.trace').remove(); + // TODO: scatterlayer is manually excluded from this since it knows how + // to update instead of fully removing and redrawing every time. The + // remaining plot traces should also be able to do this. Once implemented, + // we won't need this - which should sometimes be a big speedup. + if(subplotInfo.plot) { + subplotInfo.plot.selectAll('g:not(.scatterlayer)').selectAll('g.trace').remove(); + } - for(var j = 0; j < modules.length; j++) { + // Plot all traces for each module at once: + for(j = 0; j < modules.length; j++) { var _module = modules[j]; // skip over non-cartesian trace modules if(_module.basePlotModule.name !== 'cartesian') continue; // plot all traces of this type on this subplot at once - var cdModule = getCdModule(cdSubplot, _module); - _module.plot(gd, subplotInfo, cdModule); + var cdModule = []; + for(k = 0; k < cdSubplot.length; k++) { + cd = cdSubplot[k]; + trace = cd[0].trace; + + if((trace._module === _module) && (trace.visible === true)) { + cdModule.push(cd); + } + } + + _module.plot(gd, subplotInfo, cdModule, isFullReplot); } } }; diff --git a/src/plots/plots.js b/src/plots/plots.js index 9900e06af0f..08748bebed5 100644 --- a/src/plots/plots.js +++ b/src/plots/plots.js @@ -568,12 +568,17 @@ plots.cleanPlot = function(newFullData, newFullLayout, oldFullData, oldFullLayou if(oldUid === newTrace.uid) continue oldLoop; } - // clean old heatmap and contour traces + // clean old heatmap, contour, and scatter traces + // + // Note: This is also how scatter traces (cartesian and scatterternary) get + // removed since otherwise the scatter module is not called (and so the join + // doesn't register the removal) if scatter traces disappear entirely. if(hasPaper) { oldFullLayout._paper.selectAll( '.hm' + oldUid + ',.contour' + oldUid + - ',#clip' + oldUid + ',#clip' + oldUid + + ',.trace' + oldUid ).remove(); } diff --git a/src/traces/scatter/link_traces.js b/src/traces/scatter/link_traces.js new file mode 100644 index 00000000000..801d02b0d64 --- /dev/null +++ b/src/traces/scatter/link_traces.js @@ -0,0 +1,39 @@ +/** +* Copyright 2012-2016, 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 = function linkTraces(gd, plotinfo, cdscatter) { + var cd, trace; + var prevtrace = null; + + for(var i = 0; i < cdscatter.length; ++i) { + cd = cdscatter[i]; + trace = cd[0].trace; + + // Note: The check which ensures all cdscatter here are for the same axis and + // are either cartesian or scatterternary has been removed. This code assumes + // the passed scattertraces have been filtered to the proper plot types and + // the proper subplots. + if(trace.visible === true) { + trace._nexttrace = null; + + if(['tonextx', 'tonexty', 'tonext'].indexOf(trace.fill) !== -1) { + trace._prevtrace = prevtrace; + + if(prevtrace) { + prevtrace._nexttrace = trace; + } + } + + prevtrace = trace; + } else { + trace._prevtrace = trace._nexttrace = null; + } + } +}; diff --git a/src/traces/scatter/plot.js b/src/traces/scatter/plot.js index b36ce3f05e9..8ec17ff2183 100644 --- a/src/traces/scatter/plot.js +++ b/src/traces/scatter/plot.js @@ -15,80 +15,161 @@ var Lib = require('../../lib'); var Drawing = require('../../components/drawing'); var ErrorBars = require('../../components/errorbars'); -var polygonTester = require('../../lib/polygon').tester; - var subTypes = require('./subtypes'); var arraysToCalcdata = require('./arrays_to_calcdata'); var linePoints = require('./line_points'); +var linkTraces = require('./link_traces'); +var polygonTester = require('../../lib/polygon').tester; +module.exports = function plot(gd, plotinfo, cdscatter, isFullReplot) { + var i, uids, selection, join; -module.exports = function plot(gd, plotinfo, cdscatter) { - selectMarkers(gd, plotinfo, cdscatter); + var scatterlayer = plotinfo.plot.select('g.scatterlayer'); - var xa = plotinfo.x(), - ya = plotinfo.y(); + selection = scatterlayer.selectAll('g.trace'); - // make the container for scatter plots - // (so error bars can find them along with bars) - var scattertraces = plotinfo.plot.select('.scatterlayer') - .selectAll('g.trace.scatter') - .data(cdscatter); + join = selection.data(cdscatter, function(d) {return d[0].trace.uid;}); - scattertraces.enter().append('g') - .attr('class', 'trace scatter') + // Append new traces: + join.enter().append('g') + .attr('class', function(d) { + return 'trace scatter trace' + d[0].trace.uid; + }) .style('stroke-miterlimit', 2); - // error bars are at the bottom - scattertraces.call(ErrorBars.plot, plotinfo); + // After the elements are created but before they've been draw, we have to perform + // this extra step of linking the traces. This allows appending of fill layers so that + // the z-order of fill layers is correct. + linkTraces(gd, plotinfo, cdscatter); - // BUILD LINES AND FILLS - var prevpath = '', - prevPolygons = [], - ownFillEl3, ownFillDir, tonext, nexttonext; + createFills(gd, scatterlayer); - scattertraces.each(function(d) { - var trace = d[0].trace, - line = trace.line, - tr = d3.select(this); - if(trace.visible !== true) return; + // Sort the traces, once created, so that the ordering is preserved even when traces + // are shown and hidden. This is needed since we're not just wiping everything out + // and recreating on every update. + for(i = 0, uids = []; i < cdscatter.length; i++) { + uids[i] = cdscatter[i][0].trace.uid; + } - ownFillDir = trace.fill.charAt(trace.fill.length - 1); - if(ownFillDir !== 'x' && ownFillDir !== 'y') ownFillDir = ''; + scatterlayer.selectAll('g.trace').sort(function(a, b) { + var idx1 = uids.indexOf(a[0].trace.uid); + var idx2 = uids.indexOf(b[0].trace.uid); + return idx1 > idx2 ? 1 : -1; + }); - d[0].node3 = tr; // store node for tweaking by selectPoints + // Must run the selection again since otherwise enters/updates get grouped together + // and these get executed out of order. Except we need them in order! + scatterlayer.selectAll('g.trace').each(function(d, i) { + plotOne(gd, i, plotinfo, d, cdscatter, this); + }); - arraysToCalcdata(d); + if(isFullReplot) { + join.exit().remove(); + } + + // remove paths that didn't get used + scatterlayer.selectAll('path:not([d])').remove(); +}; - if(!subTypes.hasLines(trace) && trace.fill === 'none') return; +function createFills(gd, scatterlayer) { + var trace; - var thispath, - thisrevpath, - // fullpath is all paths for this curve, joined together straight - // across gaps, for filling - fullpath = '', - // revpath is fullpath reversed, for fill-to-next - revpath = '', - // functions for converting a point array to a path - pathfn, revpathbase, revpathfn; + scatterlayer.selectAll('g.trace').each(function(d) { + var tr = d3.select(this); - // make the fill-to-zero path now, so it shows behind the line - // fill to next puts the fill associated with one trace - // grouped with the previous - if(trace.fill.substr(0, 6) === 'tozero' || trace.fill === 'toself' || - (trace.fill.substr(0, 2) === 'to' && !prevpath)) { - ownFillEl3 = tr.append('path') - .classed('js-fill', true); - } - else ownFillEl3 = null; + // Loop only over the traces being redrawn: + trace = d[0].trace; // make the fill-to-next path now for the NEXT trace, so it shows // behind both lines. - // nexttonext was created last time, but give it - // this curve's data for fill color - if(nexttonext) tonext = nexttonext.datum(d); + if(trace._nexttrace) { + trace._nextFill = tr.select('.js-fill.js-tonext'); + if(!trace._nextFill.size()) { + + // If there is an existing tozero fill, we must insert this *after* that fill: + var loc = ':first-child'; + if(tr.select('.js-fill.js-tozero').size()) { + loc += ' + *'; + } - // now make a new nexttonext for next time - nexttonext = tr.append('path').classed('js-fill', true); + trace._nextFill = tr.insert('path', loc).attr('class', 'js-fill js-tonext'); + } + } else { + tr.selectAll('.js-fill.js-tonext').remove(); + trace._nextFill = null; + } + + if(trace.fill.substr(0, 6) === 'tozero' || trace.fill === 'toself' || + (trace.fill.substr(0, 2) === 'to' && !trace._prevtrace)) { + trace._ownFill = tr.select('.js-fill.js-tozero'); + if(!trace._ownFill.size()) { + trace._ownFill = tr.insert('path', ':first-child').attr('class', 'js-fill js-tozero'); + } + } else { + tr.selectAll('.js-fill.js-tozero').remove(); + trace._ownFill = null; + } + }); +} + +function plotOne(gd, idx, plotinfo, cdscatter, cdscatterAll, element) { + var join, i; + + // Since this has been reorganized and we're executing this on individual traces, + // we need to pass it the full list of cdscatter as well as this trace's index (idx) + // since it does an internal n^2 loop over comparisons with other traces: + selectMarkers(gd, idx, plotinfo, cdscatter, cdscatterAll); + + var xa = plotinfo.x(), + ya = plotinfo.y(); + + var trace = cdscatter[0].trace, + line = trace.line, + tr = d3.select(element); + + // (so error bars can find them along with bars) + // error bars are at the bottom + tr.call(ErrorBars.plot, plotinfo); + + if(trace.visible !== true) return; + + // BUILD LINES AND FILLS + var ownFillEl3, tonext; + var ownFillDir = trace.fill.charAt(trace.fill.length - 1); + if(ownFillDir !== 'x' && ownFillDir !== 'y') ownFillDir = ''; + + // store node for tweaking by selectPoints + cdscatter[0].node3 = tr; + + arraysToCalcdata(cdscatter); + + var prevpath = ''; + var prevPolygons = []; + var prevtrace = trace._prevtrace; + + if(prevtrace) { + prevpath = prevtrace._revpath || ''; + tonext = prevtrace._nextFill; + prevPolygons = prevtrace._polygons; + } + + var thispath, + thisrevpath, + // fullpath is all paths for this curve, joined together straight + // across gaps, for filling + fullpath = '', + // revpath is fullpath reversed, for fill-to-next + revpath = '', + // functions for converting a point array to a path + pathfn, revpathbase, revpathfn; + + ownFillEl3 = trace._ownFill; + + if(subTypes.hasLines(trace) || trace.fill !== 'none') { + if(tonext) { + // This tells .style which trace to use for fill information: + tonext.datum(cdscatter); + } if(['hv', 'vh', 'hvh', 'vhv'].indexOf(line.shape) !== -1) { pathfn = Drawing.steps(line.shape); @@ -120,7 +201,7 @@ module.exports = function plot(gd, plotinfo, cdscatter) { return revpathbase(pts.reverse()); }; - var segments = linePoints(d, { + var segments = linePoints(cdscatter, { xaxis: xa, yaxis: ya, connectGaps: trace.connectgaps, @@ -132,9 +213,7 @@ module.exports = function plot(gd, plotinfo, cdscatter) { // polygons for hover on fill // TODO: can we skip this if hoveron!=fills? That would mean we // need to redraw when you change hoveron... - var thisPolygons = trace._polygons = new Array(segments.length), - i; - + var thisPolygons = trace._polygons = new Array(segments.length); for(i = 0; i < segments.length; i++) { trace._polygons[i] = polygonTester(segments[i]); } @@ -144,8 +223,12 @@ module.exports = function plot(gd, plotinfo, cdscatter) { lastSegment = segments[segments.length - 1], pt1 = lastSegment[lastSegment.length - 1]; - for(i = 0; i < segments.length; i++) { - var pts = segments[i]; + var lineJoin = tr.selectAll('.js-line').data(segments); + + lineJoin.enter() + .append('path').classed('js-line', true); + + lineJoin.each(function(pts) { thispath = pathfn(pts); thisrevpath = revpathfn(pts); if(!fullpath) { @@ -161,12 +244,14 @@ module.exports = function plot(gd, plotinfo, cdscatter) { revpath = thisrevpath + 'Z' + revpath; } if(subTypes.hasLines(trace) && pts.length > 1) { - tr.append('path') - .classed('js-line', true) - .style('vector-effect', 'non-scaling-stroke') - .attr('d', thispath); + d3.select(this) + .attr('d', thispath) + .datum(cdscatter); } - } + }); + + lineJoin.exit().remove(); + if(ownFillEl3) { if(pt0 && pt1) { if(ownFillDir) { @@ -180,9 +265,10 @@ module.exports = function plot(gd, plotinfo, cdscatter) { // fill to zero: full trace path, plus extension of // the endpoints to the appropriate axis ownFillEl3.attr('d', fullpath + 'L' + pt1 + 'L' + pt0 + 'Z'); + } else { + // fill to self: just join the path to itself + ownFillEl3.attr('d', fullpath + 'Z'); } - // fill to self: just join the path to itself - else ownFillEl3.attr('d', fullpath + 'Z'); } } else if(trace.fill.substr(0, 6) === 'tonext' && fullpath && prevpath) { @@ -204,88 +290,128 @@ module.exports = function plot(gd, plotinfo, cdscatter) { } trace._polygons = trace._polygons.concat(prevPolygons); } - prevpath = revpath; - prevPolygons = thisPolygons; + trace._revpath = revpath; + trace._prevPolygons = thisPolygons; } - }); + } - // remove paths that didn't get used - scattertraces.selectAll('path:not([d])').remove(); function visFilter(d) { return d.filter(function(v) { return v.vis; }); } - scattertraces.append('g') - .attr('class', 'points') - .each(function(d) { - var trace = d[0].trace, - s = d3.select(this), - showMarkers = subTypes.hasMarkers(trace), - showText = subTypes.hasText(trace); - - if((!showMarkers && !showText) || trace.visible !== true) s.remove(); - else { - if(showMarkers) { - s.selectAll('path.point') - .data(trace.marker.maxdisplayed ? visFilter : Lib.identity) - .enter().append('path') - .classed('point', true) - .call(Drawing.translatePoints, xa, ya); - } - if(showText) { - s.selectAll('g') - .data(trace.marker.maxdisplayed ? visFilter : Lib.identity) - // each text needs to go in its own 'g' in case - // it gets converted to mathjax - .enter().append('g') - .append('text') - .call(Drawing.translatePoints, xa, ya); - } + function keyFunc(d) { + return d.key; + } + + // Returns a function if the trace is keyed, otherwise returns undefined + function getKeyFunc(trace) { + if(trace.key) { + return keyFunc; + } + } + + function makePoints(d) { + var join, selection; + var trace = d[0].trace, + s = d3.select(this), + showMarkers = subTypes.hasMarkers(trace), + showText = subTypes.hasText(trace); + + if((!showMarkers && !showText) || trace.visible !== true) s.remove(); + else { + if(showMarkers) { + selection = s.selectAll('path.point'); + + join = selection + .data(trace.marker.maxdisplayed ? visFilter : Lib.identity, getKeyFunc(trace)); + + join.enter().append('path') + .classed('point', true) + .call(Drawing.pointStyle, trace) + .call(Drawing.translatePoints, xa, ya); + + selection + .call(Drawing.translatePoints, xa, ya) + .call(Drawing.pointStyle, trace); + + join.exit().remove(); } - }); -}; + if(showText) { + selection = s.selectAll('g'); + + join = selection + .data(trace.marker.maxdisplayed ? visFilter : Lib.identity); + + // each text needs to go in its own 'g' in case + // it gets converted to mathjax + join.enter().append('g') + .append('text') + .call(Drawing.translatePoints, xa, ya); + + selection + .call(Drawing.translatePoints, xa, ya); + + join.exit().remove(); + } + } + } -function selectMarkers(gd, plotinfo, cdscatter) { + // NB: selectAll is evaluated on instantiation: + var pointSelection = tr.selectAll('.points'); + + // Join with new data + join = pointSelection.data([cdscatter]); + + // Transition existing, but don't defer this to an async .transition since + // there's no timing involved: + pointSelection.each(makePoints); + + join.enter().append('g') + .classed('points', true) + .each(makePoints); + + join.exit().remove(); +} + +function selectMarkers(gd, idx, plotinfo, cdscatter, cdscatterAll) { var xa = plotinfo.x(), ya = plotinfo.y(), xr = d3.extent(xa.range.map(xa.l2c)), yr = d3.extent(ya.range.map(ya.l2c)); - cdscatter.forEach(function(d, i) { - var trace = d[0].trace; - if(!subTypes.hasMarkers(trace)) return; - // if marker.maxdisplayed is used, select a maximum of - // mnum markers to show, from the set that are in the viewport - var mnum = trace.marker.maxdisplayed; - - // TODO: remove some as we get away from the viewport? - if(mnum === 0) return; - - var cd = d.filter(function(v) { - return v.x >= xr[0] && v.x <= xr[1] && v.y >= yr[0] && v.y <= yr[1]; - }), - inc = Math.ceil(cd.length / mnum), - tnum = 0; - cdscatter.forEach(function(cdj, j) { - var tracei = cdj[0].trace; - if(subTypes.hasMarkers(tracei) && - tracei.marker.maxdisplayed > 0 && j < i) { - tnum++; - } - }); + var trace = cdscatter[0].trace; + if(!subTypes.hasMarkers(trace)) return; + // if marker.maxdisplayed is used, select a maximum of + // mnum markers to show, from the set that are in the viewport + var mnum = trace.marker.maxdisplayed; + + // TODO: remove some as we get away from the viewport? + if(mnum === 0) return; + + var cd = cdscatter.filter(function(v) { + return v.x >= xr[0] && v.x <= xr[1] && v.y >= yr[0] && v.y <= yr[1]; + }), + inc = Math.ceil(cd.length / mnum), + tnum = 0; + cdscatterAll.forEach(function(cdj, j) { + var tracei = cdj[0].trace; + if(subTypes.hasMarkers(tracei) && + tracei.marker.maxdisplayed > 0 && j < idx) { + tnum++; + } + }); - // if multiple traces use maxdisplayed, stagger which markers we - // display this formula offsets successive traces by 1/3 of the - // increment, adding an extra small amount after each triplet so - // it's not quite periodic - var i0 = Math.round(tnum * inc / 3 + Math.floor(tnum / 3) * inc / 7.1); - - // for error bars: save in cd which markers to show - // so we don't have to repeat this - d.forEach(function(v) { delete v.vis; }); - cd.forEach(function(v, i) { - if(Math.round((i + i0) % inc) === 0) v.vis = true; - }); + // if multiple traces use maxdisplayed, stagger which markers we + // display this formula offsets successive traces by 1/3 of the + // increment, adding an extra small amount after each triplet so + // it's not quite periodic + var i0 = Math.round(tnum * inc / 3 + Math.floor(tnum / 3) * inc / 7.1); + + // for error bars: save in cd which markers to show + // so we don't have to repeat this + cdscatter.forEach(function(v) { delete v.vis; }); + cd.forEach(function(v, i) { + if(Math.round((i + i0) % inc) === 0) v.vis = true; }); } diff --git a/test/image/mocks/ternary_simple.json b/test/image/mocks/ternary_simple.json index ea1d78ff2a3..62bc4574a66 100644 --- a/test/image/mocks/ternary_simple.json +++ b/test/image/mocks/ternary_simple.json @@ -16,8 +16,7 @@ 1, 2.12345 ], - "type": "scatterternary", - "uid": "412fa8" + "type": "scatterternary" } ], "layout": { diff --git a/test/jasmine/tests/calcdata_test.js b/test/jasmine/tests/calcdata_test.js index fb9c3049994..931b151bed0 100644 --- a/test/jasmine/tests/calcdata_test.js +++ b/test/jasmine/tests/calcdata_test.js @@ -18,15 +18,15 @@ describe('calculated data and points', function() { it('should exclude null and undefined points when false', function() { Plotly.plot(gd, [{ x: [1, 2, 3, undefined, 5], y: [1, null, 3, 4, 5]}], {}); - expect(gd.calcdata[0][1]).toEqual({ x: false, y: false}); - expect(gd.calcdata[0][3]).toEqual({ x: false, y: false}); + expect(gd.calcdata[0][1]).toEqual(jasmine.objectContaining({x: false, y: false})); + expect(gd.calcdata[0][3]).toEqual(jasmine.objectContaining({x: false, y: false})); }); it('should exclude null and undefined points as categories when false', function() { Plotly.plot(gd, [{ x: [1, 2, 3, undefined, 5], y: [1, null, 3, 4, 5] }], { xaxis: { type: 'category' }}); - expect(gd.calcdata[0][1]).toEqual({ x: false, y: false}); - expect(gd.calcdata[0][3]).toEqual({ x: false, y: false}); + expect(gd.calcdata[0][1]).toEqual(jasmine.objectContaining({x: false, y: false})); + expect(gd.calcdata[0][3]).toEqual(jasmine.objectContaining({x: false, y: false})); }); }); @@ -180,9 +180,9 @@ describe('calculated data and points', function() { }}); expect(gd.calcdata[0][0]).toEqual(jasmine.objectContaining({x: 4, y: 15})); - expect(gd.calcdata[0][1]).toEqual({ x: false, y: false}); + expect(gd.calcdata[0][1]).toEqual(jasmine.objectContaining({x: false, y: false})); expect(gd.calcdata[0][2]).toEqual(jasmine.objectContaining({x: 3, y: 12})); - expect(gd.calcdata[0][3]).toEqual({ x: false, y: false}); + expect(gd.calcdata[0][3]).toEqual(jasmine.objectContaining({x: false, y: false})); expect(gd.calcdata[0][4]).toEqual(jasmine.objectContaining({x: 2, y: 14})); }); @@ -257,7 +257,7 @@ describe('calculated data and points', function() { }}); expect(gd.calcdata[0][0]).toEqual(jasmine.objectContaining({x: 6, y: 15})); - expect(gd.calcdata[0][1]).toEqual({x: false, y: false}); + expect(gd.calcdata[0][1]).toEqual(jasmine.objectContaining({x: false, y: false})); expect(gd.calcdata[0][2]).toEqual(jasmine.objectContaining({x: 5, y: 12})); expect(gd.calcdata[0][3]).toEqual(jasmine.objectContaining({x: 0, y: 13})); expect(gd.calcdata[0][4]).toEqual(jasmine.objectContaining({x: 3, y: 14})); diff --git a/test/jasmine/tests/cartesian_test.js b/test/jasmine/tests/cartesian_test.js index 7fbe66fbef5..9bc024d6acb 100644 --- a/test/jasmine/tests/cartesian_test.js +++ b/test/jasmine/tests/cartesian_test.js @@ -7,7 +7,6 @@ var createGraphDiv = require('../assets/create_graph_div'); var destroyGraphDiv = require('../assets/destroy_graph_div'); var mouseEvent = require('../assets/mouse_event'); - describe('zoom box element', function() { var mock = require('@mocks/14.json'); @@ -50,6 +49,104 @@ describe('zoom box element', function() { }); }); +describe('restyle', function() { + describe('scatter traces', function() { + var gd; + + beforeEach(function() { + gd = createGraphDiv(); + }); + + afterEach(destroyGraphDiv); + + it('reuses SVG fills', function(done) { + var fills, firstToZero, secondToZero, firstToNext, secondToNext; + var mock = Lib.extendDeep({}, require('@mocks/basic_area.json')); + + Plotly.plot(gd, mock.data, mock.layout).then(function() { + // Assert there are two fills: + fills = d3.selectAll('g.trace.scatter .js-fill')[0]; + + // First is tozero, second is tonext: + expect(d3.selectAll('g.trace.scatter .js-fill').size()).toEqual(2); + expect(fills[0].classList.contains('js-tozero')).toBe(true); + expect(fills[0].classList.contains('js-tonext')).toBe(false); + expect(fills[1].classList.contains('js-tozero')).toBe(false); + expect(fills[1].classList.contains('js-tonext')).toBe(true); + + firstToZero = fills[0]; + firstToNext = fills[1]; + }).then(function() { + return Plotly.restyle(gd, {visible: [false]}, [1]); + }).then(function() { + // Trace 1 hidden leaves only trace zero's tozero fill: + expect(d3.selectAll('g.trace.scatter .js-fill').size()).toEqual(1); + expect(fills[0].classList.contains('js-tozero')).toBe(true); + expect(fills[0].classList.contains('js-tonext')).toBe(false); + }).then(function() { + return Plotly.restyle(gd, {visible: [true]}, [1]); + }).then(function() { + // Reshow means two fills again AND order is preserved: + fills = d3.selectAll('g.trace.scatter .js-fill')[0]; + + // First is tozero, second is tonext: + expect(d3.selectAll('g.trace.scatter .js-fill').size()).toEqual(2); + expect(fills[0].classList.contains('js-tozero')).toBe(true); + expect(fills[0].classList.contains('js-tonext')).toBe(false); + expect(fills[1].classList.contains('js-tozero')).toBe(false); + expect(fills[1].classList.contains('js-tonext')).toBe(true); + + secondToZero = fills[0]; + secondToNext = fills[1]; + + // The identity of the first is retained: + expect(firstToZero).toBe(secondToZero); + + // The second has been recreated so is different: + expect(firstToNext).not.toBe(secondToNext); + }).then(done); + }); + + it('reuses SVG lines', function(done) { + var lines, firstLine1, secondLine1, firstLine2, secondLine2; + var mock = Lib.extendDeep({}, require('@mocks/basic_line.json')); + + Plotly.plot(gd, mock.data, mock.layout).then(function() { + lines = d3.selectAll('g.scatter.trace .js-line'); + + firstLine1 = lines[0][0]; + firstLine2 = lines[0][1]; + + // One line for each trace: + expect(lines.size()).toEqual(2); + }).then(function() { + return Plotly.restyle(gd, {visible: [false]}, [0]); + }).then(function() { + lines = d3.selectAll('g.scatter.trace .js-line'); + + // Only one line now and it's equal to the second trace's line from above: + expect(lines.size()).toEqual(1); + expect(lines[0][0]).toBe(firstLine2); + }).then(function() { + return Plotly.restyle(gd, {visible: [true]}, [0]); + }).then(function() { + lines = d3.selectAll('g.scatter.trace .js-line'); + secondLine1 = lines[0][0]; + secondLine2 = lines[0][1]; + + // Two lines once again: + expect(lines.size()).toEqual(2); + + // First line has been removed and recreated: + expect(firstLine1).not.toBe(secondLine1); + + // Second line was persisted: + expect(firstLine2).toBe(secondLine2); + }).then(done); + }); + }); +}); + describe('relayout', function() { describe('axis category attributes', function() { From d7e4e1a5b1f5443f2ec563fb92f7bf1775305dbf Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Fri, 29 Jul 2016 11:10:09 -0400 Subject: [PATCH 47/82] Implement plotly .animate() and keyframe API Conflicts: src/plot_api/plot_api.js --- animate-api.md | 133 +++++++++++ src/core.js | 6 + src/lib/merge_keyframes.js | 31 +++ src/plot_api/plot_api.js | 257 ++++++++++++++++++++- src/plots/plots.js | 62 +++++ src/traces/scatter/plot.js | 6 +- test/jasmine/tests/frame_api_test.js | 205 ++++++++++++++++ test/jasmine/tests/keyframe_computation.js | 48 ++++ 8 files changed, 745 insertions(+), 3 deletions(-) create mode 100644 animate-api.md create mode 100644 src/lib/merge_keyframes.js create mode 100644 test/jasmine/tests/frame_api_test.js create mode 100644 test/jasmine/tests/keyframe_computation.js diff --git a/animate-api.md b/animate-api.md new file mode 100644 index 00000000000..8b65e1aee14 --- /dev/null +++ b/animate-api.md @@ -0,0 +1,133 @@ +## Top-level Plotly API methods + +#### `Plotly.transition(gd, data, layout[, traceIndices[, config]])` +Transition (eased or abruptly if desired) to a new set of data. Knows nothing about the larger state of transitions and frames; identically a 'transition the plot to look like X over Y ms' command. + +**Parameters**: +- `data`: an *array* of *objects* containing trace data, e.g. `[{x: [1, 2, 3], 'lines.color': 'red'}, {y: [7,8]}]`, mapped to traces. +- `layout`: layout properties to which to transition, probably mostly just axis ranges +- `traceIndices`: a mapping between the items of `data` and the trace indices, e.g. `[0, 2]`. If omitted, is inferred from semantics like for `restyle`—which means maybe affecting all traces? +- `config`: object containing transition configuration, including: + - `duration`: duration in ms of transition + - `ease`: d3 easing function, e.g. `elastic-in-out` + - `delay`: delay until animation; not so useful, just very very easy to pass to d3 + - `cascade`: transition points in sequence for a nice visual effect. Maybe just leave out. Kind of a common visual effect for eye candy purposes. Very easy. Can leave out if it leads to weird corner cases. See: http://rickyreusser.com/animation-experiments/#object-constancy + +**Returns**: promise that resolves when animation begins or rejects if config is invalid. + +**Events**: +- `plotly_starttransition` +- `plotly_endtransition` + +
+ +#### `Plotly.animate(gd, frame[, config])` +Transition to a keyframe. Animation sequence is: + +1. Compute the requested frame +2. Separate animatable and non-animatable properties into separate objects +3. Mark exactly what needs to happen. This includes transitions vs. non-animatable properties, whether the axis needs to be redrawn (`needsRelayout`?), and any other optimizations that seem relevant. Since for some cases very simple updates may be coming through at up to 60fps, cutting out work here could be fairly important. + +**Parameters**: +- `frame`: name of the frame to which to animate +- `config`: see `.transition`. + +**Returns**: promise that resolves when animation begins or rejects if config is invalid. + +**Events**: +- `plotly_startanimation` +- `plotly_endanimation` + +
+ +#### `Plotly.addFrames(gd, frames[, frameIndices])` +Add or overwrite frames. New frames are appended to current frame list. + +**Parameters** +- `frames`: an array of objects containing any of `name`, `data`, `layout` and `traceIndices` fields as specified above. If no name is provided, a unique name (e.g. `frame 7`) will be assigned. If the frame already exists, then its definition is overwritten. +- `frameIndices`: optional array of indices at which to insert the given frames. If indices are omitted or a specific index is falsey, then frame is appended. + +**Returns**: Promise that resolves on completion. (In this case, that's synchronously and mainly for the sake of API consistency.) + +
+ +#### `Plotly.deleteFrames(gd, frameIndices)` +Remove frames by frame index. + +**Parameters**: +- `frameIndices`: an array of integer indices of the frames to be removed. + +**Returns**: Promise that resolves on completion (which here means synchronously). + +
+ +## Frame definition + +Frames are defined similarly to mirror the input format, *not* that of `Plotly.restyle`. The easiest way to explain seems to be via an example that touches all features: + +```json +{ + "data": [{ + "x": [1, 2, 3], + "y": [4, 5, 6], + "identifiers": ["China", "Pakistan", "Australia"], + "lines": { + "color": "red" + } + }, { + "x": [1, 2, 3], + "y": [3, 8, 9], + "markers": { + "color": "red" + } + }], + "layout": { + "slider": { + "visible": true, + "plotly_method": "animate", + "args": ["$value", {"duration": 500}] + }, + "slider2": { + "visible": true, + "plotly_method": "animate", + "args": ["$value", {"duration": 500}] + } + }, + "frames": [ + { + "name": "base", + "y": [4, 5, 7], + "identifiers": ["China", "Pakistan", "Australia"], + }, { + "name": "1960", + "data": [{ + "y": [1, 2, 3], + "identifiers": ["China", "Pakistan", "Australia"], + }], + "layout": { + "xaxis": {"range": [7, 3]}, + "yaxis": {"range": [0, 5]} + }, + "baseFrame": "base", + "traceIndices": [0] + }, { + "name": "1965", + "data": [{ + "y": [5, 3, 2], + "identifiers": ["China", "Pakistan", "Australia"], + }], + "layout": { + "xaxis": {"range": [7, 3]}, + "yaxis": {"range": [0, 5]} + }, + "baseFrame": "base", + "traceIndices": [0] + } + ] +} +``` + +Notes on JSON: +- `identifiers` is used as a d3 `key` argument. +- `baseFrame` is merged… recursively? non-recursively? We'll see. Not a crucial implementation choice. +- `frames` seems maybe best stored at top level. Or maybe best on the object. If on the object, `Plotly.plot` would have to be variadic (probably), accepting `Plotly.plot(gd, data, layout[, frames], config)`. That's backward-compatible but a bit ugly. If not on the object, then it would have to be shoved into `layout` (except how, because it's really awkward place in `layout`. diff --git a/src/core.js b/src/core.js index 795a2b6367c..e3c6a6f13f1 100644 --- a/src/core.js +++ b/src/core.js @@ -28,6 +28,12 @@ exports.prependTraces = Plotly.prependTraces; exports.addTraces = Plotly.addTraces; exports.deleteTraces = Plotly.deleteTraces; exports.moveTraces = Plotly.moveTraces; +exports.animate = Plotly.animate; +exports.addFrames = Plotly.addFrames; +exports.deleteFrames = Plotly.deleteFrames; +exports.renameFrame = Plotly.renameFrame; +exports.transition = Plotly.transition; +exports.animate = Plotly.animate; exports.purge = Plotly.purge; exports.setPlotConfig = require('./plot_api/set_plot_config'); exports.register = Plotly.register; diff --git a/src/lib/merge_keyframes.js b/src/lib/merge_keyframes.js new file mode 100644 index 00000000000..5ec15d8e39a --- /dev/null +++ b/src/lib/merge_keyframes.js @@ -0,0 +1,31 @@ +/** +* Copyright 2012-2016, 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 extend = require('./extend'); + +/* + * Merge two keyframe specifications, returning in a third object that + * can be used for plotting. + * + * @param {object} target + * An object with data, layout, and trace data + * @param {object} source + * An object with data, layout, and trace data + * + * Returns: a third object with the merged content + */ +module.exports = function mergeKeyframes(target, source) { + var result; + + result = extend.extendDeep({}, target); + result = extend.extendDeep(result, source); + + return result; +}; diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index 95f1edd1076..73965fab877 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -857,13 +857,17 @@ Plotly.newPlot = function(gd, data, layout, config) { return Plotly.plot(gd, data, layout, config); }; -function doCalcdata(gd) { +function doCalcdata(gd, traces) { var axList = Plotly.Axes.list(gd), fullData = gd._fullData, fullLayout = gd._fullLayout, i; - var calcdata = gd.calcdata = new Array(fullData.length); + // XXX: Is this correct? Needs a closer look so that *some* traces can be recomputed without + // *all* needing doCalcdata: + var calcdata = new Array(fullData.length); + var oldCalcdata = gd.calcdata.slice(0); + gd.calcdata = calcdata; // extra helper variables // firstscatter: fill-to-next on the first trace goes to zero @@ -890,6 +894,12 @@ function doCalcdata(gd) { var trace = fullData[i], _module = trace._module, cd = []; + // If traces were specified and this trace was not included, then transfer it over from + // the old calcdata: + if(Array.isArray(traces) && traces.indexOf(i) === -1) { + calcdata[i] = oldCalcdata[i]; + continue; + } if(_module && trace.visible === true) { if(_module.calc) cd = _module.calc(gd, trace); @@ -2491,6 +2501,249 @@ Plotly.relayout = function relayout(gd, astr, val) { }); }; +/** + * Transition to a set of new data and layout properties + * + * @param {string id or DOM element} gd + * the id or DOM element of the graph container div + */ +Plotly.transition = function(gd /*, data, layout, traces, transitionConfig*/) { + gd = getGraphDiv(gd); + + /*var fullLayout = gd._fullLayout; + + transitionConfig = Lib.extendFlat({ + ease: 'cubic-in-out', + duration: 500, + delay: 0 + }, transitionConfig || {}); + + // Create a single transition to be passed around: + if(transitionConfig.duration > 0) { + gd._currentTransition = d3.transition() + .duration(transitionConfig.duration) + .delay(transitionConfig.delay) + .ease(transitionConfig.ease); + } else { + gd._currentTransition = null; + } + + // Select which traces will be updated: + if(isNumeric(traces)) traces = [traces]; + else if(!Array.isArray(traces) || !traces.length) { + traces = gd._fullData.map(function(v, i) {return i;}); + } + + var transitioningTraces = []; + + function prepareAnimations() { + for(i = 0; i < traces.length; i++) { + var traceIdx = traces[i]; + var trace = gd._fullData[traceIdx]; + var module = trace._module; + + if(!module.animatable) { + continue; + } + + transitioningTraces.push(traceIdx); + + newTraceData = newData[i]; + curData = gd.data[traces[i]]; + + for(var ai in newTraceData) { + var value = newTraceData[ai]; + Lib.nestedProperty(curData, ai).set(value); + } + + var traceIdx = traces[i]; + if(gd.data[traceIdx].marker && gd.data[traceIdx].marker.size) { + gd._fullData[traceIdx].marker.size = gd.data[traceIdx].marker.size; + } + if(gd.data[traceIdx].error_y && gd.data[traceIdx].error_y.array) { + gd._fullData[traceIdx].error_y.array = gd.data[traceIdx].error_y.array; + } + if(gd.data[traceIdx].error_x && gd.data[traceIdx].error_x.array) { + gd._fullData[traceIdx].error_x.array = gd.data[traceIdx].error_x.array; + } + gd._fullData[traceIdx].x = gd.data[traceIdx].x; + gd._fullData[traceIdx].y = gd.data[traceIdx].y; + gd._fullData[traceIdx].z = gd.data[traceIdx].z; + gd._fullData[traceIdx].key = gd.data[traceIdx].key; + } + + doCalcdata(gd, transitioningTraces); + + ErrorBars.calc(gd); + } + + function doAnimations() { + var a, i, j; + var basePlotModules = fullLayout._basePlotModules; + for(j = 0; j < basePlotModules.length; j++) { + basePlotModules[j].plot(gd, transitioningTraces, transitionOpts); + } + + if(layout) { + for(j = 0; j < basePlotModules.length; j++) { + if(basePlotModules[j].transitionAxes) { + basePlotModules[j].transitionAxes(gd, layout, transitionOpts); + } + } + } + + if(!transitionOpts.leadingEdgeRestyle) { + return new Promise(function(resolve, reject) { + completion = resolve; + completionTimeout = setTimeout(resolve, transitionOpts.duration); + }); + } + }*/ +}; + +/** + * Animate to a keyframe + * + * @param {string} name + * name of the keyframe to create + * @param {object} transitionConfig + * configuration for transition + */ +Plotly.animate = function(gd, name /*, transitionConfig*/) { + gd = getGraphDiv(gd); + + var _frames = gd._frameData._frames; + + if(!_frames[name]) { + Lib.warn('animateToFrame failure: keyframe does not exist', name); + return Promise.reject(); + } + + return Promise.resolve(); +}; + +/** + * Create new keyframes + * + * @param {array of objects} frameList + * list of frame definitions, in which each object includes any of: + * - name: {string} name of keyframe to add + * - data: {array of objects} trace data + * - layout {object} layout definition + * - traces {array} trace indices + * - baseFrame {string} name of keyframe from which this keyframe gets defaults + */ +Plotly.addFrames = function(gd, frameList, indices) { + gd = getGraphDiv(gd); + + var i, frame, j, idx; + var _frames = gd._frameData._frames; + var _hash = gd._frameData._frameHash; + + + if(!Array.isArray(frameList)) { + Lib.warn('addFrames failure: frameList must be an Array of frame definitions', frameList); + return Promise.reject(); + } + + // Create a sorted list of insertions since we run into lots of problems if these + // aren't in ascending order of index: + // + // Strictly for sorting. Make sure this is guaranteed to never collide with any + // already-exisisting indices: + var bigIndex = _frames.length + frameList.length * 2; + + var insertions = []; + for(i = frameList.length - 1; i >= 0; i--) { + insertions.push({ + frame: frameList[i], + index: (indices && indices[i] !== undefined) ? indices[i] : bigIndex + i + }); + } + + // Sort this, taking note that undefined insertions end up at the end: + insertions.sort(function(a, b) { + if(a.index > b.index) return -1; + if(a.index < b.index) return 1; + return 0; + }); + + var ops = []; + var revops = []; + var frameCount = _frames.length; + + for(i = insertions.length - 1; i >= 0; i--) { + //frame = frameList[i]; + frame = insertions[i].frame; + + if(!frame.name) { + while(_frames[(frame.name = 'frame ' + gd._frameData._counter++)]); + } + + if(_hash[frame.name]) { + // If frame is present, overwrite its definition: + for(j = 0; j < _frames.length; j++) { + if(_frames[j].name === frame.name) break; + } + ops.push({type: 'replace', index: j, value: frame}); + revops.unshift({type: 'replace', index: j, value: _frames[j]}); + } else { + // Otherwise insert it at the end of the list: + idx = Math.min(insertions[i].index, frameCount); + + ops.push({type: 'insert', index: idx, value: frame}); + revops.unshift({type: 'delete', index: idx}); + frameCount++; + } + } + + Plots.modifyFrames(gd, ops); + + var undoFunc = Plots.modifyFrames, + redoFunc = Plots.modifyFrames, + undoArgs = [gd, revops], + redoArgs = [gd, ops]; + + if(Queue) Queue.add(gd, undoFunc, undoArgs, redoFunc, redoArgs); + + return Promise.resolve(); +}; + +/** + * Delete keyframes + * + * @param {array of integers} frameList + * list of integer indices of frames to be deleted + */ +Plotly.deleteFrames = function(gd, frameList) { + gd = getGraphDiv(gd); + + var i, idx; + var _frames = gd._frameData._frames; + var ops = []; + var revops = []; + + frameList = frameList.splice(0); + frameList.sort(); + + for(i = frameList.length - 1; i >= 0; i--) { + idx = frameList[i]; + ops.push({type: 'delete', index: idx}); + revops.unshift({type: 'insert', index: idx, value: _frames[idx]}); + } + + Plots.modifyFrames(gd, ops); + + var undoFunc = Plots.modifyFrames, + redoFunc = Plots.modifyFrames, + undoArgs = [gd, revops], + redoArgs = [gd, ops]; + + if(Queue) Queue.add(gd, undoFunc, undoArgs, redoFunc, redoArgs); + + return Promise.resolve(); +}; + /** * Purge a graph container div back to its initial pre-Plotly.plot state * diff --git a/src/plots/plots.js b/src/plots/plots.js index 08748bebed5..0c54d8f4620 100644 --- a/src/plots/plots.js +++ b/src/plots/plots.js @@ -453,6 +453,10 @@ plots.sendDataToCloud = function(gd) { // gd._fullLayout._basePlotModules // is a list of all the plot modules required to draw the plot. // +// gd._frameData +// object containing frame definitions (_frameData._frames) and +// associated metadata. +// plots.supplyDefaults = function(gd) { var oldFullLayout = gd._fullLayout || {}, newFullLayout = gd._fullLayout = {}, @@ -526,6 +530,23 @@ plots.supplyDefaults = function(gd) { (gd.calcdata[i][0] || {}).trace = trace; } } + + // Set up the default keyframe if it doesn't exist: + if(!gd._frameData) { + gd._frameData = {}; + } + + if(!gd._frameData._frames) { + gd._frameData._frames = []; + } + + if(!gd._frameData._frameHash) { + gd._frameData._frameHash = {}; + } + + if(!gd._frameData._counter) { + gd._frameData._counter = 0; + } }; // helper function to be bound to fullLayout to check @@ -929,6 +950,7 @@ plots.purge = function(gd) { delete gd.numboxes; delete gd._hoverTimer; delete gd._lastHoverTime; + delete gd._frameData; // remove all event listeners if(gd.removeAllListeners) gd.removeAllListeners(); @@ -1193,3 +1215,43 @@ plots.graphJson = function(gd, dataonly, mode, output, useDefaults) { return (output === 'object') ? obj : JSON.stringify(obj); }; + +/** + * Modify a keyframe using a list of operations: + * + * @param {array of objects} operations + * Sequence of operations to be performed on the keyframes + */ +plots.modifyFrames = function(gd, operations) { + var i, op; + var _frames = gd._frameData._frames; + var _hash = gd._frameData._frameHash; + + for(i = 0; i < operations.length; i++) { + op = operations[i]; + + switch(op.type) { + case 'rename': + var frame = _frames[op.index]; + delete _hash[frame.name]; + _hash[op.name] = frame; + break; + case 'replace': + frame = op.value; + _frames[op.index] = _hash[frame.name] = frame; + break; + case 'insert': + frame = op.value; + _hash[frame.name] = frame; + _frames.splice(op.index, 0, frame); + break; + case 'delete': + frame = _frames[op.index]; + delete _hash[frame.name]; + _frames.splice(op.index, 1); + break; + } + } + + return Promise.resolve(); +}; diff --git a/src/traces/scatter/plot.js b/src/traces/scatter/plot.js index 8ec17ff2183..f6984cb78d3 100644 --- a/src/traces/scatter/plot.js +++ b/src/traces/scatter/plot.js @@ -21,11 +21,15 @@ var linePoints = require('./line_points'); var linkTraces = require('./link_traces'); var polygonTester = require('../../lib/polygon').tester; -module.exports = function plot(gd, plotinfo, cdscatter, isFullReplot) { +module.exports = function plot(gd, plotinfo, cdscatter, transitionConfig) { var i, uids, selection, join; var scatterlayer = plotinfo.plot.select('g.scatterlayer'); + // If transition config is provided, then it is only a partial replot and traces not + // updated are removed. + var isFullReplot = !transitionConfig; + selection = scatterlayer.selectAll('g.trace'); join = selection.data(cdscatter, function(d) {return d[0].trace.uid;}); diff --git a/test/jasmine/tests/frame_api_test.js b/test/jasmine/tests/frame_api_test.js new file mode 100644 index 00000000000..37dc9821b8c --- /dev/null +++ b/test/jasmine/tests/frame_api_test.js @@ -0,0 +1,205 @@ +var Plotly = require('@lib/index'); + +var createGraphDiv = require('../assets/create_graph_div'); +var destroyGraphDiv = require('../assets/destroy_graph_div'); + +// A helper function so that failed tests don't simply stall: +function fail(done) { + return function(err) { + console.error(err.toString()); + expect(err.toString()).toBe(true); + done(); + }; +} + +describe('Test frame api', function() { + 'use strict'; + + var gd, mock, f, h; + + beforeEach(function(done) { + mock = [{x: [1, 2, 3], y: [2, 1, 3]}, {x: [1, 2, 3], y: [6, 4, 5]}]; + gd = createGraphDiv(); + Plotly.plot(gd, mock).then(function() { + f = gd._frameData._frames; + h = gd._frameData._frameHash; + }).then(done); + }); + + afterEach(destroyGraphDiv); + + describe('gd initialization', function() { + it('creates an empty list for frames', function() { + expect(gd._frameData._frames).toEqual([]); + }); + + it('creates an empty lookup table for frames', function() { + expect(gd._frameData._frameHash).toEqual({}); + }); + + it('initializes a name counter to zero', function() { + expect(gd._frameData._counter).toEqual(0); + }); + }); + + describe('addFrames', function() { + it('names an unnamed frame', function(done) { + Plotly.addFrames(gd, [{}]).then(function() { + expect(Object.keys(h)).toEqual(['frame 0']); + }).then(done, fail(done)); + }); + + it('creates multiple unnamed frames at the same time', function(done) { + Plotly.addFrames(gd, [{}, {}]).then(function() { + expect(f).toEqual([{name: 'frame 0'}, {name: 'frame 1'}]); + }).then(done, fail(done)); + }); + + it('creates multiple unnamed frames in series', function(done) { + Plotly.addFrames(gd, [{}]).then( + Plotly.addFrames(gd, [{}]) + ).then(function() { + expect(f).toEqual([{name: 'frame 0'}, {name: 'frame 1'}]); + }).then(done, fail(done)); + }); + + it('inserts frames at specific indices', function(done) { + var i; + var frames = []; + for(i = 0; i < 10; i++) { + frames.push({name: 'frame' + i}); + } + + Plotly.addFrames(gd, frames).then(function() { + return Plotly.addFrames(gd, [{name: 'inserted1'}, {name: 'inserted2'}, {name: 'inserted3'}], [5, 7, undefined]); + }).then(function() { + expect(f[5]).toEqual({name: 'inserted1'}); + expect(f[7]).toEqual({name: 'inserted2'}); + expect(f[12]).toEqual({name: 'inserted3'}); + + return Plotly.Queue.undo(gd); + }).then(function() { + for(i = 0; i < 10; i++) { + expect(f[i]).toEqual({name: 'frame' + i}); + } + }).then(done, fail(done)); + }); + + it('inserts frames at specific indices (reversed)', function(done) { + var i; + var frames = []; + for(i = 0; i < 10; i++) { + frames.push({name: 'frame' + i}); + } + + Plotly.addFrames(gd, frames).then(function() { + return Plotly.addFrames(gd, [{name: 'inserted3'}, {name: 'inserted2'}, {name: 'inserted1'}], [undefined, 7, 5]); + }).then(function() { + expect(f[5]).toEqual({name: 'inserted1'}); + expect(f[7]).toEqual({name: 'inserted2'}); + expect(f[12]).toEqual({name: 'inserted3'}); + + return Plotly.Queue.undo(gd); + }).then(function() { + for(i = 0; i < 10; i++) { + expect(f[i]).toEqual({name: 'frame' + i}); + } + }).then(done, fail(done)); + }); + + it('implements undo/redo', function(done) { + Plotly.addFrames(gd, [{name: 'frame 0'}, {name: 'frame 1'}]).then(function() { + expect(f).toEqual([{name: 'frame 0'}, {name: 'frame 1'}]); + expect(h).toEqual({'frame 0': {name: 'frame 0'}, 'frame 1': {name: 'frame 1'}}); + + return Plotly.Queue.undo(gd); + }).then(function() { + expect(f).toEqual([]); + expect(h).toEqual({}); + + return Plotly.Queue.redo(gd); + }).then(function() { + expect(f).toEqual([{name: 'frame 0'}, {name: 'frame 1'}]); + expect(h).toEqual({'frame 0': {name: 'frame 0'}, 'frame 1': {name: 'frame 1'}}); + }).then(done, fail(done)); + }); + + it('overwrites frames', function(done) { + // The whole shebang. This hits insertion + replacements + deletion + undo + redo: + Plotly.addFrames(gd, [{name: 'test1', x: 'y'}, {name: 'test2'}]).then(function() { + expect(f).toEqual([{name: 'test1', x: 'y'}, {name: 'test2'}]); + expect(Object.keys(h)).toEqual(['test1', 'test2']); + + return Plotly.addFrames(gd, [{name: 'test1'}, {name: 'test3'}]); + }).then(function() { + expect(f).toEqual([{name: 'test1'}, {name: 'test2'}, {name: 'test3'}]); + expect(Object.keys(h)).toEqual(['test1', 'test2', 'test3']); + + return Plotly.Queue.undo(gd); + }).then(function() { + expect(f).toEqual([{name: 'test1', x: 'y'}, {name: 'test2'}]); + expect(Object.keys(h)).toEqual(['test1', 'test2']); + + return Plotly.Queue.redo(gd); + }).then(function() { + expect(f).toEqual([{name: 'test1'}, {name: 'test2'}, {name: 'test3'}]); + expect(Object.keys(h)).toEqual(['test1', 'test2', 'test3']); + }).then(done, fail(done)); + }); + }); + + describe('deleteFrames', function() { + it('deletes a frame', function(done) { + Plotly.addFrames(gd, [{name: 'frame1'}]).then(function() { + expect(f).toEqual([{name: 'frame1'}]); + expect(Object.keys(h)).toEqual(['frame1']); + + return Plotly.deleteFrames(gd, [0]); + }).then(function() { + expect(f).toEqual([]); + expect(Object.keys(h)).toEqual([]); + + return Plotly.Queue.undo(gd); + }).then(function() { + expect(f).toEqual([{name: 'frame1'}]); + + return Plotly.Queue.redo(gd); + }).then(function() { + expect(f).toEqual([]); + expect(Object.keys(h)).toEqual([]); + }).then(done, fail(done)); + }); + + it('deletes multiple frames', function(done) { + var i; + var frames = []; + for(i = 0; i < 10; i++) { + frames.push({name: 'frame' + i}); + } + + Plotly.addFrames(gd, frames).then(function() { + return Plotly.deleteFrames(gd, [2, 8, 4, 6]); + }).then(function() { + var expected = ['frame0', 'frame1', 'frame3', 'frame5', 'frame7', 'frame9']; + expect(f.length).toEqual(expected.length); + for(i = 0; i < expected.length; i++) { + expect(f[i].name).toEqual(expected[i]); + } + + return Plotly.Queue.undo(gd); + }).then(function() { + for(i = 0; i < 10; i++) { + expect(f[i]).toEqual({name: 'frame' + i}); + } + + return Plotly.Queue.redo(gd); + }).then(function() { + var expected = ['frame0', 'frame1', 'frame3', 'frame5', 'frame7', 'frame9']; + expect(f.length).toEqual(expected.length); + for(i = 0; i < expected.length; i++) { + expect(f[i].name).toEqual(expected[i]); + } + }).then(done, fail(done)); + }); + }); +}); diff --git a/test/jasmine/tests/keyframe_computation.js b/test/jasmine/tests/keyframe_computation.js new file mode 100644 index 00000000000..429b52f81cd --- /dev/null +++ b/test/jasmine/tests/keyframe_computation.js @@ -0,0 +1,48 @@ +var mergeKeyframes = require('@src/lib/merge_keyframes'); + +describe('Test mergeKeyframes', function() { + 'use strict'; + + it('returns a new object', function() { + var f1 = {}; + var f2 = {}; + var result = mergeKeyframes(f1, f2); + expect(result).toEqual({}); + expect(result).not.toBe(f1); + expect(result).not.toBe(f2); + }); + + it('overrides properties of target with those of source', function() { + var tar = {xaxis: {range: [0, 1]}}; + var src = {xaxis: {range: [3, 4]}}; + var out = mergeKeyframes(tar, src); + + expect(out).toEqual({xaxis: {range: [3, 4]}}); + }); + + it('merges dotted properties', function() { + var tar = {}; + var src = {'xaxis.range': [0, 1]}; + var out = mergeKeyframes(tar, src); + + expect(out).toEqual({'xaxis.range': [0, 1]}); + }); + + describe('assimilating dotted properties', function() { + it('xaxis.range', function() { + var tar = {xaxis: {range: [0, 1]}}; + var src = {'xaxis.range': [3, 4]}; + var out = mergeKeyframes(tar, src); + + expect(out).toEqual({xaxis: {range: [3, 4]}}); + }); + + it('xaxis.range.0', function() { + var tar = {xaxis: {range: [0, 1]}}; + var src = {'xaxis.range.0': 3}; + var out = mergeKeyframes(tar, src); + + expect(out).toEqual({xaxis: {range: [3, 1]}}); + }); + }); +}); From b39f2210187f179b58661990f667c5238b5e6b05 Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Fri, 29 Jul 2016 11:13:54 -0400 Subject: [PATCH 48/82] Reuse SVG DOM elements for scatter traces Conflicts: src/traces/scatter/plot.js --- src/traces/scatter/plot.js | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/traces/scatter/plot.js b/src/traces/scatter/plot.js index f6984cb78d3..7da6c421a1c 100644 --- a/src/traces/scatter/plot.js +++ b/src/traces/scatter/plot.js @@ -26,10 +26,6 @@ module.exports = function plot(gd, plotinfo, cdscatter, transitionConfig) { var scatterlayer = plotinfo.plot.select('g.scatterlayer'); - // If transition config is provided, then it is only a partial replot and traces not - // updated are removed. - var isFullReplot = !transitionConfig; - selection = scatterlayer.selectAll('g.trace'); join = selection.data(cdscatter, function(d) {return d[0].trace.uid;}); From 1767a7328ff3ff7cefb4e446d28e884cc3b8b70c Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Fri, 29 Jul 2016 11:14:25 -0400 Subject: [PATCH 49/82] Implement plotly .animate() and keyframe API Conflicts: src/plot_api/plot_api.js --- src/traces/scatter/plot.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/traces/scatter/plot.js b/src/traces/scatter/plot.js index 7da6c421a1c..f6984cb78d3 100644 --- a/src/traces/scatter/plot.js +++ b/src/traces/scatter/plot.js @@ -26,6 +26,10 @@ module.exports = function plot(gd, plotinfo, cdscatter, transitionConfig) { var scatterlayer = plotinfo.plot.select('g.scatterlayer'); + // If transition config is provided, then it is only a partial replot and traces not + // updated are removed. + var isFullReplot = !transitionConfig; + selection = scatterlayer.selectAll('g.trace'); join = selection.data(cdscatter, function(d) {return d[0].trace.uid;}); From c2e17106e52668eea5a204f0396321dac91b678c Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Wed, 6 Jul 2016 12:02:21 -0400 Subject: [PATCH 50/82] Clean up frame API tests and corner cases --- src/plot_api/plot_api.js | 21 +++---- src/plots/plots.js | 22 +++++-- test/jasmine/assets/fail_test.js | 6 +- test/jasmine/tests/frame_api_test.js | 91 ++++++++++++++-------------- 4 files changed, 75 insertions(+), 65 deletions(-) diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index 73965fab877..111cfd9d350 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -866,7 +866,7 @@ function doCalcdata(gd, traces) { // XXX: Is this correct? Needs a closer look so that *some* traces can be recomputed without // *all* needing doCalcdata: var calcdata = new Array(fullData.length); - var oldCalcdata = gd.calcdata.slice(0); + var oldCalcdata = (gd.calcdata || []).slice(0); gd.calcdata = calcdata; // extra helper variables @@ -2657,7 +2657,7 @@ Plotly.addFrames = function(gd, frameList, indices) { for(i = frameList.length - 1; i >= 0; i--) { insertions.push({ frame: frameList[i], - index: (indices && indices[i] !== undefined) ? indices[i] : bigIndex + i + index: (indices && indices[i] !== undefined && indices[i] !== null) ? indices[i] : bigIndex + i }); } @@ -2673,11 +2673,12 @@ Plotly.addFrames = function(gd, frameList, indices) { var frameCount = _frames.length; for(i = insertions.length - 1; i >= 0; i--) { - //frame = frameList[i]; frame = insertions[i].frame; if(!frame.name) { - while(_frames[(frame.name = 'frame ' + gd._frameData._counter++)]); + // Repeatedly assign a default name, incrementing the counter each time until + // we get a name that's not in the hashed lookup table: + while(_hash[(frame.name = 'frame ' + gd._frameData._counter++)]); } if(_hash[frame.name]) { @@ -2689,7 +2690,7 @@ Plotly.addFrames = function(gd, frameList, indices) { revops.unshift({type: 'replace', index: j, value: _frames[j]}); } else { // Otherwise insert it at the end of the list: - idx = Math.min(insertions[i].index, frameCount); + idx = Math.max(0, Math.min(insertions[i].index, frameCount)); ops.push({type: 'insert', index: idx, value: frame}); revops.unshift({type: 'delete', index: idx}); @@ -2697,8 +2698,6 @@ Plotly.addFrames = function(gd, frameList, indices) { } } - Plots.modifyFrames(gd, ops); - var undoFunc = Plots.modifyFrames, redoFunc = Plots.modifyFrames, undoArgs = [gd, revops], @@ -2706,7 +2705,7 @@ Plotly.addFrames = function(gd, frameList, indices) { if(Queue) Queue.add(gd, undoFunc, undoArgs, redoFunc, redoArgs); - return Promise.resolve(); + return Plots.modifyFrames(gd, ops); }; /** @@ -2723,7 +2722,7 @@ Plotly.deleteFrames = function(gd, frameList) { var ops = []; var revops = []; - frameList = frameList.splice(0); + frameList = frameList.slice(0); frameList.sort(); for(i = frameList.length - 1; i >= 0; i--) { @@ -2732,8 +2731,6 @@ Plotly.deleteFrames = function(gd, frameList) { revops.unshift({type: 'insert', index: idx, value: _frames[idx]}); } - Plots.modifyFrames(gd, ops); - var undoFunc = Plots.modifyFrames, redoFunc = Plots.modifyFrames, undoArgs = [gd, revops], @@ -2741,7 +2738,7 @@ Plotly.deleteFrames = function(gd, frameList) { if(Queue) Queue.add(gd, undoFunc, undoArgs, redoFunc, redoArgs); - return Promise.resolve(); + return Plots.modifyFrames(gd, ops); }; /** diff --git a/src/plots/plots.js b/src/plots/plots.js index 0c54d8f4620..55fb16a45f7 100644 --- a/src/plots/plots.js +++ b/src/plots/plots.js @@ -1223,7 +1223,7 @@ plots.graphJson = function(gd, dataonly, mode, output, useDefaults) { * Sequence of operations to be performed on the keyframes */ plots.modifyFrames = function(gd, operations) { - var i, op; + var i, op, frame; var _frames = gd._frameData._frames; var _hash = gd._frameData._frameHash; @@ -1231,14 +1231,26 @@ plots.modifyFrames = function(gd, operations) { op = operations[i]; switch(op.type) { - case 'rename': - var frame = _frames[op.index]; + // No reason this couldn't exist, but is currently unused/untested: + /*case 'rename': + frame = _frames[op.index]; delete _hash[frame.name]; _hash[op.name] = frame; - break; + frame.name = op.name; + break;*/ case 'replace': frame = op.value; - _frames[op.index] = _hash[frame.name] = frame; + var oldName = _frames[op.index].name; + var newName = frame.name; + _frames[op.index] = _hash[newName] = frame; + + if(newName !== oldName) { + // If name has changed in addition to replacement, then update + // the lookup table: + delete _hash[oldName]; + _hash[newName] = frame; + } + break; case 'insert': frame = op.value; diff --git a/test/jasmine/assets/fail_test.js b/test/jasmine/assets/fail_test.js index 12b591a35f7..468a7640c59 100644 --- a/test/jasmine/assets/fail_test.js +++ b/test/jasmine/assets/fail_test.js @@ -18,5 +18,9 @@ * See ./with_setup_teardown.js for a different example. */ module.exports = function failTest(error) { - expect(error).toBeUndefined(); + if(error === undefined) { + expect(error).not.toBeUndefined(); + } else { + expect(error).toBeUndefined(); + } }; diff --git a/test/jasmine/tests/frame_api_test.js b/test/jasmine/tests/frame_api_test.js index 37dc9821b8c..f35cc6310a9 100644 --- a/test/jasmine/tests/frame_api_test.js +++ b/test/jasmine/tests/frame_api_test.js @@ -2,15 +2,7 @@ var Plotly = require('@lib/index'); var createGraphDiv = require('../assets/create_graph_div'); var destroyGraphDiv = require('../assets/destroy_graph_div'); - -// A helper function so that failed tests don't simply stall: -function fail(done) { - return function(err) { - console.error(err.toString()); - expect(err.toString()).toBe(true); - done(); - }; -} +var fail = require('../assets/fail_test'); describe('Test frame api', function() { 'use strict'; @@ -34,25 +26,21 @@ describe('Test frame api', function() { }); it('creates an empty lookup table for frames', function() { - expect(gd._frameData._frameHash).toEqual({}); - }); - - it('initializes a name counter to zero', function() { expect(gd._frameData._counter).toEqual(0); }); }); - describe('addFrames', function() { + describe('#addFrames', function() { it('names an unnamed frame', function(done) { Plotly.addFrames(gd, [{}]).then(function() { expect(Object.keys(h)).toEqual(['frame 0']); - }).then(done, fail(done)); + }).catch(fail).then(done); }); it('creates multiple unnamed frames at the same time', function(done) { Plotly.addFrames(gd, [{}, {}]).then(function() { expect(f).toEqual([{name: 'frame 0'}, {name: 'frame 1'}]); - }).then(done, fail(done)); + }).catch(fail).then(done); }); it('creates multiple unnamed frames in series', function(done) { @@ -60,7 +48,17 @@ describe('Test frame api', function() { Plotly.addFrames(gd, [{}]) ).then(function() { expect(f).toEqual([{name: 'frame 0'}, {name: 'frame 1'}]); - }).then(done, fail(done)); + }).catch(fail).then(done); + }); + + it('avoids name collisions', function(done) { + Plotly.addFrames(gd, [{name: 'frame 0'}, {name: 'frame 2'}]).then(function() { + expect(f).toEqual([{name: 'frame 0'}, {name: 'frame 2'}]); + + return Plotly.addFrames(gd, [{}, {name: 'foobar'}, {}]); + }).then(function() { + expect(f).toEqual([{name: 'frame 0'}, {name: 'frame 2'}, {name: 'frame 1'}, {name: 'foobar'}, {name: 'frame 3'}]); + }).catch(fail).then(done); }); it('inserts frames at specific indices', function(done) { @@ -70,7 +68,13 @@ describe('Test frame api', function() { frames.push({name: 'frame' + i}); } - Plotly.addFrames(gd, frames).then(function() { + function validate() { + for(i = 0; i < 10; i++) { + expect(f[i]).toEqual({name: 'frame' + i}); + } + } + + Plotly.addFrames(gd, frames).then(validate).then(function() { return Plotly.addFrames(gd, [{name: 'inserted1'}, {name: 'inserted2'}, {name: 'inserted3'}], [5, 7, undefined]); }).then(function() { expect(f[5]).toEqual({name: 'inserted1'}); @@ -78,11 +82,7 @@ describe('Test frame api', function() { expect(f[12]).toEqual({name: 'inserted3'}); return Plotly.Queue.undo(gd); - }).then(function() { - for(i = 0; i < 10; i++) { - expect(f[i]).toEqual({name: 'frame' + i}); - } - }).then(done, fail(done)); + }).then(validate).catch(fail).then(done); }); it('inserts frames at specific indices (reversed)', function(done) { @@ -92,7 +92,13 @@ describe('Test frame api', function() { frames.push({name: 'frame' + i}); } - Plotly.addFrames(gd, frames).then(function() { + function validate() { + for(i = 0; i < 10; i++) { + expect(f[i]).toEqual({name: 'frame' + i}); + } + } + + Plotly.addFrames(gd, frames).then(validate).then(function() { return Plotly.addFrames(gd, [{name: 'inserted3'}, {name: 'inserted2'}, {name: 'inserted1'}], [undefined, 7, 5]); }).then(function() { expect(f[5]).toEqual({name: 'inserted1'}); @@ -100,28 +106,23 @@ describe('Test frame api', function() { expect(f[12]).toEqual({name: 'inserted3'}); return Plotly.Queue.undo(gd); - }).then(function() { - for(i = 0; i < 10; i++) { - expect(f[i]).toEqual({name: 'frame' + i}); - } - }).then(done, fail(done)); + }).then(validate).catch(fail).then(done); }); it('implements undo/redo', function(done) { - Plotly.addFrames(gd, [{name: 'frame 0'}, {name: 'frame 1'}]).then(function() { + function validate() { expect(f).toEqual([{name: 'frame 0'}, {name: 'frame 1'}]); expect(h).toEqual({'frame 0': {name: 'frame 0'}, 'frame 1': {name: 'frame 1'}}); + } + Plotly.addFrames(gd, [{name: 'frame 0'}, {name: 'frame 1'}]).then(validate).then(function() { return Plotly.Queue.undo(gd); }).then(function() { expect(f).toEqual([]); expect(h).toEqual({}); return Plotly.Queue.redo(gd); - }).then(function() { - expect(f).toEqual([{name: 'frame 0'}, {name: 'frame 1'}]); - expect(h).toEqual({'frame 0': {name: 'frame 0'}, 'frame 1': {name: 'frame 1'}}); - }).then(done, fail(done)); + }).then(validate).catch(fail).then(done); }); it('overwrites frames', function(done) { @@ -144,11 +145,11 @@ describe('Test frame api', function() { }).then(function() { expect(f).toEqual([{name: 'test1'}, {name: 'test2'}, {name: 'test3'}]); expect(Object.keys(h)).toEqual(['test1', 'test2', 'test3']); - }).then(done, fail(done)); + }).catch(fail).then(done); }); }); - describe('deleteFrames', function() { + describe('#deleteFrames', function() { it('deletes a frame', function(done) { Plotly.addFrames(gd, [{name: 'frame1'}]).then(function() { expect(f).toEqual([{name: 'frame1'}]); @@ -167,7 +168,7 @@ describe('Test frame api', function() { }).then(function() { expect(f).toEqual([]); expect(Object.keys(h)).toEqual([]); - }).then(done, fail(done)); + }).catch(fail).then(done); }); it('deletes multiple frames', function(done) { @@ -177,15 +178,17 @@ describe('Test frame api', function() { frames.push({name: 'frame' + i}); } - Plotly.addFrames(gd, frames).then(function() { - return Plotly.deleteFrames(gd, [2, 8, 4, 6]); - }).then(function() { + function validate() { var expected = ['frame0', 'frame1', 'frame3', 'frame5', 'frame7', 'frame9']; expect(f.length).toEqual(expected.length); for(i = 0; i < expected.length; i++) { expect(f[i].name).toEqual(expected[i]); } + } + Plotly.addFrames(gd, frames).then(function() { + return Plotly.deleteFrames(gd, [2, 8, 4, 6]); + }).then(validate).then(function() { return Plotly.Queue.undo(gd); }).then(function() { for(i = 0; i < 10; i++) { @@ -193,13 +196,7 @@ describe('Test frame api', function() { } return Plotly.Queue.redo(gd); - }).then(function() { - var expected = ['frame0', 'frame1', 'frame3', 'frame5', 'frame7', 'frame9']; - expect(f.length).toEqual(expected.length); - for(i = 0; i < expected.length; i++) { - expect(f[i].name).toEqual(expected[i]); - } - }).then(done, fail(done)); + }).then(validate).catch(fail).then(done); }); }); }); From 9ea685088049e2279c8a60566441ccf46a99eb84 Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Wed, 6 Jul 2016 14:33:02 -0400 Subject: [PATCH 51/82] Implement Lib.expandObjectPaths --- src/lib/extend.js | 21 +++++-- src/lib/index.js | 34 ++++++++++ .../{merge_keyframes.js => merge_frames.js} | 2 +- test/jasmine/tests/extend_test.js | 21 +++++++ test/jasmine/tests/lib_test.js | 63 +++++++++++++++++++ ...ame_computation.js => merge_frame_test.js} | 14 ++--- 6 files changed, 141 insertions(+), 14 deletions(-) rename src/lib/{merge_keyframes.js => merge_frames.js} (92%) rename test/jasmine/tests/{keyframe_computation.js => merge_frame_test.js} (77%) diff --git a/src/lib/extend.js b/src/lib/extend.js index 5b1eb5ce827..d76740d326d 100644 --- a/src/lib/extend.js +++ b/src/lib/extend.js @@ -27,15 +27,19 @@ function primitivesLoopSplice(source, target) { } exports.extendFlat = function() { - return _extend(arguments, false, false); + return _extend(arguments, false, false, false); }; exports.extendDeep = function() { - return _extend(arguments, true, false); + return _extend(arguments, true, false, false); }; exports.extendDeepAll = function() { - return _extend(arguments, true, true); + return _extend(arguments, true, true, false); +}; + +exports.extendDeepNoArrays = function() { + return _extend(arguments, true, false, true); }; /* @@ -55,7 +59,7 @@ exports.extendDeepAll = function() { * Warning: this might result in infinite loops. * */ -function _extend(inputs, isDeep, keepAllKeys) { +function _extend(inputs, isDeep, keepAllKeys, noArrayCopies) { var target = inputs[0], length = inputs.length; @@ -79,8 +83,13 @@ function _extend(inputs, isDeep, keepAllKeys) { src = target[key]; copy = input[key]; + // Stop early and just transfer the array if array copies are disallowed: + if(noArrayCopies && isArray(copy)) { + target[key] = copy; + } + // recurse if we're merging plain objects or arrays - if(isDeep && copy && (isPlainObject(copy) || (copyIsArray = isArray(copy)))) { + else if(isDeep && copy && (isPlainObject(copy) || (copyIsArray = isArray(copy)))) { if(copyIsArray) { copyIsArray = false; clone = src && isArray(src) ? src : []; @@ -89,7 +98,7 @@ function _extend(inputs, isDeep, keepAllKeys) { } // never move original objects, clone them - target[key] = _extend([clone, copy], isDeep, keepAllKeys); + target[key] = _extend([clone, copy], isDeep, keepAllKeys, noArrayCopies); } // don't bring in undefined values, except for extendDeepAll diff --git a/src/lib/index.js b/src/lib/index.js index 186b0ba7ce3..511f7a38462 100644 --- a/src/lib/index.js +++ b/src/lib/index.js @@ -58,6 +58,7 @@ var extendModule = require('./extend'); lib.extendFlat = extendModule.extendFlat; lib.extendDeep = extendModule.extendDeep; lib.extendDeepAll = extendModule.extendDeepAll; +lib.extendDeepNoArrays = extendModule.extendDeepNoArrays; var loggersModule = require('./loggers'); lib.log = loggersModule.log; @@ -550,6 +551,39 @@ lib.objectFromPath = function(path, value) { return obj; }; +/** + * Iterate through an object in-place, converting dotted properties to objects. + * + * @example + * lib.expandObjectPaths('nested.test[2].path', 'value'); + * // returns { nested: { test: [null, null, { path: 'value' }]} + * + */ +// Store this to avoid recompiling regex on every prop since this may happen many +// many times for animations. +// TODO: Premature optimization? Remove? +var dottedPropertyRegex = /^([^\.]*)\../; + +lib.expandObjectPaths = function(data) { + var match, key, prop, datum; + if(typeof data === 'object' && !Array.isArray(data)) { + for(key in data) { + if(data.hasOwnProperty(key)) { + if((match = key.match(dottedPropertyRegex))) { + datum = data[key]; + prop = match[1]; + + delete data[key]; + + data[prop] = lib.extendDeepNoArrays(data[prop] || {}, lib.objectFromPath(key, lib.expandObjectPaths(datum))[prop]); + } else { + data[key] = lib.expandObjectPaths(data[key]); + } + } + } + } + return data; +}; /** * Converts value to string separated by the provided separators. diff --git a/src/lib/merge_keyframes.js b/src/lib/merge_frames.js similarity index 92% rename from src/lib/merge_keyframes.js rename to src/lib/merge_frames.js index 5ec15d8e39a..6d374c82758 100644 --- a/src/lib/merge_keyframes.js +++ b/src/lib/merge_frames.js @@ -21,7 +21,7 @@ var extend = require('./extend'); * * Returns: a third object with the merged content */ -module.exports = function mergeKeyframes(target, source) { +module.exports = function mergeFrames(target, source) { var result; result = extend.extendDeep({}, target); diff --git a/test/jasmine/tests/extend_test.js b/test/jasmine/tests/extend_test.js index 60737b5b1fd..0fc54661a5c 100644 --- a/test/jasmine/tests/extend_test.js +++ b/test/jasmine/tests/extend_test.js @@ -2,6 +2,7 @@ var extendModule = require('@src/lib/extend.js'); var extendFlat = extendModule.extendFlat; var extendDeep = extendModule.extendDeep; var extendDeepAll = extendModule.extendDeepAll; +var extendDeepNoArrays = extendModule.extendDeepNoArrays; var str = 'me a test', integer = 10, @@ -452,3 +453,23 @@ describe('extendDeepAll', function() { expect(ori.arr[2]).toBe(undefined); }); }); + +describe('extendDeepNoArrays', function() { + 'use strict'; + + it('does not copy arrays', function() { + var src = {foo: {bar: [1, 2, 3], baz: [5, 4, 3]}}; + var tar = {foo: {bar: [4, 5, 6], bop: [8, 2, 1]}}; + var ext = extendDeepNoArrays(tar, src); + + expect(ext).not.toBe(src); + expect(ext).toBe(tar); + + expect(ext.foo).not.toBe(src.foo); + expect(ext.foo).toBe(tar.foo); + + expect(ext.foo.bar).toBe(src.foo.bar); + expect(ext.foo.baz).toBe(src.foo.baz); + expect(ext.foo.bop).toBe(tar.foo.bop); + }); +}); diff --git a/test/jasmine/tests/lib_test.js b/test/jasmine/tests/lib_test.js index b25821f9fd0..da5af8018c9 100644 --- a/test/jasmine/tests/lib_test.js +++ b/test/jasmine/tests/lib_test.js @@ -476,6 +476,69 @@ describe('Test lib.js:', function() { }); }); + describe('expandObjectPaths', function() { + it('returns the original object', function() { + var x = {}; + expect(Lib.expandObjectPaths(x)).toBe(x); + }); + + it('unpacks top-level paths', function() { + var input = {'marker.color': 'red', 'marker.size': [1, 2, 3]}; + var expected = {marker: {color: 'red', size: [1, 2, 3]}}; + expect(Lib.expandObjectPaths(input)).toEqual(expected); + }); + + it('unpacks recursively', function() { + var input = {'marker.color': {'red.certainty': 'definitely'}}; + var expected = {marker: {color: {red: {certainty: 'definitely'}}}}; + expect(Lib.expandObjectPaths(input)).toEqual(expected); + }); + + it('unpacks deep paths', function() { + var input = {'foo.bar.baz': 'red'}; + var expected = {foo: {bar: {baz: 'red'}}}; + expect(Lib.expandObjectPaths(input)).toEqual(expected); + }); + + it('unpacks non-top-level deep paths', function() { + var input = {color: {'foo.bar.baz': 'red'}}; + var expected = {color: {foo: {bar: {baz: 'red'}}}}; + expect(Lib.expandObjectPaths(input)).toEqual(expected); + }); + + it('merges dotted properties into objects', function() { + var input = {marker: {color: 'red'}, 'marker.size': 8}; + var expected = {marker: {color: 'red', size: 8}}; + expect(Lib.expandObjectPaths(input)).toEqual(expected); + }); + + it('merges objects into dotted properties', function() { + var input = {'marker.size': 8, marker: {color: 'red'}}; + var expected = {marker: {color: 'red', size: 8}}; + expect(Lib.expandObjectPaths(input)).toEqual(expected); + }); + + it('retains the identity of nested objects', function() { + var input = {marker: {size: 8}}; + var origNested = input.marker; + var expanded = Lib.expandObjectPaths(input); + var newNested = expanded.marker; + + expect(input).toBe(expanded); + expect(origNested).toBe(newNested); + }); + + it('retains the identity of nested arrays', function() { + var input = {'marker.size': [1, 2, 3]}; + var origArray = input['marker.size']; + var expanded = Lib.expandObjectPaths(input); + var newArray = expanded.marker.size; + + expect(input).toBe(expanded); + expect(origArray).toBe(newArray); + }); + }); + describe('coerce', function() { var coerce = Lib.coerce, out; diff --git a/test/jasmine/tests/keyframe_computation.js b/test/jasmine/tests/merge_frame_test.js similarity index 77% rename from test/jasmine/tests/keyframe_computation.js rename to test/jasmine/tests/merge_frame_test.js index 429b52f81cd..76796135dd2 100644 --- a/test/jasmine/tests/keyframe_computation.js +++ b/test/jasmine/tests/merge_frame_test.js @@ -1,12 +1,12 @@ -var mergeKeyframes = require('@src/lib/merge_keyframes'); +var mergeFrames = require('@src/lib/merge_frames'); -describe('Test mergeKeyframes', function() { +describe('Test mergeFrames', function() { 'use strict'; it('returns a new object', function() { var f1 = {}; var f2 = {}; - var result = mergeKeyframes(f1, f2); + var result = mergeFrames(f1, f2); expect(result).toEqual({}); expect(result).not.toBe(f1); expect(result).not.toBe(f2); @@ -15,7 +15,7 @@ describe('Test mergeKeyframes', function() { it('overrides properties of target with those of source', function() { var tar = {xaxis: {range: [0, 1]}}; var src = {xaxis: {range: [3, 4]}}; - var out = mergeKeyframes(tar, src); + var out = mergeFrames(tar, src); expect(out).toEqual({xaxis: {range: [3, 4]}}); }); @@ -23,7 +23,7 @@ describe('Test mergeKeyframes', function() { it('merges dotted properties', function() { var tar = {}; var src = {'xaxis.range': [0, 1]}; - var out = mergeKeyframes(tar, src); + var out = mergeFrames(tar, src); expect(out).toEqual({'xaxis.range': [0, 1]}); }); @@ -32,7 +32,7 @@ describe('Test mergeKeyframes', function() { it('xaxis.range', function() { var tar = {xaxis: {range: [0, 1]}}; var src = {'xaxis.range': [3, 4]}; - var out = mergeKeyframes(tar, src); + var out = mergeFrames(tar, src); expect(out).toEqual({xaxis: {range: [3, 4]}}); }); @@ -40,7 +40,7 @@ describe('Test mergeKeyframes', function() { it('xaxis.range.0', function() { var tar = {xaxis: {range: [0, 1]}}; var src = {'xaxis.range.0': 3}; - var out = mergeKeyframes(tar, src); + var out = mergeFrames(tar, src); expect(out).toEqual({xaxis: {range: [3, 1]}}); }); From bcb3ce2138d09e207daf0b6df77f3260929382b0 Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Wed, 6 Jul 2016 16:46:49 -0400 Subject: [PATCH 52/82] Frame merging logic --- src/lib/index.js | 6 +- src/lib/merge_frames.js | 31 ---- src/plots/plots.js | 83 ++++++++++ test/jasmine/tests/compute_frame_test.js | 185 +++++++++++++++++++++++ test/jasmine/tests/merge_frame_test.js | 48 ------ 5 files changed, 271 insertions(+), 82 deletions(-) delete mode 100644 src/lib/merge_frames.js create mode 100644 test/jasmine/tests/compute_frame_test.js delete mode 100644 test/jasmine/tests/merge_frame_test.js diff --git a/src/lib/index.js b/src/lib/index.js index 511f7a38462..f7aa518c061 100644 --- a/src/lib/index.js +++ b/src/lib/index.js @@ -555,10 +555,10 @@ lib.objectFromPath = function(path, value) { * Iterate through an object in-place, converting dotted properties to objects. * * @example - * lib.expandObjectPaths('nested.test[2].path', 'value'); - * // returns { nested: { test: [null, null, { path: 'value' }]} - * + * lib.expandObjectPaths({'nested.test.path': 'value'}); + * // returns { nested: { test: {path: 'value'}}} */ + // Store this to avoid recompiling regex on every prop since this may happen many // many times for animations. // TODO: Premature optimization? Remove? diff --git a/src/lib/merge_frames.js b/src/lib/merge_frames.js deleted file mode 100644 index 6d374c82758..00000000000 --- a/src/lib/merge_frames.js +++ /dev/null @@ -1,31 +0,0 @@ -/** -* Copyright 2012-2016, 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 extend = require('./extend'); - -/* - * Merge two keyframe specifications, returning in a third object that - * can be used for plotting. - * - * @param {object} target - * An object with data, layout, and trace data - * @param {object} source - * An object with data, layout, and trace data - * - * Returns: a third object with the merged content - */ -module.exports = function mergeFrames(target, source) { - var result; - - result = extend.extendDeep({}, target); - result = extend.extendDeep(result, source); - - return result; -}; diff --git a/src/plots/plots.js b/src/plots/plots.js index 55fb16a45f7..c94977f422e 100644 --- a/src/plots/plots.js +++ b/src/plots/plots.js @@ -1267,3 +1267,86 @@ plots.modifyFrames = function(gd, operations) { return Promise.resolve(); }; + +/* + * Compute a keyframe. Merge a keyframe into its base frame(s) and + * expand properties. + * + * @param {object} frame + * The keyframe to be computed + * + * Returns: a third object with the merged content + */ +plots.computeFrame = function(gd, frameName) { + var i, traceIndices, traceIndex, expandedObj, destIndex; + var _hash = gd._frameData._frameHash; + + var framePtr = _hash[frameName]; + + // Return false if the name is invalid: + if(!framePtr) { + return false; + } + + var frameStack = [framePtr]; + var frameNameStack = [framePtr.name]; + + // Follow frame pointers: + while((framePtr = gd._frameData._frameHash[framePtr.baseFrame])) { + // Avoid infinite loops: + if(frameNameStack.indexOf(framePtr.name) !== -1) break; + + frameStack.push(framePtr); + frameNameStack.push(framePtr.name); + } + + // A new object for the merged result: + var result = {}; + + // Merge, starting with the last and ending with the desired frame: + while((framePtr = frameStack.pop())) { + if(framePtr.layout) { + expandedObj = Lib.expandObjectPaths(framePtr.layout); + result.layout = Lib.extendDeepNoArrays(result.layout || {}, expandedObj); + } + + if(framePtr.data) { + if(!result.data) { + result.data = []; + } + traceIndices = framePtr.traceIndices; + + if(!traceIndices) { + // If not defined, assume serial order starting at zero + traceIndices = []; + for(i = 0; i < framePtr.data.length; i++) { + traceIndices[i] = i; + } + } + + if(!result.traceIndices) { + result.traceIndices = []; + } + + for(i = 0; i < framePtr.data.length; i++) { + // Loop through this frames data, find out where it should go, + // and merge it! + traceIndex = traceIndices[i]; + if(traceIndex === undefined || traceIndex === null) { + continue; + } + + destIndex = result.traceIndices.indexOf(traceIndex); + if(destIndex === -1) { + destIndex = result.data.length; + result.traceIndices[destIndex] = traceIndex; + } + + expandedObj = Lib.expandObjectPaths(framePtr.data[i]); + result.data[destIndex] = Lib.extendDeepNoArrays(result.data[destIndex] || {}, expandedObj); + } + } + } + + return result; +}; diff --git a/test/jasmine/tests/compute_frame_test.js b/test/jasmine/tests/compute_frame_test.js new file mode 100644 index 00000000000..cb2972d3af8 --- /dev/null +++ b/test/jasmine/tests/compute_frame_test.js @@ -0,0 +1,185 @@ +var Plotly = require('@lib/index'); + +var createGraphDiv = require('../assets/create_graph_div'); +var destroyGraphDiv = require('../assets/destroy_graph_div'); +var computeFrame = require('@src/plots/plots').computeFrame; + +describe('Test mergeFrames', function() { + 'use strict'; + + var gd, mock; + + beforeEach(function(done) { + mock = [{x: [1, 2, 3], y: [2, 1, 3]}, {x: [1, 2, 3], y: [6, 4, 5]}]; + gd = createGraphDiv(); + Plotly.plot(gd, mock).then(done); + }); + + afterEach(destroyGraphDiv); + + describe('computing a single frame', function() { + var frame1; + + beforeEach(function(done) { + frame1 = { + name: 'frame1', + data: [{'marker.size': 8, marker: {color: 'red'}}] + }; + + Plotly.addFrames(gd, [frame1]).then(done); + }); + + it('returns false if the frame does not exist', function() { + expect(computeFrame(gd, 'frame8')).toBe(false); + }); + + it('returns a new object', function() { + expect(computeFrame(gd, 'frame1')).not.toBe(frame1); + }); + + it('computes a single frame', function() { + var computed = computeFrame(gd, 'frame1'); + var expected = {data: [{marker: {size: 8, color: 'red'}}], traceIndices: [0]}; + expect(computed).toEqual(expected); + }); + }); + + describe('circularly defined frames', function() { + var frames, results; + + beforeEach(function(done) { + frames = [ + {name: 'frame0', baseFrame: 'frame1', data: [{'marker.size': 0}]}, + {name: 'frame1', baseFrame: 'frame2', data: [{'marker.size': 1}]}, + {name: 'frame2', baseFrame: 'frame0', data: [{'marker.size': 2}]} + ]; + + results = [ + {traceIndices: [0], data: [{marker: {size: 0}}]}, + {traceIndices: [0], data: [{marker: {size: 1}}]}, + {traceIndices: [0], data: [{marker: {size: 2}}]} + ]; + + Plotly.addFrames(gd, frames).then(done); + }); + + function doTest(i) { + it('avoid infinite recursion (starting point = ' + i + ')', function() { + var result = computeFrame(gd, 'frame' + i); + expect(result).toEqual(results[i]); + }); + } + + for(var ii = 0; ii < 3; ii++) { + doTest(ii); + } + }); + + describe('computing trace data', function() { + var frames; + + beforeEach(function(done) { + frames = [{ + name: 'frame0', + data: [{'marker.size': 0}], + traceIndices: [2] + }, { + name: 'frame1', + data: [{'marker.size': 1}], + traceIndices: [8] + }, { + name: 'frame2', + data: [{'marker.size': 2}], + traceIndices: [2] + }, { + name: 'frame3', + data: [{'marker.size': 3}, {'marker.size': 4}], + traceIndices: [2, 8] + }, { + name: 'frame4', + data: [ + {'marker.size': 5}, + {'marker.size': 6}, + {'marker.size': 7} + ] + }]; + + Plotly.addFrames(gd, frames).then(done); + }); + + it('merges orthogonal traces', function() { + frames[0].baseFrame = frames[1].name; + var result = computeFrame(gd, 'frame0'); + expect(result).toEqual({ + traceIndices: [8, 2], + data: [ + {marker: {size: 1}}, + {marker: {size: 0}} + ] + }); + }); + + it('merges overlapping traces', function() { + frames[0].baseFrame = frames[2].name; + var result = computeFrame(gd, 'frame0'); + expect(result).toEqual({ + traceIndices: [2], + data: [{marker: {size: 0}}] + }); + }); + + it('merges partially overlapping traces', function() { + frames[0].baseFrame = frames[1].name; + frames[1].baseFrame = frames[2].name; + frames[2].baseFrame = frames[3].name; + var result = computeFrame(gd, 'frame0'); + expect(result).toEqual({ + traceIndices: [2, 8], + data: [ + {marker: {size: 0}}, + {marker: {size: 1}} + ] + }); + }); + + it('assumes serial order without traceIndices specified', function() { + frames[4].baseFrame = frames[3].name; + var result = computeFrame(gd, 'frame4'); + expect(result).toEqual({ + traceIndices: [2, 8, 0, 1], + data: [ + {marker: {size: 7}}, + {marker: {size: 4}}, + {marker: {size: 5}}, + {marker: {size: 6}} + ] + }); + }); + }); + + describe('computing trace layout', function() { + var frames; + + beforeEach(function(done) { + frames = [{ + name: 'frame0', + layout: {'margin.l': 40} + }, { + name: 'frame1', + layout: {'margin.l': 80} + }]; + + Plotly.addFrames(gd, frames).then(done); + }); + + it('merges layouts', function() { + frames[0].baseFrame = frames[1].name; + var result = computeFrame(gd, 'frame0'); + + expect(result).toEqual({ + layout: {margin: {l: 40}} + }); + }); + + }); +}); diff --git a/test/jasmine/tests/merge_frame_test.js b/test/jasmine/tests/merge_frame_test.js deleted file mode 100644 index 76796135dd2..00000000000 --- a/test/jasmine/tests/merge_frame_test.js +++ /dev/null @@ -1,48 +0,0 @@ -var mergeFrames = require('@src/lib/merge_frames'); - -describe('Test mergeFrames', function() { - 'use strict'; - - it('returns a new object', function() { - var f1 = {}; - var f2 = {}; - var result = mergeFrames(f1, f2); - expect(result).toEqual({}); - expect(result).not.toBe(f1); - expect(result).not.toBe(f2); - }); - - it('overrides properties of target with those of source', function() { - var tar = {xaxis: {range: [0, 1]}}; - var src = {xaxis: {range: [3, 4]}}; - var out = mergeFrames(tar, src); - - expect(out).toEqual({xaxis: {range: [3, 4]}}); - }); - - it('merges dotted properties', function() { - var tar = {}; - var src = {'xaxis.range': [0, 1]}; - var out = mergeFrames(tar, src); - - expect(out).toEqual({'xaxis.range': [0, 1]}); - }); - - describe('assimilating dotted properties', function() { - it('xaxis.range', function() { - var tar = {xaxis: {range: [0, 1]}}; - var src = {'xaxis.range': [3, 4]}; - var out = mergeFrames(tar, src); - - expect(out).toEqual({xaxis: {range: [3, 4]}}); - }); - - it('xaxis.range.0', function() { - var tar = {xaxis: {range: [0, 1]}}; - var src = {'xaxis.range.0': 3}; - var out = mergeFrames(tar, src); - - expect(out).toEqual({xaxis: {range: [3, 1]}}); - }); - }); -}); From 78b37e2080b90cf0647ba2e5ca8409b4825395d5 Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Thu, 7 Jul 2016 13:00:03 -0400 Subject: [PATCH 53/82] Clean up object identity sloppiness in frame computation --- src/plots/plots.js | 8 ++- test/jasmine/tests/compute_frame_test.js | 85 +++++++++++++++++++----- 2 files changed, 73 insertions(+), 20 deletions(-) diff --git a/src/plots/plots.js b/src/plots/plots.js index c94977f422e..f6c56fe785b 100644 --- a/src/plots/plots.js +++ b/src/plots/plots.js @@ -1278,7 +1278,7 @@ plots.modifyFrames = function(gd, operations) { * Returns: a third object with the merged content */ plots.computeFrame = function(gd, frameName) { - var i, traceIndices, traceIndex, expandedObj, destIndex; + var i, traceIndices, traceIndex, expandedObj, destIndex, copy; var _hash = gd._frameData._frameHash; var framePtr = _hash[frameName]; @@ -1306,7 +1306,8 @@ plots.computeFrame = function(gd, frameName) { // Merge, starting with the last and ending with the desired frame: while((framePtr = frameStack.pop())) { if(framePtr.layout) { - expandedObj = Lib.expandObjectPaths(framePtr.layout); + copy = Lib.extendDeepNoArrays({}, framePtr.layout); + expandedObj = Lib.expandObjectPaths(copy); result.layout = Lib.extendDeepNoArrays(result.layout || {}, expandedObj); } @@ -1342,7 +1343,8 @@ plots.computeFrame = function(gd, frameName) { result.traceIndices[destIndex] = traceIndex; } - expandedObj = Lib.expandObjectPaths(framePtr.data[i]); + copy = Lib.extendDeepNoArrays({}, framePtr.data[i]); + expandedObj = Lib.expandObjectPaths(copy); result.data[destIndex] = Lib.extendDeepNoArrays(result.data[destIndex] || {}, expandedObj); } } diff --git a/test/jasmine/tests/compute_frame_test.js b/test/jasmine/tests/compute_frame_test.js index cb2972d3af8..e2d6b026c96 100644 --- a/test/jasmine/tests/compute_frame_test.js +++ b/test/jasmine/tests/compute_frame_test.js @@ -1,9 +1,14 @@ var Plotly = require('@lib/index'); +var Lib = require('@src/lib'); var createGraphDiv = require('../assets/create_graph_div'); var destroyGraphDiv = require('../assets/destroy_graph_div'); var computeFrame = require('@src/plots/plots').computeFrame; +function clone(obj) { + return Lib.extendDeep({}, obj); +} + describe('Test mergeFrames', function() { 'use strict'; @@ -18,15 +23,20 @@ describe('Test mergeFrames', function() { afterEach(destroyGraphDiv); describe('computing a single frame', function() { - var frame1; + var frame1, input; beforeEach(function(done) { frame1 = { name: 'frame1', - data: [{'marker.size': 8, marker: {color: 'red'}}] + data: [{ + x: [1, 2, 3], + 'marker.size': 8, + marker: {color: 'red'} + }] }; - Plotly.addFrames(gd, [frame1]).then(done); + input = clone(frame1); + Plotly.addFrames(gd, [input]).then(done); }); it('returns false if the frame does not exist', function() { @@ -34,14 +44,31 @@ describe('Test mergeFrames', function() { }); it('returns a new object', function() { - expect(computeFrame(gd, 'frame1')).not.toBe(frame1); + var result = computeFrame(gd, 'frame1'); + expect(result).not.toBe(input); + }); + + it('copies objects', function() { + var result = computeFrame(gd, 'frame1'); + expect(result.data).not.toBe(input.data); + expect(result.data[0].marker).not.toBe(input.data[0].marker); + }); + + it('does NOT copy arrays', function() { + var result = computeFrame(gd, 'frame1'); + expect(result.data[0].x).toBe(input.data[0].x); }); it('computes a single frame', function() { var computed = computeFrame(gd, 'frame1'); - var expected = {data: [{marker: {size: 8, color: 'red'}}], traceIndices: [0]}; + var expected = {data: [{x: [1, 2, 3], marker: {size: 8, color: 'red'}}], traceIndices: [0]}; expect(computed).toEqual(expected); }); + + it('leaves the frame unaffected', function() { + computeFrame(gd, 'frame1'); + expect(gd._frameData._frameHash.frame1).toEqual(frame1); + }); }); describe('circularly defined frames', function() { @@ -78,7 +105,7 @@ describe('Test mergeFrames', function() { describe('computing trace data', function() { var frames; - beforeEach(function(done) { + beforeEach(function() { frames = [{ name: 'frame0', data: [{'marker.size': 0}], @@ -103,49 +130,65 @@ describe('Test mergeFrames', function() { {'marker.size': 7} ] }]; - - Plotly.addFrames(gd, frames).then(done); }); it('merges orthogonal traces', function() { frames[0].baseFrame = frames[1].name; - var result = computeFrame(gd, 'frame0'); - expect(result).toEqual({ + + // This technically returns a promise, but it's not actually asynchronous so + // that we'll just keep this synchronous: + Plotly.addFrames(gd, frames.map(clone)); + + expect(computeFrame(gd, 'frame0')).toEqual({ traceIndices: [8, 2], data: [ {marker: {size: 1}}, {marker: {size: 0}} ] }); + + // Verify that the frames are untouched (by value, at least, but they should + // also be unmodified by identity too) by the computation: + expect(gd._frameData._frames).toEqual(frames); }); it('merges overlapping traces', function() { frames[0].baseFrame = frames[2].name; - var result = computeFrame(gd, 'frame0'); - expect(result).toEqual({ + + Plotly.addFrames(gd, frames.map(clone)); + + expect(computeFrame(gd, 'frame0')).toEqual({ traceIndices: [2], data: [{marker: {size: 0}}] }); + + expect(gd._frameData._frames).toEqual(frames); }); it('merges partially overlapping traces', function() { frames[0].baseFrame = frames[1].name; frames[1].baseFrame = frames[2].name; frames[2].baseFrame = frames[3].name; - var result = computeFrame(gd, 'frame0'); - expect(result).toEqual({ + + Plotly.addFrames(gd, frames.map(clone)); + + expect(computeFrame(gd, 'frame0')).toEqual({ traceIndices: [2, 8], data: [ {marker: {size: 0}}, {marker: {size: 1}} ] }); + + expect(gd._frameData._frames).toEqual(frames); }); it('assumes serial order without traceIndices specified', function() { frames[4].baseFrame = frames[3].name; - var result = computeFrame(gd, 'frame4'); - expect(result).toEqual({ + + Plotly.addFrames(gd, frames.map(clone)); + + expect(computeFrame(gd, 'frame4')).toEqual({ traceIndices: [2, 8, 0, 1], data: [ {marker: {size: 7}}, @@ -154,11 +197,13 @@ describe('Test mergeFrames', function() { {marker: {size: 6}} ] }); + + expect(gd._frameData._frames).toEqual(frames); }); }); describe('computing trace layout', function() { - var frames; + var frames, frameCopies; beforeEach(function(done) { frames = [{ @@ -169,6 +214,8 @@ describe('Test mergeFrames', function() { layout: {'margin.l': 80} }]; + frameCopies = frames.map(clone); + Plotly.addFrames(gd, frames).then(done); }); @@ -181,5 +228,9 @@ describe('Test mergeFrames', function() { }); }); + it('leaves the frame unaffected', function() { + computeFrame(gd, 'frame0'); + expect(gd._frameData._frames).toEqual(frameCopies); + }); }); }); From 57fe9608adbd48c95199038c0c8122e64eb3d5a7 Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Thu, 7 Jul 2016 13:08:52 -0400 Subject: [PATCH 54/82] Remove dependence of frame logic on gd --- src/lib/index.js | 86 ++++++++++++++++++++++++++++++++++++++++++++++ src/plots/plots.js | 80 +++--------------------------------------- 2 files changed, 90 insertions(+), 76 deletions(-) diff --git a/src/lib/index.js b/src/lib/index.js index f7aa518c061..d8b2d7efaae 100644 --- a/src/lib/index.js +++ b/src/lib/index.js @@ -628,3 +628,89 @@ lib.numSeparate = function(value, separators) { return x1 + x2; }; + +/* + * Compute a keyframe. Merge a keyframe into its base frame(s) and + * expand properties. + * + * @param {object} frameLookup + * An object containing frames keyed by name (i.e. gd._frameData._frameHash) + * @param {string} frame + * The name of the keyframe to be computed + * + * Returns: a new object with the merged content + */ +lib.computeFrame = function(frameLookup, frameName) { + var i, traceIndices, traceIndex, expandedObj, destIndex, copy; + + var framePtr = frameLookup[frameName]; + + // Return false if the name is invalid: + if(!framePtr) { + return false; + } + + var frameStack = [framePtr]; + var frameNameStack = [framePtr.name]; + + // Follow frame pointers: + while((framePtr = frameLookup[framePtr.baseFrame])) { + // Avoid infinite loops: + if(frameNameStack.indexOf(framePtr.name) !== -1) break; + + frameStack.push(framePtr); + frameNameStack.push(framePtr.name); + } + + // A new object for the merged result: + var result = {}; + + // Merge, starting with the last and ending with the desired frame: + while((framePtr = frameStack.pop())) { + if(framePtr.layout) { + copy = lib.extendDeepNoArrays({}, framePtr.layout); + expandedObj = lib.expandObjectPaths(copy); + result.layout = lib.extendDeepNoArrays(result.layout || {}, expandedObj); + } + + if(framePtr.data) { + if(!result.data) { + result.data = []; + } + traceIndices = framePtr.traceIndices; + + if(!traceIndices) { + // If not defined, assume serial order starting at zero + traceIndices = []; + for(i = 0; i < framePtr.data.length; i++) { + traceIndices[i] = i; + } + } + + if(!result.traceIndices) { + result.traceIndices = []; + } + + for(i = 0; i < framePtr.data.length; i++) { + // Loop through this frames data, find out where it should go, + // and merge it! + traceIndex = traceIndices[i]; + if(traceIndex === undefined || traceIndex === null) { + continue; + } + + destIndex = result.traceIndices.indexOf(traceIndex); + if(destIndex === -1) { + destIndex = result.data.length; + result.traceIndices[destIndex] = traceIndex; + } + + copy = lib.extendDeepNoArrays({}, framePtr.data[i]); + expandedObj = lib.expandObjectPaths(copy); + result.data[destIndex] = lib.extendDeepNoArrays(result.data[destIndex] || {}, expandedObj); + } + } + } + + return result; +}; diff --git a/src/plots/plots.js b/src/plots/plots.js index f6c56fe785b..efd91f849dd 100644 --- a/src/plots/plots.js +++ b/src/plots/plots.js @@ -1272,83 +1272,11 @@ plots.modifyFrames = function(gd, operations) { * Compute a keyframe. Merge a keyframe into its base frame(s) and * expand properties. * - * @param {object} frame - * The keyframe to be computed + * @param {string} frame + * The name of the keyframe to be computed * - * Returns: a third object with the merged content + * Returns: a new object with the merged content */ plots.computeFrame = function(gd, frameName) { - var i, traceIndices, traceIndex, expandedObj, destIndex, copy; - var _hash = gd._frameData._frameHash; - - var framePtr = _hash[frameName]; - - // Return false if the name is invalid: - if(!framePtr) { - return false; - } - - var frameStack = [framePtr]; - var frameNameStack = [framePtr.name]; - - // Follow frame pointers: - while((framePtr = gd._frameData._frameHash[framePtr.baseFrame])) { - // Avoid infinite loops: - if(frameNameStack.indexOf(framePtr.name) !== -1) break; - - frameStack.push(framePtr); - frameNameStack.push(framePtr.name); - } - - // A new object for the merged result: - var result = {}; - - // Merge, starting with the last and ending with the desired frame: - while((framePtr = frameStack.pop())) { - if(framePtr.layout) { - copy = Lib.extendDeepNoArrays({}, framePtr.layout); - expandedObj = Lib.expandObjectPaths(copy); - result.layout = Lib.extendDeepNoArrays(result.layout || {}, expandedObj); - } - - if(framePtr.data) { - if(!result.data) { - result.data = []; - } - traceIndices = framePtr.traceIndices; - - if(!traceIndices) { - // If not defined, assume serial order starting at zero - traceIndices = []; - for(i = 0; i < framePtr.data.length; i++) { - traceIndices[i] = i; - } - } - - if(!result.traceIndices) { - result.traceIndices = []; - } - - for(i = 0; i < framePtr.data.length; i++) { - // Loop through this frames data, find out where it should go, - // and merge it! - traceIndex = traceIndices[i]; - if(traceIndex === undefined || traceIndex === null) { - continue; - } - - destIndex = result.traceIndices.indexOf(traceIndex); - if(destIndex === -1) { - destIndex = result.data.length; - result.traceIndices[destIndex] = traceIndex; - } - - copy = Lib.extendDeepNoArrays({}, framePtr.data[i]); - expandedObj = Lib.expandObjectPaths(copy); - result.data[destIndex] = Lib.extendDeepNoArrays(result.data[destIndex] || {}, expandedObj); - } - } - } - - return result; + return Lib.computeFrame(gd._frameData._frameHash, frameName); }; From f4837885e12a5a1101791370cd7dac99950ec690 Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Thu, 7 Jul 2016 14:12:32 -0400 Subject: [PATCH 55/82] Really simple .animate function --- src/plot_api/plot_api.js | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index 111cfd9d350..ad187670ca2 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -2609,17 +2609,22 @@ Plotly.transition = function(gd /*, data, layout, traces, transitionConfig*/) { * @param {object} transitionConfig * configuration for transition */ -Plotly.animate = function(gd, name /*, transitionConfig*/) { +Plotly.animate = function(gd, frameName, transitionConfig) { gd = getGraphDiv(gd); - var _frames = gd._frameData._frames; - - if(!_frames[name]) { - Lib.warn('animateToFrame failure: keyframe does not exist', name); + if(!gd._frameData._frameHash[frameName]) { + Lib.warn('animateToFrame failure: keyframe does not exist', frameName); return Promise.reject(); } - return Promise.resolve(); + var computedFrame = Plots.computeFrame(gd, frameName); + + return Plotly.transition(gd, + computedFrame.data, + computedFrame.layout, + computedFrame.traceIndices, + transitionConfig + ); }; /** From d2d7099bd4266c4f5ecdee3f776810f57995bd45 Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Thu, 7 Jul 2016 15:26:42 -0400 Subject: [PATCH 56/82] Add .animate tests --- src/plot_api/plot_api.js | 4 ++- test/image/mocks/animation.json | 46 ++++++++++++++++++++++++ test/jasmine/tests/animate_test.js | 56 ++++++++++++++++++++++++++++++ 3 files changed, 105 insertions(+), 1 deletion(-) create mode 100644 test/image/mocks/animation.json create mode 100644 test/jasmine/tests/animate_test.js diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index ad187670ca2..199927b9f30 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -2507,9 +2507,11 @@ Plotly.relayout = function relayout(gd, astr, val) { * @param {string id or DOM element} gd * the id or DOM element of the graph container div */ -Plotly.transition = function(gd /*, data, layout, traces, transitionConfig*/) { +Plotly.transition = function(gd, data, layout, traces, transitionConfig) { gd = getGraphDiv(gd); + return Promise.resolve(); + /*var fullLayout = gd._fullLayout; transitionConfig = Lib.extendFlat({ diff --git a/test/image/mocks/animation.json b/test/image/mocks/animation.json new file mode 100644 index 00000000000..b6184c148b1 --- /dev/null +++ b/test/image/mocks/animation.json @@ -0,0 +1,46 @@ +{ + "data": [ + { + "x": [0, 1, 2], + "y": [0, 2, 8], + "type": "scatter" + }, + { + "x": [0, 1, 2], + "y": [4, 2, 3], + "type": "scatter" + } + ], + "layout": { + "title": "Animation test", + "showlegend": true, + "autosize": false, + "width": 800, + "height": 440, + "xaxis": { + "range": [0, 2], + "domain": [0, 1], + }, + "yaxis": { + "range": [0, 10], + "domain": [0, 1], + } + }, + "frames": [{ + "name": 'frame0', + "data": [ + {"y": [0, 2, 8]}, + {"y": [4, 2, 3]} + ], + "traceIndices": [0, 1], + "layout": { } + }, { + "name": 'frame1', + "data": [ + {"y": [1, 3, 9]}, + {"y": [5, 3, 4]} + ], + "traceIndices": [0, 1], + "layout": { } + }] +} diff --git a/test/jasmine/tests/animate_test.js b/test/jasmine/tests/animate_test.js new file mode 100644 index 00000000000..fe2f99c2119 --- /dev/null +++ b/test/jasmine/tests/animate_test.js @@ -0,0 +1,56 @@ +var Plotly = require('@lib/index'); +var PlotlyInternal = require('@src/plotly'); +var Lib = require('@src/lib'); + +var createGraphDiv = require('../assets/create_graph_div'); +var destroyGraphDiv = require('../assets/destroy_graph_div'); +var fail = require('../assets/fail_test'); + +describe('Test animate API', function() { + 'use strict'; + + var gd; + + beforeEach(function(done) { + gd = createGraphDiv(); + + var mock = require('@mocks/animation'); + var mockCopy = Lib.extendDeep({}, mock); + + spyOn(PlotlyInternal, 'transition').and.callFake(function() { + return Promise.resolve(); + }); + + Plotly.plot(gd, mockCopy.data, mockCopy.layout).then(function() { + Plotly.addFrames(gd, mockCopy.frames); + }).then(done); + }); + + afterEach(function() { + destroyGraphDiv(); + }); + + it('rejects if the frame is not found', function(done) { + Plotly.animate(gd, 'foobar').then(fail).then(done, done); + }); + + it('animates to a frame', function(done) { + Plotly.animate(gd, 'frame0').then(function() { + expect(PlotlyInternal.transition).toHaveBeenCalled(); + + var args = PlotlyInternal.transition.calls.mostRecent().args; + + // was called with gd, data, layout, traceIndices, transitionConfig: + expect(args.length).toEqual(5); + + // data has two traces: + expect(args[1].length).toEqual(2); + + // layout + expect(args[2]).toEqual({}); + + // traces are [0, 1]: + expect(args[3]).toEqual([0, 1]); + }).catch(fail).then(done); + }); +}); From ea7c037da23658f91b1a6b9603459a3d7f9bd17e Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Thu, 7 Jul 2016 17:53:04 -0400 Subject: [PATCH 57/82] Start adding back transition behavior --- src/plot_api/plot_api.js | 133 ++++++++----- src/plots/cartesian/index.js | 13 +- src/plots/cartesian/transition_axes.js | 266 +++++++++++++++++++++++++ src/traces/scatter/index.js | 1 + test/image/mocks/animation.json | 49 ++++- 5 files changed, 400 insertions(+), 62 deletions(-) create mode 100644 src/plots/cartesian/transition_axes.js diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index 199927b9f30..ee883019890 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -2507,12 +2507,11 @@ Plotly.relayout = function relayout(gd, astr, val) { * @param {string id or DOM element} gd * the id or DOM element of the graph container div */ -Plotly.transition = function(gd, data, layout, traces, transitionConfig) { +Plotly.transition = function(gd, data, layout, traceIndices, transitionConfig) { gd = getGraphDiv(gd); - return Promise.resolve(); - - /*var fullLayout = gd._fullLayout; + var i, value, traceIdx; + var fullLayout = gd._fullLayout; transitionConfig = Lib.extendFlat({ ease: 'cubic-in-out', @@ -2531,16 +2530,16 @@ Plotly.transition = function(gd, data, layout, traces, transitionConfig) { } // Select which traces will be updated: - if(isNumeric(traces)) traces = [traces]; - else if(!Array.isArray(traces) || !traces.length) { - traces = gd._fullData.map(function(v, i) {return i;}); + if(isNumeric(traceIndices)) traceIndices = [traceIndices]; + else if(!Array.isArray(traceIndices) || !traceIndices.length) { + traceIndices = gd._fullData.map(function(v, i) {return i;}); } - var transitioningTraces = []; + var animatedTraces = []; - function prepareAnimations() { - for(i = 0; i < traces.length; i++) { - var traceIdx = traces[i]; + function prepareTransitions() { + for(i = 0; i < traceIndices.length; i++) { + var traceIdx = traceIndices[i]; var trace = gd._fullData[traceIdx]; var module = trace._module; @@ -2548,59 +2547,95 @@ Plotly.transition = function(gd, data, layout, traces, transitionConfig) { continue; } - transitioningTraces.push(traceIdx); + animatedTraces.push(traceIdx); - newTraceData = newData[i]; - curData = gd.data[traces[i]]; - - for(var ai in newTraceData) { - var value = newTraceData[ai]; - Lib.nestedProperty(curData, ai).set(value); - } - - var traceIdx = traces[i]; - if(gd.data[traceIdx].marker && gd.data[traceIdx].marker.size) { - gd._fullData[traceIdx].marker.size = gd.data[traceIdx].marker.size; - } - if(gd.data[traceIdx].error_y && gd.data[traceIdx].error_y.array) { - gd._fullData[traceIdx].error_y.array = gd.data[traceIdx].error_y.array; - } - if(gd.data[traceIdx].error_x && gd.data[traceIdx].error_x.array) { - gd._fullData[traceIdx].error_x.array = gd.data[traceIdx].error_x.array; - } - gd._fullData[traceIdx].x = gd.data[traceIdx].x; - gd._fullData[traceIdx].y = gd.data[traceIdx].y; - gd._fullData[traceIdx].z = gd.data[traceIdx].z; - gd._fullData[traceIdx].key = gd.data[traceIdx].key; + Lib.extendDeepNoArrays(gd.data[traceIndices[i]], data[i]); } - doCalcdata(gd, transitioningTraces); + Plots.supplyDefaults(gd) + + // doCalcdata(gd, animatedTraces); + doCalcdata(gd); ErrorBars.calc(gd); } - function doAnimations() { - var a, i, j; + var restyleList = []; + var relayoutList = []; + var completionTimeout = null; + var completion = null; + + function executeTransitions () { + var j; var basePlotModules = fullLayout._basePlotModules; - for(j = 0; j < basePlotModules.length; j++) { - basePlotModules[j].plot(gd, transitioningTraces, transitionOpts); + for (j = 0; j < basePlotModules.length; j++) { + basePlotModules[j].plot(gd, animatedTraces, transitionConfig); } - if(layout) { - for(j = 0; j < basePlotModules.length; j++) { - if(basePlotModules[j].transitionAxes) { - basePlotModules[j].transitionAxes(gd, layout, transitionOpts); + if (layout) { + for (j = 0; j < basePlotModules.length; j++) { + if (basePlotModules[j].transitionAxes) { + var newLayout = Lib.extendDeep({}, gd._fullLayout, layout); + basePlotModules[j].transitionAxes(gd, newLayout, transitionConfig); } } } - if(!transitionOpts.leadingEdgeRestyle) { - return new Promise(function(resolve, reject) { - completion = resolve; - completionTimeout = setTimeout(resolve, transitionOpts.duration); - }); + return new Promise(function(resolve, reject) { + completion = resolve; + completionTimeout = setTimeout(resolve, transitionConfig.duration); + }); + } + + function interruptPreviousTransitions () { + var ret; + clearTimeout(completionTimeout); + + if (completion) { + completion(); + } + + if (gd._animationInterrupt) { + ret = gd._animationInterrupt(); + gd._animationInterrupt = null; } - }*/ + return ret; + } + + for (i = 0; i < traceIndices.length; i++) { + var traceIdx = traceIndices[i]; + var contFull = gd._fullData[traceIdx]; + var module = contFull._module; + + if (!module.animatable) { + var thisTrace = [traceIdx]; + var thisUpdate = {}; + + for (var ai in data[i]) { + thisUpdate[ai] = [data[i][ai]]; + } + + restyleList.push((function (md, data, traces) { + return function () { + return Plotly.restyle(gd, data, traces); + } + }(module, thisUpdate, [traceIdx]))); + } + } + + var seq = [Plots.previousPromises, interruptPreviousTransitions, prepareTransitions, executeTransitions]; + seq = seq.concat(restyleList); + + var plotDone = Lib.syncOrAsync(seq, gd); + + if(!plotDone || !plotDone.then) plotDone = Promise.resolve(); + + return plotDone.then(function() { + gd.emit('plotly_beginanimate', []); + return gd; + }); + + return Promise.resolve(); }; /** diff --git a/src/plots/cartesian/index.js b/src/plots/cartesian/index.js index 7ced4299776..5f113a12b7a 100644 --- a/src/plots/cartesian/index.js +++ b/src/plots/cartesian/index.js @@ -25,8 +25,10 @@ exports.attrRegex = constants.attrRegex; exports.attributes = require('./attributes'); -exports.plot = function(gd, traces) { - var cdSubplot, cd, trace, i, j, k, isFullReplot; +exports.transitionAxes = require('./transition_axes'); + +exports.plot = function(gd, traces, transitionOpts) { + var cdSubplot, cd, trace, i, j, k; var fullLayout = gd._fullLayout, subplots = Plots.getSubplotIds(fullLayout, 'cartesian'), @@ -36,15 +38,10 @@ exports.plot = function(gd, traces) { if(!Array.isArray(traces)) { // If traces is not provided, then it's a complete replot and missing // traces are removed - isFullReplot = true; traces = []; for(i = 0; i < calcdata.length; i++) { traces.push(i); } - } else { - // If traces are explicitly specified, then it's a partial replot and - // traces are not removed. - isFullReplot = false; } for(i = 0; i < subplots.length; i++) { @@ -92,7 +89,7 @@ exports.plot = function(gd, traces) { } } - _module.plot(gd, subplotInfo, cdModule, isFullReplot); + _module.plot(gd, subplotInfo, cdModule, transitionOpts); } } }; diff --git a/src/plots/cartesian/transition_axes.js b/src/plots/cartesian/transition_axes.js new file mode 100644 index 00000000000..99eefe5c699 --- /dev/null +++ b/src/plots/cartesian/transition_axes.js @@ -0,0 +1,266 @@ +/** +* Copyright 2012-2016, 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 isNumeric = require('fast-isnumeric'); + +var Plotly = require('../../plotly'); +var Lib = require('../../lib'); +var svgTextUtils = require('../../lib/svg_text_utils'); +var Titles = require('../../components/titles'); +var Color = require('../../components/color'); +var Drawing = require('../../components/drawing'); +var Axes = require('./axes'); + +var axisRegex = /((x|y)([2-9]|[1-9][0-9]+)?)axis$/; + +module.exports = function transitionAxes(gd, newLayout, transitionConfig) { + var fullLayout = gd._fullLayout; + var axes = []; + + function computeUpdates (layout) { + var ai, attrList, match, to, axis, update, i; + var updates = {}; + + for (ai in layout) { + var attrList = ai.split('.'); + var match = attrList[0].match(axisRegex); + if (match) { + var axisName = match[1]; + axis = fullLayout[axisName + 'axis']; + update = {}; + + if (Array.isArray(layout[ai])) { + update.to = layout[ai].slice(0); + } else { + if (Array.isArray(layout[ai].range)) { + update.to = layout[ai].range.slice(0); + } + } + if (!update.to) continue; + + update.axis = axis; + update.length = axis._length; + + axes.push(axisName); + + updates[axisName] = update; + } + } + + return updates; + } + + function computeAffectedSubplots (fullLayout, updatedAxisIds) { + var plotName; + var plotinfos = fullLayout._plots; + var affectedSubplots = []; + + for (plotName in plotinfos) { + var plotinfo = plotinfos[plotName]; + + if (affectedSubplots.indexOf(plotinfo) !== -1) continue; + + var x = plotinfo.xaxis._id; + var y = plotinfo.yaxis._id; + + if (updatedAxisIds.indexOf(x) !== -1 || updatedAxisIds.indexOf(y) !== -1) { + affectedSubplots.push(plotinfo); + } + } + + return affectedSubplots; + } + + var updates = computeUpdates(newLayout); + var updatedAxisIds = Object.keys(updates); + var affectedSubplots = computeAffectedSubplots(fullLayout, updatedAxisIds); + var easeFn = d3.ease(transitionConfig.ease); + + function ticksAndAnnotations(xa, ya) { + var activeAxIds = [], + i; + + activeAxIds = [xa._id, ya._id]; + + for(i = 0; i < activeAxIds.length; i++) { + Axes.doTicks(gd, activeAxIds[i], true); + } + + function redrawObjs(objArray, module) { + var obji; + for(i = 0; i < objArray.length; i++) { + obji = objArray[i]; + if((activeAxIds.indexOf(obji.xref) !== -1) || + (activeAxIds.indexOf(obji.yref) !== -1)) { + module.draw(gd, i); + } + } + } + + redrawObjs(fullLayout.annotations || [], Plotly.Annotations); + redrawObjs(fullLayout.shapes || [], Plotly.Shapes); + redrawObjs(fullLayout.images || [], Plotly.Images); + } + + function unsetSubplotTransform (subplot) { + var xa2 = subplot.x(); + var ya2 = subplot.y(); + + var viewBox = [0, 0, xa2._length, ya2._length]; + + var xScaleFactor = xa2._length / viewBox[2], + yScaleFactor = ya2._length / viewBox[3]; + + var clipDx = viewBox[0], + clipDy = viewBox[1]; + + var fracDx = (viewBox[0] / viewBox[2] * xa2._length), + fracDy = (viewBox[1] / viewBox[3] * ya2._length); + + var plotDx = xa2._offset - fracDx, + plotDy = ya2._offset - fracDy; + + fullLayout._defs.selectAll('#' + subplot.clipId) + .call(Lib.setTranslate, clipDx, clipDy) + .call(Lib.setScale, 1 / xScaleFactor, 1 / yScaleFactor); + + subplot.plot + .call(Lib.setTranslate, plotDx, plotDy) + .call(Lib.setScale, xScaleFactor, yScaleFactor); + + } + + function updateSubplot (subplot, progress) { + var axis, r0, r1; + var xUpdate = updates[subplot.xaxis._id]; + var yUpdate = updates[subplot.yaxis._id]; + + var viewBox = []; + + if (xUpdate) { + axis = xUpdate.axis; + r0 = axis._r; + r1 = xUpdate.to; + viewBox[0] = (r0[0] * (1 - progress) + progress * r1[0] - r0[0]) / (r0[1] - r0[0]) * subplot.xaxis._length; + var dx1 = r0[1] - r0[0]; + var dx2 = r1[1] - r1[0]; + + axis.range[0] = r0[0] * (1 - progress) + progress * r1[0]; + axis.range[1] = r0[1] * (1 - progress) + progress * r1[1]; + + viewBox[2] = subplot.xaxis._length * ((1 - progress) + progress * dx2 / dx1); + } else { + viewBox[0] = 0; + viewBox[2] = subplot.xaxis._length; + } + + if (yUpdate) { + axis = yUpdate.axis; + r0 = axis._r; + r1 = yUpdate.to; + viewBox[1] = (r0[1] * (1 - progress) + progress * r1[1] - r0[1]) / (r0[0] - r0[1]) * subplot.yaxis._length; + var dy1 = r0[1] - r0[0]; + var dy2 = r1[1] - r1[0]; + + axis.range[0] = r0[0] * (1 - progress) + progress * r1[0]; + axis.range[1] = r0[1] * (1 - progress) + progress * r1[1]; + + viewBox[3] = subplot.yaxis._length * ((1 - progress) + progress * dy2 / dy1); + } else { + viewBox[1] = 0; + viewBox[3] = subplot.yaxis._length; + } + + ticksAndAnnotations(subplot.x(), subplot.y()); + + + var xa2 = subplot.x(); + var ya2 = subplot.y(); + + var editX = !!xUpdate; + var editY = !!yUpdate; + + var xScaleFactor = editX ? xa2._length / viewBox[2] : 1, + yScaleFactor = editY ? ya2._length / viewBox[3] : 1; + + var clipDx = editX ? viewBox[0] : 0, + clipDy = editY ? viewBox[1] : 0; + + var fracDx = editX ? (viewBox[0] / viewBox[2] * xa2._length) : 0, + fracDy = editY ? (viewBox[1] / viewBox[3] * ya2._length) : 0; + + var plotDx = xa2._offset - fracDx, + plotDy = ya2._offset - fracDy; + + fullLayout._defs.selectAll('#' + subplot.clipId) + .call(Lib.setTranslate, clipDx, clipDy) + .call(Lib.setScale, 1 / xScaleFactor, 1 / yScaleFactor); + + subplot.plot + .call(Lib.setTranslate, plotDx, plotDy) + .call(Lib.setScale, xScaleFactor, yScaleFactor); + } + + // transitionTail - finish a drag event with a redraw + function transitionTail() { + var attrs = {}; + // revert to the previous axis settings, then apply the new ones + // through relayout - this lets relayout manage undo/redo + for(var i = 0; i < updatedAxisIds.length; i++) { + var axi = updates[updatedAxisIds[i]].axis; + if(axi._r[0] !== axi.range[0]) attrs[axi._name + '.range[0]'] = axi.range[0]; + if(axi._r[1] !== axi.range[1]) attrs[axi._name + '.range[1]'] = axi.range[1]; + + axi.range = axi._r.slice(); + } + + for (var i = 0; i < affectedSubplots.length; i++) { + unsetSubplotTransform(affectedSubplots[i]); + } + + Plotly.relayout(gd, attrs); + } + + return new Promise(function (resolve, reject) { + var t1, t2, raf; + + gd._animationInterrupt = function () { + reject(); + cancelAnimationFrame(raf); + raf = null; + transitionTail(); + }; + + function doFrame () { + t2 = Date.now(); + + var tInterp = Math.min(1, (t2 - t1) / transitionConfig.duration); + var progress = easeFn(tInterp); + + for (var i = 0; i < affectedSubplots.length; i++) { + updateSubplot(affectedSubplots[i], progress); + } + + if (t2 - t1 > transitionConfig.duration) { + raf = cancelAnimationFrame(doFrame); + transitionTail(); + resolve(); + } else { + raf = requestAnimationFrame(doFrame); + resolve(); + } + } + + t1 = Date.now(); + raf = requestAnimationFrame(doFrame); + }); +} diff --git a/src/traces/scatter/index.js b/src/traces/scatter/index.js index 3b576a561d0..5c21f7c6710 100644 --- a/src/traces/scatter/index.js +++ b/src/traces/scatter/index.js @@ -29,6 +29,7 @@ Scatter.colorbar = require('./colorbar'); Scatter.style = require('./style'); Scatter.hoverPoints = require('./hover'); Scatter.selectPoints = require('./select'); +Scatter.animatable = true; Scatter.moduleType = 'trace'; Scatter.name = 'scatter'; diff --git a/test/image/mocks/animation.json b/test/image/mocks/animation.json index b6184c148b1..5a1fe57193a 100644 --- a/test/image/mocks/animation.json +++ b/test/image/mocks/animation.json @@ -15,8 +15,6 @@ "title": "Animation test", "showlegend": true, "autosize": false, - "width": 800, - "height": 440, "xaxis": { "range": [0, 2], "domain": [0, 1], @@ -27,20 +25,61 @@ } }, "frames": [{ - "name": 'frame0', + "name": "base", "data": [ {"y": [0, 2, 8]}, {"y": [4, 2, 3]} ], + "layout": { + "xaxis": { + "range": [0, 2] + }, + "yaxis": { + "range": [0, 10] + } + } + }, { + "name": 'frame0', + "data": [ + {"y": [0.5, 1.5, 7.5]}, + {"y": [4.25, 2.25, 3.05]} + ], + "baseFrame": "base", "traceIndices": [0, 1], "layout": { } }, { "name": 'frame1', "data": [ - {"y": [1, 3, 9]}, - {"y": [5, 3, 4]} + {"y": [2.1, 1, 7]}, + {"y": [4.5, 2.5, 3.1]} + ], + "baseFrame": "base", + "traceIndices": [0, 1], + "layout": { } + }, { + "name": 'frame2', + "data": [ + {"y": [3.5, 0.5, 6]}, + {"y": [5.7, 2.7, 3.9]} ], + "baseFrame": "base", "traceIndices": [0, 1], "layout": { } + }, { + "name": 'frame3', + "data": [ + {"y": [5.1, 0.25, 5]}, + {"y": [7, 2.9, 6]} + ], + "baseFrame": "base", + "traceIndices": [0, 1], + "layout": { + "xaxis": { + "range": [-1, 4] + }, + "yaxis": { + "range": [-5, 15] + } + } }] } From 977372015e0e7ceeab10158da22ded63926b667f Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Fri, 8 Jul 2016 10:16:43 -0400 Subject: [PATCH 58/82] Fix animation tests; lint --- src/plot_api/plot_api.js | 40 +++++++++--------- src/plots/cartesian/transition_axes.js | 56 ++++++++++++-------------- test/jasmine/tests/animate_test.js | 5 ++- 3 files changed, 48 insertions(+), 53 deletions(-) diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index ee883019890..428d864ca97 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -2510,7 +2510,7 @@ Plotly.relayout = function relayout(gd, astr, val) { Plotly.transition = function(gd, data, layout, traceIndices, transitionConfig) { gd = getGraphDiv(gd); - var i, value, traceIdx; + var i, traceIdx; var fullLayout = gd._fullLayout; transitionConfig = Lib.extendFlat({ @@ -2552,7 +2552,7 @@ Plotly.transition = function(gd, data, layout, traceIndices, transitionConfig) { Lib.extendDeepNoArrays(gd.data[traceIndices[i]], data[i]); } - Plots.supplyDefaults(gd) + Plots.supplyDefaults(gd); // doCalcdata(gd, animatedTraces); doCalcdata(gd); @@ -2561,64 +2561,62 @@ Plotly.transition = function(gd, data, layout, traceIndices, transitionConfig) { } var restyleList = []; - var relayoutList = []; var completionTimeout = null; var completion = null; - function executeTransitions () { + function executeTransitions() { var j; var basePlotModules = fullLayout._basePlotModules; - for (j = 0; j < basePlotModules.length; j++) { + for(j = 0; j < basePlotModules.length; j++) { basePlotModules[j].plot(gd, animatedTraces, transitionConfig); } - if (layout) { - for (j = 0; j < basePlotModules.length; j++) { - if (basePlotModules[j].transitionAxes) { + if(layout) { + for(j = 0; j < basePlotModules.length; j++) { + if(basePlotModules[j].transitionAxes) { var newLayout = Lib.extendDeep({}, gd._fullLayout, layout); basePlotModules[j].transitionAxes(gd, newLayout, transitionConfig); } } } - return new Promise(function(resolve, reject) { + return new Promise(function(resolve) { completion = resolve; completionTimeout = setTimeout(resolve, transitionConfig.duration); }); } - function interruptPreviousTransitions () { + function interruptPreviousTransitions() { var ret; clearTimeout(completionTimeout); - if (completion) { + if(completion) { completion(); } - if (gd._animationInterrupt) { + if(gd._animationInterrupt) { ret = gd._animationInterrupt(); gd._animationInterrupt = null; } return ret; } - for (i = 0; i < traceIndices.length; i++) { - var traceIdx = traceIndices[i]; + for(i = 0; i < traceIndices.length; i++) { + traceIdx = traceIndices[i]; var contFull = gd._fullData[traceIdx]; var module = contFull._module; - if (!module.animatable) { - var thisTrace = [traceIdx]; + if(!module.animatable) { var thisUpdate = {}; - for (var ai in data[i]) { + for(var ai in data[i]) { thisUpdate[ai] = [data[i][ai]]; } - restyleList.push((function (md, data, traces) { - return function () { + restyleList.push((function(md, data, traces) { + return function() { return Plotly.restyle(gd, data, traces); - } + }; }(module, thisUpdate, [traceIdx]))); } } @@ -2634,8 +2632,6 @@ Plotly.transition = function(gd, data, layout, traceIndices, transitionConfig) { gd.emit('plotly_beginanimate', []); return gd; }); - - return Promise.resolve(); }; /** diff --git a/src/plots/cartesian/transition_axes.js b/src/plots/cartesian/transition_axes.js index 99eefe5c699..ffe6e30af19 100644 --- a/src/plots/cartesian/transition_axes.js +++ b/src/plots/cartesian/transition_axes.js @@ -10,14 +10,9 @@ 'use strict'; var d3 = require('d3'); -var isNumeric = require('fast-isnumeric'); var Plotly = require('../../plotly'); var Lib = require('../../lib'); -var svgTextUtils = require('../../lib/svg_text_utils'); -var Titles = require('../../components/titles'); -var Color = require('../../components/color'); -var Drawing = require('../../components/drawing'); var Axes = require('./axes'); var axisRegex = /((x|y)([2-9]|[1-9][0-9]+)?)axis$/; @@ -26,26 +21,26 @@ module.exports = function transitionAxes(gd, newLayout, transitionConfig) { var fullLayout = gd._fullLayout; var axes = []; - function computeUpdates (layout) { - var ai, attrList, match, to, axis, update, i; + function computeUpdates(layout) { + var ai, attrList, match, axis, update; var updates = {}; - for (ai in layout) { - var attrList = ai.split('.'); - var match = attrList[0].match(axisRegex); - if (match) { + for(ai in layout) { + attrList = ai.split('.'); + match = attrList[0].match(axisRegex); + if(match) { var axisName = match[1]; axis = fullLayout[axisName + 'axis']; update = {}; - if (Array.isArray(layout[ai])) { + if(Array.isArray(layout[ai])) { update.to = layout[ai].slice(0); } else { - if (Array.isArray(layout[ai].range)) { + if(Array.isArray(layout[ai].range)) { update.to = layout[ai].range.slice(0); } } - if (!update.to) continue; + if(!update.to) continue; update.axis = axis; update.length = axis._length; @@ -59,20 +54,20 @@ module.exports = function transitionAxes(gd, newLayout, transitionConfig) { return updates; } - function computeAffectedSubplots (fullLayout, updatedAxisIds) { + function computeAffectedSubplots(fullLayout, updatedAxisIds) { var plotName; var plotinfos = fullLayout._plots; var affectedSubplots = []; - for (plotName in plotinfos) { + for(plotName in plotinfos) { var plotinfo = plotinfos[plotName]; - if (affectedSubplots.indexOf(plotinfo) !== -1) continue; + if(affectedSubplots.indexOf(plotinfo) !== -1) continue; var x = plotinfo.xaxis._id; var y = plotinfo.yaxis._id; - if (updatedAxisIds.indexOf(x) !== -1 || updatedAxisIds.indexOf(y) !== -1) { + if(updatedAxisIds.indexOf(x) !== -1 || updatedAxisIds.indexOf(y) !== -1) { affectedSubplots.push(plotinfo); } } @@ -111,7 +106,7 @@ module.exports = function transitionAxes(gd, newLayout, transitionConfig) { redrawObjs(fullLayout.images || [], Plotly.Images); } - function unsetSubplotTransform (subplot) { + function unsetSubplotTransform(subplot) { var xa2 = subplot.x(); var ya2 = subplot.y(); @@ -139,14 +134,14 @@ module.exports = function transitionAxes(gd, newLayout, transitionConfig) { } - function updateSubplot (subplot, progress) { + function updateSubplot(subplot, progress) { var axis, r0, r1; var xUpdate = updates[subplot.xaxis._id]; var yUpdate = updates[subplot.yaxis._id]; var viewBox = []; - if (xUpdate) { + if(xUpdate) { axis = xUpdate.axis; r0 = axis._r; r1 = xUpdate.to; @@ -163,7 +158,7 @@ module.exports = function transitionAxes(gd, newLayout, transitionConfig) { viewBox[2] = subplot.xaxis._length; } - if (yUpdate) { + if(yUpdate) { axis = yUpdate.axis; r0 = axis._r; r1 = yUpdate.to; @@ -212,10 +207,11 @@ module.exports = function transitionAxes(gd, newLayout, transitionConfig) { // transitionTail - finish a drag event with a redraw function transitionTail() { + var i; var attrs = {}; // revert to the previous axis settings, then apply the new ones // through relayout - this lets relayout manage undo/redo - for(var i = 0; i < updatedAxisIds.length; i++) { + for(i = 0; i < updatedAxisIds.length; i++) { var axi = updates[updatedAxisIds[i]].axis; if(axi._r[0] !== axi.range[0]) attrs[axi._name + '.range[0]'] = axi.range[0]; if(axi._r[1] !== axi.range[1]) attrs[axi._name + '.range[1]'] = axi.range[1]; @@ -223,34 +219,34 @@ module.exports = function transitionAxes(gd, newLayout, transitionConfig) { axi.range = axi._r.slice(); } - for (var i = 0; i < affectedSubplots.length; i++) { + for(i = 0; i < affectedSubplots.length; i++) { unsetSubplotTransform(affectedSubplots[i]); } Plotly.relayout(gd, attrs); } - return new Promise(function (resolve, reject) { + return new Promise(function(resolve, reject) { var t1, t2, raf; - gd._animationInterrupt = function () { + gd._animationInterrupt = function() { reject(); cancelAnimationFrame(raf); raf = null; transitionTail(); }; - function doFrame () { + function doFrame() { t2 = Date.now(); var tInterp = Math.min(1, (t2 - t1) / transitionConfig.duration); var progress = easeFn(tInterp); - for (var i = 0; i < affectedSubplots.length; i++) { + for(var i = 0; i < affectedSubplots.length; i++) { updateSubplot(affectedSubplots[i], progress); } - if (t2 - t1 > transitionConfig.duration) { + if(t2 - t1 > transitionConfig.duration) { raf = cancelAnimationFrame(doFrame); transitionTail(); resolve(); @@ -263,4 +259,4 @@ module.exports = function transitionAxes(gd, newLayout, transitionConfig) { t1 = Date.now(); raf = requestAnimationFrame(doFrame); }); -} +}; diff --git a/test/jasmine/tests/animate_test.js b/test/jasmine/tests/animate_test.js index fe2f99c2119..ad0fca74ece 100644 --- a/test/jasmine/tests/animate_test.js +++ b/test/jasmine/tests/animate_test.js @@ -47,7 +47,10 @@ describe('Test animate API', function() { expect(args[1].length).toEqual(2); // layout - expect(args[2]).toEqual({}); + expect(args[2]).toEqual({ + xaxis: {range: [0, 2]}, + yaxis: {range: [0, 10]} + }); // traces are [0, 1]: expect(args[3]).toEqual([0, 1]); From 17529ab805f47072bd1280847bb1454833c045be Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Fri, 8 Jul 2016 10:32:35 -0400 Subject: [PATCH 59/82] Animation cleanup --- src/plot_api/plot_api.js | 22 ++++++++++++++-------- src/plots/cartesian/transition_axes.js | 4 ++-- src/plots/plots.js | 8 ++++++++ test/image/mocks/animation.json | 8 ++++---- 4 files changed, 28 insertions(+), 14 deletions(-) diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index 428d864ca97..1ed0d695e5d 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -2554,7 +2554,10 @@ Plotly.transition = function(gd, data, layout, traceIndices, transitionConfig) { Plots.supplyDefaults(gd); + // TODO: Add logic that computes animatedTraces to avoid unnecessary work while + // still handling things like box plots that are interrelated. // doCalcdata(gd, animatedTraces); + doCalcdata(gd); ErrorBars.calc(gd); @@ -2562,7 +2565,7 @@ Plotly.transition = function(gd, data, layout, traceIndices, transitionConfig) { var restyleList = []; var completionTimeout = null; - var completion = null; + var resolveTransitionCallback = null; function executeTransitions() { var j; @@ -2581,22 +2584,25 @@ Plotly.transition = function(gd, data, layout, traceIndices, transitionConfig) { } return new Promise(function(resolve) { - completion = resolve; + resolveTransitionCallback = resolve; completionTimeout = setTimeout(resolve, transitionConfig.duration); }); } function interruptPreviousTransitions() { - var ret; + var ret, interrupt; clearTimeout(completionTimeout); - if(completion) { - completion(); + if(resolveTransitionCallback) { + resolveTransitionCallback(); + } + + while(gd._frameData._layoutInterrupts.length) { + (gd._frameData._layoutInterrupts.pop())(); } - if(gd._animationInterrupt) { - ret = gd._animationInterrupt(); - gd._animationInterrupt = null; + while(gd._frameData._styleInterrupts.length) { + (gd._frameData._styleInterrupts.pop())(); } return ret; } diff --git a/src/plots/cartesian/transition_axes.js b/src/plots/cartesian/transition_axes.js index ffe6e30af19..3698f24665d 100644 --- a/src/plots/cartesian/transition_axes.js +++ b/src/plots/cartesian/transition_axes.js @@ -229,12 +229,12 @@ module.exports = function transitionAxes(gd, newLayout, transitionConfig) { return new Promise(function(resolve, reject) { var t1, t2, raf; - gd._animationInterrupt = function() { + gd._frameData._layoutInterrupts.push(function() { reject(); cancelAnimationFrame(raf); raf = null; transitionTail(); - }; + }); function doFrame() { t2 = Date.now(); diff --git a/src/plots/plots.js b/src/plots/plots.js index efd91f849dd..3cece41e497 100644 --- a/src/plots/plots.js +++ b/src/plots/plots.js @@ -547,6 +547,14 @@ plots.supplyDefaults = function(gd) { if(!gd._frameData._counter) { gd._frameData._counter = 0; } + + if(!gd._frameData._layoutInterrupts) { + gd._frameData._layoutInterrupts = []; + } + + if(!gd._frameData._styleInterrupts) { + gd._frameData._styleInterrupts = []; + } }; // helper function to be bound to fullLayout to check diff --git a/test/image/mocks/animation.json b/test/image/mocks/animation.json index 5a1fe57193a..dfd8972d01b 100644 --- a/test/image/mocks/animation.json +++ b/test/image/mocks/animation.json @@ -39,7 +39,7 @@ } } }, { - "name": 'frame0', + "name": "frame0", "data": [ {"y": [0.5, 1.5, 7.5]}, {"y": [4.25, 2.25, 3.05]} @@ -48,7 +48,7 @@ "traceIndices": [0, 1], "layout": { } }, { - "name": 'frame1', + "name": "frame1", "data": [ {"y": [2.1, 1, 7]}, {"y": [4.5, 2.5, 3.1]} @@ -57,7 +57,7 @@ "traceIndices": [0, 1], "layout": { } }, { - "name": 'frame2', + "name": "frame2", "data": [ {"y": [3.5, 0.5, 6]}, {"y": [5.7, 2.7, 3.9]} @@ -66,7 +66,7 @@ "traceIndices": [0, 1], "layout": { } }, { - "name": 'frame3', + "name": "frame3", "data": [ {"y": [5.1, 0.25, 5]}, {"y": [7, 2.9, 6]} From 99556257b747e01fa21f36177c682c33cc50f83c Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Fri, 8 Jul 2016 17:29:36 -0400 Subject: [PATCH 60/82] Avoid transitioning axes if no change --- src/plot_api/plot_api.js | 10 ++++++++-- src/plots/cartesian/transition_axes.js | 17 ++++++++++++++--- src/plots/plots.js | 9 +++++++++ 3 files changed, 31 insertions(+), 5 deletions(-) diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index 1ed0d695e5d..01a20472f71 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -2516,7 +2516,7 @@ Plotly.transition = function(gd, data, layout, traceIndices, transitionConfig) { transitionConfig = Lib.extendFlat({ ease: 'cubic-in-out', duration: 500, - delay: 0 + delay: 0, }, transitionConfig || {}); // Create a single transition to be passed around: @@ -2574,15 +2574,21 @@ Plotly.transition = function(gd, data, layout, traceIndices, transitionConfig) { basePlotModules[j].plot(gd, animatedTraces, transitionConfig); } + var hasAxisTransition = false; + if(layout) { for(j = 0; j < basePlotModules.length; j++) { if(basePlotModules[j].transitionAxes) { var newLayout = Lib.extendDeep({}, gd._fullLayout, layout); - basePlotModules[j].transitionAxes(gd, newLayout, transitionConfig); + hasAxisTransition = hasAxisTransition || basePlotModules[j].transitionAxes(gd, newLayout, transitionConfig); } } } + if (!hasAxisTransition) { + return false; + } + return new Promise(function(resolve) { resolveTransitionCallback = resolve; completionTimeout = setTimeout(resolve, transitionConfig.duration); diff --git a/src/plots/cartesian/transition_axes.js b/src/plots/cartesian/transition_axes.js index 3698f24665d..89f1a17dbdf 100644 --- a/src/plots/cartesian/transition_axes.js +++ b/src/plots/cartesian/transition_axes.js @@ -54,7 +54,7 @@ module.exports = function transitionAxes(gd, newLayout, transitionConfig) { return updates; } - function computeAffectedSubplots(fullLayout, updatedAxisIds) { + function computeAffectedSubplots(fullLayout, updatedAxisIds, updates) { var plotName; var plotinfos = fullLayout._plots; var affectedSubplots = []; @@ -66,6 +66,12 @@ module.exports = function transitionAxes(gd, newLayout, transitionConfig) { var x = plotinfo.xaxis._id; var y = plotinfo.yaxis._id; + var fromX = plotinfo.xaxis.range; + var fromY = plotinfo.yaxis.range; + var toX = updates[x].to; + var toY = updates[y].to; + + if (fromX[0] === toX[0] && fromX[1] === toX[1] && fromY[0] === toY[0] && fromY[1] === toY[1]) continue; if(updatedAxisIds.indexOf(x) !== -1 || updatedAxisIds.indexOf(y) !== -1) { affectedSubplots.push(plotinfo); @@ -77,8 +83,11 @@ module.exports = function transitionAxes(gd, newLayout, transitionConfig) { var updates = computeUpdates(newLayout); var updatedAxisIds = Object.keys(updates); - var affectedSubplots = computeAffectedSubplots(fullLayout, updatedAxisIds); - var easeFn = d3.ease(transitionConfig.ease); + var affectedSubplots = computeAffectedSubplots(fullLayout, updatedAxisIds, updates); + + if (!affectedSubplots.length) { + return false; + } function ticksAndAnnotations(xa, ya) { var activeAxIds = [], @@ -226,6 +235,8 @@ module.exports = function transitionAxes(gd, newLayout, transitionConfig) { Plotly.relayout(gd, attrs); } + var easeFn = d3.ease(transitionConfig.ease); + return new Promise(function(resolve, reject) { var t1, t2, raf; diff --git a/src/plots/plots.js b/src/plots/plots.js index 3cece41e497..0964303cf49 100644 --- a/src/plots/plots.js +++ b/src/plots/plots.js @@ -512,6 +512,15 @@ plots.supplyDefaults = function(gd) { // relink functions and _ attributes to promote consistency between plots relinkPrivateKeys(newFullLayout, oldFullLayout); + // XXX: This is a hack that should be refactored by more generally removing the + // need for relinkPrivateKeys + var subplots = plots.getSubplotIds(newFullLayout, 'cartesian'); + for(i = 0; i < subplots.length; i++) { + var subplot = newFullLayout._plots[subplots[i]]; + subplot.xaxis = newFullLayout[subplot.xaxis._name]; + subplot.yaxis = newFullLayout[subplot.yaxis._name]; + } + plots.doAutoMargin(gd); // can't quite figure out how to get rid of this... each axis needs From 93eeaa81aa3811a1ac701bb0ab21cdc038bd6e0b Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Mon, 11 Jul 2016 14:29:38 -0400 Subject: [PATCH 61/82] Transfer animation code over to new PR --- src/components/drawing/index.js | 54 ++++++++++++-- src/components/errorbars/plot.js | 99 +++++++++++++++++++++----- src/plot_api/plot_api.js | 27 ++++--- src/plots/cartesian/transition_axes.js | 17 +++-- src/traces/scatter/attributes.js | 4 ++ src/traces/scatter/calc.js | 4 ++ src/traces/scatter/defaults.js | 1 + src/traces/scatter/plot.js | 66 +++++++++++------ 8 files changed, 213 insertions(+), 59 deletions(-) diff --git a/src/components/drawing/index.js b/src/components/drawing/index.js index 2c9a6a6261c..36984f0fd0d 100644 --- a/src/components/drawing/index.js +++ b/src/components/drawing/index.js @@ -46,16 +46,62 @@ drawing.setRect = function(s, x, y, w, h) { s.call(drawing.setPosition, x, y).call(drawing.setSize, w, h); }; -drawing.translatePoints = function(s, xa, ya) { - s.each(function(d) { +drawing.translatePoints = function(s, xa, ya, trace, transitionConfig, joinDirection) { + var size; + + var hasTransition = transitionConfig && (transitionConfig || {}).duration > 0; + + if(hasTransition) { + size = s.size(); + } + + s.each(function(d, i) { // put xp and yp into d if pixel scaling is already done var x = d.xp || xa.c2p(d.x), y = d.yp || ya.c2p(d.y), p = d3.select(this); if(isNumeric(x) && isNumeric(y)) { // for multiline text this works better - if(this.nodeName === 'text') p.attr('x', x).attr('y', y); - else p.attr('transform', 'translate(' + x + ',' + y + ')'); + if(this.nodeName === 'text') { + p.attr('x', x).attr('y', y); + } else { + if(hasTransition) { + var trans; + if(!joinDirection) { + trans = p.transition() + .delay(transitionConfig.delay + transitionConfig.cascade / size * i) + .duration(transitionConfig.duration) + .ease(transitionConfig.ease) + .attr('transform', 'translate(' + x + ',' + y + ')'); + + if(trace) { + trans.call(drawing.pointStyle, trace); + } + } else if(joinDirection === -1) { + trans = p.style('opacity', 1) + .transition() + .duration(transitionConfig.duration) + .ease(transitionConfig.ease) + .style('opacity', 0) + .remove(); + } else if(joinDirection === 1) { + trans = p.attr('transform', 'translate(' + x + ',' + y + ')'); + + if(trace) { + trans.call(drawing.pointStyle, trace); + } + + trans.style('opacity', 0) + .transition() + .duration(transitionConfig.duration) + .ease(transitionConfig.ease) + .style('opacity', 1); + } + + } else { + p.attr('transform', 'translate(' + x + ',' + y + ')'); + } + } } else p.remove(); }); diff --git a/src/components/errorbars/plot.js b/src/components/errorbars/plot.js index eb66769760e..f8fb96d9d3b 100644 --- a/src/components/errorbars/plot.js +++ b/src/components/errorbars/plot.js @@ -14,12 +14,17 @@ var isNumeric = require('fast-isnumeric'); var Lib = require('../../lib'); var subTypes = require('../../traces/scatter/subtypes'); +var styleError = require('./style'); -module.exports = function plot(traces, plotinfo) { +module.exports = function plot(traces, plotinfo, transitionConfig) { + var isNew; var xa = plotinfo.x(), ya = plotinfo.y(); + transitionConfig = transitionConfig || {}; + var hasAnimation = isNumeric(transitionConfig.duration) && transitionConfig.duration > 0; + traces.each(function(d) { var trace = d[0].trace, // || {} is in case the trace (specifically scatterternary) @@ -29,29 +34,38 @@ module.exports = function plot(traces, plotinfo) { xObj = trace.error_x || {}, yObj = trace.error_y || {}; + var keyFunc; + + if(trace.identifier) { + keyFunc = function(d) {return d.identifier;}; + } + var sparse = ( subTypes.hasMarkers(trace) && trace.marker.maxdisplayed > 0 ); - var keyFunc; - - if(trace.key) { - keyFunc = function(d) { return d.key; }; - } - if(!yObj.visible && !xObj.visible) return; - var selection = d3.select(this).selectAll('g.errorbar'); - var join = selection.data(Lib.identity, keyFunc); + var errorbars = d3.select(this).selectAll('g.errorbar') + .data(Lib.identity, keyFunc); - join.enter().append('g') + errorbars.enter().append('g') .classed('errorbar', true); - join.exit().remove(); + if(hasAnimation) { + errorbars.exit() + .style('opacity', 1) + .transition() + .duration(transitionConfig.duration) + .style('opacity', 0) + .remove(); + } else { + errorbars.exit().remove(); + } - join.each(function(d) { + errorbars.each(function(d) { var errorbar = d3.select(this); var coords = errorCoords(d, xa, ya); @@ -68,14 +82,37 @@ module.exports = function plot(traces, plotinfo) { coords.yh + 'h' + (2 * yw) + // hat 'm-' + yw + ',0V' + coords.ys; // bar + if(!coords.noYS) path += 'm-' + yw + ',0h' + (2 * yw); // shoe - errorbar.append('path') - .classed('yerror', true) - .attr('d', path); + var yerror = errorbar.select('path.yerror'); + + isNew = !yerror.size(); + + if(isNew) { + yerror = errorbar.append('path') + .classed('yerror', true); + + if(hasAnimation) { + yerror = yerror.style('opacity', 0); + } + } else if(hasAnimation) { + yerror = yerror.transition() + .duration(transitionConfig.duration) + .ease(transitionConfig.ease) + .delay(transitionConfig.delay); + } + + yerror.attr('d', path); + + if(isNew && hasAnimation) { + yerror = yerror.transition() + .duration(transitionConfig.duration) + .style('opacity', 1); + } } - if(xObj.visible && isNumeric(coords.y) && + if(xObj.visible && isNumeric(coords.x) && isNumeric(coords.xh) && isNumeric(coords.xs)) { var xw = (xObj.copy_ystyle ? yObj : xObj).width; @@ -86,11 +123,35 @@ module.exports = function plot(traces, plotinfo) { if(!coords.noXS) path += 'm0,-' + xw + 'v' + (2 * xw); // shoe - errorbar.append('path') - .classed('xerror', true) - .attr('d', path); + var xerror = errorbar.select('path.xerror'); + + isNew = !xerror.size(); + + if(isNew) { + xerror = errorbar.append('path') + .classed('xerror', true); + + if(hasAnimation) { + xerror = xerror.style('opacity', 0); + } + } else if(hasAnimation) { + xerror = xerror.transition() + .duration(transitionConfig.duration) + .ease(transitionConfig.ease) + .delay(transitionConfig.delay); + } + + xerror.attr('d', path); + + if(isNew && hasAnimation) { + xerror = xerror.transition() + .duration(transitionConfig.duration) + .style('opacity', 1); + } } }); + + d3.select(this).call(styleError); }); }; diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index 01a20472f71..5e73cf9fa2a 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -2517,6 +2517,7 @@ Plotly.transition = function(gd, data, layout, traceIndices, transitionConfig) { ease: 'cubic-in-out', duration: 500, delay: 0, + cascade: 0 }, transitionConfig || {}); // Create a single transition to be passed around: @@ -2529,13 +2530,19 @@ Plotly.transition = function(gd, data, layout, traceIndices, transitionConfig) { gd._currentTransition = null; } + var dataLength = Array.isArray(data) ? data.length : 0; + // Select which traces will be updated: if(isNumeric(traceIndices)) traceIndices = [traceIndices]; else if(!Array.isArray(traceIndices) || !traceIndices.length) { traceIndices = gd._fullData.map(function(v, i) {return i;}); } - var animatedTraces = []; + if(traceIndices.length > dataLength) { + traceIndices = traceIndices.slice(0, dataLength); + } + + var transitionedTraces = []; function prepareTransitions() { for(i = 0; i < traceIndices.length; i++) { @@ -2547,16 +2554,16 @@ Plotly.transition = function(gd, data, layout, traceIndices, transitionConfig) { continue; } - animatedTraces.push(traceIdx); + transitionedTraces.push(traceIdx); Lib.extendDeepNoArrays(gd.data[traceIndices[i]], data[i]); } Plots.supplyDefaults(gd); - // TODO: Add logic that computes animatedTraces to avoid unnecessary work while + // TODO: Add logic that computes transitionedTraces to avoid unnecessary work while // still handling things like box plots that are interrelated. - // doCalcdata(gd, animatedTraces); + // doCalcdata(gd, transitionedTraces); doCalcdata(gd); @@ -2568,10 +2575,14 @@ Plotly.transition = function(gd, data, layout, traceIndices, transitionConfig) { var resolveTransitionCallback = null; function executeTransitions() { + var hasTraceTransition = false; var j; var basePlotModules = fullLayout._basePlotModules; for(j = 0; j < basePlotModules.length; j++) { - basePlotModules[j].plot(gd, animatedTraces, transitionConfig); + if(basePlotModules[j].animatable) { + hasTraceTransition = true; + } + basePlotModules[j].plot(gd, transitionedTraces, transitionConfig); } var hasAxisTransition = false; @@ -2579,13 +2590,13 @@ Plotly.transition = function(gd, data, layout, traceIndices, transitionConfig) { if(layout) { for(j = 0; j < basePlotModules.length; j++) { if(basePlotModules[j].transitionAxes) { - var newLayout = Lib.extendDeep({}, gd._fullLayout, layout); + var newLayout = Lib.expandObjectPaths(layout); hasAxisTransition = hasAxisTransition || basePlotModules[j].transitionAxes(gd, newLayout, transitionConfig); } } } - if (!hasAxisTransition) { + if(!hasAxisTransition && !hasTraceTransition) { return false; } @@ -2596,7 +2607,6 @@ Plotly.transition = function(gd, data, layout, traceIndices, transitionConfig) { } function interruptPreviousTransitions() { - var ret, interrupt; clearTimeout(completionTimeout); if(resolveTransitionCallback) { @@ -2610,7 +2620,6 @@ Plotly.transition = function(gd, data, layout, traceIndices, transitionConfig) { while(gd._frameData._styleInterrupts.length) { (gd._frameData._styleInterrupts.pop())(); } - return ret; } for(i = 0; i < traceIndices.length; i++) { diff --git a/src/plots/cartesian/transition_axes.js b/src/plots/cartesian/transition_axes.js index 89f1a17dbdf..0eec069ac08 100644 --- a/src/plots/cartesian/transition_axes.js +++ b/src/plots/cartesian/transition_axes.js @@ -58,6 +58,7 @@ module.exports = function transitionAxes(gd, newLayout, transitionConfig) { var plotName; var plotinfos = fullLayout._plots; var affectedSubplots = []; + var toX, toY; for(plotName in plotinfos) { var plotinfo = plotinfos[plotName]; @@ -68,10 +69,18 @@ module.exports = function transitionAxes(gd, newLayout, transitionConfig) { var y = plotinfo.yaxis._id; var fromX = plotinfo.xaxis.range; var fromY = plotinfo.yaxis.range; - var toX = updates[x].to; - var toY = updates[y].to; + if(updates[x]) { + toX = updates[x].to; + } else { + toX = fromX; + } + if(updates[y]) { + toY = updates[y].to; + } else { + toY = fromY; + } - if (fromX[0] === toX[0] && fromX[1] === toX[1] && fromY[0] === toY[0] && fromY[1] === toY[1]) continue; + if(fromX[0] === toX[0] && fromX[1] === toX[1] && fromY[0] === toY[0] && fromY[1] === toY[1]) continue; if(updatedAxisIds.indexOf(x) !== -1 || updatedAxisIds.indexOf(y) !== -1) { affectedSubplots.push(plotinfo); @@ -85,7 +94,7 @@ module.exports = function transitionAxes(gd, newLayout, transitionConfig) { var updatedAxisIds = Object.keys(updates); var affectedSubplots = computeAffectedSubplots(fullLayout, updatedAxisIds, updates); - if (!affectedSubplots.length) { + if(!affectedSubplots.length) { return false; } diff --git a/src/traces/scatter/attributes.js b/src/traces/scatter/attributes.js index 28db0d1343e..1f23210d81b 100644 --- a/src/traces/scatter/attributes.js +++ b/src/traces/scatter/attributes.js @@ -65,6 +65,10 @@ module.exports = { 'See `y0` for more info.' ].join(' ') }, + identifier: { + valType: 'data_array', + description: 'A list of keys for object constancy of data points during animation' + }, text: { valType: 'string', role: 'info', diff --git a/src/traces/scatter/calc.js b/src/traces/scatter/calc.js index 3ac952cce2d..61f3a6300cd 100644 --- a/src/traces/scatter/calc.js +++ b/src/traces/scatter/calc.js @@ -115,6 +115,10 @@ module.exports = function calc(gd, trace) { for(i = 0; i < serieslen; i++) { cd[i] = (isNumeric(x[i]) && isNumeric(y[i])) ? {x: x[i], y: y[i]} : {x: false, y: false}; + + if(trace.identifier && trace.identifier[i] !== undefined) { + cd[i].identifier = trace.identifier[i]; + } } // this has migrated up from arraysToCalcdata as we have a reference to 's' here diff --git a/src/traces/scatter/defaults.js b/src/traces/scatter/defaults.js index e8582802450..7efa194e810 100644 --- a/src/traces/scatter/defaults.js +++ b/src/traces/scatter/defaults.js @@ -38,6 +38,7 @@ module.exports = function supplyDefaults(traceIn, traceOut, defaultColor, layout coerce('text'); coerce('mode', defaultMode); + coerce('identifier'); if(subTypes.hasLines(traceOut)) { handleLineDefaults(traceIn, traceOut, defaultColor, layout, coerce); diff --git a/src/traces/scatter/plot.js b/src/traces/scatter/plot.js index f6984cb78d3..e7c99ee87db 100644 --- a/src/traces/scatter/plot.js +++ b/src/traces/scatter/plot.js @@ -64,7 +64,7 @@ module.exports = function plot(gd, plotinfo, cdscatter, transitionConfig) { // Must run the selection again since otherwise enters/updates get grouped together // and these get executed out of order. Except we need them in order! scatterlayer.selectAll('g.trace').each(function(d, i) { - plotOne(gd, i, plotinfo, d, cdscatter, this); + plotOne(gd, i, plotinfo, d, cdscatter, this, transitionConfig); }); if(isFullReplot) { @@ -116,7 +116,7 @@ function createFills(gd, scatterlayer) { }); } -function plotOne(gd, idx, plotinfo, cdscatter, cdscatterAll, element) { +function plotOne(gd, idx, plotinfo, cdscatter, cdscatterAll, element, transitionConfig) { var join, i; // Since this has been reorganized and we're executing this on individual traces, @@ -124,6 +124,19 @@ function plotOne(gd, idx, plotinfo, cdscatter, cdscatterAll, element) { // since it does an internal n^2 loop over comparisons with other traces: selectMarkers(gd, idx, plotinfo, cdscatter, cdscatterAll); + var hasTransition = !!transitionConfig && transitionConfig.duration > 0; + + function transition(selection) { + if(hasTransition) { + return selection.transition() + .duration(transitionConfig.duration) + .delay(transitionConfig.delay) + .ease(transitionConfig.ease); + } else { + return selection; + } + } + var xa = plotinfo.x(), ya = plotinfo.y(); @@ -133,7 +146,7 @@ function plotOne(gd, idx, plotinfo, cdscatter, cdscatterAll, element) { // (so error bars can find them along with bars) // error bars are at the bottom - tr.call(ErrorBars.plot, plotinfo); + tr.call(ErrorBars.plot, plotinfo, transitionConfig); if(trace.visible !== true) return; @@ -227,12 +240,11 @@ function plotOne(gd, idx, plotinfo, cdscatter, cdscatterAll, element) { lastSegment = segments[segments.length - 1], pt1 = lastSegment[lastSegment.length - 1]; - var lineJoin = tr.selectAll('.js-line').data(segments); - - lineJoin.enter() - .append('path').classed('js-line', true); + //var lineJoin = tr.selectAll('.js-line').data(segments); + //lineJoin.enter().append('path').classed('js-line', true); - lineJoin.each(function(pts) { + for(i = 0; i < segments.length; i++) { + var pts = segments[i]; thispath = pathfn(pts); thisrevpath = revpathfn(pts); if(!fullpath) { @@ -248,13 +260,16 @@ function plotOne(gd, idx, plotinfo, cdscatter, cdscatterAll, element) { revpath = thisrevpath + 'Z' + revpath; } if(subTypes.hasLines(trace) && pts.length > 1) { - d3.select(this) - .attr('d', thispath) - .datum(cdscatter); + var lineJoin = tr.selectAll('.js-line').data([cdscatter]); + + lineJoin.enter() + .append('path').classed('js-line', true).attr('d', thispath); + + transition(lineJoin).attr('d', thispath); } - }); + } - lineJoin.exit().remove(); + //lineJoin.exit().remove(); if(ownFillEl3) { if(pt0 && pt1) { @@ -268,10 +283,10 @@ function plotOne(gd, idx, plotinfo, cdscatter, cdscatterAll, element) { // fill to zero: full trace path, plus extension of // the endpoints to the appropriate axis - ownFillEl3.attr('d', fullpath + 'L' + pt1 + 'L' + pt0 + 'Z'); + transition(ownFillEl3).attr('d', fullpath + 'L' + pt1 + 'L' + pt0 + 'Z'); } else { // fill to self: just join the path to itself - ownFillEl3.attr('d', fullpath + 'Z'); + transition(ownFillEl3).attr('d', fullpath + 'Z'); } } } @@ -282,7 +297,7 @@ function plotOne(gd, idx, plotinfo, cdscatter, cdscatterAll, element) { // contours, we just add the two paths closed on themselves. // This makes strange results if one path is *not* entirely // inside the other, but then that is a strange usage. - tonext.attr('d', fullpath + 'Z' + prevpath + 'Z'); + transition(tonext).attr('d', fullpath + 'Z' + prevpath + 'Z'); } else { // tonextx/y: for now just connect endpoints with lines. This is @@ -290,7 +305,7 @@ function plotOne(gd, idx, plotinfo, cdscatter, cdscatterAll, element) { // y/x, but if they *aren't*, we should ideally do more complicated // things depending on whether the new endpoint projects onto the // existing curve or off the end of it - tonext.attr('d', fullpath + 'L' + prevpath.substr(1) + 'Z'); + transition(tonext).attr('d', fullpath + 'L' + prevpath.substr(1) + 'Z'); } trace._polygons = trace._polygons.concat(prevPolygons); } @@ -305,12 +320,12 @@ function plotOne(gd, idx, plotinfo, cdscatter, cdscatterAll, element) { } function keyFunc(d) { - return d.key; + return d.identifier; } // Returns a function if the trace is keyed, otherwise returns undefined function getKeyFunc(trace) { - if(trace.key) { + if(trace.identifier) { return keyFunc; } } @@ -333,13 +348,18 @@ function plotOne(gd, idx, plotinfo, cdscatter, cdscatterAll, element) { join.enter().append('path') .classed('point', true) .call(Drawing.pointStyle, trace) - .call(Drawing.translatePoints, xa, ya); + .call(Drawing.translatePoints, xa, ya, trace, transitionConfig, 1); - selection - .call(Drawing.translatePoints, xa, ya) + join.transition() + .call(Drawing.translatePoints, xa, ya, trace, transitionConfig, 0) .call(Drawing.pointStyle, trace); - join.exit().remove(); + if(hasTransition) { + join.exit() + .call(Drawing.translatePoints, xa, ya, trace, transitionConfig, -1); + } else { + join.exit().remove(); + } } if(showText) { selection = s.selectAll('g'); From 964381198707f60ecf2dd38a107cf6ffc928fce1 Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Mon, 11 Jul 2016 15:40:59 -0400 Subject: [PATCH 62/82] Fix bad json --- test/image/mocks/animation.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/image/mocks/animation.json b/test/image/mocks/animation.json index dfd8972d01b..34cb8e737cb 100644 --- a/test/image/mocks/animation.json +++ b/test/image/mocks/animation.json @@ -17,11 +17,11 @@ "autosize": false, "xaxis": { "range": [0, 2], - "domain": [0, 1], + "domain": [0, 1] }, "yaxis": { "range": [0, 10], - "domain": [0, 1], + "domain": [0, 1] } }, "frames": [{ From 7e2e2d82df02c5a4bc267b60ab993ab2000dc6a9 Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Tue, 12 Jul 2016 13:19:06 -0400 Subject: [PATCH 63/82] Fix scatter line issue --- src/traces/scatter/plot.js | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/traces/scatter/plot.js b/src/traces/scatter/plot.js index e7c99ee87db..3578bd53dcf 100644 --- a/src/traces/scatter/plot.js +++ b/src/traces/scatter/plot.js @@ -259,16 +259,16 @@ function plotOne(gd, idx, plotinfo, cdscatter, cdscatterAll, element, transition fullpath += 'Z' + thispath; revpath = thisrevpath + 'Z' + revpath; } - if(subTypes.hasLines(trace) && pts.length > 1) { - var lineJoin = tr.selectAll('.js-line').data([cdscatter]); + } - lineJoin.enter() - .append('path').classed('js-line', true).attr('d', thispath); + if(subTypes.hasLines(trace) && pts.length > 1) { + var lineJoin = tr.selectAll('.js-line').data([cdscatter]); - transition(lineJoin).attr('d', thispath); - } - } + lineJoin.enter() + .append('path').classed('js-line', true).attr('d', fullpath); + transition(lineJoin).attr('d', fullpath); + } //lineJoin.exit().remove(); if(ownFillEl3) { From 2cecc66270a2d4ad1c5ec873c0d500b9bbe14dbc Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Thu, 14 Jul 2016 11:02:54 -0400 Subject: [PATCH 64/82] Expand the trace update to all interdependent traces #717 --- src/plots/cartesian/index.js | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/src/plots/cartesian/index.js b/src/plots/cartesian/index.js index 5f113a12b7a..619e8e455ce 100644 --- a/src/plots/cartesian/index.js +++ b/src/plots/cartesian/index.js @@ -50,6 +50,7 @@ exports.plot = function(gd, traces, transitionOpts) { // Get all calcdata for this subplot: cdSubplot = []; + var pcd; for(j = 0; j < calcdata.length; j++) { cd = calcdata[j]; trace = cd[0].trace; @@ -57,8 +58,25 @@ exports.plot = function(gd, traces, transitionOpts) { // Skip trace if whitelist provided and it's not whitelisted: // if (Array.isArray(traces) && traces.indexOf(i) === -1) continue; - if(trace.xaxis + trace.yaxis === subplot && traces.indexOf(trace.index) !== -1) { - cdSubplot.push(cd); + if(trace.xaxis + trace.yaxis === subplot) { + // Okay, so example: traces 0, 1, and 2 have fill = tonext. You animate + // traces 0 and 2. Trace 1 also needs to be updated, otherwise its fill + // is outdated. So this retroactively adds the previous trace if the + // traces are interdependent. + if (['tonextx', 'tonexty', 'tonext'].indexOf(trace.fill) !== -1) { + if (cdSubplot.indexOf(pcd) === -1) { + cdSubplot.push(pcd); + } + } + + // If this trace is specifically requested, add it to the list: + if (traces.indexOf(trace.index) !== -1) { + cdSubplot.push(cd); + } + + // Track the previous trace on this subplot for the retroactive-add step + // above: + pcd = cd; } } From fd5a3bce6d28a6e8d0d8defe80240346196f95eb Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Thu, 14 Jul 2016 11:04:59 -0400 Subject: [PATCH 65/82] Fix lint issues --- src/plots/cartesian/index.js | 6 +++--- src/traces/scatter/plot.js | 3 ++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/plots/cartesian/index.js b/src/plots/cartesian/index.js index 619e8e455ce..2c5968786f0 100644 --- a/src/plots/cartesian/index.js +++ b/src/plots/cartesian/index.js @@ -63,14 +63,14 @@ exports.plot = function(gd, traces, transitionOpts) { // traces 0 and 2. Trace 1 also needs to be updated, otherwise its fill // is outdated. So this retroactively adds the previous trace if the // traces are interdependent. - if (['tonextx', 'tonexty', 'tonext'].indexOf(trace.fill) !== -1) { - if (cdSubplot.indexOf(pcd) === -1) { + if(['tonextx', 'tonexty', 'tonext'].indexOf(trace.fill) !== -1) { + if(cdSubplot.indexOf(pcd) === -1) { cdSubplot.push(pcd); } } // If this trace is specifically requested, add it to the list: - if (traces.indexOf(trace.index) !== -1) { + if(traces.indexOf(trace.index) !== -1) { cdSubplot.push(cd); } diff --git a/src/traces/scatter/plot.js b/src/traces/scatter/plot.js index 3578bd53dcf..54fb2282fb2 100644 --- a/src/traces/scatter/plot.js +++ b/src/traces/scatter/plot.js @@ -236,6 +236,7 @@ function plotOne(gd, idx, plotinfo, cdscatter, cdscatterAll, element, transition } if(segments.length) { + var pts; var pt0 = segments[0][0], lastSegment = segments[segments.length - 1], pt1 = lastSegment[lastSegment.length - 1]; @@ -244,7 +245,7 @@ function plotOne(gd, idx, plotinfo, cdscatter, cdscatterAll, element, transition //lineJoin.enter().append('path').classed('js-line', true); for(i = 0; i < segments.length; i++) { - var pts = segments[i]; + pts = segments[i]; thispath = pathfn(pts); thisrevpath = revpathfn(pts); if(!fullpath) { From 13053a34e051963d44a3b14a88b9d96bed2a4476 Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Thu, 14 Jul 2016 11:55:44 -0400 Subject: [PATCH 66/82] Reorder path fill drawing --- src/traces/scatter/plot.js | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/traces/scatter/plot.js b/src/traces/scatter/plot.js index 54fb2282fb2..f0c6745aabf 100644 --- a/src/traces/scatter/plot.js +++ b/src/traces/scatter/plot.js @@ -160,12 +160,12 @@ function plotOne(gd, idx, plotinfo, cdscatter, cdscatterAll, element, transition arraysToCalcdata(cdscatter); - var prevpath = ''; + var prevRevpath = ''; var prevPolygons = []; var prevtrace = trace._prevtrace; if(prevtrace) { - prevpath = prevtrace._revpath || ''; + prevRevpath = prevtrace._prevRevpath || ''; tonext = prevtrace._nextFill; prevPolygons = prevtrace._polygons; } @@ -284,21 +284,24 @@ function plotOne(gd, idx, plotinfo, cdscatter, cdscatterAll, element, transition // fill to zero: full trace path, plus extension of // the endpoints to the appropriate axis - transition(ownFillEl3).attr('d', fullpath + 'L' + pt1 + 'L' + pt0 + 'Z'); + // For the sake of animations, wrap the points around so that + // the points on the axes are the first two points. Otherwise + // animations get a little crazy if the number of points changes. + transition(ownFillEl3).attr('d', 'M' + pt1 + 'L' + pt0 + 'L' + fullpath.substr(1)); } else { // fill to self: just join the path to itself transition(ownFillEl3).attr('d', fullpath + 'Z'); } } } - else if(trace.fill.substr(0, 6) === 'tonext' && fullpath && prevpath) { + else if(trace.fill.substr(0, 6) === 'tonext' && fullpath && prevRevpath) { // fill to next: full trace path, plus the previous path reversed if(trace.fill === 'tonext') { // tonext: for use by concentric shapes, like manually constructed // contours, we just add the two paths closed on themselves. // This makes strange results if one path is *not* entirely // inside the other, but then that is a strange usage. - transition(tonext).attr('d', fullpath + 'Z' + prevpath + 'Z'); + transition(tonext).attr('d', fullpath + 'Z' + prevRevpath + 'Z'); } else { // tonextx/y: for now just connect endpoints with lines. This is @@ -306,11 +309,11 @@ function plotOne(gd, idx, plotinfo, cdscatter, cdscatterAll, element, transition // y/x, but if they *aren't*, we should ideally do more complicated // things depending on whether the new endpoint projects onto the // existing curve or off the end of it - transition(tonext).attr('d', fullpath + 'L' + prevpath.substr(1) + 'Z'); + transition(tonext).attr('d', fullpath + 'L' + prevRevpath.substr(1) + 'Z'); } trace._polygons = trace._polygons.concat(prevPolygons); } - trace._revpath = revpath; + trace._prevRevpath = revpath; trace._prevPolygons = thisPolygons; } } From 15b47f623e7e486a9fdd6708a97b23d0b59384e4 Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Thu, 14 Jul 2016 13:12:41 -0400 Subject: [PATCH 67/82] Add config to disable line simplification --- src/traces/scatter/attributes.js | 10 ++++++++++ src/traces/scatter/line_defaults.js | 1 + src/traces/scatter/line_points.js | 5 +++++ src/traces/scatter/plot.js | 3 ++- 4 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/traces/scatter/attributes.js b/src/traces/scatter/attributes.js index 1f23210d81b..2cc6cb00a68 100644 --- a/src/traces/scatter/attributes.js +++ b/src/traces/scatter/attributes.js @@ -156,6 +156,16 @@ module.exports = { 'Sets the style of the lines. Set to a dash string type', 'or a dash length in px.' ].join(' ') + }, + simplify: { + valType: 'boolean', + dflt: true, + role: 'info', + description: [ + 'Simplifies lines by removing nearly-collinear points. When transitioning', + 'lines, it may be desirable to disable this so that the number of points', + 'along the resulting SVG path is unaffected.' + ].join(' ') } }, connectgaps: { diff --git a/src/traces/scatter/line_defaults.js b/src/traces/scatter/line_defaults.js index f5bb0d249fa..e9d88b8274b 100644 --- a/src/traces/scatter/line_defaults.js +++ b/src/traces/scatter/line_defaults.js @@ -28,4 +28,5 @@ module.exports = function lineDefaults(traceIn, traceOut, defaultColor, layout, coerce('line.width'); coerce('line.dash'); + coerce('line.simplify'); }; diff --git a/src/traces/scatter/line_points.js b/src/traces/scatter/line_points.js index 60d7e3c77ea..390242a1fd7 100644 --- a/src/traces/scatter/line_points.js +++ b/src/traces/scatter/line_points.js @@ -15,6 +15,7 @@ var Axes = require('../../plots/cartesian/axes'); module.exports = function linePoints(d, opts) { var xa = opts.xaxis, ya = opts.yaxis, + simplify = opts.simplify, connectGaps = opts.connectGaps, baseTolerance = opts.baseTolerance, linear = opts.linear, @@ -48,6 +49,10 @@ module.exports = function linePoints(d, opts) { clusterMaxDeviation, thisDeviation; + if(!simplify) { + baseTolerance = minTolerance = -1; + } + // turn one calcdata point into pixel coordinates function getPt(index) { var x = xa.c2p(d[index].x), diff --git a/src/traces/scatter/plot.js b/src/traces/scatter/plot.js index f0c6745aabf..b17c710ea36 100644 --- a/src/traces/scatter/plot.js +++ b/src/traces/scatter/plot.js @@ -223,7 +223,8 @@ function plotOne(gd, idx, plotinfo, cdscatter, cdscatterAll, element, transition yaxis: ya, connectGaps: trace.connectgaps, baseTolerance: Math.max(line.width || 1, 3) / 4, - linear: line.shape === 'linear' + linear: line.shape === 'linear', + simplify: line.simplify }); // since we already have the pixel segments here, use them to make From e887faa5da3367ad70c0d4064cba1fcc9654fcd9 Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Mon, 18 Jul 2016 15:52:32 -0400 Subject: [PATCH 68/82] Error bar styling tweaks --- src/components/errorbars/plot.js | 58 ++++++++++++++------------------ src/plot_api/plot_api.js | 11 +++++- 2 files changed, 35 insertions(+), 34 deletions(-) diff --git a/src/components/errorbars/plot.js b/src/components/errorbars/plot.js index f8fb96d9d3b..9ee75f4b7aa 100644 --- a/src/components/errorbars/plot.js +++ b/src/components/errorbars/plot.js @@ -37,7 +37,10 @@ module.exports = function plot(traces, plotinfo, transitionConfig) { var keyFunc; if(trace.identifier) { - keyFunc = function(d) {return d.identifier;}; + keyFunc = function(d) { + console.log('d:', d); + return d.identifier; + }; } var sparse = ( @@ -47,13 +50,9 @@ module.exports = function plot(traces, plotinfo, transitionConfig) { if(!yObj.visible && !xObj.visible) return; - var errorbars = d3.select(this).selectAll('g.errorbar') .data(Lib.identity, keyFunc); - errorbars.enter().append('g') - .classed('errorbar', true); - if(hasAnimation) { errorbars.exit() .style('opacity', 1) @@ -65,6 +64,17 @@ module.exports = function plot(traces, plotinfo, transitionConfig) { errorbars.exit().remove(); } + errorbars.style('opacity', 1); + + var enter = errorbars.enter().append('g') + .classed('errorbar', true); + + if (hasAnimation) { + enter.style('opacity', 0).transition() + .duration(transitionConfig.duration) + .style('opacity', 1); + } + errorbars.each(function(d) { var errorbar = d3.select(this); var coords = errorCoords(d, xa, ya); @@ -92,24 +102,15 @@ module.exports = function plot(traces, plotinfo, transitionConfig) { if(isNew) { yerror = errorbar.append('path') .classed('yerror', true); - - if(hasAnimation) { - yerror = yerror.style('opacity', 0); - } } else if(hasAnimation) { - yerror = yerror.transition() - .duration(transitionConfig.duration) - .ease(transitionConfig.ease) - .delay(transitionConfig.delay); + yerror = yerror + .transition() + .duration(transitionConfig.duration) + .ease(transitionConfig.ease) + .delay(transitionConfig.delay); } yerror.attr('d', path); - - if(isNew && hasAnimation) { - yerror = yerror.transition() - .duration(transitionConfig.duration) - .style('opacity', 1); - } } if(xObj.visible && isNumeric(coords.x) && @@ -130,24 +131,15 @@ module.exports = function plot(traces, plotinfo, transitionConfig) { if(isNew) { xerror = errorbar.append('path') .classed('xerror', true); - - if(hasAnimation) { - xerror = xerror.style('opacity', 0); - } } else if(hasAnimation) { - xerror = xerror.transition() - .duration(transitionConfig.duration) - .ease(transitionConfig.ease) - .delay(transitionConfig.delay); + xerror = xerror + .transition() + .duration(transitionConfig.duration) + .ease(transitionConfig.ease) + .delay(transitionConfig.delay); } xerror.attr('d', path); - - if(isNew && hasAnimation) { - xerror = xerror.transition() - .duration(transitionConfig.duration) - .style('opacity', 1); - } } }); diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index 5e73cf9fa2a..42b2e0702d1 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -2556,7 +2556,16 @@ Plotly.transition = function(gd, data, layout, traceIndices, transitionConfig) { transitionedTraces.push(traceIdx); - Lib.extendDeepNoArrays(gd.data[traceIndices[i]], data[i]); + // This is a multi-step process. First clone w/o arrays so that + // we're not modifying the original: + var update = Lib.extendDeepNoArrays({}, data[i]); + + // Then expand object paths since we don't obey object-overwrite + // semantics here: + update = Lib.expandObjectPaths(update); + + // Finally apply the update (without copying arrays, of course): + Lib.extendDeepNoArrays(gd.data[traceIndices[i]], update); } Plots.supplyDefaults(gd); From f2ffa2a60c0c9ab669437695a767a45d16a4642a Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Mon, 18 Jul 2016 17:13:25 -0400 Subject: [PATCH 69/82] Cut losses; make error bars basically work --- src/components/errorbars/plot.js | 24 ++++++------------------ 1 file changed, 6 insertions(+), 18 deletions(-) diff --git a/src/components/errorbars/plot.js b/src/components/errorbars/plot.js index 9ee75f4b7aa..25d6912c834 100644 --- a/src/components/errorbars/plot.js +++ b/src/components/errorbars/plot.js @@ -37,10 +37,7 @@ module.exports = function plot(traces, plotinfo, transitionConfig) { var keyFunc; if(trace.identifier) { - keyFunc = function(d) { - console.log('d:', d); - return d.identifier; - }; + keyFunc = function(d) {return d.identifier;}; } var sparse = ( @@ -51,18 +48,9 @@ module.exports = function plot(traces, plotinfo, transitionConfig) { if(!yObj.visible && !xObj.visible) return; var errorbars = d3.select(this).selectAll('g.errorbar') - .data(Lib.identity, keyFunc); - - if(hasAnimation) { - errorbars.exit() - .style('opacity', 1) - .transition() - .duration(transitionConfig.duration) - .style('opacity', 0) - .remove(); - } else { - errorbars.exit().remove(); - } + .data(d, keyFunc); + + errorbars.exit().remove(); errorbars.style('opacity', 1); @@ -71,8 +59,8 @@ module.exports = function plot(traces, plotinfo, transitionConfig) { if (hasAnimation) { enter.style('opacity', 0).transition() - .duration(transitionConfig.duration) - .style('opacity', 1); + .duration(transitionConfig.duration) + .style('opacity', 1); } errorbars.each(function(d) { From 2b0c5370620525ccd527be260e6abcba6d133752 Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Tue, 26 Jul 2016 13:40:30 -0400 Subject: [PATCH 70/82] Add 'in' to filter transform --- src/traces/scatter/select.js | 3 ++- test/jasmine/assets/transforms/filter.js | 9 ++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/traces/scatter/select.js b/src/traces/scatter/select.js index fbe0cd63a6f..03f5ed8f39a 100644 --- a/src/traces/scatter/select.js +++ b/src/traces/scatter/select.js @@ -45,7 +45,8 @@ module.exports = function selectPoints(searchInfo, polygon) { curveNumber: curveNumber, pointNumber: i, x: di.x, - y: di.y + y: di.y, + identifier: di.identifier }); di.dim = 0; } diff --git a/test/jasmine/assets/transforms/filter.js b/test/jasmine/assets/transforms/filter.js index 95215fc50cf..9f004c46e61 100644 --- a/test/jasmine/assets/transforms/filter.js +++ b/test/jasmine/assets/transforms/filter.js @@ -16,16 +16,17 @@ exports.name = 'filter'; exports.attributes = { operation: { valType: 'enumerated', - values: ['=', '<', '>'], + values: ['=', '<', '>', 'in'], dflt: '=' }, value: { - valType: 'number', + valType: 'any', + arrayOk: true, dflt: 0 }, filtersrc: { valType: 'enumerated', - values: ['x', 'y'], + values: ['x', 'y', 'identifier'], dflt: 'x' } }; @@ -129,6 +130,8 @@ function getFilterFunc(opts) { return function(v) { return v < value; }; case '>': return function(v) { return v > value; }; + case 'in': + return function(v) { return value.indexOf(v) !== -1 }; } } From fb87b42a630560e9dfb84c0561e72afa179065ef Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Fri, 29 Jul 2016 11:15:03 -0400 Subject: [PATCH 71/82] Clean up scatter trace lines Conflicts: src/traces/scatter/plot.js --- src/plots/cartesian/transition_axes.js | 17 ++++++- src/traces/scatter/plot.js | 64 +++++++++++++++----------- 2 files changed, 52 insertions(+), 29 deletions(-) diff --git a/src/plots/cartesian/transition_axes.js b/src/plots/cartesian/transition_axes.js index 0eec069ac08..96aa8b8eca7 100644 --- a/src/plots/cartesian/transition_axes.js +++ b/src/plots/cartesian/transition_axes.js @@ -148,7 +148,13 @@ module.exports = function transitionAxes(gd, newLayout, transitionConfig) { subplot.plot .call(Lib.setTranslate, plotDx, plotDy) - .call(Lib.setScale, xScaleFactor, yScaleFactor); + .call(Lib.setScale, xScaleFactor, yScaleFactor) + + // This is specifically directed at scatter traces, applying an inverse + // scale to individual points to counteract the scale of the trace + // as a whole: + .selectAll('.points').selectAll('.point') + .call(Lib.setPointGroupScale, 1 / xScaleFactor, 1 / yScaleFactor); } @@ -220,7 +226,14 @@ module.exports = function transitionAxes(gd, newLayout, transitionConfig) { subplot.plot .call(Lib.setTranslate, plotDx, plotDy) - .call(Lib.setScale, xScaleFactor, yScaleFactor); + .call(Lib.setScale, xScaleFactor, yScaleFactor) + + // This is specifically directed at scatter traces, applying an inverse + // scale to individual points to counteract the scale of the trace + // as a whole: + .selectAll('.points').selectAll('.point') + .call(Lib.setPointGroupScale, 1 / xScaleFactor, 1 / yScaleFactor); + } // transitionTail - finish a drag event with a redraw diff --git a/src/traces/scatter/plot.js b/src/traces/scatter/plot.js index b17c710ea36..ae50aefb3c7 100644 --- a/src/traces/scatter/plot.js +++ b/src/traces/scatter/plot.js @@ -103,8 +103,8 @@ function createFills(gd, scatterlayer) { trace._nextFill = null; } - if(trace.fill.substr(0, 6) === 'tozero' || trace.fill === 'toself' || - (trace.fill.substr(0, 2) === 'to' && !trace._prevtrace)) { + if(trace.fill && (trace.fill.substr(0, 6) === 'tozero' || trace.fill === 'toself' || + (trace.fill.substr(0, 2) === 'to' && !trace._prevtrace))) { trace._ownFill = tr.select('.js-fill.js-tozero'); if(!trace._ownFill.size()) { trace._ownFill = tr.insert('path', ':first-child').attr('class', 'js-fill js-tozero'); @@ -237,42 +237,52 @@ function plotOne(gd, idx, plotinfo, cdscatter, cdscatterAll, element, transition } if(segments.length) { - var pts; var pt0 = segments[0][0], lastSegment = segments[segments.length - 1], pt1 = lastSegment[lastSegment.length - 1]; + } - //var lineJoin = tr.selectAll('.js-line').data(segments); - //lineJoin.enter().append('path').classed('js-line', true); + var lineSegments = segments.filter(function (s) { + return s.length > 1; + }); - for(i = 0; i < segments.length; i++) { - pts = segments[i]; - thispath = pathfn(pts); - thisrevpath = revpathfn(pts); - if(!fullpath) { - fullpath = thispath; - revpath = thisrevpath; - } - else if(ownFillDir) { - fullpath += 'L' + thispath.substr(1); - revpath = thisrevpath + ('L' + revpath.substr(1)); - } - else { - fullpath += 'Z' + thispath; - revpath = thisrevpath + 'Z' + revpath; - } + var lineJoin = tr.selectAll('.js-line').data(lineSegments); + + var lineEnter = lineJoin.enter().append('path') + .classed('js-line', true) + .style('vector-effect', 'non-scaling-stroke') + .call(Drawing.lineGroupStyle) + + lineJoin.each(function(pts) { + thispath = pathfn(pts); + thisrevpath = revpathfn(pts); + if(!fullpath) { + fullpath = thispath; + revpath = thisrevpath; + } + else if(ownFillDir) { + fullpath += 'L' + thispath.substr(1); + revpath = thisrevpath + ('L' + revpath.substr(1)); + } + else { + fullpath += 'Z' + thispath; + revpath = thisrevpath + 'Z' + revpath; } if(subTypes.hasLines(trace) && pts.length > 1) { - var lineJoin = tr.selectAll('.js-line').data([cdscatter]); + var el = d3.select(this); + el.datum(cdscatter); + transition(el).attr('d', thispath) + .call(Drawing.lineGroupStyle); + } + }); - lineJoin.enter() - .append('path').classed('js-line', true).attr('d', fullpath); - transition(lineJoin).attr('d', fullpath); - } - //lineJoin.exit().remove(); + transition(lineJoin.exit()) + .style('opacity', 0) + .remove(); + if(segments.length) { if(ownFillEl3) { if(pt0 && pt1) { if(ownFillDir) { From 8cf4395d3ba8eddb40014b2185364ec46ee397d0 Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Tue, 26 Jul 2016 16:24:06 -0400 Subject: [PATCH 72/82] Fix lint errors --- src/traces/scatter/plot.js | 14 ++++++++------ test/jasmine/assets/transforms/filter.js | 2 +- test/jasmine/tests/plots_test.js | 2 +- test/jasmine/tests/transforms_test.js | 14 +++++++------- test/jasmine/tests/validate_test.js | 4 ++-- 5 files changed, 19 insertions(+), 17 deletions(-) diff --git a/src/traces/scatter/plot.js b/src/traces/scatter/plot.js index ae50aefb3c7..07bf5231047 100644 --- a/src/traces/scatter/plot.js +++ b/src/traces/scatter/plot.js @@ -236,22 +236,24 @@ function plotOne(gd, idx, plotinfo, cdscatter, cdscatterAll, element, transition trace._polygons[i] = polygonTester(segments[i]); } + var pt0, lastSegment, pt1; + if(segments.length) { - var pt0 = segments[0][0], - lastSegment = segments[segments.length - 1], - pt1 = lastSegment[lastSegment.length - 1]; + pt0 = segments[0][0]; + lastSegment = segments[segments.length - 1]; + pt1 = lastSegment[lastSegment.length - 1]; } - var lineSegments = segments.filter(function (s) { + var lineSegments = segments.filter(function(s) { return s.length > 1; }); var lineJoin = tr.selectAll('.js-line').data(lineSegments); - var lineEnter = lineJoin.enter().append('path') + lineJoin.enter().append('path') .classed('js-line', true) .style('vector-effect', 'non-scaling-stroke') - .call(Drawing.lineGroupStyle) + .call(Drawing.lineGroupStyle); lineJoin.each(function(pts) { thispath = pathfn(pts); diff --git a/test/jasmine/assets/transforms/filter.js b/test/jasmine/assets/transforms/filter.js index 9f004c46e61..5dedd82a5bf 100644 --- a/test/jasmine/assets/transforms/filter.js +++ b/test/jasmine/assets/transforms/filter.js @@ -131,7 +131,7 @@ function getFilterFunc(opts) { case '>': return function(v) { return v > value; }; case 'in': - return function(v) { return value.indexOf(v) !== -1 }; + return function(v) { return value.indexOf(v) !== -1; }; } } diff --git a/test/jasmine/tests/plots_test.js b/test/jasmine/tests/plots_test.js index aa9f52d228f..cd410a58066 100644 --- a/test/jasmine/tests/plots_test.js +++ b/test/jasmine/tests/plots_test.js @@ -25,7 +25,7 @@ describe('Test Plots', function() { xaxis: { c2p: function() {} }, yaxis: { _m: 20 }, scene: { _scene: {} }, - annotations: [{ _min: 10, }, { _max: 20 }], + annotations: [{ _min: 10 }, { _max: 20 }], someFunc: function() {} }; diff --git a/test/jasmine/tests/transforms_test.js b/test/jasmine/tests/transforms_test.js index d95eb1319bd..5b832a9436c 100644 --- a/test/jasmine/tests/transforms_test.js +++ b/test/jasmine/tests/transforms_test.js @@ -66,7 +66,7 @@ describe('one-to-one transforms:', function() { it('supplyDataDefaults should apply the transform while', function() { var dataIn = [{ x: [-2, -2, 1, 2, 3], - y: [1, 2, 2, 3, 1], + y: [1, 2, 2, 3, 1] }, { x: [-2, -1, -2, 0, 1, 2, 3], y: [1, 2, 3, 1, 2, 3, 1], @@ -288,12 +288,12 @@ describe('one-to-many transforms:', function() { it('supplyDataDefaults should apply the transform while', function() { var dummyTrace0 = { x: [-2, -2, 1, 2, 3], - y: [1, 2, 2, 3, 1], + y: [1, 2, 2, 3, 1] }; var dummyTrace1 = { x: [-1, 2, 3], - y: [2, 3, 1], + y: [2, 3, 1] }; var dataIn = [ @@ -493,12 +493,12 @@ describe('multiple transforms:', function() { it('supplyDataDefaults should apply the transform while', function() { var dummyTrace0 = { x: [-2, -2, 1, 2, 3], - y: [1, 2, 2, 3, 1], + y: [1, 2, 2, 3, 1] }; var dummyTrace1 = { x: [-1, 2, 3], - y: [2, 3, 1], + y: [2, 3, 1] }; var dataIn = [ @@ -718,12 +718,12 @@ describe('multiple traces with transforms:', function() { it('supplyDataDefaults should apply the transform while', function() { var dummyTrace0 = { x: [-2, -2, 1, 2, 3], - y: [1, 2, 2, 3, 1], + y: [1, 2, 2, 3, 1] }; var dummyTrace1 = { x: [-1, 2, 3], - y: [2, 3, 1], + y: [2, 3, 1] }; var dataIn = [ diff --git a/test/jasmine/tests/validate_test.js b/test/jasmine/tests/validate_test.js index 5ce33eb738f..461d87c951b 100644 --- a/test/jasmine/tests/validate_test.js +++ b/test/jasmine/tests/validate_test.js @@ -258,7 +258,7 @@ describe('Plotly.validate', function() { it('should work with attributes in registered transforms', function() { var base = { x: [-2, -1, -2, 0, 1, 2, 3], - y: [1, 2, 3, 1, 2, 3, 1], + y: [1, 2, 3, 1, 2, 3, 1] }; var out = Plotly.validate([ @@ -286,7 +286,7 @@ describe('Plotly.validate', function() { transforms: [{ type: 'no gonna work' }] - }), + }) ], { title: 'my transformed graph' }); From 99ee7d9b7aef9c44651d9c1a8a3603a522056449 Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Tue, 26 Jul 2016 16:49:32 -0400 Subject: [PATCH 73/82] Fix frame API tests and set queueLength as needed --- test/jasmine/tests/frame_api_test.js | 31 ++++++++++++++++------------ 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/test/jasmine/tests/frame_api_test.js b/test/jasmine/tests/frame_api_test.js index f35cc6310a9..b105d63f1fb 100644 --- a/test/jasmine/tests/frame_api_test.js +++ b/test/jasmine/tests/frame_api_test.js @@ -15,10 +15,15 @@ describe('Test frame api', function() { Plotly.plot(gd, mock).then(function() { f = gd._frameData._frames; h = gd._frameData._frameHash; + }).then(function() { + Plotly.setPlotConfig({ queueLength: 10 }); }).then(done); }); - afterEach(destroyGraphDiv); + afterEach(function() { + destroyGraphDiv(); + Plotly.setPlotConfig({queueLength: 0}); + }); describe('gd initialization', function() { it('creates an empty list for frames', function() { @@ -69,17 +74,17 @@ describe('Test frame api', function() { } function validate() { - for(i = 0; i < 10; i++) { - expect(f[i]).toEqual({name: 'frame' + i}); + for(i = 0; i < f.length; i++) { + expect(f[i].name).toEqual('frame' + i); } } Plotly.addFrames(gd, frames).then(validate).then(function() { - return Plotly.addFrames(gd, [{name: 'inserted1'}, {name: 'inserted2'}, {name: 'inserted3'}], [5, 7, undefined]); + return Plotly.addFrames(gd, [{name: 'frame5', x: [1]}, {name: 'frame7', x: [2]}, {name: 'frame10', x: [3]}], [5, 7, undefined]); }).then(function() { - expect(f[5]).toEqual({name: 'inserted1'}); - expect(f[7]).toEqual({name: 'inserted2'}); - expect(f[12]).toEqual({name: 'inserted3'}); + expect(f[5]).toEqual({name: 'frame5', x: [1]}); + expect(f[7]).toEqual({name: 'frame7', x: [2]}); + expect(f[10]).toEqual({name: 'frame10', x: [3]}); return Plotly.Queue.undo(gd); }).then(validate).catch(fail).then(done); @@ -93,17 +98,17 @@ describe('Test frame api', function() { } function validate() { - for(i = 0; i < 10; i++) { - expect(f[i]).toEqual({name: 'frame' + i}); + for(i = 0; i < f.length; i++) { + expect(f[i].name).toEqual('frame' + i); } } Plotly.addFrames(gd, frames).then(validate).then(function() { - return Plotly.addFrames(gd, [{name: 'inserted3'}, {name: 'inserted2'}, {name: 'inserted1'}], [undefined, 7, 5]); + return Plotly.addFrames(gd, [{name: 'frame10', x: [3]}, {name: 'frame7', x: [2]}, {name: 'frame5', x: [1]}], [undefined, 7, 5]); }).then(function() { - expect(f[5]).toEqual({name: 'inserted1'}); - expect(f[7]).toEqual({name: 'inserted2'}); - expect(f[12]).toEqual({name: 'inserted3'}); + expect(f[5]).toEqual({name: 'frame5', x: [1]}); + expect(f[7]).toEqual({name: 'frame7', x: [2]}); + expect(f[10]).toEqual({name: 'frame10', x: [3]}); return Plotly.Queue.undo(gd); }).then(validate).catch(fail).then(done); From bcd2fbfb1f42e02b68a11565b88af6cf8f5fc7f7 Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Wed, 27 Jul 2016 10:31:49 -0400 Subject: [PATCH 74/82] Add missing scattergeo attribute --- src/traces/scattergeo/attributes.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/traces/scattergeo/attributes.js b/src/traces/scattergeo/attributes.js index 7ee7155fc1c..fc6e153856f 100644 --- a/src/traces/scattergeo/attributes.js +++ b/src/traces/scattergeo/attributes.js @@ -59,7 +59,8 @@ module.exports = { line: { color: scatterLineAttrs.color, width: scatterLineAttrs.width, - dash: scatterLineAttrs.dash + dash: scatterLineAttrs.dash, + simplify: scatterLineAttrs.simplify }, marker: extendFlat({}, { symbol: scatterMarkerAttrs.symbol, From 7319c836c69b80fa59ce28b593f4697168ab3659 Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Wed, 27 Jul 2016 10:33:06 -0400 Subject: [PATCH 75/82] Add missing scatterternay attribute --- src/traces/scatterternary/attributes.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/traces/scatterternary/attributes.js b/src/traces/scatterternary/attributes.js index fb28327b348..b782eb3a112 100644 --- a/src/traces/scatterternary/attributes.js +++ b/src/traces/scatterternary/attributes.js @@ -76,6 +76,7 @@ module.exports = { color: scatterLineAttrs.color, width: scatterLineAttrs.width, dash: scatterLineAttrs.dash, + simplify: scatterLineAttrs.simplify, shape: extendFlat({}, scatterLineAttrs.shape, {values: ['linear', 'spline']}), smoothing: scatterLineAttrs.smoothing From 248736ed6e817aba5befd7bd6098ba76207edee8 Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Wed, 27 Jul 2016 10:46:21 -0400 Subject: [PATCH 76/82] Remove animation mock --- test/image/mocks/animation.json | 85 ----------------------------- test/jasmine/tests/animate_test.js | 88 +++++++++++++++++++++++++++++- 2 files changed, 87 insertions(+), 86 deletions(-) delete mode 100644 test/image/mocks/animation.json diff --git a/test/image/mocks/animation.json b/test/image/mocks/animation.json deleted file mode 100644 index 34cb8e737cb..00000000000 --- a/test/image/mocks/animation.json +++ /dev/null @@ -1,85 +0,0 @@ -{ - "data": [ - { - "x": [0, 1, 2], - "y": [0, 2, 8], - "type": "scatter" - }, - { - "x": [0, 1, 2], - "y": [4, 2, 3], - "type": "scatter" - } - ], - "layout": { - "title": "Animation test", - "showlegend": true, - "autosize": false, - "xaxis": { - "range": [0, 2], - "domain": [0, 1] - }, - "yaxis": { - "range": [0, 10], - "domain": [0, 1] - } - }, - "frames": [{ - "name": "base", - "data": [ - {"y": [0, 2, 8]}, - {"y": [4, 2, 3]} - ], - "layout": { - "xaxis": { - "range": [0, 2] - }, - "yaxis": { - "range": [0, 10] - } - } - }, { - "name": "frame0", - "data": [ - {"y": [0.5, 1.5, 7.5]}, - {"y": [4.25, 2.25, 3.05]} - ], - "baseFrame": "base", - "traceIndices": [0, 1], - "layout": { } - }, { - "name": "frame1", - "data": [ - {"y": [2.1, 1, 7]}, - {"y": [4.5, 2.5, 3.1]} - ], - "baseFrame": "base", - "traceIndices": [0, 1], - "layout": { } - }, { - "name": "frame2", - "data": [ - {"y": [3.5, 0.5, 6]}, - {"y": [5.7, 2.7, 3.9]} - ], - "baseFrame": "base", - "traceIndices": [0, 1], - "layout": { } - }, { - "name": "frame3", - "data": [ - {"y": [5.1, 0.25, 5]}, - {"y": [7, 2.9, 6]} - ], - "baseFrame": "base", - "traceIndices": [0, 1], - "layout": { - "xaxis": { - "range": [-1, 4] - }, - "yaxis": { - "range": [-5, 15] - } - } - }] -} diff --git a/test/jasmine/tests/animate_test.js b/test/jasmine/tests/animate_test.js index ad0fca74ece..feb8784a588 100644 --- a/test/jasmine/tests/animate_test.js +++ b/test/jasmine/tests/animate_test.js @@ -6,6 +6,92 @@ var createGraphDiv = require('../assets/create_graph_div'); var destroyGraphDiv = require('../assets/destroy_graph_div'); var fail = require('../assets/fail_test'); +var mock = { + "data": [ + { + "x": [0, 1, 2], + "y": [0, 2, 8], + "type": "scatter" + }, + { + "x": [0, 1, 2], + "y": [4, 2, 3], + "type": "scatter" + } + ], + "layout": { + "title": "Animation test", + "showlegend": true, + "autosize": false, + "xaxis": { + "range": [0, 2], + "domain": [0, 1] + }, + "yaxis": { + "range": [0, 10], + "domain": [0, 1] + } + }, + "frames": [{ + "name": "base", + "data": [ + {"y": [0, 2, 8]}, + {"y": [4, 2, 3]} + ], + "layout": { + "xaxis": { + "range": [0, 2] + }, + "yaxis": { + "range": [0, 10] + } + } + }, { + "name": "frame0", + "data": [ + {"y": [0.5, 1.5, 7.5]}, + {"y": [4.25, 2.25, 3.05]} + ], + "baseFrame": "base", + "traceIndices": [0, 1], + "layout": { } + }, { + "name": "frame1", + "data": [ + {"y": [2.1, 1, 7]}, + {"y": [4.5, 2.5, 3.1]} + ], + "baseFrame": "base", + "traceIndices": [0, 1], + "layout": { } + }, { + "name": "frame2", + "data": [ + {"y": [3.5, 0.5, 6]}, + {"y": [5.7, 2.7, 3.9]} + ], + "baseFrame": "base", + "traceIndices": [0, 1], + "layout": { } + }, { + "name": "frame3", + "data": [ + {"y": [5.1, 0.25, 5]}, + {"y": [7, 2.9, 6]} + ], + "baseFrame": "base", + "traceIndices": [0, 1], + "layout": { + "xaxis": { + "range": [-1, 4] + }, + "yaxis": { + "range": [-5, 15] + } + } + }] +}; + describe('Test animate API', function() { 'use strict'; @@ -14,7 +100,7 @@ describe('Test animate API', function() { beforeEach(function(done) { gd = createGraphDiv(); - var mock = require('@mocks/animation'); + //var mock = require('@mocks/animation'); var mockCopy = Lib.extendDeep({}, mock); spyOn(PlotlyInternal, 'transition').and.callFake(function() { From 6afc3391f4c21ca5efa542b713f0daaede081dbb Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Wed, 27 Jul 2016 11:34:42 -0400 Subject: [PATCH 77/82] Catch degenerate trace-fill-linking case --- src/plots/cartesian/index.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/plots/cartesian/index.js b/src/plots/cartesian/index.js index 2c5968786f0..63aa425bf1a 100644 --- a/src/plots/cartesian/index.js +++ b/src/plots/cartesian/index.js @@ -63,10 +63,10 @@ exports.plot = function(gd, traces, transitionOpts) { // traces 0 and 2. Trace 1 also needs to be updated, otherwise its fill // is outdated. So this retroactively adds the previous trace if the // traces are interdependent. - if(['tonextx', 'tonexty', 'tonext'].indexOf(trace.fill) !== -1) { - if(cdSubplot.indexOf(pcd) === -1) { - cdSubplot.push(pcd); - } + if(pcd && + ['tonextx', 'tonexty', 'tonext'].indexOf(trace.fill) !== -1 && + cdSubplot.indexOf(pcd) === -1) { + cdSubplot.push(pcd); } // If this trace is specifically requested, add it to the list: From 6e426e7dd7210dd220e8c1cdb92f1f99835f24ec Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Mon, 1 Aug 2016 09:32:13 -0400 Subject: [PATCH 78/82] Clean up scatter trace line enter/exit --- src/traces/scatter/plot.js | 70 +++++++++++++++++++++++--------------- 1 file changed, 42 insertions(+), 28 deletions(-) diff --git a/src/traces/scatter/plot.js b/src/traces/scatter/plot.js index 07bf5231047..f7dd6fb77af 100644 --- a/src/traces/scatter/plot.js +++ b/src/traces/scatter/plot.js @@ -248,42 +248,56 @@ function plotOne(gd, idx, plotinfo, cdscatter, cdscatterAll, element, transition return s.length > 1; }); - var lineJoin = tr.selectAll('.js-line').data(lineSegments); + function makeUpdate (isEnter) { + return function(pts) { + thispath = pathfn(pts); + thisrevpath = revpathfn(pts); + if(!fullpath) { + fullpath = thispath; + revpath = thisrevpath; + } + else if(ownFillDir) { + fullpath += 'L' + thispath.substr(1); + revpath = thisrevpath + ('L' + revpath.substr(1)); + } + else { + fullpath += 'Z' + thispath; + revpath = thisrevpath + 'Z' + revpath; + } - lineJoin.enter().append('path') - .classed('js-line', true) - .style('vector-effect', 'non-scaling-stroke') - .call(Drawing.lineGroupStyle); - - lineJoin.each(function(pts) { - thispath = pathfn(pts); - thisrevpath = revpathfn(pts); - if(!fullpath) { - fullpath = thispath; - revpath = thisrevpath; - } - else if(ownFillDir) { - fullpath += 'L' + thispath.substr(1); - revpath = thisrevpath + ('L' + revpath.substr(1)); - } - else { - fullpath += 'Z' + thispath; - revpath = thisrevpath + 'Z' + revpath; - } + if(subTypes.hasLines(trace) && pts.length > 1) { + var el = d3.select(this); - if(subTypes.hasLines(trace) && pts.length > 1) { - var el = d3.select(this); - el.datum(cdscatter); - transition(el).attr('d', thispath) - .call(Drawing.lineGroupStyle); - } - }); + // This makes the coloring work correctly: + el.datum(cdscatter); + if (isEnter) { + transition(el.style('opacity', 0) + .attr('d', thispath) + .call(Drawing.lineGroupStyle)) + .style('opacity', 1); + } else { + transition(el).attr('d', thispath) + .call(Drawing.lineGroupStyle); + } + } + }; + } + + var lineJoin = tr.selectAll('.js-line').data(lineSegments); transition(lineJoin.exit()) .style('opacity', 0) .remove(); + lineJoin.each(makeUpdate(false)); + + var lineEnter = lineJoin.enter().append('path') + .classed('js-line', true) + .style('vector-effect', 'non-scaling-stroke') + .call(Drawing.lineGroupStyle) + .each(makeUpdate(true)) + if(segments.length) { if(ownFillEl3) { if(pt0 && pt1) { From 0180730bea2a0fa99b9b8383bc5a71c4aa2571eb Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Mon, 1 Aug 2016 11:09:20 -0400 Subject: [PATCH 79/82] Add dummy line attrs to scattergl/scatter3d --- src/traces/scatter3d/attributes.js | 1 + src/traces/scattergl/attributes.js | 1 + 2 files changed, 2 insertions(+) diff --git a/src/traces/scatter3d/attributes.js b/src/traces/scatter3d/attributes.js index 06b1cecc708..9a898a95b58 100644 --- a/src/traces/scatter3d/attributes.js +++ b/src/traces/scatter3d/attributes.js @@ -100,6 +100,7 @@ module.exports = { line: extendFlat({}, { width: scatterLineAttrs.width, dash: scatterLineAttrs.dash, + simplify: scatterLineAttrs.simplify, showscale: { valType: 'boolean', role: 'info', diff --git a/src/traces/scattergl/attributes.js b/src/traces/scattergl/attributes.js index 50a123dd66e..7e9311d8379 100644 --- a/src/traces/scattergl/attributes.js +++ b/src/traces/scattergl/attributes.js @@ -48,6 +48,7 @@ module.exports = { line: { color: scatterLineAttrs.color, width: scatterLineAttrs.width, + simplify: scatterLineAttrs.simplify, dash: { valType: 'enumerated', values: Object.keys(DASHES), From 3ee4bf512d847494ddffd9752755f0c5272b0413 Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Mon, 1 Aug 2016 11:15:44 -0400 Subject: [PATCH 80/82] Restore missing function --- src/traces/scatter/plot.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/traces/scatter/plot.js b/src/traces/scatter/plot.js index a58b0ec0d9c..f7dd6fb77af 100644 --- a/src/traces/scatter/plot.js +++ b/src/traces/scatter/plot.js @@ -290,6 +290,14 @@ function plotOne(gd, idx, plotinfo, cdscatter, cdscatterAll, element, transition .style('opacity', 0) .remove(); + lineJoin.each(makeUpdate(false)); + + var lineEnter = lineJoin.enter().append('path') + .classed('js-line', true) + .style('vector-effect', 'non-scaling-stroke') + .call(Drawing.lineGroupStyle) + .each(makeUpdate(true)) + if(segments.length) { if(ownFillEl3) { if(pt0 && pt1) { From 085203df7609f15a502912d8bffb5331da4984c7 Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Mon, 1 Aug 2016 11:35:42 -0400 Subject: [PATCH 81/82] Fix lint issues --- src/traces/scatter/plot.js | 8 +- test/jasmine/tests/animate_test.js | 118 ++++++++++++++--------------- 2 files changed, 63 insertions(+), 63 deletions(-) diff --git a/src/traces/scatter/plot.js b/src/traces/scatter/plot.js index f7dd6fb77af..7411ebd3a66 100644 --- a/src/traces/scatter/plot.js +++ b/src/traces/scatter/plot.js @@ -248,7 +248,7 @@ function plotOne(gd, idx, plotinfo, cdscatter, cdscatterAll, element, transition return s.length > 1; }); - function makeUpdate (isEnter) { + var makeUpdate = function (isEnter) { return function(pts) { thispath = pathfn(pts); thisrevpath = revpathfn(pts); @@ -271,7 +271,7 @@ function plotOne(gd, idx, plotinfo, cdscatter, cdscatterAll, element, transition // This makes the coloring work correctly: el.datum(cdscatter); - if (isEnter) { + if(isEnter) { transition(el.style('opacity', 0) .attr('d', thispath) .call(Drawing.lineGroupStyle)) @@ -292,11 +292,11 @@ function plotOne(gd, idx, plotinfo, cdscatter, cdscatterAll, element, transition lineJoin.each(makeUpdate(false)); - var lineEnter = lineJoin.enter().append('path') + lineJoin.enter().append('path') .classed('js-line', true) .style('vector-effect', 'non-scaling-stroke') .call(Drawing.lineGroupStyle) - .each(makeUpdate(true)) + .each(makeUpdate(true)); if(segments.length) { if(ownFillEl3) { diff --git a/test/jasmine/tests/animate_test.js b/test/jasmine/tests/animate_test.js index feb8784a588..e8b57719f43 100644 --- a/test/jasmine/tests/animate_test.js +++ b/test/jasmine/tests/animate_test.js @@ -7,86 +7,86 @@ var destroyGraphDiv = require('../assets/destroy_graph_div'); var fail = require('../assets/fail_test'); var mock = { - "data": [ + 'data': [ { - "x": [0, 1, 2], - "y": [0, 2, 8], - "type": "scatter" + 'x': [0, 1, 2], + 'y': [0, 2, 8], + 'type': 'scatter' }, { - "x": [0, 1, 2], - "y": [4, 2, 3], - "type": "scatter" + 'x': [0, 1, 2], + 'y': [4, 2, 3], + 'type': 'scatter' } ], - "layout": { - "title": "Animation test", - "showlegend": true, - "autosize": false, - "xaxis": { - "range": [0, 2], - "domain": [0, 1] + 'layout': { + 'title': 'Animation test', + 'showlegend': true, + 'autosize': false, + 'xaxis': { + 'range': [0, 2], + 'domain': [0, 1] }, - "yaxis": { - "range": [0, 10], - "domain": [0, 1] + 'yaxis': { + 'range': [0, 10], + 'domain': [0, 1] } }, - "frames": [{ - "name": "base", - "data": [ - {"y": [0, 2, 8]}, - {"y": [4, 2, 3]} + 'frames': [{ + 'name': 'base', + 'data': [ + {'y': [0, 2, 8]}, + {'y': [4, 2, 3]} ], - "layout": { - "xaxis": { - "range": [0, 2] + 'layout': { + 'xaxis': { + 'range': [0, 2] }, - "yaxis": { - "range": [0, 10] + 'yaxis': { + 'range': [0, 10] } } }, { - "name": "frame0", - "data": [ - {"y": [0.5, 1.5, 7.5]}, - {"y": [4.25, 2.25, 3.05]} + 'name': 'frame0', + 'data': [ + {'y': [0.5, 1.5, 7.5]}, + {'y': [4.25, 2.25, 3.05]} ], - "baseFrame": "base", - "traceIndices": [0, 1], - "layout": { } + 'baseFrame': 'base', + 'traceIndices': [0, 1], + 'layout': { } }, { - "name": "frame1", - "data": [ - {"y": [2.1, 1, 7]}, - {"y": [4.5, 2.5, 3.1]} + 'name': 'frame1', + 'data': [ + {'y': [2.1, 1, 7]}, + {'y': [4.5, 2.5, 3.1]} ], - "baseFrame": "base", - "traceIndices": [0, 1], - "layout": { } + 'baseFrame': 'base', + 'traceIndices': [0, 1], + 'layout': { } }, { - "name": "frame2", - "data": [ - {"y": [3.5, 0.5, 6]}, - {"y": [5.7, 2.7, 3.9]} + 'name': 'frame2', + 'data': [ + {'y': [3.5, 0.5, 6]}, + {'y': [5.7, 2.7, 3.9]} ], - "baseFrame": "base", - "traceIndices": [0, 1], - "layout": { } + 'baseFrame': 'base', + 'traceIndices': [0, 1], + 'layout': { } }, { - "name": "frame3", - "data": [ - {"y": [5.1, 0.25, 5]}, - {"y": [7, 2.9, 6]} + 'name': 'frame3', + 'data': [ + {'y': [5.1, 0.25, 5]}, + {'y': [7, 2.9, 6]} ], - "baseFrame": "base", - "traceIndices": [0, 1], - "layout": { - "xaxis": { - "range": [-1, 4] + 'baseFrame': 'base', + 'traceIndices': [0, 1], + 'layout': { + 'xaxis': { + 'range': [-1, 4] }, - "yaxis": { - "range": [-5, 15] + 'yaxis': { + 'range': [-5, 15] } } }] From 266f21a8ad45c32b1d5e9fa4d4ceccf1a6eceea0 Mon Sep 17 00:00:00 2001 From: Ricky Reusser Date: Mon, 1 Aug 2016 11:43:14 -0400 Subject: [PATCH 82/82] Fix lint error for the last time --- src/traces/scatter/plot.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/traces/scatter/plot.js b/src/traces/scatter/plot.js index 7411ebd3a66..23f9124e924 100644 --- a/src/traces/scatter/plot.js +++ b/src/traces/scatter/plot.js @@ -248,7 +248,7 @@ function plotOne(gd, idx, plotinfo, cdscatter, cdscatterAll, element, transition return s.length > 1; }); - var makeUpdate = function (isEnter) { + var makeUpdate = function(isEnter) { return function(pts) { thispath = pathfn(pts); thisrevpath = revpathfn(pts); @@ -282,7 +282,7 @@ function plotOne(gd, idx, plotinfo, cdscatter, cdscatterAll, element, transition } } }; - } + }; var lineJoin = tr.selectAll('.js-line').data(lineSegments);