Return to Home Page

Map Widget

Part of building a home-brew navigation system is making a map :)

When I started this HMI in February 2021, Jetpack Compose MPP didn’t support nesting Swing components within Compose yet. This would’ve still posed some challenges:

I figured it would be easier and more educational to make my own OSM map widget in Compose rather than struggle to get Compose MPP to play well with Swing.

Goal and Scope

I set out to write a replacement for JxMapViewer2

JxMapViewer2 Screenshot

JxMapViewer2 has the following attributes:

I had the following goals in mind:

OSM Tiles

Tile Path

OSM Raster Tile x, y, zoom

If we fetch a bunch of x, y tiles for a particular zoom level once, and put them into the right folder structure, it’s easy to know which tiles to load on our grid of tiles.

Calculations

There are two calculations needed to make this work.

  1. Given tile coordinates (x, y, and zoom), we need to find the LatLng extents of the tile. That is, what’s the most/least latitude and longitude contained in a tile (x, y, zoom)?
  2. Given an arbitrary LatLng, calculate which (x, y, zoom) tile is needed to show that LatLng.

Plan of Attack

This sets up the following parameters going into our map widget: center LatLng, zoom level. Everything else reacts to those parameters.

Image Download : Picasso Clone

Picasso Screenshot

Picasso is a library that was popular in the Android 3.x - 6.x days.

It’s main advantage was that it was easy to fetch/cache a network image into an imageView:

Picasso.get().load("https://example.com/example.png").into(imageView)

and it just handled all the loading, caching, conversions for you.

Original Blurb:

Many common pitfalls of image loading on Android are handled automatically by Picasso:

There are libraries now (in 2025) that do this for Compose multiplatform, but there weren’t in early 2021.

TileView

TileView is our magical caching ImageView. It makes uses of a LaunchedEffect to lauch a coroutine to fetch a tile from the local or remote store by using a TileFetcher.

@Composable
fun TileView(
    x : Int,
    y : Int,
    zoom : Int,
    debug : Boolean =
        DaggerApplicationComponent.create().configurationStorage()
            .config[E39Config.MapConfig.showDebugInfoOnTiles]
) {

    val image = remember { mutableStateOf<ImageBitmap?>(null) }

    LaunchedEffect(x, y, zoom) {
    
        // Sneaky Dependency Injection here!! See Note below!
        val tileFetcher = DaggerApplicationComponent.create().tileFetcher()

        val tileFile = tileFetcher.getTile(x, y, zoom)

        tileFile.inputStream().buffered().use {
            image.value = org.jetbrains.skia.Image.makeFromEncoded(
                        it.readBytes()
                    ).toComposeImageBitmap()
        }
    }

    Box(
        modifier = Modifier
            .size(256.dp)
            .then(
                if (debug) {
//                    Modifier.border(1.dp, Color.Red)
                    Modifier
                } else {
                    Modifier
                }
            )
    ) {
        if (image.value != null) {
            Image(
                bitmap = image.value!!,
                modifier = Modifier.size(256.dp),
                contentDescription = "x: $x, y: $y, zoom: $zoom"
            )
        }

        if (debug) {
            Column(
                Modifier
                    .align(Alignment.Center)
                    .background(Color(0, 0, 0, 128))
            ) {
                Text("x: $x")
                Text("y: $y")
                Text("zoom: $zoom")
            }
        }
    }
}

Sneaky Dependency Injection

One challenge with Compose is that a Composable isn’t a class. When I wrote this, I figured I could get an instance of a TileFetcher by calling val tileFetcher = DaggerApplicationComponent.create().tileFetcher()

Keep in mind that a Composable could be called at any frequence from once to once-per-frame (eveyr 16ms). Constructing a DaggerApplicationComponent just to get a singleton in a TileView can be pretty expensive.

There’s a few ways around this:

TileFetcher

The TileFetcher uses Ktor’s HTTP client to download tiles if they’re not found in the tile cache.

The TileFetcher is also accessed from the MapSettings screen.

The DownloadStatus class is used to report the current progress:

data class DownloadStatus(
    val tilesDownloaded : Int,
    val totalTilesToDownload : Int
)

The technique to parallelize the tile download is pretty interesting.

val tilesToDownload : List<Triple<Int, Int, Int>> = ...  //Triple(x, y, zoom)

