Merge TAVRIDA-MR-6: limit-parallel-requests

This commit is contained in:
Alexander Nozik 2022-07-16 16:24:39 +00:00 committed by Space
commit 7cfc76f2c7
6 changed files with 124 additions and 66 deletions

View File

@ -13,5 +13,5 @@ pluginManagement {
} }
} }
rootProject.name = "maps-kt-compose" rootProject.name = "maps-kt"

View File

@ -1,6 +1,7 @@
package centre.sciprog.maps.compose package centre.sciprog.maps.compose
import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.ImageBitmap
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred import kotlinx.coroutines.Deferred
import kotlin.math.floor import kotlin.math.floor
@ -16,7 +17,7 @@ data class MapTile(
) )
interface MapTileProvider { interface MapTileProvider {
fun loadTileAsync(id: TileId): Deferred<MapTile> fun CoroutineScope.loadTileAsync(tileId: TileId): Deferred<MapTile>
val tileSize: Int get() = DEFAULT_TILE_SIZE val tileSize: Int get() = DEFAULT_TILE_SIZE
@ -28,5 +29,3 @@ interface MapTileProvider {
const val DEFAULT_TILE_SIZE = 256 const val DEFAULT_TILE_SIZE = 256
} }
} }
suspend fun MapTileProvider.loadTile(id: TileId): MapTile = loadTileAsync(id).await()

View File

