var d3 = require('d3'); var cdb = require('internal-carto.js'); var CoreView = require('backbone/core-view'); var moment = require('moment'); var formatter = require('../../formatter'); var viewportUtils = require('../../viewport-utils'); var TIP_RECT_HEIGHT = 17; var TIP_H_PADDING = 6; var CHART_MARGIN = 16; var MOBILE_BAR_HEIGHT = 3; var TOOLTIP_MARGIN = 2; /** * Time-slider, expected to be used in a histogram view */ module.exports = CoreView.extend({ defaults: { width: 6, height: 8 }, initialize: function () { if (!this.options.chartView) throw new Error('chartView is required'); if (!this.options.torqueLayerModel) throw new Error('torqeLayerModel is required'); if (!this.options.timeSeriesModel) throw new Error('timeSeriesModel is required'); this.model = new cdb.core.Model(); this._dataviewModel = this.options.dataviewModel; this._chartView = this.options.chartView; this._torqueLayerModel = this.options.torqueLayerModel; this._timeSeriesModel = this.options.timeSeriesModel; this._chartMargins = this._chartView.model.get('margin'); this._initBinds(); this._updateXScale(); this._createFormatter(); }, render: function () { // Make the render call idempotent; only create time slider once if (!this.timeSlider) { var dragBehavior = d3.behavior.drag() .on('dragstart', this._onDragStart.bind(this)) .on('drag', this._onDrag.bind(this)) .on('dragend', this._onDragEnd.bind(this)); var d3el = this._chartView.canvas.append('rect'); this.timeSlider = d3el .attr('class', 'CDB-TimeSlider') .attr('width', this.defaults.width) .attr('height', this._calcHeight()) .attr('rx', 3) .attr('ry', 3) .data([{ x: 0, y: 0 }]) .attr('transform', this._translateXY) .call(dragBehavior); this.setElement(d3el.node()); } if (this._isTabletViewport()) { this._generateTimeSliderTip(); } return this; }, _isTabletViewport: function () { return viewportUtils.isTabletViewport(); }, _generateTimeSliderTip: function () { var yPos = this._calcHeight() / 2 + MOBILE_BAR_HEIGHT + TOOLTIP_MARGIN; yPos = Math.floor(yPos); this.timeSliderTip = this._chartView.canvas.select('.CDB-WidgetCanvas').append('g') .attr('class', 'CDB-Chart-timeSliderTip') .data([{ x: CHART_MARGIN, y: yPos }]) .attr('transform', this._translateXY); this.timeSliderTip.append('rect') .attr('class', 'CDB-Chart-timeSliderTipRect') .attr('rx', '2') .attr('ry', '2') .attr('height', TIP_RECT_HEIGHT); this.timeSliderTip.append('text') .attr('class', 'CDB-Text CDB-Size-small CDB-Chart-timeSliderTipText') .attr('dy', '11') .attr('dx', '0'); }, _onLocalTimezoneChanged: function () { this._createFormatter(); this._updateTimeSliderTip(); }, _updateTimeSliderTip: function () { var self = this; var textLabelData = this._isDateTimeSeries() ? this._torqueLayerModel.get('time') : this._torqueLayerModel.get('step'); if (textLabelData === void 0) { return; } var chart = this._chartView.canvas; var textLabel = chart.select('.CDB-Chart-timeSliderTipText'); var scale = d3.scale.linear() .domain([0, this._dataviewModel.get('data').length]) .range([this._dataviewModel.get('start'), this._dataviewModel.get('end')]); textLabel .data([textLabelData]) .text(function (d) { return self._isDateTimeSeries() ? this.formatter(moment(d).unix()) : this.formatter(scale(d)); }.bind(this)); if (!textLabel.node()) { return; } var rectLabel = chart.select('.CDB-Chart-timeSliderTipRect'); var textBBox = textLabel.node().getBBox(); var width = textBBox.width; var rectWidth = width + TIP_H_PADDING; var chartWidth = this._chartView.chartWidth() + CHART_MARGIN; rectLabel.attr('width', rectWidth); textLabel.attr('dx', TIP_H_PADDING / 2); textLabel.attr('dy', textBBox.height - Math.abs((textBBox.height - TIP_RECT_HEIGHT) / 2)); var timeSliderX = this._xScale(this._torqueLayerModel.get('step')); var xPos = timeSliderX + this.defaults.width - rectWidth / 2; var yPos = this._calcHeight() / 2 + MOBILE_BAR_HEIGHT + TOOLTIP_MARGIN; yPos = Math.floor(yPos); var timeSliderTipData = this.timeSliderTip.data(); timeSliderTipData[0].y = yPos; var newX = xPos; if (xPos < CHART_MARGIN) { newX = CHART_MARGIN; } else if ((xPos + rectWidth) >= chartWidth) { newX = chartWidth - rectWidth; } if (!isNaN(newX)) { timeSliderTipData[0].x = newX; this.timeSliderTip .data(timeSliderTipData) .transition() .ease('linear') .attr('transform', this._translateXY); } }, _initBinds: function () { this.listenTo(this._torqueLayerModel, 'change:start change:end', this._updateChartandTimeslider); this.listenTo(this._torqueLayerModel, 'change:step', this._onChangeStep); this.listenTo(this._torqueLayerModel, 'change:time', this._onChangeTime); this.listenTo(this._torqueLayerModel, 'change:steps', this._updateChartandTimeslider); this.listenTo(this._chartView.model, 'change:width', this._updateChartandTimeslider); this.listenTo(this._chartView.model, 'change:height', this._onChangeChartHeight); this.listenTo(this._dataviewModel, 'change:bins', this._updateChartandTimeslider); this.listenTo(this._dataviewModel, 'change:column_type', this._createFormatter); this.listenTo(this._dataviewModel.filter, 'change:min change:max', this._onFilterMinMaxChange); this.listenTo(this._timeSeriesModel, 'change:local_timezone', this._onLocalTimezoneChanged); this.listenTo(this._dataviewModel, 'change:start change:end', this._updateChartandTimeslider); }, clean: function () { if (this.timeSlider) { this.timeSlider.remove(); } CoreView.prototype.clean.call(this); }, _onFilterMinMaxChange: function (m, isFiltering) { this.$el.toggle(!isFiltering); }, _onDragStart: function () { var isRunning = this._torqueLayerModel.get('isRunning'); if (isRunning) { this._torqueLayerModel.pause(); } this.model.set({ isDragging: true, wasRunning: isRunning }); }, _onDrag: function (d, i) { var nextX = d.x + d3.event.dx; if (this._isWithinRange(nextX)) { d.x = nextX; this.timeSlider.attr('transform', this._translateXY); var step = Math.round(this._xScale.invert(d.x)); this._torqueLayerModel.setStep(step); } }, _onDragEnd: function () { this.model.set('isDragging', false); if (this.model.get('wasRunning')) { this._torqueLayerModel.play(); } }, _translateXY: function (d) { return 'translate(' + [d.x, d.y] + ')'; }, _isWithinRange: function (x) { return x >= this._chartMargins.left && x <= this._width() - this._chartMargins.right; }, _onChangeStep: function () { // Time slider might not be created when this method is first called if (this.timeSlider && !this.model.get('isDragging')) { var data = this.timeSlider.data(); var newX = this._xScale(this._torqueLayerModel.get('step')); if (!isNaN(newX)) { data[0].x = newX; this.timeSlider .data(data) .transition() .ease('linear') .attr('transform', this._translateXY); } } }, _onChangeChartHeight: function () { var height = this._isTabletViewport() ? this._calcHeight() / 2 + MOBILE_BAR_HEIGHT : this._calcHeight(); this.timeSlider.attr('height', height); }, _onChangeTime: function () { if (this._dataviewModel.filter.isEmpty() && this._isTabletViewport()) { var timeSliderTip = this._chartView.canvas.select('.CDB-Chart-timeSliderTip'); if (!timeSliderTip.node()) { this._generateTimeSliderTip(); } this._updateTimeSliderTip(); } else { this._removeTimeSliderTip(); } }, _removeTimeSliderTip: function () { var timeSliderTip = this._chartView.canvas.select('.CDB-Chart-timeSliderTip'); if (timeSliderTip.node()) { timeSliderTip.remove(); } }, _updateChartandTimeslider: function () { this._updateXScale(); this._onChangeStep(); }, _calcHeight: function () { return this._chartView.chartHeight() + this.defaults.height; }, _createFormatter: function () { this.formatter = formatter.formatNumber; if (this._isDateTimeSeries()) { this.formatter = formatter.timestampFactory(this._dataviewModel.get('aggregation'), this._dataviewModel.getCurrentOffset()); } }, _isDateTimeSeries: function () { return this._dataviewModel.getColumnType() === 'date'; }, _updateXScale: function () { // calculate range based on the torque layer bounds (that are not the same than the histogram ones) var range = 1000 * (this._dataviewModel.get('end') - this._dataviewModel.get('start')); // get normalized start and end var start = (this._torqueLayerModel.get('start') - 1000 * this._dataviewModel.get('start')) / range; var end = (this._torqueLayerModel.get('end') - 1000 * this._dataviewModel.get('start')) / range; // This function might be called in-between state changes, so just to be safe let's keep the range sane var scaleRangeMin = (start * this._width()) + this._chartMargins.left; var scaleRangeMax = (end * this._width()) - this._chartMargins.right; scaleRangeMin = scaleRangeMin < 0 ? this._chartMargins.left : scaleRangeMin; scaleRangeMax = scaleRangeMax > this._width() ? this._width() - this._chartMargins.right : scaleRangeMax; this._xScale = d3.scale.linear() .domain([0, this._torqueLayerModel.get('steps')]) .range([scaleRangeMin, scaleRangeMax]); }, _width: function () { return this._chartView.model.get('width'); } });