MapApp3 : Writing a tiles manager
Welcome to the third part of my tutorial on how to create a map app for Android from scratch and –>without<– using Google™ APIs :P.
Series outline:
- Series aim.
- Theory you need to know.
- App design.
- Writing a TilesManager.
- Writing a TilesProvider.
- Seeing results with MapView.
- Adding web support.
- Creating MapView in XML.
- Extra: Fuchs Maps.
______________________________________
Okay, as I said earlier the TilesManager will provide us with calculations needed to get the right tiles and render them in the right place.
TilesManager will have some state variables: position in degrees (longitude & latitude) plus a zoom level.
Using these state variables the TilesManager should be able to provide us with a rectangle (left, top, right, bottom) that contains the indices for the tiles, for example if the rectangle was like (0,3,2,5) it means we should fetch any tile that satisfies:
where zoom is the zoom level stored in the tile manager.
First we need to write a helper class, PointD is just a double Point which holds two double values
package com.mapapp.helpers; public class PointD { public double x, y; public PointD(double x, double y) { this.x = x; this.y = y; } public PointD() { this(0, 0); } @Override public String toString() { return "(" + Double.toString(x) + "," + Double.toString(y) + ")"; } }
The TilesManagerfields & constructor are simple, you should really pay attention to the comments in code:
package com.mapapp.tileManagement; import android.graphics.Point; import android.graphics.Rect; import com.mapapp.helpers.PointD; public class TilesManager { public final static double EarthRadius = 6378137; // in meters public final static double MinLatitude = -85.05112878; // Near South pole public final static double MaxLatitude = 85.05112878; // Near North pole public final static double MinLongitude = -180; // West public final static double MaxLongitude = 180; // East // this value should be extracted from DB, I used 17 for simplicity protected int maxZoom = 17; protected int tileSize = 256; // Size in pixels of a single tile image // Dimensions in pixels of the view the map is rendered in. protected int viewWidth, viewHeight; // Number of tiles (horizontally and vertically) needed to fill the view, // calculated later. protected int tileCountX, tileCountY; // Will hold the indices of the visible tiles protected Rect visibleRegion; // Current location of the tiles manager protected PointD location = new PointD(0, 0); // Current zoom level protected int zoom = 0; public TilesManager(int tileSize, int viewWidth, int viewHeight) { this.tileSize = tileSize; this.viewWidth = viewWidth; this.viewHeight = viewHeight; // Simple math 🙂 tileCountX = (int) ((float) viewWidth / tileSize); tileCountY = (int) ((float) viewHeight / tileSize); // Updates visible region, this function will be explained later updateVisibleRegion(location.x, location.y, zoom); } // We will add some methods here... }
Now the more serious code :), we need a function to convert longitude and latitude to ratio values, in other words we need a way of mapping ranges like this:
For longitude : (Easy one!)
For latitude :
The first one is fairly easy since the longitude lines are linearly distributed so :
The second one needs more math 😦 (I love math but only when I understand it)
Knowing these two ratio values is necessary to get the right tiles and perform various calculations.
So the final function would be like :
public static PointD calcRatio(double longitude, double latitude) { double ratioX = ((longitude + 180.0) / 360.0); double sinLatitude = Math.sin(latitude * Math.PI / 180.0); double ratioY = (0.5 - Math.log((1 + sinLatitude) / (1.0 - sinLatitude)) / (4.0 * Math.PI)); return new PointD(ratioX, ratioY); }
To explain why those two ratio values are useful here’s an example: if I say I have ratioX=0.3 and ratioY=0.75, to know which tile contains these coordinates I just need to know how many horizontal tiles are there in the world map at this zoom level, which is exactly since the tile system is a quad tree! so to calculate the indices of the tile:
very simple right :)? this will work because the tiles are indexed in a way where the most top left tile has the index of (0,0) and the most bottom right tile has the index of (zoom^2 -1, zoom^2 -1).
Now we should define a function mapSize which simply returns . we will use it in another function called calcTileIndices, given longitude and latitude values, this function will give us the x and y index for the tile that these coordinates belong to:
public int mapSize() { return (int) Math.pow(2, zoom); } protected Point calcTileIndices(double longitude, double latitude) { // Simple calculations PointD ratio = calcRatio(longitude, latitude); int mapSize = mapSize(); return new Point((int) (ratio.x * mapSize), (int) (ratio.y * mapSize)); }
So now if you have a longitude latitude pair you can tell which map tile they belong to.
Since the MapView usually fits more than one tile, we need to get our tile and its neighbors to make sure the MapView is full, otherwise the MapView will contain one tile.
protected void updateVisibleRegion(double longitude, double latitude, int zoom) { // Update manager state location.x = longitude; location.y = latitude; this.zoom = zoom; // Get the index of the tile we are interested in Point tileIndex = calcTileIndices(location.x, location.y); // We get some of the neighbors from left and some from right // Same thing for up and down int halfTileCountX = (int) ((float) (tileCountX + 1) / 2f); int halfTileCountY = (int) ((float) (tileCountY + 1) / 2f); visibleRegion = new Rect(tileIndex.x - halfTileCountX, tileIndex.y - halfTileCountY, tileIndex.x + halfTileCountX, tileIndex.y + halfTileCountY); }
Three more functions to finish this class :D:
Point lonLatToPixelXY(double longitude, double latitude) : easy to implement, converts longitude latitude pair to pixel values, in other words it gives the the pixel that these coordinates belong to (most top left pixel is assumed to be 0,0).
These pixel values range from 0 to
public double calcGroundResolution(double latitude) : requires some sorcery (Math :)), calculates how many meters a single pixel represents.
public PointD pixelXYToLonLat(int pixelX, int pixelY) : does the opposite of lonLatToPixelXY, but more complex.
// Simple clamp function protected static double clamp(double x, double min, double max) { return Math.min(Math.max(x, min), max); } public double calcGroundResolution(double latitude) { latitude = clamp(latitude, MinLatitude, MaxLatitude); return Math.cos(latitude * Math.PI / 180.0) * 2.0 * Math.PI * EarthRadius / (double) (tileSize * mapSize()); } public Point lonLatToPixelXY(double longitude, double latitude) { // Clamp values longitude = clamp(longitude, MinLongitude, MaxLongitude); latitude = clamp(latitude, MinLatitude, MaxLatitude); PointD ratio = calcRatio(longitude, latitude); double x = ratio.x; double y = ratio.y; long mapSize = mapSize() * tileSize; int pixelX = (int) clamp(x * mapSize + 0.5, 0, mapSize - 1); int pixelY = (int) clamp(y * mapSize + 0.5, 0, mapSize - 1); return new Point(pixelX, pixelY); } public PointD pixelXYToLonLat(int pixelX, int pixelY) { double mapSize = mapSize() * tileSize; double x = (clamp(pixelX, 0, mapSize - 1) / mapSize) - 0.5; double y = 0.5 - (clamp(pixelY, 0, mapSize - 1) / mapSize); double latitude = 90.0 - 360.0 * Math.atan(Math.exp(-y * 2.0 * Math.PI)) / Math.PI; double longitude = 360.0 * x; return new PointD(longitude, latitude); }
Adding some getters and setters the complete TilesManager.java class looks like:
package com.mapapp.tileManagement; import android.graphics.Point; import android.graphics.Rect; import com.mapapp.helpers.PointD; public class TilesManager { public final static double EarthRadius = 6378137; // in meters public final static double MinLatitude = -85.05112878; // Near South pole public final static double MaxLatitude = 85.05112878; // Near North pole public final static double MinLongitude = -180; // West public final static double MaxLongitude = 180; // East // this value should be extracted from DB, I used 17 for simplicity protected int maxZoom = 17; protected int tileSize = 256; // Size in pixels of a single tile image // Dimensions in pixels of the view the map is rendered in. protected int viewWidth, viewHeight; // Number of tiles (horizontally and vertically) needed to fill the view, // calculated later. protected int tileCountX, tileCountY; // Will hold the indices of the visible tiles protected Rect visibleRegion; // Current location of the tiles manager protected PointD location = new PointD(0, 0); // Current zoom level protected int zoom = 0; public TilesManager(int tileSize, int viewWidth, int viewHeight) { this.tileSize = tileSize; this.viewWidth = viewWidth; this.viewHeight = viewHeight; // Simple math 🙂 tileCountX = (int) ((float) viewWidth / tileSize); tileCountY = (int) ((float) viewHeight / tileSize); // Updates visible region, this function will be explained later updateVisibleRegion(location.x, location.y, zoom); } public static PointD calcRatio(double longitude, double latitude) { double ratioX = ((longitude + 180.0) / 360.0); double sinLatitude = Math.sin(latitude * Math.PI / 180.0); double ratioY = (0.5 - Math.log((1 + sinLatitude) / (1.0 - sinLatitude)) / (4.0 * Math.PI)); return new PointD(ratioX, ratioY); } public int mapSize() { return (int) Math.pow(2, zoom); } protected Point calcTileIndices(double longitude, double latitude) { // Simple calculations PointD ratio = calcRatio(longitude, latitude); int mapSize = mapSize(); return new Point((int) (ratio.x * mapSize), (int) (ratio.y * mapSize)); } protected void updateVisibleRegion(double longitude, double latitude, int zoom) { // Update manager state location.x = longitude; location.y = latitude; this.zoom = zoom; // Get the index of the tile we are interested in Point tileIndex = calcTileIndices(location.x, location.y); // We get some of the neighbors from left and some from right // Same thing for up and down int halfTileCountX = (int) ((float) (tileCountX + 1) / 2f); int halfTileCountY = (int) ((float) (tileCountY + 1) / 2f); visibleRegion = new Rect(tileIndex.x - halfTileCountX, tileIndex.y - halfTileCountY, tileIndex.x + halfTileCountX, tileIndex.y + halfTileCountY); } // Simple clamp function protected static double clamp(double x, double min, double max) { return Math.min(Math.max(x, min), max); } public double calcGroundResolution(double latitude) { latitude = clamp(latitude, MinLatitude, MaxLatitude); return Math.cos(latitude * Math.PI / 180.0) * 2.0 * Math.PI * EarthRadius / (double) (tileSize * mapSize()); } public Point lonLatToPixelXY(double longitude, double latitude) { // Clamp values longitude = clamp(longitude, MinLongitude, MaxLongitude); latitude = clamp(latitude, MinLatitude, MaxLatitude); PointD ratio = calcRatio(longitude, latitude); double x = ratio.x; double y = ratio.y; long mapSize = mapSize() * tileSize; int pixelX = (int) clamp(x * mapSize + 0.5, 0, mapSize - 1); int pixelY = (int) clamp(y * mapSize + 0.5, 0, mapSize - 1); return new Point(pixelX, pixelY); } public PointD pixelXYToLonLat(int pixelX, int pixelY) { double mapSize = mapSize() * tileSize; double x = (clamp(pixelX, 0, mapSize - 1) / mapSize) - 0.5; double y = 0.5 - (clamp(pixelY, 0, mapSize - 1) / mapSize); double latitude = 90.0 - 360.0 * Math.atan(Math.exp(-y * 2.0 * Math.PI)) / Math.PI; double longitude = 360.0 * x; return new PointD(longitude, latitude); } public void setZoom(int zoom) { zoom = (int) clamp(zoom, 0, maxZoom); updateVisibleRegion(location.x, location.y, zoom); } public void setLocation(double longitude, double latitude) { updateVisibleRegion(longitude, latitude, zoom); } public Rect getVisibleRegion() { return visibleRegion; } public int getZoom() { return zoom; } public int zoomIn() { setZoom(zoom + 1); return zoom; } public int zoomOut() { setZoom(zoom - 1); return zoom; } public int getTileSize() { return tileSize; } public int getMaxZoom() { return maxZoom; } public void setMaxZoom(int maxZoom) { this.maxZoom = maxZoom; } }
One note about the code if you’re writing is to take care about the package names.
So finally we have our tiles manager class, we will use its methods later in the MapView class, this TilesManager class should now be complete and we will never change its code :).
Man!!! much unexplained math!!, you’re right my friend :), I didn’t really search for the math behind the calculations, I just got them and tried them and they worked, I guess they have something to do with converting back and forth from spherical to Cartesian coordinates. If you really want to understand the math please use comments and tell me, I’ll try to understand them myself first then I’ll update the post :).
In the next tutorial we’ll write the code of TilesProvider, a simple one I guess :).
When I move the map left or right, I reach the end of the displayed tiles. Once the screen is half empty, it switches to the next set of tiles. Calculation flaw?
I’m not sure I understand what you mean, I tried the app on my Xperia Neo, and on the tablet emulator and it worked fine!
Moving the map has a simple limit, the center of your mobile screen cannot get outside the map.
What exactly do you mean by the next set of tiles?
If you could post a screenshot or two maybe I’ll be able to help 🙂
Sent the images by mail, as I can’t post them here.
Thanks for the screenshots, I think I know the problem now, I had this behavior before.
The problem was that the view is small and the tiles manager decided that it only fits one tile at a time, to fix this go to the TilesManager class (or what ever you named it) and add 1 to both tileCountX and tileCountY
In the tiles manager constructor
I hope this works for you.
If that didn’t fix it, then you might want to check the code that updates the visible region in the TilesManager and add 1 to halfTileCountX, halfTileCountY calculation
So the problem is probably a rounding error, something like 1.3f horizontal tiles being rounding to 1 (int) which displays only one tile at a time, and when you rotate the phone the problem happens on the vertical tiles.
Please let me know about the results :).
The first option works like a charm!
(Mentioned earlier in the email: this issue happened on a Galaxy Mini, which has a small screen)
I noticed the screen size from the screenshots.
I tried a view size of 320*240 but the app still works perfectly!
Anyway, I’m glad it worked, if you have any other issues please share them.
Hi again Fuchs,
I’m sorry for being so boring, but can I make some kind of canvas so that the map itself doesnt leave that canvas? Here are some screenshot from my device. I hope you understand what I mean.
Thanks again.
Hi, I don’t remember hard-coding screen dimensions!. If you mean initializing the two fields in the class TilesManager:
Those two values will be replaced in the constructor so don’t pay attention to them :). Anyway here’s how you can get the size of a display:
http://stackoverflow.com/a/13515612
About your second question, do you mean that you want a map that is always covering the canvas completely? I’m not sure that can be done! you can’t just always have the tiles at all zoom levels!.
I hope this helps :).
Thank you very much, it helped a lot. 😀
Thats actually what I was trying to do. Can I set the default Zoom level,that the app will start with?
Can you help me out here to?
I made my custom sqlitedb in R maps format via Mobile Atlas creator. How do I make the map file copy from assets to sdcard root. In the source I set the default db path to sd root and set the db name to my db. I was wondering if the asset method doesn’t work, can I make it download from the internet when the app starts.
Thank you Fuchs
Hi,
About setting the default zoom level you can check the method MapAppActivity.restoreMapViewSettings, the zoom level is loaded from the preferences and you can simply provide the default value if no previous zoom value exists, just put the value you want instead of the zero:
Please note that you have to make sure you have the map tiles where the map is centered.
About copying the map from assets: If you do so you’ll end up with two databases!, downloading the database from the internet seems more logical.
You can also have an empty database and fill it as the tiles are needed and downloaded from the internet, to do that you can check the tutorial part 6 Web Support.
Congratulations for the series. Very usefull and inspiring.
I have a farm map, located it inside a bounding box (max and min longitudes and latitudes), and tiled it. In the TilesManager I should use the map’s MinLatitude, MaxLatitude, MinLongitude and MaxLongitude right? And in the calcRatio I should use: double ratioX = ((longitude + MinLongitude) / (MaxLongitude-MinLongitude));
instead of double ratioX = ((longitude + 180.0) / 360.0); ?
Thanks in advance.
Hello, again. About my last quentions:
I made it work with my map using the earth’s min and max of the tutorial. In fact I used the FuchsMaps app’s source code, but I tried to use the data of my bounding box in the tiles manager and it did not work. It only showed a grey screen. I used these values:
MinLongitude = -8.07248317733486
MaxLongitude = -7.98289999999999
MinLatitude = -50.07369999999997
MaxLatitude = -49.98278657566054
calcRatio:
double ratioX = ((longitude + MinLongitude) / (MaxLongitude-MinLongitude));
What other Math should I have to change to get it working? I am interested in this approach, because when I click on the map I will receive the rights coordinates of the custom map.
Thank you very much.
Hi Andre,
Assuming the map tiles have the correct scaling in respect to the current zoom level this should work:
then for each tile:
tileIndexX and tileIndexY are zero based indices.
The code above is very similar to drawing the marker on the map where offset is calculated in the method onDraw.
Now to address the issue of the scaling. With the code above you should get your map rendered correctly only at a specific zoom level.
It’s better to come with a generic solution.
using the method here:
drawBitmap (Bitmap bitmap, Rect src, RectF dst, Paint paint)
we can draw a bitmap by specifying a destination rectangle, we just need to calculate the four points of the rectangle, something like this:
then for each tile:
I guess that should work fine on all zoom levels, it could however cause problems if the rectangle is all zeros which could happen if the tiles you have are small and you’re viewing them from a low zoom level (the tiles occupy less than a pixel), this can be fixed by a simple check of the dst rectangle size.
Sorry for not trying the code above and for taking long time to reply, if the solution doesn’t work please let me know and I’ll try to figure it out :).
Thanks for the reply. Don’t bother with the time. I should take a time too, to try and let you know if it worked. You are very helpful, thank you again.
hi andre,
I have faced same problem as u had been faced, If you solved your problem, can u tell me how do you fixed u ur code for perticular city map