diff --git a/.gitignore b/.gitignore index f803ab3..7925e19 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ build/ .gradle/ .idea/ -mapCache/ +*.iml -*.iml \ No newline at end of file +mapCache/ \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index a6bad7f..9498324 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ kotlin.code.style=official kotlin.version=1.6.10 agp.version=4.2.2 -compose.version=1.1.0 \ No newline at end of file +compose.version=1.1.1 \ No newline at end of file diff --git a/src/jvmMain/kotlin/Main.kt b/src/jvmMain/kotlin/Main.kt index c85ba56..fed4798 100644 --- a/src/jvmMain/kotlin/Main.kt +++ b/src/jvmMain/kotlin/Main.kt @@ -1,27 +1,23 @@ // 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.fillMaxSize -import androidx.compose.material.Button import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text -import androidx.compose.runtime.* -import androidx.compose.ui.Modifier +import androidx.compose.runtime.Composable import androidx.compose.ui.window.Window import androidx.compose.ui.window.application import centre.sciprog.maps.compose.GeodeticMapCoordinates -import centre.sciprog.maps.compose.MapRectangle import centre.sciprog.maps.compose.MapView +import centre.sciprog.maps.compose.MapViewPoint import java.nio.file.Path @Composable @Preview fun App() { MaterialTheme { - val map = MapRectangle.of( - GeodeticMapCoordinates.ofDegrees(66.513260, 0.0), - GeodeticMapCoordinates.ofDegrees(40.979897, 44.999999), + val viewPoint = MapViewPoint( + GeodeticMapCoordinates.ofDegrees(55.7558, 37.6173), + 6.0 ) - MapView(map, modifier = Modifier.fillMaxSize(), initialZoom = 4.0, cacheDirectory = Path.of("mapCache")) + MapView(viewPoint, cacheDirectory = Path.of("mapCache")) } } diff --git a/src/jvmMain/kotlin/centre/sciprog/maps/compose/MapView.kt b/src/jvmMain/kotlin/centre/sciprog/maps/compose/MapView.kt index 99b15f1..045b606 100644 --- a/src/jvmMain/kotlin/centre/sciprog/maps/compose/MapView.kt +++ b/src/jvmMain/kotlin/centre/sciprog/maps/compose/MapView.kt @@ -29,7 +29,7 @@ import org.jetbrains.skia.Image import java.net.URL import java.nio.file.Path import kotlin.io.path.* -import kotlin.math.* +import kotlin.math.floor private const val TILE_SIZE = 256 @@ -89,64 +89,46 @@ private class OsMapCache(val scope: CoroutineScope, val client: HttpClient, priv } } +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") @OptIn(ExperimentalComposeUiApi::class) @Composable fun MapView( - initialRectangle: MapRectangle, - modifier: Modifier, + initialViewPoint: MapViewPoint, + modifier: Modifier = Modifier.fillMaxSize(), client: HttpClient = remember { HttpClient(CIO) }, cacheDirectory: Path? = null, - initialZoom: Double? = null, ) { + var viewPoint by remember { mutableStateOf(initialViewPoint) } + val scope = rememberCoroutineScope() val mapCache = remember { OsMapCache(scope, client, cacheDirectory) } - val mapTiles = remember { mutableStateListOf()} + val mapTiles = remember { mutableStateListOf() } - var mapRectangle by remember { mutableStateOf(initialRectangle) } + //var mapRectangle by remember { mutableStateOf(initialRectangle) } var canvasSize by remember { mutableStateOf(Size(512f, 512f)) } - //TODO provide override for tiling - val numTilesHorizontal by derivedStateOf { - ceil(canvasSize.width / TILE_SIZE).toInt() + val centerCoordinates by derivedStateOf { viewPoint.toMercator() } - } - val numTilesVertical by derivedStateOf { - ceil(canvasSize.height / TILE_SIZE).toInt() - } + LaunchedEffect(viewPoint, canvasSize) { + val left = centerCoordinates.x - canvasSize.width / 2 + val right = centerCoordinates.x + canvasSize.width / 2 - val zoom by derivedStateOf { - val xOffsetUnscaled = mapRectangle.bottomRight.longitude - mapRectangle.topLeft.longitude - val yOffsetUnscaled = ln( - tan(PI / 4 + mapRectangle.topLeft.latitude / 2) / tan(PI / 4 + mapRectangle.bottomRight.latitude / 2) - ) + val horizontalIndices = left.toIndex()..right.toIndex() - initialZoom ?: ceil( - log2( - PI / max( - abs(xOffsetUnscaled / numTilesHorizontal), - abs(yOffsetUnscaled / numTilesVertical) - ) - ) - ) - } - - val scaleFactor by derivedStateOf { WebMercatorProjection.scaleFactor(zoom) } - - val topLeft by derivedStateOf { with(WebMercatorProjection) { mapRectangle.topLeft.toMercator(zoom) } } - - LaunchedEffect(mapRectangle, canvasSize, zoom) { - - val startIndexHorizontal = (topLeft.x / TILE_SIZE).toInt() - val startIndexVertical = (topLeft.y / TILE_SIZE).toInt() + val top = (centerCoordinates.y + canvasSize.height / 2) + val bottom = (centerCoordinates.y - canvasSize.height / 2) + val verticalIndices = bottom.toIndex()..top.toIndex() mapTiles.clear() - for (j in 0 until numTilesVertical) { - for (i in 0 until numTilesHorizontal) { - val tileId = OsMapTileId(zoom.toInt(), startIndexHorizontal + i, startIndexVertical + j) + for (j in verticalIndices) { + for (i in horizontalIndices) { + val tileId = OsMapTileId(viewPoint.zoom.toInt(), i, j) val tile = mapCache.loadTile(tileId) mapTiles.add(tile) } @@ -159,18 +141,20 @@ fun MapView( val canvasModifier = modifier.onPointerEvent(PointerEventType.Move) { val position = it.changes.first().position val screenCoordinates = TileWebMercatorCoordinates( - zoom, - position.x.toDouble() + topLeft.x, - position.y.toDouble() + topLeft.y + viewPoint.zoom, + position.x + centerCoordinates.x - canvasSize.width / 2, + position.y + centerCoordinates.y - canvasSize.height / 2, ) coordinates = with(WebMercatorProjection) { - screenCoordinates.toGeodetic() + toGeodetic(screenCoordinates) } }.onPointerEvent(PointerEventType.Press) { println(coordinates) + }.onPointerEvent(PointerEventType.Scroll) { + viewPoint = viewPoint.zoom(-it.changes.first().scrollDelta.y.toDouble()) }.pointerInput(Unit) { detectDragGestures { change: PointerInputChange, dragAmount: Offset -> - mapRectangle = mapRectangle.move(dragAmount.y / scaleFactor, -dragAmount.x / scaleFactor) + viewPoint = viewPoint.move(-dragAmount.x, +dragAmount.y) } }.fillMaxSize() @@ -187,8 +171,8 @@ fun MapView( //converting back from tile index to screen offset logger.debug { "Drawing tile $id" } val offset = Offset( - id.i.toFloat() * TILE_SIZE - topLeft.x.toFloat(), - id.j.toFloat() * TILE_SIZE - topLeft.y.toFloat() + (canvasSize.width / 2 - centerCoordinates.x + id.i.toCoordinate()).toFloat(), + (canvasSize.height / 2 - centerCoordinates.y + id.j.toCoordinate()).toFloat() ) drawImage( image = image, diff --git a/src/jvmMain/kotlin/centre/sciprog/maps/compose/MapViewPoint.kt b/src/jvmMain/kotlin/centre/sciprog/maps/compose/MapViewPoint.kt new file mode 100644 index 0000000..1e4388e --- /dev/null +++ b/src/jvmMain/kotlin/centre/sciprog/maps/compose/MapViewPoint.kt @@ -0,0 +1,39 @@ +package centre.sciprog.maps.compose + +/** + * Observable position on the map + */ +data class MapViewPoint( + val focus: GeodeticMapCoordinates, + val zoom: Double, +) { + val scaleFactor by lazy { WebMercatorProjection.scaleFactor(zoom) } +} + +fun MapViewPoint.move(deltaX: Float, deltaY: Float): MapViewPoint { + val newCoordinates = GeodeticMapCoordinates.ofRadians( + (focus.latitude + deltaY / scaleFactor).coerceIn( + -MercatorProjection.MAXIMUM_LATITUDE, + MercatorProjection.MAXIMUM_LATITUDE + ), + focus.longitude + deltaX / scaleFactor + ) + return MapViewPoint(newCoordinates, zoom) +} + +fun MapViewPoint.move(delta: GeodeticMapCoordinates): MapViewPoint { + val newCoordinates = GeodeticMapCoordinates.ofRadians( + (focus.latitude + delta.latitude).coerceIn( + -MercatorProjection.MAXIMUM_LATITUDE, + MercatorProjection.MAXIMUM_LATITUDE + ), + focus.longitude + delta.longitude + ) + return MapViewPoint(newCoordinates, zoom) +} + +fun MapViewPoint.zoom(zoomDelta: Double): MapViewPoint { + return copy(zoom = (zoom + zoomDelta).coerceIn(1.0, 18.0)) +} + +fun MapViewPoint.toMercator() = WebMercatorProjection.toMercator(focus, zoom) \ 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 f4c8bb1..ee7e186 100644 --- a/src/jvmMain/kotlin/centre/sciprog/maps/compose/WebMercatorProjection.kt +++ b/src/jvmMain/kotlin/centre/sciprog/maps/compose/WebMercatorProjection.kt @@ -16,47 +16,47 @@ public object WebMercatorProjection { */ public fun scaleFactor(zoom: Double) = 256.0 / 2 / PI * 2.0.pow(zoom) - public fun TileWebMercatorCoordinates.toGeodetic(): GeodeticMapCoordinates { - val scaleFactor = scaleFactor(zoom) - val longitude = x / scaleFactor - PI - val latitude = (atan(exp(PI - y / scaleFactor)) - PI / 4) * 2 + public fun toGeodetic(mercator: TileWebMercatorCoordinates): GeodeticMapCoordinates { + val scaleFactor = scaleFactor(mercator.zoom) + val longitude = mercator.x / scaleFactor - PI + val latitude = (atan(exp(PI - mercator.y / scaleFactor)) - PI / 4) * 2 return GeodeticMapCoordinates.ofRadians(latitude, longitude) } /** * https://en.wikipedia.org/wiki/Web_Mercator_projection#Formulas */ - public fun GeodeticMapCoordinates.toMercator(zoom: Double): TileWebMercatorCoordinates { - require(abs(latitude) <= MercatorProjection.MAXIMUM_LATITUDE) { "Latitude exceeds the maximum latitude for mercator coordinates" } + public fun toMercator(gmc: GeodeticMapCoordinates, zoom: Double): TileWebMercatorCoordinates { + require(abs(gmc.latitude) <= MercatorProjection.MAXIMUM_LATITUDE) { "Latitude exceeds the maximum latitude for mercator coordinates" } val scaleFactor = scaleFactor(zoom) return TileWebMercatorCoordinates( zoom = zoom, - x = scaleFactor * (longitude + PI), - y = scaleFactor * (PI - ln(tan(PI / 4 + latitude / 2))) + x = scaleFactor * (gmc.longitude + PI), + y = scaleFactor * (PI - ln(tan(PI / 4 + gmc.latitude / 2))) ) } - /** - * Compute and offset of [target] coordinate relative to [base] coordinate. If [zoom] is null, then optimal zoom - * will be computed to put the resulting x and y coordinates between -127.0 and 128.0 - */ - public fun computeOffset( - base: GeodeticMapCoordinates, - target: GeodeticMapCoordinates, - zoom: Double? = null, - ): TileWebMercatorCoordinates { - val xOffsetUnscaled = target.longitude - base.longitude - val yOffsetUnscaled = ln( - tan(PI / 4 + target.latitude / 2) / tan(PI / 4 + base.latitude / 2) - ) - - val computedZoom = zoom ?: ceil(log2(PI / max(abs(xOffsetUnscaled), abs(yOffsetUnscaled)))) - val scaleFactor = scaleFactor(computedZoom) - return TileWebMercatorCoordinates( - computedZoom, - x = scaleFactor * xOffsetUnscaled, - y = scaleFactor * yOffsetUnscaled - ) - } +// /** +// * Compute and offset of [target] coordinate relative to [base] coordinate. If [zoom] is null, then optimal zoom +// * will be computed to put the resulting x and y coordinates between -127.0 and 128.0 +// */ +// public fun computeOffset( +// base: GeodeticMapCoordinates, +// target: GeodeticMapCoordinates, +// zoom: Double? = null, +// ): TileWebMercatorCoordinates { +// val xOffsetUnscaled = target.longitude - base.longitude +// val yOffsetUnscaled = ln( +// tan(PI / 4 + target.latitude / 2) / tan(PI / 4 + base.latitude / 2) +// ) +// +// val computedZoom = zoom ?: ceil(log2(PI / max(abs(xOffsetUnscaled), abs(yOffsetUnscaled)))) +// val scaleFactor = scaleFactor(computedZoom) +// return TileWebMercatorCoordinates( +// computedZoom, +// x = scaleFactor * xOffsetUnscaled, +// y = scaleFactor * yOffsetUnscaled +// ) +// } } \ No newline at end of file diff --git a/src/jvmMain/kotlin/centre/sciprog/maps/compose/model.kt b/src/jvmMain/kotlin/centre/sciprog/maps/compose/model.kt deleted file mode 100644 index e9d034f..0000000 --- a/src/jvmMain/kotlin/centre/sciprog/maps/compose/model.kt +++ /dev/null @@ -1,58 +0,0 @@ -package centre.sciprog.maps.compose - -import kotlin.math.max -import kotlin.math.min - -class MapRectangle private constructor( - var topLeft: GeodeticMapCoordinates, - var bottomRight: GeodeticMapCoordinates, -) { - init { - require(topLeft.latitude >= bottomRight.latitude) - require(topLeft.longitude <= bottomRight.longitude) - } - - val topRight: GeodeticMapCoordinates get() = GeodeticMapCoordinates.ofRadians(topLeft.latitude, bottomRight.longitude) - val bottomLeft: GeodeticMapCoordinates get() = GeodeticMapCoordinates.ofRadians(bottomRight.latitude, topLeft.longitude) - - val topLatitude get() = topLeft.latitude - val bottomLatitude get() = bottomRight.latitude - - val leftLongitude get() = topLeft.longitude - val rightLongitude get() = bottomRight.longitude - - companion object{ - fun of( - a: GeodeticMapCoordinates, - b: GeodeticMapCoordinates - ) = MapRectangle( - GeodeticMapCoordinates.ofRadians(max(a.latitude, b.latitude), min(a.longitude,b.longitude)), - GeodeticMapCoordinates.ofRadians(min(a.latitude, b.latitude), max(a.longitude,b.longitude)), - ) - } -} - -internal fun MapRectangle.move(latitudeDelta: Double, longitudeDelta: Double): MapRectangle { - val safeLatitudeDelta: Double = if(topLatitude + latitudeDelta > MercatorProjection.MAXIMUM_LATITUDE){ - 0.0 - } else if(bottomLatitude + latitudeDelta < -MercatorProjection.MAXIMUM_LATITUDE){ - 0.0 - } else { - latitudeDelta - } - return MapRectangle.of( - GeodeticMapCoordinates.ofRadians(topLeft.latitude + safeLatitudeDelta, topLeft.longitude + longitudeDelta), - GeodeticMapCoordinates.ofRadians(bottomRight.latitude + safeLatitudeDelta, bottomRight.longitude + longitudeDelta) - ) -} - -sealed interface MapFeature - -class MapLine( - val from: GeodeticMapCoordinates, - val to: GeodeticMapCoordinates, -) : MapFeature - -class MapCircle( - val center: GeodeticMapCoordinates, -) \ No newline at end of file