makes a large list of tiles to download.

Next, the list is segmented (windowed) by the number of concurrentWorkers, and each list of (x, y, zoom) is converted to a flow:

val flowsByWorker : List<Flow<Boolean>> = tilesToDownload.windowed(
    size = tilesToDownload.size / concurrentWorkers,
    step = tilesToDownload.size / concurrentWorkers,
    partialWindows = true
).map {
    flowOf(*it.toTypedArray()).map { tile ->
        try {
            getTile(tile.first, tile.second, tile.third)
            true
        } catch (e : Exception) {
            logger.e("TileFetcher", "Server response exception on tile ${tile}", e)
            notificationHub.postNotificationBackground(Notification(
                image = Notification.NotificationImage.ALERT_TRIANGLE,
                topText = "Tile Download Error",
                contentText = "Tile: ${tile}, error: ${e.message}",
                duration = Notification.NotificationDuration.SHORT
            ))
            false
        }
    }
}

Finally, merge is used to start all the flows in a race in parallel. Scan is used to collate the download statuses from each worker to a DownloadStatus.

return merge(*flowsByWorker.toTypedArray())
    .scan(DownloadStatus(tilesDownloaded = 0, totalTilesToDownload = tilesToDownload.size)) { accumulator, tileLoadSuccess ->
        if (tileLoadSuccess) {
            DownloadStatus(
                tilesDownloaded = accumulator.tilesDownloaded + 1,
                totalTilesToDownload = accumulator.totalTilesToDownload
            )
        } else {
            DownloadStatus(
                tilesDownloaded = accumulator.tilesDownloaded,
                totalTilesToDownload = accumulator.totalTilesToDownload
            )
        }
    }
} //Method returns Flow<DownloadStatus>

Tile Grid : Large Canvas

The TileViews need to be placed on a grid so that we can piece together a “world map”.

At first I thought about using an infinite grid of tiles and lazily loading them as I pan. However, that approach was too much complexity at once. I then realized, I could lean on Compose re-using the Rows and Columns in the grid when I change the extents of the grid.

RawTileGrid

RawTileGrid is what holds the TileViews in an eager grid. The source is here

@Composable
fun RawTileGrid(
    startX : Int,
    endX :  Int,
    startY : Int,
    endY : Int,
    zoom : Int,
) {
    val validXIndices = (0 .. (2.0.pow(zoom) - 1).toInt()).toList().circular()
    val validYIndices = (0 .. (2.0.pow(zoom) - 1).toInt()).toList().circular()

    val rowIterations =
        (min(startY, endY) .. kotlin.math.max(startY, endY))
            .map { validYIndices[it] }

    val columnIterations =
        (min(startX, endX) .. kotlin.math.max(startX, endX)).map { validXIndices[it] }

    Column {
        for (y in rowIterations) {
            Row(Modifier.height(256.dp)) {
                for (x in columnIterations) {
                    Column(Modifier.width(256.dp)) {
                        TileView(x, y, zoom)
                    }
                }
            }
        }
    }
}

It is very convienient that tiles are a fixed 256*256px size. Grids are just rows of columns.

Layers of Composables

Slippy Map Scrollbars

Earlier, we showed that the parameters to the map are the center and zoom. To populate the viewport, first find the tile containing the requested center, then load a few tiles in every direction (north, south, east, west) so that the user can’t see the “edge of the world”.

The tile containing the center won’t actually be positioned right, we have to adjust the invisible scrollbars once the tile grid is populated so that the center of the map screen is the requested center.

Scrollbars

Basics

Scrollbars on Jetpack Compose are described here.

ScrollBoxesSmooth

@Composable
private fun ScrollBoxesSmooth() {

    // Smoothly scroll 100px on first composition
    val state = rememberScrollState()
    LaunchedEffect(Unit) { state.animateScrollTo(100) }

    Column(
        modifier = Modifier
            .background(Color.LightGray)
            .size(100.dp)
            .padding(horizontal = 8.dp)
            .verticalScroll(state)
    ) {
        repeat(10) {
            Text("Item $it", modifier = Modifier.padding(2.dp))
        }
    }
}

Scrollbars reference a remember {} containing the scrollbar’s state. The state is passed to a modifier. Gestures on will affect the modifier.

