14 Commits

Author SHA1 Message Date
a.kalmakhanov
d6d00827aa й 2022-10-22 20:51:20 +09:00
a.kalmakhanov
5247ce10ab Layers are added to place features below or on top of others 2022-09-01 15:11:26 +09:00
491a4e6000 Drag done 2022-08-31 22:48:52 +03:00
5a1d3d701f Refactored drag processing 2022-08-30 10:29:56 +03:00
26aaac2ecd Fix after merge 2022-08-29 22:24:39 +03:00
d490b3f37c Merge remote-tracking branch 'space/main'
# Conflicts:
#	maps-kt-compose/src/commonMain/kotlin/center/sciprog/maps/compose/MapFeature.kt
#	maps-kt-compose/src/jvmMain/kotlin/center/sciprog/maps/compose/MapTextFeature.kt
2022-08-29 22:23:24 +03:00
9ffc9d3d67 Add geodetic distance measurement. 2022-08-29 22:12:03 +03:00
e0cc0bc60b Tests for curve calculation 2022-08-29 18:37:42 +03:00
4614b1f7bc Type safe angles and distances 2022-08-29 13:38:46 +03:00
Alexander Nozik
6459246eb5 Merge pull request #16 from SciProgCentre/connect_timeout_exception_handling
Connect timeout exception handling
2022-08-26 14:23:51 +03:00
3467a6dbe0 Refactor error management 2022-08-26 14:22:17 +03:00
a.kalmakhanov
b110ab2b4c Merge remote-tracking branch 'origin/main' 2022-08-21 22:37:13 +06:00
a.kalmakhanov
4b512d0967 ConnectTimeoutException handling enabled
TextFeature multiplatform capability enabled
2022-08-21 22:36:56 +06:00
11b278fc81 Frodo goes to Mordor (directly) 2022-08-18 18:47:06 +03:00
26 changed files with 1145 additions and 339 deletions

View File

@@ -1,4 +0,0 @@
import kotlin.math.PI
fun Double.toDegrees() = this * 180 / PI

View File

