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/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/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/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/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 3f44ed58df1..5f074b63bc2 100644
--- a/src/components/errorbars/plot.js
+++ b/src/components/errorbars/plot.js
@@ -12,14 +12,18 @@
var d3 = require('d3');
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,6 +33,12 @@ 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
@@ -37,11 +47,21 @@ module.exports = function plot(traces, plotinfo) {
if(!yObj.visible && !xObj.visible) return;
var errorbars = d3.select(this).selectAll('g.errorbar')
- .data(Lib.identity);
+ .data(d, keyFunc);
- errorbars.enter().append('g')
+ 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);
@@ -59,14 +79,28 @@ 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);
+ } else if(hasAnimation) {
+ yerror = yerror
+ .transition()
+ .duration(transitionConfig.duration)
+ .ease(transitionConfig.ease)
+ .delay(transitionConfig.delay);
+ }
+
+ yerror.attr('d', path);
}
- 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;
@@ -77,11 +111,26 @@ 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);
+ } else if(hasAnimation) {
+ xerror = xerror
+ .transition()
+ .duration(transitionConfig.duration)
+ .ease(transitionConfig.ease)
+ .delay(transitionConfig.delay);
+ }
+
+ xerror.attr('d', path);
}
});
+
+ d3.select(this).call(styleError);
});
};
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/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/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/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/core.js b/src/core.js
index 795a2b6367c..f222cd9c4d3 100644
--- a/src/core.js
+++ b/src/core.js
@@ -28,6 +28,10 @@ exports.prependTraces = Plotly.prependTraces;
exports.addTraces = Plotly.addTraces;
exports.deleteTraces = Plotly.deleteTraces;
exports.moveTraces = Plotly.moveTraces;
+exports.addFrames = Plotly.addFrames;
+exports.deleteFrames = Plotly.deleteFrames;
+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/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 9f3686462e4..d8b2d7efaae 100644
--- a/src/lib/index.js
+++ b/src/lib/index.js
@@ -58,12 +58,16 @@ 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;
lib.warn = loggersModule.warn;
lib.error = loggersModule.error;
+var cssModule = require('./plotcss_utils');
+lib.injectStyles = cssModule.injectStyles;
+
lib.notifier = require('./notifier');
/**
@@ -388,30 +392,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].*/,
@@ -571,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.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?
+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.
@@ -615,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/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
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/lib/plotcss_utils.js b/src/lib/plotcss_utils.js
new file mode 100644
index 00000000000..58f7383e8f6
--- /dev/null
+++ b/src/lib/plotcss_utils.js
@@ -0,0 +1,81 @@
+/**
+* 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 lib = require('./index');
+var plotcss = require('../../build/plotcss');
+
+// Inject styling information into the document containing the graph div
+exports.injectStyles = function injectStyles(gd) {
+ // If the graph div has already been styled, bail
+ if(gd._plotCSSLoaded) return;
+
+ var targetSelectors = exports.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 = exports.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;
+};
+
+// 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];
+
+ 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];
+
+ allSelectors.push(cssRule.selectorText);
+ }
+ }
+
+ return allSelectors;
+};
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 + '"' : '') + '>';
diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js
index 9dc8c7b1e57..0b5f11ecfb3 100644
--- a/src/plot_api/plot_api.js
+++ b/src/plot_api/plot_api.js
@@ -54,6 +54,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
+ Lib.injectStyles(gd);
+
// Events.init is idempotent and bails early if gd has already been init'd
Events.init(gd);
@@ -326,10 +334,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 +359,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);
@@ -847,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
@@ -877,9 +891,22 @@ 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;
+ }
+
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);
@@ -1536,6 +1563,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 +2104,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);
@@ -2479,6 +2508,314 @@ 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, traceIndices, transitionConfig) {
+ gd = getGraphDiv(gd);
+
+ var i, traceIdx;
+ var fullLayout = gd._fullLayout;
+
+ transitionConfig = Lib.extendFlat({
+ ease: 'cubic-in-out',
+ duration: 500,
+ delay: 0,
+ cascade: 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;
+ }
+
+ 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;});
+ }
+
+ if(traceIndices.length > dataLength) {
+ traceIndices = traceIndices.slice(0, dataLength);
+ }
+
+ var transitionedTraces = [];
+
+ function prepareTransitions() {
+ for(i = 0; i < traceIndices.length; i++) {
+ var traceIdx = traceIndices[i];
+ var trace = gd._fullData[traceIdx];
+ var module = trace._module;
+
+ if(!module.animatable) {
+ continue;
+ }
+
+ transitionedTraces.push(traceIdx);
+
+ // 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);
+
+ // TODO: Add logic that computes transitionedTraces to avoid unnecessary work while
+ // still handling things like box plots that are interrelated.
+ // doCalcdata(gd, transitionedTraces);
+
+ doCalcdata(gd);
+
+ ErrorBars.calc(gd);
+ }
+
+ var restyleList = [];
+ var completionTimeout = null;
+ var resolveTransitionCallback = null;
+
+ function executeTransitions() {
+ var hasTraceTransition = false;
+ var j;
+ var basePlotModules = fullLayout._basePlotModules;
+ for(j = 0; j < basePlotModules.length; j++) {
+ if(basePlotModules[j].animatable) {
+ hasTraceTransition = true;
+ }
+ basePlotModules[j].plot(gd, transitionedTraces, transitionConfig);
+ }
+
+ var hasAxisTransition = false;
+
+ if(layout) {
+ for(j = 0; j < basePlotModules.length; j++) {
+ if(basePlotModules[j].transitionAxes) {
+ var newLayout = Lib.expandObjectPaths(layout);
+ hasAxisTransition = hasAxisTransition || basePlotModules[j].transitionAxes(gd, newLayout, transitionConfig);
+ }
+ }
+ }
+
+ if(!hasAxisTransition && !hasTraceTransition) {
+ return false;
+ }
+
+ return new Promise(function(resolve) {
+ resolveTransitionCallback = resolve;
+ completionTimeout = setTimeout(resolve, transitionConfig.duration);
+ });
+ }
+
+ function interruptPreviousTransitions() {
+ clearTimeout(completionTimeout);
+
+ if(resolveTransitionCallback) {
+ resolveTransitionCallback();
+ }
+
+ while(gd._frameData._layoutInterrupts.length) {
+ (gd._frameData._layoutInterrupts.pop())();
+ }
+
+ while(gd._frameData._styleInterrupts.length) {
+ (gd._frameData._styleInterrupts.pop())();
+ }
+ }
+
+ for(i = 0; i < traceIndices.length; i++) {
+ traceIdx = traceIndices[i];
+ var contFull = gd._fullData[traceIdx];
+ var module = contFull._module;
+
+ if(!module.animatable) {
+ 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;
+ });
+};
+
+/**
+ * Animate to a keyframe
+ *
+ * @param {string} name
+ * name of the keyframe to create
+ * @param {object} transitionConfig
+ * configuration for transition
+ */
+Plotly.animate = function(gd, frameName, transitionConfig) {
+ gd = getGraphDiv(gd);
+
+ if(!gd._frameData._frameHash[frameName]) {
+ Lib.warn('animateToFrame failure: keyframe does not exist', frameName);
+ return Promise.reject();
+ }
+
+ var computedFrame = Plots.computeFrame(gd, frameName);
+
+ return Plotly.transition(gd,
+ computedFrame.data,
+ computedFrame.layout,
+ computedFrame.traceIndices,
+ transitionConfig
+ );
+};
+
+/**
+ * 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] !== null) ? 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 = insertions[i].frame;
+
+ if(!frame.name) {
+ // 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]) {
+ // 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.max(0, Math.min(insertions[i].index, frameCount));
+
+ ops.push({type: 'insert', index: idx, value: frame});
+ revops.unshift({type: 'delete', index: idx});
+ frameCount++;
+ }
+ }
+
+ var undoFunc = Plots.modifyFrames,
+ redoFunc = Plots.modifyFrames,
+ undoArgs = [gd, revops],
+ redoArgs = [gd, ops];
+
+ if(Queue) Queue.add(gd, undoFunc, undoArgs, redoFunc, redoArgs);
+
+ return Plots.modifyFrames(gd, ops);
+};
+
+/**
+ * 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.slice(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]});
+ }
+
+ var undoFunc = Plots.modifyFrames,
+ redoFunc = Plots.modifyFrames,
+ undoArgs = [gd, revops],
+ redoArgs = [gd, ops];
+
+ if(Queue) Queue.add(gd, undoFunc, undoArgs, redoFunc, redoArgs);
+
+ return Plots.modifyFrames(gd, ops);
+};
+
/**
* Purge a graph container div back to its initial pre-Plotly.plot state
*
@@ -2547,12 +2884,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),
@@ -2569,7 +2906,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 32f552a45a4..3f405bb39e0 100644
--- a/src/plotly.js
+++ b/src/plotly.js
@@ -26,9 +26,6 @@ var Lib = 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 5b18574482f..81801d93d36 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/index.js b/src/plots/cartesian/index.js
index 56f827f0ec6..63aa425bf1a 100644
--- a/src/plots/cartesian/index.js
+++ b/src/plots/cartesian/index.js
@@ -25,61 +25,89 @@ exports.attrRegex = constants.attrRegex;
exports.attributes = require('./attributes');
-exports.plot = function(gd) {
+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'),
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
+ traces = [];
+ for(i = 0; i < calcdata.length; i++) {
+ traces.push(i);
}
-
- return cdSubplot;
}
- 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 = [];
+ var pcd;
+ 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) {
+ // 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(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:
+ if(traces.indexOf(trace.index) !== -1) {
+ cdSubplot.push(cd);
+ }
+
+ // Track the previous trace on this subplot for the retroactive-add step
+ // above:
+ pcd = 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, transitionOpts);
}
}
};
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/cartesian/transition_axes.js b/src/plots/cartesian/transition_axes.js
new file mode 100644
index 00000000000..96aa8b8eca7
--- /dev/null
+++ b/src/plots/cartesian/transition_axes.js
@@ -0,0 +1,295 @@
+/**
+* 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 Plotly = require('../../plotly');
+var Lib = require('../../lib');
+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, axis, update;
+ var updates = {};
+
+ 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])) {
+ 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, updates) {
+ var plotName;
+ var plotinfos = fullLayout._plots;
+ var affectedSubplots = [];
+ var toX, toY;
+
+ for(plotName in plotinfos) {
+ var plotinfo = plotinfos[plotName];
+
+ if(affectedSubplots.indexOf(plotinfo) !== -1) continue;
+
+ var x = plotinfo.xaxis._id;
+ var y = plotinfo.yaxis._id;
+ var fromX = plotinfo.xaxis.range;
+ var fromY = plotinfo.yaxis.range;
+ 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(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, updates);
+
+ if(!affectedSubplots.length) {
+ return false;
+ }
+
+ 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)
+
+ // 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);
+
+ }
+
+ 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)
+
+ // 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
+ 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(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(i = 0; i < affectedSubplots.length; i++) {
+ unsetSubplotTransform(affectedSubplots[i]);
+ }
+
+ Plotly.relayout(gd, attrs);
+ }
+
+ var easeFn = d3.ease(transitionConfig.ease);
+
+ return new Promise(function(resolve, reject) {
+ var t1, t2, raf;
+
+ gd._frameData._layoutInterrupts.push(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/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/plots/plots.js b/src/plots/plots.js
index 2e0a4d28e85..0964303cf49 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 = {},
@@ -508,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
@@ -526,6 +539,31 @@ 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;
+ }
+
+ if(!gd._frameData._layoutInterrupts) {
+ gd._frameData._layoutInterrupts = [];
+ }
+
+ if(!gd._frameData._styleInterrupts) {
+ gd._frameData._styleInterrupts = [];
+ }
};
// helper function to be bound to fullLayout to check
@@ -568,12 +606,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();
}
@@ -891,6 +934,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;
@@ -920,6 +967,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();
@@ -1184,3 +1232,68 @@ 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, frame;
+ var _frames = gd._frameData._frames;
+ var _hash = gd._frameData._frameHash;
+
+ for(i = 0; i < operations.length; i++) {
+ op = operations[i];
+
+ switch(op.type) {
+ // 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;
+ frame.name = op.name;
+ break;*/
+ case 'replace':
+ frame = op.value;
+ 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;
+ _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();
+};
+
+/*
+ * Compute a keyframe. Merge a keyframe into its base frame(s) and
+ * expand properties.
+ *
+ * @param {string} frame
+ * The name of the keyframe to be computed
+ *
+ * Returns: a new object with the merged content
+ */
+plots.computeFrame = function(gd, frameName) {
+ return Lib.computeFrame(gd._frameData._frameHash, frameName);
+};
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/src/traces/scatter/attributes.js b/src/traces/scatter/attributes.js
index 28db0d1343e..2cc6cb00a68 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',
@@ -152,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/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/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/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/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..23f9124e924 100644
--- a/src/traces/scatter/plot.js
+++ b/src/traces/scatter/plot.js
@@ -15,80 +15,178 @@ 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, transitionConfig) {
+ 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();
+ // If transition config is provided, then it is only a partial replot and traces not
+ // updated are removed.
+ var isFullReplot = !transitionConfig;
- // 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);
+ selection = scatterlayer.selectAll('g.trace');
+
+ 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;
-
- ownFillDir = trace.fill.charAt(trace.fill.length - 1);
- if(ownFillDir !== 'x' && ownFillDir !== 'y') ownFillDir = '';
-
- d[0].node3 = tr; // store node for tweaking by selectPoints
-
- arraysToCalcdata(d);
-
- if(!subTypes.hasLines(trace) && trace.fill === 'none') return;
-
- 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;
-
- // 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;
+ // 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;
+ }
+
+ 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;
+ });
+
+ // 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, transitionConfig);
+ });
+
+ if(isFullReplot) {
+ join.exit().remove();
+ }
+
+ // remove paths that didn't get used
+ scatterlayer.selectAll('path:not([d])').remove();
+};
+
+function createFills(gd, scatterlayer) {
+ var trace;
+
+ scatterlayer.selectAll('g.trace').each(function(d) {
+ var tr = d3.select(this);
+
+ // 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 += ' + *';
+ }
+
+ 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 && (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, transitionConfig) {
+ 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 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;
+ }
+ }
- // now make a new nexttonext for next time
- nexttonext = tr.append('path').classed('js-fill', true);
+ 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, transitionConfig);
+
+ 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 prevRevpath = '';
+ var prevPolygons = [];
+ var prevtrace = trace._prevtrace;
+
+ if(prevtrace) {
+ prevRevpath = prevtrace._prevRevpath || '';
+ 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,32 +218,38 @@ 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,
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
// 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]);
}
+ 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];
+ }
- for(i = 0; i < segments.length; i++) {
- var pts = segments[i];
+ var lineSegments = segments.filter(function(s) {
+ return s.length > 1;
+ });
+
+ var makeUpdate = function(isEnter) {
+ return function(pts) {
thispath = pathfn(pts);
thisrevpath = revpathfn(pts);
if(!fullpath) {
@@ -160,13 +264,41 @@ module.exports = function plot(gd, plotinfo, cdscatter) {
fullpath += 'Z' + thispath;
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);
+ var el = d3.select(this);
+
+ // 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));
+
+ 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) {
if(ownFillDir) {
@@ -179,20 +311,24 @@ 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');
+ // 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');
}
- // 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) {
+ 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.
- 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
@@ -200,92 +336,137 @@ module.exports = function plot(gd, plotinfo, cdscatter) {
// 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' + prevRevpath.substr(1) + 'Z');
}
trace._polygons = trace._polygons.concat(prevPolygons);
}
- prevpath = revpath;
- prevPolygons = thisPolygons;
+ trace._prevRevpath = 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.identifier;
+ }
+
+ // Returns a function if the trace is keyed, otherwise returns undefined
+ function getKeyFunc(trace) {
+ if(trace.identifier) {
+ 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, trace, transitionConfig, 1);
+
+ join.transition()
+ .call(Drawing.translatePoints, xa, ya, trace, transitionConfig, 0)
+ .call(Drawing.pointStyle, trace);
+
+ if(hasTransition) {
+ join.exit()
+ .call(Drawing.translatePoints, xa, ya, trace, transitionConfig, -1);
+ } else {
+ join.exit().remove();
}
}
- });
-};
+ if(showText) {
+ selection = s.selectAll('g');
-function selectMarkers(gd, plotinfo, cdscatter) {
+ 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();
+ }
+ }
+ }
+
+ // 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/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/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);
}
};
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/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,
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),
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
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/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
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/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/assets/transforms/filter.js b/test/jasmine/assets/transforms/filter.js
index 95215fc50cf..5dedd82a5bf 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; };
}
}
diff --git a/test/jasmine/tests/animate_test.js b/test/jasmine/tests/animate_test.js
new file mode 100644
index 00000000000..e8b57719f43
--- /dev/null
+++ b/test/jasmine/tests/animate_test.js
@@ -0,0 +1,145 @@
+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');
+
+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';
+
+ 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({
+ xaxis: {range: [0, 2]},
+ yaxis: {range: [0, 10]}
+ });
+
+ // traces are [0, 1]:
+ expect(args[3]).toEqual([0, 1]);
+ }).catch(fail).then(done);
+ });
+});
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() {
diff --git a/test/jasmine/tests/compute_frame_test.js b/test/jasmine/tests/compute_frame_test.js
new file mode 100644
index 00000000000..e2d6b026c96
--- /dev/null
+++ b/test/jasmine/tests/compute_frame_test.js
@@ -0,0 +1,236 @@
+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';
+
+ 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, input;
+
+ beforeEach(function(done) {
+ frame1 = {
+ name: 'frame1',
+ data: [{
+ x: [1, 2, 3],
+ 'marker.size': 8,
+ marker: {color: 'red'}
+ }]
+ };
+
+ input = clone(frame1);
+ Plotly.addFrames(gd, [input]).then(done);
+ });
+
+ it('returns false if the frame does not exist', function() {
+ expect(computeFrame(gd, 'frame8')).toBe(false);
+ });
+
+ it('returns a new object', function() {
+ 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: [{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() {
+ 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() {
+ 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}
+ ]
+ }];
+ });
+
+ it('merges orthogonal traces', function() {
+ frames[0].baseFrame = frames[1].name;
+
+ // 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;
+
+ 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;
+
+ 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;
+
+ Plotly.addFrames(gd, frames.map(clone));
+
+ expect(computeFrame(gd, 'frame4')).toEqual({
+ traceIndices: [2, 8, 0, 1],
+ data: [
+ {marker: {size: 7}},
+ {marker: {size: 4}},
+ {marker: {size: 5}},
+ {marker: {size: 6}}
+ ]
+ });
+
+ expect(gd._frameData._frames).toEqual(frames);
+ });
+ });
+
+ describe('computing trace layout', function() {
+ var frames, frameCopies;
+
+ beforeEach(function(done) {
+ frames = [{
+ name: 'frame0',
+ layout: {'margin.l': 40}
+ }, {
+ name: 'frame1',
+ layout: {'margin.l': 80}
+ }];
+
+ frameCopies = frames.map(clone);
+
+ 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}}
+ });
+ });
+
+ it('leaves the frame unaffected', function() {
+ computeFrame(gd, 'frame0');
+ expect(gd._frameData._frames).toEqual(frameCopies);
+ });
+ });
+});
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/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/frame_api_test.js b/test/jasmine/tests/frame_api_test.js
new file mode 100644
index 00000000000..b105d63f1fb
--- /dev/null
+++ b/test/jasmine/tests/frame_api_test.js
@@ -0,0 +1,207 @@
+var Plotly = require('@lib/index');
+
+var createGraphDiv = require('../assets/create_graph_div');
+var destroyGraphDiv = require('../assets/destroy_graph_div');
+var fail = require('../assets/fail_test');
+
+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(function() {
+ Plotly.setPlotConfig({ queueLength: 10 });
+ }).then(done);
+ });
+
+ afterEach(function() {
+ destroyGraphDiv();
+ Plotly.setPlotConfig({queueLength: 0});
+ });
+
+ 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._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']);
+ }).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'}]);
+ }).catch(fail).then(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'}]);
+ }).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) {
+ var i;
+ var frames = [];
+ for(i = 0; i < 10; i++) {
+ frames.push({name: 'frame' + i});
+ }
+
+ function validate() {
+ 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: 'frame5', x: [1]}, {name: 'frame7', x: [2]}, {name: 'frame10', x: [3]}], [5, 7, undefined]);
+ }).then(function() {
+ 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);
+ });
+
+ it('inserts frames at specific indices (reversed)', function(done) {
+ var i;
+ var frames = [];
+ for(i = 0; i < 10; i++) {
+ frames.push({name: 'frame' + i});
+ }
+
+ function validate() {
+ 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: 'frame10', x: [3]}, {name: 'frame7', x: [2]}, {name: 'frame5', x: [1]}], [undefined, 7, 5]);
+ }).then(function() {
+ 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);
+ });
+
+ it('implements undo/redo', function(done) {
+ 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(validate).catch(fail).then(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']);
+ }).catch(fail).then(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([]);
+ }).catch(fail).then(done);
+ });
+
+ it('deletes multiple frames', function(done) {
+ var i;
+ var frames = [];
+ for(i = 0; i < 10; i++) {
+ frames.push({name: 'frame' + i});
+ }
+
+ 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++) {
+ expect(f[i]).toEqual({name: 'frame' + i});
+ }
+
+ return Plotly.Queue.redo(gd);
+ }).then(validate).catch(fail).then(done);
+ });
+ });
+});
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/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'),
diff --git a/test/jasmine/tests/plot_css_test.js b/test/jasmine/tests/plot_css_test.js
new file mode 100644
index 00000000000..830a606c39f
--- /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 plotcss_utils = require('@src/lib/plotcss_utils');
+ 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 = plotcss_utils.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 = plotcss_utils.getAllRuleSelectors(document);
+
+ for(selector in plotcss) {
+ fullSelector = plotcss_utils.buildFullSelector(selector);
+
+ expect(allSelectors.indexOf(fullSelector)).toEqual(-1);
+ }
+
+ // plot
+ var gd = createGraphDiv();
+ plot(gd);
+
+ // check for styles
+ allSelectors = plotcss_utils.getAllRuleSelectors(document);
+
+ for(selector in plotcss) {
+ fullSelector = plotcss_utils.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 = plotcss_utils.getAllRuleSelectors(gd.ownerDocument);
+
+ for(var selector in plotcss) {
+ var fullSelector = plotcss_utils.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 = plotcss_utils.getAllRuleSelectors(document);
+
+ for(selector in plotcss) {
+ fullSelector = plotcss_utils.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 = plotcss_utils.getAllRuleSelectors(document);
+
+ for(selector in plotcss) {
+ fullSelector = plotcss_utils.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/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/range_selector_test.js b/test/jasmine/tests/range_selector_test.js
index 4d56997b005..c48a3e36f0f 100644
--- a/test/jasmine/tests/range_selector_test.js
+++ b/test/jasmine/tests/range_selector_test.js
@@ -12,474 +12,510 @@ 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 skip over non-object buttons', function() {
+ var containerIn = {
+ rangeselector: {
+ buttons: [{
+ label: 'button 0'
+ }, null, {
+ label: 'button 2'
+ }, 'remove', {
+ label: 'button 4'
+ }]
+ }
+ };
+ 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(containerIn.rangeselector.buttons.length).toEqual(5);
+ expect(containerOut.rangeselector.buttons.length).toEqual(3);
});
- describe('getUpdateObject:', function() {
- var axisLayout = {
- _name: 'xaxis',
- range: [
- (new Date(1948, 0, 1)).getTime(),
- (new Date(2015, 10, 30)).getTime()
- ]
+ it('should coerce all buttons present', function() {
+ var containerIn = {
+ rangeselector: {
+ buttons: [{
+ step: 'year',
+ count: 10
+ }, {
+ count: 6
+ }]
+ }
};
+ 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.visible).toBe(true);
+ expect(containerOut.rangeselector.buttons).toEqual([
+ { step: 'year', stepmode: 'backward', count: 10 },
+ { step: 'month', stepmode: 'backward', count: 6 }
+ ]);
+ });
- var update = getUpdateObject(axisLayout, buttonLayout);
+ it('should not coerce \'stepmode\' and \'count\', for \'step\' all buttons', function() {
+ var containerIn = {
+ rangeselector: {
+ buttons: [{
+ step: 'all',
+ label: 'full range'
+ }]
+ }
+ };
+ var containerOut = {};
- assertRanges(update, new Date(2015, 9, 30), new Date(2015, 10, 30));
- });
+ supply(containerIn, containerOut, {}, []);
- it('should return update object (3 months backward case)', function() {
- var buttonLayout = {
- step: 'month',
- stepmode: 'backward',
- count: 3
- };
+ 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, 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);
+ 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, 4, 30), new Date(2015, 10, 30));
- });
+ supplyLayoutDefaults(containerIn, containerOut, layout, counterAxes);
- it('should return update object (5 months to-date case)', function() {
- var buttonLayout = {
- step: 'month',
- stepmode: 'todate',
- count: 5
- };
+ 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, 6, 1), 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 (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, 9, 30), new Date(2015, 10, 30));
+ });
- assertRanges(update, new Date(2015, 0, 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 (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, 7, 30), new Date(2015, 10, 30));
+ });
- assertRanges(update, new Date(2006, 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 (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, 4, 30), new Date(2015, 10, 30));
+ });
- assertRanges(update, new Date(2014, 10, 30), 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 (reset case)', function() {
- var buttonLayout = {
- step: 'all'
- };
+ var update = getUpdateObject(axisLayout, buttonLayout);
- var update = getUpdateObject(axisLayout, buttonLayout);
+ assertRanges(update, new Date(2015, 6, 1), new Date(2015, 10, 30));
+ });
- expect(update).toEqual({ 'xaxis.autorange': true });
- });
+ it('should return update object (1 year to-date case)', function() {
+ var buttonLayout = {
+ step: 'year',
+ stepmode: 'todate',
+ count: 1
+ };
- 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(2015, 0, 1), new Date(2015, 10, 30));
+ });
- assertRanges(update, new Date(2015, 10, 20), new Date(2015, 10, 30));
- });
+ it('should return update object (10 year to-date case)', function() {
+ var buttonLayout = {
+ step: 'year',
+ stepmode: 'todate',
+ count: 10
+ };
- 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(2006, 0, 1), new Date(2015, 10, 30));
+ });
- assertRanges(update, new Date(2015, 10, 29, 19), 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 (15 minute backward case)', function() {
- var buttonLayout = {
- step: 'minute',
- stepmode: 'backward',
- count: 15
- };
+ 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, 23, 45), new Date(2015, 10, 30));
- });
+ it('should return update object (reset case)', function() {
+ var buttonLayout = {
+ step: 'all'
+ };
- 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);
+ expect(update).toEqual({ 'xaxis.autorange': true });
+ });
- assertRanges(update, new Date(2015, 10, 29, 23, 59, 50), 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 (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, 20), new Date(2015, 10, 30));
+ });
- var update = getUpdateObject(axisLayout, buttonLayout);
+ it('should return update object (5 hour backward case)', function() {
+ var buttonLayout = {
+ step: 'hour',
+ stepmode: 'backward',
+ count: 5
+ };
- 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, 19), new Date(2015, 10, 30));
+ });
- axisLayout.range[1] = new Date(2015, 10, 30, 12, 20).getTime();
+ 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);
- assertRanges(update, new Date(2015, 10, 30, 12, 1), new Date(2015, 10, 30, 12, 20));
- });
+ assertRanges(update, new Date(2015, 10, 29, 23, 45), 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 (10 second backward case)', function() {
+ var buttonLayout = {
+ step: 'second',
+ stepmode: 'backward',
+ count: 10
+ };
- axisLayout.range[1] = new Date(2015, 10, 30, 12, 20, 2).getTime();
+ var update = getUpdateObject(axisLayout, buttonLayout);
- var update = getUpdateObject(axisLayout, buttonLayout);
+ assertRanges(update, new Date(2015, 10, 29, 23, 59, 50), new Date(2015, 10, 30));
+ });
- assertRanges(update, new Date(2015, 10, 30, 12, 20, 1), new Date(2015, 10, 30, 12, 20, 2));
- });
+ it('should return update object (12 hour to-date case)', function() {
+ var buttonLayout = {
+ step: 'hour',
+ stepmode: 'todate',
+ count: 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()
- });
+ axisLayout.range[1] = new Date(2015, 10, 30, 12).getTime();
- });
+ var update = getUpdateObject(axisLayout, buttonLayout);
+
+ assertRanges(update, new Date(2015, 10, 30, 1), new Date(2015, 10, 30, 12));
});
- describe('interactions:', function() {
- var mock = require('@mocks/range_selector.json');
+ it('should return update object (15 minute backward case)', function() {
+ var buttonLayout = {
+ step: 'minute',
+ stepmode: 'todate',
+ count: 20
+ };
- var gd, mockCopy;
+ axisLayout.range[1] = new Date(2015, 10, 30, 12, 20).getTime();
- beforeEach(function(done) {
- gd = createGraphDiv();
- mockCopy = Lib.extendDeep({}, mock);
+ var update = getUpdateObject(axisLayout, buttonLayout);
- Plotly.plot(gd, mockCopy.data, mockCopy.layout).then(done);
- });
+ assertRanges(update, new Date(2015, 10, 30, 12, 1), new Date(2015, 10, 30, 12, 20));
+ });
- afterEach(destroyGraphDiv);
+ it('should return update object (2 second to-date case)', function() {
+ var buttonLayout = {
+ step: 'second',
+ stepmode: 'todate',
+ count: 2
+ };
- function assertNodeCount(query, cnt) {
- expect(d3.selectAll(query).size()).toEqual(cnt);
- }
+ axisLayout.range[1] = new Date(2015, 10, 30, 12, 20, 2).getTime();
- function checkActiveButton(activeIndex) {
- d3.selectAll('.button').each(function(d, i) {
- expect(d.isActive).toBe(activeIndex === i);
- });
- }
+ var update = getUpdateObject(axisLayout, buttonLayout);
- it('should display the correct nodes', function() {
- assertNodeCount('.rangeselector', 1);
- assertNodeCount('.button', mockCopy.layout.xaxis.rangeselector.buttons.length);
- });
+ assertRanges(update, new Date(2015, 10, 30, 12, 20, 1), new Date(2015, 10, 30, 12, 20, 2));
+ });
- 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 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 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);
+describe('range selector interactions:', function() {
+ 'use strict';
- var pos0 = getRectCenter(buttons[0][0]);
- var posReset = getRectCenter(buttons[0][buttons.size() - 1]);
+ var mock = require('@mocks/range_selector.json');
- mouseEvent('click', pos0[0], pos0[1]);
- expect(gd.layout.xaxis.range[0]).toBeGreaterThan(range0);
+ var gd, mockCopy;
- checkActiveButton(0);
+ beforeEach(function(done) {
+ gd = createGraphDiv();
+ mockCopy = Lib.extendDeep({}, mock);
+
+ Plotly.plot(gd, mockCopy.data, mockCopy.layout).then(done);
+ });
- mouseEvent('click', posReset[0], posReset[1]);
- expect(gd.layout.xaxis.range[0]).toEqual(range0);
+ afterEach(destroyGraphDiv);
- checkActiveButton(buttons.size() - 1);
+ 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 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 change color on mouse over', function() {
- var button = d3.select('.button').select('rect');
- var pos = getRectCenter(button.node());
+ });
+
+ it('should be able to remove button(s) on `relayout`', function(done) {
+ var len = mockCopy.layout.xaxis.rangeselector.buttons.length;
- var fillColor = Color.rgb(gd._fullLayout.xaxis.rangeselector.bgcolor);
- var activeColor = Color.rgb(constants.activeColor);
+ assertNodeCount('.button', len);
- expect(button.style('fill')).toEqual(fillColor);
+ Plotly.relayout(gd, 'xaxis.rangeselector.buttons[0]', null).then(function() {
+ assertNodeCount('.button', len - 1);
- mouseEvent('mouseover', pos[0], pos[1]);
- expect(button.style('fill')).toEqual(activeColor);
+ return Plotly.relayout(gd, 'xaxis.rangeselector.buttons[1]', 'remove');
+ }).then(function() {
+ assertNodeCount('.button', len - 2);
- mouseEvent('mouseout', pos[0], pos[1]);
- expect(button.style('fill')).toEqual(fillColor);
+ done();
});
+ });
- it('should update is active relayout calls', function(done) {
- 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');
- // 'all' should be active at first
- checkActiveButton(buttons.size() - 1);
+ 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 pos0 = getRectCenter(buttons[0][0]);
+ var posReset = getRectCenter(buttons[0][buttons.size() - 1]);
- Plotly.relayout(gd, update).then(function() {
+ mouseEvent('click', pos0[0], pos0[1]);
+ expect(gd.layout.xaxis.range[0]).toBeGreaterThan(range0);
- // '1m' should be active after the relayout
- checkActiveButton(0);
+ checkActiveButton(0);
- return Plotly.relayout(gd, 'xaxis.autorange', true);
- }).then(function() {
+ mouseEvent('click', posReset[0], posReset[1]);
+ expect(gd.layout.xaxis.range[0]).toEqual(range0);
- // 'all' should be after an autoscale
- checkActiveButton(buttons.size() - 1);
+ checkActiveButton(buttons.size() - 1);
+ });
- done();
- });
- });
+ 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);
+
+ expect(button.style('fill')).toEqual(fillColor);
+
+ mouseEvent('mouseover', pos[0], pos[1]);
+ expect(button.style('fill')).toEqual(activeColor);
+
+ 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');
+
+ // '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()
+ };
+
+ Plotly.relayout(gd, update).then(function() {
+
+ // '1m' should be active after the relayout
+ checkActiveButton(0);
+
+ return Plotly.relayout(gd, 'xaxis.autorange', true);
+ }).then(function() {
+
+ // 'all' should be after an autoscale
+ checkActiveButton(buttons.size() - 1);
+
+ done();
+ });
+ });
});
diff --git a/test/jasmine/tests/svg_text_utils_test.js b/test/jasmine/tests/svg_text_utils_test.js
index 6d11560a105..be4601743c8 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');
@@ -75,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'
@@ -134,5 +149,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&\';;');
+ });
});
});
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'
});