** With a copy of the scrollbar state, we can react to the scrollbar position **. With that:

We have a few tricks up our sleeve:

Linear Interpolation

Latitude

Latitude Interpolation

The above picture shows how we’re going to to interpolate across a tile to relate Latitude <-> Percentage accross the tile (the yellow line).

Some of the functions used are defined in ExtentCalculator

First, we know how many meters tall a one tile is. We can use this without error because OSM uses a Mercartor projection and assumes a spherical earth. Despite Mercartor projections looking visibly stretched at the poles, the stretching comes from “pulling apart” lines of latitude (left/right across the earth) so that north-south lines aren’t stretched. We’ll see on the Longitude case how to account for this stretch, but it’s not needed for the Latitude case.

We also know how many tiles are in the the TileGrid. So, we know height of canvas in meters = (height of tile in meters) * (number of tiles).

// Our parameters are `extents.center` and `extents.mapScale` (which is zoom)
// So, we're trying to scroll the map so that extents.center is in the middle of the canvas.

val canvasHeightPixels = (canvasTilesTall) * 256

// Construct a vertical line going from the top of the center tile to the desired center
// and measure its length.
val actualMetersFromTop = LatLngTool.distance(
    LatLng(ExtentCalculator.tile2lat(startY, zoom), extents.center.longitude),
    LatLng(extents.center.latitude, extents.center.longitude),
    LengthUnit.METER
)

// Coefficient * maximum scroll value
val scrollPixelsFromTop = (actualMetersFromTop / canvasHeightMeters) * stateVertical.maxValue

// Awkward API likes deltas more than absolute scroll positions, so scroll to top, then scroll
// to desired absolute value.
stateVertical.scrollTo(0)
stateVertical.dispatchRawDelta(scrollPixelsFromTop.toFloat())

The trapezoids highlight the distortion along west-east lines through a tile.

Longitude

Longitude Interpolation

Our mission here is to setup the formula val scrollPixelsFromLeft = (actualMetersFromLeft / canvasWidthMeters) * stateHorizontal.maxValue and perform the scroll, just like in the latitude case.

However, error is introduced because on the Mercartor projection, tiles are stretched width-wise to make a spherical earth into a square.

However, the same idea applies.

val canvasWidthTiles = endX - startX
val canvasWidthMeters = LatLngTool.distance(
    LatLng(extents.center.latitude, ExtentCalculator.tile2lon(startX, zoom)),
    // Go to endX + 1 because the tile number is for the top left of the tile
    LatLng(extents.center.latitude, ExtentCalculator.tile2lon(endX + 1, zoom)),
    LengthUnit.METER
)
val canvasWidthPixels = (canvasWidthTiles) * 256
val actualMetersFromLeft = LatLngTool.distance(
    // Error is introduced here because if the tile is really tall (high zoom levels),
    // then this calculation needs to be stretched or shrunken because the percentage
    // across the tile isn't consistent at the top vs the bottom. (Unlike in the latitude case)
    LatLng(extents.center.latitude, ExtentCalculator.tile2lon(startX, zoom)),
    LatLng(extents.center.latitude, extents.center.longitude),
    LengthUnit.METER
)

val scrollPixelsFromLeft = (actualMetersFromLeft / canvasWidthMeters) * stateHorizontal.maxValue
stateHorizontal.scrollTo(0)
stateHorizontal.dispatchRawDelta(scrollPixelsFromLeft.toFloat())

API

The API for the map widget takes in a (center, zoom) and a few booleans for overlays (whether to show the zoom indicator, etc). It allows listening to the actual center of the map.

@Composable
fun MapViewer(
    overlayProperties: OverlayProperties,
    extents: Extents,
    onCenterPositionUpdated : (newCenter : LatLng) -> Unit
) { ... }

//Defines overlays on the map.
data class OverlayProperties(
    val centerCrossHairsVisible: Boolean,
    val mapScaleVisible : Boolean,
    val gpsReceptionIconVisible : Boolean,
    val route : Route
)

//Defines how much of the world we can see
data class Extents(
    val center : LatLng,
    val mapScale: MapScale
)

//This is the driving route we want to draw on the map.
data class Route(
    val path : List<LatLng>
)

Screenshots

Map Debug Screen

Return to Top