├── .backups └── .gitignore ├── .meteor ├── identifier ├── release ├── .gitignore ├── platforms ├── .id ├── .finished-upgraders ├── packages └── versions ├── packages ├── csv │ ├── server.js │ ├── .gitignore │ └── package.js ├── analysis │ ├── .gitignore │ ├── client │ │ ├── indivPerformance.html │ │ ├── graphing.coffee │ │ ├── tagging.html │ │ ├── groupScatter.html │ │ ├── indivPerformance.coffee │ │ ├── viz.styl │ │ ├── groupPerformance.html │ │ ├── viz.html │ │ ├── groupSlices.html │ │ ├── routes.coffee │ │ ├── tagging.coffee │ │ ├── overview.coffee │ │ ├── groupSlices.coffee │ │ ├── groupScatter.coffee │ │ └── overview.html │ ├── generate_all.sh │ ├── rpc.coffee │ ├── analysis.py │ ├── common.coffee │ ├── package.js │ ├── util.coffee │ ├── scoring.py │ └── tagging_biclustering.py └── .gitignore ├── restore-db.sh ├── client ├── css │ ├── meta.styl │ ├── index.styl │ ├── compact.styl │ ├── chat.styl │ ├── map.styl │ ├── datastream.styl │ ├── events.styl │ └── mapper.styl ├── lib │ ├── OpenLayersExtensions.coffee │ └── mapper.coffee ├── helpers.coffee ├── views │ ├── datastream.html │ ├── notifications.html │ ├── map.html │ ├── notifications.coffee │ ├── docs.html │ ├── datastream.coffee │ ├── common.html │ ├── docs.coffee │ ├── chat.html │ ├── common.coffee │ ├── events.html │ └── chat.coffee ├── admin.html ├── meta │ ├── exitsurvey.coffee │ └── exitsurvey.html ├── admin.coffee ├── index.html ├── tutorial │ └── tutorial.coffee └── index.coffee ├── public └── images │ └── map │ ├── fire-red.png │ ├── fire-cyan.png │ ├── fire-green.png │ ├── flood-cyan.png │ ├── flood-red.png │ ├── skull-cyan.png │ ├── skull-red.png │ ├── fire-yellow.png │ ├── flood-green.png │ ├── flood-yellow.png │ ├── skull-green.png │ ├── skull-yellow.png │ ├── tornado-2-red.png │ ├── treedown-cyan.png │ ├── treedown-red.png │ ├── tsunami-cyan.png │ ├── tsunami-green.png │ ├── tsunami-red.png │ ├── avalanche1-cyan.png │ ├── avalanche1-red.png │ ├── tornado-2-cyan.png │ ├── tornado-2-green.png │ ├── treedown-green.png │ ├── treedown-yellow.png │ ├── tsunami-yellow.png │ ├── avalanche1-green.png │ ├── avalanche1-yellow.png │ ├── earthquake-3-cyan.png │ ├── earthquake-3-green.png │ ├── earthquake-3-red.png │ ├── shark-export-cyan.png │ ├── shark-export-green.png │ ├── shark-export-red.png │ ├── tornado-2-yellow.png │ ├── earthquake-3-yellow.png │ ├── shark-export-yellow.png │ ├── zombie-outbreak1-red.png │ ├── zombie-outbreak1-cyan.png │ ├── zombie-outbreak1-green.png │ └── zombie-outbreak1-yellow.png ├── deploy_demo.sh ├── restore-db-files.sh ├── dump-db.sh ├── dump-db-files.sh ├── start-production.sh ├── .gitignore ├── server ├── events_server.coffee ├── lib │ └── mapper_utils.coffee ├── experiment_init.coffee ├── server.coffee ├── chat_server.coffee └── firstrun.coffee ├── package.json ├── private ├── tutorial.csv ├── README.md ├── seed-instructions.txt └── fields-pablo.json ├── settings.json ├── smart.json └── README.md /.backups/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | -------------------------------------------------------------------------------- /.meteor/identifier: -------------------------------------------------------------------------------- 1 | ikva1s1f9xydy12o3721 -------------------------------------------------------------------------------- /.meteor/release: -------------------------------------------------------------------------------- 1 | METEOR@1.3.5.1 2 | -------------------------------------------------------------------------------- /.meteor/.gitignore: -------------------------------------------------------------------------------- 1 | dev_bundle 2 | local 3 | -------------------------------------------------------------------------------- /.meteor/platforms: -------------------------------------------------------------------------------- 1 | server 2 | browser 3 | -------------------------------------------------------------------------------- /packages/csv/server.js: -------------------------------------------------------------------------------- 1 | csv = Npm.require('csv'); 2 | -------------------------------------------------------------------------------- /packages/csv/.gitignore: -------------------------------------------------------------------------------- 1 | versions.json 2 | 3 | .build* 4 | .npm 5 | -------------------------------------------------------------------------------- /restore-db.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | mongorestore --host localhost:3001 --drop $@ 3 | -------------------------------------------------------------------------------- /client/css/meta.styl: -------------------------------------------------------------------------------- 1 | form.survey textarea 2 | min-width: 50% 3 | min-height: 120px 4 | -------------------------------------------------------------------------------- /public/images/map/fire-red.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TurkServer/CrowdMapper/HEAD/public/images/map/fire-red.png -------------------------------------------------------------------------------- /public/images/map/fire-cyan.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TurkServer/CrowdMapper/HEAD/public/images/map/fire-cyan.png -------------------------------------------------------------------------------- /public/images/map/fire-green.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TurkServer/CrowdMapper/HEAD/public/images/map/fire-green.png -------------------------------------------------------------------------------- /public/images/map/flood-cyan.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TurkServer/CrowdMapper/HEAD/public/images/map/flood-cyan.png -------------------------------------------------------------------------------- /public/images/map/flood-red.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TurkServer/CrowdMapper/HEAD/public/images/map/flood-red.png -------------------------------------------------------------------------------- /public/images/map/skull-cyan.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TurkServer/CrowdMapper/HEAD/public/images/map/skull-cyan.png -------------------------------------------------------------------------------- /public/images/map/skull-red.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TurkServer/CrowdMapper/HEAD/public/images/map/skull-red.png -------------------------------------------------------------------------------- /deploy_demo.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | meteor deploy --password --release 0.6.5-rc13 --settings settings.json crowdmapper.meteor.com 3 | -------------------------------------------------------------------------------- /public/images/map/fire-yellow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TurkServer/CrowdMapper/HEAD/public/images/map/fire-yellow.png -------------------------------------------------------------------------------- /public/images/map/flood-green.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TurkServer/CrowdMapper/HEAD/public/images/map/flood-green.png -------------------------------------------------------------------------------- /public/images/map/flood-yellow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TurkServer/CrowdMapper/HEAD/public/images/map/flood-yellow.png -------------------------------------------------------------------------------- /public/images/map/skull-green.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TurkServer/CrowdMapper/HEAD/public/images/map/skull-green.png -------------------------------------------------------------------------------- /public/images/map/skull-yellow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TurkServer/CrowdMapper/HEAD/public/images/map/skull-yellow.png -------------------------------------------------------------------------------- /public/images/map/tornado-2-red.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TurkServer/CrowdMapper/HEAD/public/images/map/tornado-2-red.png -------------------------------------------------------------------------------- /public/images/map/treedown-cyan.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TurkServer/CrowdMapper/HEAD/public/images/map/treedown-cyan.png -------------------------------------------------------------------------------- /public/images/map/treedown-red.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TurkServer/CrowdMapper/HEAD/public/images/map/treedown-red.png -------------------------------------------------------------------------------- /public/images/map/tsunami-cyan.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TurkServer/CrowdMapper/HEAD/public/images/map/tsunami-cyan.png -------------------------------------------------------------------------------- /public/images/map/tsunami-green.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TurkServer/CrowdMapper/HEAD/public/images/map/tsunami-green.png -------------------------------------------------------------------------------- /public/images/map/tsunami-red.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TurkServer/CrowdMapper/HEAD/public/images/map/tsunami-red.png -------------------------------------------------------------------------------- /public/images/map/avalanche1-cyan.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TurkServer/CrowdMapper/HEAD/public/images/map/avalanche1-cyan.png -------------------------------------------------------------------------------- /public/images/map/avalanche1-red.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TurkServer/CrowdMapper/HEAD/public/images/map/avalanche1-red.png -------------------------------------------------------------------------------- /public/images/map/tornado-2-cyan.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TurkServer/CrowdMapper/HEAD/public/images/map/tornado-2-cyan.png -------------------------------------------------------------------------------- /public/images/map/tornado-2-green.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TurkServer/CrowdMapper/HEAD/public/images/map/tornado-2-green.png -------------------------------------------------------------------------------- /public/images/map/treedown-green.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TurkServer/CrowdMapper/HEAD/public/images/map/treedown-green.png -------------------------------------------------------------------------------- /public/images/map/treedown-yellow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TurkServer/CrowdMapper/HEAD/public/images/map/treedown-yellow.png -------------------------------------------------------------------------------- /public/images/map/tsunami-yellow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TurkServer/CrowdMapper/HEAD/public/images/map/tsunami-yellow.png -------------------------------------------------------------------------------- /packages/analysis/.gitignore: -------------------------------------------------------------------------------- 1 | .build* 2 | .npm 3 | 4 | versions.json 5 | 6 | # Don't store generated analysis output 7 | *.png 8 | 9 | -------------------------------------------------------------------------------- /public/images/map/avalanche1-green.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TurkServer/CrowdMapper/HEAD/public/images/map/avalanche1-green.png -------------------------------------------------------------------------------- /public/images/map/avalanche1-yellow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TurkServer/CrowdMapper/HEAD/public/images/map/avalanche1-yellow.png -------------------------------------------------------------------------------- /public/images/map/earthquake-3-cyan.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TurkServer/CrowdMapper/HEAD/public/images/map/earthquake-3-cyan.png -------------------------------------------------------------------------------- /public/images/map/earthquake-3-green.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TurkServer/CrowdMapper/HEAD/public/images/map/earthquake-3-green.png -------------------------------------------------------------------------------- /public/images/map/earthquake-3-red.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TurkServer/CrowdMapper/HEAD/public/images/map/earthquake-3-red.png -------------------------------------------------------------------------------- /public/images/map/shark-export-cyan.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TurkServer/CrowdMapper/HEAD/public/images/map/shark-export-cyan.png -------------------------------------------------------------------------------- /public/images/map/shark-export-green.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TurkServer/CrowdMapper/HEAD/public/images/map/shark-export-green.png -------------------------------------------------------------------------------- /public/images/map/shark-export-red.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TurkServer/CrowdMapper/HEAD/public/images/map/shark-export-red.png -------------------------------------------------------------------------------- /public/images/map/tornado-2-yellow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TurkServer/CrowdMapper/HEAD/public/images/map/tornado-2-yellow.png -------------------------------------------------------------------------------- /public/images/map/earthquake-3-yellow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TurkServer/CrowdMapper/HEAD/public/images/map/earthquake-3-yellow.png -------------------------------------------------------------------------------- /public/images/map/shark-export-yellow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TurkServer/CrowdMapper/HEAD/public/images/map/shark-export-yellow.png -------------------------------------------------------------------------------- /public/images/map/zombie-outbreak1-red.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TurkServer/CrowdMapper/HEAD/public/images/map/zombie-outbreak1-red.png -------------------------------------------------------------------------------- /restore-db-files.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | rm -rf .backups/cmdump 3 | tar xjf $@ 4 | mongorestore --dbpath .meteor/local/db --drop .backups/cmdump 5 | -------------------------------------------------------------------------------- /public/images/map/zombie-outbreak1-cyan.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TurkServer/CrowdMapper/HEAD/public/images/map/zombie-outbreak1-cyan.png -------------------------------------------------------------------------------- /public/images/map/zombie-outbreak1-green.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TurkServer/CrowdMapper/HEAD/public/images/map/zombie-outbreak1-green.png -------------------------------------------------------------------------------- /public/images/map/zombie-outbreak1-yellow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TurkServer/CrowdMapper/HEAD/public/images/map/zombie-outbreak1-yellow.png -------------------------------------------------------------------------------- /dump-db.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | rm -rf .backups/cmdump 3 | mongodump --host localhost:3001 -o .backups/cmdump 4 | tar cjvf .backups/cmdump.tar.bz2 .backups/cmdump/ 5 | -------------------------------------------------------------------------------- /dump-db-files.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | rm -rf .backups/cmdump 3 | mongodump --dbpath .meteor/local/db -o .backups/cmdump 4 | tar cjvf .backups/cmdump.tar.bz2 .backups/cmdump/ 5 | -------------------------------------------------------------------------------- /start-production.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | export HTTP_FORWARDED_COUNT=1 3 | # Don't listen on public interface of port 3000. 4 | meteor --port=localhost:3000 --settings settings-private.json --production 5 | -------------------------------------------------------------------------------- /packages/csv/package.js: -------------------------------------------------------------------------------- 1 | Npm.depends({ 2 | csv: "0.3.5" 3 | }); 4 | 5 | Package.on_use(function (api) { 6 | api.add_files('server.js', 'server'); 7 | 8 | api.export('csv'); 9 | }); 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | smart.lock 3 | dump.rdb 4 | settings-private.json 5 | settings-sandbox.json 6 | 7 | lib-cov 8 | *.seed 9 | *.log 10 | # *.csv 11 | *.dat 12 | *.out 13 | *.pid 14 | *.gz 15 | 16 | pids 17 | logs 18 | results 19 | 20 | npm-debug.log 21 | node_modules 22 | -------------------------------------------------------------------------------- /packages/analysis/client/indivPerformance.html: -------------------------------------------------------------------------------- 1 | 10 | -------------------------------------------------------------------------------- /packages/analysis/client/graphing.coffee: -------------------------------------------------------------------------------- 1 | # Helper functions for d3 graphs and visualizations 2 | colors = d3.scale.category10().domain( [0...10] ) 3 | 4 | Util.groupColor = (size) -> colors(Math.log(size) / Math.LN2) 5 | 6 | Template.registerHelper "sizeColor", (size) -> 7 | size ?= @nominalSize 8 | return Util.groupColor(size) 9 | -------------------------------------------------------------------------------- /.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 | 1js89sbtwtctgmw94ch 8 | -------------------------------------------------------------------------------- /packages/analysis/client/tagging.html: -------------------------------------------------------------------------------- 1 | 11 | -------------------------------------------------------------------------------- /packages/.gitignore: -------------------------------------------------------------------------------- 1 | /mizzao:user-status 2 | /matb33:collection-hooks 3 | /mizzao:partitioner 4 | /mizzao:turkserver 5 | /mizzao:sharejs 6 | /mizzao:autocomplete 7 | /mizzao:tutorials 8 | /mizzao:timesync 9 | /mizzao:jquery-ui 10 | /mizzao:openlayers 11 | /mrt:bootstrap-3 12 | /mrt:moment 13 | /natestrauser:x-editable-bootstrap 14 | /mizzao:bootstrap-3 15 | /mizzao:build-fetcher 16 | /mizzao:sharejs-ace 17 | /mizzao:animated-each 18 | -------------------------------------------------------------------------------- /.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 | 1.3.5-remove-old-dev-bundle-link 15 | -------------------------------------------------------------------------------- /server/events_server.coffee: -------------------------------------------------------------------------------- 1 | # Publish all event fields 2 | Meteor.publish "eventFieldData", -> 3 | sub = this 4 | 5 | EventFields.find().forEach (doc) -> 6 | sub.added("eventfields", doc._id, doc) 7 | 8 | sub.ready() 9 | 10 | # Create an index on events to delete editors who piss off 11 | # No need to index non-editor events 12 | Events._ensureIndex {editor: 1}, {sparse: true} 13 | 14 | TurkServer.onDisconnect -> 15 | Events.update { editor: @userId }, 16 | $unset: { editor: null } 17 | , multi: true 18 | -------------------------------------------------------------------------------- /packages/analysis/generate_all.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | python tagging_biclustering.py 100 3 | python tagging_biclustering.py 100 100 4 | python tagging_biclustering.py 100 75 5 | python tagging_biclustering.py 100 50 6 | python tagging_biclustering.py 100 40 7 | python tagging_biclustering.py 100 30 8 | python tagging_biclustering.py 100 20 9 | python tagging_biclustering.py 100 10 10 | 11 | python tagging_biclustering.py 140 30 12 | python tagging_biclustering.py 120 30 13 | python tagging_biclustering.py 80 30 14 | python tagging_biclustering.py 60 30 15 | python tagging_biclustering.py 40 30 16 | python tagging_biclustering.py 20 30 17 | -------------------------------------------------------------------------------- /server/lib/mapper_utils.coffee: -------------------------------------------------------------------------------- 1 | @Mapper ?= {} 2 | 3 | Mapper.loadCSVTweets = (file, limit) -> 4 | # csv is exported by the csv package 5 | 6 | Assets.getText file, (err, res) -> 7 | throw err if err 8 | 9 | csv() 10 | .from.string(res, { 11 | columns: true 12 | trim: true 13 | }) 14 | .to.array Meteor.bindEnvironment ( arr, count ) -> 15 | 16 | i = 0 17 | while i < limit and i < arr.length 18 | Datastream.insert 19 | num: i+1 # Indexed from 1 20 | text: arr[i].text 21 | i++ 22 | # console.log(i + " tweets inserted") 23 | 24 | , (e) -> 25 | Meteor._debug "Exception while reading CSV:", e 26 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "CrowdMapper", 3 | "version": "0.0.0", 4 | "description": "Crisis mapping and collective intelligence experiment", 5 | "main": "index.js", 6 | "directories": { 7 | "test": "tests" 8 | }, 9 | "scripts": { 10 | "test": "echo \"Error: no test specified\" && exit 1" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "https://github.com/TurkServer/CrowdMapper.git" 15 | }, 16 | "author": "Andrew Mao", 17 | "license": "MIT", 18 | "bugs": { 19 | "url": "https://github.com/TurkServer/CrowdMapper/issues" 20 | }, 21 | "homepage": "https://github.com/TurkServer/CrowdMapper", 22 | "devDependencies": { 23 | "zerorpc": "^0.9.3" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /packages/analysis/rpc.coffee: -------------------------------------------------------------------------------- 1 | Meteor.defer -> 2 | console.log "Trying setup for python RPC analysis server..." 3 | 4 | try 5 | zerorpc = Npm.require("zerorpc") 6 | 7 | client = new zerorpc.Client() 8 | # Try to talk to localhost. If the python service hasn't started, an error 9 | # will be thrown later. 10 | client.connect("tcp://127.0.0.1:4242") 11 | 12 | # Print out any errors that might be encountered 13 | client.on "error", (err) -> console.error("RPC client error: ", err) 14 | 15 | Analysis.invokeRPC = Meteor.wrapAsync(client.invoke, client) 16 | 17 | response = Analysis.invokeRPC("maxMatching", [ [0, 0.5], [1, 0.5] ]) 18 | console.log("Got python response (expect 1.5): ", response) 19 | catch e 20 | console.error("RPC error: ", e) 21 | -------------------------------------------------------------------------------- /client/lib/OpenLayersExtensions.coffee: -------------------------------------------------------------------------------- 1 | Meteor.startup -> 2 | # adapted from dev.openlayers.org/releases/OpenLayers-2.13.1/examples/click.html 3 | OpenLayers.Control.Click = OpenLayers.Class OpenLayers.Control, 4 | defaultHandlerOptions: { 5 | 'single': true 6 | 'double': false 7 | 'pixelTolerance': 0 8 | 'stopSingle': false 9 | 'stopDouble': false 10 | } 11 | initialize: (options) -> 12 | @handlerOptions = OpenLayers.Util.extend({}, @defaultHandlerOptions) 13 | OpenLayers.Control::initialize.apply(@, arguments) 14 | @handler = new OpenLayers.Handler.Click(@, {'click': @trigger}, @handlerOptions) 15 | return # This is super important or it breaks 16 | # Give this an explicit name so we can use its active class: .olControlClickActive 17 | CLASS_NAME: "OpenLayers.Control.Click" 18 | -------------------------------------------------------------------------------- /packages/analysis/analysis.py: -------------------------------------------------------------------------------- 1 | # RPC server using the method described at 2 | # http://ianhinsdale.com/code/2013/12/08/communicating-between-nodejs-and-python/ 3 | 4 | import zerorpc 5 | import logging 6 | 7 | from munkres import Munkres 8 | 9 | logging.basicConfig() 10 | 11 | class AnalysisRPC(object): 12 | def __init__(self): 13 | self.m = Munkres() 14 | 15 | def hello(self, name): 16 | print "Hello called with: %s" % name 17 | return "Hello, %s" % name 18 | 19 | # mat is an nxm list of lists 20 | # representing a weight matching matrix 21 | # satisfying 0 < mat(x,y) < 1 22 | def maxMatching(self, mat): 23 | result = self.m.compute(mat) 24 | return sum([1 - mat[row][column] for row, column in result]) 25 | 26 | s = zerorpc.Server(AnalysisRPC()) 27 | s.bind("tcp://127.0.0.1:4242") 28 | 29 | print "Starting RPC server..." 30 | s.run() 31 | -------------------------------------------------------------------------------- /client/helpers.coffee: -------------------------------------------------------------------------------- 1 | Handlebars.registerHelper "debug", -> 2 | console.log arguments 3 | 4 | Meteor.Spinner.options = 5 | lines: 13 # The number of lines to draw 6 | length: 13 # The length of each line 7 | width: 5 # The line thickness 8 | radius: 12 # The radius of the inner circle 9 | corners: 1 # Corner roundness (0..1) 10 | rotate: 0 # The rotation offset 11 | direction: 1 # 1: clockwise, -1: counterclockwise 12 | color: "#000" # #rgb or #rrggbb or array of colors 13 | speed: 1 # Rounds per second 14 | trail: 60 # Afterglow percentage 15 | shadow: false # Whether to render a shadow 16 | hwaccel: false # Whether to use hardware acceleration 17 | className: "spinner" # The CSS class to assign to the spinner 18 | zIndex: 2e9 # The z-index (defaults to 2000000000) 19 | top: "50%" # Top position relative to parent in px 20 | left: "50%" # Left position relative to parent in px 21 | -------------------------------------------------------------------------------- /server/experiment_init.coffee: -------------------------------------------------------------------------------- 1 | TurkServer.initialize -> 2 | return if Datastream.find().count() > 0 3 | 4 | if @instance.treatment().tutorialEnabled 5 | Mapper.loadCSVTweets("tutorial.csv", 10) 6 | else 7 | # Load initial tweets on first start 8 | # Meta-cleaned version has 1567 tweets 9 | Mapper.loadCSVTweets("PabloPh_UN_cm.csv", 2000) 10 | # Create a seed instructions document for the app 11 | docId = Documents.insert 12 | title: "Instructions" 13 | 14 | Assets.getText "seed-instructions.txt", (err, res) -> 15 | if err? 16 | console.log "Error getting document" 17 | return 18 | ShareJS.initializeDoc(docId, res) 19 | 20 | TurkServer.onConnect -> 21 | if @instance.treatment().tutorialEnabled 22 | # Help the poor folks who shot themselves in the foot 23 | # TODO do a more generalized restore 24 | Datastream.update({}, {$unset: hidden: null}, {multi: true}) 25 | -------------------------------------------------------------------------------- /private/tutorial.csv: -------------------------------------------------------------------------------- 1 | tweetid,text,date,username,userid 2.76E+17, now that #PabloPh's leaving CDO i have the every right to just CHILL on my bed and have a peace of mind #cuddleweather http://t.co/tpaqkxhi, Wed Dec 05 11:56:59 CET 2012, imArielleLu,147559729 2.76E+17, Just landed! Hello Davao! Will be covering typhoon #PabloPH. May God bless us all. (@ Davao Int'l Airport Runway) http://t.co/n6BR8jZY, Wed Dec 05 14:42:53 CET 2012, hadjirieta,64646127 2.76E+17, CATEEL DAVAO ORIENTAL badly needs clothing food bottled water medicines and other basic needs #PabloPH #HelpCateel http://t.co/gjdwyi79, Wed Dec 05 15:01:03 CET 2012, highreaching,10781252 2.76E+17, Mati and Caraga Davao Oriental need help. Food water urgent needs. #PabloPH #ReliefPH @dswdserves @ANCALES @gmanews http://t.co/YQ6x7AlA, Wed Dec 05 15:49:49 CET 2012, highreaching,10781252 2.76E+17, Negros Oriental still without power - NORECO #PabloPH http://t.co/0KOZWBGc, Wed Dec 05 16:05:25 CET 2012, gmanews,39453212 -------------------------------------------------------------------------------- /settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "public": { 3 | "map": { 4 | "extent": [ 12800000, 525000, 14500000, 2450000 ], 5 | "bingAPIKey": "AtsCXPry0QFxHVXRBJDXPVVy88GhE6tTwtW61SNJoVl8AYwcNce_UsO3VZ3lGT3Q" 6 | }, 7 | "turkserver": { 8 | "watchRoute": "/mapper" 9 | } 10 | }, 11 | "turkserver": { 12 | "adminPassword": "test", 13 | "mturk": { 14 | "sandbox": true, 15 | "accessKeyId": "FILL_ME_IN", 16 | "secretAccessKey": "FILL_ME_IN_TOO", 17 | "externalUrl": "http://localhost:3000/", 18 | "frameHeight": 800 19 | } 20 | }, 21 | "sharejs": { 22 | "options": { 23 | "accounts_auth": { 24 | "authenticate": { 25 | "collection": "users", 26 | "token_validations": { 27 | "_id": "is_equal" 28 | } 29 | } 30 | } 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /client/views/datastream.html: -------------------------------------------------------------------------------- 1 | 11 | 12 | 19 | 20 | 30 | 31 | -------------------------------------------------------------------------------- /client/views/notifications.html: -------------------------------------------------------------------------------- 1 | 2 | 20 | 21 | 28 | 29 | 36 | -------------------------------------------------------------------------------- /packages/analysis/client/groupScatter.html: -------------------------------------------------------------------------------- 1 | 34 | -------------------------------------------------------------------------------- /client/css/index.styl: -------------------------------------------------------------------------------- 1 | @import 'nib' 2 | 3 | body 4 | // padding: 20px 20px 10px; 5 | padding-bottom: 5px 6 | overflow: hidden // Don't expand height/width when shit being dragged off screen 7 | 8 | blockquote, q 9 | quotes: none; 10 | 11 | blockquote:before, blockquote:after, q:before, q:after 12 | content: ''; 13 | content: none; 14 | 15 | html, body, .fill-parent 16 | height: 100% 17 | width: 100% 18 | 19 | .flex 20 | display: flex 21 | 22 | .inherit-height 23 | height: inherit 24 | 25 | .fill-height 26 | height: 100% 27 | 28 | .fill-width 29 | width: 100% 30 | 31 | .carrier 32 | position: relative 33 | 34 | .scroll-vertical 35 | overflow-x: hidden 36 | overflow-y: auto 37 | 38 | .vertical-table 39 | display: table 40 | border-spacing: 0px 8px 41 | 42 | .vertical-table-row 43 | display: table-row 44 | 45 | .vertical-table-cell 46 | display: table-cell 47 | 48 | // Tweaks to bootstrap classes 49 | 50 | .label-inverse 51 | background-color: #000000 52 | 53 | .form-control.input-wide 54 | width: 300px 55 | 56 | .dropdown-info // For non-links in dropdowns 57 | padding: 3px 20px 58 | min-width: 240px 59 | -------------------------------------------------------------------------------- /client/admin.html: -------------------------------------------------------------------------------- 1 | 31 | -------------------------------------------------------------------------------- /client/css/compact.styl: -------------------------------------------------------------------------------- 1 | /* 2 | Compact styling modifications for bootstrap 2.3.2 3 | */ 4 | 5 | form.compact 6 | margin: 0px 7 | 8 | h1.compact, h2.compact, h3.compact, h4.compact, h5.compact, h6.compact 9 | margin: 0px 10 | 11 | p.compact 12 | margin: 0 0 3px 13 | 14 | td.text-center 15 | text-align: center 16 | 17 | .container-fluid.compact 18 | padding-left: 5px 19 | padding-right: 5px 20 | 21 | .nav.nav-stacked.compact 22 | margin-bottom: 0px 23 | 24 | .nav.compact > li > a 25 | padding: 5px 8px 26 | 27 | .nav-pills.compact:before, .nav-pills.compact:after 28 | content: none 29 | 30 | .navbar.compact 31 | margin-bottom: 5px 32 | 33 | .navbar.compact .nav > li > a 34 | padding-left: 8px 35 | padding-right: 8px 36 | 37 | .navbar-inner.compact 38 | padding-left: 8px 39 | padding-right: 8px 40 | 41 | .row-fluid.compact:before, .row-fluid.compact:after 42 | content: none 43 | 44 | .table.compact th 45 | font-size: 12px // Smaller text = less horizontal space 46 | font-weight: bold 47 | line-height: 19px // Height of the sorting chevrons 48 | 49 | .well.well-nopad 50 | padding: 0 51 | margin: 0 52 | 53 | .well.well-skinny 54 | padding: 3px 55 | margin: 0px 56 | -------------------------------------------------------------------------------- /client/views/map.html: -------------------------------------------------------------------------------- 1 | 4 | 5 | 36 | -------------------------------------------------------------------------------- /client/views/notifications.coffee: -------------------------------------------------------------------------------- 1 | Template.notifications.helpers 2 | notifications: -> 3 | return Notifications.find {}, 4 | # show most recent first 5 | sort: {timestamp: -1} 6 | 7 | glowClass: -> 8 | if Notifications.find().count() > 0 then "glowing" else "" 9 | 10 | notificationCount: -> 11 | return Notifications.find().count() 12 | 13 | notificationTemplate: -> 14 | switch @type 15 | when "invite" then Template._inviteNotification 16 | when "mention" then Template._mentionNotification 17 | else null 18 | 19 | notifyEvents = 20 | 'click a': (e) -> 21 | e.preventDefault() 22 | Session.set("room", this.room) 23 | Meteor.call "readNotification", this._id 24 | 25 | Template._inviteNotification.events(notifyEvents) 26 | Template._mentionNotification.events(notifyEvents) 27 | 28 | notifyUsername = -> 29 | Meteor.users.findOne(@sender)?.username 30 | 31 | notifyRoomname = -> 32 | ChatRooms.findOne(@room)?.name 33 | 34 | notificationHelpers = { 35 | username: notifyUsername 36 | roomName: notifyRoomname 37 | } 38 | 39 | Template._inviteNotification.helpers(notificationHelpers) 40 | Template._mentionNotification.helpers(notificationHelpers) 41 | -------------------------------------------------------------------------------- /private/README.md: -------------------------------------------------------------------------------- 1 | # Description of files 2 | 3 | - `PabloPh_UN_cm.csv`: filtered set of original data used by SBTF for Typhoon Pablo in 2012. 4 | 5 | - `tutorial.csv`: small set of data used for the interactive tutorial. 6 | 7 | - `fields-pablo.json`: configuration for the data entry fields used for the Pablo data. 8 | 9 | - `seed-instructions.txt`: initial document loaded as instructions for groups working together. 10 | 11 | - `groundtruth-pablo.json`: gold-standard set of events constructed from groups working together and corroborated with derived SBTF generated maps, found 12 | [here](http://www.arcgis.com/home/webmap/viewer.html?webmap=1e606f1a7cf74a599ccec9d0d5893fb0&extent=115.1752,4.4788,133.5663,13.6042) 13 | and 14 | [here](http://www.arcgis.com/home/webmap/viewer.html?webmap=fa64e3f0b09b4d61b0b907f8644cc272&extent=115.5322,4.5144,135.6152,16.1232). 15 | Some "events" in this set do not have locations and represent data that may have been relevant but were not specific enough to tag. Only events that were tagged with a location were used for evaluation. Note also that many links may have broken since this dataset was generated, and as such it may be harder to verify; however, it was produced from a best effort using what was available at the time. 16 | -------------------------------------------------------------------------------- /client/css/chat.styl: -------------------------------------------------------------------------------- 1 | .chat-overview 2 | height: 172px 3 | max-height: 172px 4 | 5 | .chat-rooms, .room-users 6 | height: 150px 7 | max-height: 150px 8 | overflow: auto 9 | 10 | .room-item.deleted 11 | opacity: 0.4 12 | 13 | .action-room-enter 14 | overflow: hidden 15 | white-space: nowrap 16 | text-overflow: ellipsis 17 | 18 | .roomScreen 19 | margin: 0px 20 | 21 | .roomScreen form 22 | margin: 0px 23 | 24 | .chat-header 25 | position: absolute 26 | width: 100% 27 | 28 | .messages 29 | position: absolute 30 | width: 100% 31 | top: 30px 32 | bottom: 35px 33 | margin: 0 34 | padding: 0px 35 | 36 | .messages-body 37 | list-style: none 38 | margin: 0 39 | padding: 5px 40 | height: 100% 41 | width: 100% 42 | 43 | .chat-messaging > form 44 | position: absolute 45 | width: 100% 46 | height: auto 47 | bottom: 0 48 | 49 | .chat-button-container 50 | float: right 51 | 52 | .chat-input-container 53 | margin-right: 45px 54 | position: relative 55 | 56 | .chat-input.form-control 57 | width: 100% 58 | padding-right: 20px // So chat isn't covered by help icon 59 | 60 | .chat-help 61 | position: absolute 62 | top: 0 63 | bottom: 0 64 | right: 5px 65 | height: auto 66 | line-height: 36px 67 | 68 | -------------------------------------------------------------------------------- /smart.json: -------------------------------------------------------------------------------- 1 | { 2 | "packages": { 3 | "mizzao:user-status": { 4 | "path": "../mizzao:user-status" 5 | }, 6 | "mizzao:build-fetcher": { 7 | "path": "../mizzao:build-fetcher" 8 | }, 9 | "matb33:collection-hooks": { 10 | "path": "../matb33:collection-hooks" 11 | }, 12 | "mizzao:partitioner": { 13 | "path": "../mizzao:partitioner" 14 | }, 15 | "mizzao:turkserver": { 16 | "path": "../mizzao:turkserver" 17 | }, 18 | "mizzao:sharejs": { 19 | "path": "../mizzao:sharejs/sharejs-base" 20 | }, 21 | "mizzao:sharejs-ace": { 22 | "path": "../mizzao:sharejs/sharejs-ace" 23 | }, 24 | "mizzao:animated-each": { 25 | "path": "../mizzao:animated-each" 26 | }, 27 | "mizzao:autocomplete": { 28 | "path": "../mizzao:autocomplete" 29 | }, 30 | "mizzao:tutorials": { 31 | "path": "../mizzao:tutorials" 32 | }, 33 | "mizzao:timesync": { 34 | "path": "../mizzao:timesync" 35 | }, 36 | "mizzao:jquery-ui": { 37 | "path": "../mizzao:jquery-ui" 38 | }, 39 | "mizzao:openlayers": { 40 | "path": "../mizzao:openlayers" 41 | }, 42 | "natestrauser:x-editable-bootstrap": { 43 | "path": "../natestrauser:x-editable-bootstrap" 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /client/css/map.styl: -------------------------------------------------------------------------------- 1 | @import 'nib' 2 | 3 | // Fix for bootstrap crapping on openlayers 4 | #map img 5 | max-width: none 6 | 7 | .olControlClickActive 8 | cursor: crosshair 9 | 10 | .olControlLayerSwitcher .layersDiv 11 | border-radius: 10px 0 0 10px; 12 | opacity: 0.75; 13 | 14 | .olControlLayerSwitcher .layersDiv label 15 | display: inline // Cancel bootstrap styling 16 | 17 | .olControlMousePosition 18 | color: #b94a48 19 | font-size: larger !important 20 | font-weight: bold 21 | 22 | // Fixes hover events on vector layer in IE 23 | .olLayerDiv > svg 24 | pointer-events: all 25 | 26 | // HACK: remove the explicit visibility attribute on the vector layer - causes display issues in Firefox 27 | .olLayerDiv > svg > g 28 | visibility: inherit !important 29 | 30 | // Allow tweet tags to extend outside popups 31 | .olPopup 32 | overflow: visible !important 33 | border-radius: 5px 34 | box-shadow 0px 0px 3px 3px rgba(0, 0, 0, 0.3); 35 | 36 | .olPopup > div 37 | overflow: visible !important 38 | // position: static !important 39 | 40 | // One of the hacks below is needed to allow popovers to show outside of popups 41 | .olPopupContent 42 | overflow: visible !important 43 | position: static !important 44 | display: block 45 | max-width: 300px // Stop the popups from rendering too wide 46 | -------------------------------------------------------------------------------- /.meteor/packages: -------------------------------------------------------------------------------- 1 | # Meteor packages used by this project, one per line. 2 | # 3 | # 'meteor add' and 'meteor remove' will edit this file for you, 4 | # but you can also edit it by hand. 5 | 6 | meteor-base 7 | mobile-experience 8 | mongo 9 | blaze-html-templates 10 | session 11 | tracker 12 | logging 13 | reload 14 | random 15 | ejson 16 | spacebars 17 | check 18 | coffeescript 19 | jquery 20 | stylus 21 | accounts-ui 22 | webapp 23 | 24 | # Published, versioned shit 25 | iron:router 26 | mizzao:bootboxjs@4.4.0 27 | sacha:spin@2.3.1 28 | mizzao:animated-each@0.2.0 29 | 30 | d3js:d3@3.5.5 31 | twbs:bootstrap@3.3.5 32 | mizzao:jquery-ui@1.11.4 33 | natestrauser:x-editable-bootstrap@1.5.2_2 34 | 35 | # Pulls in the openlayers code and images 36 | mizzao:openlayers@2.13.1 37 | 38 | # DavidSichau's 0.10.0 breaks mongo connectivity due to some bugs in Meteor 1.4 39 | # So we'll leave this for now and change it in the future if necessary 40 | mizzao:sharejs@0.9.0 41 | mizzao:sharejs-ace 42 | 43 | # Development packages 44 | mizzao:user-status 45 | mizzao:autocomplete 46 | mizzao:tutorials 47 | mizzao:partitioner # So we can use the APIs directly 48 | mizzao:turkserver 49 | mizzao:timesync 50 | 51 | # Local - For parsing input files 52 | csv 53 | analysis 54 | aslagle:reactive-table 55 | standard-minifier-css 56 | standard-minifier-js 57 | -------------------------------------------------------------------------------- /client/css/datastream.styl: -------------------------------------------------------------------------------- 1 | @import 'nib' 2 | 3 | .datastream 4 | width: 100% 5 | height: 100% 6 | 7 | .data-body 8 | padding: 4px 9 | margin: 0px 10 | 11 | .data-filters 12 | width: 100% 13 | 14 | .data-filters > .btn 15 | padding: 4px 0px 16 | width: 33.33% 17 | 18 | .data-cell 19 | position: relative 20 | width: 100% 21 | overflow: hidden 22 | // border: 1px dotted #E08E79 23 | margin-bottom: 3px 24 | border: 1px solid #CCCCCC 25 | 26 | .data-cell, 27 | .data-dragging 28 | background: white 29 | border-radius: 5px 30 | 31 | /* 32 | .data-cell.bolded 33 | background: white 34 | border: 1px solid #69D2E7 35 | */ 36 | .data-cell.data-cell-hidden // Show deleted data to admins 37 | opacity: 0.4 38 | border: 1px dotted #CCCCCC 39 | 40 | .data-cell .label // full-width header bar 41 | cursor: move 42 | display: block 43 | text-align: left 44 | 45 | .data-cell.selected 46 | background: white 47 | box-shadow: 0px 0px 0px 1px rgba(0, 0, 0, 1) // A rounded outline 48 | 49 | .data-cell.selected .label 50 | background-color: #333333 51 | 52 | .data-cell:hover, 53 | .data-dragging 54 | border: 1px solid #000000 55 | 56 | .data-cell:hover, 57 | .data-dragging, 58 | .tweet-icon-container:hover, 59 | .tweet-icon-container.ui-draggable-dragging 60 | box-shadow: 4px 4px 5px rgba(0, 0, 0, 0.4) 61 | 62 | .data-cell-icon 63 | position: absolute 64 | bottom: 1px 65 | right: 1px 66 | 67 | 68 | -------------------------------------------------------------------------------- /client/views/docs.html: -------------------------------------------------------------------------------- 1 | 15 | 16 | 29 | 30 | 37 | 38 | 45 | 46 | 49 | -------------------------------------------------------------------------------- /client/meta/exitsurvey.coffee: -------------------------------------------------------------------------------- 1 | Template.exitsurvey.helpers 2 | surveyTemplate: -> 3 | # TODO generalize this based on batch 4 | treatments = TurkServer.batch()?.treatments 5 | if _.indexOf(treatments, "recruiting") >= 0 6 | Template.tutorialSurvey 7 | else if _.indexOf(treatments, "parallel_worlds") >= 0 8 | Template.postTaskSurvey 9 | else 10 | Template.loadingSurvey 11 | 12 | Template.tutorialSurvey.events 13 | "submit form": (e, tmpl) -> 14 | e.preventDefault() 15 | 16 | results = 17 | comprehension: tmpl.find("textarea[name=comprehension]").value 18 | prepared: tmpl.find("textarea[name=prepared]").value 19 | bugs: tmpl.find("textarea[name=bugs]").value 20 | 21 | panel = 22 | contact: tmpl.find("input[name=contact]").checked 23 | times: [ 24 | tmpl.find("select[name=pickTime1]").value 25 | tmpl.find("select[name=pickTime2]").value 26 | tmpl.find("select[name=pickTime3]").value 27 | ] 28 | 29 | tmpl.find("button[type=submit]").disabled = true # Prevent multiple submissions 30 | 31 | TurkServer.submitExitSurvey(results, panel) 32 | 33 | Template.postTaskSurvey.events 34 | "submit form": (e, tmpl) -> 35 | e.preventDefault() 36 | 37 | results = {} 38 | results.age = tmpl.find("input[name=age]").value 39 | results.gender = tmpl.find("select[name=gender]").value 40 | 41 | fields = [ "approach", "specialize", "teamwork", "workwith", "leadership", "misc" ] 42 | 43 | for field in fields 44 | results[field] = tmpl.find("textarea[name=#{field}]").value 45 | 46 | tmpl.find("button[type=submit]").disabled = true # Prevent multiple submissions 47 | 48 | TurkServer.submitExitSurvey(results) 49 | -------------------------------------------------------------------------------- /client/css/events.styl: -------------------------------------------------------------------------------- 1 | #mapper-events table 2 | border-collapse: collapse; 3 | border-spacing: 0; 4 | table-layout: fixed 5 | // word-wrap: normal 6 | word-wrap: break-word 7 | 8 | .events-header.table, .events-body > .table 9 | margin-bottom: 0 10 | 11 | // Don't allow table headers to wrap - They are also sized small to help with this 12 | .events-header th 13 | white-space: nowrap 14 | 15 | .header-num 16 | width: 45px 17 | 18 | .header-sources 19 | width: 170px // Allows four-digit tweets to display 3 to a row. 20 | 21 | .header-buttons, .header-location 22 | width: 71px 23 | 24 | .data-body 25 | list-style: none 26 | 27 | .events-body, .data-body 28 | display: block 29 | height: 100% 30 | width: 100%; 31 | 32 | /* 33 | Table row CSS 34 | */ 35 | 36 | shadow-color = rgba(0,0,0,0.5) 37 | shadow = 14px 38 | -shadow = - shadow 39 | blur = 4px 40 | spread = -10px 41 | 42 | tr.event-record.deleted 43 | opacity: 0.4 44 | 45 | tr.event-record.selected > td 46 | box-shadow: 47 | 0 shadow blur spread shadow-color inset, 48 | 0 -shadow blur spread shadow-color inset 49 | 50 | // Since we have to, make the top left and bottom right corners the dark overlapping ones 51 | tr.event-record.selected > td:first-child 52 | box-shadow: 53 | shadow -shadow blur spread shadow-color inset, 54 | 0 shadow blur spread shadow-color inset 55 | 56 | tr.event-record.selected > td:last-child 57 | box-shadow: 58 | 0 -shadow blur spread shadow-color inset, 59 | -shadow shadow blur spread shadow-color inset 60 | 61 | // Make the selected event black 62 | tr.event-record.selected .label.label-danger 63 | background-color: #333333 64 | 65 | .table-condensed th.vertical-nopad 66 | padding-top: 0px 67 | padding-bottom: 0px 68 | 69 | .editable-coord 70 | display: block 71 | margin-bottom: 5px 72 | -------------------------------------------------------------------------------- /packages/analysis/common.coffee: -------------------------------------------------------------------------------- 1 | # Collections for analysis 2 | Analysis.People = new Meteor.Collection("analysis.people") 3 | ### 4 | Fields: 5 | - userId 6 | - age 7 | - dropped (whether this user quit) 8 | - gender 9 | - exitSurveyWords 10 | - groupSize (nominalSize of group) 11 | - instanceId 12 | - treated 13 | - tutorialMins 14 | - tutorialWords 15 | ### 16 | 17 | # Nominal world stats that don't change over time 18 | Analysis.Worlds = new Meteor.Collection("analysis.worlds") 19 | ### 20 | Fields: 21 | - batchId 22 | - completed (whether at least one person submitted) 23 | - endTime 24 | - fracFemale 25 | - nominalSize 26 | - startTime 27 | - users 28 | - treated (valid treatment for analysis) 29 | - treatments 30 | ### 31 | 32 | # Stats computed in sliced world states 33 | Analysis.Stats = new Meteor.Collection("analysis.stats") 34 | ### 35 | Fields: 36 | - instanceId 37 | - chatFrac / chatWeight 38 | - chatWordCount / chatWordEntropy 39 | - avgIndivEntropy 40 | - binaryScore 41 | - classifyFrac / classifyWeight 42 | - effortEntropy (equality across users) 43 | - eventContention (maybe) 44 | - filterFrac / filterWeight 45 | - fractionalScore (rounded) 46 | - groupEntropy (distribution in group) 47 | - personTime 48 | - precision 49 | - recall 50 | - totalEffort 51 | - verifyFrac / verifyWeight 52 | - wallTime 53 | ### 54 | 55 | ### 56 | People fields 57 | - chatWordCount / chatWordFrac 58 | - dropped (whether dropout happened yet) 59 | - effort 60 | - time (activeTime) 61 | - tutorialMins / tutorialWords 62 | - wallTime (of group) 63 | - userId 64 | ### 65 | 66 | if Meteor.isServer 67 | Analysis.People._ensureIndex({instanceId: 1, userId: 1}) 68 | 69 | # Index stats collection by world/user, then by time 70 | Analysis.Stats._ensureIndex({instanceId: 1, wallTime: 1}, {sparse: true}) 71 | Analysis.Stats._ensureIndex({userId: 1, wallTime: 1}, {sparse: true}) 72 | -------------------------------------------------------------------------------- /packages/analysis/package.js: -------------------------------------------------------------------------------- 1 | // This is used to send RPC requests over ZeroMQ to Python 2 | Npm.depends({ 3 | json2csv: "2.2.1", 4 | "simple-statistics": "0.9.0", 5 | }); 6 | 7 | Package.on_use(function (api) { 8 | api.use("coffeescript"); 9 | api.use("stylus"); 10 | api.use("templating"); 11 | api.use("tracker"); 12 | api.use("reactive-dict"); 13 | 14 | api.use("underscore"); 15 | 16 | // use versions of these specified in main project 17 | api.use("mizzao:turkserver"); 18 | api.use("mizzao:jquery-ui"); 19 | api.use("iron:router"); 20 | api.use("d3js:d3"); 21 | api.use("aslagle:reactive-table"); 22 | 23 | // Use local csv package to generate stuff 24 | api.use("csv"); 25 | 26 | // Basic stats in browser 27 | api.addFiles('.npm/package/node_modules/simple-statistics/src/simple_statistics.js', 'client'); 28 | 29 | api.addFiles("util.coffee"); 30 | api.addFiles("common.coffee"); 31 | 32 | api.addFiles([ 33 | "client/viz.styl", 34 | "client/routes.coffee", 35 | "client/graphing.coffee", 36 | "client/viz.html", 37 | "client/viz.coffee", 38 | "client/overview.html", 39 | "client/overview.coffee", 40 | "client/tagging.html", 41 | "client/tagging.coffee", 42 | "client/box.js", // from http://bl.ocks.org/jensgrubert/7789216 43 | "client/groupScatter.html", 44 | "client/groupScatter.coffee", 45 | "client/groupPerformance.html", 46 | "client/groupPerformance.coffee", 47 | "client/groupSlices.html", 48 | "client/groupSlices.coffee", 49 | "client/indivPerformance.html", 50 | "client/indivPerformance.coffee" 51 | ], "client"); 52 | 53 | api.addFiles('rpc.coffee', 'server'); 54 | 55 | api.addFiles('replay.coffee', 'server'); 56 | api.addFiles('aggregation.coffee', 'server'); 57 | api.addFiles('analysis.coffee', 'server'); 58 | 59 | // Exports 60 | api.export('Analysis'); 61 | api.export('ReplayHandler', 'server'); 62 | 63 | api.export('AdminController', 'client'); 64 | // Make global available only in this package 65 | api.export('Util', {testOnly: true}); 66 | }); 67 | 68 | -------------------------------------------------------------------------------- /client/views/datastream.coffee: -------------------------------------------------------------------------------- 1 | Template.datastream.helpers 2 | loaded: -> Session.equals("dataSubReady", true) 3 | 4 | # We want events to exist and events.length > 0 to display 5 | # So we get all docs where events either doesn't exist or it's of size 0 6 | dataSelector = { 7 | $or: [ 8 | { events: {$exists: false} }, 9 | { events: {$size: 0} } 10 | ] 11 | } 12 | 13 | Template.dataList.helpers 14 | data: -> 15 | selector = if TurkServer.isAdmin() and Session.equals("adminShowDeleted", true) 16 | # Ignoring just tagged events values 17 | dataSelector 18 | else 19 | _.extend({}, dataSelector, { hidden: {$exists: false} }) 20 | return Datastream.find(selector, sort: {num: 1}) # Sort in increasing insertion order 21 | 22 | Template.dataList.rendered = -> 23 | AnimatedEach.attachHooks(@firstNode) 24 | 25 | dragProps = 26 | # Adding classes is okay because we activate on mouseover 27 | # addClasses: false 28 | # containment: "window" 29 | cursorAt: { top: 0, left: 0 } 30 | distance: 5 31 | # Temporarily disabled, see below 32 | # handle: ".label" # the header 33 | helper: Mapper.tweetDragHelper 34 | revert: "invalid" 35 | scroll: false 36 | # Make it really obvious where to drop these 37 | start: Mapper.highlightEvents 38 | drag: Mapper.tweetDragScroll 39 | stop: Mapper.unhighlightEvents 40 | zIndex: 1000 41 | 42 | Template.dataList.events 43 | "click .data-cell": (e, t) -> Mapper.selectData(@_id) 44 | 45 | # Debounce multi-click data hides 46 | "click .action-data-hide": _.debounce( (e) -> 47 | # Somehow it's still possible to click hide on missing stuff. Slow machines? 48 | unless @_id? 49 | bootbox.alert("Couldn't hide that data. If this persists, try reloading the app.") 50 | return 51 | Meteor.call "dataHide", @_id 52 | , 750, true) 53 | 54 | # Enable draggable when entering a tweet cell 55 | "mouseenter .data-cell:not(.ui-draggable-dragging)": (e) -> 56 | cell = $(e.target) 57 | 58 | # TODO remove temporary fix for jquery UI 1.11.0 59 | # http://bugs.jqueryui.com/ticket/10212 60 | cell.draggable( 61 | $.extend({ 62 | handle: cell.find(".label") 63 | }, dragProps) 64 | ) 65 | 66 | # TODO sometimes this throws an error. Why? 67 | cell.one("mouseleave", -> cell.draggable("destroy") ) 68 | -------------------------------------------------------------------------------- /packages/analysis/client/indivPerformance.coffee: -------------------------------------------------------------------------------- 1 | Template.overviewIndivPerformance.rendered = -> 2 | peopleData = Analysis.People.find().fetch() 3 | 4 | margin = {top: 30, right: 50, bottom: 70, left: 50} 5 | 6 | svg = @find("svg") 7 | 8 | accessor = (d) -> d.effort / d.time 9 | min = d3.min(peopleData, accessor) 10 | max = d3.max(peopleData, accessor) 11 | 12 | nest = d3.nest() 13 | .key( (d) -> d.groupSize ) 14 | .sortKeys(d3.ascending) 15 | .rollup( (leaves) -> leaves.map(accessor) ) 16 | .entries(peopleData) 17 | 18 | # Get this shit into the [0][1] format for rows 19 | data = nest.map( (o) -> [o.key, o.values] ) 20 | 21 | width = $(svg).width() - margin.left - margin.right 22 | height = $(svg).height() - margin.top - margin.bottom 23 | 24 | chart = d3.box() 25 | .whiskers(Util.iqrFun(1.5)) 26 | .height(height) 27 | .domain([min, max]) 28 | .showLabels(true) 29 | 30 | # Switch svg over to d3 g element 31 | svg = d3.select(svg) 32 | .attr("class", "viz box") 33 | .append("g") 34 | .attr("transform", "translate(#{margin.left},#{margin.top})") 35 | 36 | # the x-axis 37 | x = d3.scale.ordinal() 38 | .domain([1, 2, 4, 8, 16, 32]) 39 | .rangeRoundBands([0, width], 0.6, 0.3) 40 | 41 | xAxis = d3.svg.axis().scale(x).orient("bottom") 42 | 43 | # the y-axis 44 | y = d3.scale.linear() 45 | .domain([min, max]) 46 | .range([height + margin.top, 0 + margin.top]) 47 | 48 | yAxis = d3.svg.axis().scale(y).orient("left") 49 | 50 | # draw the boxplots 51 | svg.selectAll(".box") 52 | .data(data) 53 | .enter() 54 | .append("g") 55 | .attr("transform", (d) -> "translate(" + x(d[0]) + "," + margin.top + ")") 56 | .call chart.width(x.rangeBand()) 57 | 58 | # draw y axis 59 | svg.append("g") 60 | .attr("class", "y axis") 61 | .call(yAxis) 62 | .append("text") 63 | .attr("transform", "rotate(-90)") 64 | .attr("y", 6) 65 | .attr("dy", ".71em") 66 | .style("text-anchor", "end") 67 | .text("Normalized Effort") 68 | 69 | # draw x axis 70 | # text label for the x axis 71 | svg.append("g") 72 | .attr("class", "x axis") 73 | .attr("transform", "translate(0," + (height + margin.top + 10) + ")") 74 | .call(xAxis) 75 | .append("text") 76 | .attr("x", (width / 2)) 77 | .attr("y", 10) 78 | .attr("dy", ".71em") 79 | .style("text-anchor", "middle") 80 | .text("Group Size") 81 | -------------------------------------------------------------------------------- /packages/analysis/client/viz.styl: -------------------------------------------------------------------------------- 1 | .legend div 2 | color: white 3 | padding: 4px 4 | 5 | // Box plots - janky CSS 6 | .box line, .box rect, .box circle 7 | fill: steelblue; 8 | stroke: #777; 9 | stroke-width: 1px; 10 | 11 | .box .center 12 | stroke-dasharray: 3,3; 13 | 14 | .box .outlier 15 | fill: none; 16 | stroke: #000; 17 | 18 | svg.viz 19 | // Charting stuff 20 | .grid .tick 21 | stroke: grey 22 | stroke-dasharray: 2, 2 23 | opacity: 0.7 24 | 25 | .line 26 | fill: none 27 | stroke: steelblue 28 | stroke-width: 1.5px 29 | 30 | .line.regression 31 | stroke: maroon; 32 | 33 | .axis text 34 | font-size: 150% 35 | text-anchor: middle 36 | 37 | // Graphing stuff 38 | .node 39 | stroke: #fff; 40 | stroke-width: 1.5px; 41 | 42 | .link 43 | stroke: #999; 44 | stroke-opacity: .6; 45 | 46 | // Visualizer 47 | .band 48 | fill: #dddddd 49 | stroke: #aaaaaa 50 | 51 | .pie .action 52 | fill-opacity: 0.50 53 | 54 | // The root node, showing the user's main activity 55 | .pie .action.root 56 | fill-opacity: 0.3 57 | 58 | .pie .action.type 59 | fill-opacity: 1.0 60 | 61 | // Filtering actions 62 | .action.filter, .action.data-hide 63 | fill: #d62728 64 | 65 | .action.data-link 66 | fill: #9467bd 67 | 68 | // Classification actions 69 | .action.event-edit 70 | fill: #1f77b4 71 | 72 | .action.classify, .action.event-create, .action.event-update 73 | fill: #bcbd22 74 | 75 | // Location updates 76 | .action.event-update.location, .action.event-update.province, .action.event-update.region 77 | fill: #17becf 78 | 79 | .action.event-save 80 | fill: #2ca02c 81 | 82 | // Verification actions 83 | .action.verify, .action.event-vote 84 | fill: #ff7f0e 85 | 86 | .action.data-move, .action.event-unvote 87 | fill: #8c564b 88 | 89 | .action.data-unlink, .action.event-delete 90 | fill: #7f7f7f 91 | 92 | // Chat actions 93 | .chat 94 | fill: #e377c2 95 | 96 | .chat.tagged 97 | stroke: #000000 98 | 99 | .chat.tagged, .chat.undirected 100 | fill-opacity: 0.50 101 | 102 | path 103 | fill-rule: evenodd 104 | stroke: #ffffff 105 | 106 | path.untreated 107 | stroke-dasharray: 5, 5 108 | 109 | path.pseudo 110 | stroke-dasharray: 3, 3 111 | 112 | .outline 113 | fill: #EEEEEE 114 | 115 | .brush .extent 116 | stroke: #000; 117 | fill-opacity: .250; 118 | shape-rendering: crispEdges; 119 | -------------------------------------------------------------------------------- /server/server.coffee: -------------------------------------------------------------------------------- 1 | ### 2 | TurkServer-ed publications 3 | All of these publications check for grouping, except notifications 4 | ### 5 | 6 | userFields = { 7 | fields: { 8 | username: 1 9 | status: 1 10 | } 11 | } 12 | 13 | # User status and username 14 | Meteor.publish "userStatus", -> 15 | ### 16 | The status field below should really be "status.online" to not publish random other status fields 17 | But we need to leave it at status because otherwise we will be missing fields on the merge. 18 | https://github.com/meteor/meteor/issues/998 19 | ### 20 | Meteor.users.find({}, userFields) # All users (in my group) 21 | 22 | # Publish all events, docs, and events, including deleted - filtering is done on the client 23 | # This means admins can see deleted items easily, and they still work in chat 24 | 25 | Meteor.publish "datastream", -> Datastream.find() 26 | 27 | Meteor.publish "docs", -> Documents.find() 28 | 29 | Meteor.publish "events", -> Events.find() 30 | 31 | Meteor.publish 'notifications', -> 32 | # Only publish unread notifications for this user (in this instance) 33 | Notifications.find 34 | user: this.userId 35 | read: {$exists: false} 36 | 37 | # Alternate admin publication for watching, that does not block TurkServer 38 | Meteor.publish "adminWatch", (instance) -> 39 | return [] unless Meteor.users.findOne(@userId)?.admin 40 | check(instance, String) 41 | 42 | # Hack to make sure we get both current users and past ones 43 | # TODO does not update as group is filling 44 | exp = Experiments.findOne(instance) 45 | treatments = exp?.treatments || [] 46 | users = exp?.users || [] 47 | 48 | return Partitioner.directOperation -> 49 | [ 50 | # Group and treatment data 51 | Experiments.find(instance), 52 | Treatments.find({name: $in : treatments}) 53 | # Experiment data 54 | ChatRooms.find({_groupId: instance}), 55 | Meteor.users.find({$or: [ 56 | { _id: $in: users }, 57 | { group: instance } 58 | ]}, userFields), 59 | Datastream.find({_groupId: instance}), 60 | Documents.find({_groupId: instance}), 61 | Events.find({_groupId: instance}), 62 | ] 63 | 64 | ### 65 | Methods 66 | ### 67 | 68 | Meteor.methods 69 | "finishTutorial": -> 70 | exp = TurkServer.Instance.currentInstance() 71 | # If finish button is mashed, this may not exist. 72 | unless exp? 73 | Meteor._debug("Finish tutorial: may have already finished for ", Meteor.userId()) 74 | return 75 | 76 | if exp.treatment()?.tutorialEnabled 77 | # Don't accidentally teardown something that isn't the tutorial 78 | exp.teardown() 79 | 80 | return 81 | 82 | 83 | -------------------------------------------------------------------------------- /client/css/mapper.styl: -------------------------------------------------------------------------------- 1 | @import 'nib' 2 | 3 | .stack > .item:not(.active) 4 | display: none 5 | 6 | .stack > .pages:not(.active) 7 | visibility: hidden 8 | 9 | .pages 10 | position: absolute 11 | top: 57px // 52px + 5px bottom margin on the navbar 12 | bottom: 0px 13 | left: 0px 14 | right: 0px 15 | 16 | @keyframes glow { 17 | to { 18 | text-shadow: 0 0 10px red; 19 | } 20 | } 21 | 22 | .notification > a.glowing 23 | animation: glow .5s infinite alternate; 24 | 25 | .bigger-labels .label 26 | font-size: 85% 27 | 28 | .data-labels .label 29 | // http://stackoverflow.com/q/11126685/586086 30 | display: inline-block 31 | padding: 3px 5px 32 | 33 | .user-list 34 | display: block 35 | height: 80px 36 | max-height: 80px 37 | 38 | .user-pill-container, .tweet-icon-container 39 | border-radius: .25em // To match the label inside, so shadow is round 40 | display: inline-block 41 | margin-bottom: 1px 42 | 43 | .tweet-icon-container .label 44 | cursor: move 45 | 46 | .tweet-icon-container .popover 47 | max-width: 250px 48 | 49 | .shadow-outline, #editor 50 | box-shadow 0px 0px 5px 5px rgba(0, 0, 0, 0.2); 51 | 52 | .shadow-outline.highlighted 53 | box-shadow 0px 0px 150px 100px rgba(0, 0, 0, 0.5); 54 | 55 | .guidanceMessage 56 | color: #FFFFFF 57 | position: absolute 58 | top: 0 59 | width: 100% 60 | height: auto 61 | z-index: 1000 62 | 63 | #editor 64 | position: absolute 65 | width: auto 66 | top: 30px 67 | bottom: 0 68 | left: 15px 69 | right: 15px 70 | 71 | #map, #events 72 | display: block 73 | border-radius: 4px 74 | 75 | newdoc-height = 34px 76 | 77 | #doc-list 78 | position: absolute 79 | top: 0px 80 | bottom: newdoc-height + 10px 81 | left: 0px 82 | right: 0px 83 | 84 | .doc-item.deleted 85 | opacity: 0.4 86 | 87 | #events 88 | position: absolute 89 | top: 32px // 27px header height 90 | bottom: 35px // 30px button height 91 | left: 0px 92 | right: 0px 93 | 94 | .event-create 95 | position: absolute 96 | height: 30px 97 | left: 0px 98 | bottom: 0px 99 | right: 0px 100 | 101 | .action-document-new 102 | position: absolute 103 | height: newdoc-height 104 | bottom: 0px 105 | 106 | .chopped 107 | display: inline-block 108 | font-size: 14px // Esp. necessary to cancel an input-append inheritance 109 | max-width: 160px 110 | overflow: hidden 111 | white-space: nowrap 112 | text-overflow: ellipsis 113 | 114 | .center 115 | float: none 116 | display: block 117 | margin-left: auto 118 | margin-right: auto 119 | 120 | .clickme 121 | cursor: pointer 122 | -------------------------------------------------------------------------------- /packages/analysis/client/groupPerformance.html: -------------------------------------------------------------------------------- 1 | 66 | -------------------------------------------------------------------------------- /client/admin.coffee: -------------------------------------------------------------------------------- 1 | # Admin Routes 2 | Router.map -> 3 | # Multi-watching capable route 4 | @route 'watch', 5 | path: '/watch/:instance' 6 | template: 'mapper', 7 | controller: AdminController 8 | waitOn: -> 9 | return unless @params.instance 10 | # Need to set all these session variables to true for it to work 11 | Meteor.subscribe "adminWatch", @params.instance, -> 12 | Session.set("userSubReady", true) 13 | Session.set("chatSubReady", true) 14 | Session.set("dataSubReady", true) 15 | Session.set("docSubReady", true) 16 | Session.set("eventSubReady", true) 17 | 18 | # Route to re-play a given crisis mapping instantiation 19 | @route 'replay', 20 | path: '/replay/:instance/:speed?/:scroll?' 21 | template: 'mapper', 22 | controller: AdminController 23 | waitOn: -> 24 | speed = parseFloat(this.params.speed) || 20 25 | Meteor.subscribe "replay", this.params.instance, speed, -> 26 | Session.set("userSubReady", true) 27 | Session.set("dataSubReady", true) 28 | # Session.set("docSubReady", true) 29 | Session.set("eventSubReady", true) 30 | 31 | Session.set("chatSubReady", true) 32 | Session.set("chatRoomReady", true) 33 | 34 | onAfterAction: -> 35 | # Set displayed chat room upon arrival of any message. 36 | # This just filters the chat collection based on the room, and shouldn't 37 | # be too slow because most worlds only had one room. 38 | @chatWatcher = ChatMessages.find().observeChanges 39 | added: (id, fields) -> Session.set("room", fields.room) 40 | 41 | return unless this.params.scroll 42 | # turn on auto scrolling for new events and hidden tweets 43 | @dataWatcher = Datastream.find({hidden: $exists: false}).observeChanges 44 | removed: (id) -> Mapper.scrollToData(id, 100) 45 | 46 | @eventWatcher = Events.find().observeChanges 47 | added: (id) -> 48 | Deps.afterFlush -> Mapper.scrollToEvent(id, 100) 49 | 50 | onStop: -> 51 | @chatWatcher?.stop() 52 | @dataWatcher?.stop() 53 | @eventWatcher?.stop() 54 | 55 | Template.adminControls.events 56 | "change input": (e, t) -> 57 | Session.set("adminShowDeleted", e.target.checked) 58 | 59 | Template.adminControls.helpers 60 | showDeleted: -> Session.equals("adminShowDeleted", true) 61 | remainingData: -> Datastream.find({ 62 | $or: [ { events: null }, { events: {$size: 0} } ], 63 | hidden: null 64 | }).count() 65 | 66 | hiddenData: -> Datastream.find({ hidden: true }).count() 67 | 68 | attachedData: -> Datastream.find({ 69 | "events.0": $exists: true 70 | }).count() 71 | 72 | createdEvents: -> Events.find({deleted: null}).count() 73 | deletedEvents: -> Events.find({deleted: true}).count() 74 | -------------------------------------------------------------------------------- /packages/analysis/util.coffee: -------------------------------------------------------------------------------- 1 | # Define analysis global for export 2 | Analysis = {} 3 | 4 | bisectors = {} 5 | 6 | Util = 7 | # Overall classification of log actions 8 | logActionType: (entry) -> Util.actionCategory(entry.action) 9 | 10 | actionCategory: (action) -> 11 | switch action 12 | when "data-hide", "data-link" then "filter" 13 | when "event-create", "event-edit", "event-update", "event-save" then "classify" 14 | when "event-vote", "event-unvote", "event-unmap", "event-delete", "data-move", "data-unlink", \ 15 | "event-edit-verify", "event-update-verify", "event-save-verify" then "verify" 16 | when "chat" then "chat" 17 | when "chat-create", "chat-rename", "room-enter", "chat-delete", \ 18 | "document-create", "document-rename", "document-delete", "document-open" 19 | # explicitly ignored 20 | null 21 | else 22 | throw new Meteor.Error(500, "Unrecognized action") 23 | 24 | typeFields: [ "filter", "classify", "verify", "chat", "" ] 25 | 26 | # Convenience function for binning log or chat actions 27 | actionType: (item) -> 28 | if item.timestamp? then "chat" else Util.logActionType(item) 29 | 30 | weightOf: (item, weights) -> 31 | # Chat entry 32 | return weights.chat if item.timestamp? 33 | # Log entry 34 | return weights[item.action] || 0 35 | 36 | # Returns a function to compute the interquartile range. 37 | iqrFun: (k) -> 38 | (d, i) -> 39 | q1 = d.quartiles[0] 40 | q3 = d.quartiles[2] 41 | iqr = (q3 - q1) * k 42 | i = -1 43 | j = d.length 44 | while (d[++i] < q1 - iqr) 45 | null 46 | while (d[--j] > q3 + iqr) 47 | null 48 | return [ i, j ] 49 | 50 | # Abbreviations used in progress array 51 | fieldAbbr: 52 | partialCreditScore: "ps" 53 | fullCreditScore: "ss" 54 | totalEffort: "ef" 55 | wallTime: "wt" 56 | personTime: "mt" 57 | precision: "p" 58 | recall: "r" 59 | 60 | # Memoize bisectors so they aren't constructed each time. 61 | getBisector: (key) -> 62 | return bisectors[key] ?= d3.bisector( (d) -> d[key] ) 63 | 64 | # compute a linearly interpolated value for a field in a progress array. 65 | interpolateArray: (progress, xField, yField, xVal) -> 66 | return if progress[progress.length - 1][xField] < xVal 67 | bisector = Util.getBisector(xField) 68 | 69 | i = bisector.right(progress, xVal) 70 | 71 | lower = progress[i - 1] || progress[0] 72 | upper = progress[i] 73 | 74 | l = (xVal - lower[xField]) / (upper[xField] - lower[xField]) 75 | return l * upper[yField] + (1-l) * lower[yField] 76 | 77 | # Compute entropy of an array of probabilities that sum to 1. 78 | entropy: (probArr) -> 79 | e = 0 80 | for p in probArr 81 | continue if p is 0 # 0 log 0 = 0 82 | e -= p * Math.log(p) / Math.LN2 83 | return e 84 | -------------------------------------------------------------------------------- /client/views/common.html: -------------------------------------------------------------------------------- 1 | 12 | 13 | 16 | 17 | 20 | 21 | 28 | 29 | 37 | 38 | 44 | 45 | 51 | 52 | 53 | 59 | 60 | 64 | 65 | 69 | 70 | 80 | -------------------------------------------------------------------------------- /client/views/docs.coffee: -------------------------------------------------------------------------------- 1 | Meteor.startup -> 2 | # No session document by default 3 | Session.setDefault("document", undefined) 4 | 5 | Template.docs.helpers 6 | loaded: -> Session.equals("docSubReady", true) 7 | 8 | Template.docTabs.helpers 9 | documents: -> 10 | selector = if TurkServer.isAdmin() and Session.equals("adminShowDeleted", true) then {} 11 | else { deleted: {$exists: false} } 12 | Documents.find(selector) 13 | 14 | noDocuments: -> 15 | Documents.find(deleted: {$exists: false}).count() is 0 16 | 17 | Template.docTabs.events = 18 | "click .action-document-new": -> 19 | bootbox.prompt "Name the document", (docName) -> 20 | return unless !!docName 21 | 22 | Meteor.call "createDocument", docName, (err, id) -> 23 | return unless id 24 | Session.set("document", id) 25 | 26 | "click a": (e) -> 27 | e.preventDefault() 28 | Session.set("document", @_id) 29 | 30 | TurkServer.log 31 | action: "document-open" 32 | docId: @_id 33 | 34 | Template.docTab.helpers 35 | active: -> if Session.equals("document", @_id) then "active" else "" 36 | deleted: -> if @deleted then "deleted" else "" 37 | 38 | # TODO: make sure this doesn't cause thrashing of the currently open document. 39 | Template.docCurrent.helpers 40 | document: -> 41 | id = Session.get("document") 42 | # Can't stay in a document if someone deletes it, unless we're admin 43 | selector = {_id: id} 44 | selector.deleted = {$exists: false} unless TurkServer.isAdmin() 45 | 46 | if Documents.findOne(selector) 47 | return id 48 | else 49 | return undefined 50 | 51 | title: -> Documents.findOne(""+@)?.title 52 | 53 | Template.docTitle.rendered = -> 54 | tmplInst = this 55 | 56 | this.autorun -> 57 | # Trigger this whenever title changes 58 | title = Blaze.getData() 59 | # Destroy old editable if it exists 60 | tmplInst.$(".editable").editable("destroy").editable 61 | display: -> 62 | success: (response, newValue) -> 63 | docId = Session.get("document") 64 | return unless document 65 | Meteor.call "renameDocument", docId, newValue 66 | 67 | Template.docCurrent.events = 68 | "click .action-document-delete": -> 69 | bootbox.confirm "Deleting this document will kick out all other editors! Are you sure?", (res) -> 70 | return unless res 71 | id = Session.get("document") 72 | Meteor.call "deleteDocument", id 73 | Session.set("document", undefined) 74 | 75 | aceConfig = (ace) -> 76 | # Set some reasonable options on the editor 77 | ace.setShowPrintMargin(false) 78 | # ace.renderer.setShowGutter(false) 79 | ace.session.setUseWrapMode(true) 80 | ace.session.setMode("ace/mode/markdown") 81 | 82 | aceCheckAdmin = (ace) -> 83 | ace.setReadOnly(true) if TurkServer.isAdmin() 84 | 85 | Template.docCurrent.helpers 86 | config: -> aceConfig 87 | checkAdmin: -> aceCheckAdmin 88 | -------------------------------------------------------------------------------- /.meteor/versions: -------------------------------------------------------------------------------- 1 | accounts-base@1.2.8 2 | accounts-password@1.1.13 3 | accounts-ui@1.1.9 4 | accounts-ui-unstyled@1.1.12 5 | aldeed:template-extension@3.4.3 6 | allow-deny@1.0.5 7 | analysis@0.0.0 8 | anti:i18n@0.4.3 9 | aslagle:reactive-table@0.8.12 10 | autoupdate@1.2.11 11 | babel-compiler@6.8.5 12 | babel-runtime@0.1.9_1 13 | base64@1.0.9 14 | binary-heap@1.0.9 15 | blaze@2.1.8 16 | blaze-html-templates@1.0.4 17 | blaze-tools@1.0.9 18 | boilerplate-generator@1.0.9 19 | caching-compiler@1.0.6 20 | caching-html-compiler@1.0.6 21 | callback-hook@1.0.9 22 | check@1.2.3 23 | coffeescript@1.1.4 24 | csv@0.0.0 25 | d3js:d3@3.5.5 26 | dandv:caret-position@2.1.1 27 | ddp@1.2.5 28 | ddp-client@1.2.9 29 | ddp-common@1.2.6 30 | ddp-rate-limiter@1.0.5 31 | ddp-server@1.2.10 32 | deps@1.0.12 33 | diff-sequence@1.0.6 34 | ecmascript@0.4.8 35 | ecmascript-runtime@0.2.12 36 | ejson@1.0.12 37 | email@1.0.16 38 | facts@1.0.8 39 | fastclick@1.0.12 40 | geojson-utils@1.0.9 41 | handlebars@1.0.7 42 | hot-code-push@1.0.4 43 | html-tools@1.0.10 44 | htmljs@1.0.10 45 | http@1.1.8 46 | id-map@1.0.8 47 | iron:controller@1.0.12 48 | iron:core@1.0.11 49 | iron:dynamic-template@1.0.12 50 | iron:layout@1.0.12 51 | iron:location@1.0.11 52 | iron:middleware-stack@1.1.0 53 | iron:router@1.0.12 54 | iron:url@1.0.11 55 | jquery@1.11.9 56 | launch-screen@1.0.12 57 | less@2.6.5 58 | livedata@1.0.18 59 | localstorage@1.0.11 60 | logging@1.0.14 61 | matb33:collection-hooks@0.7.15 62 | meteor@1.1.16 63 | meteor-base@1.0.4 64 | minifier-css@1.1.13 65 | minifier-js@1.1.13 66 | minimongo@1.0.17 67 | mizzao:animated-each@0.2.0 68 | mizzao:autocomplete@0.5.1 69 | mizzao:bootboxjs@4.4.0 70 | mizzao:build-fetcher@0.3.2 71 | mizzao:jquery-ui@1.11.4 72 | mizzao:openlayers@2.13.1_3 73 | mizzao:partitioner@0.5.9 74 | mizzao:sharejs@0.9.0 75 | mizzao:sharejs-ace@1.4.1 76 | mizzao:timesync@0.4.0 77 | mizzao:turkserver@0.5.0 78 | mizzao:tutorials@0.6.7 79 | mizzao:user-status@0.6.6 80 | mobile-experience@1.0.4 81 | mobile-status-bar@1.0.12 82 | modules@0.6.5 83 | modules-runtime@0.6.5 84 | momentjs:moment@2.10.6 85 | mongo@1.1.9_1 86 | mongo-id@1.0.5 87 | mongo-livedata@1.0.12 88 | natestrauser:x-editable-bootstrap@1.5.2_3 89 | npm-bcrypt@0.8.6_3 90 | npm-mongo@1.4.45 91 | observe-sequence@1.0.12 92 | ordered-dict@1.0.8 93 | promise@0.7.3 94 | random@1.0.10 95 | rate-limit@1.0.5 96 | reactive-dict@1.1.8 97 | reactive-var@1.0.10 98 | reload@1.1.10 99 | retry@1.0.8 100 | routepolicy@1.0.11 101 | sacha:spin@2.3.1 102 | service-configuration@1.0.10 103 | session@1.1.6 104 | sha@1.0.8 105 | spacebars@1.0.12 106 | spacebars-compiler@1.0.12 107 | srp@1.0.9 108 | standard-minifier-css@1.0.8 109 | standard-minifier-js@1.0.8 110 | stylus@2.512.5 111 | templating@1.1.14 112 | templating-tools@1.0.4 113 | tracker@1.0.15 114 | twbs:bootstrap@3.3.5 115 | ui@1.0.11 116 | underscore@1.0.9 117 | url@1.0.10 118 | webapp@1.2.11 119 | webapp-hashing@1.0.9 120 | -------------------------------------------------------------------------------- /packages/analysis/client/viz.html: -------------------------------------------------------------------------------- 1 | 42 | 43 | 74 | -------------------------------------------------------------------------------- /private/seed-instructions.txt: -------------------------------------------------------------------------------- 1 | These instructions briefly recap the content you went through in the tutorial. This document is editable, so you should edit and improve the instructions below to help other mappers. 2 | 3 | You and your team members will be working actual Twitter reports from Typhoon Pablo (also known as Bopha), which hit the Phillippines in December 2012. Your team must figure out which reports describe relevant crisis events, and record them with the type of event, its description, and its location. By effectively solving this problem and creating an accurate crisis map with your teammates, you can help us learn how to better respond to future natural disasters. 4 | 5 | # Data reports 6 | 7 | - Twitter reports show up on the left of the screen. 8 | - Click on links to see what they refer to. 9 | - Click the red X to hide tweets that are irrelevant. Hidden tweets are hidden for everyone. 10 | 11 | # Event Records 12 | 13 | - Event records are the main way of recording crisis events. 14 | - Drag a tweet by its blue title bar to attach it as a reference to an event. 15 | - Drag tweets between events to reorganize them. 16 | - Move your mouse over a tweet to see its content. 17 | - Click edit to start editing the fields of an event. 18 | - When editing, click a field (underlined) to select or type a new value. 19 | - Click save when you are done editing so that someone else can edit. 20 | - You can edit the location field to type in a longitude and latitude directly, or use the Locate button to place it on the map visually. 21 | - Mouse over the check box on the right to verify an event if you have checked it. 22 | 23 | # Map 24 | 25 | - Drag, pan, and zoom on the map to navigate. 26 | - Mouse over an event on the map to see a popup of its information. 27 | - Click to select events on the map, which will keep the popover open. 28 | - Drag events on the map to change their location. 29 | - Switch between a satellite and political map using the '+' at the top right. 30 | 31 | # Documents 32 | 33 | - Create documents to share information with other users that does not fit in the events, map, or chat. 34 | - Documents can be edited simultaneously. 35 | 36 | # Users and Chat 37 | 38 | - The user list at the top right shows all users that are on your team. 39 | - You can join a chat room by clicking on it, or switch to another chat room. 40 | - You can only be in one room at a time. 41 | - When you are in a room, you can hover over an online user's chat to send them an invite to your room. 42 | - Invites will show up as notifications at the top of the screen. 43 | - When you mention a user in chat, a notification will also appear for them. 44 | - Click notifications to go to the room that they came from. 45 | - Use symbols to tag tweets, events, and users in the chat. Hover over the '?' in the chat input for more info. 46 | 47 | # Payment 48 | 49 | - You will be paid for the amount of time you spend on the task, plus a bonus for how well your team does. 50 | - If you do not do anything for several minutes, you will be put in an idle mode. 51 | - You will not earn anything for the time you spend while idle. 52 | -------------------------------------------------------------------------------- /packages/analysis/scoring.py: -------------------------------------------------------------------------------- 1 | 2 | # Script to score groups using a min-cost max-flow matching algorithm 3 | # based on the ground truth created from clustering 4 | 5 | import argparse 6 | 7 | parser = argparse.ArgumentParser(description='Score groups using the gold standard.') 8 | 9 | parser.add_argument('goldStandardId', help='The world to use as the benchmark.') 10 | 11 | parser.add_argument('--groupId', '-g', help='A specific group to run on. If unspecified, runs on all groups.') 12 | 13 | parser.add_argument('--write', '-w', action='store_const', const=True, default=False, 14 | help='Write results to database.') 15 | 16 | args = parser.parse_args() 17 | 18 | from munkres import Munkres, make_cost_matrix 19 | from math import sqrt, log10 20 | from pymongo import MongoClient 21 | 22 | m = Munkres() 23 | 24 | client = MongoClient('localhost', 3001) 25 | db = client.meteor 26 | 27 | events = db['events'] 28 | worlds = db['analysis.worlds'] 29 | 30 | world_list = list( 31 | worlds.find() if not args.groupId else worlds.find({_id: worlds.groupId}) ) 32 | 33 | gs_events = list( events.find({ 34 | '_groupId': args.goldStandardId, 35 | 'deleted': { '$exists': False }, 36 | # Some events in gold standard don't have location: 37 | # They are just being used to hold data, so ignore them. 38 | 'location': { '$exists': True } 39 | }) ) 40 | 41 | # Scoring function for an event. Current scheme is: 42 | # 0.25 to type, 0.25 to region, 0.25 to province, 43 | # 0.25 for within 10km to 0 beyond 100km 44 | def score(event, goldst): 45 | s = 0 46 | for field in ["type", "region", "province"]: 47 | if field in event and event[field] == goldst[field]: 48 | s += 0.25 49 | 50 | if "location" in event: 51 | dist_meters = 0.1 + sqrt( 52 | sum( (a - b)**2 for a, b in zip(event["location"], goldst["location"]))) 53 | 54 | s += 0.25 * (1 - max(0, min(1, (log10(dist_meters) - 4))) ) 55 | 56 | # Flip so it's a cost matrix 57 | return 1 - s 58 | 59 | # 0.33 = up to 1 field wrong and ~20km away 60 | # < 0.24 = just errors in the location 61 | 62 | errorThresh = 0.33 63 | 64 | for world in world_list: 65 | worldId = world["_id"] 66 | world_events = list( events.find({ 67 | '_groupId': worldId, 68 | 'deleted': { '$exists': False } 69 | }) ) 70 | 71 | # Build matrix for Munkres 72 | mat = [] 73 | for event in world_events: 74 | mat.append([score(event, gs) for gs in gs_events]) 75 | 76 | partialCredit = sum([1 - mat[row][column] for row, column in m.compute(mat)]) 77 | 78 | # Clamp matrix values for a threshold 79 | for row in mat: 80 | for j in range(len(row)): 81 | row[j] = 0 if row[j] < errorThresh else 1 82 | 83 | fullCredit = sum([1 - mat[row][column] for row, column in m.compute(mat)]) 84 | 85 | print 'world %s, size %d, partial %.02f, full %.02f' % ( 86 | worldId, world["nominalSize"], partialCredit, fullCredit) 87 | 88 | if args.write: 89 | world["partialCreditScore"] = partialCredit 90 | world["fullCreditScore"] = fullCredit 91 | worlds.save(world) 92 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | CrowdMapper 2 | =========== 3 | 4 | Real-time collaborative application for tagging streams of geospatial data, built on top of the Meteor Javascript platform. Built with the humanitarian goal of crisis mapping in mind. 5 | 6 | [![CrowdMapper Replay](http://share.gifyoutube.com/mLnMWR.gif)](https://www.youtube.com/watch?v=xJYq_kh6NlI) 7 | 8 | This project uses [TurkServer](https://github.com/HarvardEconCS/turkserver-meteor) to study how people can organize to do crisis mapping. It was used to run the experiment and generate the data for the following paper: 9 | 10 | > [Mao A, Mason W, Suri S, Watts DJ (2016) An Experimental Study of Team Size and Performance on a Complex Task. PLoS ONE 11(4): e0153048.][cm-plos] 11 | 12 | [cm-plos]: http://journals.plos.org/plosone/article?id=10.1371/journal.pone.0153048 13 | 14 | ## Running the app 15 | 16 | [Install Meteor]. Then, clone this repository and run the following command: 17 | 18 | ``` 19 | meteor --settings settings.json 20 | ``` 21 | 22 | If that works, then you should be able to view the data from the experiment. Using this software to run another experiment is a bit more complicated, as it's not that well documented. However, the code is well-commented. 23 | 24 | [install meteor]: https://www.meteor.com/install 25 | 26 | ## Viewing the data 27 | 28 | CrowdMapper was designed to both facilitate a teamwork task as well as log the interactions for further analysis. Using the data from the experiment, you can access visualizations and replays with the following instructions: 29 | 30 | 1. Start Meteor in development mode as above. 31 | 2. Unzip the MongoDB dump to a local directory, e.g. `tar xjvf data.tar.bz2`. Usually, you want to make sure this is in a folder preceded by `.` so that Meteor doesn't try to read it, e.g. `.backups/data`. 32 | 3. Replace the database `mongorestore --host localhost:3001 --drop .backups/data`. Once this finishes, you will have a copy of the database from after the experiment. 33 | 4. You will need to restart Meteor to reset the admin password. Then, you can access the visualizations and replays at http://127.0.0.1:3000/overview. 34 | 35 | To do further analysis on the data, please consult the instructions below, and the source code. 36 | 37 | ## Additional dependencies 38 | 39 | If you are replicating the original analysis from the paper, this project has several dependencies that can't be installed by Meteor. 40 | 41 | The data analysis uses [libzmq](https://github.com/zeromq/libzmq) for Node.js to make RPC calls to algorithms implemented in Python, as outlined in [this blog post](http://ianhinsdale.com/code/2013/12/08/communicating-between-nodejs-and-python/). If you don't have ZeroMQ installed on your system, you will see an error when starting the Meteor app (it should still start) and you won't be able to start the Python computation process or run any of the data analysis methods. 42 | 43 | To make the analysis algorithms available, first install the `libzmq` and `libevent` libraries, using whatever is appropriate for your system. For example, on Ubuntu: 44 | 45 | ``` 46 | apt-get install libzmq-dev 47 | apt-get install libevent 48 | ``` 49 | 50 | If all the above works, Meteor should be able to install the `zerorpc` npm package as part of the app without any issues. 51 | 52 | In addition, install the python dependencies for ZeroRPC, and Munkres (the Hungarian algorithm): 53 | 54 | ``` 55 | pip install pyzmq 56 | pip install zerorpc 57 | pip install munkres 58 | ``` 59 | -------------------------------------------------------------------------------- /packages/analysis/client/groupSlices.html: -------------------------------------------------------------------------------- 1 | 78 | 79 | 84 | -------------------------------------------------------------------------------- /client/lib/mapper.coffee: -------------------------------------------------------------------------------- 1 | # The map needs to load first or openlayers complains 2 | @Mapper = @Mapper || {} 3 | 4 | @ChatUsers = new Mongo.Collection("chatusers") 5 | 6 | # Allow bing map code to be loaded by OpenLayers 7 | UI._allowJavascriptUrls() 8 | 9 | Mapper.events = new EventEmitter() 10 | 11 | Mapper.switchTab = (page) -> 12 | return unless page is "docs" or page is "events" or page is "map" 13 | # Simulate click on navbar in index.coffee 14 | $("a[data-target='#{page}']").trigger("click") 15 | return 16 | 17 | scrollPos = (element, parent) -> 18 | scrollTop: 19 | parent.scrollTop() + element.position().top - parent.height() / 2 + element.height() / 2 20 | 21 | Mapper.selectData = (id) -> 22 | $(".data-cell").removeClass("selected") 23 | $("#data-#{id}").addClass("selected") if id? 24 | return 25 | 26 | Mapper.scrollToData = (id, speed = "slow") -> 27 | parent = $(".scroll-vertical.data-body") 28 | element = $("#data-#{id}") 29 | return unless element.length # Can't scroll to things that aren't in datastream 30 | parent.animate(scrollPos(element, parent), speed) 31 | return 32 | 33 | Mapper.selectEvent = (id) -> 34 | Mapper.mapSelectEvent?(id) 35 | $(".events-body tr").removeClass("selected") 36 | $("#event-#{id}").addClass("selected") if id? 37 | return 38 | 39 | Mapper.scrollToEvent = (id, speed = "slow") -> 40 | parent = $(".scroll-vertical.events-body") 41 | element = $("#event-#{id}") 42 | return unless element.length 43 | parent.animate(scrollPos(element, parent), speed) 44 | return 45 | 46 | Mapper.tweetDragHelper = (e) -> 47 | # Get width of current item, if dragging from datastream 48 | currentWidth = Math.max $(this).width(), 200 49 | data = Blaze.getData(this) 50 | 51 | # Grab tweetId either from datastream object or event tweet array 52 | tweetId = data?._id || data 53 | helper = $ Blaze.toHTMLWithData Template.tweetDragHelper, Datastream.findOne(tweetId) 54 | 55 | # Make a clone of just the text of the same width 56 | # Append to events-body, so it can be scrolled while dragging (see below). 57 | return helper.appendTo(".events-body").width(currentWidth) 58 | 59 | # jQuery UI's scrolling doesn't quite work. So we roll our own. 60 | scrollSensitivity = 80 61 | scrollSpeed = 25 62 | 63 | Mapper.tweetDragScroll = (event, ui) -> 64 | scrollParent = $(".events-body") 65 | overflowOffset = scrollParent.offset() 66 | 67 | # Don't scroll if outside the x-bounds of the event window 68 | return unless overflowOffset.left < event.pageX < overflowOffset.left + scrollParent[0].offsetWidth 69 | 70 | # Adapted from https://github.com/jquery/jquery-ui/blob/1.11.0/ui/draggable.js 71 | if ((overflowOffset.top + scrollParent[0].offsetHeight) - event.pageY < scrollSensitivity) 72 | scrollParent[0].scrollTop = scrollParent[0].scrollTop + scrollSpeed; 73 | else if (event.pageY - overflowOffset.top < scrollSensitivity) 74 | scrollParent[0].scrollTop = scrollParent[0].scrollTop - scrollSpeed; 75 | 76 | Mapper.highlightEvents = -> 77 | Mapper.switchTab("events") 78 | $("#events").addClass("highlighted") 79 | Session.set("guidanceMessage", "Drop on an event below to attach this tweet.") 80 | 81 | Mapper.unhighlightEvents = -> 82 | $("#events").removeClass("highlighted") 83 | Session.set("guidanceMessage", undefined) 84 | 85 | # Highlighting and unhighlighting map can run automatically from a placing event 86 | Deps.autorun -> 87 | id = Session.get("placingEvent") 88 | if id 89 | $("#map").addClass("highlighted") 90 | Session.set("guidanceMessage", "Click to map a location for this event.") 91 | else 92 | $("#map").removeClass("highlighted") 93 | Session.set("guidanceMessage", undefined) 94 | 95 | Mapper.extent = Meteor.settings.public.map.extent 96 | 97 | -------------------------------------------------------------------------------- /packages/analysis/client/routes.coffee: -------------------------------------------------------------------------------- 1 | adminRedirectURL = null 2 | 3 | # This controller handles the behavior of all admin templates 4 | class AdminController extends RouteController 5 | onRun: -> 6 | unless TurkServer.isAdmin() 7 | # Redirect to turkserver admin login 8 | adminRedirectURL = Router.current().url 9 | Router.go("/turkserver") 10 | else 11 | this.next() 12 | 13 | # Redirect to the appropriate path after login, if it was set; then remove. 14 | Tracker.autorun -> 15 | if Meteor.userId() and TurkServer.isAdmin() and adminRedirectURL? 16 | Router.go(adminRedirectURL) 17 | adminRedirectURL = null 18 | 19 | # TODO fix hack below that is used to avoid hitting the server twice 20 | # https://github.com/EventedMind/iron-router/issues/1011 and related 21 | class AdminDataController extends AdminController 22 | waitOn: -> 23 | return if @data()? 24 | 25 | args = @route.options.methodArgs.call(this) 26 | console.log "getting data" 27 | 28 | # Tack on callback argument 29 | args.push (err, res) => 30 | bootbox.alert(err) if err 31 | console.log "got data" 32 | @state.set("data", res) 33 | 34 | Meteor.call.apply(null, args) 35 | 36 | return => @data()? 37 | 38 | data: -> @state.get("data") 39 | 40 | Router.map -> 41 | # Single-instance visualization templates 42 | @route 'viz', 43 | path: 'viz/:groupId/:type?/:layout?' 44 | controller: AdminDataController 45 | methodArgs: -> [ "cm-get-viz-data", this.params.groupId ] 46 | 47 | # Overview route, with access to experiments and stuff 48 | @route 'overview', 49 | controller: AdminController 50 | layoutTemplate: "overviewLayout" 51 | 52 | @route 'overviewExperiments', 53 | path: 'overview/experiments' 54 | controller: AdminController 55 | layoutTemplate: "overviewLayout" 56 | waitOn: -> 57 | Meteor.subscribe("cm-analysis-worlds", { 58 | pseudo: null, 59 | synthetic: null 60 | }) 61 | 62 | @route 'overviewPeople', 63 | path: 'overview/people' 64 | controller: AdminController 65 | layoutTemplate: "overviewLayout" 66 | waitOn: -> Meteor.subscribe("cm-analysis-people") 67 | 68 | @route 'overviewTagging', 69 | path: 'overview/tagging' 70 | controller: AdminDataController 71 | layoutTemplate: "overviewLayout" 72 | methodArgs: -> [ "cm-get-group-cooccurences" ] 73 | 74 | @route 'overviewStats', 75 | path: 'overview/stats' 76 | controller: AdminDataController 77 | layoutTemplate: "overviewLayout" 78 | methodArgs: -> [ "cm-get-action-weights" ] 79 | 80 | @route 'overviewGroupScatter', 81 | path: 'overview/groupScatter' 82 | controller: AdminController 83 | layoutTemplate: "overviewLayout" 84 | waitOn: -> 85 | Meteor.subscribe("cm-analysis-worlds", { 86 | pseudo: null, 87 | synthetic: null 88 | }) 89 | 90 | @route 'overviewGroupPerformance', 91 | path: 'overview/groupPerformance' 92 | controller: AdminController 93 | layoutTemplate: "overviewLayout" 94 | waitOn: -> 95 | Meteor.subscribe("cm-analysis-worlds") 96 | 97 | @route 'overviewGroupSlices', 98 | path: 'overview/groupSlices' 99 | controller: AdminController 100 | layoutTemplate: "overviewLayout" 101 | waitOn: -> 102 | Meteor.subscribe("cm-analysis-worlds", { 103 | pseudo: null, 104 | synthetic: null 105 | }) 106 | 107 | @route 'overviewIndivPerformance', 108 | path: 'overview/indivPerformance' 109 | controller: AdminController 110 | layoutTemplate: "overviewLayout" 111 | waitOn: -> Meteor.subscribe("cm-analysis-people") 112 | -------------------------------------------------------------------------------- /private/fields-pablo.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "key": "type", 4 | "name": "Type", 5 | "order": 1, 6 | "type": "dropdown", 7 | "choices": [ 8 | "Damaged bridges", 9 | "Damaged crops", 10 | "Damaged hospitals/health facilities", 11 | "Damaged housing", 12 | "Damaged roads", 13 | "Damaged schools", 14 | "Damaged vehicles", 15 | "Damaged infrastructure (other)", 16 | "Death(s) reported", 17 | "Displaced population", 18 | "Evacuation center", 19 | "Flooding" 20 | ] 21 | }, 22 | { 23 | "key": "description", 24 | "name": "Description", 25 | "order": 2, 26 | "type": "text" 27 | }, 28 | { 29 | "key": "region", 30 | "name": "Region", 31 | "order": 3, 32 | "type": "dropdown", 33 | "choices": [ 34 | "ARMM - Autonomous Region in Muslim Mindanao", 35 | "CAR - Cordillera Administrative Region", 36 | "NCR - National Capital Region", 37 | "REGION I (Ilocos Region)", 38 | "REGION II (Cagayan Valley)", 39 | "REGION III (Central Luzon)", 40 | "REGION IV-A (Calabarzon)", 41 | "REGION IV-B (Mimaropa)", 42 | "REGION V (Bicol Region)", 43 | "REGION VI (Western Visayas)", 44 | "REGION VII (Central Visayas)", 45 | "REGION VIII (Eastern Visayas)", 46 | "REGION IX (Zamboanga Peninsula)", 47 | "REGION X (Northern Mindanao)", 48 | "REGION XI (Davao Region)", 49 | "REGION XII (Soccsksargen)", 50 | "REGION XIII (Caraga)" 51 | ] 52 | }, 53 | { 54 | "key": "province", 55 | "name": "Province", 56 | "order": 4, 57 | "type": "dropdown", 58 | "choices": [ 59 | "Abra", 60 | "Agusan del Norte", 61 | "Agusan del Sur", 62 | "Aklan", 63 | "Albay", 64 | "Antique", 65 | "Apayao", 66 | "Aurora", 67 | "Basilan", 68 | "Bataan", 69 | "Batanes", 70 | "Batangas", 71 | "Benguet", 72 | "Biliran", 73 | "Bohol", 74 | "Bukidnon", 75 | "Bulacan", 76 | "Cagayan", 77 | "Camarines Norte", 78 | "Camarines Sur", 79 | "Camiguin", 80 | "Capiz", 81 | "Catanduanes", 82 | "Cavite", 83 | "Cebu", 84 | "Compostela Valley", 85 | "Cotabato", 86 | "Davao del Norte", 87 | "Davao del Sur", 88 | "Davao Oriental", 89 | "Dinagat Islands", 90 | "Eastern Samar", 91 | "Guimaras", 92 | "Ifugao", 93 | "Ilocos Norte", 94 | "Ilocos Sur", 95 | "Iloilo", 96 | "Isabela", 97 | "Kalinga", 98 | "La Union", 99 | "Laguna", 100 | "Lanao del Norte", 101 | "Lanao del Sur", 102 | "Leyte", 103 | "Maguindanao", 104 | "Marinduque", 105 | "Masbate", 106 | "Metro Manila", 107 | "Misamis Occidental", 108 | "Misamis Oriental", 109 | "Mountain Province", 110 | "Negros Occidental", 111 | "Negros Oriental", 112 | "Northern Samar", 113 | "Nueva Ecija", 114 | "Nueva Vizcaya", 115 | "Occidental Mindoro", 116 | "Oriental Mindoro", 117 | "Palawan", 118 | "Pampanga", 119 | "Pangasinan", 120 | "Quezon", 121 | "Quirino", 122 | "Rizal", 123 | "Romblon", 124 | "Samar", 125 | "Sarangani", 126 | "Siquijor", 127 | "Sorsogon", 128 | "South Cotabato", 129 | "Southern Leyte", 130 | "Sultan Kudarat", 131 | "Sulu", 132 | "Surigao del Norte", 133 | "Surigao del Sur", 134 | "Tarlac", 135 | "Tawi-Tawi", 136 | "Zambales", 137 | "Zamboanga del Norte", 138 | "Zamboanga del Sur", 139 | "Zamboanga Sibugay" 140 | ] 141 | } 142 | ] 143 | -------------------------------------------------------------------------------- /packages/analysis/client/tagging.coffee: -------------------------------------------------------------------------------- 1 | skipEventSize = 20 2 | 3 | computeGraph = (occurrences, filter) -> 4 | occurrences = _.filter(occurrences, (o) -> o.length <= skipEventSize) 5 | 6 | # compute number of occurrences for each tweet 7 | nodes = d3.nest() 8 | .key(Object) 9 | .rollup( (leaves) -> leaves.length ) 10 | .entries($.map(occurrences, Object)) 11 | 12 | # Only consider tweets that were tagged at least once 13 | nodes = _.filter(nodes, (e) -> e.values > 1) if filter 14 | 15 | # Create a map of nodes for the next step (may be helpful later too) 16 | indices = {} 17 | for obj, i in nodes 18 | indices[obj.key] = i 19 | 20 | console.log "#{nodes.length} nodes" 21 | 22 | # Compute co-occurrences for each pair 23 | 24 | # Create a temporary 2-level associative array, indexing higher numbers first 25 | linkMap = {} 26 | 27 | for arr in occurrences 28 | 29 | for x, i in arr 30 | continue unless indices[x] 31 | 32 | j = i+1 33 | 34 | while j < arr.length 35 | y = arr[j] 36 | j++ 37 | continue unless indices[y] 38 | 39 | [first, second] = if parseInt(x) > parseInt(y) then [x, y] else [y, x] 40 | 41 | linkMap[first] ?= {} 42 | linkMap[first][second] ?= { count: 0 } 43 | linkMap[first][second].count++ 44 | 45 | links = [] 46 | 47 | for k1, map of linkMap 48 | for k2, val of map 49 | links.push 50 | source: indices[k1] 51 | target: indices[k2] 52 | value: val.count 53 | 54 | console.log "#{links.length} edges" 55 | 56 | return [nodes, links] 57 | 58 | minNodeRadius = 3 59 | minCharge = -15 60 | 61 | Template.overviewTagging.rendered = -> 62 | tmpl = this 63 | 64 | occurrences = this.data.occurrences 65 | tweetText = this.data.tweetText 66 | 67 | console.log "#{occurrences.length} events" 68 | 69 | svg = @find("svg") 70 | width = $(svg).width() 71 | height = $(svg).height() 72 | 73 | gEdges = d3.select(svg).append("g") 74 | gNodes = d3.select(svg).append("g") 75 | 76 | strengthScale = d3.scale.pow().exponent(3) 77 | 78 | force = d3.layout.force() 79 | .size([width, height]) 80 | # Bigger nodes should repel harder, but not too much harder... 81 | .charge( (d) -> minCharge * d.values ) 82 | .linkDistance(15) 83 | 84 | @redrawGraph = (filterNodes) -> 85 | [nodes, links] = computeGraph(occurrences, filterNodes) 86 | 87 | maxStr = d3.max(nodes, (d) -> d.values) 88 | 89 | # The co-occurrence strength needs to be nonlinear 90 | # and rapidly increasing with for smaller values 91 | force.linkStrength( (e) -> 1 - strengthScale(1 - e.value / maxStr) ) 92 | .nodes(nodes) 93 | .links(links) 94 | 95 | linkEls = gEdges.selectAll(".link") 96 | .data(links, (l) -> "#{l.source},#{l.target}" ) 97 | 98 | linkEls.enter().append("line") 99 | .attr("class", "link") 100 | .style("stroke-width", (d) -> Math.sqrt(d.value) ) 101 | 102 | linkEls.exit().remove() 103 | 104 | nodeEls = gNodes.selectAll(".node") 105 | .data(nodes, (n) -> n.key) 106 | 107 | nodeEls.enter().append("circle") 108 | .attr("class": "node") 109 | .attr("r", (d) -> minNodeRadius * Math.sqrt(d.values) ) 110 | # .style("fill", function(d) { return color(d.group); }) 111 | .call(force.drag) 112 | .append("title") 113 | .text( (d) -> "#{d.key} #{tweetText[parseInt(d.key)]}" ) 114 | 115 | nodeEls.exit().remove() 116 | 117 | tmpl.link = linkEls 118 | tmpl.node = nodeEls 119 | 120 | force.start() 121 | 122 | # Redraw events 123 | force.on "tick", -> 124 | tmpl.link 125 | .attr("x1", (d) -> d.source.x ) 126 | .attr("y1", (d) -> d.source.y ) 127 | .attr("x2", (d) -> d.target.x ) 128 | .attr("y2", (d) -> d.target.y ) 129 | tmpl.node 130 | .attr("cx", (d) -> d.x) 131 | .attr("cy", (d) -> d.y) 132 | 133 | @redrawGraph(false) # With checked box 134 | 135 | Template.overviewTagging.events 136 | "change input[type=checkbox]": (e, t) -> 137 | t.redrawGraph(e.target.checked) 138 | -------------------------------------------------------------------------------- /client/views/chat.html: -------------------------------------------------------------------------------- 1 | 19 | 20 | 29 | 30 | 41 | 42 | 55 | 56 | 62 | 63 | 71 | 72 | 85 | 86 | 95 | 96 | 112 | 113 | 121 | -------------------------------------------------------------------------------- /server/chat_server.coffee: -------------------------------------------------------------------------------- 1 | # Don't persist the contents of this collection 2 | @ChatUsers = new Mongo.Collection("chatusers") #, {connection: null}) 3 | 4 | # Because it is in the DB, we can have this index 5 | ChatUsers._ensureIndex({roomId: 1}) 6 | 7 | # Index chat messages by room and then by timestamp. 8 | # It will not be partitioned by TurkServer. 9 | ChatMessages._ensureIndex({ room: 1, timestamp: 1}) 10 | 11 | # Managed by TurkServer; publish all chatrooms 12 | # Deleted chatrooms are filtered on the client 13 | Meteor.publish "chatrooms", -> ChatRooms.find() 14 | 15 | # Generalize what we are doing below 16 | 17 | enterRoom = (sessionId, roomId, userId) -> 18 | ChatUsers.upsert sessionId, 19 | $set: { userId, roomId } 20 | 21 | ChatRooms.update roomId, 22 | $inc: { users: 1 } 23 | 24 | TurkServer.log 25 | action: "room-enter" 26 | room: roomId 27 | 28 | leaveRoom = (sessionId, roomId, userId) -> 29 | # Remove the chatuser record unless they changed rooms 30 | ChatUsers.remove 31 | _id: sessionId 32 | roomId: roomId 33 | 34 | ChatRooms.update roomId, 35 | $inc: { users: -1 } 36 | 37 | # Because this is no longer a null connection, clear it on startup 38 | Meteor.startup -> 39 | ChatUsers.remove({}) 40 | 41 | # Clear all users stored in chatrooms on start 42 | TurkServer.startup -> 43 | ChatRooms.update {}, 44 | $set: 45 | {users: 0} 46 | , multi: true 47 | 48 | # Clean up any chat state when a user disconnects 49 | UserStatus.events.on "connectionLogout", (doc) -> 50 | # No groupId used here because ChatUsers and ChatRooms are not partitioned 51 | if (existing = ChatUsers.findOne(doc.sessionId))? 52 | leaveRoom(doc.sessionId, existing.roomId, doc.userId) 53 | 54 | # publish messages and users for a room 55 | Meteor.publish "chatstate", (room) -> 56 | userId = @userId 57 | return [] unless userId? # No chat for unauthenticated users 58 | sessionId = @_session.id 59 | 60 | # Don't update room state for admin 61 | unless Meteor.users.findOne(userId)?.admin 62 | # don't try to enter room if no room specified 63 | return [] unless room 64 | 65 | # Update room state - except for admin 66 | enterRoom(sessionId, room, userId) 67 | 68 | this.onStop -> 69 | # Leave this room when the subscription is stopped 70 | leaveRoom(sessionId, room, userId) 71 | else 72 | # Don't publish arbitrary rooms to admin 73 | return [] unless room 74 | 75 | return [ 76 | ChatUsers.find(roomId: room), 77 | ChatMessages.find(room: room) 78 | ] 79 | 80 | userRegex = new RegExp('(^|\\b|\\s)(@[\\w.]+)($|\\b|\\s)','g') 81 | 82 | unreadNotificationExists = (user, sender, room, type) -> 83 | return Notifications.findOne({user, sender, room, type, read: {$exists: false} })? 84 | 85 | Meteor.methods 86 | inviteChat: (userId, roomId) -> 87 | TurkServer.checkNotAdmin() 88 | check(userId, String) 89 | check(roomId, String) 90 | 91 | myId = Meteor.userId() 92 | return unless myId? 93 | # Don't invite if user is already in the same room 94 | return if ChatUsers.findOne(userId: userId)?.roomId is roomId 95 | 96 | # Skip invite if this user already has an outstanding invitee to the other user to this room 97 | return if unreadNotificationExists(userId, myId, roomId, "invite") 98 | 99 | Notifications.insert 100 | user: userId 101 | sender: myId 102 | type: "invite" 103 | room: roomId 104 | timestamp: new Date() 105 | 106 | # No need to log this, we have it as a notification 107 | return 108 | 109 | sendChat: (roomId, message) -> 110 | TurkServer.checkNotAdmin() 111 | check(roomId, String) 112 | check(message, String) 113 | 114 | userId = Meteor.userId() 115 | return unless userId? 116 | 117 | chatTime = new Date() 118 | 119 | obj = 120 | room: roomId 121 | userId: userId 122 | text: message 123 | timestamp: chatTime # Attach server-side timestamps to chat messages 124 | 125 | ChatMessages.insert(obj) 126 | @unblock() 127 | 128 | # Parse and generate any notifications from this chat, using this regex ability 129 | message.replace userRegex, (_, p1, p2) -> 130 | targetUser = Meteor.users.findOne({username: p2.substring(1)}) 131 | return unless targetUser? 132 | # Don't notify if user is already in the same room 133 | return if ChatUsers.findOne(userId: targetUser._id)?.roomId is roomId 134 | 135 | return if unreadNotificationExists(targetUser._id, userId, roomId, "mention") 136 | 137 | Notifications.insert 138 | user: targetUser._id 139 | sender: userId 140 | type: "mention" 141 | room: roomId 142 | timestamp: chatTime 143 | -------------------------------------------------------------------------------- /packages/analysis/tagging_biclustering.py: -------------------------------------------------------------------------------- 1 | # print(__doc__) 2 | 3 | # Script to use scikit-learn for spectral co-clustering. Run this 4 | # after generating the analysis datasets from experimental data 5 | 6 | import sys 7 | import argparse 8 | 9 | parser = argparse.ArgumentParser(description='Run co-clustering from local database.') 10 | 11 | # We expect about this many events from Pablo 12 | parser.add_argument('--clusters', '-c', type=int, default=100, 13 | help='number of clusters') 14 | 15 | # Ignore events with too much stuff, no signal 16 | parser.add_argument('--threshold', '-t', type=int, default=None, 17 | help='threshold above which events are ignored') 18 | 19 | # Whether to write the clusters back to the database 20 | parser.add_argument('--write', '-w', action='store_const', const=True, default=False, 21 | help='write results to db') 22 | 23 | args = parser.parse_args() 24 | 25 | skip_thresh = args.threshold 26 | n_clusters = args.clusters 27 | 28 | import numpy as np 29 | from matplotlib import pyplot as plt 30 | import pymongo 31 | from pymongo import MongoClient 32 | 33 | from sklearn.cluster.bicluster import SpectralCoclustering 34 | # from sklearn.metrics import consensus_score 35 | 36 | client = MongoClient('localhost', 3001) 37 | db = client.meteor 38 | 39 | events = db['analysis.events'] 40 | datastream = db['analysis.datastream'] 41 | 42 | identifier = "i%d_c%d" % (skip_thresh, n_clusters) if skip_thresh else "c%d" % (n_clusters) 43 | 44 | # Build array of relationships between events and tweets 45 | 46 | n_rows = datastream.find().count() 47 | n_cols = events.find().count() 48 | shape = (n_rows, n_cols) 49 | 50 | # Should we really use float64 here? 51 | data = np.ones(shape, dtype=np.float64) * 0.001 52 | 53 | # Map tweets to a contiguous list, for now 54 | data_list = list(datastream.find().sort('num', pymongo.ASCENDING)) 55 | events_list = list(events.find()) 56 | 57 | row_lookup = {} 58 | for i, tweet in enumerate(data_list): 59 | row_lookup[tweet['num']] = i 60 | 61 | for j, event in enumerate(events_list): 62 | sources = event['sources'] 63 | # Skip empty lists 64 | if not sources: 65 | continue 66 | 67 | # TODO hack: skip very long lists 68 | if skip_thresh and len(sources) > skip_thresh: 69 | continue 70 | 71 | # All events have numbered tweets 72 | rowSelector = np.array([row_lookup[source] for source in sources]) 73 | data[rowSelector, j] = 1 74 | 75 | plt.matshow(data, cmap=plt.cm.Blues) 76 | plt.title("Original dataset") 77 | 78 | plt.savefig('%s_original.png' % (identifier), bbox_inches='tight') 79 | 80 | model = SpectralCoclustering(n_clusters=n_clusters, random_state=0) 81 | model.fit(data) 82 | 83 | fit_data = data[np.argsort(model.row_labels_)] 84 | fit_data = fit_data[:, np.argsort(model.column_labels_)] 85 | 86 | plt.matshow(fit_data, cmap=plt.cm.Blues) 87 | plt.title("After biclustering; rearranged") 88 | 89 | plt.savefig('%s_clustered.png' % (identifier), bbox_inches='tight') 90 | 91 | avg_data = np.copy(data) 92 | 93 | # Compute average value in each co-cluster for display purposes 94 | for c in range(n_clusters): 95 | for d in range(n_clusters): 96 | row_ind = np.nonzero(model.rows_[c]) 97 | col_ind = np.nonzero(model.columns_[d]) 98 | # print row_ind, col_ind 99 | 100 | row_sel = np.tile(row_ind, (col_ind[0].size, 1)) 101 | col_sel = np.tile(col_ind, (row_ind[0].size, 1)).transpose() 102 | # print row_sel, col_sel 103 | 104 | avg_data[row_sel, col_sel] = np.average(data[row_sel, col_sel]) 105 | 106 | avg_data = avg_data[np.argsort(model.row_labels_)] 107 | avg_data = avg_data[:, np.argsort(model.column_labels_)] 108 | 109 | plt.matshow(avg_data, cmap=plt.cm.Blues) 110 | plt.title("Average cluster intensity") 111 | 112 | plt.savefig('%s_averaged.png' % (identifier), bbox_inches='tight') 113 | 114 | if args.write: 115 | print "Writing clusters to database." 116 | # No need to clean up here, just overwrite by _id. 117 | for c in range(n_clusters): 118 | (nr, nc) = model.get_shape(c) 119 | (row_ind, col_ind) = model.get_indices(c) 120 | 121 | cluster_val = None 122 | if nr > 25 or nc > 50: 123 | print "Nulling cluster %d: shape (%d, %d)" % (c, nr, nc) 124 | else: 125 | cluster_val = c 126 | 127 | for ri in row_ind: 128 | data_list[ri]['cluster'] = cluster_val 129 | datastream.save(data_list[ri]) 130 | for ci in col_ind: 131 | events_list[ci]['cluster'] = cluster_val 132 | events.save(events_list[ci]) 133 | 134 | # plt.show() 135 | -------------------------------------------------------------------------------- /packages/analysis/client/overview.coffee: -------------------------------------------------------------------------------- 1 | Template.overview.events 2 | "click .cm-analysis": (e) -> 3 | method = $(e.target).data("method") 4 | 5 | dialog = bootbox.dialog 6 | closeButton: false 7 | message: "

