Creating a User-Contributed Map with PHP and Google Spreadsheets

Pamela Fox, Google Maps API Team
November 2007

Objective

The web is full of communities centered around geographies and interests: people who love museums, European cathedrals, state parks, etc. So, there's always a need for a developer (like you!) to create a system where users can contribute geotagged places to a map, and that's exactly what we'll do here. At the end of this article, you'll have a system where users can register, login, and add geotagged places. The system will use AJAX for the front-end, PHP for server-side scripting, and Google Spreadsheets for storage. If you're accustomed to using MySQL databases for storage, you could easily modify the code here to use a MySQL database backend instead.

This article is broken up into the following steps:


Setting up the Spreadsheet

We'll be using Google Spreadsheets to store all the data for this system. There are two types of data we need to store: user account information and user-added places, so we'll create one worksheet for each data type. We'll be interacting with the worksheets using their list feed, which relies on the first row in a worksheet containing column labels, and each subsequent row containing data.

Visit docs.google.com, and create a new spreadsheet. Rename the default worksheet to "Users," and create columns named "user," "password," and "session." Then add another sheet, rename it to "Locations," and create columns named "user," "status," "lat," "lng," and "date." Or, if you don't feel like all that manual labor, download this template and import it into Google Spreadsheets via the File->Import command.

The user account information needs to be kept private (only visible to the spreadsheet owner-you), while the user-added places will be displayed on a publicly visible map. Luckily, Google Spreadsheets allows you to selectively decide which worksheets in a spreadsheet can be public and which should remain private (default). To publish the "Locations" worksheet, click on the "Publish" tab, click "Publish now," check the "Automatically re-publish" checkbox, and then in the "What parts?" drop-down, select "Sheet 'Locations' only." The correct options are shown in the screenshot below:

Working with the Zend GData Framework

The Google Spreadsheets API provides an HTTP interface for CRUD operations like retrieving rows, inserting rows, updating rows and deleting rows. The Zend Framework provides a PHP wrapper on top of the API (and the other GData APIs) so that you don't have to worry about implementing the raw HTTP operations. The Zend Framework requires PHP 5.

If you don't have it already, download the Zend framework and upload it to your server. The framework is available here: http://framework.zend.com/download/gdata.

You should modify your PHP include_path to include the Zend library. There are several ways of doing that, depending on the level of administration rights you have on your server. One way is to add this line above the require statements in any PHP files using the library:

ini_set("include_path", ".:/usr/lib/php:/usr/local/lib/php:../../../library/");

To test it out, run the Spreadsheets demo by entering this at the command line in the demos/Zend/Gdata folder:

php Spreadsheet-ClientLogin.php --user=YourGMailUsername --pass=YourPassword

If it works, you should see a list of your spreadsheets displayed. If you get an error, check your include path is set correctly and that you have PHP 5 installed.

Creating Global Functions

All of the PHP scripts we'll be writing for the Community Map will be using common includes, variables, and functions, which we'll put in one file.

At the beginning of the file, we'll have the necessary statements to include and load the Zend library, taken from the Spreadsheets-ClientLogin.php example.

Then we'll define the constants that will be used throughout the files: the spreadsheet key and the two worksheet IDs. To find the information for your spreadsheet, open it up, click on the "Publish tab," and click on "More publishing options." Select "ATOM" from the File Format drop-down list, and click "Generate URL." You'll see something like:

http://spreadsheets.google.com/feeds/list/o16162288751915453340.4016005092390554215/od6/public/basic

The spreadsheet key is the long alphanumeric string after "/list/," and the worksheet ID is the 3-character long string after that. To find the other worksheet ID, select the other sheet from the "What Sheets?" drop-down.

Then we'll create 3 functions: setupClient, getWkshtListFeed, and printFeed. In setupClient, we'll set our GMail username and password, authenticate with ClientLogin, and return a Zend_Gdata_Spreadsheets object. In getWkshtListFeed, we'll return a Spreadsheets list feed for a given spreadsheet key and worksheet id, with an optional spreadsheets query (link). The printFeed function is taken from the Spreadsheets-ClientLogin.php example, and may be useful to you for debugging. It will take in a feed object and print it out to the screen.

