diff --git a/.gitignore b/.gitignore index b738de6..f803ab3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ build/ .gradle/ .idea/ +mapCache/ *.iml \ No newline at end of file diff --git a/src/jvmMain/kotlin/Main.kt b/src/jvmMain/kotlin/Main.kt index 314964e..c85ba56 100644 --- a/src/jvmMain/kotlin/Main.kt +++ b/src/jvmMain/kotlin/Main.kt @@ -11,16 +11,17 @@ import androidx.compose.ui.window.application import centre.sciprog.maps.compose.GeodeticMapCoordinates import centre.sciprog.maps.compose.MapRectangle import centre.sciprog.maps.compose.MapView +import java.nio.file.Path @Composable @Preview fun App() { MaterialTheme { - val map = MapRectangle( + val map = MapRectangle.of( GeodeticMapCoordinates.ofDegrees(66.513260, 0.0), GeodeticMapCoordinates.ofDegrees(40.979897, 44.999999), ) - MapView(map, modifier = Modifier.fillMaxSize()) + MapView(map, modifier = Modifier.fillMaxSize(), initialZoom = 4.0, cacheDirectory = Path.of("mapCache")) } } diff --git a/src/jvmMain/kotlin/centre/sciprog/maps/compose/MapView.kt b/src/jvmMain/kotlin/centre/sciprog/maps/compose/MapView.kt index 8866a95..99b15f1 100644 --- a/src/jvmMain/kotlin/centre/sciprog/maps/compose/MapView.kt +++ b/src/jvmMain/kotlin/centre/sciprog/maps/compose/MapView.kt @@ -1,18 +1,21 @@ package centre.sciprog.maps.compose import androidx.compose.foundation.Canvas +import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material.Text import androidx.compose.runtime.* import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.drawscope.clipRect import androidx.compose.ui.graphics.toComposeImageBitmap 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.pointerInput import io.ktor.client.HttpClient import io.ktor.client.engine.cio.CIO import io.ktor.client.request.get @@ -24,6 +27,8 @@ import kotlinx.coroutines.async import mu.KotlinLogging import org.jetbrains.skia.Image import java.net.URL +import java.nio.file.Path +import kotlin.io.path.* import kotlin.math.* @@ -36,26 +41,48 @@ private data class OsMapTileId( val j: Int, ) -private fun OsMapTileId.osmUrl() = URL("https://tile.openstreetmap.org/${zoom.toInt()}/${i}/${j}.png") - private data class OsMapTile( val id: OsMapTileId, 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>() + 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 { val image = cache.getOrPut(id) { - scope.async(Dispatchers.IO) { - 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() - } + scope.downloadImageAsync(id) }.await() return OsMapTile(id, image) @@ -71,16 +98,15 @@ fun MapView( initialRectangle: MapRectangle, modifier: Modifier, client: HttpClient = remember { HttpClient(CIO) }, + cacheDirectory: Path? = null, initialZoom: Double? = null, ) { - val mapRectangle by remember { mutableStateOf(initialRectangle) } - val scope = rememberCoroutineScope() - val mapCache = remember { OsMapCache(scope, client) } + val mapCache = remember { OsMapCache(scope, client, cacheDirectory) } + val mapTiles = remember { mutableStateListOf()} - val mapTiles = mutableStateListOf() - - var canvasSize by remember { mutableStateOf(Size(512f,512f)) } + var mapRectangle by remember { mutableStateOf(initialRectangle) } + var canvasSize by remember { mutableStateOf(Size(512f, 512f)) } //TODO provide override for tiling 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) } } - LaunchedEffect(canvasSize, zoom) { + LaunchedEffect(mapRectangle, canvasSize, zoom) { val startIndexHorizontal = (topLeft.x / TILE_SIZE).toInt() val startIndexVertical = (topLeft.y / TILE_SIZE).toInt() @@ -142,37 +168,33 @@ fun MapView( } }.onPointerEvent(PointerEventType.Press) { println(coordinates) + }.pointerInput(Unit) { + detectDragGestures { change: PointerInputChange, dragAmount: Offset -> + mapRectangle = mapRectangle.move(dragAmount.y / scaleFactor, -dragAmount.x / scaleFactor) + } }.fillMaxSize() -// .pointerInput(Unit) { -// forEachGesture { -// awaitPointerEventScope { -// val down = awaitFirstDown() -// drag(down.id) { -// println(currentEvent.mouseEvent?.button) -// } -// } -// } -// } + Column { //Text(coordinates.toString()) Canvas(canvasModifier) { - if(canvasSize!= size) { + if (canvasSize != size) { canvasSize = size logger.debug { "Redraw canvas. Size: $size" } } - - mapTiles.forEach { (id, image) -> - //converting back from tile index to screen offset - logger.debug { "Drawing tile $id" } - val offset = Offset( - id.i.toFloat() * TILE_SIZE - topLeft.x.toFloat(), - id.j.toFloat() * TILE_SIZE - topLeft.y.toFloat() - ) - drawImage( - image = image, - topLeft = offset - ) + clipRect { + mapTiles.forEach { (id, image) -> + //converting back from tile index to screen offset + logger.debug { "Drawing tile $id" } + val offset = Offset( + id.i.toFloat() * TILE_SIZE - topLeft.x.toFloat(), + id.j.toFloat() * TILE_SIZE - topLeft.y.toFloat() + ) + drawImage( + image = image, + topLeft = offset + ) + } } } } diff --git a/src/jvmMain/kotlin/centre/sciprog/maps/compose/coordinates.kt b/src/jvmMain/kotlin/centre/sciprog/maps/compose/coordinates.kt index e204f2e..923b35e 100644 --- a/src/jvmMain/kotlin/centre/sciprog/maps/compose/coordinates.kt +++ b/src/jvmMain/kotlin/centre/sciprog/maps/compose/coordinates.kt @@ -27,13 +27,13 @@ public class GeodeticMapCoordinates private constructor(public val latitude: Dou } 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 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)) } diff --git a/src/jvmMain/kotlin/centre/sciprog/maps/compose/model.kt b/src/jvmMain/kotlin/centre/sciprog/maps/compose/model.kt index 4df1fc9..e9d034f 100644 --- a/src/jvmMain/kotlin/centre/sciprog/maps/compose/model.kt +++ b/src/jvmMain/kotlin/centre/sciprog/maps/compose/model.kt @@ -1,8 +1,9 @@ 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 bottomRight: GeodeticMapCoordinates, ) { @@ -10,8 +11,39 @@ class MapRectangle( require(topLeft.latitude >= bottomRight.latitude) 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