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.
import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.foundation.layout.Column
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
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.application
import centre.sciprog.maps.compose.*
import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import java.nio.file.Path
@Composable
@ -26,7 +30,18 @@ fun App() {
add(MapTextFeature(pointOne.toCoordinates(), "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
//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)
class MapCircleFeature(
val center: GeodeticMapCoordinates,
zoomRange: ClosedFloatingPointRange<Double> = defaultZoomRange,
zoomRange: IntRange = defaultZoomRange,
val size: Float = 5f,
val color: Color = Color.Red,
) : MapFeature(zoomRange)
fun MapCircleFeature(
centerCoordinates: Pair<Double, Double>,
zoomRange: ClosedFloatingPointRange<Double> = defaultZoomRange,
zoomRange: IntRange = defaultZoomRange,
size: Float = 5f,
color: Color = Color.Red,
) = MapCircleFeature(
@ -38,21 +38,21 @@ fun MapCircleFeature(
class MapLineFeature(
val a: GeodeticMapCoordinates,
val b: GeodeticMapCoordinates,
zoomRange: ClosedFloatingPointRange<Double> = defaultZoomRange,
zoomRange: IntRange = defaultZoomRange,
val color: Color = Color.Red,
) : MapFeature(zoomRange)
fun MapLineFeature(
aCoordinates: Pair<Double, Double>,
bCoordinates: Pair<Double, Double>,
zoomRange: ClosedFloatingPointRange<Double> = defaultZoomRange,
zoomRange: IntRange = defaultZoomRange,
color: Color = Color.Red,
) = MapLineFeature(aCoordinates.toCoordinates(), bCoordinates.toCoordinates(), zoomRange, color)
class MapTextFeature(
val position: GeodeticMapCoordinates,
val text: String,
zoomRange: ClosedFloatingPointRange<Double> = defaultZoomRange,
zoomRange: IntRange = defaultZoomRange,
val color: Color = Color.Red,
) : MapFeature(zoomRange)
@ -60,7 +60,7 @@ class MapBitmapImageFeature(
val position: GeodeticMapCoordinates,
val image: ImageBitmap,
val size: IntSize = IntSize(15, 15),
zoomRange: ClosedFloatingPointRange<Double> = defaultZoomRange,
zoomRange: IntRange = defaultZoomRange,
val color: Color = Color.Red,
) : MapFeature(zoomRange)
@ -69,7 +69,7 @@ class MapVectorImageFeature internal constructor(
val position: GeodeticMapCoordinates,
val painter: VectorPainter,
val size: Size,
zoomRange: ClosedFloatingPointRange<Double> = defaultZoomRange,
zoomRange: IntRange = defaultZoomRange,
val color: Color = Color.Red,
) : MapFeature(zoomRange)
@ -78,6 +78,6 @@ fun MapVectorImageFeature(
position: GeodeticMapCoordinates,
image: ImageVector,
size: Size = Size(20f,20f),
zoomRange: ClosedFloatingPointRange<Double> = defaultZoomRange,
zoomRange: IntRange = defaultZoomRange,
color: Color = Color.Red,
): 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.gestures.detectDragGestures
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
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.*
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.clipRect
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
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.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
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.Font
import org.jetbrains.skia.Image
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.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 {
isAntiAlias = true
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")
/**
* A component that renders map and provides basic map manipulation capabilities
*/
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun MapView(
initialViewPoint: MapViewPoint,
mapTileProvider: MapTileProvider,
features: Collection<MapFeature> = emptyList(),
modifier: Modifier = Modifier.fillMaxSize(),
client: HttpClient = remember { HttpClient(CIO) },
cacheDirectory: Path? = null,
onClick: (GeodeticMapCoordinates) -> Unit = {},
) {
var viewPoint by remember { mutableStateOf(initialViewPoint) }
val scope = rememberCoroutineScope()
val mapCache = remember { OsMapCache(scope, client, cacheDirectory) }
val mapTiles = remember { mutableStateListOf<OsMapTile>() }
val zoom: Int by derivedStateOf { viewPoint.zoom.roundToInt() }
val mapTiles = remember { mutableStateListOf<MapTile>() }
//var mapRectangle by remember { mutableStateOf(initialRectangle) }
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) {
val z = viewPoint.zoom.toInt()
//remember zoom state to avoid races
val z = zoom
val left = 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 bottom = (centerCoordinates.y - canvasSize.height / 2)
val verticalIndices = bottom.toIndex()..top.toIndex()
val verticalIndices = mapTileProvider.toIndex(bottom)..mapTileProvider.toIndex(top)
mapTiles.clear()
@ -139,9 +74,9 @@ fun MapView(
for (j in verticalIndices) {
for (i in horizontalIndices) {
if (z == viewPoint.zoom.toInt() && i in indexRange && j in indexRange) {
val tileId = OsMapTileId(z, i, j)
val tile = mapCache.loadTile(tileId)
if (z == zoom && i in indexRange && j in indexRange) {
val tileId = TileId(z, i, j)
val tile = mapTileProvider.loadTile(tileId)
mapTiles.add(tile)
}
}
@ -150,7 +85,7 @@ fun MapView(
}
fun Offset.toMercator(): WebMercatorCoordinates = WebMercatorCoordinates(
viewPoint.zoom,
zoom,
x + centerCoordinates.x - canvasSize.width / 2,
y + centerCoordinates.y - canvasSize.height / 2,
)
@ -162,15 +97,10 @@ fun MapView(
(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.Move) {
val position = it.changes.first().position
coordinates = position.toGeodetic()
}.onPointerEvent(PointerEventType.Press) {
println(coordinates)
val canvasModifier = modifier.onPointerEvent(PointerEventType.Press) {
onClick(it.changes.first().position.toGeodetic())
}.onPointerEvent(PointerEventType.Scroll) {
viewPoint = viewPoint.zoom(-it.changes.first().scrollDelta.y.toDouble())
}.pointerInput(Unit) {
@ -180,8 +110,6 @@ fun MapView(
}.fillMaxSize()
Column {
//Text(coordinates.toString())
Canvas(canvasModifier) {
if (canvasSize != size) {
canvasSize = size
@ -190,17 +118,16 @@ fun MapView(
clipRect {
mapTiles.forEach { (id, image) ->
//converting back from tile index to screen offset
logger.debug { "Drawing tile $id" }
val offset = Offset(
(canvasSize.width / 2 - centerCoordinates.x + id.i.toCoordinate()).toFloat(),
(canvasSize.height / 2 - centerCoordinates.y + id.j.toCoordinate()).toFloat()
(canvasSize.width / 2 - centerCoordinates.x + mapTileProvider.toCoordinate(id.i)).toFloat(),
(canvasSize.height / 2 - centerCoordinates.y + mapTileProvider.toCoordinate(id.j)).toFloat()
)
drawImage(
image = image,
topLeft = offset
)
}
features.filter { viewPoint.zoom in it.zoomRange }.forEach { feature ->
features.filter { zoom in it.zoomRange }.forEach { feature ->
when (feature) {
is MapCircleFeature -> drawCircle(
feature.color,
@ -211,7 +138,7 @@ fun MapView(
is MapBitmapImageFeature -> drawImage(feature.image, feature.position.toOffset())
is MapVectorImageFeature -> {
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) {
draw(feature.size)
}
@ -232,5 +159,4 @@ fun MapView(
}
}
}
}
}

View File

@ -1,13 +1,15 @@
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(
val focus: GeodeticMapCoordinates,
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 {
@ -32,8 +34,4 @@ fun MapViewPoint.move(delta: GeodeticMapCoordinates): MapViewPoint {
return MapViewPoint(newCoordinates, zoom)
}
fun MapViewPoint.zoom(zoomDelta: Double): MapViewPoint {
return copy(zoom = (zoom + zoomDelta).coerceIn(2.0, 18.0))
}
fun MapViewPoint.toMercator() = WebMercatorProjection.toMercator(focus, zoom)
fun MapViewPoint.zoom(zoomDelta: Double): MapViewPoint = copy(zoom = (zoom + zoomDelta).coerceIn(2.0, 18.0))

View File

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