DEV Community

Cover image for Constructing a Map in the Mercator Projection for Android
Mad Devs for Mad Devs

Posted on • Edited on

Constructing a Map in the Mercator Projection for Android

Have you ever thought of creating a map on Android?

In this article, I will tell you how I created my Mercator projection map from scratch. I’ll describe the basic functionality and methods I used to change camera positions. Finally, I’ll talk about the algorithms I used for multiple touches and, of course, share a link to my repository in Github.

For the record: this is not a magic pill that will solve all your problems but a tutorial, which may be useful for developers to understand the way maps work on Android.

The map is developed from the Mercator projection.

  • The Mercator projection is one of the basic map projections used to represent the surface of the Earth or another celestial body on a plane. The projection was developed by the 16th-century geographer Gerardus Mercator and is still used by cartographers, navigators, and geographers.

Alt Text


The map functionality is quite simple: there are basic functionality (e.g., displaying and converting geodata) and secondary functionality (e.g., changing camera positions and adding markers). All the features add up to a complete map.


The basic functionality is divided into two parts: converting the geodata (latitude and longitude) into x/y coordinates and displaying the data on the screen.

Basic functionality

All objects on the map are geodata that we convert to x/y coordinates and then display on the screen.
Mercator projection geodatabase conversion code:

private const val A = 6378137
private const val B = 6356752.3142
private var ZOOM = 10000
fun converterLatitudeToX(latitude: Double, zoom: Int = 
ZOOM): Double {
val rLat = latitude * PI / 180
val f = (A - B) / A
val e = sqrt(2 * f - f.pow(2))
return (A * Math.log(tan(PI / 4 + rLat / 2) * ((1 - e * sin(rLat)) / (1 + e * sin(rLat))).pow(e / 2))) / zoom
}
fun converterLongitudeToY(longitude: Double, zoom: Int = ZOOM): Double {
val rLong= longitude * PI / 180
return (A * rLong) / zoom
}
fun converterGeoData(latitude: Double, longitude: Double, zoom: Int = ZOOM): Point {
return Point(converterLatitudeToX(latitude, zoom), converterLongitudeToY(longitude, zoom))
}
Enter fullscreen mode Exit fullscreen mode

To display, we need to create a custom View class, in which we have to describe the logic of displaying objects on the screen in the onDraw method with the help of Canvas.
In the custom View class, write the following code:

private lateinit var presenter: MapPresenter
private lateinit var bitmap: Bitmap
private var drawCanvas: Canvas? = null
constructor(context: Context) : super(context, null) {
initMap(context)
}
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) {
initMap(context)
}
fun initMap(context: Context) {
presenter = MapPresenter(context)
post {
    initMapCanvas()
}
}
private fun initMapCanvas() {
bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
drawCanvas = Canvas(bitmap)
invalidate()
}
@SuppressLint("DrawAllocation")
override fun onDraw(canvas: Canvas) {
    if (drawCanvas != null) {
    canvas.save()
    drawCanvas!!.drawColor(backgroundColor)
    presenter.getShapes()?.forEach {
        val shape = Path()
        for ((index, shapeItem) in it.shapeList.withIndex()) {
            if (index == 0) {
                shape.moveTo(shapeItem)
            } else {
                shape.lineTo(shapeItem)
            }
        }
        drawCanvas!!.save()
        paintShape.color = Color.parseColor(it.color)
        drawCanvas!!.drawPath(shape, paintShape)
        drawCanvas!!.restore()
    }
}
}
Enter fullscreen mode Exit fullscreen mode

We use Path to display shapes on the screen. The Path class allows you to create straight lines, curves, patterns, and other lines.

object PathUtil {
fun Path.moveTo(point: Point) {
    this.moveTo(point.x.toFloat(), point.y.toFloat())
    }
fun Path.lineTo(point: Point) {
    this.lineTo(point.x.toFloat(), point.y.toFloat())
}
}
Enter fullscreen mode Exit fullscreen mode

