├── .gitignore ├── Preprocessing-scripts ├── README.md └── webapp ├── .meteor ├── .finished-upgraders ├── .gitignore ├── .id ├── packages ├── platforms ├── release └── versions ├── client ├── about.html ├── admin.html ├── admin.js ├── expired.html ├── home.html ├── home.js ├── main.css ├── main.html ├── main.js ├── people.html ├── routes.js ├── stats.html └── stats.js ├── lib └── dataCollections.js ├── package.json ├── public ├── arch.png └── esteelauder.gif └── server ├── admin.js └── main.js /.gitignore: -------------------------------------------------------------------------------- 1 | webapp/node_modules 2 | webapp/settings.json 3 | -------------------------------------------------------------------------------- /Preprocessing-scripts: -------------------------------------------------------------------------------- 1 | # Preprocessing scripts (Run on ubuntu 12.04) 2 | 3 | # Crop a gif 4 | convert input.gif -coalesce -repage 0x0 -crop 520x360+0+30 +repage output.gif 5 | 6 | # Convert loop gif to noloop 7 | gifsicle input.gif --no-loopcount --optimize 3 > output.gif 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SpeedPerceptionApp 2 | 3 | ******************** 4 | Overview 5 | ******************** 6 | We describe here the steps involved in creating a UI that allows people to compare webpage loading process between two webpages, and get their input on which one they think loads faster. 7 | 8 | Clearly, no one likes slow webpages. We want to create a free open-source benchmark dataset to advance the systematic study of how human beings perceive webpage loading process, and the above-fold rendering in particular. The web performance field needs a systematic way to compare algorithms and metrics on a standardized dataset of webpage loading videos. Our belief is that such a benchmark would provide a quantitative basis to compare different algorithms and spur computer scientists to make progress on helping quantify perceived webpage performance. 9 | 10 | ******************** 11 | Experimental Design 12 | ******************** 13 | 14 | -- Data Collection: 15 | 16 | 600+ URLs from IR500 and Alexa-1000 list were tested on WPT (WebPageTest) 17 | Videos were generated from WPT filmstip 18 | HARs were collected along with each video 19 | Fixed browser (on Desktop and Mobile Device) and connection type (Cable, Chrome) 20 | 21 | -- Video Pairs Selection: 22 | 23 | In order to compare between pairs, we need to pay attention on Visual Complete because both SI and PSI’s implementation are sensitive to the time frame of a video, which we only select video pairs within 5% of visual complete difference.. 24 | 25 | Difference is calculated as: 26 | diff(a1, a2) = (a1 - a2) / [(a1 + a2)/2] 27 | 28 | Within 5% difference, we subgroup them based on 4 conditions of SI difference: 29 | 1 <= si_diff < 10 , 30 | si_diff >= 10 , 31 | -10 < si_diff <=- 1 , 32 | si_diff <=- 10 ; 33 | WIthin each SI difference condition, we subgroup each of them into 4 conditions of PSI difference: 34 | psi_diff >= 10 , 35 | 1 <= psi_diff < 10 , 36 | -10 < psi_diff <= -1 , 37 | psi_diff <= -10 ; 38 | 39 | In total, we have 4 * 4 = 16 conditions for pair selection. 40 | 41 | ******************** 42 | Code (WebApp) 43 | ************** 44 | 45 | Webapp were developed under Meteor JS framework. 46 | 47 | To install meteor: 48 | 49 | > curl https://install.meteor.com | sh 50 | 51 | Checkout and go to webapp/ 52 | 53 | > meteor run --settings settings.json 54 | 55 | App live on localhost:3000 56 | 57 | -------------------------------------------------------------------------------- /webapp/.meteor/.finished-upgraders: -------------------------------------------------------------------------------- 1 | # This file contains information which helps Meteor properly upgrade your 2 | # app when you run 'meteor update'. You should check it into version control 3 | # with your project. 4 | 5 | notices-for-0.9.0 6 | notices-for-0.9.1 7 | 0.9.4-platform-file 8 | notices-for-facebook-graph-api-2 9 | 1.2.0-standard-minifiers-package 10 | 1.2.0-meteor-platform-split 11 | 1.2.0-cordova-changes 12 | 1.2.0-breaking-changes 13 | 1.3.0-split-minifiers-package 14 | -------------------------------------------------------------------------------- /webapp/.meteor/.gitignore: -------------------------------------------------------------------------------- 1 | local 2 | -------------------------------------------------------------------------------- /webapp/.meteor/.id: -------------------------------------------------------------------------------- 1 | # This file contains a token that is unique to your project. 2 | # Check it into your repository along with the rest of this directory. 3 | # It can be used for purposes such as: 4 | # - ensuring you don't accidentally deploy one app on top of another 5 | # - providing package authors with aggregated statistics 6 | 7 | 1kku7qwq01qkv1c4ilyj 8 | -------------------------------------------------------------------------------- /webapp/.meteor/packages: -------------------------------------------------------------------------------- 1 | # Meteor packages used by this project, one per line. 2 | # Check this file (and the other files in this directory) into your repository. 3 | # 4 | # 'meteor add' and 'meteor remove' will edit this file for you, 5 | # but you can also edit it by hand. 6 | 7 | meteor-base # Packages every Meteor app needs to have 8 | mobile-experience # Packages for a great mobile UX 9 | mongo # The database Meteor supports right now 10 | blaze-html-templates # Compile .html files into Meteor Blaze views 11 | reactive-var # Reactive variable for tracker 12 | jquery # Helpful client-side library 13 | tracker # Meteor's client-side reactive programming library 14 | 15 | standard-minifier-css # CSS minifier run for production mode 16 | standard-minifier-js # JS minifier run for production mode 17 | es5-shim # ECMAScript 5 compatibility for older browsers. 18 | ecmascript # Enable ECMAScript2015+ syntax in app code 19 | 20 | twbs:bootstrap 21 | cfs:standard-packages 22 | cfs:gridfs 23 | momentjs:moment 24 | accounts-ui 25 | underscore 26 | session 27 | random 28 | iron:router 29 | mystor:device-detection 30 | fortawesome:fontawesome 31 | pcel:loading 32 | -------------------------------------------------------------------------------- /webapp/.meteor/platforms: -------------------------------------------------------------------------------- 1 | server 2 | browser 3 | -------------------------------------------------------------------------------- /webapp/.meteor/release: -------------------------------------------------------------------------------- 1 | METEOR@1.3.2.4 2 | -------------------------------------------------------------------------------- /webapp/.meteor/versions: -------------------------------------------------------------------------------- 1 | accounts-base@1.2.7 2 | accounts-ui@1.1.9 3 | accounts-ui-unstyled@1.1.12 4 | allow-deny@1.0.4 5 | autoupdate@1.2.9 6 | babel-compiler@6.6.4 7 | babel-runtime@0.1.8 8 | base64@1.0.8 9 | binary-heap@1.0.8 10 | blaze@2.1.7 11 | blaze-html-templates@1.0.4 12 | blaze-tools@1.0.8 13 | boilerplate-generator@1.0.8 14 | caching-compiler@1.0.4 15 | caching-html-compiler@1.0.6 16 | callback-hook@1.0.8 17 | cfs:access-point@0.1.49 18 | cfs:base-package@0.0.30 19 | cfs:collection@0.5.5 20 | cfs:collection-filters@0.2.4 21 | cfs:data-man@0.0.6 22 | cfs:file@0.1.17 23 | cfs:gridfs@0.0.33 24 | cfs:http-methods@0.0.32 25 | cfs:http-publish@0.0.13 26 | cfs:power-queue@0.9.11 27 | cfs:reactive-list@0.0.9 28 | cfs:reactive-property@0.0.4 29 | cfs:standard-packages@0.5.9 30 | cfs:storage-adapter@0.2.3 31 | cfs:tempstore@0.1.5 32 | cfs:upload-http@0.0.20 33 | cfs:worker@0.1.4 34 | check@1.2.1 35 | ddp@1.2.5 36 | ddp-client@1.2.7 37 | ddp-common@1.2.5 38 | ddp-rate-limiter@1.0.4 39 | ddp-server@1.2.6 40 | deps@1.0.12 41 | diff-sequence@1.0.5 42 | ecmascript@0.4.3 43 | ecmascript-runtime@0.2.10 44 | ejson@1.0.11 45 | es5-shim@4.5.10 46 | fastclick@1.0.11 47 | fortawesome:fontawesome@4.5.0 48 | geojson-utils@1.0.8 49 | hot-code-push@1.0.4 50 | html-tools@1.0.9 51 | htmljs@1.0.9 52 | http@1.1.5 53 | id-map@1.0.7 54 | iron:controller@1.0.12 55 | iron:core@1.0.11 56 | iron:dynamic-template@1.0.12 57 | iron:layout@1.0.12 58 | iron:location@1.0.11 59 | iron:middleware-stack@1.1.0 60 | iron:router@1.0.13 61 | iron:url@1.0.11 62 | jquery@1.11.8 63 | launch-screen@1.0.11 64 | less@2.6.0 65 | livedata@1.0.18 66 | localstorage@1.0.9 67 | logging@1.0.12 68 | meteor@1.1.14 69 | meteor-base@1.0.4 70 | minifier-css@1.1.11 71 | minifier-js@1.1.11 72 | minimongo@1.0.16 73 | mobile-experience@1.0.4 74 | mobile-status-bar@1.0.12 75 | modules@0.6.1 76 | modules-runtime@0.6.3 77 | momentjs:moment@2.13.1 78 | mongo@1.1.7 79 | mongo-id@1.0.4 80 | mongo-livedata@1.0.12 81 | mystor:device-detection@0.2.0 82 | npm-mongo@1.4.43 83 | observe-sequence@1.0.11 84 | ordered-dict@1.0.7 85 | pcel:loading@1.0.3 86 | promise@0.6.7 87 | raix:eventemitter@0.1.3 88 | random@1.0.9 89 | rate-limit@1.0.4 90 | reactive-dict@1.1.7 91 | reactive-var@1.0.9 92 | reload@1.1.8 93 | retry@1.0.7 94 | routepolicy@1.0.10 95 | service-configuration@1.0.9 96 | session@1.1.5 97 | spacebars@1.0.11 98 | spacebars-compiler@1.0.11 99 | standard-minifier-css@1.0.6 100 | standard-minifier-js@1.0.6 101 | templating@1.1.9 102 | templating-tools@1.0.4 103 | tracker@1.0.13 104 | twbs:bootstrap@3.3.6 105 | ui@1.0.11 106 | underscore@1.0.8 107 | url@1.0.9 108 | webapp@1.2.8 109 | webapp-hashing@1.0.9 110 | -------------------------------------------------------------------------------- /webapp/client/about.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /webapp/client/admin.html: -------------------------------------------------------------------------------- 1 | 11 | 12 | 13 | 22 | 23 | 24 | 32 | 33 | 34 | 47 | 48 | 49 | 58 | 59 | 60 | 87 | 88 | 89 | 93 | 94 | 95 | 172 | 173 | 192 | 193 | 194 | 201 | 202 | 209 | 210 | 218 | 219 | 240 | 241 | 257 | 258 | -------------------------------------------------------------------------------- /webapp/client/admin.js: -------------------------------------------------------------------------------- 1 | // Admin page access 2 | 3 | Meteor.loginAsAdmin = function(password, callback) { 4 | var loginRequest = {admin: true, password: password}; 5 | 6 | Accounts.callLoginMethod({ 7 | methodArguments: [loginRequest], 8 | userCallback: callback 9 | }); 10 | }; 11 | 12 | // View filters 13 | var curViewDatasetId = null; 14 | var _curDatasetDeps = new Deps.Dependency; 15 | 16 | //==== Video Previews 17 | var curPairId = null; 18 | 19 | function displayVideos() { 20 | var pair = VideoPairs.findOne({_id: curPairId}); 21 | console.log(pair); 22 | preloadGifs(getVideoURL(pair.wptId_1), getVideoURL(pair.wptId_2)); 23 | } 24 | 25 | function getVideoURL(wptId) { 26 | let url = `http://speedperception.instartlogic.com/${wptId}.gif`; 27 | return url; 28 | } 29 | 30 | function preloadGifs(url1, url2) { 31 | // Remove existing gif images. 32 | $('#loaderIcon').show(); 33 | 34 | if($('#gifVideo1')) { 35 | $('#gifVideo1').attr('src', ''); 36 | } 37 | if($('#gifVideo2')) { 38 | $('#gifVideo2').attr('src', ''); 39 | } 40 | 41 | $('.first-gif').empty(); 42 | $('.second-gif').empty(); 43 | 44 | var firstGif = new Image(); 45 | var secondGif = new Image(); 46 | 47 | firstGif.src = url1; 48 | secondGif.src = url2; 49 | 50 | var numLoaded = 0; 51 | 52 | firstGif.onload = function() {syncGifLoad(firstGif);}; 53 | secondGif.onload = function() {syncGifLoad(secondGif);}; 54 | 55 | function syncGifLoad(video) { 56 | numLoaded++; 57 | 58 | if(numLoaded == 2) { 59 | console.log("Both loaded"); 60 | $('#loaderIcon').hide(); 61 | $(firstGif).attr('id', 'gifVideo1').addClass('img-responsive'); 62 | $(secondGif).attr('id', 'gifVideo2').addClass('img-responsive'); 63 | $('.first-gif').append($(firstGif)); 64 | $('.second-gif').append($(secondGif)); 65 | 66 | // start timer 67 | videoStartTime = new Date().getTime(); 68 | 69 | numLoaded = 0; 70 | } 71 | }; 72 | }; 73 | 74 | /* Template : adminAuth */ 75 | Template.adminAuth.events({ 76 | 'submit .admin-access': function(e, t) { 77 | e.preventDefault(); 78 | var password = e.target.pwd.value; 79 | Meteor.loginAsAdmin(password, function(err, res){ 80 | if(err) { 81 | console.error(err); 82 | return; 83 | } 84 | console.log(res); 85 | }); 86 | } 87 | }); 88 | //====================================================== 89 | // Helper functions for converting collection array to csv. 90 | function jsonArrToCsv(jsonArr) { 91 | var headers = _.keys(_.first(jsonArr)).join(','); 92 | var rows = _.chain(jsonArr) 93 | .map(function(r) {return _.values(r).join(',');}) 94 | .value(); 95 | var csvStr = headers + '\n'; 96 | _.each(rows, function(r) { 97 | csvStr += r + '\n'; 98 | }); 99 | return csvStr; 100 | } 101 | 102 | // This creates a link and triggers a click event on that link 103 | function dispatchDownload(objectURL, filename) { 104 | var link = document.createElement('a'); 105 | link.href = objectURL; 106 | link.download = filename; 107 | link.target = '_blank'; 108 | 109 | var event = document.createEvent("MouseEvents"); 110 | event.initMouseEvent( 111 | "click", true, false, window, 0, 0, 0, 0, 0 112 | , false, false, false, false, 0, null 113 | ); 114 | link.dispatchEvent(event); 115 | } 116 | 117 | //====================================================== 118 | /* Template: datasetUploader */ 119 | Template.datasetUploader.events({ 120 | // submit csv dataset 121 | 'submit #uploadDataset': function(e, t){ 122 | e.preventDefault(); 123 | console.log('uploading file'); 124 | var file = e.target.csvfileinput.files[0]; 125 | var datasetName = file.name.split('.')[0]; 126 | 127 | var reader = new FileReader(); 128 | 129 | // load file 130 | reader.addEventListener('load', function(event){ 131 | var csv = event.target.result; 132 | var objArr = csv2ObjArray(csv); 133 | // filter object to remove all sensitive data. 134 | var securedObjArr = _.map(objArr, function(o) {return _.pick(o, 'domain', 'wpt_test_id')}) 135 | // console.log(objArr); 136 | Meteor.call('datasets.insert', datasetName, securedObjArr); 137 | }); 138 | 139 | reader.readAsText(file); 140 | 141 | // helper function for converting csv to json. 142 | function csv2ObjArray(csv) { 143 | var lines = _.filter(csv.split('\n'), function(l) {return ! _.isEmpty(l);}); 144 | var headers = _.first(lines).split(','); 145 | var rows = _.chain(lines).rest() 146 | .map( 147 | function(line) { 148 | return _.chain(line.split(',')) 149 | .map(function(v){return _.isNaN(Number(v))? v: Number(v);}) 150 | .value(); 151 | }) 152 | .value(); 153 | 154 | var obj = _.chain(rows) 155 | .map( 156 | function(row) { 157 | var o = {}; 158 | _.each(_.zip(headers, row), 159 | function(p){ 160 | o[p[0]] = p[1]; 161 | }); 162 | return o; 163 | }) 164 | .value(); 165 | return obj; 166 | } 167 | } 168 | }); 169 | //============================================================================= 170 | 171 | /* Dataset Viewer templates */ 172 | 173 | /* Dependency on search input */ 174 | var _deps = new Deps.Dependency; 175 | var domainSearchCriteria = {}; 176 | 177 | Template.datasetViewer.helpers({ 178 | datasets: function() { 179 | return DataSets.find(); 180 | } 181 | }); 182 | 183 | Template.singleDataset.helpers({ 184 | searchData: function() { 185 | _deps.depend(); 186 | var res = _.sortBy(this.data, "domain"); 187 | 188 | if(domainSearchCriteria.input) { 189 | res = _.filter(res, function(d){ 190 | return d.domain.startsWith(domainSearchCriteria.input); 191 | }) ; 192 | } 193 | 194 | if(domainSearchCriteria.hasVideo) { 195 | var dataset = this.name; 196 | var res = _.filter(res, function(d){ 197 | return VideoData.findOne({dataset: dataset, wptId: d.wpt_test_id}); 198 | }); 199 | } 200 | return res; 201 | } 202 | }); 203 | 204 | Template.singleDataset.events({ 205 | 'keyup input.search-input': function(e, t) { 206 | var searchInput = t.find('.search-input').value; 207 | domainSearchCriteria.input = searchInput; 208 | _deps.changed(); 209 | }, 210 | 211 | 'change input.check-video': function(e, t) { 212 | var checked = e.target.checked; 213 | domainSearchCriteria.hasVideo = checked; 214 | _deps.changed(); 215 | }, 216 | 217 | // Download dataset 218 | 'submit #downloadDataset': function(e, t) { 219 | e.preventDefault(); 220 | var datasetId = e.target.datasetId.value; 221 | console.log("Downloading dataset :" + datasetId); 222 | var dataset = DataSets.findOne({_id: datasetId}); 223 | var name = dataset.name; 224 | var data = dataset.data; 225 | var csvStr = jsonArrToCsv(data); 226 | var blob = new Blob([csvStr], {type:'text/csv'}); 227 | var objectURL = window.URL.createObjectURL(blob); 228 | dispatchDownload(objectURL, name +'.csv'); 229 | }, 230 | 231 | // Download results 232 | 'submit #downloadResults': function(e, t) { 233 | e.preventDefault(); 234 | var datasetId = e.target.datasetId.value; 235 | console.log("Downloading results for dataset: " + datasetId); 236 | var name = DataSets.findOne({_id: datasetId}).name; 237 | var results = []; 238 | var pairs = VideoPairs.find({datasetId: datasetId}).fetch(); 239 | _.each(pairs, function(pair) { 240 | var pairId = pair._id; 241 | var tests = TestResults.find({pairId: pairId}).fetch(); 242 | _.each(tests, function(test) { 243 | results.push({ 244 | session: test.session, 245 | ip: test.ip, 246 | userAgent: test.userAgent.replace(/,/g, ' '), 247 | wpt_test_id_1: pair.wptId_1, 248 | wpt_test_id_2: pair.wptId_2, 249 | type: pair.type, 250 | expected: (pair.result)?pair.result:'None', 251 | result: test.result, 252 | criteria: (pair.criteria)?pair.criteria:'None' 253 | }); 254 | }); 255 | }); 256 | var csvStr = jsonArrToCsv(results); 257 | var blob = new Blob([csvStr], {type:'text/csv'}); 258 | var objectURL = window.URL.createObjectURL(blob); 259 | var date = moment().format('MM-DD-YYYY'); 260 | var filename = name + '-results-' + date + '.csv'; 261 | dispatchDownload(objectURL, filename); 262 | }, 263 | 264 | // Remove a dataset completely 265 | 'submit #removeDataset': function(e, t) { 266 | e.preventDefault(); 267 | var datasetId = e.target.datasetId.value; 268 | console.log("Removing dataset: " + datasetId); 269 | Meteor.call('purge.dataset', datasetId); 270 | } 271 | }); 272 | 273 | Template.singleDomain.events({ 274 | 'submit #uploadVideo': function(e, t){ 275 | e.preventDefault(); 276 | var dataset_name = e.target.dataset_name.value; 277 | var wpt_test_id = e.target.wpt_test_id.value; 278 | var file = e.target.mp4input.files[0]; 279 | VideoUploads.insert(file, function(err, fileObj){ 280 | if(err) { 281 | console.error(err); 282 | return; 283 | } 284 | // Insert into video data. 285 | Meteor.call('videos.insert', dataset_name, wpt_test_id, fileObj._id); 286 | }); 287 | }, 288 | 289 | 'submit #removeVideo': function(e, t) { 290 | e.preventDefault(); 291 | var dataset_name = e.target.dataset_name.value; 292 | var wpt_test_id = e.target.wpt_test_id.value; 293 | Meteor.call('videos.remove', dataset_name, wpt_test_id); 294 | } 295 | }); 296 | 297 | Template.singleDomain.helpers({ 298 | hasVideo: function(parentCxt) { 299 | var datasetName = parentCxt.name; 300 | var wptId = this.wpt_test_id; 301 | if(VideoData.findOne({dataset: datasetName, wptId: wptId})) { 302 | return true; 303 | } 304 | return false; 305 | } 306 | }); 307 | //===================================================================== 308 | 309 | /* Train/Test data uploader */ 310 | Template.videoPairUpload.helpers({ 311 | datasets: function() { 312 | return DataSets.find(); 313 | }, 314 | 315 | selectionCriteria: function() { 316 | return _.map(_.range(1, 17, 1), 317 | function(i) { 318 | return {'condition': i}; 319 | }); 320 | }, 321 | 322 | videoPairs: function() { 323 | _curDatasetDeps.depend(); 324 | if(! curViewDatasetId) { 325 | curViewDatasetId = DataSets.findOne()._id; 326 | } 327 | return VideoPairs.find({datasetId: curViewDatasetId}, {sort: {type: -1, criteria: 1, approved: -1}}); 328 | } 329 | }); 330 | 331 | Template.videoPairUpload.events({ 332 | 'change #dataType': function(e, t) { 333 | e.preventDefault(); 334 | var selection = t.$('form #dataType').val(); 335 | switch(selection) { 336 | case 'test': 337 | t.$('form #expectedResult').addClass('hidden'); 338 | t.$('form #criteria').removeClass('hidden'); 339 | break; 340 | case 'train': 341 | t.$('form #expectedResult').removeClass('hidden'); 342 | t.$('form #criteria').addClass('hidden'); 343 | break; 344 | } 345 | }, 346 | 347 | 'submit #addPair': function(e, t) { 348 | e.preventDefault(); 349 | var datasetId = e.target.dataset.value; 350 | var wptId_1 = e.target.wpt_test_id_1.value; 351 | var wptId_2 = e.target.wpt_test_id_2.value; 352 | var criteria = e.target.criteriaNo.value; 353 | var type = e.target.type.value; 354 | var result = e.target.result.value; 355 | console.log(datasetId, wptId_1, wptId_2, type, result); 356 | if(validate()) { 357 | var newPair = {}; 358 | newPair.datasetId = datasetId; 359 | newPair.wptId_1 = wptId_1; 360 | newPair.wptId_2 = wptId_2; 361 | newPair.type = type; 362 | 363 | if(newPair.type == 'train') { 364 | newPair.result = result; 365 | } else { 366 | newPair.criteria = criteria; 367 | } 368 | 369 | Meteor.call('videoPairs.insert', newPair); 370 | return true; 371 | } 372 | 373 | // form validation 374 | function validate() { 375 | return true; 376 | // var ds = DataSets.findOne({_id: datasetId}); 377 | // var data = ds.data; 378 | // var has_id_1 = _.findWhere(data, {wpt_test_id: wptId_1}); 379 | // var has_id_2 = _.findWhere(data, {wpt_test_id: wptId_2}); 380 | // if(has_id_1 && has_id_2) { 381 | // return true; 382 | // } else { 383 | // console.error("Invalid test ids"); 384 | // return false; 385 | // } 386 | } 387 | }, 388 | 389 | 'change .view-dataset-filter': function(e, t) { 390 | curViewDatasetId = $(e.target).val(); 391 | _curDatasetDeps.changed(); 392 | } 393 | }); 394 | 395 | Template.singleVideoPairDisplay.helpers({ 396 | datasetName: function(id) { 397 | return DataSets.findOne({_id:id}).name; 398 | } 399 | }); 400 | 401 | Template.singleVideoPairDisplay.events({ 402 | 403 | 'click .review-videos': function(e, t) { 404 | e.preventDefault(); 405 | t.$('a').addClass('text-warning'); 406 | console.log(this._id); 407 | curPairId = this._id; 408 | // var pair = VideoPairs.findOne({_id: this._id}); 409 | $('#showVideos').modal('show'); 410 | }, 411 | 412 | 'submit #toggleVideoPair': function(e, t) { 413 | e.preventDefault(); 414 | var dbId = e.target.pairid.value; 415 | console.log("Toggle pair: " + dbId); 416 | // Remove from db 417 | Meteor.call('videoPairs.toggle', dbId); 418 | return true; 419 | } 420 | }); 421 | 422 | /* Template: previewVideosModal */ 423 | Template.previewVideosModal.events({ 424 | 'click .replay-btn': function(e, t) { 425 | e.preventDefault(); 426 | var first = $('#gifVideo1').attr('src'); 427 | var second = $('#gifVideo2').attr('src'); 428 | preloadGifs(first, second); 429 | }, 430 | 431 | 'shown.bs.modal #showVideos': function(e, t) { 432 | console.log('showing videos'); 433 | displayVideos(); 434 | } 435 | }); 436 | 437 | /* Template: adminConsole */ 438 | Template.adminConsole.events({ 439 | 'click .vote-count-btn': function(e, t) { 440 | e.preventDefault(); 441 | // console.log(`clicked`); 442 | $('#voteCounts').modal('show'); 443 | } 444 | }); 445 | 446 | /* Template: voteCountDisplay */ 447 | Template.voteCountDisplay.helpers({ 448 | voteCounts: function() { 449 | return voteCounts = VideoPairVoteCount.find({}, {sort: {count: 1}}); 450 | } 451 | }); 452 | 453 | /* Template: voteCountUpdater */ 454 | Template.voteCountUpdater.events({ 455 | 'click .update-count-btn': function(e, t) { 456 | console.log('updating vote counts') 457 | e.preventDefault(); 458 | var testVideos = VideoPairs.find({type: "test", approved: true}).fetch(); 459 | _.each(testVideos, function(pair) { 460 | var pairId = pair._id; 461 | var count = TestResults.find({pairId: pairId}).count(); 462 | // console.log(`${pairId}:${count}`); 463 | Meteor.call('videoPairVoteCount.insertOrUpdate', pairId, count) 464 | }) 465 | } 466 | }); -------------------------------------------------------------------------------- /webapp/client/expired.html: -------------------------------------------------------------------------------- 1 | 2 | 5 | -------------------------------------------------------------------------------- /webapp/client/home.html: -------------------------------------------------------------------------------- 1 | 2 | 11 | 12 | 29 | 30 | 54 | 55 | 56 | 72 | 73 | 114 | 115 | 116 | 139 | 140 | 156 | 157 | 165 | 166 | 184 | 185 | 237 | -------------------------------------------------------------------------------- /webapp/client/home.js: -------------------------------------------------------------------------------- 1 | // Js helpers for the main AB testing templates. 2 | var videosForCurrentSession = null; 3 | var currentPair = null; 4 | var curIndex = 0; 5 | var visualResponseCount = 0; 6 | 7 | var _scoreDeps = new Deps.Dependency; 8 | var _progressDeps = new Deps.Dependency; 9 | var passedTrainingData = {}; 10 | var totalTrainingData = 0; 11 | 12 | var videoStartTime = 0; 13 | var replayCount = 0; 14 | 15 | var visualResponseStartTime = 0; 16 | var playAgain = false; 17 | 18 | // randomly select 8 test videos from each condition and 3 training videos for a user. 19 | function selectVideosForUser() { 20 | var trainingVideos = VideoPairs.find({type: "train", approved: true}).fetch(); 21 | // sample 3 training pairs 22 | var selectedVideos = _.chain(trainingVideos).sample(3).value(); 23 | totalTrainingData = _.size(selectedVideos); 24 | console.log(`total training data: ${totalTrainingData}`); 25 | // return _.first(selectedVideos, 1); 26 | 27 | // sample 2 test pairs from each dataset 28 | var testVideos = VideoPairs.find({type:"test", approved: true}).fetch(); 29 | var videoGroups = _.groupBy(testVideos, "datasetId"); 30 | _.each(_.keys(videoGroups), function(datasetId){ 31 | var videosByDataset = videoGroups[datasetId]; 32 | // Select 2 least voted videos from this dataset. 33 | var selectedFromDataset = _.chain(videosByDataset) 34 | .sortBy(function(pair) { 35 | let pairId = pair._id; 36 | return VideoPairVoteCount.findOne({pairId: pairId}).count 37 | }) 38 | .first(2) 39 | .value() 40 | _.each(selectedFromDataset, function(pair) {selectedVideos.push(pair)}); 41 | }); 42 | var finalizedVideos = _.chain(selectedVideos).shuffle().value(); 43 | return finalizedVideos; 44 | }; 45 | 46 | function getNextVideoPair() { 47 | if(! videosForCurrentSession) { 48 | videosForCurrentSession = selectVideosForUser(); 49 | //console.log("Selected Videos:"); 50 | //console.log(videosForCurrentSession); 51 | } 52 | var total = _.size(videosForCurrentSession); 53 | console.log(`total: ${total}, at index: ${curIndex})`); 54 | 55 | if(curIndex == total) { 56 | curIndex = 0; 57 | currentPair = null; 58 | videosForCurrentSession = null; 59 | passedTrainingData = {}; 60 | totalTrainingData = 0; 61 | return null; 62 | } 63 | 64 | var pair = videosForCurrentSession[curIndex]; 65 | curIndex += 1; 66 | return pair; 67 | }; 68 | 69 | function getVideoURL(wptId) { 70 | let url = `http://speedperception.instartlogic.com/${wptId}.gif`; 71 | return url; 72 | }; 73 | 74 | function saveResult(comp) { 75 | var curTime = new Date().getTime(); 76 | var viewingDuration = curTime - videoStartTime; 77 | 78 | console.log(currentPair, comp); 79 | 80 | Meteor.call('testResults.insert', 81 | { 82 | pairId: currentPair._id, 83 | session: Session.get('userSessionKey'), 84 | result: comp, 85 | viewDurationInMS: viewingDuration, 86 | repeatCount: replayCount 87 | }); 88 | if(currentPair.type == 'train' 89 | && currentPair.result == comp) { 90 | passedTrainingData[currentPair._id] =1; 91 | console.log('passed training data:', _.size(passedTrainingData)); 92 | _scoreDeps.changed(); 93 | // showProgress(); 94 | } 95 | 96 | // Show the visual response check modal after the 4th and 8th pair. 97 | if ( (curIndex == 4 && visualResponseCount < 2) || 98 | (curIndex == 8 && visualResponseCount < 3)) { 99 | $('.visual-response-circle').css('background', 'black'); 100 | $('#visual-response-modal').modal('show'); 101 | } 102 | } 103 | 104 | function saveVisualResponse() { 105 | visualResponseCount++; 106 | var curTime = new Date().getTime(); 107 | var latency = curTime - visualResponseStartTime; 108 | 109 | Meteor.call('visualResponse.insert', 110 | { 111 | session: Session.get('userSessionKey'), 112 | latencyInMS: latency 113 | }); 114 | } 115 | 116 | function preloadGifs(url1, url2) { 117 | // Remove existing gif images. 118 | $('#loaderIcon').show(); 119 | 120 | if($('#gifVideo1')) { 121 | $('#gifVideo1').attr('src', ''); 122 | } 123 | if($('#gifVideo2')) { 124 | $('#gifVideo2').attr('src', ''); 125 | } 126 | 127 | $('.first-gif').empty(); 128 | $('.second-gif').empty(); 129 | 130 | var firstGif = new Image(); 131 | var secondGif = new Image(); 132 | 133 | firstGif.src = url1; 134 | secondGif.src = url2; 135 | 136 | var numLoaded = 0; 137 | 138 | firstGif.onload = function() {syncGifLoad(firstGif);}; 139 | secondGif.onload = function() {syncGifLoad(secondGif);}; 140 | 141 | function syncGifLoad(video) { 142 | numLoaded++; 143 | 144 | if(numLoaded == 2) { 145 | console.log("Both loaded"); 146 | $('#loaderIcon').hide(); 147 | 148 | // Check for mobile device. 149 | if (Meteor.Device.isPhone()) { 150 | $(firstGif).attr('id', 'gifVideo1').addClass('height-responsive'); 151 | $(secondGif).attr('id', 'gifVideo2').addClass('height-responsive'); 152 | } else { 153 | $(firstGif).attr('id', 'gifVideo1').addClass('img-responsive'); 154 | $(secondGif).attr('id', 'gifVideo2').addClass('img-responsive'); 155 | } 156 | 157 | $('.first-gif').append($(firstGif)); 158 | $('.second-gif').append($(secondGif)); 159 | 160 | // start timer 161 | videoStartTime = new Date().getTime(); 162 | 163 | numLoaded = 0; 164 | } 165 | }; 166 | }; 167 | 168 | function conclude() { 169 | $('#thanksModal').modal('show'); 170 | }; 171 | 172 | function isVerticalDisplayDevice() { 173 | return (Meteor.Device.isTablet() || Meteor.Device.isPhone()); 174 | } 175 | 176 | // function showProgress() { 177 | // $('#scoreModal').modal('show'); 178 | // } 179 | 180 | Template.abTest.helpers({ 181 | isPhoneOrTablet: function() { 182 | return isVerticalDisplayDevice(); 183 | } 184 | }); 185 | 186 | Template.gifView.helpers({ 187 | gifWidth: function() { 188 | if (Meteor.Device.isTablet()) { 189 | return "col-sm-6"; 190 | } else if(Meteor.Device.isPhone()) { 191 | return "col-xs-100"; 192 | } else { 193 | return "col-md-6"; 194 | } 195 | } 196 | }); 197 | 198 | Template.abTest.events({ 199 | 'click .show-next': function(e, t) { 200 | replayCount = 0; 201 | t.$('.btn-decision').prop('disabled', false).show(); 202 | t.$('.show-next').prop('disabled', true); 203 | e.preventDefault(); 204 | 205 | _progressDeps.changed(); 206 | 207 | // remove current gifs 208 | currentPair = getNextVideoPair(); 209 | if(_.isNull(currentPair)) { 210 | // Done showing all pairs. 211 | conclude(); 212 | return; 213 | } 214 | preloadGifs( 215 | getVideoURL(currentPair.wptId_1), 216 | getVideoURL(currentPair.wptId_2) 217 | ); 218 | }, 219 | 220 | 'click .replay-btn': function(e, t) { 221 | replayCount++; 222 | t.$('.btn-decision').prop('disabled', false).show(); 223 | t.$('.show-next').prop('disabled', true); 224 | e.preventDefault(); 225 | var first = $('#gifVideo1').attr('src'); 226 | var second = $('#gifVideo2').attr('src'); 227 | //console.log(first, second); 228 | // Reset 229 | preloadGifs(first, second); 230 | }, 231 | 232 | // Results 233 | 'click .mid-btn': function(e, t) { 234 | t.$('.btn-decision').prop('disabled', true); 235 | t.$('.show-next').prop('disabled', false); 236 | t.$('.left-btn').hide(); 237 | t.$('.right-btn').hide(); 238 | 239 | e.preventDefault(); 240 | saveResult(0); 241 | }, 242 | 243 | 'click .left-btn': function(e, t) { 244 | t.$('.btn-decision').prop('disabled', true); 245 | t.$('.show-next').prop('disabled', false); 246 | t.$('.mid-btn').hide(); 247 | t.$('.right-btn').hide(); 248 | 249 | e.preventDefault(); 250 | saveResult(1); 251 | }, 252 | 253 | 'click .right-btn': function(e, t) { 254 | t.$('.btn-decision').prop('disabled', true); 255 | t.$('.show-next').prop('disabled', false); 256 | t.$('.mid-btn').hide(); 257 | t.$('.left-btn').hide(); 258 | 259 | e.preventDefault(); 260 | saveResult(2); 261 | } 262 | }); 263 | 264 | Template.abTest.onRendered(function(){ 265 | 266 | $('#guideModal').on('hidden.bs.modal', function() { 267 | $('#user-info-modal').modal('show'); 268 | }); 269 | 270 | $('#user-info-modal').on('hidden.bs.modal', function() { 271 | // First visual response check. 272 | console.log('start playing videos'); 273 | 274 | $('.visual-response-circle').css('background', 'black'); 275 | $('#visual-response-modal').modal('show'); 276 | }); 277 | 278 | 279 | $('#visual-response-modal').on('shown.bs.modal', function() { 280 | console.log('Visual response modal shown'); 281 | let delay = 4000; 282 | 283 | Meteor.setTimeout(function() { 284 | console.log('changing circle color'); 285 | $('.visual-response').prop('disabled', false); 286 | $('.visual-response-circle').css('background', 'blue'); 287 | visualResponseStartTime = new Date().getTime(); 288 | }, delay); 289 | }); 290 | 291 | $('#visual-response-modal').on('hidden.bs.modal', function() { 292 | console.log('Visual response modal hidden'); 293 | $('.visual-response').prop('disabled', true); 294 | $('.visual-response-circle').css('background', 'black'); 295 | if(curIndex == 0) { 296 | // First time visual response check. 297 | Meteor.setTimeout(function(){ 298 | currentPair = getNextVideoPair(); 299 | preloadGifs( 300 | getVideoURL(currentPair.wptId_1), 301 | getVideoURL(currentPair.wptId_2)); 302 | }, 1000); 303 | } 304 | }); 305 | 306 | $('#guideModal').modal('show'); 307 | 308 | $('.show-next').prop('disabled', true); 309 | $('.visual-response').prop('disabled', true); 310 | 311 | curIndex = 0; 312 | visualResponseCount = 0; 313 | // Generate a random hash for this user and store in session 314 | Session.set('userSessionKey', Random.id()); 315 | 316 | // Reset session every 15 minutes. 317 | this.refresh_session = setInterval(function(){ 318 | Session.set('userSessionKey', Random.id()); 319 | }, 20*60*1000); 320 | }); 321 | 322 | Template.abTest.onDestroyed(function(){ 323 | clearInterval(this.refresh_session); 324 | }); 325 | 326 | 327 | /* Template: modal dialogs */ 328 | Template.guide_modal.events({ 329 | 'click .start-play': function(e, t) { 330 | e.preventDefault(); 331 | t.$('#guideModal').modal('hide'); 332 | } 333 | }); 334 | 335 | Template.user_info_modal.events({ 336 | 'click .continue-play': function(e, t) { 337 | e.preventDefault(); 338 | console.log("Collecting user info!"); 339 | let gender = t.$('#gender').val(); 340 | let age = t.$('#age').val(); 341 | let occupation = t.$('#occupation').val(); 342 | console.log(Session.get('userSessionKey'), gender, age, occupation); 343 | Meteor.call('userInfo.insert', gender, age, occupation, Session.get('userSessionKey')); 344 | 345 | t.$('#user-info-modal').modal('hide'); 346 | } 347 | }); 348 | 349 | Template.thanks_modal.helpers({ 350 | success_percent: function() { 351 | _scoreDeps.depend(); 352 | //console.log(_.size(passedTrainingData), totalTrainingData); 353 | return Math.ceil((_.size(passedTrainingData)*100)/totalTrainingData); 354 | } 355 | }); 356 | 357 | Template.thanks_modal.events({ 358 | 'click .stop-play': function(e, t) { 359 | e.preventDefault(); 360 | playAgain = false; 361 | t.$('#thanksModal').modal('hide'); 362 | }, 363 | 364 | 'click .play-again': function(e, t) { 365 | e.preventDefault(); 366 | playAgain = true; 367 | t.$('#thanksModal').modal('hide'); 368 | }, 369 | 370 | 'click .send-feedback': function(e, t) { 371 | e.preventDefault(); 372 | // feedback 373 | var feedback = t.$('#feedback-text').val(); 374 | t.$('#feedback-text').prop('disabled', true); 375 | t.$('.send-feedback').prop('disabled', true); 376 | if(feedback && feedback.length > 3) { 377 | Meteor.call('feedbacks.insert', feedback, Session.get('userSessionKey')); 378 | } 379 | // t.$('#thanksModal').modal('hide'); 380 | } 381 | }); 382 | 383 | Template.thanks_modal.onRendered(function() { 384 | $('#thanksModal').on('hidden.bs.modal', function() { 385 | if (playAgain) { 386 | document.location.reload(true); 387 | } else { 388 | Router.go('/'); 389 | } 390 | }); 391 | }); 392 | 393 | Template.score_modal.helpers({ 394 | success_percent: function() { 395 | _scoreDeps.depend(); 396 | //console.log(_.size(passedTrainingData), totalTrainingData); 397 | return Math.ceil((_.size(passedTrainingData)*100)/totalTrainingData); 398 | } 399 | }); 400 | 401 | Template.score_modal.events({ 402 | 'click .score-button': function(e, t) { 403 | e.preventDefault(); 404 | t.$('#scoreModal').modal('hide'); 405 | } 406 | }); 407 | 408 | Template.score_modal.onRendered(function(){ 409 | $('#scoreModal').on('hidden.bs.modal', function(){ 410 | $('.show-next').trigger('click'); 411 | }); 412 | }); 413 | 414 | Template.progressbar.helpers({ 415 | progress: function() { 416 | _progressDeps.depend(); 417 | return Math.ceil(100*(curIndex - 1) / _.size(videosForCurrentSession)); 418 | } 419 | }); 420 | 421 | Template.instructions.helpers({ 422 | isPhoneOrTablet: function() { 423 | return isVerticalDisplayDevice(); 424 | } 425 | }); 426 | 427 | // Visual Response Modal 428 | Template.visual_response_modal.events({ 429 | 'click .visual-response': function(e, t) { 430 | e.preventDefault(); 431 | // Register the timing. 432 | saveVisualResponse(); 433 | t.$('#visual-response-modal').modal('hide'); 434 | }, 435 | 436 | 'click .replay-visual': function(e, t) { 437 | t.$('.visual-response').prop('disabled', true); 438 | t.$('.visual-response-circle').css('background', 'black'); 439 | let delay = 4000; 440 | 441 | Meteor.setTimeout(function() { 442 | console.log('changing circle color'); 443 | $('.visual-response').prop('disabled', false); 444 | $('.visual-response-circle').css('background', 'blue'); 445 | visualResponseStartTime = new Date().getTime(); 446 | }, delay); 447 | } 448 | }); -------------------------------------------------------------------------------- /webapp/client/main.css: -------------------------------------------------------------------------------- 1 | /* CSS declarations go here */ 2 | 3 | .force-inline { 4 | display: inline-block !important; 5 | } 6 | 7 | .pull-right { 8 | float: right !important; 9 | } 10 | 11 | .right-inner-addon { 12 | position: relative; 13 | } 14 | 15 | .right-inner-addon input { 16 | padding-right: 30px; 17 | } 18 | 19 | .right-inner-addon i { 20 | position: absolute; 21 | right: 0px; 22 | padding: 10px 12px; 23 | pointer-events: none; 24 | } 25 | 26 | .media-body { 27 | margin-top: 10px; 28 | } 29 | 30 | .circle { 31 | position: relative; 32 | width: 150px; 33 | height: 150px; 34 | border-radius: 50%; 35 | } 36 | 37 | 38 | .navbar-brand { 39 | font-size: x-large; 40 | } 41 | 42 | .navbar-nav li a { 43 | font-size: medium; 44 | } 45 | 46 | .navbar-brand, 47 | .navbar-nav li a { 48 | line-height: 80px; 49 | height: 80px; 50 | padding-top: 0; 51 | } 52 | 53 | .modal-header { 54 | text-align: center; 55 | } 56 | 57 | .text-centered { 58 | text-align: center; 59 | } 60 | 61 | body.modal-open{ 62 | padding-right: 0 !important; 63 | overflow-y: auto; 64 | } 65 | 66 | .button-wrapper .btn { 67 | margin-bottom: 2px; 68 | } 69 | 70 | .gif-container div { 71 | margin-bottom: 10px; 72 | } 73 | 74 | img { 75 | display: block; 76 | margin-left: auto; 77 | margin-right: auto; 78 | } 79 | 80 | /* Loading screen styles */ 81 | .loading-message { 82 | font-size: 25px; 83 | } 84 | 85 | .pg-loading { 86 | background-color: white !important; 87 | } 88 | 89 | .pg-loading-logo-header { 90 | display: none; 91 | } 92 | 93 | .sk-cube-grid { 94 | width: 40px; 95 | height: 40px; 96 | margin: 40px auto; 97 | /* 98 | * Spinner positions 99 | * 1 2 3 100 | * 4 5 6 101 | * 7 8 9 102 | */ } 103 | .sk-cube-grid .sk-cube { 104 | width: 33.33%; 105 | height: 33.33%; 106 | background-color: #333; 107 | float: left; 108 | -webkit-animation: sk-cubeGridScaleDelay 1.3s infinite ease-in-out; 109 | animation: sk-cubeGridScaleDelay 1.3s infinite ease-in-out; } 110 | .sk-cube-grid .sk-cube1 { 111 | -webkit-animation-delay: 0.2s; 112 | animation-delay: 0.2s; } 113 | .sk-cube-grid .sk-cube2 { 114 | -webkit-animation-delay: 0.3s; 115 | animation-delay: 0.3s; } 116 | .sk-cube-grid .sk-cube3 { 117 | -webkit-animation-delay: 0.4s; 118 | animation-delay: 0.4s; } 119 | .sk-cube-grid .sk-cube4 { 120 | -webkit-animation-delay: 0.1s; 121 | animation-delay: 0.1s; } 122 | .sk-cube-grid .sk-cube5 { 123 | -webkit-animation-delay: 0.2s; 124 | animation-delay: 0.2s; } 125 | .sk-cube-grid .sk-cube6 { 126 | -webkit-animation-delay: 0.3s; 127 | animation-delay: 0.3s; } 128 | .sk-cube-grid .sk-cube7 { 129 | -webkit-animation-delay: 0.0s; 130 | animation-delay: 0.0s; } 131 | .sk-cube-grid .sk-cube8 { 132 | -webkit-animation-delay: 0.1s; 133 | animation-delay: 0.1s; } 134 | .sk-cube-grid .sk-cube9 { 135 | -webkit-animation-delay: 0.2s; 136 | animation-delay: 0.2s; } 137 | 138 | @-webkit-keyframes sk-cubeGridScaleDelay { 139 | 0%, 70%, 100% { 140 | -webkit-transform: scale3D(1, 1, 1); 141 | transform: scale3D(1, 1, 1); } 142 | 35% { 143 | -webkit-transform: scale3D(0, 0, 1); 144 | transform: scale3D(0, 0, 1); } } 145 | 146 | @keyframes sk-cubeGridScaleDelay { 147 | 0%, 70%, 100% { 148 | -webkit-transform: scale3D(1, 1, 1); 149 | transform: scale3D(1, 1, 1); } 150 | 35% { 151 | -webkit-transform: scale3D(0, 0, 1); 152 | transform: scale3D(0, 0, 1); } } 153 | 154 | .visual-response-circle { 155 | width:100px; 156 | height:100px; 157 | border-radius:50px; 158 | background:#000; 159 | } 160 | 161 | .height-responsive { 162 | width: 100%; 163 | display:block; 164 | height: 48vh; 165 | } 166 | 167 | #speedComparison { 168 | height: 100vh; 169 | } 170 | 171 | .navbar-inverse { 172 | background-color: #0d2f66; 173 | border-color: #030033; 174 | } 175 | 176 | .brand-name { 177 | color: #1e2dd8; 178 | } -------------------------------------------------------------------------------- /webapp/client/main.html: -------------------------------------------------------------------------------- 1 | 2 | Speed-Perception 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 16 | 17 | 18 | 20 | 21 | 52 | 53 | 54 | 57 | 58 | 69 | -------------------------------------------------------------------------------- /webapp/client/main.js: -------------------------------------------------------------------------------- 1 | import { Template } from 'meteor/templating'; 2 | import { ReactiveVar } from 'meteor/reactive-var'; 3 | 4 | import './main.html'; 5 | 6 | //=========================================================================== 7 | // Loading template 8 | Template.loading.rendered = function () { 9 | var message = '

