diff options
author | 2015-01-16 16:26:32 -0500 | |
---|---|---|
committer | 2015-01-16 16:26:32 -0500 | |
commit | 8b4489b6e696ce4b1abbffa9b2cbd0d3bfdeb387 (patch) | |
tree | 71654dae8a3b62af5907eccae7b93d00e3ec9c10 /gm/rebaseline_server/static | |
parent | 15b125d40122e966bd723d23e82c3224b1da4898 (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/.gitignore | 3 | ||||
-rw-r--r-- | gm/rebaseline_server/static/constants.js | 100 | ||||
-rw-r--r-- | gm/rebaseline_server/static/live-loader.js | 1024 | ||||
-rw-r--r-- | gm/rebaseline_server/static/live-view.html | 446 | ||||
-rw-r--r-- | gm/rebaseline_server/static/loader.js | 1035 | ||||
-rw-r--r-- | gm/rebaseline_server/static/new/bower.json | 22 | ||||
-rw-r--r-- | gm/rebaseline_server/static/new/css/app.css | 71 | ||||
-rw-r--r-- | gm/rebaseline_server/static/new/js/app.js | 1130 | ||||
-rw-r--r-- | gm/rebaseline_server/static/new/new-index.html | 37 | ||||
-rw-r--r-- | gm/rebaseline_server/static/new/partials/index-view.html | 22 | ||||
-rw-r--r-- | gm/rebaseline_server/static/new/partials/rebaseline-view.html | 207 | ||||
-rw-r--r-- | gm/rebaseline_server/static/utils.js | 12 | ||||
-rw-r--r-- | gm/rebaseline_server/static/view.css | 104 | ||||
-rw-r--r-- | gm/rebaseline_server/static/view.html | 417 |
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)"> + {{tab}} ({{numResultsPerTab[tab]}}) + </div> + <div class="tab-spacer"> + + </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"> + –none– + </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"> + –none– + </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"> + –none– + </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"> + –none– + </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">–none–</span> + <span ng-hide="oneCol.url === null"> </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)"> + {{tab}} ({{numResultsPerTab[tab]}}) + </div> + <div class="tab-spacer"> + + </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"> + –none– + </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"> + –none– + </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"> + –none– + </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"> + –none– + </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> |