Mforns has submitted this change and it was merged. Change subject: Use Dygraphs in Vital Signs ......................................................................
Use Dygraphs in Vital Signs Bug: T96339 Change-Id: If20a496a7dbee13b73a013cd36213f4e55cded0c --- M .jshintrc M src/app/apis/annotations-api.js A src/app/data-converters/annotations-data.js M src/app/require.config.js M src/components/a-b-compare/compare-sunburst.html M src/components/breakdown-toggle/breakdown-toggle.js M src/components/compare-layout/compare-layout.js M src/components/visualizers/dygraphs-timeseries/bindings.js M src/components/visualizers/dygraphs-timeseries/dygraphs-timeseries.html M src/components/wikimetrics-layout/wikimetrics-layout.html M src/components/wikimetrics-visualizer/wikimetrics-visualizer.html M src/components/wikimetrics-visualizer/wikimetrics-visualizer.js M src/layouts/metrics-by-project/01_styles.css M test/components/wikimetrics-visualizer.js 14 files changed, 242 insertions(+), 63 deletions(-) Approvals: Mforns: Verified; Looks good to me, approved diff --git a/.jshintrc b/.jshintrc index adb4994..8c65e7a 100644 --- a/.jshintrc +++ b/.jshintrc @@ -9,7 +9,6 @@ "undef": true, "unused": true, "esnext": false, - "moz": true, "boss": true, "node": true, "validthis": true, diff --git a/src/app/apis/annotations-api.js b/src/app/apis/annotations-api.js index d853d1e..481869b 100644 --- a/src/app/apis/annotations-api.js +++ b/src/app/apis/annotations-api.js @@ -2,13 +2,65 @@ * This module gets metric annotations that reside in Mediawiki. * To get them, it uses mediawiki-storage library. */ -define(['mediawiki-storage', 'moment', 'logger'], function (mediawikiStorage, moment) { +define(function (require) { 'use strict'; + + var mediawikiStorage = require('mediawiki-storage'), + moment = require('moment'), + logger = require('logger'), + converter = require('converters.annotations'), + TimeseriesData = require('converters.timeseries'); function AnnotationsApi () {} /** - * Retrieves the annotations for the given metric. + * Gets the annotations in a TimeseriesData format. Important notes: + * 1. range annotations (from date A to date B) come in as separate rows, prefixed + * with Start: [the note] and End: [the note] + * 2. if duplicate dates are present, duplicateDates is set to true on the returned + * object. Callers should keep this in mind since it disallows merges + * + * Parameters + * metric : Metric object that has an annotations object: + * { + * ... + * annotations: { + * host: 'mediawiki.host', + * pageName: 'PageName' + * }, + * ... + * } + * + * Returns + * A promise to a TimeseriesData instance with the annotations + */ + AnnotationsApi.prototype.getTimeseriesData = function (metric) { + + var params = metric.annotations, + deferred = new $.Deferred(); + + if (!this.checkParams(params)) { + deferred.resolve(new TimeseriesData()); + + } else { + mediawikiStorage.get({ + host: params.host, + pageName: params.pageName + }).done(function (data) { + deferred.resolve(converter()({}, data)); + + }).fail(function (error) { + // resolve as done with empty results and log the error + deferred.resolve(new TimeseriesData()); + logger.error(error); + }); + } + return deferred; + }; + + /** + * Retrieves the annotations for the given metric, as written on the wiki + * but verified to be in the correct format * * Parameters * @@ -51,10 +103,10 @@ } var params = metric.annotations, - deferred = $.Deferred(), - that = this; + that = this, + deferred = new $.Deferred(); - if (!this._checkParams(params)) { + if (!this.checkParams(params)) { // accept metrics without annotation params // and just return an empty array deferred.resolve([]); @@ -66,7 +118,7 @@ }) .fail(deferred.reject) .done(function (data) { - that._checkAnnotations(data, deferred, params); + that.checkAnnotations(data, deferred, params); }); } @@ -75,7 +127,7 @@ return deferred.promise(); }; - AnnotationsApi.prototype._checkParams = function (params) { + AnnotationsApi.prototype.checkParams = function (params) { return ( typeof params === 'object' && typeof params.host === 'string' && @@ -83,7 +135,7 @@ ); }; - AnnotationsApi.prototype._checkDate = function (date) { + AnnotationsApi.prototype.checkDate = function (date) { return ( date === void 0 || typeof date === 'string' && @@ -91,8 +143,8 @@ ); }; - AnnotationsApi.prototype._checkInterval = function (start, end) { - // assumes both dates have passed _checkDate + AnnotationsApi.prototype.checkInterval = function (start, end) { + // assumes both dates have passed checkDate return ( start === void 0 || end === void 0 || @@ -100,7 +152,7 @@ ); }; - AnnotationsApi.prototype._checkAnnotations = function (data, deferred, params) { + AnnotationsApi.prototype.checkAnnotations = function (data, deferred, params) { if (!(data instanceof Array)) { var message = ( 'Mediawiki page with host "' + params.host + @@ -117,9 +169,9 @@ var annotations = data.filter(function (annotation) { return ( typeof annotation === 'object' && - that._checkDate(annotation.start) && - that._checkDate(annotation.end) && - that._checkInterval(annotation.start, annotation.end) && + that.checkDate(annotation.start) && + that.checkDate(annotation.end) && + that.checkInterval(annotation.start, annotation.end) && typeof annotation.note === 'string' ); }); diff --git a/src/app/data-converters/annotations-data.js b/src/app/data-converters/annotations-data.js new file mode 100644 index 0000000..09a089f --- /dev/null +++ b/src/app/data-converters/annotations-data.js @@ -0,0 +1,72 @@ +/** + * This module returns a function that parses a Wiki article which + * holds annotations in a pre-defined format: + * + * [ + * { + * "start": "2014-12-25", "end": "2015-01-01", + * "note": "information about this range of dates" + * } + * ] + * + * Parameters for the generated function: + * label : Used for header, colors, etc. default '(annotations)' + * startRangePrefix : Prefixed to notes that have a range. default 'Start: ' + * endRangePrefix : Prefixed to notes that have a range. default 'End: ' + */ +define(function (require) { + 'use strict'; + + var _ = require('lodash'), + TimeseriesData = require('converters.timeseries'); + + + return function () { + + return function (options, rawData) { + + var opt = $.extend({ + label: '(annotations)', + startRangePrefix: 'Begin: ', + endRangePrefix: 'End: ', + }, options); + + var ret = _.attempt(function () { + var duplicateDates = false; + + var notesByDate = _(rawData).transform(function (result, annotation) { + if (!annotation || !annotation.start) { + return true; + } + + if (annotation.end && (annotation.start !== annotation.end)) { + result.push([annotation.start, opt.startRangePrefix + annotation.note]); + result.push([annotation.end, opt.endRangePrefix + annotation.note]); + } else { + result.push([annotation.start, annotation.note]); + } + + }).transform(function (result, annotation) { + + if (_.has(result, annotation[0])) { + duplicateDates = true; + } else { + result[annotation[0]] = []; + } + result[annotation[0]].push(annotation.splice(1)); + + }, {}).value(); + + return new TimeseriesData( + [opt.label], + notesByDate, + [opt.label], + [opt.label], + duplicateDates + ); + }); + + return _.isError(ret) ? new TimeseriesData() : ret; + }; + }; +}); diff --git a/src/app/require.config.js b/src/app/require.config.js index 36d6a0e..bf76e7b 100644 --- a/src/app/require.config.js +++ b/src/app/require.config.js @@ -58,6 +58,7 @@ 'converters.wikimetrics-timeseries' : 'app/data-converters/wikimetrics-timeseries', 'converters.funnel-data' : 'app/data-converters/funnel-data', 'converters.timeseries' : 'app/data-converters/timeseries-data', + 'converters.annotations' : 'app/data-converters/annotations-data', // *** lib 'lib.polyfills' : 'lib/polyfills', diff --git a/src/components/a-b-compare/compare-sunburst.html b/src/components/a-b-compare/compare-sunburst.html index 9d88cb4..80191a1 100644 --- a/src/components/a-b-compare/compare-sunburst.html +++ b/src/components/a-b-compare/compare-sunburst.html @@ -1,4 +1,4 @@ -<div class="ui grid height540"> +<div class="ui grid height540" data-bind="if: data"> <div data-bind="if: data().showAB.a" class="eight wide full height column"> <sunburst params="height: 450, data: data().a.data, colors: colors"/> </div> diff --git a/src/components/breakdown-toggle/breakdown-toggle.js b/src/components/breakdown-toggle/breakdown-toggle.js index 9b1422a..933336f 100644 --- a/src/components/breakdown-toggle/breakdown-toggle.js +++ b/src/components/breakdown-toggle/breakdown-toggle.js @@ -18,7 +18,7 @@ // think carefully about the interface to that component. return [ '', - '2, 5', + '5, 5', '15, 15', '30, 5', ][index() + 1]; diff --git a/src/components/compare-layout/compare-layout.js b/src/components/compare-layout/compare-layout.js index e400e3c..e925c4e 100644 --- a/src/components/compare-layout/compare-layout.js +++ b/src/components/compare-layout/compare-layout.js @@ -116,7 +116,7 @@ this.comparisons(config.comparisons.map(function (c) { // a container for the async output of the following logic - c.data = ko.observable({showAB: {}}); + c.data = ko.observable(); // promise what is being selected from the api var apiPromises = ko.computed(function () { @@ -125,8 +125,8 @@ return ['a', 'b'].map(function (side) { return { - promise: showAB[side] - ? api.getData(c.metric, config[side], wiki) + promise: showAB[side] ? + api.getData(c.metric, config[side], wiki) : emptyPromise, label: config[side] }; diff --git a/src/components/visualizers/dygraphs-timeseries/bindings.js b/src/components/visualizers/dygraphs-timeseries/bindings.js index 510b743..a2d1602 100644 --- a/src/components/visualizers/dygraphs-timeseries/bindings.js +++ b/src/components/visualizers/dygraphs-timeseries/bindings.js @@ -8,15 +8,21 @@ require('dygraphs'); ko.bindingHandlers.dygraphs = { + init: function (element, valueAccessor) { + var val = ko.unwrap(valueAccessor()); + + if (val.height) { + $(element).height(val.height); + } + + $(element).append('<div class="resizable container graph"></div>'); + }, update: function (element, valueAccessor) { var val = ko.unwrap(valueAccessor()), data = ko.unwrap(val.data), annotations = ko.unwrap(val.annotations), - colors = val.colors; - - var graph = $(element).append( - $('<div/>').addClass('resizable').addClass('container') - ).find('div.resizable')[0]; + colors = val.colors, + graph = $(element).find('div.graph')[0]; if (data) { var rows = data.rowData({convertToDate: true}); @@ -25,7 +31,7 @@ if (type === 'Date') { return moment(d).format('YYYY-MM-DD'); } - return d.toFixed(3); + return d.toFixed ? d.toFixed(3) : d; }, labels: ['Date'], labelsDivWidth: 350, @@ -45,15 +51,20 @@ gridLinePattern: [10, 5], series: {}, showRoller: true, + animatedZooms: true, }; var patterns = _(data.patternLabels) .uniq() - .zipObject([[], [5, 5], [15, 15], [30, 5]]) + .zipObject([null, [5, 5], [15, 15], [30, 5]]) .value(); data.header.forEach(function (column, i) { - var label = data.patternLabels[i] + ': ' + column; + var label = data.patternLabels[i] === column + ? data.colorLabels[i] + ': ' + column + : isNaN(data.patternLabels[i]) + ? data.patternLabels[i] + ': ' + column + : column; options.labels.push(label); options.series[label] = { color: colors(data.colorLabels[i]), @@ -69,14 +80,31 @@ if (annotations) { dygraphChart.ready(function () { - dygraphChart.setAnnotations(annotations.rowData().map(function (a) { + var i = 0; + dygraphChart.setAnnotations(annotations.rowData().map(function (a){ + // find the closest date in the data that fits + var closestDate = null; + var lastDistance = Math.pow(2, 53) - 1; + for (; i < rows.length; i++) { + var date = rows[i][0]; + var thisDistance = Math.min(lastDistance, Math.abs(date.getTime() - a[0])); + // both arrays are sorted so the distance will only get further now + if (thisDistance === lastDistance) { + i--; + break; + } + lastDistance = thisDistance; + closestDate = rows[i][0]; + } return { // just attach to the first series and show on X axis series: options.labels[1], attachAtBottom: true, - shortText: 'D', - // strip time information from the annotation so it matches the data - x: new Date(a[0].getFullYear(), a[0].getMonth(), a[0].getDate()).getTime(), + shortText: 'A', + // annoying thing to learn through experimentation: + // Dygraphs requires Date instances for the data, but + // Dygraphs requires milliseconds since epoch for the annotations + x: closestDate.getTime(), text: a[1], }; })); diff --git a/src/components/visualizers/dygraphs-timeseries/dygraphs-timeseries.html b/src/components/visualizers/dygraphs-timeseries/dygraphs-timeseries.html index 9ed26af..34819c4 100644 --- a/src/components/visualizers/dygraphs-timeseries/dygraphs-timeseries.html +++ b/src/components/visualizers/dygraphs-timeseries/dygraphs-timeseries.html @@ -1,7 +1,7 @@ -<div data-bind=" +<div class="parent-of-resizable" data-bind=" if: data && data.header, - style: {height: height + 'px'}, dygraphs: { + height: $data.height, data: data, annotations: $data.annotations, colors: colors, diff --git a/src/components/wikimetrics-layout/wikimetrics-layout.html b/src/components/wikimetrics-layout/wikimetrics-layout.html index 3fa7da0..b06184b 100644 --- a/src/components/wikimetrics-layout/wikimetrics-layout.html +++ b/src/components/wikimetrics-layout/wikimetrics-layout.html @@ -20,7 +20,7 @@ </metric-selector> <time-selector></time-selector> </div> - <div class="ui segment graph"> + <div class="ui segment"> <wikimetrics-visualizer params="projects: selectedProjects, metric: selectedMetric"> </wikimetrics-visualizer> diff --git a/src/components/wikimetrics-visualizer/wikimetrics-visualizer.html b/src/components/wikimetrics-visualizer/wikimetrics-visualizer.html index bf3e26a..7f62d92 100644 --- a/src/components/wikimetrics-visualizer/wikimetrics-visualizer.html +++ b/src/components/wikimetrics-visualizer/wikimetrics-visualizer.html @@ -1,15 +1,24 @@ <!-- ko if: mergedData && mergedData() && mergedData().header.length --> + <a class="header" data-bind="attr: {href: metric().definition}" title="view metric definition" target="_blank"> <span data-bind="metricName: metric"></span> <i class="external url icon"></i> </a> -<vega-timeseries params="data: mergedData, colorScale: colorScale"></vega-timeseries> + +<dygraphs-timeseries params=" + data: mergedData, + colors: colorScale, + annotations: annotations, +"></dygraphs-timeseries> + <!-- /ko --> + + <!-- ko ifnot: mergedData && mergedData() && mergedData().header.length --> + <p>Please select a metric above <i class="up icon"/><p> <p><i class="left icon"/> And projects from the left</p> + <!-- /ko --> -<annotation-list params="metric: metric"> -</annotation-list> diff --git a/src/components/wikimetrics-visualizer/wikimetrics-visualizer.js b/src/components/wikimetrics-visualizer/wikimetrics-visualizer.js index bc09170..a633cbe 100644 --- a/src/components/wikimetrics-visualizer/wikimetrics-visualizer.js +++ b/src/components/wikimetrics-visualizer/wikimetrics-visualizer.js @@ -17,6 +17,7 @@ _ = require('lodash'), TimeseriesData = require('converters.timeseries'), templateMarkup = require('text!./wikimetrics-visualizer.html'), + annotationsApi = require('apis.annotations'), apiFinder = require('app/apis/api-finder'); function WikimetricsVisualizer(params) { @@ -25,16 +26,6 @@ this.metric = params.metric; this.projects = params.projects; this.mergedData = ko.observable(); - this.colorScale = ko.observable(); - - this.applyColors = function (projects, color) { - var scale = color || this.colorScale(); - if (scale) { - projects.forEach(function (project) { - project.color(scale(project.database)); - }); - } - }; this.datasets = ko.computed(function () { var projects = ko.unwrap(this.projects), @@ -53,19 +44,44 @@ return api.getData(metric, project.database, showBreakdown); }); $.when.apply(this, promises).then(function () { - visualizer.mergedData(TimeseriesData.mergeAll(_.toArray(arguments))); - visualizer.applyColors(projects); - }); + this.mergedData(TimeseriesData.mergeAll(_.toArray(arguments))); + this.applyColors(projects); + }.bind(this)); } else { - visualizer.mergedData(new TimeseriesData([])); + this.mergedData(new TimeseriesData([])); } }, this); - this.colorScale.subscribe(function (color) { - var projects = ko.unwrap(this.projects); - this.applyColors(projects, color); + // get annotations as a TimeseriesData observable + this.annotations = ko.observable(); + ko.computed(function () { + var metric = ko.unwrap(this.metric); + if (metric) { + annotationsApi.getTimeseriesData(metric).done(this.annotations); + } }, this); + + // build our own simple color scale to match the d3.scale.category10 + // because otherwise we'd have to import all of d3 + this.coloredProjects = []; + this.colors = [ + '#1f77b4', '#ff7f0e', '#2ca02c', '#d62728', '#9467bd', + '#8c564b', '#e377c2', '#7f7f7f', '#bcbd22', '#17becf', + ]; + this.colorScale = function (project) { + var i = _.indexOf(visualizer.coloredProjects, project); + if (i === -1) { + i = visualizer.coloredProjects.push(project) - 1; + } + return visualizer.colors[i % visualizer.colors.length]; + }; + + this.applyColors = function (projects) { + projects.forEach(function (project) { + project.color(visualizer.colorScale(project.database)); + }); + }; } return { diff --git a/src/layouts/metrics-by-project/01_styles.css b/src/layouts/metrics-by-project/01_styles.css index 8f9bf25..fa1c56f 100644 --- a/src/layouts/metrics-by-project/01_styles.css +++ b/src/layouts/metrics-by-project/01_styles.css @@ -111,18 +111,16 @@ left: 300px; } .graph { - height: 87vh; + height: 80vh; } -.graph .parent-of-resizable { - height: 100%; - min-height: 300px; - min-width: 800px; - max-height: 76vh; +dygraphs-timeseries { + display: block; + margin-top: 20px; } .parent-of-resizable { - width: 100%; - height: 100%; padding: 0; + min-height: 300px; + min-width: 800px; } .colored i.swatch { vertical-align: bottom; diff --git a/test/components/wikimetrics-visualizer.js b/test/components/wikimetrics-visualizer.js index d78f042..46b94ac 100644 --- a/test/components/wikimetrics-visualizer.js +++ b/test/components/wikimetrics-visualizer.js @@ -51,10 +51,14 @@ projects: selectedProjects, }); deferred.resolveWith(this, [transformedResponse]); + + // just stub the apply colors function to get it out of the way + sinon.stub(visualizer, 'applyColors'); }); afterEach(function () { api.getData.restore(); + visualizer.applyColors.restore(); }); it('should not do anything without a selected metric', function () { -- To view, visit https://gerrit.wikimedia.org/r/214270 To unsubscribe, visit https://gerrit.wikimedia.org/r/settings Gerrit-MessageType: merged Gerrit-Change-Id: If20a496a7dbee13b73a013cd36213f4e55cded0c Gerrit-PatchSet: 6 Gerrit-Project: analytics/dashiki Gerrit-Branch: master Gerrit-Owner: Milimetric <dandree...@wikimedia.org> Gerrit-Reviewer: Mforns <mfo...@wikimedia.org> Gerrit-Reviewer: Milimetric <dandree...@wikimedia.org> _______________________________________________ MediaWiki-commits mailing list MediaWiki-commits@lists.wikimedia.org https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits