Added proper file cache and map drag

This commit is contained in:
Alexander Nozik 2022-07-09 22:45:54 +03:00
parent f7151548e5
commit ea16a02a48
No known key found for this signature in database
GPG Key ID: F7FCF2DD25C71357
5 changed files with 107 additions and 51 deletions

1
.gitignore vendored
View File

@ -1,5 +1,6 @@
build/ build/
.gradle/ .gradle/
.idea/ .idea/
mapCache/
*.iml *.iml

View File

@ -11,16 +11,17 @@ import androidx.compose.ui.window.application
import centre.sciprog.maps.compose.GeodeticMapCoordinates import centre.sciprog.maps.compose.GeodeticMapCoordinates
import centre.sciprog.maps.compose.MapRectangle import centre.sciprog.maps.compose.MapRectangle
import centre.sciprog.maps.compose.MapView import centre.sciprog.maps.compose.MapView
import java.nio.file.Path
@Composable @Composable
@Preview @Preview
fun App() { fun App() {
MaterialTheme { MaterialTheme {
val map = MapRectangle( val map = MapRectangle.of(
GeodeticMapCoordinates.ofDegrees(66.513260, 0.0), GeodeticMapCoordinates.ofDegrees(66.513260, 0.0),
GeodeticMapCoordinates.ofDegrees(40.979897, 44.999999), GeodeticMapCoordinates.ofDegrees(40.979897, 44.999999),
) )
MapView(map, modifier = Modifier.fillMaxSize()) MapView(map, modifier = Modifier.fillMaxSize(), initialZoom = 4.0, cacheDirectory = Path.of("mapCache"))
} }
} }

View File

