For a while, cs50.tv (and its sister sites) have had a "guestmap," whereby visitors are invited to record their names and hometowns on a Google Map, if only for the fun of it. To see the map, head to http://cs50.tv/ and click Guestmap in the site's top-left corner. (Feel free to add yourself!) For the impatient, here's a screenshot of what it looks like at the moment:
When we decided (spontaneously one night) to implement this guestmap, we weren't sure if anyone would notice (or care!) if we did, so we didn't want to spend too much time on its implementation. In particular, we didn't want to bother setting up an entire database (e.g., MySQL) just for this one application. Simpler would have been to store folks' submissions in a local file (e.g., CSV, SQLite, or XML). But the plan was to start replicating cs50.tv's content across multiple servers, in which case we'd then have to keep copies of that file synchronized across multiple servers. Doable, but somewhat annoying. If we used a local file as our database, we'd also have to worry about concurrent writes (as when two visitors happen to submit their names and hometowns simultaneously, the result of which might be to lose one of the visitor's data). Also doable, and not very hard (thanks to functions like flock). But we'd still have to implement the form via which we ask for visitors' names and hometowns. (And at this point in the story, it was probably already midnight or so.)
If only there were someone to whom we could outsource both the form and the database...
Turns out there was! Odds are you're familiar with Google Spreadsheets, which is essentially Google's web-based answer to Microsoft Excel. It's actually wonderfully useful, in large part because it makes it so easy to share (and edit simultaneously) spreadsheets with other people. But with Google Spreadsheets can you also create web-based forms whose submissions end up in a Google Spreadsheet: each row represents a submission, and each column represents a visitor's answer to a particular question. And you can then share that spreadsheet with yourself (or others) in a variety of formats, among them CSV.
Nice! Even without writing any code, I was almost finished implementing the guestmap! (Okay, admittedly, it took a few minutes to create the form and share it with myself as CSV, but way fewer minutes than it'd have taken to implement all that myself.)
So, at this point in the story, I had two URLs: one for the form and one for the CSV file. My plan was (1) to embed the former in an iframe on cs50.tv so that folks could add themselves to the guestmap without leaving cs50.tv itself and (2) query the latter anytime someone wanted to look at the map. All that remained was to write the code that queries the CSV and generates the map.
Thanks to the Google Maps API, this part, too, was amazingly easy. To embed a Google Map in one of your own pages, you first need to include the API in your page's head, as with:
<script type="text/javascript" src="http://maps.google.com/maps/api/js?sensor=false"></script>
You then need to provide a container for it in your page's body, as with:
<div id="map" style="height: 100%; width: 100%;"></div>
Lastly, you need to tell Google to transform that container into a map, as with:
new google.maps.Map(document.getElementById("map"), {
center: new google.maps.LatLng(42.37411257777324, -71.11905097961426),
disableDefaultUI: true,
mapTypeControl: false,
mapTypeId: google.maps.MapTypeId.HYBRID,
navigationControl: true,
zoom: 2
});
Of course, that map won't have any markers on it until you plant them yourself. Planting a marker, though, requires knowing the GPS coordinates of each visitor's hometown. Probably not something most people know. So, how can we geocode (i.e., convert) each visitor's city, state, and country (that he or she inputted via the Guestmap's web-based form) into a latitude and longitude?
Well, the Google Maps API does provide a JavaScript geocoding service, but using that service would mean that every visitor's hometown would need to be geocoded every time someone pulls up the guestmap by that someone's own browser. Certainly not the most efficient approach. Plus, Google appears to limit the number of geocoding requests a browser can make in a short period of time, the result of which is that some visitors wouldn't end up marked on the map. (We, um, learned that the hard way.) Better to do the geocoding server-side and remember each visitor's coordinates so that we needn't query for them again.
As luck would have it, Google also provides a web service with which you can do exactly that. Via Google's Geocoding API can you ask for the GPS coordinates of some location (e.g., 33 Oxford Street, Cambridge, Massachusetts) with code like the below:
$o = json_decode(file_get_contents("http://maps.googleapis.com/maps/api/geocode/json?address=33+Oxford+Street,+Cambridge,+Massachusetts+02138&sensor=false"));
I called json_encode in the above because what's returned is a JSON object like the below:
{
"status": "OK",
"results": [ {
"types": [ "street_address" ],
"formatted_address": "33 Oxford St, Cambridge, MA 02138, USA",
"address_components": [ {
"long_name": "33",
"short_name": "33",
"types": [ "street_number" ]
}, {
"long_name": "Oxford St",
"short_name": "Oxford St",
"types": [ "route" ]
}, {
"long_name": "Cambridge",
"short_name": "Cambridge",
"types": [ "locality", "political" ]
}, {
"long_name": "Cambridge",
"short_name": "Cambridge",
"types": [ "administrative_area_level_3", "political" ]
}, {
"long_name": "Middlesex",
"short_name": "Middlesex",
"types": [ "administrative_area_level_2", "political" ]
}, {
"long_name": "Massachusetts",
"short_name": "MA",
"types": [ "administrative_area_level_1", "political" ]
}, {
"long_name": "United States",
"short_name": "US",
"types": [ "country", "political" ]
}, {
"long_name": "02138",
"short_name": "02138",
"types": [ "postal_code" ]
} ],
"geometry": {
"location": {
"lat": 42.3787770,
"lng": -71.1168170
},
"location_type": "ROOFTOP",
"viewport": {
"southwest": {
"lat": 42.3756294,
"lng": -71.1199646
},
"northeast": {
"lat": 42.3819246,
"lng": -71.1136694
}
}
}
} ]
}
And so we can get at the GPS coordinates we want with code like the below:
$lat = $o->results[0]->geometry->location->lat;
$lng = $o->results[0]->geometry->location->lng;
And we can then store those coordinates somewhere. (We happen to use the Google Data Protocol in order to write them back to our own Google Spreadsheet in a column that the web-based form itself doesn't touch.)
With all those pieces now assembled, we render the map itself with code like the below, wherein CSV is the URL of our Google Spreadsheet:
<!DOCTYPE html "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" style="height: 100%;">
<head>
<link rel="stylesheet" type="text/css" href="http://yui.yahooapis.com/2.8.1/build/reset-fonts/reset-fonts.css" />
<meta http-equiv="content-type" content="text/html; charset=utf-8"/>
<script type="text/javascript" src="http://yui.yahooapis.com/2.8.1/build/yahoo-dom-event/yahoo-dom-event.js"></script>
<script type="text/javascript" src="http://maps.google.com/maps/api/js?sensor=false"></script>
<script type="text/javascript">
// <![CDATA[
YAHOO.util.Event.onDOMReady(function() {
var gmap = new google.maps.Map(document.getElementById("map"), {
center: new google.maps.LatLng(42.37411257777324, -71.11905097961426),
disableDefaultUI: true,
mapTypeControl: false,
mapTypeId: google.maps.MapTypeId.HYBRID,
navigationControl: true,
zoom: 2
});
// resize as needed
YAHOO.util.Event.addListener(window, "resize", function() {
if (typeof(gmap) !== "undefined")
{
google.maps.event.trigger(gmap, "resize");
}
});
// prepare infowindow
var infowindow = new google.maps.InfoWindow();
<?
// get subscribers
$rows = array();
$handle = fopen(CSV, "r");
fgetcsv($handle);
while ($row = fgetcsv($handle))
$rows[] = $row;
fclose($handle);
?>
<? foreach ($rows as $row): ?>
<? if (!@$row[9] || $row[12]) continue; ?>
var marker = new google.maps.Marker({
map: gmap,
position: new google.maps.LatLng(<?= $row[10] ?>, <?= $row[11] ?>)
});
google.maps.event.addListener(marker, "click", function(e) {
infowindow.setContent("<div style='width: 240px;'><b>" + <?= json_encode(CapitalizeLastName($row[1])) ?> + "</b>" + "<br />" + <?= json_encode($row[9]) ?> + "</div>");
infowindow.open(gmap, this);
});
<? endforeach ?>
});
// ]]>
</script>
<title>Map</title>
</head>
<body class="yui-skin-sam" style="height: 100%;">
<div id="map" style="height: 100%; width: 100%;"></div>
</body>
</html>