The application is loading ..

'; 10 | var spinner = '
'+ 11 | '
'+ 12 | '
'+ 13 | '
'+ 14 | '
'+ 15 | '
'+ 16 | '
'+ 17 | '
'+ 18 | '
'+ 19 | '
'+ 20 | '
'; 21 | this.loading = window.pleaseWait({ 22 | logo: null, 23 | backgroundColor: '#c8e4e6', 24 | loadingHtml: message + spinner 25 | }); 26 | }; 27 | 28 | Template.loading.destroyed = function () { 29 | if ( this.loading ) { 30 | this.loading.finish(); 31 | } 32 | }; 33 | 34 | UI.registerHelper('shareOnFacebookLink', function() { 35 | return 'https://www.facebook.com/sharer/sharer.php?&u=' + window.location.href; 36 | }); 37 | 38 | UI.registerHelper('shareOnTwitterLink', function() { 39 | return 'https://twitter.com/intent/tweet?url=' + window.location.href + '&text=' + document.title; 40 | }); 41 | 42 | UI.registerHelper('shareOnGooglePlusLink', function() { 43 | return 'https://plus.google.com/share?url=' + window.location.href; 44 | }); -------------------------------------------------------------------------------- /webapp/client/people.html: -------------------------------------------------------------------------------- 1 | 2 | 21 | 22 | 34 | -------------------------------------------------------------------------------- /webapp/client/routes.js: -------------------------------------------------------------------------------- 1 | // Setting up routes 2 | 3 | // Router configuration 4 | Router.configure({ 5 | // global layout 6 | layoutTemplate: 'appLayout' 7 | }); 8 | 9 | // Home route 10 | Router.route('/', { 11 | action: function() { 12 | if (Meteor.settings.public.expt_expired) { 13 | this.render('experiment_expired'); 14 | return; 15 | } 16 | this.render('about'); 17 | } 18 | }); 19 | 20 | // SpeedPerception experiment page. 21 | Router.route('/challenge', { 22 | loadingTemplate: 'loading', 23 | 24 | waitOn: function() { 25 | return [ 26 | Meteor.subscribe('videoPairs'), 27 | Meteor.subscribe('videoPairVoteCount') 28 | ]; 29 | }, 30 | 31 | action: function() { 32 | if (Meteor.settings.public.expt_expired) { 33 | this.render('experiment_expired'); 34 | return; 35 | } 36 | 37 | this.render('home'); 38 | } 39 | }); 40 | 41 | // Download page 42 | Router.route('/download', { 43 | action: function() { 44 | this.render('download'); 45 | } 46 | }); 47 | 48 | // People page 49 | Router.route('/people', { 50 | data: function() { 51 | return { 52 | teamMembers: 53 | [ 54 | { 55 | name: 'Clark Gao', 56 | website: 'https://www.linkedin.com/in/clark-g-84a32530', 57 | photo: 'https://media.licdn.com/mpr/mpr/shrinknp_400_400/AAEAAQAAAAAAAARcAAAAJDI0ZWZiMjZlLTFlYjAtNDQzYS1iYTZjLTYxN2U5NjNiZTk0Yw.jpg', 58 | designation: 'Data Science Engineer', 59 | company_name: 'Instart Logic', 60 | company_website: 'https://www.instartlogic.com/' 61 | }, 62 | 63 | { 64 | name: 'Parvez Ahammad, PhD', 65 | website: 'https://www.linkedin.com/in/parvezahammad', 66 | photo: 'https://media.licdn.com/media/p/3/000/206/3ec/070cd62.jpg', 67 | designation: 'Head of Data Science & Machine Learning', 68 | company_name: 'Instart Logic', 69 | company_website: 'https://www.instartlogic.com/' 70 | }, 71 | 72 | { 73 | name: 'Prasenjit Dey', 74 | website: 'https://www.linkedin.com/in/prasenjitdey', 75 | photo: 'https://media.licdn.com/mpr/mpr/shrinknp_400_400/p/3/005/0b6/3f9/31c2ca6.jpg', 76 | designation: 'Software Engineer', 77 | company_name: 'Instart Logic', 78 | company_website: 'https://www.instartlogic.com/' 79 | } 80 | ], 81 | coMembers: 82 | [ 83 | { 84 | name: 'Estelle Weyl', 85 | website: 'https://www.linkedin.com/in/estellevw', 86 | photo: 'https://media.licdn.com/mpr/mpr/shrinknp_400_400/p/5/005/0b1/383/3eed648.jpg', 87 | designation: 'Open Web Evangelist', 88 | company_name: 'Instart Logic', 89 | company_website: 'https://www.instartlogic.com/' 90 | }, 91 | 92 | { 93 | name: 'Patrick Meenan', 94 | website: 'https://www.linkedin.com/in/patrickmeenan', 95 | photo: 'https://media.licdn.com/media/AAEAAQAAAAAAAAOtAAAAJDhlN2FhZTczLTE3NWYtNDQyYi05NGNmLTBiNjBmODM1NDM5Mw.jpg', 96 | designation: 'Staff Engineer at Google', 97 | company_name: 'WebPagetest LLC', 98 | company_website: 'http://www.webpagetest.org/' 99 | } 100 | ] 101 | }; 102 | }, 103 | action: function(){ 104 | this.render('people'); 105 | } 106 | }); 107 | 108 | // About page 109 | Router.route('/about', { 110 | action: function() { 111 | this.render('about'); 112 | } 113 | }); 114 | 115 | // Admin route 116 | Router.route('/admin', { 117 | loadingTemplate: 'loading', 118 | 119 | waitOn: function() { 120 | return [ 121 | Meteor.subscribe('datasets'), 122 | Meteor.subscribe('videos'), 123 | Meteor.subscribe('videoPairs'), 124 | Meteor.subscribe('videoUploads'), 125 | Meteor.subscribe('testResults'), 126 | Meteor.subscribe('videoPairVoteCount') 127 | ]; 128 | }, 129 | 130 | action: function() { 131 | this.render('admin'); 132 | } 133 | }); 134 | 135 | // Stats page 136 | Router.route('/stats', { 137 | loadingTemplate: 'loading', 138 | 139 | waitOn: function() { 140 | return [ 141 | Meteor.subscribe('datasets'), 142 | Meteor.subscribe('videos'), 143 | Meteor.subscribe('videoPairs'), 144 | Meteor.subscribe('videoUploads'), 145 | Meteor.subscribe('expertComments') 146 | ]; 147 | }, 148 | 149 | action: function() { 150 | this.render('stats') 151 | } 152 | }); 153 | -------------------------------------------------------------------------------- /webapp/client/stats.html: -------------------------------------------------------------------------------- 1 | 9 | 10 | 21 | 22 | 28 | 29 | 40 | 41 | 52 | 53 | -------------------------------------------------------------------------------- /webapp/client/stats.js: -------------------------------------------------------------------------------- 1 | var curIndex = 0; 2 | var _curIndexDeps = new Deps.Dependency; 3 | 4 | var videoStartTime = 0; 5 | 6 | function displayVideos() { 7 | var pair = VideoPairs.find({type: 'test'}).fetch()[curIndex]; 8 | console.log(pair); 9 | preloadGifs(getVideoURL(pair.wptId_1), getVideoURL(pair.wptId_2)); 10 | } 11 | 12 | function getVideoURL(wptId) { 13 | var videoData = VideoData.findOne({wptId: wptId}); 14 | var fs = VideoUploads.findOne({_id: videoData.fileId}); 15 | return fs.url(); 16 | } 17 | 18 | function preloadGifs(url1, url2) { 19 | // Remove existing gif images. 20 | $('#loaderIcon').show(); 21 | 22 | if($('#gifVideo1')) { 23 | $('#gifVideo1').attr('src', ''); 24 | } 25 | if($('#gifVideo2')) { 26 | $('#gifVideo2').attr('src', ''); 27 | } 28 | 29 | $('.first-gif').empty(); 30 | $('.second-gif').empty(); 31 | 32 | var firstGif = new Image(); 33 | var secondGif = new Image(); 34 | 35 | firstGif.src = url1; 36 | secondGif.src = url2; 37 | 38 | var numLoaded = 0; 39 | 40 | firstGif.onload = function() {syncGifLoad(firstGif);}; 41 | secondGif.onload = function() {syncGifLoad(secondGif);}; 42 | 43 | function syncGifLoad(video) { 44 | numLoaded++; 45 | 46 | if(numLoaded == 2) { 47 | console.log("Both loaded"); 48 | $('#loaderIcon').hide(); 49 | $(firstGif).attr('id', 'gifVideo1').addClass('img-responsive'); 50 | $(secondGif).attr('id', 'gifVideo2').addClass('img-responsive'); 51 | $('.first-gif').append($(firstGif)); 52 | $('.second-gif').append($(secondGif)); 53 | 54 | // start timer 55 | videoStartTime = new Date().getTime(); 56 | 57 | numLoaded = 0; 58 | } 59 | }; 60 | }; 61 | 62 | /* Template: showStats */ 63 | 64 | Template.showStats.helpers({ 65 | 'pairId': function() { 66 | _curIndexDeps.depend(); 67 | return VideoPairs.find({type: 'test'}).fetch()[curIndex]._id; 68 | } 69 | }); 70 | 71 | Template.showStats.events({ 72 | 'click .show-videos': function(e, t) { 73 | e.preventDefault(); 74 | $('#showVideos').modal('show'); 75 | }, 76 | 77 | 'click .show-next': function(e, t) { 78 | e.preventDefault(); 79 | var total = VideoPairs.find({type: 'test'}).count(); 80 | ++curIndex; 81 | if (curIndex == total) { 82 | curIndex = 0; 83 | } 84 | _curIndexDeps.changed(); 85 | }, 86 | 87 | 'click .show-previous': function(e, t) { 88 | e.preventDefault(); 89 | var total = VideoPairs.find({type: 'test'}).count(); 90 | --curIndex; 91 | if (curIndex == -1) { 92 | curIndex = total -1; 93 | } 94 | _curIndexDeps.changed(); 95 | } 96 | }); 97 | 98 | 99 | /* Template: showVideosModal */ 100 | Template.showVideosModal.events({ 101 | 'click .replay-btn': function(e, t) { 102 | e.preventDefault(); 103 | $('#timer').text(''); 104 | var first = $('#gifVideo1').attr('src'); 105 | var second = $('#gifVideo2').attr('src'); 106 | preloadGifs(first, second); 107 | }, 108 | 109 | 'click .time-btn': function(e, t) { 110 | e.preventDefault(); 111 | // show timing. 112 | var curTime = new Date().getTime(); 113 | var duration = curTime - videoStartTime; 114 | $('#timer').text(duration + ' msec'); 115 | console.log(duration); 116 | }, 117 | 118 | 'shown.bs.modal #showVideos': function(e, t) { 119 | console.log('showing videos'); 120 | $('#timer').text(''); 121 | displayVideos(); 122 | } 123 | }); 124 | 125 | /* Template: pairStats */ 126 | Template.pairStats.helpers({ 127 | ttcDistImgUrl: function() { 128 | _curIndexDeps.depend(); 129 | var pairId = VideoPairs.find({type: 'test'}).fetch()[curIndex]._id; 130 | var url = 'http://sp-app.s3.amazonaws.com/ttc_dist_pid_' + pairId + '.png'; 131 | return url; 132 | } 133 | }); 134 | 135 | /* Template: add_comment */ 136 | Template.add_comment.events({ 137 | 'click .submit-comment': function(e, t) { 138 | e.preventDefault(); 139 | var comment = t.$('#comment-text').val(); 140 | t.$('#comment-text').val(""); 141 | if(comment && comment.length > 10) { 142 | var pairId = VideoPairs.find({type: 'test'}).fetch()[curIndex]._id; 143 | Meteor.call('expertComments.insert', comment, pairId); 144 | } 145 | } 146 | }); 147 | 148 | /* Template: show_comments */ 149 | Template.show_comments.helpers({ 150 | comments: function() { 151 | _curIndexDeps.depend(); 152 | var pairId = VideoPairs.find({type: 'test'}).fetch()[curIndex]._id; 153 | var comments = ExpertComments.find({pairId: pairId}); 154 | return comments; 155 | } 156 | }); -------------------------------------------------------------------------------- /webapp/lib/dataCollections.js: -------------------------------------------------------------------------------- 1 | // Storage for video files in mp4 format. 2 | 3 | var videoStorage = new FS.Store.GridFS("videoUploads"); 4 | 5 | FS.config.uploadChunkSize = 4 * 1024 * 1024; // Setting 4MB as chunk size. 6 | VideoUploads = new FS.Collection("videoUploads", { 7 | stores: [videoStorage] 8 | }); 9 | 10 | VideoUploads.deny({ 11 | insert: function(){ 12 | return false; 13 | }, 14 | update: function(){ 15 | return false; 16 | }, 17 | remove: function(){ 18 | return false; 19 | }, 20 | download: function(){ 21 | return false; 22 | } 23 | }); 24 | 25 | VideoUploads.allow({ 26 | insert: function(){ 27 | return true; 28 | }, 29 | update: function(){ 30 | return true; 31 | }, 32 | remove: function(){ 33 | return true; 34 | }, 35 | download: function(){ 36 | return true; 37 | } 38 | }); 39 | 40 | // DataSet loaded from csv files 41 | DataSets = new Meteor.Collection('datasets'); 42 | 43 | // Video meta-data 44 | VideoData = new Meteor.Collection('videos'); 45 | 46 | // Video pairs curated from a dataset. 47 | VideoPairs = new Meteor.Collection('videoPairs'); 48 | 49 | // Results of experiment. 50 | TestResults = new Meteor.Collection('testResults'); 51 | 52 | // User survey information 53 | UserInfo = new Meteor.Collection('userInfo'); 54 | 55 | // Visual response timer 56 | VisualResponse = new Meteor.Collection('visualResponse'); 57 | 58 | // User feedback. 59 | UserFeedbacks = new Meteor.Collection('userFeedbacks'); 60 | 61 | // Expert comments on video pairs. 62 | ExpertComments = new Meteor.Collection('expertComments'); 63 | 64 | // Video pair vote counts. 65 | VideoPairVoteCount = new Meteor.Collection('videoPairVoteCount') -------------------------------------------------------------------------------- /webapp/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "webapp", 3 | "private": true, 4 | "scripts": { 5 | "start": "meteor run" 6 | }, 7 | "dependencies": { 8 | "meteor-node-stubs": "~0.2.0" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /webapp/public/arch.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pdey/SpeedPerceptionApp/c248f0a5b0d12d4be82503d6f22b0b95c1053934/webapp/public/arch.png -------------------------------------------------------------------------------- /webapp/public/esteelauder.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pdey/SpeedPerceptionApp/c248f0a5b0d12d4be82503d6f22b0b95c1053934/webapp/public/esteelauder.gif -------------------------------------------------------------------------------- /webapp/server/admin.js: -------------------------------------------------------------------------------- 1 | Accounts.registerLoginHandler(function(loginRequest){ 2 | console.log(loginRequest); 3 | if(! loginRequest.admin) { 4 | return undefined; 5 | } 6 | 7 | if(loginRequest.password != Meteor.settings.admin_secret) { 8 | return null; 9 | } 10 | 11 | var userId = null; 12 | var user = Meteor.users.findOne({username: 'admin'}); 13 | if(! user) { 14 | userId = Meteor.users.insert({username: 'admin'}); 15 | } else { 16 | userId = user._id; 17 | } 18 | 19 | console.log(user); 20 | 21 | var stampedToken = Accounts._generateStampedLoginToken(); 22 | var hashStampedToken = Accounts._hashStampedToken(stampedToken); 23 | 24 | Meteor.users.update(userId, 25 | {$push: {'services.resume.loginTokens': hashStampedToken}} 26 | ); 27 | 28 | return { 29 | userId : userId, 30 | token: stampedToken.token 31 | } 32 | }); 33 | 34 | -------------------------------------------------------------------------------- /webapp/server/main.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import { Meteor } from 'meteor/meteor'; 4 | import { Mongo } from 'meteor/mongo'; 5 | import {check} from 'meteor/check'; 6 | 7 | 8 | 9 | Meteor.startup(() => { 10 | // code to run on server at startup 11 | }); 12 | 13 | // Publications 14 | Meteor.publish('videoPairs', function() { 15 | return VideoPairs.find(); 16 | }); 17 | 18 | Meteor.publish('videos', function() { 19 | return VideoData.find(); 20 | }); 21 | 22 | Meteor.publish('videoUploads', function() { 23 | return VideoUploads.find(); 24 | }); 25 | 26 | Meteor.publish('datasets', function() { 27 | return DataSets.find(); 28 | }); 29 | 30 | Meteor.publish('testResults', function() { 31 | return TestResults.find(); 32 | }); 33 | 34 | Meteor.publish('expertComments', function() { 35 | return ExpertComments.find(); 36 | }); 37 | 38 | Meteor.publish('videoPairVoteCount', function() { 39 | return VideoPairVoteCount.find(); 40 | }); 41 | 42 | 43 | Meteor.methods({ 44 | 'datasets.insert'(name, data) { 45 | check(name, String); 46 | if(! this.userId) { 47 | throw new Meteor.Error('not-authorized'); 48 | } 49 | 50 | console.log(`Inserting dataset:${name}`); 51 | console.log(`Accessed by: ${this.userId}`); 52 | 53 | DataSets.insert({ 54 | name: name, 55 | data: data 56 | }); 57 | }, 58 | 59 | 'videos.insert'(datasetName, wptId, fileId) { 60 | check(datasetName, String); 61 | check(wptId, String); 62 | if(! this.userId) { 63 | throw new Meteor.Error('not-authorized'); 64 | } 65 | console.log('Inserting video'); 66 | VideoData.insert({ 67 | dataset: datasetName, 68 | wptId: wptId, 69 | fileId: fileId 70 | }); 71 | }, 72 | 73 | 'videos.remove'(datasetName, wptId) { 74 | if(! this.userId) { 75 | throw new Meteor.Error('not-authorized'); 76 | } 77 | console.log('Removing video'); 78 | var video = VideoData.findOne({dataset: datasetName, wptId: wptId}); 79 | if(video) { 80 | var fileRef = video.fileId; 81 | VideoUploads.remove(fileRef); 82 | VideoData.remove(video._id); 83 | } 84 | }, 85 | 86 | 'videoPairs.insert'(obj) { 87 | if(! this.userId) { 88 | throw new Meteor.Error('not-authorized'); 89 | } 90 | console.log('Inserting test/train pairs'); 91 | VideoPairs.insert(obj); 92 | }, 93 | 94 | 'videoPairs.toggle'(id) { 95 | if(! this.userId) { 96 | throw new Meteor.Error('not-authorized'); 97 | } 98 | console.log('Changing approval of test/train pair:' + id); 99 | var pair = VideoPairs.findOne({_id: id}); 100 | console.log(pair); 101 | VideoPairs.update({_id: id}, {$set:{approved: !(pair.approved)}}); 102 | }, 103 | 104 | 'testResults.insert'(obj) { 105 | var conn = this.connection; 106 | // de-duplication 107 | var existing = TestResults.findOne({$and: [ 108 | {pairId:obj.pairId}, 109 | {session: obj.session}] 110 | }); 111 | 112 | // console.log(existing) 113 | var updateVoteCount = true; 114 | if(existing) { 115 | updateVoteCount = false; 116 | TestResults.remove({_id: existing._id}); 117 | } 118 | _.extend(obj, { 119 | ip: conn.clientAddress, 120 | userAgent: conn.httpHeaders['user-agent'], 121 | timestamp: new Date() 122 | }); 123 | TestResults.insert(obj); 124 | 125 | // Update vote count 126 | var videoPair = VideoPairs.findOne({_id:obj.pairId}) 127 | if (videoPair.type == "train") { 128 | updateVoteCount = false 129 | } 130 | if(updateVoteCount) { 131 | var pairVoteCount = VideoPairVoteCount.findOne({pairId: obj.pairId}); 132 | // console.log('Before insert') 133 | // console.log(pairVoteCount) 134 | if (!!pairVoteCount) { 135 | var count = pairVoteCount.count + 1; 136 | VideoPairVoteCount.update(pairVoteCount._id, {$set: {count: count}}); 137 | } else { 138 | VideoPairVoteCount.insert({pairId: obj.pairId, count: 1}); 139 | } 140 | } 141 | }, 142 | 143 | 'userInfo.insert'(gender, age, occupation, session) { 144 | check(gender, String); 145 | check(age, String); 146 | check(occupation, String); 147 | check(session, String); 148 | UserInfo.insert( 149 | { 150 | session: session, 151 | gender: gender, 152 | age: age, 153 | occupation: occupation 154 | } 155 | ); 156 | }, 157 | 158 | 'visualResponse.insert'(obj) { 159 | _.extend(obj, {timestamp: new Date()}); 160 | VisualResponse.insert(obj); 161 | }, 162 | 163 | 'feedbacks.insert'(feedback, session) { 164 | check(feedback, String); 165 | check(session, String); 166 | if(feedback.length > 500) { 167 | feedback = feedback.substring(0, 500); 168 | } 169 | console.log("Storing user feedback"); 170 | UserFeedbacks.insert( 171 | { 172 | session: session, 173 | feedback: feedback, 174 | timestamp: new Date() 175 | } 176 | ); 177 | }, 178 | 179 | 'expertComments.insert'(comment, pairId) { 180 | if (! this.userId) { 181 | throw new Meteor.Error('not-authorized'); 182 | } 183 | 184 | ExpertComments.insert( 185 | { 186 | pairId: pairId, 187 | comment: comment, 188 | timestamp: new Date() 189 | }); 190 | }, 191 | 192 | 'videoPairVoteCount.insertOrUpdate'(pairId, voteCount) { 193 | if (! this.userId) { 194 | throw new Meteor.Error('not-authorized'); 195 | } 196 | 197 | var existing = VideoPairVoteCount.findOne({pairId: pairId}) 198 | if (!!existing) { 199 | VideoPairVoteCount.update(existing._id, {$set: {count: voteCount}}); 200 | } else { 201 | VideoPairVoteCount.insert({pairId: pairId, count: voteCount}) 202 | } 203 | }, 204 | 205 | 'purge.dataset'(datasetId) { 206 | if(! this.userId) { 207 | throw new Meteor.Error('not-authorized'); 208 | } 209 | console.log("Purging dataset with id: " + datasetId); 210 | 211 | // Remove all videoPairs 212 | var pairs = VideoPairs.find({datasetId: datasetId}).fetch(); 213 | _.each(pairs, function(p) { 214 | // Remove all results 215 | var results = TestResults.find({pairId: p._id}).fetch(); 216 | _.each(results, function(r) { 217 | TestResults.remove(r._id); 218 | }); 219 | VideoPairs.remove(p._id); 220 | }); 221 | 222 | // Remove all files. 223 | var dataset = DataSets.findOne({_id: datasetId}); 224 | var videos = VideoData.find({dataset: dataset.name}).fetch(); 225 | _.each(videos, function(video){ 226 | var fileRef = video.fileId; 227 | VideoUploads.remove(fileRef); 228 | VideoData.remove(video._id); 229 | }); 230 | 231 | // Remove dataset 232 | DataSets.remove(datasetId); 233 | } 234 | }); 235 | --------------------------------------------------------------------------------