app/assets/javascripts/insights/views/glad-alerts/InsightsGladAlertsChartView.js
define(
[
'backbone',
'handlebars',
'underscore',
'd3',
'moment',
'helpers/NumbersHelper',
'text!insights/templates/glad-alerts/insights-glad-alerts-tooltip.handlebars'
],
function(Backbone, Handlebars, _, d3, moment, NumbersHelper, tplTooltip) {
'use strict';
var WEEKS_YEAR = 53;
var GladAlertsVis = Backbone.View.extend({
el: '#vis-glad',
templateTooltip: Handlebars.compile(tplTooltip),
defaults: {
chartClass: 'chart-glad',
interpolate: 'linear',
paddingAxisLabels: 10,
paddingXAxisLabels: 20,
paddingYAxisLabels: 18,
paddingLinesLabels: 6,
tooltipPadding: 16,
underscriptPadding: 1.5,
dateFormat: '%b',
underscript: '2',
margin: {
top: 50,
right: 55,
bottom: 35,
left: 55
},
marginEmbed: {
top: 50,
right: 25,
bottom: 35,
left: 45
},
circleRadius: 6,
triangleSize: 200,
filter: 'carbon_emissions',
chartConfig: {
carbon_emissions: {
domain: {
x: 'date',
x2: 'week',
y: ['cumulative_emissions', 'carbon_average', 'carbon_target'],
r: 'alerts'
},
dataColumns: {
dots: {
x: 'week',
y: 'cumulative_emissions',
r: 'alerts'
},
line: {
x: 'week',
y: 'cumulative_emissions'
},
dashed: {
x: 'week',
y: 'carbon_target'
},
semiDashed: {
x: 'week',
y: 'carbon_average'
}
}
},
deforestation: {
domain: {
x: 'date',
x2: 'week',
y: [
'cumulative_deforestation',
'deforestation_average',
'deforestation_target'
],
r: 'alerts'
},
dataColumns: {
dots: {
x: 'week',
y: 'cumulative_deforestation',
r: 'alerts'
},
line: {
x: 'week',
y: 'cumulative_deforestation'
},
dashed: {
x: 'week',
y: 'deforestation_target'
},
semiDashed: {
x: 'week',
y: 'deforestation_average'
}
}
}
},
units: {
carbon_emissions: 'Mt CO2/yr',
deforestation: 'ha/yr'
},
handleWidth: 1,
timelineButtonRadius: 15,
maxWidth: 600
},
initialize: function(settings) {
this.defaults = _.extend({}, this.defaults, settings.params);
this.data = this.defaults.data;
this.filter = this.defaults.filter;
this.currentStep = this.defaults.currentStep;
this.iso = this.defaults.iso;
this.year = this.defaults.year;
this.imageURI = this.defaults.imageURI;
this.locations = this.defaults.locations;
this.currentLocation = this.defaults.currentLocation;
this._initChart();
// Sets listeners
this.setListeners();
},
/**
* Sets the listeners for the component
*/
setListeners: function() {
this.refreshEvent = _.debounce(_.bind(this._update, this), 30);
window.addEventListener('resize', this.refreshEvent, false);
this.hoverEvent = this._onHoverOut.bind(this);
this.$el.on('mouseleave', this.hoverEvent);
},
unsetListeners: function() {
window.removeEventListener('resize', this.refreshEvent, false);
this.$el.off('mouseleave', this.hoverEvent);
this.refreshEvent = null;
this.hoverEvent = null;
},
_initChart: function() {
// Data parsing and initialization
this._parseData(this.data);
this._checkIsEmbed();
this._start();
},
_start: function() {
if (this.chartData.length) {
// Render the component on initialize
this.render();
} else {
this._renderNoData();
}
},
/**
* Parses the data for the chart
*/
_parseData: function(data) {
var range = _.range(WEEKS_YEAR);
this.chartData = data;
this.dataColumns = this.defaults.chartConfig[this.filter].dataColumns;
this.dataDomain = this.defaults.chartConfig[this.filter].domain;
this.unit = this.defaults.units[this.filter];
range.forEach(
function(d) {
var week = d + 1;
var data = this.chartData[d];
if (data) {
data.date = moment()
.week(data.week)
.toDate();
} else {
var mainData = this.chartData[0];
this.chartData.push({
date: moment()
.week(week)
.toDate(),
carbon_average: mainData.carbon_average,
carbon_target: mainData.carbon_target,
deforestation_average: mainData.deforestation_average,
deforestation_target: mainData.deforestation_target,
year: mainData.year,
week: week.toString()
});
}
}.bind(this)
);
this.dotsData = _.filter(
this.chartData,
function(d) {
var value = d[this.dataColumns.dots.r];
return value && value > 0;
}.bind(this)
);
var maxDomainXData = this.dotsData[this.dotsData.length - 1];
this.maxDomain = maxDomainXData[this.dataColumns.dots.x];
this.solidLineData = _.filter(
this.chartData,
function(d) {
return d[this.dataColumns.dots.y];
}.bind(this)
);
},
render: function() {
this._setUpGraph();
this._setAxisScale();
this._setDomain();
this._drawAxis();
this._drawGraph();
},
_renderNoData: function() {
this.$el.html(this.template({}));
},
/**
* Renders the chart after a resize
*/
_update: function() {
this.remove({
keepEvents: true
});
this._checkIsEmbed();
this.render();
},
_checkIsEmbed: function() {
if (document.body.clientWidth <= this.defaults.maxWidth) {
this.isEmbed = true;
} else {
this.isEmbed = false;
}
},
/**
* Sets up the SVG for the graph
*/
_setUpGraph: function() {
var el = this.el;
var margin = this.defaults.margin;
var isDesforestation =
this.filter === this.defaults.desforestationFilter;
var label = 'Cumulative Emissions to Date (MtCO';
if (isDesforestation) {
label = 'Cumulative Area of Loss to Date (ha)';
}
if (this.isEmbed) {
margin = this.defaults.marginEmbed;
}
this.el.innerHTML = '';
this.el.classList.add(this.defaults.chartClass);
this.el.classList.add(this.filter);
this.cWidth = el.clientWidth;
this.cHeight = el.clientHeight;
this.domain = this._getDomain();
this.cWidth = this.cWidth - margin.left - margin.right;
this.cHeight = this.cHeight - margin.top - margin.bottom;
var svg = d3
.select(el)
.append('svg')
.attr('width', this.cWidth + margin.left + margin.right + 'px')
.attr('height', this.cHeight + margin.top + margin.bottom + 'px');
var yAxisLabel = svg
.append('g')
.attr('transform', 'translate(0, 12)')
.append('text')
.attr('class', 'y-label')
.text(label);
if (!isDesforestation) {
yAxisLabel
.append('tspan')
.attr('class', 'underscript')
.attr('dy', this.defaults.underscriptPadding)
.text(this.defaults.underscript);
yAxisLabel.append('tspan').text(')');
}
if (this.currentLocation && this.currentLocation.legend) {
yAxisLabel
.append('tspan')
.attr('class', 'light')
.text(' *' + this.currentLocation.legend);
}
this.svg = svg
.append('g')
.attr(
'transform',
'translate(' + margin.left + ',' + margin.top + ')'
);
},
/**
* Sets the axis
*/
_setAxisScale: function() {
var _this = this;
var xTickFormat = d3.time.format(_this.defaults.dateFormat);
var yTickFormat = function(d) {
return d > 999 ? (d / 1000).toFixed(1) + 'k' : d;
};
var yNumTicks = 4;
var xNumTicks = this.isEmbed ? 5 : 12;
this.x = d3.time.scale().range([0, this.cWidth]);
this.x2 = d3.scale
.linear()
.range([0, this.cWidth])
.nice();
this.y = d3.scale
.linear()
.range([this.cHeight, 0])
.nice();
this.xAxis = d3.svg
.axis()
.scale(this.x)
.orient('bottom')
.innerTickSize(-this.cHeight)
.tickValues(this.months)
.ticks(xNumTicks)
.outerTickSize(0)
.tickPadding(10)
.tickFormat(xTickFormat);
this.yAxis = d3.svg
.axis()
.scale(this.y)
.orient('left')
.innerTickSize(0)
.outerTickSize(0)
.ticks(yNumTicks)
.tickFormat(yTickFormat);
},
/**
* Get the domain values
*/
_getDomain: function() {
var _this = this;
var xValues = [];
var x2Values = [];
var yValues = [];
var rValues = [];
this.chartData.forEach(function(d) {
xValues.push(d[_this.dataDomain.x] * 1);
x2Values.push(d[_this.dataDomain.x2] * 1);
rValues.push(d[_this.dataDomain.r] * 1);
});
// Support for multiple columns for one axis
_.each(_this.dataDomain.y, function(y) {
var rawData = _.pluck(_this.chartData, y);
_.each(rawData, function(d) {
if (d && d !== '') {
yValues.push(d * 1);
}
});
});
return {
x: d3.extent(xValues, function(d) {
return d;
}),
x2: d3.extent(x2Values, function(d) {
return d;
}),
y: d3.extent(yValues, function(d) {
return d;
}),
r: d3.extent(rValues, function(d) {
return d;
})
};
},
/**
* Sets the domain
*/
_setDomain: function() {
this.x.domain(this.domain.x);
this.x2.domain(this.domain.x2);
this.y.domain(this.domain.y);
// Add extra padding to Y domain
var numTicks = 10;
this.y.domain([
this.domain.y[0],
d3.max(this.y.ticks(numTicks)) + this.y.ticks(numTicks)[2]
]);
},
/**
* Draws the axis
*/
_drawAxis: function() {
var _this = this;
// X Axis
var xAxis = this.svg
.append('g')
.attr('class', 'x axis')
.attr('transform', 'translate(0,' + this.cHeight + ')')
.call(this.xAxis);
xAxis
.selectAll('text')
.attr('x', _this.defaults.paddingXAxisLabels)
.attr('y', -this.cHeight - _this.defaults.paddingYAxisLabels);
xAxis
.selectAll('line')
.attr('x1', _this.defaults.paddingXAxisLabels)
.attr('x2', _this.defaults.paddingXAxisLabels);
// Y Axis
var yAxis = this.svg
.append('g')
.attr('class', 'y axis')
.attr(
'transform',
'translate(' +
-_this.defaults.paddingAxisLabels +
',' +
-_this.defaults.paddingAxisLabels +
')'
);
yAxis
.append('g')
.call(this.yAxis)
.selectAll('text')
.attr('x', 0);
// Custom domain
this.svg
.append('g')
.attr('class', 'custom-domain-group')
.attr('transform', 'translate(0, ' + this.cHeight + ')')
.append('line')
.attr('class', 'curstom-domain')
.attr('x1', -_this.defaults.paddingAxisLabels)
.attr('x2', this.cWidth + _this.defaults.paddingAxisLabels)
.attr('y1', 0)
.attr('y2', 0);
},
/**
* Draws the entire graph
*/
_drawGraph: function() {
this._drawHandle();
this._drawSemiDashedLine();
this._drawDashedLine();
this._drawSolidLine();
this._drawDots();
this._drawBrush();
this._renderTooltip();
this._updateTooltip();
this._setCurrentTooltip();
},
/**
* Draws the dots
*/
_drawDots: function() {
var _this = this;
var dotsGroup = this.svg
.append('g')
.attr('class', 'dots-group')
.attr(
'transform',
'translate( 0,' + -this.defaults.paddingAxisLabels + ')'
);
dotsGroup
.selectAll('.dot')
.data(this.dotsData)
.enter()
.append('circle')
.attr('class', function(d) {
return 'dot dot-' + d.week;
})
.attr('r', _this.defaults.circleRadius)
.attr('cx', function(d) {
return _this.x2(d[_this.dataColumns.dots.x]);
})
.attr('cy', function(d) {
return _this.y(d[_this.dataColumns.dots.y]);
})
.style('transform-origin', function(d) {
return (
Math.round(_this.x2(d[_this.dataColumns.dots.x])) +
'px ' +
Math.round(_this.y(d[_this.dataColumns.dots.y])) +
'px'
);
});
},
/**
* Draws the dashed line
*/
_drawDashedLine: function() {
var _this = this;
var allYData = _.pluck(this.chartData, this.dataColumns.dashed.y);
var allXData = _.pluck(this.chartData, this.dataColumns.dashed.x);
var dashedLine = this.svg
.append('g')
.attr('class', 'line-dashed-group')
.attr(
'transform',
'translate( 0,' + -this.defaults.paddingAxisLabels + ')'
);
var line = d3.svg
.line()
.x(function(d) {
return _this.x2(d[_this.dataColumns.dashed.x]);
})
.y(function(d) {
return _this.y(d[_this.dataColumns.dashed.y]);
})
.interpolate(this.defaults.interpolate);
dashedLine
.append('path')
.attr('d', line(this.chartData))
.attr('class', 'dash-line-path');
var dashLineLabel = dashedLine
.append('text')
.attr('x', this.x2(allXData[allXData.length - 1]))
.attr('y', this.y(_.max(allYData)))
.attr('class', 'line-label')
.attr('dy', -this.defaults.paddingLinesLabels)
.text('2020 Target: ');
this._addLineLabel(dashLineLabel, _.max(allYData));
},
_drawSemiDashedLine: function() {
var _this = this;
var allYData = _.pluck(this.chartData, this.dataColumns.semiDashed.y);
var allXData = _.pluck(this.chartData, this.dataColumns.semiDashed.x);
var label = '2001 - 2014 Average:';
if (this.iso === 'BRA') {
label = '2013 Average:';
}
var dashedLine = this.svg
.append('g')
.attr('class', 'line-semidashed-group')
.attr(
'transform',
'translate( 0,' + -this.defaults.paddingLinesLabels + ')'
);
var line = d3.svg
.line()
.x(function(d) {
return _this.x2(d[_this.dataColumns.semiDashed.x]);
})
.y(function(d) {
return _this.y(d[_this.dataColumns.semiDashed.y]);
})
.interpolate(this.defaults.interpolate);
dashedLine
.append('path')
.attr('d', line(this.chartData))
.attr('class', 'semidash-line-path');
var dashLineLabel = dashedLine
.append('text')
.attr('x', this.x2(allXData[allXData.length - 1]))
.attr('y', this.y(_.max(allYData)))
.attr('class', 'line-label')
.attr('dy', -this.defaults.paddingLinesLabels)
.text(label + ' ');
this._addLineLabel(dashLineLabel, _.max(allYData));
},
/**
* Draws the solid line
*/
_drawSolidLine: function() {
var _this = this;
var solidLineGroup = this.svg
.append('g')
.attr('class', 'line-solid-group')
.attr(
'transform',
'translate( 0,' + -this.defaults.paddingAxisLabels + ')'
);
this.linePath = d3.svg
.line()
.x(function(d) {
return _this.x2(d[_this.dataColumns.line.x]);
})
.y(function(d) {
return _this.y(d[_this.dataColumns.line.y]);
})
.interpolate(this.defaults.interpolate);
this.graphLine = solidLineGroup
.append('path')
.attr('d', this.linePath(this.solidLineData))
.attr('class', 'line-solid-path');
},
_addLineLabel: function(el, value) {
var parsedValue = parseFloat((value * 1).toFixed(2));
if (parsedValue > 1) {
parsedValue = Math.round(parsedValue);
}
el
.append('tspan')
.text(NumbersHelper.addNumberDecimals(parsedValue) + ' ');
if (this.unit.search(this.defaults.underscript) !== -1) {
var unit = this.unit.split(this.defaults.underscript);
for (var x = 0; x < unit.length; x++) {
el.append('tspan').text(unit[x]);
if (x === 0) {
el
.append('tspan')
.attr('class', 'underscript')
.attr('dy', this.defaults.underscriptPadding)
.text(this.defaults.underscript);
}
}
} else {
el.append('tspan').text(this.unit);
}
},
/**
* Renders the tooltip of the visualization
*/
_renderTooltip: function() {
d3
.select(this.el)
.insert('div', 'svg')
.attr('class', 'tooltip');
var tooltip = this.el.querySelector('.tooltip');
tooltip.innerHTML = this.templateTooltip({});
this.tooltip = this.el.querySelector('.insights-glad-alerts-tooltip');
},
/**
* Updates the tooltip of the current step
* @param {Number} current step
*/
_updateTooltip: function(step) {
var currentStep = step || this.currentStep;
var data = _.findWhere(this.chartData, { week: currentStep });
if (data) {
var tooltip = this.el.querySelector('.tooltip');
var emissions = parseFloat(
(data.cumulative_emissions * 1).toFixed(2)
);
var deforestation = parseFloat(
(data.cumulative_deforestation * 1).toFixed(2)
);
if (emissions > 1) {
emissions = Math.round(emissions);
}
if (deforestation > 1) {
deforestation = Math.round(deforestation);
}
tooltip.innerHTML = this.templateTooltip({
isDesforestation:
this.filter === this.defaults.desforestationFilter,
emissions: NumbersHelper.addNumberDecimals(emissions),
deforestation: NumbersHelper.addNumberDecimals(deforestation)
});
this.tooltip = this.el.querySelector('.insights-glad-alerts-tooltip');
}
},
_getCurrentState: function(step) {
return {
step: step,
maxData: this.maxDomain
};
},
/**
* Sets up the brush feature
* of the graph
*/
_drawBrush: function() {
this.brush = d3.svg
.brush()
.x(this.x2)
.extent([0, 0])
.on(
'brush',
function() {
this._onBrush();
}.bind(this)
)
.on(
'brushend',
function() {
this._onBrushEnd();
}.bind(this)
);
this.slider = this.svg
.append('g')
.attr('class', 'handles')
.call(this.brush);
this.slider.selectAll('.extent,.resize').remove();
this.slider
.select('.background')
.attr('height', this.cHeight + this.defaults.margin.bottom);
this._setHoverEvent();
this._setHandlePosition();
},
_onBrush: function() {
var domain = this._getDomain();
var xDomain = domain.x2;
var value = Math.round(this.brush.extent()[0]);
var element = this.el.querySelector('svg .handles');
if (d3.event.sourceEvent) {
d3.event.sourceEvent.stopPropagation();
value = this.x2.invert(d3.mouse(element)[0]);
this.brush.extent([value, value]);
}
if (value < xDomain[0]) {
value = xDomain[0];
}
if (value > xDomain[1]) {
value = xDomain[1];
}
this.currentStep = Math.round(value);
if (this.isEmbed) {
Backbone.Events.trigger(
'insights:glad:update',
this._getCurrentState(this.currentStep)
);
}
this._setHandlePosition();
},
_onBrushEnd: function() {
if (!d3.event.sourceEvent) return;
var domain = this._getDomain();
var xDomain = domain.x2;
var element = this.el.querySelector('svg .handles');
var extent0 = this.brush.extent(),
extent1 = extent0;
var current = Math.round(extent1[0]);
if (extent1 < xDomain[0]) {
current = xDomain[0];
}
if (extent1 > xDomain[1]) {
current = xDomain[1];
}
this.currentStep = current;
// if (this.isEmbed) {
Backbone.Events.trigger(
'insights:glad:update',
this._getCurrentState(current)
);
// }
d3
.select(element)
.transition()
.duration(0)
.call(this.brush.extent(extent1))
.call(this.brush.event);
this._updateTooltip();
this._setCurrentTooltip();
},
_setHoverEvent: function() {
var _this = this;
d3.select('svg .background').on('mousemove', function() {
var value = Math.round(_this.x2.invert(d3.mouse(this)[0]));
_this._changeStepByValue(value);
_this._updateTooltip(value);
Backbone.Events.trigger(
'insights:glad:update',
_this._getCurrentState(value)
);
_this._setCurrentTooltip(value);
});
},
_setCurrentTooltip: function(value) {
var currentStep = value || this.currentStep;
var current = d3.select('svg .dot-' + currentStep);
d3.selectAll('svg .dot').classed('hovered', false);
if (current) {
current.classed('hovered', true);
if (current.node()) {
var cords = current.node().getBBox();
var left = cords.x + this.defaults.margin.left + cords.width;
var width = this.tooltip.clientWidth;
var height = this.tooltip.clientHeight;
var leftPos = left - width - this.defaults.tooltipPadding;
if (leftPos < 0) {
leftPos = left + this.defaults.tooltipPadding;
this.tooltip.classList.add('-right');
} else {
this.tooltip.classList.remove('-right');
}
if (this.isEmbed) {
leftPos = leftPos - this.defaults.tooltipPadding / 2;
}
this.tooltip.style.left = leftPos + 'px';
this._showTooltip();
} else {
this._hideTooltip();
}
} else {
this._hideTooltip();
}
},
_drawHandle: function() {
this.handleContainer = this.svg.append('g');
this.handle = this.handleContainer
.append('rect')
.attr('class', 'handle')
.attr('width', this.defaults.handleWidth + 'px')
.attr('height', this.cHeight + this.defaults.margin.top / 2);
var triangle = d3.svg
.symbol()
.type('triangle-up')
.size(this.defaults.triangleSize);
this.handleContainer
.append('path')
.attr('class', 'handle-triangle')
.attr('d', triangle)
.attr(
'transform',
'translate(0, ' +
(this.cHeight + this.defaults.margin.bottom / 2) +
') scale(1.5, 1)'
);
this.handleContainer
.append('path')
.attr('class', 'handle-triangle -fill')
.attr('d', triangle)
.attr(
'transform',
'translate(0, ' +
(this.cHeight + this.defaults.margin.bottom / 2 + 2) +
') scale(1.5, 0.9)'
);
},
/**
* Set the handle position with the
* current step of the brush
*/
_setHandlePosition: function() {
var _this = this;
this.handleContainer.attr('transform', function() {
return (
'translate(' +
(_this.x2(_this.currentStep) - _this.defaults.handleWidth / 2) +
', 0)'
);
});
},
_changeStepByValue: function(step) {
this.currentStep = step;
this._setHandlePosition();
},
updateByFilter: function(filter) {
this.remove({
keepEvents: true
});
this.filter = filter;
this._initChart();
},
_onHoverOut: function() {
this._hideTooltip();
},
_hideTooltip: function() {
this.tooltip.classList.remove('-visible');
d3.select('.' + this.defaults.chartClass).classed('hovering', false);
},
_showTooltip: function() {
this.tooltip.classList.add('-visible');
d3.select('.' + this.defaults.chartClass).classed('hovering', true);
},
/**
* Removes the SVG
*/
remove: function(params) {
if (this.svg) {
var svgContainer = this.el.querySelector('svg');
if (params && !params.keepEvents) {
this.unsetListeners();
this.stopListening();
}
this.svg.remove();
this.svg = null;
this.el.classList.remove(this.filter);
this.el.removeChild(svgContainer);
}
}
});
return GladAlertsVis;
}
);