Node.js has made it simple to run event code on the web, as well as perform some basic geospatial operations. CouchDB gives a quick easy way to query spatial indices as well as a robust document based data store. Combined, these tools can be used to easily create a great new project.
The project is www.mapchat.im. It will allow users to interact by posting real-time chat messages that are tagged with their current location on the map. Other users will see only the messages that are in their current map bounds. CouchDB will be used to store history for the map chat room, and will also handle server side point clustering so multiple chat messages in the same area will be grouped into a conversations. The project will include several other smaller features, including using a custom Google Maps overlay to display chat messages.
There are many chat examples for Node.js. Some of the more interesting Node.js projects leverage websockets where available and use JSON to exchange data quickly, giving the developer a synchronized object that both the client- and server-side JavaScript can use. XHR-long polling is also a technique used in order to provide information about changes from the server or client quickly, even in older browsers. One project has become in many ways the standard for real-time communication between the browser and the server: socket.io.
Socket.io provides a simple API for an application to use to handle messages passing between the client and server. It automatically uses the best type of connection available. That includes using websockets, flash proxy, XHR-long polling, and a few others. Many of the other libraries such as now.js are built on top of the functionality of socket.io. Socket.io has proven to be such a good idea that it has been ported to a couple of other server side languages, though Node.js surely has the most consistent feel between the client and server API.
Install Socket.io using the Node.js Package Manager:
npm install socket.io
To get started, look at a simple example of using socket.io to pass messages back and forth. The server first needs to start up a socket.io listener:
var io = require('socket.io').listen(80); io.sockets.on('connection', function (socket) { socket.send({message:"hello"}); });
After setting up the socket.io object, a callback is added for new connections. Once the callback is run, it sends the client a JavaScript object.
On the client side, it loads the socket.io JavaScript, and then connects to the server:
<script src="/socket.io/socket.io.js"></script> <script> var socket = io.connect('http://localhost'); socket.on('message', function (data) { console.log(data); }); </script>
When the client gets a new message, which will happen when it first connects, the client will output the message to the browser console log.
MapChat uses the ExpressJS web framework. Socket.io handles all of its own setup; it just needs to be passed the server object which is returned by the createServer function:
var express = require("express"), app = express.createServer(), io = require("socket.io"); socket = io.listen(app);
It is that simple to set up socket.io to work with ExpressJS.
Now to set up the rest of the web app. The application will need to serve some static files for the JavaScript and stylesheet as well as a couple of images. To do this, add a static handler. Everything on a path that starts with “/static” will be served from a directory named “static” in the root of our application directory:
app.use('/static', express.static(__dirname + '/static'));
Now add the handler for the main page:
app.get('/', function(req, res){ res.render('index.ejs', { layout: false}); });
This looks for the view template “index.ejs” in the “views” directory. The view template is fairly simple:
<html> <head> <title>Map Chat</title> <script type="text/javascript" src="https://ajax.googleapis.com/ajax/libs/jquery/1.5.2/jquery.min.js"></script> <script type="text/javascript" src="/socket.io/socket.io.js"></script> <script type="text/javascript" src="http://maps.google.com/maps/api/js?sensor=true"></script> <script type="text/javascript" src="/static/client.js"></script> <link rel="stylesheet" type="text/css" href="/static/style.css" /> </head> <body> <div id="content"> <div id="headerwrapper"> <div id="header"> <div id="logo"> </div> </div> </div> <div id="footerwrapper"> <div id="chatsend"> <div id="chatarea"><textarea id="message"></textarea></div> <div id="send"> Send </div> </div> </div> <div id="map"></div> </div> </body> </html>
The JavaScript that gets loaded is for jQuery, socket.io, which is automatically served by socket.io, Google Maps, and our own client JavaScript. There is also a stylesheet. The stylesheet is basic, but can be altered to change most of the application’s style. It is included in the example code for MapChat.
Now the application is ready for the server to start listening for user requests.
app.listen(8000);
Socket.io will form the basis for messaging between the client and server in MapChat. After the connection is created, the client will make a subscription request to the server which will include the client’s current map bounds. In the JavaScript after socket.io is set up, pass the hardcoded data for a worldwide subscription:
bounds = [[-180,-90],[180, 90]]; socket.send({action:"subscribe", bounds:bounds});
When the server gets a new subscription message from the client, it will add the client along with its bounds to the subscription list:
mapchat = { subscriptions: [], subscribe:function(client, msg){ bottomlatlng = new geojs.latLng(msg.bounds[0][1], msg.bounds[0][0]); toplatlng = new geojs.latLng(msg.bounds[1][1], msg.bounds[1][0]); bounds = new geojs.bounds(bottomlatlng, toplatlng); var allReadySubscribed = false; // If this connection already has a subscription update the bounds for(s in mapchat.subscriptions){ if(mapchat.subscriptions[s].client.sessionId == client.sessionId){ allReadySubscribed = true; //Set new bounds. mapchat.subscriptions[s].bounds = bounds break; } } // If no existing subscription, then add a new one. if(!allReadySubscribed){ mapchat.subscriptions.push({client:client, bounds:bounds}); } } };
When a client connects and sends a subscription, we add it to the subscription list. But first the application checks to see if the connection already has a subscription, and if there is an existing subscription, it updates the new bounds. Subscription information does not need to be persisted so it is just kept in memory.
There needs to be a handler for the subscription messages from
the client. The client adds the property named
action
and sets that to subscribe, so the server
knows how to handle that message:
socket.on('connection', function(client){ client.on('message', function(msg){ if(msg.action == "subscribe"){ mapchat.subscribe(this, msg); }else if(msg.action="message"){ mapchat.message(this,msg); } }); });
On a new connection on the server, there needs to be a handler
for new messages from the client. Then the server can check the
action
property that the client added and handle
the messages properly.
Note
Socket.io has just added handling for custom event names.
Using the emit
function instead of send, the
client can name the event that the message will trigger on the
server or vice versa. The server or client can add a callback for
that event using the same “on” function and specifying the event
name as the first argument.
Now that the server has a subscription set up, the client can send a message. The client will send the chat message tagged with the current center of the map. For now, start by just sending a message with a hardcoded location:
var lat = 40.334, lon -103.644; var point = {"type":"Point", "coordinates":[lon, lat]}; socket.send({action:"message", message:"hello", geometry:point});
On the server, the messages from the client will be handled by
checking the action property, and when that is set to “message”,
calling the message
function:
mapchat = { //subscribe: function..., message: function(client, msg){ // Save message to the database msg.date = new Date(); db.save(msg, function (err, res) { if(err){sys.puts("error: "+sys.inspect(err));} }); for(s in mapchat.subscriptions){ sub = mapchat.subscriptions[s]; // We dont need to send a message to the same client that // sent the message. if(sub.client.sessionId != client.sessionId){ // Check see if the bounds match. point = new geojs.point(msg.geometry); if(sub.bounds.contains(point)){ sub.client.send({"type":"message", "geometry":msg.geometry, "message":msg.message}); } } } } };
First the new message is saved to CouchDB so that the map can prepopulated on the first load with with recent messages. Next, the subscription list is checked to see if the new message is inside the bounds of any of the existing subscriptions. If the message is inside the bounds, it will be sent to that client. The client will then show the new message on the map. For now, the client will just output the message to the console.
Depending on the browser’s script console, the output of the object sent to the client should like something like this:
geometry: Object coordinates: Array[2] 0: -105.27054499999997 1: 40.014985 type: "Point" message: "hello" type: "message"
Now that the messages are being successfully passed back and forth between the client and server, the client interface can be added next.
Chat messages should be shown on the map, and new messages should also be tagged with the current center of the map that the user is viewing. Google Maps is easy to use, and is what we will use for MapChat. There are other JavaScript map options, including open source projects like http://openlayers.org/.
Now to set up the map:
$(document).ready(function(){ var myLatlng = new google.maps.LatLng(40.334, -103.644); var myOptions = { zoom: 8, center: myLatlng, mapTypeId: google.maps.MapTypeId.ROADMAP }; map = new google.maps.Map(jQuery("#map")[0], myOptions); });
This will simply default the map to a hardcoded location and load it at zoom level 8. To make MapChat more interesting to the user, it will start them out at their current location.
Modern browsers allow JavaScript to request the user’s current location. The browsers all then ask if the user wants to share their location with the current website. Not all of the users will allow location, nor will all browsers support it. In MapChat if the browser doesn’t support returning location, or if the user does not allow it, the map will default to a central location. Another option would be to add a lookup using the user’s IP address to get a general location based on the Whois record for that IP range. There are databases for this available—however they are not always accurate, especially on mobile devices. Using the built in location request in the browser is becoming more widely supported, gives a more accurate answer, and allows users to opt out of the feature if sharing their location is not something the user is comfortable allowing.
In order to get the user’s location, the client has to check to see if the browser supports location, and if it does, register a callback function to handle the data once the user allows it and the browser has been able to locate the user.
Note
The way the browser gets the location information varies. It can be a lookup of the location based on IP, based on the known location of wireless networks in the area, or on some devices, especially mobile, via GPS. All we need to know is what to do on the callback.
The code to handle browser location:
if(navigator.geolocation) { navigator.geolocation.getCurrentPosition(function(position) { initialLocation = new google.maps.LatLng(position.coords.latitude, position.coords.longitude); map.setCenter(initialLocation); }); }
In this case once the callback is run, the map will be centered on the user’s location.
MapChat needs to know the map bounds in order to send the subscription message, as well as the center of the map so chat messages can be tagged with the current location. In order to get the bounds the client will use the built in functionality of Google Maps and change the subscription message a bit.
google.maps.event.addListener(map, 'bounds_changed', function(){ var mapbounds = map.getBounds(); bounds = [[mapbounds.getSouthWest().lng(), mapbounds.getSouthWest().lat()], [mapbounds.getNorthEast().lng(), mapbounds.getNorthEast().lat()]]; socket.send({action:"subscribe", bounds:bounds}); });
Notice that the subscription code is inside a
bounds_changed
event on the map. Now when the user
moves the map or changes the zoom level, the client can update the
subscription on the server with the new bounds.
When the user sends new chat messages, the client needs to know the current center of the map so the client can add location to the data sent to the server:
var latlon = map.getCenter(); var lat = latlon.lat(), lon = latlon.lng(); var point = {"type":"Point", "coordinates":[lon, lat]}; socket.send({action:"message", message:chatmsg, geometry:point});
Since the client now knows the location of the user, it can set the bounds subscription, as well as tag the message with the location it was sent from.
To start displaying some chat messages on the map, the client will
use the default Google Maps infowindow. In the receive
message
function, the call to create and open an infowindow
will be added:
var chat= { //sendMessage:function()... receiveMessage:function(data){ latlon = new google.maps.LatLng(data.geometry.coordinates[1], data.geometry.coordinates[0]); infowindow = new google.maps.InfoWindow({ content: data.message, disableAutoPan: true }); infowindow.open(map); } };
For MapChat, it would be great to show some custom overlays for chat messages. Using Google Maps, it isn’t too hard to extend the build in OverlayView and make a custom overlay. Custom overlays can be used to show styled messages on maps, custom tile sets, or any other geographic data overlays.
The first step is to set up the new type of overlay. For MapChat, the custom overlay will be ChatOverlay. It is just a div with custom styling to make it fit in better with the rest of the look and feel for MapChat. Whatever data needs to be used by the overlay should be added as arguments to the function so it can be initialized in one call:
function ChatOverlay(latlon, message) { // Now initialize all properties. this._latlon = latlon; this._map = map; this._message = message; this._div = null; // Call setMap() on this overlay this.setMap(map); } ChatOverlay.prototype = new google.maps.OverlayView();
The overlay inherits from the Google Maps OverlayView. The custom overlay still needs to override some functions so it can show up properly. The onAdd function creates the element that is actually added to the map:
ChatOverlay.prototype.onAdd = function() { // Create a new div that will be added to the map. var chatbox = $("<div class='chatmsg'><div class='message'>"+ this._message+"</div></div>"); chatbox.css("position", "absolute"); // This is the reference to the div. this._div = chatbox; // Have to add it to a map pane. in this case the overlay layer. var panes = this.getPanes(); panes.overlayLayer.appendChild(chatbox[0]); };
onDraw
is called as the map is rendered. It
will be called again when the user moves the map, the zoom changes, or
any other interaction that causes the Google map to redraw. Here the
overlay’s position can be set based on the current view options:
ChatOverlay.prototype.draw = function() { // This function is called when the map is redrawn, such as when the user // zooms or moves // To size and position the div correctly, get the projection. var overlayProjection = this.getProjection(); // Convert the Lat Lon into a pixel position var point = overlayProjection.fromLatLngToDivPixel(this._latlon); var div = this._div; // The overlay is dynamically resized depending on zoom level to make // showing a lot of them not cover as much of the map width = 22 *(this.getMap().getZoom()/16)*10; height = 15 * (this.getMap().getZoom()/16)*10; div.css("width", width+"px"); div.css("height", height+"px"); // Set the poition of the div. div.css("left", point.x-(width/2) + 'px'); div.css("top", point.y-height + 'px'); };
To remove overlays from the map, the overlay’s map is set to null.
When that happens, the API will call the overlay’s
onRemove
function. Any extra cleanup should be done
in the onRemove
function:
ChatOverlay.prototype.onRemove = function() { this._div.remove(); this._div = null; };
Other functions can also be added. For example, the ability to show and hide the overlay are commonly added functions:
ChatOverlay.prototype.hide = function() { if (this._div) { this._div.hide(); } }; ChatOverlay.prototype.show = function() { if (this._div) { this._div.show(); } };
Now that the custom overlay is in place, MapChat will use the
ChatOverlay
for new chat messages. In the
receive message
function, make a new
ChatOverlay.
var chat = { //sendMessage:function()... recieveMessage:function(data){ latlon = new google.maps.LatLng(data.geometry.coordinates[1], data.geometry.coordinates[0]); var chatbox = new ChatOverlay(latlon, data.message, map); chatbox.show(); } };
That will open the new overlay when a message is received. With a bit of styling and adding some other data, the new custom overlay starts to look better, as seen in Figure 4-1.
MapChat needs to populate the map with recent messages on the first load. From CouchDB the client will get the recent messages within the current bounds of the user’s subscription. This time, use the geocouch-utils repo to set up the spatial design document. The repository provides some extra functions for working with geospatial data in CouchDB. The repository also comes with some basic spatial view functions, which are ready to use.
The repository is available at https://github.com/vmx/geocouch-utils.
Note
There are several forks of this repo that haven’t all been merged. Max Ogden has one of the most useful forks that is worth looking at for some additional functionality. The geocouch-utils repository includes the geojson-js-util repo (as a submodule) from Max. It is also available at https://github.com/maxogden/geojson-js-utils.
Also check out the submodule for the rep:
hostname $git submodule init
hostname $git submodule update
From the couchapp
directory, push the geo utils
to the mapchat
database:
hostname $ couchapp push mapchat
If the couchapp says that it is not a valid app, then it needs a .couchapprc file to be added:
hostname $ echo "{}" > .couchapprc
Now from the existing chat messages, CouchDB can run spatial bounding box queries (http://127.0.0.1:5984/mapchat/_design/geo/_spatial/points?bbox=-180,-90,180,90).
MapChat needs more data than is returned by the default points function—and it only needs recent points. Create another spatial function (recentPoints.js):
function(doc){ if(doc.geometry){ startdate = new Date(); //Only in the last 24hours. startdate.setTime(startdate.getTime() - (1000*60*60*24)); if(doc.date > startdate){ emit(doc.geometry, { id: doc._id, geometry: doc.geometry, date:doc.date, message:doc.message }); } } }
For new subscriptions or when the user updates their bounds, the
server needs to return the recent messages in the current boundary. In the
subscribe
function, add a query to CouchDB:
mapchat = { subscribe:function(client, msg){ // ...make subscription... bbox = bounds.toBoundsArray().join(","); db.spatial("geo/recentPoints", {"bbox":bbox}, function(er, docs) { if(er){sys.puts("Error: "+sys.inspect(er)); return;} // For each of the recent message in the bounds, // send the client a message. for(d in docs){ client.send({"type":"message", "geometry":docs[d].geometry, "date":docs[d].value.date, "message":docs[d].value.message}); } }); } }
Great! Now recent messages are loaded so when a user first gets to the map they see some past chat messages. The next feature to add is a list of the most concentrated areas of chat activity. These clusters of activity will be shown to the user as places they might want go join the conversation.
In order to use CouchDB to cluster the points, a list function is required. List and show functions are primarily used to format data especially for use in standalone CouchApps. A list function can provide custom formatting for a view. List functions iterate over view results one row at a time, to avoid loading all the view results in the memory at once.
The geocouch-utils repo includes a function for proximity clustering. It works by grouping nearby points that are within a distance threshold from an averaged center point. All the list function needs to do is add documents that contain valid GeoJSON points, and it will return a list of clustered points.
The proximity clustering list function is already included in geocouch-utils:
function(head, req) { var g = require('vendor/clustering/ProximityCluster'), row, threshold =100; start({"headers":{"Content-Type" : "application/json"}}); if ('callback' in req.query) send(req.query['callback'] + "("); if('threshold' in req.query){ threshold = req.query.threshold;} var pc = new g.PointCluster(parseInt(threshold)); while (row = getRow()) { pc.addToClosestCluster(row.value); } send(JSON.stringify({"rows":pc.getClusters()})); if ('callback' in req.query) send(")"); };
Three new options will be added to that list function. First, add an option to return the cluster list without the full list of points in each cluster. The cluster function, by default, includes the list of points that are included in the cluster. The second option is to sort the list by the size of each cluster. The third option is to limit the number of clusters that are returned. The call to the send function will be replaced with the following code that applies the new options:
// ... in the list function clusters = pc.getClusters(); if(('nopoints' in req.query) &&(req.query.nopoints == "true")){ for(c in clusters){ delete clusters[c]['points']; } } if(('sort' in req.query) &&(req.query.sort == "true")){ clusters.sort(function(a, b) {return a.size < b.size}) } if('limit' in req.query){ if(clusters.length > req.query.limit){ clusters.splice(req.query.limit, clusters.length-req.query.limit); } } send(JSON.stringify({"rows":clusters})); // ...
Now the new list function can be requested at http://127.0.0.1:5984/mapchat/_design/geo/_spatiallist/proximity-clustering/recentPoints?bbox=-180,-90,180,90&nopoints=true&sort=true&limit=2.
Notice that this list function can be called on any view. The view to use is specified in the URL. MapChat will use the recentPoints spatial view. When querying for clustered chat locations, that data should be sent to all connected clients, as well as cached for new clients.
First, add a send
function to the MapChat
server:
maphat = { //subscribe:function()... sendChatClusters: function(client){ if(client != undefined){ // Send to just the one client client.send({"type":"clusters", "clusters":mapchat.clusters}); }else{ // Send to all subscriptions for(s in mapchat.subscriptions){ sub = mapchat.subscriptions[s]; sub.client.send({"type":"clusters", "clusters":mapchat.clusters}); } } } };
There are two cases for this function. First, if a new client just connected, it will send them the current clustered chat locations. Second, it will periodically update all connected clients.
In mapchat.subscribe:
//... if(!allReadySubscribed){ mapchat.subscriptions.push({client:client, bounds:bounds}); mapchat.sendChatClusters(client); } //...
All the active connections will be updated every time the server requests the CouchDB list function. Also, the results of that request will be cached:
mapchat = { //sendChatClusters:function()... getChatClusters: function(){ db.spatiallist("geo/proximity-clustering/recentPoints", {"bbox":"-180,-90,180,90", "sort":"true", "limit":"5", "nopoints":"true"}, function(er, docs) { if(er){sys.puts("Error: "+sys.inspect(er));return;} mapchat.clusters = docs; mapchat.sendChatClusters(); // Check the clustered chat locationed every 10 mins. setTimeout(mapchat.getChatClusters, (1000*600)); } } } }
To start the first check of the clustered chat locations simply
call the getChatClusters
function:
mapchat.getChatClusters();
Every ten minutes the server will check with CouchDB for the curent clustered points and send that to the connected clients. The client will add those points to a list, and add click events to those list items so users can navigate to other active conversations on the map.
The client needs to handle a new message type,
clusters
:
var chat = { displayChatClusters: function(clusters){ $("div#clusters ul").empty(); for(c in clusters){ center = [clusters[c].center.coordinates[1], clusters[c].center.coordinates[0]].join(","); image_url = "http://maps.google.com/maps/api/staticmap?center=" + center + "&zoom=4&size=80x40&sensor=true" $li = $("<li><img src='"+image_url+"' />"+ "<div class='location'>"+clusters[c].locationName+"</div></li>"); $("div#clusters ul").append($li); $li.data("location", center); $li.click(function(e){ lat =$(this).data("location").split(",")[0]; lon = $(this).data("location").split(",")[1]; map.setCenter(new google.maps.LatLng(lat,lon)); }); } } };
By adding a bit of styling, images from Google Maps API, and context from SimpleGeo, the clustered points become a well-presented list of recent chat messages, as seen in Figure 4-2.
There is the completed project, MapChat. A demo version of MapChat is hosted at http://mapchat.im and the source code is available at http://github.com/dthompson/mapchat. MapChat makes good use of real-time communications via Node.js. It handles saving and querying geo-tagged chat messages using CouchDB. It also renders those messages on the map for users to interact with, by making use of the Google Maps API. This quick demo shows the power of getting started with geospatial data using Node.js and CouchDB.
Get Getting Started with GEO, CouchDB, and Node.js now with the O’Reilly learning platform.
O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.