Working...

" 8 | 9 | Meteor.call method, (err, res) -> 10 | dialog.modal("hide") 11 | if err 12 | bootbox.alert(err) 13 | else 14 | bootbox.alert("done") 15 | 16 | "click .cm-download": (e) -> 17 | $target = $(e.target) 18 | Meteor.call $target.data("method"), $target.data("arg1"), $target.data("arg2"), (err, res) -> 19 | if err 20 | bootbox.alert(err) 21 | else 22 | # http://stackoverflow.com/a/18197511/586086 23 | pom = document.createElement('a') 24 | pom.setAttribute("href", "data:text/csv," + encodeURIComponent(res)) 25 | pom.setAttribute("download", "data.csv") 26 | pom.click() 27 | 28 | Template.analysisExpLinks.helpers 29 | # Get the groupId associated with an analysis.world or analysis.person. 30 | id: -> @instanceId || @_id 31 | 32 | Template.overviewExperiments.helpers 33 | settings: { 34 | collection: Analysis.Worlds 35 | rowsPerPage: 100 36 | fields: [ 37 | { 38 | key: "nominalSize" 39 | label: "nominal size" 40 | sort: 'descending' # Default sort order 41 | }, 42 | { 43 | key: "wallTime" 44 | label: "wall time" 45 | fn: (v) -> v.toFixed(2) 46 | sortByValue: true 47 | }, 48 | { 49 | key: "personTime" 50 | label: "person-time" 51 | fn: (v) -> v.toFixed(2) 52 | sortByValue: true 53 | }, 54 | { 55 | key: "totalEffort" 56 | label: "effort-time" 57 | fn: (v) -> v.toFixed(2) 58 | sortByValue: true 59 | }, 60 | { 61 | key: "personEffort" 62 | label: "effort/person" 63 | fn: (v, o) -> 64 | # Return a number so value is properly sorted 65 | +(o.totalEffort / o.personTime).toFixed(2) 66 | }, 67 | { 68 | key: "treated" 69 | label: "valid treatment" 70 | }, 71 | { 72 | key: "partialCreditScore" 73 | label: "partial score" 74 | fn: (v) -> v.toFixed(3) 75 | sortByValue: true 76 | }, 77 | { 78 | key: "fullCreditScore" 79 | label: "0-1 score" 80 | }, 81 | { 82 | key: "avgIndivEntropy" 83 | label: "mean indiv. entropy" 84 | fn: (v) -> v.toFixed(3) 85 | sortByValue: true 86 | }, 87 | { 88 | key: "groupEntropy" 89 | label: "collective entropy" 90 | fn: (v) -> v.toFixed(3) 91 | sortByValue: true 92 | }, 93 | { 94 | key: "links" 95 | label: "links" 96 | tmpl: Template.analysisExpLinks 97 | } 98 | ] 99 | } 100 | 101 | Template.overviewPeople.helpers 102 | settings: { 103 | collection: Analysis.People 104 | rowsPerPage: 100 105 | fields: [ 106 | { 107 | key: "age" 108 | label: "age" 109 | }, 110 | { 111 | key: "gender" 112 | label: "gender" 113 | }, 114 | { 115 | key: "groupSize" 116 | label: "group size" 117 | }, 118 | { 119 | key: "time" 120 | label: "active time" 121 | fn: (v) -> v.toFixed(2) 122 | sortByValue: true 123 | }, 124 | { 125 | key: "effort" 126 | label: "effort-time" 127 | fn: (v) -> v.toFixed(2) 128 | sortByValue: true 129 | }, 130 | { 131 | key: "normalizedEffort" 132 | label: "effort/time" 133 | fn: (v, o) -> 134 | # Return a number so value is properly sorted 135 | +(o.effort / o.time).toFixed(2) 136 | }, 137 | { 138 | key: "treated" 139 | label: "valid treatment" 140 | }, 141 | { 142 | key: "tutorialWords" 143 | label: "tut. response words" 144 | }, 145 | { 146 | key: "tutorialMins" 147 | label: "tut. time mins" 148 | fn: (v) -> v.toFixed(2) 149 | sortByValue: true 150 | }, 151 | { 152 | key: "exitSurveyWords" 153 | label: "exit survey words" 154 | }, 155 | { 156 | key: "links" 157 | label: "links" 158 | tmpl: Template.analysisExpLinks 159 | } 160 | 161 | ] 162 | } 163 | 164 | Template.overviewStats.helpers 165 | actionArray: -> ({action: k, time: v} for k, v of this) 166 | settings: { 167 | rowsPerPage: 50 168 | fields: [ 169 | { 170 | key: "action" 171 | label: "action" 172 | }, 173 | { 174 | key: "time" 175 | label: "mean time since previous action (s)" 176 | fn: (v) -> (v / 1000).toFixed(2) 177 | sortByValue: true 178 | } 179 | ] 180 | } 181 | 182 | Template.sizeLegend.helpers 183 | color: Util.groupColor 184 | -------------------------------------------------------------------------------- /server/firstrun.coffee: -------------------------------------------------------------------------------- 1 | Meteor.startup -> 2 | return if EventFields.find().count() > 0 3 | 4 | pabloFields = Assets.getText("fields-pablo.json") 5 | 6 | EventFields.insert(field) for field in JSON.parse(pabloFields) 7 | 8 | # Set up treatments 9 | Meteor.startup -> 10 | TurkServer.ensureTreatmentExists 11 | name: "tutorial" 12 | tutorial: "pre_task" 13 | tutorialEnabled: true 14 | payment: 1.00 15 | 16 | TurkServer.ensureTreatmentExists 17 | name: "recruiting" 18 | tutorial: "recruiting" 19 | tutorialEnabled: true 20 | payment: 1.00 21 | 22 | TurkServer.ensureTreatmentExists 23 | name: "parallel_worlds" 24 | wage: 6.00 25 | bonus: 9.00 26 | 27 | # Create Assigner on recruiting batch, if it exists 28 | if (batch = Batches.findOne(treatments: $in: [ "recruiting" ]))? 29 | # Enable re-attempts on recruiting batch if returned 30 | Batches.update batch._id, 31 | $set: allowReturns: true 32 | 33 | TurkServer.Batch.getBatch(batch._id).setAssigner(new TurkServer.Assigners.SimpleAssigner) 34 | console.log "Set up assigner on recruiting batch" 35 | 36 | # Ensure we have a hit type on this batch 37 | HITTypes.upsert { 38 | batchId: batch._id, 39 | Title: "Complete a tutorial for the Crisis Mapping Project" 40 | }, { 41 | $setOnInsert: { 42 | Description: "Complete a tutorial for the Crisis Mapping Project, which takes about 10 minutes. After you complete this, you will be qualified to participate in collaborative Crisis Mapping sessions, which will pay from $6 to $15 per hour. You may see some disturbing content from natural disasters. 43 | 44 | You cannot do this HIT if you've done it before. If you accept it again, you will be asked to return it." 45 | Keywords: "crisis mapping, tutorial, collaborative" 46 | Reward: 1.00 47 | QualificationRequirement: [ 48 | Qualifications.findOne({ # 95% 49 | QualificationTypeId: "000000000000000000L0" 50 | Comparator: "GreaterThanOrEqualTo" 51 | IntegerValue: "95" 52 | })._id 53 | Qualifications.findOne({ # 100 HITs 54 | QualificationTypeId: "00000000000000000040" 55 | Comparator: "GreaterThan" 56 | IntegerValue: "100" 57 | })._id 58 | Qualifications.findOne({ # US Worker 59 | QualificationTypeId: "00000000000000000071" 60 | Comparator: "EqualTo" 61 | LocaleValue: "US" 62 | })._id 63 | Qualifications.findOne({ # Adult Worker 64 | QualificationTypeId: "00000000000000000060" 65 | Comparator: "EqualTo" 66 | IntegerValue: "1" 67 | })._id 68 | ] 69 | AssignmentDurationInSeconds: 43200 70 | AutoApprovalDelayInSeconds: 86400 71 | } 72 | } 73 | 74 | # Set up pilot testing batch - currently disabled 75 | if Meteor.settings.pilot 76 | 77 | TurkServer.ensureBatchExists 78 | name: "pilot testing" 79 | 80 | pilotBatchId = Batches.findOne(name: "pilot testing")._id 81 | 82 | Batches.upsert pilotBatchId, 83 | $addToSet: { treatments: "parallel_worlds" } 84 | 85 | pilotBatch = TurkServer.Batch.getBatch(pilotBatchId) 86 | pilotBatch.setAssigner new TurkServer.Assigners.TutorialGroupAssigner( 87 | [ "tutorial" ], [ "parallel_worlds" ] 88 | ) 89 | console.log "Set up pilot testing assigner" 90 | 91 | ### 92 | Set up group size experiments! Yay! 93 | 94 | Old group size batch was "group sizes" which we are discarding, because no 95 | one got through it during this server crash. 96 | 97 | This is the new one. 98 | ### 99 | groupSizeBatchName = "group sizes redux" 100 | 101 | TurkServer.ensureBatchExists 102 | name: groupSizeBatchName 103 | 104 | groupSizeBatchId = Batches.findOne(name: groupSizeBatchName)._id 105 | 106 | # Needed to trigger the right preview/exit survey 107 | Batches.upsert groupSizeBatchId, 108 | $addToSet: { treatments: "parallel_worlds" } 109 | 110 | groupBatch = TurkServer.Batch.getBatch(groupSizeBatchId) 111 | # 8x1, 4x2, 2x4, 1x8, 1x16, 1x32 112 | groupArray = [ 113 | 1, 1, 1, 1, 1, 1, 1, 1, 114 | 2, 2, 2, 2, 115 | 4, 4, 116 | 8, 16, 32 117 | ] 118 | 119 | groupAssigner = new TurkServer.Assigners.TutorialRandomizedGroupAssigner( 120 | [ "tutorial" ], [ "parallel_worlds" ], groupArray) 121 | 122 | groupBatch.setAssigner(groupAssigner) 123 | 124 | Meteor._debug "Set up group size assigner" 125 | 126 | Meteor.methods 127 | "cm-delete-world-data": (worldId) -> 128 | TurkServer.checkAdmin() 129 | 130 | if Experiments.remove(worldId) 131 | Partitioner.bindGroup worldId, -> 132 | Events.remove({}) 133 | Datastream.remove({}) 134 | 135 | return 136 | 137 | # Load gold standard data if it exists 138 | tryImport = (worldName) -> 139 | return if Experiments.findOne(worldName)? 140 | result = JSON.parse Assets.getText("#{worldName}.json") 141 | 142 | Experiments.upsert({worldName}, $set: { treatments: [ "editable" ] }) 143 | 144 | for event in result.events 145 | event._groupId = worldName 146 | Events.direct.insert(event) 147 | 148 | for data in result.datastream 149 | data._groupId = worldName 150 | Datastream.direct.insert(data) 151 | 152 | console.log "Imported #{worldName}; events: #{result.events.length}, datastream: #{result.datastream.length}" 153 | 154 | Meteor.startup -> tryImport("groundtruth-pablo") 155 | Meteor.startup -> tryImport("sbtf-pablo") 156 | -------------------------------------------------------------------------------- /client/meta/exitsurvey.html: -------------------------------------------------------------------------------- 1 | 4 | 5 | 8 | 9 | 62 | 63 | 123 | -------------------------------------------------------------------------------- /client/index.html: -------------------------------------------------------------------------------- 1 | 2 | CrowdMapper 3 | 4 | 5 | 12 | 13 | 16 | 17 | 22 | 23 | 30 | 31 | 36 | 37 | 42 | 43 | 48 | 49 | 77 | 78 | 83 | 84 | 91 | 92 | 115 | 116 | 119 | 120 | 133 | 134 | 152 | 153 | 172 | 173 | -------------------------------------------------------------------------------- /client/views/common.coffee: -------------------------------------------------------------------------------- 1 | urlExp = /(\b(https?|ftp|file):\/\/[-A-Z0-9+&@#\/%?=~_|!:,.;]*[-A-Z0-9+&@#\/%=~_|])/ig 2 | 3 | UI.registerHelper "replaceURLs", (text) -> 4 | # Shim so old links aren't broken, can be removed later for speed 5 | return text if text.indexOf("target='_blank'>") > -1 6 | text.replace(urlExp, "$1") 7 | 8 | Template.userList.helpers 9 | loaded: -> Session.equals("userSubReady", true) 10 | users: -> Meteor.users.find() 11 | 12 | Template.userPill.helpers 13 | labelClass: -> 14 | if @_id is Meteor.userId() 15 | "inverse" 16 | else if @status?.online 17 | "success" 18 | else "default" 19 | 20 | Template.userPill.events = 21 | "click .action-chat-invite": (e) -> 22 | myId = Meteor.userId() 23 | unless myId? 24 | bootbox.alert("You must be logged in to invite others to chat.") 25 | return 26 | 27 | myRoom = Session.get("room") 28 | unless myRoom? 29 | bootbox.alert("Join a chat room first to invite someone to chat with you.") 30 | return 31 | 32 | user = Blaze.getData(e.target) 33 | 34 | if ChatUsers.findOne(userId: user._id)? 35 | bootbox.alert("You and #{user.username} are already in the same room.") 36 | return 37 | 38 | bootbox.confirm "Invite #{user.username} to join you in " + ChatRooms.findOne(myRoom).name + "?" 39 | , (result) -> 40 | Meteor.call "inviteChat", user._id, myRoom if result 41 | 42 | # String conversion needed: https://github.com/meteor/meteor/issues/1447 43 | Handlebars.registerHelper "findTweet", -> Datastream.findOne(""+@) 44 | 45 | Handlebars.registerHelper "lookupUser", -> Meteor.users.findOne(""+@) 46 | 47 | ### 48 | XXX tweet icon mouseover and dragging is handled at the global page level and the 49 | event page level respectively 50 | ### 51 | 52 | Template.tweetIcon.events = 53 | "click .action-unlink-tweet": (e) -> 54 | # This needs to work on both events and map 55 | # both the table row and the popup are .event-record 56 | tweet = Blaze.getData(e.target) 57 | event = Blaze.getData $(e.target).closest(".event-record")[0] 58 | 59 | # The unlink function will also hide this if it's not tagged somewhere 60 | # TODO: the unlink-hide process causes an unwanted scroll adjustment 61 | Meteor.call "dataUnlink", tweet._id, event._id 62 | 63 | # Mapping helpers 64 | epsg4326 = new OpenLayers.Projection("EPSG:4326") 65 | epsg900913 = new OpenLayers.Projection("EPSG:900913") 66 | 67 | transformLocation = (location) -> 68 | point = new OpenLayers.Geometry.Point(location[0], location[1]) 69 | point.transform(epsg900913, epsg4326) 70 | return [point.x, point.y] 71 | 72 | formatLocation = (location) -> 73 | return "" unless location 74 | [x, y] = transformLocation(location) 75 | return x.toFixed(2) + ", " + y.toFixed(2) 76 | 77 | Handlebars.registerHelper "formatLocation", -> formatLocation(@location) 78 | 79 | transformLongLat = (longlat) -> 80 | point = new OpenLayers.Geometry.Point(longlat[0], longlat[1]) 81 | point.transform(epsg4326, epsg900913) 82 | return [point.x, point.y] 83 | 84 | minLongLat = transformLocation([Mapper.extent[0], Mapper.extent[1]]) 85 | maxLongLat = transformLocation([Mapper.extent[2], Mapper.extent[3]]) 86 | 87 | entryPrecision = 3 88 | 89 | Template.longLatEntry.helpers 90 | minLong: minLongLat[0].toFixed(entryPrecision) 91 | maxLong: maxLongLat[0].toFixed(entryPrecision) 92 | minLat: minLongLat[1].toFixed(entryPrecision) 93 | maxLat: maxLongLat[1].toFixed(entryPrecision) 94 | 95 | # LongLat editable 96 | # see http://vitalets.github.io/x-editable/assets/x-editable/inputs-ext/address/address.js 97 | 98 | LongLat = (options) -> 99 | @init("longlat", options, LongLat.defaults) 100 | return 101 | 102 | # inherit from Abstract input 103 | $.fn.editableutils.inherit(LongLat, $.fn.editabletypes.abstractinput) 104 | $.extend LongLat.prototype, { 105 | 106 | ### 107 | Renders input from tpl 108 | 109 | @method render() 110 | ### 111 | render: -> 112 | @$input = @$tpl.find("input") 113 | return 114 | 115 | ### 116 | Default method to show value in element. Can be overwritten by display option. 117 | 118 | @method value2html(value, element) 119 | ### 120 | value2html: (value, element) -> 121 | unless value 122 | $(element).empty() 123 | return 124 | # Call above function to render 125 | $(element).html formatLocation(value) 126 | return 127 | 128 | ### 129 | Gets value from element's html 130 | We set value directly via javascript. 131 | 132 | @method html2value(html) 133 | ### 134 | html2value: (html) -> null 135 | 136 | ### 137 | Converts value to string. 138 | It is used in internal comparing (not for sending to server). 139 | 140 | @method value2str(value) 141 | ### 142 | value2str: formatLocation # This will ignore manual changes to the third decimal place. 143 | 144 | ### 145 | Converts string to value. Used for reading value from 'data-value' attribute. 146 | 147 | this is mainly for parsing value defined in data-value attribute. 148 | If you will always set value by javascript, no need to overwrite it 149 | 150 | @method str2value(str) 151 | ### 152 | str2value: (str) -> str 153 | 154 | ### 155 | Sets value of input. 156 | 157 | @method value2input(value) 158 | @param {mixed} value 159 | ### 160 | value2input: (value) -> 161 | return unless value 162 | transformed = transformLocation(value) 163 | @$input.filter('[name="long"]').val transformed[0].toFixed(entryPrecision) 164 | @$input.filter('[name="lat"]').val transformed[1].toFixed(entryPrecision) 165 | return 166 | 167 | ### 168 | Returns value of input. 169 | 170 | @method input2value() 171 | ### 172 | input2value: -> 173 | transformLongLat [ 174 | @$input.filter('[name="long"]').val(), 175 | @$input.filter('[name="lat"]').val() 176 | ] 177 | 178 | ### 179 | Activates input: sets focus on the first field. 180 | 181 | @method activate() 182 | ### 183 | activate: -> 184 | @$input.filter('[name="long"]').focus() 185 | return 186 | 187 | ### 188 | Attaches handler to submit form in case of 'showbuttons=false' mode 189 | 190 | @method autosubmit() 191 | ### 192 | autosubmit: -> 193 | @$input.keydown (e) -> 194 | $(this).closest("form").submit() if e.which is 13 195 | return 196 | return 197 | } 198 | 199 | LongLat.defaults = $.extend {}, $.fn.editabletypes.abstractinput.defaults, 200 | tpl: Blaze.toHTML Template.longLatEntry # No reactive contents 201 | inputclass: "" 202 | 203 | $.fn.editabletypes.longlat = LongLat 204 | -------------------------------------------------------------------------------- /packages/analysis/client/groupSlices.coffee: -------------------------------------------------------------------------------- 1 | height = 700 2 | leftMargin = 80 3 | bottomMargin = 50 4 | 5 | Template.overviewGroupSlices.helpers({ 6 | height: height 7 | leftMargin: leftMargin 8 | bottomOffset: height - bottomMargin 9 | points: -> Analysis.Worlds.find({treated: true}) 10 | treatments: -> this.treatments.join(" ") 11 | xLabelPosition: 450 12 | yLabelPosition: (height - bottomMargin) / 2 13 | settingOf: (key) -> 14 | val = Template.instance().settings.get(key) 15 | return val.toFixed(2) if $.isNumeric(val) 16 | return val 17 | }) 18 | 19 | Template.overviewGroupSlices.created = -> 20 | @settings = new ReactiveDict 21 | 22 | Template.overviewGroupSlices.rendered = -> 23 | @slider = this.$(".slider").slider({ 24 | min: 0 25 | step: 0.01 26 | slide: (event, ui) => 27 | # This updates faster than the reactive bits 28 | this.$(".slider-immediate").text(ui.value) 29 | change: (event, ui) => 30 | this.$(".slider-immediate").text(ui.value) 31 | @settings.set("sliceValue", ui.value) 32 | }) 33 | 34 | @setField = (field, value, label) -> 35 | @settings.set(field, value) 36 | # Update y axis label if necessary 37 | if field is "groupScoring" then @settings.set("yLabel", label) 38 | 39 | # default settings - change in template; fields must match here 40 | for field in [ "groupScoring", "xScale", "groupComparator" ] 41 | $input = @$("input[name=#{field}]:checked") 42 | value = $input.val() 43 | label = $input.closest("label").text().trim() 44 | @setField(field, value, label) 45 | 46 | svg = @find("svg") 47 | 48 | graphWidth = $(svg).width() - leftMargin 49 | graphHeight = $(svg).height() - bottomMargin 50 | 51 | x = null 52 | 53 | y = d3.scale.linear() 54 | .range([graphHeight, 0]) 55 | 56 | # Median line draw-er. 57 | line = d3.svg.line() 58 | .x((d) -> x(d.key)) 59 | .y((d) -> y(d.values)) 60 | 61 | xAxis = d3.svg.axis() 62 | .orient("bottom") 63 | 64 | xGrid = d3.svg.axis() 65 | .orient("bottom") 66 | .tickSize(-graphHeight, 0, 0) 67 | .tickFormat("") 68 | 69 | yAxis = d3.svg.axis() 70 | .orient("left") 71 | .scale(y) 72 | 73 | yGrid = d3.svg.axis() 74 | .orient("left") 75 | .scale(y) 76 | .tickSize(-graphWidth, 0, 0) 77 | .tickFormat("") 78 | 79 | transDuration = 600 80 | 81 | # Set slider value appropriately and a default slice value. 82 | this.autorun => 83 | xField = Util.fieldAbbr[@settings.get("groupComparator")] 84 | 85 | data = d3.select(svg).selectAll(".point").data() 86 | 87 | # min-max is largest value for which all groups have this field 88 | [sliceVal, sliceMax] = d3.extent data, (g) -> 89 | g.progress[g.progress.length - 1][xField] 90 | 91 | # Special case: don't let wallTime go over 1 92 | sliceMax = Math.min(sliceMax, 1) if xField is "wt" 93 | 94 | # set new slice value, which will update the function below. 95 | @slider.slider "option", 96 | max: sliceMax 97 | value: sliceVal # Propagates through the callback 98 | 99 | @settings.set("sliceMax", sliceMax) 100 | 101 | return 102 | 103 | # Display x scale appropriately. 104 | this.autorun => 105 | xScale = @settings.get("xScale") 106 | 107 | if xScale is "linear" 108 | x = d3.scale.linear() 109 | .domain([0, 33]) 110 | .range([0, graphWidth]) 111 | else 112 | x = d3.scale.log() 113 | .base(2) 114 | .domain([0.5, 40]) 115 | .range([0, graphWidth]) 116 | 117 | xAxis.tickValues([1, 2, 4, 8, 16, 32]) 118 | xAxis.tickFormat(x.tickFormat(6, "2d")) 119 | xAxis.scale(x) 120 | xGrid.scale(x) 121 | 122 | # Transition x axis and grid. 123 | d3.select(svg) 124 | .transition() 125 | .duration(transDuration) 126 | .each -> 127 | 128 | d3.select(this).selectAll(".x.axis") 129 | .transition().call(xAxis) 130 | d3.select(this).selectAll(".x.grid") 131 | .transition().call(xGrid) 132 | 133 | # Update axes and transition the points. 134 | this.autorun => 135 | xScale = @settings.get("xScale") # redraw if this changes. 136 | 137 | xField = Util.fieldAbbr[@settings.get("groupComparator")] 138 | yField = Util.fieldAbbr[@settings.get("groupScoring")] 139 | 140 | sliceVal = @settings.get("sliceValue") 141 | 142 | points = d3.select(svg).selectAll(".point") 143 | pointData = points.data() 144 | 145 | # Compute new interpolated values in-place while simultaneously returning them. 146 | yMax = d3.max pointData, (g) -> 147 | g.interp = Util.interpolateArray(g.progress, xField, yField, sliceVal) 148 | 149 | # Run linear regression on points that exist 150 | validPoints = pointData.filter (g) -> g.interp? 151 | 152 | regData = validPoints.map (g) -> 153 | [ (if xScale is "log" then Math.log(g.nominalSize)/Math.LN2 else g.nominalSize), g.interp ] 154 | 155 | # Run simple linear regression 156 | reg = ss.linear_regression().data(regData) 157 | regLine = reg.line() 158 | r2 = ss.r_squared(regData, regLine) 159 | 160 | indepVarText = if xScale is "log" then "log2(groupSize)" else "groupSize" 161 | 162 | regText = """#{@settings.get("groupScoring")} = #{reg.m().toFixed(2)} x #{indepVarText} + #{reg.b().toFixed(2)}, R^2 = #{r2.toFixed(4)}""" 163 | 164 | # Shared transition. 165 | d3.select(svg) 166 | .transition() 167 | .duration(transDuration) 168 | .each -> 169 | # Transition y axis 170 | y.domain([0, yMax * 1.1]) 171 | 172 | d3.select(this).selectAll(".y.axis") 173 | .transition().call(yAxis) 174 | 175 | d3.select(this).selectAll(".y.grid") 176 | .transition().call(yGrid) 177 | 178 | # Update point values 179 | points.transition().attr({ 180 | cx: (g) -> x(g.nominalSize) 181 | cy: (g) -> y(g.interp || 0) 182 | }) 183 | 184 | # Transition median line, using only values that exist 185 | medians = d3.nest() 186 | .key((g) -> g.nominalSize ).sortKeys( (a, b) -> a - b) 187 | .rollup((leaves) -> d3.median(leaves, (g) -> g.interp) ) 188 | .entries( validPoints ) 189 | 190 | d3.select(this).selectAll(".line.median").datum(medians) 191 | .transition().attr("d", line) 192 | 193 | # Transition regression line 194 | if xScale is "log" 195 | y1 = y(regLine( Math.log(x.domain()[0]) / Math.LN2 )) 196 | y2 = y(regLine( Math.log(x.domain()[1]) / Math.LN2 )) 197 | else 198 | y1 = y(regLine(x.domain()[0])) 199 | y2 = y(regLine(x.domain()[1])) 200 | 201 | d3.select(this).selectAll(".line.regression") 202 | .transition().attr({ 203 | "x1": x.range()[0] 204 | "y1": y1 205 | "x2": x.range()[1] 206 | "y2": y2 207 | }) 208 | 209 | d3.select(this).selectAll("text.regression").text(regText) 210 | 211 | Template.overviewGroupSlices.events 212 | "change input": (e, t) -> 213 | t.setField(e.target.name, e.target.value, $(e.target).closest("label").text().trim()) 214 | 215 | Template.groupSlicePoint.rendered = -> 216 | # Bind the meteor data to d3's datum. 217 | point = d3.select(this.firstNode) 218 | point.datum(@data) 219 | 220 | 221 | -------------------------------------------------------------------------------- /packages/analysis/client/groupScatter.coffee: -------------------------------------------------------------------------------- 1 | labels = { 2 | avgIndivEntropy: "Average Individual Entropy" 3 | groupEntropy: "Group Entropy" 4 | nominalSize: "Nominal Group Size" 5 | partialCreditScore: "Group Score" 6 | fullCreditScore: "Group 0-1 Score" 7 | precision: "Precision" 8 | recall: "Recall" 9 | f1: "F1 Score" 10 | } 11 | 12 | # Custom values computed from the data 13 | transform = { 14 | f1: (d) -> 2 * d.precision * d.recall / (d.precision + d.recall) 15 | } 16 | 17 | accessor = (key) -> 18 | return transform[key] if transform[key] 19 | return (d) -> d[key] 20 | 21 | Template.overviewGroupScatter.helpers 22 | labels: _.map(labels, (v, k) -> { key: k, value: v } ) 23 | 24 | Template.overviewGroupScatter.rendered = -> 25 | svg = @find("svg") 26 | 27 | leftMargin = 80 28 | bottomMargin = 50 29 | 30 | graphWidth = $(svg).width() - leftMargin 31 | graphHeight = $(svg).height() - bottomMargin 32 | 33 | colors = d3.scale.category10() 34 | .domain( [0...10] ) 35 | 36 | graph = d3.select(svg).append("g") 37 | .attr("class", "graph") 38 | .attr("transform", "translate(#{leftMargin}, 0)") 39 | 40 | # TODO generalize this for transitions 41 | filteredData = null 42 | xKey = null 43 | yKey = null 44 | displayOrdinal = false 45 | showBoxes = false 46 | 47 | filterData = => 48 | displayOrdinal = xKey is "nominalSize" and showBoxes 49 | 50 | # if displayOrdinal 51 | # filteredData = Analysis.Worlds.find({ 52 | # nominalSize: { $gt: 1 } 53 | # treated: true 54 | # }).fetch() 55 | # else 56 | filteredData = Analysis.Worlds.find().fetch() 57 | 58 | # TODO allow selection of circle radius 59 | # radius = (g) -> 2 * Math.sqrt(g.partialCreditScore) 60 | radius = 5 61 | 62 | chart = d3.box() 63 | .whiskers(Util.iqrFun(1.5)) 64 | .height(graphHeight) 65 | .showLabels(false) 66 | 67 | # x scale, to be set later with linear or ordinal 68 | x = null 69 | 70 | y = d3.scale.linear() 71 | .range([graphHeight, 0]) 72 | 73 | xAxis = d3.svg.axis() 74 | .orient("bottom") 75 | 76 | xAxisGrid = d3.svg.axis() 77 | .orient("bottom") 78 | .tickSize(-graphHeight, 0, 0) 79 | .tickFormat("") 80 | 81 | yAxis = d3.svg.axis() 82 | .orient("left") 83 | .scale(y) 84 | 85 | yAxisGrid = d3.svg.axis() 86 | .orient("left") 87 | .scale(y) 88 | .tickSize(-graphWidth, 0, 0) 89 | .tickFormat("") 90 | 91 | d3.select(svg).append("g") 92 | .attr("class", "x axis") 93 | .attr("transform", "translate(#{leftMargin}, #{graphHeight})") 94 | .append("text") 95 | .attr("x", (graphWidth / 2)) 96 | .attr("y", 30) 97 | .attr("dy", ".71em") 98 | 99 | d3.select(svg).append("g") 100 | .attr("class", "x grid") 101 | .attr("transform", "translate(#{leftMargin}, #{graphHeight})") 102 | 103 | d3.select(svg).append("g") 104 | .attr("class", "y axis") 105 | .attr("transform", "translate(#{leftMargin}, 0)") 106 | .append("text") 107 | .attr("transform", "rotate(-90)") 108 | .attr("x", -(graphHeight / 2)) 109 | .attr("y", -55) 110 | .attr("dy", ".71em") 111 | 112 | d3.select(svg).append("g") 113 | .attr("class", "y grid") 114 | .attr("transform", "translate(#{leftMargin}, 0)") 115 | 116 | groupColor = (size) -> colors(Math.log(size) / Math.LN2) 117 | 118 | # Redraw X axis 119 | redrawX = -> 120 | xExtent = d3.extent(filteredData, accessor(xKey) ) 121 | 122 | if displayOrdinal 123 | domain = _.uniq( filteredData.map((d) -> d.nominalSize) ) 124 | .sort( (a,b) -> a-b ) 125 | 126 | x = d3.scale.ordinal() 127 | .domain(domain) 128 | .rangeRoundBands([0, graphWidth], 0.6) 129 | 130 | chart.width(x.rangeBand()) 131 | 132 | else 133 | # Start domain at 0 for group size 134 | xExtent[0] = if xKey is "nominalSize" then 0 else xExtent[0] * 0.9 135 | xExtent[1] *= 1.1 136 | 137 | x = d3.scale.linear() 138 | .domain(xExtent) 139 | .range([0, graphWidth]) 140 | 141 | # Because x axis may have been replaced, update references 142 | xAxis.scale(x) 143 | xAxisGrid.scale(x) 144 | 145 | d3.select(".x.axis").call(xAxis) 146 | d3.select(".x.grid").call(xAxisGrid) 147 | d3.select(".x.axis > text").text(labels[xKey]) 148 | 149 | redrawY = -> 150 | yExtent = d3.extent(filteredData, accessor(yKey) ) 151 | yExtent[0] *= 0.9 152 | yExtent[1] *= 1.1 153 | 154 | y.domain(yExtent) 155 | 156 | d3.select(".y.axis").call(yAxis) 157 | d3.select(".y.grid").call(yAxisGrid) 158 | d3.select(".y.axis > text").text(labels[yKey]) 159 | 160 | # Draw points or boxplots 161 | redrawData = -> 162 | if displayOrdinal 163 | graph.selectAll(".point").remove() 164 | # TODO hack; should not have to remove but box re-plotting is buggy 165 | graph.selectAll(".box").remove() 166 | 167 | # Draw box plots 168 | nested = d3.nest() 169 | .key( (d) -> d.nominalSize ) 170 | .sortKeys(d3.ascending) 171 | .rollup( (leaves) -> leaves.map( accessor(yKey) ) ) 172 | .entries(filteredData) 173 | .map( (o) -> [o.key, o.values] ) 174 | 175 | # Update boxplot domain from y axis 176 | chart.domain(y.domain()) 177 | 178 | boxes = graph.selectAll(".box") 179 | .data(nested) 180 | 181 | boxes.enter().append("g") 182 | .attr("class", "box") 183 | 184 | boxes.attr("transform", (d) -> "translate(#{x(d[0])},0)") 185 | .call(chart) 186 | 187 | else 188 | graph.selectAll(".box").remove() 189 | 190 | # Draw points 191 | points = graph.selectAll(".point") 192 | .data(filteredData, (d) -> d._id) 193 | 194 | # TODO Somehow, pseudo treatments get in here, even though they don't 195 | # appear in the final result. 196 | points.enter().append("circle") 197 | .attr("class", (g) -> "point " + g.treatments?.join(" ")) 198 | .attr("stroke", (g) -> groupColor(g.nominalSize) ) 199 | .attr("fill", (g) -> if g.treated then groupColor(g.nominalSize) else "white" ) 200 | .append("svg:title") 201 | .text((g) -> g._id) 202 | 203 | # Update display values 204 | accX = accessor(xKey) 205 | accY = accessor(yKey) 206 | points.attr("cx", (g) -> x(accX(g)) #+ x.rangeBand() / 2 207 | ) 208 | .attr("cy", (g) -> y(accY(g))) 209 | .attr("r", radius ) 210 | 211 | @setX = (key) -> 212 | xKey = key 213 | filterData() 214 | redrawX() 215 | redrawData() 216 | 217 | @setY = (key) -> 218 | yKey = key 219 | filterData() 220 | redrawY() 221 | redrawData() 222 | 223 | @setShowBoxes = (show) -> 224 | showBoxes = show 225 | filterData() 226 | redrawX() # May need to switch to ordinal mode 227 | redrawData() 228 | 229 | # Initial config 230 | xKey = "nominalSize" 231 | yKey = "groupEntropy" 232 | 233 | filterData() 234 | redrawX() 235 | redrawY() 236 | redrawData() 237 | 238 | Template.overviewGroupScatter.events 239 | "change input[name=xaxis]": (e, t) -> t.setX(e.target.value) 240 | "change input[name=yaxis]": (e, t) -> t.setY(e.target.value) 241 | "change input[name=boxplot]": (e, t) -> t.setShowBoxes(e.target.checked) 242 | 243 | -------------------------------------------------------------------------------- /client/tutorial/tutorial.coffee: -------------------------------------------------------------------------------- 1 | Template.tut_chatting.myusername = -> Meteor.users.findOne()?.username || "someone" 2 | 3 | editEvent = -> 4 | unless Events.findOne(editor: $exists: true) 5 | event = Events.findOne() 6 | Meteor.call("editEvent", event._id) if event? 7 | 8 | openDocument = -> 9 | unless Session.get("document")? 10 | # open a doc if there is one 11 | someDoc = Documents.findOne() 12 | Session.set("document", someDoc._id) if someDoc? 13 | 14 | joinChatroom = -> 15 | unless Session.get("room")? 16 | # join a chat room there is one 17 | someRoom = ChatRooms.findOne() 18 | Session.set("room", someRoom._id) if someRoom? 19 | 20 | tutorialSteps = [ 21 | template: "tut_welcome" 22 | , 23 | template: "tut_whatis" 24 | , 25 | template: "tut_project" 26 | , 27 | template: "tut_yourtask" 28 | , 29 | template: "tut_goal" 30 | , 31 | spot: ".datastream" 32 | template: "tut_datastream" 33 | , 34 | spot: ".datastream" 35 | template: "tut_filterdata" 36 | require: 37 | event: "data-hide" 38 | , 39 | spot: ".navbar" 40 | template: "tut_navbar" 41 | , 42 | spot: ".navbar, #mapper-events" 43 | template: "tut_events" 44 | onLoad: -> Mapper.switchTab("events") 45 | , 46 | spot: "#mapper-events" 47 | template: "tut_event_description" 48 | onLoad: -> Mapper.switchTab("events") 49 | , 50 | spot: ".event-create" 51 | template: "tut_create_event" 52 | onLoad: -> 53 | Mapper.switchTab("events") 54 | require: 55 | event: "event-create" 56 | , 57 | spot: "#mapper-events" 58 | template: "tut_editevent" 59 | onLoad: -> Mapper.switchTab("events") 60 | # require: 61 | # event: "event-edit" 62 | , 63 | spot: ".events-header tr > th:eq(0), .events-body tr > td:nth-child(1)" 64 | template: "tut_events_index" 65 | onLoad: -> Mapper.switchTab("events") 66 | , 67 | spot: ".events-header tr > th:eq(1), .events-body tr > td:nth-child(2), .datastream" 68 | template: "tut_events_sources" 69 | onLoad: -> Mapper.switchTab("events") 70 | require: 71 | event: "data-link" 72 | , 73 | spot: ".events-header tr > th:eq(2), .events-body tr > td:nth-child(3)" 74 | template: "tut_events_type" 75 | onLoad: -> 76 | Mapper.switchTab("events") 77 | editEvent() 78 | require: 79 | event: "event-update-type" 80 | , 81 | spot: ".events-header tr > th:eq(3), .events-body tr > td:nth-child(4)" 82 | template: "tut_events_description" 83 | onLoad: -> Mapper.switchTab("events") 84 | require: 85 | event: "event-update-description" 86 | , 87 | spot: ".events-header tr > th:eq(4), .events-body tr > td:nth-child(5)" 88 | template: "tut_events_region" 89 | onLoad: -> Mapper.switchTab("events") 90 | require: 91 | event: "event-update-region" 92 | , 93 | spot: ".events-header tr > th:eq(5), .events-body tr > td:nth-child(6)" 94 | template: "tut_events_province" 95 | onLoad: -> Mapper.switchTab("events") 96 | # No required event here. 97 | , 98 | spot: ".events-header tr > th:eq(6), .events-body tr > td:nth-child(7)" 99 | template: "tut_events_location" 100 | onLoad: -> Mapper.switchTab("events") 101 | , 102 | spot: ".datastream, #mapper-events" 103 | template: "tut_addtweet" 104 | onLoad: -> Mapper.switchTab("events") 105 | , 106 | spot: ".events-header" 107 | template: "tut_sortevent" 108 | onLoad: -> Mapper.switchTab("events") 109 | , 110 | spot: ".navbar, #mapper-map" 111 | template: "tut_map" 112 | onLoad: -> Mapper.switchTab("map") 113 | , 114 | spot: ".olControlPanZoomBar > *" 115 | template: "tut_mapcontrols" 116 | onLoad: -> Mapper.switchTab("map") 117 | , 118 | spot: ".navbar, #mapper-events" 119 | template: "tut_maplocate" 120 | onLoad: -> Mapper.switchTab("events") 121 | require: 122 | event: "event-update-location" 123 | , 124 | spot: "#mapper-map" 125 | template: "tut_editmap" 126 | onLoad: -> Mapper.switchTab("map") 127 | , 128 | spot: "#mapper-events" 129 | template: "tut_maplocation" 130 | onLoad: -> Mapper.switchTab("events") 131 | require: 132 | event: "event-save" 133 | , 134 | spot: ".event-voting-container" 135 | template: "tut_verify" 136 | onLoad: -> Mapper.switchTab("events") 137 | require: 138 | event: "event-vote" 139 | , 140 | spot: ".navbar, #mapper-docs" 141 | template: "tut_documents" 142 | onLoad: -> Mapper.switchTab("docs") 143 | require: 144 | event: "document-create" 145 | , 146 | spot: "#mapper-docs" 147 | template: "tut_editdocs" 148 | onLoad: -> 149 | Mapper.switchTab("docs") 150 | openDocument() 151 | , 152 | spot: ".user-list" 153 | template: "tut_userlist" 154 | , 155 | spot: ".chat-overview" 156 | template: "tut_chatrooms" 157 | require: 158 | event: "chat-create" 159 | , 160 | spot: ".notification" 161 | template: "tut_notifications" 162 | , 163 | spot: ".chat-overview" 164 | template: "tut_joinchat" 165 | # require: event: "chat-join" 166 | , 167 | spot: ".chat-overview, .chat-messaging" 168 | template: "tut_leavechat" 169 | onLoad: joinChatroom 170 | , 171 | spot: ".chat-messaging" 172 | template: "tut_chatting" 173 | require: 174 | event: "chat-message" 175 | ] 176 | 177 | Template.tut_end.events = 178 | "change input[type=checkbox]": (e, tmpl) -> 179 | Session.set("consentChecked", e.target.checked) 180 | Mapper.events.emit("check-consent") if e.target.checked 181 | 182 | checked = -> Session.get("consentChecked") 183 | 184 | Template.tut_end.helpers 185 | checked: checked 186 | # Override stepComplete function on this template, to update message. 187 | stepCompleted: checked 188 | 189 | getRecruitingSteps = -> 190 | # replace templates with _recruiting if they exist 191 | # Don't modify original objects to avoid errors 192 | copiedSteps = $.map(tutorialSteps, (obj) -> $.extend({}, obj)) 193 | 194 | for i, step of copiedSteps 195 | recruitingTemplate = step.template + "_recruiting" 196 | step.template = recruitingTemplate if Template[recruitingTemplate] 197 | 198 | return copiedSteps.concat [ 199 | template: "tut_payment_recruiting" 200 | ] 201 | 202 | getTutorialSteps = -> 203 | return tutorialSteps.concat [ 204 | template: "tut_groundrules" 205 | , 206 | spot: ".payment" 207 | template: "tut_payment" 208 | , 209 | template: "tut_end" 210 | require: 211 | event: "check-consent" 212 | ] 213 | 214 | Template.mapperTutorial.helpers 215 | tutorialEnabled: -> 216 | treatment = TurkServer.treatment() 217 | return treatment?.tutorialEnabled and not Meteor.user()?.admin 218 | 219 | options: -> 220 | treatment = TurkServer.treatment() 221 | 222 | steps = switch treatment?.tutorial 223 | when "recruiting" then getRecruitingSteps() 224 | when "pre_task" then getTutorialSteps() 225 | else 226 | Meteor._debug("Unknown tutorial type: " + treatment.tutorial) 227 | [] 228 | 229 | return { 230 | id: "mapperTutorial" 231 | steps: steps 232 | emitter: Mapper.events 233 | onFinish: -> Meteor.call "finishTutorial" 234 | } 235 | 236 | # Handy function to allow the entire tutorial for testing 237 | Mapper.bypassTutorial = (skipToEnd) -> 238 | for i, step of tutorialSteps 239 | Mapper.events.emit(step.require.event) if step?.require?.event 240 | 241 | if skipToEnd 242 | tm = Blaze.getData( $(".modal-dialog.positioned")[0]) 243 | # Get the tutorial manager and skip it to the end 244 | tm.step = tm.steps.length - 1 245 | tm.stepDep.changed() 246 | return 247 | -------------------------------------------------------------------------------- /client/views/events.html: -------------------------------------------------------------------------------- 1 | 2 | 20 | 21 | 46 | 47 | 69 | 70 | 77 | 78 | 83 | 84 | 106 | 107 | 112 | 113 | 122 | 123 | 124 | 125 | 134 | 135 | 148 | 149 | 158 | 159 | 172 | 173 | 192 | 193 | 198 | 199 | 200 | 211 | 212 | 217 | 218 | 226 | 227 | 234 | 235 | 259 | -------------------------------------------------------------------------------- /packages/analysis/client/overview.html: -------------------------------------------------------------------------------- 1 | 39 | 40 | 172 | 173 | 178 | 179 | 185 | 186 | 192 | 193 | 199 | 200 | 211 | -------------------------------------------------------------------------------- /client/index.coffee: -------------------------------------------------------------------------------- 1 | ### 2 | Subscriptions 3 | ### 4 | 5 | fieldSub = Meteor.subscribe("eventFieldData", Mapper.processSources) 6 | 7 | # Unsets and sets a session variable for a subscription 8 | watchReady = (key) -> 9 | Session.set(key, false) 10 | # TODO temporary hack for people pushing back button / Meteor re-subscribe foolishness. 11 | # Show loading for at most 8 seconds. This is better than infinitely bugging out because most people seem to load the app just fine. 12 | Meteor.setTimeout((-> Session.set(key, true)), 8000) 13 | return (-> Session.set(key, true)) 14 | 15 | ### 16 | Routing 17 | ### 18 | 19 | Router.configure 20 | notFoundTemplate: 'home' 21 | loadingTemplate: 'spinner' 22 | 23 | # TODO move the functionality of these before functions into TurkServer by providing built-in Iron Router controllers 24 | Router.map -> 25 | @route('home', {path: '/'}) 26 | 27 | @route 'mapper', 28 | path: '/mapper' 29 | onBeforeAction: -> 30 | unless Meteor.user() 31 | @layout("defaultContainer") 32 | @render("awaitingLogin") 33 | else unless TurkServer.isAdmin() or TurkServer.inExperiment() 34 | @layout("defaultContainer") 35 | @render("loadError") 36 | else 37 | @next() 38 | 39 | waitOn: -> 40 | subHandles = [ fieldSub ] 41 | 42 | group = TurkServer.group() 43 | # Don't keep a room when going from tutorial to actual task 44 | # TODO this can be removed when the chat subscription is fixed 45 | unless group 46 | Session.set("room", undefined) 47 | return subHandles # Otherwise admin will derpily subscribe to the entire set of users 48 | 49 | # No need to clean up subscriptions because this is a Deps.autorun 50 | # We need to pass the group handle down to make Meteor think the subscription is different 51 | subHandles.push Meteor.subscribe("userStatus", group, watchReady("userSubReady")) 52 | # Chat messages are subscribed to by room 53 | subHandles.push Meteor.subscribe("chatrooms", group, watchReady("chatSubReady")) 54 | subHandles.push Meteor.subscribe("datastream", group, watchReady("dataSubReady")) 55 | subHandles.push Meteor.subscribe("docs", group, watchReady("docSubReady")) 56 | subHandles.push Meteor.subscribe("events", group, watchReady("eventSubReady")) 57 | # User specific, but shouldn't leak across instances 58 | subHandles.push Meteor.subscribe("notifications", group) 59 | 60 | return subHandles 61 | 62 | @route 'exitsurvey/:template?', 63 | layoutTemplate: 'defaultContainer' 64 | onBeforeAction: -> 65 | unless TurkServer.isAdmin() or TurkServer.inExitSurvey() 66 | @layout("defaultContainer") 67 | @render("loadError") 68 | else 69 | @next() 70 | action: -> 71 | # Override the route, for debugging use. 72 | if @params.template? 73 | @render(@params.template) 74 | else 75 | @render("exitsurvey") 76 | 77 | Meteor.startup -> 78 | Session.setDefault("taskView", 'events') 79 | 80 | # Defer setting up these autorun functions: 81 | # https://github.com/EventedMind/iron-router/issues/639 82 | Meteor.defer -> 83 | Deps.autorun -> 84 | Router.go("/mapper") if TurkServer.inExperiment() 85 | 86 | Deps.autorun -> 87 | Router.go("/exitsurvey") if TurkServer.inExitSurvey() 88 | 89 | ### 90 | Window sizing warning 91 | ### 92 | sizeWarningDialog = null 93 | 94 | checkSize = -> 95 | bigEnough = $(window).width() > 1200 and $(window).height() > 500 96 | 97 | if bigEnough and sizeWarningDialog? 98 | sizeWarningDialog.modal("hide") 99 | sizeWarningDialog = null 100 | return 101 | 102 | if !bigEnough and sizeWarningDialog is null 103 | sizeWarningDialog = bootbox.dialog 104 | closeButton: false 105 | message: "