The PHP that does this is shown below (communitymap_globals.php):

<?php
ini_set("include_path", ".:/usr/lib/php:/usr/local/lib/php:../../../library/");
require_once 'Zend/Loader.php';
Zend_Loader::loadClass('Zend_Gdata');
Zend_Loader::loadClass('Zend_Gdata_ClientLogin');
Zend_Loader::loadClass('Zend_Gdata_Spreadsheets');
Zend_Loader::loadClass('Zend_Http_Client');

define("SPREADSHEET_KEY", "o16162288751915453340.4016005092390554215");
define("USER_WORKSHEET_ID", "od6");
define("LOC_WORKSHEET_ID", "od7");
 
function setupClient() {
  $email = "your.name@gmail.com";
  $password = "yourPassword";
  $client = Zend_Gdata_ClientLogin::getHttpClient($email, $password,
          Zend_Gdata_Spreadsheets::AUTH_SERVICE_NAME);
  $gdClient = new Zend_Gdata_Spreadsheets($client);
  return $gdClient;
}
 
function getWkshtListFeed($gdClient, $ssKey, $wkshtId, $queryString=null) {
  $query = new Zend_Gdata_Spreadsheets_ListQuery();
  $query->setSpreadsheetKey($ssKey);
  $query->setWorksheetId($wkshtId);
  if ($queryString !== null) {
    $query->setSpreadsheetQuery($queryString);
  }
  $listFeed = $gdClient->getListFeed($query);
  return $listFeed;
}
 
function printFeed($feed)
{
  print "printing feed";
  $i = 0;
  foreach($feed->entries as $entry) {
      if ($entry instanceof Zend_Gdata_Spreadsheets_CellEntry) {
         print $entry->title->text .' '. $entry->content->text . "\n";
      } else if ($entry instanceof Zend_Gdata_Spreadsheets_ListEntry) {
         print $i .' '. $entry->title->text .' '. $entry->content->text . "\n";
      } else {
         print $i .' '. $entry->title->text . "\n";
      }
      $i++;
  }
}
 
?>

Registering a New User

To register a new user, we'll want a user-facing HTML page with text fields and a submit button, and a PHP backend script to add the user to the spreadsheet.

In the PHP script, we first include the global script and then get the username and password values from the GET variable. Then we set up a Spreadsheets client, and request the list feed for the users worksheet with a query string to restrict results to only rows where the username column equals the username passed into the script. If we get no rows in the list feed result, then we can safely proceed knowing that the username passed in is unique. Before inserting a row in the list feed, we create an associative array of the column values: the username, an encryption of the password using PHP's sha1 function, and a filler character for the session. Then we call insertRow on the spreadsheets client, passing in the associative array, the spreadsheets key, and the worksheet ID. If the returned object is a ListFeedEntry, then we output a Success! message.

The PHP that does this is shown below (communitymap_newuser.php):

<?php
 
require_once 'communitymap_globals.php';
 
$username = $_GET['username'];
$password = $_GET['password'];
 
$gdClient = setupClient();
 
$listFeed = getWkshtListFeed($gdClient, SPREADSHEET_KEY, USER_WORKSHEET_ID, ('user='.$username));
$totalResults = $listFeed->totalResults;
if ( $totalResults != "0") {
  // Username already exists
  exit;
}
 
$rowArray["user"] = $username;
$rowArray["password"] = sha1($password);
$rowArray["session"] = "a";
 
$entry = $gdClient->insertRow($rowArray, SPREADSHEET_KEY, USER_WORKSHEET_ID);
if ($entry instanceof Zend_Gdata_Spreadsheets_ListEntry) {
  echo "Success!";
}
?>

On the registration page, we can include the Maps API so that we can use it's XMLHttpRequest wrapper function called GDownloadUrl. When the user clicks the submit button, we'll get the username and password from the text fields, construct a parameters string from their values, and call GDownloadUrl on the script url and parameters. Since we're sending sensitive information, we're using the HTTP POST version of GDownloadUrl (by sending the parameters as the third argument instead of appending them to the URL). In the callback function, we'll check for a successful response and output an appropriate message to the user.

A screenshot and code are shown below for a sample registration page (communitymap_register.htm):


<!DOCTYPE html PUBLIC "-//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">
  <head>
  <title> Community Map - Register/Login </title>

  <script src="http://maps.google.com/maps?file=api&v=2&key=abcdef"
      type="text/javascript"></script>
  <script type="text/javascript">

  function register() {
    var username = document.getElementById("username").value;
    var password = document.getElementById("password").value;
    var url = "communitymap_newuser.php?";
    var params = "username=" + username + "&password=" + password;
    GDownloadUrl(url, function(data, responseCode) {
      if (data.length > 1) {
        document.getElementById("message").innerHTML = "Successfully registered." + 
          "<a href='communitymap_login.htm'>Proceed to Login</a>.";
      } else {
        document.getElementById("message").innerHTML = "Username already exists. Try again.";
      }
    }, params);
  }

  </script>

  </head>
  <body>
  <h1>Register for Community Map</h1>
  <input type="text" id="username">
  <input type="password" id="password">

  <input type="button" onclick="register()" value="Register">
  <div id="message"></div>
  </body>
</html>

Logging in a User

To allow users to sign into our system, we'll want a user-facing HTML page to prompt them for their username and password - and a PHP script to verify the login information, create a session ID, and pass it back to the login page to set a cookie. The user will stay logged in through the session cookie on subsequent pages.

In the PHP script, we first include the global script and then get the username and password values from the GET variable. Then we set up a Spreadsheets client, and request the list feed for the users worksheet with a query string to restrict results to only rows where the username column equals the username passed into the script.

In the row that's returned, we'll check that the hash of the password passed in matches the hash stored in the spreadsheet. If it is, we'll create a session ID using the md5, uniqid, and rand functions. Then we'll update the row in the spreadsheet with the session, and output it to the screen if the row update is successful.

The PHP that does that is shown below (communitymap_loginuser.php):

<?php
 
require_once 'communitymap_globals.php';
 
$username = $_POST['username'];
$password = $_POST['password'];
 
$gdClient = setupClient();
 
$listFeed = getWkshtListFeed($gdClient, SPREADSHEET_KEY, USER_WORKSHEET_ID, ('user='.$username));
 
$password_hash = sha1($password);
$row = $listFeed->entries[0];
$rowData = $row->getCustom();
foreach($rowData as $customEntry) {
  if ($customEntry->getColumnName()=="password" && $customEntry->getText()==$password_hash) {
    $updatedRowArray["user"] = $username;
    $updatedRowArray["password"] = sha1($password);
    $updatedRowArray["session"] = md5(uniqid(rand(), true));
    $updatedRow = $gdClient->updateRow($row, $updatedRowArray); 
    if ($updatedRow instanceof Zend_Gdata_Spreadsheets_ListEntry) {
      echo $updatedRowArray["session"];
    }
  }
}
?>

On the login page, we can again include the Maps API so that we can use it's XMLHttpRequest wrapper function called GDownloadUrl. When the user clicks the submit button, we'll get the username and password from the text fields, construct the script URL with the query parameters, and call GDownloadUrl on the script url. In the callback function, we'll set a cookie with the session ID that's returned by the script, or output an error message if none is returned. The setCookie function comes from a cookies.js that's based on the w3c JavaScript tutorial: http://www.w3schools.com/js/js_cookies.asp.

A screenshot and code are shown below for a sample login page (communitymap_login.htm):


<!DOCTYPE html PUBLIC "-//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">
  <head>

  <title>Community Map - Login</title>
      <script src="http://maps.google.com/maps?file=api&v=2&key=abcdef"
      type="text/javascript"></script>
  <script src="cookies.js" type="text/javascript"></script>
  <script type="text/javascript">

  function login() {
    var username = document.getElementById("username").value;
    var password = document.getElementById("password").value;
    var url = "communitymap_loginuser.php?username=" + username + "&password=" + password;
    GDownloadUrl(url, function(data, responseCode) {
      if (data.length > 1) {
        setCookie("session", data, 5);
      } else {
        document.getElementById("nessage").innerHTML = "Error. Try again.";
      }
    });
  }

  </script>
  </head>
  <body>

  <h1>Login for Community Map</h1>

  <input type="text" id="username">
  <input type="password" id="password">
  <input type="button" onclick="login()" value="Login">
  <div id="message"></div>
  </body>
