Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d6d00827aa | ||
|
|
5247ce10ab | ||
| 491a4e6000 | |||
| 5a1d3d701f | |||
| 26aaac2ecd | |||
| d490b3f37c | |||
| 9ffc9d3d67 | |||
| e0cc0bc60b | |||
| 4614b1f7bc | |||
|
|
6459246eb5 | ||
| 3467a6dbe0 | |||
|
|
b110ab2b4c | ||
|
|
4b512d0967 | ||
| 11b278fc81 | |||
|
|
190877c10e | ||
|
|
954cc26bfd | ||
|
|
22cbeddaf0 | ||
|
|
3e2c8d2db2 | ||
| 14acd88358 | |||
| 4c3aefcfae | |||
| 8867388e85 | |||
| 2c87ba7638 | |||
| cdee88573d | |||
| 09dfdcc84a | |||
| 14b3142f43 | |||
| 7ada7f85f2 |
14
README.md
14
README.md
@@ -1 +1,13 @@
|
||||
This repository is a work-in-progress implementation of Map-with-markers component for Compose-Multiplatform
|
||||
This repository is a work-in-progress implementation of Map-with-markers component for Compose-Multiplatform
|
||||
|
||||
## [maps-kt-core](maps-kt-core)
|
||||
A multiplatform coordinates representation and conversion.
|
||||
|
||||
## [maps-kt-compose](maps-kt-compose)
|
||||
A compose multiplatform (currently desktop only, contributions of android target are welcome) implementation of a map component, features and builder.
|
||||
|
||||
## [maps-kt-scheme](maps-kt-scheme)
|
||||
An alternative component used for the same functionality on 2D schemes. Not all features from maps could be ported because it requires some code duplication (ideas for common API are welcome).
|
||||
|
||||
## [demo](demo)
|
||||
Demonstration projects for different features
|
||||
@@ -33,7 +33,7 @@ compose.desktop {
|
||||
mainClass = "MainKt"
|
||||
nativeDistributions {
|
||||
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
|
||||
packageName = "maps-kt-compose"
|
||||
packageName = "maps-compose-demo"
|
||||
packageVersion = "1.0.0"
|
||||
}
|
||||
}
|
||||
@@ -5,13 +5,16 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Home
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
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 centre.sciprog.maps.GeodeticMapCoordinates
|
||||
import centre.sciprog.maps.MapViewPoint
|
||||
import centre.sciprog.maps.compose.*
|
||||
import center.sciprog.maps.compose.*
|
||||
import center.sciprog.maps.coordinates.Distance
|
||||
import center.sciprog.maps.coordinates.GeodeticMapCoordinates
|
||||
import center.sciprog.maps.coordinates.MapViewPoint
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.engine.cio.CIO
|
||||
import kotlinx.coroutines.delay
|
||||
@@ -22,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
|
||||
@@ -48,35 +51,53 @@ fun App() {
|
||||
var centerCoordinates by remember { mutableStateOf<GeodeticMapCoordinates?>(null) }
|
||||
|
||||
|
||||
val pointOne = 55.568548 to 37.568604
|
||||
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 }
|
||||
onViewChange = { centerCoordinates = focus },
|
||||
)
|
||||
) {
|
||||
val pointOne = 55.568548 to 37.568604
|
||||
val pointTwo = 55.929444 to 37.518434
|
||||
val pointThree = 60.929444 to 37.518434
|
||||
|
||||
image(pointOne, Icons.Filled.Home)
|
||||
|
||||
//remember feature Id
|
||||
rectangle(dragPoint, id = "dragMe", size = DpSize(10.dp, 10.dp)).draggable()
|
||||
|
||||
points(
|
||||
points = listOf(
|
||||
55.742465 to 37.615812,
|
||||
55.742713 to 37.616370,
|
||||
55.742815 to 37.616659,
|
||||
55.742320 to 37.617132,
|
||||
55.742086 to 37.616566,
|
||||
55.741715 to 37.616716
|
||||
),
|
||||
pointMode = PointMode.Polygon
|
||||
)
|
||||
|
||||
//remember feature ID
|
||||
val circleId: FeatureId = circle(
|
||||
centerCoordinates = pointTwo,
|
||||
)
|
||||
|
||||
custom(position = pointThree) {
|
||||
drawRect(
|
||||
color = Color.Red,
|
||||
topLeft = Offset(-10f, -10f),
|
||||
size = Size(20f, 20f)
|
||||
)
|
||||
draw(position = pointThree) {
|
||||
drawLine(start = Offset(-10f, -10f), end = Offset(10f, 10f), color = Color.Red)
|
||||
drawLine(start = Offset(-10f, 10f), end = Offset(10f, -10f), color = Color.Red)
|
||||
}
|
||||
|
||||
line(pointOne, pointTwo)
|
||||
text(pointOne, "Home")
|
||||
circle(pointThree, layer = -1, color = Color.Blue)
|
||||
|
||||
arc(pointOne, Distance(10.0), 0f, PI)
|
||||
|
||||
line(pointOne, pointTwo, id = "line")
|
||||
text(pointOne, "Home", font = { size = 32f })
|
||||
|
||||
centerCoordinates?.let {
|
||||
group(id = "center") {
|
||||
39
demo/scheme/build.gradle.kts
Normal file
39
demo/scheme/build.gradle.kts
Normal file
@@ -0,0 +1,39 @@
|
||||
import org.jetbrains.compose.compose
|
||||
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
|
||||
|
||||
plugins {
|
||||
kotlin("multiplatform")
|
||||
id("org.jetbrains.compose")
|
||||
}
|
||||
|
||||
val ktorVersion: String by rootProject.extra
|
||||
|
||||
kotlin {
|
||||
jvm {
|
||||
compilations.all {
|
||||
kotlinOptions.jvmTarget = "11"
|
||||
}
|
||||
withJava()
|
||||
}
|
||||
sourceSets {
|
||||
val jvmMain by getting {
|
||||
dependencies {
|
||||
implementation(projects.mapsKtScheme)
|
||||
implementation(compose.desktop.currentOs)
|
||||
implementation("ch.qos.logback:logback-classic:1.2.11")
|
||||
}
|
||||
}
|
||||
val jvmTest by getting
|
||||
}
|
||||
}
|
||||
|
||||
compose.desktop {
|
||||
application {
|
||||
mainClass = "MainKt"
|
||||
nativeDistributions {
|
||||
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
|
||||
packageName = "scheme-compose-demo"
|
||||
packageVersion = "1.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
67
demo/scheme/src/jvmMain/kotlin/Main.kt
Normal file
67
demo/scheme/src/jvmMain/kotlin/Main.kt
Normal file
@@ -0,0 +1,67 @@
|
||||
// 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.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
|
||||
fun App() {
|
||||
MaterialTheme {
|
||||
//create a view point
|
||||
val viewPoint = remember {
|
||||
SchemeViewPoint(
|
||||
SchemeCoordinates(0f, 0f),
|
||||
1f
|
||||
)
|
||||
}
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
|
||||
SchemeView(
|
||||
viewPoint,
|
||||
config = SchemeViewConfig(
|
||||
inferViewBoxFromFeatures = true,
|
||||
onClick = {
|
||||
println("${focus.x}, ${focus.y}")
|
||||
}
|
||||
)
|
||||
) {
|
||||
background(painterResource("middle-earth.jpg"))
|
||||
circle(410.52737 to 868.7676, 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)
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun main() = application {
|
||||
Window(onCloseRequest = ::exitApplication) {
|
||||
App()
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import org.jetbrains.compose.compose
|
||||
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
|
||||
|
||||
plugins {
|
||||
kotlin("multiplatform")
|
||||
@@ -10,17 +9,19 @@ plugins {
|
||||
val ktorVersion: String by rootProject.extra
|
||||
|
||||
kotlin {
|
||||
explicitApi = org.jetbrains.kotlin.gradle.dsl.ExplicitApiMode.Warning
|
||||
jvm {
|
||||
compilations.all {
|
||||
kotlinOptions.jvmTarget = "11"
|
||||
}
|
||||
}
|
||||
sourceSets {
|
||||
commonMain{
|
||||
dependencies{
|
||||
commonMain {
|
||||
dependencies {
|
||||
api(projects.mapsKtCore)
|
||||
api(compose.foundation)
|
||||
api("io.ktor:ktor-client-core:$ktorVersion")
|
||||
api("io.github.microutils:kotlin-logging:2.1.23")
|
||||
}
|
||||
}
|
||||
val jvmMain by getting
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package centre.sciprog.maps.compose
|
||||
package center.sciprog.maps.compose
|
||||
|
||||
import kotlin.jvm.Synchronized
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
package center.sciprog.maps.compose
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.PointMode
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
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.*
|
||||
import kotlin.math.floor
|
||||
|
||||
public interface MapFeature {
|
||||
public val zoomRange: IntRange
|
||||
public fun getBoundingBox(zoom: Double): GmcRectangle?
|
||||
public val layer: Int
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
internal val defaultZoomRange = 1..18
|
||||
|
||||
/**
|
||||
* A feature that decides what to show depending on the zoom value (it could change size of shape)
|
||||
*/
|
||||
public class MapFeatureSelector(
|
||||
public val selector: (zoom: Int) -> MapFeature,
|
||||
override val layer: Int
|
||||
) : MapFeature {
|
||||
override val zoomRange: IntRange get() = defaultZoomRange
|
||||
|
||||
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,
|
||||
override val layer: Int
|
||||
) : DraggableMapFeature {
|
||||
override fun getBoundingBox(zoom: Double): GmcRectangle {
|
||||
//TODO add box computation
|
||||
return GmcRectangle(position, position)
|
||||
}
|
||||
|
||||
override fun withCoordinates(newCoordinates: GeodeticMapCoordinates): MapFeature =
|
||||
MapDrawFeature(newCoordinates, zoomRange, drawFeature, layer)
|
||||
}
|
||||
|
||||
public class MapPointsFeature(
|
||||
public val points: List<GeodeticMapCoordinates>,
|
||||
override val zoomRange: IntRange = defaultZoomRange,
|
||||
public val stroke: Float = 2f,
|
||||
public val color: Color = Color.Red,
|
||||
public val pointMode: PointMode = PointMode.Points,
|
||||
override val layer: Int
|
||||
) : MapFeature {
|
||||
override fun getBoundingBox(zoom: Double): GmcRectangle {
|
||||
return GmcRectangle(points.first(), points.last())
|
||||
}
|
||||
}
|
||||
|
||||
public class MapCircleFeature(
|
||||
public val center: GeodeticMapCoordinates,
|
||||
override val zoomRange: IntRange = defaultZoomRange,
|
||||
public val size: Float = 5f,
|
||||
public val color: Color = Color.Red,
|
||||
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(
|
||||
public val center: GeodeticMapCoordinates,
|
||||
override val zoomRange: IntRange = defaultZoomRange,
|
||||
public val size: DpSize = DpSize(5.dp, 5.dp),
|
||||
public val color: Color = Color.Red,
|
||||
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(
|
||||
public val a: GeodeticMapCoordinates,
|
||||
public val b: GeodeticMapCoordinates,
|
||||
override val zoomRange: IntRange = defaultZoomRange,
|
||||
public val color: Color = Color.Red,
|
||||
override val layer: Int
|
||||
) : MapFeature {
|
||||
override fun getBoundingBox(zoom: Double): GmcRectangle = GmcRectangle(a, b)
|
||||
}
|
||||
|
||||
public class MapArcFeature(
|
||||
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: Double): GmcRectangle = oval
|
||||
}
|
||||
|
||||
public class MapBitmapImageFeature(
|
||||
public val position: GeodeticMapCoordinates,
|
||||
public val image: ImageBitmap,
|
||||
public val size: IntSize = IntSize(15, 15),
|
||||
override val zoomRange: IntRange = defaultZoomRange,
|
||||
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(
|
||||
public val position: GeodeticMapCoordinates,
|
||||
public val painter: Painter,
|
||||
public val size: DpSize,
|
||||
override val zoomRange: IntRange = defaultZoomRange,
|
||||
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
|
||||
public fun MapVectorImageFeature(
|
||||
position: GeodeticMapCoordinates,
|
||||
image: ImageVector,
|
||||
size: DpSize = DpSize(20.dp, 20.dp),
|
||||
zoomRange: IntRange = defaultZoomRange,
|
||||
layer: Int
|
||||
): MapVectorImageFeature = MapVectorImageFeature(position, rememberVectorPainter(image), size, zoomRange, layer)
|
||||
|
||||
/**
|
||||
* A group of other features
|
||||
*/
|
||||
public class MapFeatureGroup(
|
||||
public val children: Map<FeatureId, MapFeature>,
|
||||
override val zoomRange: IntRange = defaultZoomRange,
|
||||
override val layer: Int
|
||||
) : MapFeature {
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package center.sciprog.maps.compose
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.snapshots.SnapshotStateMap
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.PointMode
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
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.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 <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(
|
||||
override val features: SnapshotStateMap<FeatureId, MapFeature>,
|
||||
) : MapFeatureBuilder {
|
||||
|
||||
private val attributes = SnapshotStateMap<FeatureId, SnapshotStateMap<MapFeatureAttributeKey<out Any?>, in Any?>>()
|
||||
|
||||
|
||||
private fun generateID(feature: MapFeature): FeatureId = "@feature[${feature.hashCode().toUInt()}]"
|
||||
|
||||
override fun addFeature(id: FeatureId?, feature: MapFeature): FeatureId {
|
||||
val safeId = id ?: generateID(feature)
|
||||
features[id ?: generateID(feature)] = feature
|
||||
return safeId
|
||||
}
|
||||
|
||||
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(
|
||||
center: GeodeticMapCoordinates,
|
||||
zoomRange: IntRange = defaultZoomRange,
|
||||
size: Float = 5f,
|
||||
color: Color = Color.Red,
|
||||
id: FeatureId? = null,
|
||||
layer: Int = 0
|
||||
): FeatureId = addFeature(
|
||||
id, MapCircleFeature(center, zoomRange, size, color, layer)
|
||||
)
|
||||
|
||||
public fun MapFeatureBuilder.circle(
|
||||
centerCoordinates: Pair<Double, Double>,
|
||||
zoomRange: IntRange = defaultZoomRange,
|
||||
size: Float = 5f,
|
||||
color: Color = Color.Red,
|
||||
id: FeatureId? = null,
|
||||
layer: Int = 0
|
||||
): FeatureId = addFeature(
|
||||
id, MapCircleFeature(centerCoordinates.toCoordinates(), zoomRange, size, color, layer)
|
||||
)
|
||||
|
||||
public fun MapFeatureBuilder.rectangle(
|
||||
centerCoordinates: Pair<Double, Double>,
|
||||
zoomRange: IntRange = defaultZoomRange,
|
||||
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, layer)
|
||||
)
|
||||
|
||||
public fun MapFeatureBuilder.draw(
|
||||
position: Pair<Double, Double>,
|
||||
zoomRange: IntRange = defaultZoomRange,
|
||||
id: FeatureId? = null,
|
||||
layer: Int = 0,
|
||||
drawFeature: DrawScope.() -> Unit
|
||||
): FeatureId = addFeature(id, MapDrawFeature(position.toCoordinates(), zoomRange, drawFeature, layer))
|
||||
|
||||
public fun MapFeatureBuilder.line(
|
||||
aCoordinates: Pair<Double, Double>,
|
||||
bCoordinates: Pair<Double, Double>,
|
||||
zoomRange: IntRange = defaultZoomRange,
|
||||
color: Color = Color.Red,
|
||||
id: FeatureId? = null,
|
||||
layer: Int = 0
|
||||
): FeatureId = addFeature(
|
||||
id,
|
||||
MapLineFeature(aCoordinates.toCoordinates(), bCoordinates.toCoordinates(), zoomRange, color, layer)
|
||||
)
|
||||
|
||||
public fun MapFeatureBuilder.arc(
|
||||
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, layer)
|
||||
)
|
||||
|
||||
public fun MapFeatureBuilder.arc(
|
||||
center: Pair<Double, Double>,
|
||||
radius: Distance,
|
||||
startAngle: Number,
|
||||
endAngle: Number,
|
||||
zoomRange: IntRange = defaultZoomRange,
|
||||
color: Color = Color.Red,
|
||||
id: FeatureId? = null,
|
||||
layer: Int = 0
|
||||
): FeatureId = addFeature(
|
||||
id,
|
||||
MapArcFeature(
|
||||
GmcRectangle.square(center.toCoordinates(), radius, radius),
|
||||
startAngle.toFloat(),
|
||||
endAngle.toFloat(),
|
||||
zoomRange,
|
||||
color,
|
||||
layer
|
||||
)
|
||||
)
|
||||
|
||||
public fun MapFeatureBuilder.points(
|
||||
points: List<Pair<Double, Double>>,
|
||||
zoomRange: IntRange = defaultZoomRange,
|
||||
stroke: Float = 2f,
|
||||
color: Color = Color.Red,
|
||||
pointMode: PointMode = PointMode.Points,
|
||||
id: FeatureId? = null,
|
||||
layer: Int = 0
|
||||
): FeatureId =
|
||||
addFeature(id, MapPointsFeature(points.map { it.toCoordinates() }, zoomRange, stroke, color, pointMode, layer))
|
||||
|
||||
@Composable
|
||||
public fun MapFeatureBuilder.image(
|
||||
position: Pair<Double, Double>,
|
||||
image: ImageVector,
|
||||
size: DpSize = DpSize(20.dp, 20.dp),
|
||||
zoomRange: IntRange = defaultZoomRange,
|
||||
id: FeatureId? = null,
|
||||
layer: Int = 0
|
||||
): FeatureId = addFeature(id, MapVectorImageFeature(position.toCoordinates(), image, size, zoomRange, layer))
|
||||
|
||||
public fun MapFeatureBuilder.group(
|
||||
zoomRange: IntRange = defaultZoomRange,
|
||||
id: FeatureId? = null,
|
||||
layer: Int = 0,
|
||||
builder: MapFeatureBuilder.() -> Unit
|
||||
): FeatureId {
|
||||
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))
|
||||
@@ -0,0 +1,5 @@
|
||||
package center.sciprog.maps.compose
|
||||
|
||||
public expect class MapTextFeatureFont {
|
||||
public var size: Float
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package center.sciprog.maps.compose
|
||||
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Deferred
|
||||
import kotlin.math.floor
|
||||
|
||||
public data class TileId(
|
||||
val zoom: Int,
|
||||
val i: Int,
|
||||
val j: Int,
|
||||
)
|
||||
|
||||
public data class MapTile(
|
||||
val id: TileId,
|
||||
val image: ImageBitmap,
|
||||
)
|
||||
|
||||
public interface MapTileProvider {
|
||||
public fun CoroutineScope.loadTileAsync(tileId: TileId): Deferred<MapTile>
|
||||
|
||||
public val tileSize: Int get() = DEFAULT_TILE_SIZE
|
||||
|
||||
public fun toIndex(d: Double): Int = floor(d / tileSize).toInt()
|
||||
|
||||
public fun toCoordinate(i: Int): Double = (i * tileSize).toDouble()
|
||||
|
||||
public companion object {
|
||||
public const val DEFAULT_TILE_SIZE: Int = 256
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package center.sciprog.maps.compose
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
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
|
||||
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
|
||||
/**
|
||||
*/
|
||||
public data class MapViewConfig(
|
||||
val zoomSpeed: Double = 1.0 / 3.0,
|
||||
val inferViewBoxFromFeatures: Boolean = false,
|
||||
val onClick: MapViewPoint.(PointerEvent) -> Unit = {},
|
||||
val dragHandle: DragHandle = DragHandle.BYPASS,
|
||||
val onViewChange: MapViewPoint.() -> Unit = {},
|
||||
val onSelect: (GmcRectangle) -> Unit = {},
|
||||
val zoomOnSelect: Boolean = true,
|
||||
val resetViewPoint: Boolean = false,
|
||||
)
|
||||
|
||||
@Composable
|
||||
public expect fun MapView(
|
||||
mapTileProvider: MapTileProvider,
|
||||
computeViewPoint: (canvasSize: DpSize) -> MapViewPoint,
|
||||
features: Map<FeatureId, MapFeature>,
|
||||
config: MapViewConfig = MapViewConfig(),
|
||||
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,
|
||||
config: MapViewConfig = MapViewConfig(),
|
||||
modifier: Modifier = Modifier.fillMaxSize(),
|
||||
buildFeatures: @Composable (MapFeatureBuilder.() -> Unit) = {},
|
||||
) {
|
||||
val featuresBuilder = MapFeatureBuilderImpl(mutableStateMapOf())
|
||||
featuresBuilder.buildFeatures()
|
||||
val features = remember { featuresBuilder.features }
|
||||
|
||||
val newConfig = remember(features) {
|
||||
prepareConfig(config, featuresBuilder)
|
||||
}
|
||||
|
||||
MapView(
|
||||
mapTileProvider,
|
||||
{ initialViewPoint },
|
||||
features,
|
||||
newConfig,
|
||||
modifier
|
||||
)
|
||||
}
|
||||
|
||||
internal fun GmcRectangle.computeViewPoint(
|
||||
mapTileProvider: MapTileProvider,
|
||||
): (canvasSize: DpSize) -> MapViewPoint = { canvasSize ->
|
||||
val zoom = log2(
|
||||
min(
|
||||
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: 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
|
||||
// )
|
||||
//}
|
||||
@@ -0,0 +1,3 @@
|
||||
package center.sciprog.maps.compose
|
||||
|
||||
public object DraggableAttribute: MapFeatureAttributeKey<Boolean>
|
||||
@@ -1,106 +0,0 @@
|
||||
package centre.sciprog.maps.compose
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.snapshots.SnapshotStateMap
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.unit.DpSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import centre.sciprog.maps.GeodeticMapCoordinates
|
||||
|
||||
typealias FeatureId = String
|
||||
|
||||
interface FeatureBuilder {
|
||||
fun addFeature(id: FeatureId?, feature: MapFeature): FeatureId
|
||||
|
||||
fun build(): SnapshotStateMap<FeatureId, MapFeature>
|
||||
}
|
||||
|
||||
internal class MapFeatureBuilder(initialFeatures: Map<FeatureId, MapFeature>) : FeatureBuilder {
|
||||
|
||||
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
|
||||
return safeId
|
||||
}
|
||||
|
||||
override fun build(): SnapshotStateMap<FeatureId, MapFeature> = content
|
||||
}
|
||||
|
||||
fun FeatureBuilder.circle(
|
||||
center: GeodeticMapCoordinates,
|
||||
zoomRange: IntRange = defaultZoomRange,
|
||||
size: Float = 5f,
|
||||
color: Color = Color.Red,
|
||||
id: FeatureId? = null,
|
||||
) = addFeature(
|
||||
id, MapCircleFeature(center, zoomRange, size, color)
|
||||
)
|
||||
|
||||
fun FeatureBuilder.circle(
|
||||
centerCoordinates: Pair<Double, Double>,
|
||||
zoomRange: IntRange = defaultZoomRange,
|
||||
size: Float = 5f,
|
||||
color: Color = Color.Red,
|
||||
id: FeatureId? = null,
|
||||
) = addFeature(
|
||||
id, MapCircleFeature(centerCoordinates.toCoordinates(), zoomRange, size, color)
|
||||
)
|
||||
|
||||
fun FeatureBuilder.custom(
|
||||
position: Pair<Double, Double>,
|
||||
zoomRange: IntRange = defaultZoomRange,
|
||||
id: FeatureId? = null,
|
||||
drawFeature: DrawScope.() -> Unit,
|
||||
) = addFeature(id, MapDrawFeature(position.toCoordinates(), zoomRange, drawFeature))
|
||||
|
||||
fun FeatureBuilder.line(
|
||||
aCoordinates: Pair<Double, Double>,
|
||||
bCoordinates: Pair<Double, Double>,
|
||||
zoomRange: IntRange = defaultZoomRange,
|
||||
color: Color = Color.Red,
|
||||
id: FeatureId? = null,
|
||||
) = addFeature(id, MapLineFeature(aCoordinates.toCoordinates(), bCoordinates.toCoordinates(), zoomRange, color))
|
||||
|
||||
fun FeatureBuilder.text(
|
||||
position: GeodeticMapCoordinates,
|
||||
text: String,
|
||||
zoomRange: IntRange = defaultZoomRange,
|
||||
color: Color = Color.Red,
|
||||
id: FeatureId? = null,
|
||||
) = addFeature(id, MapTextFeature(position, text, zoomRange, color))
|
||||
|
||||
fun FeatureBuilder.text(
|
||||
position: Pair<Double, Double>,
|
||||
text: String,
|
||||
zoomRange: IntRange = defaultZoomRange,
|
||||
color: Color = Color.Red,
|
||||
id: FeatureId? = null,
|
||||
) = addFeature(id, MapTextFeature(position.toCoordinates(), text, zoomRange, color))
|
||||
|
||||
@Composable
|
||||
fun FeatureBuilder.image(
|
||||
position: Pair<Double, Double>,
|
||||
image: ImageVector,
|
||||
size: DpSize = DpSize(20.dp, 20.dp),
|
||||
zoomRange: IntRange = defaultZoomRange,
|
||||
id: FeatureId? = null,
|
||||
) = addFeature(id, MapVectorImageFeature(position.toCoordinates(), image, size, zoomRange))
|
||||
|
||||
fun FeatureBuilder.group(
|
||||
zoomRange: IntRange = defaultZoomRange,
|
||||
id: FeatureId? = null,
|
||||
builder: FeatureBuilder.() -> Unit,
|
||||
): FeatureId {
|
||||
val map = MapFeatureBuilder(emptyMap()).apply(builder).build()
|
||||
val feature = MapFeatureGroup(map, zoomRange)
|
||||
return addFeature(id, feature)
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
package centre.sciprog.maps.compose
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
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 centre.sciprog.maps.GeodeticMapCoordinates
|
||||
import centre.sciprog.maps.GmcBox
|
||||
import centre.sciprog.maps.wrapAll
|
||||
|
||||
//TODO replace zoom range with zoom-based representation change
|
||||
sealed class MapFeature(val zoomRange: IntRange) {
|
||||
abstract fun getBoundingBox(zoom: Int): GmcBox?
|
||||
}
|
||||
|
||||
fun Iterable<MapFeature>.computeBoundingBox(zoom: Int): GmcBox? =
|
||||
mapNotNull { it.getBoundingBox(zoom) }.wrapAll()
|
||||
|
||||
internal fun Pair<Double, Double>.toCoordinates() = GeodeticMapCoordinates.ofDegrees(first, second)
|
||||
|
||||
internal val defaultZoomRange = 1..18
|
||||
|
||||
/**
|
||||
* A feature that decides what to show depending on the zoom value (it could change size of shape)
|
||||
*/
|
||||
class MapFeatureSelector(val selector: (zoom: Int) -> MapFeature) : MapFeature(defaultZoomRange) {
|
||||
override fun getBoundingBox(zoom: Int): GmcBox? = selector(zoom).getBoundingBox(zoom)
|
||||
}
|
||||
|
||||
class MapDrawFeature(
|
||||
val position: GeodeticMapCoordinates,
|
||||
zoomRange: IntRange = defaultZoomRange,
|
||||
val drawFeature: DrawScope.() -> Unit,
|
||||
) : MapFeature(zoomRange) {
|
||||
override fun getBoundingBox(zoom: Int): GmcBox {
|
||||
//TODO add box computation
|
||||
return GmcBox(position, position)
|
||||
}
|
||||
}
|
||||
|
||||
class MapCircleFeature(
|
||||
val center: GeodeticMapCoordinates,
|
||||
zoomRange: IntRange = defaultZoomRange,
|
||||
val size: Float = 5f,
|
||||
val color: Color = Color.Red,
|
||||
) : MapFeature(zoomRange) {
|
||||
override fun getBoundingBox(zoom: Int): GmcBox = GmcBox(center, center)
|
||||
}
|
||||
|
||||
class MapLineFeature(
|
||||
val a: GeodeticMapCoordinates,
|
||||
val b: GeodeticMapCoordinates,
|
||||
zoomRange: IntRange = defaultZoomRange,
|
||||
val color: Color = Color.Red,
|
||||
) : MapFeature(zoomRange) {
|
||||
override fun getBoundingBox(zoom: Int): GmcBox = GmcBox(a, b)
|
||||
}
|
||||
|
||||
class MapTextFeature(
|
||||
val position: GeodeticMapCoordinates,
|
||||
val text: String,
|
||||
zoomRange: IntRange = defaultZoomRange,
|
||||
val color: Color = Color.Red,
|
||||
) : MapFeature(zoomRange) {
|
||||
override fun getBoundingBox(zoom: Int): GmcBox = GmcBox(position, position)
|
||||
}
|
||||
|
||||
class MapBitmapImageFeature(
|
||||
val position: GeodeticMapCoordinates,
|
||||
val image: ImageBitmap,
|
||||
val size: IntSize = IntSize(15, 15),
|
||||
zoomRange: IntRange = defaultZoomRange,
|
||||
) : MapFeature(zoomRange) {
|
||||
override fun getBoundingBox(zoom: Int): GmcBox = GmcBox(position, position)
|
||||
}
|
||||
|
||||
class MapVectorImageFeature(
|
||||
val position: GeodeticMapCoordinates,
|
||||
val painter: Painter,
|
||||
val size: DpSize,
|
||||
zoomRange: IntRange = defaultZoomRange,
|
||||
) : MapFeature(zoomRange) {
|
||||
override fun getBoundingBox(zoom: Int): GmcBox = GmcBox(position, position)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MapVectorImageFeature(
|
||||
position: GeodeticMapCoordinates,
|
||||
image: ImageVector,
|
||||
size: DpSize = DpSize(20.dp, 20.dp),
|
||||
zoomRange: IntRange = defaultZoomRange,
|
||||
): MapVectorImageFeature = MapVectorImageFeature(position, rememberVectorPainter(image), size, zoomRange)
|
||||
|
||||
/**
|
||||
* A group of other features
|
||||
*/
|
||||
class MapFeatureGroup(
|
||||
val children: Map<FeatureId, MapFeature>,
|
||||
zoomRange: IntRange = defaultZoomRange,
|
||||
) : MapFeature(zoomRange) {
|
||||
override fun getBoundingBox(zoom: Int): GmcBox? = children.values.mapNotNull { it.getBoundingBox(zoom) }.wrapAll()
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
package centre.sciprog.maps.compose
|
||||
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Deferred
|
||||
import kotlin.math.floor
|
||||
|
||||
data class TileId(
|
||||
val zoom: Int,
|
||||
val i: Int,
|
||||
val j: Int,
|
||||
)
|
||||
|
||||
data class MapTile(
|
||||
val id: TileId,
|
||||
val image: ImageBitmap,
|
||||
)
|
||||
|
||||
interface MapTileProvider {
|
||||
fun CoroutineScope.loadTileAsync(tileId: TileId): Deferred<MapTile>
|
||||
|
||||
val tileSize: Int get() = DEFAULT_TILE_SIZE
|
||||
|
||||
fun toIndex(d: Double): Int = floor(d / tileSize).toInt()
|
||||
|
||||
fun toCoordinate(i: Int): Double = (i * tileSize).toDouble()
|
||||
|
||||
companion object {
|
||||
const val DEFAULT_TILE_SIZE = 256
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
package centre.sciprog.maps.compose
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.DpSize
|
||||
import centre.sciprog.maps.*
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.log2
|
||||
import kotlin.math.min
|
||||
|
||||
|
||||
//TODO consider replacing by modifier
|
||||
data class MapViewConfig(
|
||||
val zoomSpeed: Double = 1.0 / 3.0,
|
||||
val inferViewBoxFromFeatures: Boolean = false,
|
||||
val onClick: MapViewPoint.() -> Unit = {},
|
||||
val onViewChange: MapViewPoint.() -> Unit = {},
|
||||
val onSelect: (GmcBox) -> Unit = {},
|
||||
val zoomOnSelect: Boolean = true
|
||||
)
|
||||
|
||||
@Composable
|
||||
expect fun MapView(
|
||||
mapTileProvider: MapTileProvider,
|
||||
computeViewPoint: (canvasSize: DpSize) -> MapViewPoint,
|
||||
features: Map<FeatureId, MapFeature>,
|
||||
config: MapViewConfig = MapViewConfig(),
|
||||
modifier: Modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun MapView(
|
||||
mapTileProvider: MapTileProvider,
|
||||
initialViewPoint: MapViewPoint,
|
||||
features: Map<FeatureId, MapFeature> = emptyMap(),
|
||||
config: MapViewConfig = MapViewConfig(),
|
||||
modifier: Modifier = Modifier.fillMaxSize(),
|
||||
buildFeatures: @Composable (FeatureBuilder.() -> Unit) = {},
|
||||
) {
|
||||
val featuresBuilder = MapFeatureBuilder(features)
|
||||
featuresBuilder.buildFeatures()
|
||||
MapView(
|
||||
mapTileProvider,
|
||||
{ initialViewPoint },
|
||||
featuresBuilder.build(),
|
||||
config,
|
||||
modifier
|
||||
)
|
||||
}
|
||||
|
||||
internal fun GmcBox.getComputeViewPoint(mapTileProvider: MapTileProvider): (canvasSize: DpSize) -> MapViewPoint = { canvasSize ->
|
||||
val zoom = log2(
|
||||
min(
|
||||
canvasSize.width.value / width,
|
||||
canvasSize.height.value / height
|
||||
) * PI / mapTileProvider.tileSize
|
||||
)
|
||||
MapViewPoint(center, zoom)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MapView(
|
||||
mapTileProvider: MapTileProvider,
|
||||
box: GmcBox,
|
||||
features: Map<FeatureId, MapFeature> = emptyMap(),
|
||||
config: MapViewConfig = MapViewConfig(),
|
||||
modifier: Modifier = Modifier.fillMaxSize(),
|
||||
buildFeatures: @Composable (FeatureBuilder.() -> Unit) = {},
|
||||
) {
|
||||
val featuresBuilder = MapFeatureBuilder(features)
|
||||
featuresBuilder.buildFeatures()
|
||||
MapView(
|
||||
mapTileProvider,
|
||||
box.getComputeViewPoint(mapTileProvider),
|
||||
featuresBuilder.build(),
|
||||
config,
|
||||
modifier
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package center.sciprog.maps.compose
|
||||
|
||||
import org.jetbrains.skia.Font
|
||||
|
||||
public actual typealias MapTextFeatureFont = Font
|
||||
@@ -1,23 +1,21 @@
|
||||
package centre.sciprog.maps.compose
|
||||
package center.sciprog.maps.compose
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
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
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.PathEffect
|
||||
import androidx.compose.ui.graphics.*
|
||||
import androidx.compose.ui.graphics.drawscope.*
|
||||
import androidx.compose.ui.graphics.nativeCanvas
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.input.pointer.*
|
||||
import androidx.compose.ui.unit.*
|
||||
import centre.sciprog.maps.*
|
||||
import io.ktor.utils.io.CancellationException
|
||||
import center.sciprog.maps.coordinates.*
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.launch
|
||||
import mu.KotlinLogging
|
||||
import org.jetbrains.skia.Font
|
||||
@@ -33,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)
|
||||
}
|
||||
@@ -50,7 +48,7 @@ private val logger = KotlinLogging.logger("MapView")
|
||||
*/
|
||||
|
||||
@Composable
|
||||
actual fun MapView(
|
||||
public actual fun MapView(
|
||||
mapTileProvider: MapTileProvider,
|
||||
computeViewPoint: (canvasSize: DpSize) -> MapViewPoint,
|
||||
features: Map<FeatureId, MapFeature>,
|
||||
@@ -59,29 +57,38 @@ actual fun MapView(
|
||||
) {
|
||||
var canvasSize by remember { mutableStateOf(DpSize(512.dp, 512.dp)) }
|
||||
|
||||
var viewPointOverride: MapViewPoint? by remember {
|
||||
mutableStateOf(
|
||||
if (config.inferViewBoxFromFeatures) {
|
||||
features.values.computeBoundingBox(1)?.let { box ->
|
||||
val zoom = log2(
|
||||
min(
|
||||
canvasSize.width.value / box.width,
|
||||
canvasSize.height.value / box.height
|
||||
) * PI / mapTileProvider.tileSize
|
||||
)
|
||||
MapViewPoint(box.center, zoom)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
)
|
||||
var viewPointInternal: MapViewPoint? by remember {
|
||||
mutableStateOf(null)
|
||||
}
|
||||
|
||||
val viewPoint by derivedStateOf { viewPointOverride ?: computeViewPoint(canvasSize) }
|
||||
if (config.resetViewPoint) {
|
||||
viewPointInternal = null
|
||||
}
|
||||
|
||||
val zoom: Int by derivedStateOf { floor(viewPoint.zoom).toInt() }
|
||||
val viewPoint: MapViewPoint by derivedStateOf {
|
||||
viewPointInternal ?: if (config.inferViewBoxFromFeatures) {
|
||||
features.values.computeBoundingBox(1.0)?.let { box ->
|
||||
val zoom = log2(
|
||||
min(
|
||||
canvasSize.width.value / box.longitudeDelta.radians.value,
|
||||
canvasSize.height.value / box.latitudeDelta.radians.value
|
||||
) * PI / mapTileProvider.tileSize
|
||||
)
|
||||
MapViewPoint(box.center, zoom)
|
||||
} ?: computeViewPoint(canvasSize)
|
||||
} else {
|
||||
computeViewPoint(canvasSize)
|
||||
}
|
||||
}
|
||||
|
||||
val tileScale: Double by derivedStateOf { 2.0.pow(viewPoint.zoom - zoom) }
|
||||
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 mapTiles = remember { mutableStateListOf<MapTile>() }
|
||||
|
||||
@@ -108,51 +115,76 @@ 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.getComputeViewPoint(mapTileProvider).invoke(canvasSize)
|
||||
|
||||
config.onViewChange(newViewPoint)
|
||||
viewPointOverride = 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 newViewPoint = viewPoint.move(
|
||||
-dragAmount.x.toDp().value / tileScale,
|
||||
+dragAmount.y.toDp().value / tileScale
|
||||
)
|
||||
config.onViewChange(newViewPoint)
|
||||
viewPointOverride = 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -164,7 +196,7 @@ actual fun MapView(
|
||||
val invariant = DpOffset(xPos.toDp(), yPos.toDp()).toGeodetic()
|
||||
val newViewPoint = viewPoint.zoom(-change.scrollDelta.y.toDouble() * config.zoomSpeed, invariant)
|
||||
config.onViewChange(newViewPoint)
|
||||
viewPointOverride = newViewPoint
|
||||
viewPointInternal = newViewPoint
|
||||
}.fillMaxSize()
|
||||
|
||||
|
||||
@@ -186,20 +218,21 @@ 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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -223,7 +256,29 @@ actual fun MapView(
|
||||
feature.size,
|
||||
center = feature.center.toOffset()
|
||||
)
|
||||
|
||||
is MapRectangleFeature -> drawRect(
|
||||
feature.color,
|
||||
topLeft = feature.center.toOffset() - Offset(
|
||||
feature.size.width.toPx() / 2,
|
||||
feature.size.height.toPx() / 2
|
||||
),
|
||||
size = feature.size.toSize()
|
||||
)
|
||||
|
||||
is MapLineFeature -> drawLine(feature.color, feature.a.toOffset(), feature.b.toOffset())
|
||||
is MapArcFeature -> {
|
||||
val topLeft = feature.oval.topLeft.toOffset()
|
||||
val bottomRight = feature.oval.bottomRight.toOffset()
|
||||
|
||||
val path = Path().apply {
|
||||
addArcRad(Rect(topLeft, bottomRight), feature.startAngle, feature.endAngle - feature.startAngle)
|
||||
}
|
||||
|
||||
drawPath(path, color = feature.color, style = Stroke())
|
||||
|
||||
}
|
||||
|
||||
is MapBitmapImageFeature -> drawImage(feature.image, feature.position.toOffset())
|
||||
is MapVectorImageFeature -> {
|
||||
val offset = feature.position.toOffset()
|
||||
@@ -234,27 +289,44 @@ actual fun MapView(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is MapTextFeature -> drawIntoCanvas { canvas ->
|
||||
val offset = feature.position.toOffset()
|
||||
canvas.nativeCanvas.drawString(
|
||||
feature.text,
|
||||
offset.x + 5,
|
||||
offset.y - 5,
|
||||
Font().apply { size = 16f },
|
||||
Font().apply(feature.fontConfig),
|
||||
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(
|
||||
points = points,
|
||||
color = feature.color,
|
||||
strokeWidth = feature.stroke,
|
||||
pointMode = feature.pointMode
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
logger.error { "Unrecognized feature type: ${feature::class}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -279,7 +351,7 @@ 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)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package centre.sciprog.maps.compose
|
||||
package center.sciprog.maps.compose
|
||||
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.toComposeImageBitmap
|
||||
@@ -21,10 +21,10 @@ import kotlin.io.path.*
|
||||
/**
|
||||
* A [MapTileProvider] based on Open Street Map API. With in-memory and file cache
|
||||
*/
|
||||
class OpenStreetMapTileProvider(
|
||||
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 @@ 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 @@ 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 @@ 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
|
||||
@@ -93,7 +91,7 @@ class OpenStreetMapTileProvider(
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
public companion object {
|
||||
private val logger = KotlinLogging.logger("OpenStreetMapCache")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,21 +6,21 @@ plugins {
|
||||
val ktorVersion: String by rootProject.extra
|
||||
|
||||
kotlin {
|
||||
explicitApi = org.jetbrains.kotlin.gradle.dsl.ExplicitApiMode.Warning
|
||||
jvm {
|
||||
compilations.all {
|
||||
kotlinOptions.jvmTarget = "11"
|
||||
}
|
||||
}
|
||||
js(IR){
|
||||
js(IR) {
|
||||
browser()
|
||||
}
|
||||
sourceSets {
|
||||
commonMain{
|
||||
|
||||
sourceSets{
|
||||
commonTest{
|
||||
dependencies{
|
||||
api("io.github.microutils:kotlin-logging:2.1.23")
|
||||
implementation(kotlin("test"))
|
||||
}
|
||||
}
|
||||
val jvmMain by getting
|
||||
val jvmTest by getting
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package center.sciprog.maps.coordinates
|
||||
|
||||
import kotlin.jvm.JvmInline
|
||||
|
||||
@JvmInline
|
||||
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
|
||||
|
||||
public operator fun Distance.plus(other: Distance): Distance = Distance(kilometers + other.kilometers)
|
||||
public operator fun Distance.minus(other: Distance): Distance = Distance(kilometers - other.kilometers)
|
||||
|
||||
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
|
||||
@@ -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))
|
||||
//}
|
||||
@@ -1,11 +1,19 @@
|
||||
package centre.sciprog.maps
|
||||
package center.sciprog.maps.coordinates
|
||||
|
||||
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
|
||||
@@ -26,25 +34,23 @@ public class GeodeticMapCoordinates private constructor(public val latitude: Dou
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
internal typealias Gmc = GeodeticMapCoordinates
|
||||
|
||||
/**
|
||||
* Short name for GeodeticMapCoordinates
|
||||
*/
|
||||
public typealias Gmc = GeodeticMapCoordinates
|
||||
|
||||
//public interface GeoToScreenConversion {
|
||||
// public fun getScreenX(gmc: GeodeticMapCoordinates): Double
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -1,19 +1,19 @@
|
||||
package centre.sciprog.maps
|
||||
package center.sciprog.maps.coordinates
|
||||
|
||||
import kotlin.math.pow
|
||||
|
||||
/**
|
||||
* Observable position on the map. Includes observation coordinate and [zoom] factor
|
||||
*/
|
||||
data class MapViewPoint(
|
||||
public data class MapViewPoint(
|
||||
val focus: GeodeticMapCoordinates,
|
||||
val zoom: Double,
|
||||
) {
|
||||
val scaleFactor by lazy { WebMercatorProjection.scaleFactor(zoom) }
|
||||
val scaleFactor: Double by lazy { WebMercatorProjection.scaleFactor(zoom) }
|
||||
}
|
||||
|
||||
fun MapViewPoint.move(delta: GeodeticMapCoordinates): MapViewPoint {
|
||||
val newCoordinates = GeodeticMapCoordinates.ofRadians(
|
||||
public fun MapViewPoint.move(delta: GeodeticMapCoordinates): MapViewPoint {
|
||||
val newCoordinates = GeodeticMapCoordinates(
|
||||
(focus.latitude + delta.latitude).coerceIn(
|
||||
-MercatorProjection.MAXIMUM_LATITUDE,
|
||||
MercatorProjection.MAXIMUM_LATITUDE
|
||||
@@ -23,14 +23,14 @@ fun MapViewPoint.move(delta: GeodeticMapCoordinates): MapViewPoint {
|
||||
return MapViewPoint(newCoordinates, zoom)
|
||||
}
|
||||
|
||||
fun MapViewPoint.zoom(
|
||||
public fun MapViewPoint.zoom(
|
||||
zoomDelta: Double,
|
||||
invariant: GeodeticMapCoordinates = focus,
|
||||
): MapViewPoint = if (invariant == focus) {
|
||||
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
|
||||
)
|
||||
@@ -3,11 +3,14 @@
|
||||
* Use of this source code is governed by the Apache 2.0 license that can be found in the license/LICENSE.txt file.
|
||||
*/
|
||||
|
||||
package centre.sciprog.maps
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -3,18 +3,18 @@
|
||||
* Use of this source code is governed by the Apache 2.0 license that can be found in the license/LICENSE.txt file.
|
||||
*/
|
||||
|
||||
package centre.sciprog.maps
|
||||
package center.sciprog.maps.coordinates
|
||||
|
||||
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
|
||||
*/
|
||||
public fun scaleFactor(zoom: Double) = 256.0 / 2 / PI * 2.0.pow(zoom)
|
||||
public fun scaleFactor(zoom: Double): Double = 256.0 / 2 / PI * 2.0.pow(zoom)
|
||||
|
||||
public fun toGeodetic(mercator: WebMercatorCoordinates): GeodeticMapCoordinates {
|
||||
val scaleFactor = scaleFactor(mercator.zoom.toDouble())
|
||||
@@ -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)))
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -1,39 +0,0 @@
|
||||
package centre.sciprog.maps
|
||||
|
||||
import kotlin.math.*
|
||||
|
||||
class GmcBox(val a: GeodeticMapCoordinates, val b: GeodeticMapCoordinates)
|
||||
|
||||
fun GmcBox(latitudes: ClosedFloatingPointRange<Double>, longitudes: ClosedFloatingPointRange<Double>) = GmcBox(
|
||||
GeodeticMapCoordinates.ofRadians(latitudes.start, longitudes.start),
|
||||
GeodeticMapCoordinates.ofRadians(latitudes.endInclusive, longitudes.endInclusive)
|
||||
)
|
||||
|
||||
val GmcBox.center
|
||||
get() = GeodeticMapCoordinates.ofRadians(
|
||||
(a.latitude + b.latitude) / 2,
|
||||
(a.longitude + b.longitude) / 2
|
||||
)
|
||||
|
||||
val GmcBox.left get() = min(a.longitude, b.longitude)
|
||||
val GmcBox.right get() = max(a.longitude, b.longitude)
|
||||
|
||||
val GmcBox.top get() = max(a.latitude, b.latitude)
|
||||
val GmcBox.bottom get() = min(a.latitude, b.latitude)
|
||||
|
||||
//TODO take curvature into account
|
||||
val GmcBox.width get() = abs(a.longitude - b.longitude)
|
||||
val GmcBox.height get() = abs(a.latitude - b.latitude)
|
||||
|
||||
/**
|
||||
* Compute a minimal bounding box including all given boxes. Return null if collection is empty
|
||||
*/
|
||||
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(minLat..maxLat, minLong..maxLong)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
30
maps-kt-scheme/build.gradle.kts
Normal file
30
maps-kt-scheme/build.gradle.kts
Normal file
@@ -0,0 +1,30 @@
|
||||
import org.jetbrains.compose.compose
|
||||
|
||||
|
||||
plugins {
|
||||
kotlin("multiplatform")
|
||||
id("org.jetbrains.compose")
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvm {
|
||||
compilations.all {
|
||||
kotlinOptions.jvmTarget = "11"
|
||||
}
|
||||
withJava()
|
||||
}
|
||||
sourceSets {
|
||||
commonMain {
|
||||
dependencies {
|
||||
api("io.github.microutils:kotlin-logging:2.1.23")
|
||||
api(compose.foundation)
|
||||
}
|
||||
}
|
||||
val jvmMain by getting {
|
||||
dependencies {
|
||||
api(compose.desktop.currentOs)
|
||||
}
|
||||
}
|
||||
val jvmTest by getting
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package center.sciprog.maps.scheme
|
||||
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
data class SchemeCoordinates(val x: Float, val y: Float)
|
||||
|
||||
data class SchemeCoordinateBox(
|
||||
val a: SchemeCoordinates,
|
||||
val b: SchemeCoordinates,
|
||||
)
|
||||
|
||||
val SchemeCoordinateBox.top get() = max(a.y, b.y)
|
||||
val SchemeCoordinateBox.bottom get() = min(a.y, b.y)
|
||||
|
||||
val SchemeCoordinateBox.right get() = max(a.x, b.x)
|
||||
val SchemeCoordinateBox.left get() = min(a.x, b.x)
|
||||
|
||||
val SchemeCoordinateBox.width get() = abs(a.x - b.x)
|
||||
val SchemeCoordinateBox.height get() = abs(a.y - b.y)
|
||||
|
||||
val SchemeCoordinateBox.center get() = SchemeCoordinates((a.x + b.x) / 2, (a.y + b.y) / 2)
|
||||
|
||||
|
||||
fun Collection<SchemeCoordinateBox>.wrapAll(): SchemeCoordinateBox? {
|
||||
if (isEmpty()) return null
|
||||
val minX = minOf { it.left }
|
||||
val maxX = maxOf { it.right }
|
||||
|
||||
val minY = minOf { it.bottom }
|
||||
val maxY = maxOf { it.top }
|
||||
return SchemeCoordinateBox(
|
||||
SchemeCoordinates(minX, minY),
|
||||
SchemeCoordinates(maxX, maxY)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package center.sciprog.maps.scheme
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
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.scheme.SchemeFeature.Companion.defaultScaleRange
|
||||
|
||||
internal typealias FloatRange = ClosedFloatingPointRange<Float>
|
||||
|
||||
sealed class SchemeFeature(val scaleRange: FloatRange) {
|
||||
abstract fun getBoundingBox(scale: Float): SchemeCoordinateBox?
|
||||
|
||||
companion object {
|
||||
val defaultScaleRange = 0f..Float.MAX_VALUE
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun Iterable<SchemeFeature>.computeBoundingBox(scale: Float): SchemeCoordinateBox? =
|
||||
mapNotNull { it.getBoundingBox(scale) }.wrapAll()
|
||||
|
||||
|
||||
internal fun Pair<Number, Number>.toCoordinates() = SchemeCoordinates(first.toFloat(), second.toFloat())
|
||||
|
||||
/**
|
||||
* A background image that is bound to scheme coordinates and is scaled together with them
|
||||
*
|
||||
* @param position the size of background in scheme size units. The screen units to scheme units ratio equals scale.
|
||||
*/
|
||||
class SchemeBackgroundFeature(
|
||||
val position: SchemeCoordinateBox,
|
||||
val painter: Painter,
|
||||
scaleRange: FloatRange = defaultScaleRange,
|
||||
) : SchemeFeature(scaleRange) {
|
||||
override fun getBoundingBox(scale: Float): SchemeCoordinateBox = position
|
||||
}
|
||||
|
||||
class SchemeFeatureSelector(val selector: (scale: Float) -> SchemeFeature) : SchemeFeature(defaultScaleRange) {
|
||||
override fun getBoundingBox(scale: Float): SchemeCoordinateBox? = selector(scale).getBoundingBox(scale)
|
||||
}
|
||||
|
||||
class SchemeDrawFeature(
|
||||
val position: SchemeCoordinates,
|
||||
scaleRange: FloatRange = defaultScaleRange,
|
||||
val drawFeature: DrawScope.() -> Unit,
|
||||
) : SchemeFeature(scaleRange) {
|
||||
override fun getBoundingBox(scale: Float): SchemeCoordinateBox = SchemeCoordinateBox(position, position)
|
||||
}
|
||||
|
||||
class SchemeCircleFeature(
|
||||
val center: SchemeCoordinates,
|
||||
scaleRange: FloatRange = defaultScaleRange,
|
||||
val size: Float = 5f,
|
||||
val color: Color = Color.Red,
|
||||
) : SchemeFeature(scaleRange) {
|
||||
override fun getBoundingBox(scale: Float): SchemeCoordinateBox = SchemeCoordinateBox(center, center)
|
||||
}
|
||||
|
||||
class SchemeLineFeature(
|
||||
val a: SchemeCoordinates,
|
||||
val b: SchemeCoordinates,
|
||||
scaleRange: FloatRange = defaultScaleRange,
|
||||
val color: Color = Color.Red,
|
||||
) : SchemeFeature(scaleRange) {
|
||||
override fun getBoundingBox(scale: Float): SchemeCoordinateBox = SchemeCoordinateBox(a, b)
|
||||
}
|
||||
|
||||
class SchemeTextFeature(
|
||||
val position: SchemeCoordinates,
|
||||
val text: String,
|
||||
scaleRange: FloatRange = defaultScaleRange,
|
||||
val color: Color = Color.Red,
|
||||
) : SchemeFeature(scaleRange) {
|
||||
override fun getBoundingBox(scale: Float): SchemeCoordinateBox = SchemeCoordinateBox(position, position)
|
||||
}
|
||||
|
||||
class SchemeBitmapFeature(
|
||||
val position: SchemeCoordinates,
|
||||
val image: ImageBitmap,
|
||||
val size: IntSize = IntSize(15, 15),
|
||||
scaleRange: FloatRange = defaultScaleRange,
|
||||
) : SchemeFeature(scaleRange) {
|
||||
override fun getBoundingBox(scale: Float): SchemeCoordinateBox = SchemeCoordinateBox(position, position)
|
||||
}
|
||||
|
||||
class SchemeImageFeature(
|
||||
val position: SchemeCoordinates,
|
||||
val painter: Painter,
|
||||
val size: DpSize,
|
||||
scaleRange: FloatRange = defaultScaleRange,
|
||||
) : SchemeFeature(scaleRange) {
|
||||
override fun getBoundingBox(scale: Float): SchemeCoordinateBox = SchemeCoordinateBox(position, position)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SchemeVectorImageFeature(
|
||||
position: SchemeCoordinates,
|
||||
image: ImageVector,
|
||||
size: DpSize = DpSize(20.dp, 20.dp),
|
||||
scaleRange: FloatRange = defaultScaleRange,
|
||||
): SchemeImageFeature = SchemeImageFeature(position, rememberVectorPainter(image), size, scaleRange)
|
||||
|
||||
/**
|
||||
* A group of other features
|
||||
*/
|
||||
class SchemeFeatureGroup(
|
||||
val children: Map<FeatureId, SchemeFeature>,
|
||||
scaleRange: FloatRange = defaultScaleRange,
|
||||
) : SchemeFeature(scaleRange) {
|
||||
override fun getBoundingBox(scale: Float): SchemeCoordinateBox? =
|
||||
children.values.mapNotNull { it.getBoundingBox(scale) }.wrapAll()
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package center.sciprog.maps.scheme
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.snapshots.SnapshotStateMap
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.unit.DpSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import center.sciprog.maps.scheme.SchemeFeature.Companion.defaultScaleRange
|
||||
|
||||
typealias FeatureId = String
|
||||
|
||||
interface SchemeFeatureBuilder {
|
||||
fun addFeature(id: FeatureId?, feature: SchemeFeature): FeatureId
|
||||
|
||||
fun build(): SnapshotStateMap<FeatureId, SchemeFeature>
|
||||
}
|
||||
|
||||
internal class SchemeFeatureBuilderImpl(
|
||||
initialFeatures: Map<FeatureId, SchemeFeature>,
|
||||
) : SchemeFeatureBuilder {
|
||||
|
||||
private val content: SnapshotStateMap<FeatureId, SchemeFeature> =
|
||||
mutableStateMapOf<FeatureId, SchemeFeature>().apply {
|
||||
putAll(initialFeatures)
|
||||
}
|
||||
|
||||
private fun generateID(feature: SchemeFeature): FeatureId = "@feature[${feature.hashCode().toUInt()}]"
|
||||
|
||||
override fun addFeature(id: FeatureId?, feature: SchemeFeature): FeatureId {
|
||||
val safeId = id ?: generateID(feature)
|
||||
content[id ?: generateID(feature)] = feature
|
||||
return safeId
|
||||
}
|
||||
|
||||
override fun build(): SnapshotStateMap<FeatureId, SchemeFeature> = content
|
||||
}
|
||||
|
||||
fun SchemeFeatureBuilder.background(
|
||||
painter: Painter,
|
||||
box: SchemeCoordinateBox,
|
||||
id: FeatureId? = null,
|
||||
): FeatureId = addFeature(
|
||||
id,
|
||||
SchemeBackgroundFeature(box, painter)
|
||||
)
|
||||
|
||||
fun SchemeFeatureBuilder.background(
|
||||
painter: Painter,
|
||||
size: Size = painter.intrinsicSize,
|
||||
offset: SchemeCoordinates = SchemeCoordinates(0f, 0f),
|
||||
id: FeatureId? = null,
|
||||
): FeatureId {
|
||||
val box = SchemeCoordinateBox(
|
||||
offset,
|
||||
SchemeCoordinates(size.width + offset.x, size.height + offset.y)
|
||||
)
|
||||
return background(painter, box, id)
|
||||
}
|
||||
|
||||
fun SchemeFeatureBuilder.circle(
|
||||
center: SchemeCoordinates,
|
||||
scaleRange: FloatRange = defaultScaleRange,
|
||||
size: Float = 5f,
|
||||
color: Color = Color.Red,
|
||||
id: FeatureId? = null,
|
||||
) = addFeature(
|
||||
id, SchemeCircleFeature(center, scaleRange, size, color)
|
||||
)
|
||||
|
||||
fun SchemeFeatureBuilder.circle(
|
||||
centerCoordinates: Pair<Number, Number>,
|
||||
scaleRange: FloatRange = defaultScaleRange,
|
||||
size: Float = 5f,
|
||||
color: Color = Color.Red,
|
||||
id: FeatureId? = null,
|
||||
) = addFeature(
|
||||
id, SchemeCircleFeature(centerCoordinates.toCoordinates(), scaleRange, size, color)
|
||||
)
|
||||
|
||||
fun SchemeFeatureBuilder.draw(
|
||||
position: Pair<Number, Number>,
|
||||
scaleRange: FloatRange = defaultScaleRange,
|
||||
id: FeatureId? = null,
|
||||
drawFeature: DrawScope.() -> Unit,
|
||||
) = addFeature(id, SchemeDrawFeature(position.toCoordinates(), scaleRange, drawFeature))
|
||||
|
||||
fun SchemeFeatureBuilder.line(
|
||||
aCoordinates: Pair<Number, Number>,
|
||||
bCoordinates: Pair<Number, Number>,
|
||||
scaleRange: FloatRange = defaultScaleRange,
|
||||
color: Color = Color.Red,
|
||||
id: FeatureId? = null,
|
||||
) = addFeature(id, SchemeLineFeature(aCoordinates.toCoordinates(), bCoordinates.toCoordinates(), scaleRange, color))
|
||||
|
||||
fun SchemeFeatureBuilder.text(
|
||||
position: SchemeCoordinates,
|
||||
text: String,
|
||||
scaleRange: FloatRange = defaultScaleRange,
|
||||
color: Color = Color.Red,
|
||||
id: FeatureId? = null,
|
||||
) = addFeature(id, SchemeTextFeature(position, text, scaleRange, color))
|
||||
|
||||
fun SchemeFeatureBuilder.text(
|
||||
position: Pair<Number, Number>,
|
||||
text: String,
|
||||
scaleRange: FloatRange = defaultScaleRange,
|
||||
color: Color = Color.Red,
|
||||
id: FeatureId? = null,
|
||||
) = addFeature(id, SchemeTextFeature(position.toCoordinates(), text, scaleRange, color))
|
||||
|
||||
@Composable
|
||||
fun SchemeFeatureBuilder.image(
|
||||
position: Pair<Number, Number>,
|
||||
image: ImageVector,
|
||||
size: DpSize = DpSize(20.dp, 20.dp),
|
||||
scaleRange: FloatRange = defaultScaleRange,
|
||||
id: FeatureId? = null,
|
||||
) = addFeature(id, SchemeVectorImageFeature(position.toCoordinates(), image, size, scaleRange))
|
||||
|
||||
fun SchemeFeatureBuilder.group(
|
||||
scaleRange: FloatRange = defaultScaleRange,
|
||||
id: FeatureId? = null,
|
||||
builder: SchemeFeatureBuilder.() -> Unit,
|
||||
): FeatureId {
|
||||
val map = SchemeFeatureBuilderImpl(emptyMap()).apply(builder).build()
|
||||
val feature = SchemeFeatureGroup(map, scaleRange)
|
||||
return addFeature(id, feature)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package center.sciprog.maps.scheme
|
||||
|
||||
import kotlin.math.pow
|
||||
|
||||
data class SchemeViewPoint(val focus: SchemeCoordinates, val scale: Float = 1f)
|
||||
|
||||
fun SchemeViewPoint.move(deltaX: Float, deltaY: Float): SchemeViewPoint {
|
||||
return copy(focus = SchemeCoordinates(focus.x + deltaX, focus.y + deltaY))
|
||||
}
|
||||
|
||||
fun SchemeViewPoint.zoom(
|
||||
zoom: Float,
|
||||
invariant: SchemeCoordinates = focus,
|
||||
): SchemeViewPoint = if (invariant == focus) {
|
||||
copy(scale = scale * 2f.pow(zoom))
|
||||
} else {
|
||||
val difScale = (1 - 2f.pow(-zoom))
|
||||
val newCenter = SchemeCoordinates(
|
||||
focus.x + (invariant.x - focus.x) * difScale,
|
||||
focus.y + (invariant.y - focus.y) * difScale
|
||||
)
|
||||
SchemeViewPoint(newCenter, scale * 2f.pow(zoom))
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
package center.sciprog.maps.scheme
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
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.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.PathEffect
|
||||
import androidx.compose.ui.graphics.drawscope.*
|
||||
import androidx.compose.ui.graphics.nativeCanvas
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.input.pointer.*
|
||||
import androidx.compose.ui.unit.DpOffset
|
||||
import androidx.compose.ui.unit.DpSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import mu.KotlinLogging
|
||||
import org.jetbrains.skia.Font
|
||||
import org.jetbrains.skia.Paint
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
|
||||
private fun Color.toPaint(): Paint = Paint().apply {
|
||||
isAntiAlias = true
|
||||
color = toArgb()
|
||||
}
|
||||
|
||||
private fun IntRange.intersect(other: IntRange) = max(first, other.first)..min(last, other.last)
|
||||
|
||||
private val logger = KotlinLogging.logger("SchemeView")
|
||||
|
||||
data class SchemeViewConfig(
|
||||
val zoomSpeed: Float = 1f / 3f,
|
||||
val inferViewBoxFromFeatures: Boolean = false,
|
||||
val onClick: SchemeViewPoint.() -> Unit = {},
|
||||
val onViewChange: SchemeViewPoint.() -> Unit = {},
|
||||
val onSelect: (SchemeCoordinateBox) -> Unit = {},
|
||||
val zoomOnSelect: Boolean = true,
|
||||
)
|
||||
|
||||
@Composable
|
||||
public fun SchemeView(
|
||||
computeViewPoint: (canvasSize: DpSize) -> SchemeViewPoint,
|
||||
features: Map<FeatureId, SchemeFeature>,
|
||||
config: SchemeViewConfig = SchemeViewConfig(),
|
||||
modifier: Modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
|
||||
var canvasSize by remember { mutableStateOf(DpSize(512.dp, 512.dp)) }
|
||||
|
||||
var viewPointInternal: SchemeViewPoint? by remember {
|
||||
mutableStateOf(null)
|
||||
}
|
||||
|
||||
val viewPoint: SchemeViewPoint by derivedStateOf {
|
||||
viewPointInternal ?: if (config.inferViewBoxFromFeatures) {
|
||||
features.values.computeBoundingBox(1f)?.let { box ->
|
||||
val scale = min(
|
||||
canvasSize.width.value / box.width,
|
||||
canvasSize.height.value / box.height
|
||||
)
|
||||
SchemeViewPoint(box.center, scale)
|
||||
} ?: computeViewPoint(canvasSize)
|
||||
} else {
|
||||
computeViewPoint(canvasSize)
|
||||
}
|
||||
}
|
||||
|
||||
fun DpOffset.toCoordinates(): SchemeCoordinates = SchemeCoordinates(
|
||||
(x - canvasSize.width / 2).value / viewPoint.scale + viewPoint.focus.x,
|
||||
(canvasSize.height / 2 - y).value / viewPoint.scale + viewPoint.focus.y
|
||||
)
|
||||
|
||||
// Selection rectangle. If null - no selection
|
||||
var selectRect by remember { mutableStateOf<Rect?>(null) }
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
val canvasModifier = modifier.pointerInput(Unit) {
|
||||
forEachGesture {
|
||||
awaitPointerEventScope {
|
||||
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 box = SchemeCoordinateBox(
|
||||
rect.topLeft.toDpOffset().toCoordinates(),
|
||||
rect.bottomRight.toDpOffset().toCoordinates()
|
||||
)
|
||||
config.onSelect(box)
|
||||
if (config.zoomOnSelect) {
|
||||
val newScale = min(
|
||||
canvasSize.width.value / box.width,
|
||||
canvasSize.height.value / box.height
|
||||
)
|
||||
|
||||
val newViewPoint = SchemeViewPoint(box.center, newScale)
|
||||
|
||||
config.onViewChange(newViewPoint)
|
||||
viewPointInternal = newViewPoint
|
||||
}
|
||||
selectRect = null
|
||||
}
|
||||
} else {
|
||||
val dragStart = change.position
|
||||
val dpPos = DpOffset(dragStart.x.toDp(), dragStart.y.toDp())
|
||||
config.onClick(SchemeViewPoint(dpPos.toCoordinates(), viewPoint.scale))
|
||||
drag(change.id) { dragChange ->
|
||||
val dragAmount = dragChange.position - dragChange.previousPosition
|
||||
val newViewPoint = viewPoint.move(
|
||||
-dragAmount.x.toDp().value / viewPoint.scale,
|
||||
dragAmount.y.toDp().value / viewPoint.scale
|
||||
)
|
||||
config.onViewChange(newViewPoint)
|
||||
viewPointInternal = newViewPoint
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}.onPointerEvent(PointerEventType.Scroll) {
|
||||
val change = it.changes.first()
|
||||
val (xPos, yPos) = change.position
|
||||
//compute invariant point of translation
|
||||
val invariant = DpOffset(xPos.toDp(), yPos.toDp()).toCoordinates()
|
||||
val newViewPoint = viewPoint.zoom(-change.scrollDelta.y * config.zoomSpeed, invariant)
|
||||
config.onViewChange(newViewPoint)
|
||||
viewPointInternal = newViewPoint
|
||||
}.fillMaxSize()
|
||||
|
||||
Canvas(canvasModifier) {
|
||||
fun SchemeCoordinates.toOffset(): Offset = Offset(
|
||||
(canvasSize.width / 2 + (x.dp - viewPoint.focus.x.dp) * viewPoint.scale).toPx(),
|
||||
(canvasSize.height / 2 + (viewPoint.focus.y.dp - y.dp) * viewPoint.scale).toPx()
|
||||
)
|
||||
|
||||
|
||||
fun DrawScope.drawFeature(scale: Float, feature: SchemeFeature) {
|
||||
when (feature) {
|
||||
is SchemeBackgroundFeature -> {
|
||||
val offset = SchemeCoordinates(feature.position.left, feature.position.top).toOffset()
|
||||
|
||||
val backgroundSize = DpSize(
|
||||
(feature.position.width * scale).dp,
|
||||
(feature.position.height * scale).dp
|
||||
).toSize()
|
||||
|
||||
translate(offset.x, offset.y) {
|
||||
with(feature.painter) {
|
||||
draw(backgroundSize)
|
||||
}
|
||||
}
|
||||
}
|
||||
is SchemeFeatureSelector -> drawFeature(scale, feature.selector(scale))
|
||||
is SchemeCircleFeature -> drawCircle(
|
||||
feature.color,
|
||||
feature.size,
|
||||
center = feature.center.toOffset()
|
||||
)
|
||||
is SchemeLineFeature -> drawLine(feature.color, feature.a.toOffset(), feature.b.toOffset())
|
||||
is SchemeBitmapFeature -> drawImage(feature.image, feature.position.toOffset())
|
||||
is SchemeImageFeature -> {
|
||||
val offset = feature.position.toOffset()
|
||||
val imageSize = feature.size.toSize()
|
||||
translate(offset.x - imageSize.width / 2, offset.y - imageSize.height / 2) {
|
||||
with(feature.painter) {
|
||||
draw(imageSize)
|
||||
}
|
||||
}
|
||||
}
|
||||
is SchemeTextFeature -> drawIntoCanvas { canvas ->
|
||||
val offset = feature.position.toOffset()
|
||||
canvas.nativeCanvas.drawString(
|
||||
feature.text,
|
||||
offset.x + 5,
|
||||
offset.y - 5,
|
||||
Font().apply { size = 16f },
|
||||
feature.color.toPaint()
|
||||
)
|
||||
}
|
||||
is SchemeDrawFeature -> {
|
||||
val offset = feature.position.toOffset()
|
||||
translate(offset.x, offset.y) {
|
||||
feature.drawFeature(this)
|
||||
}
|
||||
}
|
||||
is SchemeFeatureGroup -> {
|
||||
feature.children.values.forEach {
|
||||
drawFeature(scale, it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (canvasSize != size.toDpSize()) {
|
||||
canvasSize = size.toDpSize()
|
||||
logger.debug { "Recalculate canvas. Size: $size" }
|
||||
}
|
||||
clipRect {
|
||||
features.values.filterIsInstance<SchemeBackgroundFeature>().forEach { background ->
|
||||
drawFeature(viewPoint.scale, background)
|
||||
}
|
||||
features.values.filter {
|
||||
it !is SchemeBackgroundFeature && viewPoint.scale in it.scaleRange
|
||||
}.forEach { feature ->
|
||||
drawFeature(viewPoint.scale, feature)
|
||||
}
|
||||
}
|
||||
selectRect?.let { rect ->
|
||||
drawRect(
|
||||
color = Color.Blue,
|
||||
topLeft = rect.topLeft,
|
||||
size = rect.size,
|
||||
alpha = 0.5f,
|
||||
style = Stroke(
|
||||
width = 2f,
|
||||
pathEffect = PathEffect.dashPathEffect(floatArrayOf(10f, 10f), 0f)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SchemeView(
|
||||
initialViewPoint: SchemeViewPoint,
|
||||
features: Map<FeatureId, SchemeFeature> = emptyMap(),
|
||||
config: SchemeViewConfig = SchemeViewConfig(),
|
||||
modifier: Modifier = Modifier.fillMaxSize(),
|
||||
buildFeatures: @Composable (SchemeFeatureBuilder.() -> Unit) = {},
|
||||
) {
|
||||
val featuresBuilder = SchemeFeatureBuilderImpl(features)
|
||||
featuresBuilder.buildFeatures()
|
||||
SchemeView(
|
||||
{ initialViewPoint },
|
||||
featuresBuilder.build(),
|
||||
config,
|
||||
modifier
|
||||
)
|
||||
}
|
||||
BIN
maps-kt-scheme/src/jvmMain/resources/middle-earth.jpg
Normal file
BIN
maps-kt-scheme/src/jvmMain/resources/middle-earth.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 469 KiB |
@@ -23,6 +23,8 @@ pluginManagement {
|
||||
include(
|
||||
":maps-kt-core",
|
||||
":maps-kt-compose",
|
||||
":demo"
|
||||
":demo:maps",
|
||||
":maps-kt-scheme",
|
||||
":demo:scheme"
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user