In the .xml file Activity, write:

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
    <com.maddevs.madmap.map.view.MapView <- our custom View
    android:id="@+id/map"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    app:layout_constraintBottom_toBottomOf="parent"
    app:layout_constraintLeft_toLeftOf="parent"
    app:layout_constraintRight_toRightOf="parent"
    app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
Enter fullscreen mode Exit fullscreen mode

The MapPresenter class stores GeoPoint data. The GeoPoint class itself converts latitude and longitude into x/y points during initialization to display in the custom View.

open class GeoPoint(var latitude: Double, var longitude: Double) : Point(converterGeoData(latitude, longitude)) {
private val angleRotate = 270.0
private val zoom = 10000
private var angleRotateXY = 0.0
private var dx: Float = 0f
private var dy: Float = 0f
init {
    rotate(angleRotate)
}
open fun updateCoordinateZoom(zoomLevel: Int) {
    updateData(converterGeoData(latitude, longitude, zoomLevel))
    rotate(angleRotate)
}
protected fun updateCoordinate(latitude: Double, longitude: Double, changeZoom: Boolean) {
    updateData(converterGeoData(latitude, longitude, zoom))
    rotate(angleRotate + angleRotateXY)
    if (changeZoom) {
        x += dx
        y += dy
    }
}
}
Enter fullscreen mode Exit fullscreen mode

The GeoPoint class inherits the x/y parameters from the Point class.

open class Point {
var x: Double
var y: Double
constructor(x: Double, y: Double) {
    this.x = x
    this.y = y
}
constructor(point: Point) {
    this.x = point.x
    this.y = point.y
}
constructor() {
    this.x = Double.NaN
    this.y = Double.NaN
}
fun updateData(point: Point) {
    this.x = point.x
    this.y = point.y
}
fun updateData(x: Double, y: Double) {
    this.x = x
    this.y = y
}
fun rotate(angle: Double) {
    val rad: Double = angle * PI / 180
    val rx = x
    val ry = y
    x = rx * cos(rad) - ry * sin(rad)
    y = ry * cos(rad) + rx * sin(rad)
}
}
Enter fullscreen mode Exit fullscreen mode

It has the rotate method and a formula for rotating a point in two-dimensional space.
We store the data in a JSON file. And we parse it in the getShapes method.

override fun getShapes(): List<ShapeObject>? {
val shapes = JSONArray(getAssetsFileString("shapes.json"))
val result = ArrayList<ShapeObject>()
for (item in shapes.iterator()) {
    val shapes = ArrayList<ShapeItemObject>()
    val color = item.getString("color")
    val type = item.getString("type")
    for (shape in item.getJSONArray("shape").iterator()) {
        shapes.add(
            ShapeItemObject(
                shape.getDouble("latitude"),
                shape.getDouble("longitude")
            )
        )
    }
    result.add(ShapeObject(shapes, type, color))
}
return result
}
Enter fullscreen mode Exit fullscreen mode

In the ShapeItemObject class, parameters are inherited from GeoPoint, where they are already located.

class ShapeItemObject(latitude: Double, longitude: Double) : GeoPoint(latitude, longitude)
Enter fullscreen mode Exit fullscreen mode

And in ShapeObject, I added the type and color of the shape to the sheet from ShapeItemObject.

class ShapeObject(val shapeList: List<ShapeItemObject>, type: String, val color: String)
Enter fullscreen mode Exit fullscreen mode

And… after all the added logic, we run the application and get the result.

Mad Map — Island of Madagascar and a Part of Africa.

We now have the shapes displayed on the screen. Initially, we have only the island of Madagascar and a part of Africa displayed.
Now we need to add secondary functionality to move the camera around the map.


Secondary functionality

Secondary functionality includes moving the camera by data and by touching the screen with your finger.
The first thing we need to implement is the movement of the camera by the data. For this, the project already has conversion tools and classes for working with geopoints.
In the custom class, we write the onChangeCameraPosition method, and latitude and longitude will be written in the parameters.

fun onChangeCameraPosition(latitude: Double, longitude: Double) {
post {
    presenter.changeCameraPosition(latitude, longitude)
    invalidate()
}
}
Enter fullscreen mode Exit fullscreen mode

