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 // 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 );
   1025