Tom MacWright

tom@macwright.com

Interacting with Image Maps

Update: This post was published in 2012. As of 2017,
  • I ended official support for TileMill in 2016. The Mapbox way to make this kind of map is now Studio. TileStream Hosting is now just called Mapbox.
  • The math in this post is still relevant, but the library I'm using here - Modest Maps - no longer is. You would use Mapbox GL JS instead now.

Last week I wrote Images as Maps, which talked about one technique of tricking spatial software like TileMill into displaying plain old images as maps. The effect is making commonplace images accessible with stuff like MapBox. Versus a ‘commercial solution’ like Zoomify, it’s a bit of a stretch but has potential benefits - cross-browser, open-source Javascript libraries, strong infrastructure. Plus, misusing systems is fun.

The first step is input, or data: and this is handled by the togeo.py script. It tells TileMill that your source image is in Spherical Mercator, and thus doesn’t need to be skewed or stretched at all.

But what about navigating within these maps?

So, the vast majority of map navigation is via latitude and longitude values. To create and center a map, you usually do something like

var map = mapbox.map('map');
map.center({ lat: 37, lon: -77 });

If the map was in the ugly plate carrée projection, then just using pixel values scaled into latitude-longitude would work, but nearly all web maps are made in good old spherical mercator, which does not have a 1:1 relationship between lines of latitude and pixels.

The first issue is what to do about bounds. The bounds of Spherical Mercator are the corners -20037508.34, -20037508.34, 20037508.34, 20037508.34 - which are technically expressed in ‘meters’ but don’t take that literally. My script chooses to fit the image as best it can within the bounds, and center it in the other direction, if the aspect ratio is not 1:1.

the three basic placements of togeo

And so now given one measurement - the original pixel size of the image, we can derive the new geographical location of any pixel location in that image - what I’ll call an ‘image pixel’. The word ‘Pixel’ is a bit overloaded, since there are screen pixels, the MM.Point class for any x,y value, etc. Image pixels will refer to absolute pixel locations on an image, like you’d see in Photoshop’s info window. We do this with coordinates and just a little bit of magic.

Coordinates

I mentioned coordinates in how web maps work: they’re a data type that tells you the column, row, and zoom level of places in a map, like you might have seen in /0/0/0.png URLs. The great thing about coordinates is that they’re simple - they’re in simple x/y space, where the ‘0’ tile - the tile that represents the whole world in one 256x256 image - has potential x & y values from 0 to 1 - so the center of that tile is the coordinate { row: 0.5, column: 0.5, zoom: 0 }.

So, let’s start coding. First this code needs to know about the size of the image. We could create an object to do this, but it’s simpler to use a javascript closure - in this case a function that returns another function having scoped a few values.

// this is the closure - a function that you call to get a function,
// which keeps a memory of the original values you passed.
function pixelToCoordinate(w, h) {
  return function(x, y) {
    // when this function is called, it still knows the values
    // of w, h, and map
  };
}

I’ll split up the implementation into two functions. The first converts an image pixel into a map coordinate:

function pixelToCoordinate(w, h) {
  return function(x, y) {
    // here's the only real tricky part
    // of the code: if the image doesn't fill
    // the whole canvas, that means that it's
    // nudged either down or to the left
    // to be centered. The size of this nudge
    // is equal to the difference between
    // the width and height over two.

    if (w > h) y += (w - h) / 2;
    if (w < h) x += (h - w) / 2;

    return new MM.Coordinate(
      // row
      y / Math.max(w, h),
      // column
      x / Math.max(w, h),
      // zoom
      0
    );
  };
}

The second function converts an image pixel into a geographical location with lat/lon values.

function pixelToLocation(w, h, map) {
  var toCoordinate = pixelToCoordinate(w, h);
  return function(x, y) {
    // map.coordinateLocation takes a coordinate
    // value and returns a geographical
    // location value.
    return map.coordinateLocation(toCoordinate(x, y));
  };
}

Why split this into two sections? The map.coordinateLocation call is somewhat expensive - it creates four objects per call. This shouldn’t matter for 95% of uses, but if you’re doing tons of calls per second, you’ll start to run into garbage collection hassles.

Anyway, on to a demo:

See this inline with source on bl.ocks.org

See Also