Add abstraction for tile storage

This commit is contained in:
Alexander Nozik 2022-07-10 22:37:07 +03:00
parent 7771a9b7b9
commit 3837af5886
No known key found for this signature in database
GPG Key ID: F7FCF2DD25C71357
8 changed files with 204 additions and 172 deletions

View File

@ -1,12 +1,16 @@
// Copyright 2000-2021 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. // Copyright 2000-2021 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file.
import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.foundation.layout.Column
import androidx.compose.material.MaterialTheme import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.filled.Home
import androidx.compose.runtime.Composable import androidx.compose.runtime.*
import androidx.compose.ui.window.Window import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application import androidx.compose.ui.window.application
import centre.sciprog.maps.compose.* import centre.sciprog.maps.compose.*
import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import java.nio.file.Path import java.nio.file.Path
@Composable @Composable
@ -26,7 +30,18 @@ fun App() {
add(MapTextFeature(pointOne.toCoordinates(), "Home")) add(MapTextFeature(pointOne.toCoordinates(), "Home"))
add(MapVectorImageFeature(pointOne.toCoordinates(), Icons.Filled.Home)) add(MapVectorImageFeature(pointOne.toCoordinates(), Icons.Filled.Home))
} }
MapView(viewPoint, features = features, cacheDirectory = Path.of("mapCache"))
val scope = rememberCoroutineScope()
val mapTileProvider = remember { OpenStreetMapTileProvider(scope, HttpClient(CIO), Path.of("mapCache")) }
var coordinates by remember { mutableStateOf<GeodeticMapCoordinates?>(null) }
Column {
Text(coordinates?.toString() ?: "")
MapView(viewPoint, mapTileProvider, features = features) {
coordinates = it
}
}
} }
} }

View File

