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