Using Leaflet.js with non-geographic imagery

Hey! This was written for Leaflet.js 0.5.1, sorry, it might not work anymore but hopefully it's still helpful. The tileup gem should still be useful. I plan to update the guide when I have time.

Leaflet.js is a free & open source javascript mapping library, akin to Google Maps. The tutorials show how easy it is to wire up something like open street maps and start mapping, but it's not so obvious how you can use Leaflet to explore non-geographical imagery (with non lat-lng coordinates).

The steps roughly required are:

  1. Split large image into smaller tiles.
  2. Rename tiles to fit naming conventions.
  3. Insert JS to convert between pixel coordinates and set the max bounds of the map.

For this example we'll assume our image is 1024x6144.

Lets get started.

Splitting your image in to tiles

Update: I wrote a gem called tileup, you can read about how to use and install it here. Once you've installed tileup, skip down to "Using the tiles in leaflet".

Install imagemagick if you don't already have it installed (avaliable on homebrew) via brew install imagemagick. There should only be 3 or 4 packages needed if you're running OS X 10.8.

Once you have imagemagick installed, you can use it to chop your image up into multiple tiles. Assuming that you have an image called big_image.png:

$ convert -crop 256x256 +repage big_image.png tiles_%d.png

256x256 is the default tile size for Leaflet and we're assuming that your image divides evenly by this. You can override the default tile size, so if this doesn't work for you, change it to something more suitable. +repage will fix some PNG encoding issues. After imagemagick finishes, you should have a stack of new tiles named tiles_[0-N].png.

Renaming the tiles

Leaflet expects to be able to request tiles by an X,Y coordinate, so now we have to rename these tiles from a linear sequence to something useable. To do this, we'll use a simple ruby script:

tile_width = 256
tile_height = 256
image_width = 1024
image_height = 6144
n = 0
# To get this number, look at the number of tiles 
# generated, find the last tile number and add 1
# e.g. tiles_99.png => total_tiles = 100
total_tiles = 100 

tiles_per_column = image_width/tile_width

row = 0
column = 0
(n...total_tiles).each do |i|
  filename = "tiles_#{i}.png" # current filename
  target = "map_#{column}_#{row}.png" # new filename

  puts "copy #{filename} to #{target}" 

  `cp -f #{filename} #{target}` # rename

  # work out next step
  column = column + 1
  if column >= tiles_per_column
    column = 0
    row = row + 1
  end
end

With that saved to the same directory as your tile images:

cd ~/tiles_dir
ruby rename_linear_to_xy.rb

You should now have a second set of tiles, named map_[0..NX]_[0..NY].png, which we can plug into Leaflet.

Using the tiles in Leaflet

To use the tiles in Leaflet we have to do a two things

  1. Set the map coordinate reference system (CRS) to something that will convert lat-lng pairs to pixels.
  2. Correctly set the map bounds.

Leaflet provides a CRS to cover step 1, so when you setup your map, set the crs option to L.CRS.Simple.

While we're setting up the map, we should also set the min, max and default zoom levels to maximum zoom (because we're only providing one level of zoom).

var map = L.map('map', {
  maxZoom: 20,
  minZoom: 20,
  crs: L.CRS.Simple
}).setView([0, 0], 20);

Now we should set the map's max bounds to fit our image, obviously replace these values with your own original large image width and height. We'll use map.unproject to convert from pixel coordinates to lat-lng coordinates. Leaflet still uses lat-lng internally. (Remember, 1024 is our image width, 6145 is our height.)

var southWest = map.unproject([0, 6145], map.getMaxZoom());
var northEast = map.unproject([1024, 0], map.getMaxZoom());
map.setMaxBounds(new L.LatLngBounds(southWest, northEast));

Finally we'll add our tile layer to the map (this is identical to using regular geographic imagery). {x} and {y} will automatically be replaced by Leaflet as required.

L.tileLayer('/map-tiles/map_{x}_{y}.png', {
  attribution: 'Map data © ???',
}).addTo(map);

You can add markers like so (note that we are using map.unproject to get from pixel coordinates to lat-lng coordinates):

// pixel coordinates in large image
var m = {
  x: 102, 
  y: 404
}
var marker = L.marker(map.unproject([m.x, m.y], map.getMaxZoom())).addTo(map);

That's all there is to it.

Adding zoom

Adding the ability to zoom is pretty simple too.

  1. Starting with your largest image (highest level zoom):
    1. Perform steps above for splitting and renaming your tiles.
    2. Save these tiles a directory called map-tiles/20/
  2. Shrink your large image to half its original size.
    1. Perform steps above for splitting and renaming your tiles.
    2. Save these tiles a directory called map-tiles/19/
  3. Repeat as necessary.

You should now have something roughly like this:

map-tiles/20/map_0_0.png
map-tiles/20/map_1_0.png
map-tiles/20/map_2_0.png
...
map-tiles/19/map_0_0.png
map-tiles/19/map_1_0.png
map-tiles/19/map_2_0.png
...
map-tiles/18/map_0_0.png
map-tiles/18/map_1_0.png
map-tiles/18/map_2_0.png

Update the layer url to include a zoom attribute ({z}):

L.tileLayer('/map-tiles/{z}/map_{x}_{y}.png', {
  attribution: 'Map data © ???',
}).addTo(map);

Set your maps min and max zoom levels to match your avaliable zooms:

var map = L.map('map', {
  maxZoom: 20,
  minZoom: 18,
  crs: L.CRS.Simple
}).setView([0, 0], 18);

Lastly, don't forget to check that your map max bounds are correct. They should match the with and height of your largest image (the full zoom image).

var southWest = map.unproject([0, 18435], map.getMaxZoom());
var northEast = map.unproject([3072, 0], map.getMaxZoom());
map.setMaxBounds(new L.LatLngBounds(southWest, northEast));

Hit refresh and hopefully you'll see a zoomable map. Leaflet seems to behave strangely if your map tiles don't cover the whole map view port, so if one of your zoom levels has a particularly small tile set (something in the order of 512px wide combined), then you may have to disable that zoom level.

Written 20th of February, 2013