Open Source Mapping with PHP and MongoDB

mapping_application_screenshot In response to Steve's call for a shift toward civic-focused applications that are a better suited for adoption and maintenance by PHP-friendly IT departments, I've momentarily set aside OpenShift's shiny new nodejs-0.10 cartridge in order to show you how easy it is to write a basic, single-page mapping application in PHP.

Those of you who are familiar with Steve's mapping quickstarts may recognize my application as a port of Steve's MongoDB-Python-Flask example application, which also uses MongoDB's spatial features.

Project source: https://github.com/openshift-quickstart/silex-mongodb-parks

Whether your goals are civic-minded or otherwise, PHP can help you craft solutions that are every bit as simple and elegant as what you might expect to see from modern Python, Ruby, or JavaScript frameworks. This particular example is intended to serve as a reusable template for folks who are interested in producing their own mapping applications - substituting in their own collection of map points or other spatial data.

The included sample data contains the names and locations of major National Parks and Historic Sites.

The Datastore: MongoDB

MongoDB provides decent support for spatial data and queries, so I've chosen to use it over MySQL in this example. Since this project is meant to be used as a basic prototype, I'm expecting that the data and schema will vary widely as the project evolves.

Support for schemaless data structures (NoSQL) makes MongoDB an ideal candidate for early projects and for applications that handle sparse data.

Our new postgresql-9.2 cartridge includes support for PostGIS 2.1, which provides the most advanced collection of geo-spatial features available on OpenShift. Ultimately, choosing the right tool for the job may involve finding solutions that help to bridge technology communities requiring minimal long-term effort to clone, configure, host, and maintain.

When importing data, MongoDB expects it's input files to list a single JSON document per line. Our project's parkcoord.json file conforms to these conditions, providing an easy way to load our sample data:

{
  "Name" : "Abraham Lincoln Birthplace National Historical Park", 
  "pos"  : [-85.7302 , 37.5332 ] 
}

Bootstrapping your Database

If included in your project, the .openshift/action_hooks/deploy script will be executed just before your application is started. A list of the available build scripts or action_hooks can be found in our guide for deploying and building applications.

Our deploy script is configured to automatically bootstrap our MongoDB database with the following command:

mongoimport --db $OPENSHIFT_APP_NAME --collection parks --host $OPENSHIFT_MONGODB_DB_HOST --username $OPENSHIFT_MONGODB_DB_USERNAME --password $OPENSHIFT_MONGODB_DB_PASSWORD --port $OPENSHIFT_MONGODB_DB_PORT --type json --file $OPENSHIFT_REPO_DIR/parkcoord.json

Passwords, keys, secrets, and other basic configuration details should be abstracted out of your application code and stored in environment variables that can be easily managed by the platform. OpenShift cartridges, just like our MongoDB cartridge, often automatically publish connection strings as environment variables (shown above).

Next, we make a call to bootstrap.php, adding a spatial index to our data collection:

$parks->ensureIndex(array('pos'=>"2d"));

Now that our data has been imported and indexed, let's take a look at our Back-end API and set up our map's bounding-box query.

The Back-End: Silex

Silex is a clean and simple microFramework built from reusable Symfony2 components. It provides an interface reminiscent of Sinatra, Flask, or RESTify, and can be installed via PHP's Composer dependency manager.

Composer

The example project includes a composer.phar file that can be used to help manage dependencies. Dependencies are recorded in composer.json as well as in the associated composer.lock file.

{
    "require": {
        "silex/silex": "~1.1"
    }
}

Composer will install all it's dependencies into your project's vendor folder.

Our project's .htaccess file will instruct Apache to run app.php to handle mosts requests.

The Silex controller code is simple to load:

require 'vendor/autoload.php';
$app = new \Silex\Application();

In app.php we include a basic route for our index page:

$app->get('/', function () use ($app) {
  return $app->sendFile('static/index.html');
});

We can also include a route to handle the static assets in our css folder:

$app->get('/css/{filename}', function ($filename) use ($app){
  if (!file_exists('static/css/' . $filename)) {
    $app->abort(404);
  }
  return $app->sendFile('static/css/' . $filename, 200, array('Content-Type' => 'text/css'));
});

Our Back-end API includes a /parks/within endpoint, which contacts MongoDB to execute our bounding-box query, producing a JSON data response for our front-end code:

$app->get('/parks/within', function () use ($app) {
  $db_name = getenv('OPENSHIFT_APP_NAME') ? getenv('OPENSHIFT_APP_NAME') : 'parks';
  $db_connection = getenv('OPENSHIFT_MONGODB_DB_URL') ? getenv('OPENSHIFT_MONGODB_DB_URL') . $db_name : "mongodb://localhost:27017/" . $db_name;
  $client = new MongoClient($db_connection);
  $db = $client->selectDB($db_name);
  $parks = new MongoCollection($db, 'parks');
 
  $bounding_box_query = array( 'pos' => 
                  array( '$within' => 
                  array( '$box' =>
                  array(  array( $lon1, $lat1),
                          array( $lon2, $lat2)))));
  $result = $parks->find( $bounding_box_query );
 
  $response = "[";
  foreach ($result as $park){
    $response .= json_encode($park);
    if( $result->hasNext()){ $response .= ","; }
  }
  $response .= "]";
 
  return $app->json(json_decode($response));
});

The above code uses a ternary operator to check several OpenShift environment variables. If the variables are available, then the connection strings will be used. If not, sensible default values are provided, allowing the same code to be run in local development environments.

The server-side code concludes with a $app->run(); statement.

The Front-End: Leaflet

For those of you who are used to working with M-V-C frameworks, all of our View-related code will be written in Javascript and executed on the client side. This separation of code keeps our back-end simple, allowing us to focus on building out clean REST APIs that can support mobile devices and other front-end views of the same data.

Leaflet's mapping solution is well documented, looks great, and is really easy to setup and configure.

We just need to include a link to Leaflet's css stylesheet and javascript code in our index.html file:

<link rel="stylesheet" href="http://cdn.leafletjs.com/leaflet-0.5.1/leaflet.css" />
<script src="http://cdn.leafletjs.com/leaflet-0.5.1/leaflet.js"></script>

We'll also need to initialize our map view, and include a few event hooks and callbacks for updating our map content whenever our viewport is being loaded or modified. Add the following javascript code to your site:

var map = L.map('map').setView([42.35, -71.06], 12);
var markerLayerGroup = L.layerGroup().addTo(map);
L.tileLayer('http://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
  maxZoom: 18,
  attribution: 'Map data &copy; <a href="http://openstreetmap.org">OpenStreetMap</a> contributors, <a href="http://creativecommons.org/licenses/by-sa/2.0/">CC-BY-SA</a>'
}).addTo(map);
 
function getPins(e){
  bounds = map.getBounds();
  url = "parks/within?lat1=" + bounds.getSouthWest().lat + "&lon1=" + bounds.getSouthWest().lng + "&lat2=" + bounds.getNorthEast().lat + "&lon2=" + bounds.getNorthEast().lng;
  $.get(url, pinTheMap, "json")
}
 
function pinTheMap(data){
  //clear the current map pins
  map.removeLayer(markerLayerGroup);
 
  //add the new pins
  var markerArray = new Array(data.length)
  for (var i = 0; i < data.length; i++){
    park = data[i];
    markerArray[i] = L.marker([park.pos[1], park.pos[0]]).bindPopup(park.name);
  }
  markerLayerGroup = L.layerGroup(markerArray).addTo(map);
}
 
// update the map pins whenever the map is redrawn:
map.on('dragend', getPins);
map.on('zoomend', getPins);
map.whenReady(getPins);

The dragend and zoomend map events should now fire whenever our map's viewport is adjusted, sending our screen's North East and South West bounding coordinates to the application's back-end. When our Back-end API returns it's JSON response, we clear the current collection of map pins and then update the map with our new collection of points.

A copy of the resulting application code is available on GitHub: https://github.com/openshift-quickstart/silex-mongodb-parks

Writing Simple, Clean, Reusable Code

I know I've used MongoDB for this demo, which may not be the easiest DB solution for older IT departments to adopt. However, OpenShift really helps to address that concern by providing an open platform that is readily available for use within any IT department - ensuring consistent, reliable, and supported access to newer technologies.

This means that you can build reusable applications that are completely portable across open clouds, an ideal scenario for civic-focused community solutions.

To deploy a clone of this application using the rhc command line tool, choose a name for your app, and list your service dependencies (php, mongodb, application source):

rhc app create parks php-5.4 mongodb-2 --from-code=https://github.com/openshift-quickstart/silex-mongodb-parks.git

Or, clone+deploy via our web-based workflow in a single click:

https://openshift.redhat.com/app/console/application_type/custom?cartridges%5B%5D=php-5&cartridges%5B%5D=mongodb-2&initial_git_url=https%3A%2F%2Fgithub.com%2Fopenshift-quickstart%2Fsilex-mongodb-parks.git

PHP 5.4 or later includes support for a local development server. If your application dependencies are available in your dev environment, you can launch a server from within your project's php folder:

php -S localhost:8000 -t static app.php

A live demo of the finished product is available here: http://phparks-shifter.rhcloud.com/

What's Next?