diff --git a/src/components/rangeslider/attributes.js b/src/components/rangeslider/attributes.js new file mode 100644 index 00000000000..ec0ae8b878c --- /dev/null +++ b/src/components/rangeslider/attributes.js @@ -0,0 +1,52 @@ +/** +* Copyright 2012-2016, Plotly, Inc. +* All rights reserved. +* +* This source code is licensed under the MIT license found in the +* LICENSE file in the root directory of this source tree. +*/ + +'use strict'; + +var colorAttributes = require('../color/attributes'); + +module.exports = { + bgcolor: { + valType: 'color', + dflt: colorAttributes.background, + role: 'style', + description: 'Sets the background color of the range slider.' + }, + bordercolor: { + valType: 'color', + dflt: colorAttributes.defaultLine, + role: 'style', + description: 'Sets the border color of the range slider.' + }, + borderwidth: { + valType: 'integer', + dflt: 0, + role: 'style', + description: 'Sets the border color of the range slider.' + }, + thickness: { + valType: 'number', + dflt: 0.15, + min: 0, + max: 1, + role: 'style', + description: [ + 'The height of the range slider as a fraction of the', + 'total plot area height.' + ].join(' ') + }, + visible: { + valType: 'boolean', + dflt: true, + role: 'info', + description: [ + 'Determines whether or not the range slider will be visible.', + 'If visible, perpendicular axes will be set to `fixedrange`' + ].join(' ') + } +}; diff --git a/src/components/rangeslider/create_slider.js b/src/components/rangeslider/create_slider.js new file mode 100644 index 00000000000..abafb11cc4d --- /dev/null +++ b/src/components/rangeslider/create_slider.js @@ -0,0 +1,248 @@ +/** +* 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 Plotly = require('../../plotly'); +var Lib = require('../../lib'); + +var svgNS = require('../../constants/xmlns_namespaces').svg; + +var helpers = require('./helpers'); +var rangePlot = require('./range_plot'); + + +module.exports = function createSlider(gd, minStart, maxStart) { + var fullLayout = gd._fullLayout, + sliderContainer = fullLayout._infolayer.selectAll('g.range-slider'), + options = fullLayout.xaxis.rangeslider, + width = fullLayout._size.w, + height = (fullLayout.height - fullLayout.margin.b - fullLayout.margin.t) * options.thickness, + handleWidth = 2, + offsetShift = Math.floor(options.borderwidth / 2), + x = fullLayout.margin.l, + y = fullLayout.height - height - fullLayout.margin.b; + + minStart = minStart || 0; + maxStart = maxStart || width; + + var slider = document.createElementNS(svgNS, 'g'); + helpers.setAttributes(slider, { + 'class': 'range-slider', + 'data-min': minStart, + 'data-max': maxStart, + 'pointer-events': 'all', + 'transform': 'translate(' + x + ',' + y + ')' + }); + + + var sliderBg = document.createElementNS(svgNS, 'rect'), + borderCorrect = options.borderwidth % 2 === 0 ? options.borderwidth : options.borderwidth - 1; + helpers.setAttributes(sliderBg, { + 'fill': options.bgcolor, + 'stroke': options.bordercolor, + 'stroke-width': options.borderwidth, + 'height': height + borderCorrect, + 'width': width + borderCorrect, + 'transform': 'translate(-' + offsetShift + ', -' + offsetShift + ')', + 'shape-rendering': 'crispEdges' + }); + + + var maskMin = document.createElementNS(svgNS, 'rect'); + helpers.setAttributes(maskMin, { + 'x': 0, + 'width': minStart, + 'height': height, + 'fill': 'rgba(0,0,0,0.4)' + }); + + + var maskMax = document.createElementNS(svgNS, 'rect'); + helpers.setAttributes(maskMax, { + 'x': maxStart, + 'width': width - maxStart, + 'height': height, + 'fill': 'rgba(0,0,0,0.4)' + }); + + + var grabberMin = document.createElementNS(svgNS, 'g'), + grabAreaMin = document.createElementNS(svgNS, 'rect'), + handleMin = document.createElementNS(svgNS, 'rect'); + helpers.setAttributes(grabberMin, { 'transform': 'translate(' + (minStart - handleWidth - 1) + ')' }); + helpers.setAttributes(grabAreaMin, { + 'width': 10, + 'height': height, + 'x': -6, + 'fill': 'transparent', + 'cursor': 'col-resize' + }); + helpers.setAttributes(handleMin, { + 'width': handleWidth, + 'height': height / 2, + 'y': height / 4, + 'rx': 1, + 'fill': 'white', + 'stroke': '#666', + 'shape-rendering': 'crispEdges' + }); + helpers.appendChildren(grabberMin, [handleMin, grabAreaMin]); + + + var grabberMax = document.createElementNS(svgNS, 'g'), + grabAreaMax = document.createElementNS(svgNS, 'rect'), + handleMax = document.createElementNS(svgNS, 'rect'); + helpers.setAttributes(grabberMax, { 'transform': 'translate(' + maxStart + ')' }); + helpers.setAttributes(grabAreaMax, { + 'width': 10, + 'height': height, + 'x': -2, + 'fill': 'transparent', + 'cursor': 'col-resize' + }); + helpers.setAttributes(handleMax, { + 'width': handleWidth, + 'height': height / 2, + 'y': height / 4, + 'rx': 1, + 'fill': 'white', + 'stroke': '#666', + 'shape-rendering': 'crispEdges' + }); + helpers.appendChildren(grabberMax, [handleMax, grabAreaMax]); + + + var slideBox = document.createElementNS(svgNS, 'rect'); + helpers.setAttributes(slideBox, { + 'x': minStart, + 'width': maxStart - minStart, + 'height': height, + 'cursor': 'ew-resize', + 'fill': 'transparent' + }); + + + slider.addEventListener('mousedown', function(event) { + var target = event.target, + startX = event.clientX, + offsetX = startX - slider.getBoundingClientRect().left, + minVal = slider.getAttribute('data-min'), + maxVal = slider.getAttribute('data-max'); + + window.addEventListener('mousemove', mouseMove); + window.addEventListener('mouseup', mouseUp); + + function mouseMove(e) { + var delta = +e.clientX - startX; + + switch(target) { + case slideBox: + slider.style.cursor = 'ew-resize'; + setPixelRange(+maxVal + delta, +minVal + delta); + break; + + case grabAreaMin: + slider.style.cursor = 'col-resize'; + setPixelRange(+minVal + delta, +maxVal); + break; + + case grabAreaMax: + slider.style.cursor = 'col-resize'; + setPixelRange(+minVal, +maxVal + delta); + break; + + default: + slider.style.cursor = 'ew-resize'; + setPixelRange(offsetX, offsetX + delta); + break; + } + } + + function mouseUp() { + window.removeEventListener('mousemove', mouseMove); + window.removeEventListener('mouseup', mouseUp); + slider.style.cursor = 'auto'; + } + }); + + + function setRange(min, max) { + min = min || -Infinity; + max = max || Infinity; + + var rangeMin = fullLayout.xaxis.range[0], + rangeMax = fullLayout.xaxis.range[1], + range = rangeMax - rangeMin, + pixelMin = (min - rangeMin) / range * width, + pixelMax = (max - rangeMin) / range * width; + + setPixelRange(pixelMin, pixelMax); + } + + + function setPixelRange(min, max) { + + min = Lib.constrain(min, 0, width); + max = Lib.constrain(max, 0, width); + + if(max < min) { + var temp = max; + max = min; + min = temp; + } + + helpers.setAttributes(slider, { + 'data-min': min, + 'data-max': max + }); + + helpers.setAttributes(slideBox, { + 'x': min, + 'width': max - min + }); + + helpers.setAttributes(maskMin, { 'width': min }); + helpers.setAttributes(maskMax, { + 'x': max, + 'width': width - max + }); + + helpers.setAttributes(grabberMin, { 'transform': 'translate(' + (min - handleWidth - 1) + ')' }); + helpers.setAttributes(grabberMax, { 'transform': 'translate(' + max + ')' }); + + // call to set range on plot here + var rangeMin = fullLayout.xaxis.range[0], + rangeMax = fullLayout.xaxis.range[1], + range = rangeMax - rangeMin, + dataMin = min / width * range + rangeMin, + dataMax = max / width * range + rangeMin; + + Plotly.relayout(gd, 'xaxis.range', [dataMin, dataMax]); + } + + + var rangePlots = rangePlot(gd, width, height); + + helpers.appendChildren(slider, [ + sliderBg, + rangePlots, + maskMin, + maskMax, + slideBox, + grabberMin, + grabberMax + ]); + + sliderContainer.data([0]) + .enter().append(function() { + options.setRange = setRange; + return slider; + }); +}; diff --git a/src/components/rangeslider/data_processors.js b/src/components/rangeslider/data_processors.js new file mode 100644 index 00000000000..4cd72e218c8 --- /dev/null +++ b/src/components/rangeslider/data_processors.js @@ -0,0 +1,18 @@ +/** +* 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('../../lib'); + +module.exports = { + 'linear': function(val) { return val; }, + 'log': function(val) { return Math.log(val)/Math.log(10); }, + 'date': function(val) { return Lib.dateTime2ms(val); }, + 'category': function(_, i) { return i; } +}; diff --git a/src/components/rangeslider/defaults.js b/src/components/rangeslider/defaults.js new file mode 100644 index 00000000000..c1b4cb3fb45 --- /dev/null +++ b/src/components/rangeslider/defaults.js @@ -0,0 +1,41 @@ +/** +* 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('../../lib'); +var attributes = require('./attributes'); + + +module.exports = function supplyLayoutDefaults(layoutIn, layoutOut, axName, counterAxes) { + + if(!layoutIn[axName].rangeslider) return; + + var containerIn = typeof layoutIn[axName].rangeslider === 'object' ? + layoutIn[axName].rangeslider : {}, + containerOut = layoutOut[axName].rangeslider = {}; + + function coerce(attr, dflt) { + return Lib.coerce(containerIn, containerOut, + attributes, attr, dflt); + } + + coerce('visible'); + coerce('thickness'); + coerce('bgcolor'); + coerce('bordercolor'); + coerce('borderwidth'); + + if(containerOut.visible) { + counterAxes.forEach(function(ax) { + var opposing = layoutOut[ax] || {}; + opposing.fixedrange = true; + layoutOut[ax] = opposing; + }); + } +}; diff --git a/src/components/rangeslider/helpers.js b/src/components/rangeslider/helpers.js new file mode 100644 index 00000000000..23ea06e24ca --- /dev/null +++ b/src/components/rangeslider/helpers.js @@ -0,0 +1,24 @@ +/** +* 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'; + +exports.setAttributes = function setAttributes(el, attributes) { + for(var key in attributes) { + el.setAttribute(key, attributes[key]); + } +}; + + +exports.appendChildren = function appendChildren(el, children) { + for(var i = 0; i < children.length; i++) { + if(children[i]) { + el.appendChild(children[i]); + } + } +}; diff --git a/src/components/rangeslider/index.js b/src/components/rangeslider/index.js new file mode 100644 index 00000000000..d74eefdbd1a --- /dev/null +++ b/src/components/rangeslider/index.js @@ -0,0 +1,51 @@ +/** +* 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 Plots = require('../../plots/plots'); + +var createSlider = require('./create_slider'); +var supplyLayoutDefaults = require('./defaults'); + + +module.exports = { + draw: draw, + supplyLayoutDefaults: supplyLayoutDefaults +}; + +function draw(gd, minStart, maxStart) { + if(!gd._fullLayout.xaxis) return; + + var fullLayout = gd._fullLayout, + sliderContainer = fullLayout._infolayer.selectAll('g.range-slider'), + options = fullLayout.xaxis.rangeslider; + + + if(!options || !options.visible) { + sliderContainer.data([]) + .exit().remove(); + + Plots.autoMargin(gd, 'range-slider'); + + return; + } + + + var height = (fullLayout.height - fullLayout.margin.b - fullLayout.margin.t) * options.thickness, + offsetShift = Math.floor(options.borderwidth / 2); + + if(sliderContainer[0].length === 0) createSlider(gd, minStart, maxStart); + + Plots.autoMargin(gd, 'range-slider', { + x: 0, y: 0, l: 0, r: 0, t: 0, + b: height + fullLayout.margin.b + fullLayout.xaxis._boundingBox.height, + pad: 15 + offsetShift * 2 + }); +} diff --git a/src/components/rangeslider/range_plot.js b/src/components/rangeslider/range_plot.js new file mode 100644 index 00000000000..36b019aa88b --- /dev/null +++ b/src/components/rangeslider/range_plot.js @@ -0,0 +1,167 @@ +/** +* 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 Symbols = require('../drawing/symbol_defs'); +var Drawing = require('../drawing'); + +var helpers = require('./helpers'); +var dataProcessors = require('./data_processors'); +var svgNS = require('../../constants/xmlns_namespaces').svg; + +module.exports = function rangePlot(gd, w, h) { + + var traces = gd._fullData, + xaxis = gd._fullLayout.xaxis, + yaxis = gd._fullLayout.yaxis, + minX = xaxis.range[0], + maxX = xaxis.range[1], + minY = yaxis.range[0], + maxY = yaxis.range[1]; + + + // create elements for plot and its clip + var clipPath = document.createElementNS(svgNS, 'path'); + clipPath.setAttribute('d', ['M0,0', w + ',0', w + ',' + h, '0,' + h, 'Z'].join(' ')); + + var clip = document.createElementNS(svgNS, 'clipPath'); + clip.setAttribute('id', 'range-clip-path'); + clip.appendChild(clipPath); + + var clipDefs = document.createElementNS(svgNS, 'defs'); + clipDefs.appendChild(clip); + + var rangePlot = document.createElementNS(svgNS, 'g'); + rangePlot.setAttribute('clip-path', 'url(#range-clip-path)'); + rangePlot.appendChild(clipDefs); + + + var processX = dataProcessors[gd._fullLayout.xaxis.type || 'category'], + processY = dataProcessors[gd._fullLayout.yaxis.type || 'category']; + + + // for now, only scatter traces are supported + var allowedTypes = ['scatter']; + + for(var i = 0; i < traces.length; i++) { + + var trace = traces[i], + pointPairs = []; + + if(allowedTypes.indexOf(trace.type) < 0) { + console.log('Trace type ' + trace.type + ' not supported for range slider!'); + continue; + } + + for(var k = 0; k < trace.x.length; k++) { + var x = processX(trace.x[k], k), + y = processY(trace.y[k], k); + + var posX = w * (x - minX) / (maxX - minX), + posY = h * (1 - (y - minY) / (maxY - minY)); + + pointPairs.push([posX, posY]); + } + + // more trace type range plots can be added here + helpers.appendChildren(rangePlot, makeScatter(trace, pointPairs, w, h)); + } + + + return rangePlot; +}; + + +function makeScatter(trace, pointPairs, w, h) { + + // create the line + var line, markers, fill; + + if(trace.line) { + line = document.createElementNS(svgNS, 'path'); + + var linePath = Drawing.smoothopen(pointPairs, trace.line.smoothing || 0); + + helpers.setAttributes(line, { + 'd': linePath, + 'fill': 'none', + 'stroke': trace.line ? trace.line.color : 'transparent', + 'stroke-width': trace.line.width / 2 || 1, + 'opacity': 1 + }); + } + + // create points if there's markers + if(trace.marker) { + markers = document.createElementNS(svgNS, 'g'); + + var points = pointPairs.map(function(p, i) { + var point = document.createElementNS(svgNS, 'g'), + symbol = document.createElementNS(svgNS, 'path'), + size; + + if(Array.isArray(trace.marker.size)) { + size = typeof trace.marker.size[i] === 'number' ? + Math.max(trace.marker.size[i] / (trace.marker.sizeref || 1) / 15, 0) : + 0; + } else { + size = Math.max(trace.marker.size / 15, 2); + } + + helpers.setAttributes(symbol, { + 'd': Symbols[trace.marker.symbol].f(size), + 'fill': trace.marker.color, + 'stroke': trace.marker.line.color, + 'stroke-width': trace.marker.line.width, + 'opacity': trace.marker.opacity + }); + + helpers.setAttributes(point, { + 'transform': 'translate(' + p[0] + ',' + p[1] + ')' + }); + + point.appendChild(symbol); + + return point; + }); + + helpers.appendChildren(markers, points); + } + + + // create fill if set + if(trace.fill !== 'none') { + fill = document.createElementNS(svgNS, 'path'); + + switch(trace.fill) { + case 'tozeroy': + pointPairs.unshift([pointPairs[0][0], h]); + pointPairs.push([pointPairs[pointPairs.length - 1][0], h]); + break; + + case 'tozerox': + pointPairs.unshift([0, pointPairs[pointPairs.length - 1][1]]); + break; + + default: + console.log('Fill type ' + trace.fill + ' not supported for range slider! (yet...)'); + break; + } + + var fillPath = Drawing.smoothopen(pointPairs, trace.line.smoothing || 0); + + helpers.setAttributes(fill, { + 'd': fillPath, + 'fill': trace.fillcolor || 'transparent' + }); + } + + + return [line, markers, fill]; +} diff --git a/src/components/titles/index.js b/src/components/titles/index.js index 0da50d3d9fc..112f3a96606 100644 --- a/src/components/titles/index.js +++ b/src/components/titles/index.js @@ -107,6 +107,11 @@ Titles.draw = function(gd, title) { ya._length + 10 + fontSize*(offsetBase + (xa.showticklabels ? 1.5 : 0.5))); + if(xa.rangeslider && xa.rangeslider.visible && xa._boundingBox) { + y += (fullLayout.height - fullLayout.margin.b - fullLayout.margin.t) * xa.rangeslider.thickness + + xa._boundingBox.height; + } + options = {x: x, y: y, 'text-anchor': 'middle'}; if(!avoid.side) avoid.side = 'bottom'; } diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index 8aabf2852c6..2793fc45919 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -25,6 +25,7 @@ var Color = require('../components/color'); var Drawing = require('../components/drawing'); var ErrorBars = require('../components/errorbars'); var Legend = require('../components/legend'); +var RangeSlider = require('../components/rangeslider'); var Shapes = require('../components/shapes'); var Titles = require('../components/titles'); var manageModeBar = require('../components/modebar/manage'); @@ -242,6 +243,7 @@ Plotly.plot = function(gd, data, layout, config) { function drawAxes() { // draw ticks, titles, and calculate axis scaling (._b, ._m) + RangeSlider.draw(gd); return Plotly.Axes.doTicks(gd, 'redraw'); } @@ -2393,12 +2395,32 @@ Plotly.relayout = function relayout(gd, astr, val) { } } + function setRange(changes) { + var newMin = changes['xaxis.range[0]'], + newMax = changes['xaxis.range[1]']; + + var rangeSlider = fullLayout.xaxis && fullLayout.xaxis.rangeslider ? + fullLayout.xaxis.rangeslider : {}; + + if(rangeSlider.visible) { + if(newMin || newMax) { + fullLayout.xaxis.rangeslider.setRange(newMin, newMax); + } else if(changes['xaxis.autorange']) { + fullLayout.xaxis.rangeslider.setRange(); + } + } + } + var plotDone = Lib.syncOrAsync(seq, gd); if(!plotDone || !plotDone.then) plotDone = Promise.resolve(gd); return plotDone.then(function() { - gd.emit('plotly_relayout', Lib.extendDeep({}, redoit)); + var changes = Lib.extendDeep({}, redoit); + + setRange(changes); + gd.emit('plotly_relayout', changes); + return gd; }); }; diff --git a/src/plots/cartesian/axes.js b/src/plots/cartesian/axes.js index c562a50fdf7..a28a531132d 100644 --- a/src/plots/cartesian/axes.js +++ b/src/plots/cartesian/axes.js @@ -1302,8 +1302,8 @@ axes.makeClipPaths = function(td) { // 'redraw' to force full redraw, and reset ax._r // (stored range for use by zoom/pan) // or can pass in an axis object directly -axes.doTicks = function(td, axid, skipTitle) { - var fullLayout = td._fullLayout, +axes.doTicks = function(gd, axid, skipTitle) { + var fullLayout = gd._fullLayout, ax, independent = false; @@ -1314,9 +1314,9 @@ axes.doTicks = function(td, axid, skipTitle) { independent = true; } else { - ax = axes.getFromId(td,axid); + ax = axes.getFromId(gd, axid); - if(axid==='redraw') { + if(axid === 'redraw') { fullLayout._paper.selectAll('g.subplot').each(function(subplot) { var plotinfo = fullLayout._plots[subplot], xa = plotinfo.x(), @@ -1334,12 +1334,12 @@ axes.doTicks = function(td, axid, skipTitle) { }); } - if(!axid || axid==='redraw') { - return Plotly.Lib.syncOrAsync(axes.list(td, '', true).map(function(ax) { + if(!axid || axid === 'redraw') { + return Plotly.Lib.syncOrAsync(axes.list(gd, '', true).map(function(ax) { return function() { if(!ax._id) return; - var axDone = axes.doTicks(td,ax._id); - if(axid==='redraw') ax._r = ax.range.slice(); + var axDone = axes.doTicks(gd,ax._id); + if(axid === 'redraw') ax._r = ax.range.slice(); return axDone; }; })); @@ -1373,27 +1373,27 @@ axes.doTicks = function(td, axid, skipTitle) { pad = (ax.linewidth||1) / 2, labelStandoff = (ax.ticks==='outside' ? ax.ticklen : 1) + (ax.linewidth||0), - gridWidth = Plotly.Drawing.crispRound(td, ax.gridwidth, 1), - zeroLineWidth = Plotly.Drawing.crispRound(td, ax.zerolinewidth, gridWidth), - tickWidth = Plotly.Drawing.crispRound(td, ax.tickwidth, 1), + gridWidth = Plotly.Drawing.crispRound(gd, ax.gridwidth, 1), + zeroLineWidth = Plotly.Drawing.crispRound(gd, ax.zerolinewidth, gridWidth), + tickWidth = Plotly.Drawing.crispRound(gd, ax.tickwidth, 1), sides, transfn, tickprefix, tickmid, i; // positioning arguments for x vs y axes - if(axletter==='x') { + if(axletter === 'x') { sides = ['bottom', 'top']; transfn = function(d) { - return 'translate('+ax.l2p(d.x)+',0)'; + return 'translate(' + ax.l2p(d.x) + ',0)'; }; // dumb templating with string concat // would be better to use an actual template tickprefix = 'M0,'; tickmid = 'v'; } - else if(axletter==='y') { + else if(axletter === 'y') { sides = ['left', 'right']; transfn = function(d) { - return 'translate(0,'+ax.l2p(d.x)+')'; + return 'translate(0,' + ax.l2p(d.x) + ')'; }; tickprefix = 'M'; tickmid = ',0h'; @@ -1402,11 +1402,11 @@ axes.doTicks = function(td, axid, skipTitle) { console.log('unrecognized doTicks axis', axid); return; } - var axside = ax.side||sides[0], + var axside = ax.side || sides[0], // which direction do the side[0], side[1], and free ticks go? // then we flip if outside XOR y axis - ticksign = [-1, 1, axside===sides[1] ? 1 : -1]; - if((ax.ticks!=='inside') === (axletter==='x')) { + ticksign = [-1, 1, axside === sides[1] ? 1 : -1]; + if((ax.ticks !== 'inside') === (axletter === 'x')) { ticksign = ticksign.map(function(v) { return -v; }); } @@ -1437,10 +1437,10 @@ axes.doTicks = function(td, axid, skipTitle) { function drawLabels(container, position) { // tick labels - for now just the main labels. // TODO: mirror labels, esp for subplots - var tickLabels=container.selectAll('g.'+tcls).data(vals, datafn); + var tickLabels = container.selectAll('g.' + tcls).data(vals, datafn); if(!ax.showticklabels || !isNumeric(position)) { tickLabels.remove(); - Titles.draw(td, axid + 'title'); + Titles.draw(gd, axid + 'title'); return; } @@ -1484,7 +1484,7 @@ axes.doTicks = function(td, axid, skipTitle) { .attr('text-anchor', 'middle') .each(function(d) { var thisLabel = d3.select(this), - newPromise = td._promises.length; + newPromise = gd._promises.length; thisLabel .call(Plotly.Drawing.setPosition, labelx(d), labely(d)) @@ -1492,13 +1492,13 @@ axes.doTicks = function(td, axid, skipTitle) { d.font, d.fontSize, d.fontColor) .text(d.text) .call(Plotly.util.convertToTspans); - newPromise = td._promises[newPromise]; + newPromise = gd._promises[newPromise]; if(newPromise) { // if we have an async label, we'll deal with that - // all here so take it out of td._promises and + // all here so take it out of gd._promises and // instead position the label and promise this in // labelsReady - labelsReady.push(td._promises.pop().then(function() { + labelsReady.push(gd._promises.pop().then(function() { positionLabels(thisLabel, ax.tickangle); })); } @@ -1608,15 +1608,20 @@ axes.doTicks = function(td, axid, skipTitle) { // (so it can move out of the way if needed) // TODO: separate out scoot so we don't need to do // a full redraw of the title (modtly relevant for MathJax) - if(!skipTitle) Titles.draw(td, axid + 'title'); + if(!skipTitle) Titles.draw(gd, axid + 'title'); return axid+' done'; } + function calcBoundingBox() { + ax._boundingBox = container.node().getBoundingClientRect(); + } + var done = Plotly.Lib.syncOrAsync([ allLabelsReady, - fixLabelOverlaps + fixLabelOverlaps, + calcBoundingBox ]); - if(done && done.then) td._promises.push(done); + if(done && done.then) gd._promises.push(done); return done; } @@ -1650,8 +1655,8 @@ axes.doTicks = function(td, axid, skipTitle) { // zero line var hasBarsOrFill = false; - for(var i = 0; i < td._fullData.length; i++) { - if(traceHasBarsOrFill(td._fullData[i], subplot)) { + for(var i = 0; i < gd._fullData.length; i++) { + if(traceHasBarsOrFill(gd._fullData[i], subplot)) { hasBarsOrFill = true; break; } @@ -1677,7 +1682,7 @@ axes.doTicks = function(td, axid, skipTitle) { return drawLabels(ax._axislayer,ax._pos); } else { - var alldone = axes.getSubplots(td,ax).map(function(subplot) { + var alldone = axes.getSubplots(gd,ax).map(function(subplot) { var plotinfo = fullLayout._plots[subplot]; if(!fullLayout._hasCartesian) return; diff --git a/src/plots/cartesian/layout_attributes.js b/src/plots/cartesian/layout_attributes.js index 8ec877fc6a2..ae9dabf7602 100644 --- a/src/plots/cartesian/layout_attributes.js +++ b/src/plots/cartesian/layout_attributes.js @@ -11,6 +11,7 @@ var Cartesian = require('./index'); var fontAttrs = require('../font_attributes'); var colorAttrs = require('../../components/color/attributes'); var extendFlat = require('../../lib/extend').extendFlat; +var rangeSliderAttrs = require('../../components/rangeslider/attributes'); module.exports = { title: { @@ -80,6 +81,7 @@ module.exports = { 'January 1st 1970 to November 4th, 2013, set the range from 0 to 1380844800000.0' ].join(' ') }, + rangeslider: rangeSliderAttrs, fixedrange: { valType: 'boolean', dflt: false, diff --git a/src/plots/cartesian/layout_defaults.js b/src/plots/cartesian/layout_defaults.js index 7f454594d4e..ba0e6de27ad 100644 --- a/src/plots/cartesian/layout_defaults.js +++ b/src/plots/cartesian/layout_defaults.js @@ -12,6 +12,8 @@ var Lib = require('../../lib'); var Plots = require('../plots'); +var RangeSlider = require('../../components/rangeslider'); + var constants = require('./constants'); var layoutAttributes = require('./layout_attributes'); var handleAxisDefaults = require('./axis_defaults'); @@ -101,9 +103,10 @@ module.exports = function supplyLayoutDefaults(layoutIn, layoutOut, fullData) { } var xaList = xaListCartesian.concat(xaListGl2d).sort(axSort), - yaList = yaListCartesian.concat(yaListGl2d).sort(axSort); + yaList = yaListCartesian.concat(yaListGl2d).sort(axSort), + axesList = xaList.concat(yaList); - xaList.concat(yaList).forEach(function(axName) { + axesList.concat(yaList).forEach(function(axName) { var axLetter = axName.charAt(0), axLayoutIn = layoutIn[axName] || {}, axLayoutOut = {}, @@ -139,6 +142,14 @@ module.exports = function supplyLayoutDefaults(layoutIn, layoutOut, fullData) { }); + // quick second pass for rangeslider defaults + axesList.forEach(function(axName) { + var axLetter = axName.charAt(0), + counterAxes = {x: yaList, y: xaList}[axLetter]; + + RangeSlider.supplyLayoutDefaults(layoutIn, layoutOut, axName, counterAxes); + }); + // plot_bgcolor only makes sense if there's a (2D) plot! // TODO: bgcolor for each subplot, to inherit from the main one if(xaList.length && yaList.length) { diff --git a/src/plots/plots.js b/src/plots/plots.js index 1cd9555a01f..bc519f70687 100644 --- a/src/plots/plots.js +++ b/src/plots/plots.js @@ -853,7 +853,7 @@ plots.autoMargin = function(gd,id,o) { if(fullLayout.margin.autoexpand!==false) { if(!o) delete fullLayout._pushmargin[id]; else { - var pad = o.pad||12; + var pad = o.pad === undefined ? 12 : o.pad; // if the item is too big, just give it enough automargin to // make sure you can still grab it and bring it back diff --git a/test/image/baselines/range_slider.png b/test/image/baselines/range_slider.png new file mode 100644 index 00000000000..a70059ec3e2 Binary files /dev/null and b/test/image/baselines/range_slider.png differ diff --git a/test/image/mocks/range_slider.json b/test/image/mocks/range_slider.json new file mode 100644 index 00000000000..66ea5dc6767 --- /dev/null +++ b/test/image/mocks/range_slider.json @@ -0,0 +1,35 @@ +{ + "data": [{ + "x": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49], + "y": [61.54407276195499, 53.01489477283346, 54.27395817178983, 53.55001411571369, 78.9775866748205, 31.884243474727825, 56.77854253236383, 4.735201078365101, 63.12484997271903, 43.09882925106262, 43.724822753247224, 24.844310873420152, 34.89218498332549, 36.0460327216264, 28.2174958198741, 29.273481072578775, 70.89188615102645, 59.76636236708869, 66.76528535512163, 11.281051334701928, 78.37153154211197, 66.08349166666542, 50.09727630364335, 58.04698479499695, 59.74272576303902, 41.86433349552978, 48.22485560857029, 37.70855269433609, 21.119967245011217, 75.10194864698312, 55.48369213601815, 59.735088561204854, 60.42028696627966, 0.10938359086638982, 77.3773017472981, 15.086946383114501, 35.82836446307611, 23.689280033989295, 2.362484581742592, 53.40692073882494, 15.134375465281735, 7.3434664768826075, 54.52433252576499, 35.798885397722806, 38.26472971248782, 17.920068000491725, 37.0479742805594, 45.83239633915396, 43.99138484769564, 69.43228444571429], + "line": { + "shape": "spline", + "smoothing": 1 + }, + "fill": "tozeroy", + "name": "Bird 1" + }, { + "x": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49], + "y": [42.61173967030771, 77.84512822099697, 55.39613813617828, 4.0231685798691785, 34.55285260557025, 15.153333721685467, 77.54957038861798, 75.64407363993858, 62.12340236506287, 68.02318161065071, 34.816275599609554, 12.97751450053445, 25.16704221023005, 39.27425976595971, 41.98830052124046, 32.88934713126341, 64.3908328308427, 31.152165702544288, 53.28979283850701, 47.831576036783474, 48.46112255786373, 13.019775174742367, 44.95556684431092, 62.13365414186855, 22.796080092935433, 53.88638249610922, 9.378276407428476, 54.87772961864103, 10.622983213205028, 31.428637782080635, 63.81786719853103, 68.85325763593313, 30.412750136966178, 29.161036950263153, 19.125624934754217, 1.7788679495654591, 31.62390207284494, 33.722958647807374, 35.3846908579102, 41.364380830218224, 24.993662278935265, 39.083235916470116, 59.576257951955824, 33.620531907520075, 11.721918308606636, 10.943094423029738, 20.385230482706316, 39.62659948914896, 72.20357742148559, 38.48711164016425], + "name": "Bird 2" + }], + "layout": { + "title": "Seagull Positions", + "xaxis": { + "rangeslider": { + "visible": true, + "thickness": 0.2, + "bgcolor": "#fafafa", + "bordercolor": "black", + "borderwidth": 2 + }, + "title": "Time (Hours)" + }, + "yaxis": { + "title": "Height (ft)" + }, + "paper_bgcolor": "#eee", + "height": 500, + "width": 800 + } +} diff --git a/test/jasmine/tests/range_slider_test.js b/test/jasmine/tests/range_slider_test.js new file mode 100644 index 00000000000..045597f9052 --- /dev/null +++ b/test/jasmine/tests/range_slider_test.js @@ -0,0 +1,317 @@ +var Plotly = require('@lib/index'); +var RangeSlider = require('@src/components/rangeslider'); +var createGraphDiv = require('../assets/create_graph_div'); +var destroyGraphDiv = require('../assets/destroy_graph_div'); +var mock = require('../../image/mocks/range_slider.json'); +var mouseEvent = require('../assets/mouse_event'); + +describe('the range slider', function() { + + var gd, + rangeSlider, + children; + + describe('when specified as visible', function() { + + beforeEach(function(done) { + gd = createGraphDiv(); + + Plotly.plot(gd, mock.data, mock.layout).then(function() { + rangeSlider = document.getElementsByClassName('range-slider')[0]; + children = rangeSlider.children; + done(); + }); + }); + + afterEach(destroyGraphDiv); + + it('should be added to the DOM when specified', function() { + expect(rangeSlider).toBeDefined(); + }); + + it('should have the correct width and height', function() { + var bg = children[0]; + + var options = mock.layout.xaxis.rangeslider, + expectedWidth = gd._fullLayout._size.w + options.borderwidth; + + // width incorporates border widths + expect(+bg.getAttribute('width')).toEqual(expectedWidth); + expect(+bg.getAttribute('height')).toEqual(66); + }); + + it('should have the correct style', function() { + var bg = children[0]; + + expect(bg.getAttribute('fill')).toBe('#fafafa'); + expect(bg.getAttribute('stroke')).toBe('black'); + expect(bg.getAttribute('stroke-width')).toBe('2'); + }); + + it('should react to resizing the minimum handle', function() { + var start = 85, + end = 140, + dataMinStart = rangeSlider.getAttribute('data-min'), + diff = end - start; + + slide(start, 400, end, 400); + + var maskMin = children[2], + handleMin = children[5]; + + expect(rangeSlider.getAttribute('data-min')).toEqual(String(+dataMinStart + diff)); + expect(maskMin.getAttribute('width')).toEqual(String(diff)); + expect(handleMin.getAttribute('transform')).toBe('translate(' + (diff - 3) + ')'); + }); + + it('should react to resizing the maximum handle', function() { + var start = 705, + end = 500, + dataMaxStart = rangeSlider.getAttribute('data-max'), + diff = end - start; + + slide(start, 400, end, 400); + + var maskMax = children[3], + handleMax = children[6]; + + expect(rangeSlider.getAttribute('data-max')).toEqual(String(+dataMaxStart + diff)); + expect(maskMax.getAttribute('width')).toEqual(String(-diff)); + expect(handleMax.getAttribute('transform')).toBe('translate(' + (+dataMaxStart + diff) + ')'); + }); + + it('should react to moving the slidebox left to right', function() { + var start = 250, + end = 300, + dataMinStart = rangeSlider.getAttribute('data-min'), + diff = end - start; + + slide(start, 400, end, 400); + + var maskMin = children[2], + handleMin = children[5]; + + expect(rangeSlider.getAttribute('data-min')).toEqual(String(+dataMinStart + diff)); + expect(maskMin.getAttribute('width')).toEqual(String(diff)); + expect(handleMin.getAttribute('transform')).toEqual('translate(' + (+dataMinStart + diff - 3) + ')'); + }); + + it('should react to moving the slidebox right to left', function() { + var start = 300, + end = 250, + dataMaxStart = rangeSlider.getAttribute('data-max'), + diff = end - start; + + slide(start, 400, end, 400); + + var maskMax = children[3], + handleMax = children[6]; + + expect(rangeSlider.getAttribute('data-max')).toEqual(String(+dataMaxStart + diff)); + expect(maskMax.getAttribute('width')).toEqual(String(-diff)); + expect(handleMax.getAttribute('transform')).toEqual('translate(' + (+dataMaxStart + diff) + ')'); + }); + + it('should resize the main plot when rangeslider has moved', function() { + var start = 300, + end = 400, + rangeDiff1 = gd._fullLayout.xaxis.range[1] - gd._fullLayout.xaxis.range[0]; + + slide(start, 400, end, 400); + + var rangeDiff2 = gd._fullLayout.xaxis.range[1] - gd._fullLayout.xaxis.range[0]; + + expect(rangeDiff2).toBeLessThan(rangeDiff1); + + start = 400; + end = 200; + + slide(start, 400, end, 400); + + var rangeDiff3 = gd._fullLayout.xaxis.range[1] - gd._fullLayout.xaxis.range[0]; + + expect(rangeDiff3).toBeLessThan(rangeDiff2); + }); + }); + + + describe('visibility property', function() { + beforeEach(function() { + gd = createGraphDiv(); + }); + + afterEach(destroyGraphDiv); + + it('should not add the slider to the DOM by default', function(done) { + Plotly.plot(gd, [{ x: [1,2,3], y: [2,3,4] }], {}) + .then(function() { + var rangeSlider = document.getElementsByClassName('range-slider')[0]; + expect(rangeSlider).not.toBeDefined(); + }) + .then(done); + }); + + it('should add the slider if rangeslider is set to anything', function(done) { + Plotly.plot(gd, [{ x: [1,2,3], y: [2,3,4] }], {}) + .then(function() { Plotly.relayout(gd, 'xaxis.rangeslider', 'exists'); }) + .then(function() { + var rangeSlider = document.getElementsByClassName('range-slider')[0]; + expect(rangeSlider).toBeDefined(); + }) + .then(done); + }); + + it('should add the slider if visible changed to `true`', function(done) { + Plotly.plot(gd, [{ x: [1,2,3], y: [2,3,4] }], {}) + .then(function() { Plotly.relayout(gd, 'xaxis.rangeslider.visible', true); }) + .then(function() { + var rangeSlider = document.getElementsByClassName('range-slider')[0]; + expect(rangeSlider).toBeDefined(); + }) + .then(done); + }); + + it('should remove the slider if changed to `false` or `undefined`', function(done) { + Plotly.plot(gd, [{ x: [1,2,3], y: [2,3,4] }], { xaxis: { rangeslider: { visible: true }}}) + .then(function() { Plotly.relayout(gd, 'xaxis.rangeslider.visible', false); }) + .then(function() { + var rangeSlider = document.getElementsByClassName('range-slider')[0]; + expect(rangeSlider).not.toBeDefined(); + }) + .then(done); + }); + }); + + describe('supplyLayoutDefaults function', function() { + + it('should not coerce anything if rangeslider isn\'t set', function() { + var layoutIn = { xaxis: {}, yaxis: {}}, + layoutOut = { xaxis: {}, yaxis: {}}, + axName = 'xaxis', + counterAxes = ['yaxis'], + expected = { xaxis: {}, yaxis: {}}; + + RangeSlider.supplyLayoutDefaults(layoutIn, layoutOut, axName, counterAxes); + + expect(layoutIn).toEqual(expected); + }); + + it('should not mutate layoutIn', function() { + var layoutIn = { xaxis: { rangeslider: { visible: true }}, yaxis: {}}, + layoutOut = { xaxis: { rangeslider: {}}, yaxis: {}}, + axName = 'xaxis', + counterAxes = ['yaxis'], + expected = { xaxis: { rangeslider: { visible: true }}, yaxis: {}}; + + RangeSlider.supplyLayoutDefaults(layoutIn, layoutOut, axName, counterAxes); + + expect(layoutIn).toEqual(expected); + }); + + it('should set defaults if rangeslider is set to anything truthy', function() { + var layoutIn = { xaxis: { rangeslider: {}}, yaxis: {}}, + layoutOut = { xaxis: {}, yaxis: {}}, + axName = 'xaxis', + counterAxes = ['yaxis'], + expected = { + xaxis: { + rangeslider: { + visible: true, + thickness: 0.15, + bgcolor: '#fff', + borderwidth: 0, + bordercolor: '#444' + } + }, + yaxis: { + fixedrange: true + } + }; + + RangeSlider.supplyLayoutDefaults(layoutIn, layoutOut, axName, counterAxes); + + expect(layoutOut).toEqual(expected); + }); + + it('should set defaults if rangeslider.visible is true', function() { + var layoutIn = { xaxis: { rangeslider: { visible: true }}, yaxis: {}}, + layoutOut = { xaxis: { rangeslider: {}}, yaxis: {}}, + axName = 'xaxis', + counterAxes = ['yaxis'], + expected = { + xaxis: { + rangeslider: { + visible: true, + thickness: 0.15, + bgcolor: '#fff', + borderwidth: 0, + bordercolor: '#444' + } + }, + yaxis: { + fixedrange: true + } + }; + + RangeSlider.supplyLayoutDefaults(layoutIn, layoutOut, axName, counterAxes); + + expect(layoutOut).toEqual(expected); + }); + + it('should set defaults if properties are invalid', function() { + var layoutIn = { xaxis: { rangeslider: { + visible: 'invalid', + thickness: 'invalid', + bgcolor: 42, + bordercolor: 42, + borderwidth: 'superfat' + }}, yaxis: {}}, + layoutOut = { xaxis: {}, yaxis: {}}, + axName = 'xaxis', + counterAxes = ['yaxis'], + expected = { xaxis: { rangeslider: { + visible: true, + thickness: 0.15, + bgcolor: '#fff', + borderwidth: 0, + bordercolor: '#444' + }}, yaxis: { + fixedrange: true + }}; + + RangeSlider.supplyLayoutDefaults(layoutIn, layoutOut, axName, counterAxes); + + expect(layoutOut).toEqual(expected); + }); + + it('should set all counterAxes to fixedrange', function() { + var layoutIn = { xaxis: { rangeslider: true}, yaxis: {}, yaxis2: {}}, + layoutOut = { xaxis: {}, yaxis: {}, yaxis2: {}}, + axName = 'xaxis', + counterAxes = ['yaxis', 'yaxis2'], + expected = { + xaxis: { rangeslider: { + visible: true, + thickness: 0.15, + bgcolor: '#fff', + borderwidth: 0, + bordercolor: '#444' + }}, + yaxis: { fixedrange: true}, + yaxis2: { fixedrange: true } + }; + + RangeSlider.supplyLayoutDefaults(layoutIn, layoutOut, axName, counterAxes); + + expect(layoutOut).toEqual(expected); + }); + }); +}); + + +function slide(fromX, fromY, toX, toY) { + mouseEvent('mousemove', fromX, fromY); + mouseEvent('mousedown', fromX, fromY); + mouseEvent('mousemove', toX, toY); + mouseEvent('mouseup', toX, toY); +}