Added proper file cache and map drag
This commit is contained in:
parent
f7151548e5
commit
ea16a02a48
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,5 +1,6 @@
|
|||||||
build/
|
build/
|
||||||
.gradle/
|
.gradle/
|
||||||
.idea/
|
.idea/
|
||||||
|
mapCache/
|
||||||
|
|
||||||
*.iml
|
*.iml
|
@ -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"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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
|
||||||
)
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
Loading…
Reference in New Issue
Block a user