Giter Club home page Giter Club logo

mapcomposemp's Introduction

Maven Central GitHub License

MapCompose-mp

MapCompose-mp is a fast, memory efficient compose multiplatform library to display tiled maps with minimal effort. It shows the visible part of a tiled map with support of markers and paths, and various gestures (flinging, dragging, scaling, and rotating). Target platforms are iOS, desktop (Windows, MacOs, Linux), and Android. It's a multiplatform port of MapCompose.

An example of setting up on desktop:

/* Inside your view-model */
val tileStreamProvider = TileStreamProvider { row, col, zoomLvl ->
    FileInputStream(File("path/{$zoomLvl}/{$row}/{$col}.jpg")).asSource() // or it can be a remote HTTP fetch
}

val state = MapState(4, 4096, 4096).apply {
    addLayer(tileStreamProvider)
    enableRotation()
}

/* Inside a composable */
@Composable
fun MapContainer(
    modifier: Modifier = Modifier, viewModel: YourViewModel
) {
    MapUI(modifier, state = viewModel.state)
}

This project holds the source code of this library, plus a demo app - which is useful to get started. To test the demo, just clone the repo and launch the demo app from Android Studio.

Clustering

Marker clustering regroups markers of close proximity into clusters. The video below shows how it works.

clustering.mp4

The sample below shows the relevant part of the code. We can still add regular markers (not managed by a clusterer), such as the red marker in the video. See the full code.

/* Add clusterer */
state.addClusterer("default") { ids ->
   { Cluster(size = ids.size) }
}

/* Add marker managed by the clusterer */
state.addMarker(
    id = "marker",
    x = 0.2,
    y = 0.3,
    renderingStrategy = RenderingStrategy.Clustering("default"),
) {
    Marker()
}

There's an example in the demo app.

Installation

Add this to your commonMain dependencies:

sourceSets {
  commonMain.dependencies {
      implementation("ovh.plrapps:mapcompose-mp:0.9.3")
  }
}

Basics

MapCompose is optimized to display maps that have several levels, like this:

Each next level is twice bigger than the former, and provides more details. Overall, this looks like a pyramid. Another common name is "deep-zoom" map. This library comes with a demo app featuring various use-cases such as using markers, paths, map rotation, etc. All examples use the same map stored in the assets, which is a great example of deep-zoom map.

MapCompose can also be used with single level maps.

Usage

With Jetpack Compose, we have to change the way we think about views. In the previous View system, we had references on views and mutated their state directly. While that could be done right, the state often ended-up scattered between views own state and application state. Sometimes, it was difficult to predict how views were rendered because there were so many things to take into account.

Now, the rendering is a function of a state. If that state changes, the "view" updates accordingly.

In a typical application, you create a MapState instance inside a ViewModel (or whatever component which survives device rotation). Your MapState should then be passed to the MapUI composable. The code sample at the top of this readme shows an example. Then, whenever you need to update the map (add a marker, a path, change the scale, etc.), you invoke APIs on your MapState instance. As its name suggests, MapState also owns the state. Therefore, composables will always render consistently - even after a device rotation.

All public APIs are located under the api package. The following sections provide details on the MapState class, and give examples of how to add markers, callouts, and paths.

MapState

The MapState class expects three parameters for its construction:

  • levelCount: The number of levels of the map,
  • fullWidth: The width of the map at scale 1.0, which is the width of last level,
  • fullHeight: The height of the map at scale 1.0, which is the height of last level

Layers

MapCompose supports layers - e.g it's possible to add several tile pyramids. Each level is made of the superposition of tiles from all pyramids at the given level. For example, at the second level (starting from the lowest scale), tiles would look like the image below when three layers are added.

Your implementation of the TileStreamProvider interface (see below) is what defines a tile pyramid. It provides RawSources of image files (png, jpg). MapCompose will request tiles using the convention that the origin is at the top-left corner. For example, the tile requested with row = 0, and col = 0 will be positioned at the top-left corner.

N.B: RawSource is a kotlinx.io concept very similar to Java's InputStream.

fun interface TileStreamProvider {
    suspend fun getTileStream(row: Int, col: Int, zoomLvl: Int): RawSource?
}

