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 6c91a99..c4d90f8 100644 --- a/src/commonMain/kotlin/centre/sciprog/maps/compose/MapTileProvider.kt +++ b/src/commonMain/kotlin/centre/sciprog/maps/compose/MapTileProvider.kt @@ -2,6 +2,7 @@ package centre.sciprog.maps.compose import androidx.compose.ui.graphics.ImageBitmap import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred import kotlin.math.floor data class TileId( @@ -16,7 +17,7 @@ data class MapTile( ) interface MapTileProvider { - suspend fun loadTileAsync(tileIds: List, scope: CoroutineScope, onTileLoad: (mapTile: MapTile) -> Unit) + fun CoroutineScope.loadTileAsync(tileId: TileId): Deferred val tileSize: Int get() = DEFAULT_TILE_SIZE diff --git a/src/commonMain/kotlin/centre/sciprog/maps/LruCache.kt b/src/jvmMain/kotlin/centre/sciprog/maps/compose/LruCache.kt similarity index 55% rename from src/commonMain/kotlin/centre/sciprog/maps/LruCache.kt rename to src/jvmMain/kotlin/centre/sciprog/maps/compose/LruCache.kt index f9726af..bf8d3fc 100644 --- a/src/commonMain/kotlin/centre/sciprog/maps/LruCache.kt +++ b/src/jvmMain/kotlin/centre/sciprog/maps/compose/LruCache.kt @@ -1,4 +1,4 @@ -package centre.sciprog.maps +package centre.sciprog.maps.compose import kotlin.jvm.Synchronized @@ -9,32 +9,14 @@ internal class LruCache( private val cache = linkedMapOf() @Synchronized - fun getCache() = cache.toMap() - - @Synchronized - fun put(key: K, value: V) = internalPut(key, value) - - @Synchronized - operator fun get(key: K) = internalGet(key) - - @Synchronized - fun remove(key: K) { - cache.remove(key) + fun put(key: K, value: V){ + if (cache.size >= capacity) { + cache.remove(cache.iterator().next().key) + } + cache[key] = value } - @Synchronized - fun getOrPut(key: K, callback: () -> V): V { - val internalGet = internalGet(key) - return internalGet ?: callback().also { internalPut(key, it) } - } - - @Synchronized - fun clear(newCapacity: Int? = null) { - cache.clear() - capacity = newCapacity ?: capacity - } - - private fun internalGet(key: K): V? { + operator fun get(key: K): V? { val value = cache[key] if (value != null) { cache.remove(key) @@ -43,11 +25,18 @@ internal class LruCache( return value } - private fun internalPut(key: K, value: V) { - if (cache.size >= capacity) { - cache.remove(cache.iterator().next().key) - } - cache[key] = 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 b4bf970..9403e85 100644 --- a/src/jvmMain/kotlin/centre/sciprog/maps/compose/MapViewJvm.kt +++ b/src/jvmMain/kotlin/centre/sciprog/maps/compose/MapViewJvm.kt @@ -17,6 +17,7 @@ import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.input.pointer.* import androidx.compose.ui.unit.* import centre.sciprog.maps.* +import kotlinx.coroutines.launch import mu.KotlinLogging import org.jetbrains.skia.Font import org.jetbrains.skia.Paint @@ -28,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") /** @@ -148,39 +151,40 @@ actual fun MapView( // Load tiles asynchronously LaunchedEffect(viewPoint, canvasSize) { - val indexRange = 0 until 2.0.pow(zoom).toInt() + with(mapTileProvider) { + val indexRange = 0 until 2.0.pow(zoom).toInt() - val left = centerCoordinates.x - canvasSize.width.value / 2 / tileScale - val right = centerCoordinates.x + canvasSize.width.value / 2 / tileScale - val horizontalIndices = mapTileProvider.toIndex(left) - .rangeTo(mapTileProvider.toIndex(right)) - .intersect(indexRange) + 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) - val top = (centerCoordinates.y + canvasSize.height.value / 2 / tileScale) - val bottom = (centerCoordinates.y - canvasSize.height.value / 2 / tileScale) - val verticalIndices = mapTileProvider.toIndex(bottom) - .rangeTo(mapTileProvider.toIndex(top)) - .intersect(indexRange) + 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) - mapTiles.clear() + mapTiles.clear() - val tileIds = verticalIndices - .flatMap { j -> - horizontalIndices - .asSequence() - .map { TileId(zoom, it, j) } + 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" } + } + } + } } - - mapTileProvider.loadTileAsync( - tileIds = tileIds, - scope = this - ) { mapTiles += it } - + } } - // 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() diff --git a/src/jvmMain/kotlin/centre/sciprog/maps/compose/OpenStreetMapTileProvider.kt b/src/jvmMain/kotlin/centre/sciprog/maps/compose/OpenStreetMapTileProvider.kt index 04daea8..7c1adf5 100644 --- a/src/jvmMain/kotlin/centre/sciprog/maps/compose/OpenStreetMapTileProvider.kt +++ b/src/jvmMain/kotlin/centre/sciprog/maps/compose/OpenStreetMapTileProvider.kt @@ -2,12 +2,15 @@ package centre.sciprog.maps.compose import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.toComposeImageBitmap -import centre.sciprog.maps.LruCache -import io.ktor.client.* -import io.ktor.client.request.* -import io.ktor.client.statement.* -import kotlinx.coroutines.* +import io.ktor.client.HttpClient +import io.ktor.client.request.get +import io.ktor.client.statement.readBytes +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 @@ -45,47 +48,42 @@ 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() + } + } 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) } - Image.makeFromEncoded(byteArray).toComposeImageBitmap() + //collect the result asynchronously + return async { MapTile(tileId, image.await()) } } - override suspend fun loadTileAsync( - tileIds: List, - scope: CoroutineScope, - onTileLoad: (mapTile: MapTile) -> Unit, - ) { - tileIds - .forEach { id -> - try { - scope.launch { - semaphore.acquire() - try { - val image = cache.getOrPut(id) { downloadImageAsync(id) } - val result = MapTile(id, image.await()) - onTileLoad(result) - } catch (e: Exception) { - cache.remove(id) - throw e - } finally { - semaphore.release() - } - } - } catch (ex: Exception) { - logger.error(ex) { "Failed to load tile $id" } - } - } - } companion object { private val logger = KotlinLogging.logger("OpenStreetMapCache")