Merge TAVRIDA-MR-6: limit-parallel-requests
This commit is contained in:
commit
7cfc76f2c7
@ -13,5 +13,5 @@ pluginManagement {
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "maps-kt-compose"
|
||||
rootProject.name = "maps-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<MapTile>
|
||||
fun CoroutineScope.loadTileAsync(tileId: TileId): Deferred<MapTile>
|
||||
|
||||
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()
|
@ -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<GeodeticMapCoordinates?>(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)
|
||||
) {
|
||||
|
42
src/jvmMain/kotlin/centre/sciprog/maps/compose/LruCache.kt
Normal file
42
src/jvmMain/kotlin/centre/sciprog/maps/compose/LruCache.kt
Normal file
@ -0,0 +1,42 @@
|
||||
package centre.sciprog.maps.compose
|
||||
|
||||
import kotlin.jvm.Synchronized
|
||||
|
||||
|
||||
internal class LruCache<K, V>(
|
||||
private var capacity: Int,
|
||||
) {
|
||||
private val cache = linkedMapOf<K, V>()
|
||||
|
||||
@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
|
||||
}
|
||||
|
||||
}
|
@ -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()
|
||||
|
@ -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<TileId, Deferred<ImageBitmap>>()
|
||||
private val semaphore = Semaphore(parallelism)
|
||||
private val cache = LruCache<TileId, Deferred<ImageBitmap>>(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<MapTile> {
|
||||
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<MapTile> {
|
||||
//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()
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user