In the presenter, we write the changeCameraPosition method.

class MapPresenter(context: Context, repository: MapRepository = MapRepository(context)) {
private val shapesRendering: List<ShapeObject>? = repository.getShapes()
private val bordersRendering: List<BorderObject>? = repository.getBorders()
private val bordersLineRendering: List<BorderLineObject>? = repository.getBordersLine()
private val shapesStringRendering: List<StringObject>? = repository.getShapesString()
private lateinit var cameraPosition: CameraPosition
override fun getShapes(): List<ShapeObject>? {
    return shapesRendering
}
override fun getShapesString(): List<StringObject>? {
    return shapesStringRendering
}
override fun getBorders(): List<BorderObject>? {
    return bordersRendering
}
override fun getBordersLine(): List<BorderLineObject>? {
    return bordersLineRendering
}
override fun initCamera(width: Int, height: Int) {
    this.heightY = height / 2
    this.widthC = width / 2
    cameraPosition = CameraPosition(width, height)
    moveCoordiante(
        ((cameraPosition.x * -1) + cameraPosition.centerX).toFloat(),
        ((cameraPosition.y * -1) + cameraPosition.centerY).toFloat()
    )
}
override fun changeCameraPosition(latitude: Double, longitude: Double) {
    cameraPosition.updatePosition(latitude, longitude)
    moveCoordiante(
        ((cameraPosition.x * -1) + cameraPosition.centerX).toFloat(),
        ((cameraPosition.y * -1) + cameraPosition.centerY).toFloat()
    )
}
fun estimationData(estimation: Estimation<GeoPoint>) {
    estimation.counting(cameraPosition)
    shapesRendering?.forEach {
        it.shapeList.forEach { item ->
            estimation.counting(item)
        }
    }
}
    fun moveCoordiante(dx: Float, dy: Float) {
    estimationData(object : Estimation<GeoPoint>{
        override fun counting(item: GeoPoint) {
            item.move(dx, dy)
        }
    })
}
}
Enter fullscreen mode Exit fullscreen mode

We need the Estimation interface to enumerate all the points on the screen.

interface Estimation<T> {
fun counting(item: T)
}
Enter fullscreen mode Exit fullscreen mode

Now, we need to write the CameraPosition class in GeoPoint; in its constructor, we write the default zero coordinates.

class CameraPosition(width: Int, height: Int) : GeoPoint(0.0, 0.0) {
var centerX: Int = width / 2
var centerY: Int = height / 2
fun updatePosition(latitude: Double, longitude: Double) {
    updateCoordinate(latitude, longitude, true)
}
}
Enter fullscreen mode Exit fullscreen mode

And then in GeoPoint we write the move method.

open class GeoPoint(var latitude: Double, var longitude: Double) : Point(converterGeoData(latitude, longitude)) {
private val angleRotate = 270.0
private val zoom = 10000
private var angleRotateXY = 0.0
private var dx: Float = 0f
private var dy: Float = 0f
init {
    rotate(angleRotate)
}
fun move(dx: Float, dy: Float) {
    x += dx
    y += dy
}
    open fun updateCoordinateZoom(zoomLevel: Int) {
    updateData(converterGeoData(latitude, longitude, zoomLevel))
    rotate(angleRotate)
}
    protected fun updateCoordinate(latitude: Double, longitude: Double, changeZoom: Boolean) {
    updateData(converterGeoData(latitude, longitude, zoom))
    rotate(angleRotate + angleRotateXY)
    if (changeZoom) {
        x += dx
        y += dy
    }
}
}
Enter fullscreen mode Exit fullscreen mode

Mad Map.

Now the entire camera moves at the specified coordinates.

How it works: During the initialization of the camera default data is assigned as zero (latitude 0.0 longitude 0.0); after initialization and data conversion, all points on the screen move to the camera point multiplied by -1, and after moving all the points move to the width and height of the custom View divided by 2 (in the middle of the screen).

To check, in Activity we have two buttons that will move the camera according to the entered coordinates:

