Commit 68bd3fd0 by Dennis Jen

Replaced trend chart with nvd3 and map with datamaps

  * added d3, datamaps, and nvd3 libraries
  * updated to use alpha3 country code returned from the API
parent 1086e222
import datetime import datetime
from bok_choy.web_app_test import WebAppTest
from bok_choy.promise import EmptyPromise
from analyticsclient import demographic from analyticsclient import demographic
from bok_choy.web_app_test import WebAppTest
from acceptance_tests import AnalyticsApiClientMixin from acceptance_tests import AnalyticsApiClientMixin
from acceptance_tests.pages import CourseEnrollmentPage from acceptance_tests.pages import CourseEnrollmentPage
...@@ -75,7 +76,60 @@ class CourseEnrollmentTests(AnalyticsApiClientMixin, WebAppTest): ...@@ -75,7 +76,60 @@ class CourseEnrollmentTests(AnalyticsApiClientMixin, WebAppTest):
# Verify *something* rendered where the graph should be. We cannot easily verify what rendered # Verify *something* rendered where the graph should be. We cannot easily verify what rendered
self.assertElementHasContent("[data-section=enrollment-basics] #enrollment-trend-view") self.assertElementHasContent("[data-section=enrollment-basics] #enrollment-trend-view")
def test_enrollment_table(self): def test_enrollment_country_map(self):
self.page.visit()
map_selector = "div[data-view=world-map]"
# ensure that the map data has been loaded (via ajax); otherwise this
# will timeout
EmptyPromise(
lambda: 'Loading Map...' not in self.page.q(css=map_selector + ' p').text,
"Map finished loading"
).fulfill()
# make sure the map section is present
element = self.page.q(css=map_selector)
self.assertTrue(element.present)
# make sure that the map is present
element = self.page.q(css=map_selector + " svg[class=datamap]")
self.assertTrue(element.present)
# make sure the legend is present
element = self.page.q(css=map_selector + " svg[class=datamaps-legend]")
self.assertTrue(element.present)
def test_enrollment_country_table(self):
self.page.visit()
table_section_selector = "div[data-role=enrollment-location-table]"
# ensure that the map data has been loaded (via ajax); otherwise this
# will timeout
EmptyPromise(
lambda: 'Loading Table...' not in self.page.q(css=table_section_selector + ' p').text,
"Table finished loading"
).fulfill()
# make sure the map section is present
element = self.page.q(css=table_section_selector)
self.assertTrue(element.present)
# make sure the table is present
table_selector = table_section_selector + " table"
element = self.page.q(css=table_selector)
self.assertTrue(element.present)
# check the headings
self.assertTableColumnHeadingsEqual(table_selector, ['Country', 'Count'])
# Verify CSV button has an href attribute
selector = "a[data-role=enrollment-location-csv]"
self.assertValidHref(selector)
def test_enrollment_trend_table(self):
self.page.visit() self.page.visit()
enrollment_data = sorted(self.get_enrollment_data(), reverse=True, key=lambda item: item['date']) enrollment_data = sorted(self.get_enrollment_data(), reverse=True, key=lambda item: item['date'])
......
...@@ -20,6 +20,7 @@ Individual course-centric enrollment view ...@@ -20,6 +20,7 @@ Individual course-centric enrollment view
{% block content %} {% block content %}
<section class="view-section" data-section="enrollment-basics"> <section class="view-section" data-section="enrollment-basics">
<h4 class="section-title"> <h4 class="section-title">
<span class="section-title-value">Enrollment Basics</span> <span class="section-title-value">Enrollment Basics</span>
...@@ -90,7 +91,13 @@ Individual course-centric enrollment view ...@@ -90,7 +91,13 @@ Individual course-centric enrollment view
</div> </div>
<div class="section-content section-data-viz"> <div class="section-content section-data-viz">
<div data-view="world-map" data-title="Enrollment by Country" data-series-name="Enrollment"></div> <div class="world-map" data-view="world-map" data-title="Enrollment by Country" data-series-name="Enrollment">
{% comment %}
The map is loaded via ajax, so display a loading message. Everything inside of this div will be
cleared when the map data loads.
{% endcomment %}
{% include "loading.html" with message="Loading Map..." %}
</div>
</div> </div>
<hr/> <hr/>
...@@ -100,7 +107,9 @@ Individual course-centric enrollment view ...@@ -100,7 +107,9 @@ Individual course-centric enrollment view
<span class="subsection-title-note small">Which countries are represented by my students?</span> <span class="subsection-title-note small">Which countries are represented by my students?</span>
</h5> </h5>
<div class="section-content section-data-table" data-role="enrollment-location-table"></div> <div class="section-content section-data-table" data-role="enrollment-location-table">
{% include "loading.html" with message="Loading Table..." %}
</div>
<div class="section-actions"> <div class="section-actions">
<a href="{% url 'courses:csv_enrollment_by_country' course_id=course_id %}" class="btn btn-primary" <a href="{% url 'courses:csv_enrollment_by_country' course_id=course_id %}" class="btn btn-primary"
......
...@@ -160,17 +160,19 @@ class CourseEnrollmentByCountryJSONViewTests(CourseEnrollmentViewTestMixin, Test ...@@ -160,17 +160,19 @@ class CourseEnrollmentByCountryJSONViewTests(CourseEnrollmentViewTestMixin, Test
viewname = 'courses:json_enrollment_by_country' viewname = 'courses:json_enrollment_by_country'
def convert_datum(self, datum): def convert_datum(self, datum):
'''
Converts the country data returned from the JSON endpoint to the format
returned by the client API. This is used to compare the two outputs.
'''
datum['date'] = '2014-01-01' datum['date'] = '2014-01-01'
datum['course_id'] = self.course_id datum['course_id'] = self.course_id
datum['count'] = datum['value']
datum['country'] = { datum['country'] = {
'code': datum['country_code'], 'alpha3': datum['countryCode'],
'name': datum['country_name'] 'name': datum['countryName']
} }
del datum['country_code'] del datum['countryCode']
del datum['country_name'] del datum['countryName']
del datum['value']
return datum return datum
......
...@@ -31,9 +31,10 @@ def get_mock_enrollment_summary(): ...@@ -31,9 +31,10 @@ def get_mock_enrollment_summary():
def get_mock_enrollment_location_data(course_id): def get_mock_enrollment_location_data(course_id):
data = [] data = []
for item in ((u'US', u'United States', 500), (u'DE', u'Germany', 100), (u'CA', u'Canada', 300)): for item in (
(u'USA', u'United States', 500), (u'GER', u'Germany', 100), (u'CAN', u'Canada', 300)):
data.append({'date': '2014-01-01', 'course_id': course_id, 'count': item[2], data.append({'date': '2014-01-01', 'course_id': course_id, 'count': item[2],
'country': {'code': item[0], 'name': item[1]}}) 'country': {'alpha3': item[0], 'name': item[1]}})
return data return data
......
...@@ -204,10 +204,10 @@ class CourseEnrollmentByCountryJSON(JSONResponseMixin, CourseView): ...@@ -204,10 +204,10 @@ class CourseEnrollmentByCountryJSON(JSONResponseMixin, CourseView):
if api_response: if api_response:
start_date = api_response[0]['date'] start_date = api_response[0]['date']
api_data = [{'country_code': datum['country']['code'], # formatting this data for easy access in the table UI
'country_name': datum['country']['name'], api_data = [{'countryCode': datum['country']['alpha3'],
'value': datum['count']} for datum in api_response] 'countryName': datum['country']['name'],
'count': datum['count']} for datum in api_response]
data.update( data.update(
{'date': get_formatted_date(start_date), 'data': api_data}) {'date': get_formatted_date(start_date), 'data': api_data})
......
...@@ -14,12 +14,13 @@ var require = { ...@@ -14,12 +14,13 @@ var require = {
views: 'js/views', views: 'js/views',
utils: 'js/utils', utils: 'js/utils',
load: 'js/load', load: 'js/load',
highcharts: 'vendor/highcharts/highcharts.min',
highchartsMap: 'vendor/highcharts/map',
highchartsMapWorld: 'vendor/highcharts/world',
holder: 'vendor/holder', holder: 'vendor/holder',
dataTables: 'vendor/dataTables/jquery.dataTables.min', dataTables: 'vendor/dataTables/jquery.dataTables.min',
dataTablesBootstrap: 'vendor/dataTables/dataTables.bootstrap', dataTablesBootstrap: 'vendor/dataTables/dataTables.bootstrap',
d3: 'vendor/d3/d3',
nvd3: 'vendor/nvd3/nv.d3',
topojson: 'vendor/topojson/topojson',
datamaps: 'vendor/datamaps/datamaps.world.min'
}, },
shim: { shim: {
bootstrap: { bootstrap: {
...@@ -37,17 +38,16 @@ var require = { ...@@ -37,17 +38,16 @@ var require = {
return Backbone; return Backbone;
} }
}, },
highcharts: {
exports: 'Highcharts'
},
highchartsMap: {
deps: ['highcharts']
},
highchartsMapWorld: {
deps: ['highcharts', 'highchartsMap']
},
dataTablesBootstrap: { dataTablesBootstrap: {
deps: ['jquery', 'dataTables'] deps: ['jquery', 'dataTables']
},
nvd3: {
deps: ['d3'],
exports: 'nv'
},
datamaps: {
deps: ['topojson', 'd3'],
exports: 'datamap'
} }
}, },
// load jquery automatically // load jquery automatically
......
...@@ -9,12 +9,13 @@ require(['vendor/domReady!', 'load/init-page'], function(doc, page){ ...@@ -9,12 +9,13 @@ require(['vendor/domReady!', 'load/init-page'], function(doc, page){
'use strict'; 'use strict';
// this is your page specific code // this is your page specific code
require(['views/data-table-view', require(['views/attribute-listener-view',
'views/attribute-view',
'views/data-table-view',
'views/enrollment-trend-view', 'views/enrollment-trend-view',
'views/simple-model-attribute-view',
'views/world-map-view'], 'views/world-map-view'],
function (DataTableView, EnrollmentTrendView, function (AttributeListenerView, AttributeView, DataTableView,
SimpleModelAttributeView, WorldMapView) { EnrollmentTrendView, WorldMapView) {
// Daily enrollment graph // Daily enrollment graph
new EnrollmentTrendView({ new EnrollmentTrendView({
...@@ -36,7 +37,7 @@ require(['vendor/domReady!', 'load/init-page'], function(doc, page){ ...@@ -36,7 +37,7 @@ require(['vendor/domReady!', 'load/init-page'], function(doc, page){
}); });
// Enrollment by country last updated label // Enrollment by country last updated label
new SimpleModelAttributeView({ new AttributeView({
el: '[data-view=enrollment-by-country-update-date]', el: '[data-view=enrollment-by-country-update-date]',
model: page.models.courseModel, model: page.models.courseModel,
modelAttribute: 'enrollmentByCountryUpdateDate' modelAttribute: 'enrollmentByCountryUpdateDate'
...@@ -55,10 +56,10 @@ require(['vendor/domReady!', 'load/init-page'], function(doc, page){ ...@@ -55,10 +56,10 @@ require(['vendor/domReady!', 'load/init-page'], function(doc, page){
model: page.models.courseModel, model: page.models.courseModel,
modelAttribute: 'enrollmentByCountry', modelAttribute: 'enrollmentByCountry',
columns: [ columns: [
{key: 'country_name', title: 'Country'}, {key: 'countryName', title: 'Country'},
{key: 'value', title: 'Count'} {key: 'count', title: 'Count'}
], ],
sorting: ['-value'] sorting: ['-count']
}); });
page.models.courseModel.fetchEnrollmentByCountry(); page.models.courseModel.fetchEnrollmentByCountry();
}); });
......
...@@ -19,7 +19,10 @@ var config = { ...@@ -19,7 +19,10 @@ var config = {
jasmine: 'vendor/jasmine/lib/jasmine-2.0.0/jasmine', jasmine: 'vendor/jasmine/lib/jasmine-2.0.0/jasmine',
'jasmine-html': 'vendor/jasmine/lib/jasmine-2.0.0/jasmine-html', 'jasmine-html': 'vendor/jasmine/lib/jasmine-2.0.0/jasmine-html',
boot: 'vendor/jasmine/lib/jasmine-2.0.0/boot', boot: 'vendor/jasmine/lib/jasmine-2.0.0/boot',
highcharts: 'vendor/highcharts/highcharts.min' d3: 'vendor/d3/d3',
nvd3: 'vendor/nvd3/nv.d3',
topojson: 'vendor/topojson/topojson',
datamaps: 'vendor/datamaps/datamaps.world.min'
}, },
shim: { shim: {
bootstrap: { bootstrap: {
...@@ -42,6 +45,14 @@ var config = { ...@@ -42,6 +45,14 @@ var config = {
boot: { boot: {
deps: ['jasmine', 'jasmine-html'], deps: ['jasmine', 'jasmine-html'],
exports: 'window.jasmineRequire' exports: 'window.jasmineRequire'
},
nvd3: {
deps: ['d3'],
exports: 'nv'
},
datamaps: {
deps: ['topojson', 'd3'],
exports: 'datamap'
} }
} }
}; };
...@@ -54,7 +65,7 @@ if(isBrowser) { ...@@ -54,7 +65,7 @@ if(isBrowser) {
specs = [ specs = [
config.baseUrl + 'js/spec/specs/course-model-spec.js', config.baseUrl + 'js/spec/specs/course-model-spec.js',
config.baseUrl + 'js/spec/specs/tracking-model-spec.js', config.baseUrl + 'js/spec/specs/tracking-model-spec.js',
config.baseUrl + 'js/spec/specs/enrollment-trend-view-spec.js', config.baseUrl + 'js/spec/specs/world-map-view-spec.js',
config.baseUrl + 'js/spec/specs/tracking-view-spec.js', config.baseUrl + 'js/spec/specs/tracking-view-spec.js',
config.baseUrl + 'js/spec/specs/utils-spec.js' config.baseUrl + 'js/spec/specs/utils-spec.js'
]; ];
......
define(['views/enrollment-trend-view', 'models/course-model'], function (EnrollmentTrendView, CourseModel) {
'use strict';
describe('EnrollmentTrendView', function () {
var model, view;
beforeEach(function () {
model = new CourseModel({courseId: 'edX/DemoX/Demo_Course'});
view = new EnrollmentTrendView({model: model, modelAttribute: 'enrollmentTrends'});
});
describe('_convertDate', function () {
it('should convert a string date to UTC', function () {
expect(view._convertDate('2014-01-01')).toEqual(1388534400000);
expect(view._convertDate('2014-12-31')).toEqual(1419984000000);
});
});
describe('_formatData', function () {
it('should zip up enrollment dates with counts', function () {
var input = [
{count: 0, date: '2014-01-01'},
{count: 4567, date: '2014-12-31'}
], expected = [
[1388534400000, 0],
[1419984000000, 4567]
];
expect(view._formatData(input)).toEqual(expected);
});
});
});
});
define(['models/course-model', 'views/world-map-view'], function(CourseModel, WorldMapView) {
'use strict';
describe('World map view', function () {
it('should have a popup template', function () {
var model = new CourseModel(),
view = new WorldMapView({
model: model
}),
actual;
actual = view.popupTemplate({
name: 'My Map',
value: 100
});
expect(actual).toBe('<div class="hoverinfo">My Map: 100</div>');
});
it('should format data for Datamaps', function () {
var rawData = [
{countryCode: 'USA', count: 100},
{countryCode: 'ARG', count: 200}],
model = new CourseModel({mapData: rawData}),
view = new WorldMapView({
model: model,
modelAttribute: 'mapData'
}),
actual,
expected;
actual = view.formatData();
expected = {
USA: { value: 100, fillKey: 'USA' },
ARG: { value: 200, fillKey: 'ARG' }
};
expect(actual).toEqual(expected);
});
it('should fill in colors for countries', function () {
var countryData = {
USA: { value: 0, fillKey: 'USA' },
BLV: { value: 100, fillKey: 'BLV' },
ARG: { value: 200, fillKey: 'ARG' }},
view = new WorldMapView({
model: new CourseModel(),
lowColor: '#000000',
highColor: '#ffffff'
}),
actual,
expected;
actual = view.getFills(countryData, 200);
expected = {
defaultFill: '#000000',
USA: '#000000',
BLV: '#808080',
ARG: '#ffffff'
};
expect(actual).toEqual(expected);
});
it('should return the maximum value', function () {
var countryData = {
USA: { value: 0, fillKey: 'USA' },
BLV: { value: 100, fillKey: 'BLV' },
ARG: { value: 200, fillKey: 'ARG' }},
view = new WorldMapView({
model: new CourseModel(),
modelAttribute: 'mapData'
}),
actual;
actual = view.getCountryMax(countryData);
expect(actual).toEqual(200);
});
});
});
...@@ -3,23 +3,27 @@ define(['backbone', 'jquery'], ...@@ -3,23 +3,27 @@ define(['backbone', 'jquery'],
'use strict'; 'use strict';
/** /**
* Given a model and attribute, this View simply renders the value of the model's attribute * This base view listens for a change in a model attribute and calls
* in the view's element whenever the attribute is changed. * render() when the attribute changes. By default, it clears out the
* view.
*/ */
var SimpleModelAttributeView = Backbone.View.extend({ var AttributeListenerView = Backbone.View.extend({
initialize: function (options) { initialize: function (options) {
this.modelAttribute = options.modelAttribute; this.modelAttribute = options.modelAttribute;
this.listenTo(this.model, 'change:' + this.modelAttribute, this.render); this.listenTo(this.model, 'change:' + this.modelAttribute, this.render);
}, },
/**
* Clears out the view.
*/
render: function () { render: function () {
this.$el.html(this.model.get(this.modelAttribute)); var self = this;
self.$el.empty();
return this; return self;
} }
}); });
return SimpleModelAttributeView; return AttributeListenerView;
} }
); );
define(['views/attribute-listener-view'],
function (AttributeListenerView) {
'use strict';
/**
* Displays the model attribute as text in the el.
*/
var AttributeView = AttributeListenerView.extend({
render: function () {
AttributeListenerView.prototype.render.call(this);
var self = this;
self.$el.html(self.model.get(self.modelAttribute));
return self;
}
});
return AttributeView;
}
);
define(['dataTablesBootstrap', 'jquery', 'underscore', 'views/simple-model-attribute-view'], define(['dataTablesBootstrap', 'jquery', 'underscore', 'views/attribute-listener-view'],
function (dt, $, _, SimpleModelAttributeView) { function (dt, $, _, AttributeListenerView) {
'use strict'; 'use strict';
var DataTableView = SimpleModelAttributeView.extend({ var DataTableView = AttributeListenerView.extend({
initialize: function (options) { initialize: function (options) {
SimpleModelAttributeView.prototype.initialize.call(this, options); AttributeListenerView.prototype.initialize.call(this, options);
var self = this; var self = this;
self.options = options || {}; self.options = options || {};
...@@ -43,25 +43,29 @@ define(['dataTablesBootstrap', 'jquery', 'underscore', 'views/simple-model-attri ...@@ -43,25 +43,29 @@ define(['dataTablesBootstrap', 'jquery', 'underscore', 'views/simple-model-attri
return dtSorting; return dtSorting;
}, },
render: function () { render: function () {
var $parent = $('<div/>', {class:'table-responsive'}).appendTo(this.$el); AttributeListenerView.prototype.render.call(this);
var $table = $('<table/>', {class: 'table table-striped'}).appendTo($parent); var self = this,
var $thead = $('<thead/>').appendTo($table); $parent = $('<div/>', {class:'table-responsive'}).appendTo(this.$el),
var $row = $('<tr/>').appendTo($thead); $table = $('<table/>', {class: 'table table-striped'}).appendTo($parent),
var dtColumns = []; $thead = $('<thead/>').appendTo($table),
var dtConfig, dtSorting; $row = $('<tr/>').appendTo($thead),
dtColumns,
dtConfig,
dtSorting;
dtColumns = this._buildColumns($row); dtColumns = self._buildColumns($row);
dtConfig = { dtConfig = {
paging: false, paging: false,
info: false, info: false,
filter: false, filter: false,
data: this.model.get(this.options.modelAttribute), data: this.model.get(self.options.modelAttribute),
columns: dtColumns columns: dtColumns
}; };
dtSorting = this._buildSorting(); dtSorting = self._buildSorting();
if (dtSorting.length) { if (dtSorting.length) {
dtConfig.order = dtSorting; dtConfig.order = dtSorting;
...@@ -69,7 +73,7 @@ define(['dataTablesBootstrap', 'jquery', 'underscore', 'views/simple-model-attri ...@@ -69,7 +73,7 @@ define(['dataTablesBootstrap', 'jquery', 'underscore', 'views/simple-model-attri
$table.dataTable(dtConfig); $table.dataTable(dtConfig);
return this; return self;
} }
}); });
......
define(['highcharts', 'jquery', 'views/simple-model-attribute-view'], define(['d3', 'nvd3', 'views/attribute-listener-view'],
function (highcharts, $, SimpleModelAttributeView) { function (d3, nvd3, AttributeListenerView) {
'use strict'; 'use strict';
var EnrollmentTrendView = SimpleModelAttributeView.extend({ var EnrollmentTrendView = AttributeListenerView.extend({
/**
* Convert string to UTC timestamp
*
* @param date
* @returns {number}
* @private
*/
_convertDate: function (date) {
var tokens = date.split('-');
// JS months start at 0
return Date.UTC(tokens[0], tokens[1] - 1, tokens[2]);
},
/**
* Convert the array of Objects received from the server to a Array<Array<date, int>>
*
* @param data
* @returns {*}
* @private
*/
_formatData: function (data) {
return _.map(data, function (datum) {
return [this._convertDate(datum.date), datum.count];
}, this);
},
render: function () { render: function () {
var series = this._formatData(this.model.get(this.modelAttribute)); var self = this,
canvas = d3.select(self.el),
chart;
chart = nvd3.models.lineChart()
.margin({left: 80, right: 40}) // margins so text fits
.showLegend(true)
.useInteractiveGuideline(true)
.forceY(0)
.x(function(d) {
// Parse dates to integers
return Date.parse(d.date);
})
.y(function(d) {
// Simply return the count
return d.count;
})
.tooltipContent(function (key, y, e, graph) {
return '<h3>' + key + '</h3>';
});
chart.xAxis
.axisLabel('Date')
.tickFormat(function (d) {
return d3.time.format.utc('%x')(new Date(d));
});
chart.yAxis
.axisLabel('Students')
.tickFormat(function(value){
// display formatted number
return value.toLocaleString();
});
// Add the title
canvas.attr('class', 'line-chart-container')
.append('div')
.attr('class', 'chart-title')
.text('Daily Student Enrollment');
// Append the svg to an inner container so that it adapts to
// the height of the inner container instead of the outer
// container which needs to create height for the title.
canvas.append('div')
.attr('class', 'line-chart')
.append('svg')
.datum([{
values: self.model.get(self.modelAttribute),
key: 'Students'
}])
.call(chart);
this.$el.highcharts({ nv.utils.windowResize(chart.update);
credits: {
enabled: false
},
chart: {
zoomType: 'x'
},
title: {
text: 'Daily Student Enrollment',
},
xAxis: {
type: 'datetime',
labels: {
formatter: function () {
return highcharts.dateFormat('%b %e, %Y', this.value);
}
}
},
yAxis: {
min: 0,
title: {
text: 'Students'
}
},
legend: {
enabled: false
},
series: [
{
name: 'Students',
data: series
}
]
});
return this; return this;
} }
......
define(['highchartsMapWorld', 'jquery', 'views/simple-model-attribute-view'], define(['jquery', 'd3', 'datamaps', 'underscore', 'views/attribute-listener-view'],
function (maps, $, SimpleModelAttributeView) { function ($, d3, Datamap, _, AttributeListenerView) {
'use strict'; 'use strict';
var WorldMapView = SimpleModelAttributeView.extend({ /**
* This view display a map colored by country count data. Tooltips and
* a darker border color will be displayed when the mouse hovers over
* the country.
*/
var WorldMapView = AttributeListenerView.extend({
initialize: function (options) {
AttributeListenerView.prototype.initialize.call(this, options);
var self = this;
// colors can be supplied
self.options = _.defaults(options, {
lowColor: '#f8f8f8',
highColor: '#e6550d',
borderColor: '#c0c0c0'
});
},
/**
* Format the data for Datamaps.
*
* @returns An object of mappings between country code and value.
* ex. { USA: { fillKey: '#f6f6f6', value: 10}, ... }
*/
formatData: function() {
var self = this,
data = self.model.get(this.options.modelAttribute),
formattedData = {};
// go through all the data and create mappings from country code
// to value/count
_(data).each(function(country) {
var key = country.countryCode;
formattedData[key] = {
value: country.count,
fillKey: key
};
});
return formattedData;
},
/**
* Given a mapping of the country to value, return the mappings of
* the countries to colors.
*/
getFills: function(countryData, max) {
var self = this,
fills = {},
colorMap;
// single hue linear scaled
colorMap = d3.scale.linear()
.domain([0, max])
.range([self.options.lowColor, self.options.highColor]);
// create the mapping from country to color
_(countryData).each(function(countryData, key) {
fills[key] = colorMap(countryData.value);
});
fills.defaultFill = self.options.lowColor;
return fills;
},
/**
* Get the maximum value for the set of countries. The mapping is
* from formatData().
*/
getCountryMax: function(countryData) {
var max = _(countryData).max(function(countryData){
return countryData.value;
}).value;
return max;
},
/**
* Plugin for the map to display a heatmap legend with labels.
*/
addHeatmapLegend: function (layer, options) {
// "this" is the Datamap (not the WorldMapView)
var self = this,
el = self.options.element,
canvasHeight = parseInt(d3.select(el).style('height'), 10),
swatch = {height: 5, width: 10},
margins = {bottom: 10},
suggestedTicks = 11,
legend,
colorMap,
ranges;
colorMap = d3.scale.linear()
.range([options.lowColor, options.highColor])
.domain([0, options.max]);
// the rounded evenly spaced intervals
ranges = colorMap.ticks(suggestedTicks);
// set up the data (color bands) to display and locations given
// the provided data
legend = d3.select(el)
.select('.datamap')
.append('svg')
.attr('class', 'datamaps-legend')
.selectAll('svg')
.data(ranges.reverse())
.enter()
.append('g')
.attr('class', 'legend')
.attr('transform', function(d, i) {
// move the legend color swatches to be arranged vertically
var x = swatch.width,
y = canvasHeight - swatch.height * ranges.length + i * swatch.height - margins.bottom;
return 'translate(' + [x,y].join(',') + ')';
});
// display the heatmap
legend.append('rect')
.attr('width', swatch.width)
.attr('height', swatch.height)
.style('fill', colorMap);
// display the high and low ranges on the legend
legend.append('text')
.filter(function(d, i) {
// only show labels for the bounds
return _([0, ranges.length-1]).contains(i);
})
.attr('x', swatch.width + 3)
.attr('y', swatch.height * 0.75)
.attr('dy', '.35em')
.text(function(d,i) {
var append = '';
// ticks are rounded, so denote this in our legend
if (i === 0) {
append = '+';
}
return d.toLocaleString() + append;
});
},
/**
* Underscore style template for the hover popup that displays a
* label/name and value.
*/
popupTemplate: _.template('<div class="hoverinfo"><%=name%>: <%=value%></div>'),
render: function () { render: function () {
var title = this.$el.data('title'); AttributeListenerView.prototype.render.call(this);
var seriesName = this.$el.data('series-name'); var self = this,
var seriesData = this.model.get(this.modelAttribute).slice(0); // Clone the array to avoid issues with other consumers mapData = self.formatData(),
max = self.getCountryMax(mapData),
this.$el.highcharts('Map', { fills = self.getFills(mapData, max),
title: { text: title }, borderColor = self.options.borderColor,
mapNavigation: { map;
enabled: true,
buttonOptions: { map = new Datamap({
verticalAlign: 'bottom' element: self.el,
projection: 'mercator',
geographyConfig: {
hideAntarctica: true,
borderWidth: 1,
borderColor: borderColor,
highlightOnHover: true,
highlightFillColor: function(geometry) {
// keep the fill color the same -- only the border
// color will change when hovering
return fills[geometry.id] || fills.defaultFill;
},
highlightBorderColor: d3.rgb(borderColor).darker(1),
highlightBorderWidth: 1,
popupOnHover: true,
popupTemplate: function (geography, data) {
return self.popupTemplate({
name: geography.properties.name,
value: data ? data.value.toLocaleString() : 0
});
} }
}, },
colorAxis: { fills: fills,
min: 0 data: mapData
},
series: [
{
data: seriesData,
mapData: Highcharts.maps['custom/world'],
joinBy: ['hc-a2', 'country_code'],
name: seriesName,
states: {
hover: {
color: '#D2167A'
}
},
dataLabels: {
enabled: true,
format: '{point.name}'
}
}
]
}); });
}
// add the legend plugin and render it
map.addPlugin('heatmapLegend', self.addHeatmapLegend);
map.heatmapLegend({
highColor: self.options.highColor,
lowColor: self.options.lowColor,
max: max
});
return this;
}
}); });
return WorldMapView; return WorldMapView;
} }
); )
;
...@@ -7,3 +7,22 @@ ...@@ -7,3 +7,22 @@
// .crazy-new-feature { // .crazy-new-feature {
// background: transparent; // background: transparent;
// } // }
.line-chart-container {
background-color: white;
padding-top: 10px; // Give the title some breathing room.
.line-chart {
height: 300px;
}
.chart-title {
text-align: center;
}
}
.world-map {
position: relative;
margin: 0 auto;
height: 500px;
}
...@@ -13,6 +13,7 @@ ...@@ -13,6 +13,7 @@
@import "../vendor/bootstrap/stylesheets/bootstrap"; // vendor: bootstrap @import "../vendor/bootstrap/stylesheets/bootstrap"; // vendor: bootstrap
@import "../vendor/font-awesome/scss/font-awesome"; // vendor: font awesome @import "../vendor/font-awesome/scss/font-awesome"; // vendor: font awesome
@import "../vendor/dataTables/dataTables.bootstrap"; @import "../vendor/dataTables/dataTables.bootstrap";
@import "../vendor/nvd3/nv.d3";
// app styling // app styling
// -------------------- // --------------------
......
Copyright (c) 2010-2014, Michael Bostock
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
* The name Michael Bostock may not be used to endorse or promote products
derived from this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL MICHAEL BOSTOCK BE LIABLE FOR ANY DIRECT,
INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY
OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE,
EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
This source diff could not be displayed because it is too large. You can view the blob instead.
The MIT License (MIT)
Copyright (c) 2012 Mark DiMarco
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
\ No newline at end of file
This source diff could not be displayed because it is too large. You can view the blob instead.
This source diff could not be displayed because it is too large. You can view the blob instead.
This source diff could not be displayed because it is too large. You can view the blob instead.
##nvd3.js License
Copyright (c) 2011, 2012 [Novus Partners, Inc.][novus]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
[novus]: https://www.novus.com/
##d3.js License
Copyright (c) 2012, Michael Bostock
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
* The name Michael Bostock may not be used to endorse or promote products
derived from this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL MICHAEL BOSTOCK BE LIABLE FOR ANY DIRECT,
INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY
OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE,
EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
This source diff could not be displayed because it is too large. You can view the blob instead.
Copyright (c) 2012, Michael Bostock
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
* The name Michael Bostock may not be used to endorse or promote products
derived from this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL MICHAEL BOSTOCK BE LIABLE FOR ANY DIRECT,
INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY
OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE,
EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
{% comment %}
Partial: Displays a loading spinner
{% endcomment %}
<p class="text-center"><i class="fa fa-spinner fa-spin fa-lg"></i> {{ message }}</p>
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment