cartodb/lib/assets/javascripts/deep-insights/widgets/histogram/content-view.js

700 lines
20 KiB
JavaScript
Raw Normal View History

2020-06-15 10:58:47 +08:00
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);
}
});