aboutsummaryrefslogtreecommitdiffhomepage
path: root/gm/rebaseline_server/static
diff options
context:
space:
mode:
authorGravatar Brian Salomon <bsalomon@google.com>2015-01-16 16:26:32 -0500
committerGravatar Brian Salomon <bsalomon@google.com>2015-01-16 16:26:32 -0500
commit8b4489b6e696ce4b1abbffa9b2cbd0d3bfdeb387 (patch)
tree71654dae8a3b62af5907eccae7b93d00e3ec9c10 /gm/rebaseline_server/static
parent15b125d40122e966bd723d23e82c3224b1da4898 (diff)
Revert "delete old things!"
This reverts commit 15b125d40122e966bd723d23e82c3224b1da4898. NOTREECHECKS=true original change breaks android tree BUG=skia: Review URL: https://codereview.chromium.org/848073005
Diffstat (limited to 'gm/rebaseline_server/static')
-rw-r--r--gm/rebaseline_server/static/.gitignore3
-rw-r--r--gm/rebaseline_server/static/constants.js100
-rw-r--r--gm/rebaseline_server/static/live-loader.js1024
-rw-r--r--gm/rebaseline_server/static/live-view.html446
-rw-r--r--gm/rebaseline_server/static/loader.js1035
-rw-r--r--gm/rebaseline_server/static/new/bower.json22
-rw-r--r--gm/rebaseline_server/static/new/css/app.css71
-rw-r--r--gm/rebaseline_server/static/new/js/app.js1130
-rw-r--r--gm/rebaseline_server/static/new/new-index.html37
-rw-r--r--gm/rebaseline_server/static/new/partials/index-view.html22
-rw-r--r--gm/rebaseline_server/static/new/partials/rebaseline-view.html207
-rw-r--r--gm/rebaseline_server/static/utils.js12
-rw-r--r--gm/rebaseline_server/static/view.css104
-rw-r--r--gm/rebaseline_server/static/view.html417
14 files changed, 4630 insertions, 0 deletions
diff --git a/gm/rebaseline_server/static/.gitignore b/gm/rebaseline_server/static/.gitignore
new file mode 100644
index 0000000000..c8e67d128f
--- /dev/null
+++ b/gm/rebaseline_server/static/.gitignore
@@ -0,0 +1,3 @@
+generated-html/
+generated-images/
+generated-json/
diff --git a/gm/rebaseline_server/static/constants.js b/gm/rebaseline_server/static/constants.js
new file mode 100644
index 0000000000..a9601ece71
--- /dev/null
+++ b/gm/rebaseline_server/static/constants.js
@@ -0,0 +1,100 @@
+/*
+ * Constants used by our imagediff-viewing Javascript code.
+ */
+var module = angular.module(
+ 'ConstantsModule',
+ []
+);
+
+module.constant('constants', (function() {
+ return {
+ // NOTE: Keep these in sync with ../column.py
+ KEY__EXTRACOLUMNHEADERS__HEADER_TEXT: 'headerText',
+ KEY__EXTRACOLUMNHEADERS__HEADER_URL: 'headerUrl',
+ KEY__EXTRACOLUMNHEADERS__IS_FILTERABLE: 'isFilterable',
+ KEY__EXTRACOLUMNHEADERS__IS_SORTABLE: 'isSortable',
+ KEY__EXTRACOLUMNHEADERS__USE_FREEFORM_FILTER: 'useFreeformFilter',
+ KEY__EXTRACOLUMNHEADERS__VALUES_AND_COUNTS: 'valuesAndCounts',
+
+ // NOTE: Keep these in sync with ../imagediffdb.py
+ KEY__DIFFERENCES__MAX_DIFF_PER_CHANNEL: 'maxDiffPerChannel',
+ KEY__DIFFERENCES__NUM_DIFF_PIXELS: 'numDifferingPixels',
+ KEY__DIFFERENCES__PERCENT_DIFF_PIXELS: 'percentDifferingPixels',
+ KEY__DIFFERENCES__PERCEPTUAL_DIFF: 'perceptualDifference',
+ KEY__DIFFERENCES__DIFF_URL: 'diffUrl',
+ KEY__DIFFERENCES__WHITE_DIFF_URL: 'whiteDiffUrl',
+
+ // NOTE: Keep these in sync with ../imagepair.py
+ KEY__IMAGEPAIRS__DIFFERENCES: 'differenceData',
+ KEY__IMAGEPAIRS__EXPECTATIONS: 'expectations',
+ KEY__IMAGEPAIRS__EXTRACOLUMNS: 'extraColumns',
+ KEY__IMAGEPAIRS__IMAGE_A_URL: 'imageAUrl',
+ KEY__IMAGEPAIRS__IMAGE_B_URL: 'imageBUrl',
+ KEY__IMAGEPAIRS__IS_DIFFERENT: 'isDifferent',
+ KEY__IMAGEPAIRS__SOURCE_JSON_FILE: 'sourceJsonFile',
+
+ // NOTE: Keep these in sync with ../imagepairset.py
+ KEY__ROOT__EXTRACOLUMNHEADERS: 'extraColumnHeaders',
+ KEY__ROOT__EXTRACOLUMNORDER: 'extraColumnOrder',
+ KEY__ROOT__HEADER: 'header',
+ KEY__ROOT__IMAGEPAIRS: 'imagePairs',
+ KEY__ROOT__IMAGESETS: 'imageSets',
+ //
+ KEY__IMAGESETS__FIELD__BASE_URL: 'baseUrl',
+ KEY__IMAGESETS__FIELD__DESCRIPTION: 'description',
+ KEY__IMAGESETS__SET__DIFFS: 'diffs',
+ KEY__IMAGESETS__SET__IMAGE_A: 'imageA',
+ KEY__IMAGESETS__SET__IMAGE_B: 'imageB',
+ KEY__IMAGESETS__SET__WHITEDIFFS: 'whiteDiffs',
+
+ // NOTE: Keep these in sync with ../results.py
+ KEY__EXPECTATIONS__BUGS: 'bugs',
+ KEY__EXPECTATIONS__IGNOREFAILURE: 'ignore-failure',
+ KEY__EXPECTATIONS__REVIEWED: 'reviewed-by-human',
+ //
+ KEY__EXTRACOLUMNS__BUILDER: 'builder',
+ KEY__EXTRACOLUMNS__CONFIG: 'config',
+ KEY__EXTRACOLUMNS__RESULT_TYPE: 'resultType',
+ KEY__EXTRACOLUMNS__TEST: 'test',
+ //
+ KEY__HEADER__DATAHASH: 'dataHash',
+ KEY__HEADER__IS_EDITABLE: 'isEditable',
+ KEY__HEADER__IS_EXPORTED: 'isExported',
+ KEY__HEADER__IS_STILL_LOADING: 'resultsStillLoading',
+ KEY__HEADER__RESULTS_ALL: 'all',
+ KEY__HEADER__RESULTS_FAILURES: 'failures',
+ KEY__HEADER__SCHEMA_VERSION: 'schemaVersion',
+ KEY__HEADER__SET_A_DESCRIPTIONS: 'setA',
+ KEY__HEADER__SET_B_DESCRIPTIONS: 'setB',
+ KEY__HEADER__TIME_NEXT_UPDATE_AVAILABLE: 'timeNextUpdateAvailable',
+ KEY__HEADER__TIME_UPDATED: 'timeUpdated',
+ KEY__HEADER__TYPE: 'type',
+ VALUE__HEADER__SCHEMA_VERSION: 5,
+ //
+ KEY__RESULT_TYPE__FAILED: 'failed',
+ KEY__RESULT_TYPE__FAILUREIGNORED: 'failure-ignored',
+ KEY__RESULT_TYPE__NOCOMPARISON: 'no-comparison',
+ KEY__RESULT_TYPE__SUCCEEDED: 'succeeded',
+ //
+ KEY__SET_DESCRIPTIONS__DIR: 'dir',
+ KEY__SET_DESCRIPTIONS__REPO_REVISION: 'repoRevision',
+ KEY__SET_DESCRIPTIONS__SECTION: 'section',
+
+ // NOTE: Keep these in sync with ../server.py
+ KEY__EDITS__MODIFICATIONS: 'modifications',
+ KEY__EDITS__OLD_RESULTS_HASH: 'oldResultsHash',
+ KEY__EDITS__OLD_RESULTS_TYPE: 'oldResultsType',
+ KEY__LIVE_EDITS__MODIFICATIONS: 'modifications',
+ KEY__LIVE_EDITS__SET_A_DESCRIPTIONS: 'setA',
+ KEY__LIVE_EDITS__SET_B_DESCRIPTIONS: 'setB',
+
+ // These are just used on the client side, no need to sync with server code.
+ KEY__IMAGEPAIRS__ROWSPAN: 'rowspan',
+ URL_KEY__SCHEMA_VERSION: 'urlSchemaVersion',
+ URL_VALUE__SCHEMA_VERSION__CURRENT: 1,
+
+ // Utility constants only used on the client side.
+ ASC: 'asc',
+ DESC: 'desc',
+ }
+})())
diff --git a/gm/rebaseline_server/static/live-loader.js b/gm/rebaseline_server/static/live-loader.js
new file mode 100644
index 0000000000..ab15aee41a
--- /dev/null
+++ b/gm/rebaseline_server/static/live-loader.js
@@ -0,0 +1,1024 @@
+/*
+ * Loader:
+ * Reads GM result reports written out by results.py, and imports
+ * them into $scope.extraColumnHeaders and $scope.imagePairs .
+ */
+var Loader = angular.module(
+ 'Loader',
+ ['ConstantsModule']
+);
+
+// This configuration is needed to allow downloads of the diff patch.
+// See https://github.com/angular/angular.js/issues/3889
+Loader.config(['$compileProvider', function($compileProvider) {
+ $compileProvider.aHrefSanitizationWhitelist(/^\s*(https?|ftp|file|blob):/);
+}]);
+
+Loader.directive(
+ 'resultsUpdatedCallbackDirective',
+ ['$timeout',
+ function($timeout) {
+ return function(scope, element, attrs) {
+ if (scope.$last) {
+ $timeout(function() {
+ scope.resultsUpdatedCallback();
+ });
+ }
+ };
+ }
+ ]
+);
+
+// TODO(epoger): Combine ALL of our filtering operations (including
+// truncation) into this one filter, so that runs most efficiently?
+// (We would have to make sure truncation still took place after
+// sorting, though.)
+Loader.filter(
+ 'removeHiddenImagePairs',
+ function(constants) {
+ return function(unfilteredImagePairs, filterableColumnNames, showingColumnValues,
+ viewingTab) {
+ var filteredImagePairs = [];
+ for (var i = 0; i < unfilteredImagePairs.length; i++) {
+ var imagePair = unfilteredImagePairs[i];
+ var extraColumnValues = imagePair[constants.KEY__IMAGEPAIRS__EXTRACOLUMNS];
+ var allColumnValuesAreVisible = true;
+ // Loop over all columns, and if any of them contain values not found in
+ // showingColumnValues[columnName], don't include this imagePair.
+ //
+ // We use this same filtering mechanism regardless of whether each column
+ // has USE_FREEFORM_FILTER set or not; if that flag is set, then we will
+ // have already used the freeform text entry block to populate
+ // showingColumnValues[columnName].
+ for (var j = 0; j < filterableColumnNames.length; j++) {
+ var columnName = filterableColumnNames[j];
+ var columnValue = extraColumnValues[columnName];
+ if (!showingColumnValues[columnName][columnValue]) {
+ allColumnValuesAreVisible = false;
+ break;
+ }
+ }
+ if (allColumnValuesAreVisible && (viewingTab == imagePair.tab)) {
+ filteredImagePairs.push(imagePair);
+ }
+ }
+ return filteredImagePairs;
+ };
+ }
+);
+
+/**
+ * Limit the input imagePairs to some max number, and merge identical rows
+ * (adjacent rows which have the same (imageA, imageB) pair).
+ *
+ * @param unfilteredImagePairs imagePairs to filter
+ * @param maxPairs maximum number of pairs to output, or <0 for no limit
+ * @param mergeIdenticalRows if true, merge identical rows by setting
+ * ROWSPAN>1 on the first merged row, and ROWSPAN=0 for the rest
+ */
+Loader.filter(
+ 'mergeAndLimit',
+ function(constants) {
+ return function(unfilteredImagePairs, maxPairs, mergeIdenticalRows) {
+ var numPairs = unfilteredImagePairs.length;
+ if ((maxPairs > 0) && (maxPairs < numPairs)) {
+ numPairs = maxPairs;
+ }
+ var filteredImagePairs = [];
+ if (!mergeIdenticalRows || (numPairs == 1)) {
+ // Take a shortcut if we're not merging identical rows.
+ // We still need to set ROWSPAN to 1 for each row, for the HTML viewer.
+ for (var i = numPairs-1; i >= 0; i--) {
+ var imagePair = unfilteredImagePairs[i];
+ imagePair[constants.KEY__IMAGEPAIRS__ROWSPAN] = 1;
+ filteredImagePairs[i] = imagePair;
+ }
+ } else if (numPairs > 1) {
+ // General case--there are at least 2 rows, so we may need to merge some.
+ // Work from the bottom up, so we can keep a running total of how many
+ // rows should be merged, and set ROWSPAN of the top row accordingly.
+ var imagePair = unfilteredImagePairs[numPairs-1];
+ var nextRowImageAUrl = imagePair[constants.KEY__IMAGEPAIRS__IMAGE_A_URL];
+ var nextRowImageBUrl = imagePair[constants.KEY__IMAGEPAIRS__IMAGE_B_URL];
+ imagePair[constants.KEY__IMAGEPAIRS__ROWSPAN] = 1;
+ filteredImagePairs[numPairs-1] = imagePair;
+ for (var i = numPairs-2; i >= 0; i--) {
+ imagePair = unfilteredImagePairs[i];
+ var thisRowImageAUrl = imagePair[constants.KEY__IMAGEPAIRS__IMAGE_A_URL];
+ var thisRowImageBUrl = imagePair[constants.KEY__IMAGEPAIRS__IMAGE_B_URL];
+ if ((thisRowImageAUrl == nextRowImageAUrl) &&
+ (thisRowImageBUrl == nextRowImageBUrl)) {
+ imagePair[constants.KEY__IMAGEPAIRS__ROWSPAN] =
+ filteredImagePairs[i+1][constants.KEY__IMAGEPAIRS__ROWSPAN] + 1;
+ filteredImagePairs[i+1][constants.KEY__IMAGEPAIRS__ROWSPAN] = 0;
+ } else {
+ imagePair[constants.KEY__IMAGEPAIRS__ROWSPAN] = 1;
+ nextRowImageAUrl = thisRowImageAUrl;
+ nextRowImageBUrl = thisRowImageBUrl;
+ }
+ filteredImagePairs[i] = imagePair;
+ }
+ } else {
+ // No results.
+ }
+ return filteredImagePairs;
+ };
+ }
+);
+
+
+Loader.controller(
+ 'Loader.Controller',
+ function($scope, $http, $filter, $location, $log, $timeout, constants) {
+ $scope.readyToDisplay = false;
+ $scope.constants = constants;
+ $scope.windowTitle = "Loading GM Results...";
+ $scope.setADir = $location.search().setADir;
+ $scope.setASection = $location.search().setASection;
+ $scope.setBDir = $location.search().setBDir;
+ $scope.setBSection = $location.search().setBSection;
+ $scope.loadingMessage = "please wait...";
+
+ var currSortAsc = true;
+
+
+ /**
+ * On initial page load, load a full dictionary of results.
+ * Once the dictionary is loaded, unhide the page elements so they can
+ * render the data.
+ */
+ $scope.liveQueryUrl =
+ "/live-results/setADir=" + encodeURIComponent($scope.setADir) +
+ "&setASection=" + encodeURIComponent($scope.setASection) +
+ "&setBDir=" + encodeURIComponent($scope.setBDir) +
+ "&setBSection=" + encodeURIComponent($scope.setBSection);
+ $http.get($scope.liveQueryUrl).success(
+ function(data, status, header, config) {
+ var dataHeader = data[constants.KEY__ROOT__HEADER];
+ if (dataHeader[constants.KEY__HEADER__SCHEMA_VERSION] !=
+ constants.VALUE__HEADER__SCHEMA_VERSION) {
+ $scope.loadingMessage = "ERROR: Got JSON file with schema version "
+ + dataHeader[constants.KEY__HEADER__SCHEMA_VERSION]
+ + " but expected schema version "
+ + constants.VALUE__HEADER__SCHEMA_VERSION;
+ } else if (dataHeader[constants.KEY__HEADER__IS_STILL_LOADING]) {
+ // Apply the server's requested reload delay to local time,
+ // so we will wait the right number of seconds regardless of clock
+ // skew between client and server.
+ var reloadDelayInSeconds =
+ dataHeader[constants.KEY__HEADER__TIME_NEXT_UPDATE_AVAILABLE] -
+ dataHeader[constants.KEY__HEADER__TIME_UPDATED];
+ var timeNow = new Date().getTime();
+ var timeToReload = timeNow + reloadDelayInSeconds * 1000;
+ $scope.loadingMessage =
+ "server is still loading results; will retry at " +
+ $scope.localTimeString(timeToReload / 1000);
+ $timeout(
+ function(){location.reload();},
+ timeToReload - timeNow);
+ } else {
+ $scope.loadingMessage = "processing data, please wait...";
+
+ $scope.header = dataHeader;
+ $scope.extraColumnHeaders = data[constants.KEY__ROOT__EXTRACOLUMNHEADERS];
+ $scope.orderedColumnNames = data[constants.KEY__ROOT__EXTRACOLUMNORDER];
+ $scope.imagePairs = data[constants.KEY__ROOT__IMAGEPAIRS];
+ $scope.imageSets = data[constants.KEY__ROOT__IMAGESETS];
+
+ // set the default sort column and make it ascending.
+ $scope.sortColumnSubdict = constants.KEY__IMAGEPAIRS__DIFFERENCES;
+ $scope.sortColumnKey = constants.KEY__DIFFERENCES__PERCEPTUAL_DIFF;
+ currSortAsc = true;
+
+ $scope.showSubmitAdvancedSettings = false;
+ $scope.submitAdvancedSettings = {};
+ $scope.submitAdvancedSettings[
+ constants.KEY__EXPECTATIONS__REVIEWED] = true;
+ $scope.submitAdvancedSettings[
+ constants.KEY__EXPECTATIONS__IGNOREFAILURE] = false;
+ $scope.submitAdvancedSettings['bug'] = '';
+
+ // Create the list of tabs (lists into which the user can file each
+ // test). This may vary, depending on isEditable.
+ $scope.tabs = [
+ 'Unfiled', 'Hidden'
+ ];
+ if (dataHeader[constants.KEY__HEADER__IS_EDITABLE]) {
+ $scope.tabs = $scope.tabs.concat(
+ ['Pending Approval']);
+ }
+ $scope.defaultTab = $scope.tabs[0];
+ $scope.viewingTab = $scope.defaultTab;
+
+ // Track the number of results on each tab.
+ $scope.numResultsPerTab = {};
+ for (var i = 0; i < $scope.tabs.length; i++) {
+ $scope.numResultsPerTab[$scope.tabs[i]] = 0;
+ }
+ $scope.numResultsPerTab[$scope.defaultTab] = $scope.imagePairs.length;
+
+ // Add index and tab fields to all records.
+ for (var i = 0; i < $scope.imagePairs.length; i++) {
+ $scope.imagePairs[i].index = i;
+ $scope.imagePairs[i].tab = $scope.defaultTab;
+ }
+
+ // Arrays within which the user can toggle individual elements.
+ $scope.selectedImagePairs = [];
+
+ // Set up filters.
+ //
+ // filterableColumnNames is a list of all column names we can filter on.
+ // allColumnValues[columnName] is a list of all known values
+ // for a given column.
+ // showingColumnValues[columnName] is a set indicating which values
+ // in a given column would cause us to show a row, rather than hiding it.
+ //
+ // columnStringMatch[columnName] is a string used as a pattern to generate
+ // showingColumnValues[columnName] for columns we filter using free-form text.
+ // It is ignored for any columns with USE_FREEFORM_FILTER == false.
+ $scope.filterableColumnNames = [];
+ $scope.allColumnValues = {};
+ $scope.showingColumnValues = {};
+ $scope.columnStringMatch = {};
+
+ angular.forEach(
+ Object.keys($scope.extraColumnHeaders),
+ function(columnName) {
+ var columnHeader = $scope.extraColumnHeaders[columnName];
+ if (columnHeader[constants.KEY__EXTRACOLUMNHEADERS__IS_FILTERABLE]) {
+ $scope.filterableColumnNames.push(columnName);
+ $scope.allColumnValues[columnName] = $scope.columnSliceOf2DArray(
+ columnHeader[constants.KEY__EXTRACOLUMNHEADERS__VALUES_AND_COUNTS], 0);
+ $scope.showingColumnValues[columnName] = {};
+ $scope.toggleValuesInSet($scope.allColumnValues[columnName],
+ $scope.showingColumnValues[columnName]);
+ $scope.columnStringMatch[columnName] = "";
+ }
+ }
+ );
+
+ // TODO(epoger): Special handling for RESULT_TYPE column:
+ // by default, show only KEY__RESULT_TYPE__FAILED results
+ $scope.showingColumnValues[constants.KEY__EXTRACOLUMNS__RESULT_TYPE] = {};
+ $scope.showingColumnValues[constants.KEY__EXTRACOLUMNS__RESULT_TYPE][
+ constants.KEY__RESULT_TYPE__FAILED] = true;
+
+ // Set up mapping for URL parameters.
+ // parameter name -> copier object to load/save parameter value
+ $scope.queryParameters.map = {
+ 'setADir': $scope.queryParameters.copiers.simple,
+ 'setASection': $scope.queryParameters.copiers.simple,
+ 'setBDir': $scope.queryParameters.copiers.simple,
+ 'setBSection': $scope.queryParameters.copiers.simple,
+ 'displayLimitPending': $scope.queryParameters.copiers.simple,
+ 'showThumbnailsPending': $scope.queryParameters.copiers.simple,
+ 'mergeIdenticalRowsPending': $scope.queryParameters.copiers.simple,
+ 'imageSizePending': $scope.queryParameters.copiers.simple,
+ 'sortColumnSubdict': $scope.queryParameters.copiers.simple,
+ 'sortColumnKey': $scope.queryParameters.copiers.simple,
+ };
+ // Some parameters are handled differently based on whether they USE_FREEFORM_FILTER.
+ angular.forEach(
+ $scope.filterableColumnNames,
+ function(columnName) {
+ if ($scope.extraColumnHeaders[columnName]
+ [constants.KEY__EXTRACOLUMNHEADERS__USE_FREEFORM_FILTER]) {
+ $scope.queryParameters.map[columnName] =
+ $scope.queryParameters.copiers.columnStringMatch;
+ } else {
+ $scope.queryParameters.map[columnName] =
+ $scope.queryParameters.copiers.showingColumnValuesSet;
+ }
+ }
+ );
+
+ // If any defaults were overridden in the URL, get them now.
+ $scope.queryParameters.load();
+
+ // Any image URLs which are relative should be relative to the JSON
+ // file's source directory; absolute URLs should be left alone.
+ var baseUrlKey = constants.KEY__IMAGESETS__FIELD__BASE_URL;
+ angular.forEach(
+ $scope.imageSets,
+ function(imageSet) {
+ var baseUrl = imageSet[baseUrlKey];
+ if ((baseUrl.substring(0, 1) != '/') &&
+ (baseUrl.indexOf('://') == -1)) {
+ imageSet[baseUrlKey] = '/' + baseUrl;
+ }
+ }
+ );
+
+ $scope.readyToDisplay = true;
+ $scope.updateResults();
+ $scope.loadingMessage = "";
+ $scope.windowTitle = "Current GM Results";
+
+ $timeout( function() {
+ make_results_header_sticky();
+ });
+ }
+ }
+ ).error(
+ function(data, status, header, config) {
+ $scope.loadingMessage = "FAILED to load.";
+ $scope.windowTitle = "Failed to Load GM Results";
+ }
+ );
+
+
+ //
+ // Select/Clear/Toggle all tests.
+ //
+
+ /**
+ * Select all currently showing tests.
+ */
+ $scope.selectAllImagePairs = function() {
+ var numImagePairsShowing = $scope.limitedImagePairs.length;
+ for (var i = 0; i < numImagePairsShowing; i++) {
+ var index = $scope.limitedImagePairs[i].index;
+ if (!$scope.isValueInArray(index, $scope.selectedImagePairs)) {
+ $scope.toggleValueInArray(index, $scope.selectedImagePairs);
+ }
+ }
+ }
+
+ /**
+ * Deselect all currently showing tests.
+ */
+ $scope.clearAllImagePairs = function() {
+ var numImagePairsShowing = $scope.limitedImagePairs.length;
+ for (var i = 0; i < numImagePairsShowing; i++) {
+ var index = $scope.limitedImagePairs[i].index;
+ if ($scope.isValueInArray(index, $scope.selectedImagePairs)) {
+ $scope.toggleValueInArray(index, $scope.selectedImagePairs);
+ }
+ }
+ }
+
+ /**
+ * Toggle selection of all currently showing tests.
+ */
+ $scope.toggleAllImagePairs = function() {
+ var numImagePairsShowing = $scope.limitedImagePairs.length;
+ for (var i = 0; i < numImagePairsShowing; i++) {
+ var index = $scope.limitedImagePairs[i].index;
+ $scope.toggleValueInArray(index, $scope.selectedImagePairs);
+ }
+ }
+
+ /**
+ * Toggle selection state of a subset of the currently showing tests.
+ *
+ * @param startIndex index within $scope.limitedImagePairs of the first
+ * test to toggle selection state of
+ * @param num number of tests (in a contiguous block) to toggle
+ */
+ $scope.toggleSomeImagePairs = function(startIndex, num) {
+ var numImagePairsShowing = $scope.limitedImagePairs.length;
+ for (var i = startIndex; i < startIndex + num; i++) {
+ var index = $scope.limitedImagePairs[i].index;
+ $scope.toggleValueInArray(index, $scope.selectedImagePairs);
+ }
+ }
+
+
+ //
+ // Tab operations.
+ //
+
+ /**
+ * Change the selected tab.
+ *
+ * @param tab (string): name of the tab to select
+ */
+ $scope.setViewingTab = function(tab) {
+ $scope.viewingTab = tab;
+ $scope.updateResults();
+ }
+
+ /**
+ * Move the imagePairs in $scope.selectedImagePairs to a different tab,
+ * and then clear $scope.selectedImagePairs.
+ *
+ * @param newTab (string): name of the tab to move the tests to
+ */
+ $scope.moveSelectedImagePairsToTab = function(newTab) {
+ $scope.moveImagePairsToTab($scope.selectedImagePairs, newTab);
+ $scope.selectedImagePairs = [];
+ $scope.updateResults();
+ }
+
+ /**
+ * Move a subset of $scope.imagePairs to a different tab.
+ *
+ * @param imagePairIndices (array of ints): indices into $scope.imagePairs
+ * indicating which test results to move
+ * @param newTab (string): name of the tab to move the tests to
+ */
+ $scope.moveImagePairsToTab = function(imagePairIndices, newTab) {
+ var imagePairIndex;
+ var numImagePairs = imagePairIndices.length;
+ for (var i = 0; i < numImagePairs; i++) {
+ imagePairIndex = imagePairIndices[i];
+ $scope.numResultsPerTab[$scope.imagePairs[imagePairIndex].tab]--;
+ $scope.imagePairs[imagePairIndex].tab = newTab;
+ }
+ $scope.numResultsPerTab[newTab] += numImagePairs;
+ }
+
+
+ //
+ // $scope.queryParameters:
+ // Transfer parameter values between $scope and the URL query string.
+ //
+ $scope.queryParameters = {};
+
+ // load and save functions for parameters of each type
+ // (load a parameter value into $scope from nameValuePairs,
+ // save a parameter value from $scope into nameValuePairs)
+ $scope.queryParameters.copiers = {
+ 'simple': {
+ 'load': function(nameValuePairs, name) {
+ var value = nameValuePairs[name];
+ if (value) {
+ $scope[name] = value;
+ }
+ },
+ 'save': function(nameValuePairs, name) {
+ nameValuePairs[name] = $scope[name];
+ }
+ },
+
+ 'columnStringMatch': {
+ 'load': function(nameValuePairs, name) {
+ var value = nameValuePairs[name];
+ if (value) {
+ $scope.columnStringMatch[name] = value;
+ }
+ },
+ 'save': function(nameValuePairs, name) {
+ nameValuePairs[name] = $scope.columnStringMatch[name];
+ }
+ },
+
+ 'showingColumnValuesSet': {
+ 'load': function(nameValuePairs, name) {
+ var value = nameValuePairs[name];
+ if (value) {
+ var valueArray = value.split(',');
+ $scope.showingColumnValues[name] = {};
+ $scope.toggleValuesInSet(valueArray, $scope.showingColumnValues[name]);
+ }
+ },
+ 'save': function(nameValuePairs, name) {
+ nameValuePairs[name] = Object.keys($scope.showingColumnValues[name]).join(',');
+ }
+ },
+
+ };
+
+ // Loads all parameters into $scope from the URL query string;
+ // any which are not found within the URL will keep their current value.
+ $scope.queryParameters.load = function() {
+ var nameValuePairs = $location.search();
+
+ // If urlSchemaVersion is not specified, we assume the current version.
+ var urlSchemaVersion = constants.URL_VALUE__SCHEMA_VERSION__CURRENT;
+ if (constants.URL_KEY__SCHEMA_VERSION in nameValuePairs) {
+ urlSchemaVersion = nameValuePairs[constants.URL_KEY__SCHEMA_VERSION];
+ } else if ('hiddenResultTypes' in nameValuePairs) {
+ // The combination of:
+ // - absence of an explicit urlSchemaVersion, and
+ // - presence of the old 'hiddenResultTypes' field
+ // tells us that the URL is from the original urlSchemaVersion.
+ // See https://codereview.chromium.org/367173002/
+ urlSchemaVersion = 0;
+ }
+ $scope.urlSchemaVersionLoaded = urlSchemaVersion;
+
+ if (urlSchemaVersion != constants.URL_VALUE__SCHEMA_VERSION__CURRENT) {
+ nameValuePairs = $scope.upconvertUrlNameValuePairs(nameValuePairs, urlSchemaVersion);
+ }
+ angular.forEach($scope.queryParameters.map,
+ function(copier, paramName) {
+ copier.load(nameValuePairs, paramName);
+ }
+ );
+ };
+
+ // Saves all parameters from $scope into the URL query string.
+ $scope.queryParameters.save = function() {
+ var nameValuePairs = {};
+ nameValuePairs[constants.URL_KEY__SCHEMA_VERSION] = constants.URL_VALUE__SCHEMA_VERSION__CURRENT;
+ angular.forEach($scope.queryParameters.map,
+ function(copier, paramName) {
+ copier.save(nameValuePairs, paramName);
+ }
+ );
+ $location.search(nameValuePairs);
+ };
+
+ /**
+ * Converts URL name/value pairs that were stored by a previous urlSchemaVersion
+ * to the currently needed format.
+ *
+ * @param oldNValuePairs name/value pairs found in the loaded URL
+ * @param oldUrlSchemaVersion which version of the schema was used to generate that URL
+ *
+ * @returns nameValuePairs as needed by the current URL parser
+ */
+ $scope.upconvertUrlNameValuePairs = function(oldNameValuePairs, oldUrlSchemaVersion) {
+ var newNameValuePairs = {};
+ angular.forEach(oldNameValuePairs,
+ function(value, name) {
+ if (oldUrlSchemaVersion < 1) {
+ if ('hiddenConfigs' == name) {
+ name = 'config';
+ var valueSet = {};
+ $scope.toggleValuesInSet(value.split(','), valueSet);
+ $scope.toggleValuesInSet(
+ $scope.allColumnValues[constants.KEY__EXTRACOLUMNS__CONFIG],
+ valueSet);
+ value = Object.keys(valueSet).join(',');
+ } else if ('hiddenResultTypes' == name) {
+ name = 'resultType';
+ var valueSet = {};
+ $scope.toggleValuesInSet(value.split(','), valueSet);
+ $scope.toggleValuesInSet(
+ $scope.allColumnValues[constants.KEY__EXTRACOLUMNS__RESULT_TYPE],
+ valueSet);
+ value = Object.keys(valueSet).join(',');
+ }
+ }
+
+ newNameValuePairs[name] = value;
+ }
+ );
+ return newNameValuePairs;
+ }
+
+
+ //
+ // updateResults() and friends.
+ //
+
+ /**
+ * Set $scope.areUpdatesPending (to enable/disable the Update Results
+ * button).
+ *
+ * TODO(epoger): We could reduce the amount of code by just setting the
+ * variable directly (from, e.g., a button's ng-click handler). But when
+ * I tried that, the HTML elements depending on the variable did not get
+ * updated.
+ * It turns out that this is due to variable scoping within an ng-repeat
+ * element; see http://stackoverflow.com/questions/15388344/behavior-of-assignment-expression-invoked-by-ng-click-within-ng-repeat
+ *
+ * @param val boolean value to set $scope.areUpdatesPending to
+ */
+ $scope.setUpdatesPending = function(val) {
+ $scope.areUpdatesPending = val;
+ }
+
+ /**
+ * Update the displayed results, based on filters/settings,
+ * and call $scope.queryParameters.save() so that the new filter results
+ * can be bookmarked.
+ */
+ $scope.updateResults = function() {
+ $scope.renderStartTime = window.performance.now();
+ $log.debug("renderStartTime: " + $scope.renderStartTime);
+ $scope.displayLimit = $scope.displayLimitPending;
+ $scope.mergeIdenticalRows = $scope.mergeIdenticalRowsPending;
+
+ // For each USE_FREEFORM_FILTER column, populate showingColumnValues.
+ // This is more efficient than applying the freeform filter within the
+ // tight loop in removeHiddenImagePairs.
+ angular.forEach(
+ $scope.filterableColumnNames,
+ function(columnName) {
+ var columnHeader = $scope.extraColumnHeaders[columnName];
+ if (columnHeader[constants.KEY__EXTRACOLUMNHEADERS__USE_FREEFORM_FILTER]) {
+ var columnStringMatch = $scope.columnStringMatch[columnName];
+ var showingColumnValues = {};
+ angular.forEach(
+ $scope.allColumnValues[columnName],
+ function(columnValue) {
+ if (-1 != columnValue.indexOf(columnStringMatch)) {
+ showingColumnValues[columnValue] = true;
+ }
+ }
+ );
+ $scope.showingColumnValues[columnName] = showingColumnValues;
+ }
+ }
+ );
+
+ // TODO(epoger): Every time we apply a filter, AngularJS creates
+ // another copy of the array. Is there a way we can filter out
+ // the imagePairs as they are displayed, rather than storing multiple
+ // array copies? (For better performance.)
+
+ if ($scope.viewingTab == $scope.defaultTab) {
+ var doReverse = !currSortAsc;
+
+ $scope.filteredImagePairs =
+ $filter("orderBy")(
+ $filter("removeHiddenImagePairs")(
+ $scope.imagePairs,
+ $scope.filterableColumnNames,
+ $scope.showingColumnValues,
+ $scope.viewingTab
+ ),
+ [$scope.getSortColumnValue, $scope.getSecondOrderSortValue],
+ doReverse);
+ $scope.limitedImagePairs = $filter("mergeAndLimit")(
+ $scope.filteredImagePairs, $scope.displayLimit, $scope.mergeIdenticalRows);
+ } else {
+ $scope.filteredImagePairs =
+ $filter("orderBy")(
+ $filter("filter")(
+ $scope.imagePairs,
+ {tab: $scope.viewingTab},
+ true
+ ),
+ [$scope.getSortColumnValue, $scope.getSecondOrderSortValue]);
+ $scope.limitedImagePairs = $filter("mergeAndLimit")(
+ $scope.filteredImagePairs, -1, $scope.mergeIdenticalRows);
+ }
+ $scope.showThumbnails = $scope.showThumbnailsPending;
+ $scope.imageSize = $scope.imageSizePending;
+ $scope.setUpdatesPending(false);
+ $scope.queryParameters.save();
+ }
+
+ /**
+ * This function is called when the results have been completely rendered
+ * after updateResults().
+ */
+ $scope.resultsUpdatedCallback = function() {
+ $scope.renderEndTime = window.performance.now();
+ $log.debug("renderEndTime: " + $scope.renderEndTime);
+ }
+
+ /**
+ * Re-sort the displayed results.
+ *
+ * @param subdict (string): which KEY__IMAGEPAIRS__* subdictionary
+ * the sort column key is within, or 'none' if the sort column
+ * key is one of KEY__IMAGEPAIRS__*
+ * @param key (string): sort by value associated with this key in subdict
+ */
+ $scope.sortResultsBy = function(subdict, key) {
+ // if we are already sorting by this column then toggle between asc/desc
+ if ((subdict === $scope.sortColumnSubdict) && ($scope.sortColumnKey === key)) {
+ currSortAsc = !currSortAsc;
+ } else {
+ $scope.sortColumnSubdict = subdict;
+ $scope.sortColumnKey = key;
+ currSortAsc = true;
+ }
+ $scope.updateResults();
+ }
+
+ /**
+ * Returns ASC or DESC (from constants) if currently the data
+ * is sorted by the provided column.
+ *
+ * @param colName: name of the column for which we need to get the class.
+ */
+
+ $scope.sortedByColumnsCls = function (colName) {
+ if ($scope.sortColumnKey !== colName) {
+ return '';
+ }
+
+ var result = (currSortAsc) ? constants.ASC : constants.DESC;
+ console.log("sort class:", result);
+ return result;
+ };
+
+ /**
+ * For a particular ImagePair, return the value of the column we are
+ * sorting on (according to $scope.sortColumnSubdict and
+ * $scope.sortColumnKey).
+ *
+ * @param imagePair: imagePair to get a column value out of.
+ */
+ $scope.getSortColumnValue = function(imagePair) {
+ if ($scope.sortColumnSubdict in imagePair) {
+ return imagePair[$scope.sortColumnSubdict][$scope.sortColumnKey];
+ } else if ($scope.sortColumnKey in imagePair) {
+ return imagePair[$scope.sortColumnKey];
+ } else {
+ return undefined;
+ }
+ };
+
+ /**
+ * For a particular ImagePair, return the value we use for the
+ * second-order sort (tiebreaker when multiple rows have
+ * the same getSortColumnValue()).
+ *
+ * We join the imageA and imageB urls for this value, so that we merge
+ * adjacent rows as much as possible.
+ *
+ * @param imagePair: imagePair to get a column value out of.
+ */
+ $scope.getSecondOrderSortValue = function(imagePair) {
+ return imagePair[constants.KEY__IMAGEPAIRS__IMAGE_A_URL] + "-vs-" +
+ imagePair[constants.KEY__IMAGEPAIRS__IMAGE_B_URL];
+ };
+
+ /**
+ * Set $scope.columnStringMatch[name] = value, and update results.
+ *
+ * @param name
+ * @param value
+ */
+ $scope.setColumnStringMatch = function(name, value) {
+ $scope.columnStringMatch[name] = value;
+ $scope.updateResults();
+ };
+
+ /**
+ * Update $scope.showingColumnValues[columnName] and $scope.columnStringMatch[columnName]
+ * so that ONLY entries with this columnValue are showing, and update the visible results.
+ * (We update both of those, so we cover both freeform and checkbox filtered columns.)
+ *
+ * @param columnName
+ * @param columnValue
+ */
+ $scope.showOnlyColumnValue = function(columnName, columnValue) {
+ $scope.columnStringMatch[columnName] = columnValue;
+ $scope.showingColumnValues[columnName] = {};
+ $scope.toggleValueInSet(columnValue, $scope.showingColumnValues[columnName]);
+ $scope.updateResults();
+ };
+
+ /**
+ * Update $scope.showingColumnValues[columnName] and $scope.columnStringMatch[columnName]
+ * so that ALL entries are showing, and update the visible results.
+ * (We update both of those, so we cover both freeform and checkbox filtered columns.)
+ *
+ * @param columnName
+ */
+ $scope.showAllColumnValues = function(columnName) {
+ $scope.columnStringMatch[columnName] = "";
+ $scope.showingColumnValues[columnName] = {};
+ $scope.toggleValuesInSet($scope.allColumnValues[columnName],
+ $scope.showingColumnValues[columnName]);
+ $scope.updateResults();
+ };
+
+
+ //
+ // Operations for sending info back to the server.
+ //
+
+ /**
+ * Tell the server that the actual results of these particular tests
+ * are acceptable.
+ *
+ * This assumes that the original expectations are in imageSetA, and the
+ * new expectations are in imageSetB. That's fine, because the server
+ * mandates that anyway (it will swap the sets if the user requests them
+ * in the opposite order).
+ *
+ * @param imagePairsSubset an array of test results, most likely a subset of
+ * $scope.imagePairs (perhaps with some modifications)
+ */
+ $scope.submitApprovals = function(imagePairsSubset) {
+ $scope.submitPending = true;
+ $scope.diffResults = "";
+
+ // Convert bug text field to null or 1-item array.
+ var bugs = null;
+ var bugNumber = parseInt($scope.submitAdvancedSettings['bug']);
+ if (!isNaN(bugNumber)) {
+ bugs = [bugNumber];
+ }
+
+ var updatedExpectations = [];
+ for (var i = 0; i < imagePairsSubset.length; i++) {
+ var imagePair = imagePairsSubset[i];
+ var updatedExpectation = {};
+ updatedExpectation[constants.KEY__IMAGEPAIRS__EXPECTATIONS] =
+ imagePair[constants.KEY__IMAGEPAIRS__EXPECTATIONS];
+ updatedExpectation[constants.KEY__IMAGEPAIRS__EXTRACOLUMNS] =
+ imagePair[constants.KEY__IMAGEPAIRS__EXTRACOLUMNS];
+ updatedExpectation[constants.KEY__IMAGEPAIRS__SOURCE_JSON_FILE] =
+ imagePair[constants.KEY__IMAGEPAIRS__SOURCE_JSON_FILE];
+ // IMAGE_B_URL contains the actual image (which is now the expectation)
+ updatedExpectation[constants.KEY__IMAGEPAIRS__IMAGE_B_URL] =
+ imagePair[constants.KEY__IMAGEPAIRS__IMAGE_B_URL];
+
+ // Advanced settings...
+ if (null == updatedExpectation[constants.KEY__IMAGEPAIRS__EXPECTATIONS]) {
+ updatedExpectation[constants.KEY__IMAGEPAIRS__EXPECTATIONS] = {};
+ }
+ updatedExpectation[constants.KEY__IMAGEPAIRS__EXPECTATIONS]
+ [constants.KEY__EXPECTATIONS__REVIEWED] =
+ $scope.submitAdvancedSettings[
+ constants.KEY__EXPECTATIONS__REVIEWED];
+ if (true == $scope.submitAdvancedSettings[
+ constants.KEY__EXPECTATIONS__IGNOREFAILURE]) {
+ // if it's false, don't send it at all (just keep the default)
+ updatedExpectation[constants.KEY__IMAGEPAIRS__EXPECTATIONS]
+ [constants.KEY__EXPECTATIONS__IGNOREFAILURE] = true;
+ }
+ updatedExpectation[constants.KEY__IMAGEPAIRS__EXPECTATIONS]
+ [constants.KEY__EXPECTATIONS__BUGS] = bugs;
+
+ updatedExpectations.push(updatedExpectation);
+ }
+ var modificationData = {};
+ modificationData[constants.KEY__LIVE_EDITS__MODIFICATIONS] =
+ updatedExpectations;
+ modificationData[constants.KEY__LIVE_EDITS__SET_A_DESCRIPTIONS] =
+ $scope.header[constants.KEY__HEADER__SET_A_DESCRIPTIONS];
+ modificationData[constants.KEY__LIVE_EDITS__SET_B_DESCRIPTIONS] =
+ $scope.header[constants.KEY__HEADER__SET_B_DESCRIPTIONS];
+ $http({
+ method: "POST",
+ url: "/live-edits",
+ data: modificationData
+ }).success(function(data, status, headers, config) {
+ $scope.diffResults = data;
+ var blob = new Blob([$scope.diffResults], {type: 'text/plain'});
+ $scope.diffResultsBlobUrl = window.URL.createObjectURL(blob);
+ $scope.submitPending = false;
+ }).error(function(data, status, headers, config) {
+ alert("There was an error submitting your baselines.\n\n" +
+ "Please see server-side log for details.");
+ $scope.submitPending = false;
+ });
+ };
+
+
+ //
+ // Operations we use to mimic Set semantics, in such a way that
+ // checking for presence within the Set is as fast as possible.
+ // But getting a list of all values within the Set is not necessarily
+ // possible.
+ // TODO(epoger): move into a separate .js file?
+ //
+
+ /**
+ * Returns the number of values present within set "set".
+ *
+ * @param set an Object which we use to mimic set semantics
+ */
+ $scope.setSize = function(set) {
+ return Object.keys(set).length;
+ };
+
+ /**
+ * Returns true if value "value" is present within set "set".
+ *
+ * @param value a value of any type
+ * @param set an Object which we use to mimic set semantics
+ * (this should make isValueInSet faster than if we used an Array)
+ */
+ $scope.isValueInSet = function(value, set) {
+ return (true == set[value]);
+ };
+
+ /**
+ * If value "value" is already in set "set", remove it; otherwise, add it.
+ *
+ * @param value a value of any type
+ * @param set an Object which we use to mimic set semantics
+ */
+ $scope.toggleValueInSet = function(value, set) {
+ if (true == set[value]) {
+ delete set[value];
+ } else {
+ set[value] = true;
+ }
+ };
+
+ /**
+ * For each value in valueArray, call toggleValueInSet(value, set).
+ *
+ * @param valueArray
+ * @param set
+ */
+ $scope.toggleValuesInSet = function(valueArray, set) {
+ var arrayLength = valueArray.length;
+ for (var i = 0; i < arrayLength; i++) {
+ $scope.toggleValueInSet(valueArray[i], set);
+ }
+ };
+
+
+ //
+ // Array operations; similar to our Set operations, but operate on a
+ // Javascript Array so we *can* easily get a list of all values in the Set.
+ // TODO(epoger): move into a separate .js file?
+ //
+
+ /**
+ * Returns true if value "value" is present within array "array".
+ *
+ * @param value a value of any type
+ * @param array a Javascript Array
+ */
+ $scope.isValueInArray = function(value, array) {
+ return (-1 != array.indexOf(value));
+ };
+
+ /**
+ * If value "value" is already in array "array", remove it; otherwise,
+ * add it.
+ *
+ * @param value a value of any type
+ * @param array a Javascript Array
+ */
+ $scope.toggleValueInArray = function(value, array) {
+ var i = array.indexOf(value);
+ if (-1 == i) {
+ array.push(value);
+ } else {
+ array.splice(i, 1);
+ }
+ };
+
+
+ //
+ // Miscellaneous utility functions.
+ // TODO(epoger): move into a separate .js file?
+ //
+
+ /**
+ * Returns a single "column slice" of a 2D array.
+ *
+ * For example, if array is:
+ * [[A0, A1],
+ * [B0, B1],
+ * [C0, C1]]
+ * and index is 0, this this will return:
+ * [A0, B0, C0]
+ *
+ * @param array a Javascript Array
+ * @param column (numeric): index within each row array
+ */
+ $scope.columnSliceOf2DArray = function(array, column) {
+ var slice = [];
+ var numRows = array.length;
+ for (var row = 0; row < numRows; row++) {
+ slice.push(array[row][column]);
+ }
+ return slice;
+ };
+
+ /**
+ * Returns a human-readable (in local time zone) time string for a
+ * particular moment in time.
+ *
+ * @param secondsPastEpoch (numeric): seconds past epoch in UTC
+ */
+ $scope.localTimeString = function(secondsPastEpoch) {
+ var d = new Date(secondsPastEpoch * 1000);
+ return d.toString();
+ };
+
+ /**
+ * Returns a hex color string (such as "#aabbcc") for the given RGB values.
+ *
+ * @param r (numeric): red channel value, 0-255
+ * @param g (numeric): green channel value, 0-255
+ * @param b (numeric): blue channel value, 0-255
+ */
+ $scope.hexColorString = function(r, g, b) {
+ var rString = r.toString(16);
+ if (r < 16) {
+ rString = "0" + rString;
+ }
+ var gString = g.toString(16);
+ if (g < 16) {
+ gString = "0" + gString;
+ }
+ var bString = b.toString(16);
+ if (b < 16) {
+ bString = "0" + bString;
+ }
+ return '#' + rString + gString + bString;
+ };
+
+ /**
+ * Returns a hex color string (such as "#aabbcc") for the given brightness.
+ *
+ * @param brightnessString (string): 0-255, 0 is completely black
+ *
+ * TODO(epoger): It might be nice to tint the color when it's not completely
+ * black or completely white.
+ */
+ $scope.brightnessStringToHexColor = function(brightnessString) {
+ var v = parseInt(brightnessString);
+ return $scope.hexColorString(v, v, v);
+ };
+ }
+);
diff --git a/gm/rebaseline_server/static/live-view.html b/gm/rebaseline_server/static/live-view.html
new file mode 100644
index 0000000000..1662adf89c
--- /dev/null
+++ b/gm/rebaseline_server/static/live-view.html
@@ -0,0 +1,446 @@
+<!DOCTYPE html>
+
+<html ng-app="Loader" ng-controller="Loader.Controller">
+
+<head>
+ <title ng-bind="windowTitle"></title>
+ <script src="//ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js"></script>
+ <script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.2.20/angular.js"></script>
+ <script src="constants.js"></script>
+ <script src="live-loader.js"></script>
+ <script src="utils.js"></script>
+
+ <link rel="stylesheet" href="view.css">
+</head>
+
+<body>
+ <h2>
+ Instructions, roadmap, etc. are at
+ <a href="http://tinyurl.com/SkiaRebaselineServer">
+ http://tinyurl.com/SkiaRebaselineServer
+ </a>
+ </h2>
+
+ <em ng-show="!readyToDisplay">
+ Loading results of query:
+ <ul>
+ <li>setA: "{{setASection}}" within {{setADir}}</li>
+ <li>setB: "{{setBSection}}" within {{setBDir}}</li>
+ </ul>
+ <br>
+ {{loadingMessage}}
+ </em>
+
+ <div ng-show="readyToDisplay">
+
+ <div class="warning-div"
+ ng-show="urlSchemaVersionLoaded != constants.URL_VALUE__SCHEMA_VERSION__CURRENT">
+ WARNING! The URL you loaded used schema version {{urlSchemaVersionLoaded}}, rather than
+ the most recent version {{constants.URL_VALUE__SCHEMA_VERSION__CURRENT}}. It has been
+ converted to the most recent version on a best-effort basis; you may wish to double-check
+ which records are displayed.
+ </div>
+
+ <div ng-show="header[constants.KEY__HEADER__TIME_UPDATED]">
+ setA: "{{header[constants.KEY__HEADER__SET_A_DESCRIPTIONS][constants.KEY__SET_DESCRIPTIONS__SECTION]}}"
+ within {{header[constants.KEY__HEADER__SET_A_DESCRIPTIONS][constants.KEY__SET_DESCRIPTIONS__DIR]}}
+ <span ng-show="header[constants.KEY__HEADER__SET_A_DESCRIPTIONS][constants.KEY__SET_DESCRIPTIONS__REPO_REVISION]">at <a href="https://skia.googlesource.com/skia/+/{{header[constants.KEY__HEADER__SET_A_DESCRIPTIONS][constants.KEY__SET_DESCRIPTIONS__REPO_REVISION]}}">rev {{header[constants.KEY__HEADER__SET_A_DESCRIPTIONS][constants.KEY__SET_DESCRIPTIONS__REPO_REVISION]}}</a></span>
+ <br>
+ setB: "{{header[constants.KEY__HEADER__SET_B_DESCRIPTIONS][constants.KEY__SET_DESCRIPTIONS__SECTION]}}"
+ within {{header[constants.KEY__HEADER__SET_B_DESCRIPTIONS][constants.KEY__SET_DESCRIPTIONS__DIR]}}
+ <span ng-show="header[constants.KEY__HEADER__SET_B_DESCRIPTIONS][constants.KEY__SET_DESCRIPTIONS__REPO_REVISION]">at <a href="https://skia.googlesource.com/skia/+/{{header[constants.KEY__HEADER__SET_B_DESCRIPTIONS][constants.KEY__SET_DESCRIPTIONS__REPO_REVISION]}}">rev {{header[constants.KEY__HEADER__SET_B_DESCRIPTIONS][constants.KEY__SET_DESCRIPTIONS__REPO_REVISION]}}</a></span>
+ <br>
+ <a href="{{liveQueryUrl}}">latest raw JSON diffs between these two sets</a><br>
+ These results current as of
+ {{localTimeString(header[constants.KEY__HEADER__TIME_UPDATED])}}
+ </div>
+
+ <div class="tab-wrapper"><!-- tabs -->
+ <div class="tab-spacer" ng-repeat="tab in tabs">
+ <div class="tab tab-{{tab == viewingTab}}"
+ ng-click="setViewingTab(tab)">
+ &nbsp;{{tab}} ({{numResultsPerTab[tab]}})&nbsp;
+ </div>
+ <div class="tab-spacer">
+ &nbsp;
+ </div>
+ </div>
+ </div><!-- tabs -->
+
+ <div class="tab-main"><!-- main display area of selected tab -->
+
+ <br>
+ <!-- We only show the filters/settings table on the Unfiled tab. -->
+ <table ng-show="viewingTab == defaultTab" border="1">
+ <tr>
+ <th colspan="4">
+ Filters
+ </th>
+ <th>
+ Settings
+ </th>
+ </tr>
+ <tr valign="top">
+
+ <!-- filters -->
+ <td ng-repeat="columnName in orderedColumnNames">
+
+ <!-- Only display filterable columns here... -->
+ <div ng-if="extraColumnHeaders[columnName][constants.KEY__EXTRACOLUMNHEADERS__IS_FILTERABLE]">
+ {{extraColumnHeaders[columnName][constants.KEY__EXTRACOLUMNHEADERS__HEADER_TEXT]}}<br>
+
+ <!-- If we filter this column using free-form text match... -->
+ <div ng-if="extraColumnHeaders[columnName][constants.KEY__EXTRACOLUMNHEADERS__USE_FREEFORM_FILTER]">
+ <input type="text"
+ ng-model="columnStringMatch[columnName]"
+ ng-change="setUpdatesPending(true)"/>
+ <br>
+ <button ng-click="setColumnStringMatch(columnName, '')"
+ ng-disabled="('' == columnStringMatch[columnName])">
+ clear (show all)
+ </button>
+ </div>
+
+ <!-- If we filter this column using checkboxes... -->
+ <div ng-if="!extraColumnHeaders[columnName][constants.KEY__EXTRACOLUMNHEADERS__USE_FREEFORM_FILTER]">
+ <label ng-repeat="valueAndCount in extraColumnHeaders[columnName][constants.KEY__EXTRACOLUMNHEADERS__VALUES_AND_COUNTS]">
+ <input type="checkbox"
+ name="resultTypes"
+ value="{{valueAndCount[0]}}"
+ ng-checked="isValueInSet(valueAndCount[0], showingColumnValues[columnName])"
+ ng-click="toggleValueInSet(valueAndCount[0], showingColumnValues[columnName]); setUpdatesPending(true)">
+ {{valueAndCount[0]}} ({{valueAndCount[1]}})<br>
+ </label>
+ <button ng-click="showingColumnValues[columnName] = {}; toggleValuesInSet(allColumnValues[columnName], showingColumnValues[columnName]); updateResults()"
+ ng-disabled="!readyToDisplay || allColumnValues[columnName].length == setSize(showingColumnValues[columnName])">
+ all
+ </button>
+ <button ng-click="showingColumnValues[columnName] = {}; updateResults()"
+ ng-disabled="!readyToDisplay || 0 == setSize(showingColumnValues[columnName])">
+ none
+ </button>
+ <button ng-click="toggleValuesInSet(allColumnValues[columnName], showingColumnValues[columnName]); updateResults()">
+ toggle
+ </button>
+ </div>
+
+ </div>
+ </td>
+
+ <!-- settings -->
+ <td><table>
+ <tr><td>
+ <input type="checkbox" ng-model="showThumbnailsPending"
+ ng-init="showThumbnailsPending = true"
+ ng-change="areUpdatesPending = true"/>
+ Show thumbnails
+ </td></tr>
+ <tr><td>
+ <input type="checkbox" ng-model="mergeIdenticalRowsPending"
+ ng-init="mergeIdenticalRowsPending = true"
+ ng-change="areUpdatesPending = true"/>
+ Merge identical rows
+ </td></tr>
+ <tr><td>
+ Image width
+ <input type="text" ng-model="imageSizePending"
+ ng-init="imageSizePending=100"
+ ng-change="areUpdatesPending = true"
+ maxlength="4"/>
+ </td></tr>
+ <tr><td>
+ Max records to display
+ <input type="text" ng-model="displayLimitPending"
+ ng-init="displayLimitPending=50"
+ ng-change="areUpdatesPending = true"
+ maxlength="4"/>
+ </td></tr>
+ <tr><td>
+ <button class="update-results-button"
+ ng-click="updateResults()"
+ ng-disabled="!areUpdatesPending">
+ Update Results
+ </button>
+ </td></tr>
+ </tr></table></td>
+ </tr>
+ </table>
+
+ <p>
+
+ <!-- Submission UI that we only show in the Pending Approval tab. -->
+ <div ng-show="'Pending Approval' == viewingTab">
+ <div style="display:inline-block">
+ <button style="font-size:20px"
+ ng-click="submitApprovals(filteredImagePairs)"
+ ng-disabled="submitPending || (filteredImagePairs.length == 0)">
+ Get a patchfile to update these {{filteredImagePairs.length}} expectations
+ </button>
+ </div>
+ <div style="display:inline-block">
+ <div style="font-size:20px"
+ ng-show="submitPending">
+ Submitting, please wait...
+ </div>
+ </div>
+ <div>
+ Advanced settings...
+ <input type="checkbox" ng-model="showSubmitAdvancedSettings">
+ show
+ <ul ng-show="showSubmitAdvancedSettings">
+ <li ng-repeat="setting in [constants.KEY__EXPECTATIONS__REVIEWED, constants.KEY__EXPECTATIONS__IGNOREFAILURE]">
+ {{setting}}
+ <input type="checkbox" ng-model="submitAdvancedSettings[setting]">
+ </li>
+ <li ng-repeat="setting in ['bug']">
+ {{setting}}
+ <input type="text" ng-model="submitAdvancedSettings[setting]">
+ </li>
+ </ul>
+ </div>
+ <div ng-show="diffResults">
+ <p>
+ Here is the patch to apply to your local checkout:
+ <br>
+ <textarea rows="8" cols="50">{{diffResults}}</textarea>
+ <br>
+ <a download="patch.txt" ng-href="{{diffResultsBlobUrl}}">
+ Click here to download that patch as a text file.
+ </a>
+ </div>
+ </div>
+
+ <p>
+
+ <table border="0"><tr><td> <!-- table holding results header + results table -->
+ <table border="0" width="100%"> <!-- results header -->
+ <tr>
+ <td>
+ Found {{filteredImagePairs.length}} matches;
+ <span ng-show="filteredImagePairs.length > limitedImagePairs.length">
+ displaying the first {{limitedImagePairs.length}}.
+ </span>
+ <span ng-show="filteredImagePairs.length <= limitedImagePairs.length">
+ displaying them all.
+ </span>
+ <span ng-show="renderEndTime > renderStartTime">
+ Rendered in {{(renderEndTime - renderStartTime).toFixed(0)}} ms.
+ </span>
+ <br>
+ (click on the column header radio buttons to re-sort by that column)
+ </td>
+ <td align="right">
+ <div>
+ all tests shown:
+ <button ng-click="selectAllImagePairs()">
+ select
+ </button>
+ <button ng-click="clearAllImagePairs()">
+ clear
+ </button>
+ <button ng-click="toggleAllImagePairs()">
+ toggle
+ </button>
+ </div>
+ <div ng-repeat="otherTab in tabs">
+ <button ng-click="moveSelectedImagePairsToTab(otherTab)"
+ ng-disabled="selectedImagePairs.length == 0"
+ ng-show="otherTab != viewingTab">
+ move {{selectedImagePairs.length}} selected tests to {{otherTab}} tab
+ </button>
+ </div>
+ </td>
+ </tr>
+ </table> <!-- results header -->
+ </td></tr><tr><td>
+ <table border="1" ng-app="diff_viewer"> <!-- results -->
+ <tr>
+ <!-- Most column headers are displayed in a common fashion... -->
+ <th ng-repeat="columnName in orderedColumnNames">
+ <a ng-class="'sort-' + sortedByColumnsCls(columnName)"
+ ng-click="sortResultsBy(constants.KEY__IMAGEPAIRS__EXTRACOLUMNS, columnName)"
+ href=""
+ class="sortable-header">
+ {{extraColumnHeaders[columnName][constants.KEY__EXTRACOLUMNHEADERS__HEADER_TEXT]}}
+ </a>
+ </th>
+ <!-- ... but there are a few columns where we display things differently. -->
+ <th>
+ <a ng-class="'sort-' + sortedByColumnsCls(constants.KEY__EXPECTATIONS__BUGS)"
+ ng-click="sortResultsBy(constants.KEY__IMAGEPAIRS__EXPECTATIONS, constants.KEY__EXPECTATIONS__BUGS)"
+ href=""
+ class="sortable-header">
+ bugs
+ </a>
+ </th>
+ <th width="{{imageSize}}">
+ <a ng-class="'sort-' + sortedByColumnsCls(constants.KEY__IMAGEPAIRS__IMAGE_A_URL)"
+ ng-click="sortResultsBy('none', constants.KEY__IMAGEPAIRS__IMAGE_A_URL)"
+ href=""
+ title="setA: '{{header[constants.KEY__HEADER__SET_A_DESCRIPTIONS][constants.KEY__SET_DESCRIPTIONS__SECTION]}}' within {{header[constants.KEY__HEADER__SET_A_DESCRIPTIONS][constants.KEY__SET_DESCRIPTIONS__DIR]}}"
+ class="sortable-header">
+ <span ng-show="'Pending Approval' != viewingTab">
+ {{imageSets[constants.KEY__IMAGESETS__SET__IMAGE_A][constants.KEY__IMAGESETS__FIELD__DESCRIPTION]}}
+ </span>
+ <span ng-show="'Pending Approval' == viewingTab">
+ old expectations
+ </span>
+ </a>
+ </th>
+ <th width="{{imageSize}}">
+ <a ng-class="'sort-' + sortedByColumnsCls(constants.KEY__IMAGEPAIRS__IMAGE_B_URL)"
+ ng-click="sortResultsBy('none', constants.KEY__IMAGEPAIRS__IMAGE_B_URL)"
+ href=""
+ title="setB: '{{header[constants.KEY__HEADER__SET_B_DESCRIPTIONS][constants.KEY__SET_DESCRIPTIONS__SECTION]}}' within {{header[constants.KEY__HEADER__SET_B_DESCRIPTIONS][constants.KEY__SET_DESCRIPTIONS__DIR]}}"
+ class="sortable-header">
+ <span ng-show="'Pending Approval' != viewingTab">
+ {{imageSets[constants.KEY__IMAGESETS__SET__IMAGE_B][constants.KEY__IMAGESETS__FIELD__DESCRIPTION]}}
+ </span>
+ <span ng-show="'Pending Approval' == viewingTab">
+ new expectations
+ </span>
+ </a>
+ </th>
+ <th width="{{imageSize}}">
+ <a ng-class="'sort-' + sortedByColumnsCls(constants.KEY__DIFFERENCES__PERCENT_DIFF_PIXELS)"
+ ng-click="sortResultsBy(constants.KEY__IMAGEPAIRS__DIFFERENCES, constants.KEY__DIFFERENCES__PERCENT_DIFF_PIXELS)"
+ href=""
+ class="sortable-header">
+ differing pixels in white
+ </a>
+ </th>
+ <th width="{{imageSize}}">
+ <a ng-class="'sort-' + sortedByColumnsCls(constants.KEY__DIFFERENCES__PERCEPTUAL_DIFF)"
+ ng-click="sortResultsBy(constants.KEY__IMAGEPAIRS__DIFFERENCES, constants.KEY__DIFFERENCES__PERCEPTUAL_DIFF)"
+ href=""
+ class="sortable-header">
+ perceptual difference
+ </a>
+ <br>
+ <input type="range" ng-model="pixelDiffBgColorBrightness"
+ ng-init="pixelDiffBgColorBrightness=64; pixelDiffBgColor=brightnessStringToHexColor(pixelDiffBgColorBrightness)"
+ ng-change="pixelDiffBgColor=brightnessStringToHexColor(pixelDiffBgColorBrightness)"
+ title="image background brightness"
+ min="0" max="255"/>
+ </th>
+ <th>
+ <!-- imagepair-selection checkbox column -->
+ </th>
+ </tr>
+
+ <tr ng-repeat="imagePair in limitedImagePairs" valign="top"
+ ng-class-odd="'results-odd'" ng-class-even="'results-even'"
+ results-updated-callback-directive>
+
+ <td ng-repeat="columnName in orderedColumnNames">
+ {{imagePair[constants.KEY__IMAGEPAIRS__EXTRACOLUMNS][columnName]}}
+ <br>
+ <button class="show-only-button"
+ ng-show="viewingTab == defaultTab"
+ ng-disabled="1 == setSize(showingColumnValues[columnName])"
+ ng-click="showOnlyColumnValue(columnName, imagePair[constants.KEY__IMAGEPAIRS__EXTRACOLUMNS][columnName])"
+ title="show only results of {{extraColumnHeaders[columnName][constants.KEY__EXTRACOLUMNHEADERS__HEADER_TEXT]}} {{imagePair[constants.KEY__IMAGEPAIRS__EXTRACOLUMNS][columnName]}}">
+ show only
+ </button>
+ <br>
+ <button class="show-all-button"
+ ng-show="viewingTab == defaultTab"
+ ng-disabled="allColumnValues[columnName].length == setSize(showingColumnValues[columnName])"
+ ng-click="showAllColumnValues(columnName)"
+ title="show results of all {{extraColumnHeaders[columnName][constants.KEY__EXTRACOLUMNHEADERS__HEADER_TEXT]}}s">
+ show all
+ </button>
+ </td>
+
+ <!-- bugs -->
+ <td>
+ <a ng-repeat="bug in imagePair[constants.KEY__IMAGEPAIRS__EXPECTATIONS][constants.KEY__EXPECTATIONS__BUGS]"
+ href="https://code.google.com/p/skia/issues/detail?id={{bug}}"
+ target="_blank">
+ {{bug}}
+ </a>
+ </td>
+
+ <!-- image A -->
+ <td width="{{imageSize}}" ng-if="imagePair[constants.KEY__IMAGEPAIRS__ROWSPAN] > 0" rowspan="{{imagePair[constants.KEY__IMAGEPAIRS__ROWSPAN]}}">
+ <div ng-if="imagePair[constants.KEY__IMAGEPAIRS__IMAGE_A_URL] != null">
+ <a href="{{imageSets[constants.KEY__IMAGESETS__SET__IMAGE_A][constants.KEY__IMAGESETS__FIELD__BASE_URL]}}/{{imagePair[constants.KEY__IMAGEPAIRS__IMAGE_A_URL]}}" target="_blank">View Image</a><br/>
+ <img ng-if="showThumbnails"
+ width="{{imageSize}}"
+ ng-src="{{imageSets[constants.KEY__IMAGESETS__SET__IMAGE_A][constants.KEY__IMAGESETS__FIELD__BASE_URL]}}/{{imagePair[constants.KEY__IMAGEPAIRS__IMAGE_A_URL]}}" />
+ </div>
+ <div ng-show="imagePair[constants.KEY__IMAGEPAIRS__IMAGE_A_URL] == null"
+ style="text-align:center">
+ &ndash;none&ndash;
+ </div>
+ </td>
+
+ <!-- image B -->
+ <td width="{{imageSize}}" ng-if="imagePair[constants.KEY__IMAGEPAIRS__ROWSPAN] > 0" rowspan="{{imagePair[constants.KEY__IMAGEPAIRS__ROWSPAN]}}">
+ <div ng-if="imagePair[constants.KEY__IMAGEPAIRS__IMAGE_B_URL] != null">
+ <a href="{{imageSets[constants.KEY__IMAGESETS__SET__IMAGE_B][constants.KEY__IMAGESETS__FIELD__BASE_URL]}}/{{imagePair[constants.KEY__IMAGEPAIRS__IMAGE_B_URL]}}" target="_blank">View Image</a><br/>
+ <img ng-if="showThumbnails"
+ width="{{imageSize}}"
+ ng-src="{{imageSets[constants.KEY__IMAGESETS__SET__IMAGE_B][constants.KEY__IMAGESETS__FIELD__BASE_URL]}}/{{imagePair[constants.KEY__IMAGEPAIRS__IMAGE_B_URL]}}" />
+ </div>
+ <div ng-show="imagePair[constants.KEY__IMAGEPAIRS__IMAGE_B_URL] == null"
+ style="text-align:center">
+ &ndash;none&ndash;
+ </div>
+ </td>
+
+ <!-- whitediffs: every differing pixel shown in white -->
+ <td width="{{imageSize}}" ng-if="imagePair[constants.KEY__IMAGEPAIRS__ROWSPAN] > 0" rowspan="{{imagePair[constants.KEY__IMAGEPAIRS__ROWSPAN]}}">
+ <div ng-if="imagePair[constants.KEY__IMAGEPAIRS__IS_DIFFERENT]"
+ title="{{imagePair[constants.KEY__IMAGEPAIRS__DIFFERENCES][constants.KEY__DIFFERENCES__NUM_DIFF_PIXELS] | number:0}} of {{(100 * imagePair[constants.KEY__IMAGEPAIRS__DIFFERENCES][constants.KEY__DIFFERENCES__NUM_DIFF_PIXELS] / imagePair[constants.KEY__IMAGEPAIRS__DIFFERENCES][constants.KEY__DIFFERENCES__PERCENT_DIFF_PIXELS]) | number:0}} pixels ({{imagePair[constants.KEY__IMAGEPAIRS__DIFFERENCES][constants.KEY__DIFFERENCES__PERCENT_DIFF_PIXELS].toFixed(4)}}%) differ from expectation.">
+
+ <a href="{{imageSets[constants.KEY__IMAGESETS__SET__WHITEDIFFS][constants.KEY__IMAGESETS__FIELD__BASE_URL]}}/{{imagePair[constants.KEY__IMAGEPAIRS__DIFFERENCES][constants.KEY__DIFFERENCES__WHITE_DIFF_URL]}}" target="_blank">View Image</a><br/>
+ <img ng-if="showThumbnails"
+ width="{{imageSize}}"
+ ng-src="{{imageSets[constants.KEY__IMAGESETS__SET__WHITEDIFFS][constants.KEY__IMAGESETS__FIELD__BASE_URL]}}/{{imagePair[constants.KEY__IMAGEPAIRS__DIFFERENCES][constants.KEY__DIFFERENCES__WHITE_DIFF_URL]}}" />
+ <br/>
+ {{imagePair[constants.KEY__IMAGEPAIRS__DIFFERENCES][constants.KEY__DIFFERENCES__PERCENT_DIFF_PIXELS].toFixed(4)}}%
+ ({{imagePair[constants.KEY__IMAGEPAIRS__DIFFERENCES][constants.KEY__DIFFERENCES__NUM_DIFF_PIXELS]}})
+ </div>
+ <div ng-show="!imagePair[constants.KEY__IMAGEPAIRS__IS_DIFFERENT]"
+ style="text-align:center">
+ &ndash;none&ndash;
+ </div>
+ </td>
+
+ <!-- diffs: per-channel RGB deltas -->
+ <td width="{{imageSize}}" ng-if="imagePair[constants.KEY__IMAGEPAIRS__ROWSPAN] > 0" rowspan="{{imagePair[constants.KEY__IMAGEPAIRS__ROWSPAN]}}">
+ <div ng-if="imagePair[constants.KEY__IMAGEPAIRS__IS_DIFFERENT]"
+ title="Perceptual difference measure is {{imagePair[constants.KEY__IMAGEPAIRS__DIFFERENCES][constants.KEY__DIFFERENCES__PERCEPTUAL_DIFF].toFixed(4)}}%. Maximum difference per channel: R={{imagePair[constants.KEY__IMAGEPAIRS__DIFFERENCES][constants.KEY__DIFFERENCES__MAX_DIFF_PER_CHANNEL][0]}}, G={{imagePair[constants.KEY__IMAGEPAIRS__DIFFERENCES][constants.KEY__DIFFERENCES__MAX_DIFF_PER_CHANNEL][1]}}, B={{imagePair[constants.KEY__IMAGEPAIRS__DIFFERENCES][constants.KEY__DIFFERENCES__MAX_DIFF_PER_CHANNEL][2]}}">
+
+ <a href="{{imageSets[constants.KEY__IMAGESETS__SET__DIFFS][constants.KEY__IMAGESETS__FIELD__BASE_URL]}}/{{imagePair[constants.KEY__IMAGEPAIRS__DIFFERENCES][constants.KEY__DIFFERENCES__DIFF_URL]}}" target="_blank">View Image</a><br/>
+ <img ng-if="showThumbnails"
+ ng-style="{backgroundColor: pixelDiffBgColor}"
+ width="{{imageSize}}"
+ ng-src="{{imageSets[constants.KEY__IMAGESETS__SET__DIFFS][constants.KEY__IMAGESETS__FIELD__BASE_URL]}}/{{imagePair[constants.KEY__IMAGEPAIRS__DIFFERENCES][constants.KEY__DIFFERENCES__DIFF_URL]}}" />
+ <br/>
+ {{imagePair[constants.KEY__IMAGEPAIRS__DIFFERENCES][constants.KEY__DIFFERENCES__PERCEPTUAL_DIFF].toFixed(4)}}%
+ {{imagePair[constants.KEY__IMAGEPAIRS__DIFFERENCES][constants.KEY__DIFFERENCES__MAX_DIFF_PER_CHANNEL]}}
+ </div>
+ <div ng-show="!imagePair[constants.KEY__IMAGEPAIRS__IS_DIFFERENT]"
+ style="text-align:center">
+ &ndash;none&ndash;
+ </div>
+ </td>
+
+ <td ng-if="imagePair[constants.KEY__IMAGEPAIRS__ROWSPAN] > 0" rowspan="{{imagePair[constants.KEY__IMAGEPAIRS__ROWSPAN]}}">
+ <br/>
+ <input type="checkbox"
+ name="rowSelect"
+ value="{{imagePair.index}}"
+ ng-checked="isValueInArray(imagePair.index, selectedImagePairs)"
+ ng-click="toggleSomeImagePairs($index, imagePair[constants.KEY__IMAGEPAIRS__ROWSPAN])">
+ </tr>
+ </table> <!-- imagePairs -->
+ </td></tr></table> <!-- table holding results header + imagePairs table -->
+
+ </div><!-- main display area of selected tab -->
+ </div><!-- everything: hide until readyToDisplay -->
+
+</body>
+</html>
diff --git a/gm/rebaseline_server/static/loader.js b/gm/rebaseline_server/static/loader.js
new file mode 100644
index 0000000000..bfc639e33b
--- /dev/null
+++ b/gm/rebaseline_server/static/loader.js
@@ -0,0 +1,1035 @@
+/*
+ * Loader:
+ * Reads GM result reports written out by results.py, and imports
+ * them into $scope.extraColumnHeaders and $scope.imagePairs .
+ */
+var Loader = angular.module(
+ 'Loader',
+ ['ConstantsModule']
+);
+
+Loader.directive(
+ 'resultsUpdatedCallbackDirective',
+ ['$timeout',
+ function($timeout) {
+ return function(scope, element, attrs) {
+ if (scope.$last) {
+ $timeout(function() {
+ scope.resultsUpdatedCallback();
+ });
+ }
+ };
+ }
+ ]
+);
+
+// TODO(epoger): Combine ALL of our filtering operations (including
+// truncation) into this one filter, so that runs most efficiently?
+// (We would have to make sure truncation still took place after
+// sorting, though.)
+Loader.filter(
+ 'removeHiddenImagePairs',
+ function(constants) {
+ return function(unfilteredImagePairs, filterableColumnNames, showingColumnValues,
+ viewingTab) {
+ var filteredImagePairs = [];
+ for (var i = 0; i < unfilteredImagePairs.length; i++) {
+ var imagePair = unfilteredImagePairs[i];
+ var extraColumnValues = imagePair[constants.KEY__IMAGEPAIRS__EXTRACOLUMNS];
+ var allColumnValuesAreVisible = true;
+ // Loop over all columns, and if any of them contain values not found in
+ // showingColumnValues[columnName], don't include this imagePair.
+ //
+ // We use this same filtering mechanism regardless of whether each column
+ // has USE_FREEFORM_FILTER set or not; if that flag is set, then we will
+ // have already used the freeform text entry block to populate
+ // showingColumnValues[columnName].
+ for (var j = 0; j < filterableColumnNames.length; j++) {
+ var columnName = filterableColumnNames[j];
+ var columnValue = extraColumnValues[columnName];
+ if (!showingColumnValues[columnName][columnValue]) {
+ allColumnValuesAreVisible = false;
+ break;
+ }
+ }
+ if (allColumnValuesAreVisible && (viewingTab == imagePair.tab)) {
+ filteredImagePairs.push(imagePair);
+ }
+ }
+ return filteredImagePairs;
+ };
+ }
+);
+
+/**
+ * Limit the input imagePairs to some max number, and merge identical rows
+ * (adjacent rows which have the same (imageA, imageB) pair).
+ *
+ * @param unfilteredImagePairs imagePairs to filter
+ * @param maxPairs maximum number of pairs to output, or <0 for no limit
+ * @param mergeIdenticalRows if true, merge identical rows by setting
+ * ROWSPAN>1 on the first merged row, and ROWSPAN=0 for the rest
+ */
+Loader.filter(
+ 'mergeAndLimit',
+ function(constants) {
+ return function(unfilteredImagePairs, maxPairs, mergeIdenticalRows) {
+ var numPairs = unfilteredImagePairs.length;
+ if ((maxPairs > 0) && (maxPairs < numPairs)) {
+ numPairs = maxPairs;
+ }
+ var filteredImagePairs = [];
+ if (!mergeIdenticalRows || (numPairs == 1)) {
+ // Take a shortcut if we're not merging identical rows.
+ // We still need to set ROWSPAN to 1 for each row, for the HTML viewer.
+ for (var i = numPairs-1; i >= 0; i--) {
+ var imagePair = unfilteredImagePairs[i];
+ imagePair[constants.KEY__IMAGEPAIRS__ROWSPAN] = 1;
+ filteredImagePairs[i] = imagePair;
+ }
+ } else if (numPairs > 1) {
+ // General case--there are at least 2 rows, so we may need to merge some.
+ // Work from the bottom up, so we can keep a running total of how many
+ // rows should be merged, and set ROWSPAN of the top row accordingly.
+ var imagePair = unfilteredImagePairs[numPairs-1];
+ var nextRowImageAUrl = imagePair[constants.KEY__IMAGEPAIRS__IMAGE_A_URL];
+ var nextRowImageBUrl = imagePair[constants.KEY__IMAGEPAIRS__IMAGE_B_URL];
+ imagePair[constants.KEY__IMAGEPAIRS__ROWSPAN] = 1;
+ filteredImagePairs[numPairs-1] = imagePair;
+ for (var i = numPairs-2; i >= 0; i--) {
+ imagePair = unfilteredImagePairs[i];
+ var thisRowImageAUrl = imagePair[constants.KEY__IMAGEPAIRS__IMAGE_A_URL];
+ var thisRowImageBUrl = imagePair[constants.KEY__IMAGEPAIRS__IMAGE_B_URL];
+ if ((thisRowImageAUrl == nextRowImageAUrl) &&
+ (thisRowImageBUrl == nextRowImageBUrl)) {
+ imagePair[constants.KEY__IMAGEPAIRS__ROWSPAN] =
+ filteredImagePairs[i+1][constants.KEY__IMAGEPAIRS__ROWSPAN] + 1;
+ filteredImagePairs[i+1][constants.KEY__IMAGEPAIRS__ROWSPAN] = 0;
+ } else {
+ imagePair[constants.KEY__IMAGEPAIRS__ROWSPAN] = 1;
+ nextRowImageAUrl = thisRowImageAUrl;
+ nextRowImageBUrl = thisRowImageBUrl;
+ }
+ filteredImagePairs[i] = imagePair;
+ }
+ } else {
+ // No results.
+ }
+ return filteredImagePairs;
+ };
+ }
+);
+
+
+Loader.controller(
+ 'Loader.Controller',
+ function($scope, $http, $filter, $location, $log, $timeout, constants) {
+ $scope.readyToDisplay = false;
+ $scope.constants = constants;
+ $scope.windowTitle = "Loading GM Results...";
+ $scope.resultsToLoad = $location.search().resultsToLoad;
+ $scope.loadingMessage = "please wait...";
+
+ var currSortAsc = true;
+
+
+ /**
+ * On initial page load, load a full dictionary of results.
+ * Once the dictionary is loaded, unhide the page elements so they can
+ * render the data.
+ */
+ $http.get($scope.resultsToLoad).success(
+ function(data, status, header, config) {
+ var dataHeader = data[constants.KEY__ROOT__HEADER];
+ if (dataHeader[constants.KEY__HEADER__SCHEMA_VERSION] !=
+ constants.VALUE__HEADER__SCHEMA_VERSION) {
+ $scope.loadingMessage = "ERROR: Got JSON file with schema version "
+ + dataHeader[constants.KEY__HEADER__SCHEMA_VERSION]
+ + " but expected schema version "
+ + constants.VALUE__HEADER__SCHEMA_VERSION;
+ } else if (dataHeader[constants.KEY__HEADER__IS_STILL_LOADING]) {
+ // Apply the server's requested reload delay to local time,
+ // so we will wait the right number of seconds regardless of clock
+ // skew between client and server.
+ var reloadDelayInSeconds =
+ dataHeader[constants.KEY__HEADER__TIME_NEXT_UPDATE_AVAILABLE] -
+ dataHeader[constants.KEY__HEADER__TIME_UPDATED];
+ var timeNow = new Date().getTime();
+ var timeToReload = timeNow + reloadDelayInSeconds * 1000;
+ $scope.loadingMessage =
+ "server is still loading results; will retry at " +
+ $scope.localTimeString(timeToReload / 1000);
+ $timeout(
+ function(){location.reload();},
+ timeToReload - timeNow);
+ } else {
+ $scope.loadingMessage = "processing data, please wait...";
+
+ $scope.header = dataHeader;
+ $scope.extraColumnHeaders = data[constants.KEY__ROOT__EXTRACOLUMNHEADERS];
+ $scope.orderedColumnNames = data[constants.KEY__ROOT__EXTRACOLUMNORDER];
+ $scope.imagePairs = data[constants.KEY__ROOT__IMAGEPAIRS];
+ $scope.imageSets = data[constants.KEY__ROOT__IMAGESETS];
+
+ // set the default sort column and make it ascending.
+ $scope.sortColumnSubdict = constants.KEY__IMAGEPAIRS__DIFFERENCES;
+ $scope.sortColumnKey = constants.KEY__DIFFERENCES__PERCEPTUAL_DIFF;
+ currSortAsc = true;
+
+ $scope.showSubmitAdvancedSettings = false;
+ $scope.submitAdvancedSettings = {};
+ $scope.submitAdvancedSettings[
+ constants.KEY__EXPECTATIONS__REVIEWED] = true;
+ $scope.submitAdvancedSettings[
+ constants.KEY__EXPECTATIONS__IGNOREFAILURE] = false;
+ $scope.submitAdvancedSettings['bug'] = '';
+
+ // Create the list of tabs (lists into which the user can file each
+ // test). This may vary, depending on isEditable.
+ $scope.tabs = [
+ 'Unfiled', 'Hidden'
+ ];
+ if (dataHeader[constants.KEY__HEADER__IS_EDITABLE]) {
+ $scope.tabs = $scope.tabs.concat(
+ ['Pending Approval']);
+ }
+ $scope.defaultTab = $scope.tabs[0];
+ $scope.viewingTab = $scope.defaultTab;
+
+ // Track the number of results on each tab.
+ $scope.numResultsPerTab = {};
+ for (var i = 0; i < $scope.tabs.length; i++) {
+ $scope.numResultsPerTab[$scope.tabs[i]] = 0;
+ }
+ $scope.numResultsPerTab[$scope.defaultTab] = $scope.imagePairs.length;
+
+ // Add index and tab fields to all records.
+ for (var i = 0; i < $scope.imagePairs.length; i++) {
+ $scope.imagePairs[i].index = i;
+ $scope.imagePairs[i].tab = $scope.defaultTab;
+ }
+
+ // Arrays within which the user can toggle individual elements.
+ $scope.selectedImagePairs = [];
+
+ // Set up filters.
+ //
+ // filterableColumnNames is a list of all column names we can filter on.
+ // allColumnValues[columnName] is a list of all known values
+ // for a given column.
+ // showingColumnValues[columnName] is a set indicating which values
+ // in a given column would cause us to show a row, rather than hiding it.
+ //
+ // columnStringMatch[columnName] is a string used as a pattern to generate
+ // showingColumnValues[columnName] for columns we filter using free-form text.
+ // It is ignored for any columns with USE_FREEFORM_FILTER == false.
+ $scope.filterableColumnNames = [];
+ $scope.allColumnValues = {};
+ $scope.showingColumnValues = {};
+ $scope.columnStringMatch = {};
+
+ angular.forEach(
+ Object.keys($scope.extraColumnHeaders),
+ function(columnName) {
+ var columnHeader = $scope.extraColumnHeaders[columnName];
+ if (columnHeader[constants.KEY__EXTRACOLUMNHEADERS__IS_FILTERABLE]) {
+ $scope.filterableColumnNames.push(columnName);
+ $scope.allColumnValues[columnName] = $scope.columnSliceOf2DArray(
+ columnHeader[constants.KEY__EXTRACOLUMNHEADERS__VALUES_AND_COUNTS], 0);
+ $scope.showingColumnValues[columnName] = {};
+ $scope.toggleValuesInSet($scope.allColumnValues[columnName],
+ $scope.showingColumnValues[columnName]);
+ $scope.columnStringMatch[columnName] = "";
+ }
+ }
+ );
+
+ // TODO(epoger): Special handling for RESULT_TYPE column:
+ // by default, show only KEY__RESULT_TYPE__FAILED results
+ $scope.showingColumnValues[constants.KEY__EXTRACOLUMNS__RESULT_TYPE] = {};
+ $scope.showingColumnValues[constants.KEY__EXTRACOLUMNS__RESULT_TYPE][
+ constants.KEY__RESULT_TYPE__FAILED] = true;
+
+ // Set up mapping for URL parameters.
+ // parameter name -> copier object to load/save parameter value
+ $scope.queryParameters.map = {
+ 'resultsToLoad': $scope.queryParameters.copiers.simple,
+ 'displayLimitPending': $scope.queryParameters.copiers.simple,
+ 'showThumbnailsPending': $scope.queryParameters.copiers.simple,
+ 'mergeIdenticalRowsPending': $scope.queryParameters.copiers.simple,
+ 'imageSizePending': $scope.queryParameters.copiers.simple,
+ 'sortColumnSubdict': $scope.queryParameters.copiers.simple,
+ 'sortColumnKey': $scope.queryParameters.copiers.simple,
+ };
+ // Some parameters are handled differently based on whether they USE_FREEFORM_FILTER.
+ angular.forEach(
+ $scope.filterableColumnNames,
+ function(columnName) {
+ if ($scope.extraColumnHeaders[columnName]
+ [constants.KEY__EXTRACOLUMNHEADERS__USE_FREEFORM_FILTER]) {
+ $scope.queryParameters.map[columnName] =
+ $scope.queryParameters.copiers.columnStringMatch;
+ } else {
+ $scope.queryParameters.map[columnName] =
+ $scope.queryParameters.copiers.showingColumnValuesSet;
+ }
+ }
+ );
+
+ // If any defaults were overridden in the URL, get them now.
+ $scope.queryParameters.load();
+
+ // Any image URLs which are relative should be relative to the JSON
+ // file's source directory; absolute URLs should be left alone.
+ var baseUrlKey = constants.KEY__IMAGESETS__FIELD__BASE_URL;
+ angular.forEach(
+ $scope.imageSets,
+ function(imageSet) {
+ var baseUrl = imageSet[baseUrlKey];
+ if ((baseUrl.substring(0, 1) != '/') &&
+ (baseUrl.indexOf('://') == -1)) {
+ imageSet[baseUrlKey] = $scope.resultsToLoad + '/../' + baseUrl;
+ }
+ }
+ );
+
+ $scope.readyToDisplay = true;
+ $scope.updateResults();
+ $scope.loadingMessage = "";
+ $scope.windowTitle = "Current GM Results";
+
+ $timeout( function() {
+ make_results_header_sticky();
+ });
+ }
+ }
+ ).error(
+ function(data, status, header, config) {
+ $scope.loadingMessage = "FAILED to load.";
+ $scope.windowTitle = "Failed to Load GM Results";
+ }
+ );
+
+
+ //
+ // Select/Clear/Toggle all tests.
+ //
+
+ /**
+ * Select all currently showing tests.
+ */
+ $scope.selectAllImagePairs = function() {
+ var numImagePairsShowing = $scope.limitedImagePairs.length;
+ for (var i = 0; i < numImagePairsShowing; i++) {
+ var index = $scope.limitedImagePairs[i].index;
+ if (!$scope.isValueInArray(index, $scope.selectedImagePairs)) {
+ $scope.toggleValueInArray(index, $scope.selectedImagePairs);
+ }
+ }
+ };
+
+ /**
+ * Deselect all currently showing tests.
+ */
+ $scope.clearAllImagePairs = function() {
+ var numImagePairsShowing = $scope.limitedImagePairs.length;
+ for (var i = 0; i < numImagePairsShowing; i++) {
+ var index = $scope.limitedImagePairs[i].index;
+ if ($scope.isValueInArray(index, $scope.selectedImagePairs)) {
+ $scope.toggleValueInArray(index, $scope.selectedImagePairs);
+ }
+ }
+ };
+
+ /**
+ * Toggle selection of all currently showing tests.
+ */
+ $scope.toggleAllImagePairs = function() {
+ var numImagePairsShowing = $scope.limitedImagePairs.length;
+ for (var i = 0; i < numImagePairsShowing; i++) {
+ var index = $scope.limitedImagePairs[i].index;
+ $scope.toggleValueInArray(index, $scope.selectedImagePairs);
+ }
+ };
+
+ /**
+ * Toggle selection state of a subset of the currently showing tests.
+ *
+ * @param startIndex index within $scope.limitedImagePairs of the first
+ * test to toggle selection state of
+ * @param num number of tests (in a contiguous block) to toggle
+ */
+ $scope.toggleSomeImagePairs = function(startIndex, num) {
+ var numImagePairsShowing = $scope.limitedImagePairs.length;
+ for (var i = startIndex; i < startIndex + num; i++) {
+ var index = $scope.limitedImagePairs[i].index;
+ $scope.toggleValueInArray(index, $scope.selectedImagePairs);
+ }
+ };
+
+
+ //
+ // Tab operations.
+ //
+
+ /**
+ * Change the selected tab.
+ *
+ * @param tab (string): name of the tab to select
+ */
+ $scope.setViewingTab = function(tab) {
+ $scope.viewingTab = tab;
+ $scope.updateResults();
+ };
+
+ /**
+ * Move the imagePairs in $scope.selectedImagePairs to a different tab,
+ * and then clear $scope.selectedImagePairs.
+ *
+ * @param newTab (string): name of the tab to move the tests to
+ */
+ $scope.moveSelectedImagePairsToTab = function(newTab) {
+ $scope.moveImagePairsToTab($scope.selectedImagePairs, newTab);
+ $scope.selectedImagePairs = [];
+ $scope.updateResults();
+ };
+
+ /**
+ * Move a subset of $scope.imagePairs to a different tab.
+ *
+ * @param imagePairIndices (array of ints): indices into $scope.imagePairs
+ * indicating which test results to move
+ * @param newTab (string): name of the tab to move the tests to
+ */
+ $scope.moveImagePairsToTab = function(imagePairIndices, newTab) {
+ var imagePairIndex;
+ var numImagePairs = imagePairIndices.length;
+ for (var i = 0; i < numImagePairs; i++) {
+ imagePairIndex = imagePairIndices[i];
+ $scope.numResultsPerTab[$scope.imagePairs[imagePairIndex].tab]--;
+ $scope.imagePairs[imagePairIndex].tab = newTab;
+ }
+ $scope.numResultsPerTab[newTab] += numImagePairs;
+ };
+
+
+ //
+ // $scope.queryParameters:
+ // Transfer parameter values between $scope and the URL query string.
+ //
+ $scope.queryParameters = {};
+
+ // load and save functions for parameters of each type
+ // (load a parameter value into $scope from nameValuePairs,
+ // save a parameter value from $scope into nameValuePairs)
+ $scope.queryParameters.copiers = {
+ 'simple': {
+ 'load': function(nameValuePairs, name) {
+ var value = nameValuePairs[name];
+ if (value) {
+ $scope[name] = value;
+ }
+ },
+ 'save': function(nameValuePairs, name) {
+ nameValuePairs[name] = $scope[name];
+ }
+ },
+
+ 'columnStringMatch': {
+ 'load': function(nameValuePairs, name) {
+ var value = nameValuePairs[name];
+ if (value) {
+ $scope.columnStringMatch[name] = value;
+ }
+ },
+ 'save': function(nameValuePairs, name) {
+ nameValuePairs[name] = $scope.columnStringMatch[name];
+ }
+ },
+
+ 'showingColumnValuesSet': {
+ 'load': function(nameValuePairs, name) {
+ var value = nameValuePairs[name];
+ if (value) {
+ var valueArray = value.split(',');
+ $scope.showingColumnValues[name] = {};
+ $scope.toggleValuesInSet(valueArray, $scope.showingColumnValues[name]);
+ }
+ },
+ 'save': function(nameValuePairs, name) {
+ nameValuePairs[name] = Object.keys($scope.showingColumnValues[name]).join(',');
+ }
+ },
+
+ };
+
+ // Loads all parameters into $scope from the URL query string;
+ // any which are not found within the URL will keep their current value.
+ $scope.queryParameters.load = function() {
+ var nameValuePairs = $location.search();
+
+ // If urlSchemaVersion is not specified, we assume the current version.
+ var urlSchemaVersion = constants.URL_VALUE__SCHEMA_VERSION__CURRENT;
+ if (constants.URL_KEY__SCHEMA_VERSION in nameValuePairs) {
+ urlSchemaVersion = nameValuePairs[constants.URL_KEY__SCHEMA_VERSION];
+ } else if ('hiddenResultTypes' in nameValuePairs) {
+ // The combination of:
+ // - absence of an explicit urlSchemaVersion, and
+ // - presence of the old 'hiddenResultTypes' field
+ // tells us that the URL is from the original urlSchemaVersion.
+ // See https://codereview.chromium.org/367173002/
+ urlSchemaVersion = 0;
+ }
+ $scope.urlSchemaVersionLoaded = urlSchemaVersion;
+
+ if (urlSchemaVersion != constants.URL_VALUE__SCHEMA_VERSION__CURRENT) {
+ nameValuePairs = $scope.upconvertUrlNameValuePairs(nameValuePairs, urlSchemaVersion);
+ }
+ angular.forEach($scope.queryParameters.map,
+ function(copier, paramName) {
+ copier.load(nameValuePairs, paramName);
+ }
+ );
+ };
+
+ // Saves all parameters from $scope into the URL query string.
+ $scope.queryParameters.save = function() {
+ var nameValuePairs = {};
+ nameValuePairs[constants.URL_KEY__SCHEMA_VERSION] = constants.URL_VALUE__SCHEMA_VERSION__CURRENT;
+ angular.forEach($scope.queryParameters.map,
+ function(copier, paramName) {
+ copier.save(nameValuePairs, paramName);
+ }
+ );
+ $location.search(nameValuePairs);
+ };
+
+ /**
+ * Converts URL name/value pairs that were stored by a previous urlSchemaVersion
+ * to the currently needed format.
+ *
+ * @param oldNValuePairs name/value pairs found in the loaded URL
+ * @param oldUrlSchemaVersion which version of the schema was used to generate that URL
+ *
+ * @returns nameValuePairs as needed by the current URL parser
+ */
+ $scope.upconvertUrlNameValuePairs = function(oldNameValuePairs, oldUrlSchemaVersion) {
+ var newNameValuePairs = {};
+ angular.forEach(oldNameValuePairs,
+ function(value, name) {
+ if (oldUrlSchemaVersion < 1) {
+ if ('hiddenConfigs' == name) {
+ name = 'config';
+ var valueSet = {};
+ $scope.toggleValuesInSet(value.split(','), valueSet);
+ $scope.toggleValuesInSet(
+ $scope.allColumnValues[constants.KEY__EXTRACOLUMNS__CONFIG],
+ valueSet);
+ value = Object.keys(valueSet).join(',');
+ } else if ('hiddenResultTypes' == name) {
+ name = 'resultType';
+ var valueSet = {};
+ $scope.toggleValuesInSet(value.split(','), valueSet);
+ $scope.toggleValuesInSet(
+ $scope.allColumnValues[constants.KEY__EXTRACOLUMNS__RESULT_TYPE],
+ valueSet);
+ value = Object.keys(valueSet).join(',');
+ }
+ }
+
+ newNameValuePairs[name] = value;
+ }
+ );
+ return newNameValuePairs;
+ }
+
+
+ //
+ // updateResults() and friends.
+ //
+
+ /**
+ * Set $scope.areUpdatesPending (to enable/disable the Update Results
+ * button).
+ *
+ * TODO(epoger): We could reduce the amount of code by just setting the
+ * variable directly (from, e.g., a button's ng-click handler). But when
+ * I tried that, the HTML elements depending on the variable did not get
+ * updated.
+ * It turns out that this is due to variable scoping within an ng-repeat
+ * element; see http://stackoverflow.com/questions/15388344/behavior-of-assignment-expression-invoked-by-ng-click-within-ng-repeat
+ *
+ * @param val boolean value to set $scope.areUpdatesPending to
+ */
+ $scope.setUpdatesPending = function(val) {
+ $scope.areUpdatesPending = val;
+ }
+
+ /**
+ * Update the displayed results, based on filters/settings,
+ * and call $scope.queryParameters.save() so that the new filter results
+ * can be bookmarked.
+ */
+ $scope.updateResults = function() {
+ $scope.renderStartTime = window.performance.now();
+ $log.debug("renderStartTime: " + $scope.renderStartTime);
+ $scope.displayLimit = $scope.displayLimitPending;
+ $scope.mergeIdenticalRows = $scope.mergeIdenticalRowsPending;
+
+ // For each USE_FREEFORM_FILTER column, populate showingColumnValues.
+ // This is more efficient than applying the freeform filter within the
+ // tight loop in removeHiddenImagePairs.
+ angular.forEach(
+ $scope.filterableColumnNames,
+ function(columnName) {
+ var columnHeader = $scope.extraColumnHeaders[columnName];
+ if (columnHeader[constants.KEY__EXTRACOLUMNHEADERS__USE_FREEFORM_FILTER]) {
+ var columnStringMatch = $scope.columnStringMatch[columnName];
+ var showingColumnValues = {};
+ angular.forEach(
+ $scope.allColumnValues[columnName],
+ function(columnValue) {
+ if (-1 != columnValue.indexOf(columnStringMatch)) {
+ showingColumnValues[columnValue] = true;
+ }
+ }
+ );
+ $scope.showingColumnValues[columnName] = showingColumnValues;
+ }
+ }
+ );
+
+ // TODO(epoger): Every time we apply a filter, AngularJS creates
+ // another copy of the array. Is there a way we can filter out
+ // the imagePairs as they are displayed, rather than storing multiple
+ // array copies? (For better performance.)
+ if ($scope.viewingTab == $scope.defaultTab) {
+ var doReverse = !currSortAsc;
+
+ $scope.filteredImagePairs =
+ $filter("orderBy")(
+ $filter("removeHiddenImagePairs")(
+ $scope.imagePairs,
+ $scope.filterableColumnNames,
+ $scope.showingColumnValues,
+ $scope.viewingTab
+ ),
+ // [$scope.getSortColumnValue, $scope.getSecondOrderSortValue],
+ $scope.getSortColumnValue,
+ doReverse);
+ $scope.limitedImagePairs = $filter("mergeAndLimit")(
+ $scope.filteredImagePairs, $scope.displayLimit, $scope.mergeIdenticalRows);
+ } else {
+ $scope.filteredImagePairs =
+ $filter("orderBy")(
+ $filter("filter")(
+ $scope.imagePairs,
+ {tab: $scope.viewingTab},
+ true
+ ),
+ // [$scope.getSortColumnValue, $scope.getSecondOrderSortValue]);
+ $scope.getSortColumnValue);
+ $scope.limitedImagePairs = $filter("mergeAndLimit")(
+ $scope.filteredImagePairs, -1, $scope.mergeIdenticalRows);
+ }
+ $scope.showThumbnails = $scope.showThumbnailsPending;
+ $scope.imageSize = $scope.imageSizePending;
+ $scope.setUpdatesPending(false);
+ $scope.queryParameters.save();
+ }
+
+ /**
+ * This function is called when the results have been completely rendered
+ * after updateResults().
+ */
+ $scope.resultsUpdatedCallback = function() {
+ $scope.renderEndTime = window.performance.now();
+ $log.debug("renderEndTime: " + $scope.renderEndTime);
+ };
+
+ /**
+ * Re-sort the displayed results.
+ *
+ * @param subdict (string): which KEY__IMAGEPAIRS__* subdictionary
+ * the sort column key is within, or 'none' if the sort column
+ * key is one of KEY__IMAGEPAIRS__*
+ * @param key (string): sort by value associated with this key in subdict
+ */
+ $scope.sortResultsBy = function(subdict, key) {
+ // if we are already sorting by this column then toggle between asc/desc
+ if ((subdict === $scope.sortColumnSubdict) && ($scope.sortColumnKey === key)) {
+ currSortAsc = !currSortAsc;
+ } else {
+ $scope.sortColumnSubdict = subdict;
+ $scope.sortColumnKey = key;
+ currSortAsc = true;
+ }
+ $scope.updateResults();
+ };
+
+ /**
+ * Returns ASC or DESC (from constants) if currently the data
+ * is sorted by the provided column.
+ *
+ * @param colName: name of the column for which we need to get the class.
+ */
+
+ $scope.sortedByColumnsCls = function (colName) {
+ if ($scope.sortColumnKey !== colName) {
+ return '';
+ }
+
+ var result = (currSortAsc) ? constants.ASC : constants.DESC;
+ console.log("sort class:", result);
+ return result;
+ };
+
+ /**
+ * For a particular ImagePair, return the value of the column we are
+ * sorting on (according to $scope.sortColumnSubdict and
+ * $scope.sortColumnKey).
+ *
+ * @param imagePair: imagePair to get a column value out of.
+ */
+ $scope.getSortColumnValue = function(imagePair) {
+ if ($scope.sortColumnSubdict in imagePair) {
+ return imagePair[$scope.sortColumnSubdict][$scope.sortColumnKey];
+ } else if ($scope.sortColumnKey in imagePair) {
+ return imagePair[$scope.sortColumnKey];
+ } else {
+ return undefined;
+ }
+ };
+
+ /**
+ * For a particular ImagePair, return the value we use for the
+ * second-order sort (tiebreaker when multiple rows have
+ * the same getSortColumnValue()).
+ *
+ * We join the imageA and imageB urls for this value, so that we merge
+ * adjacent rows as much as possible.
+ *
+ * @param imagePair: imagePair to get a column value out of.
+ */
+ $scope.getSecondOrderSortValue = function(imagePair) {
+ return imagePair[constants.KEY__IMAGEPAIRS__IMAGE_A_URL] + "-vs-" +
+ imagePair[constants.KEY__IMAGEPAIRS__IMAGE_B_URL];
+ };
+
+ /**
+ * Set $scope.columnStringMatch[name] = value, and update results.
+ *
+ * @param name
+ * @param value
+ */
+ $scope.setColumnStringMatch = function(name, value) {
+ $scope.columnStringMatch[name] = value;
+ $scope.updateResults();
+ };
+
+ /**
+ * Update $scope.showingColumnValues[columnName] and $scope.columnStringMatch[columnName]
+ * so that ONLY entries with this columnValue are showing, and update the visible results.
+ * (We update both of those, so we cover both freeform and checkbox filtered columns.)
+ *
+ * @param columnName
+ * @param columnValue
+ */
+ $scope.showOnlyColumnValue = function(columnName, columnValue) {
+ $scope.columnStringMatch[columnName] = columnValue;
+ $scope.showingColumnValues[columnName] = {};
+ $scope.toggleValueInSet(columnValue, $scope.showingColumnValues[columnName]);
+ $scope.updateResults();
+ };
+
+ /**
+ * Update $scope.showingColumnValues[columnName] and $scope.columnStringMatch[columnName]
+ * so that ALL entries are showing, and update the visible results.
+ * (We update both of those, so we cover both freeform and checkbox filtered columns.)
+ *
+ * @param columnName
+ */
+ $scope.showAllColumnValues = function(columnName) {
+ $scope.columnStringMatch[columnName] = "";
+ $scope.showingColumnValues[columnName] = {};
+ $scope.toggleValuesInSet($scope.allColumnValues[columnName],
+ $scope.showingColumnValues[columnName]);
+ $scope.updateResults();
+ };
+
+
+ //
+ // Operations for sending info back to the server.
+ //
+
+ /**
+ * Tell the server that the actual results of these particular tests
+ * are acceptable.
+ *
+ * TODO(epoger): This assumes that the original expectations are in
+ * imageSetA, and the actuals are in imageSetB.
+ *
+ * @param imagePairsSubset an array of test results, most likely a subset of
+ * $scope.imagePairs (perhaps with some modifications)
+ */
+ $scope.submitApprovals = function(imagePairsSubset) {
+ $scope.submitPending = true;
+
+ // Convert bug text field to null or 1-item array.
+ var bugs = null;
+ var bugNumber = parseInt($scope.submitAdvancedSettings['bug']);
+ if (!isNaN(bugNumber)) {
+ bugs = [bugNumber];
+ }
+
+ // TODO(epoger): This is a suboptimal way to prevent users from
+ // rebaselining failures in alternative renderModes, but it does work.
+ // For a better solution, see
+ // https://code.google.com/p/skia/issues/detail?id=1748 ('gm: add new
+ // result type, RenderModeMismatch')
+ var encounteredComparisonConfig = false;
+
+ var updatedExpectations = [];
+ for (var i = 0; i < imagePairsSubset.length; i++) {
+ var imagePair = imagePairsSubset[i];
+ var updatedExpectation = {};
+ updatedExpectation[constants.KEY__IMAGEPAIRS__EXPECTATIONS] =
+ imagePair[constants.KEY__IMAGEPAIRS__EXPECTATIONS];
+ updatedExpectation[constants.KEY__IMAGEPAIRS__EXTRACOLUMNS] =
+ imagePair[constants.KEY__IMAGEPAIRS__EXTRACOLUMNS];
+ // IMAGE_B_URL contains the actual image (which is now the expectation)
+ updatedExpectation[constants.KEY__IMAGEPAIRS__IMAGE_B_URL] =
+ imagePair[constants.KEY__IMAGEPAIRS__IMAGE_B_URL];
+ if (0 == updatedExpectation[constants.KEY__IMAGEPAIRS__EXTRACOLUMNS]
+ [constants.KEY__EXTRACOLUMNS__CONFIG]
+ .indexOf('comparison-')) {
+ encounteredComparisonConfig = true;
+ }
+
+ // Advanced settings...
+ if (null == updatedExpectation[constants.KEY__IMAGEPAIRS__EXPECTATIONS]) {
+ updatedExpectation[constants.KEY__IMAGEPAIRS__EXPECTATIONS] = {};
+ }
+ updatedExpectation[constants.KEY__IMAGEPAIRS__EXPECTATIONS]
+ [constants.KEY__EXPECTATIONS__REVIEWED] =
+ $scope.submitAdvancedSettings[
+ constants.KEY__EXPECTATIONS__REVIEWED];
+ if (true == $scope.submitAdvancedSettings[
+ constants.KEY__EXPECTATIONS__IGNOREFAILURE]) {
+ // if it's false, don't send it at all (just keep the default)
+ updatedExpectation[constants.KEY__IMAGEPAIRS__EXPECTATIONS]
+ [constants.KEY__EXPECTATIONS__IGNOREFAILURE] = true;
+ }
+ updatedExpectation[constants.KEY__IMAGEPAIRS__EXPECTATIONS]
+ [constants.KEY__EXPECTATIONS__BUGS] = bugs;
+
+ updatedExpectations.push(updatedExpectation);
+ }
+ if (encounteredComparisonConfig) {
+ alert("Approval failed -- you cannot approve results with config " +
+ "type comparison-*");
+ $scope.submitPending = false;
+ return;
+ }
+ var modificationData = {};
+ modificationData[constants.KEY__EDITS__MODIFICATIONS] =
+ updatedExpectations;
+ modificationData[constants.KEY__EDITS__OLD_RESULTS_HASH] =
+ $scope.header[constants.KEY__HEADER__DATAHASH];
+ modificationData[constants.KEY__EDITS__OLD_RESULTS_TYPE] =
+ $scope.header[constants.KEY__HEADER__TYPE];
+ $http({
+ method: "POST",
+ url: "/edits",
+ data: modificationData
+ }).success(function(data, status, headers, config) {
+ var imagePairIndicesToMove = [];
+ for (var i = 0; i < imagePairsSubset.length; i++) {
+ imagePairIndicesToMove.push(imagePairsSubset[i].index);
+ }
+ $scope.moveImagePairsToTab(imagePairIndicesToMove,
+ "HackToMakeSureThisImagePairDisappears");
+ $scope.updateResults();
+ alert("New baselines submitted successfully!\n\n" +
+ "You still need to commit the updated expectations files on " +
+ "the server side to the Skia repo.\n\n" +
+ "When you click OK, your web UI will reload; after that " +
+ "completes, you will see the updated data (once the server has " +
+ "finished loading the update results into memory!) and you can " +
+ "submit more baselines if you want.");
+ // I don't know why, but if I just call reload() here it doesn't work.
+ // Making a timer call it fixes the problem.
+ $timeout(function(){location.reload();}, 1);
+ }).error(function(data, status, headers, config) {
+ alert("There was an error submitting your baselines.\n\n" +
+ "Please see server-side log for details.");
+ $scope.submitPending = false;
+ });
+ };
+
+
+ //
+ // Operations we use to mimic Set semantics, in such a way that
+ // checking for presence within the Set is as fast as possible.
+ // But getting a list of all values within the Set is not necessarily
+ // possible.
+ // TODO(epoger): move into a separate .js file?
+ //
+
+ /**
+ * Returns the number of values present within set "set".
+ *
+ * @param set an Object which we use to mimic set semantics
+ */
+ $scope.setSize = function(set) {
+ return Object.keys(set).length;
+ };
+
+ /**
+ * Returns true if value "value" is present within set "set".
+ *
+ * @param value a value of any type
+ * @param set an Object which we use to mimic set semantics
+ * (this should make isValueInSet faster than if we used an Array)
+ */
+ $scope.isValueInSet = function(value, set) {
+ return (true == set[value]);
+ };
+
+ /**
+ * If value "value" is already in set "set", remove it; otherwise, add it.
+ *
+ * @param value a value of any type
+ * @param set an Object which we use to mimic set semantics
+ */
+ $scope.toggleValueInSet = function(value, set) {
+ if (true == set[value]) {
+ delete set[value];
+ } else {
+ set[value] = true;
+ }
+ };
+
+ /**
+ * For each value in valueArray, call toggleValueInSet(value, set).
+ *
+ * @param valueArray
+ * @param set
+ */
+ $scope.toggleValuesInSet = function(valueArray, set) {
+ var arrayLength = valueArray.length;
+ for (var i = 0; i < arrayLength; i++) {
+ $scope.toggleValueInSet(valueArray[i], set);
+ }
+ };
+
+
+ //
+ // Array operations; similar to our Set operations, but operate on a
+ // Javascript Array so we *can* easily get a list of all values in the Set.
+ // TODO(epoger): move into a separate .js file?
+ //
+
+ /**
+ * Returns true if value "value" is present within array "array".
+ *
+ * @param value a value of any type
+ * @param array a Javascript Array
+ */
+ $scope.isValueInArray = function(value, array) {
+ return (-1 != array.indexOf(value));
+ };
+
+ /**
+ * If value "value" is already in array "array", remove it; otherwise,
+ * add it.
+ *
+ * @param value a value of any type
+ * @param array a Javascript Array
+ */
+ $scope.toggleValueInArray = function(value, array) {
+ var i = array.indexOf(value);
+ if (-1 == i) {
+ array.push(value);
+ } else {
+ array.splice(i, 1);
+ }
+ };
+
+
+ //
+ // Miscellaneous utility functions.
+ // TODO(epoger): move into a separate .js file?
+ //
+
+ /**
+ * Returns a single "column slice" of a 2D array.
+ *
+ * For example, if array is:
+ * [[A0, A1],
+ * [B0, B1],
+ * [C0, C1]]
+ * and index is 0, this this will return:
+ * [A0, B0, C0]
+ *
+ * @param array a Javascript Array
+ * @param column (numeric): index within each row array
+ */
+ $scope.columnSliceOf2DArray = function(array, column) {
+ var slice = [];
+ var numRows = array.length;
+ for (var row = 0; row < numRows; row++) {
+ slice.push(array[row][column]);
+ }
+ return slice;
+ };
+
+ /**
+ * Returns a human-readable (in local time zone) time string for a
+ * particular moment in time.
+ *
+ * @param secondsPastEpoch (numeric): seconds past epoch in UTC
+ */
+ $scope.localTimeString = function(secondsPastEpoch) {
+ var d = new Date(secondsPastEpoch * 1000);
+ return d.toString();
+ };
+
+ /**
+ * Returns a hex color string (such as "#aabbcc") for the given RGB values.
+ *
+ * @param r (numeric): red channel value, 0-255
+ * @param g (numeric): green channel value, 0-255
+ * @param b (numeric): blue channel value, 0-255
+ */
+ $scope.hexColorString = function(r, g, b) {
+ var rString = r.toString(16);
+ if (r < 16) {
+ rString = "0" + rString;
+ }
+ var gString = g.toString(16);
+ if (g < 16) {
+ gString = "0" + gString;
+ }
+ var bString = b.toString(16);
+ if (b < 16) {
+ bString = "0" + bString;
+ }
+ return '#' + rString + gString + bString;
+ };
+
+ /**
+ * Returns a hex color string (such as "#aabbcc") for the given brightness.
+ *
+ * @param brightnessString (string): 0-255, 0 is completely black
+ *
+ * TODO(epoger): It might be nice to tint the color when it's not completely
+ * black or completely white.
+ */
+ $scope.brightnessStringToHexColor = function(brightnessString) {
+ var v = parseInt(brightnessString);
+ return $scope.hexColorString(v, v, v);
+ };
+
+ }
+);
diff --git a/gm/rebaseline_server/static/new/bower.json b/gm/rebaseline_server/static/new/bower.json
new file mode 100644
index 0000000000..775213dd56
--- /dev/null
+++ b/gm/rebaseline_server/static/new/bower.json
@@ -0,0 +1,22 @@
+{
+ "name": "rebasline",
+ "version": "0.1.0",
+ "authors": [],
+ "description": "Rebaseline Server",
+ "license": "BSD",
+ "private": true,
+ "ignore": [
+ "**/.*",
+ "node_modules",
+ "bower_components",
+ "third_party/bower_components",
+ "test",
+ "tests"
+ ],
+ "dependencies": {
+ "angular": "1.2.x",
+ "angular-route": "1.2.x",
+ "angular-bootstrap": "0.11.x",
+ "bootstrap": "3.1.x"
+ }
+}
diff --git a/gm/rebaseline_server/static/new/css/app.css b/gm/rebaseline_server/static/new/css/app.css
new file mode 100644
index 0000000000..fb0cc09e5c
--- /dev/null
+++ b/gm/rebaseline_server/static/new/css/app.css
@@ -0,0 +1,71 @@
+/* app css stylesheet */
+
+.formPadding {
+ padding-left: 2em !important;
+ padding-right: 0 !important;
+}
+
+.controlBox {
+ border-left: 1px solid #ddd;
+ border-right: 1px solid #ddd;
+ border-bottom: 1px solid #ddd;
+ padding-right: 0;
+ width: 100%;
+ padding-top: 2em;
+}
+
+.simpleLegend {
+ font-size: 16px;
+ margin-bottom: 3px;
+ width: 95%;
+}
+
+.settingsForm {
+ padding-left: 1em;
+ padding-right: 0;
+}
+
+
+.resultsHeaderActions {
+ float: right;
+}
+
+.sticky {
+ position: fixed;
+ top: 2px;
+ box-shadow: -2px 2px 5px 0 rgba(0,0,0,.45);
+ background: white;
+ right: 2px;
+ padding: 10px;
+ border: 2px solid #222;
+}
+
+.sortDesc {
+ background:no-repeat left center url(data:image/gif;base64,R0lGODlhCgAKALMAAHFxcYKCgp2dnaampq+vr83NzeHh4f///wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACH5BAkAAAgAIf/8SUNDUkdCRzEwMTIAAAUwYXBwbAIgAABtbnRyUkdCIFhZWiAH2QACABkACwAaAAthY3NwQVBQTAAAAABhcHBsAAAAAAAAAAAAAAAAAAAAAAAA9tYAAQAAAADTLWFwcGwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAtkc2NtAAABCAAAAvJkZXNjAAAD/AAAAG9nWFlaAAAEbAAAABR3dHB0AAAEgAAAABRyWFlaAAAElAAAABRiWFlaAAAEqAAAABRyVFJDAAAEvAAAAA5jcHJ0AAAEzAAAADhjaGFkAAAFBAAAACxn/1RSQwAABLwAAAAOYlRSQwAABLwAAAAObWx1YwAAAAAAAAARAAAADGVuVVMAAAAmAAACfmVzRVMAAAAmAAABgmRhREsAAAAuAAAB6mRlREUAAAAsAAABqGZpRkkAAAAoAAAA3GZyRlUAAAAoAAABKml0SVQAAAAoAAACVm5sTkwAAAAoAAACGG5iTk8AAAAmAAABBHB0QlIAAAAmAAABgnN2U0UAAAAmAAABBGphSlAAAAAaAAABUmtvS1IAAAAWAAACQHpoVFcAAAAWAAABbHpoQ04AAAAWAAAB1HJ1UlUAAAAiAAACpHBsUEwAAAAsAAACxgBZAGwAZQBpAG4AZf8AbgAgAFIARwBCAC0AcAByAG8AZgBpAGkAbABpAEcAZQBuAGUAcgBpAHMAawAgAFIARwBCAC0AcAByAG8AZgBpAGwAUAByAG8AZgBpAGwAIABHAOkAbgDpAHIAaQBxAHUAZQAgAFIAVgBCTgCCLAAgAFIARwBCACAw1zDtMNUwoTCkMOuQGnUoACAAUgBHAEIAIIJyX2ljz4/wAFAAZQByAGYAaQBsACAAUgBHAEIAIABHAGUAbgDpAHIAaQBjAG8AQQBsAGwAZwBlAG0AZQBpAG4AZQBzACAAUgBHAEIALQBQAHIAbwBmAGkAbGZukBoAIABSAEcAQgAgY8+P8GX/h072AEcAZQBuAGUAcgBlAGwAIABSAEcAQgAtAGIAZQBzAGsAcgBpAHYAZQBsAHMAZQBBAGwAZwBlAG0AZQBlAG4AIABSAEcAQgAtAHAAcgBvAGYAaQBlAGzHfLwYACAAUgBHAEIAINUEuFzTDMd8AFAAcgBvAGYAaQBsAG8AIABSAEcAQgAgAEcAZQBuAGUAcgBpAGMAbwBHAGUAbgBlAHIAaQBjACAAUgBHAEIAIABQAHIAbwBmAGkAbABlBB4EMQRJBDgEOQAgBD8EQAQ+BEQEOAQ7BEwAIABSAEcAQgBVAG4AaQB3AGUAcgBzAGEAbABuAHkAIABwAHIAbwBm/wBpAGwAIABSAEcAQgAAZGVzYwAAAAAAAAAUR2VuZXJpYyBSR0IgUHJvZmlsZQAAAAAAAAAAAAAAFEdlbmVyaWMgUkdCIFByb2ZpbGUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFhZWiAAAAAAAABadQAArHMAABc0WFlaIAAAAAAAAPNSAAEAAAABFs9YWVogAAAAAAAAdE0AAD3uAAAD0FhZWiAAAAAAAAAoGgAAFZ8AALg2Y3VydgAAAAAAAAABAc0AAHRleHQAAAAAQ29weXJpZ2h0IDIwMDcgQXBwbGUgSW5jLkMsIGFsbCByaWdodHMgcmVzZXJ2ZWQuAHNmMzIAAAAAAAEMQgAABd7///MmAAAHkgAA/ZH///ui///9owAAA9wAAMBsACwAAAAACgAKAAAEJZAMIcakQZjNtyhFxwEIIRofAookUnapu26t+6KFLYe1TgQ5VwQAOw%3D%3D);
+}
+
+.sortAsc {
+ background:no-repeat left center url(data:image/gif;base64,R0lGODlhCgAKALMAAHFxcYKCgp2dnaampq+vr83NzeHh4f///wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACH5BAkAAAgAIf/8SUNDUkdCRzEwMTIAAAUwYXBwbAIgAABtbnRyUkdCIFhZWiAH2QACABkACwAaAAthY3NwQVBQTAAAAABhcHBsAAAAAAAAAAAAAAAAAAAAAAAA9tYAAQAAAADTLWFwcGwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAtkc2NtAAABCAAAAvJkZXNjAAAD/AAAAG9nWFlaAAAEbAAAABR3dHB0AAAEgAAAABRyWFlaAAAElAAAABRiWFlaAAAEqAAAABRyVFJDAAAEvAAAAA5jcHJ0AAAEzAAAADhjaGFkAAAFBAAAACxn/1RSQwAABLwAAAAOYlRSQwAABLwAAAAObWx1YwAAAAAAAAARAAAADGVuVVMAAAAmAAACfmVzRVMAAAAmAAABgmRhREsAAAAuAAAB6mRlREUAAAAsAAABqGZpRkkAAAAoAAAA3GZyRlUAAAAoAAABKml0SVQAAAAoAAACVm5sTkwAAAAoAAACGG5iTk8AAAAmAAABBHB0QlIAAAAmAAABgnN2U0UAAAAmAAABBGphSlAAAAAaAAABUmtvS1IAAAAWAAACQHpoVFcAAAAWAAABbHpoQ04AAAAWAAAB1HJ1UlUAAAAiAAACpHBsUEwAAAAsAAACxgBZAGwAZQBpAG4AZf8AbgAgAFIARwBCAC0AcAByAG8AZgBpAGkAbABpAEcAZQBuAGUAcgBpAHMAawAgAFIARwBCAC0AcAByAG8AZgBpAGwAUAByAG8AZgBpAGwAIABHAOkAbgDpAHIAaQBxAHUAZQAgAFIAVgBCTgCCLAAgAFIARwBCACAw1zDtMNUwoTCkMOuQGnUoACAAUgBHAEIAIIJyX2ljz4/wAFAAZQByAGYAaQBsACAAUgBHAEIAIABHAGUAbgDpAHIAaQBjAG8AQQBsAGwAZwBlAG0AZQBpAG4AZQBzACAAUgBHAEIALQBQAHIAbwBmAGkAbGZukBoAIABSAEcAQgAgY8+P8GX/h072AEcAZQBuAGUAcgBlAGwAIABSAEcAQgAtAGIAZQBzAGsAcgBpAHYAZQBsAHMAZQBBAGwAZwBlAG0AZQBlAG4AIABSAEcAQgAtAHAAcgBvAGYAaQBlAGzHfLwYACAAUgBHAEIAINUEuFzTDMd8AFAAcgBvAGYAaQBsAG8AIABSAEcAQgAgAEcAZQBuAGUAcgBpAGMAbwBHAGUAbgBlAHIAaQBjACAAUgBHAEIAIABQAHIAbwBmAGkAbABlBB4EMQRJBDgEOQAgBD8EQAQ+BEQEOAQ7BEwAIABSAEcAQgBVAG4AaQB3AGUAcgBzAGEAbABuAHkAIABwAHIAbwBm/wBpAGwAIABSAEcAQgAAZGVzYwAAAAAAAAAUR2VuZXJpYyBSR0IgUHJvZmlsZQAAAAAAAAAAAAAAFEdlbmVyaWMgUkdCIFByb2ZpbGUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFhZWiAAAAAAAABadQAArHMAABc0WFlaIAAAAAAAAPNSAAEAAAABFs9YWVogAAAAAAAAdE0AAD3uAAAD0FhZWiAAAAAAAAAoGgAAFZ8AALg2Y3VydgAAAAAAAAABAc0AAHRleHQAAAAAQ29weXJpZ2h0IDIwMDcgQXBwbGUgSW5jLkMsIGFsbCByaWdodHMgcmVzZXJ2ZWQuAHNmMzIAAAAAAAEMQgAABd7///MmAAAHkgAA/ZH///ui///9owAAA9wAAMBsACwAAAAACgAKAAAEJRBJREKZsxQDsCSGIVzZFnYTGIqktp7fG46uzAn2TAyCMPC9QAQAOw%3D%3D);
+}
+
+.sortableHeader {
+ padding-right: 3px;
+ padding-left: 13px;
+ margin-left: 4px;
+}
+
+.updateBtn {
+ padding-top: 1em;
+ margin-left: 0;
+}
+
+.filterBox {
+ border: 1px solid #DDDDDD;
+ margin-right: 1em;
+ padding-top: 5px;
+ padding-bottom: 5px;
+}
+
+.filterKey {
+ font-weight: bold;
+} \ No newline at end of file
diff --git a/gm/rebaseline_server/static/new/js/app.js b/gm/rebaseline_server/static/new/js/app.js
new file mode 100644
index 0000000000..0a1fac0a45
--- /dev/null
+++ b/gm/rebaseline_server/static/new/js/app.js
@@ -0,0 +1,1130 @@
+'use strict';
+
+/**
+ * TODO (stephana@): This is still work in progress.
+ * It does not offer the same functionality as the current version, but
+ * will serve as the starting point for a new backend.
+ * It works with the current backend, but does not support rebaselining.
+ */
+
+/*
+ * Wrap everything into an IIFE to not polute the global namespace.
+ */
+(function () {
+
+ // Declare app level module which contains everything of the current app.
+ // ui.bootstrap refers to directives defined in the AngularJS Bootstrap
+ // UI package (http://angular-ui.github.io/bootstrap/).
+ var app = angular.module('rbtApp', ['ngRoute', 'ui.bootstrap']);
+
+ // Configure the different within app views.
+ app.config(['$routeProvider', function($routeProvider) {
+ $routeProvider.when('/', {templateUrl: 'partials/index-view.html',
+ controller: 'IndexCtrl'});
+ $routeProvider.when('/view', {templateUrl: 'partials/rebaseline-view.html',
+ controller: 'RebaselineCrtrl'});
+ $routeProvider.otherwise({redirectTo: '/'});
+ }]);
+
+
+ // TODO (stephana): Some of these constants are 'gm' specific. In the
+ // next iteration we need to remove those as we move the more generic
+ // 'dm' testing tool.
+ //
+ // Shared constants used here and in the markup. These are exported when
+ // when used by a controller.
+ var c = {
+ // Define different view states as we load the data.
+ ST_LOADING: 1,
+ ST_STILL_LOADING: 2,
+ ST_READY: 3,
+
+ // These column types are used by the Column class.
+ COL_T_FILTER: 'filter',
+ COL_T_IMAGE: 'image',
+ COL_T_REGULAR: 'regular',
+
+ // Request parameters used to select between subsets of results.
+ RESULTS_ALL: 'all',
+ RESULTS_FAILURES: 'failures',
+
+ // Filter types are used by the Column class.
+ FILTER_FREE_FORM: 'free_form',
+ FILTER_CHECK_BOX: 'checkbox',
+
+ // Columns either provided by the backend response or added in code.
+ // TODO (stephana): This should go away once we switch to 'dm'.
+ COL_BUGS: 'bugs',
+ COL_IGNORE_FAILURE: 'ignore-failure',
+ COL_REVIEWED_BY_HUMANS: 'reviewed-by-human',
+
+ // Defines the order in which image columns appear.
+ // TODO (stephana@): needs to be driven by backend data.
+ IMG_COL_ORDER: [
+ {
+ key: 'imageA',
+ urlField: ['imageAUrl']
+ },
+ {
+ key: 'imageB',
+ urlField: ['imageBUrl']
+ },
+ {
+ key: 'whiteDiffs',
+ urlField: ['differenceData', 'whiteDiffUrl'],
+ percentField: ['differenceData', 'percentDifferingPixels'],
+ valueField: ['differenceData', 'numDifferingPixels']
+ },
+ {
+ key: 'diffs',
+ urlField: ['differenceData', 'diffUrl'],
+ percentField: ['differenceData', 'perceptualDifference'],
+ valueField: ['differenceData', 'maxDiffPerChannel']
+ }
+ ],
+
+ // Choice of availabe image size selection.
+ IMAGE_SIZES: [
+ 100,
+ 200,
+ 400
+ ],
+
+ // Choice of available number of records selection.
+ MAX_RECORDS: [
+ '100',
+ '200',
+ '300'
+ ]
+ }; // end constants
+
+ /*
+ * Index Controller
+ */
+ // TODO (stephana): Remove $timeout since it only simulates loading delay.
+ app.controller('IndexCtrl', ['$scope', '$timeout', 'dataService',
+ function($scope, $timeout, dataService) {
+ // init the scope
+ $scope.c = c;
+ $scope.state = c.ST_LOADING;
+ $scope.qStr = dataService.getQueryString;
+
+ // TODO (stephana): Remove and replace with index data generated by the
+ // backend to reflect the current "known" image sets to compare.
+ $scope.allSKPs = [
+ {
+ params: {
+ setBSection: 'actual-results',
+ setASection: 'expected-results',
+ setBDir: 'gs://chromium-skia-skp-summaries/' +
+ 'Test-Mac10.8-MacMini4.1-GeForce320M-x86_64-Debug',
+ setADir: 'repo:expectations/skp/' +
+ 'Test-Mac10.8-MacMini4.1-GeForce320M-x86_64-Debug'
+ },
+ title: 'expected vs actuals on ' +
+ 'Test-Mac10.8-MacMini4.1-GeForce320M-x86_64-Debug'
+ },
+ {
+ params: {
+ setBSection: 'actual-results',
+ setASection: 'expected-results',
+ setBDir: 'gs://chromium-skia-skp-summaries/' +
+ 'Test-Ubuntu12-ShuttleA-GTX660-x86-Release',
+ setADir: 'repo:expectations/skp/'+
+ 'Test-Ubuntu12-ShuttleA-GTX660-x86-Release'
+ },
+ title: 'expected vs actuals on Test-Ubuntu12-ShuttleA-GTX660-x86-Release'
+ },
+ {
+ params: {
+ setBSection: 'actual-results',
+ setASection: 'actual-results',
+ setBDir: 'gs://chromium-skia-skp-summaries/' +
+ 'Test-Ubuntu12-ShuttleA-GTX660-x86-Release',
+ setADir: 'gs://chromium-skia-skp-summaries/' +
+ 'Test-Mac10.8-MacMini4.1-GeForce320M-x86_64-Debug'
+ },
+ title: 'Actuals on Test-Mac10.8-MacMini4.1-GeForce320M-x86_64-Debug ' +
+ 'vs Test-Ubuntu12-ShuttleA-GTX660-x86-Release'
+ }
+ ];
+
+ // TODO (stephana): Remove this once we load index data from the server.
+ $timeout(function () {
+ $scope.state = c.ST_READY;
+ });
+ }]);
+
+ /*
+ * RebaselineCtrl
+ * Controls the main comparison view.
+ *
+ * @param {service} dataService Service that encapsulates functions to
+ * retrieve data from the backend.
+ *
+ */
+ app.controller('RebaselineCrtrl', ['$scope', '$timeout', 'dataService',
+ function($scope, $timeout, dataService) {
+ // determine which to request
+ // TODO (stephana): This should be extracted from the query parameters.
+ var target = c.TARGET_GM;
+
+ // process the rquest arguments
+ // TODO (stephana): This should be determined from the query parameters.
+ var loadFn = dataService.loadAll;
+
+ // controller state variables
+ var allData = null;
+ var filterFuncs = null;
+ var currentData = null;
+ var selectedData = null;
+
+ // Index of the column that should provide the sort key
+ var sortByIdx = 0;
+
+ // Sort in asending (true) or descending (false) order
+ var sortOrderAsc = true;
+
+ // Array of functions for each column used for comparison during sort.
+ var compareFunctions = null;
+
+ // Variables to track load and render times
+ var startTime;
+ var loadStartTime;
+
+
+ /** Load the data from the backend **/
+ loadStartTime = Date.now();
+ function loadData() {
+ loadFn().then(
+ function (serverData) {
+ $scope.header = serverData.header;
+ $scope.loadTime = (Date.now() - loadStartTime)/1000;
+
+ // keep polling if the data are not ready yet
+ if ($scope.header.resultsStillLoading) {
+ $scope.state = c.ST_STILL_LOADING;
+ $timeout(loadData, 5000);
+ return;
+ }
+
+ // get the filter colunms and an array to hold filter data by user
+ var fcol = getFilterColumns(serverData);
+ $scope.filterCols = fcol[0];
+ $scope.filterVals = fcol[1];
+
+ // Add extra columns and retrieve the image columns
+ var otherCols = [ Column.regular(c.COL_BUGS) ];
+ var imageCols = getImageColumns(serverData);
+
+ // Concat to get all columns
+ // NOTE: The order is important since filters are rendered first,
+ // followed by regular columns and images
+ $scope.allCols = $scope.filterCols.concat(otherCols, imageCols);
+
+ // Pre-process the data and get the filter functions.
+ var dataFilters = getDataAndFilters(serverData, $scope.filterCols,
+ otherCols, imageCols);
+ allData = dataFilters[0];
+ filterFuncs = dataFilters[1];
+
+ // Get regular columns (== not image columns)
+ var regularCols = $scope.filterCols.concat(otherCols);
+
+ // Get the compare functions for regular and image columns. These
+ // are then used to sort by the respective columns.
+ compareFunctions = DataRow.getCompareFunctions(regularCols,
+ imageCols);
+
+ // Filter and sort the results to get them ready for rendering
+ updateResults();
+
+ // Data are ready for display
+ $scope.state = c.ST_READY;
+ },
+ function (httpErrResponse) {
+ console.log(httpErrResponse);
+ });
+ };
+
+ /*
+ * updateResults
+ * Central render function. Everytime settings/filters/etc. changed
+ * this function is called to filter, sort and splice the data.
+ *
+ * NOTE (stephana): There is room for improvement here: before filtering
+ * and sorting we could check if this is necessary. But this has not been
+ * a bottleneck so far.
+ */
+ function updateResults () {
+ // run digest before we update the results. This allows
+ // updateResults to be called from functions trigger by ngChange
+ $scope.updating = true;
+ startTime = Date.now();
+
+ // delay by one render cycle so it can be called via ng-change
+ $timeout(function() {
+ // filter data
+ selectedData = filterData(allData, filterFuncs, $scope.filterVals);
+
+ // sort the selected data.
+ sortData(selectedData, compareFunctions, sortByIdx, sortOrderAsc);
+
+ // only conside the elements that we really need
+ var nRecords = $scope.settings.nRecords;
+ currentData = selectedData.slice(0, parseInt(nRecords));
+
+ DataRow.setRowspanValues(currentData, $scope.mergeIdenticalRows);
+
+ // update the scope with relevant data for rendering.
+ $scope.data = currentData;
+ $scope.totalRecords = allData.length;
+ $scope.showingRecords = currentData.length;
+ $scope.selectedRecords = selectedData.length;
+ $scope.updating = false;
+
+ // measure the filter time and total render time (via timeout).
+ $scope.filterTime = Date.now() - startTime;
+ $timeout(function() {
+ $scope.renderTime = Date.now() - startTime;
+ });
+ });
+ };
+
+ /**
+ * Generate the style value to set the width of images.
+ *
+ * @param {Column} col Column that we are trying to render.
+ * @param {int} paddingPx Number of padding pixels.
+ * @param {string} defaultVal Default value if not an image column.
+ *
+ * @return {string} Value to be used in ng-style element to set the width
+ * of a image column.
+ **/
+ $scope.getImageWidthStyle = function (col, paddingPx, defaultVal) {
+ var result = (col.ctype === c.COL_T_IMAGE) ?
+ ($scope.imageSize + paddingPx + 'px') : defaultVal;
+ return result;
+ };
+
+ /**
+ * Sets the column by which to sort the data. If called for the
+ * currently sorted column it will cause the sort to toggle between
+ * ascending and descending.
+ *
+ * @param {int} colIdx Index of the column to use for sorting.
+ **/
+ $scope.sortBy = function (colIdx) {
+ if (sortByIdx === colIdx) {
+ sortOrderAsc = !sortOrderAsc;
+ } else {
+ sortByIdx = colIdx;
+ sortOrderAsc = true;
+ }
+ updateResults();
+ };
+
+ /**
+ * Helper function to generate a CSS class indicating whether this column
+ * is the sort key. If it is a class name with the sort direction (Asc/Desc) is
+ * return otherwise the default value is returned. In markup we use this
+ * to display (or not display) an arrow next to the column name.
+ *
+ * @param {string} prefix Prefix of the classname to be generated.
+ * @param {int} idx Index of the target column.
+ * @param {string} defaultVal Value to return if current column is not used
+ * for sorting.
+ *
+ * @return {string} CSS class name that a combination of the prefix and
+ * direction indicator ('Asc' or 'Desc') if the column is
+ * used for sorting. Otherwise the defaultVal is returned.
+ **/
+ $scope.getSortedClass = function (prefix, idx, defaultVal) {
+ if (idx === sortByIdx) {
+ return prefix + ((sortOrderAsc) ? 'Asc' : 'Desc');
+ }
+
+ return defaultVal;
+ };
+
+ /**
+ * Checkbox to merge identical records has change. Force an update.
+ **/
+ $scope.mergeRowsChanged = function () {
+ updateResults();
+ }
+
+ /**
+ * Max number of records to display has changed. Force an update.
+ **/
+ $scope.maxRecordsChanged = function () {
+ updateResults();
+ };
+
+ /**
+ * Filter settings changed. Force an update.
+ **/
+ $scope.filtersChanged = function () {
+ updateResults();
+ };
+
+ /**
+ * Sets all possible values of the specified values to the given value.
+ * That means all checkboxes are eiter selected or unselected.
+ * Then force an update.
+ *
+ * @param {int} idx Index of the target filter column.
+ * @param {boolean} val Value to set the filter values to.
+ *
+ **/
+ $scope.setFilterAll = function (idx, val) {
+ for(var i=0, len=$scope.filterVals[idx].length; i<len; i++) {
+ $scope.filterVals[idx][i] = val;
+ }
+ updateResults();
+ };
+
+ /**
+ * Toggle the values of a filter. This toggles all values in a
+ * filter.
+ *
+ * @param {int} idx Index of the target filter column.
+ **/
+ $scope.setFilterToggle = function (idx) {
+ for(var i=0, len=$scope.filterVals[idx].length; i<len; i++) {
+ $scope.filterVals[idx][i] = !$scope.filterVals[idx][i];
+ }
+ updateResults();
+ };
+
+ // ****************************************
+ // Initialize the scope.
+ // ****************************************
+
+ // Inject the constants into the scope and set the initial state.
+ $scope.c = c;
+ $scope.state = c.ST_LOADING;
+
+ // Initial settings
+ $scope.settings = {
+ showThumbnails: true,
+ imageSize: c.IMAGE_SIZES[0],
+ nRecords: c.MAX_RECORDS[0],
+ mergeIdenticalRows: true
+ };
+
+ // Initial values for filters set in loadData()
+ $scope.filterVals = [];
+
+ // Information about records - set in loadData()
+ $scope.totalRecords = 0;
+ $scope.showingRecords = 0;
+ $scope.updating = false;
+
+ // Trigger the data loading.
+ loadData();
+
+ }]);
+
+ // data structs to interface with markup and backend
+ /**
+ * Models a column. It aggregates attributes of all
+ * columns types. Some might be empty. See convenience
+ * factory methods below for different column types.
+ *
+ * @param {string} key Uniquely identifies this columns
+ * @param {string} ctype Type of columns. Use COL_* constants.
+ * @param {string} ctitle Human readable title of the column.
+ * @param {string} ftype Filter type. Use FILTER_* constants.
+ * @param {FilterOpt[]} foptions Filter options. For 'checkbox' filters this
+ is used to render all the checkboxes.
+ For freeform filters this is a list of all
+ available values.
+ * @param {string} baseUrl Baseurl for image columns. All URLs are relative
+ to this.
+ *
+ * @return {Column} Instance of the Column class.
+ **/
+ function Column(key, ctype, ctitle, ftype, foptions, baseUrl) {
+ this.key = key;
+ this.ctype = ctype;
+ this.ctitle = ctitle;
+ this.ftype = ftype;
+ this.foptions = foptions;
+ this.baseUrl = baseUrl;
+ this.foptionsArr = [];
+
+ // get the array of filter options for lookup in indexOfOptVal
+ if (this.foptions) {
+ for(var i=0, len=foptions.length; i<len; i++) {
+ this.foptionsArr.push(this.foptions[i].value);
+ }
+ }
+ }
+
+ /**
+ * Find the index of an value in a column with a fixed set
+ * of options.
+ *
+ * @param {string} optVal Value of the column.
+ *
+ * @return {int} Index of optVal in this column.
+ **/
+ Column.prototype.indexOfOptVal = function (optVal) {
+ return this.foptionsArr.indexOf(optVal);
+ };
+
+ /**
+ * Set filter options for this column.
+ *
+ * @param {FilterOpt[]} foptions Possible values for this column.
+ **/
+ Column.prototype.setFilterOptions = function (foptions) {
+ this.foptions = foptions;
+ };
+
+ /**
+ * Factory function to create a filter column. Same args as Column()
+ **/
+ Column.filter = function(key, ctitle, ftype, foptions) {
+ return new Column(key, c.COL_T_FILTER, ctitle || key, ftype, foptions);
+ }
+
+ /**
+ * Factory function to create an image column. Same args as Column()
+ **/
+ Column.image = function (key, ctitle, baseUrl) {
+ return new Column(key, c.COL_T_IMAGE, ctitle || key, null, null, baseUrl);
+ };
+
+ /**
+ * Factory function to create a regular column. Same args as Column()
+ **/
+ Column.regular = function (key, ctitle) {
+ return new Column(key, c.COL_T_REGULAR, ctitle || key);
+ };
+
+ /**
+ * Helper class to wrap a single option in a filter.
+ *
+ * @param {string} value Option value.
+ * @param {int} count Number of instances of this option in the dataset.
+ *
+ * @return {} Instance of FiltertOpt
+ **/
+ function FilterOpt(value, count) {
+ this.value = value;
+ this.count = count;
+ }
+
+ /**
+ * Container for a single row in the dataset.
+ *
+ * @param {int} rowspan Number of rows (including this and following rows)
+ that have identical values.
+ * @param {string[]} dataCols Values of the respective columns (combination
+ of filter and regular columns)
+ * @param {ImgVal[]} imageCols Image meta data for the image columns.
+ *
+ * @return {DataRow} Instance of DataRow.
+ **/
+ function DataRow(rowspan, dataCols, imageCols) {
+ this.rowspan = rowspan;
+ this.dataCols = dataCols;
+ this.imageCols = imageCols;
+ }
+
+ /**
+ * Gets the comparator functions for the columns in this dataset.
+ * The comparators are then used to sort the dataset by the respective
+ * column.
+ *
+ * @param {Column[]} dataCols Data columns (= non-image columns)
+ * @param {Column[]} imgCols Image columns.
+ *
+ * @return {Function[]} Array of functions that can be used to sort by the
+ * respective column.
+ **/
+ DataRow.getCompareFunctions = function (dataCols, imgCols) {
+ var result = [];
+ for(var i=0, len=dataCols.length; i<len; i++) {
+ result.push(( function (col, idx) {
+ return function (a, b) {
+ return (a.dataCols[idx] < b.dataCols[idx]) ? -1 :
+ ((a.dataCols[idx] === b.dataCols[idx]) ? 0 : 1);
+ };
+ }(dataCols[i], i) ));
+ }
+
+ for(var i=0, len=imgCols.length; i<len; i++) {
+ result.push((function (col, idx) {
+ return function (a,b) {
+ var aVal = a.imageCols[idx].percent;
+ var bVal = b.imageCols[idx].percent;
+
+ return (aVal < bVal) ? -1 : ((aVal === bVal) ? 0 : 1);
+ };
+ }(imgCols[i], i) ));
+ }
+
+ return result;
+ };
+
+ /**
+ * Set the rowspan values of a given array of DataRow instances.
+ *
+ * @param {DataRow[]} data Dataset in desired order (after sorting).
+ * @param {mergeRows} mergeRows Indicate whether to sort
+ **/
+ DataRow.setRowspanValues = function (data, mergeRows) {
+ var curIdx, rowspan, cur;
+ if (mergeRows) {
+ for(var i=0, len=data.length; i<len;) {
+ curIdx = i;
+ cur = data[i];
+ rowspan = 1;
+ for(i++; ((i<len) && (data[i].dataCols === cur.dataCols)); i++) {
+ rowspan++;
+ data[i].rowspan=0;
+ }
+ data[curIdx].rowspan = rowspan;
+ }
+ } else {
+ for(var i=0, len=data.length; i<len; i++) {
+ data[i].rowspan = 1;
+ }
+ }
+ };
+
+ /**
+ * Wrapper class for image related data.
+ *
+ * @param {string} url Relative Url of the image or null if not available.
+ * @param {float} percent Percent of pixels that are differing.
+ * @param {int} value Absolute number of pixes differing.
+ *
+ * @return {ImgVal} Instance of ImgVal.
+ **/
+ function ImgVal(url, percent, value) {
+ this.url = url;
+ this.percent = percent;
+ this.value = value;
+ }
+
+ /**
+ * Extracts the filter columns from the JSON response of the server.
+ *
+ * @param {object} data Server response.
+ *
+ * @return {Column[]} List of filter columns as described in 'header' field.
+ **/
+ function getFilterColumns(data) {
+ var result = [];
+ var vals = [];
+ var colOrder = data.extraColumnOrder;
+ var colHeaders = data.extraColumnHeaders;
+ var fopts, optVals, val;
+
+ for(var i=0, len=colOrder.length; i<len; i++) {
+ if (colHeaders[colOrder[i]].isFilterable) {
+ if (colHeaders[colOrder[i]].useFreeformFilter) {
+ result.push(Column.filter(colOrder[i],
+ colHeaders[colOrder[i]].headerText,
+ c.FILTER_FREE_FORM));
+ vals.push('');
+ }
+ else {
+ fopts = [];
+ optVals = [];
+
+ // extract the different options for this column
+ for(var j=0, jlen=colHeaders[colOrder[i]].valuesAndCounts.length;
+ j<jlen; j++) {
+ val = colHeaders[colOrder[i]].valuesAndCounts[j];
+ fopts.push(new FilterOpt(val[0], val[1]));
+ optVals.push(false);
+ }
+
+ // ad the column and values
+ result.push(Column.filter(colOrder[i],
+ colHeaders[colOrder[i]].headerText,
+ c.FILTER_CHECK_BOX,
+ fopts));
+ vals.push(optVals);
+ }
+ }
+ }
+
+ return [result, vals];
+ }
+
+ /**
+ * Extracts the image columns from the JSON response of the server.
+ *
+ * @param {object} data Server response.
+ *
+ * @return {Column[]} List of images columns as described in 'header' field.
+ **/
+ function getImageColumns(data) {
+ var CO = c.IMG_COL_ORDER;
+ var imgSet;
+ var result = [];
+ for(var i=0, len=CO.length; i<len; i++) {
+ imgSet = data.imageSets[CO[i].key];
+ result.push(Column.image(CO[i].key,
+ imgSet.description,
+ ensureTrailingSlash(imgSet.baseUrl)));
+ }
+ return result;
+ }
+
+ /**
+ * Make sure Url has a trailing '/'.
+ *
+ * @param {string} url Base url.
+ * @return {string} Same url with a trailing '/' or same as input if it
+ already contained '/'.
+ **/
+ function ensureTrailingSlash(url) {
+ var result = url.trim();
+
+ // TODO: remove !!!
+ result = fixUrl(url);
+ if (result[result.length-1] !== '/') {
+ result += '/';
+ }
+ return result;
+ }
+
+ // TODO: remove. The backend should provide absoute URLs
+ function fixUrl(url) {
+ url = url.trim();
+ if ('http' === url.substr(0, 4)) {
+ return url;
+ }
+
+ var idx = url.indexOf('static');
+ if (idx != -1) {
+ return '/' + url.substr(idx);
+ }
+
+ return url;
+ };
+
+ /**
+ * Processes that data and returns filter functions.
+ *
+ * @param {object} Server response.
+ * @param {Column[]} filterCols Filter columns.
+ * @param {Column[]} otherCols Columns that are neither filters nor images.
+ * @param {Column[]} imageCols Image columns.
+ *
+ * @return {[]} Returns a pair [dataRows, filterFunctions] where:
+ * - dataRows is an array of DataRow instances.
+ * - filterFunctions is an array of functions that can be used to
+ * filter the column at the corresponding index.
+ *
+ **/
+ function getDataAndFilters(data, filterCols, otherCols, imageCols) {
+ var el;
+ var result = [];
+ var lookupIndices = [];
+ var indexerFuncs = [];
+ var temp;
+
+ // initialize the lookupIndices
+ var filterFuncs = initIndices(filterCols, lookupIndices, indexerFuncs);
+
+ // iterate over the data and get the rows
+ for(var i=0, len=data.imagePairs.length; i<len; i++) {
+ el = data.imagePairs[i];
+ temp = new DataRow(1, getColValues(el, filterCols, otherCols),
+ getImageValues(el, imageCols));
+ result.push(temp);
+
+ // index the row
+ for(var j=0, jlen=filterCols.length; j < jlen; j++) {
+ indexerFuncs[j](lookupIndices[j], filterCols[j], temp.dataCols[j], i);
+ }
+ }
+
+ setFreeFormFilterOptions(filterCols, lookupIndices);
+ return [result, filterFuncs];
+ }
+
+ /**
+ * Initiazile the lookup indices and indexer functions for the filter
+ * columns.
+ *
+ * @param {Column} filterCols Filter columns
+ * @param {[]} lookupIndices Will be filled with datastructures for
+ fast lookup (output parameter)
+ * @param {[]} lookupIndices Will be filled with functions to index data
+ of the column with the corresponding column.
+ *
+ * @return {[]} Returns an array of filter functions that can be used to
+ filter the respective column.
+ **/
+ function initIndices(filterCols, lookupIndices, indexerFuncs) {
+ var filterFuncs = [];
+ var temp;
+
+ for(var i=0, len=filterCols.length; i<len; i++) {
+ if (filterCols[i].ftype === c.FILTER_FREE_FORM) {
+ lookupIndices.push({});
+ indexerFuncs.push(indexFreeFormValue);
+ filterFuncs.push(
+ getFreeFormFilterFunc(lookupIndices[lookupIndices.length-1]));
+ }
+ else if (filterCols[i].ftype === c.FILTER_CHECK_BOX) {
+ temp = [];
+ for(var j=0, jlen=filterCols[i].foptions.length; j<jlen; j++) {
+ temp.push([]);
+ }
+ lookupIndices.push(temp);
+ indexerFuncs.push(indexDiscreteValue);
+ filterFuncs.push(
+ getDiscreteFilterFunc(lookupIndices[lookupIndices.length-1]));
+ }
+ }
+
+ return filterFuncs;
+ }
+
+ /**
+ * Helper function that extracts the values of free form columns from
+ * the lookupIndex and injects them into the Column object as FilterOpt
+ * objects.
+ **/
+ function setFreeFormFilterOptions(filterCols, lookupIndices) {
+ var temp, k;
+ for(var i=0, len=filterCols.length; i<len; i++) {
+ if (filterCols[i].ftype === c.FILTER_FREE_FORM) {
+ temp = []
+ for(k in lookupIndices[i]) {
+ if (lookupIndices[i].hasOwnProperty(k)) {
+ temp.push(new FilterOpt(k, lookupIndices[i][k].length));
+ }
+ }
+ filterCols[i].setFilterOptions(temp);
+ }
+ }
+ }
+
+ /**
+ * Index a discrete column (column with fixed number of values).
+ *
+ **/
+ function indexDiscreteValue(lookupIndex, col, dataVal, dataRowIndex) {
+ var i = col.indexOfOptVal(dataVal);
+ lookupIndex[i].push(dataRowIndex);
+ }
+
+ /**
+ * Index a column with free form text (= not fixed upfront)
+ *
+ **/
+ function indexFreeFormValue(lookupIndex, col, dataVal, dataRowIndex) {
+ if (!lookupIndex[dataVal]) {
+ lookupIndex[dataVal] = [];
+ }
+ lookupIndex[dataVal].push(dataRowIndex);
+ }
+
+
+ /**
+ * Get the function to filter a column with the given lookup index
+ * for discrete (fixed upfront) values.
+ *
+ **/
+ function getDiscreteFilterFunc(lookupIndex) {
+ return function(filterVal) {
+ var result = [];
+ for(var i=0, len=lookupIndex.length; i < len; i++) {
+ if (filterVal[i]) {
+ // append the indices to the current array
+ result.push.apply(result, lookupIndex[i]);
+ }
+ }
+ return { nofilter: false, records: result };
+ };
+ }
+
+ /**
+ * Get the function to filter a column with the given lookup index
+ * for free form values.
+ *
+ **/
+ function getFreeFormFilterFunc(lookupIndex) {
+ return function(filterVal) {
+ filterVal = filterVal.trim();
+ if (filterVal === '') {
+ return { nofilter: true };
+ }
+ return {
+ nofilter: false,
+ records: lookupIndex[filterVal] || []
+ };
+ };
+ }
+
+ /**
+ * Filters the data based on the given filterColumns and
+ * corresponding filter values.
+ *
+ * @return {[]} Subset of the input dataset based on the
+ * filter values.
+ **/
+ function filterData(data, filterFuncs, filterVals) {
+ var recordSets = [];
+ var filterResult;
+
+ // run through all the filters
+ for(var i=0, len=filterFuncs.length; i<len; i++) {
+ filterResult = filterFuncs[i](filterVals[i]);
+ if (!filterResult.nofilter) {
+ recordSets.push(filterResult.records);
+ }
+ }
+
+ // If there are no restrictions then return the whole dataset.
+ if (recordSets.length === 0) {
+ return data;
+ }
+
+ // intersect the records returned by filters.
+ var targets = intersectArrs(recordSets);
+ var result = [];
+ for(var i=0, len=targets.length; i<len; i++) {
+ result.push(data[targets[i]]);
+ }
+
+ return result;
+ }
+
+ /**
+ * Creates an object where the keys are the elements of the input array
+ * and the values are true. To be used for set operations with integer.
+ **/
+ function arrToObj(arr) {
+ var o = {};
+ var i,len;
+ for(i=0, len=arr.length; i<len; i++) {
+ o[arr[i]] = true;
+ }
+ return o;
+ }
+
+ /**
+ * Converts the keys of an object to an array after converting
+ * each key to integer. To be used for set operations with integers.
+ **/
+ function objToArr(obj) {
+ var result = [];
+ for(var k in obj) {
+ if (obj.hasOwnProperty(k)) {
+ result.push(parseInt(k));
+ }
+ }
+ return result;
+ }
+
+ /**
+ * Find the intersection of a set of arrays.
+ **/
+ function intersectArrs(sets) {
+ var temp, obj;
+
+ if (sets.length === 1) {
+ return sets[0];
+ }
+
+ // sort by size and load the smallest into the object
+ sets.sort(function(a,b) { return a.length - b.length; });
+ obj = arrToObj(sets[0]);
+
+ // shrink the hash as we fail to find elements in the other sets
+ for(var i=1, len=sets.length; i<len; i++) {
+ temp = arrToObj(sets[i]);
+ for(var k in obj) {
+ if (obj.hasOwnProperty(k) && !temp[k]) {
+ delete obj[k];
+ }
+ }
+ }
+
+ return objToArr(obj);
+ }
+
+ /**
+ * Extract the column values from an ImagePair (contained in the server
+ * response) into filter and data columns.
+ *
+ * @return {[]} Array of data contained in one data row.
+ **/
+ function getColValues(imagePair, filterCols, otherCols) {
+ var result = [];
+ for(var i=0, len=filterCols.length; i<len; i++) {
+ result.push(imagePair.extraColumns[filterCols[i].key]);
+ }
+
+ for(var i=0, len=otherCols.length; i<len; i++) {
+ result.push(get_robust(imagePair, ['expectations', otherCols[i].key]));
+ }
+
+ return result;
+ }
+
+ /**
+ * Extract the image meta data from an Image pair returned by the server.
+ **/
+ function getImageValues(imagePair, imageCols) {
+ var result=[];
+ var url, value, percent, diff;
+ var CO = c.IMG_COL_ORDER;
+
+ for(var i=0, len=imageCols.length; i<len; i++) {
+ percent = get_robust(imagePair, CO[i].percentField);
+ value = get_robust(imagePair, CO[i].valueField);
+ url = get_robust(imagePair, CO[i].urlField);
+ if (url) {
+ url = imageCols[i].baseUrl + url;
+ }
+ result.push(new ImgVal(url, percent, value));
+ }
+
+ return result;
+ }
+
+ /**
+ * Given an object find sub objects for the given index without
+ * throwing an error if any of the sub objects do not exist.
+ **/
+ function get_robust(obj, idx) {
+ if (!idx) {
+ return;
+ }
+
+ for(var i=0, len=idx.length; i<len; i++) {
+ if ((typeof obj === 'undefined') || (!idx[i])) {
+ return; // returns 'undefined'
+ }
+
+ obj = obj[idx[i]];
+ }
+
+ return obj;
+ }
+
+ /**
+ * Set all elements in the array to the given value.
+ **/
+ function setArrVals(arr, newVal) {
+ for(var i=0, len=arr.length; i<len; i++) {
+ arr[i] = newVal;
+ }
+ }
+
+ /**
+ * Toggle the elements of a boolean array.
+ *
+ **/
+ function toggleArrVals(arr) {
+ for(var i=0, len=arr.length; i<len; i++) {
+ arr[i] = !arr[i];
+ }
+ }
+
+ /**
+ * Sort the array of DataRow instances with the given compare functions
+ * and the column at the given index either in ascending or descending order.
+ **/
+ function sortData (allData, compareFunctions, sortByIdx, sortOrderAsc) {
+ var cmpFn = compareFunctions[sortByIdx];
+ var useCmp = cmpFn;
+ if (!sortOrderAsc) {
+ useCmp = function ( _ ) {
+ return -cmpFn.apply(this, arguments);
+ };
+ }
+ allData.sort(useCmp);
+ }
+
+
+ // ***************************** Services *********************************
+
+ /**
+ * Encapsulates all interactions with the backend by handling
+ * Urls and HTTP requests. Also exposes some utility functions
+ * related to processing Urls.
+ */
+ app.factory('dataService', [ '$http', function ($http) {
+ /** Backend related constants **/
+ var c = {
+ /** Url to retrieve failures */
+ FAILURES: '/results/failures',
+
+ /** Url to retrieve all GM results */
+ ALL: '/results/all'
+ };
+
+ /**
+ * Convenience function to retrieve all results.
+ *
+ * @return {Promise} Will resolve to either the data (success) or to
+ * the HTTP response (error).
+ **/
+ function loadAll() {
+ return httpGetData(c.ALL);
+ }
+
+ /**
+ * Make a HTTP get request with the given query parameters.
+ *
+ * @param {}
+ * @param {}
+ *
+ * @return {}
+ **/
+ function httpGetData(url, queryParams) {
+ var reqConfig = {
+ method: 'GET',
+ url: url,
+ params: queryParams
+ };
+
+ return $http(reqConfig).then(
+ function(successResp) {
+ return successResp.data;
+ });
+ }
+
+ /**
+ * Takes an arbitrary number of objects and generates a Url encoded
+ * query string.
+ *
+ **/
+ function getQueryString( _params_ ) {
+ var result = [];
+ for(var i=0, len=arguments.length; i < len; i++) {
+ if (arguments[i]) {
+ for(var k in arguments[i]) {
+ if (arguments[i].hasOwnProperty(k)) {
+ result.push(encodeURIComponent(k) + '=' +
+ encodeURIComponent(arguments[i][k]));
+ }
+ }
+ }
+ }
+ return result.join("&");
+ }
+
+ // Interface of the service:
+ return {
+ getQueryString: getQueryString,
+ loadAll: loadAll
+ };
+
+ }]);
+
+})();
diff --git a/gm/rebaseline_server/static/new/new-index.html b/gm/rebaseline_server/static/new/new-index.html
new file mode 100644
index 0000000000..b7067f19dd
--- /dev/null
+++ b/gm/rebaseline_server/static/new/new-index.html
@@ -0,0 +1,37 @@
+<!DOCTYPE html>
+<html lang="en" ng-app="rbtApp">
+
+<head>
+ <meta name="viewport" content="width=device-width">
+ <meta charset="utf-8">
+ <title>Rebaseline Tool</title>
+ <link rel="stylesheet" href="bower_components/bootstrap/dist/css/bootstrap.min.css">
+ <link rel="stylesheet" href="css/app.css">
+</head>
+<body>
+
+
+ <div class="container-fluid">
+ <div class="pull-right">
+ Instructions, roadmap, etc. are at <a href="http://goo.gl/CqeVHq" target="_blank">http://goo.gl/CqeVHq</a>
+ </div>
+ </div>
+
+ <!-- Include the different views here.
+ Make everything fluid to scale to the maximum size of any screen. -->
+ <div class="container-fluid">
+ <div ng-view></div>
+ </div>
+
+ <!-- do everything local right now: Move to CDN fix when it's a performance issue -->
+ <script src="bower_components/angular/angular.js"></script>
+ <script src="bower_components/angular-route/angular-route.js"></script>
+
+ <!-- Local includes external libs -->
+ <script src="bower_components/angular-bootstrap/ui-bootstrap.js"></script>
+ <script src="bower_components/angular-bootstrap/ui-bootstrap-tpls.js"></script>
+
+ <!-- Local JS -->
+ <script src="js/app.js"></script>
+</body>
+</html>
diff --git a/gm/rebaseline_server/static/new/partials/index-view.html b/gm/rebaseline_server/static/new/partials/index-view.html
new file mode 100644
index 0000000000..72db231d08
--- /dev/null
+++ b/gm/rebaseline_server/static/new/partials/index-view.html
@@ -0,0 +1,22 @@
+<div class="container-fluid ng-cloak" ng-cloak>
+ <div class="row" ng-show="state === c.ST_LOADING">
+ <h4>Loading ...</h4>
+ </div>
+
+ <div class="row" ng-show="state === c.ST_READY">
+ <h4>GM Expectations vs Actuals:</h4>
+ <ul>
+ <li><a href="#/view?{{ qStr({ resultsToLoad: c.RESULTS_FAILURES }) }}">Failures</a></li>
+ <li><a href="#/view?{{ qStr({ resultsToLoad: c.RESULTS_ALL }) }}">All</a></li>
+ </ul>
+
+ <h4>Rendered SKPs:</h4>
+ <ul>
+ <li ng-repeat="oneSKP in allSKPs">
+ <a href="#/view?{{ qStr(oneSKP.params) }}">
+ {{oneSKP.title}}
+ </a>
+ </li>
+ </ul>
+ </div>
+</div>
diff --git a/gm/rebaseline_server/static/new/partials/rebaseline-view.html b/gm/rebaseline_server/static/new/partials/rebaseline-view.html
new file mode 100644
index 0000000000..a2c28f7357
--- /dev/null
+++ b/gm/rebaseline_server/static/new/partials/rebaseline-view.html
@@ -0,0 +1,207 @@
+<div class="container-fluid ng-cloak" ng-cloak>
+
+ <div class="row" ng-show="state === c.ST_LOADING">
+ <h4>Loading ...</h4>
+ </div>
+
+ <div class="row" ng-show="state === c.ST_STILL_LOADING">
+ <h4>Still loading from backend.</h4>
+ <div>
+ Load time so far: {{ loadTime | number:0 }} s.
+ </div>
+ </div>
+
+ <div class="row" ng-show="state === c.ST_READY">
+ <tabset>
+ <tab heading="Unfiled">
+ <!-- settings -->
+ <div class="container controlBox">
+ <form class="form-inline settingsForm" novalidate >
+ <legend class="simpleLegend">Settings</legend>
+ <div class="checkbox formPadding">
+ <label>
+ <input type="checkbox"
+ ng-model="settings.showThumbnails">Show thumbnails
+ </label>
+ </div>
+
+ <div class="checkbox formPadding">
+ <label>
+ <input type="checkbox"
+ ng-model="settings.mergeIdenticalRows"
+ ng-change="mergeRowsChanged(mergeIdenticalRows)"> Merge identical rows
+ </label>
+ </div>
+
+ <div class="form-group formPadding">
+ <label for="imageWidth">Image Width</label>
+ <select ng-model="settings.imageSize"
+ ng-options="iSize for iSize in c.IMAGE_SIZES"
+ class="form-control input-sm">
+
+ </select>
+ </div>
+ <div class="form-group formPadding">
+ <label>Max records</label>
+ <select ng-model="settings.nRecords"
+ ng-options="n for n in c.MAX_RECORDS"
+ ng-change="maxRecordsChanged();"
+ class="form-control input-sm">
+ </select>
+ </div>
+ </form>
+ <br>
+
+ <form class="form settingsForm" novalidate>
+ <legend class="simpleLegend">Filters</legend>
+ <div class="container-fluid">
+ <div class="col-lg-2 filterBox" ng-repeat="oneCol in filterCols">
+ <div class="filterKey">{{ oneCol.key }}</div>
+
+ <!-- If we filter this column using free-form text match... -->
+ <div ng-if="oneCol.ftype === c.FILTER_FREE_FORM">
+ <input type="text"
+ ng-model="filterVals[$index]"
+ typeahead="opt.value for opt in oneCol.foptions | filter:$viewValue"
+ class="form-control input-sm">
+ <br>
+ <a ng-click="filterVals[$index]=''"
+ ng-disabled="'' === filterVals[$index]"
+ href="">
+ Clear
+ </a>
+ </div>
+
+ <!-- If we filter this column using checkboxes... -->
+ <div ng-if="oneCol.ftype === c.FILTER_CHECK_BOX">
+
+ <div class="checkbox" ng-repeat="oneOpt in oneCol.foptions">
+ <label>
+ <input type="checkbox"
+ ng-model="filterVals[$parent.$index][$index]">{{oneOpt.value}} ({{ oneOpt.count }})
+ </label>
+ </div>
+ <div>
+ <a ng-click="setFilterAll($index, true)" href="">All</a> -
+ <a ng-click="setFilterAll($index, False)" href="">None</a> -
+ <a ng-click="setFilterToggle($index)" href="">Toggle</a>
+ </div>
+ </div>
+ </div>
+ <br>
+ </div>
+
+ <div class="container updateBtn">
+ <button class="btn btn-success col-lg-4 pull-left"
+ ng-click="filtersChanged()"
+ ng-disabled="updating">
+ {{ updating && 'Updating ...' || 'Update' }}
+ </button>
+ </div>
+
+ </form>
+
+ <br>
+
+ <!-- Rows -->
+
+ <!-- results header -->
+ <div class="col-lg-12 resultsHeaderActions well">
+ <div class="col-lg-6">
+ <h4>Showing {{showingRecords}} of {{selectedRecords}} (of {{totalRecords}} total)</h4>
+ <span ng-show="renderTime > 0">
+ Rendered in {{renderTime | number:0 }} ms (filtered and sorted in {{ filterTime | number:0 }} ms).
+ </span>
+ <br>
+ (click on the column header radio buttons to re-sort by that column)
+ </div>
+
+
+ <div class="col-lg-6">
+ All tests shown:
+ <button class="btn btn-default btn-sm" ng-click="selectAllImagePairs()">Select</button>
+ <button class="btn btn-default btn-sm" ng-click="clearAllImagePairs()">Clear</button>
+ <button class="btn btn-default btn-sm" ng-click="toggleAllImagePairs()">Toggle</button>
+
+ <div ng-repeat="otherTab in tabs">
+ <button class="btn btn-default btn-sm"
+ ng-click="moveSelectedImagePairsToTab(otherTab)"
+ ng-disabled="selectedImagePairs.length == 0"
+ ng-show="otherTab != viewingTab">
+ Move {{selectedImagePairs.length}} selected tests to {{otherTab}} tab
+ </button>
+ </div>
+ </div>
+ <br>
+ </div>
+
+ <!-- results -->
+ <table class="table table-bordered">
+ <thead>
+ <tr>
+ <!-- Most column headers are displayed in a common fashion... -->
+ <th ng-repeat="oneCol in allCols" ng-style="{ 'min-width': getImageWidthStyle(oneCol, 20, 'auto') }">
+ <a ng-class="getSortedClass('sort', $index, '')"
+ ng-click="sortBy($index)"
+ href=""
+ class="sortableHeader">
+ {{ oneCol.ctitle }}
+ </a>
+ </th>
+ <th>
+ <div class="checkbox">
+ <label>
+ <input type="checkbox" ng-model="allChecked" ng-change="checkAll()">All
+ </label>
+ </div>
+ </th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr ng-repeat="oneRow in data">
+ <td ng-repeat="oneColVal in oneRow.dataCols">
+ {{oneColVal}}
+ </td>
+
+ <td ng-repeat="oneCol in oneRow.imageCols" ng-if="oneRow.rowspan > 0" rowspan="{{ oneRow.rowspan }}">
+ <div ng-show="oneCol.url">
+ <a href="{{ oneCol.url }}" target="_blank">View Image</a><br/>
+ <img ng-if="settings.showThumbnails"
+ ng-style="{ width: settings.imageSize+'px' }"
+ ng-src="{{ oneCol.url }}" />
+ <div ng-if="oneCol.percent && oneCol.value">
+ {{oneCol.percent}}% ({{ oneCol.value }})
+ </div>
+ </div>
+ <div ng-hide="oneCol.url" style="text-align:center">
+ <span ng-show="oneCol.url === null">&ndash;none&ndash;</span>
+ <span ng-hide="oneCol.url === null">&nbsp;</span>
+ </div>
+ </td>
+
+ <td ng-if="oneRow.rowspan > 0" rowspan="{{ oneRow.rowspan }}">
+ <div class="checkbox">
+ <input type="checkbox"
+ ng-model="checkRows[$index]"
+ ng-change="rowCheckChanged($index)">
+ </div>
+ </td>
+ </tr>
+ </tbody>
+ </table>
+
+ </div>
+ </tab>
+
+ <tab heading="Hidden">
+ <h3>Hidden</h3>
+ </tab>
+
+ <tab heading="Pending Approval">
+ <h3>Pending Approval</h3>
+ </tab>
+
+ </tabset>
+
+ </div>
+</div>
diff --git a/gm/rebaseline_server/static/utils.js b/gm/rebaseline_server/static/utils.js
new file mode 100644
index 0000000000..e846b90bd6
--- /dev/null
+++ b/gm/rebaseline_server/static/utils.js
@@ -0,0 +1,12 @@
+function make_results_header_sticky( ) {
+ element = $(".results-header-actions");
+ var pos = element.position();
+ $(window).scroll( function() {
+ var windowPos = $(window).scrollTop();
+ if (windowPos > pos.top) {
+ element.addClass("sticky");
+ } else {
+ element.removeClass("sticky");
+ }
+ });
+}
diff --git a/gm/rebaseline_server/static/view.css b/gm/rebaseline_server/static/view.css
new file mode 100644
index 0000000000..80f28091c4
--- /dev/null
+++ b/gm/rebaseline_server/static/view.css
@@ -0,0 +1,104 @@
+/* Special alert areas at the top of the page. */
+.todo-div {
+ background-color: #bbffbb;
+}
+.warning-div {
+ background-color: #ffbb00;
+}
+
+.tab-wrapper {
+ margin-top: 10px;
+}
+
+.tab {
+ display: inline-block;
+ font-size: 20px;
+ padding: 5px;
+ border-top-left-radius: 5px;
+ border-top-right-radius: 5px;
+ position: relative;
+}
+
+/* Tab which has been selected. */
+.tab-true {
+ background-color: #ccccff;
+ border: 1px solid black;
+ border-bottom-width: 0px;
+ bottom: -1px;
+}
+/* All other tabs. */
+.tab-false {
+ background-color: #8888ff;
+ cursor: pointer;
+}
+
+.tab-false:hover {
+ background-color: #aa88ff;
+}
+
+/* Spacers between tabs. */
+.tab-spacer {
+ display: inline-block;
+}
+/* The main working area (connected to the selected tab). */
+.tab-main {
+ background-color: #ccccff;
+ border: 1px solid black;
+}
+
+.update-results-button {
+ font-size: 30px;
+}
+
+/* Odd and even lines within results display. */
+.results-odd {
+ background-color: #ddffdd;
+}
+.results-even {
+ background-color: #ddddff;
+}
+
+.show-only-button {
+ font-size: 8px;
+}
+.show-all-button {
+ font-size: 8px;
+}
+
+.image-link {
+ text-decoration: none;
+}
+
+.results-header {
+ overflow: hidden;
+ padding: 10px;
+ background-color: #ccccff;
+}
+
+.results-header-actions {
+ float: right;
+}
+
+.sticky {
+ position: fixed;
+ top: 2px;
+ box-shadow: -2px 2px 5px 0 rgba(0,0,0,.45);
+ background: white;
+ right: 2px;
+ padding: 10px;
+ border: 2px solid #222;
+}
+
+.sort-desc {
+ background:no-repeat left center url(data:image/gif;base64,R0lGODlhCgAKALMAAHFxcYKCgp2dnaampq+vr83NzeHh4f///wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACH5BAkAAAgAIf/8SUNDUkdCRzEwMTIAAAUwYXBwbAIgAABtbnRyUkdCIFhZWiAH2QACABkACwAaAAthY3NwQVBQTAAAAABhcHBsAAAAAAAAAAAAAAAAAAAAAAAA9tYAAQAAAADTLWFwcGwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAtkc2NtAAABCAAAAvJkZXNjAAAD/AAAAG9nWFlaAAAEbAAAABR3dHB0AAAEgAAAABRyWFlaAAAElAAAABRiWFlaAAAEqAAAABRyVFJDAAAEvAAAAA5jcHJ0AAAEzAAAADhjaGFkAAAFBAAAACxn/1RSQwAABLwAAAAOYlRSQwAABLwAAAAObWx1YwAAAAAAAAARAAAADGVuVVMAAAAmAAACfmVzRVMAAAAmAAABgmRhREsAAAAuAAAB6mRlREUAAAAsAAABqGZpRkkAAAAoAAAA3GZyRlUAAAAoAAABKml0SVQAAAAoAAACVm5sTkwAAAAoAAACGG5iTk8AAAAmAAABBHB0QlIAAAAmAAABgnN2U0UAAAAmAAABBGphSlAAAAAaAAABUmtvS1IAAAAWAAACQHpoVFcAAAAWAAABbHpoQ04AAAAWAAAB1HJ1UlUAAAAiAAACpHBsUEwAAAAsAAACxgBZAGwAZQBpAG4AZf8AbgAgAFIARwBCAC0AcAByAG8AZgBpAGkAbABpAEcAZQBuAGUAcgBpAHMAawAgAFIARwBCAC0AcAByAG8AZgBpAGwAUAByAG8AZgBpAGwAIABHAOkAbgDpAHIAaQBxAHUAZQAgAFIAVgBCTgCCLAAgAFIARwBCACAw1zDtMNUwoTCkMOuQGnUoACAAUgBHAEIAIIJyX2ljz4/wAFAAZQByAGYAaQBsACAAUgBHAEIAIABHAGUAbgDpAHIAaQBjAG8AQQBsAGwAZwBlAG0AZQBpAG4AZQBzACAAUgBHAEIALQBQAHIAbwBmAGkAbGZukBoAIABSAEcAQgAgY8+P8GX/h072AEcAZQBuAGUAcgBlAGwAIABSAEcAQgAtAGIAZQBzAGsAcgBpAHYAZQBsAHMAZQBBAGwAZwBlAG0AZQBlAG4AIABSAEcAQgAtAHAAcgBvAGYAaQBlAGzHfLwYACAAUgBHAEIAINUEuFzTDMd8AFAAcgBvAGYAaQBsAG8AIABSAEcAQgAgAEcAZQBuAGUAcgBpAGMAbwBHAGUAbgBlAHIAaQBjACAAUgBHAEIAIABQAHIAbwBmAGkAbABlBB4EMQRJBDgEOQAgBD8EQAQ+BEQEOAQ7BEwAIABSAEcAQgBVAG4AaQB3AGUAcgBzAGEAbABuAHkAIABwAHIAbwBm/wBpAGwAIABSAEcAQgAAZGVzYwAAAAAAAAAUR2VuZXJpYyBSR0IgUHJvZmlsZQAAAAAAAAAAAAAAFEdlbmVyaWMgUkdCIFByb2ZpbGUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFhZWiAAAAAAAABadQAArHMAABc0WFlaIAAAAAAAAPNSAAEAAAABFs9YWVogAAAAAAAAdE0AAD3uAAAD0FhZWiAAAAAAAAAoGgAAFZ8AALg2Y3VydgAAAAAAAAABAc0AAHRleHQAAAAAQ29weXJpZ2h0IDIwMDcgQXBwbGUgSW5jLkMsIGFsbCByaWdodHMgcmVzZXJ2ZWQuAHNmMzIAAAAAAAEMQgAABd7///MmAAAHkgAA/ZH///ui///9owAAA9wAAMBsACwAAAAACgAKAAAEJZAMIcakQZjNtyhFxwEIIRofAookUnapu26t+6KFLYe1TgQ5VwQAOw%3D%3D);
+}
+
+.sort-asc {
+ background:no-repeat left center url(data:image/gif;base64,R0lGODlhCgAKALMAAHFxcYKCgp2dnaampq+vr83NzeHh4f///wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACH5BAkAAAgAIf/8SUNDUkdCRzEwMTIAAAUwYXBwbAIgAABtbnRyUkdCIFhZWiAH2QACABkACwAaAAthY3NwQVBQTAAAAABhcHBsAAAAAAAAAAAAAAAAAAAAAAAA9tYAAQAAAADTLWFwcGwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAtkc2NtAAABCAAAAvJkZXNjAAAD/AAAAG9nWFlaAAAEbAAAABR3dHB0AAAEgAAAABRyWFlaAAAElAAAABRiWFlaAAAEqAAAABRyVFJDAAAEvAAAAA5jcHJ0AAAEzAAAADhjaGFkAAAFBAAAACxn/1RSQwAABLwAAAAOYlRSQwAABLwAAAAObWx1YwAAAAAAAAARAAAADGVuVVMAAAAmAAACfmVzRVMAAAAmAAABgmRhREsAAAAuAAAB6mRlREUAAAAsAAABqGZpRkkAAAAoAAAA3GZyRlUAAAAoAAABKml0SVQAAAAoAAACVm5sTkwAAAAoAAACGG5iTk8AAAAmAAABBHB0QlIAAAAmAAABgnN2U0UAAAAmAAABBGphSlAAAAAaAAABUmtvS1IAAAAWAAACQHpoVFcAAAAWAAABbHpoQ04AAAAWAAAB1HJ1UlUAAAAiAAACpHBsUEwAAAAsAAACxgBZAGwAZQBpAG4AZf8AbgAgAFIARwBCAC0AcAByAG8AZgBpAGkAbABpAEcAZQBuAGUAcgBpAHMAawAgAFIARwBCAC0AcAByAG8AZgBpAGwAUAByAG8AZgBpAGwAIABHAOkAbgDpAHIAaQBxAHUAZQAgAFIAVgBCTgCCLAAgAFIARwBCACAw1zDtMNUwoTCkMOuQGnUoACAAUgBHAEIAIIJyX2ljz4/wAFAAZQByAGYAaQBsACAAUgBHAEIAIABHAGUAbgDpAHIAaQBjAG8AQQBsAGwAZwBlAG0AZQBpAG4AZQBzACAAUgBHAEIALQBQAHIAbwBmAGkAbGZukBoAIABSAEcAQgAgY8+P8GX/h072AEcAZQBuAGUAcgBlAGwAIABSAEcAQgAtAGIAZQBzAGsAcgBpAHYAZQBsAHMAZQBBAGwAZwBlAG0AZQBlAG4AIABSAEcAQgAtAHAAcgBvAGYAaQBlAGzHfLwYACAAUgBHAEIAINUEuFzTDMd8AFAAcgBvAGYAaQBsAG8AIABSAEcAQgAgAEcAZQBuAGUAcgBpAGMAbwBHAGUAbgBlAHIAaQBjACAAUgBHAEIAIABQAHIAbwBmAGkAbABlBB4EMQRJBDgEOQAgBD8EQAQ+BEQEOAQ7BEwAIABSAEcAQgBVAG4AaQB3AGUAcgBzAGEAbABuAHkAIABwAHIAbwBm/wBpAGwAIABSAEcAQgAAZGVzYwAAAAAAAAAUR2VuZXJpYyBSR0IgUHJvZmlsZQAAAAAAAAAAAAAAFEdlbmVyaWMgUkdCIFByb2ZpbGUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFhZWiAAAAAAAABadQAArHMAABc0WFlaIAAAAAAAAPNSAAEAAAABFs9YWVogAAAAAAAAdE0AAD3uAAAD0FhZWiAAAAAAAAAoGgAAFZ8AALg2Y3VydgAAAAAAAAABAc0AAHRleHQAAAAAQ29weXJpZ2h0IDIwMDcgQXBwbGUgSW5jLkMsIGFsbCByaWdodHMgcmVzZXJ2ZWQuAHNmMzIAAAAAAAEMQgAABd7///MmAAAHkgAA/ZH///ui///9owAAA9wAAMBsACwAAAAACgAKAAAEJRBJREKZsxQDsCSGIVzZFnYTGIqktp7fG46uzAn2TAyCMPC9QAQAOw%3D%3D);
+}
+
+.sortable-header {
+ padding-right: 3px;
+ padding-left: 13px;
+ margin-left: 4px;
+}
diff --git a/gm/rebaseline_server/static/view.html b/gm/rebaseline_server/static/view.html
new file mode 100644
index 0000000000..f6ebf5a9a4
--- /dev/null
+++ b/gm/rebaseline_server/static/view.html
@@ -0,0 +1,417 @@
+<!DOCTYPE html>
+
+<html ng-app="Loader" ng-controller="Loader.Controller">
+
+<head>
+ <title ng-bind="windowTitle"></title>
+ <script src="//ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js"></script>
+ <script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.2.20/angular.js"></script>
+ <script src="constants.js"></script>
+ <script src="loader.js"></script>
+ <script src="utils.js"></script>
+ <link rel="stylesheet" href="view.css">
+</head>
+
+<body>
+ <h2>
+ Instructions, roadmap, etc. are at
+ <a href="http://tinyurl.com/SkiaRebaselineServer">
+ http://tinyurl.com/SkiaRebaselineServer
+ </a>
+ </h2>
+
+ <em ng-show="!readyToDisplay">
+ Loading results from <a href="{{resultsToLoad}}">{{resultsToLoad}}</a> ...
+ {{loadingMessage}}
+ </em>
+
+ <div ng-show="readyToDisplay">
+
+ <div class="warning-div"
+ ng-show="urlSchemaVersionLoaded != constants.URL_VALUE__SCHEMA_VERSION__CURRENT">
+ WARNING! The URL you loaded used schema version {{urlSchemaVersionLoaded}}, rather than
+ the most recent version {{constants.URL_VALUE__SCHEMA_VERSION__CURRENT}}. It has been
+ converted to the most recent version on a best-effort basis; you may wish to double-check
+ which records are displayed.
+ </div>
+
+ <div class="warning-div"
+ ng-show="header[constants.KEY__HEADER__IS_EDITABLE] && header[constants.KEY__HEADER__IS_EXPORTED]">
+ WARNING! These results are editable and exported, so any user
+ who can connect to this server over the network can modify them.
+ </div>
+
+ <div ng-show="header[constants.KEY__HEADER__TIME_UPDATED]">
+ These results, from raw JSON file
+ <a href="{{resultsToLoad}}">{{resultsToLoad}}</a>, current as of
+ {{localTimeString(header[constants.KEY__HEADER__TIME_UPDATED])}}
+ <br>
+ To see other sets of results (all results, failures only, etc.),
+ <a href="/">click here</a>
+ </div>
+
+ <div class="tab-wrapper"><!-- tabs -->
+ <div class="tab-spacer" ng-repeat="tab in tabs">
+ <div class="tab tab-{{tab == viewingTab}}"
+ ng-click="setViewingTab(tab)">
+ &nbsp;{{tab}} ({{numResultsPerTab[tab]}})&nbsp;
+ </div>
+ <div class="tab-spacer">
+ &nbsp;
+ </div>
+ </div>
+ </div><!-- tabs -->
+
+ <div class="tab-main"><!-- main display area of selected tab -->
+
+ <br>
+ <!-- We only show the filters/settings table on the Unfiled tab. -->
+ <table ng-show="viewingTab == defaultTab" border="1">
+ <tr>
+ <th colspan="4">
+ Filters
+ </th>
+ <th>
+ Settings
+ </th>
+ </tr>
+ <tr valign="top">
+
+ <!-- filters -->
+ <td ng-repeat="columnName in orderedColumnNames">
+
+ <!-- Only display filterable columns here... -->
+ <div ng-if="extraColumnHeaders[columnName][constants.KEY__EXTRACOLUMNHEADERS__IS_FILTERABLE]">
+ {{extraColumnHeaders[columnName][constants.KEY__EXTRACOLUMNHEADERS__HEADER_TEXT]}}<br>
+
+ <!-- If we filter this column using free-form text match... -->
+ <div ng-if="extraColumnHeaders[columnName][constants.KEY__EXTRACOLUMNHEADERS__USE_FREEFORM_FILTER]">
+ <input type="text"
+ ng-model="columnStringMatch[columnName]"
+ ng-change="setUpdatesPending(true)"/>
+ <br>
+ <button ng-click="setColumnStringMatch(columnName, '')"
+ ng-disabled="('' == columnStringMatch[columnName])">
+ clear (show all)
+ </button>
+ </div>
+
+ <!-- If we filter this column using checkboxes... -->
+ <div ng-if="!extraColumnHeaders[columnName][constants.KEY__EXTRACOLUMNHEADERS__USE_FREEFORM_FILTER]">
+ <label ng-repeat="valueAndCount in extraColumnHeaders[columnName][constants.KEY__EXTRACOLUMNHEADERS__VALUES_AND_COUNTS]">
+ <input type="checkbox"
+ name="resultTypes"
+ value="{{valueAndCount[0]}}"
+ ng-checked="isValueInSet(valueAndCount[0], showingColumnValues[columnName])"
+ ng-click="toggleValueInSet(valueAndCount[0], showingColumnValues[columnName]); setUpdatesPending(true)">
+ {{valueAndCount[0]}} ({{valueAndCount[1]}})<br>
+ </label>
+ <button ng-click="showingColumnValues[columnName] = {}; toggleValuesInSet(allColumnValues[columnName], showingColumnValues[columnName]); updateResults()"
+ ng-disabled="!readyToDisplay || allColumnValues[columnName].length == setSize(showingColumnValues[columnName])">
+ all
+ </button>
+ <button ng-click="showingColumnValues[columnName] = {}; updateResults()"
+ ng-disabled="!readyToDisplay || 0 == setSize(showingColumnValues[columnName])">
+ none
+ </button>
+ <button ng-click="toggleValuesInSet(allColumnValues[columnName], showingColumnValues[columnName]); updateResults()">
+ toggle
+ </button>
+ </div>
+
+ </div>
+ </td>
+
+ <!-- settings -->
+ <td><table>
+ <tr><td>
+ <input type="checkbox" ng-model="showThumbnailsPending"
+ ng-init="showThumbnailsPending = true"
+ ng-change="areUpdatesPending = true"/>
+ Show thumbnails
+ </td></tr>
+ <tr><td>
+ <input type="checkbox" ng-model="mergeIdenticalRowsPending"
+ ng-init="mergeIdenticalRowsPending = true"
+ ng-change="areUpdatesPending = true"/>
+ Merge identical rows
+ </td></tr>
+ <tr><td>
+ Image width
+ <input type="text" ng-model="imageSizePending"
+ ng-init="imageSizePending=100"
+ ng-change="areUpdatesPending = true"
+ maxlength="4"/>
+ </td></tr>
+ <tr><td>
+ Max records to display
+ <input type="text" ng-model="displayLimitPending"
+ ng-init="displayLimitPending=50"
+ ng-change="areUpdatesPending = true"
+ maxlength="4"/>
+ </td></tr>
+ <tr><td>
+ <button class="update-results-button"
+ ng-click="updateResults()"
+ ng-disabled="!areUpdatesPending">
+ Update Results
+ </button>
+ </td></tr>
+ </tr></table></td>
+ </tr>
+ </table>
+
+ <p>
+
+ <!-- Submission UI that we only show in the Pending Approval tab. -->
+ <div ng-show="'Pending Approval' == viewingTab">
+ <div style="display:inline-block">
+ <button style="font-size:20px"
+ ng-click="submitApprovals(filteredImagePairs)"
+ ng-disabled="submitPending || (filteredImagePairs.length == 0)">
+ Update these {{filteredImagePairs.length}} expectations on the server
+ </button>
+ </div>
+ <div style="display:inline-block">
+ <div style="font-size:20px"
+ ng-show="submitPending">
+ Submitting, please wait...
+ </div>
+ </div>
+ <div>
+ Advanced settings...
+ <input type="checkbox" ng-model="showSubmitAdvancedSettings">
+ show
+ <ul ng-show="showSubmitAdvancedSettings">
+ <li ng-repeat="setting in [constants.KEY__EXPECTATIONS__REVIEWED, constants.KEY__EXPECTATIONS__IGNOREFAILURE]">
+ {{setting}}
+ <input type="checkbox" ng-model="submitAdvancedSettings[setting]">
+ </li>
+ <li ng-repeat="setting in ['bug']">
+ {{setting}}
+ <input type="text" ng-model="submitAdvancedSettings[setting]">
+ </li>
+ </ul>
+ </div>
+ </div>
+
+ <p>
+
+ <div class="results-header"> <!-- results header -->
+ <div class="results-header-actions">
+ all tests shown:
+ <button ng-click="selectAllImagePairs()">
+ select
+ </button>
+ <button ng-click="clearAllImagePairs()">
+ clear
+ </button>
+ <button ng-click="toggleAllImagePairs()">
+ toggle
+ </button>
+ <div ng-repeat="otherTab in tabs">
+ <button ng-click="moveSelectedImagePairsToTab(otherTab)"
+ ng-disabled="selectedImagePairs.length == 0"
+ ng-show="otherTab != viewingTab">
+ move {{selectedImagePairs.length}} selected tests to {{otherTab}} tab
+ </button>
+ </div>
+ </div>
+ <div class="results-header-stats">
+ Found {{filteredImagePairs.length}} matches;
+ <span ng-show="filteredImagePairs.length > limitedImagePairs.length">
+ displaying the first {{limitedImagePairs.length}}.
+ </span>
+ <span ng-show="filteredImagePairs.length <= limitedImagePairs.length">
+ displaying them all.
+ </span>
+ <span ng-show="renderEndTime > renderStartTime">
+ Rendered in {{(renderEndTime - renderStartTime).toFixed(0)}} ms.
+ </span>
+ <br>
+ (click on the column header radio buttons to re-sort by that column)
+ </div>
+ </div> <!-- results header -->
+
+ <table border="0"><tr><td> <!-- table holding results header + results table -->
+ </td></tr><tr><td>
+ <table border="1"> <!-- results -->
+ <tr>
+ <!-- Most column headers are displayed in a common fashion... -->
+ <th ng-repeat="columnName in orderedColumnNames">
+ <a ng-class="'sort-' + sortedByColumnsCls(columnName)"
+ ng-click="sortResultsBy(constants.KEY__IMAGEPAIRS__EXTRACOLUMNS, columnName)"
+ href=""
+ class="sortable-header">
+ {{extraColumnHeaders[columnName][constants.KEY__EXTRACOLUMNHEADERS__HEADER_TEXT]}}
+ </a>
+ </th>
+
+ <!-- ... but there are a few columns where we display things differently. -->
+ <th>
+ <a ng-class="'sort-' + sortedByColumnsCls(constants.KEY__EXPECTATIONS__BUGS)"
+ ng-click="sortResultsBy(constants.KEY__IMAGEPAIRS__EXPECTATIONS, constants.KEY__EXPECTATIONS__BUGS)"
+ href=""
+ class="sortable-header">
+ bugs
+ </a>
+ </th>
+ <th width="{{imageSize}}">
+ <a ng-class="'sort-' + sortedByColumnsCls(constants.KEY__IMAGEPAIRS__IMAGE_A_URL)"
+ ng-click="sortResultsBy('none', constants.KEY__IMAGEPAIRS__IMAGE_A_URL)"
+ href=""
+ class="sortable-header">
+ {{imageSets[constants.KEY__IMAGESETS__SET__IMAGE_A][constants.KEY__IMAGESETS__FIELD__DESCRIPTION]}}
+ </a>
+ </th>
+ <th width="{{imageSize}}">
+ <a ng-class="'sort-' + sortedByColumnsCls(constants.KEY__IMAGEPAIRS__IMAGE_B_URL)"
+ ng-click="sortResultsBy('none', constants.KEY__IMAGEPAIRS__IMAGE_B_URL)"
+ href=""
+ class="sortable-header">
+ {{imageSets[constants.KEY__IMAGESETS__SET__IMAGE_B][constants.KEY__IMAGESETS__FIELD__DESCRIPTION]}}
+ </a>
+ </th>
+ <th width="{{imageSize}}">
+ <a ng-class="'sort-' + sortedByColumnsCls(constants.KEY__DIFFERENCES__PERCENT_DIFF_PIXELS)"
+ ng-click="sortResultsBy(constants.KEY__IMAGEPAIRS__DIFFERENCES, constants.KEY__DIFFERENCES__PERCENT_DIFF_PIXELS)"
+ href=""
+ class="sortable-header">
+ differing pixels in white
+ </a>
+ </th>
+ <th width="{{imageSize}}">
+ <a ng-class="'sort-' + sortedByColumnsCls(constants.KEY__DIFFERENCES__PERCEPTUAL_DIFF)"
+ ng-click="sortResultsBy(constants.KEY__IMAGEPAIRS__DIFFERENCES, constants.KEY__DIFFERENCES__PERCEPTUAL_DIFF)"
+ href=""
+ class="sortable-header">
+ perceptual difference
+ </a>
+ <br>
+ <input type="range" ng-model="pixelDiffBgColorBrightness"
+ ng-init="pixelDiffBgColorBrightness=64; pixelDiffBgColor=brightnessStringToHexColor(pixelDiffBgColorBrightness)"
+ ng-change="pixelDiffBgColor=brightnessStringToHexColor(pixelDiffBgColorBrightness)"
+ title="image background brightness"
+ min="0" max="255"/>
+ </th>
+ <th>
+ <!-- imagepair-selection checkbox column -->
+ </th>
+ </tr>
+
+ <tr ng-repeat="imagePair in limitedImagePairs" valign="top"
+ ng-class-odd="'results-odd'" ng-class-even="'results-even'"
+ results-updated-callback-directive>
+
+ <td ng-repeat="columnName in orderedColumnNames">
+ {{imagePair[constants.KEY__IMAGEPAIRS__EXTRACOLUMNS][columnName]}}
+ <br>
+ <button class="show-only-button"
+ ng-show="viewingTab == defaultTab"
+ ng-disabled="1 == setSize(showingColumnValues[columnName])"
+ ng-click="showOnlyColumnValue(columnName, imagePair[constants.KEY__IMAGEPAIRS__EXTRACOLUMNS][columnName])"
+ title="show only results of {{extraColumnHeaders[columnName][constants.KEY__EXTRACOLUMNHEADERS__HEADER_TEXT]}} {{imagePair[constants.KEY__IMAGEPAIRS__EXTRACOLUMNS][columnName]}}">
+ show only
+ </button>
+ <br>
+ <button class="show-all-button"
+ ng-show="viewingTab == defaultTab"
+ ng-disabled="allColumnValues[columnName].length == setSize(showingColumnValues[columnName])"
+ ng-click="showAllColumnValues(columnName)"
+ title="show results of all {{extraColumnHeaders[columnName][constants.KEY__EXTRACOLUMNHEADERS__HEADER_TEXT]}}s">
+ show all
+ </button>
+ </td>
+
+ <!-- bugs -->
+ <td>
+ <a ng-repeat="bug in imagePair[constants.KEY__IMAGEPAIRS__EXPECTATIONS][constants.KEY__EXPECTATIONS__BUGS]"
+ href="https://code.google.com/p/skia/issues/detail?id={{bug}}"
+ target="_blank">
+ {{bug}}
+ </a>
+ </td>
+
+ <!-- image A -->
+ <td width="{{imageSize}}" ng-if="imagePair[constants.KEY__IMAGEPAIRS__ROWSPAN] > 0" rowspan="{{imagePair[constants.KEY__IMAGEPAIRS__ROWSPAN]}}">
+ <div ng-if="imagePair[constants.KEY__IMAGEPAIRS__IMAGE_A_URL] != null">
+ <a href="{{imageSets[constants.KEY__IMAGESETS__SET__IMAGE_A][constants.KEY__IMAGESETS__FIELD__BASE_URL]}}/{{imagePair[constants.KEY__IMAGEPAIRS__IMAGE_A_URL]}}" target="_blank">View Image</a><br/>
+ <img ng-if="showThumbnails"
+ width="{{imageSize}}"
+ ng-src="{{imageSets[constants.KEY__IMAGESETS__SET__IMAGE_A][constants.KEY__IMAGESETS__FIELD__BASE_URL]}}/{{imagePair[constants.KEY__IMAGEPAIRS__IMAGE_A_URL]}}" />
+ </div>
+ <div ng-show="imagePair[constants.KEY__IMAGEPAIRS__IMAGE_A_URL] == null"
+ style="text-align:center">
+ &ndash;none&ndash;
+ </div>
+ </td>
+
+ <!-- image B -->
+ <td width="{{imageSize}}" ng-if="imagePair[constants.KEY__IMAGEPAIRS__ROWSPAN] > 0" rowspan="{{imagePair[constants.KEY__IMAGEPAIRS__ROWSPAN]}}">
+ <div ng-if="imagePair[constants.KEY__IMAGEPAIRS__IMAGE_B_URL] != null">
+ <a href="{{imageSets[constants.KEY__IMAGESETS__SET__IMAGE_B][constants.KEY__IMAGESETS__FIELD__BASE_URL]}}/{{imagePair[constants.KEY__IMAGEPAIRS__IMAGE_B_URL]}}" target="_blank">View Image</a><br/>
+ <img ng-if="showThumbnails"
+ width="{{imageSize}}"
+ ng-src="{{imageSets[constants.KEY__IMAGESETS__SET__IMAGE_B][constants.KEY__IMAGESETS__FIELD__BASE_URL]}}/{{imagePair[constants.KEY__IMAGEPAIRS__IMAGE_B_URL]}}" />
+ </div>
+ <div ng-show="imagePair[constants.KEY__IMAGEPAIRS__IMAGE_B_URL] == null"
+ style="text-align:center">
+ &ndash;none&ndash;
+ </div>
+ </td>
+
+ <!-- whitediffs: every differing pixel shown in white -->
+ <td width="{{imageSize}}" ng-if="imagePair[constants.KEY__IMAGEPAIRS__ROWSPAN] > 0" rowspan="{{imagePair[constants.KEY__IMAGEPAIRS__ROWSPAN]}}">
+ <div ng-if="imagePair[constants.KEY__IMAGEPAIRS__IS_DIFFERENT]"
+ title="{{imagePair[constants.KEY__IMAGEPAIRS__DIFFERENCES][constants.KEY__DIFFERENCES__NUM_DIFF_PIXELS] | number:0}} of {{(100 * imagePair[constants.KEY__IMAGEPAIRS__DIFFERENCES][constants.KEY__DIFFERENCES__NUM_DIFF_PIXELS] / imagePair[constants.KEY__IMAGEPAIRS__DIFFERENCES][constants.KEY__DIFFERENCES__PERCENT_DIFF_PIXELS]) | number:0}} pixels ({{imagePair[constants.KEY__IMAGEPAIRS__DIFFERENCES][constants.KEY__DIFFERENCES__PERCENT_DIFF_PIXELS].toFixed(4)}}%) differ from expectation.">
+
+ <a href="{{imageSets[constants.KEY__IMAGESETS__SET__WHITEDIFFS][constants.KEY__IMAGESETS__FIELD__BASE_URL]}}/{{imagePair[constants.KEY__IMAGEPAIRS__DIFFERENCES][constants.KEY__DIFFERENCES__WHITE_DIFF_URL]}}" target="_blank">View Image</a><br/>
+ <img ng-if="showThumbnails"
+ width="{{imageSize}}"
+ ng-src="{{imageSets[constants.KEY__IMAGESETS__SET__WHITEDIFFS][constants.KEY__IMAGESETS__FIELD__BASE_URL]}}/{{imagePair[constants.KEY__IMAGEPAIRS__DIFFERENCES][constants.KEY__DIFFERENCES__WHITE_DIFF_URL]}}" />
+ <br/>
+ {{imagePair[constants.KEY__IMAGEPAIRS__DIFFERENCES][constants.KEY__DIFFERENCES__PERCENT_DIFF_PIXELS].toFixed(4)}}%
+ ({{imagePair[constants.KEY__IMAGEPAIRS__DIFFERENCES][constants.KEY__DIFFERENCES__NUM_DIFF_PIXELS]}})
+ </div>
+ <div ng-show="!imagePair[constants.KEY__IMAGEPAIRS__IS_DIFFERENT]"
+ style="text-align:center">
+ &ndash;none&ndash;
+ </div>
+ </td>
+
+ <!-- diffs: per-channel RGB deltas -->
+ <td width="{{imageSize}}" ng-if="imagePair[constants.KEY__IMAGEPAIRS__ROWSPAN] > 0" rowspan="{{imagePair[constants.KEY__IMAGEPAIRS__ROWSPAN]}}">
+ <div ng-if="imagePair[constants.KEY__IMAGEPAIRS__IS_DIFFERENT]"
+ title="Perceptual difference measure is {{imagePair[constants.KEY__IMAGEPAIRS__DIFFERENCES][constants.KEY__DIFFERENCES__PERCEPTUAL_DIFF].toFixed(4)}}%. Maximum difference per channel: R={{imagePair[constants.KEY__IMAGEPAIRS__DIFFERENCES][constants.KEY__DIFFERENCES__MAX_DIFF_PER_CHANNEL][0]}}, G={{imagePair[constants.KEY__IMAGEPAIRS__DIFFERENCES][constants.KEY__DIFFERENCES__MAX_DIFF_PER_CHANNEL][1]}}, B={{imagePair[constants.KEY__IMAGEPAIRS__DIFFERENCES][constants.KEY__DIFFERENCES__MAX_DIFF_PER_CHANNEL][2]}}">
+
+ <a href="{{imageSets[constants.KEY__IMAGESETS__SET__DIFFS][constants.KEY__IMAGESETS__FIELD__BASE_URL]}}/{{imagePair[constants.KEY__IMAGEPAIRS__DIFFERENCES][constants.KEY__DIFFERENCES__DIFF_URL]}}" target="_blank">View Image</a><br/>
+ <img ng-if="showThumbnails"
+ ng-style="{backgroundColor: pixelDiffBgColor}"
+ width="{{imageSize}}"
+ ng-src="{{imageSets[constants.KEY__IMAGESETS__SET__DIFFS][constants.KEY__IMAGESETS__FIELD__BASE_URL]}}/{{imagePair[constants.KEY__IMAGEPAIRS__DIFFERENCES][constants.KEY__DIFFERENCES__DIFF_URL]}}" />
+ <br/>
+ {{imagePair[constants.KEY__IMAGEPAIRS__DIFFERENCES][constants.KEY__DIFFERENCES__PERCEPTUAL_DIFF].toFixed(4)}}%
+ {{imagePair[constants.KEY__IMAGEPAIRS__DIFFERENCES][constants.KEY__DIFFERENCES__MAX_DIFF_PER_CHANNEL]}}
+ </div>
+ <div ng-show="!imagePair[constants.KEY__IMAGEPAIRS__IS_DIFFERENT]"
+ style="text-align:center">
+ &ndash;none&ndash;
+ </div>
+ </td>
+
+ <td ng-if="imagePair[constants.KEY__IMAGEPAIRS__ROWSPAN] > 0" rowspan="{{imagePair[constants.KEY__IMAGEPAIRS__ROWSPAN]}}">
+ <br/>
+ <input type="checkbox"
+ name="rowSelect"
+ value="{{imagePair.index}}"
+ ng-checked="isValueInArray(imagePair.index, selectedImagePairs)"
+ ng-click="toggleSomeImagePairs($index, imagePair[constants.KEY__IMAGEPAIRS__ROWSPAN])">
+ </tr>
+ </table> <!-- imagePairs -->
+ </td></tr></table> <!-- table holding results header + imagePairs table -->
+
+ </div><!-- main display area of selected tab -->
+ </div><!-- everything: hide until readyToDisplay -->
+
+</body>
+</html>