class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_main)
    one.setOnClickListener {
        map.onChangeCameraPosition(18.902389796969448, 14.877051673829557)
    }
    two.setOnClickListener {
        map.onChangeCameraPosition(-20.182886472696126, 46.45624604076147)
    }
}
}
Enter fullscreen mode Exit fullscreen mode

Result moveCoordiante.


Now, we need to implement the movement of the map by having the screen touched with your finger. To do this, we need to implement the onTouchEvent method in the custom View.

@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean {
val x = event.x
val y = event.y
when (event.action) {
    MotionEvent.ACTION_DOWN -> {
        presenter.touchStart(x, y)
       }
    MotionEvent.ACTION_MOVE -> {
        presenter.touchMove(x, y)
    }
    }

invalidate()
return true
}
Enter fullscreen mode Exit fullscreen mode

And implement two methods, touchStart and touchMove, in the presenter. They will contain the code for finding the difference between the touches.

private var mX = 0f
private var mY = 0f
fun touchStart(x: Float, y: Float) {
mX = x
mY = y
}
fun touchMove(x: Float, y: Float) {
val dx = x - mX
val dy = y - mY
if (Point(x.toDouble(), y.toDouble()).distanceTo(Point(mX.toDouble(), mY.toDouble())) < 300) {
    moveCoordiante(dx, dy)
}
mX = x
mY = y
}
Enter fullscreen mode Exit fullscreen mode

Also, in the Point class, you must add a method for finding the distance between points distanceTo.

fun distanceTo(point: Point) : Double {
return sqrt((x - point.x).pow(2) + (y - point.y).pow(2))
}
Enter fullscreen mode Exit fullscreen mode

Result onTouchEvent.


We already have geodata converted into coordinates, movement by touch, and movement by geodata. For full functionality, we need to add a change of map scale.

I added a class for scale types.

class CameraZoom : Point() {
enum class Type { PLUS, MINUS }
}
Enter fullscreen mode Exit fullscreen mode

To do this, you need to write a method for changing the size of shapes in GeoPoint.

open fun changeZoom(addedZoom: Double, type: CameraZoom.Type) {
when (type) {
    CameraZoom.Type.PLUS -> {
        x /= addedZoom
        y /= addedZoom
    }
    CameraZoom.Type.MINUS -> {
        x *= addedZoom
        y *= addedZoom
    }
}
}
Enter fullscreen mode Exit fullscreen mode

Also, add in the presenter the enumeration and call the changeZoom method.

fun changeCameraPosition(zoom: Double, type: CameraZoom.Type) {
zoomCoordiante(zoom, type)
}
private fun zoomCoordiante(zoom: Double, type: CameraZoom.Type) {
moveCoordiante((widthC * -1).toFloat(), (heightY * -1).toFloat())
estimationData(object : Estimation<GeoPoint> {
    override fun counting(item: GeoPoint) {
        item.changeZoom(zoom, type)
    }
})
moveCoordiante((widthC).toFloat(), (heightY).toFloat())
}
Enter fullscreen mode Exit fullscreen mode

In the custom View, let’s add the onChangeCameraPosition method.

fun onChangeCameraPosition(zoom: Double, type: CameraZoom.Type) {
post {
    presenter.changeCameraPosition(zoom, type)
    invalidate()
}
}
Enter fullscreen mode Exit fullscreen mode

Everything is ready. Now all that’s left is to test. Let’s add 2 buttons and call methods onChangeCameraPosition in Activity.

plus.setOnClickListener {
map.onChangeCameraPosition(2.0, CameraZoom.Type.PLUS)
}
minus.setOnClickListener {
map.onChangeCameraPosition(2.0, CameraZoom.Type.MINUS)
}
Enter fullscreen mode Exit fullscreen mode

Result of zoomCoordiante.

Result of the work: the code on the map scale change. Everything works by dividing and shifting the camera to the center of the map. From the code in the presenter, you can see that the moveCoordinate (move the map) method goes to the center first, multiplied by -1. This moves the center to the upper left corner and zooms the map, and then returns everything to the original center by moving everything to the middle.


