mapscript recipe - perl mapscript, cgi and rdf store

The Situation

We have a PostGIS database which has a table in it which consists of point geometry, each row having a point, a type and a node identifier. The identifier maps to a table containing nodes in a Subject-Predicate-Object set of statements; an RDF store.

Many of the objects in the RDF store correspond to a point in space. (Specifically, a node in a wireless network). We want people to be able to enter postal code, or perhaps click on an appropriate layer on the map; we get back a map showing points, and a list of the points that are shown on the map with links to another page displaying their metadata.

This Solution

When we started working on this application, we used the mapserver cgi wrapper; each point in our database was accesible via a point query; a form option, selectable via a radio button, switched the map from 'move around' to 'get point information' mode.

The Mapfile

HTML template for the cgi and the mapfile used to generate this map.

class
	    template "http://space.frot.org/splash/node/[node]"

We instruct mapserver to do a point query; if the point clicked on, or near, has a property named 'node', it will be sent on to the URI. This is ideal for creating simple RESTful interfaces.

I had to make sure to set the tolerance property for the layer - not just for the class - to a reasonable distance - 20 to 50 - to make sure that a mouse click would actually be in range of the nearest point. (Without a tolerance, mapserver demands unrealistic precision.)

When one clicks where there is no point nearby, an ERROR or EMPTY property will supply a template to handle the results.

This worked well for quick demos, but soon i found myself wanting more interactivity between the 'web application' and the 'map application' - a map with potential interactivity next to a list of results resulting from a spatial search, for example.

I actually had in mind briefly google's spatial search, for the US only at local.google.com when i was making the interface; you see the points labelled on a map and can select their details via hyperlinks.

The Code

This was designed to be embedded in a lot of perl code, so we built the perl mapscript interface. To get started with perl mapscript:

 #!/usr/bin/perl

 use strict;
 use CGI;
 use mapscript;

 my $mapfile = '/home/jo/01_cgi_mapfile.txt';
 my $map = mapscript::mapObj->new($mapfile);

This creates a map object. The properties which you can set on this correspond pretty closely to what one can do with a mapfile; except that one can add to and overload properties dynamically.

Our application would either be passed a node identifier, which is attached to a wgs84 lat/long point geometry in the database, or an lat/long pair.

From this central point, we need to work out the extents - the top left and bottom right of the map image of which our point is the centre.

 sub extents {
        my ($self,%p) = @_;
        my ($lat,$long,$x,$y,$node);
        if ($node = $p{node}) {
                $lat = $node->geo::lat;
                $long = $node->geo::long;
                $x =  $node->geo::easting;
                $y = $node->geo::northing;
        }
        elsif ($p{lat}) {
                $lat = $p{lat};
                $long = $p{long};
        }
        return if not $lat;

        if (not $x) {
                my $proj = Geo::Proj4->new( proj => "utm", zone => 31 );
                ($x, $y) = $proj->forward($lat,$long);
                $node->geo::easting($x) if $node and not $node->geo::easting;
                $node->geo::northing($y) if $node and not $node->geo::northing;
        }       
        
        my $zoom = $p{zoom};
        $zoom ||= 2000;
        my %data = (minx => int($x - $zoom),
                    miny => int($y - $zoom),
                    maxx => int($x + $zoom),
                    maxy => int($y + $zoom) );
        return %data;
 }

This function accepts either a node or a lat/long pair. It tries to look up the UTM coordinates in the first case, else calculates them.

Once we have the UTM x and y coordinates we add a zoom distance to them - this is supplied with a parameter to the method, or defaults to 2000 metres - and return the resulting bottom left, top right coordinates in a hash.

In order to change the extents of the map object, we need to create a new rectObj, which is a bounding box to load the extent from. Taking the results of the previous function:

 my $rect = mapscript::rectObj->new($e{minx},$e{miny},$e{maxx},$e{maxy});
 $map->{extent} = $rect;

We need to save a copy of this image to return to the browser. In this example, a temporary storage path is provided in the mapfile. We create a new image object, and then save it to disk.

 my $img = $map->prepareImage();
 my $id = 'MS'.time().'.jpg';

 $img = $map->draw();
 $img->save($img->{imagepath}.$id,$mapscript::MS_JPG);

Now to supply a bit of interactivity to the map; the ability to 'page' up and down by clicking arrows to the north, south east and west; also the ability to zoom in or out, and maintain a zoom level.

Index of Features

How do we create our list of points that are featured on the map? We can use a bounding box query in PostGIS, specifying as the box, the same extent which we supplied to the map drawing function.

 my $proj = Geo::Proj4->new(proj => "utm", zone => 31 );
 my ($tlat,$tlong) = $proj->inverse($e{maxx},$e{maxy});
 my ($blat,$blong) = $proj->inverse($e{minx},$e{miny});
 my $sql = "select node, geom, rdf_type from geom2d where geom && setSRID('BOX3D($blong $blat, $tlong $tlat)'::box3d,4326)";
 $sql .= " and rdf_type = '$e{type}'" if $e{type};
 my $dbh = Class::RDF::Store->db_Main;
 my $sth = $dbh->prepare($sql);
 $sth->execute;
 my @nodes; 
 while (my $row = $sth->fetchrow_hashref) {
	push @nodes, $row;
 }

The extents() function gives us back the bounding coordinates of the box in UTM - this is ideal for mapserver, and easy to calculate with as each UTM unit represents a metre; one can do regular cartesian maths with spatial calculations. Our points are stored in WGS84 (SRID 4326 in the database) so we convert back to WGS84 before doing the calculation.

We get back a list of points, with their names and identifiers and types, which are within the bounding box for our query.

Each point has a node property which is the URI of something in an RDF store. Currently this is a quite slow implementation based on Grout, a simple spatial rdf aggregator. We request the list of objects corresponding to the list of URIs from the RDF store. One could imagine equally doing this with a plain relational table.

Paging Navigation

We wanted to be able to "page through" the map by clicking up, down, left, right buttons. I think these work best on either side of the map pane, a-la streetmap. To page around, supply a node identifier or a lat/long pair and a paging direction. The following code shows how to set the central point before working out the extents.


 if (my $page = $self->q->param('page')) {
     my $deg = 0.01;
     if ($page eq 'up') {
             $lat += $deg;
     }
     if ($page eq 'down') {
             $lat -= $deg;
     }
     if ($page eq 'left') {
             $long -= $deg if $long =~ /^-/;
             $long += $deg if $long !~ /^-/;
     }
     if ($page eq 'right') {
             $long += $deg if $long =~ /^-/;
             $long -= $deg if $long !~ /^-/;
     }
 }

This may not be incredibly aesthetically pleasing but it does work in allowing one to pan back and forth and up and down through the map.

screenshot of map with listing of nodes

Each node links through to extended metadata about the point. There is a 'local area info' view with other points of interest near to that wireless node, including other nodes. Both these views also include the map, generated to a sca le supplied in the html template.

local area info

Notes>

01:13 < seang> do not create a rectObj
01:14 < seang> and then assign it to the map's extent
01:14 < seang> instead, you should use the mapObj::setExtent method