The following describes my experience generating an OpenLayers slippy map from GPS tracklogs using TileMill, TileCache, and Mapnik.

I started by exporting my run data from Garmin Training Center and converting it from Garmin Training Center format (.tcx) to GPS Exchange format (.gpx) using GPSBabel.

The resulting GPX had some improperly long, straight segments caused when I would stop and start the tracklog after taking BART underneath the Bay. I massaged the data a bit using a python script to weed out these long segments.

Cleaning discontinuities in gpx tracks.

The excellent QGIS is a great way to view and convert tracklogs as it reads GPX. I used it to preview my tracks and convert them to formats like GEOJson that seemed to work better in Mapnik. QGIS even has a plugin, Quantumnik, that will export, Mapnik xml files.

I downloaded the gray road lines from the US Census’ Tiger/Line site. They’re organized by county. I grabbed ESRI Shapefiles for the handful of counties that make up the Bay Area.

Next I installed and ran TileMill. I’ll skip any discussion of how to build and style map layers in TileMill as their own documentation is excellent. When your map looks the way you want it, export it as Mapnik XML.

Install Mapnik

The next step was to install Mapnik to render the map tiles. TileMill ships with its own build of Mapnik that you theoretically might be able to use, but I wanted to use a standalone install to serve tiles on my computer. The Mapnik install was a little painful on my Mac OS X 10.6 computer. I needed the ogr plugin to read gpx data, so I built Mapnik with the following command:

$ python scons/ INPUT_PLUGINS='csv,geojson,ogr,osm,postgis,python,raster,shape'

TileCache setup

Download TileCache. Inside the tilecache directory open the tilecache.cfg file. This file defines the various map layers that will be served by TileCache. Here’s what mine looks like.

#bbox is in world mercator coordinates (meters)
#maxResolution is also in meters
This parameter defines the projection of the map. I specified EPSG:3857, which is a type of spherical mercator projection popularized by Google Maps.
The path to the exported Mapnik xml file.
This should define a square describing the full extent of your map at the lowest zoom level. Since my map is in EPSG:3857 this bbox will be defined in web mercator UTM coordinates, in the form, lowerLeftLongitude, lowerLeftLatitude, upperRightLongitude, upperRightLatitude.
The resolution in map units per-pixel at the lowest zoom resolution (zoom level 0). The default Mapnik tile size is 256 pixel squares, so maxResolution can be calculated by dividing the the number of map units along one edge of your maps bounding box (upper left longitude – lower left longitude) by 256.

Once tilecache.cfg was properly set up, I was able to run a small tilecache server locally by calling the included This serves map tiles according to the Tile Map Service Specification. I invoked it with the following command:


Once the server was running I could open the URL http://localhost:8080/1.0.0/ in a web browser. This displayed a list of base URLS for all the maps defined in the tilecache.cfg file. Opening the URL for a given map reveals some useful parameters of the map:

Adding 0/0/0.png to the URL, e.g. http://localhost:8080/1.0.0/run_data/0/0/0.png loaded the tile at the widest zoom level containing the entire extent of my map. Once I confirmed Mapnik was actually drawing something, it was time to load it up in a proper slippy map with OpenLayers.

The relationship between maxZoom, bbox, and the various zoom levels.


The TileCache install actually comes with a simple html file that uses OpenLayers to view the basemap, index.html. I copied it and modified it to make it load up my run_data map directly from the tilecache server running on my local computer.

After I was done modifying the file to load my own map layer, the section of the file that defines the init() function looked like this:

function init(){
    var mapDiv = document.getElementById('map');
    var wgs84 = new OpenLayers.Projection("EPSG:4326");
    var epsg3857 = new OpenLayers.Projection("EPSG:3857");
    var map = new OpenLayers.Map( mapDiv, {  
                              numZoomLevels: 8
    var layer = new OpenLayers.Layer.TMS( "Runs", 
            layername: 'run_data', 
            type: 'png',
            projection: epsg3857,
            maxExtent: new OpenLayers.Bounds(-13697662.967029,
            maxResolution: 1344.55846495703008258715, // meters
    var center = new OpenLayers.LonLat(-122.27275, 37.87159);
    center.transform(wgs84, epsg3857);
    map.setCenter(center, 6);


var mapDiv = document.getElementById('map');

This line finds the div element using the DOM that will be filled with the OpenLayers map.

var wgs84 = new OpenLayers.Projection("EPSG:4326");

Constructs a Projection element that will be used to address coordinates in latitude and longitude degreees, AKA WGS84.

var epsg3857 = new OpenLayers.Projection("EPSG:3857");

Constructs an EPSG:3857, AKA “Web Mercator,” object. This is the map projection.

map = new OpenLayers.Map( mapDiv, { . . .

Constructs the OpenLayers map instance that will be rendered in the map div.

layer = new OpenLayers.Layer.TMS( "Runs", . . .

Constructs an OpenLayers TMS Layer object that will actually fetch the map layers from the tilecache server started earlier.


The second argument to the TMS constructor is the base URL of the local tilecache server instance.

layername: 'run_data',

The name of the layer defined in the tilecache.cfg file.

type: 'png',

The tilecache instance is serving PNG’s.

projection: epsg3857,

The layer uses Web Mercator projection. Pass it the projection object constructed earlier via the projection parameter.

maxExtent: new OpenLayers.Bounds(-13697662.967029, . . .

This is the bounding box of the map at its widest zoom extent. It is in Web Mercator coordinates and is identical to the “bbox” parameter defined in the tilecache.cfg file for this map layer. It is also in the XML metadata served by the tilecache instance.

maxResolution: 1344.55846495703008258715, // meters

The resolution in map units per-pixel at the widest zoom extent (zoom level 0). This is the same as the maxResolution parameter for the layer in the tilecache.cfg file.

var center = new OpenLayers.LonLat(-122.27275, 37.87159);

Define a point with latitude/longitude degrees.

center.transform(wgs84, epsg3857);

Transform the point from WGS84 to the map’s coordinate system.

map.setCenter(center, 6);

Center the map on the point that was just defined, at zoom level 6.

At this point I was able to load up the test page in my browser and see the map rendered live by the TileCache instance. This was useful to validate that my map was rendering properly. Here are some of the key points I discovered:

  • The bounding box of the map must be a perfect square.
  • I found it useful to use OpenLayer’s own projection objects to convert from more human readable coordinates (WGS84) to my map’s coordinates. In the Firebug console something like this:
    mypt = new OpenLayers.LonLat(-122.74269, 37.34669);
    mypt.transform(new OpenLayers.Projection("EPSG:4326"), new OpenLayers.Projection("EPSG:3857"));
  • Everything must be defined in the map’s coordinate system.

In future posts I’ll describe how I generated a cache of tiles to disk to host them on my regular web server and also how I was able to follow the instructions on the MapBox site to add OpenStreetMap data to the tracks.

Tagged on:                     

Leave a Reply