@ -10,22 +10,22 @@ import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.IntSize
//TODO replace zoom range with zoom-based representation change //TODO replace zoom range with zoom-based representation change
sealed class MapFeature(val zoomRange: ClosedFloatingPointRange<Double>) sealed class MapFeature(val zoomRange: IntRange)
private val defaultZoomRange = 1.0..18.0 private val defaultZoomRange = 1..18
internal fun Pair<Double, Double>.toCoordinates() = GeodeticMapCoordinates.ofDegrees(first, second) internal fun Pair<Double, Double>.toCoordinates() = GeodeticMapCoordinates.ofDegrees(first, second)
class MapCircleFeature( class MapCircleFeature(
val center: GeodeticMapCoordinates, val center: GeodeticMapCoordinates,
zoomRange: ClosedFloatingPointRange<Double> = defaultZoomRange, zoomRange: IntRange = defaultZoomRange,
val size: Float = 5f, val size: Float = 5f,
val color: Color = Color.Red, val color: Color = Color.Red,
) : MapFeature(zoomRange) ) : MapFeature(zoomRange)
fun MapCircleFeature( fun MapCircleFeature(
centerCoordinates: Pair<Double, Double>, centerCoordinates: Pair<Double, Double>,
zoomRange: ClosedFloatingPointRange<Double> = defaultZoomRange, zoomRange: IntRange = defaultZoomRange,
size: Float = 5f, size: Float = 5f,
color: Color = Color.Red, color: Color = Color.Red,
) = MapCircleFeature( ) = MapCircleFeature(
@ -38,21 +38,21 @@ fun MapCircleFeature(
class MapLineFeature( class MapLineFeature(
val a: GeodeticMapCoordinates, val a: GeodeticMapCoordinates,
val b: GeodeticMapCoordinates, val b: GeodeticMapCoordinates,
zoomRange: ClosedFloatingPointRange<Double> = defaultZoomRange, zoomRange: IntRange = defaultZoomRange,
val color: Color = Color.Red, val color: Color = Color.Red,
) : MapFeature(zoomRange) ) : MapFeature(zoomRange)
fun MapLineFeature( fun MapLineFeature(
aCoordinates: Pair<Double, Double>, aCoordinates: Pair<Double, Double>,
bCoordinates: Pair<Double, Double>, bCoordinates: Pair<Double, Double>,
zoomRange: ClosedFloatingPointRange<Double> = defaultZoomRange, zoomRange: IntRange = defaultZoomRange,
color: Color = Color.Red, color: Color = Color.Red,
) = MapLineFeature(aCoordinates.toCoordinates(), bCoordinates.toCoordinates(), zoomRange, color) ) = MapLineFeature(aCoordinates.toCoordinates(), bCoordinates.toCoordinates(), zoomRange, color)
class MapTextFeature( class MapTextFeature(
val position: GeodeticMapCoordinates, val position: GeodeticMapCoordinates,
val text: String, val text: String,
zoomRange: ClosedFloatingPointRange<Double> = defaultZoomRange, zoomRange: IntRange = defaultZoomRange,
val color: Color = Color.Red, val color: Color = Color.Red,
) : MapFeature(zoomRange) ) : MapFeature(zoomRange)
@ -60,7 +60,7 @@ class MapBitmapImageFeature(
val position: GeodeticMapCoordinates, val position: GeodeticMapCoordinates,
val image: ImageBitmap, val image: ImageBitmap,
val size: IntSize = IntSize(15, 15), val size: IntSize = IntSize(15, 15),
zoomRange: ClosedFloatingPointRange<Double> = defaultZoomRange, zoomRange: IntRange = defaultZoomRange,
val color: Color = Color.Red, val color: Color = Color.Red,
) : MapFeature(zoomRange) ) : MapFeature(zoomRange)
@ -69,7 +69,7 @@ class MapVectorImageFeature internal constructor(
val position: GeodeticMapCoordinates, val position: GeodeticMapCoordinates,
val painter: VectorPainter, val painter: VectorPainter,
val size: Size, val size: Size,
zoomRange: ClosedFloatingPointRange<Double> = defaultZoomRange, zoomRange: IntRange = defaultZoomRange,
val color: Color = Color.Red, val color: Color = Color.Red,
) : MapFeature(zoomRange) ) : MapFeature(zoomRange)
@ -78,6 +78,6 @@ fun MapVectorImageFeature(
position: GeodeticMapCoordinates, position: GeodeticMapCoordinates,
image: ImageVector, image: ImageVector,
size: Size = Size(20f,20f), size: Size = Size(20f,20f),
zoomRange: ClosedFloatingPointRange<Double> = defaultZoomRange, zoomRange: IntRange = defaultZoomRange,
color: Color = Color.Red, color: Color = Color.Red,
): MapVectorImageFeature = MapVectorImageFeature(position, rememberVectorPainter(image), size, zoomRange, color) ): MapVectorImageFeature = MapVectorImageFeature(position, rememberVectorPainter(image), size, zoomRange, color)

View File

@ -0,0 +1,27 @@
package centre.sciprog.maps.compose
import androidx.compose.ui.graphics.ImageBitmap
import kotlin.math.floor
data class TileId(
val zoom: Int,
val i: Int,
val j: Int,
)
data class MapTile(
val id: TileId,
val image: ImageBitmap,
)
interface MapTileProvider {
suspend fun loadTile(id: TileId): MapTile
fun toIndex(d: Double): Int = floor(d / DEFAULT_TILE_SIZE).toInt()
fun toCoordinate(i: Int): Double = (i * DEFAULT_TILE_SIZE).toDouble()
companion object{
const val DEFAULT_TILE_SIZE = 256
}
}

View File