@@ -7,6 +7,8 @@ import androidx.compose.runtime.*
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.PointMode
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application
import center.sciprog.maps.compose.*
@@ -23,7 +25,7 @@ import kotlin.math.PI
import kotlin.random.Random
private fun GeodeticMapCoordinates.toShortString(): String =
"${(latitude * 180.0 / PI).toString().take(6)}:${(longitude * 180.0 / PI).toString().take(6)}"
"${(latitude.degrees.value).toString().take(6)}:${(longitude.degrees.value).toString().take(6)}"
@Composable
@@ -50,28 +52,24 @@ fun App() {
val pointOne = 55.568548 to 37.568604
var pointTwo by remember { mutableStateOf(55.929444 to 37.518434) }
val pointTwo = 55.929444 to 37.518434
val pointThree = 60.929444 to 37.518434
val dragPoint = 55.744 to 37.614
MapView(
mapTileProvider = mapTileProvider,
initialViewPoint = viewPoint,
config = MapViewConfig(
inferViewBoxFromFeatures = true,
onViewChange = { centerCoordinates = focus },
onDrag = { start, end ->
if (start.focus.latitude.toDegrees() in (pointTwo.first - 0.05)..(pointTwo.first + 0.05) &&
start.focus.longitude.toDegrees() in (pointTwo.second - 0.05)..(pointTwo.second + 0.05)
) {
pointTwo = pointTwo.first + (end.focus.latitude - start.focus.latitude).toDegrees() to
pointTwo.second + (end.focus.longitude - start.focus.longitude).toDegrees()
false// returning false, because when we are dragging circle we don't want to drag map
} else true
}
)
) {
image(pointOne, Icons.Filled.Home)
rectangle(dragPoint, id = "dragMe", size = DpSize(10.dp, 10.dp)).draggable()
points(
points = listOf(
55.742465 to 37.615812,
@@ -84,7 +82,7 @@ fun App() {
pointMode = PointMode.Polygon
)
//remember feature Id
//remember feature ID
val circleId: FeatureId = circle(
centerCoordinates = pointTwo,
)
@@ -94,6 +92,8 @@ fun App() {
drawLine(start = Offset(-10f, 10f), end = Offset(10f, -10f), color = Color.Red)
}
circle(pointThree, layer = -1, color = Color.Blue)
arc(pointOne, Distance(10.0), 0f, PI)
line(pointOne, pointTwo, id = "line")

View File

@@ -3,11 +3,15 @@ import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application
import center.sciprog.maps.scheme.*
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
@Composable
@Preview
@@ -20,6 +24,7 @@ fun App() {
1f
)
}
val scope = rememberCoroutineScope()
SchemeView(
@@ -33,9 +38,24 @@ fun App() {
) {
background(painterResource("middle-earth.jpg"))
circle(410.52737 to 868.7676, color = Color.Blue)
text(410.52737 to 868.7676,"Shire", color = Color.Blue)
text(410.52737 to 868.7676, "Shire", color = Color.Blue)
circle(1132.0881 to 394.99127, color = Color.Red)
text(1132.0881 to 394.99127, "Ordruin",color = Color.Red)
text(1132.0881 to 394.99127, "Ordruin", color = Color.Red)
val hobbitId = circle(410.52737 to 868.7676)
scope.launch {
var t = 0.0
while (isActive) {
val x = 410.52737 + t * (1132.0881 - 410.52737)
val y = 868.7676 + t * (394.99127 - 868.7676)
circle(x to y, color = Color.Green, id = hobbitId)
delay(100)
t += 0.005
if (t >= 1.0) t = 0.0
}
}
}
}
}

View File

@@ -11,16 +11,20 @@ import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import center.sciprog.maps.coordinates.GeodeticMapCoordinates
import center.sciprog.maps.coordinates.GmcBox
import center.sciprog.maps.coordinates.wrapAll
import center.sciprog.maps.coordinates.*
import kotlin.math.floor
public interface MapFeature {
public val zoomRange: IntRange
public fun getBoundingBox(zoom: Int): GmcBox?
public fun getBoundingBox(zoom: Double): GmcRectangle?
public val layer: Int
}
public fun Iterable<MapFeature>.computeBoundingBox(zoom: Int): GmcBox? =
public interface DraggableMapFeature : MapFeature {
public fun withCoordinates(newCoordinates: GeodeticMapCoordinates): MapFeature
}
public fun Iterable<MapFeature>.computeBoundingBox(zoom: Double): GmcRectangle? =
mapNotNull { it.getBoundingBox(zoom) }.wrapAll()
internal fun Pair<Double, Double>.toCoordinates() = GeodeticMapCoordinates.ofDegrees(first, second)
@@ -32,21 +36,26 @@ internal val defaultZoomRange = 1..18
*/
public class MapFeatureSelector(
public val selector: (zoom: Int) -> MapFeature,
override val layer: Int
) : MapFeature {
override val zoomRange: IntRange get() = defaultZoomRange
override fun getBoundingBox(zoom: Int): GmcBox? = selector(zoom).getBoundingBox(zoom)
override fun getBoundingBox(zoom: Double): GmcRectangle? = selector(floor(zoom).toInt()).getBoundingBox(zoom)
}
public class MapDrawFeature(
public val position: GeodeticMapCoordinates,
override val zoomRange: IntRange = defaultZoomRange,
public val drawFeature: DrawScope.() -> Unit,
) : MapFeature {
override fun getBoundingBox(zoom: Int): GmcBox {
override val layer: Int
) : DraggableMapFeature {
override fun getBoundingBox(zoom: Double): GmcRectangle {
//TODO add box computation
return GmcBox(position, position)
return GmcRectangle(position, position)
}
override fun withCoordinates(newCoordinates: GeodeticMapCoordinates): MapFeature =
MapDrawFeature(newCoordinates, zoomRange, drawFeature, layer)
}
public class MapPointsFeature(
@@ -54,10 +63,11 @@ public class MapPointsFeature(
override val zoomRange: IntRange = defaultZoomRange,
public val stroke: Float = 2f,
public val color: Color = Color.Red,
public val pointMode: PointMode = PointMode.Points
public val pointMode: PointMode = PointMode.Points,
override val layer: Int
) : MapFeature {
override fun getBoundingBox(zoom: Int): GmcBox {
return GmcBox(points.first(), points.last())
override fun getBoundingBox(zoom: Double): GmcRectangle {
return GmcRectangle(points.first(), points.last())
}
}
@@ -66,8 +76,15 @@ public class MapCircleFeature(
override val zoomRange: IntRange = defaultZoomRange,
public val size: Float = 5f,
public val color: Color = Color.Red,
) : MapFeature {
override fun getBoundingBox(zoom: Int): GmcBox = GmcBox(center, center)
override val layer: Int
) : DraggableMapFeature {
override fun getBoundingBox(zoom: Double): GmcRectangle {
val scale = WebMercatorProjection.scaleFactor(zoom)
return GmcRectangle.square(center, (size / scale).radians, (size / scale).radians)
}
override fun withCoordinates(newCoordinates: GeodeticMapCoordinates): MapFeature =
MapCircleFeature(newCoordinates, zoomRange, size, color, layer)
}
public class MapRectangleFeature(
@@ -75,8 +92,15 @@ public class MapRectangleFeature(
override val zoomRange: IntRange = defaultZoomRange,
public val size: DpSize = DpSize(5.dp, 5.dp),
public val color: Color = Color.Red,
) : MapFeature {
override fun getBoundingBox(zoom: Int): GmcBox = GmcBox(center, center)
override val layer: Int
) : DraggableMapFeature {
override fun getBoundingBox(zoom: Double): GmcRectangle {
val scale = WebMercatorProjection.scaleFactor(zoom)
return GmcRectangle.square(center, (size.height.value / scale).radians, (size.width.value / scale).radians)
}
override fun withCoordinates(newCoordinates: GeodeticMapCoordinates): MapFeature =
MapRectangleFeature(newCoordinates, zoomRange, size, color, layer)
}
public class MapLineFeature(
@@ -84,18 +108,20 @@ public class MapLineFeature(
public val b: GeodeticMapCoordinates,
override val zoomRange: IntRange = defaultZoomRange,
public val color: Color = Color.Red,
override val layer: Int
) : MapFeature {
override fun getBoundingBox(zoom: Int): GmcBox = GmcBox(a, b)
override fun getBoundingBox(zoom: Double): GmcRectangle = GmcRectangle(a, b)
}
public class MapArcFeature(
public val oval: GmcBox,
public val oval: GmcRectangle,
public val startAngle: Float,
public val endAngle: Float,
override val zoomRange: IntRange = defaultZoomRange,
public val color: Color = Color.Red,
override val layer: Int
) : MapFeature {
override fun getBoundingBox(zoom: Int): GmcBox = oval
override fun getBoundingBox(zoom: Double): GmcRectangle = oval
}
public class MapBitmapImageFeature(
@@ -103,8 +129,12 @@ public class MapBitmapImageFeature(
public val image: ImageBitmap,
public val size: IntSize = IntSize(15, 15),
override val zoomRange: IntRange = defaultZoomRange,
) : MapFeature {
override fun getBoundingBox(zoom: Int): GmcBox = GmcBox(position, position)
override val layer: Int
) : DraggableMapFeature {
override fun getBoundingBox(zoom: Double): GmcRectangle = GmcRectangle(position, position)
override fun withCoordinates(newCoordinates: GeodeticMapCoordinates): MapFeature =
MapBitmapImageFeature(newCoordinates, image, size, zoomRange, layer)
}
public class MapVectorImageFeature(
@@ -112,8 +142,12 @@ public class MapVectorImageFeature(
public val painter: Painter,
public val size: DpSize,
override val zoomRange: IntRange = defaultZoomRange,
) : MapFeature {
override fun getBoundingBox(zoom: Int): GmcBox = GmcBox(position, position)
override val layer: Int
) : DraggableMapFeature {
override fun getBoundingBox(zoom: Double): GmcRectangle = GmcRectangle(position, position)
override fun withCoordinates(newCoordinates: GeodeticMapCoordinates): MapFeature =
MapVectorImageFeature(newCoordinates, painter, size, zoomRange, layer)
}
@Composable
@@ -122,7 +156,8 @@ public fun MapVectorImageFeature(
image: ImageVector,
size: DpSize = DpSize(20.dp, 20.dp),
zoomRange: IntRange = defaultZoomRange,
): MapVectorImageFeature = MapVectorImageFeature(position, rememberVectorPainter(image), size, zoomRange)
layer: Int
): MapVectorImageFeature = MapVectorImageFeature(position, rememberVectorPainter(image), size, zoomRange, layer)
/**
* A group of other features
@@ -130,6 +165,22 @@ public fun MapVectorImageFeature(
public class MapFeatureGroup(
public val children: Map<FeatureId, MapFeature>,
override val zoomRange: IntRange = defaultZoomRange,
override val layer: Int
) : MapFeature {
override fun getBoundingBox(zoom: Int): GmcBox? = children.values.mapNotNull { it.getBoundingBox(zoom) }.wrapAll()
}
override fun getBoundingBox(zoom: Double): GmcRectangle? =
children.values.mapNotNull { it.getBoundingBox(zoom) }.wrapAll()
}
public class MapTextFeature(
public val position: GeodeticMapCoordinates,
public val text: String,
override val zoomRange: IntRange = defaultZoomRange,
public val color: Color,
public val fontConfig: MapTextFeatureFont.() -> Unit,
override val layer: Int
) : DraggableMapFeature {
override fun getBoundingBox(zoom: Double): GmcRectangle = GmcRectangle(position, position)
override fun withCoordinates(newCoordinates: GeodeticMapCoordinates): MapFeature =
MapTextFeature(newCoordinates, text, zoomRange, color, fontConfig, layer)
}

View File

@@ -11,31 +11,57 @@ import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import center.sciprog.maps.coordinates.Distance
import center.sciprog.maps.coordinates.GeodeticMapCoordinates
import center.sciprog.maps.coordinates.GmcBox
import center.sciprog.maps.coordinates.GmcRectangle
public typealias FeatureId = String
public interface MapFeatureAttributeKey<T>
public class MapFeatureAttributeSet(private val map: Map<MapFeatureAttributeKey<*>, *>) {
public operator fun <T> get(key: MapFeatureAttributeKey<*>): T? = map[key]?.let {
@Suppress("UNCHECKED_CAST")
it as T
}
}
public interface MapFeatureBuilder {
public fun addFeature(id: FeatureId?, feature: MapFeature): FeatureId
public fun build(): SnapshotStateMap<FeatureId, MapFeature>
public fun <T> setAttribute(id: FeatureId, key: MapFeatureAttributeKey<T>, value: T)
public val features: MutableMap<FeatureId, MapFeature>
public fun attributes(): Map<FeatureId, MapFeatureAttributeSet>
//TODO use context receiver for that
public fun FeatureId.draggable(enabled: Boolean = true) {
setAttribute(this, DraggableAttribute, enabled)
}
}
internal class MapFeatureBuilderImpl(initialFeatures: Map<FeatureId, MapFeature>) : MapFeatureBuilder {
internal class MapFeatureBuilderImpl(
override val features: SnapshotStateMap<FeatureId, MapFeature>,
) : MapFeatureBuilder {
private val attributes = SnapshotStateMap<FeatureId, SnapshotStateMap<MapFeatureAttributeKey<out Any?>, in Any?>>()
private val content: SnapshotStateMap<FeatureId, MapFeature> = mutableStateMapOf<FeatureId, MapFeature>().apply {
putAll(initialFeatures)
}
private fun generateID(feature: MapFeature): FeatureId = "@feature[${feature.hashCode().toUInt()}]"
override fun addFeature(id: FeatureId?, feature: MapFeature): FeatureId {
val safeId = id ?: generateID(feature)
content[id ?: generateID(feature)] = feature
features[id ?: generateID(feature)] = feature
return safeId
}
override fun build(): SnapshotStateMap<FeatureId, MapFeature> = content
override fun <T> setAttribute(id: FeatureId, key: MapFeatureAttributeKey<T>, value: T) {
attributes.getOrPut(id) { SnapshotStateMap() }[key] = value
}
override fun attributes(): Map<FeatureId, MapFeatureAttributeSet> =
attributes.mapValues { MapFeatureAttributeSet(it.value) }
}
public fun MapFeatureBuilder.circle(
@@ -44,8 +70,9 @@ public fun MapFeatureBuilder.circle(
size: Float = 5f,
color: Color = Color.Red,
id: FeatureId? = null,
layer: Int = 0
): FeatureId = addFeature(
id, MapCircleFeature(center, zoomRange, size, color)
id, MapCircleFeature(center, zoomRange, size, color, layer)
)
public fun MapFeatureBuilder.circle(
@@ -54,8 +81,9 @@ public fun MapFeatureBuilder.circle(
size: Float = 5f,
color: Color = Color.Red,
id: FeatureId? = null,
layer: Int = 0
): FeatureId = addFeature(
id, MapCircleFeature(centerCoordinates.toCoordinates(), zoomRange, size, color)
id, MapCircleFeature(centerCoordinates.toCoordinates(), zoomRange, size, color, layer)
)
public fun MapFeatureBuilder.rectangle(
@@ -64,16 +92,18 @@ public fun MapFeatureBuilder.rectangle(
size: DpSize = DpSize(5.dp, 5.dp),
color: Color = Color.Red,
id: FeatureId? = null,
layer: Int = 0
): FeatureId = addFeature(
id, MapRectangleFeature(centerCoordinates.toCoordinates(), zoomRange, size, color)
id, MapRectangleFeature(centerCoordinates.toCoordinates(), zoomRange, size, color, layer)
)
public fun MapFeatureBuilder.draw(
position: Pair<Double, Double>,
zoomRange: IntRange = defaultZoomRange,
id: FeatureId? = null,
drawFeature: DrawScope.() -> Unit,
): FeatureId = addFeature(id, MapDrawFeature(position.toCoordinates(), zoomRange, drawFeature))
layer: Int = 0,
drawFeature: DrawScope.() -> Unit
): FeatureId = addFeature(id, MapDrawFeature(position.toCoordinates(), zoomRange, drawFeature, layer))
public fun MapFeatureBuilder.line(
aCoordinates: Pair<Double, Double>,
@@ -81,21 +111,23 @@ public fun MapFeatureBuilder.line(
zoomRange: IntRange = defaultZoomRange,
color: Color = Color.Red,
id: FeatureId? = null,
layer: Int = 0
): FeatureId = addFeature(
id,
MapLineFeature(aCoordinates.toCoordinates(), bCoordinates.toCoordinates(), zoomRange, color)
MapLineFeature(aCoordinates.toCoordinates(), bCoordinates.toCoordinates(), zoomRange, color, layer)
)
public fun MapFeatureBuilder.arc(
oval: GmcBox,
oval: GmcRectangle,
startAngle: Number,
endAngle: Number,
zoomRange: IntRange = defaultZoomRange,
color: Color = Color.Red,
id: FeatureId? = null,
layer: Int = 0
): FeatureId = addFeature(
id,
MapArcFeature(oval, startAngle.toFloat(), endAngle.toFloat(), zoomRange, color)
MapArcFeature(oval, startAngle.toFloat(), endAngle.toFloat(), zoomRange, color, layer)
)
public fun MapFeatureBuilder.arc(
@@ -106,14 +138,16 @@ public fun MapFeatureBuilder.arc(
zoomRange: IntRange = defaultZoomRange,
color: Color = Color.Red,
id: FeatureId? = null,
layer: Int = 0
): FeatureId = addFeature(
id,
MapArcFeature(
GmcBox.withCenter(center.toCoordinates(), radius, radius),
GmcRectangle.square(center.toCoordinates(), radius, radius),
startAngle.toFloat(),
endAngle.toFloat(),
zoomRange,
color
color,
layer
)
)
@@ -123,8 +157,10 @@ public fun MapFeatureBuilder.points(
stroke: Float = 2f,
color: Color = Color.Red,
pointMode: PointMode = PointMode.Points,
id: FeatureId? = null
): FeatureId = addFeature(id, MapPointsFeature(points.map { it.toCoordinates() }, zoomRange, stroke, color, pointMode))
id: FeatureId? = null,
layer: Int = 0
): FeatureId =
addFeature(id, MapPointsFeature(points.map { it.toCoordinates() }, zoomRange, stroke, color, pointMode, layer))
@Composable
public fun MapFeatureBuilder.image(
@@ -133,14 +169,36 @@ public fun MapFeatureBuilder.image(
size: DpSize = DpSize(20.dp, 20.dp),
zoomRange: IntRange = defaultZoomRange,
id: FeatureId? = null,
): FeatureId = addFeature(id, MapVectorImageFeature(position.toCoordinates(), image, size, zoomRange))
layer: Int = 0
): FeatureId = addFeature(id, MapVectorImageFeature(position.toCoordinates(), image, size, zoomRange, layer))
public fun MapFeatureBuilder.group(
zoomRange: IntRange = defaultZoomRange,
id: FeatureId? = null,
builder: MapFeatureBuilder.() -> Unit,
layer: Int = 0,
builder: MapFeatureBuilder.() -> Unit
): FeatureId {
val map = MapFeatureBuilderImpl(emptyMap()).apply(builder).build()
val feature = MapFeatureGroup(map, zoomRange)
val map = MapFeatureBuilderImpl(mutableStateMapOf()).apply(builder).features
val feature = MapFeatureGroup(map, zoomRange, layer)
return addFeature(id, feature)
}
}
public fun MapFeatureBuilder.text(
position: GeodeticMapCoordinates,
text: String,
zoomRange: IntRange = defaultZoomRange,
color: Color = Color.Red,
font: MapTextFeatureFont.() -> Unit = { size = 16f },
id: FeatureId? = null,
layer: Int = 0
): FeatureId = addFeature(id, MapTextFeature(position, text, zoomRange, color, font, layer))
public fun MapFeatureBuilder.text(
position: Pair<Double, Double>,
text: String,
zoomRange: IntRange = defaultZoomRange,
color: Color = Color.Red,
font: MapTextFeatureFont.() -> Unit = { size = 16f },
id: FeatureId? = null,
layer: Int = 0
): FeatureId = addFeature(id, MapTextFeature(position.toCoordinates(), text, zoomRange, color, font, layer))

View File

@@ -0,0 +1,5 @@
package center.sciprog.maps.compose
public expect class MapTextFeatureFont {
public var size: Float
}

View File

@@ -1,8 +1,10 @@
package center.sciprog.maps.compose
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.PointerEvent
import androidx.compose.ui.input.pointer.isPrimaryPressed
import androidx.compose.ui.unit.DpSize
import center.sciprog.maps.coordinates.*
import kotlin.math.PI
@@ -10,20 +12,56 @@ import kotlin.math.log2
import kotlin.math.min
public fun interface DragHandle {
/**
* @param event - qualifiers of the event used for drag
* @param start - is a point where drag begins, end is a point where drag ends
* @param end - end point of the drag
*
* @return true if default event processors should be used after this one
*/
public fun handle(event: PointerEvent, start: MapViewPoint, end: MapViewPoint): Boolean
public companion object {
public val BYPASS: DragHandle = DragHandle { _, _, _ -> true }
/**
* Process only events with primary button pressed
*/
public fun withPrimaryButton(
block: (event: PointerEvent, start: MapViewPoint, end: MapViewPoint) -> Boolean,
): DragHandle = DragHandle { event, start, end ->
if (event.buttons.isPrimaryPressed) {
block(event, start, end)
} else {
false
}
}
/**
* Combine several handles into one
*/
public fun combine(vararg handles: DragHandle): DragHandle = DragHandle { event, start, end ->
handles.forEach {
if (!it.handle(event, start, end)) return@DragHandle false
}
return@DragHandle true
}
}
}
//TODO consider replacing by modifier
/**
* @param onDrag - returns true if you want to drag a map and false, if you want to make map stationary.
* start - is a point where drag begins, end is a point where drag ends
*/
public data class MapViewConfig(
val zoomSpeed: Double = 1.0 / 3.0,
val inferViewBoxFromFeatures: Boolean = false,
val onClick: MapViewPoint.() -> Unit = {},
val onDrag: (start: MapViewPoint, end: MapViewPoint) -> Boolean = { _, _ -> true },
val onClick: MapViewPoint.(PointerEvent) -> Unit = {},
val dragHandle: DragHandle = DragHandle.BYPASS,
val onViewChange: MapViewPoint.() -> Unit = {},
val onSelect: (GmcBox) -> Unit = {},
val onSelect: (GmcRectangle) -> Unit = {},
val zoomOnSelect: Boolean = true,
val resetViewPoint: Boolean = false
val resetViewPoint: Boolean = false,
)
@Composable
@@ -35,52 +73,83 @@ public expect fun MapView(
modifier: Modifier = Modifier.fillMaxSize(),
)
private fun prepareConfig(initialConfig: MapViewConfig, featureBuilder: MapFeatureBuilder): MapViewConfig {
val draggableFeatureIds: Set<FeatureId> = featureBuilder.attributes().filterValues {
it[DraggableAttribute] ?: false
}.keys
val features = featureBuilder.features
val featureDrag = DragHandle.withPrimaryButton { _, start, end ->
val zoom = start.zoom
draggableFeatureIds.forEach { id ->
val feature = features[id] as? DraggableMapFeature ?: return@forEach
//val border = WebMercatorProjection.scaleFactor(zoom)
val boundingBox = feature.getBoundingBox(zoom) ?: return@forEach
if (start.focus in boundingBox) {
features[id] = feature.withCoordinates(end.focus)
return@withPrimaryButton false
}
}
return@withPrimaryButton true
}
return initialConfig.copy(
dragHandle = DragHandle.combine(featureDrag, initialConfig.dragHandle),
)
}
@Composable
public fun MapView(
mapTileProvider: MapTileProvider,
initialViewPoint: MapViewPoint,
features: Map<FeatureId, MapFeature> = emptyMap(),
config: MapViewConfig = MapViewConfig(),
modifier: Modifier = Modifier.fillMaxSize(),
buildFeatures: @Composable (MapFeatureBuilder.() -> Unit) = {},
) {
val featuresBuilder = MapFeatureBuilderImpl(features)
val featuresBuilder = MapFeatureBuilderImpl(mutableStateMapOf())
featuresBuilder.buildFeatures()
val features = remember { featuresBuilder.features }
val newConfig = remember(features) {
prepareConfig(config, featuresBuilder)
}
MapView(
mapTileProvider,
{ initialViewPoint },
featuresBuilder.build(),
config,
features,
newConfig,
modifier
)
}
internal fun GmcBox.computeViewPoint(mapTileProvider: MapTileProvider): (canvasSize: DpSize) -> MapViewPoint = { canvasSize ->
internal fun GmcRectangle.computeViewPoint(
mapTileProvider: MapTileProvider,
): (canvasSize: DpSize) -> MapViewPoint = { canvasSize ->
val zoom = log2(
min(
canvasSize.width.value / width,
canvasSize.height.value / height
canvasSize.width.value / longitudeDelta.radians.value,
canvasSize.height.value / latitudeDelta.radians.value
) * PI / mapTileProvider.tileSize
)
MapViewPoint(center, zoom)
}
@Composable
public fun MapView(
mapTileProvider: MapTileProvider,
box: GmcBox,
features: Map<FeatureId, MapFeature> = emptyMap(),
config: MapViewConfig = MapViewConfig(),
modifier: Modifier = Modifier.fillMaxSize(),
buildFeatures: @Composable (MapFeatureBuilder.() -> Unit) = {},
) {
val featuresBuilder = MapFeatureBuilderImpl(features)
featuresBuilder.buildFeatures()
MapView(
mapTileProvider,
box.computeViewPoint(mapTileProvider),
featuresBuilder.build(),
config,
modifier
)
}
//
//@Composable
//public fun MapView(
// mapTileProvider: MapTileProvider,
// box: GmcRectangle,
// config: MapViewConfig = MapViewConfig(),
// modifier: Modifier = Modifier.fillMaxSize(),
// buildFeatures: @Composable (MapFeatureBuilder.() -> Unit) = {},
//) {
// val builder by derivedStateOf { MapFeatureBuilderImpl().apply(buildFeatures) }
//
// MapView(
// mapTileProvider,
// box.computeViewPoint(mapTileProvider),
// builder.features,
// prepareConfig(config, builder),
// modifier
// )
//}

View File

@@ -0,0 +1,3 @@
package center.sciprog.maps.compose
public object DraggableAttribute: MapFeatureAttributeKey<Boolean>

View File

@@ -1,35 +0,0 @@
package center.sciprog.maps.compose
import androidx.compose.ui.graphics.Color
import center.sciprog.maps.coordinates.GeodeticMapCoordinates
import center.sciprog.maps.coordinates.GmcBox
import org.jetbrains.skia.Font
public class MapTextFeature(
public val position: GeodeticMapCoordinates,
public val text: String,
override val zoomRange: IntRange = defaultZoomRange,
public val color: Color,
public val fontConfig: Font.() -> Unit,
) : MapFeature {
override fun getBoundingBox(zoom: Int): GmcBox = GmcBox(position, position)
}
public fun MapFeatureBuilder.text(
position: GeodeticMapCoordinates,
text: String,
zoomRange: IntRange = defaultZoomRange,
color: Color = Color.Red,
font: Font.() -> Unit = { size = 16f },
id: FeatureId? = null,
): FeatureId = addFeature(id, MapTextFeature(position, text, zoomRange, color, font))
public fun MapFeatureBuilder.text(
position: Pair<Double, Double>,
text: String,
zoomRange: IntRange = defaultZoomRange,
color: Color = Color.Red,
font: Font.() -> Unit = { size = 16f },
id: FeatureId? = null,
): FeatureId = addFeature(id, MapTextFeature(position.toCoordinates(), text, zoomRange, color, font))

View File

@@ -0,0 +1,5 @@
package center.sciprog.maps.compose
import org.jetbrains.skia.Font
public actual typealias MapTextFeatureFont = Font

View File

@@ -5,6 +5,7 @@ import androidx.compose.foundation.gestures.drag
import androidx.compose.foundation.gestures.forEachGesture
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.*
import androidx.compose.runtime.snapshots.SnapshotStateMap
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
@@ -30,12 +31,12 @@ private fun Color.toPaint(): Paint = Paint().apply {
private fun IntRange.intersect(other: IntRange) = max(first, other.first)..min(last, other.last)
internal fun MapViewPoint.move(deltaX: Double, deltaY: Double): MapViewPoint {
val newCoordinates = GeodeticMapCoordinates.ofRadians(
(focus.latitude + deltaY / scaleFactor).coerceIn(
val newCoordinates = GeodeticMapCoordinates(
(focus.latitude + (deltaY / scaleFactor).radians).coerceIn(
-MercatorProjection.MAXIMUM_LATITUDE,
MercatorProjection.MAXIMUM_LATITUDE
),
focus.longitude + deltaX / scaleFactor
focus.longitude + (deltaX / scaleFactor).radians
)
return MapViewPoint(newCoordinates, zoom)
}
@@ -66,11 +67,11 @@ public actual fun MapView(
val viewPoint: MapViewPoint by derivedStateOf {
viewPointInternal ?: if (config.inferViewBoxFromFeatures) {
features.values.computeBoundingBox(1)?.let { box ->
features.values.computeBoundingBox(1.0)?.let { box ->
val zoom = log2(
min(
canvasSize.width.value / box.width,
canvasSize.height.value / box.height
canvasSize.width.value / box.longitudeDelta.radians.value,
canvasSize.height.value / box.latitudeDelta.radians.value
) * PI / mapTileProvider.tileSize
)
MapViewPoint(box.center, zoom)
@@ -80,9 +81,14 @@ public actual fun MapView(
}
}
val zoom: Int by derivedStateOf { floor(viewPoint.zoom).toInt() }
val zoom: Int by derivedStateOf {
require(viewPoint.zoom in 1.0..18.0) { "Zoom value of ${viewPoint.zoom} is not valid" }
floor(viewPoint.zoom).toInt()
}
val tileScale: Double by derivedStateOf { 2.0.pow(viewPoint.zoom - zoom) }
val tileScale: Double by derivedStateOf {
2.0.pow(viewPoint.zoom - zoom)
}
val mapTiles = remember { mutableStateListOf<MapTile>() }
@@ -109,59 +115,76 @@ public actual fun MapView(
fun Offset.toDpOffset() = DpOffset(x.toDp(), y.toDp())
val event: PointerEvent = awaitPointerEvent()
event.changes.forEach { change ->
if (event.buttons.isPrimaryPressed) {
//Evaluating selection frame
if (event.keyboardModifiers.isShiftPressed) {
selectRect = Rect(change.position, change.position)
drag(change.id) { dragChange ->
selectRect?.let { rect ->
val offset = dragChange.position
selectRect = Rect(
min(offset.x, rect.left),
min(offset.y, rect.top),
max(offset.x, rect.right),
max(offset.y, rect.bottom)
)
}
}
selectRect?.let { rect ->
//Use selection override if it is defined
val gmcBox = GmcBox(
rect.topLeft.toDpOffset().toGeodetic(),
rect.bottomRight.toDpOffset().toGeodetic()
)
config.onSelect(gmcBox)
if (config.zoomOnSelect) {
val newViewPoint = gmcBox.computeViewPoint(mapTileProvider).invoke(canvasSize)
config.onViewChange(newViewPoint)
viewPointInternal = newViewPoint
}
selectRect = null
}
} else {
val dragStart = change.position
val dpPos = DpOffset(dragStart.x.toDp(), dragStart.y.toDp())
config.onClick(MapViewPoint(dpPos.toGeodetic(), viewPoint.zoom))
drag(change.id) { dragChange ->
val dragAmount = dragChange.position - dragChange.previousPosition
val dpStart =
DpOffset(dragChange.previousPosition.x.toDp(), dragChange.previousPosition.y.toDp())
val dpEnd = DpOffset(dragChange.position.x.toDp(), dragChange.position.y.toDp())
if (!config.onDrag(
MapViewPoint(dpStart.toGeodetic(), viewPoint.zoom),
MapViewPoint(dpEnd.toGeodetic(), viewPoint.zoom)
)
) return@drag
val newViewPoint = viewPoint.move(
-dragAmount.x.toDp().value / tileScale,
+dragAmount.y.toDp().value / tileScale
)
config.onViewChange(newViewPoint)
viewPointInternal = newViewPoint
}
event.changes.forEach { change ->
val dragStart = change.position
val dpPos = DpOffset(dragStart.x.toDp(), dragStart.y.toDp())
//start selection
if (event.buttons.isPrimaryPressed && event.keyboardModifiers.isShiftPressed) {
selectRect = Rect(change.position, change.position)
}
drag(change.id) { dragChange ->
val dragAmount = dragChange.position - dragChange.previousPosition
val dpStart = DpOffset(
dragChange.previousPosition.x.toDp(),
dragChange.previousPosition.y.toDp()
)
val dpEnd = DpOffset(dragChange.position.x.toDp(), dragChange.position.y.toDp())
//apply drag handle and check if it prohibits the drag even propagation
if (
!config.dragHandle.handle(
event,
MapViewPoint(dpStart.toGeodetic(), viewPoint.zoom),
MapViewPoint(dpEnd.toGeodetic(), viewPoint.zoom)
)
) {
//clear selection just in case
selectRect = null
return@drag
}
if (event.buttons.isPrimaryPressed) {
//Evaluating selection frame
selectRect?.let { rect ->
val offset = dragChange.position
selectRect = Rect(
min(offset.x, rect.left),
min(offset.y, rect.top),
max(offset.x, rect.right),
max(offset.y, rect.bottom)
)
return@drag
}
config.onClick(MapViewPoint(dpPos.toGeodetic(), viewPoint.zoom), event)
val newViewPoint = viewPoint.move(
-dragAmount.x.toDp().value / tileScale,
+dragAmount.y.toDp().value / tileScale
)
config.onViewChange(newViewPoint)
viewPointInternal = newViewPoint
}
}
// evaluate selection
selectRect?.let { rect ->
//Use selection override if it is defined
val gmcBox = GmcRectangle(
rect.topLeft.toDpOffset().toGeodetic(),
rect.bottomRight.toDpOffset().toGeodetic()
)
config.onSelect(gmcBox)
if (config.zoomOnSelect) {
val newViewPoint = gmcBox.computeViewPoint(mapTileProvider).invoke(canvasSize)
config.onViewChange(newViewPoint)
viewPointInternal = newViewPoint
}
selectRect = null
}
}
}
@@ -195,20 +218,21 @@ public actual fun MapView(
for (j in verticalIndices) {
for (i in horizontalIndices) {
val id = TileId(zoom, i, j)
//start all
val deferred = loadTileAsync(id)
//wait asynchronously for it to finish
launch {
try {
try {
//start all
val deferred = loadTileAsync(id)
//wait asynchronously for it to finish
launch {
mapTiles += deferred.await()
} catch (ex: Exception) {
if (ex !is CancellationException) {
//displaying the error is maps responsibility
logger.error(ex) { "Failed to load tile with id=$id" }
}
}
} catch (ex: Exception) {
if (ex !is CancellationException) {
//displaying the error is maps responsibility
logger.error(ex) { "Failed to load tile with id=$id" }
}
}
}
}
}
}
@@ -232,6 +256,7 @@ public actual fun MapView(
feature.size,
center = feature.center.toOffset()
)
is MapRectangleFeature -> drawRect(
feature.color,
topLeft = feature.center.toOffset() - Offset(
@@ -240,6 +265,7 @@ public actual fun MapView(
),
size = feature.size.toSize()
)
is MapLineFeature -> drawLine(feature.color, feature.a.toOffset(), feature.b.toOffset())
is MapArcFeature -> {
val topLeft = feature.oval.topLeft.toOffset()
@@ -252,6 +278,7 @@ public actual fun MapView(
drawPath(path, color = feature.color, style = Stroke())
}
is MapBitmapImageFeature -> drawImage(feature.image, feature.position.toOffset())
is MapVectorImageFeature -> {
val offset = feature.position.toOffset()
@@ -262,6 +289,7 @@ public actual fun MapView(
}
}
}
is MapTextFeature -> drawIntoCanvas { canvas ->
val offset = feature.position.toOffset()
canvas.nativeCanvas.drawString(
@@ -272,17 +300,20 @@ public actual fun MapView(
feature.color.toPaint()
)
}
is MapDrawFeature -> {
val offset = feature.position.toOffset()
translate(offset.x, offset.y) {
feature.drawFeature(this)
}
}
is MapFeatureGroup -> {
feature.children.values.forEach {
drawFeature(zoom, it)
}
}
is MapPointsFeature -> {
val points = feature.points.map { it.toOffset() }
drawPoints(
@@ -292,6 +323,7 @@ public actual fun MapView(
pointMode = feature.pointMode
)
}
else -> {
logger.error { "Unrecognized feature type: ${feature::class}" }
}
@@ -319,7 +351,7 @@ public actual fun MapView(
dstSize = tileSize
)
}
features.values.filter { zoom in it.zoomRange }.forEach { feature ->
features.values.filter { zoom in it.zoomRange }.sortedBy { it.layer }.forEach { feature ->
drawFeature(zoom, feature)
}
}

View File

@@ -24,7 +24,7 @@ import kotlin.io.path.*
public class OpenStreetMapTileProvider(
private val client: HttpClient,
private val cacheDirectory: Path,
parallelism: Int = 1,
parallelism: Int = 4,
cacheCapacity: Int = 200,
) : MapTileProvider {
private val semaphore = Semaphore(parallelism)
@@ -37,7 +37,7 @@ public class OpenStreetMapTileProvider(
/**
* Download and cache the tile image
*/
private fun CoroutineScope.downloadImageAsync(id: TileId) = async(Dispatchers.IO) {
private fun CoroutineScope.downloadImageAsync(id: TileId): Deferred<ImageBitmap> = async(Dispatchers.IO) {
id.cacheFilePath()?.let { path ->
if (path.exists()) {
@@ -54,9 +54,7 @@ public class OpenStreetMapTileProvider(
semaphore.withPermit {
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" }
@@ -79,11 +77,11 @@ public class OpenStreetMapTileProvider(
//collect the result asynchronously
return async {
val image = try {
val image: ImageBitmap = try {
imageDeferred.await()
} catch (ex: Exception) {
cache.remove(tileId)
if(ex !is CancellationException) {
if (ex !is CancellationException) {
logger.error(ex) { "Failed to load tile image with id=$tileId" }
}
throw ex
@@ -96,4 +94,4 @@ public class OpenStreetMapTileProvider(
public companion object {
private val logger = KotlinLogging.logger("OpenStreetMapCache")
}
}
}

View File

@@ -15,4 +15,12 @@ kotlin {
js(IR) {
browser()
}
sourceSets{
commonTest{
dependencies{
implementation(kotlin("test"))
}
}
}
}

View File

@@ -3,7 +3,9 @@ package center.sciprog.maps.coordinates
import kotlin.jvm.JvmInline
@JvmInline
public value class Distance(public val kilometers: Double)
public value class Distance(public val kilometers: Double) : Comparable<Distance> {
override fun compareTo(other: Distance): Int = this.kilometers.compareTo(other.kilometers)
}
public operator fun Distance.div(other: Distance): Double = kilometers / other.kilometers
@@ -13,5 +15,4 @@ public operator fun Distance.minus(other: Distance): Distance = Distance(kilomet
public operator fun Distance.times(number: Number): Distance = Distance(kilometers * number.toDouble())
public operator fun Distance.div(number: Number): Distance = Distance(kilometers / number.toDouble())
public val Distance.meters: Double get() = kilometers * 1000

View File

@@ -1,14 +0,0 @@
package center.sciprog.maps.coordinates
public class Ellipsoid(public val equatorRadius: Distance, public val polarRadius: Distance) {
public companion object {
public val WGS84: Ellipsoid = Ellipsoid(
equatorRadius = Distance(6378.137),
polarRadius = Distance(6356.7523142)
)
}
}
public val Ellipsoid.f: Double get() = (equatorRadius.kilometers - polarRadius.kilometers) / equatorRadius.kilometers
public val Ellipsoid.inverseF: Double get() = equatorRadius.kilometers / (equatorRadius.kilometers - polarRadius.kilometers)

View File

@@ -0,0 +1,73 @@
package center.sciprog.maps.coordinates
import kotlin.math.acos
import kotlin.math.pow
import kotlin.math.sqrt
public class GeoEllipsoid(public val equatorRadius: Distance, public val polarRadius: Distance) {
/**
* Flattening https://en.wikipedia.org/wiki/Flattening
*/
public val f: Double = (equatorRadius.kilometers - polarRadius.kilometers) / equatorRadius.kilometers
/**
* Inverse flattening
*/
public val inverseF: Double = equatorRadius.kilometers / (equatorRadius.kilometers - polarRadius.kilometers)
public val eSquared: Double = 2 * f - f * f
public companion object {
public val WGS84: GeoEllipsoid = GeoEllipsoid(
equatorRadius = Distance(6378.137),
polarRadius = Distance(6356.752314245)
)
public val GRS80: GeoEllipsoid = GeoEllipsoid(
equatorRadius = Distance(6378.137),
polarRadius = Distance(6356.752314140)
)
public val sphere: GeoEllipsoid = GeoEllipsoid(
equatorRadius = Distance(6378.137),
polarRadius = Distance(6378.137)
)
// /**
// * https://en.wikipedia.org/wiki/Great-circle_distance
// */
// public fun greatCircleAngleBetween(r1: GMC, r2: GMC): Radians = acos(
// sin(r1.latitude) * sin(r2.latitude) + cos(r1.latitude) * cos(r2.latitude) * cos(r1.longitude - r2.longitude)
// ).radians
}
}
/**
* A radius of circle normal to the axis of the ellipsoid at given latitude
*/
internal fun GeoEllipsoid.reducedRadius(latitude: Angle): Distance {
val reducedLatitudeTan = (1 - f) * tan(latitude)
return equatorRadius / sqrt(1.0 + reducedLatitudeTan.pow(2))
}
//
//
///**
// * Compute distance between two map points using giv
// * https://en.wikipedia.org/wiki/Geographical_distance#Lambert's_formula_for_long_lines
// */
//public fun GeoEllipsoid.lambertDistanceBetween(r1: GMC, r2: GMC): Distance {
// val s = greatCircleAngleBetween(r1, r2)
//
// val b1: Double = (1 - f) * tan(r1.latitude)
// val b2: Double = (1 - f) * tan(r2.latitude)
// val p = (b1 + b2) / 2
// val q = (b2 - b1) / 2
//
// val x = (s.value - sin(s)) * sin(p).pow(2) * cos(q).pow(2) / cos(s / 2).pow(2)
// val y = (s.value + sin(s)) * cos(p).pow(2) * sin(q).pow(2) / sin(s / 2).pow(2)
//
// return equatorRadius * (s.value - f / 2 * (x + y))
//}

View File

@@ -5,10 +5,15 @@ import kotlin.math.PI
/**
* Geodetic coordinated
*/
public class GeodeticMapCoordinates private constructor(
public val latitude: Double,
public val longitude: Double,
){
public class GeodeticMapCoordinates(
public val latitude: Angle,
longitude: Angle,
) {
public val longitude: Radians = longitude.radians.value.rem(PI / 2).radians
init {
require(latitude.radians.value in (-PI / 2)..(PI / 2)) { "Latitude $latitude is not in (-PI/2)..(PI/2)" }
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
@@ -29,23 +34,24 @@ public class GeodeticMapCoordinates private constructor(
}
override fun toString(): String {
return "GeodeticCoordinates(latitude=${latitude / PI * 180} deg, longitude=${longitude / PI * 180} deg)"
return "GMC(latitude=${latitude.degrees.value} deg, longitude=${longitude.degrees.value} deg)"
}
public companion object {
public fun ofRadians(latitude: Double, longitude: Double): GeodeticMapCoordinates {
require(latitude in (-PI/2)..(PI/2)) { "Latitude $latitude is not in (-PI/2)..(PI/2)" }
return GeodeticMapCoordinates(latitude, longitude.rem(PI / 2))
}
public fun ofRadians(latitude: Double, longitude: Double): GeodeticMapCoordinates =
GeodeticMapCoordinates(latitude.radians, longitude.radians)
public fun ofDegrees(latitude: Double, longitude: Double): GeodeticMapCoordinates {
require(latitude in (-90.0)..(90.0)) { "Latitude $latitude is not in -90..90" }
return GeodeticMapCoordinates(latitude * PI / 180, (longitude.rem(180) * PI / 180))
}
public fun ofDegrees(latitude: Double, longitude: Double): GeodeticMapCoordinates =
GeodeticMapCoordinates(latitude.degrees.radians, longitude.degrees.radians)
}
}
/**
* Short name for GeodeticMapCoordinates
*/
public typealias Gmc = GeodeticMapCoordinates
//public interface GeoToScreenConversion {
// public fun getScreenX(gmc: GeodeticMapCoordinates): Double
// public fun getScreenY(gmc: GeodeticMapCoordinates): Double

View File

@@ -1,77 +0,0 @@
package center.sciprog.maps.coordinates
import kotlin.math.abs
import kotlin.math.cos
import kotlin.math.max
import kotlin.math.min
public data class GmcBox(
public val a: GeodeticMapCoordinates,
public val b: GeodeticMapCoordinates,
) {
public companion object {
public fun withCenter(
center: GeodeticMapCoordinates,
width: Distance,
height: Distance,
ellipsoid: Ellipsoid = Ellipsoid.WGS84,
): GmcBox {
val r = ellipsoid.equatorRadius * cos(center.latitude)
val a = GeodeticMapCoordinates.ofRadians(
center.latitude - height / ellipsoid.polarRadius / 2,
center.longitude - width / r / 2
)
val b = GeodeticMapCoordinates.ofRadians(
center.latitude + height / ellipsoid.polarRadius / 2,
center.longitude + width / r / 2
)
return GmcBox(a, b)
}
}
}
public val GmcBox.center: GeodeticMapCoordinates
get() = GeodeticMapCoordinates.ofRadians(
(a.latitude + b.latitude) / 2,
(a.longitude + b.longitude) / 2
)
/**
* Minimum longitude
*/
public val GmcBox.left: Double get() = min(a.longitude, b.longitude)
/**
* maximum longitude
*/
public val GmcBox.right: Double get() = max(a.longitude, b.longitude)
/**
* Maximum latitude
*/
public val GmcBox.top: Double get() = max(a.latitude, b.latitude)
/**
* Minimum latitude
*/
public val GmcBox.bottom: Double get() = min(a.latitude, b.latitude)
//TODO take curvature into account
public val GmcBox.width: Double get() = abs(a.longitude - b.longitude)
public val GmcBox.height: Double get() = abs(a.latitude - b.latitude)
public val GmcBox.topLeft: GeodeticMapCoordinates get() = GeodeticMapCoordinates.ofRadians(top, left)
public val GmcBox.bottomRight: GeodeticMapCoordinates get() = GeodeticMapCoordinates.ofRadians(bottom, right)
/**
* Compute a minimal bounding box including all given boxes. Return null if collection is empty
*/
public fun Collection<GmcBox>.wrapAll(): GmcBox? {
if (isEmpty()) return null
//TODO optimize computation
val minLat = minOf { it.bottom }
val maxLat = maxOf { it.top }
val minLong = minOf { it.left }
val maxLong = maxOf { it.right }
return GmcBox(GeodeticMapCoordinates.ofRadians(minLat, minLong), GeodeticMapCoordinates.ofRadians(maxLat, maxLong))
}

View File

@@ -0,0 +1,350 @@
package center.sciprog.maps.coordinates
import center.sciprog.maps.coordinates.Angle.Companion.pi
import center.sciprog.maps.coordinates.Angle.Companion.piDiv2
import center.sciprog.maps.coordinates.Angle.Companion.zero
import kotlin.math.*
/**
* A directed straight (geodetic) segment on a spheroid with given start, direction, end point and distance.
* @param forward coordinate of a start point with forward direction
* @param backward coordinate of an end point with backward direction
*/
public class GmcCurve internal constructor(
public val forward: GmcPose,
public val backward: GmcPose,
public val distance: Distance,
)
public fun GmcCurve.reversed(): GmcCurve = GmcCurve(backward, forward, distance)
/**
* Compute a curve alongside a meridian
*/
public fun GeoEllipsoid.meridianCurve(
longitude: Angle,
fromLatitude: Angle,
toLatitude: Angle,
step: Radians = 0.015.radians,
): GmcCurve {
require(fromLatitude in (-piDiv2)..(piDiv2)) { "Latitude must be in (-90, 90) degrees range" }
require(toLatitude in (-piDiv2)..(piDiv2)) { "Latitude must be in (-90, 90) degrees range" }
fun smallDistance(from: Radians, to: Radians): Distance = equatorRadius *
(1 - eSquared) *
(1 - eSquared * sin(from).pow(2)).pow(-1.5) *
abs((from - to).value)
val up = toLatitude > fromLatitude
val integrateFrom: Radians
val integrateTo: Radians
if (up) {
integrateFrom = fromLatitude.radians
integrateTo = toLatitude.radians
} else {
integrateTo = fromLatitude.radians
integrateFrom = toLatitude.radians
}
var current = integrateFrom
var s = Distance(0.0)
while (current < integrateTo) {
val next = minOf(current + step, integrateTo)
s += smallDistance(current, next)
current = next
}
return GmcCurve(
forward = GmcPose(Gmc(fromLatitude, longitude), if (up) zero else pi),
backward = GmcPose(Gmc(toLatitude, longitude), if (up) pi else zero),
distance = s
)
}
/**
* Compute a curve alongside a parallel
*/
public fun GeoEllipsoid.parallelCurve(latitude: Angle, fromLongitude: Angle, toLongitude: Angle): GmcCurve {
require(latitude in (-piDiv2)..(piDiv2)) { "Latitude must be in (-90, 90) degrees range" }
val right = toLongitude > fromLongitude
return GmcCurve(
forward = GmcPose(Gmc(latitude, fromLongitude), if (right) piDiv2.radians else -piDiv2.radians),
backward = GmcPose(Gmc(latitude, toLongitude), if (right) -piDiv2.radians else piDiv2.radians),
distance = reducedRadius(latitude) * abs((fromLongitude - toLongitude).radians.value)
)
}
/**
* Taken from https://github.com/mgavaghan/geodesy
* https://github.com/mgavaghan/geodesy/blob/ab1c6969dc964ff34929911f055b27525909ef3f/src/main/java/org/gavaghan/geodesy/GeodeticCalculator.java#L58
*
* Calculate the destination and final bearing after traveling a specified
* distance, and a specified starting bearing, for an initial location. This
* is the solution to the direct geodetic problem.
*
* @param start starting location
* @return solution to the direct geodetic problem
*/
@Suppress("SpellCheckingInspection", "LocalVariableName")
public fun GeoEllipsoid.curveInDirection(
start: GmcPose,
distance: Distance,
precision: Double = 1e-6,
): GmcCurve {
val a: Distance = equatorRadius
val b: Distance = polarRadius
val aSquared: Double = a.kilometers.pow(2)
val bSquared: Double = b.kilometers.pow(2)
val phi1 = start.latitude
val alpha1 = start.bearing
val cosAlpha1: Double = cos(alpha1)
val sinAlpha1: Double = sin(alpha1)
val tanU1: Double = (1.0 - f) * tan(phi1)
val cosU1: Double = 1.0 / sqrt(1.0 + tanU1 * tanU1)
val sinU1 = tanU1 * cosU1
// eq. 1
val sigma1: Radians = atan2(tanU1, cosAlpha1).radians
// eq. 2
val sinAlpha: Double = cosU1 * sinAlpha1
val sin2Alpha = sinAlpha * sinAlpha
val cos2Alpha = 1 - sin2Alpha
val uSquared = cos2Alpha * (aSquared - bSquared) / bSquared
// eq. 3
val A: Double = 1 + uSquared / 16384 * (4096 + uSquared * (-768 + uSquared * (320 - 175 * uSquared)))
// eq. 4
val B: Double = uSquared / 1024 * (256 + uSquared * (-128 + uSquared * (74 - 47 * uSquared)))
// iterate until there is a negligible change in sigma
val sOverbA: Radians = (distance / b / A).radians
var sigma: Radians = sOverbA
var sinSigma: Double
var prevSigma: Radians = sOverbA
var sigmaM2: Radians
var cosSigmaM2: Double
var cos2SigmaM2: Double
while (!prevSigma.value.isNaN()) {
// eq. 5
sigmaM2 = sigma1 * 2.0 + sigma
cosSigmaM2 = cos(sigmaM2)
cos2SigmaM2 = cosSigmaM2 * cosSigmaM2
sinSigma = sin(sigma)
// val cosSigma: Double = cos(sigma)
// eq. 6
val deltaSigma = B * sinSigma *
(cosSigmaM2 + B / 4.0 * (cos(sigma) * (-1 + 2 * cos2SigmaM2) -
B / 6.0 * cosSigmaM2 * (-3 + 4 * sinSigma * sinSigma) * (-3 + 4 * cos2SigmaM2)))
// eq. 7
sigma = sOverbA + deltaSigma.radians
// break after converging to tolerance
if (abs((sigma - prevSigma).value) < precision) break
prevSigma = sigma
}
sigmaM2 = sigma1 * 2.0 + sigma
cosSigmaM2 = cos(sigmaM2)
cos2SigmaM2 = cosSigmaM2 * cosSigmaM2
val cosSigma: Double = cos(sigma)
sinSigma = sin(sigma)
// eq. 8
val phi2: Radians = atan2(
sinU1 * cosSigma + cosU1 * sinSigma * cosAlpha1,
(1.0 - f) * sqrt(
sin2Alpha + (sinU1 * sinSigma - cosU1 * cosSigma * cosAlpha1).pow(2)
)
).radians
// eq. 9
// This fixes the pole crossing defect spotted by Matt Feemster. When a
// path passes a pole and essentially crosses a line of latitude twice -
// once in each direction - the longitude calculation got messed up.
//
// Using atan2 instead of atan fixes the defect. The change is in the
// next 3 lines.
//
// double tanLambda = sinSigma * sinAlpha1 / (cosU1 * cosSigma - sinU1 *
// sinSigma * cosAlpha1);
// double lambda = Math.atan(tanLambda);
val lambda: Double = atan2(
sinSigma * sinAlpha1,
cosU1 * cosSigma - sinU1 * sinSigma * cosAlpha1
)
// eq. 10
val C = f / 16 * cos2Alpha * (4 + f * (4 - 3 * cos2Alpha))
// eq. 11
val L = lambda - (1 - C) * f * sinAlpha *
(sigma.value + C * sinSigma * (cosSigmaM2 + C * cosSigma * (-1 + 2 * cos2SigmaM2)))
val endPoint = Gmc(phi2, L.radians)
// eq. 12
return GmcCurve(
start,
GmcPose(
endPoint,
atan2(sinAlpha, -sinU1 * sinSigma + cosU1 * cosSigma * cosAlpha1).radians
),
distance
)
}
/**
* Taken from https://github.com/mgavaghan/geodesy
*
* Calculate the geodetic curve between two points on a specified reference
* ellipsoid. This is the solution to the inverse geodetic problem.
*
* @receiver reference ellipsoid to use
* @param start starting coordinates
* @param end ending coordinates
* @return solution to the inverse geodetic problem
*/
@Suppress("SpellCheckingInspection", "LocalVariableName")
public fun GeoEllipsoid.curveBetween(start: Gmc, end: Gmc, precision: Double = 1e-6): GmcCurve {
//
// All equation numbers refer back to Vincenty's publication:
// See http://www.ngs.noaa.gov/PUBS_LIB/inverse.pdf
//
// get constants
val a = equatorRadius
val b = polarRadius
// get parameters as radians
val phi1 = start.latitude
val lambda1 = start.longitude
val phi2 = end.latitude
val lambda2 = end.longitude
// calculations
val a2 = a.kilometers * a.kilometers
val b2 = b.kilometers * b.kilometers
val a2b2b2 = (a2 - b2) / b2
val omega: Radians = lambda2 - lambda1
val tanphi1: Double = tan(phi1)
val tanU1 = (1.0 - f) * tanphi1
val U1: Double = atan(tanU1)
val sinU1: Double = sin(U1)
val cosU1: Double = cos(U1)
val tanphi2: Double = tan(phi2)
val tanU2 = (1.0 - f) * tanphi2
val U2: Double = atan(tanU2)
val sinU2: Double = sin(U2)
val cosU2: Double = cos(U2)
val sinU1sinU2 = sinU1 * sinU2
val cosU1sinU2 = cosU1 * sinU2
val sinU1cosU2 = sinU1 * cosU2
val cosU1cosU2 = cosU1 * cosU2
// eq. 13
var lambda = omega
// intermediates we'll need to compute 's'
var A = 0.0
var sigma = 0.0
var deltasigma = 0.0
var lambda0: Radians
var converged = false
for (i in 0..19) {
lambda0 = lambda
val sinlambda: Double = sin(lambda)
val coslambda: Double = cos(lambda)
// eq. 14
val sin2sigma =
cosU2 * sinlambda * cosU2 * sinlambda + (cosU1sinU2 - sinU1cosU2 * coslambda) * (cosU1sinU2 - sinU1cosU2 * coslambda)
val sinsigma: Double = sqrt(sin2sigma)
// eq. 15
val cossigma = sinU1sinU2 + cosU1cosU2 * coslambda
// eq. 16
sigma = atan2(sinsigma, cossigma)
// eq. 17 Careful! sin2sigma might be almost 0!
val sinalpha = if (sin2sigma == 0.0) 0.0 else cosU1cosU2 * sinlambda / sinsigma
val alpha: Double = asin(sinalpha)
val cosalpha: Double = cos(alpha)
val cos2alpha = cosalpha * cosalpha
// eq. 18 Careful! cos2alpha might be almost 0!
val cos2sigmam = if (cos2alpha == 0.0) 0.0 else cossigma - 2 * sinU1sinU2 / cos2alpha
val u2 = cos2alpha * a2b2b2
val cos2sigmam2 = cos2sigmam * cos2sigmam
// eq. 3
A = 1.0 + u2 / 16384 * (4096 + u2 * (-768 + u2 * (320 - 175 * u2)))
// eq. 4
val B = u2 / 1024 * (256 + u2 * (-128 + u2 * (74 - 47 * u2)))
// eq. 6
deltasigma =
B * sinsigma * (cos2sigmam + B / 4 * (cossigma * (-1 + 2 * cos2sigmam2) - B / 6 * cos2sigmam * (-3 + 4 * sin2sigma) * (-3 + 4 * cos2sigmam2)))
// eq. 10
val C = f / 16 * cos2alpha * (4 + f * (4 - 3 * cos2alpha))
// eq. 11 (modified)
lambda = omega + (
(1 - C) * f * sinalpha *
(sigma + C * sinsigma * (cos2sigmam + C * cossigma * (-1 + 2 * cos2sigmam2)))
).radians
// see how much improvement we got
val change: Double = abs((lambda - lambda0) / lambda)
if (i > 1 && change < precision) {
converged = true
break
}
}
// eq. 19
val s: Distance = b * A * (sigma - deltasigma)
val alpha1: Radians
val alpha2: Radians
// didn't converge? must be N/S
if (!converged) {
if (phi1 > phi2) {
alpha1 = pi.radians
alpha2 = 0.0.radians
} else if (phi1 < phi2) {
alpha1 = 0.0.radians
alpha2 = pi.radians
} else {
alpha1 = Double.NaN.radians
alpha2 = Double.NaN.radians
}
} else {
// eq. 20
alpha1 = atan2(
cosU2 * sin(lambda),
cosU1sinU2 - sinU1cosU2 * cos(lambda)
).radians
// eq. 21
alpha2 = atan2(
cosU1 * sin(lambda),
-sinU1cosU2 + cosU1sinU2 * cos(lambda)
).radians + pi
}
return GmcCurve(
GmcPose(start, alpha1),
GmcPose(end, alpha2),
s
)
}

View File

@@ -0,0 +1,12 @@
package center.sciprog.maps.coordinates
/**
* A coordinate-bearing pair
*/
public data class GmcPose(val coordinates: GeodeticMapCoordinates, val bearing: Angle) {
val latitude: Angle get() = coordinates.latitude
val longitude: Angle get() = coordinates.longitude
}
public fun GmcPose.reversed(): GmcPose = copy(bearing = (bearing + Angle.pi).normalized())

View File

@@ -0,0 +1,116 @@
package center.sciprog.maps.coordinates
/**
* A section of the map between two parallels and two meridians. The figure represents a square in a Mercator projection.
* Params are two opposing "corners" of quasi-square.
*
* Note that this is a rectangle only on a Mercator projection.
*/
public data class GmcRectangle(
public val a: GeodeticMapCoordinates,
public val b: GeodeticMapCoordinates,
) {
public companion object {
/**
* A quasi-square section.
*/
public fun square(
center: GeodeticMapCoordinates,
height: Angle,
width: Angle,
): GmcRectangle {
val a = GeodeticMapCoordinates(
center.latitude - (height / 2),
center.longitude - (width / 2)
)
val b = GeodeticMapCoordinates(
center.latitude + (height / 2),
center.longitude + (width / 2)
)
return GmcRectangle(a, b)
}
/**
* A quasi-square section. Note that latitudinal distance could be imprecise for large distances
*/
public fun square(
center: GeodeticMapCoordinates,
height: Distance,
width: Distance,
ellipsoid: GeoEllipsoid = GeoEllipsoid.WGS84,
): GmcRectangle {
val reducedRadius = ellipsoid.reducedRadius(center.latitude)
return square(center, (height / ellipsoid.polarRadius).radians, (width / reducedRadius).radians)
}
}
}
public val GmcRectangle.center: GeodeticMapCoordinates
get() = GeodeticMapCoordinates(
(a.latitude + b.latitude) / 2,
(a.longitude + b.longitude) / 2
)
/**
* Minimum longitude
*/
public val GmcRectangle.left: Angle get() = minOf(a.longitude, b.longitude)
/**
* maximum longitude
*/
public val GmcRectangle.right: Angle get() = maxOf(a.longitude, b.longitude)
/**
* Maximum latitude
*/
public val GmcRectangle.top: Angle get() = maxOf(a.latitude, b.latitude)
/**
* Minimum latitude
*/
public val GmcRectangle.bottom: Angle get() = minOf(a.latitude, b.latitude)
public val GmcRectangle.longitudeDelta: Angle get() = abs(a.longitude - b.longitude)
public val GmcRectangle.latitudeDelta: Angle get() = abs(a.latitude - b.latitude)
public val GmcRectangle.topLeft: GeodeticMapCoordinates get() = GeodeticMapCoordinates(top, left)
public val GmcRectangle.bottomRight: GeodeticMapCoordinates get() = GeodeticMapCoordinates(bottom, right)
//public fun GmcRectangle.enlarge(
// top: Distance,
// bottom: Distance = top,
// left: Distance = top,
// right: Distance = left,
//): GmcRectangle {
//
//}
//
//public fun GmcRectangle.enlarge(
// top: Angle,
// bottom: Angle = top,
// left: Angle = top,
// right: Angle = left,
//): GmcRectangle {
//
//}
/**
* Check if coordinate is inside the box
*/
public operator fun GmcRectangle.contains(coordinate: Gmc): Boolean =
coordinate.latitude in (bottom..top) && coordinate.longitude in (left..right)
/**
* Compute a minimal bounding box including all given boxes. Return null if collection is empty
*/
public fun Collection<GmcRectangle>.wrapAll(): GmcRectangle? {
if (isEmpty()) return null
//TODO optimize computation
val minLat = minOf { it.bottom }
val maxLat = maxOf { it.top }
val minLong = minOf { it.left }
val maxLong = maxOf { it.right }
return GmcRectangle(GeodeticMapCoordinates(minLat, minLong), GeodeticMapCoordinates(maxLat, maxLong))
}

View File

@@ -13,7 +13,7 @@ public data class MapViewPoint(
}
public fun MapViewPoint.move(delta: GeodeticMapCoordinates): MapViewPoint {
val newCoordinates = GeodeticMapCoordinates.ofRadians(
val newCoordinates = GeodeticMapCoordinates(
(focus.latitude + delta.latitude).coerceIn(
-MercatorProjection.MAXIMUM_LATITUDE,
MercatorProjection.MAXIMUM_LATITUDE
@@ -30,7 +30,7 @@ public fun MapViewPoint.zoom(
copy(zoom = (zoom + zoomDelta).coerceIn(2.0, 18.0))
} else {
val difScale = (1 - 2.0.pow(-zoomDelta))
val newCenter = GeodeticMapCoordinates.ofRadians(
val newCenter = GeodeticMapCoordinates(
focus.latitude + (invariant.latitude - focus.latitude) * difScale,
focus.longitude + (invariant.longitude - focus.longitude) * difScale
)

View File

@@ -5,9 +5,12 @@
package center.sciprog.maps.coordinates
import kotlin.math.*
import center.sciprog.maps.coordinates.Angle.Companion.pi
import kotlin.math.atan
import kotlin.math.ln
import kotlin.math.sinh
public data class MercatorCoordinates(val x: Double, val y: Double)
public data class MercatorCoordinates(val x: Distance, val y: Distance)
/**
* @param baseLongitude the longitude offset in radians
@@ -15,21 +18,21 @@ public data class MercatorCoordinates(val x: Double, val y: Double)
* @param correctedRadius optional radius correction to account for ellipsoid model
*/
public open class MercatorProjection(
public val baseLongitude: Double = 0.0,
protected val radius: Double = DEFAULT_EARTH_RADIUS,
private val correctedRadius: ((GeodeticMapCoordinates) -> Double)? = null,
public val baseLongitude: Angle = Angle.zero,
protected val radius: Distance = DEFAULT_EARTH_RADIUS,
private val correctedRadius: ((GeodeticMapCoordinates) -> Distance)? = null,
) {
public fun toGeodetic(mc: MercatorCoordinates): GeodeticMapCoordinates {
val res = GeodeticMapCoordinates.ofRadians(
atan(sinh(mc.y / radius)),
baseLongitude + mc.x / radius,
baseLongitude.radians.value + (mc.x / radius),
)
return if (correctedRadius != null) {
val r = correctedRadius.invoke(res)
GeodeticMapCoordinates.ofRadians(
atan(sinh(mc.y / r)),
baseLongitude + mc.x / r,
baseLongitude.radians.value + mc.x / r,
)
} else {
res
@@ -41,15 +44,15 @@ public open class MercatorProjection(
*/
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
val r: Distance = correctedRadius?.invoke(gmc) ?: radius
return MercatorCoordinates(
x = r * (gmc.longitude - baseLongitude),
y = r * ln(tan(PI / 4 + gmc.latitude / 2))
x = r * (gmc.longitude - baseLongitude).radians.value,
y = r * ln(tan(pi / 4 + gmc.latitude / 2))
)
}
public companion object : MercatorProjection(0.0, 6378137.0) {
public const val MAXIMUM_LATITUDE: Double = 85.05113
public val DEFAULT_EARTH_RADIUS: Double = radius
public companion object : MercatorProjection(Angle.zero, Distance(6378.137)) {
public val MAXIMUM_LATITUDE: Angle = 85.05113.degrees
public val DEFAULT_EARTH_RADIUS: Distance = radius
}
}

View File

@@ -9,7 +9,7 @@ import kotlin.math.*
public data class WebMercatorCoordinates(val zoom: Int, val x: Double, val y: Double)
public object WebMercatorProjection {
public object WebMercatorProjection {
/**
* Compute radians to projection coordinates ratio for given [zoom] factor
@@ -32,8 +32,8 @@ public object WebMercatorProjection {
val scaleFactor = scaleFactor(zoom.toDouble())
return WebMercatorCoordinates(
zoom = zoom,
x = scaleFactor * (gmc.longitude + PI),
y = scaleFactor * (PI - ln(tan(PI / 4 + gmc.latitude / 2)))
x = scaleFactor * (gmc.longitude.radians.value + PI),
y = scaleFactor * (PI - ln(tan(PI / 4 + gmc.latitude.radians.value / 2)))
)
}

View File

@@ -0,0 +1,92 @@
/*
* Copyright 2018-2021 KMath contributors.
* Use of this source code is governed by the Apache 2.0 license that can be found in the license/LICENSE.txt file.
*/
package center.sciprog.maps.coordinates
import kotlin.jvm.JvmInline
import kotlin.math.PI
// Taken from KMath dev version, to be used directly in the future
public sealed interface Angle : Comparable<Angle> {
public val radians: Radians
public val degrees: Degrees
public operator fun plus(other: Angle): Angle
public operator fun minus(other: Angle): Angle
public operator fun times(other: Number): Angle
public operator fun div(other: Number): Angle
public operator fun div(other: Angle): Double
public operator fun unaryMinus(): Angle
public companion object {
public val zero: Angle = 0.radians
public val pi: Angle = PI.radians
public val piTimes2: Angle = (2 * PI).radians
public val piDiv2: Angle = (PI / 2).radians
}
}
/**
* Type safe radians
*/
@JvmInline
public value class Radians(public val value: Double) : Angle {
override val radians: Radians
get() = this
override val degrees: Degrees
get() = Degrees(value * 180 / PI)
public override fun plus(other: Angle): Radians = Radians(value + other.radians.value)
public override fun minus(other: Angle): Radians = Radians(value - other.radians.value)
public override fun times(other: Number): Radians = Radians(value * other.toDouble())
public override fun div(other: Number): Radians = Radians(value / other.toDouble())
override fun div(other: Angle): Double = value / other.radians.value
public override fun unaryMinus(): Radians = Radians(-value)
override fun compareTo(other: Angle): Int = value.compareTo(other.radians.value)
}
public fun sin(angle: Angle): Double = kotlin.math.sin(angle.radians.value)
public fun cos(angle: Angle): Double = kotlin.math.cos(angle.radians.value)
public fun tan(angle: Angle): Double = kotlin.math.tan(angle.radians.value)
public val Number.radians: Radians get() = Radians(toDouble())
/**
* Type safe degrees
*/
@JvmInline
public value class Degrees(public val value: Double) : Angle {
override val radians: Radians
get() = Radians(value * PI / 180)
override val degrees: Degrees
get() = this
public override fun plus(other: Angle): Degrees = Degrees(value + other.degrees.value)
public override fun minus(other: Angle): Degrees = Degrees(value - other.degrees.value)
public override fun times(other: Number): Degrees = Degrees(value * other.toDouble())
public override fun div(other: Number): Degrees = Degrees(value / other.toDouble())
override fun div(other: Angle): Double = value / other.degrees.value
public override fun unaryMinus(): Degrees = Degrees(-value)
override fun compareTo(other: Angle): Int = value.compareTo(other.degrees.value)
}
public val Number.degrees: Degrees get() = Degrees(toDouble())
/**
* Normalized angle to (0, 2PI) for radians or (0, 360) for degrees.
*/
public fun Angle.normalized(): Angle = when (this) {
is Degrees -> (value + 180.0).rem(360.0).degrees
is Radians -> (value + PI).rem(PI * 2).radians
}
public fun abs(angle: Angle): Angle = if (angle < Angle.zero) -angle else angle

View File

@@ -0,0 +1,34 @@
package center.sciprog.maps.coordinates
import kotlin.test.Ignore
import kotlin.test.Test
import kotlin.test.assertEquals
internal class DistanceTest {
companion object {
val moscow = GMC.ofDegrees(55.76058287719673, 37.60358622841869)
val spb = GMC.ofDegrees(59.926686023580444, 30.36038109122013)
}
@Test
fun ellipsoidParameters() {
assertEquals(298.257223563, GeoEllipsoid.WGS84.inverseF, 1e-6)
}
@Test
fun curveBetween() {
val curve = GeoEllipsoid.WGS84.curveBetween(moscow, spb)
val distance = curve.distance
assertEquals(632.035426877, distance.kilometers, 0.0001)
}
@Test
fun curveInDirection() {
val curve = GeoEllipsoid.WGS84.curveInDirection(
GmcPose(moscow, (-0.6947937116552751).radians), Distance(632.035426877)
)
assertEquals(spb.latitude.radians.value,curve.backward.latitude.radians.value, 0.0001)
}
}