The last thing left to implement is map rotation. For this, we need to add a class to make the map work and display after rotation, just like in the other implementations.

For this, we already have a method for rotation in the Point class.

fun rotate(angle: Double) {
val rad: Double = angle * PI / 180
val rx = x
val ry = y
x = rx * cos(rad) - ry * sin(rad)
y = ry * cos(rad) + rx * sin(rad)
}
Enter fullscreen mode Exit fullscreen mode

It is also necessary to implement the CameraRotate class for working with angles and saving the center of the camera position. In it, you need to write the logic of saving the current angle.

class CameraRotate(width: Int, height: Int) : Point((width / 2).toDouble(), (height / 2).toDouble()) {
var regulatoryСenterX: Int = width / 2
var regulatoryСenterY: Int = height / 2
private var angleRotateXY = 0.0
    fun changeRotate(rotate: Double) {
    rotate(rotate - angleRotateXY)
    angleRotateXY = rotate
    }
fun move(dx: Float, dy: Float) {
    x += dx
    y += dy
}
}
Enter fullscreen mode Exit fullscreen mode

Now we need a method to call rotate for all points on the screen in the presenter.

private fun rotateCoordinate(rotate: Double) {
cameraRotate.changeRotate(rotate)
estimationData(object : Estimation<GeoPoint>{
    override fun counting(item: GeoPoint) {
        item.changeRotate(rotate)
    }
})
}
Enter fullscreen mode Exit fullscreen mode

Let’s also add the main method for calling and returning the map to its original state (to the center).

override fun changeCameraPosition(rotate: Double) {
rotateCoordinate(rotate)
moveCoordiante(
    ((cameraRotate.x * -1) + 
cameraRotate.regulatoryСenterX).toFloat(),
    ((cameraRotate.y * -1) + 
cameraRotate.regulatoryСenterY).toFloat(),
    true
)
}
Enter fullscreen mode Exit fullscreen mode

To make the camera work, you need to extend the moveCoordiante method and put a mode in it to call the cameraRotate method move.

private fun moveCoordiante(dx: Float, dy: Float, rotateMode: Boolean = false) {
if (rotateMode) {
    cameraRotate.move(dx, dy)
    }
    estimationData(object : Estimation<GeoPoint>{
    override fun counting(item: GeoPoint) {
        item.move(dx, dy)
    }
})
}
Enter fullscreen mode Exit fullscreen mode

And call the changeCameraPosition method in the View.

override fun onChangeCameraPosition(rotate: Double) {
post {
    presenter.changeCameraPosition(rotate)
    invalidate()
}
}
Enter fullscreen mode Exit fullscreen mode

For testing, we need to add two buttons to change the rotation in the Activity.

var rotateAngel = 0.0
rotate_one.setOnClickListener {
rotateAngel += 5.0
map.onChangeCameraPosition(rotateAngel)
}
rotate_two.setOnClickListener {
rotateAngel -= 5.0
map.onChangeCameraPosition(rotateAngel)
}
Enter fullscreen mode Exit fullscreen mode

Result of rotateCoordinate.

All rotation functionality works using the rotate method, only when the rotate method is called, rotation occurs at all points except CameraRotate, where data from the camera class is used to return to the center of the map.


The last things we’ll add to the map are motion, zooming, and rotating the map by multiple screen taps. To do this, we need to add a class to the project for handling touchManager, which will use all the methods we developed above.

But first, we need to slightly modify the rotation functionality (add saving the previous state and the map position relative to this state). Add the rotateNotSaveCoordinate method to the presenter.

private fun rotateNotSaveCoordinate(rotate: Double) {
cameraRotate.rotate(rotate)
estimationData(object : Estimation<GeoPoint>{
    override fun counting(item: GeoPoint) {
        item.rotate(rotate)
    }
})
}
Enter fullscreen mode Exit fullscreen mode

Also add a new method and a point on which to rotate the map.

