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/
.gradle/
.idea/
mapCache/
*.iml
*.iml
mapCache/

View File

@ -1,4 +1,4 @@
kotlin.code.style=official
kotlin.version=1.6.10
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.
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"))
}
}

View File

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

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 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
// )
// }
}

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