static/js/stats/chart.js (547 lines of code) (raw):

import $ from 'jquery'; import _ from 'underscore'; import { _pd } from '../lib/prevent-default'; import { normalizeRange, forEachISODate } from './dateutils'; import csv_keys from './csv_keys'; import { format } from '../lib/format'; import Highcharts from 'highcharts'; import { StatsManager } from './manager'; const dayMsecs = 24 * 3600 * 1000; (function () { var $win = $(window), $chart = $('#head-chart'), $btnZoom = $('#chart-zoomout'), baseConfig = { chart: { renderTo: 'head-chart', zoomType: 'x', events: { selection: function () { $btnZoom.removeClass('inactive').click( _pd(function (e) { $(this).trigger('zoomout'); }), ); }, }, }, credits: { enabled: false }, title: { text: null, }, xAxis: { type: 'datetime', maxZoom: 7 * 24 * 3600000, // seven days title: { text: null, }, tickmarkPlacement: 'on', startOfWeek: 0, }, yAxis: { title: { text: null, }, labels: { formatter: function () { return Highcharts.numberFormat(this.value, 0); }, }, min: 0, minPadding: 0.05, startOnTick: false, showFirstLabel: false, }, legend: { enabled: true, }, tooltip: {}, plotOptions: { line: { lineWidth: 1, animation: false, shadow: false, marker: { enabled: true, radius: 0, states: { hover: { enabled: true, radius: 5, }, }, }, states: { hover: { lineWidth: 2, }, }, connectNulls: true, }, }, }; Highcharts.setOptions({ lang: { resetZoom: '' } }); var chart; // which unit do we use for a given metric? var metricTypes = { usage: 'users', apps: 'users', locales: 'users', os: 'users', versions: 'users', statuses: 'users', users_created: 'users', downloads: 'downloads', sources: 'downloads', contributions: 'currency', revenue: 'currency', reviews_created: 'reviews', addons_in_use: 'addons', addons_created: 'addons', addons_updated: 'addons', addons_downloaded: 'addons', collections_created: 'collections', subscribers: 'collections', ratings: 'collections', sales: 'sales', refunds: 'refunds', installs: 'installs', countries: 'users', contents: 'downloads', mediums: 'downloads', campaigns: 'downloads', }; var acceptedGroups = { day: true, week: true, month: true, }; function showNoDataOverlay() { $chart.parent().addClass('nodata'); $chart.parent().removeClass('loading'); if (chart && chart.destroy) chart.destroy(); } $win.on('changeview', function () { $chart.parent().removeClass('nodata'); $chart.addClass('loading'); $btnZoom.addClass('inactive').click(_pd); }); $win.on('dataready', function (e, obj) { var view = obj.view, metric = view.metric, group = view.group, data = obj.data, range = normalizeRange(view.range), start = range.start, end = range.end, date_range_days = parseInt((end - start) / 1000 / 3600 / 24, 10), fields = obj.fields ? obj.fields.slice(0, 5) : ['count'], series = {}, events = [], // TODO: remove this, it is not used anymore because // the caller does not pass any values anymore. chartRange = {}, t, row, i, field, val, is_overview = metric == 'overview' || metric == 'app_overview'; if (!(group in acceptedGroups)) { group = 'day'; } // Disable links if they don't fit into the date range. $('.group a, .range a').removeClass('inactive').off('click', false); if (group == 'week') { $('a.days-7').addClass('inactive').on('click', false); } else if (group == 'month') { $('a.days-7, a.days-30').addClass('inactive').on('click', false); } if (group == 'day') { $('a.group-day').parent().addClass('selected'); } if (date_range_days <= 8) { $('a.group-week, a.group-month').addClass('inactive').on('click', false); } if (date_range_days <= 31) { $('a.group-month').addClass('inactive').on('click', false); } if (obj.data.empty || !data.firstIndex) { showNoDataOverlay(); $chart.removeClass('loading'); return; } // Initialize the empty series object. _.each(fields, function (f) { series[f] = []; }); // Transmute the data into something Highcharts understands. start = Date.iso(data.firstIndex); var step = '1 ' + group, point, dataSum = 0; forEachISODate( { start: start, end: end }, '1 ' + group, data, function (row, d) { for (i = 0; i < fields.length; i++) { field = fields[i]; val = parseFloat(StatsManager.getField(row, field)); if (val != val) val = null; series[field].push(val); if (val) dataSum += val; } }, this, ); // Display marker if only one data point. baseConfig.plotOptions.line.marker.radius = 3; var count = 0, dateRegex = /\d{4}-\d{2}-\d{2}/; for (var key in data) { if (dateRegex.exec(key) && data.hasOwnProperty(key)) { count++; } if (count > 1) { baseConfig.plotOptions.line.marker.radius = 0; break; } } // highCharts seems to dislike 0 and null data when determining a yAxis range. if (dataSum === 0) { baseConfig.yAxis.max = 10; } else { baseConfig.yAxis.max = null; } // Transform xAxis based on time grouping (day, week, month) and range. var pointInterval = dayMsecs; var dateRangeDays = (end - start) / dayMsecs; baseConfig.xAxis.min = start - dayMsecs; // Fix chart truncation. baseConfig.xAxis.max = end; baseConfig.xAxis.tickInterval = null; if (group == 'week') { pointInterval = 7 * dayMsecs; baseConfig.xAxis.maxZoom = 7 * dayMsecs; if (dateRangeDays <= 90) { baseConfig.xAxis.tickInterval = 7 * dayMsecs; } } else if (group == 'month') { pointInterval = 30 * dayMsecs; baseConfig.xAxis.maxZoom = 31 * dayMsecs; if (dateRangeDays <= 365) { baseConfig.xAxis.tickInterval = 30 * dayMsecs; } } // Set minimum max value for yAxis to prevent duplicate yAxis values. var max = 0; for (var _key in data) { if (data[_key].count > max) { max = data[_key].count; } } // Chart has minimum 5 ticks so set max to 5 to avoid pigeonholing. if (max < 5) { baseConfig.yAxis.max = 5; } // Round the start time to the nearest day (truncate the time) and // account for time zone to line up ticks and points on datetime axis. const date = new Date(start); date.setHours(0, 0, 0); start = date.getTime() - date.getTimezoneOffset() * 60000; // Populate the chart config object. var chartData = [], id; for (i = 0; i < fields.length; i++) { field = fields[i]; id = field.split('|').slice(-1)[0]; chartData.push({ type: 'line', name: StatsManager.getPrettyName(view.metric, id), id: id, pointInterval: pointInterval, // Add offset to line up points and ticks on day grouping. pointStart: start, data: series[field], visible: !(metric == 'contributions' && id != 'total'), }); } // Generate the tooltip function for this chart. // both x and y axis can be displayed differently. var tooltipFormatter = (function () { var xFormatter, yFormatter; function dayFormatter(d) { return Highcharts.dateFormat('%a, %b %e, %Y', new Date(d)); } function weekFormatter(d) { return format( gettext('Week of {0}'), Highcharts.dateFormat('%b %e, %Y', new Date(d)), ); } function monthFormatter(d) { return Highcharts.dateFormat('%B %Y', new Date(d)); } function downloadFormatter(n) { return format( ngettext('{0} download', '{0} downloads', n), Highcharts.numberFormat(n, 0), ); } function userFormatter(n) { return format( ngettext('{0} user', '{0} users', n), Highcharts.numberFormat(n, 0), ); } function addonsFormatter(n) { return format( ngettext('{0} add-on', '{0} add-ons', n), Highcharts.numberFormat(n, 0), ); } function collectionsFormatter(n) { return format( ngettext('{0} collection', '{0} collections', n), Highcharts.numberFormat(n, 0), ); } function reviewsFormatter(n) { return format( ngettext('{0} review', '{0} reviews', n), Highcharts.numberFormat(n, 0), ); } function currencyFormatter(n) { return '$' + Highcharts.numberFormat(n, 2); } function salesFormatter(n) { return format( ngettext('{0} sale', '{0} sales', n), Highcharts.numberFormat(n, 0), ); } function refundsFormatter(n) { return format( ngettext('{0} refund', '{0} refunds', n), Highcharts.numberFormat(n, 0), ); } function installsFormatter(n) { return format( ngettext('{0} install', '{0} installs', n), Highcharts.numberFormat(n, 0), ); } function addEventData(s, date) { var e = events[date]; if (e) { s += format('<br><br><b>{type_pretty}</b>', e); } return s; } // Determine x-axis formatter. if (group == 'week') { xFormatter = weekFormatter; } else if (group == 'month') { xFormatter = monthFormatter; } else { xFormatter = dayFormatter; } if (is_overview) { return function () { var ret = '<b>' + xFormatter(this.x) + '</b>', p; for (var i = 0; i < this.points.length; i++) { p = this.points[i]; ret += '<br>' + p.series.name + ': '; ret += Highcharts.numberFormat(p.y, 0); } return addEventData(ret, this.x); }; } else if (metric == 'contributions') { return function () { var ret = '<b>' + xFormatter(this.x) + '</b>', p; for (var i = 0; i < this.points.length; i++) { p = this.points[i]; ret += '<br>' + p.series.name + ': '; if (p.series.options.yAxis > 0) { ret += Highcharts.numberFormat(p.y, 0); } else { ret += currencyFormatter(p.y); } } return addEventData(ret, this.x); }; } else { // Determine y-axis formatter. switch (metricTypes[metric]) { case 'users': yFormatter = userFormatter; break; case 'downloads': yFormatter = downloadFormatter; break; case 'currency': case 'revenue': yFormatter = currencyFormatter; break; case 'collections': yFormatter = collectionsFormatter; break; case 'reviews': yFormatter = reviewsFormatter; break; case 'addons': yFormatter = addonsFormatter; break; case 'sales': yFormatter = salesFormatter; break; case 'refunds': yFormatter = refundsFormatter; break; case 'installs': yFormatter = installsFormatter; break; } return function () { var ret = '<b>' + this.series.name + '</b><br>' + xFormatter(this.x) + '<br>' + yFormatter(this.y); return addEventData(ret, this.x); }; } })(); // Set up the new chart's configuration. var newConfig = $.extend(baseConfig, { series: chartData }); // set up dual-axes for the overview chart. if (is_overview && newConfig.series.length) { _.extend(newConfig, { yAxis: [ { // Downloads title: { text: gettext('Downloads'), }, min: 0, labels: { formatter: function () { return Highcharts.numberFormat(this.value, 0); }, }, }, { // Daily Users title: { text: gettext('Daily Users'), }, labels: { formatter: function () { return Highcharts.numberFormat(this.value, 0); }, }, min: 0, opposite: true, }, ], tooltip: { shared: true, crosshairs: true, }, }); // set Daily Users series to use the right yAxis. if (metric == 'overview') { _.find(newConfig.series, function (s) { return s.id == 'updates'; }).yAxis = 1; } else { _.find(newConfig.series, function (s) { return s.id == 'usage'; }).yAxis = 1; } } if (metric == 'contributions' && newConfig.series.length) { _.extend(newConfig, { yAxis: [ { // Amount title: { text: gettext('Amount, in USD'), }, labels: { formatter: function () { return Highcharts.numberFormat(this.value, 2); }, }, min: 0, }, { // Number of Contributions title: { text: gettext('Number of Contributions'), }, min: 0, labels: { formatter: function () { return Highcharts.numberFormat(this.value, 0); }, }, opposite: true, }, ], tooltip: { shared: true, crosshairs: true, }, }); // set Daily Users series to use the right yAxis. newConfig.series[0].yAxis = 1; } newConfig.tooltip.formatter = tooltipFormatter; function makeSiteEventHandler(e) { return function () { var s = format('<h3>{type_pretty}</h3><p>{description}</p>', e); if (e.url) { s += format('<p><a href="{0}">{1}</a></p>', [ e.url, gettext('More Info...'), ]); } $('#exception-note h2').html( format( // L10n: {0} is an ISO-formatted date. gettext('Details for {0}'), e.start, ), ); $('#exception-note div').html(s); $chart.trigger('explain-exception'); }; } var pb = [], pl = []; const eventColors = ['#DDD', '#DDD', '#FDFFD0', '#D0FFD8']; _.forEach(events, function (e) { pb.push({ color: eventColors[e.type], from: Date.iso(e.start).backward('12h'), to: Date.iso(e.end || e.start).forward('12h'), events: { click: makeSiteEventHandler(e), }, }); }); newConfig.xAxis.plotBands = pb; newConfig.xAxis.plotLines = pl; if (fields.length == 1) { newConfig.legend.enabled = false; } // Generate a pretty title for the chart. var title; if (typeof obj.view.range == 'string') { var numDays = parseInt(obj.view.range, 10); title = format(csv_keys.chartTitle[metric][0], numDays); } else { // This is a custom range so display a range shorter by one day. end = new Date(end.getTime() - 24 * 60 * 60 * 1000); title = format(csv_keys.chartTitle[metric][1], [ new Date(start).iso(), end.iso(), ]); } newConfig.title = { text: title, }; if (chart && chart.destroy) chart.destroy(); chart = new Highcharts.Chart(newConfig); chartRange = chart.xAxis[0].getExtremes(); $win.on('zoomout', function () { chart.xAxis[0].setExtremes(chartRange.min, chartRange.max); $btnZoom.addClass('inactive').click(_pd); }); $chart.removeClass('loading'); }); })();