mirror of
https://github.com/LaCasemate/fab-manager.git
synced 2024-12-01 12:24:28 +01:00
751 lines
27 KiB
JavaScript
751 lines
27 KiB
JavaScript
/* eslint-disable
|
|
camelcase,
|
|
no-return-assign,
|
|
no-undef,
|
|
no-unreachable,
|
|
no-unused-vars,
|
|
standard/no-callback-literal,
|
|
*/
|
|
// TODO: This file was created by bulk-decaffeinate.
|
|
// Fix any style issues and re-enable lint.
|
|
/*
|
|
* decaffeinate suggestions:
|
|
* DS101: Remove unnecessary use of Array.from
|
|
* DS102: Remove unnecessary code created because of implicit returns
|
|
* DS205: Consider reworking code to avoid use of IIFEs
|
|
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
|
|
*/
|
|
'use strict';
|
|
|
|
Application.Controllers.controller('GraphsController', ['$scope', '$state', '$rootScope', 'es', 'Statistics', '_t',
|
|
function ($scope, $state, $rootScope, es, Statistics, _t) {
|
|
/* PRIVATE STATIC CONSTANTS */
|
|
|
|
// height of the HTML/SVG charts elements in pixels
|
|
const CHART_HEIGHT = 500;
|
|
|
|
// Label of the charts' horizontal axes
|
|
const X_AXIS_LABEL = _t('app.admin.stats_graphs.date');
|
|
|
|
// Label of the charts' vertical axes
|
|
const Y_AXIS_LABEL = _t('app.admin.stats_graphs.number');
|
|
|
|
// Colors for the line charts. Each new line uses the next color in this array
|
|
const CHART_COLORS = ['#b35a94', '#1c5794', '#00b49e', '#6fac48', '#ebcf4a', '#fd7e33', '#ca3436', '#a26e3a'];
|
|
|
|
/* PUBLIC SCOPE */
|
|
|
|
// ui-view transitions optimization: if true, the charts will never be refreshed
|
|
$scope.preventRefresh = false;
|
|
|
|
// statistics structure in elasticSearch
|
|
$scope.statistics = [];
|
|
|
|
// statistics data recovered from elasticSearch
|
|
$scope.data = null;
|
|
|
|
// default interval: one day
|
|
$scope.display =
|
|
{ interval: 'week' };
|
|
|
|
// active tab will be set here
|
|
$scope.selectedIndex = null;
|
|
|
|
// for palmares graphs, filters values are stored here
|
|
$scope.ranking = {
|
|
sortCriterion: 'ca',
|
|
groupCriterion: 'subType'
|
|
};
|
|
|
|
// default: we do not open the datepicker menu
|
|
$scope.datePicker =
|
|
{ show: false };
|
|
|
|
// datePicker parameters for interval beginning
|
|
$scope.datePickerStart = {
|
|
format: Fablab.uibDateFormat,
|
|
opened: false, // default: datePicker is not shown
|
|
minDate: null,
|
|
maxDate: moment().subtract(1, 'day').toDate(),
|
|
selected: moment().utc().subtract(1, 'months').subtract(1, 'day').startOf('day').toDate(),
|
|
options: {
|
|
startingDay: Fablab.weekStartingDay
|
|
}
|
|
};
|
|
|
|
// datePicker parameters for interval ending
|
|
$scope.datePickerEnd = {
|
|
format: Fablab.uibDateFormat,
|
|
opened: false, // default: datePicker is not shown
|
|
minDate: null,
|
|
maxDate: moment().subtract(1, 'day').toDate(),
|
|
selected: moment().subtract(1, 'day').endOf('day').toDate(),
|
|
options: {
|
|
startingDay: Fablab.weekStartingDay
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Callback to open the datepicker (interval start)
|
|
* @param {Object} jQuery event object
|
|
*/
|
|
$scope.toggleStartDatePicker = $event => toggleDatePicker($event, $scope.datePickerStart);
|
|
|
|
/**
|
|
* Callback to open the datepicker (interval end)
|
|
* @param {Object} jQuery event object
|
|
*/
|
|
$scope.toggleEndDatePicker = $event => toggleDatePicker($event, $scope.datePickerEnd);
|
|
|
|
/**
|
|
* Callback called when the active tab is changed.
|
|
* Recover the current tab and store its value in $scope.selectedIndex
|
|
* @param tab {Object} elasticsearch statistic structure
|
|
*/
|
|
$scope.setActiveTab = function (tab) {
|
|
$scope.selectedIndex = tab;
|
|
$scope.ranking.groupCriterion = 'subType';
|
|
if (tab.ca) {
|
|
$scope.ranking.sortCriterion = 'ca';
|
|
} else {
|
|
$scope.ranking.sortCriterion = tab.types[0].key;
|
|
}
|
|
return refreshChart();
|
|
};
|
|
|
|
/**
|
|
* Callback to close the date-picking popup and refresh the results
|
|
*/
|
|
$scope.validateDateChange = function () {
|
|
$scope.datePicker.show = false;
|
|
return refreshChart();
|
|
};
|
|
|
|
/* PRIVATE SCOPE */
|
|
|
|
/**
|
|
* Kind of constructor: these actions will be realized first when the controller is loaded
|
|
*/
|
|
const initialize = function () {
|
|
Statistics.query(function (stats) {
|
|
$scope.statistics = stats;
|
|
// watch the interval changes to refresh the graph
|
|
$scope.$watch(scope => scope.display.interval
|
|
, (newValue, oldValue) => refreshChart());
|
|
$scope.$watch(scope => scope.ranking.sortCriterion
|
|
, (newValue, oldValue) => refreshChart());
|
|
$scope.$watch(scope => scope.ranking.groupCriterion
|
|
, (newValue, oldValue) => refreshChart());
|
|
return refreshChart();
|
|
});
|
|
|
|
// workaround for angular-bootstrap::tabs behavior: on tab deletion, another tab will be selected
|
|
// which will cause every tabs to reload, one by one, when the view is closed
|
|
return $rootScope.$on('$stateChangeStart', function (event, toState, toParams, fromState, fromParams) {
|
|
if ((fromState.name === 'app.admin.stats_graphs') && (Object.keys(fromParams).length === 0)) {
|
|
return $scope.preventRefresh = true;
|
|
}
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Generic function to toggle a bootstrap datePicker
|
|
* @param $event {Object} jQuery event object
|
|
* @param datePicker {Object} settings object of the concerned datepicker. Must have an 'opened' property
|
|
*/
|
|
var toggleDatePicker = function ($event, datePicker) {
|
|
$event.preventDefault();
|
|
$event.stopPropagation();
|
|
return datePicker.opened = !datePicker.opened;
|
|
};
|
|
|
|
/**
|
|
* Query elasticSearch according to the current parameters and update the chart
|
|
*/
|
|
var refreshChart = function () {
|
|
if ($scope.selectedIndex && !$scope.preventRefresh) {
|
|
return query($scope.selectedIndex, function (aggregations, error) {
|
|
if (error) {
|
|
return console.error(error);
|
|
} else {
|
|
if ($scope.selectedIndex.graph.chart_type !== 'discreteBarChart') {
|
|
$scope.data = formatAggregations(aggregations);
|
|
return angular.forEach($scope.data, (datum, key) => updateChart($scope.selectedIndex.graph.chart_type, datum, key));
|
|
} else {
|
|
$scope.data = formatRankingAggregations(aggregations, $scope.selectedIndex.graph.limit, $scope.ranking.groupCriterion);
|
|
return updateChart($scope.selectedIndex.graph.chart_type, $scope.data.ranking, $scope.selectedIndex.es_type_key);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Callback used in NVD3 to print timestamps as literal dates on the X axis
|
|
*/
|
|
const xAxisTickFormatFunction = function (d, x, y) {
|
|
/* WARNING !! These tests (typeof/instanceof) may become broken on nvd3 update */
|
|
if ($scope.display.interval === 'day') {
|
|
if ((typeof d === 'number') || d instanceof Date) {
|
|
return d3.time.format(Fablab.d3DateFormat)(moment(d).toDate());
|
|
} else { // typeof d == 'string'
|
|
return d;
|
|
}
|
|
} else if ($scope.display.interval === 'week') {
|
|
if ((typeof x === 'number') || d instanceof Date) {
|
|
return d3.time.format(_t('app.admin.stats_graphs.week_short') + ' %U')(moment(d).toDate());
|
|
} else if (typeof d === 'number') {
|
|
return _t('app.admin.stats_graphs.week_of_START_to_END', { START: moment(d).format('L'), END: moment(d).add(6, 'days').format('L') });
|
|
} else { // typeof d == 'string'
|
|
return d;
|
|
}
|
|
} else if ($scope.display.interval === 'month') {
|
|
if (typeof d === 'number') {
|
|
const label = moment(d).format('MMMM YYYY');
|
|
return label.substr(0, 1).toUpperCase() + label.substr(1).toLowerCase();
|
|
} else { // typeof d == 'string'
|
|
return d;
|
|
}
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Format aggregations as retuned by elasticSearch to an understandable format for NVD3
|
|
* @param aggs {Object} as returned by elasticsearch
|
|
*/
|
|
var formatAggregations = function (aggs) {
|
|
const format = {};
|
|
|
|
angular.forEach(aggs, function (type, type_key) { // go through aggs[$TYPE] where $TYPE = month|year|hour|booking|...
|
|
format[type_key] = [];
|
|
if (type.subgroups) {
|
|
return angular.forEach(type.subgroups.buckets, subgroup => // go through aggs.$TYPE.subgroups.buckets where each bucket represent a $SUBTYPE
|
|
angular.forEach($scope.selectedIndex.types, function (cur_type) { // in the mean time, go through the types of the current index (active tab) ...
|
|
if (cur_type.key === type_key) { // ... looking for the type matching $TYPE
|
|
return (() => {
|
|
const result = [];
|
|
for (let it_st = 0, end = cur_type.subtypes.length - 1; it_st <= end; it_st++) { // when we've found it, iterate over its subtypes ...
|
|
const cur_subtype = cur_type.subtypes[it_st];
|
|
if (subgroup.key === cur_subtype.key) { // ... which match $SUBTYPE
|
|
// then we construct NVD3 dataSource according to these information
|
|
var dataSource = {
|
|
values: [],
|
|
key: cur_subtype.label,
|
|
total: 0,
|
|
color: CHART_COLORS[it_st],
|
|
area: true
|
|
};
|
|
// finally, we iterate over 'intervals' buckets witch contains
|
|
// per date aggregations for our current dataSource
|
|
angular.forEach(subgroup.intervals.buckets, function (interval) {
|
|
dataSource.values.push({
|
|
x: interval.key,
|
|
y: interval.total.value
|
|
});
|
|
return dataSource.total += parseInt(interval.total.value);
|
|
});
|
|
dataSource.key += ` (${dataSource.total})`;
|
|
result.push(format[type_key].push(dataSource));
|
|
} else {
|
|
result.push(undefined);
|
|
}
|
|
}
|
|
return result;
|
|
})();
|
|
}
|
|
})
|
|
);
|
|
}
|
|
});
|
|
return format;
|
|
};
|
|
|
|
/**
|
|
* Format aggregations for ranking charts to an understandable format for NVD3
|
|
* @param aggs {Object} as returned by elasticsearch
|
|
* @param limit {number} limit the number of stats in the bar chart
|
|
* @param typeKey {String} field name witch results are grouped by
|
|
*/
|
|
var formatRankingAggregations = function (aggs, limit, typeKey) {
|
|
const format =
|
|
{ ranking: [] };
|
|
|
|
let it = 0;
|
|
while (it < aggs.subgroups.buckets.length) {
|
|
const bucket = aggs.subgroups.buckets[it];
|
|
const dataSource = {
|
|
values: [],
|
|
key: getRankingLabel(bucket.key, typeKey),
|
|
color: CHART_COLORS[it],
|
|
area: true
|
|
};
|
|
dataSource.values.push({
|
|
x: getRankingLabel(bucket.key, typeKey),
|
|
y: bucket.total.value
|
|
});
|
|
format.ranking.push(dataSource);
|
|
it++;
|
|
}
|
|
const getY = object => object.values[0].y;
|
|
format.ranking = stableSort(format.ranking, 'DESC', getY).slice(0, limit);
|
|
for (let i = 0, end = format.ranking.length; i <= end; i++) {
|
|
if (typeof format.ranking[i] === 'undefined') { format.ranking.splice(i, 1); }
|
|
}
|
|
return format;
|
|
};
|
|
|
|
/**
|
|
* For BarCharts, return the label for a given bar
|
|
* @param key {string} raw value of the label
|
|
* @param typeKey {string} name of the field the results are grouped by
|
|
*/
|
|
var getRankingLabel = function (key, typeKey) {
|
|
if ($scope.selectedIndex) {
|
|
if (typeKey === 'subType') {
|
|
for (let type of Array.from($scope.selectedIndex.types)) {
|
|
for (let subtype of Array.from(type.subtypes)) {
|
|
if (subtype.key === key) {
|
|
return subtype.label;
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
for (let field of Array.from($scope.selectedIndex.additional_fields)) {
|
|
if (field.key === typeKey) {
|
|
switch (field.data_type) {
|
|
case 'date': return moment(key).format('LL'); break;
|
|
case 'list': return key.name; break;
|
|
default: return key;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Prepare the elasticSearch query for the stats matching the current controller's parameters
|
|
* @param index {{id:{number}, es_type_key:{string}, label:{string}, table:{boolean}, additional_fields:{Array},
|
|
* types:{Array}, graph:{Object}}} elasticSearch type in stats index to query
|
|
* @param callback {function} function be to run after results were retrieved,
|
|
* it will receive two parameters : results {Array}, error {String} (if any)
|
|
*/
|
|
var query = function (index, callback) {
|
|
// invalid callback handeling
|
|
if (typeof (callback) !== 'function') {
|
|
console.error('[graphsController::query] Error: invalid callback provided');
|
|
return;
|
|
}
|
|
if (!index) {
|
|
callback([], '[graphsController::query] Error: invalid index provided');
|
|
return;
|
|
}
|
|
|
|
if (index.graph.chart_type !== 'discreteBarChart') {
|
|
// list statistics types
|
|
const stat_types = [];
|
|
for (let t of Array.from(index.types)) {
|
|
if (t.graph) {
|
|
stat_types.push(t.key);
|
|
}
|
|
}
|
|
|
|
// exception handeling
|
|
if (stat_types.length === 0) {
|
|
callback([], 'Error: Unable to retrieve any graphical statistic types in the provided index');
|
|
}
|
|
|
|
let type_it = 0;
|
|
const results = {};
|
|
let error = '';
|
|
var recursiveCb = function () {
|
|
if (type_it < stat_types.length) {
|
|
return queryElasticStats(index.es_type_key, stat_types[type_it], function (prevResults, prevError) {
|
|
if (prevError) {
|
|
console.error(`[graphsController::query] ${prevError}`);
|
|
error += `\n${prevError}`;
|
|
}
|
|
results[stat_types[type_it]] = prevResults;
|
|
type_it++;
|
|
return recursiveCb();
|
|
});
|
|
} else {
|
|
return callback(results);
|
|
}
|
|
};
|
|
return recursiveCb();
|
|
} else { // palmares (ranking)
|
|
return queryElasticRanking(index.es_type_key, $scope.ranking.groupCriterion, $scope.ranking.sortCriterion, function (results, error) {
|
|
if (error) {
|
|
return callback([], error);
|
|
} else {
|
|
return callback(results);
|
|
}
|
|
});
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Run the elasticSearch query to retreive the /stats/type aggregations
|
|
* @param esType {String} elasticSearch document type (subscription|machine|training|...)
|
|
* @param statType {String} statistics type (year|month|hour|booking|...)
|
|
* @param callback {function} function be to run after results were retrieved,
|
|
* it will receive two parameters : results {Array}, error {String} (if any)
|
|
*/
|
|
var queryElasticStats = function (esType, statType, callback) {
|
|
// handle invalid callback
|
|
if (typeof (callback) !== 'function') {
|
|
console.error('[graphsController::queryElasticStats] Error: invalid callback provided');
|
|
return;
|
|
}
|
|
if (!esType || !statType) {
|
|
callback([], '[graphsController::queryElasticStats] Error: invalid parameters provided');
|
|
}
|
|
|
|
// run query
|
|
return es.search({
|
|
'index': 'stats',
|
|
'type': esType,
|
|
'searchType': 'query_then_fetch',
|
|
'size': 0,
|
|
'stat-type': statType,
|
|
'custom-query': '',
|
|
'start-date': moment($scope.datePickerStart.selected).format(),
|
|
'end-date': moment($scope.datePickerEnd.selected).format(),
|
|
'body': buildElasticAggregationsQuery(statType, $scope.display.interval, moment($scope.datePickerStart.selected), moment($scope.datePickerEnd.selected))
|
|
}
|
|
, function (error, response) {
|
|
if (error) {
|
|
return callback([], `Error: something unexpected occurred during elasticSearch query: ${error}`);
|
|
} else {
|
|
return callback(response.aggregations);
|
|
}
|
|
});
|
|
};
|
|
|
|
/**
|
|
* For ranking displays, run the elasticSearch query to retreive the /stats/type aggregations
|
|
* @param esType {string} elasticSearch document type (subscription|machine|training|...)
|
|
* @param groupKey {string} statistics subtype or custom field
|
|
* @param sortKey {string} statistics type or 'ca'
|
|
* @param callback {function} function be to run after results were retrieved,
|
|
* it will receive two parameters : results {Array}, error {String} (if any)
|
|
*/
|
|
var queryElasticRanking = function (esType, groupKey, sortKey, callback) {
|
|
// handle invalid callback
|
|
if (typeof (callback) !== 'function') {
|
|
return console.error('[graphsController::queryElasticRanking] Error: invalid callback provided');
|
|
}
|
|
if (!esType || !groupKey || !sortKey) {
|
|
return callback([], '[graphsController::queryElasticRanking] Error: invalid parameters provided');
|
|
}
|
|
|
|
// run query
|
|
return es.search({
|
|
'index': 'stats',
|
|
'type': esType,
|
|
'searchType': 'query_then_fetch',
|
|
'size': 0,
|
|
'body': buildElasticAggregationsRankingQuery(groupKey, sortKey, moment($scope.datePickerStart.selected), moment($scope.datePickerEnd.selected))
|
|
}
|
|
, function (error, response) {
|
|
if (error) {
|
|
return callback([], `Error: something unexpected occurred during elasticSearch query: ${error}`);
|
|
} else {
|
|
return callback(response.aggregations);
|
|
}
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Parse a final elastic results bucket and return a D3 compatible object
|
|
* @param bucket {{key_as_string:{String}, key:{Number}, doc_count:{Number}, total:{{value:{Number}}}}} interval bucket
|
|
*/
|
|
const parseElasticBucket = bucket => [ bucket.key, bucket.total.value ];
|
|
|
|
/**
|
|
* Build an object representing the content of the REST-JSON query to elasticSearch, based on the parameters
|
|
* currently defined for data aggegations.
|
|
* @param type {String} statistics type (visit|rdv|rating|ca|plan|account|search|...)
|
|
* @param interval {String} statistics interval (year|quarter|month|week|day|hour|minute|second)
|
|
* @param intervalBegin {moment} statitics interval beginning (moment.js type)
|
|
* @param intervalEnd {moment} statitics interval ending (moment.js type)
|
|
*/
|
|
var buildElasticAggregationsQuery = function (type, interval, intervalBegin, intervalEnd) {
|
|
const q = {
|
|
'query': {
|
|
'bool': {
|
|
'must': [
|
|
{
|
|
'match': {
|
|
'type': type
|
|
}
|
|
},
|
|
{
|
|
'range': {
|
|
'date': {
|
|
'gte': intervalBegin.format(),
|
|
'lte': intervalEnd.format()
|
|
}
|
|
}
|
|
}
|
|
]
|
|
}
|
|
},
|
|
'aggregations': {
|
|
'subgroups': {
|
|
'terms': {
|
|
'field': 'subType'
|
|
}, // TODO allow aggregate by custom field
|
|
'aggregations': {
|
|
'intervals': {
|
|
'date_histogram': {
|
|
'field': 'date',
|
|
'interval': interval,
|
|
'min_doc_count': 0,
|
|
'extended_bounds': {
|
|
'min': intervalBegin.valueOf(),
|
|
'max': intervalEnd.valueOf()
|
|
}
|
|
},
|
|
'aggregations': {
|
|
'total': {
|
|
'sum': {
|
|
'field': 'stat'
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
// scale weeks on sunday as nvd3 supports only these weeks
|
|
if (interval === 'week') {
|
|
q.aggregations.subgroups.aggregations.intervals.date_histogram['offset'] = '-1d';
|
|
// scale days to UTC time
|
|
} else if (interval === 'day') {
|
|
const offset = moment().utcOffset();
|
|
q.aggregations.subgroups.aggregations.intervals.date_histogram['offset'] = (-offset) + 'm';
|
|
}
|
|
return q;
|
|
};
|
|
|
|
/**
|
|
* Build an object representing the content of the REST-JSON query to elasticSearch, based on the parameters
|
|
* currently defined for data aggegations.
|
|
* @param groupKey {String} statistics subtype or custom field
|
|
* @param sortKey {String} statistics type or 'ca'
|
|
* @param intervalBegin {moment} statitics interval beginning (moment.js type)
|
|
* @param intervalEnd {moment} statitics interval ending (moment.js type)
|
|
*/
|
|
var buildElasticAggregationsRankingQuery = function (groupKey, sortKey, intervalBegin, intervalEnd) {
|
|
const q = {
|
|
'query': {
|
|
'bool': {
|
|
'must': [
|
|
{
|
|
'range': {
|
|
'date': {
|
|
'gte': intervalBegin.format(),
|
|
'lte': intervalEnd.format()
|
|
}
|
|
}
|
|
},
|
|
{
|
|
'term': {
|
|
'type': 'booking'
|
|
}
|
|
}
|
|
]
|
|
}
|
|
},
|
|
'aggregations': {
|
|
'subgroups': {
|
|
'terms': {
|
|
'field': groupKey,
|
|
'size': 10,
|
|
'order': {
|
|
'total': 'desc'
|
|
}
|
|
},
|
|
'aggregations': {
|
|
'top_events': {
|
|
'top_hits': {
|
|
'size': 1,
|
|
'sort': [
|
|
{ 'ca': 'desc' }
|
|
]
|
|
}
|
|
},
|
|
'total': {
|
|
'sum': {
|
|
'field': 'stat'
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
// results must be sorted and limited later by angular
|
|
if (sortKey !== 'ca') {
|
|
angular.forEach(q.query.bool.must, function (must) {
|
|
if (must.term) {
|
|
return must.term.type = sortKey;
|
|
}
|
|
});
|
|
} else {
|
|
q.aggregations.subgroups.aggregations.total.sum.field = sortKey;
|
|
}
|
|
|
|
return q;
|
|
};
|
|
|
|
/**
|
|
* Redraw the NDV3 chart using the provided data
|
|
* @param chart_type {String} stackedAreaChart|discreteBarChart|lineChart
|
|
* @param data {Array} array of NVD3 dataSources
|
|
* @param type {String} which chart to update (statistic type key)
|
|
*/
|
|
var updateChart = function (chart_type, data, type) {
|
|
const id = `#chart-${type} svg`;
|
|
|
|
// clean old charts
|
|
d3.selectAll(id + ' > *').remove();
|
|
|
|
return nv.addGraph(function () {
|
|
// no data or many dates, display line charts
|
|
let chart;
|
|
if ((data.length === 0) || ((data[0].values.length > 1) && (chart_type !== 'discreteBarChart'))) {
|
|
if (chart_type === 'stackedAreaChart') {
|
|
chart = nv.models.stackedAreaChart().useInteractiveGuideline(true);
|
|
} else {
|
|
chart = nv.models.lineChart().useInteractiveGuideline(true);
|
|
}
|
|
|
|
if (data.length > 0) {
|
|
if ($scope.display.interval === 'day') {
|
|
setTimeScale(chart.xAxis, chart.xScale, [d3.time.day, data[0].values.length]);
|
|
} else if ($scope.display.interval === 'week') {
|
|
setTimeScale(chart.xAxis, chart.xScale, [d3.time.week, data[0].values.length]);
|
|
} else if ($scope.display.interval === 'month') {
|
|
setTimeScale(chart.xAxis, chart.xScale, [d3.time.month, data[0].values.length]);
|
|
}
|
|
}
|
|
|
|
chart.xAxis.tickFormat(xAxisTickFormatFunction);
|
|
chart.yAxis.tickFormat(d3.format('d'));
|
|
|
|
chart.xAxis.axisLabel(X_AXIS_LABEL);
|
|
chart.yAxis.axisLabel(Y_AXIS_LABEL);
|
|
|
|
// only one date, display histograms
|
|
} else {
|
|
chart = nv.models.discreteBarChart();
|
|
chart.tooltip.enabled(false);
|
|
chart.showValues(true);
|
|
chart.x(d => d.label);
|
|
chart.y(d => d.value);
|
|
data = prepareDataForBarChart(data, type);
|
|
}
|
|
|
|
// common for each charts
|
|
chart.margin({ left: 100, right: 100 });
|
|
chart.noData(_t('app.admin.stats_graphs.no_data_for_this_period'));
|
|
chart.height(CHART_HEIGHT);
|
|
|
|
// add new chart to the page
|
|
d3.select(id).datum(data).transition().duration(350).call(chart);
|
|
|
|
// resize the graph when the page is resized
|
|
nv.utils.windowResize(chart.update);
|
|
// return the chart
|
|
return chart;
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Given an NVD3 line chart axis, scale it to display ordinated dates, according to the given arguments
|
|
*/
|
|
var setTimeScale = function (nvd3Axis, nvd3Scale, argsArray) {
|
|
const scale = d3.time.scale();
|
|
|
|
nvd3Axis.scale(scale);
|
|
nvd3Scale(scale);
|
|
|
|
if (!argsArray && !argsArray.length) {
|
|
const oldTicks = nvd3Axis.axis.ticks;
|
|
return nvd3Axis.axis.ticks = () => oldTicks.apply(nvd3Axis.axis, argsArray);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Translate line chart data in dates row to bar chart data, one bar per type.
|
|
*/
|
|
var prepareDataForBarChart = function (data, type) {
|
|
const newData = [{
|
|
key: type,
|
|
values: []
|
|
}
|
|
];
|
|
for (let info of Array.from(data)) {
|
|
if (info) {
|
|
newData[0].values.push({
|
|
'label': info.key,
|
|
'value': info.values[0].y,
|
|
'color': info.color
|
|
});
|
|
}
|
|
}
|
|
|
|
return newData;
|
|
};
|
|
|
|
/**
|
|
* Sort the provided array, in the specified order, on the value returned by the callback.
|
|
* This is a stable-sorting algorithm implementation, ie. two call with the same array will return the same results
|
|
* orders, especially with equal values.
|
|
* @param array {Array} the array to sort
|
|
* @param order {string} 'ASC' or 'DESC'
|
|
* @param getValue {function} the callback which will return the value on which the sort will occurs
|
|
* @returns {Array}
|
|
*/
|
|
var stableSort = function (array, order, getValue) {
|
|
// prepare sorting
|
|
const keys_order = [];
|
|
const result = [];
|
|
for (let i = 0, end = array.length; i <= end; i++) {
|
|
keys_order[array[i]] = i;
|
|
result.push(array[i]);
|
|
}
|
|
|
|
// callback for javascript native Array.sort()
|
|
const sort_fc = function (a, b) {
|
|
const val_a = getValue(a);
|
|
const val_b = getValue(b);
|
|
if (val_a === val_b) {
|
|
return keys_order[a] - keys_order[b];
|
|
}
|
|
if (val_a < val_b) {
|
|
if (order === 'ASC') {
|
|
return -1;
|
|
} else { return 1; }
|
|
} else {
|
|
if (order === 'ASC') {
|
|
return 1;
|
|
} else { return -1; }
|
|
}
|
|
};
|
|
|
|
// finish the sort
|
|
result.sort(sort_fc);
|
|
return result;
|
|
};
|
|
|
|
// !!! MUST BE CALLED AT THE END of the controller
|
|
return initialize();
|
|
}
|
|
]);
|