In the last post, we put together a simple application to render the tile that contains a particular LatLn
. We even drew an X over the exact point to convince ourselves we'd got the maths right.
Now we're going to try for step 2:
LatLn
The majority of this post is going to be some fairly arduous 2D calculations. Bear with me.
Let's say that we want to have a map x pixels wide, and y pixels high. We'll want to center it at (lat, ln).
We know that, in fact, we can only render a map that has height and width as multiples of our tile size. We can then deploy some overflow: hidden
cunning so that the visible portion is exactly x
by y
.
To do that, we'll already have to render enough tiles such that:
columnCount * tileSize > x
rowCount * tileSize > y
This will work perfectly so long as the centre of the visible part of our map is the centre of the whole tiling.
Why? Consider the pathological case, where x = y = tileSize
, but the desired centre is in one of the corners of the tile in question.
If we rendered only a single tile, moving any corner to the center leaves three quarters of our x
by y
area blank.
We could, if we knew the corner in advance, render a 2x2 grid. Given we're going to be moving the centre around at some point in future, let's not do that.
So we need at least
columnCount = (x // tileSize) + 2
rowCount = (y // tileSize) + 2
Here //
is integer division, e.g 5 // 2 = 2
We're not done, sadly. Imagine a case where columnCount
ends up being even. The smallest such number we can get with the above equation is 4
; the largest such x
that could produce this columnCount
is 767.
Our centre tile must be in the second or third column, i.e its horizontal co-ordinate is somewhere in [256, 768]
. We will have to place it at visible horizontal co-ordinate 383
, so there must always be at least that number of pixels either side of it.
This means our grid should really contain all the horizontal co-ordinates in [-127, 1151]
, which is wider than the [0, 1024]
we have using four tile rows. 127 + (1151 - 1024) = 254
tells us we will need one more tile.
Let's go for
columnCount = (x // tileSize) + 3
rowCount = (y // tileSize) + 3
So, given a request for a visible map later that is x
by y
, we know how big the render area needs to be, and how many tiles it will need to be made up of.
How will we make it so that the requested centre is in the middle of the visible portion, though?
Our visible portion is x
by y
. In any sensible co-ordinate system, we want our chosen LatLn
to be rendered at (x / 2, y / 2)
.
Our rendering layer is actually tileSize * ((x // tileSize) + 3)
by tileSize * ((y // tileSize) + 3)
.
Let's imagine a perfect world, where our LatLn
magically turns out as the centre of its central tile (or the apex of the four centre tiles should there be no canonical centre tile).
Here's a terrible ASCII representation of that state of affairs:
______________________________________________
| invisible portion |
| ___________________________________ |
| | | |
| | visible portion | |
| | | |
| | | |
| |_________________________________| |
| |
|____________________________________________|
If both portions were visible, we'd achieve this with padding, with:
padding-top = padding-bottom =
(invisible height - visible height) / 2 =
(tileSize * ((y // tileSize) + 3) - y) / 2
padding-left = padding-right =
(invisible width - visible width) / 2 =
(tileSize * ((x // tileSize) + 3) - x) / 2
We'll probably have to do this with some position: absolute
, so in fact we'll probably provide:
invisible-top = - ((tileSize * ((y // tileSize) + 3) - y) / 2)
invisible-left = - ((tileSize * ((x // tileSize) + 3) - x) / 2)
This already seems non-trivial, and it's about to get worse: our LatLn
is almost never going to be this well behaved for us.
Have a pause and think about how we might do this before continuing.
I got to an answer by thinking about the following question:
Let's consider the coordinates of our LatLn
in the invisible portion. This is just going to be a simple m
by n
grid of tiles. Which tile will our LatLn
be in?
Let's consider this in only the m
dimension (everything so far has been symmetric, I reckon we can do this WLOG).
If m
is odd, the centre is in the m // 2 + 1
th tile. If m
is even - well, hmm - we have to choose, I guess. Let's choose to always put it in the m // 2 + 1
th tile once again.
We now know that LatLn
is in the (m // 2 + 1, n // 2 + 1)
th tile. We know how big tiles are, and we know (from the pixelWithinTile
part of the calculated TileAddress
) the location of LatLn
within that tile.
We can now calculate the coordinates of LatLn
within the grid. Once again, we're going to choose the top left of our plane as (0,0), with right and down being the positive directions. Sorry again, maths folk.
tileAddress = lookup zoom latln
(xPixel, yPixel) = tileAddress.pixelWithinTile
m = (x // tileSize) + 3
n = (y // tileSize) + 3
latlnx = ((m // 2) * tileSize) + xPixel
latlny = ((n // 2) * tileSize) + yPixel
Now, all we need to do is calculate top
and left
such that:
top = y // 2 - latlny
left = x // 2 - latlnx
This should centre our LatLn
within the visible portion. Let's translate this nonsense into elm
to see if it works.
First, we extract out the lazy grid loader from part three. We let it own the event type it sends to avoid coupling our new demo and part three's demo together.
type alias ImageLoaded =
{ coordinate: (Int, Int)
, url: Url
}
loadingTileImages : Dict (Int, Int) Url -> Tiler.Tile -> Html ImageLoaded
See part three for the full details thereof.
Here's most of our app:
type alias Model =
{ location: LatLn
, x: Int
, y: Int
, images : Dict (Int, Int) Url
}
model = Model (LatLn 48.858193 2.2940533) 712 466 Dict.empty
type Msg = Complete (Int, Int) Url
update : Msg -> Model -> Model
update message model =
case message of
Complete key url ->
{ model | images = Dict.insert key url model.images }
view : Model -> Html Msg
view model =
let zoom = 15
tileSize = 256
(columnCount, rowCount) = calculateTileCount tileSize (model.x, model.y)
tileAddress = lookup 15 model.location
(centreTx, centreTy) = tileAddress.tile
(left, top) = calculateOffsets tileSize (model.x, model.y) (columnCount, rowCount) tileAddress.pixelWithinTile
tiles = Tiler.tile { rowCount = rowCount
, columnCount = columnCount
, origin = Tiler.Tile (centreTx - (columnCount // 2)) (centreTy - (rowCount // 2)) zoom
, viewTile = (loadingTileImages model.images)
, viewRow = fixedWidth tileSize
, outerAttributes = [ style [("position", "relative"), ("top", px top), ("left", px left)] ]
}
lift = \imageLoaded -> Complete imageLoaded.coordinate imageLoaded.url
in Html.div [ style [("width", px model.x), ("height", px model.y), ("overflow", "hidden")] ] [ App.map lift tiles ]
App.map lift tiles
is helping us convert LazyTiles
's ImageLoaded
type into our own Msg
type.
We quietly added zoom
as a parameter to Tiler.Tile somewhere in this change; we're going to need it later, and having it hardcoded deep down in the image url factory was making the author unhappy.
In two helper functions we can see the calculations we outlined earlier:
calculateTileCount : Int -> (Int, Int) -> (Int, Int)
calculateTileCount tileSize (x, y) =
((x // tileSize) + 3, (y // tileSize) + 3)
calculateOffsets : Int -> (Int, Int) -> (Int, Int) -> (Int, Int) -> (Int, Int)
calculateOffsets tileSize (x, y) (columnCount, rowCount) (xpixel, ypixel) =
let xoff = (columnCount // 2) * tileSize
yoff = (rowCount // 2) * tileSize
in
( (x // 2) - (xoff + xpixel)
, (y // 2) - (yoff + ypixel)
)
We might consider reworking calculateOffsets
to take a type alias instead - it was quite tricky making sure each of those tuples was in the right place.
The resulting demo should be centred on the Eiffel Tower.