New perf dashboard should have the ability to overlay moving average with an envelope
authorrniwa@webkit.org <rniwa@webkit.org@268f45cc-cd09-0410-ab3c-d52691b4dbfc>
Tue, 10 Feb 2015 21:39:41 +0000 (21:39 +0000)
committerrniwa@webkit.org <rniwa@webkit.org@268f45cc-cd09-0410-ab3c-d52691b4dbfc>
Tue, 10 Feb 2015 21:39:41 +0000 (21:39 +0000)
https://bugs.webkit.org/show_bug.cgi?id=141438

Reviewed by Andreas Kling.

This patch adds three kinds of moving average strategies and two kinds of enveloping strategies:

Simple Moving Average - The moving average x̄_i of x_i is computed as the arithmetic mean of values
from x_(i - n) though x_(i + m) where n is a non-negative integer and m is a positive integer. It takes
n, backward window size, and m, forward window size, as an argument.

Cumulative Moving Average - x̄_i is computed as the arithmetic mean of all values x_0 though x_i.
That is, x̄_1 = x_1 and x̄_i = ((i - 1) * M_(i - 1) + x_i) / i for all i > 1.

Exponential Moving Average - x̄_i is computed as the weighted average of x_i and x̄_(i - 1) with α as
an argument specifying x_i's weight. To be precise, x̄_1 = x_1 and x̄_i = α * x_i + (α - 1) x̄_(i-1).

Average Difference - The enveloping delta d is computed as the arithmetic mean of the difference
between each x_i and x̄_i.

Moving Average Standard Deviation - d is computed like the standard deviation except the deviation
for each term is measured from the moving average instead of the sample arithmetic mean. i.e. it uses
the average of (x_i - x̄_i)^2 as the "sample variance" instead of the conventional (x_i - x̄)^2 where
x̄ is the sample mean of all x_i's. This change was necessary since our time series is non-stationary.

Each strategy is cloned for an App.Pane instance so that its parameterList can be configured per pane.
The configuration of the currently chosen strategies is saved in the query string for convenience.

Also added the "stat pane" to choose a moving average strategy and a enveloping strategy in each pane.

* public/v2/app.css: Specify the fill color for all SVG groups in the pane toolbar icons.

* public/v2/app.js:
(App.Pane._fetch): Delegate the creation of 'chartData' to _computeChartData.
(App.Pane.updateStatisticsTools): Added. Clones moving average and enveloping strategies for this pane.
(App.Pane._cloneStrategy): Added. Clones a strategy for a new pane.
(App.Pane._configureStrategy): Added. Finds and configures a strategy from the configuration retrieved
from the query string via ChartsController.
(App.Pane._computeChartData): Added. Creates chartData from fetchedData.
(App.Pane._computeMovingAverage): Added. Computes the moving average and the envelope.
(App.Pane._executeStrategy): Added.
(App.Pane._updateStrategyConfigIfNeeded): Pushes the strategy configurations to the query string via
ChartsController.
(App.ChartsController._parsePaneList): Merged the query string arguments for the range and point
selections, and added two new arguments for the moving average and the enveloping configurations.
(App.ChartsController._serializePaneList): Ditto.
(App.ChartsController._scheduleQueryStringUpdate): Observes strategy configurations.
(App.PaneController.actions.toggleBugsPane): Hides the stat pane.
(App.PaneController.actions.toggleSearchPane): Hides the stat pane.
(App.PaneController.actions.toggleStatPane): Added.

* public/v2/chart-pane.css: Added CSS rules for the new stat pane. Also added .foreground class for the
current (as opposed to baseline and target) time series for when it's the most foreground graph without
moving average and its envelope overlapping on top of it.

* public/v2/index.html: Added the templates for the stat pane and the corresponding icon (Σ).

* public/v2/interactive-chart.js:
(App.InteractiveChartComponent.chartDataDidChange): Unset _totalWidth and _totalHeight to avoid exiting
early inside _updateDimensionsIfNeeded when chartData changes after the initial layout.
(App.InteractiveChartComponent.didInsertElement): Attach event listeners here instead of inside
_constructGraphIfPossible since that could be called multiple times on the same SVG element.
(App.InteractiveChartComponent._constructGraphIfPossible): Clear down the old SVG element we created
but don't bother removing individual paths and circles. Added the code to show the moving average time
series when there is one. Also add "foreground" class on SVG elements for the current time series when
we're not showing the moving average. chart-pane.css has been updated to "dim down" the current time
series when "foreground" is not set.
(App.InteractiveChartComponent._minMaxForAllTimeSeries): Take the moving average time series into
account when computing the y-axis range.
(App.InteractiveChartComponent._brushChanged): Removed 'selectionIsLocked' argument as it's useless.

* public/v2/js/statistics.js:
(Statistics.MovingAverageStrategies): Added.
(Statistics.EnvelopingStrategies): Added.

