diff --git a/settings.gradle.kts b/settings.gradle.kts index 1fed0e7..374b7cd 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -13,5 +13,5 @@ pluginManagement { } } -rootProject.name = "maps-kt-compose" +rootProject.name = "maps-kt" diff --git a/src/commonMain/kotlin/centre/sciprog/maps/compose/MapTileProvider.kt b/src/commonMain/kotlin/centre/sciprog/maps/compose/MapTileProvider.kt index 20366b0..c4d90f8 100644 --- a/src/commonMain/kotlin/centre/sciprog/maps/compose/MapTileProvider.kt +++ b/src/commonMain/kotlin/centre/sciprog/maps/compose/MapTileProvider.kt @@ -1,6 +1,7 @@ package centre.sciprog.maps.compose import androidx.compose.ui.graphics.ImageBitmap +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Deferred import kotlin.math.floor @@ -16,7 +17,7 @@ data class MapTile( ) interface MapTileProvider { - fun loadTileAsync(id: TileId): Deferred + fun CoroutineScope.loadTileAsync(tileId: TileId): Deferred val tileSize: Int get() = DEFAULT_TILE_SIZE @@ -28,5 +29,3 @@ interface MapTileProvider { const val DEFAULT_TILE_SIZE = 256 } } - -suspend fun MapTileProvider.loadTile(id: TileId): MapTile = loadTileAsync(id).await() \ No newline at end of file diff --git a/src/jvmMain/kotlin/Main.kt b/src/jvmMain/kotlin/Main.kt index 7112b15..7e60182 100644 --- a/src/jvmMain/kotlin/Main.kt +++ b/src/jvmMain/kotlin/Main.kt @@ -12,8 +12,8 @@ import androidx.compose.ui.window.application import centre.sciprog.maps.GeodeticMapCoordinates import centre.sciprog.maps.MapViewPoint import centre.sciprog.maps.compose.* -import io.ktor.client.HttpClient -import io.ktor.client.engine.cio.CIO +import io.ktor.client.* +import io.ktor.client.engine.cio.* import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch @@ -33,7 +33,12 @@ fun App() { } val scope = rememberCoroutineScope() - val mapTileProvider = remember { OpenStreetMapTileProvider(scope, HttpClient(CIO), Path.of("mapCache")) } + val mapTileProvider = remember { + OpenStreetMapTileProvider( + client = HttpClient(CIO), + cacheDirectory = Path.of("mapCache") + ) + } var coordinates by remember { mutableStateOf(null) } @@ -41,8 +46,8 @@ fun App() { //display click coordinates Text(coordinates?.toString() ?: "") MapView( - mapTileProvider, - viewPoint, + mapTileProvider = mapTileProvider, + initialViewPoint = viewPoint, onClick = { coordinates = focus }, config = MapViewConfig(inferViewBoxFromFeatures = true) ) { diff --git a/src/jvmMain/kotlin/centre/sciprog/maps/compose/LruCache.kt b/src/jvmMain/kotlin/centre/sciprog/maps/compose/LruCache.kt new file mode 100644 index 0000000..bf8d3fc --- /dev/null +++ b/src/jvmMain/kotlin/centre/sciprog/maps/compose/LruCache.kt @@ -0,0 +1,42 @@ +package centre.sciprog.maps.compose + +import kotlin.jvm.Synchronized + + +internal class LruCache( + private var capacity: Int, +) { + private val cache = linkedMapOf() + + @Synchronized + fun put(key: K, value: V){ + if (cache.size >= capacity) { + cache.remove(cache.iterator().next().key) + } + cache[key] = value + } + + operator fun get(key: K): V? { + val value = cache[key] + if (value != null) { + cache.remove(key) + cache[key] = value + } + return value + } + + @Synchronized + fun remove(key: K) { + cache.remove(key) + } + + @Synchronized + fun getOrPut(key: K, factory: () -> V): V = get(key) ?: factory().also { put(key, it) } + + @Synchronized + fun reset(newCapacity: Int? = null) { + cache.clear() + capacity = newCapacity ?: capacity + } + +} diff --git a/src/jvmMain/kotlin/centre/sciprog/maps/compose/MapViewJvm.kt b/src/jvmMain/kotlin/centre/sciprog/maps/compose/MapViewJvm.kt index 910fd44..9403e85 100644 --- a/src/jvmMain/kotlin/centre/sciprog/maps/compose/MapViewJvm.kt +++ b/src/jvmMain/kotlin/centre/sciprog/maps/compose/MapViewJvm.kt @@ -29,6 +29,8 @@ private fun Color.toPaint(): Paint = Paint().apply { color = toArgb() } +private fun IntRange.intersect(other: IntRange) = max(first, other.first)..min(last, other.last) + private val logger = KotlinLogging.logger("MapView") /** @@ -101,10 +103,10 @@ actual fun MapView( selectRect?.let { rect -> val offset = dragChange.position selectRect = Rect( - kotlin.math.min(offset.x, rect.left), - kotlin.math.min(offset.y, rect.top), - kotlin.math.max(offset.x, rect.right), - kotlin.math.max(offset.y, rect.bottom) + min(offset.x, rect.left), + min(offset.y, rect.top), + max(offset.x, rect.right), + max(offset.y, rect.bottom) ) } } @@ -118,7 +120,7 @@ actual fun MapView( viewPointOverride = MapViewPoint( centerGmc, - viewPoint.zoom + kotlin.math.min(verticalZoom, horizontalZoom) + viewPoint.zoom + min(verticalZoom, horizontalZoom) ) selectRect = null } @@ -149,39 +151,40 @@ actual fun MapView( // Load tiles asynchronously LaunchedEffect(viewPoint, canvasSize) { - val left = centerCoordinates.x - canvasSize.width.value / 2 / tileScale - val right = centerCoordinates.x + canvasSize.width.value / 2 / tileScale - val horizontalIndices = mapTileProvider.toIndex(left)..mapTileProvider.toIndex(right) + with(mapTileProvider) { + val indexRange = 0 until 2.0.pow(zoom).toInt() - val top = (centerCoordinates.y + canvasSize.height.value / 2 / tileScale) - val bottom = (centerCoordinates.y - canvasSize.height.value / 2 / tileScale) - val verticalIndices = mapTileProvider.toIndex(bottom)..mapTileProvider.toIndex(top) + val left = centerCoordinates.x - canvasSize.width.value / 2 / tileScale + val right = centerCoordinates.x + canvasSize.width.value / 2 / tileScale + val horizontalIndices: IntRange = (toIndex(left)..toIndex(right)).intersect(indexRange) - mapTiles.clear() + val top = (centerCoordinates.y + canvasSize.height.value / 2 / tileScale) + val bottom = (centerCoordinates.y - canvasSize.height.value / 2 / tileScale) + val verticalIndices: IntRange = (toIndex(bottom)..toIndex(top)).intersect(indexRange) - val indexRange = 0 until 2.0.pow(zoom).toInt() + mapTiles.clear() - for (j in verticalIndices) { - for (i in horizontalIndices) { - if (i in indexRange && j in indexRange) { - val tileId = TileId(zoom, i, j) - try { - launch { - val tile = mapTileProvider.loadTileAsync(tileId) - mapTiles.add(tile.await()) + for (j in verticalIndices) { + for (i in horizontalIndices) { + val id = TileId(zoom, i, j) + //start all + val deferred = loadTileAsync(id) + //wait asynchronously for it to finish + launch { + try { + mapTiles += deferred.await() + } catch (ex: Exception) { + //displaying the error is maps responsibility + logger.error(ex) { "Failed to load tile with id=$id" } } - } catch (ex: Exception) { - logger.error(ex) { "Failed to load tile $tileId" } } } } } - } - // d - Canvas(canvasModifier) { + Canvas(canvasModifier) { fun WebMercatorCoordinates.toOffset(): Offset = Offset( (canvasSize.width / 2 + (x.dp - centerCoordinates.x.dp) * tileScale.toFloat()).toPx(), (canvasSize.height / 2 + (y.dp - centerCoordinates.y.dp) * tileScale.toFloat()).toPx() @@ -261,4 +264,4 @@ actual fun MapView( ) } } -} \ No newline at end of file +} diff --git a/src/jvmMain/kotlin/centre/sciprog/maps/compose/OpenStreetMapTileProvider.kt b/src/jvmMain/kotlin/centre/sciprog/maps/compose/OpenStreetMapTileProvider.kt index 351a2c0..7c1adf5 100644 --- a/src/jvmMain/kotlin/centre/sciprog/maps/compose/OpenStreetMapTileProvider.kt +++ b/src/jvmMain/kotlin/centre/sciprog/maps/compose/OpenStreetMapTileProvider.kt @@ -9,22 +9,25 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Deferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit import mu.KotlinLogging import org.jetbrains.skia.Image import java.net.URL import java.nio.file.Path import kotlin.io.path.* -import kotlin.math.pow /** * A [MapTileProvider] based on Open Street Map API. With in-memory and file cache */ -public class OpenStreetMapTileProvider( - private val scope: CoroutineScope, +class OpenStreetMapTileProvider( private val client: HttpClient, private val cacheDirectory: Path, + parallelism: Int = 1, + cacheCapacity: Int = 200, ) : MapTileProvider { - private val cache = HashMap>() + private val semaphore = Semaphore(parallelism) + private val cache = LruCache>(cacheCapacity) private fun TileId.osmUrl() = URL("https://tile.openstreetmap.org/${zoom}/${i}/${j}.png") @@ -33,7 +36,7 @@ public class OpenStreetMapTileProvider( /** * Download and cache the tile image */ - private fun downloadImageAsync(id: TileId) = scope.async(Dispatchers.IO) { + private fun CoroutineScope.downloadImageAsync(id: TileId) = async(Dispatchers.IO) { id.cacheFilePath()?.let { path -> if (path.exists()) { try { @@ -45,38 +48,44 @@ public class OpenStreetMapTileProvider( } } - val url = id.osmUrl() - val byteArray = client.get(url).readBytes() + try { + //semaphore works only for actual download + semaphore.withPermit { + val url = id.osmUrl() + val byteArray = client.get(url).readBytes() - logger.debug { "Finished downloading map tile with id $id from $url" } + logger.debug { "Finished downloading map tile with id $id from $url" } - id.cacheFilePath()?.let { path -> - logger.debug { "Caching map tile $id to $path" } + id.cacheFilePath()?.let { path -> + logger.debug { "Caching map tile $id to $path" } - path.parent.createDirectories() - path.writeBytes(byteArray) - } + path.parent.createDirectories() + path.writeBytes(byteArray) + } - Image.makeFromEncoded(byteArray).toComposeImageBitmap() - } - - override fun loadTileAsync(id: TileId): Deferred { - val indexRange = indexRange(id.zoom) - if (id.i !in indexRange || id.j !in indexRange) { - error("Indices (${id.i}, ${id.j}) are not in index range $indexRange for zoom ${id.zoom}") - } - - val image = cache.getOrPut(id) { - downloadImageAsync(id) - } - - return scope.async { - MapTile(id, image.await()) + Image.makeFromEncoded(byteArray).toComposeImageBitmap() + } + } catch (ex: Exception){ + //if loading is failed for some reason, clear the cache + cache.remove(id) + throw ex } } + override fun CoroutineScope.loadTileAsync( + tileId: TileId, + ): Deferred { + //start image download + val image = cache.getOrPut(tileId) { + downloadImageAsync(tileId) + } + + //collect the result asynchronously + return async { MapTile(tileId, image.await()) } + } + + companion object { private val logger = KotlinLogging.logger("OpenStreetMapCache") - private fun indexRange(zoom: Int): IntRange = 0 until 2.0.pow(zoom).toInt() } -} \ No newline at end of file +}