Your screen is not big enough for this task. Please maximize your window if possible, or use a computer with a higher-resolution screen.

" 106 | return 107 | 108 | Meteor.startup -> 109 | checkSize() 110 | $(window).resize checkSize 111 | # Ask for username once user logs in 112 | TurkServer.ensureUsername() 113 | 114 | ### 115 | Idle Monitoring 116 | ### 117 | Deps.autorun -> 118 | return unless (treatment = TurkServer.treatment())? 119 | 120 | # Change monitoring setting whenever treatment changes 121 | # The TurkServer code will automatically handle starting and stopping during an experiment 122 | if treatment?.tutorialEnabled 123 | # Mostly for testing purposes during tutorial 124 | TurkServer.enableIdleMonitor(30000, true) 125 | else 126 | # 8 minute idle timer, ignore window blur 127 | TurkServer.enableIdleMonitor(8 * 60 * 1000, false) 128 | 129 | return 130 | 131 | ### 132 | Templates and helpers 133 | ### 134 | 135 | # TODO update this to use a more generalized API 136 | Template.home.helpers 137 | landingTemplate: -> 138 | treatments = TurkServer.batch()?.treatments 139 | if _.indexOf(treatments, "recruiting") >= 0 140 | Template.recruitingLanding 141 | else if _.indexOf(treatments, "parallel_worlds") >= 0 142 | Template.taskLanding 143 | else 144 | Template.loadingLanding 145 | 146 | ### 147 | Global level events in the mapper application - activating popovers on 148 | mouseover 149 | 150 | TODO generalize events below to remove boilerplate 151 | ### 152 | popoverDelay = 200 153 | 154 | Template.mapper.events 155 | # Attach and destroy a popover when mousing over a container. 'mouseenter' 156 | # only fires once when entering an element, so we use that to ensure that we 157 | # get the right target. However, exclude containers being dragged. 158 | "mouseenter .tweet-icon-container:not(.ui-draggable-dragging)": (e) -> 159 | container = $(e.target) 160 | tweet = Blaze.getData(e.target) 161 | delayShow = true 162 | 163 | Meteor.setTimeout -> 164 | # Skip creating popover if moused out already 165 | return unless delayShow 166 | 167 | container.popover({ 168 | html: true 169 | placement: "auto right" # Otherwise it goes off the top of the screen 170 | trigger: "manual" 171 | container: e.target # Hovering over the popover should hold it open 172 | # No need for reactivity here since tweet does not change 173 | content: Blaze.toHTMLWithData Template.tweetPopup, Datastream.findOne(tweet._id) 174 | }).popover("show") 175 | , popoverDelay 176 | 177 | container.one "mouseleave", -> 178 | delayShow = false 179 | # Destroy any popover if it was created 180 | container.popover("destroy") 181 | 182 | "mouseenter .user-pill-container": (e) -> 183 | container = $(e.target) 184 | 185 | container.popover({ 186 | html: true 187 | placement: "auto right" 188 | trigger: "manual" 189 | container: e.target 190 | content: -> 191 | # Grab updated data 192 | user = Blaze.getData(e.target) 193 | # Check if we should show chat invite 194 | if user.status?.online and user._id isnt Meteor.userId() 195 | return Blaze.toHTML Template.userInvitePopup 196 | else 197 | return null 198 | }).popover("show") 199 | 200 | container.one("mouseleave", -> container.popover("destroy") ) 201 | 202 | Template.mapper.helpers 203 | adminControls: Template.adminControls 204 | 205 | Template.mapper.rendered = -> 206 | # Set initial active tab when state changes 207 | @comp = Deps.autorun -> 208 | tab = Session.get('taskView') 209 | return unless tab? 210 | $('.stack .pages').removeClass('active') 211 | $('#mapper-'+tab).addClass('active') 212 | 213 | Template.mapper.destroyed = -> @comp?.stop() 214 | 215 | Template.guidance.helpers 216 | message: -> Session.get("guidanceMessage") 217 | 218 | switchTab = (page) -> 219 | return if Deps.nonreactive(-> Session.equals("taskView", page)) 220 | Session.set("taskView", page) 221 | 222 | Template.pageNav.events = 223 | "click a": (e) -> e.preventDefault() 224 | # This function sets the styling on the navbar as well 225 | "click a[data-toggle='tab']": (e) -> 226 | if (target = $(e.target).data("target"))? 227 | switchTab(target) 228 | else 229 | e.stopPropagation() # Avoid effect of click if no tab change 230 | 231 | Template.pageNav.helpers 232 | payment: -> 233 | return null unless (treatment = TurkServer.treatment())? 234 | return switch 235 | when treatment.payment? then Template.tutorialPayment 236 | when treatment.wage? then Template.scaledPayment 237 | else null 238 | treatment: TurkServer.treatment 239 | 240 | Template.tutorialPayment.helpers 241 | amount: -> "$" + @payment.toFixed(2) 242 | 243 | Template.scaledPayment.helpers 244 | amount: -> 245 | hours = TurkServer.Timers.activeTime() / (3600000) # millis per hour 246 | lowest = (@wage * hours).toFixed(2) 247 | highest = ((@wage + @bonus) * hours).toFixed(2) 248 | return "$#{lowest} - $#{highest}" 249 | lowest: -> @wage.toFixed(2) 250 | highest: -> (@wage + @bonus).toFixed(2) 251 | 252 | Template.help.helpers 253 | teamInfo: -> 254 | switch @groupSize 255 | when 1 then "You are working by yourself. There are no other team members for this task." 256 | when undefined then "You are working in a team." 257 | else "You are working in a team of #{@groupSize} members." 258 | 259 | instructionsInfo: -> 260 | instr = "Refer to the Instructions document for an overview of the instructions." 261 | if @groupSize > 1 or not @groupSize? 262 | instr += " You should feel free to ask your teammates about anything that you don't understand." 263 | return instr 264 | 265 | Template.help.rendered = -> 266 | this.$(".dropdown > a").click() 267 | -------------------------------------------------------------------------------- /client/views/chat.coffee: -------------------------------------------------------------------------------- 1 | # This should only change if the room changes, else chat box will be re-rendering a lot 2 | Handlebars.registerHelper "currentRoom", -> 3 | return unless Meteor.userId()? 4 | # Return room only if it exists in the collection (not deleted) 5 | return ChatRooms.findOne(Session.get("room"), {fields: _id: 1})?._id 6 | 7 | Template.chat.rendered = -> 8 | # Send room changes to server 9 | # TODO this incurs a high traffic/rendering cost when switching between rooms 10 | # TODO only subscribe to the room if found in the ChatRooms collection 11 | this.autorun -> 12 | roomId = Session.get("room") 13 | 14 | # in replay mode, messages will be automatically pushed over by the server 15 | if Router.current().route.getName() is "replay" 16 | console.log "Room change in replay; no action taken." 17 | return 18 | 19 | # request the contents of the chat room otherwise 20 | Session.set("chatRoomReady", false) 21 | Meteor.subscribe("chatstate", roomId, -> Session.set("chatRoomReady", true)) 22 | 23 | Template.chat.events 24 | "click .action-room-create": (e) -> 25 | e.preventDefault() 26 | 27 | bootbox.prompt "Name the room", (roomName) -> 28 | return unless !!roomName 29 | Meteor.call "createChat", roomName, (err, id) -> 30 | return unless id 31 | Session.set "room", id 32 | 33 | Template.currentChatroom.events 34 | "click .action-room-leave": -> 35 | # TODO convert this to a method call ... ! 36 | # bootbox.confirm "Leave this room?", (value) -> 37 | Session.set("room", undefined) # if value 38 | 39 | Template.currentChatroom.helpers 40 | nameDoc: -> ChatRooms.findOne(""+@, {fields: name: 1}) 41 | 42 | Template.rooms.helpers 43 | loaded: -> Session.equals("chatSubReady", true) 44 | 45 | availableRooms: -> 46 | selector = if TurkServer.isAdmin() and Session.equals("adminShowDeleted", true) then {} 47 | else { deleted: {$exists: false} } 48 | ChatRooms.find(selector, {sort: {name: 1}}) # For a consistent ordering 49 | 50 | Template.roomItem.helpers 51 | active: -> if Session.equals("room", @_id) then "active" else "" 52 | deleted: -> if @deleted then "deleted" else "" 53 | 54 | empty: -> @users is 0 55 | 56 | Template.roomItem.events = 57 | "click .action-room-enter": (e) -> 58 | e.preventDefault() 59 | 60 | unless Meteor.userId() 61 | bootbox.alert "You must be logged in to join chat rooms." 62 | return 63 | 64 | Session.set "room", @_id 65 | Mapper.events.emit("chat-join") 66 | 67 | "click .action-room-delete": (e) -> 68 | e.preventDefault() 69 | roomId = @_id 70 | bootbox.confirm "This will delete the chat room and its messages. Are you sure?", (res) -> 71 | return unless res # Only if "yes" clicked 72 | Meteor.call("deleteChat", roomId) if roomId 73 | 74 | # don't select chatroom (above function) - http://stackoverflow.com/questions/10407783/stop-event-propagation-in-meteor 75 | e.stopImmediatePropagation() 76 | 77 | Template.roomUsers.helpers 78 | users: -> ChatUsers.find {} 79 | findUser: -> Meteor.users.findOne @userId 80 | 81 | Template.roomHeader.rendered = -> 82 | tmplInst = this 83 | 84 | this.autorun -> 85 | # Trigger this whenever title changes - note only name is reactively depended on 86 | Blaze.getData() 87 | # Destroy old editable if it exists 88 | tmplInst.$(".editable").editable("destroy").editable 89 | display: -> 90 | success: (response, newValue) -> 91 | roomId = Session.get("room") 92 | return unless roomId 93 | Meteor.call "renameChat", roomId, newValue 94 | 95 | showEvent = (eventId) -> 96 | Mapper.switchTab 'events' # Make sure we are on the event page 97 | # Set up a scroll event, then trigger a re-render 98 | Mapper.selectEvent(eventId) 99 | Mapper.scrollToEvent(eventId) 100 | 101 | Template.messageBox.events = 102 | "click .tweet-icon.clickme": (e) -> 103 | tweetId = $(e.target).data("tweetid") + "" # Ensure string, not integer 104 | data = Datastream.findOne(tweetId) 105 | return unless data 106 | 107 | # Error message if tweet is hidden, or went on a deleted event 108 | if data.hidden or Events.findOne(data.events?[0])?.deleted 109 | bootbox.alert("That data has been deleted.") 110 | return 111 | 112 | if data.events? and data.events.length > 0 113 | showEvent data.events[0] # Scroll to event 114 | else 115 | Mapper.selectData(tweetId) 116 | Mapper.scrollToData(tweetId) 117 | 118 | "click .event-icon.clickme": (e) -> 119 | eventId = $(e.target).data("eventid") + "" 120 | event = Events.findOne(eventId) 121 | return unless event 122 | 123 | if event.deleted 124 | bootbox.alert("That event has been deleted.") 125 | return 126 | 127 | showEvent(eventId) 128 | 129 | Template.messageBox.helpers 130 | loaded: -> Session.equals("chatRoomReady", true) 131 | messages: -> 132 | # Multiple chatrooms may be loaded, to save on traffic or for the replay. 133 | # Since we're sorting anyway, filter by room. 134 | ChatMessages.find { room: Session.get("room") }, 135 | sort: {timestamp: 1} 136 | 137 | # These usernames are nonreactive because find does not use any reactive variables 138 | Template.messageItem.helpers 139 | username: -> Meteor.users.findOne(@userId)?.username || @userId 140 | 141 | # If updating the user, also update server notification generations. 142 | userRegex = new RegExp('(^|\\b|\\s)(@[\\w.]+)($|\\b|\\s)','g') 143 | tweetRegex = new RegExp('(^|\\b|\\s)(~[\\d]+)($|\\b|\\s)','g') 144 | eventRegex = new RegExp('(^|\\b|\\s)(#[\\d]+)($|\\b|\\s)','g') 145 | 146 | ### 147 | Blaze.toHTML registers reactive dependencies, so chat messages can get 148 | re-rendered with state. However, this can cause excessive CPU usage. 149 | 150 | As a result, we use Blaze.toHTML with static data, and use very specific 151 | reactive dependencies below. It has to be reactive, or if the chat loads 152 | before the events/tweets then messages will be empty. 153 | 154 | Moving the findOne functions inside the Blaze.With won't make any difference 155 | below as the entire chat message has to be re-rendered anyway. 156 | ### 157 | 158 | # TODO: remove ugly spaces added below 159 | userFunc = (_, p1, p2) -> 160 | username = p2.substring(1) 161 | # userPill uses _id, username, and status 162 | user = Meteor.users.findOne(username: username, {fields: {username: 1, status: 1}}) 163 | return " " + if user then Blaze.toHTMLWithData(Template.userPill, user) else p2 164 | 165 | tweetFunc = (_, p1, p2) -> 166 | tweetNum = parseInt( p2.substring(1) ) 167 | # tweetIconClickable only uses _id and num 168 | tweet = Datastream.findOne( {num: tweetNum}, {fields: num: 1} ) 169 | return " " + if tweet then Blaze.toHTMLWithData(Template.tweetIconClickable, tweet) else p2 170 | 171 | eventFunc = (_, p1, p2) -> 172 | eventNum = parseInt( p2.substring(1) ) 173 | # eventIconClickable only uses _id and num 174 | event = Events.findOne( {num: eventNum}, {fields: num: 1} ) 175 | return " " + if event then Blaze.toHTMLWithData(Template.eventIconClickable, event) else p2 176 | 177 | # Because messages only render when inserted, we can use this to scroll the chat window 178 | Template.messageItem.rendered = -> 179 | # Scroll down whenever anything happens 180 | $messages = $(".messages-body") 181 | $messages.scrollTop $messages[0].scrollHeight 182 | 183 | # Replace any matched users, tweets, or events with links 184 | Template.messageItem.helpers 185 | renderText: -> 186 | text = Handlebars._escape(@text) 187 | # No SafeString needed here as long as renderText is unescaped 188 | text = text.replace userRegex, userFunc 189 | text = text.replace tweetRegex, tweetFunc 190 | text = text.replace eventRegex, eventFunc 191 | 192 | eventText: -> 193 | username = Meteor.users.findOne(@userId).username 194 | return username + " has " + (if @event is "enter" then "entered" else "left" ) + " the room." 195 | 196 | Template.chatInput.rendered = -> 197 | $(@find(".chat-help")).popover 198 | html: true 199 | placement: "top" 200 | trigger: "hover" 201 | content: Blaze.toHTML Template.chatPopover 202 | 203 | Template.chatInput.events = 204 | submit: (e, tmpl) -> 205 | e.preventDefault() 206 | $msg = $( tmpl.find(".chat-input") ) 207 | return unless $msg.val() 208 | 209 | Meteor.call "sendChat", Session.get("room"), $msg.val() # Server only method 210 | 211 | $msg.val("") 212 | $msg.focus() 213 | Meteor.flush() 214 | 215 | # Auto scroll happens on messageBox render now.. 216 | Mapper.events.emit("chat-message") 217 | 218 | # RegExp syntax below taken from 219 | # https://github.com/meteor/meteor/blob/devel/packages/minimongo/selector.js 220 | # We use $where because we need the regex to match on a number! 221 | # This worked before but was removed in 0.7.1: 222 | # https://github.com/meteor/meteor/pull/1874#issuecomment-37074734 223 | # However, since it's all on the client, this will result in the same performance. 224 | numericMatcher = (filter) -> 225 | re = new RegExp("^" + filter) 226 | return { $where: -> re.test(@num) } 227 | 228 | Template.chatInput.helpers 229 | settings: -> { 230 | position: "top" 231 | limit: 5 232 | rules: [ 233 | { 234 | token: '@' 235 | collection: Meteor.users 236 | field: "username" 237 | template: Template.userPill 238 | }, 239 | { 240 | token: '~' 241 | collection: Datastream 242 | field: "num" 243 | template: Template.tweetNumbered 244 | # TODO this can select tweets attached to deleted events, but error 245 | # shows up when they are clicked 246 | filter: { hidden: $exists: false } 247 | selector: numericMatcher 248 | }, 249 | { 250 | token: '#' 251 | collection: Events 252 | field: "num" 253 | template: Template.eventShort 254 | filter: { deleted: $exists: false } 255 | selector: numericMatcher 256 | } 257 | ] 258 | } 259 | --------------------------------------------------------------------------------