Add abstraction for tile storage
This commit is contained in:
parent
7771a9b7b9
commit
3837af5886
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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)
|
@ -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
|
||||
}
|
||||
}
|
@ -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,55 +110,51 @@ fun MapView(
|
||||
}.fillMaxSize()
|
||||
|
||||
|
||||
Column {
|
||||
//Text(coordinates.toString())
|
||||
Canvas(canvasModifier) {
|
||||
if (canvasSize != size) {
|
||||
canvasSize = size
|
||||
logger.debug { "Redraw canvas. Size: $size" }
|
||||
Canvas(canvasModifier) {
|
||||
if (canvasSize != size) {
|
||||
canvasSize = size
|
||||
logger.debug { "Redraw canvas. Size: $size" }
|
||||
}
|
||||
clipRect {
|
||||
mapTiles.forEach { (id, image) ->
|
||||
//converting back from tile index to screen offset
|
||||
val offset = Offset(
|
||||
(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
|
||||
)
|
||||
}
|
||||
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()
|
||||
features.filter { zoom in it.zoomRange }.forEach { feature ->
|
||||
when (feature) {
|
||||
is MapCircleFeature -> drawCircle(
|
||||
feature.color,
|
||||
feature.size,
|
||||
center = feature.center.toOffset()
|
||||
)
|
||||
drawImage(
|
||||
image = image,
|
||||
topLeft = offset
|
||||
)
|
||||
}
|
||||
features.filter { viewPoint.zoom in it.zoomRange }.forEach { feature ->
|
||||
when (feature) {
|
||||
is MapCircleFeature -> drawCircle(
|
||||
feature.color,
|
||||
feature.size,
|
||||
center = feature.center.toOffset()
|
||||
)
|
||||
is MapLineFeature -> drawLine(feature.color, feature.a.toOffset(), feature.b.toOffset())
|
||||
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) {
|
||||
with(feature.painter) {
|
||||
draw(feature.size)
|
||||
}
|
||||
is MapLineFeature -> drawLine(feature.color, feature.a.toOffset(), feature.b.toOffset())
|
||||
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) {
|
||||
with(feature.painter) {
|
||||
draw(feature.size)
|
||||
}
|
||||
}
|
||||
is MapTextFeature -> drawIntoCanvas { canvas ->
|
||||
val offset = feature.position.toOffset()
|
||||
canvas.nativeCanvas.drawString(
|
||||
feature.text,
|
||||
offset.x + 5,
|
||||
offset.y - 5,
|
||||
Font().apply { size = 16f },
|
||||
feature.color.toPaint()
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
is MapTextFeature -> drawIntoCanvas { canvas ->
|
||||
val offset = feature.position.toOffset()
|
||||
canvas.nativeCanvas.drawString(
|
||||
feature.text,
|
||||
offset.x + 5,
|
||||
offset.y - 5,
|
||||
Font().apply { size = 16f },
|
||||
feature.color.toPaint()
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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))
|
@ -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))
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -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")
|
||||
}
|
||||
}
|
@ -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)
|
||||
|
Loading…
Reference in New Issue
Block a user