diff --git a/src/components/sliders/attributes.js b/src/components/sliders/attributes.js new file mode 100644 index 00000000000..420287f7ded --- /dev/null +++ b/src/components/sliders/attributes.js @@ -0,0 +1,269 @@ +/** +* 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 fontAttrs = require('../../plots/font_attributes'); +var padAttrs = require('../../plots/pad_attributes'); +var extendFlat = require('../../lib/extend').extendFlat; +var animationAttrs = require('../../plots/animation_attributes'); +var constants = require('./constants'); + +var stepsAttrs = { + _isLinkedToArray: true, + + method: { + valType: 'enumerated', + values: ['restyle', 'relayout', 'animate', 'update'], + dflt: 'restyle', + role: 'info', + description: [ + 'Sets the Plotly method to be called when the slider value is changed.' + ].join(' ') + }, + args: { + valType: 'info_array', + role: 'info', + freeLength: true, + items: [ + { valType: 'any' }, + { valType: 'any' }, + { valType: 'any' } + ], + description: [ + 'Sets the arguments values to be passed to the Plotly', + 'method set in `method` on slide.' + ].join(' ') + }, + label: { + valType: 'string', + role: 'info', + description: 'Sets the text label to appear on the slider' + }, + value: { + valType: 'string', + role: 'info', + description: [ + 'Sets the value of the slider step, used to refer to the step programatically.', + 'Defaults to the slider label if not provided.' + ].join(' ') + } +}; + +module.exports = { + visible: { + valType: 'boolean', + role: 'info', + dflt: true, + description: [ + 'Determines whether or not the slider is visible.' + ].join(' ') + }, + + active: { + valType: 'number', + role: 'info', + min: -10, + dflt: 0, + description: [ + 'Determines which button (by index starting from 0) is', + 'considered active.' + ].join(' ') + }, + + steps: stepsAttrs, + + lenmode: { + valType: 'enumerated', + values: ['fraction', 'pixels'], + role: 'info', + dflt: 'fraction', + description: [ + 'Determines whether this slider length', + 'is set in units of plot *fraction* or in *pixels.', + 'Use `len` to set the value.' + ].join(' ') + }, + len: { + valType: 'number', + min: 0, + dflt: 1, + role: 'style', + description: [ + 'Sets the length of the slider', + 'This measure excludes the padding of both ends.', + 'That is, the slider\'s length is this length minus the', + 'padding on both ends.' + ].join(' ') + }, + x: { + valType: 'number', + min: -2, + max: 3, + dflt: -0.05, + role: 'style', + description: 'Sets the x position (in normalized coordinates) of the slider.' + }, + pad: extendFlat({}, padAttrs, { + description: 'Set the padding of the slider component along each side.' + }), + xanchor: { + valType: 'enumerated', + values: ['auto', 'left', 'center', 'right'], + dflt: 'left', + role: 'info', + description: [ + 'Sets the slider\'s horizontal position anchor.', + 'This anchor binds the `x` position to the *left*, *center*', + 'or *right* of the range selector.' + ].join(' ') + }, + y: { + valType: 'number', + min: -2, + max: 3, + dflt: 1, + role: 'style', + description: 'Sets the y position (in normalized coordinates) of the slider.' + }, + yanchor: { + valType: 'enumerated', + values: ['auto', 'top', 'middle', 'bottom'], + dflt: 'bottom', + role: 'info', + description: [ + 'Sets the slider\'s vertical position anchor', + 'This anchor binds the `y` position to the *top*, *middle*', + 'or *bottom* of the range selector.' + ].join(' ') + }, + + transition: { + duration: { + valType: 'number', + role: 'info', + min: 0, + dflt: 150, + description: 'Sets the duration of the slider transition' + }, + easing: { + valType: 'enumerated', + values: animationAttrs.transition.easing.values, + role: 'info', + dflt: 'cubic-in-out', + description: 'Sets the easing function of the slider transition' + }, + }, + + currentvalue: { + visible: { + valType: 'boolean', + role: 'info', + dflt: true, + description: [ + 'Shows the currently-selected value above the slider.' + ].join(' ') + }, + + xanchor: { + valType: 'enumerated', + values: ['left', 'center', 'right'], + dflt: 'left', + role: 'info', + description: [ + 'The alignment of the value readout relative to the length of the slider.' + ].join(' ') + }, + + offset: { + valType: 'number', + dflt: 10, + role: 'info', + description: [ + 'The amount of space, in pixels, between the current value label', + 'and the slider.' + ].join(' ') + }, + + prefix: { + valType: 'string', + role: 'info', + description: 'When currentvalue.visible is true, this sets the prefix of the label.' + }, + + suffix: { + valType: 'string', + role: 'info', + description: 'When currentvalue.visible is true, this sets the suffix of the label.' + }, + + font: extendFlat({}, fontAttrs, { + description: 'Sets the font of the current value label text.' + }), + }, + + font: extendFlat({}, fontAttrs, { + description: 'Sets the font of the slider step labels.' + }), + + activebgcolor: { + valType: 'color', + role: 'style', + dflt: constants.gripBgActiveColor, + description: [ + 'Sets the background color of the slider grip', + 'while dragging.' + ].join(' ') + }, + bgcolor: { + valType: 'color', + role: 'style', + dflt: constants.railBgColor, + description: 'Sets the background color of the slider.' + }, + bordercolor: { + valType: 'color', + dflt: constants.railBorderColor, + role: 'style', + description: 'Sets the color of the border enclosing the slider.' + }, + borderwidth: { + valType: 'number', + min: 0, + dflt: constants.railBorderWidth, + role: 'style', + description: 'Sets the width (in px) of the border enclosing the slider.' + }, + ticklen: { + valType: 'number', + min: 0, + dflt: constants.tickLength, + role: 'style', + description: 'Sets the length in pixels of step tick marks' + }, + tickcolor: { + valType: 'color', + dflt: constants.tickColor, + role: 'style', + description: 'Sets the color of the border enclosing the slider.' + }, + tickwidth: { + valType: 'number', + min: 0, + dflt: 1, + role: 'style', + description: 'Sets the tick width (in px).' + }, + minorticklen: { + valType: 'number', + min: 0, + dflt: constants.minorTickLength, + role: 'style', + description: 'Sets the length in pixels of minor step tick marks' + }, +}; diff --git a/src/components/sliders/constants.js b/src/components/sliders/constants.js new file mode 100644 index 00000000000..cb13d711d56 --- /dev/null +++ b/src/components/sliders/constants.js @@ -0,0 +1,96 @@ +/** +* 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 = { + + // layout attribute names + name: 'sliders', + itemName: 'slider', + + // class names + containerClassName: 'slider-container', + groupClassName: 'slider-group', + inputAreaClass: 'slider-input-area', + railRectClass: 'slider-rail-rect', + railTouchRectClass: 'slider-rail-touch-rect', + gripRectClass: 'slider-grip-rect', + tickRectClass: 'slider-tick-rect', + inputProxyClass: 'slider-input-proxy', + labelsClass: 'slider-labels', + labelGroupClass: 'slider-label-group', + labelClass: 'slider-label', + currentValueClass: 'slider-current-value', + + railHeight: 5, + + // DOM attribute name in button group keeping track + // of active update menu + menuIndexAttrName: 'slider-active-index', + + // id root pass to Plots.autoMargin + autoMarginIdRoot: 'slider-', + + // min item width / height + minWidth: 30, + minHeight: 30, + + // padding around item text + textPadX: 40, + + // font size to height scale + fontSizeToHeight: 1.3, + + // arrow offset off right edge + arrowOffsetX: 4, + + railRadius: 2, + railWidth: 5, + railBorder: 4, + railBorderWidth: 1, + railBorderColor: '#bec8d9', + railBgColor: '#f8fafc', + + // The distance of the rail from the edge of the touchable area + // Slightly less than the step inset because of the curved edges + // of the rail + railInset: 8, + + // The distance from the extremal tick marks to the edge of the + // touchable area. This is basically the same as the grip radius, + // but for other styles it wouldn't really need to be. + stepInset: 10, + + gripRadius: 10, + gripWidth: 20, + gripHeight: 20, + gripBorder: 20, + gripBorderWidth: 1, + gripBorderColor: '#bec8d9', + gripBgColor: '#f6f8fa', + gripBgActiveColor: '#dbdde0', + + labelPadding: 8, + labelOffset: 0, + + tickWidth: 1, + tickColor: '#333', + tickOffset: 25, + tickLength: 7, + + minorTickOffset: 25, + minorTickColor: '#333', + minorTickLength: 4, + + // Extra space below the current value label: + currentValuePadding: 8, + currentValueInset: 0, +}; diff --git a/src/components/sliders/defaults.js b/src/components/sliders/defaults.js new file mode 100644 index 00000000000..b4b3bdce900 --- /dev/null +++ b/src/components/sliders/defaults.js @@ -0,0 +1,121 @@ +/** +* 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'); +var constants = require('./constants'); + +var name = constants.name; +var stepAttrs = attributes.steps; + + +module.exports = function slidersDefaults(layoutIn, layoutOut) { + var contIn = Array.isArray(layoutIn[name]) ? layoutIn[name] : [], + contOut = layoutOut[name] = []; + + for(var i = 0; i < contIn.length; i++) { + var sliderIn = contIn[i] || {}, + sliderOut = {}; + + sliderDefaults(sliderIn, sliderOut, layoutOut); + + // used on button click to update the 'active' field + sliderOut._input = sliderIn; + + // used to determine object constancy + sliderOut._index = i; + + contOut.push(sliderOut); + } +}; + +function sliderDefaults(sliderIn, sliderOut, layoutOut) { + + function coerce(attr, dflt) { + return Lib.coerce(sliderIn, sliderOut, attributes, attr, dflt); + } + + var steps = stepsDefaults(sliderIn, sliderOut); + + var visible = coerce('visible', steps.length > 0); + if(!visible) return; + + coerce('active'); + + coerce('x'); + coerce('y'); + Lib.noneOrAll(sliderIn, sliderOut, ['x', 'y']); + + coerce('xanchor'); + coerce('yanchor'); + + coerce('len'); + coerce('lenmode'); + + coerce('pad.t'); + coerce('pad.r'); + coerce('pad.b'); + coerce('pad.l'); + + Lib.coerceFont(coerce, 'font', layoutOut.font); + + var currentValueIsVisible = coerce('currentvalue.visible'); + + if(currentValueIsVisible) { + coerce('currentvalue.xanchor'); + coerce('currentvalue.prefix'); + coerce('currentvalue.suffix'); + coerce('currentvalue.offset'); + + Lib.coerceFont(coerce, 'currentvalue.font', sliderOut.font); + } + + coerce('transition.duration'); + coerce('transition.easing'); + + coerce('bgcolor'); + coerce('activebgcolor'); + coerce('bordercolor'); + coerce('borderwidth'); + coerce('ticklen'); + coerce('tickwidth'); + coerce('tickcolor'); + coerce('minorticklen'); +} + +function stepsDefaults(sliderIn, sliderOut) { + var valuesIn = sliderIn.steps || [], + valuesOut = sliderOut.steps = []; + + var valueIn, valueOut; + + function coerce(attr, dflt) { + return Lib.coerce(valueIn, valueOut, stepAttrs, attr, dflt); + } + + for(var i = 0; i < valuesIn.length; i++) { + valueIn = valuesIn[i]; + valueOut = {}; + + if(!Lib.isPlainObject(valueIn) || !Array.isArray(valueIn.args)) { + continue; + } + + coerce('method'); + coerce('args'); + coerce('label', 'step-' + i); + coerce('value', valueOut.label); + + valuesOut.push(valueOut); + } + + return valuesOut; +} diff --git a/src/components/sliders/draw.js b/src/components/sliders/draw.js new file mode 100644 index 00000000000..c24e322c385 --- /dev/null +++ b/src/components/sliders/draw.js @@ -0,0 +1,549 @@ +/** +* 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 Plots = require('../../plots/plots'); +var Lib = require('../../lib'); +var Color = require('../color'); +var Drawing = require('../drawing'); +var svgTextUtils = require('../../lib/svg_text_utils'); +var anchorUtils = require('../legend/anchor_utils'); + +var constants = require('./constants'); + + +module.exports = function draw(gd) { + var fullLayout = gd._fullLayout, + sliderData = makeSliderData(fullLayout); + + // draw a container for *all* sliders: + var sliders = fullLayout._infolayer + .selectAll('g.' + constants.containerClassName) + .data(sliderData.length > 0 ? [0] : []); + + sliders.enter().append('g') + .classed(constants.containerClassName, true) + .style('cursor', 'ew-resize'); + + sliders.exit().remove(); + + // If no more sliders, clear the margisn: + if(sliders.exit().size()) clearPushMargins(gd); + + // Return early if no menus visible: + if(sliderData.length === 0) return; + + var sliderGroups = sliders.selectAll('g.' + constants.groupClassName) + .data(sliderData, keyFunction); + + sliderGroups.enter().append('g') + .classed(constants.groupClassName, true); + + sliderGroups.exit().each(function(sliderOpts) { + d3.select(this).remove(); + + Plots.autoMargin(gd, constants.autoMarginIdRoot + sliderOpts._index); + }); + + // Find the dimensions of the sliders: + for(var i = 0; i < sliderData.length; i++) { + var sliderOpts = sliderData[i]; + findDimensions(gd, sliderOpts); + } + + sliderGroups.each(function(sliderOpts) { + // If it has fewer than two options, it's not really a slider: + if(sliderOpts.steps.length < 2) return; + + computeLabelSteps(sliderOpts); + + drawSlider(gd, d3.select(this), sliderOpts); + + // makeInputProxy(gd, d3.select(this), sliderOpts); + + }); +}; + +/* function makeInputProxy(gd, sliderGroup, sliderOpts) { + sliderOpts.inputProxy = gd._fullLayout._paperdiv.selectAll('input.' + constants.inputProxyClass) + .data([0]); +}*/ + +// This really only just filters by visibility: +function makeSliderData(fullLayout) { + var contOpts = fullLayout[constants.name], + sliderData = []; + + for(var i = 0; i < contOpts.length; i++) { + var item = contOpts[i]; + if(!item.visible || !item.steps.length) continue; + sliderData.push(item); + } + + return sliderData; +} + +// This is set in the defaults step: +function keyFunction(opts) { + return opts._index; +} + +// Compute the dimensions (mutates sliderOpts): +function findDimensions(gd, sliderOpts) { + var sliderLabels = gd._tester.selectAll('g.' + constants.labelGroupClass) + .data(sliderOpts.steps); + + sliderLabels.enter().append('g') + .classed(constants.labelGroupClass, true); + + // loop over fake buttons to find width / height + var maxLabelWidth = 0; + var labelHeight = 0; + sliderLabels.each(function(stepOpts) { + var labelGroup = d3.select(this); + + var text = drawLabel(labelGroup, {step: stepOpts}, sliderOpts); + + var tWidth = (text.node() && Drawing.bBox(text.node()).width) || 0; + + // This just overwrites with the last. Which is fine as long as + // the bounding box (probably incorrectly) measures the text *on + // a single line*: + labelHeight = (text.node() && Drawing.bBox(text.node()).height) || 0; + + maxLabelWidth = Math.max(maxLabelWidth, tWidth); + }); + + sliderLabels.remove(); + + sliderOpts.inputAreaWidth = Math.max( + constants.railWidth, + constants.gripHeight + ); + + sliderOpts.currentValueMaxWidth = 0; + sliderOpts.currentValueHeight = 0; + sliderOpts.currentValueTotalHeight = 0; + + if(sliderOpts.currentvalue.visible) { + // Get the dimensions of the current value label: + var dummyGroup = gd._tester.append('g'); + + sliderLabels.each(function(stepOpts) { + var curValPrefix = drawCurrentValue(dummyGroup, sliderOpts, stepOpts.label); + var curValSize = (curValPrefix.node() && Drawing.bBox(curValPrefix.node())) || {width: 0, height: 0}; + sliderOpts.currentValueMaxWidth = Math.max(sliderOpts.currentValueMaxWidth, Math.ceil(curValSize.width)); + sliderOpts.currentValueHeight = Math.max(sliderOpts.currentValueHeight, Math.ceil(curValSize.height)); + }); + + sliderOpts.currentValueTotalHeight = sliderOpts.currentValueHeight + sliderOpts.currentvalue.offset; + + dummyGroup.remove(); + } + + var graphSize = gd._fullLayout._size; + sliderOpts.lx = graphSize.l + graphSize.w * sliderOpts.x; + sliderOpts.ly = graphSize.t + graphSize.h * (1 - sliderOpts.y); + + if(sliderOpts.lenmode === 'fraction') { + // fraction: + sliderOpts.outerLength = Math.round(graphSize.w * sliderOpts.len); + } else { + // pixels: + sliderOpts.outerLength = sliderOpts.len; + } + + // Set the length-wise padding so that the grip ends up *on* the end of + // the bar when at either extreme + sliderOpts.lenPad = Math.round(constants.gripWidth * 0.5); + + // The length of the rail, *excluding* padding on either end: + sliderOpts.inputAreaStart = 0; + sliderOpts.inputAreaLength = Math.round(sliderOpts.outerLength - sliderOpts.pad.l - sliderOpts.pad.r); + + var textableInputLength = sliderOpts.inputAreaLength - 2 * constants.stepInset; + var availableSpacePerLabel = textableInputLength / (sliderOpts.steps.length - 1); + var computedSpacePerLabel = maxLabelWidth + constants.labelPadding; + sliderOpts.labelStride = Math.max(1, Math.ceil(computedSpacePerLabel / availableSpacePerLabel)); + sliderOpts.labelHeight = labelHeight; + + sliderOpts.height = sliderOpts.currentValueTotalHeight + constants.tickOffset + sliderOpts.ticklen + constants.labelOffset + sliderOpts.labelHeight + sliderOpts.pad.t + sliderOpts.pad.b; + + var xanchor = 'left'; + if(anchorUtils.isRightAnchor(sliderOpts)) { + sliderOpts.lx -= sliderOpts.outerLength; + xanchor = 'right'; + } + if(anchorUtils.isCenterAnchor(sliderOpts)) { + sliderOpts.lx -= sliderOpts.outerLength / 2; + xanchor = 'center'; + } + + var yanchor = 'top'; + if(anchorUtils.isBottomAnchor(sliderOpts)) { + sliderOpts.ly -= sliderOpts.height; + yanchor = 'bottom'; + } + if(anchorUtils.isMiddleAnchor(sliderOpts)) { + sliderOpts.ly -= sliderOpts.height / 2; + yanchor = 'middle'; + } + + sliderOpts.outerLength = Math.ceil(sliderOpts.outerLength); + sliderOpts.height = Math.ceil(sliderOpts.height); + sliderOpts.lx = Math.round(sliderOpts.lx); + sliderOpts.ly = Math.round(sliderOpts.ly); + + Plots.autoMargin(gd, constants.autoMarginIdRoot + sliderOpts._index, { + x: sliderOpts.x, + y: sliderOpts.y, + l: sliderOpts.outerLength * ({right: 1, center: 0.5}[xanchor] || 0), + r: sliderOpts.outerLength * ({left: 1, center: 0.5}[xanchor] || 0), + b: sliderOpts.height * ({top: 1, middle: 0.5}[yanchor] || 0), + t: sliderOpts.height * ({bottom: 1, middle: 0.5}[yanchor] || 0) + }); +} + +function drawSlider(gd, sliderGroup, sliderOpts) { + // These are carefully ordered for proper z-ordering: + sliderGroup + .call(drawCurrentValue, sliderOpts) + .call(drawRail, sliderOpts) + .call(drawLabelGroup, sliderOpts) + .call(drawTicks, sliderOpts) + .call(drawTouchRect, gd, sliderOpts) + .call(drawGrip, gd, sliderOpts); + + // Position the rectangle: + Lib.setTranslate(sliderGroup, sliderOpts.lx + sliderOpts.pad.l, sliderOpts.ly + sliderOpts.pad.t); + + setActive(gd, sliderGroup, sliderOpts, sliderOpts.active, false, false); +} + +function drawCurrentValue(sliderGroup, sliderOpts, valueOverride) { + if(!sliderOpts.currentvalue.visible) return; + + var x0, textAnchor; + var text = sliderGroup.selectAll('text') + .data([0]); + + switch(sliderOpts.currentvalue.xanchor) { + case 'right': + // This is anchored left and adjusted by the width of the longest label + // so that the prefix doesn't move. The goal of this is to emphasize + // what's actually changing and make the update less distracting. + x0 = sliderOpts.inputAreaLength - constants.currentValueInset - sliderOpts.currentValueMaxWidth; + textAnchor = 'left'; + break; + case 'center': + x0 = sliderOpts.inputAreaLength * 0.5; + textAnchor = 'middle'; + break; + default: + x0 = constants.currentValueInset; + textAnchor = 'left'; + } + + text.enter().append('text') + .classed(constants.labelClass, true) + .classed('user-select-none', true) + .attr('text-anchor', textAnchor); + + var str = sliderOpts.currentvalue.prefix ? sliderOpts.currentvalue.prefix : ''; + + if(typeof valueOverride === 'string') { + str += valueOverride; + } else { + var curVal = sliderOpts.steps[sliderOpts.active].label; + str += curVal; + } + + if(sliderOpts.currentvalue.suffix) { + str += sliderOpts.currentvalue.suffix; + } + + text.call(Drawing.font, sliderOpts.currentvalue.font) + .text(str) + .call(svgTextUtils.convertToTspans); + + Lib.setTranslate(text, x0, sliderOpts.currentValueHeight); + + return text; +} + +function drawGrip(sliderGroup, gd, sliderOpts) { + var grip = sliderGroup.selectAll('rect.' + constants.gripRectClass) + .data([0]); + + grip.enter().append('rect') + .classed(constants.gripRectClass, true) + .call(attachGripEvents, gd, sliderGroup, sliderOpts) + .style('pointer-events', 'all'); + + grip.attr({ + width: constants.gripWidth, + height: constants.gripHeight, + rx: constants.gripRadius, + ry: constants.gripRadius, + }) + .call(Color.stroke, sliderOpts.bordercolor) + .call(Color.fill, sliderOpts.bgcolor) + .style('stroke-width', sliderOpts.borderwidth + 'px'); +} + +function drawLabel(item, data, sliderOpts) { + var text = item.selectAll('text') + .data([0]); + + text.enter().append('text') + .classed(constants.labelClass, true) + .classed('user-select-none', true) + .attr('text-anchor', 'middle'); + + text.call(Drawing.font, sliderOpts.font) + .text(data.step.label) + .call(svgTextUtils.convertToTspans); + + return text; +} + +function drawLabelGroup(sliderGroup, sliderOpts) { + var labels = sliderGroup.selectAll('g.' + constants.labelsClass) + .data([0]); + + labels.enter().append('g') + .classed(constants.labelsClass, true); + + var labelItems = labels.selectAll('g.' + constants.labelGroupClass) + .data(sliderOpts.labelSteps); + + labelItems.enter().append('g') + .classed(constants.labelGroupClass, true); + + labelItems.exit().remove(); + + labelItems.each(function(d) { + var item = d3.select(this); + + item.call(drawLabel, d, sliderOpts); + + Lib.setTranslate(item, + normalizedValueToPosition(sliderOpts, d.fraction), + constants.tickOffset + sliderOpts.ticklen + sliderOpts.labelHeight + constants.labelOffset + sliderOpts.currentValueTotalHeight + ); + }); + +} + +function handleInput(gd, sliderGroup, sliderOpts, normalizedPosition, doTransition) { + var quantizedPosition = Math.round(normalizedPosition * (sliderOpts.steps.length - 1)); + + if(quantizedPosition !== sliderOpts.active) { + setActive(gd, sliderGroup, sliderOpts, quantizedPosition, true, doTransition); + } +} + +function setActive(gd, sliderGroup, sliderOpts, index, doCallback, doTransition) { + sliderOpts._input.active = sliderOpts.active = index; + + var step = sliderOpts.steps[sliderOpts.active]; + + sliderGroup.call(setGripPosition, sliderOpts, sliderOpts.active / (sliderOpts.steps.length - 1), doTransition); + sliderGroup.call(drawCurrentValue, sliderOpts); + + if(step && step.method && doCallback) { + if(sliderGroup._nextMethod) { + // If we've already queued up an update, just overwrite it with the most recent: + sliderGroup._nextMethod.step = step; + sliderGroup._nextMethod.doCallback = doCallback; + sliderGroup._nextMethod.doTransition = doTransition; + } else { + sliderGroup._nextMethod = {step: step, doCallback: doCallback, doTransition: doTransition}; + sliderGroup._nextMethodRaf = window.requestAnimationFrame(function() { + var _step = sliderGroup._nextMethod.step; + var args = _step.args; + if(!_step.method) return; + + sliderOpts._invokingCommand = true; + Plotly[_step.method](gd, args[0], args[1], args[2]).then(function() { + sliderOpts._invokingCommand = false; + }, function() { + sliderOpts._invokingCommand = false; + + // This is not a disaster. Some methods like `animate` reject if interrupted + // and *should* nicely log a warning. + Lib.warn('Warning: Plotly.' + _step.method + ' was called and rejected.'); + }); + + sliderGroup._nextMethod = null; + sliderGroup._nextMethodRaf = null; + }); + } + } +} + +function attachGripEvents(item, gd, sliderGroup, sliderOpts) { + var node = sliderGroup.node(); + var $gd = d3.select(gd); + + item.on('mousedown', function() { + var grip = sliderGroup.select('.' + constants.gripRectClass); + + d3.event.stopPropagation(); + d3.event.preventDefault(); + grip.call(Color.fill, sliderOpts.activebgcolor); + + var normalizedPosition = positionToNormalizedValue(sliderOpts, d3.mouse(node)[0]); + handleInput(gd, sliderGroup, sliderOpts, normalizedPosition, true); + + $gd.on('mousemove', function() { + var normalizedPosition = positionToNormalizedValue(sliderOpts, d3.mouse(node)[0]); + handleInput(gd, sliderGroup, sliderOpts, normalizedPosition, false); + }); + + $gd.on('mouseup', function() { + grip.call(Color.fill, sliderOpts.bgcolor); + $gd.on('mouseup', null); + $gd.on('mousemove', null); + }); + }); +} + +function drawTicks(sliderGroup, sliderOpts) { + var tick = sliderGroup.selectAll('rect.' + constants.tickRectClass) + .data(sliderOpts.steps); + + tick.enter().append('rect') + .classed(constants.tickRectClass, true); + + tick.exit().remove(); + + tick.attr({ + width: sliderOpts.tickwidth + 'px', + 'shape-rendering': 'crispEdges' + }); + + tick.each(function(d, i) { + var isMajor = i % sliderOpts.labelStride === 0; + var item = d3.select(this); + + item + .attr({height: isMajor ? sliderOpts.ticklen : sliderOpts.minorticklen}) + .call(Color.fill, isMajor ? sliderOpts.tickcolor : sliderOpts.tickcolor); + + Lib.setTranslate(item, + normalizedValueToPosition(sliderOpts, i / (sliderOpts.steps.length - 1)) - 0.5 * sliderOpts.tickwidth, + (isMajor ? constants.tickOffset : constants.minorTickOffset) + sliderOpts.currentValueTotalHeight + ); + }); + +} + +function computeLabelSteps(sliderOpts) { + sliderOpts.labelSteps = []; + var i0 = 0; + var nsteps = sliderOpts.steps.length; + + for(var i = i0; i < nsteps; i += sliderOpts.labelStride) { + sliderOpts.labelSteps.push({ + fraction: i / (nsteps - 1), + step: sliderOpts.steps[i] + }); + } +} + +function setGripPosition(sliderGroup, sliderOpts, position, doTransition) { + var grip = sliderGroup.select('rect.' + constants.gripRectClass); + + var x = normalizedValueToPosition(sliderOpts, position); + + var el = grip; + if(doTransition && sliderOpts.transition.duration > 0 && !sliderOpts._invokingCommand) { + el = el.transition() + .duration(sliderOpts.transition.duration) + .ease(sliderOpts.transition.easing); + } + + // Lib.setTranslate doesn't work here becasue of the transition duck-typing. + // It's also not necessary because there are no other transitions to preserve. + el.attr('transform', 'translate(' + (x - constants.gripWidth * 0.5) + ',' + (sliderOpts.currentValueTotalHeight) + ')'); +} + +// Convert a number from [0-1] to a pixel position relative to the slider group container: +function normalizedValueToPosition(sliderOpts, normalizedPosition) { + return sliderOpts.inputAreaStart + constants.stepInset + + (sliderOpts.inputAreaLength - 2 * constants.stepInset) * Math.min(1, Math.max(0, normalizedPosition)); +} + +// Convert a position relative to the slider group to a nubmer in [0, 1] +function positionToNormalizedValue(sliderOpts, position) { + return Math.min(1, Math.max(0, (position - constants.stepInset - sliderOpts.inputAreaStart) / (sliderOpts.inputAreaLength - 2 * constants.stepInset - 2 * sliderOpts.inputAreaStart))); +} + +function drawTouchRect(sliderGroup, gd, sliderOpts) { + var rect = sliderGroup.selectAll('rect.' + constants.railTouchRectClass) + .data([0]); + + rect.enter().append('rect') + .classed(constants.railTouchRectClass, true) + .call(attachGripEvents, gd, sliderGroup, sliderOpts) + .style('pointer-events', 'all'); + + rect.attr({ + width: sliderOpts.inputAreaLength, + height: Math.max(sliderOpts.inputAreaWidth, constants.tickOffset + sliderOpts.ticklen + sliderOpts.labelHeight) + }) + .call(Color.fill, sliderOpts.bgcolor) + .attr('opacity', 0); + + Lib.setTranslate(rect, 0, sliderOpts.currentValueTotalHeight); +} + +function drawRail(sliderGroup, sliderOpts) { + var rect = sliderGroup.selectAll('rect.' + constants.railRectClass) + .data([0]); + + rect.enter().append('rect') + .classed(constants.railRectClass, true); + + var computedLength = sliderOpts.inputAreaLength - constants.railInset * 2; + + rect.attr({ + width: computedLength, + height: constants.railWidth, + rx: constants.railRadius, + ry: constants.railRadius, + 'shape-rendering': 'crispEdges' + }) + .call(Color.stroke, sliderOpts.bordercolor) + .call(Color.fill, sliderOpts.bgcolor) + .style('stroke-width', sliderOpts.borderwidth + 'px'); + + Lib.setTranslate(rect, + constants.railInset, + (sliderOpts.inputAreaWidth - constants.railWidth) * 0.5 + sliderOpts.currentValueTotalHeight + ); +} + +function clearPushMargins(gd) { + var pushMargins = gd._fullLayout._pushmargin || {}, + keys = Object.keys(pushMargins); + + for(var i = 0; i < keys.length; i++) { + var k = keys[i]; + + if(k.indexOf(constants.autoMarginIdRoot) !== -1) { + Plots.autoMargin(gd, k); + } + } +} diff --git a/src/components/sliders/index.js b/src/components/sliders/index.js new file mode 100644 index 00000000000..28e755fd68f --- /dev/null +++ b/src/components/sliders/index.js @@ -0,0 +1,20 @@ +/** +* 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.moduleType = 'component'; + +exports.name = 'sliders'; + +exports.layoutAttributes = require('./attributes'); + +exports.supplyLayoutDefaults = require('./defaults'); + +exports.draw = require('./draw'); diff --git a/src/components/updatemenus/attributes.js b/src/components/updatemenus/attributes.js index eb3a55a766b..5efac53243b 100644 --- a/src/components/updatemenus/attributes.js +++ b/src/components/updatemenus/attributes.js @@ -11,6 +11,7 @@ var fontAttrs = require('../../plots/font_attributes'); var colorAttrs = require('../color/attributes'); var extendFlat = require('../../lib/extend').extendFlat; +var padAttrs = require('../../plots/pad_attributes'); var buttonsAttrs = { _isLinkedToArray: true, @@ -140,6 +141,10 @@ module.exports = { ].join(' ') }, + pad: extendFlat({}, padAttrs, { + description: 'Sets the padding around the buttons or dropdown menu.' + }), + font: extendFlat({}, fontAttrs, { description: 'Sets the font of the update menu button text.' }), diff --git a/src/components/updatemenus/defaults.js b/src/components/updatemenus/defaults.js index 39f662c9003..d32a8c1892a 100644 --- a/src/components/updatemenus/defaults.js +++ b/src/components/updatemenus/defaults.js @@ -11,9 +11,9 @@ var Lib = require('../../lib'); var attributes = require('./attributes'); -var contants = require('./constants'); +var constants = require('./constants'); -var name = contants.name; +var name = constants.name; var buttonAttrs = attributes.buttons; @@ -60,6 +60,11 @@ function menuDefaults(menuIn, menuOut, layoutOut) { coerce('xanchor'); coerce('yanchor'); + coerce('pad.t'); + coerce('pad.r'); + coerce('pad.b'); + coerce('pad.l'); + Lib.coerceFont(coerce, 'font', layoutOut.font); coerce('bgcolor', layoutOut.paper_bgcolor); diff --git a/src/components/updatemenus/draw.js b/src/components/updatemenus/draw.js index 8acf51820ca..6da09277e69 100644 --- a/src/components/updatemenus/draw.js +++ b/src/components/updatemenus/draw.js @@ -108,7 +108,7 @@ module.exports = function draw(gd) { // find dimensions before plotting anything (this mutates menuOpts) for(var i = 0; i < menuData.length; i++) { var menuOpts = menuData[i]; - findDimenstions(gd, menuOpts); + findDimensions(gd, menuOpts); } // draw headers! @@ -169,7 +169,7 @@ function drawHeader(gd, gHeader, gButton, menuOpts) { var active = menuOpts.active, headerOpts = menuOpts.buttons[active] || constants.blankHeaderOpts, - posOpts = { y: 0, yPad: 0, x: 0, xPad: 0, index: 0 }, + posOpts = { y: menuOpts.pad.t, yPad: 0, x: menuOpts.pad.l, xPad: 0, index: 0 }, positionOverrides = { width: menuOpts.headerWidth, height: menuOpts.headerHeight @@ -191,8 +191,8 @@ function drawHeader(gd, gHeader, gButton, menuOpts) { .text('▼'); arrow.attr({ - x: menuOpts.headerWidth - constants.arrowOffsetX, - y: menuOpts.headerHeight / 2 + constants.textOffsetY + x: menuOpts.headerWidth - constants.arrowOffsetX + menuOpts.pad.l, + y: menuOpts.headerHeight / 2 + constants.textOffsetY + menuOpts.pad.t }); header.on('click', function() { @@ -221,6 +221,9 @@ function drawHeader(gd, gHeader, gButton, menuOpts) { } function drawButtons(gd, gHeader, gButton, menuOpts) { + // If this is a set of buttons, set pointer events = all since we play + // some minor games with which container is which in order to simplify + // the drawing of *either* buttons or menus if(!gButton) { gButton = gHeader; gButton.attr('pointer-events', 'all'); @@ -275,8 +278,8 @@ function drawButtons(gd, gHeader, gButton, menuOpts) { } var posOpts = { - x: x0, - y: y0, + x: x0 + menuOpts.pad.l, + y: y0 + menuOpts.pad.t, yPad: constants.gapButton, xPad: constants.gapButton, index: 0, @@ -383,7 +386,7 @@ function styleOnMouseOut(item, menuOpts) { } // find item dimensions (this mutates menuOpts) -function findDimenstions(gd, menuOpts) { +function findDimensions(gd, menuOpts) { menuOpts.width1 = 0; menuOpts.height1 = 0; menuOpts.heights = []; @@ -468,27 +471,30 @@ function findDimenstions(gd, menuOpts) { fakeButtons.remove(); + var paddedWidth = menuOpts.totalWidth + menuOpts.pad.l + menuOpts.pad.r; + var paddedHeight = menuOpts.totalHeight + menuOpts.pad.t + menuOpts.pad.b; + var graphSize = gd._fullLayout._size; menuOpts.lx = graphSize.l + graphSize.w * menuOpts.x; menuOpts.ly = graphSize.t + graphSize.h * (1 - menuOpts.y); var xanchor = 'left'; if(anchorUtils.isRightAnchor(menuOpts)) { - menuOpts.lx -= menuOpts.totalWidth; + menuOpts.lx -= paddedWidth; xanchor = 'right'; } if(anchorUtils.isCenterAnchor(menuOpts)) { - menuOpts.lx -= menuOpts.totalWidth / 2; + menuOpts.lx -= paddedWidth / 2; xanchor = 'center'; } var yanchor = 'top'; if(anchorUtils.isBottomAnchor(menuOpts)) { - menuOpts.ly -= menuOpts.totalHeight; + menuOpts.ly -= paddedHeight; yanchor = 'bottom'; } if(anchorUtils.isMiddleAnchor(menuOpts)) { - menuOpts.ly -= menuOpts.totalHeight / 2; + menuOpts.ly -= paddedHeight / 2; yanchor = 'middle'; } @@ -500,10 +506,10 @@ function findDimenstions(gd, menuOpts) { Plots.autoMargin(gd, constants.autoMarginIdRoot + menuOpts._index, { x: menuOpts.x, y: menuOpts.y, - l: menuOpts.totalWidth * ({right: 1, center: 0.5}[xanchor] || 0), - r: menuOpts.totalWidth * ({left: 1, center: 0.5}[xanchor] || 0), - b: menuOpts.totalHeight * ({top: 1, middle: 0.5}[yanchor] || 0), - t: menuOpts.totalHeight * ({bottom: 1, middle: 0.5}[yanchor] || 0) + l: paddedWidth * ({right: 1, center: 0.5}[xanchor] || 0), + r: paddedWidth * ({left: 1, center: 0.5}[xanchor] || 0), + b: paddedHeight * ({top: 1, middle: 0.5}[yanchor] || 0), + t: paddedHeight * ({bottom: 1, middle: 0.5}[yanchor] || 0) }); } diff --git a/src/core.js b/src/core.js index 00a266c962e..c7da95352ce 100644 --- a/src/core.js +++ b/src/core.js @@ -58,6 +58,7 @@ exports.register([ require('./components/shapes'), require('./components/images'), require('./components/updatemenus'), + require('./components/sliders'), require('./components/rangeslider'), require('./components/rangeselector') ]); diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index 72b40252361..9784f00d5da 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -178,6 +178,7 @@ Plotly.plot = function(gd, data, layout, config) { Registry.getComponentMethod('legend', 'draw')(gd); Registry.getComponentMethod('rangeselector', 'draw')(gd); Registry.getComponentMethod('updatemenus', 'draw')(gd); + Registry.getComponentMethod('sliders', 'draw')(gd); for(i = 0; i < calcdata.length; i++) { cd = calcdata[i]; @@ -303,6 +304,7 @@ Plotly.plot = function(gd, data, layout, config) { Registry.getComponentMethod('rangeslider', 'draw')(gd); Registry.getComponentMethod('rangeselector', 'draw')(gd); Registry.getComponentMethod('updatemenus', 'draw')(gd); + Registry.getComponentMethod('sliders', 'draw')(gd); } function cleanUp() { @@ -1944,6 +1946,15 @@ function _relayout(gd, aobj) { for(i = 0; i < diff; i++) menus.push({}); flags.doplot = true; } + else if(p.parts[0] === 'sliders') { + Lib.extendDeepAll(gd.layout, Lib.objectFromPath(ai, vi)); + + var sliders = gd._fullLayout.sliders || []; + diff = (p.parts[2] + 1) - sliders.length; + + for(i = 0; i < diff; i++) sliders.push({}); + flags.doplot = true; + } // alter gd.layout else { // check whether we can short-circuit a full redraw diff --git a/src/plotly.js b/src/plotly.js index 3f8cba139c0..899696c639c 100644 --- a/src/plotly.js +++ b/src/plotly.js @@ -37,6 +37,7 @@ exports.Shapes = require('./components/shapes'); exports.Legend = require('./components/legend'); exports.Images = require('./components/images'); exports.UpdateMenus = require('./components/updatemenus'); +exports.Sliders = require('./components/sliders'); exports.ModeBar = require('./components/modebar'); // plot api diff --git a/src/plots/attributes.js b/src/plots/attributes.js index a9c09f810fd..69496df0ee3 100644 --- a/src/plots/attributes.js +++ b/src/plots/attributes.js @@ -94,6 +94,8 @@ module.exports = { maxpoints: { valType: 'number', min: 0, + max: 10000, + dflt: 500, role: 'info', description: [ 'Sets the maximum number of points to keep on the plots from an', diff --git a/src/plots/layout_attributes.js b/src/plots/layout_attributes.js index 3aaba1ed04d..44e051ee539 100644 --- a/src/plots/layout_attributes.js +++ b/src/plots/layout_attributes.js @@ -183,6 +183,7 @@ module.exports = { 'annotations': 'annotations', 'shapes': 'shapes', 'images': 'images', - 'updatemenus': 'updatemenus' + 'updatemenus': 'updatemenus', + 'sliders': 'sliders' } }; diff --git a/src/plots/pad_attributes.js b/src/plots/pad_attributes.js new file mode 100644 index 00000000000..bfadb4c54b0 --- /dev/null +++ b/src/plots/pad_attributes.js @@ -0,0 +1,36 @@ +/** +* 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 = { + t: { + valType: 'number', + dflt: 0, + role: 'style', + description: 'The amount of padding (in px) along the top of the component.' + }, + r: { + valType: 'number', + dflt: 0, + role: 'style', + description: 'The amount of padding (in px) on the right side of the component.' + }, + b: { + valType: 'number', + dflt: 0, + role: 'style', + description: 'The amount of padding (in px) along the bottom of the component.' + }, + l: { + valType: 'number', + dflt: 0, + role: 'style', + description: 'The amount of padding (in px) on the left side of the component.' + } +}; diff --git a/tasks/test_syntax.js b/tasks/test_syntax.js index 76d9f631f30..b1ffa7494a9 100644 --- a/tasks/test_syntax.js +++ b/tasks/test_syntax.js @@ -105,7 +105,7 @@ function assertCircularDeps() { // as of v1.17.0 - 2016/09/08 // see https://github.com/plotly/plotly.js/milestone/9 // for more details - var MAX_ALLOWED_CIRCULAR_DEPS = 33; + var MAX_ALLOWED_CIRCULAR_DEPS = 34; if(circularDeps.length > MAX_ALLOWED_CIRCULAR_DEPS) { logs.push('some new circular dependencies were added to src/'); diff --git a/test/image/baselines/sliders.png b/test/image/baselines/sliders.png new file mode 100644 index 00000000000..bb8cbabdb04 Binary files /dev/null and b/test/image/baselines/sliders.png differ diff --git a/test/image/baselines/updatemenus_positioning.png b/test/image/baselines/updatemenus_positioning.png index 4931a807f68..27699ee4298 100644 Binary files a/test/image/baselines/updatemenus_positioning.png and b/test/image/baselines/updatemenus_positioning.png differ diff --git a/test/image/mocks/sliders.json b/test/image/mocks/sliders.json new file mode 100644 index 00000000000..4fb9035f17c --- /dev/null +++ b/test/image/mocks/sliders.json @@ -0,0 +1,173 @@ +{ + "data": [ + { + "x": [0, 1, 2], + "y": [0.5, 1, 2.5] + } + ], + "layout": { + "sliders": [{ + "active": 2, + "steps": [{ + "label": "red", + "method": "restyle", + "args": [{"line.color": "red"}] + }, { + "label": "orange", + "method": "restyle", + "args": [{"line.color": "orange"}] + }, { + "label": "yellow", + "method": "restyle", + "args": [{"line.color": "yellow"}] + }, { + "label": "green", + "method": "restyle", + "args": [{"line.color": "green"}] + }, { + "label": "blue", + "method": "restyle", + "args": [{"line.color": "blue"}] + }, { + "label": "purple", + "method": "restyle", + "args": [{"line.color": "purple"}] + }], + "visible": true, + "x": 0.5, + "len": 0.5, + "xanchor": "right", + "y": -0.1, + "yanchor": "top", + "currentvalue": { + "visible": false + }, + + "transition": { + "duration": 150, + "easing": "cubic-in-out" + }, + + "pad": { + "r": 20, + "t": 60 + }, + + "font": {} + }, { + "active": 4, + "steps": [{ + "label": "red", + "method": "restyle", + "args": [{"marker.color": "red"}] + }, { + "label": "orange", + "method": "restyle", + "args": [{"marker.color": "orange"}] + }, { + "label": "yellow", + "method": "restyle", + "args": [{"marker.color": "yellow"}] + }, { + "label": "green", + "method": "restyle", + "args": [{"marker.color": "green"}] + }, { + "label": "orange", + "method": "restyle", + "args": [{"marker.color": "orange"}] + }, { + "label": "yellow", + "method": "restyle", + "args": [{"marker.color": "yellow"}] + }, { + "label": "green", + "method": "restyle", + "args": [{"marker.color": "green"}] + }, { + "label": "orange", + "method": "restyle", + "args": [{"marker.color": "orange"}] + }, { + "label": "yellow", + "method": "restyle", + "args": [{"marker.color": "yellow"}] + }, { + "label": "green", + "method": "restyle", + "args": [{"marker.color": "green"}] + }, { + "label": "orange", + "method": "restyle", + "args": [{"marker.color": "orange"}] + }, { + "label": "yellow", + "method": "restyle", + "args": [{"marker.color": "yellow"}] + }, { + "label": "green", + "method": "restyle", + "args": [{"marker.color": "green"}] + }, { + "label": "blue", + "method": "restyle", + "args": [{"marker.color": "blue"}] + }, { + "label": "purple", + "method": "restyle", + "args": [{"marker.color": "purple"}] + }], + "visible": true, + "x": 0.5, + "len": 0.5, + "xanchor": "left", + "y": -0.1, + "yanchor": "top", + + "transition": { + "duration": 150, + "easing": "cubic-in-out" + }, + + "pad": { + "l": 20, + "t": 20 + }, + + "bgcolor": "red", + "bordercolor": "blue", + "borderwidth": 2, + "activebgcolor": "green", + "ticklen": 20, + "tickcolor": "purple", + "minorticklen": 10, + "tickwidth": 2, + + "font": { + "color": "purple", + "size": 15 + }, + "currentvalue": { + "prefix": "prefix ", + "suffix": " suffix", + "xanchor": "right", + "font": { + "color": "orange", + "size": 20 + } + } + }], + "xaxis": { + "range": [0, 2], + "autorange": true + }, + "yaxis": { + "type": "linear", + "range": [0, 3], + "autorange": true + }, + "height": 450, + "width": 1100, + "autosize": true + } +} diff --git a/test/image/mocks/updatemenus_positioning.json b/test/image/mocks/updatemenus_positioning.json index ecff0b0c906..77048b4b6d3 100644 --- a/test/image/mocks/updatemenus_positioning.json +++ b/test/image/mocks/updatemenus_positioning.json @@ -24,11 +24,11 @@ "updatemenus": [ { "buttons": [ - {"label": "red", "method": "restyle", "args": ["marker.color", "red"]}, - {"label": "blue", "method": "restyle", "args": ["marker.color", "blue"]}, - {"label": "green", "method": "restyle", "args": ["marker.color", "green"]}, - {"label": "yellow", "method": "restyle", "args": ["marker.color", "yellow"]}, - {"label": "orange", "method": "restyle", "args": ["marker.color", "orange"]} + {"label": "A0", "method": "restyle", "args": ["marker.color", "red"]}, + {"label": "A1", "method": "restyle", "args": ["marker.color", "blue"]}, + {"label": "A2", "method": "restyle", "args": ["marker.color", "green"]}, + {"label": "A3", "method": "restyle", "args": ["marker.color", "yellow"]}, + {"label": "A4", "method": "restyle", "args": ["marker.color", "orange"]} ], "x": 0.3, "y": 1.0, @@ -37,11 +37,11 @@ }, { "buttons": [ - {"label": "red", "method": "restyle", "args": ["marker.color", "red"]}, - {"label": "blue", "method": "restyle", "args": ["marker.color", "blue"]}, - {"label": "green", "method": "restyle", "args": ["marker.color", "green"]}, - {"label": "yellow", "method": "restyle", "args": ["marker.color", "yellow"]}, - {"label": "orange", "method": "restyle", "args": ["marker.color", "orange"]} + {"label": "B1", "method": "restyle", "args": ["marker.color", "red"]}, + {"label": "B2", "method": "restyle", "args": ["marker.color", "blue"]}, + {"label": "B3", "method": "restyle", "args": ["marker.color", "green"]}, + {"label": "B4", "method": "restyle", "args": ["marker.color", "yellow"]}, + {"label": "B5", "method": "restyle", "args": ["marker.color", "orange"]} ], "x": 0.3, "y": 0.66, @@ -51,11 +51,11 @@ }, { "buttons": [ - {"label": "red", "method": "restyle", "args": ["marker.color", "red"]}, - {"label": "blue", "method": "restyle", "args": ["marker.color", "blue"]}, - {"label": "green", "method": "restyle", "args": ["marker.color", "green"]}, - {"label": "yellow", "method": "restyle", "args": ["marker.color", "yellow"]}, - {"label": "orange", "method": "restyle", "args": ["marker.color", "orange"]} + {"label": "C0", "method": "restyle", "args": ["marker.color", "red"]}, + {"label": "C1", "method": "restyle", "args": ["marker.color", "blue"]}, + {"label": "C2", "method": "restyle", "args": ["marker.color", "green"]}, + {"label": "C3", "method": "restyle", "args": ["marker.color", "yellow"]}, + {"label": "C4", "method": "restyle", "args": ["marker.color", "orange"]} ], "x": 0.3, "y": 0.33, @@ -65,25 +65,28 @@ }, { "buttons": [ - {"label": "red", "method": "restyle", "args": ["marker.color", "red"]}, - {"label": "blue", "method": "restyle", "args": ["marker.color", "blue"]}, - {"label": "green", "method": "restyle", "args": ["marker.color", "green"]}, - {"label": "yellow", "method": "restyle", "args": ["marker.color", "yellow"]}, - {"label": "orange", "method": "restyle", "args": ["marker.color", "orange"]} + {"label": "D0", "method": "restyle", "args": ["marker.color", "red"]}, + {"label": "D1", "method": "restyle", "args": ["marker.color", "blue"]}, + {"label": "D2", "method": "restyle", "args": ["marker.color", "green"]}, + {"label": "D3", "method": "restyle", "args": ["marker.color", "yellow"]}, + {"label": "D4", "method": "restyle", "args": ["marker.color", "orange"]} ], "x": 0.3, "y": 0.0, "yanchor": "top", "xanchor": "left", - "direction": "up" + "direction": "up", + "pad": { + "t": 40 + } }, { "buttons": [ - {"label": "red", "method": "restyle", "args": ["marker.color", "red"]}, - {"label": "blue", "method": "restyle", "args": ["marker.color", "blue"]}, - {"label": "green", "method": "restyle", "args": ["marker.color", "green"]}, - {"label": "yellow", "method": "restyle", "args": ["marker.color", "yellow"]}, - {"label": "orange", "method": "restyle", "args": ["marker.color", "orange"]} + {"label": "E0", "method": "restyle", "args": ["marker.color", "red"]}, + {"label": "E1", "method": "restyle", "args": ["marker.color", "blue"]}, + {"label": "E2longgg", "method": "restyle", "args": ["marker.color", "green"]}, + {"label": "E3", "method": "restyle", "args": ["marker.color", "yellow"]}, + {"label": "E4", "method": "restyle", "args": ["marker.color", "orange"]} ], "type": "buttons", "x": -0.12, @@ -94,11 +97,11 @@ }, { "buttons": [ - {"label": "red", "method": "restyle", "args": ["marker.color", "red"]}, - {"label": "blue", "method": "restyle", "args": ["marker.color", "blue"]}, - {"label": "green", "method": "restyle", "args": ["marker.color", "green"]}, - {"label": "yellow", "method": "restyle", "args": ["marker.color", "yellow"]}, - {"label": "orange", "method": "restyle", "args": ["marker.color", "orange"]} + {"label": "F0", "method": "restyle", "args": ["marker.color", "red"]}, + {"label": "F1", "method": "restyle", "args": ["marker.color", "blue"]}, + {"label": "F2longgg", "method": "restyle", "args": ["marker.color", "green"]}, + {"label": "F3", "method": "restyle", "args": ["marker.color", "yellow"]}, + {"label": "F4", "method": "restyle", "args": ["marker.color", "orange"]} ], "type": "buttons", "x": -0.12, @@ -110,8 +113,8 @@ }, { "buttons": [ - {"label": "red", "method": "restyle", "args": ["marker.color", "red"]}, - {"label": "blue", "method": "restyle", "args": ["marker.color", "blue"]} + {"label": "G0", "method": "restyle", "args": ["marker.color", "red"]}, + {"label": "G1", "method": "restyle", "args": ["marker.color", "blue"]} ], "type": "buttons", "x": 1, @@ -121,8 +124,8 @@ }, { "buttons": [ - {"label": "red", "method": "restyle", "args": ["marker.color", "red"]}, - {"label": "blue", "method": "restyle", "args": ["marker.color", "blue"]} + {"label": "H0", "method": "restyle", "args": ["marker.color", "red"]}, + {"label": "H1", "method": "restyle", "args": ["marker.color", "blue"]} ], "type": "buttons", "x": 1, @@ -133,11 +136,11 @@ }, { "buttons": [ - {"label": "red", "method": "restyle", "args": ["marker.color", "red"]}, - {"label": "blue", "method": "restyle", "args": ["marker.color", "blue"]}, - {"label": "green", "method": "restyle", "args": ["marker.color", "green"]}, - {"label": "yellow", "method": "restyle", "args": ["marker.color", "yellow"]}, - {"label": "orange", "method": "restyle", "args": ["marker.color", "orange"]} + {"label": "I0", "method": "restyle", "args": ["marker.color", "red"]}, + {"label": "I1", "method": "restyle", "args": ["marker.color", "blue"]}, + {"label": "I2", "method": "restyle", "args": ["marker.color", "green"]}, + {"label": "I3", "method": "restyle", "args": ["marker.color", "yellow"]}, + {"label": "I4", "method": "restyle", "args": ["marker.color", "orange"]} ], "x": 0.6, "y": 0.9, @@ -146,11 +149,11 @@ }, { "buttons": [ - {"label": "red", "method": "restyle", "args": ["marker.color", "red"]}, - {"label": "blue", "method": "restyle", "args": ["marker.color", "blue"]}, - {"label": "green", "method": "restyle", "args": ["marker.color", "green"]}, - {"label": "yellow", "method": "restyle", "args": ["marker.color", "yellow"]}, - {"label": "orange", "method": "restyle", "args": ["marker.color", "orange"]} + {"label": "J0", "method": "restyle", "args": ["marker.color", "red"]}, + {"label": "J1", "method": "restyle", "args": ["marker.color", "blue"]}, + {"label": "J2", "method": "restyle", "args": ["marker.color", "green"]}, + {"label": "J3", "method": "restyle", "args": ["marker.color", "yellow"]}, + {"label": "J4", "method": "restyle", "args": ["marker.color", "orange"]} ], "x": 0.6, "y": 0.9, @@ -159,8 +162,42 @@ }, { "buttons": [ - {"label": "red", "method": "restyle", "args": ["marker.color", "red"]}, - {"label": "blue", "method": "restyle", "args": ["marker.color", "blue"]} + {"label": "N0", "method": "restyle", "args": ["marker.color", "red"]}, + {"label": "N1", "method": "restyle", "args": ["marker.color", "blue"]}, + {"label": "N2", "method": "restyle", "args": ["marker.color", "green"]}, + {"label": "N3", "method": "restyle", "args": ["marker.color", "yellow"]}, + {"label": "N4", "method": "restyle", "args": ["marker.color", "orange"]} + ], + "x": 0.6, + "y": 0.9, + "yanchor": "bottom", + "xanchor": "right", + "pad": { + "b": 10, + "r": 20 + } + }, + { + "buttons": [ + {"label": "O0", "method": "restyle", "args": ["marker.color", "red"]}, + {"label": "O1", "method": "restyle", "args": ["marker.color", "blue"]}, + {"label": "O2", "method": "restyle", "args": ["marker.color", "green"]}, + {"label": "O3", "method": "restyle", "args": ["marker.color", "yellow"]}, + {"label": "O4", "method": "restyle", "args": ["marker.color", "orange"]} + ], + "x": 0.6, + "y": 0.9, + "yanchor": "top", + "xanchor": "left", + "pad": { + "t": 10, + "l": 20 + } + }, + { + "buttons": [ + {"label": "K0", "method": "restyle", "args": ["marker.color", "red"]}, + {"label": "K1", "method": "restyle", "args": ["marker.color", "blue"]} ], "x": 0.6, "y": 0.4, @@ -170,14 +207,82 @@ }, { "buttons": [ - {"label": "red", "method": "restyle", "args": ["marker.color", "red"]}, - {"label": "blue", "method": "restyle", "args": ["marker.color", "blue"]} + {"label": "P0", "method": "restyle", "args": ["marker.color", "red"]}, + {"label": "P1", "method": "restyle", "args": ["marker.color", "blue"]} + ], + "type": "buttons", + "x": 0.6, + "y": 0.4, + "yanchor": "top", + "xanchor": "left", + "pad": { + "r": 50, + "b": 80 + } + }, + { + "buttons": [ + {"label": "Q0", "method": "restyle", "args": ["marker.color", "red"]}, + {"label": "Q1", "method": "restyle", "args": ["marker.color", "blue"]} + ], + "type": "buttons", + "x": 0.6, + "y": 0.4, + "yanchor": "bottom", + "xanchor": "right", + "pad": { + "l": 50, + "t": 80 + } + }, + { + "buttons": [ + {"label": "L0", "method": "restyle", "args": ["marker.color", "red"]}, + {"label": "L1", "method": "restyle", "args": ["marker.color", "blue"]} ], "type": "buttons", "x": 0.6, "y": 0.4, "yanchor": "bottom", "xanchor": "left" + }, + { + "buttons": [ + {"label": "M0", "method": "restyle", "args": ["marker.color", "red"]}, + {"label": "M1", "method": "restyle", "args": ["marker.color", "blue"]}, + {"label": "M2", "method": "restyle", "args": ["marker.color", "green"]}, + {"label": "M3", "method": "restyle", "args": ["marker.color", "yellow"]}, + {"label": "M4", "method": "restyle", "args": ["marker.color", "orange"]} + ], + "x": 1.0, + "y": 0.5, + "yanchor": "middle", + "xanchor": "center", + "pad": { + "t": 80, + "r": 100, + "b": 80, + "l": 80 + } + }, + { + "buttons": [ + {"label": "R0", "method": "restyle", "args": ["marker.color", "red"]}, + {"label": "R1", "method": "restyle", "args": ["marker.color", "blue"]}, + {"label": "R2", "method": "restyle", "args": ["marker.color", "green"]}, + {"label": "R3", "method": "restyle", "args": ["marker.color", "yellow"]}, + {"label": "R4", "method": "restyle", "args": ["marker.color", "orange"]} + ], + "x": 0.9, + "y": 0.5, + "yanchor": "middle", + "xanchor": "center", + "pad": { + "t": 2, + "r": 50, + "b": 2, + "l": 40 + } } ], "xaxis": { diff --git a/test/jasmine/assets/fail_test.js b/test/jasmine/assets/fail_test.js index 468a7640c59..32cb8a178f9 100644 --- a/test/jasmine/assets/fail_test.js +++ b/test/jasmine/assets/fail_test.js @@ -23,4 +23,7 @@ module.exports = function failTest(error) { } else { expect(error).toBeUndefined(); } + if(error && error.stack) { + console.error(error.stack); + } }; diff --git a/test/jasmine/tests/sliders_test.js b/test/jasmine/tests/sliders_test.js new file mode 100644 index 00000000000..f23a6926f4e --- /dev/null +++ b/test/jasmine/tests/sliders_test.js @@ -0,0 +1,296 @@ +var Sliders = require('@src/components/sliders'); +var constants = require('@src/components/sliders/constants'); + +var d3 = require('d3'); +var Plotly = require('@lib'); +var Lib = require('@src/lib'); +var createGraphDiv = require('../assets/create_graph_div'); +var destroyGraphDiv = require('../assets/destroy_graph_div'); +var fail = require('../assets/fail_test'); + +describe('sliders defaults', function() { + 'use strict'; + + var supply = Sliders.supplyLayoutDefaults; + + var layoutIn, layoutOut; + + beforeEach(function() { + layoutIn = {}; + layoutOut = {}; + }); + + it('should set \'visible\' to false when no steps are present', function() { + layoutIn.sliders = [{ + steps: [{ + method: 'relayout', + args: ['title', 'Hello World'] + }, { + method: 'update', + args: [ { 'marker.size': 20 }, { 'xaxis.range': [0, 10] }, [0, 1] ] + }, { + method: 'animate', + args: [ 'frame1', { transition: { duration: 500, ease: 'cubic-in-out' }}] + }] + }, { + bgcolor: 'red' + }, { + visible: false, + steps: [{ + method: 'relayout', + args: ['title', 'Hello World'] + }] + }]; + + supply(layoutIn, layoutOut); + + expect(layoutOut.sliders[0].visible).toBe(true); + expect(layoutOut.sliders[0].active).toEqual(0); + expect(layoutOut.sliders[0].steps[0].args.length).toEqual(2); + expect(layoutOut.sliders[0].steps[1].args.length).toEqual(3); + expect(layoutOut.sliders[0].steps[2].args.length).toEqual(2); + + expect(layoutOut.sliders[1].visible).toBe(false); + expect(layoutOut.sliders[1].active).toBeUndefined(); + + expect(layoutOut.sliders[2].visible).toBe(false); + expect(layoutOut.sliders[2].active).toBeUndefined(); + }); + + it('should not coerce currentvalue defaults unless currentvalue is visible', function() { + layoutIn.sliders = [{ + currentvalue: { + visible: false, + xanchor: 'left' + }, + steps: [ + {method: 'restyle', args: [], label: 'step0'}, + {method: 'restyle', args: [], label: 'step1'} + ] + }]; + + supply(layoutIn, layoutOut); + + expect(layoutOut.sliders[0].currentvalue.xanchor).toBeUndefined(); + expect(layoutOut.sliders[0].currentvalue.prefix).toBeUndefined(); + expect(layoutOut.sliders[0].currentvalue.suffix).toBeUndefined(); + expect(layoutOut.sliders[0].currentvalue.offset).toBeUndefined(); + expect(layoutOut.sliders[0].currentvalue.font).toBeUndefined(); + }); + + it('should set the default values equal to the labels', function() { + layoutIn.sliders = [{ + steps: [{ + method: 'relayout', args: [], + label: 'Label #1', + value: 'label-1' + }, { + method: 'update', args: [], + label: 'Label #2' + }, { + method: 'animate', args: [], + value: 'lacks-label' + }] + }]; + + supply(layoutIn, layoutOut); + + expect(layoutOut.sliders[0].steps.length).toEqual(3); + expect(layoutOut.sliders[0].steps).toEqual([{ + method: 'relayout', args: [], + label: 'Label #1', + value: 'label-1' + }, { + method: 'update', args: [], + label: 'Label #2', + value: 'Label #2' + }, { + method: 'animate', args: [], + label: 'step-2', + value: 'lacks-label' + }]); + }); + + it('should skip over non-object steps', function() { + layoutIn.sliders = [{ + steps: [ + null, + { + method: 'relayout', + args: ['title', 'Hello World'] + }, + 'remove' + ] + }]; + + supply(layoutIn, layoutOut); + + expect(layoutOut.sliders[0].steps.length).toEqual(1); + expect(layoutOut.sliders[0].steps[0]).toEqual({ + method: 'relayout', + args: ['title', 'Hello World'], + label: 'step-1', + value: 'step-1', + }); + }); + + it('should skip over steps with non-array \'args\' field', function() { + layoutIn.sliders = [{ + steps: [{ + method: 'restyle', + }, { + method: 'relayout', + args: ['title', 'Hello World'] + }, { + method: 'relayout', + args: null + }, {}] + }]; + + supply(layoutIn, layoutOut); + + expect(layoutOut.sliders[0].steps.length).toEqual(1); + expect(layoutOut.sliders[0].steps[0]).toEqual({ + method: 'relayout', + args: ['title', 'Hello World'], + label: 'step-1', + value: 'step-1', + }); + }); + + it('should keep ref to input update menu container', function() { + layoutIn.sliders = [{ + steps: [{ + method: 'relayout', + args: ['title', 'Hello World'] + }] + }, { + bgcolor: 'red' + }, { + visible: false, + steps: [{ + method: 'relayout', + args: ['title', 'Hello World'] + }] + }]; + + supply(layoutIn, layoutOut); + + expect(layoutOut.sliders[0]._input).toBe(layoutIn.sliders[0]); + expect(layoutOut.sliders[1]._input).toBe(layoutIn.sliders[1]); + expect(layoutOut.sliders[2]._input).toBe(layoutIn.sliders[2]); + }); +}); + +describe('update sliders interactions', function() { + 'use strict'; + + var mock = require('@mocks/sliders.json'); + var mockCopy; + + var gd; + + beforeEach(function(done) { + gd = createGraphDiv(); + + mockCopy = Lib.extendDeep({}, mock); + + Plotly.plot(gd, mockCopy.data, mockCopy.layout).then(done); + }); + + afterEach(function() { + Plotly.purge(gd); + destroyGraphDiv(); + }); + + it('should draw only visible sliders', function(done) { + expect(gd._fullLayout._pushmargin['slider-0']).toBeDefined(); + expect(gd._fullLayout._pushmargin['slider-1']).toBeDefined(); + + Plotly.relayout(gd, 'sliders[0].visible', false).then(function() { + assertNodeCount('.' + constants.groupClassName, 1); + expect(gd._fullLayout._pushmargin['slider-0']).toBeUndefined(); + expect(gd._fullLayout._pushmargin['slider-1']).toBeDefined(); + + return Plotly.relayout(gd, 'sliders[1]', null); + }) + .then(function() { + assertNodeCount('.' + constants.groupClassName, 0); + expect(gd._fullLayout._pushmargin['slider-0']).toBeUndefined(); + expect(gd._fullLayout._pushmargin['slider-1']).toBeUndefined(); + + return Plotly.relayout(gd, { + 'sliders[0].visible': true, + 'sliders[1].visible': true + }); + }).then(function() { + assertNodeCount('.' + constants.groupClassName, 1); + expect(gd._fullLayout._pushmargin['slider-0']).toBeDefined(); + expect(gd._fullLayout._pushmargin['slider-1']).toBeUndefined(); + + return Plotly.relayout(gd, { + 'sliders[1]': { + steps: [{ + method: 'relayout', + args: ['title', 'new title'], + label: '1970' + }, { + method: 'relayout', + args: ['title', 'new title'], + label: '1971' + }] + } + }); + }) + .then(function() { + assertNodeCount('.' + constants.groupClassName, 2); + expect(gd._fullLayout._pushmargin['slider-0']).toBeDefined(); + expect(gd._fullLayout._pushmargin['slider-1']).toBeDefined(); + }) + .catch(fail).then(done); + }); + + it('should respond to mouse clicks', function(done) { + var firstGroup = gd._fullLayout._infolayer.select('.' + constants.railTouchRectClass); + var firstGrip = gd._fullLayout._infolayer.select('.' + constants.gripRectClass); + var railNode = firstGroup.node(); + var touchRect = railNode.getBoundingClientRect(); + + var originalFill = firstGrip.style('fill'); + + // Dispatch a click on the right side of the bar: + railNode.dispatchEvent(new MouseEvent('mousedown', { + clientY: touchRect.top + 5, + clientX: touchRect.left + touchRect.width - 5, + })); + + expect(mockCopy.layout.sliders[0].active).toEqual(5); + var mousedownFill = firstGrip.style('fill'); + expect(mousedownFill).not.toEqual(originalFill); + + // Drag to the left side: + gd.dispatchEvent(new MouseEvent('mousemove', { + clientY: touchRect.top + 5, + clientX: touchRect.left + 5, + })); + + var mousemoveFill = firstGrip.style('fill'); + expect(mousemoveFill).toEqual(mousedownFill); + + setTimeout(function() { + expect(mockCopy.layout.sliders[0].active).toEqual(0); + + gd.dispatchEvent(new MouseEvent('mouseup')); + + var mouseupFill = firstGrip.style('fill'); + expect(mouseupFill).toEqual(originalFill); + expect(mockCopy.layout.sliders[0].active).toEqual(0); + + done(); + }, 100); + }); + + function assertNodeCount(query, cnt) { + expect(d3.selectAll(query).size()).toEqual(cnt); + } +}); diff --git a/test/jasmine/tests/updatemenus_test.js b/test/jasmine/tests/updatemenus_test.js index e155bf27eff..f3e1e95a06e 100644 --- a/test/jasmine/tests/updatemenus_test.js +++ b/test/jasmine/tests/updatemenus_test.js @@ -7,6 +7,7 @@ var Lib = require('@src/lib'); var createGraphDiv = require('../assets/create_graph_div'); var destroyGraphDiv = require('../assets/destroy_graph_div'); var TRANSITION_DELAY = 100; +var fail = require('../assets/fail_test'); describe('update menus defaults', function() { 'use strict'; @@ -446,6 +447,60 @@ describe('update menus interactions', function() { }); }); + it('applies padding on all sides', function(done) { + var xy1, xy2; + var firstMenu = d3.select('.' + constants.headerGroupClassName); + var xpad = 80; + var ypad = 60; + + // Position it center-anchored and in the middle of the plot: + Plotly.relayout(gd, { + 'updatemenus[0].x': 0.2, + 'updatemenus[0].y': 0.5, + 'updatemenus[0].xanchor': 'center', + 'updatemenus[0].yanchor': 'middle', + }).then(function() { + // Convert to xy: + xy1 = firstMenu.attr('transform').match(/translate\(([^,]*),\s*([^\)]*)\)/).slice(1).map(parseFloat); + + // Set three of four paddings. This should move it. + return Plotly.relayout(gd, { + 'updatemenus[0].pad.t': ypad, + 'updatemenus[0].pad.r': xpad, + 'updatemenus[0].pad.b': ypad, + 'updatemenus[0].pad.l': xpad, + }); + }).then(function() { + xy2 = firstMenu.attr('transform').match(/translate\(([^,]*),\s*([^\)]*)\)/).slice(1).map(parseFloat); + + expect(xy1[0] - xy2[0]).toEqual(xpad); + expect(xy1[1] - xy2[1]).toEqual(ypad); + }).catch(fail).then(done); + }); + + it('appliesy padding on relayout', function(done) { + var x1, x2; + var firstMenu = d3.select('.' + constants.headerGroupClassName); + var padShift = 40; + + // Position the menu in the center of the plot horizontal so that + // we can test padding updates without worrying about margin pushing. + Plotly.relayout(gd, { + 'updatemenus[0].x': 0.5, + 'updatemenus[0].pad.r': 0, + }).then(function() { + // Extract the x-component of the translation: + x1 = parseInt(firstMenu.attr('transform').match(/translate\(([^,]*).*/)[1]); + + return Plotly.relayout(gd, 'updatemenus[0].pad.r', 40); + }).then(function() { + // Extract the x-component of the translation: + x2 = parseInt(firstMenu.attr('transform').match(/translate\(([^,]*).*/)[1]); + + expect(x1 - x2).toBeCloseTo(padShift, 1); + }).catch(fail).then(done); + }); + function assertNodeCount(query, cnt) { expect(d3.selectAll(query).size()).toEqual(cnt); }