(function ()()

in static/js/stats/chart.js [12:600]


(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');
  });
})();