@ -2,136 +2,71 @@ 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.gestures.detectDragGestures
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
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.* import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.clipRect import androidx.compose.ui.graphics.drawscope.clipRect
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.graphics.drawscope.translate import androidx.compose.ui.graphics.drawscope.translate
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.graphics.toArgb
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.PointerInputChange
import androidx.compose.ui.input.pointer.onPointerEvent import androidx.compose.ui.input.pointer.onPointerEvent
import androidx.compose.ui.input.pointer.pointerInput 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
import io.ktor.client.statement.readBytes
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import mu.KotlinLogging import mu.KotlinLogging
import org.jetbrains.skia.Font import org.jetbrains.skia.Font
import org.jetbrains.skia.Image
import org.jetbrains.skia.Paint import org.jetbrains.skia.Paint
import java.net.URL
import java.nio.file.Path
import kotlin.io.path.*
import kotlin.math.floor
import kotlin.math.pow import kotlin.math.pow
import kotlin.math.round
import kotlin.math.roundToInt
private const val TILE_SIZE = 256
private data class OsMapTileId(
val zoom: Int,
val i: Int,
val j: Int,
)
private data class OsMapTile(
val id: OsMapTileId,
val image: ImageBitmap,
)
private class OsMapCache(val scope: CoroutineScope, val client: HttpClient, private val cacheDirectory: Path? = null) {
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 {
val image = cache.getOrPut(id) {
scope.downloadImageAsync(id)
}.await()
return OsMapTile(id, image)
}
}
private fun Color.toPaint(): Paint = Paint().apply { private fun Color.toPaint(): Paint = Paint().apply {
isAntiAlias = true isAntiAlias = true
color = toArgb() color = toArgb()
} }
private fun Double.toIndex(): Int = floor(this / TILE_SIZE).toInt()
private fun Int.toCoordinate(): Double = (this * TILE_SIZE).toDouble()
private val logger = KotlinLogging.logger("MapView") private val logger = KotlinLogging.logger("MapView")
/**
* A component that renders map and provides basic map manipulation capabilities
*/
@OptIn(ExperimentalComposeUiApi::class) @OptIn(ExperimentalComposeUiApi::class)
@Composable @Composable
fun MapView( fun MapView(
initialViewPoint: MapViewPoint, initialViewPoint: MapViewPoint,
mapTileProvider: MapTileProvider,
features: Collection<MapFeature> = emptyList(), features: Collection<MapFeature> = emptyList(),
modifier: Modifier = Modifier.fillMaxSize(), modifier: Modifier = Modifier.fillMaxSize(),
client: HttpClient = remember { HttpClient(CIO) }, onClick: (GeodeticMapCoordinates) -> Unit = {},
cacheDirectory: Path? = null,
) { ) {
var viewPoint by remember { mutableStateOf(initialViewPoint) } var viewPoint by remember { mutableStateOf(initialViewPoint) }
val scope = rememberCoroutineScope() val zoom: Int by derivedStateOf { viewPoint.zoom.roundToInt() }
val mapCache = remember { OsMapCache(scope, client, cacheDirectory) }
val mapTiles = remember { mutableStateListOf<OsMapTile>() } val mapTiles = remember { mutableStateListOf<MapTile>() }
//var mapRectangle by remember { mutableStateOf(initialRectangle) } //var mapRectangle by remember { mutableStateOf(initialRectangle) }
var canvasSize by remember { mutableStateOf(Size(512f, 512f)) } var canvasSize by remember { mutableStateOf(Size(512f, 512f)) }
val centerCoordinates by derivedStateOf { viewPoint.toMercator() } val centerCoordinates by derivedStateOf { WebMercatorProjection.toMercator(viewPoint.focus, zoom) }
// Load tiles asynchronously
LaunchedEffect(viewPoint, canvasSize) { LaunchedEffect(viewPoint, canvasSize) {
val z = viewPoint.zoom.toInt() //remember zoom state to avoid races
val z = zoom
val left = centerCoordinates.x - canvasSize.width / 2 val left = centerCoordinates.x - canvasSize.width / 2
val right = centerCoordinates.x + canvasSize.width / 2 val right = centerCoordinates.x + canvasSize.width / 2
val horizontalIndices = left.toIndex()..right.toIndex() val horizontalIndices = mapTileProvider.toIndex(left)..mapTileProvider.toIndex(right)
val top = (centerCoordinates.y + canvasSize.height / 2) val top = (centerCoordinates.y + canvasSize.height / 2)
val bottom = (centerCoordinates.y - canvasSize.height / 2) val bottom = (centerCoordinates.y - canvasSize.height / 2)
val verticalIndices = bottom.toIndex()..top.toIndex() val verticalIndices = mapTileProvider.toIndex(bottom)..mapTileProvider.toIndex(top)
mapTiles.clear() mapTiles.clear()
@ -139,9 +74,9 @@ fun MapView(
for (j in verticalIndices) { for (j in verticalIndices) {
for (i in horizontalIndices) { for (i in horizontalIndices) {
if (z == viewPoint.zoom.toInt() && i in indexRange && j in indexRange) { if (z == zoom && i in indexRange && j in indexRange) {
val tileId = OsMapTileId(z, i, j) val tileId = TileId(z, i, j)
val tile = mapCache.loadTile(tileId) val tile = mapTileProvider.loadTile(tileId)
mapTiles.add(tile) mapTiles.add(tile)
} }
} }
@ -150,7 +85,7 @@ fun MapView(
} }
fun Offset.toMercator(): WebMercatorCoordinates = WebMercatorCoordinates( fun Offset.toMercator(): WebMercatorCoordinates = WebMercatorCoordinates(
viewPoint.zoom, zoom,
x + centerCoordinates.x - canvasSize.width / 2, x + centerCoordinates.x - canvasSize.width / 2,
y + centerCoordinates.y - canvasSize.height / 2, y + centerCoordinates.y - canvasSize.height / 2,
) )
@ -162,15 +97,10 @@ fun MapView(
(canvasSize.height / 2 - centerCoordinates.y + y).toFloat() (canvasSize.height / 2 - centerCoordinates.y + y).toFloat()
) )
fun GeodeticMapCoordinates.toOffset(): Offset = WebMercatorProjection.toMercator(this, viewPoint.zoom).toOffset() fun GeodeticMapCoordinates.toOffset(): Offset = WebMercatorProjection.toMercator(this, zoom).toOffset()
var coordinates by remember { mutableStateOf<GeodeticMapCoordinates?>(null) } val canvasModifier = modifier.onPointerEvent(PointerEventType.Press) {
onClick(it.changes.first().position.toGeodetic())
val canvasModifier = modifier.onPointerEvent(PointerEventType.Move) {
val position = it.changes.first().position
coordinates = position.toGeodetic()
}.onPointerEvent(PointerEventType.Press) {
println(coordinates)
}.onPointerEvent(PointerEventType.Scroll) { }.onPointerEvent(PointerEventType.Scroll) {
viewPoint = viewPoint.zoom(-it.changes.first().scrollDelta.y.toDouble()) viewPoint = viewPoint.zoom(-it.changes.first().scrollDelta.y.toDouble())
}.pointerInput(Unit) { }.pointerInput(Unit) {
@ -180,8 +110,6 @@ fun MapView(
}.fillMaxSize() }.fillMaxSize()
Column {
//Text(coordinates.toString())
Canvas(canvasModifier) { Canvas(canvasModifier) {
if (canvasSize != size) { if (canvasSize != size) {
canvasSize = size canvasSize = size
@ -190,17 +118,16 @@ fun MapView(
clipRect { 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" }
val offset = Offset( val offset = Offset(
(canvasSize.width / 2 - centerCoordinates.x + id.i.toCoordinate()).toFloat(), (canvasSize.width / 2 - centerCoordinates.x + mapTileProvider.toCoordinate(id.i)).toFloat(),
(canvasSize.height / 2 - centerCoordinates.y + id.j.toCoordinate()).toFloat() (canvasSize.height / 2 - centerCoordinates.y + mapTileProvider.toCoordinate(id.j)).toFloat()
) )
drawImage( drawImage(
image = image, image = image,
topLeft = offset topLeft = offset
) )
} }
features.filter { viewPoint.zoom in it.zoomRange }.forEach { feature -> features.filter { zoom in it.zoomRange }.forEach { feature ->
when (feature) { when (feature) {
is MapCircleFeature -> drawCircle( is MapCircleFeature -> drawCircle(
feature.color, feature.color,
@ -211,7 +138,7 @@ fun MapView(
is MapBitmapImageFeature -> drawImage(feature.image, feature.position.toOffset()) is MapBitmapImageFeature -> drawImage(feature.image, feature.position.toOffset())
is MapVectorImageFeature -> { is MapVectorImageFeature -> {
val offset = feature.position.toOffset() val offset = feature.position.toOffset()
translate(offset.x - feature.size.width/2, offset.y - feature.size.height/2) { translate(offset.x - feature.size.width / 2, offset.y - feature.size.height / 2) {
with(feature.painter) { with(feature.painter) {
draw(feature.size) draw(feature.size)
} }
@ -232,5 +159,4 @@ fun MapView(
} }
} }
} }
}
} }

View File

@ -1,13 +1,15 @@
package centre.sciprog.maps.compose package centre.sciprog.maps.compose
import kotlin.math.roundToInt
/** /**
* Observable position on the map * Observable position on the map. Includes observation coordinate and [zoom] factor
*/ */
data class MapViewPoint( data class MapViewPoint(
val focus: GeodeticMapCoordinates, val focus: GeodeticMapCoordinates,
val zoom: Double, val zoom: Double,
) { ) {
val scaleFactor by lazy { WebMercatorProjection.scaleFactor(zoom) } val scaleFactor by lazy { WebMercatorProjection.scaleFactor(zoom.roundToInt()) }
} }
fun MapViewPoint.move(deltaX: Float, deltaY: Float): MapViewPoint { fun MapViewPoint.move(deltaX: Float, deltaY: Float): MapViewPoint {
@ -32,8 +34,4 @@ fun MapViewPoint.move(delta: GeodeticMapCoordinates): MapViewPoint {
return MapViewPoint(newCoordinates, zoom) return MapViewPoint(newCoordinates, zoom)
} }
fun MapViewPoint.zoom(zoomDelta: Double): MapViewPoint { fun MapViewPoint.zoom(zoomDelta: Double): MapViewPoint = copy(zoom = (zoom + zoomDelta).coerceIn(2.0, 18.0))
return copy(zoom = (zoom + zoomDelta).coerceIn(2.0, 18.0))
}
fun MapViewPoint.toMercator() = WebMercatorProjection.toMercator(focus, zoom)

View File

@ -20,16 +20,16 @@ public open class MercatorProjection(
private val correctedRadius: ((GeodeticMapCoordinates) -> Double)? = null, private val correctedRadius: ((GeodeticMapCoordinates) -> Double)? = null,
) { ) {
public fun MercatorCoordinates.toGeodetic(): GeodeticMapCoordinates { public fun toGeodetic(mc: MercatorCoordinates): GeodeticMapCoordinates {
val res = GeodeticMapCoordinates.ofRadians( val res = GeodeticMapCoordinates.ofRadians(
atan(sinh(y / radius)), atan(sinh(mc.y / radius)),
baseLongitude + x / radius, baseLongitude + mc.x / radius,
) )
return if (correctedRadius != null) { return if (correctedRadius != null) {
val r = correctedRadius.invoke(res) val r = correctedRadius.invoke(res)
GeodeticMapCoordinates.ofRadians( GeodeticMapCoordinates.ofRadians(
atan(sinh(y / r)), atan(sinh(mc.y / r)),
baseLongitude + x / r, baseLongitude + mc.x / r,
) )
} else { } else {
res res
@ -39,12 +39,12 @@ public open class MercatorProjection(
/** /**
* https://en.wikipedia.org/wiki/Web_Mercator_projection#Formulas * https://en.wikipedia.org/wiki/Web_Mercator_projection#Formulas
*/ */
public fun GeodeticMapCoordinates.toMercator(): MercatorCoordinates { public fun toMercator(gmc: GeodeticMapCoordinates): MercatorCoordinates {
require(abs(latitude) <= MAXIMUM_LATITUDE) { "Latitude exceeds the maximum latitude for mercator coordinates" } require(abs(gmc.latitude) <= MAXIMUM_LATITUDE) { "Latitude exceeds the maximum latitude for mercator coordinates" }
val r = correctedRadius?.invoke(this) ?: radius val r = correctedRadius?.invoke(gmc) ?: radius
return MercatorCoordinates( return MercatorCoordinates(
x = r * (longitude - baseLongitude), x = r * (gmc.longitude - baseLongitude),
y = r * ln(tan(PI / 4 + latitude / 2)) y = r * ln(tan(PI / 4 + gmc.latitude / 2))
) )
} }

View File

@ -0,0 +1,66 @@
package centre.sciprog.maps.compose
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.toComposeImageBitmap
import io.ktor.client.HttpClient
import io.ktor.client.request.get
import io.ktor.client.statement.readBytes
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
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.*
/**
* A [MapTileProvider] based on Open Street Map API. With in-memory and file cache
*/
public class OpenStreetMapTileProvider(private val scope: CoroutineScope, private val client: HttpClient, private val cacheDirectory: Path): MapTileProvider {
private val cache = HashMap<TileId, Deferred<ImageBitmap>>()
private fun TileId.osmUrl() = URL("https://tile.openstreetmap.org/${zoom}/${i}/${j}.png")
private fun TileId.cacheFilePath() = cacheDirectory.resolve("${zoom}/${i}/${j}.png")
private fun downloadImageAsync(id: TileId) = 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()
}
override suspend fun loadTile(id: TileId): MapTile {
val image = cache.getOrPut(id) {
downloadImageAsync(id)
}.await()
return MapTile(id, image)
}
companion object{
private val logger = KotlinLogging.logger("OpenStreetMapCache")
}
}

View File

@ -7,14 +7,14 @@ package centre.sciprog.maps.compose
import kotlin.math.* import kotlin.math.*
public data class WebMercatorCoordinates(val zoom: Double, val x: Double, val y: Double) public data class WebMercatorCoordinates(val zoom: Int, val x: Double, val y: Double)
public object WebMercatorProjection { public object WebMercatorProjection {
/** /**
* Compute radians to projection coordinates ratio for given [zoom] factor * Compute radians to projection coordinates ratio for given [zoom] factor
*/ */
public fun scaleFactor(zoom: Double) = 256.0 / 2 / PI * 2.0.pow(zoom) public fun scaleFactor(zoom: Int) = 256.0 / 2 / PI * 2.0.pow(zoom)
public fun toGeodetic(mercator: WebMercatorCoordinates): GeodeticMapCoordinates { public fun toGeodetic(mercator: WebMercatorCoordinates): GeodeticMapCoordinates {
val scaleFactor = scaleFactor(mercator.zoom) val scaleFactor = scaleFactor(mercator.zoom)
@ -26,7 +26,7 @@ public object WebMercatorProjection {
/** /**
* https://en.wikipedia.org/wiki/Web_Mercator_projection#Formulas * https://en.wikipedia.org/wiki/Web_Mercator_projection#Formulas
*/ */
public fun toMercator(gmc: GeodeticMapCoordinates, zoom: Double): WebMercatorCoordinates { public fun toMercator(gmc: GeodeticMapCoordinates, zoom: Int): WebMercatorCoordinates {
require(abs(gmc.latitude) <= MercatorProjection.MAXIMUM_LATITUDE) { "Latitude exceeds the maximum latitude for mercator coordinates" } require(abs(gmc.latitude) <= MercatorProjection.MAXIMUM_LATITUDE) { "Latitude exceeds the maximum latitude for mercator coordinates" }
val scaleFactor = scaleFactor(zoom) val scaleFactor = scaleFactor(zoom)