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