</html>

Letting Users Add Map Places

To let a user add places to our map, we'll want a user-facing HTML page to let them provide information about the location, and two PHP scripts - one to check that they're logged in through the cookie we set, and another to add the location to the locations worksheet.

In the first PHP script that checks if a user is logged in, we first include the global script and then get the session value from the GET variable. Then we set up a Spreadsheets client, and request the list feed for the users worksheet with a query string to restrict results to only those rows where the session column equals the session value passed into the script. We then iterate through the custom entries of that feed (the ones that correspond to our column headers), and print out the corresponding user name for that session if one exists.

The PHP that does that is shown below (communitymap_checksession.php):

<?php

require_once 'communitymap_globals.php';

$session = $_GET['session'];

$gdClient = setupClient();

$listFeed = getWkshtListFeed($gdClient, SPREADSHEET_KEY, USER_WORKSHEET_ID, ('session='.$session));

if ( count($listFeed->entries) > 0) {
  $row = $listFeed->entries[0];
  $rowData = $row->getCustom();
  foreach($rowData as $customEntry) {
    if ($customEntry->getColumnName()=="user") {
      echo $customEntry->getText();
    }
  }
}
?>

In the second PHP script that lets a user add a location, we first replicate the code from communitymap_checksession.php, to make sure the user is still logged in and valid. Then once we get a valid username back from the users sheet, we get the place, lat, and lng values from the GET variable. We put all those values into an associative array, and we also add a "date" value using PHP's date() function, so that we know when the user added the place. We pass that associative array, the spreadsheets key constant, and the locations worksheet id constant into the insertRow function. We then output "Success" if a row for the new location was added to the spreadsheet. If you're getting an error at this step, it's likely due to a mismatch in column header names. The keys in the associative array must match the column headers in the worksheet specified by the spreadsheet key and worksheet ID.

The PHP that does that is shown below (communitymap_addlocation.php):

<?php

require_once 'communitymap_globals.php';

$session = $_GET['session'];

$gdClient = setupClient();

$listFeed = getWkshtListFeed($gdClient, SPREADSHEET_KEY, USER_WORKSHEET_ID, ('session='.$session));

if ( count($listFeed->entries) > 0) {
  $row = $listFeed->entries[0];
  $rowData = $row->getCustom();
  foreach($rowData as $customEntry) {
    if ($customEntry->getColumnName()=="user") {
      $user = $customEntry->getText();
    }
  }

  $place = $_GET['place'];
  $lat = $_GET['lat'];
  $lng = $_GET['lng'];
  $rowArray["user"] = $user;
  $rowArray["place"] = $place;
  $rowArray["lat"] = $lat;
  $rowArray["lng"] = $lng;
  $rowArray["date"] = date("F j, Y, g:i a");
  $entry = $gdClient->insertRow($rowArray, SPREADSHEET_KEY, LOC_WORKSHEET_ID);
  if ($entry instanceof Zend_Gdata_Spreadsheets_ListEntry) {
    echo "Success!\n";
  }
}

?>

On the add location page, we can again include the Maps API so that we can use GDownloadUrl and create a map. After the page loads, we use the getCookie function from cookies.js to retrieve the session value. If the session string is null or empty, we output an error message. If it's not, then we call GDownloadUrl on map.checksession.php, sending in the session. If that successfully returns a username, we display a welcome message to the user, reveal the add-a-location form, and load the map in. The form consists of an address text field, map, and text fields for the placename, latitude, and longitude. If the user doesn't already know the latitude/longitude of the location, they can geocode it by entering their address in the form and pressing "submit." That will send a call to the Map API's GClientGeocoder, which will place a marker on the map if it finds the address, and auto-populate the lat/lng text fields.

When the user is satisfied, they can press the "add location" button. Then, in the JavaScript, we'll get the values for user, place, lat, and lng, and send them off to the communitymap_addlocation.php script with GDownloadUrl.

If that script returns success, we'll output a success message to the screen.

A screenshot and code are shown below for a sample add location page (communitymap_addlocation.htm):

<!DOCTYPE html PUBLIC "-//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">
  <head>
    <meta http-equiv="content-type" content="text/html; charset=UTF-8"/>
    <title>Community Map - Add a Place!</title>

    <script src="http://maps.google.com/maps?file=api&v=2.x&key=abcdef" type="text/javascript"></script>
    <script src="cookies.js" type="text/javascript"></script>
    <script type="text/javascript">
    //<![CDATA[

    var map = null;
    var geocoder = null; 
    var session = null;

    function load() {
      session = getCookie('session');
      if (session != null && session != "") {
        url = "communitymap_checksession.php?session=" + session;
        GDownloadUrl(url, function(data, responseCode) {
          if (data.length > 0) {
            document.getElementById("message").innerHTML = "Welcome " + data;
            document.getElementById("content").style.display = "block";
            map = new GMap2(document.getElementById("map"));
            map.setCenter(new GLatLng(37.4419, -122.1419), 13);
            geocoder = new GClientGeocoder();
          }
        });
      } else {
        document.getElementById("message").innerHTML = "Error: Not logged in.";
      }
    }

    function addLocation() {
      var place = document.getElementById("place").value;
      var lat = document.getElementById("lat").value;
      var lng = document.getElementById("lng").value;

      var url = "communitymap_addlocation.php?session=" + session + "&place=" + place +
                "&lat=" + lat + "&lng=" + lng;
      GDownloadUrl(url, function(data, responseCode) {
        GLog.write(data);
        if (data.length > 0) {
          document.getElementById("message").innerHTML = "Location added.";
        }
      });
    }

    function showAddress(address) {
      if (geocoder) {
        geocoder.getLatLng(
          address,
          function(point) {
            if (!point) {
              alert(address + " not found");
            } else {
              map.setCenter(point, 13);
              var marker = new GMarker(point, {draggable:true});
              document.getElementById("lat").value = marker.getPoint().lat().toFixed(6);
              document.getElementById("lng").value = marker.getPoint().lng().toFixed(6);

              map.addOverlay(marker);
              GEvent.addListener(marker, "dragend", function() {
                document.getElementById("lat").value = marker.getPoint().lat().toFixed(6);
                document.getElementById("lng").value = marker.getPoint().lng().toFixed(6);
	      });
            }
          }
        );
      }
    }
    //]]>

    </script>

  </head>

  <body onload="load()" onunload="GUnload()">
   <div id="message"></div>
   <div id="content" style="display:none">

   <form action="#" onsubmit="showAddress(this.address.value); return false">
        <p>
        <input type="text" size="60" name="address" value="1600 Amphitheatre Pky, Mountain View, CA" />
        <input type="submit" value="Geocode!" />

    </form>
      </p>

      <div id="map" style="width: 500px; height: 300px"></div>
 
 	Place name: <input type="text" size="20" id="place" value="" />
	<br/>
 	Lat: <input type="text" size="20" id="lat" value="" />
	<br/>

 	Lng: <input type="text" size="20" id="lng" value="" />

        <br/>
	<input type="button" onclick="addLocation()" value="Add a location" />
    </form>
    </div>

  </body>
</html>

Creating the Map

Since you made the locations worksheet public in the first step, there is no server-side programming required to create a map of them. In fact, there is no programming required at all. You can use this Spreadsheets -> Map wizard, and it will generate all the code needed for the map. The wizard downloads the worksheet entries into the page by way of appending a script tag that points to the JSON output for the feed, and specifies a callback function that gets called once the JSON has downloaded. More information is available here.

Sample HTML code for doing that is available here: mainmap.htm. A screenshot is shown below:

Conclusion

Hopefully, you now have your very own user-contributed map system running on your server. This article provides the very basic code needed for the essential aspects of this system, but now that you're familiar with the Zend Spreadsheets library, you should be able to extend the system to meet your particular needs. If you've run into errors along the way, remember that you can use the echo command in PHP or the Map API's GLog.write() in JavaScript for debugging, and you can always post in the Maps API or Spreadsheets API developer forums for additional help.