@ -1,18 +1,21 @@
package centre.sciprog.maps.compose package centre.sciprog.maps.compose
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.Text
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.drawscope.clipRect
import androidx.compose.ui.graphics.toComposeImageBitmap import androidx.compose.ui.graphics.toComposeImageBitmap
import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.input.pointer.PointerEventType
import androidx.compose.ui.input.pointer.PointerInputChange
import androidx.compose.ui.input.pointer.onPointerEvent import androidx.compose.ui.input.pointer.onPointerEvent
import androidx.compose.ui.input.pointer.pointerInput
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO import io.ktor.client.engine.cio.CIO
import io.ktor.client.request.get import io.ktor.client.request.get
@ -24,6 +27,8 @@ import kotlinx.coroutines.async
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 kotlin.io.path.*
import kotlin.math.* import kotlin.math.*
@ -36,26 +41,48 @@ private data class OsMapTileId(
val j: Int, val j: Int,
) )
private fun OsMapTileId.osmUrl() = URL("https://tile.openstreetmap.org/${zoom.toInt()}/${i}/${j}.png")
private data class OsMapTile( private data class OsMapTile(
val id: OsMapTileId, val id: OsMapTileId,
val image: ImageBitmap, val image: ImageBitmap,
) )
private class OsMapCache(val scope: CoroutineScope, val client: HttpClient) { private class OsMapCache(val scope: CoroutineScope, val client: HttpClient, private val cacheDirectory: Path? = null) {
private val cache = HashMap<OsMapTileId, Deferred<ImageBitmap>>() private val cache = HashMap<OsMapTileId, Deferred<ImageBitmap>>()
private fun OsMapTileId.osmUrl() = URL("https://tile.openstreetmap.org/${zoom}/${i}/${j}.png")
private fun OsMapTileId.cacheFilePath() = cacheDirectory?.resolve("${zoom}/${i}/${j}.png")
private fun CoroutineScope.downloadImageAsync(id: OsMapTileId) = scope.async(Dispatchers.IO) {
id.cacheFilePath()?.let { path ->
if (path.exists()) {
try {
return@async Image.makeFromEncoded(path.readBytes()).toComposeImageBitmap()
} catch (ex: Exception) {
logger.debug { "Failed to load image from $path" }
path.deleteIfExists()
}
}
}
val url = id.osmUrl()
val byteArray = client.get(url).readBytes()
logger.debug { "Finished downloading map tile with id $id from $url" }
id.cacheFilePath()?.let { path ->
logger.debug { "Caching map tile $id to $path" }
path.parent.createDirectories()
path.writeBytes(byteArray)
}
Image.makeFromEncoded(byteArray).toComposeImageBitmap()
}
public suspend fun loadTile(id: OsMapTileId): OsMapTile { public suspend fun loadTile(id: OsMapTileId): OsMapTile {
val image = cache.getOrPut(id) { val image = cache.getOrPut(id) {
scope.async(Dispatchers.IO) { scope.downloadImageAsync(id)
val url = id.osmUrl()
val byteArray = client.get(url).readBytes()
logger.debug { "Finished downloading map tile with id $id from $url" }
Image.makeFromEncoded(byteArray).toComposeImageBitmap()
}
}.await() }.await()
return OsMapTile(id, image) return OsMapTile(id, image)
@ -71,16 +98,15 @@ fun MapView(
initialRectangle: MapRectangle, initialRectangle: MapRectangle,
modifier: Modifier, modifier: Modifier,
client: HttpClient = remember { HttpClient(CIO) }, client: HttpClient = remember { HttpClient(CIO) },
cacheDirectory: Path? = null,
initialZoom: Double? = null, initialZoom: Double? = null,
) { ) {
val mapRectangle by remember { mutableStateOf(initialRectangle) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val mapCache = remember { OsMapCache(scope, client) } val mapCache = remember { OsMapCache(scope, client, cacheDirectory) }
val mapTiles = remember { mutableStateListOf<OsMapTile>()}
val mapTiles = mutableStateListOf<OsMapTile>() var mapRectangle by remember { mutableStateOf(initialRectangle) }
var canvasSize by remember { mutableStateOf(Size(512f, 512f)) }
var canvasSize by remember { mutableStateOf(Size(512f,512f)) }
//TODO provide override for tiling //TODO provide override for tiling
val numTilesHorizontal by derivedStateOf { val numTilesHorizontal by derivedStateOf {
@ -107,11 +133,11 @@ fun MapView(
) )
} }
//val scaleFactor = WebMercatorProjection.scaleFactor(computedZoom) val scaleFactor by derivedStateOf { WebMercatorProjection.scaleFactor(zoom) }
val topLeft by derivedStateOf { with(WebMercatorProjection) { mapRectangle.topLeft.toMercator(zoom) } } val topLeft by derivedStateOf { with(WebMercatorProjection) { mapRectangle.topLeft.toMercator(zoom) } }
LaunchedEffect(canvasSize, zoom) { LaunchedEffect(mapRectangle, canvasSize, zoom) {
val startIndexHorizontal = (topLeft.x / TILE_SIZE).toInt() val startIndexHorizontal = (topLeft.x / TILE_SIZE).toInt()
val startIndexVertical = (topLeft.y / TILE_SIZE).toInt() val startIndexVertical = (topLeft.y / TILE_SIZE).toInt()
@ -142,37 +168,33 @@ fun MapView(
} }
}.onPointerEvent(PointerEventType.Press) { }.onPointerEvent(PointerEventType.Press) {
println(coordinates) println(coordinates)
}.pointerInput(Unit) {
detectDragGestures { change: PointerInputChange, dragAmount: Offset ->
mapRectangle = mapRectangle.move(dragAmount.y / scaleFactor, -dragAmount.x / scaleFactor)
}
}.fillMaxSize() }.fillMaxSize()
// .pointerInput(Unit) {
// forEachGesture {
// awaitPointerEventScope {
// val down = awaitFirstDown()
// drag(down.id) {
// println(currentEvent.mouseEvent?.button)
// }
// }
// }
// }
Column { Column {
//Text(coordinates.toString()) //Text(coordinates.toString())
Canvas(canvasModifier) { Canvas(canvasModifier) {
if(canvasSize!= size) { if (canvasSize != size) {
canvasSize = size canvasSize = size
logger.debug { "Redraw canvas. Size: $size" } logger.debug { "Redraw canvas. Size: $size" }
} }
clipRect {
mapTiles.forEach { (id, image) -> mapTiles.forEach { (id, image) ->
//converting back from tile index to screen offset //converting back from tile index to screen offset
logger.debug { "Drawing tile $id" } logger.debug { "Drawing tile $id" }
val offset = Offset( val offset = Offset(
id.i.toFloat() * TILE_SIZE - topLeft.x.toFloat(), id.i.toFloat() * TILE_SIZE - topLeft.x.toFloat(),
id.j.toFloat() * TILE_SIZE - topLeft.y.toFloat() id.j.toFloat() * TILE_SIZE - topLeft.y.toFloat()
) )
drawImage( drawImage(
image = image, image = image,
topLeft = offset topLeft = offset
) )
}
} }
} }
} }

View File

@ -27,13 +27,13 @@ public class GeodeticMapCoordinates private constructor(public val latitude: Dou
} }
override fun toString(): String { override fun toString(): String {
return "GeodeticCoordinates(latitude=${latitude / PI * 180}, longitude=${longitude / PI * 180})" return "GeodeticCoordinates(latitude=${latitude / PI * 180} deg, longitude=${longitude / PI * 180} deg)"
} }
public companion object { public companion object {
public fun ofRadians(latitude: Double, longitude: Double): GeodeticMapCoordinates { public fun ofRadians(latitude: Double, longitude: Double): GeodeticMapCoordinates {
require(longitude in (-PI)..(PI)) { "Longitude $longitude is not in (-PI)..(PI)" } require(latitude in (-PI/2)..(PI/2)) { "Latitude $latitude is not in (-PI/2)..(PI/2)" }
return GeodeticMapCoordinates(latitude, longitude.rem(PI / 2)) return GeodeticMapCoordinates(latitude, longitude.rem(PI / 2))
} }

View File

@ -1,8 +1,9 @@
package centre.sciprog.maps.compose package centre.sciprog.maps.compose
import kotlin.math.abs import kotlin.math.max
import kotlin.math.min
class MapRectangle( class MapRectangle private constructor(
var topLeft: GeodeticMapCoordinates, var topLeft: GeodeticMapCoordinates,
var bottomRight: GeodeticMapCoordinates, var bottomRight: GeodeticMapCoordinates,
) { ) {
@ -10,8 +11,39 @@ class MapRectangle(
require(topLeft.latitude >= bottomRight.latitude) require(topLeft.latitude >= bottomRight.latitude)
require(topLeft.longitude <= bottomRight.longitude) require(topLeft.longitude <= bottomRight.longitude)
} }
val topRight: GeodeticMapCoordinates = GeodeticMapCoordinates.ofRadians(topLeft.latitude, bottomRight.longitude)
val bottomLeft: GeodeticMapCoordinates = GeodeticMapCoordinates.ofRadians(bottomRight.latitude, topLeft.longitude) val topRight: GeodeticMapCoordinates get() = GeodeticMapCoordinates.ofRadians(topLeft.latitude, bottomRight.longitude)
val bottomLeft: GeodeticMapCoordinates get() = GeodeticMapCoordinates.ofRadians(bottomRight.latitude, topLeft.longitude)
val topLatitude get() = topLeft.latitude
val bottomLatitude get() = bottomRight.latitude
val leftLongitude get() = topLeft.longitude
val rightLongitude get() = bottomRight.longitude
companion object{
fun of(
a: GeodeticMapCoordinates,
b: GeodeticMapCoordinates
) = MapRectangle(
GeodeticMapCoordinates.ofRadians(max(a.latitude, b.latitude), min(a.longitude,b.longitude)),
GeodeticMapCoordinates.ofRadians(min(a.latitude, b.latitude), max(a.longitude,b.longitude)),
)
}
}
internal fun MapRectangle.move(latitudeDelta: Double, longitudeDelta: Double): MapRectangle {
val safeLatitudeDelta: Double = if(topLatitude + latitudeDelta > MercatorProjection.MAXIMUM_LATITUDE){
0.0
} else if(bottomLatitude + latitudeDelta < -MercatorProjection.MAXIMUM_LATITUDE){
0.0
} else {
latitudeDelta
}
return MapRectangle.of(
GeodeticMapCoordinates.ofRadians(topLeft.latitude + safeLatitudeDelta, topLeft.longitude + longitudeDelta),
GeodeticMapCoordinates.ofRadians(bottomRight.latitude + safeLatitudeDelta, bottomRight.longitude + longitudeDelta)
)
} }
sealed interface MapFeature sealed interface MapFeature