Depending on your configuration, your TileStreamProvider implementation might fetch local files, as well as performing remote HTTP requests - it's up to you. You don't have to worry about threading, MapCompose takes care of that (the main thread isn't blocked by getTileStream calls). However, in case of HTTP requests, it's advised to create a MapState with a higher than default workerCount. That optional parameter defines the size of the dedicated thread pool for fetching tiles, and defaults to the number of cores minus one. Typically, you would want to set workerCount to 16 when performing HTTP requests. Otherwise, you can safely leave it to its default.

To add a layer, use the addLayer on your MapState instance. There are others APIs for reordering, removing, setting alpha - all dynamically.

Markers

To add a marker, use the addMarker API, like so:

/* Add a marker at the center of the map */
mapState.addMarker("id", x = 0.5, y = 0.5) {
    Icon(
        painter = painterResource(id = R.drawable.map_marker),
        contentDescription = null,
        modifier = Modifier.size(50.dp),
        tint = Color(0xCC2196F3)
    )
}

A marker is a composable that you supply (in the example above, it's an Icon). It can be whatever composable you like. A marker does not scale, but it's position updates as the map scales, so it's always attached to the original position. A marker has an anchor point defined - the point which is fixed relatively to the map. This anchor point is defined using relative offsets, which are applied to the width and height of the marker. For example, to have a marker centered horizontally and aligned at the bottom edge (like a typical map pin would do), you'd pass -0.5f and -1.0f as relative offsets (left position is offset by half the width, and top is offset by the full height). If necessary, an absolute offset expressed in pixels can be applied, in addition to the relative offset.

Markers can be moved, removed, and be draggable. See the following APIs: moveMarker, removeMarker, enableMarkerDrag.

Callouts

Callouts are typically message popups which are, like markers, attached to a specific position. However, they automatically dismiss on touch down. This default behavior can be changed. To add a callout, use addCallout.

Callouts can be programmatically removed (if automatic dismiss was disabled).

Paths

To add a path, use the addPath api:

mapState.addPath("pathId", color = Color(0xFF448AFF)) {
  addPoints(points)
}

The demo app shows a complete example.

Animate state change

It's pretty common to programmatically animate the scroll and/or the scale, or even the rotation of the map.

scroll and/or scale animation

When animating the scale, we generally do so while maintaining the center of the screen at a specific position. Likewise, when animating the scroll position, we can do so with or without animating the scale altogether, using scrollTo and snapScrollTo.

rotation animation

For animating the rotation while keeping the current scale and scroll, use the rotateTo API.

Both scrollTo and rotateTo are suspending functions. Therefore, you know exactly when an animation finishes, and you can easily chain animations inside a coroutine.

// Inside a ViewModel
viewModelScope.launch {
    mapState.scrollTo(0.8, 0.8, destScale = 2f)
    mapState.rotateTo(180f, TweenSpec(2000, easing = FastOutSlowInEasing))
}

For a detailed example, see the "AnimationDemo".

Differences with Android native MapCompose

The api is mostly the same as the native library. There's one noticeable difference: TileStreamProvider returns RawSource instead of InputStream.

Some apis expect dp values instead of pixels.

Some optimizations are temporarily disabled, such as:

  • "Bitmap" pooling on ios and desktop
  • Subsampling

Contributors

Special thanks to Roger (@rkreienbuehl) who made the first proof-of-concept, starting from MapCompose code base.

Marcin (@Nohus) has contributed and fixed some issues. He also thoroughly tested the layers feature โ€“ which made MapCompose better.

mapcomposemp's People

Contributors

p-lr avatar rkreienbuehl avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar

mapcomposemp's Issues

Support MapBox

Please add support for MapBox maps in MapComposeMP. Thank you!

Maptiler TileSize Issue

Hey Pierre,
thanks so much for your amazing work :)

I've been trying to get the map to work with MapTiler Raster Tiles and none of my changes seem to do the trick,
I am using the default demo Project with the MapTiler URL;

val url = URL("https://api.maptiler.com/maps/basic-v2/256/$zoomLvl/$col/$row.png?key=qZb8Y1pCBG6YO3hIQZt8")

My Map seems to have too many Tiles:
photo_6001414011394048399_y
photo_6001414011394048398_y
photo_6001414011394048397_y

Do you know what causes this Issue?
In my old Flutter Project switching between OSM and MapTiler, I had to add the @2x modifier.
https://docs.maptiler.com/cloud/api/maps/ -> Raster XYZ Tiles.

If this works I can enhance the Readme.md to include a description for the MapTiler Setup.

Thanks

Getting exception on TileCanvasThread - Problem decoding into existing bitmap

Is this a known bug, or is there something I am doing wrong somewhere? I thought I caught it in my else block in the code below, becuase it stopped happening, or so I thought, but it randomly happened when I zoomed out very far (on a real device, didn't happen on emulator, yet), and once when I disconnected my phone while running the app (for testing GPS).

class MapRepositoryImpl (
    private val db: SARDatabase,
    private val tileDownloader: TileDownloader,
    private val scope: CoroutineScope
) : MapRepository {
    override fun getTile(row: Long, col: Long, zoomLvl: Long): Flow<RawSource?> = flow {
        val log = logging("MapRepository DB")
        val tileData = db.tileQueries.selectTile(row, col, zoomLvl).executeAsOneOrNull()

        val buffer = Buffer()
        buffer.write(tileData?.tile_data ?: byteArrayOf())
        if (tileData != null) {
            emit(buffer)
        } else {
            log.d { "Downloading from web" }
            tileDownloader.downloadTile(row, col, zoomLvl).let { dataPair ->
                val tileStream = dataPair.first
                val bytes: ByteArray? = dataPair.second
                if (tileStream != null) {
                    emit(tileStream)
                    scope.launch {
                        if (bytes != null) {
                            try {
                                db.tileQueries.insertTile(row, col, zoomLvl, bytes)
                            } catch (e: Exception) {
                                log.e{ "Attempt to insert duplicate tile.: $e" }
                            }
                        }
                    }
                } else {
                    log.e { "Failed to download tile" }
                    emit(null)
                }
            }
        }
    }
}

Any tips as to why this happens and which steps I need to take to avoid this are very much appreciated! :)

Location status bar icon blinks when rotating map and asking for the map rotation value in composable

I can't see any reason for it to happen, I am using Moko Geo 0.18 to get location, not that I think that is relevant, because it happens when I don't ask for location at all, just have the right things initialised in the ViewModel to be able to get the location, and rotate the map. I need the map rotation to get the proper heading with data from the magnetometer/accelerometer and all that.

It seems like it blinks when it recomposes, but I can't get why it would happen when I am not using location actively.

If anyone has succeeded with this, let me know!

Coroutine Scope in MapState references Dispatchers.Main

Currently, ovh.plrapps.mapcompose.ui.state.MapState creates coroutine scope like this: CoroutineScope(SupervisorJob() + Dispatchers.Main) but Dispatchers.Main is not present on desktop so it forces dependency on some coroutine library that provides it, which kotlinx.coroutines.Dispatchers documentation tells me should javafx, but that doesn't work for me. All this could be avoided if the MapState took coroutine scope as it's parameter, avoiding any direct references to Dispatchers.Main. I haven't tested it, but it should resolve the issue.

I am willing to submit pull request if you think this solution is acceptable. I am just trying to run this in multiplatform project and this seems to be a show stopper for now.

This is the error I get btw:

Module with the Main dispatcher is missing. Add dependency providing the Main dispatcher, e.g. 'kotlinx-coroutines-android' and ensure it has the same version as 'kotlinx-coroutines-core'

Blinking when navigating to map with Compose Navigation?

Seems to be blinking everytime I am navigating to the map for some reason, not sure why this would happen.
I am using Navigation from Compose Multiplatform.

@Composable fun MapScreen( modifier: Modifier = Modifier, navController: NavController ) { val viewModel = koinInject<MapViewModel>() MapUI( modifier, state = viewModel.state )

Pretty basic setup, the tileStreamProvider gets the data from SQLDelight unless there is nothing in the database, then it will load it from a remote tileserver.

Use bitmap pooling on ios and desktop

On Android, Bitmap pooling is done in Source.android.kt using the inBitmap option of BitmapFactory.
The same technique should be implemented at least on ios.

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    ๐Ÿ–– Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. ๐Ÿ“Š๐Ÿ“ˆ๐ŸŽ‰

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google โค๏ธ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.