git-svn-id: https://svn.webkit.org/repository/webkit/trunk@179878 268f45cc-cd09-0410-ab3c-d52691b4dbfc

Websites/perf.webkit.org/ChangeLog
Websites/perf.webkit.org/public/v2/app.css
Websites/perf.webkit.org/public/v2/app.js
Websites/perf.webkit.org/public/v2/chart-pane.css
Websites/perf.webkit.org/public/v2/index.html
Websites/perf.webkit.org/public/v2/interactive-chart.js
Websites/perf.webkit.org/public/v2/js/statistics.js

index d18c347..2df2789 100644 (file)
@@ -1,3 +1,82 @@
+2015-02-10  Ryosuke Niwa  <rniwa@webkit.org>
+
+        New perf dashboard should have the ability to overlay moving average with an envelope
+        https://bugs.webkit.org/show_bug.cgi?id=141438
+
+        Reviewed by Andreas Kling.
+
+        This patch adds three kinds of moving average strategies and two kinds of enveloping strategies:
+
+        Simple Moving Average - The moving average x̄_i of x_i is computed as the arithmetic mean of values
+        from x_(i - n) though x_(i + m) where n is a non-negative integer and m is a positive integer. It takes
+        n, backward window size, and m, forward window size, as an argument.
+
+        Cumulative Moving Average - x̄_i is computed as the arithmetic mean of all values x_0 though x_i.
+        That is, x̄_1 = x_1 and x̄_i = ((i - 1) * M_(i - 1) + x_i) / i for all i > 1.
+
+        Exponential Moving Average - x̄_i is computed as the weighted average of x_i and x̄_(i - 1) with α as
+        an argument specifying x_i's weight. To be precise, x̄_1 = x_1 and x̄_i = α * x_i + (α - 1) x̄_(i-1).
+
+
+        Average Difference - The enveloping delta d is computed as the arithmetic mean of the difference
+        between each x_i and x̄_i.
+
+        Moving Average Standard Deviation - d is computed like the standard deviation except the deviation
+        for each term is measured from the moving average instead of the sample arithmetic mean. i.e. it uses
+        the average of (x_i - x̄_i)^2 as the "sample variance" instead of the conventional (x_i - x̄)^2 where
+        x̄ is the sample mean of all x_i's. This change was necessary since our time series is non-stationary.
+
+
+        Each strategy is cloned for an App.Pane instance so that its parameterList can be configured per pane.
+        The configuration of the currently chosen strategies is saved in the query string for convenience.
+
+        Also added the "stat pane" to choose a moving average strategy and a enveloping strategy in each pane.
+
+        * public/v2/app.css: Specify the fill color for all SVG groups in the pane toolbar icons.
+
+        * public/v2/app.js:
+        (App.Pane._fetch): Delegate the creation of 'chartData' to _computeChartData.
+        (App.Pane.updateStatisticsTools): Added. Clones moving average and enveloping strategies for this pane.
+        (App.Pane._cloneStrategy): Added. Clones a strategy for a new pane.
+        (App.Pane._configureStrategy): Added. Finds and configures a strategy from the configuration retrieved
+        from the query string via ChartsController.
+        (App.Pane._computeChartData): Added. Creates chartData from fetchedData.
+        (App.Pane._computeMovingAverage): Added. Computes the moving average and the envelope.
+        (App.Pane._executeStrategy): Added.
+        (App.Pane._updateStrategyConfigIfNeeded): Pushes the strategy configurations to the query string via
+        ChartsController.
+        (App.ChartsController._parsePaneList): Merged the query string arguments for the range and point
+        selections, and added two new arguments for the moving average and the enveloping configurations.
+        (App.ChartsController._serializePaneList): Ditto.
+        (App.ChartsController._scheduleQueryStringUpdate): Observes strategy configurations.
+        (App.PaneController.actions.toggleBugsPane): Hides the stat pane.
+        (App.PaneController.actions.toggleSearchPane): Hides the stat pane.
+        (App.PaneController.actions.toggleStatPane): Added.
+
+        * public/v2/chart-pane.css: Added CSS rules for the new stat pane. Also added .foreground class for the
+        current (as opposed to baseline and target) time series for when it's the most foreground graph without
+        moving average and its envelope overlapping on top of it.
+
+        * public/v2/index.html: Added the templates for the stat pane and the corresponding icon (Σ).
+
+        * public/v2/interactive-chart.js:
+        (App.InteractiveChartComponent.chartDataDidChange): Unset _totalWidth and _totalHeight to avoid exiting
+        early inside _updateDimensionsIfNeeded when chartData changes after the initial layout.
+        (App.InteractiveChartComponent.didInsertElement): Attach event listeners here instead of inside
+        _constructGraphIfPossible since that could be called multiple times on the same SVG element.
+        (App.InteractiveChartComponent._constructGraphIfPossible): Clear down the old SVG element we created
+        but don't bother removing individual paths and circles. Added the code to show the moving average time
+        series when there is one. Also add "foreground" class on SVG elements for the current time series when
+        we're not showing the moving average. chart-pane.css has been updated to "dim down" the current time
+        series when "foreground" is not set.
+        (App.InteractiveChartComponent._minMaxForAllTimeSeries): Take the moving average time series into
+        account when computing the y-axis range.
+        (App.InteractiveChartComponent._brushChanged): Removed 'selectionIsLocked' argument as it's useless.
+
+        * public/v2/js/statistics.js:
+        (Statistics.MovingAverageStrategies): Added.
+        (Statistics.EnvelopingStrategies): Added.
+
 2015-02-06  Ryosuke Niwa  <rniwa@webkit.org>
 
         The delta value in the chart pane sometimes doens't show '+' for a positive delta
