From 3837af58863279aaec047dffad8438e2c6535db7 Mon Sep 17 00:00:00 2001 From: Alexander Nozik Date: Sun, 10 Jul 2022 22:37:07 +0300 Subject: [PATCH] Add abstraction for tile storage --- src/jvmMain/kotlin/Main.kt | 19 +- .../centre/sciprog/maps/compose/MapFeature.kt | 20 +- .../sciprog/maps/compose/MapTileProvider.kt | 27 +++ .../centre/sciprog/maps/compose/MapView.kt | 206 ++++++------------ .../sciprog/maps/compose/MapViewPoint.kt | 12 +- .../maps/compose/MercatorProjection.kt | 20 +- .../maps/compose/OpenStreetMapTileProvider.kt | 66 ++++++ .../maps/compose/WebMercatorProjection.kt | 6 +- 8 files changed, 204 insertions(+), 172 deletions(-) create mode 100644 src/jvmMain/kotlin/centre/sciprog/maps/compose/MapTileProvider.kt create mode 100644 src/jvmMain/kotlin/centre/sciprog/maps/compose/OpenStreetMapTileProvider.kt diff --git a/src/jvmMain/kotlin/Main.kt b/src/jvmMain/kotlin/Main.kt index 68ce992..501fecb 100644 --- a/src/jvmMain/kotlin/Main.kt +++ b/src/jvmMain/kotlin/Main.kt @@ -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(null) } + + Column { + Text(coordinates?.toString() ?: "") + MapView(viewPoint, mapTileProvider, features = features) { + coordinates = it + } + } } } diff --git a/src/jvmMain/kotlin/centre/sciprog/maps/compose/MapFeature.kt b/src/jvmMain/kotlin/centre/sciprog/maps/compose/MapFeature.kt index e96e156..20284aa 100644 --- a/src/jvmMain/kotlin/centre/sciprog/maps/compose/MapFeature.kt +++ b/src/jvmMain/kotlin/centre/sciprog/maps/compose/MapFeature.kt @@ -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) +sealed class MapFeature(val zoomRange: IntRange) -private val defaultZoomRange = 1.0..18.0 +private val defaultZoomRange = 1..18 internal fun Pair.toCoordinates() = GeodeticMapCoordinates.ofDegrees(first, second) class MapCircleFeature( val center: GeodeticMapCoordinates, - zoomRange: ClosedFloatingPointRange = defaultZoomRange, + zoomRange: IntRange = defaultZoomRange, val size: Float = 5f, val color: Color = Color.Red, ) : MapFeature(zoomRange) fun MapCircleFeature( centerCoordinates: Pair, - zoomRange: ClosedFloatingPointRange = 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 = defaultZoomRange, + zoomRange: IntRange = defaultZoomRange, val color: Color = Color.Red, ) : MapFeature(zoomRange) fun MapLineFeature( aCoordinates: Pair, bCoordinates: Pair, - zoomRange: ClosedFloatingPointRange = 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 = 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 = 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 = 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 = defaultZoomRange, + zoomRange: IntRange = defaultZoomRange, color: Color = Color.Red, ): MapVectorImageFeature = MapVectorImageFeature(position, rememberVectorPainter(image), size, zoomRange, color) \ No newline at end of file diff --git a/src/jvmMain/kotlin/centre/sciprog/maps/compose/MapTileProvider.kt b/src/jvmMain/kotlin/centre/sciprog/maps/compose/MapTileProvider.kt new file mode 100644 index 0000000..7c2c58d --- /dev/null +++ b/src/jvmMain/kotlin/centre/sciprog/maps/compose/MapTileProvider.kt @@ -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 + } +} \ No newline at end of file diff --git a/src/jvmMain/kotlin/centre/sciprog/maps/compose/MapView.kt b/src/jvmMain/kotlin/centre/sciprog/maps/compose/MapView.kt index 3606180..5e507d6 100644 --- a/src/jvmMain/kotlin/centre/sciprog/maps/compose/MapView.kt +++ b/src/jvmMain/kotlin/centre/sciprog/maps/compose/MapView.kt @@ -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>() - - 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 = 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() } + val zoom: Int by derivedStateOf { viewPoint.zoom.roundToInt() } + + val mapTiles = remember { mutableStateListOf() } //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(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() + ) + } + } } } diff --git a/src/jvmMain/kotlin/centre/sciprog/maps/compose/MapViewPoint.kt b/src/jvmMain/kotlin/centre/sciprog/maps/compose/MapViewPoint.kt index 57a9f8f..57d1f7d 100644 --- a/src/jvmMain/kotlin/centre/sciprog/maps/compose/MapViewPoint.kt +++ b/src/jvmMain/kotlin/centre/sciprog/maps/compose/MapViewPoint.kt @@ -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) \ No newline at end of file +fun MapViewPoint.zoom(zoomDelta: Double): MapViewPoint = copy(zoom = (zoom + zoomDelta).coerceIn(2.0, 18.0)) \ No newline at end of file diff --git a/src/jvmMain/kotlin/centre/sciprog/maps/compose/MercatorProjection.kt b/src/jvmMain/kotlin/centre/sciprog/maps/compose/MercatorProjection.kt index 978c69f..6318370 100644 --- a/src/jvmMain/kotlin/centre/sciprog/maps/compose/MercatorProjection.kt +++ b/src/jvmMain/kotlin/centre/sciprog/maps/compose/MercatorProjection.kt @@ -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)) ) } diff --git a/src/jvmMain/kotlin/centre/sciprog/maps/compose/OpenStreetMapTileProvider.kt b/src/jvmMain/kotlin/centre/sciprog/maps/compose/OpenStreetMapTileProvider.kt new file mode 100644 index 0000000..2972e04 --- /dev/null +++ b/src/jvmMain/kotlin/centre/sciprog/maps/compose/OpenStreetMapTileProvider.kt @@ -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>() + + 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") + } +} \ No newline at end of file diff --git a/src/jvmMain/kotlin/centre/sciprog/maps/compose/WebMercatorProjection.kt b/src/jvmMain/kotlin/centre/sciprog/maps/compose/WebMercatorProjection.kt index 487322c..33479df 100644 --- a/src/jvmMain/kotlin/centre/sciprog/maps/compose/WebMercatorProjection.kt +++ b/src/jvmMain/kotlin/centre/sciprog/maps/compose/WebMercatorProjection.kt @@ -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)