loader.js revision 16f418080ff6751e15e0193263149412de9c848a
1/*
2 * Loader:
3 * Reads GM result reports written out by results.py, and imports
4 * them into $scope.extraColumnHeaders and $scope.imagePairs .
5 */
6var Loader = angular.module(
7    'Loader',
8    ['ConstantsModule', 'diff_viewer']
9);
10
11// TODO(epoger): Combine ALL of our filtering operations (including
12// truncation) into this one filter, so that runs most efficiently?
13// (We would have to make sure truncation still took place after
14// sorting, though.)
15Loader.filter(
16  'removeHiddenImagePairs',
17  function(constants) {
18    return function(unfilteredImagePairs, hiddenResultTypes, hiddenConfigs,
19                    builderSubstring, testSubstring, viewingTab) {
20      var filteredImagePairs = [];
21      for (var i = 0; i < unfilteredImagePairs.length; i++) {
22        var imagePair = unfilteredImagePairs[i];
23        var extraColumnValues = imagePair[constants.KEY__EXTRA_COLUMN_VALUES];
24        // For performance, we examine the "set" objects directly rather
25        // than calling $scope.isValueInSet().
26        // Besides, I don't think we have access to $scope in here...
27        if (!(true == hiddenResultTypes[extraColumnValues[
28                  constants.KEY__EXTRACOLUMN__RESULT_TYPE]]) &&
29            !(true == hiddenConfigs[extraColumnValues[
30                  constants.KEY__EXTRACOLUMN__CONFIG]]) &&
31            !(-1 == extraColumnValues[constants.KEY__EXTRACOLUMN__BUILDER]
32                    .indexOf(builderSubstring)) &&
33            !(-1 == extraColumnValues[constants.KEY__EXTRACOLUMN__TEST]
34                    .indexOf(testSubstring)) &&
35            (viewingTab == imagePair.tab)) {
36          filteredImagePairs.push(imagePair);
37        }
38      }
39      return filteredImagePairs;
40    };
41  }
42);
43
44
45Loader.controller(
46  'Loader.Controller',
47    function($scope, $http, $filter, $location, $timeout, constants) {
48    $scope.constants = constants;
49    $scope.windowTitle = "Loading GM Results...";
50    $scope.resultsToLoad = $location.search().resultsToLoad;
51    $scope.loadingMessage = "Loading results of type '" + $scope.resultsToLoad +
52        "', please wait...";
53
54    /**
55     * On initial page load, load a full dictionary of results.
56     * Once the dictionary is loaded, unhide the page elements so they can
57     * render the data.
58     */
59    $http.get("/results/" + $scope.resultsToLoad).success(
60      function(data, status, header, config) {
61        var dataHeader = data[constants.KEY__HEADER];
62        if (dataHeader[constants.KEY__HEADER__IS_STILL_LOADING]) {
63          $scope.loadingMessage =
64              "Server is still loading results; will retry at " +
65              $scope.localTimeString(dataHeader[
66                  constants.KEY__HEADER__TIME_NEXT_UPDATE_AVAILABLE]);
67          $timeout(
68              function(){location.reload();},
69              (dataHeader[constants.KEY__HEADER__TIME_NEXT_UPDATE_AVAILABLE]
70               * 1000) - new Date().getTime());
71        } else {
72          $scope.loadingMessage = "Processing data, please wait...";
73
74          $scope.header = dataHeader;
75          $scope.extraColumnHeaders = data[constants.KEY__EXTRACOLUMNHEADERS];
76          $scope.imagePairs = data[constants.KEY__IMAGEPAIRS];
77          $scope.imageSets = data[constants.KEY__IMAGESETS];
78          $scope.sortColumnSubdict = constants.KEY__DIFFERENCE_DATA;
79          $scope.sortColumnKey = constants.KEY__DIFFERENCE_DATA__WEIGHTED_DIFF;
80          $scope.showTodos = false;
81
82          $scope.showSubmitAdvancedSettings = false;
83          $scope.submitAdvancedSettings = {};
84          $scope.submitAdvancedSettings[
85              constants.KEY__EXPECTATIONS__REVIEWED] = true;
86          $scope.submitAdvancedSettings[
87              constants.KEY__EXPECTATIONS__IGNOREFAILURE] = false;
88          $scope.submitAdvancedSettings['bug'] = '';
89
90          // Create the list of tabs (lists into which the user can file each
91          // test).  This may vary, depending on isEditable.
92          $scope.tabs = [
93            'Unfiled', 'Hidden'
94          ];
95          if (dataHeader[constants.KEY__HEADER__IS_EDITABLE]) {
96            $scope.tabs = $scope.tabs.concat(
97                ['Pending Approval']);
98          }
99          $scope.defaultTab = $scope.tabs[0];
100          $scope.viewingTab = $scope.defaultTab;
101
102          // Track the number of results on each tab.
103          $scope.numResultsPerTab = {};
104          for (var i = 0; i < $scope.tabs.length; i++) {
105            $scope.numResultsPerTab[$scope.tabs[i]] = 0;
106          }
107          $scope.numResultsPerTab[$scope.defaultTab] = $scope.imagePairs.length;
108
109          // Add index and tab fields to all records.
110          for (var i = 0; i < $scope.imagePairs.length; i++) {
111            $scope.imagePairs[i].index = i;
112            $scope.imagePairs[i].tab = $scope.defaultTab;
113          }
114
115          // Arrays within which the user can toggle individual elements.
116          $scope.selectedImagePairs = [];
117
118          // Sets within which the user can toggle individual elements.
119          $scope.hiddenResultTypes = {};
120          $scope.hiddenResultTypes[
121              constants.KEY__RESULT_TYPE__FAILUREIGNORED] = true;
122          $scope.hiddenResultTypes[
123              constants.KEY__RESULT_TYPE__NOCOMPARISON] = true;
124          $scope.hiddenResultTypes[
125              constants.KEY__RESULT_TYPE__SUCCEEDED] = true;
126          $scope.allResultTypes = Object.keys(
127              $scope.extraColumnHeaders[constants.KEY__EXTRACOLUMN__RESULT_TYPE]
128                                       [constants.KEY__VALUES_AND_COUNTS]);
129          $scope.hiddenConfigs = {};
130          $scope.allConfigs = Object.keys(
131              $scope.extraColumnHeaders[constants.KEY__EXTRACOLUMN__CONFIG]
132                                       [constants.KEY__VALUES_AND_COUNTS]);
133
134          // Associative array of partial string matches per category.
135          $scope.categoryValueMatch = {};
136          $scope.categoryValueMatch.builder = "";
137          $scope.categoryValueMatch.test = "";
138
139          // If any defaults were overridden in the URL, get them now.
140          $scope.queryParameters.load();
141
142          $scope.updateResults();
143          $scope.loadingMessage = "";
144          $scope.windowTitle = "Current GM Results";
145        }
146      }
147    ).error(
148      function(data, status, header, config) {
149        $scope.loadingMessage = "Failed to load results of type '"
150            + $scope.resultsToLoad + "'";
151        $scope.windowTitle = "Failed to Load GM Results";
152      }
153    );
154
155
156    //
157    // Select/Clear/Toggle all tests.
158    //
159
160    /**
161     * Select all currently showing tests.
162     */
163    $scope.selectAllImagePairs = function() {
164      var numImagePairsShowing = $scope.limitedImagePairs.length;
165      for (var i = 0; i < numImagePairsShowing; i++) {
166        var index = $scope.limitedImagePairs[i].index;
167        if (!$scope.isValueInArray(index, $scope.selectedImagePairs)) {
168          $scope.toggleValueInArray(index, $scope.selectedImagePairs);
169        }
170      }
171    }
172
173    /**
174     * Deselect all currently showing tests.
175     */
176    $scope.clearAllImagePairs = function() {
177      var numImagePairsShowing = $scope.limitedImagePairs.length;
178      for (var i = 0; i < numImagePairsShowing; i++) {
179        var index = $scope.limitedImagePairs[i].index;
180        if ($scope.isValueInArray(index, $scope.selectedImagePairs)) {
181          $scope.toggleValueInArray(index, $scope.selectedImagePairs);
182        }
183      }
184    }
185
186    /**
187     * Toggle selection of all currently showing tests.
188     */
189    $scope.toggleAllImagePairs = function() {
190      var numImagePairsShowing = $scope.limitedImagePairs.length;
191      for (var i = 0; i < numImagePairsShowing; i++) {
192        var index = $scope.limitedImagePairs[i].index;
193        $scope.toggleValueInArray(index, $scope.selectedImagePairs);
194      }
195    }
196
197
198    //
199    // Tab operations.
200    //
201
202    /**
203     * Change the selected tab.
204     *
205     * @param tab (string): name of the tab to select
206     */
207    $scope.setViewingTab = function(tab) {
208      $scope.viewingTab = tab;
209      $scope.updateResults();
210    }
211
212    /**
213     * Move the imagePairs in $scope.selectedImagePairs to a different tab,
214     * and then clear $scope.selectedImagePairs.
215     *
216     * @param newTab (string): name of the tab to move the tests to
217     */
218    $scope.moveSelectedImagePairsToTab = function(newTab) {
219      $scope.moveImagePairsToTab($scope.selectedImagePairs, newTab);
220      $scope.selectedImagePairs = [];
221      $scope.updateResults();
222    }
223
224    /**
225     * Move a subset of $scope.imagePairs to a different tab.
226     *
227     * @param imagePairIndices (array of ints): indices into $scope.imagePairs
228     *        indicating which test results to move
229     * @param newTab (string): name of the tab to move the tests to
230     */
231    $scope.moveImagePairsToTab = function(imagePairIndices, newTab) {
232      var imagePairIndex;
233      var numImagePairs = imagePairIndices.length;
234      for (var i = 0; i < numImagePairs; i++) {
235        imagePairIndex = imagePairIndices[i];
236        $scope.numResultsPerTab[$scope.imagePairs[imagePairIndex].tab]--;
237        $scope.imagePairs[imagePairIndex].tab = newTab;
238      }
239      $scope.numResultsPerTab[newTab] += numImagePairs;
240    }
241
242
243    //
244    // $scope.queryParameters:
245    // Transfer parameter values between $scope and the URL query string.
246    //
247    $scope.queryParameters = {};
248
249    // load and save functions for parameters of each type
250    // (load a parameter value into $scope from nameValuePairs,
251    //  save a parameter value from $scope into nameValuePairs)
252    $scope.queryParameters.copiers = {
253      'simple': {
254        'load': function(nameValuePairs, name) {
255          var value = nameValuePairs[name];
256          if (value) {
257            $scope[name] = value;
258          }
259        },
260        'save': function(nameValuePairs, name) {
261          nameValuePairs[name] = $scope[name];
262        }
263      },
264
265      'categoryValueMatch': {
266        'load': function(nameValuePairs, name) {
267          var value = nameValuePairs[name];
268          if (value) {
269            $scope.categoryValueMatch[name] = value;
270          }
271        },
272        'save': function(nameValuePairs, name) {
273          nameValuePairs[name] = $scope.categoryValueMatch[name];
274        }
275      },
276
277      'set': {
278        'load': function(nameValuePairs, name) {
279          var value = nameValuePairs[name];
280          if (value) {
281            var valueArray = value.split(',');
282            $scope[name] = {};
283            $scope.toggleValuesInSet(valueArray, $scope[name]);
284          }
285        },
286        'save': function(nameValuePairs, name) {
287          nameValuePairs[name] = Object.keys($scope[name]).join(',');
288        }
289      },
290
291    };
292
293    // parameter name -> copier objects to load/save parameter value
294    $scope.queryParameters.map = {
295      'resultsToLoad':       $scope.queryParameters.copiers.simple,
296      'displayLimitPending': $scope.queryParameters.copiers.simple,
297      'imageSizePending':    $scope.queryParameters.copiers.simple,
298      'sortColumnSubdict':   $scope.queryParameters.copiers.simple,
299      'sortColumnKey':       $scope.queryParameters.copiers.simple,
300
301      'hiddenResultTypes': $scope.queryParameters.copiers.set,
302      'hiddenConfigs':     $scope.queryParameters.copiers.set,
303    };
304    $scope.queryParameters.map[constants.KEY__EXTRACOLUMN__BUILDER] =
305        $scope.queryParameters.copiers.categoryValueMatch;
306    $scope.queryParameters.map[constants.KEY__EXTRACOLUMN__TEST] =
307        $scope.queryParameters.copiers.categoryValueMatch;
308
309    // Loads all parameters into $scope from the URL query string;
310    // any which are not found within the URL will keep their current value.
311    $scope.queryParameters.load = function() {
312      var nameValuePairs = $location.search();
313      angular.forEach($scope.queryParameters.map,
314                      function(copier, paramName) {
315                        copier.load(nameValuePairs, paramName);
316                      }
317                     );
318    };
319
320    // Saves all parameters from $scope into the URL query string.
321    $scope.queryParameters.save = function() {
322      var nameValuePairs = {};
323      angular.forEach($scope.queryParameters.map,
324                      function(copier, paramName) {
325                        copier.save(nameValuePairs, paramName);
326                      }
327                     );
328      $location.search(nameValuePairs);
329    };
330
331
332    //
333    // updateResults() and friends.
334    //
335
336    /**
337     * Set $scope.areUpdatesPending (to enable/disable the Update Results
338     * button).
339     *
340     * TODO(epoger): We could reduce the amount of code by just setting the
341     * variable directly (from, e.g., a button's ng-click handler).  But when
342     * I tried that, the HTML elements depending on the variable did not get
343     * updated.
344     * It turns out that this is due to variable scoping within an ng-repeat
345     * element; see http://stackoverflow.com/questions/15388344/behavior-of-assignment-expression-invoked-by-ng-click-within-ng-repeat
346     *
347     * @param val boolean value to set $scope.areUpdatesPending to
348     */
349    $scope.setUpdatesPending = function(val) {
350      $scope.areUpdatesPending = val;
351    }
352
353    /**
354     * Update the displayed results, based on filters/settings,
355     * and call $scope.queryParameters.save() so that the new filter results
356     * can be bookmarked.
357     */
358    $scope.updateResults = function() {
359      $scope.displayLimit = $scope.displayLimitPending;
360      // TODO(epoger): Every time we apply a filter, AngularJS creates
361      // another copy of the array.  Is there a way we can filter out
362      // the imagePairs as they are displayed, rather than storing multiple
363      // array copies?  (For better performance.)
364
365      if ($scope.viewingTab == $scope.defaultTab) {
366
367        // TODO(epoger): Until we allow the user to reverse sort order,
368        // there are certain columns we want to sort in a different order.
369        var doReverse = (
370            ($scope.sortColumnKey ==
371             constants.KEY__DIFFERENCE_DATA__PERCENT_DIFF_PIXELS) ||
372            ($scope.sortColumnKey ==
373             constants.KEY__DIFFERENCE_DATA__WEIGHTED_DIFF));
374
375        $scope.filteredImagePairs =
376            $filter("orderBy")(
377                $filter("removeHiddenImagePairs")(
378                    $scope.imagePairs,
379                    $scope.hiddenResultTypes,
380                    $scope.hiddenConfigs,
381                    $scope.categoryValueMatch.builder,
382                    $scope.categoryValueMatch.test,
383                    $scope.viewingTab
384                ),
385                $scope.getSortColumnValue, doReverse);
386        $scope.limitedImagePairs = $filter("limitTo")(
387            $scope.filteredImagePairs, $scope.displayLimit);
388      } else {
389        $scope.filteredImagePairs =
390            $filter("orderBy")(
391                $filter("filter")(
392                    $scope.imagePairs,
393                    {tab: $scope.viewingTab},
394                    true
395                ),
396                $scope.getSortColumnValue);
397        $scope.limitedImagePairs = $scope.filteredImagePairs;
398      }
399      $scope.imageSize = $scope.imageSizePending;
400      $scope.setUpdatesPending(false);
401      $scope.queryParameters.save();
402    }
403
404    /**
405     * Re-sort the displayed results.
406     *
407     * @param subdict (string): which subdictionary
408     *     (constants.KEY__DIFFERENCE_DATA, constants.KEY__EXPECTATIONS_DATA,
409     *      constants.KEY__EXTRA_COLUMN_VALUES) the sort column key is within
410     * @param key (string): sort by value associated with this key in subdict
411     */
412    $scope.sortResultsBy = function(subdict, key) {
413      $scope.sortColumnSubdict = subdict;
414      $scope.sortColumnKey = key;
415      $scope.updateResults();
416    }
417
418    /**
419     * For a particular ImagePair, return the value of the column we are
420     * sorting on (according to $scope.sortColumnSubdict and
421     * $scope.sortColumnKey).
422     *
423     * @param imagePair: imagePair to get a column value out of.
424     */
425    $scope.getSortColumnValue = function(imagePair) {
426      if ($scope.sortColumnSubdict in imagePair) {
427        return imagePair[$scope.sortColumnSubdict][$scope.sortColumnKey];
428      } else {
429        return undefined;
430      }
431    }
432
433    /**
434     * Set $scope.categoryValueMatch[name] = value, and update results.
435     *
436     * @param name
437     * @param value
438     */
439    $scope.setCategoryValueMatch = function(name, value) {
440      $scope.categoryValueMatch[name] = value;
441      $scope.updateResults();
442    }
443
444    /**
445     * Update $scope.hiddenResultTypes so that ONLY this resultType is showing,
446     * and update the visible results.
447     *
448     * @param resultType
449     */
450    $scope.showOnlyResultType = function(resultType) {
451      $scope.hiddenResultTypes = {};
452      // TODO(epoger): Maybe change $scope.allResultTypes to be a Set like
453      // $scope.hiddenResultTypes (rather than an array), so this operation is
454      // simpler (just assign or add allResultTypes to hiddenResultTypes).
455      $scope.toggleValuesInSet($scope.allResultTypes, $scope.hiddenResultTypes);
456      $scope.toggleValueInSet(resultType, $scope.hiddenResultTypes);
457      $scope.updateResults();
458    }
459
460    /**
461     * Update $scope.hiddenResultTypes so that ALL resultTypes are showing,
462     * and update the visible results.
463     */
464    $scope.showAllResultTypes = function() {
465      $scope.hiddenResultTypes = {};
466      $scope.updateResults();
467    }
468
469    /**
470     * Update $scope.hiddenConfigs so that ONLY this config is showing,
471     * and update the visible results.
472     *
473     * @param config
474     */
475    $scope.showOnlyConfig = function(config) {
476      $scope.hiddenConfigs = {};
477      $scope.toggleValuesInSet($scope.allConfigs, $scope.hiddenConfigs);
478      $scope.toggleValueInSet(config, $scope.hiddenConfigs);
479      $scope.updateResults();
480    }
481
482    /**
483     * Update $scope.hiddenConfigs so that ALL configs are showing,
484     * and update the visible results.
485     */
486    $scope.showAllConfigs = function() {
487      $scope.hiddenConfigs = {};
488      $scope.updateResults();
489    }
490
491
492    //
493    // Operations for sending info back to the server.
494    //
495
496    /**
497     * Tell the server that the actual results of these particular tests
498     * are acceptable.
499     *
500     * TODO(epoger): This assumes that the original expectations are in
501     * imageSetA, and the actuals are in imageSetB.
502     *
503     * @param imagePairsSubset an array of test results, most likely a subset of
504     *        $scope.imagePairs (perhaps with some modifications)
505     */
506    $scope.submitApprovals = function(imagePairsSubset) {
507      $scope.submitPending = true;
508
509      // Convert bug text field to null or 1-item array.
510      var bugs = null;
511      var bugNumber = parseInt($scope.submitAdvancedSettings['bug']);
512      if (!isNaN(bugNumber)) {
513        bugs = [bugNumber];
514      }
515
516      // TODO(epoger): This is a suboptimal way to prevent users from
517      // rebaselining failures in alternative renderModes, but it does work.
518      // For a better solution, see
519      // https://code.google.com/p/skia/issues/detail?id=1748 ('gm: add new
520      // result type, RenderModeMismatch')
521      var encounteredComparisonConfig = false;
522
523      var updatedExpectations = [];
524      for (var i = 0; i < imagePairsSubset.length; i++) {
525        var imagePair = imagePairsSubset[i];
526        var updatedExpectation = {};
527        updatedExpectation[constants.KEY__EXPECTATIONS_DATA] =
528            imagePair[constants.KEY__EXPECTATIONS_DATA];
529        updatedExpectation[constants.KEY__EXTRA_COLUMN_VALUES] =
530            imagePair[constants.KEY__EXTRA_COLUMN_VALUES];
531        updatedExpectation[constants.KEY__NEW_IMAGE_URL] =
532            imagePair[constants.KEY__IMAGE_B_URL];
533        if (0 == updatedExpectation[constants.KEY__EXTRA_COLUMN_VALUES]
534                                   [constants.KEY__EXTRACOLUMN__CONFIG]
535                                   .indexOf('comparison-')) {
536          encounteredComparisonConfig = true;
537        }
538
539        // Advanced settings...
540        if (null == updatedExpectation[constants.KEY__EXPECTATIONS_DATA]) {
541          updatedExpectation[constants.KEY__EXPECTATIONS_DATA] = {};
542        }
543        updatedExpectation[constants.KEY__EXPECTATIONS_DATA]
544                          [constants.KEY__EXPECTATIONS__REVIEWED] =
545            $scope.submitAdvancedSettings[
546                constants.KEY__EXPECTATIONS__REVIEWED];
547        if (true == $scope.submitAdvancedSettings[
548            constants.KEY__EXPECTATIONS__IGNOREFAILURE]) {
549          // if it's false, don't send it at all (just keep the default)
550          updatedExpectation[constants.KEY__EXPECTATIONS_DATA]
551                            [constants.KEY__EXPECTATIONS__IGNOREFAILURE] = true;
552        }
553        updatedExpectation[constants.KEY__EXPECTATIONS_DATA]
554                          [constants.KEY__EXPECTATIONS__BUGS] = bugs;
555
556        updatedExpectations.push(updatedExpectation);
557      }
558      if (encounteredComparisonConfig) {
559        alert("Approval failed -- you cannot approve results with config " +
560            "type comparison-*");
561        $scope.submitPending = false;
562        return;
563      }
564      var modificationData = {};
565      modificationData[constants.KEY__EDITS__MODIFICATIONS] =
566          updatedExpectations;
567      modificationData[constants.KEY__EDITS__OLD_RESULTS_HASH] =
568          $scope.header[constants.KEY__HEADER__DATAHASH];
569      modificationData[constants.KEY__EDITS__OLD_RESULTS_TYPE] =
570          $scope.header[constants.KEY__HEADER__TYPE];
571      $http({
572        method: "POST",
573        url: "/edits",
574        data: modificationData
575      }).success(function(data, status, headers, config) {
576        var imagePairIndicesToMove = [];
577        for (var i = 0; i < imagePairsSubset.length; i++) {
578          imagePairIndicesToMove.push(imagePairsSubset[i].index);
579        }
580        $scope.moveImagePairsToTab(imagePairIndicesToMove,
581                                   "HackToMakeSureThisImagePairDisappears");
582        $scope.updateResults();
583        alert("New baselines submitted successfully!\n\n" +
584            "You still need to commit the updated expectations files on " +
585            "the server side to the Skia repo.\n\n" +
586            "When you click OK, your web UI will reload; after that " +
587            "completes, you will see the updated data (once the server has " +
588            "finished loading the update results into memory!) and you can " +
589            "submit more baselines if you want.");
590        // I don't know why, but if I just call reload() here it doesn't work.
591        // Making a timer call it fixes the problem.
592        $timeout(function(){location.reload();}, 1);
593      }).error(function(data, status, headers, config) {
594        alert("There was an error submitting your baselines.\n\n" +
595            "Please see server-side log for details.");
596        $scope.submitPending = false;
597      });
598    }
599
600
601    //
602    // Operations we use to mimic Set semantics, in such a way that
603    // checking for presence within the Set is as fast as possible.
604    // But getting a list of all values within the Set is not necessarily
605    // possible.
606    // TODO(epoger): move into a separate .js file?
607    //
608
609    /**
610     * Returns the number of values present within set "set".
611     *
612     * @param set an Object which we use to mimic set semantics
613     */
614    $scope.setSize = function(set) {
615      return Object.keys(set).length;
616    }
617
618    /**
619     * Returns true if value "value" is present within set "set".
620     *
621     * @param value a value of any type
622     * @param set an Object which we use to mimic set semantics
623     *        (this should make isValueInSet faster than if we used an Array)
624     */
625    $scope.isValueInSet = function(value, set) {
626      return (true == set[value]);
627    }
628
629    /**
630     * If value "value" is already in set "set", remove it; otherwise, add it.
631     *
632     * @param value a value of any type
633     * @param set an Object which we use to mimic set semantics
634     */
635    $scope.toggleValueInSet = function(value, set) {
636      if (true == set[value]) {
637        delete set[value];
638      } else {
639        set[value] = true;
640      }
641    }
642
643    /**
644     * For each value in valueArray, call toggleValueInSet(value, set).
645     *
646     * @param valueArray
647     * @param set
648     */
649    $scope.toggleValuesInSet = function(valueArray, set) {
650      var arrayLength = valueArray.length;
651      for (var i = 0; i < arrayLength; i++) {
652        $scope.toggleValueInSet(valueArray[i], set);
653      }
654    }
655
656
657    //
658    // Array operations; similar to our Set operations, but operate on a
659    // Javascript Array so we *can* easily get a list of all values in the Set.
660    // TODO(epoger): move into a separate .js file?
661    //
662
663    /**
664     * Returns true if value "value" is present within array "array".
665     *
666     * @param value a value of any type
667     * @param array a Javascript Array
668     */
669    $scope.isValueInArray = function(value, array) {
670      return (-1 != array.indexOf(value));
671    }
672
673    /**
674     * If value "value" is already in array "array", remove it; otherwise,
675     * add it.
676     *
677     * @param value a value of any type
678     * @param array a Javascript Array
679     */
680    $scope.toggleValueInArray = function(value, array) {
681      var i = array.indexOf(value);
682      if (-1 == i) {
683        array.push(value);
684      } else {
685        array.splice(i, 1);
686      }
687    }
688
689
690    //
691    // Miscellaneous utility functions.
692    // TODO(epoger): move into a separate .js file?
693    //
694
695    /**
696     * Returns a human-readable (in local time zone) time string for a
697     * particular moment in time.
698     *
699     * @param secondsPastEpoch (numeric): seconds past epoch in UTC
700     */
701    $scope.localTimeString = function(secondsPastEpoch) {
702      var d = new Date(secondsPastEpoch * 1000);
703      return d.toString();
704    }
705
706    /**
707     * Returns a hex color string (such as "#aabbcc") for the given RGB values.
708     *
709     * @param r (numeric): red channel value, 0-255
710     * @param g (numeric): green channel value, 0-255
711     * @param b (numeric): blue channel value, 0-255
712     */
713    $scope.hexColorString = function(r, g, b) {
714      var rString = r.toString(16);
715      if (r < 16) {
716        rString = "0" + rString;
717      }
718      var gString = g.toString(16);
719      if (g < 16) {
720        gString = "0" + gString;
721      }
722      var bString = b.toString(16);
723      if (b < 16) {
724        bString = "0" + bString;
725      }
726      return '#' + rString + gString + bString;
727    }
728
729    /**
730     * Returns a hex color string (such as "#aabbcc") for the given brightness.
731     *
732     * @param brightnessString (string): 0-255, 0 is completely black
733     *
734     * TODO(epoger): It might be nice to tint the color when it's not completely
735     * black or completely white.
736     */
737    $scope.brightnessStringToHexColor = function(brightnessString) {
738      var v = parseInt(brightnessString);
739      return $scope.hexColorString(v, v, v);
740    }
741
742    /**
743     * Returns the last path component of image diff URL for a given ImagePair.
744     *
745     * Depending on which diff this is (whitediffs, pixeldiffs, etc.) this
746     * will be relative to different base URLs.
747     *
748     * We must keep this function in sync with _get_difference_locator() in
749     * ../imagediffdb.py
750     *
751     * @param imagePair: ImagePair to generate image diff URL for
752     */
753    $scope.getImageDiffRelativeUrl = function(imagePair) {
754      var before =
755          imagePair[constants.KEY__IMAGE_A_URL] + "-vs-" +
756          imagePair[constants.KEY__IMAGE_B_URL];
757      return before.replace(/[^\w\-]/g, "_") + ".png";
758    }
759
760  }
761);
762