diff --git a/app/controllers/main.controller.js b/app/controllers/main.controller.js index 3388baa..510e9de 100644 --- a/app/controllers/main.controller.js +++ b/app/controllers/main.controller.js @@ -1,170 +1,174 @@ var pageviewChartModel = require('../models/pageviewchart.model'); module.exports = function($scope, pageViews, searchService, chartService) { chart = chartService.createChart('myChart', new pageviewChartModel()); $scope.dateFrom = new Date(Date.parse("2016-01-01")); $scope.dateTo = new Date(Date.parse("2016-04-01")); - chart.setDateRange($scope.dateFrom, $scope.dateTo); - + chart.getModel().setDateRange($scope.dateFrom, $scope.dateTo); $scope.groups = []; $scope.search = { str: "", list: [] }; $scope.chart = { - selected: "Line" + selected: 'line' }; - + + $scope.$watch('chart.selected', function() + { + chart.setType($scope.chart.selected); + }); + $scope.dateToStr = function(date) { var day = date.getDate() + ''; var month = date.getMonth() + 1 + ''; var year = date.getFullYear() + ''; var month = month < 10 ? '0' + month: month; var day = day < 10 ? '0' + day: day; return year+month+day; }; $scope.addNewArticle = function (name) { pageViews.query({ project: "sv.wikipedia", article: name, from: $scope.dateToStr($scope.dateFrom), to: $scope.dateToStr($scope.dateTo), }).$promise.then(function(result) { result.article.name = name; $scope.groups.push({ articles:[result.article], name: name }); - chart.addDataset(name, result.article.views); + chart.getModel().addDataset(name, result.article.views); }); $scope.search = { str: "", list: [] }; }; function reloadAll() { var g = angular.copy($scope.groups, g); $scope.groups = []; - chart.clearDataset(); + chart.getModel().clearDatasets(); angular.forEach(g, function (val, key) { $scope.addNewArticle(val.name); }); - chart.setDateRange($scope.dateFrom, $scope.dateTo); + chart.getModel().setDateRange($scope.dateFrom, $scope.dateTo); } $scope.projects = [ // TODO: Proper externalization and language checking {name: "Wikipedia", url: "$lang$.wikipedia", multilang: true}, {name: "Wikiversity", url: "$lang$.wikiversity", multilang: true}, {name: "Wikisource", url: "$lang$.wikisource", multilang: true}, {name: "Wikinews", url: "$lang$.wikinews", multilang: true}, {name: "Wikibooks", url: "$lang$.wikibooks", multilang: true}, {name: "Wikiquote", url: "$lang$.wikiquote", multilang: true}, {name: "Wikispecies", url: "species.wikimedia", multilang: false}, {name: "Wikivoyage", url: "$lang$.wikivoyage", multilang: true}, {name: "Wikidata", url: "www.wikidata", multilang: false}, {name: "Wikicommons", url: "commons.wikimedia", multilang: false}, {name: "Metawiki", url: "meta.wikimedia", multilang: false} ]; $scope.chosen = { proj: $scope.projects[0].name, lang: "Svenska" }; $scope.changeChosen = function(name, dropdown){ $scope.chosen[dropdown] = name; }; $scope.searchArticle = function (str) { var searchstr = str; return searchService.query({ namespace: 'sv.wikipedia', //TODO str: searchstr }).then(function(response) { return response.data.url; }); }; // DATEPICKER // TODO: Move everything related to bottom bar date pickers // to separate controller $scope.today = function() { $scope.dt = new Date(); }; $scope.today(); $scope.clear = function() { $scope.dt = null; }; $scope.inlineOptions = { customClass: getDayClass, minDate: new Date(), showWeeks: true }; $scope.dateOptions = { formatYear: 'yy', maxDate: new Date(), minDate: new Date(), startingDay: 1 }; $scope.toggleMin = function() { $scope.inlineOptions.minDate = $scope.inlineOptions.minDate ? null : new Date(); $scope.dateOptions.minDate = $scope.inlineOptions.minDate; }; $scope.toggleMin(); $scope.openFrom = function() { $scope.popupFrom.opened = true; }; $scope.openTo = function() { $scope.popupTo.opened = true; }; $scope.setDate = function (year, month, day) { $scope.dt = new Date(year, month, day); }; $scope.popupFrom = { opened: false }; $scope.popupTo = { opened: false }; $scope.$watch('dateFrom', reloadAll); $scope.$watch('dateTo', reloadAll); function getDayClass(data) { var date = data.date, mode = data.mode; if (mode === 'day') { var dayToCheck = new Date(date).setHours(0, 0, 0, 0); for (var i = 0; i < $scope.events.length; i++) { var currentDay = new Date($scope.events[i].date).setHours(0, 0, 0, 0); if (dayToCheck === currentDay) { return $scope.events[i].status; } } } return ''; } }; diff --git a/app/directives/highchart.directive.js b/app/directives/highchart.directive.js index d15eeab..7ba7d66 100644 --- a/app/directives/highchart.directive.js +++ b/app/directives/highchart.directive.js @@ -1,56 +1,181 @@ module.exports = function(chartService) { return { restrict: 'E', - + scope: {}, // isolate scope link: function(scope, element, attrs) { - scope.chartModel = chartService.getChart(attrs.chartName); - // will running this again and again properly (no leaks) replace the stuff - // that existed previously? are there more effecient ways to control - // highcharts? ("restarting" is probably the least efficient way!) - $(function () { + // var seriesAdapterInterface = { + // type: 'string' // passed to highcharts as chart type, + // init: function(chart); // initialize the chart + // add: function(chart, datasets, name); // add a data series to the chart + // setXAxis: function(chart); // configure the x-axis + // }; + + var lineSeriesAdapter = { + type: 'line', + + init: function(chart) + { + chart.xAxis[0].setCategories(scope.chartModel.getXAxisValues()); + }, + + add: function(chart, datasets, name) + { + chart.addSeries({ + name: name, + data: datasets[name] + }); + }, + + setXAxis: function(chart) + { + chart.xAxis[0].setCategories(scope.chartModel.getXAxisValues()); + } + }; + + var pieSeriesAdapter = { + type: 'pie', + + init: function(chart) + { + chart.xAxis[0].setCategories([]); + chart.addSeries({name: 'Total Views', data: []}); + }, + + add: function(chart, datasets, name) + { + chart.series[0].addPoint({ + name: name, + y: datasets[name].reduce(function(a, b) { return a + b; }, 0) + }); + }, + + setXAxis: function(chart) { } + }; + + var columnSeriesAdapter = { + type: 'column', + + init: function(chart) + { + chart.xAxis[0].setCategories([]); + chart.addSeries({name: 'Total Views'}); + }, + + add: function(chart, datasets, name) + { + console.log(name); + chart.series[0].addPoint({ + name: name, + y: datasets[name].reduce(function(a, b) { return a + b; }, 0) + }); + }, + + setXAxis: function(chart) { } + }; + + scope.chart = chartService.getChart(attrs.chartName); + scope.chartModel = chart.getModel(); + + if (attrs.chartType == 'pie') + { + scope.chart.setType('pie'); + scope.chartSeriesAdapter = pieSeriesAdapter; + } + else if (attrs.chartType == 'column') + { + scope.chart.setType('column'); + scope.chartSeriesAdapter = columnSeriesAdapter; + } + else + { + scope.chart.setType('line'); + scope.chartSeriesAdapter = lineSeriesAdapter; + } + + $(function () + { + var initchart = function() + { $(element).highcharts({ chart: { - type: 'line', - animation: true, + type: scope.chartSeriesAdapter.type, + animation: true }, credits: { enabled: false }, + plotOptions: { + pie: { + dataLabels: { + format: '{point.name}: {point.percentage:.1f} %' + } + } + }, + tooltip: { + shared: true, + crosshairs: { + width: 1, + color: 'rgba(0,0,0,0.2)' + } + }, title: { text: 'Page Views' }, - xAxis: { - categories: scope.chartModel.getXAxisValues() - }, + xAxis: {}, // configured a bit later by chartSeriesAdapter.init yAxis: { title: { text: 'Views' - }, - }, - series: [] + } + } + // series: configured a bit later by chartSeriesAdapter.init/add }); - }); - - function addSeries() { - var set = scope.chartModel.getLatest(); - var chart = $(element).highcharts(); - if(set == null) { - while(chart.series.length > 0) - chart.series[0].remove(true); - return; - } - chart.addSeries({ - data: set.values, - name: set.name + + var chart = $(element).highcharts(); + var datasets = scope.chartModel.getDatasets(); + var setnames = Object.keys(datasets); + + scope.chartSeriesAdapter.init(chart); + + for (var i in setnames) + { + scope.chartSeriesAdapter.add(chart, datasets, setnames[i]); + } + }; + + initchart(); + + scope.chartModel.addEventListener('datasetadded', function(name) + { + scope.chartSeriesAdapter.add($(element).highcharts(), scope.chartModel.getDatasets(), name); }); - chart.xAxis[0].setCategories( scope.chartModel.getXAxisValues()); - - } - - scope.chartModel = chartService.getChart(attrs.chartName); - scope.$watch('chartModel', addSeries, true); + + scope.chartModel.addEventListener('daterangechanged', function() + { + console.log('date change'); + scope.chartSeriesAdapter.setXAxis($(element).highcharts()); + initchart(); + }); + + scope.$watch('chart.getType()', function(type) + { + if (type == 'pie') + { + scope.chartSeriesAdapter = pieSeriesAdapter; + } + else if (type == 'column') + { + scope.chartSeriesAdapter = columnSeriesAdapter; + } + else + { + scope.chartSeriesAdapter = lineSeriesAdapter; + } + + initchart(); + }); + }); } }; }; diff --git a/app/index.html b/app/index.html index 68b0f4f..676ab10 100644 --- a/app/index.html +++ b/app/index.html @@ -1,154 +1,154 @@ Wikistats

No Results Found

{{group.name}}
  • {{article.name}}
  • diff --git a/app/models/pageviewchart.model.js b/app/models/pageviewchart.model.js index c0070fe..ebe1a33 100644 --- a/app/models/pageviewchart.model.js +++ b/app/models/pageviewchart.model.js @@ -1,131 +1,217 @@ /** * Pageviews chart data model. * * @author Mikael Forsberg - * @version 20160421T0822 + * @version 20160502T2001 */ module.exports = function() { /** * Date range starting date. */ this.dateRangeFrom = new Date(); /** * Date range ending date. */ this.dateRangeTo = new Date(); /** * Y-value data sets. */ this.datasets = {}; - this.latest = null; // "constructor" begins this.dateRangeFrom.setUTCHours(0); this.dateRangeFrom.setUTCMinutes(0); this.dateRangeFrom.setUTCSeconds(0); this.dateRangeFrom.setUTCMilliseconds(0); this.dateRangeTo.setUTCHours(0); this.dateRangeTo.setUTCMinutes(0); this.dateRangeTo.setUTCSeconds(0); this.dateRangeTo.setUTCMilliseconds(0); // default date range is one week ending at today's date this.dateRangeFrom.setDate(this.dateRangeFrom.getDate() - 7); + // create event slots + this.events = [ + ['daterangechanged'], + ['datasetadded'], + ['datasetremoved'], + ['datasetscleared'] + ]; + // "constructor" ends + /** + * Search for and potentially return a named event. + * + * @param String which Name of event + * @access private + * @return Array if event exists, null otherwise + */ + this.findEvent = function(which) + { + var event = null; + + for (var i in this.events) + { + if (this.events[i][0] == which) + { + event = this.events[i]; + break; + } + } + + return event; + }; + + /** + * Fire a named event, calling all registered handlers. + * + * @param String which Name of event + * @param Object data Data to pass to handlers + * @access private + * @return void + */ + this.dispatchEvent = function(which, data) + { + var event = this.findEvent(which); + + if (event) + { + for (k = 1; k < event.length; ++k) + { + event[k](data); + } + } + }; + + /** + * Register an event handler to a named event. + * + * @param String which Name of event + * @param Function fn Event handler callback function + * @return void + */ + this.addEventListener = function(which, fn) + { + var event = this.findEvent(which); + + if (event) + { + event.push(fn); + } + }; + /** * Set the date range. The starting date must be an earlier date * than the ending date for the range to be considered valid. * * @param Date from Start of date range * @param Date to End of date range * @return Boolean True if given a valid range, False otherwise. */ this.setDateRange = function(from, to) { if (from.getTime() >= to.getTime()) { return false; } + var signalEvent = (from.getTime() != this.dateRangeFrom.getTime() || to.getTime() != this.dateRangeTo.getTime()); + this.dateRangeFrom = from; this.dateRangeTo = to; + if (signalEvent) + { + this.dispatchEvent('daterangechanged', null); + } + return true; }; /** * Add a Y-value dataset. There must be one numeric value in yValues * for each distinct date contained in the current date range, and the * values must be in chronological order. * * @param String name Name of new dataset * @param Array yValues Array of numeric Y-values * @return void */ this.addDataset = function(name, yValues) { - this.latest = {name: name, values: yValues}; this.datasets[name] = yValues; - }; - - this.getLatest = function() - { - return this.latest; + this.dispatchEvent('datasetadded', name); }; /** * Remove a dataset. * * @param String name Name of dataset to remove * @return void */ this.removeDataset = function(name) { delete this.datasets[name]; + this.dispatchEvent('datasetremoved', name); }; - - this.clearDataset = function() + + /** + * Remove all datasets. + * + * @return void + */ + this.clearDatasets = function() { this.datasets = {}; - this.latest = null; + this.dispatchEvent('datasetscleared', null); + }; + + /** + * Retrieve a datasets. + * + * @return Object + */ + this.getDataset = function(name) + { + return this.datasets[name]; }; /** * Retrieve all datasets. * * @return Object */ this.getDatasets = function() { return this.datasets; }; /** * Retrieve the X-axis "values" or "tick labels", i.e. the * distinct dates contained in the current date range. * * @return Date[] */ this.getXAxisValues = function() { var values = []; var at = new Date(this.dateRangeFrom); var stop = this.dateRangeTo.getTime(); while (at.getTime() < stop) { values.push(new Date(at).toISOString().slice(0, 10)); at.setDate(at.getDate() + 1); } return values; }; }; diff --git a/app/services/chart.service.js b/app/services/chart.service.js index b64f0b3..0448a48 100644 --- a/app/services/chart.service.js +++ b/app/services/chart.service.js @@ -1,15 +1,52 @@ module.exports = function() { this.charts = {}; this.createChart = function(name, model) { - this.charts[name] = model; - return model; + this.charts[name] = { + model: model, + type: 'default' + }; + + return this.getChart(name); }; + // this is either a bit clever or really stupid this.getChart = function(name) { - return this.charts[name]; + var self = this; + + return { + getModel: function() + { + return self.getModel(name); + }, + + getType: function() + { + return self.getType(name); + }, + + setType: function(type) + { + return self.setType(name, type); + } + }; + }; + + this.setType = function(name, type) + { + this.charts[name].type = type; + }; + + this.getType = function(name) + { + return this.charts[name].type; + }; + + this.getModel = function(name) + { + return this.charts[name].model; }; }; diff --git a/gulpfile.js b/gulpfile.js index 74f4399..9ffd62c 100755 --- a/gulpfile.js +++ b/gulpfile.js @@ -1,70 +1,71 @@ var gulp = require('gulp'), sass = require('gulp-sass'), browserSync = require('browser-sync').create(), uglify = require('gulp-uglify'), sourcemaps = require('gulp-sourcemaps'), minifyCSS = require('gulp-clean-css'), jshint = require('gulp-jshint'), source = require('vinyl-source-stream'), streamify = require('gulp-streamify'), browserify = require('browserify'), concat = require('gulp-concat'), buffer = require('vinyl-buffer'); gulp.task('default', ['copy','copyvendor', 'css', 'js', 'lint']); gulp.task('browserSync', function() { browserSync.init({ server: { baseDir: 'dist' }, }) }) gulp.task('copy', function() { return gulp.src('app/*.html') .pipe(gulp.dest('dist')) .pipe(browserSync.stream()); }) gulp.task('copyvendor', function() { return gulp.src(['node_modules/angular/**/*', 'node_modules/angular-resource/**/*', + 'node_modules/angular-animate/**/*', 'node_modules/bootstrap-css-only/css/**/*', 'node_modules/angular-ui-bootstrap/dist/**/*', 'node_modules/highcharts/highcharts.js', 'node_modules/jquery/dist/*']) .pipe(gulp.dest('dist/vendor')) }) gulp.task('lint', function() { return gulp.src('app/**/*.js') .pipe(jshint()) .pipe(jshint.reporter('default')) //.pipe(jshint.reporter('fail')) }); gulp.task('watch', ['browserSync', 'default'], function(){ gulp.watch('assets/scss/**/*.s*ss', ['css']); gulp.watch('app/**/*.js', ['lint', 'js']); gulp.watch('app/*.html', ['copy']); }) gulp.task('css', function() { return gulp.src('assets/scss/**/*.s*ss') .pipe(sass()) .pipe(concat('styles.min.css')) .pipe(minifyCSS()) .pipe(gulp.dest('dist/assets/css')) .pipe(browserSync.stream()); }) gulp.task('js', function () { return browserify('./app/index.js').ignore('angular').bundle() .pipe(source('app.min.js')) .pipe(buffer()) .pipe(sourcemaps.init({loadMaps: true})) .pipe(streamify(uglify())) .pipe(sourcemaps.write('.')) .pipe(gulp.dest('dist/assets/js')) })