public/src/js/utils/sparklines.js (251 lines of code) (raw):
import ko from 'knockout';
import _ from 'underscore';
import $ from 'jquery';
import numeral from 'numeral';
import {request} from 'modules/authed-ajax';
import * as vars from 'modules/vars';
import Highcharts from 'utils/highcharts';
import mediator from 'utils/mediator';
import parseQueryParams from 'utils/parse-query-params';
import urlAbsPath from 'utils/url-abs-path';
var subscribedFronts = [],
pollingId;
function goodEnoughSeries (totalHits, series) {
return series && series.length && totalHits >= 10;
}
function createSparklikes (element, totalHits, series) {
var lineWidth = Math.min(Math.ceil(totalHits / 2000), 4);
return new Highcharts.Chart($.extend(true, Highcharts.CONFIG_DEFAULTS.sparklines, {
chart: {
renderTo: element
},
title: {
text: numeral(totalHits).format(',')
},
plotOptions: {
series: {
lineWidth: lineWidth
}
},
series: series
}));
}
function getWebUrl (article) {
var url = urlAbsPath(article.props.webUrl());
if (url) {
return '/' + url;
}
}
function showSparklinesInArticle (element, article) {
var front = article.front,
webUrl = getWebUrl(article),
$element = $(element),
data, series, chart = $element.data('sparklines');
if (!front || !front.sparklines || !webUrl) {
return;
}
data = front.sparklines.data()[webUrl] || {};
series = data.series;
if (chart) {
// dispose the chart even if there's no series because the new update means
// there's no data for it. Don't show stale data
chart.destroy();
$element.removeData('sparklines');
}
if (!goodEnoughSeries(data.totalHits, series)) {
return;
}
chart = createSparklikes(element, data.totalHits, _.map(series, function (value) {
return {
name: value.name,
data: _.map(value.data, function (point) {
return point.count;
})
};
}));
$element.data('sparklines', chart);
return chart;
}
ko.bindingHandlers.sparklines = {
init: function (element, valueAccessor, allBindings, viewModel, bindingContext) {
showSparklinesInArticle(element, bindingContext.$data);
ko.utils.domNodeDisposal.addDisposeCallback(element, function () {
var $element = $(element),
chart = $element.data('sparklines');
if (chart) {
chart.destroy();
$element.removeData('sparklines');
}
});
},
update: function (element, valueAccessor, allBindings, viewModel, bindingContext) {
showSparklinesInArticle(element, bindingContext.$data);
}
};
function isEnabled () {
if (vars.model) {
const disabledFromSwitch = vars.model.switches()['facia-tool-sparklines'] === false,
enabledFromParam = parseQueryParams().sparklines === 'please';
return !disabledFromSwitch || enabledFromParam;
}
return false;
}
function allWebUrls (front) {
var all = [];
_.each(front.collections(), function (collection) {
collection.eachArticle(function (article) {
var webUrl = getWebUrl(article);
if (webUrl) {
all.push(webUrl);
}
});
});
return all;
}
function serializeParams (front, articles, options) {
var params = [];
params.push('referring-path=/' + front);
_.map(articles, function (article) {
return params.push('path=' + article);
});
params.push('hours=' + (options.hours || '1'));
params.push('interval=' + (options.interval || '10'));
return params.join('&');
}
function reduceRequest (memo, front, articles, options) {
return request({
url: '/ophan/histogram?' + serializeParams(front, articles, options)
})
.then(function (data) {
_.each(data, function (content) {
memo[content.path] = content;
});
return memo;
})
.catch(function () {
// Ignore errors from Ophan
return {};
});
}
function getHistogram (front, articles, options) {
var chain = Promise.resolve({});
// Allow max articles in one request or the GET request is too big
var maxArticles = vars.CONST.sparksBatchQueue;
_.each(_.range(0, articles.length, maxArticles), function (limit) {
chain = chain.then(function (memo) {
return reduceRequest(
memo,
front,
articles.slice(limit, Math.min(limit + maxArticles, articles.length)),
options
);
});
});
return chain;
}
function differential (collection) {
var front = collection.front,
data, newArticles = [];
if (!front || !front.sparklines || !front.sparklines.resolved) {
return;
}
data = front.sparklines.data();
collection.eachArticle(function (article) {
var webUrl = getWebUrl(article);
if (webUrl && !data[webUrl]) {
newArticles.push(webUrl);
}
});
if (newArticles.length) {
var referrerFront = front.front();
front.sparklines.resolved = false;
front.sparklines.promise = getHistogram(
front.front(),
newArticles,
front.sparklinesOptions()
).then(function (newData) {
if (referrerFront !== front.front()) {
throw new Error('Front changed since last request.');
} else {
_.each(newArticles, function (webUrl) {
data[webUrl] = newData[webUrl];
});
front.sparklines.data(data);
front.sparklines.resolved = true;
return data;
}
});
return front.sparklines.promise;
}
}
function loadSparklinesForFront (front) {
if (!front.front() || !isEnabled()) {
return;
}
var referrerFront = front.front();
if (!front.sparklines) {
front.sparklines = {
data: ko.observable({}),
resolved: false
};
}
front.sparklines.resolved = false;
front.sparklines.promise = Promise.all(_.map(front.collections(), collection => collection.loaded))
.then(() => {
if (referrerFront !== front.front()) {
throw new Error('Front changed since last request.');
}
return getHistogram(
front.front(),
allWebUrls(front),
front.sparklinesOptions()
).then(function (data) {
if (referrerFront !== front.front()) {
throw new Error('Front changed since last request.');
} else {
front.sparklines.data(data);
front.sparklines.resolved = true;
return data;
}
});
});
}
function startPolling () {
if (!pollingId) {
var period = vars.CONST.sparksRefreshMs || 60000;
pollingId = setInterval(function () {
_.each(subscribedFronts, function (front) {
loadSparklinesForFront(front, true);
});
}, period);
}
}
function stopPolling () {
if (pollingId) {
clearInterval(pollingId);
pollingId = null;
}
}
function subscribe (widget) {
if (subscribedFronts.length === 0) {
startPolling();
mediator.on('collection:populate', differential);
}
subscribedFronts.push(widget);
loadSparklinesForFront(widget);
widget.collections.subscribe(function () {
loadSparklinesForFront(widget);
});
widget.sparklinesOptions.subscribe(function () {
loadSparklinesForFront(widget);
});
}
function unsubscribe (widget) {
subscribedFronts = _.without(subscribedFronts, widget);
if (subscribedFronts.length === 0) {
stopPolling();
mediator.off('collection:populate', differential);
}
}
export {
subscribe,
unsubscribe,
isEnabled
};