Home > Android, Maps, Programming > MapApp3 : Writing a tiles manager

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:

______________________________________

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:

0 \le xIndex\le 2 \& 3 \le yIndex \le 5 \& zIndex=zoom

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 : [minLongitude, maxLongitude]\to[0, 1] (Easy one!)
For latitude : [minLatitude, maxLatitude]\to[0, 1]

The first one is fairly easy since the longitude lines are linearly distributed so :

ratioX=\frac{longitude - minLongitude}{maxLongitude - minLongitude}

The second one needs more math 😦 (I love math but only when I understand it)

sinLatitude=sin(latitude\times\frac{\pi}{180})

ratioY=\frac{0.5-log(\frac{1 + sinLatitude}{1 - sinLatitude})}{4\times\pi}

Knowing these two ratio values is necessary to get the right tiles and perform various calculations.

ratio values on map

Ratio values on map
(Original image from OpenStreetMap)



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 2^{zoom} since the tile system is a quad tree! so to calculate the indices of the tile:

count = 2^{zoom}
tileX = ratioX\times count
tileY = ratioY\times count

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 2^{zoom}. 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 tileSize\times(2^{zoom})-1

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 :).

Advertisements
Categories: Android, Maps, Programming
  1. DSC
    25/03/2012 at 1:09 am

    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?

    • Fuchs
      25/03/2012 at 3:10 am

      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 🙂

  2. DSC
    25/03/2012 at 5:30 am

    Sent the images by mail, as I can’t post them here.

    • Fuchs
      25/03/2012 at 6:09 am

      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

      public TilesManager(int tileSize, int viewWidth, int viewHeight)
      {
          this.tileSize = tileSize;
      
      	this.viewWidth = viewWidth;
      	this.viewHeight = viewHeight;
      
      	// Simple math 🙂
      	tileCountX = (int) ((float) viewWidth / tileSize)+1;
      	tileCountY = (int) ((float) viewHeight / tileSize)+1;
      
      	// Updates visible region, this function will be explained later
      	updateVisibleRegion(location.x, location.y, zoom);
      }
      

      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

      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)+1;
      	int halfTileCountY = (int) ((float) (tileCountY + 1) / 2f)+1;
      
      	visibleRegion = new Rect(tileIndex.x - halfTileCountX, tileIndex.y - halfTileCountY, tileIndex.x + halfTileCountX, tileIndex.y
      			+ halfTileCountY);
      }
      

      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 :).

  3. DSC
    25/03/2012 at 6:31 am

    The first option works like a charm!

    (Mentioned earlier in the email: this issue happened on a Galaxy Mini, which has a small screen)

    • Fuchs
      25/03/2012 at 2:53 pm

      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.

  4. Adi
    22/09/2013 at 8:19 pm

    Adi :
    Hi Fuchs!
    What do I have to do to get my phones actual screen Width and Height. In your source code it set to 800 by 600.
    Thanks in advance

    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.

    • Fuchs
      23/09/2013 at 11:34 am

      Hi, I don’t remember hard-coding screen dimensions!. If you mean initializing the two fields in the class TilesManager:

      protected int viewWidth = 800;
      protected int viewHeight = 600;
      

      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 :).

      • Adi
        24/09/2013 at 7:53 am

        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

        • Fuchs
          26/09/2013 at 5:48 pm

          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:

          zoom = pref.getInt(Pref.ZOOM, 0);
          

          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.

  5. Andre
    08/11/2013 at 8:47 pm

    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.

  6. Andre
    10/11/2013 at 12:01 am

    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.

    • Fuchs
      10/11/2013 at 6:23 pm

      Hi Andre,

      Assuming the map tiles have the correct scaling in respect to the current zoom level this should work:

      Point mapTopLeft = tileManager.lonLatToPixelXY(mapMinLongitude, mapMinLatitude);
      

      then for each tile:

      canvas.drawBitmap(myTile ,mapPos.x - offset.x + (tileIndexX*myTileWidth), mapPos.y - offset.y + (tileIndexY*myTileHeight), bitmapPaint);
      

      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:

      // Calculating full map boundaries
      Point mapTopLeft = tileManager.lonLatToPixelXY(mapMinLongitude, mapMinLatitude);
      Point mapBottomRight = tileManager.lonLatToPixelXY(mapMaxLongitude, mapMaxLatitude);
      
      // Tile dimensions in pixels for the current zoom level
      float scaledTileWidth = (mapBottomRight.x - mapTopLeft.x)/mapTilesCountX;
      float scaledTileHeight = (mapBottomRight.y - mapTopLeft.y)/mapTilesCountY;
      

      then for each tile:

      RectF dst = new RectF(mapTopLeft.x, mapTopLeft.y, scaledTileWidth , scaledTileHeight);
      dst.offset(-offset.x + (tileIndexX*scaledTileWidth), -offset.y +(tileIndexY*scaledTileHeight));
      canvas.drawBitmap (myTile, null, dst, bitmapPaint);
      

      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 :).

      • Andre
        12/11/2013 at 1:18 pm

        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.

  7. 16/02/2014 at 9:36 pm

    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

  1. No trackbacks yet.

What do you think?

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s

%d bloggers like this: