Point of view model rework. Drag and zoom fully working.

This commit is contained in:
Alexander Nozik 2022-07-10 16:05:18 +03:00
parent ea16a02a48
commit 3124073800
No known key found for this signature in database
GPG Key ID: F7FCF2DD25C71357
7 changed files with 108 additions and 147 deletions

4
.gitignore vendored
View File

@ -1,6 +1,6 @@
build/ build/
.gradle/ .gradle/
.idea/ .idea/
mapCache/ *.iml
*.iml mapCache/

View File

@ -1,4 +1,4 @@
kotlin.code.style=official kotlin.code.style=official
kotlin.version=1.6.10 kotlin.version=1.6.10
agp.version=4.2.2 agp.version=4.2.2
compose.version=1.1.0 compose.version=1.1.1

View File

@ -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. // 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.fillMaxSize
import androidx.compose.material.Button
import androidx.compose.material.MaterialTheme import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text import androidx.compose.runtime.Composable
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
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.GeodeticMapCoordinates import centre.sciprog.maps.compose.GeodeticMapCoordinates
import centre.sciprog.maps.compose.MapRectangle
import centre.sciprog.maps.compose.MapView import centre.sciprog.maps.compose.MapView
import centre.sciprog.maps.compose.MapViewPoint
import java.nio.file.Path import java.nio.file.Path
@Composable @Composable
@Preview @Preview
fun App() { fun App() {
MaterialTheme { MaterialTheme {
val map = MapRectangle.of( val viewPoint = MapViewPoint(
GeodeticMapCoordinates.ofDegrees(66.513260, 0.0), GeodeticMapCoordinates.ofDegrees(55.7558, 37.6173),
GeodeticMapCoordinates.ofDegrees(40.979897, 44.999999), 6.0
) )
MapView(map, modifier = Modifier.fillMaxSize(), initialZoom = 4.0, cacheDirectory = Path.of("mapCache")) MapView(viewPoint, cacheDirectory = Path.of("mapCache"))
} }
} }

View File

@ -29,7 +29,7 @@ import org.jetbrains.skia.Image
import java.net.URL import java.net.URL
import java.nio.file.Path import java.nio.file.Path
import kotlin.io.path.* import kotlin.io.path.*
import kotlin.math.* import kotlin.math.floor
private const val TILE_SIZE = 256 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") private val logger = KotlinLogging.logger("MapView")
@OptIn(ExperimentalComposeUiApi::class) @OptIn(ExperimentalComposeUiApi::class)
@Composable @Composable
fun MapView( fun MapView(
initialRectangle: MapRectangle, initialViewPoint: MapViewPoint,
modifier: Modifier, modifier: Modifier = Modifier.fillMaxSize(),
client: HttpClient = remember { HttpClient(CIO) }, client: HttpClient = remember { HttpClient(CIO) },
cacheDirectory: Path? = null, cacheDirectory: Path? = null,
initialZoom: Double? = null,
) { ) {
var viewPoint by remember { mutableStateOf(initialViewPoint) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val mapCache = remember { OsMapCache(scope, client, cacheDirectory) } val mapCache = remember { OsMapCache(scope, client, cacheDirectory) }
val mapTiles = remember { mutableStateListOf<OsMapTile>()} val mapTiles = remember { mutableStateListOf<OsMapTile>() }
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)) }
//TODO provide override for tiling val centerCoordinates by derivedStateOf { viewPoint.toMercator() }
val numTilesHorizontal by derivedStateOf {
ceil(canvasSize.width / TILE_SIZE).toInt()
} LaunchedEffect(viewPoint, canvasSize) {
val numTilesVertical by derivedStateOf { val left = centerCoordinates.x - canvasSize.width / 2
ceil(canvasSize.height / TILE_SIZE).toInt() val right = centerCoordinates.x + canvasSize.width / 2
}
val zoom by derivedStateOf { val horizontalIndices = left.toIndex()..right.toIndex()
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)
)
initialZoom ?: ceil( val top = (centerCoordinates.y + canvasSize.height / 2)
log2( val bottom = (centerCoordinates.y - canvasSize.height / 2)
PI / max( val verticalIndices = bottom.toIndex()..top.toIndex()
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()
mapTiles.clear() mapTiles.clear()
for (j in 0 until numTilesVertical) { for (j in verticalIndices) {
for (i in 0 until numTilesHorizontal) { for (i in horizontalIndices) {
val tileId = OsMapTileId(zoom.toInt(), startIndexHorizontal + i, startIndexVertical + j) val tileId = OsMapTileId(viewPoint.zoom.toInt(), i, j)
val tile = mapCache.loadTile(tileId) val tile = mapCache.loadTile(tileId)
mapTiles.add(tile) mapTiles.add(tile)
} }
@ -159,18 +141,20 @@ fun MapView(
val canvasModifier = modifier.onPointerEvent(PointerEventType.Move) { val canvasModifier = modifier.onPointerEvent(PointerEventType.Move) {
val position = it.changes.first().position val position = it.changes.first().position
val screenCoordinates = TileWebMercatorCoordinates( val screenCoordinates = TileWebMercatorCoordinates(
zoom, viewPoint.zoom,
position.x.toDouble() + topLeft.x, position.x + centerCoordinates.x - canvasSize.width / 2,
position.y.toDouble() + topLeft.y position.y + centerCoordinates.y - canvasSize.height / 2,
) )
coordinates = with(WebMercatorProjection) { coordinates = with(WebMercatorProjection) {
screenCoordinates.toGeodetic() toGeodetic(screenCoordinates)
} }
}.onPointerEvent(PointerEventType.Press) { }.onPointerEvent(PointerEventType.Press) {
println(coordinates) println(coordinates)
}.onPointerEvent(PointerEventType.Scroll) {
viewPoint = viewPoint.zoom(-it.changes.first().scrollDelta.y.toDouble())
}.pointerInput(Unit) { }.pointerInput(Unit) {
detectDragGestures { change: PointerInputChange, dragAmount: Offset -> detectDragGestures { change: PointerInputChange, dragAmount: Offset ->
mapRectangle = mapRectangle.move(dragAmount.y / scaleFactor, -dragAmount.x / scaleFactor) viewPoint = viewPoint.move(-dragAmount.x, +dragAmount.y)
} }
}.fillMaxSize() }.fillMaxSize()
@ -187,8 +171,8 @@ fun MapView(
//converting back from tile index to screen offset //converting back from tile index to screen offset
logger.debug { "Drawing tile $id" } logger.debug { "Drawing tile $id" }
val offset = Offset( val offset = Offset(
id.i.toFloat() * TILE_SIZE - topLeft.x.toFloat(), (canvasSize.width / 2 - centerCoordinates.x + id.i.toCoordinate()).toFloat(),
id.j.toFloat() * TILE_SIZE - topLeft.y.toFloat() (canvasSize.height / 2 - centerCoordinates.y + id.j.toCoordinate()).toFloat()
) )
drawImage( drawImage(
image = image, image = image,

View File

@ -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)

View File

@ -16,47 +16,47 @@ public object WebMercatorProjection {
*/ */
public fun scaleFactor(zoom: Double) = 256.0 / 2 / PI * 2.0.pow(zoom) public fun scaleFactor(zoom: Double) = 256.0 / 2 / PI * 2.0.pow(zoom)
public fun TileWebMercatorCoordinates.toGeodetic(): GeodeticMapCoordinates { public fun toGeodetic(mercator: TileWebMercatorCoordinates): GeodeticMapCoordinates {
val scaleFactor = scaleFactor(zoom) val scaleFactor = scaleFactor(mercator.zoom)
val longitude = x / scaleFactor - PI val longitude = mercator.x / scaleFactor - PI
val latitude = (atan(exp(PI - y / scaleFactor)) - PI / 4) * 2 val latitude = (atan(exp(PI - mercator.y / scaleFactor)) - PI / 4) * 2
return GeodeticMapCoordinates.ofRadians(latitude, longitude) return GeodeticMapCoordinates.ofRadians(latitude, longitude)
} }
/** /**
* https://en.wikipedia.org/wiki/Web_Mercator_projection#Formulas * https://en.wikipedia.org/wiki/Web_Mercator_projection#Formulas
*/ */
public fun GeodeticMapCoordinates.toMercator(zoom: Double): TileWebMercatorCoordinates { public fun toMercator(gmc: GeodeticMapCoordinates, zoom: Double): TileWebMercatorCoordinates {
require(abs(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)
return TileWebMercatorCoordinates( return TileWebMercatorCoordinates(
zoom = zoom, zoom = zoom,
x = scaleFactor * (longitude + PI), x = scaleFactor * (gmc.longitude + PI),
y = scaleFactor * (PI - ln(tan(PI / 4 + latitude / 2))) 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 // * 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 // * will be computed to put the resulting x and y coordinates between -127.0 and 128.0
*/ // */
public fun computeOffset( // public fun computeOffset(
base: GeodeticMapCoordinates, // base: GeodeticMapCoordinates,
target: GeodeticMapCoordinates, // target: GeodeticMapCoordinates,
zoom: Double? = null, // zoom: Double? = null,
): TileWebMercatorCoordinates { // ): TileWebMercatorCoordinates {
val xOffsetUnscaled = target.longitude - base.longitude // val xOffsetUnscaled = target.longitude - base.longitude
val yOffsetUnscaled = ln( // val yOffsetUnscaled = ln(
tan(PI / 4 + target.latitude / 2) / tan(PI / 4 + base.latitude / 2) // tan(PI / 4 + target.latitude / 2) / tan(PI / 4 + base.latitude / 2)
) // )
//
val computedZoom = zoom ?: ceil(log2(PI / max(abs(xOffsetUnscaled), abs(yOffsetUnscaled)))) // val computedZoom = zoom ?: ceil(log2(PI / max(abs(xOffsetUnscaled), abs(yOffsetUnscaled))))
val scaleFactor = scaleFactor(computedZoom) // val scaleFactor = scaleFactor(computedZoom)
return TileWebMercatorCoordinates( // return TileWebMercatorCoordinates(
computedZoom, // computedZoom,
x = scaleFactor * xOffsetUnscaled, // x = scaleFactor * xOffsetUnscaled,
y = scaleFactor * yOffsetUnscaled // y = scaleFactor * yOffsetUnscaled
) // )
} // }
} }

View File

@ -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,
)