├── .gitignore ├── Gruntfile.coffee ├── Procfile ├── README.md ├── app.json ├── client.coffee ├── index.coffee ├── lib ├── geopublisher.coffee ├── logger.coffee ├── map.coffee ├── user-store.coffee └── vendor │ └── Control.FullScreen.js ├── package.json ├── public ├── client.js ├── geosockets.svg ├── index.html ├── screenshots │ ├── sf.png │ ├── us.png │ ├── west.png │ └── world.png └── styles.css ├── server.coffee └── test └── clientTest.coffee /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | node_modules 3 | dump.rdb -------------------------------------------------------------------------------- /Gruntfile.coffee: -------------------------------------------------------------------------------- 1 | module.exports = (grunt) -> 2 | grunt.initConfig 3 | 4 | casper: 5 | test: 6 | options: 7 | test: true 8 | files: 9 | 'test/casper-results.xml': ['test/clientTest.coffee'] 10 | 11 | coffeeify: 12 | basic: 13 | src: ['client.coffee', 'lib/vendor/*.js'] 14 | dest: "public/client.js" 15 | 16 | watch: 17 | casper: 18 | files: ['public/client.js', 'test/clientTest.coffee'], 19 | tasks: ['casper'] 20 | coffeeify: 21 | files: ['client.coffee', 'lib/*.coffee'] 22 | tasks: ['coffeeify'] 23 | 24 | grunt.loadNpmTasks 'grunt-casper' 25 | grunt.loadNpmTasks 'grunt-coffeeify' 26 | grunt.loadNpmTasks 'grunt-contrib-watch' 27 | grunt.registerTask 'default', ['casper', 'coffeeify'] -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: npm start 2 | redis-dev: redis-server 3 | grunt-dev: grunt watch 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Geosockets 2 | 3 | Geosockets is a Node.js webserver and javascript browser client for rendering website 4 | visitors on a map in realtime using WebSockets and the browser's Geolocation API. 5 | 6 | See the demo app at [geosockets.herokuapp.com](https://geosockets.herokuapp.com) and 7 | the Heroku WebSocket Beta announcement at [blog.heroku.com/archives/2013/10/8/websockets-public-beta](https://blog.heroku.com/archives/2013/10/8/websockets-public-beta). 8 | 9 | ### The Client 10 | 11 | [client.coffee](https://github.com/heroku-examples/geosockets/blob/master/client.coffee) is written as a node app that uses [Coffeeify](https://github.com/substack/coffeeify) (the red-headed step-child of [browserify](https://github.com/substack/node-browserify#readme)) and [Grunt](http://gruntjs.com/) to transpile the source into a single browser-ready javascript file. 12 | 13 | When the client is first run in the browser, a [UUID](https://github.com/broofa/node-uuid#readme) token is generated and stored in a cookie which is passed to the server in the headers of each WebSocket message. This gives the server a consistent way to identify each user. 14 | 15 | The client uses the [browser's geolocation API](https://www.google.com/search?q=browser%20geolocation%20api) and the [geolocation-stream](https://github.com/maxogden/geolocation-stream#readme) node module to determine the user's physical location, continually listening for location updates in realtime. Once the WebSocket connection is established, the client broadcasts its location to the server: 16 | 17 | ```js 18 | { 19 | uuid: '6e381608-2e63-4e40-bf6c-31754935a5c2', 20 | url: 'https://blog.heroku.com/archives/2013/10/3/websockets-public-beta', 21 | latitude: 37.7521248, 22 | longitude: -122.42365649999999 23 | } 24 | ``` 25 | 26 | The client then listens for messages from the server, rendering and removing markers from the map as site visitors come and go. 27 | 28 | ### The Server 29 | 30 | [server.coffee](https://github.com/heroku-examples/geosockets/blob/master/server.coffee) is a node app powered by [express 3](http://expressjs.com/guide.html), node's native [http](http://nodejs.org/api/http.html) module, and the [einaros/ws](https://github.com/einaros/ws/blob/master/doc/ws.md) WebSocket implementation. Express is used to serve the static frontend in `/public`. 31 | 32 | The server was designed with horizontal scalability in mind. The shared location dataset is stored in a redis datastore and each web dyno connects to this shared resource to pull the complete list of pins to place on the map. Clients viewing the map each establish their own WebSocket connection to any one of the backend web dynos and receive real-time updates as locations are added and removed from the redis datastore. 33 | 34 | When the server receives a message from a client, it adds the client's location data to the Redis store (or updates if it's already present), using the combined client's URL/UUID pair as the Redis key: 35 | 36 | ```coffee 37 | @redis.setex "#{user.url}---#{user.uuid}", @ttl, JSON.stringify(user) 38 | ``` 39 | 40 | The server then fetches all keys from Redis that match that URL and broadcasts the update 41 | to all connected clients at that same URL. 42 | 43 | ### Embedding the Javascript Client on Your Site 44 | 45 | The Geosockets JavasScript client can be used on any website: 46 | 47 | ```html 48 | 49 | 50 | 51 |
52 | ``` 53 | 54 | Use CSS to configure the size and position of the map container: 55 | 56 | ```css 57 | #geosockets { 58 | width: 100%; 59 | height: 100%; 60 | } 61 | ``` 62 | 63 | ### Running Geosockets Locally 64 | 65 | If you're new to Heroku or Node.js development, you'll need to install a few things first: 66 | 67 | 1. [Heroku Toolbelt](https://toolbelt.heroku.com), which gives you git, foreman, and the heroku command-line interface. 68 | 1. [Node.js](http://nodejs.org/) 69 | 1. [Redis](http://redis.io/). If you're using [homebrew](http://brew.sh/), install with `brew install redis` 70 | 71 | Clone the repo and install npm dependencies: 72 | 73 | ```sh 74 | git clone https://github.com/heroku-examples/geosockets.git 75 | cd geosockets 76 | npm install 77 | npm install -g grunt-cli 78 | ``` 79 | 80 | The foreman [Procfile](https://github.com/heroku-examples/geosockets/blob/master/Procfile) defines the processes 81 | required to run the app. Fire up redis, a grunt watcher, and the node webserver at [localhost:5000/?debug](http://localhost:5000/?debug): 82 | 83 | ```` 84 | foreman start 85 | ``` 86 | 87 | ### Debugging 88 | 89 | The client uses a [custom logging function](https://github.com/heroku-examples/geosockets/blob/master/lib/logger.coffee) 90 | that only logs messages to the console if a `debug` query param is present in the URL, e.g. 91 | [localhost:5000/?debug](http://localhost:5000/?debug). This allows you to view client 92 | behavior in production without exposing your site visitors to debugging data. 93 | 94 | ### Testing 95 | 96 | Basic integration testing is done with [CasperJS](http://casperjs.org/), a navigation scripting & testing utility for [PhantomJS](http://phantomjs.org/). Casper is integrated into the app using the [grunt-casper](https://github.com/iamchrismiller/grunt-casper) plugin, and run with foreman. Each time you make a change to your client, the casper tests are run automatically. 97 | 98 | ### Deploying Geosockets to Heroku 99 | 100 | ``` 101 | heroku create my-geosockets-app 102 | heroku labs:enable websockets 103 | heroku addons:add openredis:micro # $10/month 104 | git push heroku master 105 | heroku open 106 | ``` 107 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Geosockets", 3 | "description": "Render website visitors on a map in realtime using WebSockets and the browser's Geolocation API.", 4 | "keywords": ["geo", "gis", "map", "cartography", "analytics", "websockets", "node", "redis"], 5 | "website": "https://github.com/heroku-examples/geosockets", 6 | "repository": "https://github.com/heroku-examples/geosockets", 7 | "success_url": "https://github.com/heroku-examples/geosockets#readme", 8 | "logo": "http://geosockets.herokuapp.com/geosockets.svg", 9 | "addons": [ 10 | "openredis", 11 | "papertrail" 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /client.coffee: -------------------------------------------------------------------------------- 1 | window.cookie = require 'cookie-cutter' 2 | window.uuid = require 'node-uuid' 3 | window.mobile = require 'is-mobile' 4 | domready = require 'domready' 5 | Geopublisher = require './lib/geopublisher.coffee' 6 | Map = require './lib/map.coffee' 7 | window.log = require './lib/logger.coffee' 8 | 9 | window.Geosocket = class Geosocket 10 | 11 | constructor: (@host) -> 12 | 13 | # If no WebSocket host is specified, derive it from the URL 14 | # http://example.com -> ws://example.com 15 | # https://example.com -> wss://example.com 16 | @host or= location.origin.replace(/^http/, 'ws') 17 | 18 | domready => 19 | 20 | # Sorry, old IE. 21 | unless window['WebSocket'] 22 | alert "Your browser doesn't support WebSockets." 23 | return 24 | 25 | unless cookie.get 'geosockets-uuid' 26 | cookie.set 'geosockets-uuid', uuid.v4() 27 | 28 | # Create the map 29 | window.map = new Map() 30 | 31 | # Open the socket connection 32 | window.socket = new WebSocket(@host) 33 | 34 | # Start listening for browser geolocation events 35 | socket.onopen = (event) -> 36 | window.geoPublisher = new Geopublisher(socket) 37 | 38 | # Parse the JSON message array and each stringified JSON object 39 | # within it, then render new users on the map 40 | socket.onmessage = (event) -> 41 | users = JSON.parse(event.data).map(JSON.parse) 42 | log "users", users 43 | map.render(users) 44 | 45 | socket.onerror = (error) -> 46 | log error 47 | 48 | socket.onclose = (event) -> 49 | log 'socket closed', event 50 | 51 | @ 52 | 53 | -------------------------------------------------------------------------------- /index.coffee: -------------------------------------------------------------------------------- 1 | # This file bootstraps the node app. 2 | 3 | # Putting the server in its own module makes it easier to test. 4 | # See test/serverTest.coffee 5 | 6 | require('./server')() -------------------------------------------------------------------------------- /lib/geopublisher.coffee: -------------------------------------------------------------------------------- 1 | uuid = require 'node-uuid' 2 | GeolocationStream = require 'geolocation-stream' 3 | 4 | module.exports = class Geopublisher 5 | keepaliveInterval: 30*1000 6 | lastPublishedAt: 0 7 | position: {} 8 | 9 | constructor: (@socket) -> 10 | 11 | # Create a cookie that the server can use to uniquely identify each client. 12 | @position.uuid = cookie.get 'geosockets-uuid' or cookie.set('geosockets-uuid', uuid.v4()) 13 | 14 | @position.url = (document.querySelector('link[rel=canonical]') or window.location).href 15 | 16 | @stream = GeolocationStream() 17 | 18 | @stream.on "data", (position) => 19 | 20 | # Firefox doesn't know how to JSON.stringify the Coords 21 | # object, so just pull out the lat/lng pair 22 | @position.latitude = position.coords.latitude 23 | @position.longitude = position.coords.longitude 24 | 25 | @publish() 26 | 27 | @stream.on "error", (err) -> 28 | log err 29 | 30 | # Heroku closes the connection after 55 seconds of inactivity; 31 | setInterval (=>@publish()), @keepaliveInterval 32 | 33 | publish: => 34 | # Don't publish if the socket is still connecting 35 | return if socket.readyState isnt 1 36 | 37 | # Don't publish if geodata isn't yet available. 38 | return if !@position.latitude 39 | 40 | # Don't publish too often 41 | return if (Date.now()-@lastPublishedAt) < @keepaliveInterval/2 42 | 43 | log "publish position:", @position 44 | @socket.send JSON.stringify(@position) 45 | @lastPublishedAt = Date.now() -------------------------------------------------------------------------------- /lib/logger.coffee: -------------------------------------------------------------------------------- 1 | # Custom Logger only produces console output if browser 2 | # supports it and a `debug` query param is present 3 | 4 | module.exports = -> 5 | if window['console'] and location.search.match(/debug/) 6 | console.log.apply(console,arguments) -------------------------------------------------------------------------------- /lib/map.coffee: -------------------------------------------------------------------------------- 1 | merge = require 'merge' 2 | # Mapbox auto-attaches to window.L when you require it. 3 | # See https://github.com/mapbox/mapbox.js/pull/498 4 | require 'mapbox.js' 5 | 6 | module.exports = class Map 7 | domId: 'geosockets' 8 | tileSet: 'examples.map-20v6611k' # 'financialtimes.map-w7l4lfi8' 9 | lastRenderedAt: 0 10 | maxRenderInterval: 5*1000 # Don't render more than once every five seconds 11 | users: [] # Container array for geodata between renders 12 | defaultLatLng: [37.7720947, -122.4021025] # San Francisco 13 | defaultZoom: 11 14 | maxMarkersMobile: 50 15 | maxMarkersDesktop: 300 16 | markerOptions: 17 | clickable: false 18 | keyboard: false 19 | weight: 2 20 | color: "#6762A6" 21 | opacity: 1 22 | fillColor: "#9674B7" 23 | fillOpacity: 1 24 | radius: 6 25 | userMarkerOptions: 26 | clickable: false 27 | keyboard: false 28 | weight: 2 29 | color: "#9674B7" 30 | opacity: 1 31 | fillColor: "#FFF" 32 | fillOpacity: 0.7 33 | radius: 14 34 | dashArray: "3, 6" 35 | 36 | constructor: () -> 37 | 38 | # Inject Mapbox CSS into the DOM 39 | link = document.createElement("link") 40 | link.rel = "stylesheet" 41 | link.type = "text/css" 42 | link.href = "https://api.tiles.mapbox.com/mapbox.js/v1.3.1/mapbox.css" 43 | document.body.appendChild link 44 | 45 | # Create the Mapbox map 46 | @map = L.mapbox 47 | .map(@domId, @tileSet) 48 | .setView(@defaultLatLng, @defaultZoom) 49 | .locate 50 | setView: true 51 | maxZoom: 11 52 | 53 | # Enable fullscreen option 54 | @map.addControl(new L.Control.Fullscreen()); 55 | 56 | # Accidentally scrolling with the trackpad sucks 57 | @map.scrollWheelZoom.disable() 58 | 59 | # 60 | @map.doubleClickZoom.disable() 61 | 62 | render: (newUsers) => 63 | 64 | # Don't render if we've rendered recently 65 | if (Date.now()-@lastRenderedAt) < @maxRenderInterval 66 | log "rendered recently, skipping this round" 67 | return 68 | 69 | # Move the current user to the end of the array 70 | # so their marker z-index will be higher 71 | newUsers = newUsers.sort (a,b) -> 72 | a.uuid is cookie.get('geosockets-uuid') 73 | 74 | # Don't render more markers than the browser can handle 75 | # Take the users of the end of the array, so as to keep the newer 76 | # users and the current user. 77 | slice = if mobile() then @maxMarkersMobile else @maxMarkersDesktop 78 | newUsers = newUsers.slice -slice 79 | 80 | # Put every user on the map, even if they're already on it. 81 | newUsers = newUsers.map (user) => 82 | user.marker = new L.AnimatedCircleMarker([user.latitude, user.longitude], @markerOptions) 83 | user.marker.addTo(@map) 84 | # user.marker2.addTo(@map) if user.marker2 85 | user 86 | 87 | # Now that all user markers are drawn, 88 | # remove the previously rendered batch of markers 89 | @users.map (user) => 90 | user.marker.remove() 91 | # user.marker2.remove() if user.marker2 92 | 93 | # The number of SVG groups should equal to the number of users, 94 | # Keep an eye on it for performance reasons. 95 | log "marker count: ", 96 | document.querySelectorAll('.leaflet-container svg g').length 97 | 98 | # The new users will be the oldies next time around. Cycle of life, man. 99 | @users = newUsers 100 | 101 | # Keep track of the current time, so as not to render too often. 102 | @lastRenderedAt = Date.now() 103 | 104 | # Extend Leaflet's CircleMarker and add radius animation 105 | L.AnimatedCircleMarker = L.CircleMarker.extend 106 | options: 107 | interval: 20 #ms 108 | startRadius: 8 109 | endRadius: 8 110 | increment: 2 111 | 112 | initialize: (latlngs, options) -> 113 | L.CircleMarker::initialize.call @, latlngs, options 114 | 115 | onAdd: (map) -> 116 | L.CircleMarker::onAdd.call @, map 117 | @_map = map 118 | @setRadius @options.radius 119 | # @timer = setInterval (=>@grow()), @options.interval 120 | 121 | # grow: -> 122 | # @setRadius @_radius + @options.increment 123 | # if @_radius >= @options.endRadius 124 | # clearInterval @timer 125 | 126 | remove: -> 127 | @_map.removeLayer(@) 128 | # @timer = setInterval (=>@shrink()), @options.interval 129 | 130 | # shrink: -> 131 | # @setRadius @_radius - @options.increment 132 | # if @_radius <= @options.startRadius 133 | # clearInterval @timer 134 | # @map.removeLayer(@) 135 | 136 | L.animatedMarker = (latlngs, options) -> 137 | new L.AnimatedCircleMarker(latlngs, options) -------------------------------------------------------------------------------- /lib/user-store.coffee: -------------------------------------------------------------------------------- 1 | http = require 'http' 2 | redis = require 'redis' 3 | 4 | module.exports = class UserStore 5 | 6 | constructor: (cb) -> 7 | @ttl = 60*15 8 | @url = require("url").parse(process.env.OPENREDIS_URL or 'redis://localhost:6379') 9 | @redis = redis.createClient(@url.port, @url.hostname) 10 | @redis.auth(@url.auth.split(":")[1]) if @url.auth 11 | 12 | cb() if cb 13 | 14 | getByUrl: (url, cb) => 15 | @redis.keys url+"*", (err, keys) => 16 | return cb(err) if err 17 | return cb(null, []) if keys.length is 0 18 | @redis.mget keys, (err, users) -> 19 | cb null, users 20 | 21 | add: (user, cb) => 22 | @redis.setex "#{user.url}---#{user.uuid}", @ttl, JSON.stringify(user) 23 | cb() -------------------------------------------------------------------------------- /lib/vendor/Control.FullScreen.js: -------------------------------------------------------------------------------- 1 | L.Control.Fullscreen = L.Control.extend({ 2 | options: { 3 | position: 'topleft', 4 | title: 'View Fullscreen' 5 | }, 6 | 7 | onAdd: function (map) { 8 | var container = L.DomUtil.create('div', 'leaflet-control-fullscreen leaflet-bar leaflet-control'), 9 | link = L.DomUtil.create('a', 'leaflet-control-fullscreen-button leaflet-bar-part', container); 10 | 11 | this._map = map; 12 | 13 | link.href = '#'; 14 | link.title = this.options.title; 15 | 16 | L.DomEvent.on(link, 'click', this._click, this); 17 | 18 | return container; 19 | }, 20 | 21 | _click: function (e) { 22 | L.DomEvent.stopPropagation(e); 23 | L.DomEvent.preventDefault(e); 24 | this._map.toggleFullscreen(); 25 | } 26 | }); 27 | 28 | L.Map.include({ 29 | isFullscreen: function () { 30 | return this._isFullscreen; 31 | }, 32 | 33 | toggleFullscreen: function () { 34 | var container = this.getContainer(); 35 | if (this.isFullscreen()) { 36 | if (document.exitFullscreen) { 37 | document.exitFullscreen(); 38 | } else if (document.mozCancelFullScreen) { 39 | document.mozCancelFullScreen(); 40 | } else if (document.webkitCancelFullScreen) { 41 | document.webkitCancelFullScreen(); 42 | } else { 43 | L.DomUtil.removeClass(container, 'leaflet-pseudo-fullscreen'); 44 | this.invalidateSize(); 45 | this._isFullscreen = false; 46 | this.fire('fullscreenchange'); 47 | } 48 | } else { 49 | if (container.requestFullscreen) { 50 | container.requestFullscreen(); 51 | } else if (container.mozRequestFullScreen) { 52 | container.mozRequestFullScreen(); 53 | } else if (container.webkitRequestFullscreen) { 54 | container.webkitRequestFullscreen(Element.ALLOW_KEYBOARD_INPUT); 55 | } else { 56 | L.DomUtil.addClass(container, 'leaflet-pseudo-fullscreen'); 57 | this.invalidateSize(); 58 | this._isFullscreen = true; 59 | this.fire('fullscreenchange'); 60 | } 61 | } 62 | }, 63 | 64 | _onFullscreenChange: function () { 65 | var fullscreenElement = 66 | document.fullscreenElement || 67 | document.mozFullScreenElement || 68 | document.webkitFullscreenElement; 69 | 70 | if (fullscreenElement === this.getContainer()) { 71 | this._isFullscreen = true; 72 | this.fire('fullscreenchange'); 73 | } else if (this._isFullscreen) { 74 | this._isFullscreen = false; 75 | this.fire('fullscreenchange'); 76 | } 77 | } 78 | }); 79 | 80 | L.Map.mergeOptions({ 81 | fullscreenControl: false 82 | }); 83 | 84 | L.Map.addInitHook(function () { 85 | if (this.options.fullscreenControl) { 86 | this.fullscreenControl = new L.Control.Fullscreen(); 87 | this.addControl(this.fullscreenControl); 88 | } 89 | 90 | var fullscreenchange; 91 | 92 | if ('onfullscreenchange' in document) { 93 | fullscreenchange = 'fullscreenchange'; 94 | } else if ('onmozfullscreenchange' in document) { 95 | fullscreenchange = 'mozfullscreenchange'; 96 | } else if ('onwebkitfullscreenchange' in document) { 97 | fullscreenchange = 'webkitfullscreenchange'; 98 | } 99 | 100 | if (fullscreenchange) { 101 | this.on('load', function () { 102 | L.DomEvent.on(document, fullscreenchange, this._onFullscreenChange, this); 103 | }); 104 | 105 | this.on('unload', function () { 106 | L.DomEvent.off(document, fullscreenchange, this._onFullscreenChange); 107 | }); 108 | } 109 | }); 110 | 111 | L.control.fullscreen = function (options) { 112 | return new L.Control.Fullscreen(options); 113 | }; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "geosockets", 3 | "version": "0.0.0", 4 | "description": "Put all your site visitors on a map", 5 | "main": "index.coffee", 6 | "scripts": { 7 | "start": "coffee index.coffee", 8 | "test": "foreman run test" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "http://github.com/heroku-examples/geosockets.git" 13 | }, 14 | "keywords": [ 15 | "geo", 16 | "websockets", 17 | "heroku", 18 | "map" 19 | ], 20 | "author": "zeke", 21 | "license": "MIT", 22 | "bugs": { 23 | "url": "https://github.com/heroku-examples/geosockets/issues" 24 | }, 25 | "dependencies": { 26 | "coffee-script": "~1.6.3", 27 | "ws": "~0.4.31", 28 | "express": "~3.4.0", 29 | "redis": "~0.8.4", 30 | "hiredis": "~0.1.15", 31 | "cookie-cutter": "~0.1.0", 32 | "domready": "~0.2.12", 33 | "node-uuid": "~1.4.1", 34 | "geolocation-stream": "0.0.1", 35 | "mapbox.js": "~1.3.1", 36 | "is-mobile": "~0.2.2", 37 | "merge": "~1.1.2" 38 | }, 39 | "devDependencies": { 40 | "mocha": "~1.13.0", 41 | "grunt": "~0.4.1", 42 | "grunt-casper": "~0.1.3", 43 | "grunt-contrib-watch": "~0.5.3", 44 | "grunt-coffeeify": "~0.1.3" 45 | }, 46 | "engines": { 47 | "node": "0.10.x" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /public/geosockets.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 854 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |