OLE und MapFish zum Bearbeiten von Geodaten

Der Artikel beschreibt, wie OLE, der OpenLayers Editor, mit MapFish eingesetzt werden kann. Dabei ist kein GeoExt erforderlich, das häufig mit MapFish assoziiert wird. Das Ergebnis gibt es hier.

This article will show how to use OLE to provide feature editing and feature creation within MapFish. For the sake of simplicity it will not include ExtJS or GeoExt that are often associated with MapFish. The result of the tutorial can be seen here.

  • This tutorial assumes MapFish 2.2. The tutorial can be followed with any browser (even though MapFish's development mode only supports Firefox until 3.6 at the time of writing).
  • OLE is the OpenLayers Editor developed by geOps. It is not packed with MapFish but can be easily used with it.
  • The files which are created over the course of the tutorial can be found in a git repository.

Preparing a MapFish Installation

First you need to set up a MapFish project. Thus the setup is briefly listed here or available in more detail in the MapFish Getting Started documentation. If you already have a MapFish installation running, you can safely skip this section.

Download the installation script from http://www.mapfish.org/downloads/go-mapfish-framework-2.2.py which will be used to set up a python environment for use whith MapFish. The environment will contain the required dependencies and helper scripts. Invoke the installation as follows to create the environment in a folder called env.

python go-mapfish-framework-2.2.py --no-site-packages env

Switch into the environment for all further steps:

source env/bin/activate

We start by copying the mapfish template into testapp. testapp is an arbitrary name for your project. When asked for a template engine go with mako and answer that you want SQLAlchemy support.

paster create -t mapfish testapp
cd testapp

In case you want to access the application from another computer, you would need to specify your host name in the configuration development.ini:

[server:main]
use = egg:Paste#http
# Replace this IP address with your own
host = 192.168.0.1
port = 5000

Now, start up the development server and verify you can access the empty application at http://localhost:5000.

paster serve --reload development.ini

Setting up the client side

We need a map which we use as editing surface later on. Place the following code in public/index.html.

<!doctype html>
<html lang="en">
    <head>
        <title>OpenLayers Editor with MapFish</title>
        <meta http-equiv="Content-Type"
            content="text/html; charset=utf-8">
        <link rel="shortcut icon" href="favicon.ico"
            type="image/vnd.microsoft.icon">
        
        <!-- jQuery will be used for AJAX communication but you
            could use something else -->
        <script type="text/javascript"
            src="http://code.jquery.com/jquery-1.7.min.js">
        </script>
        
        <!-- Use a OpenLayers version later than 2.10 for supporting
            parallel script loading as in Firefox 4 and higher -->
        <script type="text/javascript"
            src="http://openlayers.org/api/2.11/OpenLayers.js">
        </script>
        
        <!-- Load OLE, the OpenLayers Editor, into your page -->
        <link rel="stylesheet" href="lib/geosilk/geosilk.css"
            type="text/css">
        <script type="text/javascript" src="lib/loader.js">
        </script>
        
        <!-- Code follows to instantiate the map with OLE and
            binding the client to MapFish via GeoJSON -->
        <script type="text/javascript">
        if(!window.console){
            window.console = {
                log: function(){
                    // Fake console.log for browsers that don't
                    // support it natively
                }
            };
        }
        
        function init() {
            // Implementation follows
        }
        
        // Initialize map on DOMContentLoaded
        $(document).ready(function(){
            init();
        });
        </script>
        <link rel="stylesheet" href="app/css/style.css"
            type="text/css">
        <script type="text/javascript">
        // Initialize menu on DOMContentLoaded
        $(document).ready(function(){
            $('#navigation a').on('click', function(){
                var clickedLink = $(this);
                
                // Remove highlights
                $('#navigation a').removeClass('active');
                // Highlight clicked menu item
                clickedLink.addClass('active');
                
                // Show desired content and nothing else
                var desiredContentId = clickedLink.attr('href')
                    .substring(1);
                $('#'+desiredContentId).show();
                $('.content').not('#'+desiredContentId).hide();
            });
        });
        </script>
    </head>
    <body>    
        <div id="page">
            <ul id="navigation">
                <li><a href="#map" class="active">Map</a></li>
                <li><a href="#video">Video</a></li>
            </ul>
            <!-- The map with the editor goes in here -->
            <h1>OpenLayers Editor with MapFish</h1>
            <!-- OLE in use within a geOps made application -->
            <div id="map" class="content"></div>
            <div id="video" class="content"
                style="display:none;">
                <iframe style="margin-left: 80px;" width="640"
                    height="480"
                    src="http://www.youtube-nocookie.com/embed/eJQIN7j2r9o?hd=1"
                    frameborder="0" allowfullscreen></iframe>
            </div>
        </div>
        <div id="credit">developed by <a
            href="https://www.geops.de"><img src="geops.png"
            alt="geOps"></a></div>
    </body>
</html>

Add the required libraries once the empty page is in place. OLE can be found at https://github.com/geops/ole along with a sample implementation for a server. In order to use OLE with MapFish it will be enough to get the files from https://github.com/geops/ole/tree/master/client/lib and place them in public/lib. Additionally, place https://github.com/geops/ole/tree/master/client/theme/geosilk into public/lib/geosilk.

Add the following code to public/index.html to test everything is functional. It should give a map with editing capabilities. However persistence, translations and some tools that rely on a server will not yet be functional.

function init() {
    var map, editor;
    // Create an OpenLayers map
    map = new OpenLayers.Map('map', {
        maxExtent: new OpenLayers.Bounds(-500,-500, 500, 500)
    });
    // Add OpenStreetMap to have a base layer
    map.addLayer(new OpenLayers.Layer.OSM());
    // Set some default location and zoom level
    map.setCenter(new OpenLayers.LonLat(10, 50), 5);

    // Create an OLE instance
    editor = new OpenLayers.Editor(map, {
        // Choose which tools shall be available
        activeControls: [
            'Navigation', 'SnappingSettings', 'Separator',
            'SplitFeature', 'MergeFeature', 'CleanFeature',
            'DeleteFeature', 'SelectFeature', 'Separator',
            'DragFeature', 'DrawHole', 'ModifyFeature', 'Separator'
        ],
        // Choose which feature types shall be available
        featureTypes: ['polygon', 'path', 'point']
    });

    // Make a toolbar appear so that the user can start to edit
    editor.startEditMode();

    // Implementation of binding OLE to the MapFish powered server
    // follows
}

Persistence and GeoJSON API

After the initial client code for basic editing is in place it is time to take care about the server side to provide persistence. We tested this tutorial using a PostgreSQL server with PostGIS, but other databases should work equally well. The tables within the database will be created by MapFish's scripts and so will the GeoJSON API.

First make your database known to MapFish in development.ini. Change the line starting with sqlalchemy.url to point to your server.

Then layers need to be added. Separate layers will be created for point, line and area features. The layer names are just examples and you could choose other names as well. Add the following lines to layers.ini:

[areas]
singular=area
plural=areas
table=areas
epsg=4326
geomcolumn=the_geom
geomtype=Polygon

[lines]
singular=line
plural=lines
table=lines
epsg=4326
geomcolumn=the_geom
geomtype=LineString

[points]
singular=point
plural=points
table=points
epsg=4326
geomcolumn=the_geom
geomtype=Point

Now, you can create model classes and controllers with a simple script call.

paster mf-layer areas
paster mf-layer lines
paster mf-layer points

The script can only be used to initially create the models and controllers. If you need to modify the table schemes later on you would have to do this manually.

This created 3 files in the model folder and 3 files in controllers. The controllers can be used as they are but the models need to be slightly amended. Leave the models for now and add routes so that the controllers are publicly available in config/routing.py.

map.connect("/areas/count", controller="areas", action="count")
map.resource("area", "areas")

map.connect("/lines/count", controller="lines", action="count")
map.resource("line", "lines")

map.connect("/points/count", controller="points", action="count")
map.resource("point", "points")

The generated controllers implement a GeoJSON based API which is further described in the MapFish Wiki. Since we are going to use this GeoJSON API we are done with the controllers now and can start adding the needed database tables.

You want to amend all the model classes according to the same scheme now. That is adding an import for geoalchemy.GeometryDDL and calling it at the files' end. Remove the __table_args__ attributes and add a primary key to each model class. For example the model for the area features will end up looking like the following:

# -*- coding: utf-8 -*-

from sqlalchemy import Column, types

from geoalchemy import GeometryColumn, Polygon, GeometryDDL

from mapfish.sqlalchemygeom import GeometryTableMixIn
from testapp.model.meta import Session, Base

class Area(Base, GeometryTableMixIn):
    __tablename__ = 'areas'
    # The following fragment needed to be commented out in order to
    # make table creation succeed from: paster setup-app development.ini
    # Additionally, this file needs to be imported in websetup.py
    #__table_args__ = {
    #    "autoload": True,
    #    "autoload_with": Session.bind
    #}

    # A primary key column is required by SQLAlchemy to create DDL
    # statements
    id = Column(types.Integer, primary_key=True)
    the_geom = GeometryColumn(Polygon(srid=4326))

# Triggers SQLAlchemy's DDL statement creation
GeometryDDL(Area.__table__)

Edit your websetup.py and import your model classes to make them known to SQLAlchemy, the OR mapper used by MapFish.

from testapp.model import areas, lines, points

The tables in the database can now be generated by calling

paster setup-app development.ini

As already mentioned above also this script is only able to create and not to modify the tables

Verify that the GeoJSON API is present and working by trying to download data using your browser. Just point it to 192.168.0.1:5000/points or whatever you've set in development.ini. Obviously no data has been added to the database so far thus you should get a JSON response without any geometries in there.

Getting Persistence for OLE

The important thing left is binding OLE to the GeoJSON API. In other words everything is now there except the connection between client and server.

Add the following code into the already existing init function within public/index.html and mind the comments in the code to learn how it works.

function init() {
    // Leave the code in here that is already present and append the
    // following code

    // A flag so that initial adding of features is not mistaken as
    // addition of new features
    var ignoreFeatureAdditions = false;
    // Conversions between GeoJSON and OpenLayers objects
    var geoJSON = new OpenLayers.Format.GeoJSON();

    /**
     * Maps feature classes to controllers. In a real application you
     * would create own feature classes for OpenLayers corresponding
     * to your controllers.
     * Will will just the mapping of a feature class to a controller
     * for the sake of providing a simple tutorial.
     * @param {OpenLayers.Feature.Vector} feature The feature for
     *     which the controller should be returned
     * @return {string} Name of controller
     */
    function getControllerForFeature(feature){
        if(feature.geometry instanceof OpenLayers.Geometry.Polygon){
            return 'areas';
        } else if(feature.geometry instanceof
            OpenLayers.Geometry.LineString){
            
            return 'lines';
        } else {
            return 'points';
        }
    }

    /**
     * Adds or updates a feature. Chosen action depends on the
     * presence of a feature identifier.
     * @param {OpenLayers.Feature.Vector} feature The feature for
     *     which the controller should be returned
     */
    function saveFeature(feature){
        $.ajax({
            // Send to controller responsible for feature
            url: getControllerForFeature(feature),
            type: 'post',
            // Provide server with correct content type
            contentType: 'application/json',
            // Serialize OpenLayers feature to the format as expected
            // by MapFish's generated controllers
            data: JSON.stringify({
                type: 'FeatureCollection',
                features: [
                    $.parseJSON(geoJSON.write(feature))
                ]
            })
        }).done(function(featureCollection){
            // Update the OpenLayers feature in the map with the
            // feature identifier that is newly created on the server
            // on feature addition. Just take the first element since
            // we know there is only one.
            feature.fid = featureCollection.features[0].id;
            console.log('Saved object');
        });
    }

    // Register event handler so that our code gets notified about new
    // elements on the map
    editor.editLayer.events.register('featureadded', this,
        function(object, element) {
            if(ignoreFeatureAdditions){
                // Do nothing during initial population of the map
                // with features
                return;
            }
            // Persist the added feature
            saveFeature(object.feature);
        }
    );
    // Register event handler so that our code gets notified about
    // elements deleted from the map
    editor.editLayer.events.register('featureremoved', this,
        function(object, element) {
            // Convert OpenLayers feature to GeoJSON
            var feature = $.parseJSON(geoJSON.write(object.feature));
            $.ajax({
                // Send to controller responsible for feature deletion
                // and pass feature identifier
                url: getControllerForFeature(object.feature)+'/'
                    +feature.id,
                // Use correct HTTP method
                type: 'delete'
            }).done(function(){
                console.log('Deleted feature', object)
            });
        }
    );
    // Register event handler so that our code gets notified about
    // altered elements on the map
    editor.editLayer.events.register('afterfeaturemodified', this,
        function(object, element) {
            // Persist new geometry
            saveFeature(object.feature);
        }
    );

    /**
     * Adds features to map
     * @param {object} data Server's response after parsing as JSON
     */
    function addFeaturesToMap(data){
        var features = JSON.stringify(data);
        ignoreFeatureAdditions = true;
        // Add features for editing
        editor.editLayer.addFeatures(geoJSON.read(features));
        ignoreFeatureAdditions = false;
    }
    // Send AJAX requests for reading the persistent storage,
    // that's retrieving features from database
    $.ajax({
        url: 'areas'
    }).done(addFeaturesToMap);
    $.ajax({
        url: 'lines'
    }).done(addFeaturesToMap);
    $.ajax({
        url: 'points'
    }).done(addFeaturesToMap);
}

Preparing Advanced Editing Tools

OLE supports some spatial operations that rely on a server backend to carry out the calculations. Namely these operations are cleaning features, merging features and cutting features along a path. We use PostGIS functions for it. If you don't need these features you may ignore this section.

To support advanced editing you need more controller and helper functions. Just copy the controller process to your controllers directory and the helper module to testapp/lib/.

Wrap Up

The tutorial showed how MapFish's generated GeoJSON API can be used with sophisticated editing of map features. It presented how OLE can be easily used with MapFish. Other examples show the use of more backends like the PHP-Framework Zend or the CMS Drupal. The nice thing about MapFish is the ready to use GeoJSON API and the ability to quickly create a simple application stub. However depending on your needs other backends may be favorable.