CartoDB/cartodb20

View on GitHub
lib/assets/javascripts/deep-insights/widgets/histogram/content-view.js

Summary

Maintainability
F
1 wk
Test Coverage
var _ = require('underscore');
var CoreView = require('backbone/core-view');
var formatter = require('../../formatter');
var HistogramTitleView = require('./histogram-title-view');
var HistogramChartView = require('./chart');
var placeholder = require('./placeholder.tpl');
var template = require('./content.tpl');
var DropdownView = require('../dropdown/widget-dropdown-view');
var TipsyTooltipView = require('../../../builder/components/tipsy-tooltip-view');
var AnimateValues = require('../animate-values.js');
var animationTemplate = require('./animation-template.tpl');
var layerColors = require('../../util/layer-colors');
var analyses = require('../../data/analyses');
var escapeHTML = require('../../util/escape-html');

var TABLET_VIEWPORT_BREAKPOINT = 1200;

/**
 * Widget content view for a histogram
 */
module.exports = CoreView.extend({
  className: 'CDB-Widget-body',

  defaults: {
    chartHeight: 48 + 20 + 4
  },

  events: {
    'click .js-clear': '_resetWidget',
    'click .js-zoom': '_zoom'
  },

  initialize: function () {
    this._dataviewModel = this.model.dataviewModel;
    this._layerModel = this.model.layerModel;
    this._originalData = this._dataviewModel.getUnfilteredDataModel();
    this.filter = this._dataviewModel.filter;
    this.lockedByUser = false;
    this._numberOfFilters = 0;
    this._initStateApplied = false;

    if (this._originalData.get('hasBeenFetched')) {
      this._initBinds();
    } else {
      this.listenToOnce(this._originalData, 'change:hasBeenFetched', function () {
        this._initBinds();
      });
    }

    window.addEventListener('resize', this._onWindowResized.bind(this), { passive: true });
  },

  render: function () {
    this.clearSubViews();
    this._unbinds();

    this.$el.toggleClass('is-collapsed', !!this.model.get('collapsed'));

    var data = this._dataviewModel.getData();
    var hasNulls = this._dataviewModel.hasNulls();
    var originalData = this._originalData.getData();
    var isDataEmpty = !_.size(data) && !_.size(originalData);

    var sourceId = this._dataviewModel.get('source').id;
    var letter = layerColors.letter(sourceId);
    var sourceColor = layerColors.getColorForLetter(letter);
    var sourceType = this._dataviewModel.getSourceType() || '';
    var isSourceType = this._dataviewModel.isSourceType();
    var layerName = isSourceType
      ? this.model.get('table_name')
      : this._layerModel.get('layer_name');

    this.$el.html(
      template({
        title: this.model.get('title'),
        sourceId: sourceId,
        sourceType: analyses.title(sourceType),
        isSourceType: isSourceType,
        showStats: this.model.get('show_stats'),
        showNulls: hasNulls,
        showSource: this.model.get('show_source') && letter !== '',
        itemsCount: !isDataEmpty ? data.length : '-',
        isCollapsed: !!this.model.get('collapsed'),
        sourceColor: sourceColor,
        layerName: escapeHTML(layerName)
      })
    );

    if (isDataEmpty) {
      this._addPlaceholder();
      this._initTitleView();
    } else {
      this._setupBindings();
      this._initViews();
    }

    return this;
  },

  _initViews: function () {
    this._initTitleView();
    this._initDropdownView();
    this._renderMiniChart();
    this._renderMainChart();
    this._renderAllValues();
  },

  _onWindowResized: function () {
    if (window.innerWidth < TABLET_VIEWPORT_BREAKPOINT) {
      this.histogramChartView && this.histogramChartView.forceResize();
    }
  },

  _initDropdownView: function () {
    var dropdown = new DropdownView({
      model: this.model,
      target: '.js-actions',
      container: this.$('.js-header'),
      flags: {
        normalizeHistogram: true
      }
    });

    this.addView(dropdown);
  },

  _toggleTooltip: function (event) {
    if (!event || !event.target) {
      return this._clearTooltip();
    }

    this._showTooltip(event);
  },

  _showTooltip: function (event) {
    if (this.tooltip && event && this.tooltip.getElement() === event.target) {
      return;
    }

    this._clearTooltip();

    this.tooltip = new TipsyTooltipView({
      el: event.target,
      title: 'data-tooltip',
      trigger: 'manual'
    });

    this.tooltip.showTipsy();
    this.addView(this.tooltip);
  },

  _clearTooltip: function () {
    if (!this.tooltip) return;

    this.tooltip.hideTipsy();
    this.removeView(this.tooltip);
    this.tooltip = undefined;
  },

  _initTitleView: function () {
    var titleView = new HistogramTitleView({
      widgetModel: this.model,
      dataviewModel: this._dataviewModel,
      layerModel: this._layerModel
    });

    this.$('.js-title').append(titleView.render().el);
    this.addView(titleView);
  },

  _initBinds: function () {
    this.listenTo(this.model, 'change:collapsed', this.render);
    this.listenTo(this.model, 'change:normalized', this._onNormalizedChanged);

    if (this.model.get('hasInitialState') === true) {
      this._setInitialState();
    } else {
      this.listenTo(this.model, 'change:hasInitialState', this._setInitialState);
    }

    this.listenTo(this._layerModel, 'change:layer_name', this.render);
  },

  _setInitialState: function () {
    var data = this._dataviewModel.getData();
    if (data.length === 0) {
      this._dataviewModel.once('change:data', this._onInitialState, this);
    } else {
      this._onInitialState();
    }
  },

  _onNormalizedChanged: function () {
    var normalized = this.model.get('normalized');
    this.histogramChartView.setNormalized(normalized);
    this.miniHistogramChartView.setNormalized(normalized);
  },

  _onInitialState: function () {
    this.add_related_model(this._dataviewModel);
    this.render();

    if (this.model.get('autoStyle') === true) {
      this.model.autoStyle();
    }

    if (this._hasRange()) {
      this._numberOfFilters = 1;
    }

    // in order to calculate the stats right, we simulate the full zommmed range
    if (this._isZoomed()) {
      this._numberOfFilters = 2;
    }

    if (this._numberOfFilters !== 0) {
      this._setInitialRange();
    } else {
      this._completeInitialState();
    }
  },

  _setupRange: function (data, min, max, updateCallback) {
    var lo = 0;
    var hi = data.length;
    var startMin;
    var startMax;

    if (_.isNumber(min)) {
      startMin = _.findWhere(data, {start: min});
      lo = startMin ? startMin.bin : 0;
    }

    if (_.isNumber(max)) {
      startMax = _.findWhere(data, {end: max});
      hi = startMax ? startMax.bin + 1 : data.length;
    }

    if ((lo && lo !== 0) || (hi && hi !== data.length)) {
      this.filter.setRange(
        data[lo].start,
        data[hi - 1].end
      );
      this._onChangeFilterEnabled();
      this.histogramChartView.selectRange(lo, hi);
    }

    updateCallback && updateCallback(lo, hi);
  },

  _setInitialRange: function () {
    var data = this._dataviewModel.getData();
    var min = this.model.get('min');
    var max = this.model.get('max');
    var filterEnabled = (min !== undefined || max !== undefined);

    this._setupRange(data, min, max, function (lo, hi) {
      this.model.set({ filter_enabled: filterEnabled, lo_index: lo, hi_index: hi });
      this._dataviewModel.bind('change:data', this._onHistogramDataChanged, this);
      this._initStateApplied = true;

      // If zoomed, we open the zoom level. Internally it does a fetch, so a change:data is triggered
      // and the _onHistogramDataChanged callback will do the _updateStats call.
      if (this._isZoomed()) {
        this._onChangeZoomEnabled();
        this._onZoomIn();
      } else {
        this._updateStats();
      }
    }.bind(this));
  },

  _completeInitialState: function () {
    this._dataviewModel.bind('change:data', this._onHistogramDataChanged, this);
    this._initStateApplied = true;
    this._updateStats();
  },

  _isZoomed: function () {
    return this.model.get('zoomed');
  },

  _hasRange: function () {
    var min = this.model.get('min');
    var max = this.model.get('max');

    return (min != null || max != null);
  },

  _onHistogramDataChanged: function () {
    // When the histogram is zoomed, we don't need to rely
    // on the change url to update the histogram
    // TODO the widget should not know about the URL… could this state be got from the dataview model somehow?
    if (this._dataviewModel.changed.url && this._isZoomed()) {
      return;
    }

    // if the action was initiated by the user
    // don't replace the stored data
    if (this.lockedByUser) {
      this.lockedByUser = false;
    } else {
      if (!this._isZoomed()) {
        this.histogramChartView.showShadowBars();
        this.miniHistogramChartView.replaceData(this._dataviewModel.getData());
      } else {
        this._filteredData = this._dataviewModel.getData();
      }
      this.histogramChartView.replaceData(this._dataviewModel.getData());
    }

    if (this.unsettingRange) {
      this._unsetRange();
    }

    this._updateStats();
  },

  _unsetRange: function () {
    this.unsettingRange = false;
    this.histogramChartView.replaceData(this._dataviewModel.getData());
    this.model.set({ lo_index: null, hi_index: null });

    if (!this._isZoomed()) {
      this.histogramChartView.showShadowBars();
    }
  },

  _addPlaceholder: function () {
    this.$('.js-content').append(placeholder());
  },

  _renderMainChart: function () {
    this.histogramChartView = new HistogramChartView(({
      type: 'histogram',
      margin: { top: 4, right: 4, bottom: 4, left: 4 },
      hasHandles: true,
      hasAxisTip: true,
      chartBarColor: this.model.getColor() || '#9DE0AD',
      width: this.canvasWidth,
      height: this.defaults.chartHeight,
      data: this._dataviewModel.getData(),
      dataviewModel: this._dataviewModel,
      layerModel: this._layerModel,
      originalData: this._originalData,
      displayShadowBars: !this.model.get('normalized'),
      normalized: this.model.get('normalized'),
      widgetModel: this.model
    }));

    this.$('.js-chart').append(this.histogramChartView.el);
    this.addView(this.histogramChartView);

    this.histogramChartView.bind('on_brush_end', this._onBrushEnd, this);
    this.histogramChartView.bind('hover', this._toggleTooltip, this);
    this.histogramChartView.render().show();

    this._updateStats();
  },

  _renderMiniChart: function () {
    this.miniHistogramChartView = new HistogramChartView(({
      type: 'histogram',
      className: 'CDB-Chart--mini',
      mini: true,
      margin: { top: 0, right: 4, bottom: 4, left: 4 },
      height: 40,
      showOnWidthChange: false,
      dataviewModel: this._dataviewModel,
      layerModel: this._layerModel,
      data: this._dataviewModel.getData(),
      normalized: this.model.get('normalized'),
      chartBarColor: this.model.getColor() || '#9DE0AD',
      originalData: this._originalData,
      widgetModel: this.model
    }));

    this.addView(this.miniHistogramChartView);
    this.$('.js-mini-chart').append(this.miniHistogramChartView.el);
    this.miniHistogramChartView.bind('on_brush_end', this._onMiniRangeUpdated, this);
    this.miniHistogramChartView.render();
  },

  _setupBindings: function () {
    this._dataviewModel.bind('change:bins', this._onChangeBins, this);
    this.model.bind('change:zoomed', this._onChangeZoomed, this);
    this.model.bind('change:zoom_enabled', this._onChangeZoomEnabled, this);
    this.model.bind('change:filter_enabled', this._onChangeFilterEnabled, this);
    this.model.bind('change:total', this._onChangeTotal, this);
    this.model.bind('change:nulls', this._onChangeNulls, this);
    this.model.bind('change:max', this._onChangeMax, this);
    this.model.bind('change:min', this._onChangeMin, this);
    this.model.bind('change:avg', this._onChangeAvg, this);
  },

  _unbinds: function () {
    this._dataviewModel.off('change:bins', this._onChangeBins, this);
    this.model.off('change:zoomed', this._onChangeZoomed, this);
    this.model.off('change:zoom_enabled', this._onChangeZoomEnabled, this);
    this.model.off('change:filter_enabled', this._onChangeFilterEnabled, this);
    this.model.off('change:total', this._onChangeTotal, this);
    this.model.off('change:nulls', this._onChangeNulls, this);
    this.model.off('change:max', this._onChangeMax, this);
    this.model.off('change:min', this._onChangeMin, this);
    this.model.off('change:avg', this._onChangeAvg, this);
  },

  _onMiniRangeUpdated: function (loBarIndex, hiBarIndex) {
    this.lockedByUser = false;

    this._clearTooltip();
    this.histogramChartView.removeSelection();

    var data = this._originalData.getData();

    this.model.set({lo_index: loBarIndex, hi_index: hiBarIndex, zlo_index: null, zhi_index: null});
    this._applyBrushFilter(data, loBarIndex, hiBarIndex);
  },

  _onBrushEnd: function (loBarIndex, hiBarIndex) {
    if (this._isZoomed()) {
      this._onBrushEndFiltered(loBarIndex, hiBarIndex);
    } else {
      this._onBrushEndUnfiltered(loBarIndex, hiBarIndex);
    }
  },

  _onBrushEndFiltered: function (loBarIndex, hiBarIndex) {
    var data = this._filteredData;
    if ((!data || !data.length) || (this.model.get('zlo_index') === loBarIndex && this.model.get('zhi_index') === hiBarIndex)) {
      return;
    }

    this._numberOfFilters = 2;
    this.lockedByUser = true;
    this.model.set({filter_enabled: true, zlo_index: loBarIndex, zhi_index: hiBarIndex});
    this._applyBrushFilter(data, loBarIndex, hiBarIndex);
  },

  _onBrushEndUnfiltered: function (loBarIndex, hiBarIndex) {
    var data = this._dataviewModel.getData();
    if ((!data || !data.length) || (this.model.get('lo_index') === loBarIndex && this.model.get('hi_index') === hiBarIndex)) {
      return;
    }
    this._numberOfFilters = 1;
    this.model.set({filter_enabled: true, zoom_enabled: true, lo_index: loBarIndex, hi_index: hiBarIndex});
    this._applyBrushFilter(data, loBarIndex, hiBarIndex);
  },

  _applyBrushFilter: function (data, loBarIndex, hiBarIndex) {
    if (loBarIndex !== hiBarIndex && loBarIndex >= 0 && loBarIndex < data.length && (hiBarIndex - 1) >= 0 && (hiBarIndex - 1) < data.length) {
      this.filter.setRange(
        data[loBarIndex].start,
        data[hiBarIndex - 1].end
      );
      this._updateStats();
    } else {
      console.error('Error accessing array bounds', loBarIndex, hiBarIndex, data);
    }
  },

  _onChangeFilterEnabled: function () {
    this.$('.js-filter').toggleClass('is-hidden', !this.model.get('filter_enabled'));
  },

  _onChangeBins: function (mdl, bins) {
    this._resetWidget();
  },

  _onChangeZoomEnabled: function () {
    this.$('.js-zoom').toggleClass('is-hidden', !this.model.get('zoom_enabled'));
  },

  _renderAllValues: function () {
    this._changeHeaderValue('.js-nulls', 'nulls', '');
    this._changeHeaderValue('.js-val', 'total', 'SELECTED');
    this._changeHeaderValue('.js-max', 'max', '');
    this._changeHeaderValue('.js-min', 'min', '');
    this._changeHeaderValue('.js-avg', 'avg', '');
  },

  _changeHeaderValue: function (className, what, suffix) {
    if (this.model.get(what) == null) {
      this.$(className).text('0 ' + suffix);
      return;
    }

    this._addTitleForValue(className, what, suffix);

    var animator = new AnimateValues({
      el: this.$el
    });

    animator.animateValue(this.model, what, className, animationTemplate, {
      formatter: formatter.formatNumber,
      templateData: { suffix: ' ' + suffix }
    });
  },

  _onChangeNulls: function () {
    this._changeHeaderValue('.js-nulls', 'nulls', '');
  },

  _onChangeTotal: function () {
    this._changeHeaderValue('.js-val', 'total', 'SELECTED');
  },

  _onChangeMax: function () {
    this._changeHeaderValue('.js-max', 'max', '');
  },

  _onChangeMin: function () {
    this._changeHeaderValue('.js-min', 'min', '');
  },

  _onChangeAvg: function () {
    this._changeHeaderValue('.js-avg', 'avg', '');
  },

  _addTitleForValue: function (className, what, unit) {
    this.$(className).attr('title', this._formatNumberWithCommas(this.model.get(what).toFixed(2)) + ' ' + unit);
  },

  _formatNumberWithCommas: function (x) {
    return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
  },

  _calculateBars: function (data) {
    var min;
    var max;
    var loBarIndex;
    var hiBarIndex;
    var startMin;
    var startMax;

    if (this._isZoomed() && this._numberOfFilters === 2) {
      min = this.model.get('zmin');
      max = this.model.get('zmax');
      loBarIndex = this.model.get('zlo_index');
      hiBarIndex = this.model.get('zhi_index');
    } else if (this._numberOfFilters === 1) {
      min = this.model.get('min');
      max = this.model.get('max');
      loBarIndex = this.model.get('lo_index');
      hiBarIndex = this.model.get('hi_index');
    }

    if (data.length > 0) {
      if (!_.isNumber(min) && !_.isNumber(loBarIndex)) {
        loBarIndex = 0;
      } else if (_.isNumber(min) && !_.isNumber(loBarIndex)) {
        startMin = _.findWhere(data, {start: min});
        loBarIndex = (startMin && startMin.bin) || 0;
      }

      if (!_.isNumber(max) && !_.isNumber(hiBarIndex)) {
        hiBarIndex = data.length;
      } else if (_.isNumber(max) && !_.isNumber(hiBarIndex)) {
        startMax = _.findWhere(data, {end: max});
        hiBarIndex = (startMax && startMax.bin + 1) || data.length;
      }
    } else {
      loBarIndex = 0;
      hiBarIndex = data.length;
    }

    return {
      loBarIndex: loBarIndex,
      hiBarIndex: hiBarIndex
    };
  },

  _updateStats: function () {
    if (!this._initStateApplied) return;
    var data;

    if (!this._isZoomed() || (this._isZoomed() && this._numberOfFilters === 2)) {
      data = this.histogramChartView.model.get('data');
    } else {
      data = this.miniHistogramChartView.model.get('data');
    }

    if (data == null) {
      return;
    }

    var nulls = this._dataviewModel.get('nulls');
    var bars = this._calculateBars(data);
    var loBarIndex = bars.loBarIndex;
    var hiBarIndex = bars.hiBarIndex;
    var sum, avg, min, max;
    var attrs;

    if (data && data.length) {
      sum = this._calcSum(data, loBarIndex, hiBarIndex);
      avg = this._calcAvg(data, loBarIndex, hiBarIndex);

      if (loBarIndex >= 0 && loBarIndex < data.length) {
        min = data[loBarIndex].start;
      }

      if (hiBarIndex >= 0 && hiBarIndex - 1 < data.length) {
        max = data[Math.max(0, hiBarIndex - 1)].end;
      }

      if (this._isZoomed() && this._numberOfFilters === 2) {
        attrs = {zmin: min, zmax: max, zlo_index: loBarIndex, zhi_index: hiBarIndex};
      } else if (!this._isZoomed() && this._numberOfFilters === 1) {
        attrs = {min: min, max: max, lo_index: loBarIndex, hi_index: hiBarIndex};
      }

      attrs = _.extend(
        { total: sum, nulls: nulls, avg: avg },
        attrs);

      this.model.set(attrs);
    }
  },

  _calcAvg: function (data, start, end) {
    var selectedData = data.slice(start, end);

    var total = this._calcSum(data, start, end);

    if (!total) {
      return 0;
    }

    var area = _.reduce(selectedData, function (memo, d) {
      return (d.avg && d.freq) ? (d.avg * d.freq) + memo : memo;
    }, 0);

    return area / total;
  },

  _calcSum: function (data, start, end) {
    return _.reduce(data.slice(start, end), function (memo, d) {
      return d.freq + memo;
    }, 0);
  },

  _onChangeZoomed: function () {
    if (this.model.get('zoomed')) {
      this._onZoomIn();
    } else {
      this._resetWidget();
      this._dataviewModel.fetch();
    }
  },

  _showMiniRange: function () {
    var loBarIndex = this.model.get('lo_index');
    var hiBarIndex = this.model.get('hi_index');

    this.miniHistogramChartView.selectRange(loBarIndex, hiBarIndex);
    this.miniHistogramChartView.show();
  },

  _zoom: function () {
    this.model.set({ zoomed: true, zoom_enabled: false });
    this.histogramChartView.removeSelection();
  },

  _onZoomIn: function () {
    this.lockedByUser = false;
    this._showMiniRange();
    this.histogramChartView.setBounds();
    this._dataviewModel.enableFilter();
    this._dataviewModel.fetch();
  },

  _resetWidget: function () {
    this._numberOfFilters = 0;
    this.model.set({
      zoomed: false,
      zoom_enabled: false,
      filter_enabled: false,
      lo_index: null,
      hi_index: null,
      min: null,
      max: null,
      zlo_index: null,
      zhi_index: null,
      zmin: null,
      zmax: null
    });
    this.filter.unsetRange();
    this._dataviewModel.disableFilter();
    this.histogramChartView.unsetBounds();
    this.miniHistogramChartView.hide();
    this._clearTooltip();
    this._updateStats();
  },

  clean: function () {
    window.removeEventListener('resize', this._onWindowResized.bind(this));
    CoreView.prototype.clean.apply(this);
  }
});