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
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()

View File

@ -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)
) {

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()
}
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(
)
}
}
}
}

View File

@ -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()
}
}
}