index 6cdf4cf..fa3e08a 100755 (executable)
@@ -121,12 +121,15 @@ body {
 }
 .icon-button g {
     stroke: #ccc;
+    fill: #ccc;
 }
 .icon-button:hover g {
     stroke: #666;
+    fill: #666;
 }
 .disabled .icon-button:hover g {
     stroke: #ccc;
+    fill: #ccc;
 }
 
 
index 9f0ea8c..b9474b5 100755 (executable)
@@ -351,7 +351,8 @@ App.Pane = Ember.Object.extend({
             App.Manifest.fetchRunsWithPlatformAndMetric(this.get('store'), platformId, metricId).then(function (result) {
                 self.set('platform', result.platform);
                 self.set('metric', result.metric);
-                self.set('chartData', App.createChartData(result));
+                self.set('fetchedData', result);
+                self._computeChartData();
             }, function (result) {
                 if (!result || typeof(result) === "string")
                     self.set('failure', 'Failed to fetch the JSON with an error: ' + result);
@@ -431,6 +432,100 @@ App.Pane = Ember.Object.extend({
 
         return this.computeStatus(lastPoint, chartData.current.previousPoint(lastPoint));
     }.property('chartData'),
+    updateStatisticsTools: function ()
+    {
+        var movingAverageStrategies = Statistics.MovingAverageStrategies.map(this._cloneStrategy.bind(this));
+        this.set('movingAverageStrategies', [{label: 'None'}].concat(movingAverageStrategies));
+        this.set('chosenMovingAverageStrategy', this._configureStrategy(movingAverageStrategies, this.get('movingAverageConfig')));
+
+        var envelopingStrategies = Statistics.EnvelopingStrategies.map(this._cloneStrategy.bind(this));
+        this.set('envelopingStrategies', [{label: 'None'}].concat(envelopingStrategies));
+        this.set('chosenEnvelopingStrategy', this._configureStrategy(envelopingStrategies, this.get('envelopingConfig')));
+    }.on('init'),
+    _cloneStrategy: function (strategy)
+    {
+        var parameterList = (strategy.parameterList || []).map(function (param) { return Ember.Object.create(param); });
+        return Ember.Object.create({
+            id: strategy.id,
+            label: strategy.label,
+            description: strategy.description,
+            parameterList: parameterList,
+            execute: strategy.execute,
+        });
+    },
+    _configureStrategy: function (strategies, config)
+    {
+        if (!config || !config[0])
+            return null;
+
+        var id = config[0];
+        var chosenStrategy = strategies.find(function (strategy) { return strategy.id == id });
+        if (!chosenStrategy)
+            return null;
+
+        for (var i = 0; i < chosenStrategy.parameters.length; i++)
+            chosenStrategy.parameters[i] = parseFloat(config[i + 1]);
+
+        return chosenStrategy;
+    },
+    _computeChartData: function ()
+    {
+        if (!this.get('fetchedData'))
+            return;
+
+        var chartData = App.createChartData(this.get('fetchedData'));
+        chartData.movingAverage = this._computeMovingAverage(chartData);
+
+        this._updateStrategyConfigIfNeeded(this.get('chosenMovingAverageStrategy'), 'movingAverageConfig');
+        this._updateStrategyConfigIfNeeded(this.get('chosenEnvelopingStrategy'), 'envelopingConfig');
+
+        this.set('chartData', chartData);
+    }.observes('chosenMovingAverageStrategy', 'chosenMovingAverageStrategy.parameterList.@each.value',
+        'chosenEnvelopingStrategy', 'chosenEnvelopingStrategy.parameterList.@each.value'),
+    _computeMovingAverage: function (chartData)
+    {
+        var currentTimeSeriesData = chartData.current.series();
+        var movingAverageStrategy = this.get('chosenMovingAverageStrategy');
+        if (!movingAverageStrategy || !movingAverageStrategy.execute)
+            return null;
+
+        var movingAverageValues = this._executeStrategy(movingAverageStrategy, currentTimeSeriesData);
+        if (!movingAverageValues)
+            return null;
+
+        var envelopeDelta = null;
+        var envelopingStrategy = this.get('chosenEnvelopingStrategy');
+        if (envelopingStrategy && envelopingStrategy.execute)
+            envelopeDelta = this._executeStrategy(envelopingStrategy, currentTimeSeriesData, [movingAverageValues]);
+        
+        return new TimeSeries(currentTimeSeriesData.map(function (point, index) {
+            var value = movingAverageValues[index];
+            return {
+                measurement: point.measurement,
+                time: point.time,
+                value: value,
+                interval: envelopeDelta !== null ? [value - envelopeDelta, value + envelopeDelta] : null,
+            }
+        }));
+    },
+    _executeStrategy: function (strategy, currentTimeSeriesData, additionalArguments)
+    {
+        var parameters = (strategy.parameterList || []).map(function (param) {
+            var parsed = parseFloat(param.value);
+            return Math.min(param.max || Infinity, Math.max(param.min || -Infinity, isNaN(parsed) ? 0 : parsed));
+        });
+        parameters.push(currentTimeSeriesData.map(function (point) { return point.value }));
+        return strategy.execute.apply(window, parameters.concat(additionalArguments));
+    },
+    _updateStrategyConfigIfNeeded: function (strategy, configName)
+    {
+        var config = null;
+        if (strategy && strategy.execute)
+            config = [strategy.id].concat((strategy.parameterList || []).map(function (param) { return param.value; }));
+
+        if (JSON.stringify(config) != JSON.stringify(this.get(configName)))
+            this.set(configName, config);
+    },
 });
 
 App.createChartData = function (data)
@@ -552,26 +647,30 @@ App.ChartsController = Ember.Controller.extend({
         if (!parsedPaneList)
             return null;
 
-        // Don't re-create all panes.
+        // FIXME: Don't re-create all panes.
         var self = this;
         return parsedPaneList.map(function (paneInfo) {
             var timeRange = null;
-            if (paneInfo[3] && paneInfo[3] instanceof Array) {
-                var timeRange = paneInfo[3];
+            var selectedItem = null;
+            if (paneInfo[2] instanceof Array) {
+                var timeRange = paneInfo[2];
                 try {
                     timeRange = [new Date(timeRange[0]), new Date(timeRange[1])];
                 } catch (error) {
                     console.log("Failed to parse the time range:", timeRange, error);
                 }
-            }
+            } else
+                selectedItem = paneInfo[2];
+
             return App.Pane.create({
                 store: self.store,
                 info: paneInfo,
                 platformId: paneInfo[0],
                 metricId: paneInfo[1],
-                selectedItem: paneInfo[2],
+                selectedItem: selectedItem,
                 timeRange: timeRange,
-                timeRangeIsLocked: !!paneInfo[4],
+                movingAverageConfig: paneInfo[3],
+                envelopingConfig: paneInfo[4],
             });
         });
     },
@@ -580,13 +679,14 @@ App.ChartsController = Ember.Controller.extend({
     {
         if (!panes.length)
             return undefined;
+        var self = this;
         return App.encodePrettifiedJSON(panes.map(function (pane) {
             return [
                 pane.get('platformId'),
                 pane.get('metricId'),
-                pane.get('selectedItem'),
-                pane.get('timeRange') ? pane.get('timeRange').map(function (date) { return date.getTime() }) : null,
-                !!pane.get('timeRangeIsLocked'),
+                pane.get('timeRange') ? pane.get('timeRange').map(function (date) { return date.getTime() }) : pane.get('selectedItem'),
+                pane.get('movingAverageConfig'),
+                pane.get('envelopingConfig'),
             ];
         }));
     },
@@ -594,8 +694,8 @@ App.ChartsController = Ember.Controller.extend({
     _scheduleQueryStringUpdate: function ()
     {
         Ember.run.debounce(this, '_updateQueryString', 1000);
-    }.observes('sharedZoom', 'panes.@each.platform', 'panes.@each.metric', 'panes.@each.selectedItem',
-        'panes.@each.timeRange', 'panes.@each.timeRangeIsLocked'),
+    }.observes('sharedZoom', 'panes.@each.platform', 'panes.@each.metric', 'panes.@each.selectedItem', 'panes.@each.timeRange',
+        'panes.@each.movingAverageConfig', 'panes.@each.envelopingConfig'),
 
     _updateQueryString: function ()
     {
@@ -711,8 +811,10 @@ App.PaneController = Ember.ObjectController.extend({
         },
         toggleBugsPane: function ()
         {
-            if (this.toggleProperty('showingAnalysisPane'))
+            if (this.toggleProperty('showingAnalysisPane')) {
                 this.set('showingSearchPane', false);
+                this.set('showingStatPane', false);
+            }
         },
         createAnalysisTask: function ()
         {
@@ -743,13 +845,22 @@ App.PaneController = Ember.ObjectController.extend({
             var model = this.get('model');
             if (!model.get('commitSearchRepository'))
                 model.set('commitSearchRepository', App.Manifest.repositoriesWithReportedCommits[0]);
-            if (this.toggleProperty('showingSearchPane'))
+            if (this.toggleProperty('showingSearchPane')) {
                 this.set('showingAnalysisPane', false);
+                this.set('showingStatPane', false);
+            }
         },
         searchCommit: function () {
             var model = this.get('model');
             model.searchCommit(model.get('commitSearchRepository'), model.get('commitSearchKeyword'));                
         },
+        toggleStatPane: function ()
+        {
+            if (this.toggleProperty('showingStatPane')) {
+                this.set('showingSearchPane', false);
+                this.set('showingAnalysisPane', false);
+            }
+        },
         zoomed: function (selection)
         {
             this.set('mainPlotDomain', selection ? selection : this.get('overviewDomain'));
@@ -786,7 +897,7 @@ App.PaneController = Ember.ObjectController.extend({
         var newSelection = this.get('parentController').get('sharedZoom');
         if (App.domainsAreEqual(newSelection, this.get('mainPlotDomain')))
             return;
-        this.set('mainPlotDomain', newSelection);
+        this.set('mainPlotDomain', newSelection || this.get('overviewDomain'));
         this.set('overviewSelection', newSelection);
     }.observes('parentController.sharedZoom').on('init'),
     _updateDetails: function ()
index a052de3..04a9654 100755 (executable)
     top: 0.55rem;
 }
 
+.chart-pane a.stat-button {
+    display: inline-block;
+    position: absolute;
+    right: 3.15rem;
+    top: 0.55rem;
+}
+
 .chart-pane a.bugs-button {
     display: inline-block;
     position: absolute;
     top: 0.55rem;
 }
 
-.search-pane, .analysis-pane {
+.popup-pane {
     position: absolute;
     top: 1.7rem;
     border: 1px solid #bbb;
-    padding: 0;
+    font-size: 0.8rem;
+    padding: 0.2rem;
     border-radius: 0.5rem;
     display: table;
     background: white;
 }
 
-.analysis-pane {
-    right: 1.3rem;
+.popup-pane.hidden {
+    display: none;
+}
+
+.stat-pane {
+    right: 2.6rem;
+    padding: 0;
 }
 
-.analysis-pane table {
+.stat-pane fieldset {
+    border: solid 1px #ccc;
+    border-radius: 0.5rem;
     margin: 0.2rem;
+    padding: 0;
+}
+
+.stat-option {
+    margin: 0;
+    padding: 0;
     font-size: 0.8rem;
 }
 
-.analysis-pane th {
-    font-weight: normal;
+.stat-option h1 {
+    font-size: inherit;
+    line-height: 0.8rem;
+    padding: 0.3rem 0.5rem;
+    margin: 0;
+    border-top: solid 1px #ccc;
+    border-bottom: solid 1px #ccc;
 }
 
-.search-pane {
-    right: 0rem;
+.stat-option:first-child h1 {
+    border-top: none;
 }
 
-.analysis-pane.hidden,
-.search-pane.hidden {
-    display: none;
+.stat-option > * {
+    display: block;
+    margin: 0.1rem 0.5rem 0.1rem 1rem;
+}
+
+.stat-option input {
+    width: 4rem;
+}
+
+.stat-option p {
+    max-width: 15rem;
+}
+
+.analysis-pane {
+    right: 1.3rem;
+}
+
+.analysis-pane > * {
+    margin: 0.2rem;
+}
+
+.search-pane {
+    right: 0rem;
 }
 
 .search-pane input {
     border: none;
     border-top-right-radius: 0.5rem;
     border-bottom-right-radius: 0.5rem;
-    padding: 0.5rem;
-    font-size: 1rem;
+    padding: 0.2rem;
+    font-size: 0.8rem;
     margin: 0;
 }
 
 .search-pane .repositories {
     display: table-cell;
     vertical-align: middle;
-    padding: 0 0.5rem;
+    padding: 0;
 }
 
 .search-pane input:focus {
 }
 
 .chart .dot {
-    fill: #666;
+    fill: #ccc;
     stroke: none;
 }
+.chart .dot.foreground {
+    fill: #666;
+}
 
 .chart path.area {
     stroke: none;
     fill: #ccc;
     opacity: 0.8;
 }
+.chart path.area.foreground {
+}
 
 .chart path.current {
+    stroke: #ccc;
+}
+
+.chart path.current.foreground {
     stroke: #999;
 }
 
+.chart path.movingAverage {
+    stroke: #363;
+    fill: none;
+    opacity: 0.8;
+}
+
+.chart path.envelope {
+    stroke: none;
+    fill: #6c6;
+    opacity: 0.4;
+}
+
 .chart path.baseline {
     stroke: #f66;
 }
index 50b8740..394aa93 100755 (executable)
                 <header>
                     <h1 {{action "toggleDetails"}}>{{metric.fullName}} - {{ platform.name}}</h1>
                     <a href="#" title="Close" class="close-button" {{action "close"}}>{{partial "close-button"}}</a>
+                    {{#if movingAverageStrategies}}
+                        <a href="#" title="Statistical Tools" class="stat-button" {{action "toggleStatPane"}}>{{partial "stat-button"}}</a>
+                    {{/if}}
                     {{#if App.Manifest.bugTrackers}}
                         <a href="#" title="Analysis" class="bugs-button" {{action "toggleBugsPane"}}>
                             {{partial "analysis-button"}}
                             rangeRoute="analysisTask"
                             selection=timeRange
                             selectedPoints=selectedPoints
-                            selectionIsLocked=timeRangeIsLocked
                             markedPoints=markedPoints
                             zoom="zoomed"}}
                     {{else}}
                     </div>
                 </div>
 
-                <form {{bind-attr class=":search-pane showingSearchPane::hidden"}}>
+                <div {{bind-attr class=":popup-pane :analysis-pane showingAnalysisPane::hidden"}}>
+                    <label>Name: {{input type=text value=newAnalysisTaskName}}</label>
+                    <button {{action "createAnalysisTask"}} {{bind-attr disabled=cannotAnalyze}}>Analyze</button>
+                </div>
+
+                <form {{bind-attr class=":popup-pane :search-pane showingSearchPane::hidden"}}>
                     <span class="repositories">
                         {{view Ember.Select
                             content=App.Manifest.repositoriesWithReportedCommits
                     {{input action="searchCommit" placeholder="Name or email" value=commitSearchKeyword}}
                 </form>
 
-                <div {{bind-attr class=":analysis-pane showingAnalysisPane::hidden"}}>
-                    <table>
-                        <tbody>
-                            <tr>
-                                <th>
-                                    <label>Name: {{input type=text value=newAnalysisTaskName}}</label>
-                                    <button {{action "createAnalysisTask"}} {{bind-attr disabled=cannotAnalyze}}>Analyze</button>
-                                </th>
-                            </tr>
-                        </tbody>
-                    </table>
-                </div>
-
+                {{partial "stat-pane"}}
             </section>
         {{/each}}
     </script>
         </svg>
     </script>
 
+    <script type="text/x-handlebars" data-template-name="stat-button">
+        <svg class="stat-button icon-button" viewBox="10 0 110 100">
+            <g stroke="none" stroke-width="0" fill="black">
+                <path id="upper-sigma" d="M 5 5 H 95 V 40 h -10 c -5 -20 -5 -20 -25 -20 H 35 L 60 50 l -20 0" />
+                <use xlink:href="#upper-sigma" transform="translate(0, 100) scale(1, -1)" />
+            </g>
+        </svg>
+    </script>
+
+    <script type="text/x-handlebars" data-template-name="stat-pane">
+        <section {{bind-attr class=":popup-pane :stat-pane showingStatPane::hidden"}}>
+            <section class="stat-option">
+                <h1>Moving average</h1>
+                <label>Type: {{view Ember.Select
+                    content=movingAverageStrategies
+                    optionValuePath='content'
+                    optionLabelPath='content.label'
+                    selection=chosenMovingAverageStrategy}}</label>
+                {{#each chosenMovingAverageStrategy.parameterList}}
+                    <label>{{label}}: {{input type="number" value=value min=min max=max step=step}}</label>
+                {{/each}}
+            </section>
+            {{#if chosenMovingAverageStrategy.execute}}
+                <section class="stat-option">
+                    <h1>Envelope</h1>
+                    <label>Type: {{view Ember.Select
+                        content=envelopingStrategies
+                        optionValuePath='content'
+                        optionLabelPath='content.label'
+                        selection=chosenEnvelopingStrategy}}</label>
+                    {{#if chosenEnvelopingStrategy.description}}
+                        <p class="description">{{chosenEnvelopingStrategy.description}}</p>
+                    {{/if}}
+                    {{#each chosenEnvelopingStrategy.parameterList}}
+                        <label>{{label}}: <input type="number" {{bind-attr value=value min=min max=max step=step}}></label>
+                    {{/each}}
+                </section>
+            {{/if}}
+        </section>
+    </script>
+
     <script type="text/x-handlebars" data-template-name="analysis-button">
         <svg class="analysis-button icon-button" viewBox="0 0 100 100">
-            <g stroke="black" stroke-width="15">
+            <g stroke="black" fill="black" stroke-width="15">
                 <circle cx="50" cy="50" r="40" fill="transparent"/>
                 <line x1="50" y1="25" x2="50" y2="55"/>
-                <circle cx="50" cy="67.5" r="2.5" fill="transparent"/>
+                <circle cx="50" cy="67.5" r="10" stroke="none"/>
             </g>
         </svg>
     </script>
index bb0aa86..9165b57 100644 (file)
@@ -18,6 +18,8 @@ App.InteractiveChartComponent = Ember.Component.extend({
         if (!chartData)
             return;
         this._needsConstruction = true;
+        this._totalWidth = undefined;
+        this._totalHeight = undefined;
         this._constructGraphIfPossible(chartData);
     }.observes('chartData').on('init'),
     didInsertElement: function ()
@@ -25,6 +27,14 @@ App.InteractiveChartComponent = Ember.Component.extend({
         var chartData = this.get('chartData');
         if (chartData)
             this._constructGraphIfPossible(chartData);
+
+        if (this.get('interactive')) {
+            var element = this.get('element');
+            this._attachEventListener(element, "mousemove", this._mouseMoved.bind(this));
+            this._attachEventListener(element, "mouseleave", this._mouseLeft.bind(this));
+            this._attachEventListener(element, "mousedown", this._mouseDown.bind(this));
+            this._attachEventListener($(element).parents("[tabindex]"), "keydown", this._keyPressed.bind(this));
+        }
     },
     willClearRender: function ()
     {
@@ -47,7 +57,8 @@ App.InteractiveChartComponent = Ember.Component.extend({
         this._x = d3.time.scale();
         this._y = d3.scale.linear();
 
-        // FIXME: Tear down the old SVG element.
+        if (this._svgElement)
+            this._svgElement.remove();
         this._svgElement = d3.select(element).append("svg")
                 .attr("width", "100%")
                 .attr("height", "100%");
@@ -86,23 +97,16 @@ App.InteractiveChartComponent = Ember.Component.extend({
             .y0(function(point) { return point.interval ? yScale(point.interval[0]) : null; })
             .y1(function(point) { return point.interval ? yScale(point.interval[1]) : null; });
 
-        if (this._paths)
-            this._paths.forEach(function (path) { path.remove(); });
         this._paths = [];
-        if (this._areas)
-            this._areas.forEach(function (area) { area.remove(); });
         this._areas = [];
-        if (this._dots)
-            this._dots.forEach(function (dot) { dots.remove(); });
         this._dots = [];
-        if (this._highlights)
-            this._highlights.remove();
         this._highlights = null;
 
         this._currentTimeSeries = chartData.current;
         this._currentTimeSeriesData = this._currentTimeSeries.series();
         this._baselineTimeSeries = chartData.baseline;
         this._targetTimeSeries = chartData.target;
+        this._movingAverageTimeSeries = chartData.movingAverage;
 
         this._yAxisUnit = chartData.unit;
 
@@ -119,29 +123,36 @@ App.InteractiveChartComponent = Ember.Component.extend({
                 .attr("class", "target"));
         }
 
+        var foregroundClass = this._movingAverageTimeSeries ? '' : ' foreground';
         this._areas.push(this._clippedContainer
             .append("path")
             .datum(this._currentTimeSeriesData)
-            .attr("class", "area"));
+            .attr("class", "area" + foregroundClass));
 
         this._paths.push(this._clippedContainer
             .append("path")
             .datum(this._currentTimeSeriesData)
-            .attr("class", "current"));
+            .attr("class", "current" + foregroundClass));
 
         this._dots.push(this._clippedContainer
             .selectAll(".dot")
                 .data(this._currentTimeSeriesData)
             .enter().append("circle")
-                .attr("class", "dot")
+                .attr("class", "dot" + foregroundClass)
                 .attr("r", this.get('chartPointRadius') || 1));
 
-        if (this.get('interactive')) {
-            this._attachEventListener(element, "mousemove", this._mouseMoved.bind(this));
-            this._attachEventListener(element, "mouseleave", this._mouseLeft.bind(this));
-            this._attachEventListener(element, "mousedown", this._mouseDown.bind(this));
-            this._attachEventListener($(element).parents("[tabindex]"), "keydown", this._keyPressed.bind(this));
+        if (this._movingAverageTimeSeries) {
+            this._paths.push(this._clippedContainer
+                .append("path")
+                .datum(this._movingAverageTimeSeries.series())
+                .attr("class", "movingAverage"));
+            this._areas.push(this._clippedContainer
+                .append("path")
+                .datum(this._movingAverageTimeSeries.series())
+                .attr("class", "envelope"));
+        }
 
+        if (this.get('interactive')) {
             this._currentItemLine = this._clippedContainer
                 .append("line")
                 .attr("class", "current-item");
@@ -331,9 +342,10 @@ App.InteractiveChartComponent = Ember.Component.extend({
         var currentRange = this._currentTimeSeries.minMaxForTimeRange(startTime, endTime);
         var baselineRange = this._baselineTimeSeries ? this._baselineTimeSeries.minMaxForTimeRange(startTime, endTime) : [Number.MAX_VALUE, Number.MIN_VALUE];
         var targetRange = this._targetTimeSeries ? this._targetTimeSeries.minMaxForTimeRange(startTime, endTime) : [Number.MAX_VALUE, Number.MIN_VALUE];
+        var movingAverageRange = this._movingAverageTimeSeries ? this._movingAverageTimeSeries.minMaxForTimeRange(startTime, endTime) : [Number.MAX_VALUE, Number.MIN_VALUE];
         return [
-            Math.min(currentRange[0], baselineRange[0], targetRange[0]),
-            Math.max(currentRange[1], baselineRange[1], targetRange[1]),
+            Math.min(currentRange[0], baselineRange[0], targetRange[0], movingAverageRange[0]),
+            Math.max(currentRange[1], baselineRange[1], targetRange[1], movingAverageRange[1]),
         ];
     },
     _currentSelection: function ()
@@ -378,7 +390,6 @@ App.InteractiveChartComponent = Ember.Component.extend({
             if (!this._brushExtent)
                 return;
 
-            this.set('selectionIsLocked', false);
             this._setCurrentSelection(undefined);
 
             // Avoid locking the indicator in _mouseDown when the brush was cleared in the same mousedown event.
@@ -391,7 +402,6 @@ App.InteractiveChartComponent = Ember.Component.extend({
             return;
         }
 
-        this.set('selectionIsLocked', true);
         this._setCurrentSelection(this._brush.extent());
     },
     _keyPressed: function (event)
index 7a20825..15fd9c8 100755 (executable)
@@ -99,6 +99,97 @@ var Statistics = new (function () {
             2.368026, 2.367566, 2.367115, 2.366674, 2.366243, 2.365821, 2.365407, 2.365002, 2.364606, 2.364217]
     };
 
+    this.MovingAverageStrategies = [
+        {
+            id: 1,
+            label: 'Simple Moving Average',
+            parameterList: [
+                {label: "Backward window size", value: 5, min: 2, step: 1},
+                {label: "Forward window size", value: 3, min: 0, step: 1}
+            ],
+            execute: function (backwardWindowSize, forwardWindowSize, values) {
+                var averages = new Array(values.length);
+                // We use naive O(n^2) algorithm for simplicy as well as to avoid accumulating round-off errors.
+                for (var i = 0; i < values.length; i++) {
+                    var sum = 0;
+                    var count = 0;
+                    for (var j = i - backwardWindowSize; j < i + backwardWindowSize; j++) {
+                        if (j >= 0 && j < values.length) {
+                            sum += values[j];
+                            count++;
+                        }
+                    }
+                    averages[i] = sum / count;
+                }
+                return averages;
+            },
+
+        },
+        {
+            id: 2,
+            label: 'Cumulative Moving Average',
+            execute: function (values) {
+                var averages = new Array(values.length);
+                var sum = 0;
+                for (var i = 0; i < values.length; i++) {
+                    sum += values[i];
+                    averages[i] = sum / (i + 1);
+                }
+                return averages;
+            }
+        },
+        {
+            id: 3,
+            label: 'Exponential Moving Average',
+            parameterList: [{label: "Smoothing factor", value: 0.1, min: 0.001, max: 0.9}],
+            execute: function (smoothingFactor, values) {
+                if (!values.length || typeof(smoothingFactor) !== "number")
+                    return null;
+
+                var averages = new Array(values.length);
+                var movingAverage = 0;
+                averages[0] = values[0];
+                for (var i = 1; i < values.length; i++)
+                    averages[i] = smoothingFactor * values[i] + (1 - smoothingFactor) * averages[i - 1];
+                return averages;
+            }
+        },
+    ];
+
+    this.EnvelopingStrategies = [
+        {
+            id: 100,
+            label: 'Average Difference',
+            description: 'The average difference between consecutive values.',
+            execute: function (values, movingAverages) {
+                if (values.length < 1)
+                    return NaN;
+
+                var diff = 0;
+                for (var i = 1; i < values.length; i++)
+                    diff += Math.abs(values[i] - values[i - 1]);
+
+                return diff / values.length;
+            }
+        },
+        {
+            id: 101,
+            label: 'Moving Average Standard Deviation',
+            description: 'The square root of the average deviation from the moving average with Bessel\'s correction.',
+            execute: function (values, movingAverages) {
+                if (values.length < 1)
+                    return NaN;
+
+                var diffSquareSum = 0;
+                for (var i = 1; i < values.length; i++) {
+                    var diff = (values[i] - movingAverages[i]);
+                    diffSquareSum += diff * diff;
+                }
+
+                return Math.sqrt(diffSquareSum / (values.length - 1));
+            }
+        },
+    ];
 })();
 
 if (typeof module != 'undefined') {