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.
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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)
|
@ -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.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,
|
||||||
@ -233,4 +160,3 @@ fun MapView(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
@ -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)
|
|
@ -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))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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.*
|
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)
|
||||||
|
Loading…
Reference in New Issue
Block a user