1 /* 2 * Loader: 3 * Reads GM result reports written out by results.py, and imports 4 * them into $scope.extraColumnHeaders and $scope.imagePairs . 5 */ 6 var Loader = angular.module( 7 'Loader', 8 ['ConstantsModule'] 9 ); 10 11 // This configuration is needed to allow downloads of the diff patch. 12 // See https://github.com/angular/angular.js/issues/3889 13 Loader.config(['$compileProvider', function($compileProvider) { 14 $compileProvider.aHrefSanitizationWhitelist(/^\s*(https?|ftp|file|blob):/); 15 }]); 16 17 Loader.directive( 18 'resultsUpdatedCallbackDirective', 19 ['$timeout', 20 function($timeout) { 21 return function(scope, element, attrs) { 22 if (scope.$last) { 23 $timeout(function() { 24 scope.resultsUpdatedCallback(); 25 }); 26 } 27 }; 28 } 29 ] 30 ); 31 32 // TODO(epoger): Combine ALL of our filtering operations (including 33 // truncation) into this one filter, so that runs most efficiently? 34 // (We would have to make sure truncation still took place after 35 // sorting, though.) 36 Loader.filter( 37 'removeHiddenImagePairs', 38 function(constants) { 39 return function(unfilteredImagePairs, filterableColumnNames, showingColumnValues, 40 viewingTab) { 41 var filteredImagePairs = []; 42 for (var i = 0; i < unfilteredImagePairs.length; i++) { 43 var imagePair = unfilteredImagePairs[i]; 44 var extraColumnValues = imagePair[constants.KEY__IMAGEPAIRS__EXTRACOLUMNS]; 45 var allColumnValuesAreVisible = true; 46 // Loop over all columns, and if any of them contain values not found in 47 // showingColumnValues[columnName], don't include this imagePair. 48 // 49 // We use this same filtering mechanism regardless of whether each column 50 // has USE_FREEFORM_FILTER set or not; if that flag is set, then we will 51 // have already used the freeform text entry block to populate 52 // showingColumnValues[columnName]. 53 for (var j = 0; j < filterableColumnNames.length; j++) { 54 var columnName = filterableColumnNames[j]; 55 var columnValue = extraColumnValues[columnName]; 56 if (!showingColumnValues[columnName][columnValue]) { 57 allColumnValuesAreVisible = false; 58 break; 59 } 60 } 61 if (allColumnValuesAreVisible && (viewingTab == imagePair.tab)) { 62 filteredImagePairs.push(imagePair); 63 } 64 } 65 return filteredImagePairs; 66 }; 67 } 68 ); 69 70 /** 71 * Limit the input imagePairs to some max number, and merge identical rows 72 * (adjacent rows which have the same (imageA, imageB) pair). 73 * 74 * @param unfilteredImagePairs imagePairs to filter 75 * @param maxPairs maximum number of pairs to output, or <0 for no limit 76 * @param mergeIdenticalRows if true, merge identical rows by setting 77 * ROWSPAN>1 on the first merged row, and ROWSPAN=0 for the rest 78 */ 79 Loader.filter( 80 'mergeAndLimit', 81 function(constants) { 82 return function(unfilteredImagePairs, maxPairs, mergeIdenticalRows) { 83 var numPairs = unfilteredImagePairs.length; 84 if ((maxPairs > 0) && (maxPairs < numPairs)) { 85 numPairs = maxPairs; 86 } 87 var filteredImagePairs = []; 88 if (!mergeIdenticalRows || (numPairs == 1)) { 89 // Take a shortcut if we're not merging identical rows. 90 // We still need to set ROWSPAN to 1 for each row, for the HTML viewer. 91 for (var i = numPairs-1; i >= 0; i--) { 92 var imagePair = unfilteredImagePairs[i]; 93 imagePair[constants.KEY__IMAGEPAIRS__ROWSPAN] = 1; 94 filteredImagePairs[i] = imagePair; 95 } 96 } else if (numPairs > 1) { 97 // General case--there are at least 2 rows, so we may need to merge some. 98 // Work from the bottom up, so we can keep a running total of how many 99 // rows should be merged, and set ROWSPAN of the top row accordingly. 100 var imagePair = unfilteredImagePairs[numPairs-1]; 101 var nextRowImageAUrl = imagePair[constants.KEY__IMAGEPAIRS__IMAGE_A_URL]; 102 var nextRowImageBUrl = imagePair[constants.KEY__IMAGEPAIRS__IMAGE_B_URL]; 103 imagePair[constants.KEY__IMAGEPAIRS__ROWSPAN] = 1; 104 filteredImagePairs[numPairs-1] = imagePair; 105 for (var i = numPairs-2; i >= 0; i--) { 106 imagePair = unfilteredImagePairs[i]; 107 var thisRowImageAUrl = imagePair[constants.KEY__IMAGEPAIRS__IMAGE_A_URL]; 108 var thisRowImageBUrl = imagePair[constants.KEY__IMAGEPAIRS__IMAGE_B_URL]; 109 if ((thisRowImageAUrl == nextRowImageAUrl) && 110 (thisRowImageBUrl == nextRowImageBUrl)) { 111 imagePair[constants.KEY__IMAGEPAIRS__ROWSPAN] = 112 filteredImagePairs[i+1][constants.KEY__IMAGEPAIRS__ROWSPAN] + 1; 113 filteredImagePairs[i+1][constants.KEY__IMAGEPAIRS__ROWSPAN] = 0; 114 } else { 115 imagePair[constants.KEY__IMAGEPAIRS__ROWSPAN] = 1; 116 nextRowImageAUrl = thisRowImageAUrl; 117 nextRowImageBUrl = thisRowImageBUrl; 118 } 119 filteredImagePairs[i] = imagePair; 120 } 121 } else { 122 // No results. 123 } 124 return filteredImagePairs; 125 }; 126 } 127 ); 128 129 130 Loader.controller( 131 'Loader.Controller', 132 function($scope, $http, $filter, $location, $log, $timeout, constants) { 133 $scope.readyToDisplay = false; 134 $scope.constants = constants; 135 $scope.windowTitle = "Loading GM Results..."; 136 $scope.setADir = $location.search().setADir; 137 $scope.setASection = $location.search().setASection; 138 $scope.setBDir = $location.search().setBDir; 139 $scope.setBSection = $location.search().setBSection; 140 $scope.loadingMessage = "please wait..."; 141 142 var currSortAsc = true; 143 144 145 /** 146 * On initial page load, load a full dictionary of results. 147 * Once the dictionary is loaded, unhide the page elements so they can 148 * render the data. 149 */ 150 $scope.liveQueryUrl = 151 "/live-results/setADir=" + encodeURIComponent($scope.setADir) + 152 "&setASection=" + encodeURIComponent($scope.setASection) + 153 "&setBDir=" + encodeURIComponent($scope.setBDir) + 154 "&setBSection=" + encodeURIComponent($scope.setBSection); 155 $http.get($scope.liveQueryUrl).success( 156 function(data, status, header, config) { 157 var dataHeader = data[constants.KEY__ROOT__HEADER]; 158 if (dataHeader[constants.KEY__HEADER__SCHEMA_VERSION] != 159 constants.VALUE__HEADER__SCHEMA_VERSION) { 160 $scope.loadingMessage = "ERROR: Got JSON file with schema version " 161 + dataHeader[constants.KEY__HEADER__SCHEMA_VERSION] 162 + " but expected schema version " 163 + constants.VALUE__HEADER__SCHEMA_VERSION; 164 } else if (dataHeader[constants.KEY__HEADER__IS_STILL_LOADING]) { 165 // Apply the server's requested reload delay to local time, 166 // so we will wait the right number of seconds regardless of clock 167 // skew between client and server. 168 var reloadDelayInSeconds = 169 dataHeader[constants.KEY__HEADER__TIME_NEXT_UPDATE_AVAILABLE] - 170 dataHeader[constants.KEY__HEADER__TIME_UPDATED]; 171 var timeNow = new Date().getTime(); 172 var timeToReload = timeNow + reloadDelayInSeconds * 1000; 173 $scope.loadingMessage = 174 "server is still loading results; will retry at " + 175 $scope.localTimeString(timeToReload / 1000); 176 $timeout( 177 function(){location.reload();}, 178 timeToReload - timeNow); 179 } else { 180 $scope.loadingMessage = "processing data, please wait..."; 181 182 $scope.header = dataHeader; 183 $scope.extraColumnHeaders = data[constants.KEY__ROOT__EXTRACOLUMNHEADERS]; 184 $scope.orderedColumnNames = data[constants.KEY__ROOT__EXTRACOLUMNORDER]; 185 $scope.imagePairs = data[constants.KEY__ROOT__IMAGEPAIRS]; 186 $scope.imageSets = data[constants.KEY__ROOT__IMAGESETS]; 187 188 // set the default sort column and make it ascending. 189 $scope.sortColumnSubdict = constants.KEY__IMAGEPAIRS__DIFFERENCES; 190 $scope.sortColumnKey = constants.KEY__DIFFERENCES__PERCEPTUAL_DIFF; 191 currSortAsc = true; 192 193 $scope.showSubmitAdvancedSettings = false; 194 $scope.submitAdvancedSettings = {}; 195 $scope.submitAdvancedSettings[ 196 constants.KEY__EXPECTATIONS__REVIEWED] = true; 197 $scope.submitAdvancedSettings[ 198 constants.KEY__EXPECTATIONS__IGNOREFAILURE] = false; 199 $scope.submitAdvancedSettings['bug'] = ''; 200 201 // Create the list of tabs (lists into which the user can file each 202 // test). This may vary, depending on isEditable. 203 $scope.tabs = [ 204 'Unfiled', 'Hidden' 205 ]; 206 if (dataHeader[constants.KEY__HEADER__IS_EDITABLE]) { 207 $scope.tabs = $scope.tabs.concat( 208 ['Pending Approval']); 209 } 210 $scope.defaultTab = $scope.tabs[0]; 211 $scope.viewingTab = $scope.defaultTab; 212 213 // Track the number of results on each tab. 214 $scope.numResultsPerTab = {}; 215 for (var i = 0; i < $scope.tabs.length; i++) { 216 $scope.numResultsPerTab[$scope.tabs[i]] = 0; 217 } 218 $scope.numResultsPerTab[$scope.defaultTab] = $scope.imagePairs.length; 219 220 // Add index and tab fields to all records. 221 for (var i = 0; i < $scope.imagePairs.length; i++) { 222 $scope.imagePairs[i].index = i; 223 $scope.imagePairs[i].tab = $scope.defaultTab; 224 } 225 226 // Arrays within which the user can toggle individual elements. 227 $scope.selectedImagePairs = []; 228 229 // Set up filters. 230 // 231 // filterableColumnNames is a list of all column names we can filter on. 232 // allColumnValues[columnName] is a list of all known values 233 // for a given column. 234 // showingColumnValues[columnName] is a set indicating which values 235 // in a given column would cause us to show a row, rather than hiding it. 236 // 237 // columnStringMatch[columnName] is a string used as a pattern to generate 238 // showingColumnValues[columnName] for columns we filter using free-form text. 239 // It is ignored for any columns with USE_FREEFORM_FILTER == false. 240 $scope.filterableColumnNames = []; 241 $scope.allColumnValues = {}; 242 $scope.showingColumnValues = {}; 243 $scope.columnStringMatch = {}; 244 245 angular.forEach( 246 Object.keys($scope.extraColumnHeaders), 247 function(columnName) { 248 var columnHeader = $scope.extraColumnHeaders[columnName]; 249 if (columnHeader[constants.KEY__EXTRACOLUMNHEADERS__IS_FILTERABLE]) { 250 $scope.filterableColumnNames.push(columnName); 251 $scope.allColumnValues[columnName] = $scope.columnSliceOf2DArray( 252 columnHeader[constants.KEY__EXTRACOLUMNHEADERS__VALUES_AND_COUNTS], 0); 253 $scope.showingColumnValues[columnName] = {}; 254 $scope.toggleValuesInSet($scope.allColumnValues[columnName], 255 $scope.showingColumnValues[columnName]); 256 $scope.columnStringMatch[columnName] = ""; 257 } 258 } 259 ); 260 261 // TODO(epoger): Special handling for RESULT_TYPE column: 262 // by default, show only KEY__RESULT_TYPE__FAILED results 263 $scope.showingColumnValues[constants.KEY__EXTRACOLUMNS__RESULT_TYPE] = {}; 264 $scope.showingColumnValues[constants.KEY__EXTRACOLUMNS__RESULT_TYPE][ 265 constants.KEY__RESULT_TYPE__FAILED] = true; 266 267 // Set up mapping for URL parameters. 268 // parameter name -> copier object to load/save parameter value 269 $scope.queryParameters.map = { 270 'setADir': $scope.queryParameters.copiers.simple, 271 'setASection': $scope.queryParameters.copiers.simple, 272 'setBDir': $scope.queryParameters.copiers.simple, 273 'setBSection': $scope.queryParameters.copiers.simple, 274 'displayLimitPending': $scope.queryParameters.copiers.simple, 275 'showThumbnailsPending': $scope.queryParameters.copiers.simple, 276 'mergeIdenticalRowsPending': $scope.queryParameters.copiers.simple, 277 'imageSizePending': $scope.queryParameters.copiers.simple, 278 'sortColumnSubdict': $scope.queryParameters.copiers.simple, 279 'sortColumnKey': $scope.queryParameters.copiers.simple, 280 }; 281 // Some parameters are handled differently based on whether they USE_FREEFORM_FILTER. 282 angular.forEach( 283 $scope.filterableColumnNames, 284 function(columnName) { 285 if ($scope.extraColumnHeaders[columnName] 286 [constants.KEY__EXTRACOLUMNHEADERS__USE_FREEFORM_FILTER]) { 287 $scope.queryParameters.map[columnName] = 288 $scope.queryParameters.copiers.columnStringMatch; 289 } else { 290 $scope.queryParameters.map[columnName] = 291 $scope.queryParameters.copiers.showingColumnValuesSet; 292 } 293 } 294 ); 295 296 // If any defaults were overridden in the URL, get them now. 297 $scope.queryParameters.load(); 298 299 // Any image URLs which are relative should be relative to the JSON 300 // file's source directory; absolute URLs should be left alone. 301 var baseUrlKey = constants.KEY__IMAGESETS__FIELD__BASE_URL; 302 angular.forEach( 303 $scope.imageSets, 304 function(imageSet) { 305 var baseUrl = imageSet[baseUrlKey]; 306 if ((baseUrl.substring(0, 1) != '/') && 307 (baseUrl.indexOf('://') == -1)) { 308 imageSet[baseUrlKey] = '/' + baseUrl; 309 } 310 } 311 ); 312 313 $scope.readyToDisplay = true; 314 $scope.updateResults(); 315 $scope.loadingMessage = ""; 316 $scope.windowTitle = "Current GM Results"; 317 318 $timeout( function() { 319 make_results_header_sticky(); 320 }); 321 } 322 } 323 ).error( 324 function(data, status, header, config) { 325 $scope.loadingMessage = "FAILED to load."; 326 $scope.windowTitle = "Failed to Load GM Results"; 327 } 328 ); 329 330 331 // 332 // Select/Clear/Toggle all tests. 333 // 334 335 /** 336 * Select all currently showing tests. 337 */ 338 $scope.selectAllImagePairs = function() { 339 var numImagePairsShowing = $scope.limitedImagePairs.length; 340 for (var i = 0; i < numImagePairsShowing; i++) { 341 var index = $scope.limitedImagePairs[i].index; 342 if (!$scope.isValueInArray(index, $scope.selectedImagePairs)) { 343 $scope.toggleValueInArray(index, $scope.selectedImagePairs); 344 } 345 } 346 } 347 348 /** 349 * Deselect all currently showing tests. 350 */ 351 $scope.clearAllImagePairs = function() { 352 var numImagePairsShowing = $scope.limitedImagePairs.length; 353 for (var i = 0; i < numImagePairsShowing; i++) { 354 var index = $scope.limitedImagePairs[i].index; 355 if ($scope.isValueInArray(index, $scope.selectedImagePairs)) { 356 $scope.toggleValueInArray(index, $scope.selectedImagePairs); 357 } 358 } 359 } 360 361 /** 362 * Toggle selection of all currently showing tests. 363 */ 364 $scope.toggleAllImagePairs = function() { 365 var numImagePairsShowing = $scope.limitedImagePairs.length; 366 for (var i = 0; i < numImagePairsShowing; i++) { 367 var index = $scope.limitedImagePairs[i].index; 368 $scope.toggleValueInArray(index, $scope.selectedImagePairs); 369 } 370 } 371 372 /** 373 * Toggle selection state of a subset of the currently showing tests. 374 * 375 * @param startIndex index within $scope.limitedImagePairs of the first 376 * test to toggle selection state of 377 * @param num number of tests (in a contiguous block) to toggle 378 */ 379 $scope.toggleSomeImagePairs = function(startIndex, num) { 380 var numImagePairsShowing = $scope.limitedImagePairs.length; 381 for (var i = startIndex; i < startIndex + num; i++) { 382 var index = $scope.limitedImagePairs[i].index; 383 $scope.toggleValueInArray(index, $scope.selectedImagePairs); 384 } 385 } 386 387 388 // 389 // Tab operations. 390 // 391 392 /** 393 * Change the selected tab. 394 * 395 * @param tab (string): name of the tab to select 396 */ 397 $scope.setViewingTab = function(tab) { 398 $scope.viewingTab = tab; 399 $scope.updateResults(); 400 } 401 402 /** 403 * Move the imagePairs in $scope.selectedImagePairs to a different tab, 404 * and then clear $scope.selectedImagePairs. 405 * 406 * @param newTab (string): name of the tab to move the tests to 407 */ 408 $scope.moveSelectedImagePairsToTab = function(newTab) { 409 $scope.moveImagePairsToTab($scope.selectedImagePairs, newTab); 410 $scope.selectedImagePairs = []; 411 $scope.updateResults(); 412 } 413 414 /** 415 * Move a subset of $scope.imagePairs to a different tab. 416 * 417 * @param imagePairIndices (array of ints): indices into $scope.imagePairs 418 * indicating which test results to move 419 * @param newTab (string): name of the tab to move the tests to 420 */ 421 $scope.moveImagePairsToTab = function(imagePairIndices, newTab) { 422 var imagePairIndex; 423 var numImagePairs = imagePairIndices.length; 424 for (var i = 0; i < numImagePairs; i++) { 425 imagePairIndex = imagePairIndices[i]; 426 $scope.numResultsPerTab[$scope.imagePairs[imagePairIndex].tab]--; 427 $scope.imagePairs[imagePairIndex].tab = newTab; 428 } 429 $scope.numResultsPerTab[newTab] += numImagePairs; 430 } 431 432 433 // 434 // $scope.queryParameters: 435 // Transfer parameter values between $scope and the URL query string. 436 // 437 $scope.queryParameters = {}; 438 439 // load and save functions for parameters of each type 440 // (load a parameter value into $scope from nameValuePairs, 441 // save a parameter value from $scope into nameValuePairs) 442 $scope.queryParameters.copiers = { 443 'simple': { 444 'load': function(nameValuePairs, name) { 445 var value = nameValuePairs[name]; 446 if (value) { 447 $scope[name] = value; 448 } 449 }, 450 'save': function(nameValuePairs, name) { 451 nameValuePairs[name] = $scope[name]; 452 } 453 }, 454 455 'columnStringMatch': { 456 'load': function(nameValuePairs, name) { 457 var value = nameValuePairs[name]; 458 if (value) { 459 $scope.columnStringMatch[name] = value; 460 } 461 }, 462 'save': function(nameValuePairs, name) { 463 nameValuePairs[name] = $scope.columnStringMatch[name]; 464 } 465 }, 466 467 'showingColumnValuesSet': { 468 'load': function(nameValuePairs, name) { 469 var value = nameValuePairs[name]; 470 if (value) { 471 var valueArray = value.split(','); 472 $scope.showingColumnValues[name] = {}; 473 $scope.toggleValuesInSet(valueArray, $scope.showingColumnValues[name]); 474 } 475 }, 476 'save': function(nameValuePairs, name) { 477 nameValuePairs[name] = Object.keys($scope.showingColumnValues[name]).join(','); 478 } 479 }, 480 481 }; 482 483 // Loads all parameters into $scope from the URL query string; 484 // any which are not found within the URL will keep their current value. 485 $scope.queryParameters.load = function() { 486 var nameValuePairs = $location.search(); 487 488 // If urlSchemaVersion is not specified, we assume the current version. 489 var urlSchemaVersion = constants.URL_VALUE__SCHEMA_VERSION__CURRENT; 490 if (constants.URL_KEY__SCHEMA_VERSION in nameValuePairs) { 491 urlSchemaVersion = nameValuePairs[constants.URL_KEY__SCHEMA_VERSION]; 492 } else if ('hiddenResultTypes' in nameValuePairs) { 493 // The combination of: 494 // - absence of an explicit urlSchemaVersion, and 495 // - presence of the old 'hiddenResultTypes' field 496 // tells us that the URL is from the original urlSchemaVersion. 497 // See https://codereview.chromium.org/367173002/ 498 urlSchemaVersion = 0; 499 } 500 $scope.urlSchemaVersionLoaded = urlSchemaVersion; 501 502 if (urlSchemaVersion != constants.URL_VALUE__SCHEMA_VERSION__CURRENT) { 503 nameValuePairs = $scope.upconvertUrlNameValuePairs(nameValuePairs, urlSchemaVersion); 504 } 505 angular.forEach($scope.queryParameters.map, 506 function(copier, paramName) { 507 copier.load(nameValuePairs, paramName); 508 } 509 ); 510 }; 511 512 // Saves all parameters from $scope into the URL query string. 513 $scope.queryParameters.save = function() { 514 var nameValuePairs = {}; 515 nameValuePairs[constants.URL_KEY__SCHEMA_VERSION] = constants.URL_VALUE__SCHEMA_VERSION__CURRENT; 516 angular.forEach($scope.queryParameters.map, 517 function(copier, paramName) { 518 copier.save(nameValuePairs, paramName); 519 } 520 ); 521 $location.search(nameValuePairs); 522 }; 523 524 /** 525 * Converts URL name/value pairs that were stored by a previous urlSchemaVersion 526 * to the currently needed format. 527 * 528 * @param oldNValuePairs name/value pairs found in the loaded URL 529 * @param oldUrlSchemaVersion which version of the schema was used to generate that URL 530 * 531 * @returns nameValuePairs as needed by the current URL parser 532 */ 533 $scope.upconvertUrlNameValuePairs = function(oldNameValuePairs, oldUrlSchemaVersion) { 534 var newNameValuePairs = {}; 535 angular.forEach(oldNameValuePairs, 536 function(value, name) { 537 if (oldUrlSchemaVersion < 1) { 538 if ('hiddenConfigs' == name) { 539 name = 'config'; 540 var valueSet = {}; 541 $scope.toggleValuesInSet(value.split(','), valueSet); 542 $scope.toggleValuesInSet( 543 $scope.allColumnValues[constants.KEY__EXTRACOLUMNS__CONFIG], 544 valueSet); 545 value = Object.keys(valueSet).join(','); 546 } else if ('hiddenResultTypes' == name) { 547 name = 'resultType'; 548 var valueSet = {}; 549 $scope.toggleValuesInSet(value.split(','), valueSet); 550 $scope.toggleValuesInSet( 551 $scope.allColumnValues[constants.KEY__EXTRACOLUMNS__RESULT_TYPE], 552 valueSet); 553 value = Object.keys(valueSet).join(','); 554 } 555 } 556 557 newNameValuePairs[name] = value; 558 } 559 ); 560 return newNameValuePairs; 561 } 562 563 564 // 565 // updateResults() and friends. 566 // 567 568 /** 569 * Set $scope.areUpdatesPending (to enable/disable the Update Results 570 * button). 571 * 572 * TODO(epoger): We could reduce the amount of code by just setting the 573 * variable directly (from, e.g., a button's ng-click handler). But when 574 * I tried that, the HTML elements depending on the variable did not get 575 * updated. 576 * It turns out that this is due to variable scoping within an ng-repeat 577 * element; see http://stackoverflow.com/questions/15388344/behavior-of-assignment-expression-invoked-by-ng-click-within-ng-repeat 578 * 579 * @param val boolean value to set $scope.areUpdatesPending to 580 */ 581 $scope.setUpdatesPending = function(val) { 582 $scope.areUpdatesPending = val; 583 } 584 585 /** 586 * Update the displayed results, based on filters/settings, 587 * and call $scope.queryParameters.save() so that the new filter results 588 * can be bookmarked. 589 */ 590 $scope.updateResults = function() { 591 $scope.renderStartTime = window.performance.now(); 592 $log.debug("renderStartTime: " + $scope.renderStartTime); 593 $scope.displayLimit = $scope.displayLimitPending; 594 $scope.mergeIdenticalRows = $scope.mergeIdenticalRowsPending; 595 596 // For each USE_FREEFORM_FILTER column, populate showingColumnValues. 597 // This is more efficient than applying the freeform filter within the 598 // tight loop in removeHiddenImagePairs. 599 angular.forEach( 600 $scope.filterableColumnNames, 601 function(columnName) { 602 var columnHeader = $scope.extraColumnHeaders[columnName]; 603 if (columnHeader[constants.KEY__EXTRACOLUMNHEADERS__USE_FREEFORM_FILTER]) { 604 var columnStringMatch = $scope.columnStringMatch[columnName]; 605 var showingColumnValues = {}; 606 angular.forEach( 607 $scope.allColumnValues[columnName], 608 function(columnValue) { 609 if (-1 != columnValue.indexOf(columnStringMatch)) { 610 showingColumnValues[columnValue] = true; 611 } 612 } 613 ); 614 $scope.showingColumnValues[columnName] = showingColumnValues; 615 } 616 } 617 ); 618 619 // TODO(epoger): Every time we apply a filter, AngularJS creates 620 // another copy of the array. Is there a way we can filter out 621 // the imagePairs as they are displayed, rather than storing multiple 622 // array copies? (For better performance.) 623 624 if ($scope.viewingTab == $scope.defaultTab) { 625 var doReverse = !currSortAsc; 626 627 $scope.filteredImagePairs = 628 $filter("orderBy")( 629 $filter("removeHiddenImagePairs")( 630 $scope.imagePairs, 631 $scope.filterableColumnNames, 632 $scope.showingColumnValues, 633 $scope.viewingTab 634 ), 635 [$scope.getSortColumnValue, $scope.getSecondOrderSortValue], 636 doReverse); 637 $scope.limitedImagePairs = $filter("mergeAndLimit")( 638 $scope.filteredImagePairs, $scope.displayLimit, $scope.mergeIdenticalRows); 639 } else { 640 $scope.filteredImagePairs = 641 $filter("orderBy")( 642 $filter("filter")( 643 $scope.imagePairs, 644 {tab: $scope.viewingTab}, 645 true 646 ), 647 [$scope.getSortColumnValue, $scope.getSecondOrderSortValue]); 648 $scope.limitedImagePairs = $filter("mergeAndLimit")( 649 $scope.filteredImagePairs, -1, $scope.mergeIdenticalRows); 650 } 651 $scope.showThumbnails = $scope.showThumbnailsPending; 652 $scope.imageSize = $scope.imageSizePending; 653 $scope.setUpdatesPending(false); 654 $scope.queryParameters.save(); 655 } 656 657 /** 658 * This function is called when the results have been completely rendered 659 * after updateResults(). 660 */ 661 $scope.resultsUpdatedCallback = function() { 662 $scope.renderEndTime = window.performance.now(); 663 $log.debug("renderEndTime: " + $scope.renderEndTime); 664 } 665 666 /** 667 * Re-sort the displayed results. 668 * 669 * @param subdict (string): which KEY__IMAGEPAIRS__* subdictionary 670 * the sort column key is within, or 'none' if the sort column 671 * key is one of KEY__IMAGEPAIRS__* 672 * @param key (string): sort by value associated with this key in subdict 673 */ 674 $scope.sortResultsBy = function(subdict, key) { 675 // if we are already sorting by this column then toggle between asc/desc 676 if ((subdict === $scope.sortColumnSubdict) && ($scope.sortColumnKey === key)) { 677 currSortAsc = !currSortAsc; 678 } else { 679 $scope.sortColumnSubdict = subdict; 680 $scope.sortColumnKey = key; 681 currSortAsc = true; 682 } 683 $scope.updateResults(); 684 } 685 686 /** 687 * Returns ASC or DESC (from constants) if currently the data 688 * is sorted by the provided column. 689 * 690 * @param colName: name of the column for which we need to get the class. 691 */ 692 693 $scope.sortedByColumnsCls = function (colName) { 694 if ($scope.sortColumnKey !== colName) { 695 return ''; 696 } 697 698 var result = (currSortAsc) ? constants.ASC : constants.DESC; 699 console.log("sort class:", result); 700 return result; 701 }; 702 703 /** 704 * For a particular ImagePair, return the value of the column we are 705 * sorting on (according to $scope.sortColumnSubdict and 706 * $scope.sortColumnKey). 707 * 708 * @param imagePair: imagePair to get a column value out of. 709 */ 710 $scope.getSortColumnValue = function(imagePair) { 711 if ($scope.sortColumnSubdict in imagePair) { 712 return imagePair[$scope.sortColumnSubdict][$scope.sortColumnKey]; 713 } else if ($scope.sortColumnKey in imagePair) { 714 return imagePair[$scope.sortColumnKey]; 715 } else { 716 return undefined; 717 } 718 }; 719 720 /** 721 * For a particular ImagePair, return the value we use for the 722 * second-order sort (tiebreaker when multiple rows have 723 * the same getSortColumnValue()). 724 * 725 * We join the imageA and imageB urls for this value, so that we merge 726 * adjacent rows as much as possible. 727 * 728 * @param imagePair: imagePair to get a column value out of. 729 */ 730 $scope.getSecondOrderSortValue = function(imagePair) { 731 return imagePair[constants.KEY__IMAGEPAIRS__IMAGE_A_URL] + "-vs-" + 732 imagePair[constants.KEY__IMAGEPAIRS__IMAGE_B_URL]; 733 }; 734 735 /** 736 * Set $scope.columnStringMatch[name] = value, and update results. 737 * 738 * @param name 739 * @param value 740 */ 741 $scope.setColumnStringMatch = function(name, value) { 742 $scope.columnStringMatch[name] = value; 743 $scope.updateResults(); 744 }; 745 746 /** 747 * Update $scope.showingColumnValues[columnName] and $scope.columnStringMatch[columnName] 748 * so that ONLY entries with this columnValue are showing, and update the visible results. 749 * (We update both of those, so we cover both freeform and checkbox filtered columns.) 750 * 751 * @param columnName 752 * @param columnValue 753 */ 754 $scope.showOnlyColumnValue = function(columnName, columnValue) { 755 $scope.columnStringMatch[columnName] = columnValue; 756 $scope.showingColumnValues[columnName] = {}; 757 $scope.toggleValueInSet(columnValue, $scope.showingColumnValues[columnName]); 758 $scope.updateResults(); 759 }; 760 761 /** 762 * Update $scope.showingColumnValues[columnName] and $scope.columnStringMatch[columnName] 763 * so that ALL entries are showing, and update the visible results. 764 * (We update both of those, so we cover both freeform and checkbox filtered columns.) 765 * 766 * @param columnName 767 */ 768 $scope.showAllColumnValues = function(columnName) { 769 $scope.columnStringMatch[columnName] = ""; 770 $scope.showingColumnValues[columnName] = {}; 771 $scope.toggleValuesInSet($scope.allColumnValues[columnName], 772 $scope.showingColumnValues[columnName]); 773 $scope.updateResults(); 774 }; 775 776 777 // 778 // Operations for sending info back to the server. 779 // 780 781 /** 782 * Tell the server that the actual results of these particular tests 783 * are acceptable. 784 * 785 * This assumes that the original expectations are in imageSetA, and the 786 * new expectations are in imageSetB. That's fine, because the server 787 * mandates that anyway (it will swap the sets if the user requests them 788 * in the opposite order). 789 * 790 * @param imagePairsSubset an array of test results, most likely a subset of 791 * $scope.imagePairs (perhaps with some modifications) 792 */ 793 $scope.submitApprovals = function(imagePairsSubset) { 794 $scope.submitPending = true; 795 $scope.diffResults = ""; 796 797 // Convert bug text field to null or 1-item array. 798 var bugs = null; 799 var bugNumber = parseInt($scope.submitAdvancedSettings['bug']); 800 if (!isNaN(bugNumber)) { 801 bugs = [bugNumber]; 802 } 803 804 var updatedExpectations = []; 805 for (var i = 0; i < imagePairsSubset.length; i++) { 806 var imagePair = imagePairsSubset[i]; 807 var updatedExpectation = {}; 808 updatedExpectation[constants.KEY__IMAGEPAIRS__EXPECTATIONS] = 809 imagePair[constants.KEY__IMAGEPAIRS__EXPECTATIONS]; 810 updatedExpectation[constants.KEY__IMAGEPAIRS__EXTRACOLUMNS] = 811 imagePair[constants.KEY__IMAGEPAIRS__EXTRACOLUMNS]; 812 updatedExpectation[constants.KEY__IMAGEPAIRS__SOURCE_JSON_FILE] = 813 imagePair[constants.KEY__IMAGEPAIRS__SOURCE_JSON_FILE]; 814 // IMAGE_B_URL contains the actual image (which is now the expectation) 815 updatedExpectation[constants.KEY__IMAGEPAIRS__IMAGE_B_URL] = 816 imagePair[constants.KEY__IMAGEPAIRS__IMAGE_B_URL]; 817 818 // Advanced settings... 819 if (null == updatedExpectation[constants.KEY__IMAGEPAIRS__EXPECTATIONS]) { 820 updatedExpectation[constants.KEY__IMAGEPAIRS__EXPECTATIONS] = {}; 821 } 822 updatedExpectation[constants.KEY__IMAGEPAIRS__EXPECTATIONS] 823 [constants.KEY__EXPECTATIONS__REVIEWED] = 824 $scope.submitAdvancedSettings[ 825 constants.KEY__EXPECTATIONS__REVIEWED]; 826 if (true == $scope.submitAdvancedSettings[ 827 constants.KEY__EXPECTATIONS__IGNOREFAILURE]) { 828 // if it's false, don't send it at all (just keep the default) 829 updatedExpectation[constants.KEY__IMAGEPAIRS__EXPECTATIONS] 830 [constants.KEY__EXPECTATIONS__IGNOREFAILURE] = true; 831 } 832 updatedExpectation[constants.KEY__IMAGEPAIRS__EXPECTATIONS] 833 [constants.KEY__EXPECTATIONS__BUGS] = bugs; 834 835 updatedExpectations.push(updatedExpectation); 836 } 837 var modificationData = {}; 838 modificationData[constants.KEY__LIVE_EDITS__MODIFICATIONS] = 839 updatedExpectations; 840 modificationData[constants.KEY__LIVE_EDITS__SET_A_DESCRIPTIONS] = 841 $scope.header[constants.KEY__HEADER__SET_A_DESCRIPTIONS]; 842 modificationData[constants.KEY__LIVE_EDITS__SET_B_DESCRIPTIONS] = 843 $scope.header[constants.KEY__HEADER__SET_B_DESCRIPTIONS]; 844 $http({ 845 method: "POST", 846 url: "/live-edits", 847 data: modificationData 848 }).success(function(data, status, headers, config) { 849 $scope.diffResults = data; 850 var blob = new Blob([$scope.diffResults], {type: 'text/plain'}); 851 $scope.diffResultsBlobUrl = window.URL.createObjectURL(blob); 852 $scope.submitPending = false; 853 }).error(function(data, status, headers, config) { 854 alert("There was an error submitting your baselines.\n\n" + 855 "Please see server-side log for details."); 856 $scope.submitPending = false; 857 }); 858 }; 859 860 861 // 862 // Operations we use to mimic Set semantics, in such a way that 863 // checking for presence within the Set is as fast as possible. 864 // But getting a list of all values within the Set is not necessarily 865 // possible. 866 // TODO(epoger): move into a separate .js file? 867 // 868 869 /** 870 * Returns the number of values present within set "set". 871 * 872 * @param set an Object which we use to mimic set semantics 873 */ 874 $scope.setSize = function(set) { 875 return Object.keys(set).length; 876 }; 877 878 /** 879 * Returns true if value "value" is present within set "set". 880 * 881 * @param value a value of any type 882 * @param set an Object which we use to mimic set semantics 883 * (this should make isValueInSet faster than if we used an Array) 884 */ 885 $scope.isValueInSet = function(value, set) { 886 return (true == set[value]); 887 }; 888 889 /** 890 * If value "value" is already in set "set", remove it; otherwise, add it. 891 * 892 * @param value a value of any type 893 * @param set an Object which we use to mimic set semantics 894 */ 895 $scope.toggleValueInSet = function(value, set) { 896 if (true == set[value]) { 897 delete set[value]; 898 } else { 899 set[value] = true; 900 } 901 }; 902 903 /** 904 * For each value in valueArray, call toggleValueInSet(value, set). 905 * 906 * @param valueArray 907 * @param set 908 */ 909 $scope.toggleValuesInSet = function(valueArray, set) { 910 var arrayLength = valueArray.length; 911 for (var i = 0; i < arrayLength; i++) { 912 $scope.toggleValueInSet(valueArray[i], set); 913 } 914 }; 915 916 917 // 918 // Array operations; similar to our Set operations, but operate on a 919 // Javascript Array so we *can* easily get a list of all values in the Set. 920 // TODO(epoger): move into a separate .js file? 921 // 922 923 /** 924 * Returns true if value "value" is present within array "array". 925 * 926 * @param value a value of any type 927 * @param array a Javascript Array 928 */ 929 $scope.isValueInArray = function(value, array) { 930 return (-1 != array.indexOf(value)); 931 }; 932 933 /** 934 * If value "value" is already in array "array", remove it; otherwise, 935 * add it. 936 * 937 * @param value a value of any type 938 * @param array a Javascript Array 939 */ 940 $scope.toggleValueInArray = function(value, array) { 941 var i = array.indexOf(value); 942 if (-1 == i) { 943 array.push(value); 944 } else { 945 array.splice(i, 1); 946 } 947 }; 948 949 950 // 951 // Miscellaneous utility functions. 952 // TODO(epoger): move into a separate .js file? 953 // 954 955 /** 956 * Returns a single "column slice" of a 2D array. 957 * 958 * For example, if array is: 959 * [[A0, A1], 960 * [B0, B1], 961 * [C0, C1]] 962 * and index is 0, this this will return: 963 * [A0, B0, C0] 964 * 965 * @param array a Javascript Array 966 * @param column (numeric): index within each row array 967 */ 968 $scope.columnSliceOf2DArray = function(array, column) { 969 var slice = []; 970 var numRows = array.length; 971 for (var row = 0; row < numRows; row++) { 972 slice.push(array[row][column]); 973 } 974 return slice; 975 }; 976 977 /** 978 * Returns a human-readable (in local time zone) time string for a 979 * particular moment in time. 980 * 981 * @param secondsPastEpoch (numeric): seconds past epoch in UTC 982 */ 983 $scope.localTimeString = function(secondsPastEpoch) { 984 var d = new Date(secondsPastEpoch * 1000); 985 return d.toString(); 986 }; 987 988 /** 989 * Returns a hex color string (such as "#aabbcc") for the given RGB values. 990 * 991 * @param r (numeric): red channel value, 0-255 992 * @param g (numeric): green channel value, 0-255 993 * @param b (numeric): blue channel value, 0-255 994 */ 995 $scope.hexColorString = function(r, g, b) { 996 var rString = r.toString(16); 997 if (r < 16) { 998 rString = "0" + rString; 999 } 1000 var gString = g.toString(16); 1001 if (g < 16) { 1002 gString = "0" + gString; 1003 } 1004 var bString = b.toString(16); 1005 if (b < 16) { 1006 bString = "0" + bString; 1007 } 1008 return '#' + rString + gString + bString; 1009 }; 1010 1011 /** 1012 * Returns a hex color string (such as "#aabbcc") for the given brightness. 1013 * 1014 * @param brightnessString (string): 0-255, 0 is completely black 1015 * 1016 * TODO(epoger): It might be nice to tint the color when it's not completely 1017 * black or completely white. 1018 */ 1019 $scope.brightnessStringToHexColor = function(brightnessString) { 1020 var v = parseInt(brightnessString); 1021 return $scope.hexColorString(v, v, v); 1022 }; 1023 } 1024 ); 1025