c46bacc9093c3d16b345611ae68771c9cba65cea
[WebKit-https.git] / Websites / perf.webkit.org / public / v2 / app.js
1 window.App = Ember.Application.create();
2
3 App.Router.map(function () {
4     this.resource('charts', {path: 'charts'});
5 });
6
7 App.DashboardRow = Ember.Object.extend({
8     header: null,
9     cells: [],
10
11     init: function ()
12     {
13         this._super();
14
15         var cellsInfo = this.get('cellsInfo') || [];
16         var columnCount = this.get('columnCount');
17         while (cellsInfo.length < columnCount)
18             cellsInfo.push([]);
19
20         this.set('cells', cellsInfo.map(this._createPane.bind(this)));
21     },
22     addPane: function (paneInfo)
23     {
24         var pane = this._createPane(paneInfo);
25         this.get('cells').pushObject(pane);
26         this.set('columnCount', this.get('columnCount') + 1);
27     },
28     _createPane: function (paneInfo)
29     {
30         if (!paneInfo || !paneInfo.length || (!paneInfo[0] && !paneInfo[1]))
31             paneInfo = null;
32
33         var pane = App.Pane.create({
34             platformId: paneInfo ? paneInfo[0] : null,
35             metricId: paneInfo ? paneInfo[1] : null,
36         });
37
38         return App.DashboardPaneProxyForPicker.create({content: pane});
39     },
40 });
41
42 App.DashboardPaneProxyForPicker = Ember.ObjectProxy.extend({
43     _platformOrMetricIdChanged: function ()
44     {
45         var self = this;
46         App.BuildPopup('choosePane', this)
47             .then(function (platforms) { self.set('pickerData', platforms); });
48     }.observes('platformId', 'metricId').on('init'),
49     paneList: function () {
50         return App.encodePrettifiedJSON([[this.get('platformId'), this.get('metricId'), null, null, false]]);
51     }.property('platformId', 'metricId'),
52 });
53
54 App.IndexController = Ember.Controller.extend({
55     queryParams: ['grid', 'numberOfDays'],
56     _previousGrid: {},
57     defaultTable: [],
58     headerColumns: [],
59     rows: [],
60     numberOfDays: 30,
61     editMode: false,
62
63     gridChanged: function ()
64     {
65         var grid = this.get('grid');
66         if (grid === this._previousGrid)
67             return;
68
69         var table = null;
70         try {
71             if (grid)
72                 table = JSON.parse(grid);
73         } catch (error) {
74             console.log("Failed to parse the grid:", error, grid);
75         }
76
77         if (!table || !table.length) // FIXME: We should fetch this from the manifest.
78             table = this.get('defaultTable');
79
80         table = this._normalizeTable(table);
81         var columnCount = table[0].length;
82         this.set('columnCount', columnCount);
83
84         this.set('headerColumns', table[0].map(function (name, index) {
85             return {label:name, index: index};
86         }));
87
88         this.set('rows', table.slice(1).map(function (rowParam) {
89             return App.DashboardRow.create({
90                 header: rowParam[0],
91                 cellsInfo: rowParam.slice(1),
92                 columnCount: columnCount,
93             })
94         }));
95
96         this.set('emptyRow', new Array(columnCount));
97     }.observes('grid').on('init'),
98
99     _normalizeTable: function (table)
100     {
101         var maxColumnCount = Math.max(table.map(function (column) { return column.length; }));
102         for (var i = 1; i < table.length; i++) {
103             var row = table[i];
104             for (var j = 1; j < row.length; j++) {
105                 if (row[j] && !(row[j] instanceof Array)) {
106                     console.log('Unrecognized (platform, metric) pair at column ' + i + ' row ' + j + ':' + row[j]);
107                     row[j] = [];
108                 }
109             }
110         }
111         return table;
112     },
113
114     updateGrid: function()
115     {
116         var headers = this.get('headerColumns').map(function (header) { return header.label; });
117         var table = [headers].concat(this.get('rows').map(function (row) {
118             return [row.get('header')].concat(row.get('cells').map(function (pane) {
119                 var platformAndMetric = [pane.get('platformId'), pane.get('metricId')];
120                 return platformAndMetric[0] || platformAndMetric[1] ? platformAndMetric : [];
121             }));
122         }));
123         this._previousGrid = JSON.stringify(table);
124         this.set('grid', this._previousGrid);
125     },
126
127     _sharedDomainChanged: function ()
128     {
129         var numberOfDays = this.get('numberOfDays');
130         if (!numberOfDays)
131             return;
132
133         numberOfDays = parseInt(numberOfDays);
134         var present = Date.now();
135         var past = present - numberOfDays * 24 * 3600 * 1000;
136         this.set('sharedDomain', [past, present]);
137     }.observes('numberOfDays').on('init'),
138
139     actions: {
140         setNumberOfDays: function (numberOfDays)
141         {
142             this.set('numberOfDays', numberOfDays);
143         },
144         choosePane: function (param)
145         {
146             var pane = param.position;
147             pane.set('platformId', param.platform.get('id'));
148             pane.set('metricId', param.metric.get('id'));
149         },
150         addColumn: function ()
151         {
152             this.get('headerColumns').pushObject({
153                 label: this.get('newColumnHeader'),
154                 index: this.get('headerColumns').length,
155             });
156             this.get('rows').forEach(function (row) {
157                 row.addPane();
158             });
159             this.set('newColumnHeader', null);
160         },
161         removeColumn: function (index)
162         {
163             this.get('headerColumns').removeAt(index);
164             this.get('rows').forEach(function (row) {
165                 row.get('cells').removeAt(index);
166             });
167         },
168         addRow: function ()
169         {
170             this.get('rows').pushObject(App.DashboardRow.create({
171                 header: this.get('newRowHeader'),
172                 columnCount: this.get('columnCount'),
173             }));
174             this.set('newRowHeader', null);
175         },
176         removeRow: function (row)
177         {
178             this.get('rows').removeObject(row);
179         },
180         resetPane: function (pane)
181         {
182             pane.set('platformId', null);
183             pane.set('metricId', null);
184         },
185         toggleEditMode: function ()
186         {
187             this.toggleProperty('editMode');
188             if (!this.get('editMode'))
189                 this.updateGrid();
190         },
191     },
192
193     init: function ()
194     {
195         this._super();
196         App.Manifest.fetch();
197     }
198 });
199
200 App.NumberOfDaysControlView = Ember.View.extend({
201     classNames: ['controls'],
202     templateName: 'number-of-days-controls',
203     didInsertElement: function ()
204     {
205         this._matchingElements(this._previousNumberOfDaysClass).addClass('active');
206     },
207     _numberOfDaysChanged: function ()
208     {
209         this._matchingElements(this._previousNumberOfDaysClass).removeClass('active');
210
211         var newNumberOfDaysClass = 'numberOfDaysIs' + this.get('numberOfDays');
212         this._matchingElements(this._previousNumberOfDaysClass).addClass('active');
213         this._previousNumberOfDaysClass = newNumberOfDaysClass;
214     }.observes('numberOfDays').on('init'),
215     _matchingElements: function (className)
216     {
217         var element = this.get('element');
218         if (!element)
219             return $([]);
220         return $(element.getElementsByClassName(className));
221     }
222 });
223
224 App.StartTimeSliderView = Ember.View.extend({
225     templateName: 'start-time-slider',
226     classNames: ['start-time-slider'],
227     startTime: Date.now() - 7 * 24 * 3600 * 1000,
228     oldestStartTime: null,
229     _numberOfDaysView: null,
230     _slider: null,
231     _startTimeInSlider: null,
232     _currentNumberOfDays: null,
233     _MILLISECONDS_PER_DAY: 24 * 3600 * 1000,
234
235     didInsertElement: function ()
236     {
237         this.oldestStartTime = Date.now() - 365 * 24 * 3600 * 1000;
238         this._slider = $(this.get('element')).find('input');
239         this._numberOfDaysView = $(this.get('element')).find('.numberOfDays');
240         this._sliderRangeChanged();
241         this._slider.change(this._sliderMoved.bind(this));
242     },
243     _sliderRangeChanged: function ()
244     {
245         var minimumNumberOfDays = 1;
246         var maximumNumberOfDays = this._timeInPastToNumberOfDays(this.get('oldestStartTime'));
247         var precision = 1000; // FIXME: Compute this from maximumNumberOfDays.
248         var slider = this._slider;
249         slider.attr('min', Math.floor(Math.log(Math.max(1, minimumNumberOfDays)) * precision) / precision);
250         slider.attr('max', Math.ceil(Math.log(maximumNumberOfDays) * precision) / precision);
251         slider.attr('step', 1 / precision);
252         this._startTimeChanged();
253     }.observes('oldestStartTime'),
254     _sliderMoved: function ()
255     {
256         this._currentNumberOfDays = Math.round(Math.exp(this._slider.val()));
257         this._numberOfDaysView.text(this._currentNumberOfDays);
258         this._startTimeInSlider = this._numberOfDaysToTimeInPast(this._currentNumberOfDays);
259         this.set('startTime', this._startTimeInSlider);
260     },
261     _startTimeChanged: function ()
262     {
263         var startTime = this.get('startTime');
264         if (startTime == this._startTimeSetBySlider)
265             return;
266         this._currentNumberOfDays = this._timeInPastToNumberOfDays(startTime);
267
268         if (this._slider) {
269             this._numberOfDaysView.text(this._currentNumberOfDays);
270             this._slider.val(Math.log(this._currentNumberOfDays));
271             this._startTimeInSlider = startTime;
272         }
273     }.observes('startTime').on('init'),
274     _timeInPastToNumberOfDays: function (timeInPast)
275     {
276         return Math.max(1, Math.round((Date.now() - timeInPast) / this._MILLISECONDS_PER_DAY));
277     },
278     _numberOfDaysToTimeInPast: function (numberOfDays)
279     {
280         return Date.now() - numberOfDays * this._MILLISECONDS_PER_DAY;
281     },
282 });
283
284 App.Pane = Ember.Object.extend({
285     platformId: null,
286     platform: null,
287     metricId: null,
288     metric: null,
289     selectedItem: null,
290     init: function ()
291     {
292         this._super();
293     },
294     _fetch: function () {
295         var platformId = this.get('platformId');
296         var metricId = this.get('metricId');
297         if (!platformId && !metricId) {
298             this.set('empty', true);
299             return;
300         }
301         this.set('empty', false);
302         this.set('platform', null);
303         this.set('chartData', null);
304         this.set('metric', null);
305         this.set('failure', null);
306
307         if (!this._isValidId(platformId))
308             this.set('failure', platformId ? 'Invalid platform id:' + platformId : 'Platform id was not specified');
309         else if (!this._isValidId(metricId))
310             this.set('failure', metricId ? 'Invalid metric id:' + metricId : 'Metric id was not specified');
311         else {
312             var self = this;
313
314             var metric;
315             var manifestPromise = App.Manifest.fetch(this.store).then(function () {
316                 return new Ember.RSVP.Promise(function (resolve, reject) {
317                     var platform = App.Manifest.platform(platformId);
318                     metric = App.Manifest.metric(metricId);
319                     if (!platform)
320                         reject('Could not find the platform "' + platformId + '"');
321                     else if (!metric)
322                         reject('Could not find the metric "' + metricId + '"');
323                     else {
324                         self.set('platform', platform);
325                         self.set('metric', metric);
326                         resolve(null);
327                     }
328                 });
329             });
330
331             Ember.RSVP.all([
332                 RunsData.fetchRuns(platformId, metricId),
333                 manifestPromise,
334             ]).then(function (values) {
335                 var runs = values[0];
336
337                 // FIXME: Include this information in JSON and process it in RunsData.fetchRuns
338                 var unit = {'Combined': '', // Assume smaller is better for now.
339                     'FrameRate': 'fps',
340                     'Runs': 'runs/s',
341                     'Time': 'ms',
342                     'Malloc': 'bytes',
343                     'JSHeap': 'bytes',
344                     'Allocations': 'bytes',
345                     'EndAllocations': 'bytes',
346                     'MaxAllocations': 'bytes',
347                     'MeanAllocations': 'bytes'}[metric.get('name')];
348                 runs.unit = unit;
349
350                 self.set('chartData', runs);
351             }, function (status) {
352                 self.set('failure', 'Failed to fetch the JSON with an error: ' + status);
353             });
354         }
355     }.observes('platformId', 'metricId').on('init'),
356     _isValidId: function (id)
357     {
358         if (typeof(id) == "number")
359             return true;
360         if (typeof(id) == "string")
361             return !!id.match(/^[A-Za-z0-9_]+$/);
362         return false;
363     }
364 });
365
366 App.encodePrettifiedJSON = function (plain)
367 {
368     function numberIfPossible(string) {
369         return string == parseInt(string) ? parseInt(string) : string;
370     }
371
372     function recursivelyConvertNumberIfPossible(input) {
373         if (input instanceof Array) {
374             return input.map(recursivelyConvertNumberIfPossible);
375         }
376         return numberIfPossible(input);
377     }
378
379     return JSON.stringify(recursivelyConvertNumberIfPossible(plain))
380         .replace(/\[/g, '(').replace(/\]/g, ')').replace(/\,/g, '-');
381 }
382
383 App.decodePrettifiedJSON = function (encoded)
384 {
385     var parsed = encoded.replace(/\(/g, '[').replace(/\)/g, ']').replace(/\-/g, ',');
386     try {
387         return JSON.parse(parsed);
388     } catch (exception) {
389         return null;
390     }
391 }
392
393 App.ChartsController = Ember.Controller.extend({
394     queryParams: ['paneList', 'zoom', 'since'],
395     needs: ['pane'],
396     _currentEncodedPaneList: null,
397     panes: [],
398     platforms: null,
399     sharedZoom: null,
400     startTime: null,
401     defaultSince: Date.now() - 7 * 24 * 3600 * 1000,
402
403     addPane: function (pane)
404     {
405         this.panes.unshiftObject(pane);
406     },
407
408     removePane: function (pane)
409     {
410         this.panes.removeObject(pane);
411     },
412
413     refreshPanes: function()
414     {
415         var paneList = this.get('paneList');
416         if (paneList === this._currentEncodedPaneList)
417             return;
418
419         var panes = this._parsePaneList(paneList || '[]');
420         if (!panes) {
421             console.log('Failed to parse', jsonPaneList, exception);
422             return;
423         }
424         this.set('panes', panes);
425         this._currentEncodedPaneList = paneList;
426     }.observes('paneList').on('init'),
427
428     refreshZoom: function()
429     {
430         var zoom = this.get('zoom');
431         if (!zoom) {
432             this.set('sharedZoom', null);
433             return;
434         }
435
436         zoom = zoom.split('-');
437         var selection = new Array(2);
438         try {
439             selection[0] = new Date(parseFloat(zoom[0]));
440             selection[1] = new Date(parseFloat(zoom[1]));
441         } catch (error) {
442             console.log('Failed to parse the zoom', zoom);
443         }
444         this.set('sharedZoom', selection);
445
446         var startTime = this.get('startTime');
447         if (startTime && startTime > selection[0])
448             this.set('startTime', selection[0]);
449
450     }.observes('zoom').on('init'),
451
452     updateSharedDomain: function ()
453     {
454         var panes = this.get('panes');
455         if (!panes.length)
456             return;
457
458         var union = [undefined, undefined];
459         for (var i = 0; i < panes.length; i++) {
460             var domain = panes[i].intrinsicDomain;
461             if (!domain)
462                 continue;
463             if (!union[0] || domain[0] < union[0])
464                 union[0] = domain[0];
465             if (!union[1] || domain[1] > union[1])
466                 union[1] = domain[1];
467         }
468         if (union[0] === undefined)
469             return;
470
471         var startTime = this.get('startTime');
472         var zoom = this.get('sharedZoom');
473         if (startTime)
474             union[0] = zoom ? Math.min(zoom[0], startTime) : startTime;
475
476         this.set('sharedDomain', union);
477     }.observes('panes.@each'),
478
479     _startTimeChanged: function () {
480         this.updateSharedDomain();
481         this._scheduleQueryStringUpdate();
482     }.observes('startTime'),
483
484     _sinceChanged: function () {
485         var since = parseInt(this.get('since'));
486         if (isNaN(since))
487             since = this.defaultSince;
488         this.set('startTime', new Date(since));
489     }.observes('since').on('init'),
490
491     _parsePaneList: function (encodedPaneList)
492     {
493         var parsedPaneList = App.decodePrettifiedJSON(encodedPaneList);
494         if (!parsedPaneList)
495             return null;
496
497         // Don't re-create all panes.
498         var self = this;
499         return parsedPaneList.map(function (paneInfo) {
500             var timeRange = null;
501             if (paneInfo[3] && paneInfo[3] instanceof Array) {
502                 var timeRange = paneInfo[3];
503                 try {
504                     timeRange = [new Date(timeRange[0]), new Date(timeRange[1])];
505                 } catch (error) {
506                     console.log("Failed to parse the time range:", timeRange, error);
507                 }
508             }
509             return App.Pane.create({
510                 info: paneInfo,
511                 platformId: paneInfo[0],
512                 metricId: paneInfo[1],
513                 selectedItem: paneInfo[2],
514                 timeRange: timeRange,
515                 timeRangeIsLocked: !!paneInfo[4],
516             });
517         });
518     },
519
520     _serializePaneList: function (panes)
521     {
522         if (!panes.length)
523             return undefined;
524         return App.encodePrettifiedJSON(panes.map(function (pane) {
525             return [
526                 pane.get('platformId'),
527                 pane.get('metricId'),
528                 pane.get('selectedItem'),
529                 pane.get('timeRange') ? pane.get('timeRange').map(function (date) { return date.getTime() }) : null,
530                 !!pane.get('timeRangeIsLocked'),
531             ];
532         }));
533     },
534
535     _scheduleQueryStringUpdate: function ()
536     {
537         Ember.run.debounce(this, '_updateQueryString', 500);
538     }.observes('sharedZoom')
539         .observes('panes.@each.platform', 'panes.@each.metric', 'panes.@each.selectedItem',
540         'panes.@each.timeRange', 'panes.@each.timeRangeIsLocked'),
541
542     _updateQueryString: function ()
543     {
544         this._currentEncodedPaneList = this._serializePaneList(this.get('panes'));
545         this.set('paneList', this._currentEncodedPaneList);
546
547         var zoom = undefined;
548         var selection = this.get('sharedZoom');
549         if (selection)
550             zoom = (selection[0] - 0) + '-' + (selection[1] - 0);
551         this.set('zoom', zoom);
552
553         if (this.get('startTime') - this.defaultSince)
554             this.set('since', this.get('startTime') - 0);
555     },
556
557     actions: {
558         addPaneByMetricAndPlatform: function (param)
559         {
560             this.addPane(App.Pane.create({
561                 platformId: param.platform.get('id'),
562                 metricId: param.metric.get('id'),
563                 showingDetails: false
564             }));
565         },
566     },
567
568     init: function ()
569     {
570         this._super();
571         var self = this;
572         App.BuildPopup('addPaneByMetricAndPlatform').then(function (platforms) {
573             self.set('platforms', platforms);
574         });
575     },
576 });
577
578 App.BuildPopup = function(action, position)
579 {
580     return App.Manifest.fetch().then(function () {
581         return App.Manifest.get('platforms').map(function (platform) {
582             return App.PlatformProxyForPopup.create({content: platform,
583                 action: action, position: position});
584         });
585     });
586 }
587
588 App.PlatformProxyForPopup = Ember.ObjectProxy.extend({
589     children: function ()
590     {
591         var platform = this.content;
592         var containsTest = this.content.containsTest.bind(this.content);
593         var action = this.get('action');
594         var position = this.get('position');
595         return App.Manifest.get('topLevelTests')
596             .filter(containsTest)
597             .map(function (test) {
598                 return App.TestProxyForPopup.create({content: test, platform: platform, action: action, position: position});
599             });
600     }.property('App.Manifest.topLevelTests'),
601 });
602
603 App.TestProxyForPopup = Ember.ObjectProxy.extend({
604     platform: null,
605     children: function ()
606     {
607         var platform = this.get('platform');
608         var action = this.get('action');
609         var position = this.get('position');
610
611         var childTests = this.get('childTests')
612             .filter(function (test) { return platform.containsTest(test); })
613             .map(function (test) {
614                 return App.TestProxyForPopup.create({content: test, platform: platform, action: action, position: position});
615             });
616
617         var metrics = this.get('metrics')
618             .filter(function (metric) { return platform.containsMetric(metric); })
619             .map(function (metric) {
620                 var aggregator = metric.get('aggregator');
621                 return {
622                     actionName: action,
623                     actionArgument: {platform: platform, metric: metric, position:position},
624                     label: metric.get('label')
625                 };
626             });
627
628         if (childTests.length && metrics.length)
629             metrics.push({isSeparator: true});
630
631         return metrics.concat(childTests);
632     }.property('childTests', 'metrics'),
633 });
634
635 App.PaneController = Ember.ObjectController.extend({
636     needs: ['charts'],
637     sharedTime: Ember.computed.alias('parentController.sharedTime'),
638     sharedSelection: Ember.computed.alias('parentController.sharedSelection'),
639     selection: null,
640     actions: {
641         toggleDetails: function()
642         {
643             this.toggleProperty('showingDetails');
644         },
645         close: function ()
646         {
647             this.parentController.removePane(this.get('model'));
648         },
649         zoomed: function (selection)
650         {
651             this.set('mainPlotDomain', selection ? selection : this.get('overviewDomain'));
652             Ember.run.debounce(this, 'propagateZoom', 100);
653         },
654         overviewDomainChanged: function (domain, intrinsicDomain)
655         {
656             this.set('overviewDomain', domain);
657             this.set('intrinsicDomain', intrinsicDomain);
658             this.get('parentController').updateSharedDomain();
659         },
660         rangeChanged: function (extent, startPoint, endPoint)
661         {
662             if (!startPoint || !endPoint) {
663                 this._hasRange = false;
664                 this.set('details', null);
665                 this.set('timeRange', null);
666                 return;
667             }
668             this._hasRange = true;
669             this._showDetails(startPoint.measurement, endPoint.measurement, false);
670             this.set('timeRange', extent);
671         },
672     },
673     _overviewSelectionChanged: function ()
674     {
675         var overviewSelection = this.get('overviewSelection');
676         this.set('mainPlotDomain', overviewSelection);
677         Ember.run.debounce(this, 'propagateZoom', 100);
678     }.observes('overviewSelection'),
679     _sharedDomainChanged: function ()
680     {
681         var newDomain = this.get('parentController').get('sharedDomain');
682         if (newDomain == this.get('overviewDomain'))
683             return;
684         this.set('overviewDomain', newDomain);
685         if (!this.get('overviewSelection'))
686             this.set('mainPlotDomain', newDomain);
687     }.observes('parentController.sharedDomain').on('init'),
688     propagateZoom: function ()
689     {
690         this.get('parentController').set('sharedZoom', this.get('mainPlotDomain'));
691     },
692     _sharedZoomChanged: function ()
693     {
694         var newSelection = this.get('parentController').get('sharedZoom');
695         if (newSelection == this.get('mainPlotDomain'))
696             return;
697         this.set('overviewSelection', newSelection);
698         this.set('mainPlotDomain', newSelection);
699     }.observes('parentController.sharedZoom').on('init'),
700     _currentItemChanged: function ()
701     {
702         if (this._hasRange)
703             return;
704         var point = this.get('currentItem');
705         if (!point || !point.measurement)
706             this.set('details', null);
707         else
708             this._showDetails(point.series.previousPoint(point).measurement, point.measurement, true);
709     }.observes('currentItem'),
710     _showDetails: function (oldMeasurement, currentMeasurement, isShowingEndPoint)
711     {
712         var revisions = [];
713
714         var formattedRevisions = currentMeasurement.formattedRevisions(oldMeasurement);
715         var repositoryNames = [];
716         for (var repositoryName in formattedRevisions)
717             repositoryNames.push(repositoryName);
718         var revisions = [];
719         repositoryNames.sort().forEach(function (repositoryName) {
720             var revision = formattedRevisions[repositoryName];
721             var repository = App.Manifest.repository(repositoryName);
722             revision['url'] = false;
723             if (repository) {
724                 revision['url'] = revision.previousRevision
725                     ? repository.urlForRevisionRange(revision.previousRevision, revision.currentRevision)
726                     : repository.urlForRevision(revision.currentRevision);
727             }
728             revision['name'] = repositoryName;
729             revision['repository'] = repository;
730             revisions.push(Ember.Object.create(revision));            
731         });
732
733         var buildNumber = null;
734         var buildURL = null;
735         if (isShowingEndPoint) {
736             buildNumber = currentMeasurement.buildNumber();
737             var builder = App.Manifest.builder(currentMeasurement.builderId());
738             if (builder)
739                 buildURL = builder.urlFromBuildNumber(buildNumber);
740         }
741         this.set('details', {
742             currentValue: currentMeasurement.mean().toFixed(2),
743             oldValue: oldMeasurement && !isShowingEndPoint ? oldMeasurement.mean().toFixed(2) : null,
744             buildNumber: buildNumber,
745             buildURL: buildURL,
746             buildTime: currentMeasurement.formattedBuildTime(),
747             revisions: revisions,
748         });
749     }
750 });
751
752 App.InteractiveChartComponent = Ember.Component.extend({
753     chartData: null,
754     showXAxis: true,
755     showYAxis: true,
756     interactive: false,
757     enableSelection: true,
758     classNames: ['chart'],
759     init: function ()
760     {
761         this._super();
762         this._needsConstruction = true;
763         this._eventHandlers = [];
764         $(window).resize(this._updateDimensionsIfNeeded.bind(this));
765     },
766     chartDataDidChange: function ()
767     {
768         var chartData = this.get('chartData');
769         if (!chartData)
770             return;
771         this._needsConstruction = true;
772         this._constructGraphIfPossible(chartData);
773     }.observes('chartData').on('init'),
774     didInsertElement: function ()
775     {
776         var chartData = this.get('chartData');
777         if (chartData)
778             this._constructGraphIfPossible(chartData);
779     },
780     willClearRender: function ()
781     {
782         this._eventHandlers.forEach(function (item) {
783             $(item[0]).off(item[1], item[2]);
784         })
785     },
786     _attachEventListener: function(target, eventName, listener)
787     {
788         this._eventHandlers.push([target, eventName, listener]);
789         $(target).on(eventName, listener);
790     },
791     _constructGraphIfPossible: function (chartData)
792     {
793         if (!this._needsConstruction || !this.get('element'))
794             return;
795
796         var element = this.get('element');
797
798         this._x = d3.time.scale();
799         this._y = d3.scale.linear();
800
801         // FIXME: Tear down the old SVG element.
802         this._svgElement = d3.select(element).append("svg")
803                 .attr("width", "100%")
804                 .attr("height", "100%");
805
806         var svg = this._svg = this._svgElement.append("g");
807
808         var clipId = element.id + "-clip";
809         this._clipPath = svg.append("defs").append("clipPath")
810             .attr("id", clipId)
811             .append("rect");
812
813         if (this.get('showXAxis')) {
814             this._xAxis = d3.svg.axis().scale(this._x).orient("bottom").ticks(6);
815             this._xAxisLabels = svg.append("g")
816                 .attr("class", "x axis");
817         }
818
819         if (this.get('showYAxis')) {
820             this._yAxis = d3.svg.axis().scale(this._y).orient("left").ticks(6).tickFormat(d3.format("s"));
821             this._yAxisLabels = svg.append("g")
822                 .attr("class", "y axis");
823         }
824
825         this._clippedContainer = svg.append("g")
826             .attr("clip-path", "url(#" + clipId + ")");
827
828         var xScale = this._x;
829         var yScale = this._y;
830         this._timeLine = d3.svg.line()
831             .x(function(point) { return xScale(point.time); })
832             .y(function(point) { return yScale(point.value); });
833
834         this._confidenceArea = d3.svg.area()
835 //            .interpolate("cardinal")
836             .x(function(point) { return xScale(point.time); })
837             .y0(function(point) { return point.interval ? yScale(point.interval[0]) : null; })
838             .y1(function(point) { return point.interval ? yScale(point.interval[1]) : null; });
839
840         if (this._paths)
841             this._paths.forEach(function (path) { path.remove(); });
842         this._paths = [];
843         if (this._areas)
844             this._areas.forEach(function (area) { area.remove(); });
845         this._areas = [];
846         this._dots = [];
847
848         this._currentTimeSeries = chartData.current.timeSeriesByCommitTime();
849         this._currentTimeSeriesData = this._currentTimeSeries.series();
850         this._baselineTimeSeries = chartData.baseline ? chartData.baseline.timeSeriesByCommitTime() : null;
851         this._targetTimeSeries = chartData.target ? chartData.target.timeSeriesByCommitTime() : null;
852
853         this._yAxisUnit = chartData.unit;
854
855         var minMax = this._minMaxForAllTimeSeries();
856         var smallEnoughValue = minMax[0] - (minMax[1] - minMax[0]) * 10;
857         var largeEnoughValue = minMax[1] + (minMax[1] - minMax[0]) * 10;
858
859         // FIXME: Flip the sides based on smallerIsBetter-ness.
860         if (this._baselineTimeSeries) {
861             var data = this._baselineTimeSeries.series();
862             this._areas.push(this._clippedContainer
863                 .append("path")
864                 .datum(data.map(function (point) { return {time: point.time, value: point.value, interval: point.interval ? point.interval : [point.value, largeEnoughValue]}; }))
865                 .attr("class", "area baseline"));
866         }
867         if (this._targetTimeSeries) {
868             var data = this._targetTimeSeries.series();
869             this._areas.push(this._clippedContainer
870                 .append("path")
871                 .datum(data.map(function (point) { return {time: point.time, value: point.value, interval: point.interval ? point.interval : [smallEnoughValue, point.value]}; }))
872                 .attr("class", "area target"));
873         }
874
875         this._areas.push(this._clippedContainer
876             .append("path")
877             .datum(this._currentTimeSeriesData)
878             .attr("class", "area"));
879
880         this._paths.push(this._clippedContainer
881             .append("path")
882             .datum(this._currentTimeSeriesData)
883             .attr("class", "commit-time-line"));
884
885         this._dots.push(this._clippedContainer
886             .selectAll(".dot")
887                 .data(this._currentTimeSeriesData)
888             .enter().append("circle")
889                 .attr("class", "dot")
890                 .attr("r", this.get('interactive') ? 2 : 1));
891
892         if (this.get('interactive')) {
893             this._attachEventListener(element, "mousemove", this._mouseMoved.bind(this));
894             this._attachEventListener(element, "mouseleave", this._mouseLeft.bind(this));
895             this._attachEventListener(element, "mousedown", this._mouseDown.bind(this));
896             this._attachEventListener($(element).parents("[tabindex]"), "keydown", this._keyPressed.bind(this));
897
898             this._currentItemLine = this._clippedContainer
899                 .append("line")
900                 .attr("class", "current-item");
901
902             this._currentItemCircle = this._clippedContainer
903                 .append("circle")
904                 .attr("class", "dot current-item")
905                 .attr("r", 3);
906         }
907
908         this._brush = null;
909         if (this.get('enableSelection')) {
910             this._brush = d3.svg.brush()
911                 .x(this._x)
912                 .on("brush", this._brushChanged.bind(this));
913
914             this._brushRect = this._clippedContainer
915                 .append("g")
916                 .attr("class", "x brush");
917         }
918
919         this._updateDomain();
920         this._updateDimensionsIfNeeded();
921
922         // Work around the fact the brush isn't set if we updated it synchronously here.
923         setTimeout(this._selectionChanged.bind(this), 0);
924
925         setTimeout(this._selectedItemChanged.bind(this), 0);
926
927         this._needsConstruction = false;
928     },
929     _updateDomain: function ()
930     {
931         var xDomain = this.get('domain');
932         var intrinsicXDomain = this._computeXAxisDomain(this._currentTimeSeriesData);
933         if (!xDomain)
934             xDomain = intrinsicXDomain;
935         var currentDomain = this._x.domain();
936         if (currentDomain && this._xDomainsAreSame(currentDomain, xDomain))
937             return currentDomain;
938
939         var yDomain = this._computeYAxisDomain(xDomain[0], xDomain[1]);
940         this._x.domain(xDomain);
941         this._y.domain(yDomain);
942         this.sendAction('domainChanged', xDomain, intrinsicXDomain);
943         return xDomain;
944     },
945     _updateDimensionsIfNeeded: function (newSelection)
946     {
947         var element = $(this.get('element'));
948
949         var newTotalWidth = element.width();
950         var newTotalHeight = element.height();
951         if (this._totalWidth == newTotalWidth && this._totalHeight == newTotalHeight)
952             return;
953
954         this._totalWidth = newTotalWidth;
955         this._totalHeight = newTotalHeight;
956
957         if (!this._rem)
958             this._rem = parseFloat(getComputedStyle(document.documentElement).fontSize);
959         var rem = this._rem;
960
961         var padding = 0.5 * rem;
962         var margin = {top: padding, right: padding, bottom: padding, left: padding};
963         if (this._xAxis)
964             margin.bottom += rem;
965         if (this._yAxis)
966             margin.left += 3 * rem;
967
968         this._margin = margin;
969         this._contentWidth = this._totalWidth - margin.left - margin.right;
970         this._contentHeight = this._totalHeight - margin.top - margin.bottom;
971
972         this._svg.attr("transform", "translate(" + margin.left + "," + margin.top + ")");
973
974         this._clipPath
975             .attr("width", this._contentWidth)
976             .attr("height", this._contentHeight);
977
978         this._x.range([0, this._contentWidth]);
979         this._y.range([this._contentHeight, 0]);
980
981         if (this._xAxis) {
982             this._xAxis.tickSize(-this._contentHeight);
983             this._xAxisLabels.attr("transform", "translate(0," + this._contentHeight + ")");
984         }
985
986         if (this._yAxis)
987             this._yAxis.tickSize(-this._contentWidth);
988
989         if (this._currentItemLine) {
990             this._currentItemLine
991                 .attr("y1", 0)
992                 .attr("y2", margin.top + this._contentHeight);
993         }
994
995         this._relayoutDataAndAxes(this._currentSelection());
996     },
997     _updateBrush: function ()
998     {
999         this._brushRect
1000             .call(this._brush)
1001         .selectAll("rect")
1002             .attr("y", 1)
1003             .attr("height", this._contentHeight - 2);
1004         this._updateSelectionToolbar();
1005     },
1006     _relayoutDataAndAxes: function (selection)
1007     {
1008         var timeline = this._timeLine;
1009         this._paths.forEach(function (path) { path.attr("d", timeline); });
1010
1011         var confidenceArea = this._confidenceArea;
1012         this._areas.forEach(function (path) { path.attr("d", confidenceArea); });
1013
1014         var xScale = this._x;
1015         var yScale = this._y;
1016         this._dots.forEach(function (dot) {
1017             dot
1018                 .attr("cx", function(measurement) { return xScale(measurement.time); })
1019                 .attr("cy", function(measurement) { return yScale(measurement.value); });
1020         });
1021
1022         if (this._brush) {
1023             if (selection)
1024                 this._brush.extent(selection);
1025             else
1026                 this._brush.clear();
1027             this._updateBrush();
1028         }
1029
1030         this._updateCurrentItemIndicators();
1031
1032         if (this._xAxis)
1033             this._xAxisLabels.call(this._xAxis);
1034         if (!this._yAxis)
1035             return;
1036
1037         this._yAxisLabels.call(this._yAxis);
1038         if (this._yAxisUnitContainer)
1039             this._yAxisUnitContainer.remove();
1040         this._yAxisUnitContainer = this._yAxisLabels.append("text")
1041             .attr("x", 0.5 * this._rem)
1042             .attr("y", this._rem)
1043             .attr("dy", '0.8rem')
1044             .style("text-anchor", "start")
1045             .style("z-index", "100")
1046             .text(this._yAxisUnit);
1047     },
1048     _computeXAxisDomain: function (timeSeries)
1049     {
1050         var extent = d3.extent(timeSeries, function(point) { return point.time; });
1051         var margin = 3600 * 1000; // Use x.inverse to compute the right amount from a margin.
1052         return [+extent[0] - margin, +extent[1] + margin];
1053     },
1054     _computeYAxisDomain: function (startTime, endTime)
1055     {
1056         var range = this._minMaxForAllTimeSeries(startTime, endTime);
1057         var min = range[0];
1058         var max = range[1];
1059         if (max < min)
1060             min = max = 0;
1061         var diff = max - min;
1062         var margin = diff * 0.05;
1063
1064         yExtent = [min - margin, max + margin];
1065 //        if (yMin !== undefined)
1066 //            yExtent[0] = parseInt(yMin);
1067         return yExtent;
1068     },
1069     _minMaxForAllTimeSeries: function (startTime, endTime)
1070     {
1071         var currentRange = this._currentTimeSeries.minMaxForTimeRange(startTime, endTime);
1072         var baselineRange = this._baselineTimeSeries ? this._baselineTimeSeries.minMaxForTimeRange(startTime, endTime) : [Number.MAX_VALUE, Number.MIN_VALUE];
1073         var targetRange = this._targetTimeSeries ? this._targetTimeSeries.minMaxForTimeRange(startTime, endTime) : [Number.MAX_VALUE, Number.MIN_VALUE];
1074         return [
1075             Math.min(currentRange[0], baselineRange[0], targetRange[0]),
1076             Math.max(currentRange[1], baselineRange[1], targetRange[1]),
1077         ];
1078     },
1079     _currentSelection: function ()
1080     {
1081         return this._brush && !this._brush.empty() ? this._brush.extent() : null;
1082     },
1083     _domainChanged: function ()
1084     {
1085         var selection = this._currentSelection() || this.get('sharedSelection');
1086         var newXDomain = this._updateDomain();
1087
1088         if (selection && newXDomain && selection[0] <= newXDomain[0] && newXDomain[1] <= selection[1])
1089             selection = null; // Otherwise the user has no way of clearing the selection.
1090
1091         this._relayoutDataAndAxes(selection);
1092     }.observes('domain'),
1093     _selectionChanged: function ()
1094     {
1095         this._updateSelection(this.get('selection'));
1096     }.observes('selection'),
1097     _sharedSelectionChanged: function ()
1098     {
1099         if (this.get('selectionIsLocked'))
1100             return;
1101         this._updateSelection(this.get('sharedSelection'));
1102     }.observes('sharedSelection'),
1103     _updateSelection: function (newSelection)
1104     {
1105         if (!this._brush)
1106             return;
1107
1108         var currentSelection = this._currentSelection();
1109         if (newSelection && currentSelection && this._xDomainsAreSame(newSelection, currentSelection))
1110             return;
1111
1112         var domain = this._x.domain();
1113         if (!newSelection || this._xDomainsAreSame(domain, newSelection))
1114             this._brush.clear();
1115         else
1116             this._brush.extent(newSelection);
1117         this._updateBrush();
1118
1119         this._setCurrentSelection(newSelection);
1120     },
1121     _xDomainsAreSame: function (domain1, domain2)
1122     {
1123         return !(domain1[0] - domain2[0]) && !(domain1[1] - domain2[1]);
1124     },
1125     _brushChanged: function ()
1126     {
1127         if (this._brush.empty()) {
1128             if (!this._brushExtent)
1129                 return;
1130
1131             this.set('selectionIsLocked', false);
1132             this._setCurrentSelection(undefined);
1133
1134             // Avoid locking the indicator in _mouseDown when the brush was cleared in the same mousedown event.
1135             this._brushJustChanged = true;
1136             var self = this;
1137             setTimeout(function () {
1138                 self._brushJustChanged = false;
1139             }, 0);
1140
1141             return;
1142         }
1143
1144         this.set('selectionIsLocked', true);
1145         this._setCurrentSelection(this._brush.extent());
1146     },
1147     _keyPressed: function (event)
1148     {
1149         if (!this._currentItemIndex || this._currentSelection())
1150             return;
1151
1152         var newIndex;
1153         switch (event.keyCode) {
1154         case 37: // Left
1155             newIndex = this._currentItemIndex - 1;
1156             break;
1157         case 39: // Right
1158             newIndex = this._currentItemIndex + 1;
1159             break;
1160         case 38: // Up
1161         case 40: // Down
1162         default:
1163             return;
1164         }
1165
1166         // Unlike mousemove, keydown shouldn't move off the edge.
1167         if (this._currentTimeSeriesData[newIndex])
1168             this._setCurrentItem(newIndex);
1169     },
1170     _mouseMoved: function (event)
1171     {
1172         if (!this._margin || this._currentSelection() || this._currentItemLocked)
1173             return;
1174
1175         var point = this._mousePointInGraph(event);
1176
1177         this._selectClosestPointToMouseAsCurrentItem(point);
1178     },
1179     _mouseLeft: function (event)
1180     {
1181         if (!this._margin || this._currentItemLocked)
1182             return;
1183
1184         this._selectClosestPointToMouseAsCurrentItem(null);
1185     },
1186     _mouseDown: function (event)
1187     {
1188         if (!this._margin || this._currentSelection() || this._brushJustChanged)
1189             return;
1190
1191         var point = this._mousePointInGraph(event);
1192         if (!point)
1193             return;
1194
1195         if (this._currentItemLocked) {
1196             this._currentItemLocked = false;
1197             this.set('selectedItem', null);
1198             return;
1199         }
1200
1201         this._currentItemLocked = true;
1202         this._selectClosestPointToMouseAsCurrentItem(point);
1203     },
1204     _mousePointInGraph: function (event)
1205     {
1206         var offset = $(this.get('element')).offset();
1207         if (!offset)
1208             return null;
1209
1210         var point = {
1211             x: event.pageX - offset.left - this._margin.left,
1212             y: event.pageY - offset.top - this._margin.top
1213         };
1214
1215         var xScale = this._x;
1216         var yScale = this._y;
1217         var xDomain = xScale.domain();
1218         var yDomain = yScale.domain();
1219         if (point.x >= xScale(xDomain[0]) && point.x <= xScale(xDomain[1])
1220             && point.y <= yScale(yDomain[0]) && point.y >= yScale(yDomain[1]))
1221             return point;
1222
1223         return null;
1224     },
1225     _selectClosestPointToMouseAsCurrentItem: function (point)
1226     {
1227         var xScale = this._x;
1228         var yScale = this._y;
1229         var distanceHeuristics = function (m) {
1230             var mX = xScale(m.time);
1231             var mY = yScale(m.value);
1232             var xDiff = mX - point.x;
1233             var yDiff = mY - point.y;
1234             return xDiff * xDiff + yDiff * yDiff / 16; // Favor horizontal affinity.
1235         };
1236         distanceHeuristics = function (m) {
1237             return Math.abs(xScale(m.time) - point.x);
1238         }
1239
1240         var newItemIndex;
1241         if (point && !this._currentSelection()) {
1242             var distances = this._currentTimeSeriesData.map(distanceHeuristics);
1243             var minDistance = Number.MAX_VALUE;
1244             for (var i = 0; i < distances.length; i++) {
1245                 if (distances[i] < minDistance) {
1246                     newItemIndex = i;
1247                     minDistance = distances[i];
1248                 }
1249             }
1250         }
1251
1252         this._setCurrentItem(newItemIndex);
1253         this._updateSelectionToolbar();
1254     },
1255     _currentTimeChanged: function ()
1256     {
1257         if (!this._margin || this._currentSelection() || this._currentItemLocked)
1258             return
1259
1260         var currentTime = this.get('currentTime');
1261         if (currentTime) {
1262             for (var i = 0; i < this._currentTimeSeriesData.length; i++) {
1263                 var point = this._currentTimeSeriesData[i];
1264                 if (point.time >= currentTime) {
1265                     this._setCurrentItem(i, /* doNotNotify */ true);
1266                     return;
1267                 }
1268             }
1269         }
1270         this._setCurrentItem(undefined, /* doNotNotify */ true);
1271     }.observes('currentTime'),
1272     _setCurrentItem: function (newItemIndex, doNotNotify)
1273     {
1274         if (newItemIndex === this._currentItemIndex) {
1275             if (this._currentItemLocked)
1276                 this.set('selectedItem', this.get('currentItem') ? this.get('currentItem').measurement.id() : null);
1277             return;
1278         }
1279
1280         var newItem = this._currentTimeSeriesData[newItemIndex];
1281         this._brushExtent = undefined;
1282         this._currentItemIndex = newItemIndex;
1283
1284         if (!newItem) {
1285             this._currentItemLocked = false;
1286             this.set('selectedItem', null);
1287         }
1288
1289         this._updateCurrentItemIndicators();
1290
1291         if (!doNotNotify)
1292             this.set('currentTime', newItem ? newItem.time : undefined);
1293
1294         this.set('currentItem', newItem);
1295         if (this._currentItemLocked)
1296             this.set('selectedItem', newItem ? newItem.measurement.id() : null);
1297     },
1298     _selectedItemChanged: function ()
1299     {
1300         if (!this._margin)
1301             return;
1302
1303         var selectedId = this.get('selectedItem');
1304         var currentItem = this.get('currentItem');
1305         if (currentItem && currentItem.measurement.id() == selectedId)
1306             return;
1307
1308         var series = this._currentTimeSeriesData;
1309         var selectedItemIndex = undefined;
1310         for (var i = 0; i < series.length; i++) {
1311             if (series[i].measurement.id() == selectedId) {
1312                 this._updateSelection(null);
1313                 this._currentItemLocked = true;
1314                 this._setCurrentItem(i);
1315                 this._updateSelectionToolbar();
1316                 return;
1317             }
1318         }
1319     }.observes('selectedItem').on('init'),
1320     _updateCurrentItemIndicators: function ()
1321     {
1322         if (!this._currentItemLine)
1323             return;
1324
1325         var item = this._currentTimeSeriesData[this._currentItemIndex];
1326         if (!item) {
1327             this._currentItemLine.attr("x1", -1000).attr("x2", -1000);
1328             this._currentItemCircle.attr("cx", -1000);
1329             return;
1330         }
1331
1332         var x = this._x(item.time);
1333         var y = this._y(item.value);
1334
1335         this._currentItemLine
1336             .attr("x1", x)
1337             .attr("x2", x);
1338
1339         this._currentItemCircle
1340             .attr("cx", x)
1341             .attr("cy", y);
1342     },
1343     _setCurrentSelection: function (newSelection)
1344     {
1345         if (this._brushExtent === newSelection)
1346             return;
1347
1348         if (newSelection) {
1349             var startPoint;
1350             var endPoint;
1351             for (var i = 0; i < this._currentTimeSeriesData.length; i++) {
1352                 var point = this._currentTimeSeriesData[i];
1353                 if (!startPoint) {
1354                     if (point.time >= newSelection[0]) {
1355                         if (point.time > newSelection[1])
1356                             break;
1357                         startPoint = point;
1358                     }
1359                 } else if (point.time > newSelection[1])
1360                     break;
1361                 if (point.time >= newSelection[0] && point.time <= newSelection[1])
1362                     endPoint = point;
1363             }
1364         }
1365
1366         this._brushExtent = newSelection;
1367         this._setCurrentItem(undefined);
1368         this._updateSelectionToolbar();
1369
1370         this.set('sharedSelection', newSelection);
1371         this.sendAction('selectionChanged', newSelection, startPoint, endPoint);
1372     },
1373     _updateSelectionToolbar: function ()
1374     {
1375         if (!this.get('interactive'))
1376             return;
1377
1378         var selection = this._currentSelection();
1379         var selectionToolbar = $(this.get('element')).children('.selection-toolbar');
1380         if (selection) {
1381             var left = this._x(selection[0]);
1382             var right = this._x(selection[1]);
1383             selectionToolbar
1384                 .css({left: this._margin.left + right, top: this._margin.top + this._contentHeight})
1385                 .show();
1386         } else
1387             selectionToolbar.hide();
1388     },
1389     actions: {
1390         zoom: function ()
1391         {
1392             this.sendAction('zoom', this._currentSelection());
1393             this.set('selection', null);
1394         },
1395     },
1396 });
1397
1398
1399
1400 App.CommitsViewerComponent = Ember.Component.extend({
1401     repository: null,
1402     revisionInfo: null,
1403     commits: null,
1404     commitsChanged: function ()
1405     {
1406         var revisionInfo = this.get('revisionInfo');
1407
1408         var to = revisionInfo.get('currentRevision');
1409         var from = revisionInfo.get('previousRevision') || to;
1410         var repository = this.get('repository');
1411         if (!from || !repository)
1412             return;
1413
1414         var self = this;
1415         FetchCommitsForTimeRange(repository, from, to).then(function (commits) {
1416             self.set('commits', commits.map(function (commit) {
1417                 return Ember.Object.create({
1418                     repository: repository,
1419                     revision: commit.revision,
1420                     url: repository.urlForRevision(commit.revision),
1421                     author: commit.author.name || commit.author.email,
1422                     message: commit.message ? commit.message.substr(0, 75) : null,
1423                 });
1424             }));
1425         }, function () {
1426             self.set('commits', []);
1427         })
1428     }.observes('repository').observes('revisionInfo').on('init'),
1429 });