'use strict'; var d3 = require('@plotly/d3'); var Plots = require('../../plots/plots'); var Color = require('../color'); var Drawing = require('../drawing'); var Lib = require('../../lib'); var strTranslate = Lib.strTranslate; var svgTextUtils = require('../../lib/svg_text_utils'); var arrayEditor = require('../../plot_api/plot_template').arrayEditor; var constants = require('./constants'); var alignmentConstants = require('../../constants/alignment'); var LINE_SPACING = alignmentConstants.LINE_SPACING; var FROM_TL = alignmentConstants.FROM_TL; var FROM_BR = alignmentConstants.FROM_BR; module.exports = function draw(gd) { var staticPlot = gd._context.staticPlot; var fullLayout = gd._fullLayout; var sliderData = makeSliderData(fullLayout, gd); // 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', staticPlot ? null : 'ew-resize'); function clearSlider(sliderOpts) { if(sliderOpts._commandObserver) { sliderOpts._commandObserver.remove(); delete sliderOpts._commandObserver; } // Most components don't need to explicitly remove autoMargin, because // marginPushers does this - but slider updates don't go through // a full replot so we need to explicitly remove it. Plots.autoMargin(gd, autoMarginId(sliderOpts)); } sliders.exit().each(function() { d3.select(this).selectAll('g.' + constants.groupClassName) .each(clearSlider); }) .remove(); // 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(clearSlider) .remove(); // 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) { var gSlider = d3.select(this); computeLabelSteps(sliderOpts); Plots.manageCommandObserver(gd, sliderOpts, sliderOpts._visibleSteps, function(data) { // NB: Same as below. This is *not* always the same as sliderOpts since // if a new set of steps comes in, the reference in this callback would // be invalid. We need to refetch it from the slider group, which is // the join data that creates this slider. So if this slider still exists, // the group should be valid, *to the best of my knowledge.* If not, // we'd have to look it up by d3 data join index/key. var opts = gSlider.data()[0]; if(opts.active === data.index) return; if(opts._dragging) return; setActive(gd, gSlider, opts, data.index, false, true); }); drawSlider(gd, d3.select(this), sliderOpts); }); }; function autoMarginId(sliderOpts) { return constants.autoMarginIdRoot + sliderOpts._index; } // This really only just filters by visibility: function makeSliderData(fullLayout, gd) { var contOpts = fullLayout[constants.name]; var sliderData = []; for(var i = 0; i < contOpts.length; i++) { var item = contOpts[i]; if(!item.visible) continue; item._gd = gd; 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 = Drawing.tester.selectAll('g.' + constants.labelGroupClass) .data(sliderOpts._visibleSteps); 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 textNode = text.node(); if(textNode) { var bBox = Drawing.bBox(textNode); labelHeight = Math.max(labelHeight, bBox.height); maxLabelWidth = Math.max(maxLabelWidth, bBox.width); } }); sliderLabels.remove(); var dims = sliderOpts._dims = {}; dims.inputAreaWidth = Math.max( constants.railWidth, constants.gripHeight ); // calculate some overall dimensions - some of these are needed for // calculating the currentValue dimensions var graphSize = gd._fullLayout._size; dims.lx = graphSize.l + graphSize.w * sliderOpts.x; dims.ly = graphSize.t + graphSize.h * (1 - sliderOpts.y); if(sliderOpts.lenmode === 'fraction') { // fraction: dims.outerLength = Math.round(graphSize.w * sliderOpts.len); } else { // pixels: dims.outerLength = sliderOpts.len; } // The length of the rail, *excluding* padding on either end: dims.inputAreaStart = 0; dims.inputAreaLength = Math.round(dims.outerLength - sliderOpts.pad.l - sliderOpts.pad.r); var textableInputLength = dims.inputAreaLength - 2 * constants.stepInset; var availableSpacePerLabel = textableInputLength / (sliderOpts._stepCount - 1); var computedSpacePerLabel = maxLabelWidth + constants.labelPadding; dims.labelStride = Math.max(1, Math.ceil(computedSpacePerLabel / availableSpacePerLabel)); dims.labelHeight = labelHeight; // loop over all possible values for currentValue to find the // area we need for it dims.currentValueMaxWidth = 0; dims.currentValueHeight = 0; dims.currentValueTotalHeight = 0; dims.currentValueMaxLines = 1; if(sliderOpts.currentvalue.visible) { // Get the dimensions of the current value label: var dummyGroup = Drawing.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}; var lines = svgTextUtils.lineCount(curValPrefix); dims.currentValueMaxWidth = Math.max(dims.currentValueMaxWidth, Math.ceil(curValSize.width)); dims.currentValueHeight = Math.max(dims.currentValueHeight, Math.ceil(curValSize.height)); dims.currentValueMaxLines = Math.max(dims.currentValueMaxLines, lines); }); dims.currentValueTotalHeight = dims.currentValueHeight + sliderOpts.currentvalue.offset; dummyGroup.remove(); } dims.height = dims.currentValueTotalHeight + constants.tickOffset + sliderOpts.ticklen + constants.labelOffset + dims.labelHeight + sliderOpts.pad.t + sliderOpts.pad.b; var xanchor = 'left'; if(Lib.isRightAnchor(sliderOpts)) { dims.lx -= dims.outerLength; xanchor = 'right'; } if(Lib.isCenterAnchor(sliderOpts)) { dims.lx -= dims.outerLength / 2; xanchor = 'center'; } var yanchor = 'top'; if(Lib.isBottomAnchor(sliderOpts)) { dims.ly -= dims.height; yanchor = 'bottom'; } if(Lib.isMiddleAnchor(sliderOpts)) { dims.ly -= dims.height / 2; yanchor = 'middle'; } dims.outerLength = Math.ceil(dims.outerLength); dims.height = Math.ceil(dims.height); dims.lx = Math.round(dims.lx); dims.ly = Math.round(dims.ly); var marginOpts = { y: sliderOpts.y, b: dims.height * FROM_BR[yanchor], t: dims.height * FROM_TL[yanchor] }; if(sliderOpts.lenmode === 'fraction') { marginOpts.l = 0; marginOpts.xl = sliderOpts.x - sliderOpts.len * FROM_TL[xanchor]; marginOpts.r = 0; marginOpts.xr = sliderOpts.x + sliderOpts.len * FROM_BR[xanchor]; } else { marginOpts.x = sliderOpts.x; marginOpts.l = dims.outerLength * FROM_TL[xanchor]; marginOpts.r = dims.outerLength * FROM_BR[xanchor]; } Plots.autoMargin(gd, autoMarginId(sliderOpts), marginOpts); } function drawSlider(gd, sliderGroup, sliderOpts) { // This is related to the other long notes in this file regarding what happens // when slider steps disappear. This particular fix handles what happens when // the *current* slider step is removed. The drawing functions will error out // when they fail to find it, so the fix for now is that it will just draw the // slider in the first position but will not execute the command. if(!((sliderOpts.steps[sliderOpts.active] || {}).visible)) { sliderOpts.active = sliderOpts._visibleSteps[0]._index; } // 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); var dims = sliderOpts._dims; // Position the rectangle: Drawing.setTranslate(sliderGroup, dims.lx + sliderOpts.pad.l, dims.ly + sliderOpts.pad.t); sliderGroup.call(setGripPosition, sliderOpts, false); sliderGroup.call(drawCurrentValue, sliderOpts); } function drawCurrentValue(sliderGroup, sliderOpts, valueOverride) { if(!sliderOpts.currentvalue.visible) return; var dims = sliderOpts._dims; var x0, textAnchor; 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 = dims.inputAreaLength - constants.currentValueInset - dims.currentValueMaxWidth; textAnchor = 'left'; break; case 'center': x0 = dims.inputAreaLength * 0.5; textAnchor = 'middle'; break; default: x0 = constants.currentValueInset; textAnchor = 'left'; } var text = Lib.ensureSingle(sliderGroup, 'text', constants.labelClass, function(s) { s.attr({ 'text-anchor': textAnchor, 'data-notex': 1 }); }); var str = sliderOpts.currentvalue.prefix ? sliderOpts.currentvalue.prefix : ''; if(typeof valueOverride === 'string') { str += valueOverride; } else { var curVal = sliderOpts.steps[sliderOpts.active].label; var _meta = sliderOpts._gd._fullLayout._meta; if(_meta) curVal = Lib.templateString(curVal, _meta); str += curVal; } if(sliderOpts.currentvalue.suffix) { str += sliderOpts.currentvalue.suffix; } text.call(Drawing.font, sliderOpts.currentvalue.font) .text(str) .call(svgTextUtils.convertToTspans, sliderOpts._gd); var lines = svgTextUtils.lineCount(text); var y0 = (dims.currentValueMaxLines + 1 - lines) * sliderOpts.currentvalue.font.size * LINE_SPACING; svgTextUtils.positionText(text, x0, y0); return text; } function drawGrip(sliderGroup, gd, sliderOpts) { var grip = Lib.ensureSingle(sliderGroup, 'rect', constants.gripRectClass, function(s) { s.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 = Lib.ensureSingle(item, 'text', constants.labelClass, function(s) { s.attr({ 'text-anchor': 'middle', 'data-notex': 1 }); }); var tx = data.step.label; var _meta = sliderOpts._gd._fullLayout._meta; if(_meta) tx = Lib.templateString(tx, _meta); text.call(Drawing.font, sliderOpts.font) .text(tx) .call(svgTextUtils.convertToTspans, sliderOpts._gd); return text; } function drawLabelGroup(sliderGroup, sliderOpts) { var labels = Lib.ensureSingle(sliderGroup, 'g', constants.labelsClass); var dims = sliderOpts._dims; var labelItems = labels.selectAll('g.' + constants.labelGroupClass) .data(dims.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); Drawing.setTranslate(item, normalizedValueToPosition(sliderOpts, d.fraction), constants.tickOffset + sliderOpts.ticklen + // position is the baseline of the top line of text only, even // if the label spans multiple lines sliderOpts.font.size * LINE_SPACING + constants.labelOffset + dims.currentValueTotalHeight ); }); } function handleInput(gd, sliderGroup, sliderOpts, normalizedPosition, doTransition) { var quantizedPosition = Math.round(normalizedPosition * (sliderOpts._stepCount - 1)); var quantizedIndex = sliderOpts._visibleSteps[quantizedPosition]._index; if(quantizedIndex !== sliderOpts.active) { setActive(gd, sliderGroup, sliderOpts, quantizedIndex, true, doTransition); } } function setActive(gd, sliderGroup, sliderOpts, index, doCallback, doTransition) { var previousActive = sliderOpts.active; sliderOpts.active = index; // due to templating, it's possible this slider doesn't even exist yet arrayEditor(gd.layout, constants.name, sliderOpts) .applyUpdate('active', index); var step = sliderOpts.steps[sliderOpts.active]; sliderGroup.call(setGripPosition, sliderOpts, doTransition); sliderGroup.call(drawCurrentValue, sliderOpts); gd.emit('plotly_sliderchange', { slider: sliderOpts, step: sliderOpts.steps[sliderOpts.active], interaction: doCallback, previousActive: previousActive }); 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; if(!_step.method) return; if(_step.execute) { Plots.executeAPICommand(gd, _step.method, _step.args); } sliderGroup._nextMethod = null; sliderGroup._nextMethodRaf = null; }); } } } function attachGripEvents(item, gd, sliderGroup) { if(gd._context.staticPlot) return; var node = sliderGroup.node(); var $gd = d3.select(gd); // NB: This is *not* the same as sliderOpts itself! These callbacks // are in a closure so this array won't actually be correct if the // steps have changed since this was initialized. The sliderGroup, // however, has not changed since that *is* the slider, so it must // be present to receive mouse events. function getSliderOpts() { return sliderGroup.data()[0]; } function mouseDownHandler() { var sliderOpts = getSliderOpts(); gd.emit('plotly_sliderstart', {slider: sliderOpts}); 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); sliderOpts._dragging = true; function mouseMoveHandler() { var sliderOpts = getSliderOpts(); var normalizedPosition = positionToNormalizedValue(sliderOpts, d3.mouse(node)[0]); handleInput(gd, sliderGroup, sliderOpts, normalizedPosition, false); } $gd.on('mousemove', mouseMoveHandler); $gd.on('touchmove', mouseMoveHandler); function mouseUpHandler() { var sliderOpts = getSliderOpts(); sliderOpts._dragging = false; grip.call(Color.fill, sliderOpts.bgcolor); $gd.on('mouseup', null); $gd.on('mousemove', null); $gd.on('touchend', null); $gd.on('touchmove', null); gd.emit('plotly_sliderend', { slider: sliderOpts, step: sliderOpts.steps[sliderOpts.active] }); } $gd.on('mouseup', mouseUpHandler); $gd.on('touchend', mouseUpHandler); } item.on('mousedown', mouseDownHandler); item.on('touchstart', mouseDownHandler); } function drawTicks(sliderGroup, sliderOpts) { var tick = sliderGroup.selectAll('rect.' + constants.tickRectClass) .data(sliderOpts._visibleSteps); var dims = sliderOpts._dims; 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 % dims.labelStride === 0; var item = d3.select(this); item .attr({height: isMajor ? sliderOpts.ticklen : sliderOpts.minorticklen}) .call(Color.fill, isMajor ? sliderOpts.tickcolor : sliderOpts.tickcolor); Drawing.setTranslate(item, normalizedValueToPosition(sliderOpts, i / (sliderOpts._stepCount - 1)) - 0.5 * sliderOpts.tickwidth, (isMajor ? constants.tickOffset : constants.minorTickOffset) + dims.currentValueTotalHeight ); }); } function computeLabelSteps(sliderOpts) { var dims = sliderOpts._dims; dims.labelSteps = []; var nsteps = sliderOpts._stepCount; for(var i = 0; i < nsteps; i += dims.labelStride) { dims.labelSteps.push({ fraction: i / (nsteps - 1), step: sliderOpts._visibleSteps[i] }); } } function setGripPosition(sliderGroup, sliderOpts, doTransition) { var grip = sliderGroup.select('rect.' + constants.gripRectClass); var quantizedIndex = 0; for(var i = 0; i < sliderOpts._stepCount; i++) { if(sliderOpts._visibleSteps[i]._index === sliderOpts.active) { quantizedIndex = i; break; } } var x = normalizedValueToPosition(sliderOpts, quantizedIndex / (sliderOpts._stepCount - 1)); // If this is true, then *this component* is already invoking its own command // and has triggered its own animation. if(sliderOpts._invokingCommand) return; var el = grip; if(doTransition && sliderOpts.transition.duration > 0) { el = el.transition() .duration(sliderOpts.transition.duration) .ease(sliderOpts.transition.easing); } // Drawing.setTranslate doesn't work here because of the transition duck-typing. // It's also not necessary because there are no other transitions to preserve. el.attr('transform', strTranslate(x - constants.gripWidth * 0.5, sliderOpts._dims.currentValueTotalHeight)); } // Convert a number from [0-1] to a pixel position relative to the slider group container: function normalizedValueToPosition(sliderOpts, normalizedPosition) { var dims = sliderOpts._dims; return dims.inputAreaStart + constants.stepInset + (dims.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) { var dims = sliderOpts._dims; return Math.min(1, Math.max(0, (position - constants.stepInset - dims.inputAreaStart) / (dims.inputAreaLength - 2 * constants.stepInset - 2 * dims.inputAreaStart))); } function drawTouchRect(sliderGroup, gd, sliderOpts) { var dims = sliderOpts._dims; var rect = Lib.ensureSingle(sliderGroup, 'rect', constants.railTouchRectClass, function(s) { s.call(attachGripEvents, gd, sliderGroup, sliderOpts) .style('pointer-events', 'all'); }); rect.attr({ width: dims.inputAreaLength, height: Math.max(dims.inputAreaWidth, constants.tickOffset + sliderOpts.ticklen + dims.labelHeight) }) .call(Color.fill, sliderOpts.bgcolor) .attr('opacity', 0); Drawing.setTranslate(rect, 0, dims.currentValueTotalHeight); } function drawRail(sliderGroup, sliderOpts) { var dims = sliderOpts._dims; var computedLength = dims.inputAreaLength - constants.railInset * 2; var rect = Lib.ensureSingle(sliderGroup, 'rect', constants.railRectClass); 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'); Drawing.setTranslate(rect, constants.railInset, (dims.inputAreaWidth - constants.railWidth) * 0.5 + dims.currentValueTotalHeight ); }