Upstream version 9.38.198.0
[platform/framework/web/crosswalk.git] / src / third_party / skia / gm / rebaseline_server / static / loader.js
1 /*
2  * Loader:
3  * Reads GM result reports written out by results.py, and imports
4  * them into $scope.extraColumnHeaders and $scope.imagePairs .
5  */
6 var Loader = angular.module(
7     'Loader',
8     ['ConstantsModule']
9 );
10
11 Loader.directive(
12   'resultsUpdatedCallbackDirective',
13   ['$timeout',
14    function($timeout) {
15      return function(scope, element, attrs) {
16        if (scope.$last) {
17          $timeout(function() {
18            scope.resultsUpdatedCallback();
19          });
20        }
21      };
22    }
23   ]
24 );
25
26 // TODO(epoger): Combine ALL of our filtering operations (including
27 // truncation) into this one filter, so that runs most efficiently?
28 // (We would have to make sure truncation still took place after
29 // sorting, though.)
30 Loader.filter(
31   'removeHiddenImagePairs',
32   function(constants) {
33     return function(unfilteredImagePairs, filterableColumnNames, showingColumnValues,
34                     viewingTab) {
35       var filteredImagePairs = [];
36       for (var i = 0; i < unfilteredImagePairs.length; i++) {
37         var imagePair = unfilteredImagePairs[i];
38         var extraColumnValues = imagePair[constants.KEY__IMAGEPAIRS__EXTRACOLUMNS];
39         var allColumnValuesAreVisible = true;
40         // Loop over all columns, and if any of them contain values not found in
41         // showingColumnValues[columnName], don't include this imagePair.
42         //
43         // We use this same filtering mechanism regardless of whether each column
44         // has USE_FREEFORM_FILTER set or not; if that flag is set, then we will
45         // have already used the freeform text entry block to populate
46         // showingColumnValues[columnName].
47         for (var j = 0; j < filterableColumnNames.length; j++) {
48           var columnName = filterableColumnNames[j];
49           var columnValue = extraColumnValues[columnName];
50           if (!showingColumnValues[columnName][columnValue]) {
51             allColumnValuesAreVisible = false;
52             break;
53           }
54         }
55         if (allColumnValuesAreVisible && (viewingTab == imagePair.tab)) {
56           filteredImagePairs.push(imagePair);
57         }
58       }
59       return filteredImagePairs;
60     };
61   }
62 );
63
64 /**
65  * Limit the input imagePairs to some max number, and merge identical rows
66  * (adjacent rows which have the same (imageA, imageB) pair).
67  *
68  * @param unfilteredImagePairs imagePairs to filter
69  * @param maxPairs maximum number of pairs to output, or <0 for no limit
70  * @param mergeIdenticalRows if true, merge identical rows by setting
71  *     ROWSPAN>1 on the first merged row, and ROWSPAN=0 for the rest
72  */
73 Loader.filter(
74   'mergeAndLimit',
75   function(constants) {
76     return function(unfilteredImagePairs, maxPairs, mergeIdenticalRows) {
77       var numPairs = unfilteredImagePairs.length;
78       if ((maxPairs > 0) && (maxPairs < numPairs)) {
79         numPairs = maxPairs;
80       }
81       var filteredImagePairs = [];
82       if (!mergeIdenticalRows || (numPairs == 1)) {
83         // Take a shortcut if we're not merging identical rows.
84         // We still need to set ROWSPAN to 1 for each row, for the HTML viewer.
85         for (var i = numPairs-1; i >= 0; i--) {
86           var imagePair = unfilteredImagePairs[i];
87           imagePair[constants.KEY__IMAGEPAIRS__ROWSPAN] = 1;
88           filteredImagePairs[i] = imagePair;
89         }
90       } else if (numPairs > 1) {
91         // General case--there are at least 2 rows, so we may need to merge some.
92         // Work from the bottom up, so we can keep a running total of how many
93         // rows should be merged, and set ROWSPAN of the top row accordingly.
94         var imagePair = unfilteredImagePairs[numPairs-1];
95         var nextRowImageAUrl = imagePair[constants.KEY__IMAGEPAIRS__IMAGE_A_URL];
96         var nextRowImageBUrl = imagePair[constants.KEY__IMAGEPAIRS__IMAGE_B_URL];
97         imagePair[constants.KEY__IMAGEPAIRS__ROWSPAN] = 1;
98         filteredImagePairs[numPairs-1] = imagePair;
99         for (var i = numPairs-2; i >= 0; i--) {
100           imagePair = unfilteredImagePairs[i];
101           var thisRowImageAUrl = imagePair[constants.KEY__IMAGEPAIRS__IMAGE_A_URL];
102           var thisRowImageBUrl = imagePair[constants.KEY__IMAGEPAIRS__IMAGE_B_URL];
103           if ((thisRowImageAUrl == nextRowImageAUrl) &&
104               (thisRowImageBUrl == nextRowImageBUrl)) {
105             imagePair[constants.KEY__IMAGEPAIRS__ROWSPAN] =
106                 filteredImagePairs[i+1][constants.KEY__IMAGEPAIRS__ROWSPAN] + 1;
107             filteredImagePairs[i+1][constants.KEY__IMAGEPAIRS__ROWSPAN] = 0;
108           } else {
109             imagePair[constants.KEY__IMAGEPAIRS__ROWSPAN] = 1;
110             nextRowImageAUrl = thisRowImageAUrl;
111             nextRowImageBUrl = thisRowImageBUrl;
112           }
113           filteredImagePairs[i] = imagePair;
114         }
115       } else {
116         // No results.
117       }
118       return filteredImagePairs;
119     };
120   }
121 );
122
123
124 Loader.controller(
125   'Loader.Controller',
126     function($scope, $http, $filter, $location, $log, $timeout, constants) {
127     $scope.readyToDisplay = false;
128     $scope.constants = constants;
129     $scope.windowTitle = "Loading GM Results...";
130     $scope.resultsToLoad = $location.search().resultsToLoad;
131     $scope.loadingMessage = "please wait...";
132
133     var currSortAsc = true; 
134
135
136     /**
137      * On initial page load, load a full dictionary of results.
138      * Once the dictionary is loaded, unhide the page elements so they can
139      * render the data.
140      */
141     $http.get($scope.resultsToLoad).success(
142       function(data, status, header, config) {
143         var dataHeader = data[constants.KEY__ROOT__HEADER];
144         if (dataHeader[constants.KEY__HEADER__SCHEMA_VERSION] !=
145             constants.VALUE__HEADER__SCHEMA_VERSION) {
146           $scope.loadingMessage = "ERROR: Got JSON file with schema version "
147               + dataHeader[constants.KEY__HEADER__SCHEMA_VERSION]
148               + " but expected schema version "
149               + constants.VALUE__HEADER__SCHEMA_VERSION;
150         } else if (dataHeader[constants.KEY__HEADER__IS_STILL_LOADING]) {
151           // Apply the server's requested reload delay to local time,
152           // so we will wait the right number of seconds regardless of clock
153           // skew between client and server.
154           var reloadDelayInSeconds =
155               dataHeader[constants.KEY__HEADER__TIME_NEXT_UPDATE_AVAILABLE] -
156               dataHeader[constants.KEY__HEADER__TIME_UPDATED];
157           var timeNow = new Date().getTime();
158           var timeToReload = timeNow + reloadDelayInSeconds * 1000;
159           $scope.loadingMessage =
160               "server is still loading results; will retry at " +
161               $scope.localTimeString(timeToReload / 1000);
162           $timeout(
163               function(){location.reload();},
164               timeToReload - timeNow);
165         } else {
166           $scope.loadingMessage = "processing data, please wait...";
167
168           $scope.header = dataHeader;
169           $scope.extraColumnHeaders = data[constants.KEY__ROOT__EXTRACOLUMNHEADERS];
170           $scope.orderedColumnNames = data[constants.KEY__ROOT__EXTRACOLUMNORDER];
171           $scope.imagePairs = data[constants.KEY__ROOT__IMAGEPAIRS];
172           $scope.imageSets = data[constants.KEY__ROOT__IMAGESETS];
173
174           // set the default sort column and make it ascending.
175           $scope.sortColumnSubdict = constants.KEY__IMAGEPAIRS__DIFFERENCES;
176           $scope.sortColumnKey = constants.KEY__DIFFERENCES__PERCEPTUAL_DIFF;
177           currSortAsc = true;
178
179           $scope.showSubmitAdvancedSettings = false;
180           $scope.submitAdvancedSettings = {};
181           $scope.submitAdvancedSettings[
182               constants.KEY__EXPECTATIONS__REVIEWED] = true;
183           $scope.submitAdvancedSettings[
184               constants.KEY__EXPECTATIONS__IGNOREFAILURE] = false;
185           $scope.submitAdvancedSettings['bug'] = '';
186
187           // Create the list of tabs (lists into which the user can file each
188           // test).  This may vary, depending on isEditable.
189           $scope.tabs = [
190             'Unfiled', 'Hidden'
191           ];
192           if (dataHeader[constants.KEY__HEADER__IS_EDITABLE]) {
193             $scope.tabs = $scope.tabs.concat(
194                 ['Pending Approval']);
195           }
196           $scope.defaultTab = $scope.tabs[0];
197           $scope.viewingTab = $scope.defaultTab;
198
199           // Track the number of results on each tab.
200           $scope.numResultsPerTab = {};
201           for (var i = 0; i < $scope.tabs.length; i++) {
202             $scope.numResultsPerTab[$scope.tabs[i]] = 0;
203           }
204           $scope.numResultsPerTab[$scope.defaultTab] = $scope.imagePairs.length;
205
206           // Add index and tab fields to all records.
207           for (var i = 0; i < $scope.imagePairs.length; i++) {
208             $scope.imagePairs[i].index = i;
209             $scope.imagePairs[i].tab = $scope.defaultTab;
210           }
211
212           // Arrays within which the user can toggle individual elements.
213           $scope.selectedImagePairs = [];
214
215           // Set up filters.
216           //
217           // filterableColumnNames is a list of all column names we can filter on.
218           // allColumnValues[columnName] is a list of all known values
219           // for a given column.
220           // showingColumnValues[columnName] is a set indicating which values
221           // in a given column would cause us to show a row, rather than hiding it.
222           //
223           // columnStringMatch[columnName] is a string used as a pattern to generate
224           // showingColumnValues[columnName] for columns we filter using free-form text.
225           // It is ignored for any columns with USE_FREEFORM_FILTER == false.
226           $scope.filterableColumnNames = [];
227           $scope.allColumnValues = {};
228           $scope.showingColumnValues = {};
229           $scope.columnStringMatch = {};
230
231           angular.forEach(
232             Object.keys($scope.extraColumnHeaders),
233             function(columnName) {
234               var columnHeader = $scope.extraColumnHeaders[columnName];
235               if (columnHeader[constants.KEY__EXTRACOLUMNHEADERS__IS_FILTERABLE]) {
236                 $scope.filterableColumnNames.push(columnName);
237                 $scope.allColumnValues[columnName] = $scope.columnSliceOf2DArray(
238                     columnHeader[constants.KEY__EXTRACOLUMNHEADERS__VALUES_AND_COUNTS], 0);
239                 $scope.showingColumnValues[columnName] = {};
240                 $scope.toggleValuesInSet($scope.allColumnValues[columnName],
241                                          $scope.showingColumnValues[columnName]);
242                 $scope.columnStringMatch[columnName] = "";
243               }
244             }
245           );
246
247           // TODO(epoger): Special handling for RESULT_TYPE column:
248           // by default, show only KEY__RESULT_TYPE__FAILED results
249           $scope.showingColumnValues[constants.KEY__EXTRACOLUMNS__RESULT_TYPE] = {};
250           $scope.showingColumnValues[constants.KEY__EXTRACOLUMNS__RESULT_TYPE][
251               constants.KEY__RESULT_TYPE__FAILED] = true;
252
253           // Set up mapping for URL parameters.
254           // parameter name -> copier object to load/save parameter value
255           $scope.queryParameters.map = {
256             'resultsToLoad':         $scope.queryParameters.copiers.simple,
257             'displayLimitPending':   $scope.queryParameters.copiers.simple,
258             'showThumbnailsPending': $scope.queryParameters.copiers.simple,
259             'mergeIdenticalRowsPending': $scope.queryParameters.copiers.simple,
260             'imageSizePending':      $scope.queryParameters.copiers.simple,
261             'sortColumnSubdict':     $scope.queryParameters.copiers.simple,
262             'sortColumnKey':         $scope.queryParameters.copiers.simple,
263           };
264           // Some parameters are handled differently based on whether they USE_FREEFORM_FILTER.
265           angular.forEach(
266             $scope.filterableColumnNames,
267             function(columnName) {
268               if ($scope.extraColumnHeaders[columnName]
269                   [constants.KEY__EXTRACOLUMNHEADERS__USE_FREEFORM_FILTER]) {
270                 $scope.queryParameters.map[columnName] =
271                     $scope.queryParameters.copiers.columnStringMatch;
272               } else {
273                 $scope.queryParameters.map[columnName] =
274                     $scope.queryParameters.copiers.showingColumnValuesSet;
275               }
276             }
277           );
278
279           // If any defaults were overridden in the URL, get them now.
280           $scope.queryParameters.load();
281
282           // Any image URLs which are relative should be relative to the JSON
283           // file's source directory; absolute URLs should be left alone.
284           var baseUrlKey = constants.KEY__IMAGESETS__FIELD__BASE_URL;
285           angular.forEach(
286             $scope.imageSets,
287             function(imageSet) {
288               var baseUrl = imageSet[baseUrlKey];
289               if ((baseUrl.substring(0, 1) != '/') &&
290                   (baseUrl.indexOf('://') == -1)) {
291                 imageSet[baseUrlKey] = $scope.resultsToLoad + '/../' + baseUrl;
292               }
293             }
294           );
295
296           $scope.readyToDisplay = true;
297           $scope.updateResults();
298           $scope.loadingMessage = "";
299           $scope.windowTitle = "Current GM Results";
300
301           $timeout( function() {
302             make_results_header_sticky();
303           });
304         }
305       }
306     ).error(
307       function(data, status, header, config) {
308         $scope.loadingMessage = "FAILED to load.";
309         $scope.windowTitle = "Failed to Load GM Results";
310       }
311     );
312
313
314     //
315     // Select/Clear/Toggle all tests.
316     //
317
318     /**
319      * Select all currently showing tests.
320      */
321     $scope.selectAllImagePairs = function() {
322       var numImagePairsShowing = $scope.limitedImagePairs.length;
323       for (var i = 0; i < numImagePairsShowing; i++) {
324         var index = $scope.limitedImagePairs[i].index;
325         if (!$scope.isValueInArray(index, $scope.selectedImagePairs)) {
326           $scope.toggleValueInArray(index, $scope.selectedImagePairs);
327         }
328       }
329     };
330
331     /**
332      * Deselect all currently showing tests.
333      */
334     $scope.clearAllImagePairs = function() {
335       var numImagePairsShowing = $scope.limitedImagePairs.length;
336       for (var i = 0; i < numImagePairsShowing; i++) {
337         var index = $scope.limitedImagePairs[i].index;
338         if ($scope.isValueInArray(index, $scope.selectedImagePairs)) {
339           $scope.toggleValueInArray(index, $scope.selectedImagePairs);
340         }
341       }
342     };
343
344     /**
345      * Toggle selection of all currently showing tests.
346      */
347     $scope.toggleAllImagePairs = function() {
348       var numImagePairsShowing = $scope.limitedImagePairs.length;
349       for (var i = 0; i < numImagePairsShowing; i++) {
350         var index = $scope.limitedImagePairs[i].index;
351         $scope.toggleValueInArray(index, $scope.selectedImagePairs);
352       }
353     };
354
355     /**
356      * Toggle selection state of a subset of the currently showing tests.
357      *
358      * @param startIndex index within $scope.limitedImagePairs of the first
359      *     test to toggle selection state of
360      * @param num number of tests (in a contiguous block) to toggle
361      */
362     $scope.toggleSomeImagePairs = function(startIndex, num) {
363       var numImagePairsShowing = $scope.limitedImagePairs.length;
364       for (var i = startIndex; i < startIndex + num; i++) {
365         var index = $scope.limitedImagePairs[i].index;
366         $scope.toggleValueInArray(index, $scope.selectedImagePairs);
367       }
368     };
369
370
371     //
372     // Tab operations.
373     //
374
375     /**
376      * Change the selected tab.
377      *
378      * @param tab (string): name of the tab to select
379      */
380     $scope.setViewingTab = function(tab) {
381       $scope.viewingTab = tab;
382       $scope.updateResults();
383     };
384
385     /**
386      * Move the imagePairs in $scope.selectedImagePairs to a different tab,
387      * and then clear $scope.selectedImagePairs.
388      *
389      * @param newTab (string): name of the tab to move the tests to
390      */
391     $scope.moveSelectedImagePairsToTab = function(newTab) {
392       $scope.moveImagePairsToTab($scope.selectedImagePairs, newTab);
393       $scope.selectedImagePairs = [];
394       $scope.updateResults();
395     };
396
397     /**
398      * Move a subset of $scope.imagePairs to a different tab.
399      *
400      * @param imagePairIndices (array of ints): indices into $scope.imagePairs
401      *        indicating which test results to move
402      * @param newTab (string): name of the tab to move the tests to
403      */
404     $scope.moveImagePairsToTab = function(imagePairIndices, newTab) {
405       var imagePairIndex;
406       var numImagePairs = imagePairIndices.length;
407       for (var i = 0; i < numImagePairs; i++) {
408         imagePairIndex = imagePairIndices[i];
409         $scope.numResultsPerTab[$scope.imagePairs[imagePairIndex].tab]--;
410         $scope.imagePairs[imagePairIndex].tab = newTab;
411       }
412       $scope.numResultsPerTab[newTab] += numImagePairs;
413     };
414
415
416     //
417     // $scope.queryParameters:
418     // Transfer parameter values between $scope and the URL query string.
419     //
420     $scope.queryParameters = {};
421
422     // load and save functions for parameters of each type
423     // (load a parameter value into $scope from nameValuePairs,
424     //  save a parameter value from $scope into nameValuePairs)
425     $scope.queryParameters.copiers = {
426       'simple': {
427         'load': function(nameValuePairs, name) {
428           var value = nameValuePairs[name];
429           if (value) {
430             $scope[name] = value;
431           }
432         },
433         'save': function(nameValuePairs, name) {
434           nameValuePairs[name] = $scope[name];
435         }
436       },
437
438       'columnStringMatch': {
439         'load': function(nameValuePairs, name) {
440           var value = nameValuePairs[name];
441           if (value) {
442             $scope.columnStringMatch[name] = value;
443           }
444         },
445         'save': function(nameValuePairs, name) {
446           nameValuePairs[name] = $scope.columnStringMatch[name];
447         }
448       },
449
450       'showingColumnValuesSet': {
451         'load': function(nameValuePairs, name) {
452           var value = nameValuePairs[name];
453           if (value) {
454             var valueArray = value.split(',');
455             $scope.showingColumnValues[name] = {};
456             $scope.toggleValuesInSet(valueArray, $scope.showingColumnValues[name]);
457           }
458         },
459         'save': function(nameValuePairs, name) {
460           nameValuePairs[name] = Object.keys($scope.showingColumnValues[name]).join(',');
461         }
462       },
463
464     };
465
466     // Loads all parameters into $scope from the URL query string;
467     // any which are not found within the URL will keep their current value.
468     $scope.queryParameters.load = function() {
469       var nameValuePairs = $location.search();
470
471       // If urlSchemaVersion is not specified, we assume the current version.
472       var urlSchemaVersion = constants.URL_VALUE__SCHEMA_VERSION__CURRENT;
473       if (constants.URL_KEY__SCHEMA_VERSION in nameValuePairs) {
474         urlSchemaVersion = nameValuePairs[constants.URL_KEY__SCHEMA_VERSION];
475       } else if ('hiddenResultTypes' in nameValuePairs) {
476         // The combination of:
477         // - absence of an explicit urlSchemaVersion, and
478         // - presence of the old 'hiddenResultTypes' field
479         // tells us that the URL is from the original urlSchemaVersion.
480         // See https://codereview.chromium.org/367173002/
481         urlSchemaVersion = 0;
482       }
483       $scope.urlSchemaVersionLoaded = urlSchemaVersion;
484
485       if (urlSchemaVersion != constants.URL_VALUE__SCHEMA_VERSION__CURRENT) {
486         nameValuePairs = $scope.upconvertUrlNameValuePairs(nameValuePairs, urlSchemaVersion);
487       }
488       angular.forEach($scope.queryParameters.map,
489                       function(copier, paramName) {
490                         copier.load(nameValuePairs, paramName);
491                       }
492                      );
493     };
494
495     // Saves all parameters from $scope into the URL query string.
496     $scope.queryParameters.save = function() {
497       var nameValuePairs = {};
498       nameValuePairs[constants.URL_KEY__SCHEMA_VERSION] = constants.URL_VALUE__SCHEMA_VERSION__CURRENT;
499       angular.forEach($scope.queryParameters.map,
500                       function(copier, paramName) {
501                         copier.save(nameValuePairs, paramName);
502                       }
503                      );
504       $location.search(nameValuePairs);
505     };
506
507     /**
508      * Converts URL name/value pairs that were stored by a previous urlSchemaVersion
509      * to the currently needed format.
510      *
511      * @param oldNValuePairs name/value pairs found in the loaded URL
512      * @param oldUrlSchemaVersion which version of the schema was used to generate that URL
513      *
514      * @returns nameValuePairs as needed by the current URL parser
515      */
516     $scope.upconvertUrlNameValuePairs = function(oldNameValuePairs, oldUrlSchemaVersion) {
517       var newNameValuePairs = {};
518       angular.forEach(oldNameValuePairs,
519                       function(value, name) {
520                         if (oldUrlSchemaVersion < 1) {
521                           if ('hiddenConfigs' == name) {
522                             name = 'config';
523                             var valueSet = {};
524                             $scope.toggleValuesInSet(value.split(','), valueSet);
525                             $scope.toggleValuesInSet(
526                                 $scope.allColumnValues[constants.KEY__EXTRACOLUMNS__CONFIG],
527                                 valueSet);
528                             value = Object.keys(valueSet).join(',');
529                           } else if ('hiddenResultTypes' == name) {
530                             name = 'resultType';
531                             var valueSet = {};
532                             $scope.toggleValuesInSet(value.split(','), valueSet);
533                             $scope.toggleValuesInSet(
534                                 $scope.allColumnValues[constants.KEY__EXTRACOLUMNS__RESULT_TYPE],
535                                 valueSet);
536                             value = Object.keys(valueSet).join(',');
537                           }
538                         }
539
540                         newNameValuePairs[name] = value;
541                       }
542                      );
543       return newNameValuePairs;
544     }
545
546
547     //
548     // updateResults() and friends.
549     //
550
551     /**
552      * Set $scope.areUpdatesPending (to enable/disable the Update Results
553      * button).
554      *
555      * TODO(epoger): We could reduce the amount of code by just setting the
556      * variable directly (from, e.g., a button's ng-click handler).  But when
557      * I tried that, the HTML elements depending on the variable did not get
558      * updated.
559      * It turns out that this is due to variable scoping within an ng-repeat
560      * element; see http://stackoverflow.com/questions/15388344/behavior-of-assignment-expression-invoked-by-ng-click-within-ng-repeat
561      *
562      * @param val boolean value to set $scope.areUpdatesPending to
563      */
564     $scope.setUpdatesPending = function(val) {
565       $scope.areUpdatesPending = val;
566     }
567
568     /**
569      * Update the displayed results, based on filters/settings,
570      * and call $scope.queryParameters.save() so that the new filter results
571      * can be bookmarked.
572      */
573     $scope.updateResults = function() {
574       $scope.renderStartTime = window.performance.now();
575       $log.debug("renderStartTime: " + $scope.renderStartTime);
576       $scope.displayLimit = $scope.displayLimitPending;
577       $scope.mergeIdenticalRows = $scope.mergeIdenticalRowsPending;
578
579       // For each USE_FREEFORM_FILTER column, populate showingColumnValues.
580       // This is more efficient than applying the freeform filter within the
581       // tight loop in removeHiddenImagePairs.
582       angular.forEach(
583         $scope.filterableColumnNames,
584         function(columnName) {
585           var columnHeader = $scope.extraColumnHeaders[columnName];
586           if (columnHeader[constants.KEY__EXTRACOLUMNHEADERS__USE_FREEFORM_FILTER]) {
587             var columnStringMatch = $scope.columnStringMatch[columnName];
588             var showingColumnValues = {};
589             angular.forEach(
590               $scope.allColumnValues[columnName],
591               function(columnValue) {
592                 if (-1 != columnValue.indexOf(columnStringMatch)) {
593                   showingColumnValues[columnValue] = true;
594                 }
595               }
596             );
597             $scope.showingColumnValues[columnName] = showingColumnValues;
598           }
599         }
600       );
601
602       // TODO(epoger): Every time we apply a filter, AngularJS creates
603       // another copy of the array.  Is there a way we can filter out
604       // the imagePairs as they are displayed, rather than storing multiple
605       // array copies?  (For better performance.)
606       if ($scope.viewingTab == $scope.defaultTab) {
607         var doReverse = !currSortAsc;
608
609         $scope.filteredImagePairs =
610             $filter("orderBy")(
611                 $filter("removeHiddenImagePairs")(
612                     $scope.imagePairs,
613                     $scope.filterableColumnNames,
614                     $scope.showingColumnValues,
615                     $scope.viewingTab
616                 ),
617                 // [$scope.getSortColumnValue, $scope.getSecondOrderSortValue],
618                 $scope.getSortColumnValue,
619                 doReverse);
620         $scope.limitedImagePairs = $filter("mergeAndLimit")(
621             $scope.filteredImagePairs, $scope.displayLimit, $scope.mergeIdenticalRows);
622       } else {
623         $scope.filteredImagePairs =
624             $filter("orderBy")(
625                 $filter("filter")(
626                     $scope.imagePairs,
627                     {tab: $scope.viewingTab},
628                     true
629                 ),
630                 // [$scope.getSortColumnValue, $scope.getSecondOrderSortValue]);
631                 $scope.getSortColumnValue);
632         $scope.limitedImagePairs = $filter("mergeAndLimit")(
633             $scope.filteredImagePairs, -1, $scope.mergeIdenticalRows);
634       }
635       $scope.showThumbnails = $scope.showThumbnailsPending;
636       $scope.imageSize = $scope.imageSizePending;
637       $scope.setUpdatesPending(false);
638       $scope.queryParameters.save();
639     }
640
641     /**
642      * This function is called when the results have been completely rendered
643      * after updateResults().
644      */
645     $scope.resultsUpdatedCallback = function() {
646       $scope.renderEndTime = window.performance.now();
647       $log.debug("renderEndTime: " + $scope.renderEndTime);
648     };
649
650     /**
651      * Re-sort the displayed results.
652      *
653      * @param subdict (string): which KEY__IMAGEPAIRS__* subdictionary
654      *     the sort column key is within, or 'none' if the sort column
655      *     key is one of KEY__IMAGEPAIRS__*
656      * @param key (string): sort by value associated with this key in subdict
657      */
658     $scope.sortResultsBy = function(subdict, key) {
659       // if we are already sorting by this column then toggle between asc/desc
660       if ((subdict === $scope.sortColumnSubdict) && ($scope.sortColumnKey === key)) {
661         currSortAsc = !currSortAsc;
662       } else {
663         $scope.sortColumnSubdict = subdict;
664         $scope.sortColumnKey = key;
665         currSortAsc = true; 
666       }
667       $scope.updateResults();
668     };
669
670     /**
671      * Returns ASC or DESC (from constants) if currently the data
672      * is sorted by the provided column. 
673      *
674      * @param colName: name of the column for which we need to get the class.
675      */
676
677     $scope.sortedByColumnsCls = function (colName) {
678       if ($scope.sortColumnKey !== colName) {
679         return '';
680       }
681
682       var result = (currSortAsc) ? constants.ASC : constants.DESC;
683       console.log("sort class:", result);
684       return result;
685     };
686
687     /**
688      * For a particular ImagePair, return the value of the column we are
689      * sorting on (according to $scope.sortColumnSubdict and
690      * $scope.sortColumnKey).
691      *
692      * @param imagePair: imagePair to get a column value out of.
693      */
694     $scope.getSortColumnValue = function(imagePair) {
695       if ($scope.sortColumnSubdict in imagePair) {
696         return imagePair[$scope.sortColumnSubdict][$scope.sortColumnKey];
697       } else if ($scope.sortColumnKey in imagePair) {
698         return imagePair[$scope.sortColumnKey];
699       } else {
700         return undefined;
701       }
702     };
703
704     /**
705      * For a particular ImagePair, return the value we use for the
706      * second-order sort (tiebreaker when multiple rows have
707      * the same getSortColumnValue()).
708      *
709      * We join the imageA and imageB urls for this value, so that we merge
710      * adjacent rows as much as possible.
711      *
712      * @param imagePair: imagePair to get a column value out of.
713      */
714     $scope.getSecondOrderSortValue = function(imagePair) {
715       return imagePair[constants.KEY__IMAGEPAIRS__IMAGE_A_URL] + "-vs-" +
716           imagePair[constants.KEY__IMAGEPAIRS__IMAGE_B_URL];
717     };
718
719     /**
720      * Set $scope.columnStringMatch[name] = value, and update results.
721      *
722      * @param name
723      * @param value
724      */
725     $scope.setColumnStringMatch = function(name, value) {
726       $scope.columnStringMatch[name] = value;
727       $scope.updateResults();
728     };
729
730     /**
731      * Update $scope.showingColumnValues[columnName] and $scope.columnStringMatch[columnName]
732      * so that ONLY entries with this columnValue are showing, and update the visible results.
733      * (We update both of those, so we cover both freeform and checkbox filtered columns.)
734      *
735      * @param columnName
736      * @param columnValue
737      */
738     $scope.showOnlyColumnValue = function(columnName, columnValue) {
739       $scope.columnStringMatch[columnName] = columnValue;
740       $scope.showingColumnValues[columnName] = {};
741       $scope.toggleValueInSet(columnValue, $scope.showingColumnValues[columnName]);
742       $scope.updateResults();
743     };
744
745     /**
746      * Update $scope.showingColumnValues[columnName] and $scope.columnStringMatch[columnName]
747      * so that ALL entries are showing, and update the visible results.
748      * (We update both of those, so we cover both freeform and checkbox filtered columns.)
749      *
750      * @param columnName
751      */
752     $scope.showAllColumnValues = function(columnName) {
753       $scope.columnStringMatch[columnName] = "";
754       $scope.showingColumnValues[columnName] = {};
755       $scope.toggleValuesInSet($scope.allColumnValues[columnName],
756                                $scope.showingColumnValues[columnName]);
757       $scope.updateResults();
758     };
759
760
761     //
762     // Operations for sending info back to the server.
763     //
764
765     /**
766      * Tell the server that the actual results of these particular tests
767      * are acceptable.
768      *
769      * TODO(epoger): This assumes that the original expectations are in
770      * imageSetA, and the actuals are in imageSetB.
771      *
772      * @param imagePairsSubset an array of test results, most likely a subset of
773      *        $scope.imagePairs (perhaps with some modifications)
774      */
775     $scope.submitApprovals = function(imagePairsSubset) {
776       $scope.submitPending = true;
777
778       // Convert bug text field to null or 1-item array.
779       var bugs = null;
780       var bugNumber = parseInt($scope.submitAdvancedSettings['bug']);
781       if (!isNaN(bugNumber)) {
782         bugs = [bugNumber];
783       }
784
785       // TODO(epoger): This is a suboptimal way to prevent users from
786       // rebaselining failures in alternative renderModes, but it does work.
787       // For a better solution, see
788       // https://code.google.com/p/skia/issues/detail?id=1748 ('gm: add new
789       // result type, RenderModeMismatch')
790       var encounteredComparisonConfig = false;
791
792       var updatedExpectations = [];
793       for (var i = 0; i < imagePairsSubset.length; i++) {
794         var imagePair = imagePairsSubset[i];
795         var updatedExpectation = {};
796         updatedExpectation[constants.KEY__IMAGEPAIRS__EXPECTATIONS] =
797             imagePair[constants.KEY__IMAGEPAIRS__EXPECTATIONS];
798         updatedExpectation[constants.KEY__IMAGEPAIRS__EXTRACOLUMNS] =
799             imagePair[constants.KEY__IMAGEPAIRS__EXTRACOLUMNS];
800         // IMAGE_B_URL contains the actual image (which is now the expectation)
801         updatedExpectation[constants.KEY__IMAGEPAIRS__IMAGE_B_URL] =
802             imagePair[constants.KEY__IMAGEPAIRS__IMAGE_B_URL];
803         if (0 == updatedExpectation[constants.KEY__IMAGEPAIRS__EXTRACOLUMNS]
804                                    [constants.KEY__EXTRACOLUMNS__CONFIG]
805                                    .indexOf('comparison-')) {
806           encounteredComparisonConfig = true;
807         }
808
809         // Advanced settings...
810         if (null == updatedExpectation[constants.KEY__IMAGEPAIRS__EXPECTATIONS]) {
811           updatedExpectation[constants.KEY__IMAGEPAIRS__EXPECTATIONS] = {};
812         }
813         updatedExpectation[constants.KEY__IMAGEPAIRS__EXPECTATIONS]
814                           [constants.KEY__EXPECTATIONS__REVIEWED] =
815             $scope.submitAdvancedSettings[
816                 constants.KEY__EXPECTATIONS__REVIEWED];
817         if (true == $scope.submitAdvancedSettings[
818             constants.KEY__EXPECTATIONS__IGNOREFAILURE]) {
819           // if it's false, don't send it at all (just keep the default)
820           updatedExpectation[constants.KEY__IMAGEPAIRS__EXPECTATIONS]
821                             [constants.KEY__EXPECTATIONS__IGNOREFAILURE] = true;
822         }
823         updatedExpectation[constants.KEY__IMAGEPAIRS__EXPECTATIONS]
824                           [constants.KEY__EXPECTATIONS__BUGS] = bugs;
825
826         updatedExpectations.push(updatedExpectation);
827       }
828       if (encounteredComparisonConfig) {
829         alert("Approval failed -- you cannot approve results with config " +
830             "type comparison-*");
831         $scope.submitPending = false;
832         return;
833       }
834       var modificationData = {};
835       modificationData[constants.KEY__EDITS__MODIFICATIONS] =
836           updatedExpectations;
837       modificationData[constants.KEY__EDITS__OLD_RESULTS_HASH] =
838           $scope.header[constants.KEY__HEADER__DATAHASH];
839       modificationData[constants.KEY__EDITS__OLD_RESULTS_TYPE] =
840           $scope.header[constants.KEY__HEADER__TYPE];
841       $http({
842         method: "POST",
843         url: "/edits",
844         data: modificationData
845       }).success(function(data, status, headers, config) {
846         var imagePairIndicesToMove = [];
847         for (var i = 0; i < imagePairsSubset.length; i++) {
848           imagePairIndicesToMove.push(imagePairsSubset[i].index);
849         }
850         $scope.moveImagePairsToTab(imagePairIndicesToMove,
851                                    "HackToMakeSureThisImagePairDisappears");
852         $scope.updateResults();
853         alert("New baselines submitted successfully!\n\n" +
854             "You still need to commit the updated expectations files on " +
855             "the server side to the Skia repo.\n\n" +
856             "When you click OK, your web UI will reload; after that " +
857             "completes, you will see the updated data (once the server has " +
858             "finished loading the update results into memory!) and you can " +
859             "submit more baselines if you want.");
860         // I don't know why, but if I just call reload() here it doesn't work.
861         // Making a timer call it fixes the problem.
862         $timeout(function(){location.reload();}, 1);
863       }).error(function(data, status, headers, config) {
864         alert("There was an error submitting your baselines.\n\n" +
865             "Please see server-side log for details.");
866         $scope.submitPending = false;
867       });
868     };
869
870
871     //
872     // Operations we use to mimic Set semantics, in such a way that
873     // checking for presence within the Set is as fast as possible.
874     // But getting a list of all values within the Set is not necessarily
875     // possible.
876     // TODO(epoger): move into a separate .js file?
877     //
878
879     /**
880      * Returns the number of values present within set "set".
881      *
882      * @param set an Object which we use to mimic set semantics
883      */
884     $scope.setSize = function(set) {
885       return Object.keys(set).length;
886     };
887
888     /**
889      * Returns true if value "value" is present within set "set".
890      *
891      * @param value a value of any type
892      * @param set an Object which we use to mimic set semantics
893      *        (this should make isValueInSet faster than if we used an Array)
894      */
895     $scope.isValueInSet = function(value, set) {
896       return (true == set[value]);
897     };
898
899     /**
900      * If value "value" is already in set "set", remove it; otherwise, add it.
901      *
902      * @param value a value of any type
903      * @param set an Object which we use to mimic set semantics
904      */
905     $scope.toggleValueInSet = function(value, set) {
906       if (true == set[value]) {
907         delete set[value];
908       } else {
909         set[value] = true;
910       }
911     };
912
913     /**
914      * For each value in valueArray, call toggleValueInSet(value, set).
915      *
916      * @param valueArray
917      * @param set
918      */
919     $scope.toggleValuesInSet = function(valueArray, set) {
920       var arrayLength = valueArray.length;
921       for (var i = 0; i < arrayLength; i++) {
922         $scope.toggleValueInSet(valueArray[i], set);
923       }
924     };
925
926
927     //
928     // Array operations; similar to our Set operations, but operate on a
929     // Javascript Array so we *can* easily get a list of all values in the Set.
930     // TODO(epoger): move into a separate .js file?
931     //
932
933     /**
934      * Returns true if value "value" is present within array "array".
935      *
936      * @param value a value of any type
937      * @param array a Javascript Array
938      */
939     $scope.isValueInArray = function(value, array) {
940       return (-1 != array.indexOf(value));
941     };
942
943     /**
944      * If value "value" is already in array "array", remove it; otherwise,
945      * add it.
946      *
947      * @param value a value of any type
948      * @param array a Javascript Array
949      */
950     $scope.toggleValueInArray = function(value, array) {
951       var i = array.indexOf(value);
952       if (-1 == i) {
953         array.push(value);
954       } else {
955         array.splice(i, 1);
956       }
957     };
958
959
960     //
961     // Miscellaneous utility functions.
962     // TODO(epoger): move into a separate .js file?
963     //
964
965     /**
966      * Returns a single "column slice" of a 2D array.
967      *
968      * For example, if array is:
969      * [[A0, A1],
970      *  [B0, B1],
971      *  [C0, C1]]
972      * and index is 0, this this will return:
973      * [A0, B0, C0]
974      *
975      * @param array a Javascript Array
976      * @param column (numeric): index within each row array
977      */
978     $scope.columnSliceOf2DArray = function(array, column) {
979       var slice = [];
980       var numRows = array.length;
981       for (var row = 0; row < numRows; row++) {
982         slice.push(array[row][column]);
983       }
984       return slice;
985     };
986
987     /**
988      * Returns a human-readable (in local time zone) time string for a
989      * particular moment in time.
990      *
991      * @param secondsPastEpoch (numeric): seconds past epoch in UTC
992      */
993     $scope.localTimeString = function(secondsPastEpoch) {
994       var d = new Date(secondsPastEpoch * 1000);
995       return d.toString();
996     };
997
998     /**
999      * Returns a hex color string (such as "#aabbcc") for the given RGB values.
1000      *
1001      * @param r (numeric): red channel value, 0-255
1002      * @param g (numeric): green channel value, 0-255
1003      * @param b (numeric): blue channel value, 0-255
1004      */
1005     $scope.hexColorString = function(r, g, b) {
1006       var rString = r.toString(16);
1007       if (r < 16) {
1008         rString = "0" + rString;
1009       }
1010       var gString = g.toString(16);
1011       if (g < 16) {
1012         gString = "0" + gString;
1013       }
1014       var bString = b.toString(16);
1015       if (b < 16) {
1016         bString = "0" + bString;
1017       }
1018       return '#' + rString + gString + bString;
1019     };
1020
1021     /**
1022      * Returns a hex color string (such as "#aabbcc") for the given brightness.
1023      *
1024      * @param brightnessString (string): 0-255, 0 is completely black
1025      *
1026      * TODO(epoger): It might be nice to tint the color when it's not completely
1027      * black or completely white.
1028      */
1029     $scope.brightnessStringToHexColor = function(brightnessString) {
1030       var v = parseInt(brightnessString);
1031       return $scope.hexColorString(v, v, v);
1032     };
1033
1034     /**
1035      * Returns the last path component of image diff URL for a given ImagePair.
1036      *
1037      * Depending on which diff this is (whitediffs, pixeldiffs, etc.) this
1038      * will be relative to different base URLs.
1039      *
1040      * We must keep this function in sync with _get_difference_locator() in
1041      * ../imagediffdb.py
1042      *
1043      * @param imagePair: ImagePair to generate image diff URL for
1044      */
1045      // TODO (stephana): this is a temporary fix. A fix is in the works
1046      // to get rid of this function and include the URL in the data 
1047      // sent from the backend.
1048
1049     $scope.getImageDiffRelativeUrl = function(imagePair) {
1050       var before =
1051           imagePair[constants.KEY__IMAGEPAIRS__IMAGE_A_URL] + "_png-vs-" +
1052           imagePair[constants.KEY__IMAGEPAIRS__IMAGE_B_URL] + "_png";
1053       return before.replace(/[^\w\-]/g, "_") + ".png";
1054     };
1055
1056   }
1057 );