override fun changeCameraPosition(rotate: Double, regulatoryPoint: Point) {
rotateNotSaveCoordinate(rotate)
moveCoordiante(
    ((cameraRotate.x * -1) + regulatoryPoint.x).toFloat(),
    ((cameraRotate.y * -1) + regulatoryPoint.y).toFloat(),
    true
)
}
Enter fullscreen mode Exit fullscreen mode

In the class constructor, let’s specify the interface for calling methods in the presenter.

    class TouchManager(var presenter: MapContract.Presenter, width: Int, height: Int) {
private val centerPoint = Point((width / 2).toDouble(), (height / 2).toDouble())
private var checkBearing = 0.0
private var checkTepm = 0
private var checkStartBearing = 0.0
private var checkEndBearing = 0.0
private var startBearing = 0.0
private var distancePoint = 0.0
fun touch(event: MotionEvent) {
    if (event.pointerCount > 1) {
        val firstPoint = Point(event.getX(0).toDouble(), event.getY(0).toDouble())
        val secondPoint = Point(event.getX(1).toDouble(), event.getY(1).toDouble())
        val middlePoint = firstPoint.middleTo(secondPoint)
        val distance = centerPoint.distanceTo(middlePoint)
        when (event.action) {
            MotionEvent.ACTION_MOVE -> {
                if (distancePoint == 0.0) {
                    distancePoint = firstPoint.distanceTo(secondPoint)
                }
                presenter.touchDoubleMove(middlePoint.x.toFloat(), middlePoint.y.toFloat())
                val checkDistance = distancePoint - firstPoint.distanceTo(secondPoint)
                if (checkDistance > 10) {
                    distancePoint = 0.0
                    presenter.changeCameraPosition(1.02, CameraZoom.Type.PLUS)
                    presenter.changeCameraPosition(centerPoint.pointTo(centerPoint.bearingTo(middlePoint), (distance / 100) * 5))
                } else if (checkDistance < -10) {
                    distancePoint = 0.0
                    presenter.changeCameraPosition(1.02, CameraZoom.Type.MINUS)
                    presenter.changeCameraPosition(centerPoint.pointTo(middlePoint.bearingTo(centerPoint), (distance / 100) * 5))
                }
                if (checkStartBearing != 0.0) {
                    checkBearing = middlePoint.bearingTo(firstPoint) - checkStartBearing
                    if (checkEndBearing == 0.0) {
                        checkEndBearing = checkBearing
                    }
                    if (abs(checkBearing) > abs(checkEndBearing)) {
                        checkEndBearing = checkBearing
                        checkTepm++
                    }
                    if (checkTepm > 6) {
                        if (startBearing != 0.0) {
                            presenter.changeCameraPosition((middlePoint.bearingTo(firstPoint) - startBearing), middlePoint)
                        }
                        startBearing = middlePoint.bearingTo(firstPoint)
                    }
                }
                checkStartBearing = middlePoint.bearingTo(firstPoint)
            }
            MotionEvent.ACTION_POINTER_UP -> {
                distancePoint = 0.0
                startBearing = 0.0
                checkBearing = 0.0
                checkEndBearing = 0.0
                checkStartBearing = 0.0
                checkTepm = 0
                presenter.touchDoubleEnd()
            }
        }
    } else {
        val x = event.x
        val y = event.y
        when (event.action) {
            MotionEvent.ACTION_DOWN -> {
                presenter.touchStart(x, y)
            }
            MotionEvent.ACTION_MOVE -> {
                presenter.touchMove(x, y)
            }
        }
    }
}
}
Enter fullscreen mode Exit fullscreen mode

TouchManager works based on the number of touches: one will make only moveCoordinate call work, and for multiple touches, there are parameters in the class based on which the functionality will work.

To use it, you need to write it in the View.

@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean {
    touchManager.touch(event)
    invalidate()
    return true
}
Enter fullscreen mode Exit fullscreen mode

Result of TouchManager.


At this point, we have added the basic toolkit for the map. The next article will deal with the logic of displaying cities (buildings, roads, and other objects) and dynamically adding markers to the map.

Map code: https://github.com/maddevsio

Alt Text

Previously published at maddevs.io.

Top comments (0)