app/assets/javascripts/abstract/timeline/TimelineMonthClass.js
/**
* The Timeline view module.
*
* Timeline for all layers configured by setting layer-specific options.
*
* @return Timeline view (extends Backbone.View).
*/
define(
[
'underscore',
'enquire',
'backbone',
'moment',
'd3',
'mps',
'handlebars',
'text!templates/timelineYear.handlebars',
'text!templates/timelineMonth-mobile.handlebars'
],
function(_, enquire, Backbone, moment, d3, mps, Handlebars, tpl, tplMobile) {
'use strict';
var TimelineYearClass = Backbone.View.extend({
className: 'timeline-month',
template: Handlebars.compile(tpl),
templateMobile: Handlebars.compile(tplMobile),
months: [
'JAN',
'FEB',
'MAR',
'APR',
'MAY',
'JUN',
'JUL',
'AUG',
'SEP',
'OCT',
'NOV',
'DEC'
],
defaults: {
dateRange: [moment([2001]), moment()],
width: 750,
height: 50,
player: true,
playSpeed: 400,
effectsSpeed: 0
},
events: {
'click .play': '_togglePlay',
'change .select-date': 'setSelects'
},
initialize: function(layer, currentDate) {
this.layer = layer;
this.name = layer.slug;
this.options = _.extend({}, this.defaults, this.options || {});
this.dateRangeStart = this.options.dateRange[0];
this.dateRangeEnd = this.options.dateRange[1];
if (currentDate && currentDate[0]) {
this.currentDate = currentDate;
} else {
this._updateCurrentDate(this.options.dateRange);
}
// Transitions duration are 100 ms. Give time to them to finish.
this._updateCurrentDate = _.debounce(
this._updateCurrentDate,
this.options.effectsSpeed
);
this.playing = false;
// Max date range
this.drMax = this.options.dateRange;
// Date range
this.dr = [
[moment(this.drMax[0]).year()],
[moment(this.drMax[1]).year() + 1]
];
// Number months to display
this.monthsCount = Math.floor(
moment(this.dr[1]).diff(moment(this.dr[0]), 'months', true)
);
enquire.register(
'screen and (min-width:' + window.gfw.config.GFW_MOBILE + 'px)',
{
match: _.bind(function() {
this.render();
}, this)
}
);
enquire.register(
'screen and (max-width:' + window.gfw.config.GFW_MOBILE + 'px)',
{
match: _.bind(function() {
this.renderMobile();
}, this)
}
);
},
/**
* Render select of years.
*/
renderMobile: function() {
this.$timeline = $('.timeline-container');
this.$el.html(this.templateMobile({ name: this.layer.title }));
this.$timeline.html('').append(this.el);
// Cache
this.$play = this.$el.find('.play');
this.$playIcon = this.$el.find('.play-icon');
this.$stopIcon = this.$el.find('.stop-icon');
this.$time = this.$el.find('.time');
// Timeline
this.$selects = $('.select-date');
this.$selectsYear = $('.select-date-year');
this.$selectsMonth = $('.select-date-month');
this.$fromMonth = $('#from-timeline-month');
this.$from = $('#from-timeline-year');
this.$toMonth = $('#to-timeline-month');
this.$to = $('#to-timeline-year');
this.fillSelects();
},
fillSelects: function() {
if (!!!this.dateRangeStart._d) {
this.dateRangeStart = moment(this.dateRangeStart);
this.dateRangeEnd = moment(this.dateRangeEnd);
this.currentDate[0] = moment(this.currentDate[0]);
this.currentDate[1] = moment(this.currentDate[1]);
}
var start = this.dateRangeStart.year(),
end = this.dateRangeEnd.year(),
range = end - start + 1,
options = '';
for (var i = 0; i < range; i++) {
options +=
'<option value="' + (start + i) + '">' + (start + i) + '</option>';
}
// Year Selects
this.$from.html(options).val(this.currentDate[0].year());
this.$to.html(options).val(this.currentDate[1].year());
// Month Selects
this.$fromMonth.val(this.months[this.currentDate[0].month()]);
this.$toMonth.val(this.months[this.currentDate[1].month()]);
this.setSelects();
},
setSelects: function() {
_.each(this.$selects, function(el) {
var date = $(el).val();
var $dateButton = $('#' + $(el).attr('id') + '-button');
$dateButton.text(date);
});
this.toggleDisabled();
},
toggleDisabled: function() {
_.each(this.$selectsYear, function(el) {
var $options = document.getElementById($(el).attr('id')).options;
var compare = $($(el).data('compare'))[0].selectedIndex;
var direction = Boolean(parseInt($(el).data('direction')));
_.each($options, function(opt, i) {
if (direction) {
compare <= i
? $(opt).prop('disabled', true)
: $(opt).prop('disabled', false);
} else {
compare >= i
? $(opt).prop('disabled', true)
: $(opt).prop('disabled', false);
}
});
});
_.each(
this.$selectsMonth,
_.bind(function(el) {
var $options = document.getElementById($(el).attr('id')).options;
var yearSelect = $(
'#' +
$(el)
.attr('id')
.replace('month', 'year')
).val();
var monthSelect = $(el)[0].selectedIndex;
var start = this.dateRangeStart.year();
var end = this.dateRangeEnd.year();
var endMonth = this.dateRangeEnd.month();
if (yearSelect == end) {
// if you select the last year of the timeslider we have to check if all months are avaible
_.each($options, function(opt, i) {
i > endMonth
? $(opt).prop('disabled', true)
: $(opt).prop('disabled', false);
});
// if previously we have selected a month that is not avaible we must select manually the last month
if (monthSelect > endMonth) {
$(el).val(this.months[endMonth]);
this.setSelects();
}
} else {
_.each($options, function(opt, i) {
$(opt).prop('disabled', false);
});
}
}, this)
);
var start = moment(
this.prepareDate(this.$fromMonth[0].selectedIndex, this.$from.val())
);
var end = moment(
this.prepareDate(this.$toMonth[0].selectedIndex, this.$to.val())
);
this._updateCurrentDate([start, end]);
},
prepareDate: function(month, year) {
return [year, month];
},
render: function() {
_.bindAll(this, '_moveHandler');
var self = this;
this.$timeline = $('.timeline-container');
this.$el.html(this.template());
this.$timeline.append(this.el);
// Cache
this.$play = this.$el.find('.play');
this.$playIcon = this.$el.find('.play-icon');
this.$stopIcon = this.$el.find('.stop-icon');
this.$time = this.$el.find('.time');
// Disable player if needed
if (!this.options.player) {
// 50 is the play div width.
this.options.width += 50;
this.$play.addClass('hidden');
this.$play.parent().addClass('no-play');
}
// SVG options
var margin = { top: 0, right: 20, bottom: 0, left: 20 };
var width = this.options.width - margin.left - margin.right;
var height = this.options.height - margin.bottom - margin.top;
// xscale
this.xscale = d3.scale
.linear()
.domain([0, this.monthsCount])
.range([0, width])
.clamp(true);
// SVG
this.svg = d3
.select(this.$time[0])
.append('svg')
.attr('width', width + margin.left + margin.right)
.attr('height', height + margin.top + margin.bottom)
.attr(
'style',
'width:' + (width + margin.left + margin.right) + 'px;'
)
.append('g')
.attr(
'transform',
'translate({0},{1})'.format(margin.left, margin.top)
);
// Dots xaxis
this.svg
.append('g')
.attr('class', 'xaxis')
.attr('transform', 'translate(0,{0})'.format(height / 2 - 3))
.call(
d3.svg
.axis()
.scale(this.xscale)
.orient('top')
.ticks(this.monthsCount)
.tickFormat(function() {
return '▪';
})
.tickSize(0)
.tickPadding(0)
)
.select('.domain')
.remove();
this.svg.selectAll('.tick').filter(function(d) {
if (moment(self._domainToDate(d)).month() === 0) {
d3.select(this).classed('highlight', true);
}
});
this.svg
.select('.xaxis')
.selectAll('g.line')
.remove();
// Years xscale
this.yearsXscale = d3.time
.scale()
.domain([moment(this.dr[0]).toDate(), moment(this.dr[1]).toDate()])
.range([0, width]);
// Years xaxis
var xAxis = d3.svg
.axis()
.scale(this.yearsXscale)
.orient('bottom')
.ticks(d3.time.years)
.tickSize(0)
.tickPadding(0)
.tickFormat(d3.time.format('%Y'));
this.svg
.append('g')
.attr('class', 'xaxis-years')
.attr('transform', 'translate({0},{1})'.format(0, height / 2 + 6))
.call(xAxis)
.select('.domain')
.remove();
// this.svg.selectAll('.xaxis-years .tick:last-child text').attr('x', -15);
// Set brush and listeners.
this.brush = d3.svg
.brush()
.x(this.xscale)
.extent([0, 0])
.on('brush', function() {
self._onBrush(this);
})
.on('brushend', function() {
self._onBrushEnd(this);
});
// Slider, brush zone, and handlers.
this.slider = this.svg
.append('g')
.attr('class', 'slider')
.attr('transform', 'translate(0,0)')
.call(this.brush);
this.handlers = {};
this.handlers.left = this.slider
.append('svg:image')
.attr('class', 'handle')
.attr('transform', 'translate(-8,{0})'.format(height / 2 - 11))
.attr('width', 16)
.attr('height', 16)
.attr('xlink:href', '/assets/svg/dragger2.svg')
.attr('x', this.xscale(this._dateToDomain(this.currentDate[0])))
.attr('y', -3);
this.handlers.right = this.handlers.left
.select(function() {
return this.parentNode.appendChild(this.cloneNode(true));
})
.attr('x', this.xscale(this._dateToDomain(this.currentDate[1])));
this.slider
.select('.background')
.style('cursor', 'pointer')
.attr('height', height);
// Selected domain.
this.domain = this.svg
.select('.xaxis')
.append('svg:line')
.attr('class', 'domain')
.attr('transform', 'translate(0,{0})'.format(-3))
.attr('x1', this.handlers.left.attr('x'))
.attr('x2', this.handlers.right.attr('x'));
// Tipsy
this.tipsy = this.svg
.insert('g', ':first-child')
.attr('class', 'tipsy')
.style('visibility', 'hidden');
this.trail = this.tipsy
.append('svg:line')
.attr('class', 'trail')
.attr('x1', this.handlers.right.attr('x'))
.attr('x2', this.handlers.right.attr('x'))
.attr('y1', 0)
.attr('y2', height - 2);
this.tooltip = d3
.select(this.$time[0])
.append('div')
.attr('class', 'tooltip')
.style('visibility', 'hidden')
.style('left', '{0}px'.format(this.handlers.right.attr('x')))
.text(this.options.dateRange[0].format('MMM'));
// Handler position. We keep position x here so we know the
// handler position without having to wait animations to finish.
this.ext = {
left: this.handlers.left.attr('x'),
right: this.handlers.right.attr('x')
};
// Hidden brush for the animation
if (this.options.player) {
this.hiddenBrush = d3.svg
.brush()
.x(this.xscale)
.extent([0, 0])
.on('brush', function() {
self._onAnimationBrush(this);
})
.on('brushend', function() {
self._onAnimationBrushEnd(this);
});
}
this.svg.selectAll('.extent,.resize').remove();
this._updateYearsStyle();
},
_onBrush: function(event) {
var value = this.xscale.invert(d3.mouse(event)[0]);
var rounded = Math.round(value);
var x = this.xscale(rounded);
var date = this._domainToDate(rounded);
var xl = this.handlers.left.attr('x');
var xr = this.handlers.right.attr('x');
this._hideTipsy();
this.playing && this.stopAnimation();
if (
Math.abs(this.xscale(value) - xr) < Math.abs(this.xscale(value) - xl)
) {
if (this.ext.left > x) {
return;
}
this.domain.attr('x1', this.ext.left);
// Set to max handler position when moving mouse fast to the right.
if (date.isAfter(moment(this.drMax[1]))) {
rounded = this._dateToDomain(this.drMax[1]);
}
this.ext.right = this.xscale(rounded);
this._moveHandler(rounded, 'right');
} else {
if (this.ext.right < x) {
return;
}
this.ext.left = x;
this.domain.attr('x2', this.ext.right);
this._moveHandler(rounded, 'left');
}
},
_moveHandler: function(rounded, side) {
var x = this.xscale(rounded);
var date = this._domainToDate(rounded);
this.handlers[side]
.transition()
.duration(this.options.effectsSpeed)
.ease('line')
.attr('x', x);
var dx = side === 'left' ? 'x1' : 'x2';
this.domain
.transition()
.duration(this.options.effectsSpeed)
.ease('line')
.attr(dx, x);
this._showTipsy();
this.tooltip
.text(moment(date).format('MMM') + ' - ' + moment(date).format('D'))
.style('left', '{0}px'.format(x));
this.trail.attr('x1', x).attr('x2', x);
this._updateYearsStyle();
},
_onBrushEnd: function() {
var start = Math.round(
this.xscale.invert(this.handlers.left.attr('x'))
);
var end = Math.round(
this.xscale.invert(_.toNumber(this.handlers.right.attr('x')))
);
start = this._domainToDate(start);
end = this._domainToDate(end);
this._updateCurrentDate([start, end]);
setTimeout(
_.bind(function() {
!this.playing && this._hideTipsy();
}, this),
600
);
},
_domainToDate: function(d) {
var year = Math.floor(d / 12) + moment(this.dr[0]).year();
var month = d >= 12 ? d - Math.floor(d / 12) * 12 : d;
return moment([year, month]);
},
_dateToDomain: function(d) {
return Math.floor(
moment(d)
.utc()
.hour(12)
.diff(moment(this.dr[0]), 'months', true)
);
},
/**
* Handles a timeline date change UI event by dispaching
* to TimelinePresenter.
*
* @param {Array} timelineDate 2D array of moment dates [begin, end]
*/
_updateCurrentDate: function(date) {
this.currentDate = date;
this.presenter.updateTimelineDate(date);
},
getCurrentDate: function() {
return this.currentDate;
},
_updateYearsStyle: function() {
var self = this;
d3
.select('.xaxis-years')
.selectAll('text')
.filter(function(d) {
d = self._dateToDomain(moment(d));
if (
d >= Math.round(self.xscale.invert(self.ext.left)) &&
d <= Math.round(self.xscale.invert(self.ext.right))
) {
d3.select(this).classed('active', true);
} else {
d3.select(this).classed('active', false);
}
});
},
/**
* Event fired when user clicks play/stop button.
*/
_togglePlay: function() {
this.playing ? this.stopAnimation() : this._animate();
},
_animate: function() {
if (!this.options.player) {
return;
}
this.presenter.startPlaying();
var hlx = this.handlers.left.attr('x');
var hrx = this.handlers.right.attr('x');
var trailFrom = Math.round(this.xscale.invert(hlx)) + 1;
var trailTo = Math.round(this.xscale.invert(hrx));
if (trailTo === trailFrom) {
return;
}
var speed = (trailTo - trailFrom) * this.options.playSpeed;
this._togglePlayIcon();
this.playing = true;
this.domainsShown = []; // clean domain already loaded
this._showTipsy();
this.hiddenBrush.extent([trailFrom, trailFrom]);
// Animate extent hiddenBrush to trailTo
this.trail
.call(this.hiddenBrush.event)
.transition()
.duration(speed)
.ease('line')
.call(this.hiddenBrush.extent([trailFrom, trailTo]))
.call(this.hiddenBrush.event);
},
stopAnimation: function() {
if (!this.options.player || !this.playing) {
return;
}
// End animation extent hiddenBrush
// this will call onAnimationBrushEnd
this.trail.call(this.hiddenBrush.event).interrupt();
},
_onAnimationBrush: function() {
var value = this.hiddenBrush.extent()[1];
var rounded = Math.round(value);
var start = this._domainToDate(
this.xscale.invert(this.handlers.left.attr('x'))
);
var end = this._domainToDate(rounded);
var x = this.xscale(rounded);
this.domain.attr('x2', x);
this.trail.attr('x1', x).attr('x2', x);
this.tooltip
.text(end.format('MMM') + ' - ' + end.format('D'))
.style('left', '{0}px'.format(x));
// domainsShown keep track of the years already loaded.
// reason to do this is that value is never an
// absolute value so we don't know when the trail
// is in the right position.
if (this.domainsShown.indexOf(rounded) < 0 && rounded > 0) {
this._updateCurrentDate([start, end]);
this.domainsShown.push(rounded);
}
},
_onAnimationBrushEnd: function() {
var value = this.hiddenBrush.extent()[1];
var hrl = this.ext.left;
// Trail from left handler + 1.
var trailFrom = Math.round(this.xscale.invert(hrl)) + 1;
if (value > 0 && value !== trailFrom) {
this.presenter.stopPlaying();
this._togglePlayIcon();
this.playing = false;
}
},
_showTipsy: function() {
this.tipsy.style('visibility', 'visible');
this.tooltip.style('visibility', 'visible');
},
_hideTipsy: function() {
this.tipsy.style('visibility', 'hidden');
this.tooltip.style('visibility', 'hidden');
},
_togglePlayIcon: function() {
this.$playIcon.toggle();
this.$stopIcon.toggle();
},
getName: function() {
return this.name;
}
});
return TimelineYearClass;
}
);