@ -12,8 +12,8 @@ import androidx.compose.ui.window.application
import centre.sciprog.maps.GeodeticMapCoordinates import centre.sciprog.maps.GeodeticMapCoordinates
import centre.sciprog.maps.MapViewPoint import centre.sciprog.maps.MapViewPoint
import centre.sciprog.maps.compose.* import centre.sciprog.maps.compose.*
import io.ktor.client.HttpClient import io.ktor.client.*
import io.ktor.client.engine.cio.CIO import io.ktor.client.engine.cio.*
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -33,7 +33,12 @@ fun App() {
} }
val scope = rememberCoroutineScope() 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) } var coordinates by remember { mutableStateOf<GeodeticMapCoordinates?>(null) }
@ -41,8 +46,8 @@ fun App() {
//display click coordinates //display click coordinates
Text(coordinates?.toString() ?: "") Text(coordinates?.toString() ?: "")
MapView( MapView(
mapTileProvider, mapTileProvider = mapTileProvider,
viewPoint, initialViewPoint = viewPoint,
onClick = { coordinates = focus }, onClick = { coordinates = focus },
config = MapViewConfig(inferViewBoxFromFeatures = true) config = MapViewConfig(inferViewBoxFromFeatures = true)
) { ) {

View 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
}
}

View File

@ -29,6 +29,8 @@ private fun Color.toPaint(): Paint = Paint().apply {
color = toArgb() color = toArgb()
} }
private fun IntRange.intersect(other: IntRange) = max(first, other.first)..min(last, other.last)
private val logger = KotlinLogging.logger("MapView") private val logger = KotlinLogging.logger("MapView")
/** /**
@ -101,10 +103,10 @@ actual fun MapView(
selectRect?.let { rect -> selectRect?.let { rect ->
val offset = dragChange.position val offset = dragChange.position
selectRect = Rect( selectRect = Rect(
kotlin.math.min(offset.x, rect.left), min(offset.x, rect.left),
kotlin.math.min(offset.y, rect.top), min(offset.y, rect.top),
kotlin.math.max(offset.x, rect.right), max(offset.x, rect.right),
kotlin.math.max(offset.y, rect.bottom) max(offset.y, rect.bottom)
) )
} }
} }
@ -118,7 +120,7 @@ actual fun MapView(
viewPointOverride = MapViewPoint( viewPointOverride = MapViewPoint(
centerGmc, centerGmc,
viewPoint.zoom + kotlin.math.min(verticalZoom, horizontalZoom) viewPoint.zoom + min(verticalZoom, horizontalZoom)
) )
selectRect = null selectRect = null
} }
@ -149,39 +151,40 @@ actual fun MapView(
// Load tiles asynchronously // Load tiles asynchronously
LaunchedEffect(viewPoint, canvasSize) { LaunchedEffect(viewPoint, canvasSize) {
val left = centerCoordinates.x - canvasSize.width.value / 2 / tileScale with(mapTileProvider) {
val right = centerCoordinates.x + canvasSize.width.value / 2 / tileScale val indexRange = 0 until 2.0.pow(zoom).toInt()
val horizontalIndices = mapTileProvider.toIndex(left)..mapTileProvider.toIndex(right)
val top = (centerCoordinates.y + canvasSize.height.value / 2 / tileScale) val left = centerCoordinates.x - canvasSize.width.value / 2 / tileScale
val bottom = (centerCoordinates.y - canvasSize.height.value / 2 / tileScale) val right = centerCoordinates.x + canvasSize.width.value / 2 / tileScale
val verticalIndices = mapTileProvider.toIndex(bottom)..mapTileProvider.toIndex(top) 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 (j in verticalIndices) {
for (i in horizontalIndices) { for (i in horizontalIndices) {
if (i in indexRange && j in indexRange) { val id = TileId(zoom, i, j)
val tileId = TileId(zoom, i, j) //start all
try { val deferred = loadTileAsync(id)
launch { //wait asynchronously for it to finish
val tile = mapTileProvider.loadTileAsync(tileId) launch {
mapTiles.add(tile.await()) 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( fun WebMercatorCoordinates.toOffset(): Offset = Offset(
(canvasSize.width / 2 + (x.dp - centerCoordinates.x.dp) * tileScale.toFloat()).toPx(), (canvasSize.width / 2 + (x.dp - centerCoordinates.x.dp) * tileScale.toFloat()).toPx(),
(canvasSize.height / 2 + (y.dp - centerCoordinates.y.dp) * tileScale.toFloat()).toPx() (canvasSize.height / 2 + (y.dp - centerCoordinates.y.dp) * tileScale.toFloat()).toPx()
@ -261,4 +264,4 @@ actual fun MapView(
) )
} }
} }
} }

View File

@ -9,22 +9,25 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import mu.KotlinLogging import mu.KotlinLogging
import org.jetbrains.skia.Image import org.jetbrains.skia.Image
import java.net.URL import java.net.URL
import java.nio.file.Path import java.nio.file.Path
import kotlin.io.path.* import kotlin.io.path.*
import kotlin.math.pow
/** /**
* A [MapTileProvider] based on Open Street Map API. With in-memory and file cache * A [MapTileProvider] based on Open Street Map API. With in-memory and file cache
*/ */
public class OpenStreetMapTileProvider( class OpenStreetMapTileProvider(
private val scope: CoroutineScope,
private val client: HttpClient, private val client: HttpClient,
private val cacheDirectory: Path, private val cacheDirectory: Path,
parallelism: Int = 1,
cacheCapacity: Int = 200,
) : MapTileProvider { ) : 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") 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 * 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 -> id.cacheFilePath()?.let { path ->
if (path.exists()) { if (path.exists()) {
try { try {
@ -45,38 +48,44 @@ public class OpenStreetMapTileProvider(
} }
} }
val url = id.osmUrl() try {
val byteArray = client.get(url).readBytes() //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 -> id.cacheFilePath()?.let { path ->
logger.debug { "Caching map tile $id to $path" } logger.debug { "Caching map tile $id to $path" }
path.parent.createDirectories() path.parent.createDirectories()
path.writeBytes(byteArray) path.writeBytes(byteArray)
} }
Image.makeFromEncoded(byteArray).toComposeImageBitmap() Image.makeFromEncoded(byteArray).toComposeImageBitmap()
} }
} catch (ex: Exception){
override fun loadTileAsync(id: TileId): Deferred<MapTile> { //if loading is failed for some reason, clear the cache
val indexRange = indexRange(id.zoom) cache.remove(id)
if (id.i !in indexRange || id.j !in indexRange) { throw ex
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())
} }
} }
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 { companion object {
private val logger = KotlinLogging.logger("OpenStreetMapCache") private val logger = KotlinLogging.logger("OpenStreetMapCache")
private fun indexRange(zoom: Int): IntRange = 0 until 2.0.pow(zoom).toInt()
} }
} }