Point of view model rework. Drag and zoom fully working.
This commit is contained in:
parent
ea16a02a48
commit
3124073800
4
.gitignore
vendored
4
.gitignore
vendored
@ -1,6 +1,6 @@
|
|||||||
build/
|
build/
|
||||||
.gradle/
|
.gradle/
|
||||||
.idea/
|
.idea/
|
||||||
mapCache/
|
*.iml
|
||||||
|
|
||||||
*.iml
|
mapCache/
|
@ -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
|
@ -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"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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,
|
||||||
|
@ -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)
|
@ -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
|
||||||
)
|
// )
|
||||||
}
|
// }
|
||||||
}
|
}
|
@ -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,
|
|
||||||
)
|
|
Loading…
Reference in New Issue
Block a user