16 Commits

Author SHA1 Message Date
a.kalmakhanov
516d8d0233 State Encapsulation Finished 2022-08-11 13:24:56 +06:00
a.kalmakhanov
6b6fa45596 Merge branch 'main' into state_encapsulation
# Conflicts:
#	demo/maps/src/jvmMain/kotlin/Main.kt
#	maps-kt-compose/src/jvmMain/kotlin/center/sciprog/maps/compose/MapViewJvm.kt
2022-08-11 12:13:30 +06:00
a.kalmakhanov
13b44a8091 State Encapsulation In progress 2022-08-11 11:58:26 +06:00
Alexander Nozik
190877c10e Merge pull request #11 from mipt-npm/drag
Ability to customise onDrag functionality
2022-08-08 20:28:17 +03:00
a.kalmakhanov
ccf61951dc Clustering implementation for demo 2022-07-28 19:30:50 +06:00
a.kalmakhanov
954cc26bfd Ability to customise onDrag functionality 2022-07-28 16:47:14 +06:00
Alexander Nozik
22cbeddaf0 Merge pull request #10 from mipt-npm/points
Added new feature to draw points on the map
2022-07-27 16:44:11 +03:00
a.kalmakhanov
3e2c8d2db2 Added new feature to draw points on the map 2022-07-27 17:48:59 +06:00
14acd88358 Add arc feature. Add (approximate) ellipsoid 2022-07-23 18:37:14 +03:00
4c3aefcfae package refactoring 2022-07-23 13:49:47 +03:00
8867388e85 package refactoring 2022-07-23 11:30:33 +03:00
2c87ba7638 package refactoring 2022-07-23 11:24:37 +03:00
cdee88573d Fix package naming 2022-07-23 10:58:16 +03:00
09dfdcc84a Add explicit API for core 2022-07-23 10:40:36 +03:00
14b3142f43 Add schemes in a separate module 2022-07-23 10:27:58 +03:00
7ada7f85f2 small autozoom fix 2022-07-23 09:59:14 +03:00
40 changed files with 1859 additions and 805 deletions

View File

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

View File

@@ -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"
}
}

View File

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

View File

@@ -0,0 +1,148 @@
// 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.material.icons.Icons
import androidx.compose.material.icons.filled.Home
import androidx.compose.runtime.*
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.PointMode
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application
import center.sciprog.maps.compose.*
import center.sciprog.maps.coordinates.*
import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import java.nio.file.Path
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)}"
@Composable
@Preview
fun App() {
MaterialTheme {
//create a view point
val viewPoint = remember {
MapViewPoint(
GeodeticMapCoordinates.ofDegrees(55.7558, 37.6173),
8.0
)
}
val scope = rememberCoroutineScope()
val mapTileProvider = remember {
OpenStreetMapTileProvider(
client = HttpClient(CIO),
cacheDirectory = Path.of("mapCache")
)
}
var centerCoordinates by remember { mutableStateOf<GeodeticMapCoordinates?>(null) }
val pointOne = 55.568548 to 37.568604
var pointTwo by remember { mutableStateOf(55.929444 to 37.518434) }
val pointThree = 60.929444 to 37.518434
val state = MapViewState(
mapTileProvider = mapTileProvider,
initialViewPoint = { viewPoint },
) {
image(pointOne, Icons.Filled.Home)
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,
)
draw(
position = pointThree,
getBoundingBox = {
GmcBox.withCenter(
center = GeodeticMapCoordinates.ofDegrees(
pointThree.first,
pointThree.second
),
height = Distance(0.001),
width = Distance(0.001)
)
}
) {
drawLine(start = Offset(-10f, -10f), end = Offset(10f, 10f), color = Color.Red)
drawLine(start = Offset(-10f, 10f), end = Offset(10f, -10f), color = Color.Red)
}
arc(pointOne, Distance(10.0), 0f, PI)
line(pointOne, pointTwo, id = "line")
text(pointOne, "Home", font = { size = 32f })
centerCoordinates?.let {
group(id = "center") {
circle(center = it, color = Color.Blue, size = 1f)
text(position = it, it.toShortString(), color = Color.Blue)
}
}
scope.launch {
while (isActive) {
delay(200)
//Overwrite a feature with new color
circle(
pointTwo,
id = circleId,
color = Color(Random.nextFloat(), Random.nextFloat(), Random.nextFloat())
)
}
}
}
val config = MapViewConfig(
onViewChange = { centerCoordinates = focus },
onDrag = { start, end ->
val markerRadius = 5f
val startPosition = with(state) { start.focus.toOffset(this@MapViewConfig) }
val markerLocation = with(state) {
GeodeticMapCoordinates.ofDegrees(pointTwo.first, pointTwo.second).toOffset(this@MapViewConfig)
}
if (startPosition.x in (markerLocation.x - markerRadius)..(markerLocation.x + markerRadius) &&
startPosition.y in (markerLocation.y - markerRadius)..(markerLocation.y + markerRadius)
) {
pointTwo = pointTwo.first + (end.focus.latitude - start.focus.latitude).toDegrees() to
pointTwo.second + (end.focus.longitude - start.focus.longitude).toDegrees()
false// returning false, because when we are dragging circle we don't want to drag map
} else true
}
)
MapView(
mapViewState = state,
mapViewConfig = config
)
}
}
fun main() = application {
Window(onCloseRequest = ::exitApplication) {
App()
}
}

View 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"
}
}
}

View File

@@ -0,0 +1,47 @@
// 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.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.*
@Composable
@Preview
fun App() {
MaterialTheme {
//create a view point
val viewPoint = remember {
SchemeViewPoint(
SchemeCoordinates(0f, 0f),
1f
)
}
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)
}
}
}
fun main() = application {
Window(onCloseRequest = ::exitApplication) {
App()
}
}

View File

@@ -1,107 +0,0 @@
// 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.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.window.Window
import androidx.compose.ui.window.application
import centre.sciprog.maps.GeodeticMapCoordinates
import centre.sciprog.maps.MapViewPoint
import centre.sciprog.maps.compose.*
import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import java.nio.file.Path
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)}"
@Composable
@Preview
fun App() {
MaterialTheme {
//create a view point
val viewPoint = remember {
MapViewPoint(
GeodeticMapCoordinates.ofDegrees(55.7558, 37.6173),
8.0
)
}
val scope = rememberCoroutineScope()
val mapTileProvider = remember {
OpenStreetMapTileProvider(
client = HttpClient(CIO),
cacheDirectory = Path.of("mapCache")
)
}
var centerCoordinates by remember { mutableStateOf<GeodeticMapCoordinates?>(null) }
MapView(
mapTileProvider = mapTileProvider,
initialViewPoint = viewPoint,
config = MapViewConfig(
inferViewBoxFromFeatures = true,
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
val circleId: FeatureId = circle(
centerCoordinates = pointTwo,
)
custom(position = pointThree) {
drawRect(
color = Color.Red,
topLeft = Offset(-10f, -10f),
size = Size(20f, 20f)
)
}
line(pointOne, pointTwo)
text(pointOne, "Home")
centerCoordinates?.let {
group(id = "center") {
circle(center = it, color = Color.Blue, size = 1f)
text(position = it, it.toShortString(), color = Color.Blue)
}
}
scope.launch {
while (isActive) {
delay(200)
//Overwrite a feature with new color
circle(
pointTwo,
id = circleId,
color = Color(Random.nextFloat(), Random.nextFloat(), Random.nextFloat())
)
}
}
}
}
}
fun main() = application {
Window(onCloseRequest = ::exitApplication) {
App()
}
}

View File

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

View File

@@ -1,4 +1,4 @@
package centre.sciprog.maps.compose
package center.sciprog.maps.compose
import kotlin.jvm.Synchronized

View File

@@ -0,0 +1,133 @@
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.GeodeticMapCoordinates
import center.sciprog.maps.coordinates.GmcBox
import center.sciprog.maps.coordinates.wrapAll
public interface MapFeature {
public val zoomRange: IntRange
public fun getBoundingBox(zoom: Int): GmcBox?
}
public 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)
*/
public class MapFeatureSelector(
public val selector: (zoom: Int) -> MapFeature,
) : MapFeature {
override val zoomRange: IntRange get() = defaultZoomRange
override fun getBoundingBox(zoom: Int): GmcBox? = selector(zoom).getBoundingBox(zoom)
}
public class MapDrawFeature(
public val position: GeodeticMapCoordinates,
override val zoomRange: IntRange = defaultZoomRange,
private val computeBoundingBox: (zoom: Int) -> GmcBox,
public val drawFeature: DrawScope.() -> Unit,
) : MapFeature {
override fun getBoundingBox(zoom: Int): GmcBox = computeBoundingBox(zoom)
}
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
) : MapFeature {
override fun getBoundingBox(zoom: Int): GmcBox {
return GmcBox(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,
) : MapFeature {
override fun getBoundingBox(zoom: Int): GmcBox = GmcBox(center, center)
}
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,
) : MapFeature {
override fun getBoundingBox(zoom: Int): GmcBox = GmcBox(center, center)
}
public class MapLineFeature(
public val a: GeodeticMapCoordinates,
public val b: GeodeticMapCoordinates,
override val zoomRange: IntRange = defaultZoomRange,
public val color: Color = Color.Red,
) : MapFeature {
override fun getBoundingBox(zoom: Int): GmcBox = GmcBox(a, b)
}
public class MapArcFeature(
public val oval: GmcBox,
public val startAngle: Float,
public val endAngle: Float,
override val zoomRange: IntRange = defaultZoomRange,
public val color: Color = Color.Red,
) : MapFeature {
override fun getBoundingBox(zoom: Int): GmcBox = oval
}
public class MapBitmapImageFeature(
public val position: GeodeticMapCoordinates,
public val image: ImageBitmap,
public val size: IntSize = IntSize(15, 15),
override val zoomRange: IntRange = defaultZoomRange,
) : MapFeature {
override fun getBoundingBox(zoom: Int): GmcBox = GmcBox(position, position)
}
public class MapVectorImageFeature(
public val position: GeodeticMapCoordinates,
public val painter: Painter,
public val size: DpSize,
override val zoomRange: IntRange = defaultZoomRange,
) : MapFeature {
override fun getBoundingBox(zoom: Int): GmcBox = GmcBox(position, position)
}
@Composable
public 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
*/
public class MapFeatureGroup(
public val children: Map<FeatureId, MapFeature>,
override val zoomRange: IntRange = defaultZoomRange,
) : MapFeature {
override fun getBoundingBox(zoom: Int): GmcBox? = children.values.mapNotNull { it.getBoundingBox(zoom) }.wrapAll()
}

View File

@@ -0,0 +1,157 @@
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.GmcBox
public typealias FeatureId = String
public interface MapFeatureBuilder {
public fun addFeature(id: FeatureId?, feature: MapFeature): FeatureId
public fun build(): SnapshotStateMap<FeatureId, MapFeature>
}
internal class MapFeatureBuilderImpl(initialFeatures: Map<FeatureId, MapFeature>) : MapFeatureBuilder {
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
}
public fun MapFeatureBuilder.circle(
center: GeodeticMapCoordinates,
zoomRange: IntRange = defaultZoomRange,
size: Float = 5f,
color: Color = Color.Red,
id: FeatureId? = null,
): FeatureId = addFeature(
id, MapCircleFeature(center, zoomRange, size, color)
)
public fun MapFeatureBuilder.circle(
centerCoordinates: Pair<Double, Double>,
zoomRange: IntRange = defaultZoomRange,
size: Float = 5f,
color: Color = Color.Red,
id: FeatureId? = null,
): FeatureId = addFeature(
id, MapCircleFeature(centerCoordinates.toCoordinates(), zoomRange, size, color)
)
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,
): FeatureId = addFeature(
id, MapRectangleFeature(centerCoordinates.toCoordinates(), zoomRange, size, color)
)
public fun MapFeatureBuilder.draw(
position: Pair<Double, Double>,
zoomRange: IntRange = defaultZoomRange,
id: FeatureId? = null,
getBoundingBox: (Int) -> GmcBox,
drawFeature: DrawScope.() -> Unit,
): FeatureId = addFeature(id, MapDrawFeature(position.toCoordinates(), zoomRange, getBoundingBox, drawFeature))
public fun MapFeatureBuilder.line(
aCoordinates: Pair<Double, Double>,
bCoordinates: Pair<Double, Double>,
zoomRange: IntRange = defaultZoomRange,
color: Color = Color.Red,
id: FeatureId? = null,
): FeatureId = addFeature(
id,
MapLineFeature(aCoordinates.toCoordinates(), bCoordinates.toCoordinates(), zoomRange, color)
)
public fun MapFeatureBuilder.arc(
oval: GmcBox,
startAngle: Number,
endAngle: Number,
zoomRange: IntRange = defaultZoomRange,
color: Color = Color.Red,
id: FeatureId? = null,
): FeatureId = addFeature(
id,
MapArcFeature(oval, startAngle.toFloat(), endAngle.toFloat(), zoomRange, color)
)
public fun MapFeatureBuilder.arc(
center: Pair<Double, Double>,
radius: Distance,
startAngle: Number,
endAngle: Number,
zoomRange: IntRange = defaultZoomRange,
color: Color = Color.Red,
id: FeatureId? = null,
): FeatureId = addFeature(
id,
MapArcFeature(
GmcBox.withCenter(center.toCoordinates(), radius, radius),
startAngle.toFloat(),
endAngle.toFloat(),
zoomRange,
color
)
)
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
): FeatureId = addFeature(id, MapPointsFeature(points.map { it.toCoordinates() }, zoomRange, stroke, color, pointMode))
@Composable
public fun MapFeatureBuilder.image(
position: Pair<Double, Double>,
image: ImageVector,
size: DpSize = DpSize(20.dp, 20.dp),
zoomRange: IntRange = defaultZoomRange,
id: FeatureId? = null,
): FeatureId = addFeature(id, MapVectorImageFeature(position.toCoordinates(), image, size, zoomRange))
public fun MapFeatureBuilder.group(
zoomRange: IntRange = defaultZoomRange,
id: FeatureId? = null,
builder: MapFeatureBuilder.() -> Unit,
): FeatureId {
val map = MapFeatureBuilderImpl(emptyMap()).apply(builder).build()
val feature = MapFeatureGroup(map, zoomRange)
return addFeature(id, feature)
}
public fun MapFeatureBuilder.featureSelector(
id: FeatureId? = null,
onSelect: MapFeatureBuilder.(zoom: Int) -> MapFeature
): FeatureId = addFeature(
id = id,
feature = MapFeatureSelector(
selector = { onSelect(this, it) }
)
)

View File

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

View File

@@ -0,0 +1,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
}
}

View File

@@ -0,0 +1,89 @@
package center.sciprog.maps.compose
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.DpSize
import center.sciprog.maps.coordinates.*
import kotlin.math.PI
import kotlin.math.log2
import kotlin.math.min
//TODO consider replacing by modifier
/**
* @param onDrag - returns true if you want to drag a map and false, if you want to make map stationary.
* start - is a point where drag begins, end is a point where drag ends
*/
public data class MapViewConfig(
val zoomSpeed: Double = 1.0 / 3.0,
val onClick: MapViewPoint.() -> Unit = {},
val onDrag: Density.(start: MapViewPoint, end: MapViewPoint) -> Boolean = { _, _ -> true },
val onViewChange: MapViewPoint.() -> Unit = {},
val onSelect: (GmcBox) -> Unit = {},
val zoomOnSelect: Boolean = true,
val resetViewPoint: Boolean = false
)
@Composable
public expect fun MapView(
modifier: Modifier = Modifier,
mapViewState: MapViewState,
mapViewConfig: MapViewConfig,
)
@Composable
public fun MapView(
mapTileProvider: MapTileProvider,
initialViewPoint: MapViewPoint,
features: Map<FeatureId, MapFeature> = emptyMap(),
config: MapViewConfig = MapViewConfig(),
modifier: Modifier = Modifier.fillMaxSize(),
buildFeatures: @Composable (MapFeatureBuilder.() -> Unit) = {},
) {
val featuresBuilder = MapFeatureBuilderImpl(features)
featuresBuilder.buildFeatures()
MapView(
mapViewState = MapViewState(
mapTileProvider = mapTileProvider,
initialViewPoint = { initialViewPoint },
features = featuresBuilder.build(),
),
mapViewConfig = config,
modifier = modifier
)
}
internal fun GmcBox.computeViewPoint(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
public fun MapView(
mapTileProvider: MapTileProvider,
box: GmcBox,
features: Map<FeatureId, MapFeature> = emptyMap(),
config: MapViewConfig = MapViewConfig(),
modifier: Modifier = Modifier.fillMaxSize(),
buildFeatures: @Composable (MapFeatureBuilder.() -> Unit) = {},
) {
val featuresBuilder = MapFeatureBuilderImpl(features)
featuresBuilder.buildFeatures()
MapView(
mapViewState = MapViewState(
mapTileProvider = mapTileProvider,
features = featuresBuilder.build(),
initialViewPoint = box.computeViewPoint(mapTileProvider),
),
modifier = modifier,
mapViewConfig = config,
)
}

View File

@@ -0,0 +1,181 @@
package center.sciprog.maps.compose
import androidx.compose.runtime.*
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.graphics.drawscope.translate
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import center.sciprog.maps.coordinates.*
import mu.KotlinLogging
import kotlin.math.*
@Composable
public fun MapViewState(
initialViewPoint: (canvasSize: DpSize) -> MapViewPoint,
mapTileProvider: MapTileProvider,
features: Map<FeatureId, MapFeature> = emptyMap(),
inferViewBoxFromFeatures: Boolean = false,
buildFeatures: @Composable (MapFeatureBuilder.() -> Unit) = {},
): MapViewState {
val featuresBuilder = MapFeatureBuilderImpl(features)
featuresBuilder.buildFeatures()
return MapViewState(
initialViewPoint = initialViewPoint,
mapTileProvider = mapTileProvider,
features = featuresBuilder.build(),
inferViewBoxFromFeatures = inferViewBoxFromFeatures
)
}
public class MapViewState(
public val initialViewPoint: (canvasSize: DpSize) -> MapViewPoint,
public val mapTileProvider: MapTileProvider,
public val features: Map<FeatureId, MapFeature> = emptyMap(),
inferViewBoxFromFeatures: Boolean = false,
) {
public var canvasSize: DpSize by mutableStateOf(DpSize(512.dp, 512.dp))
public var viewPointInternal: MapViewPoint? by mutableStateOf(null)
public val viewPoint: MapViewPoint by derivedStateOf {
viewPointInternal ?: if (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)
} ?: initialViewPoint(canvasSize)
} else {
initialViewPoint(canvasSize)
}
}
public val zoom: Int by derivedStateOf { floor(viewPoint.zoom).toInt() }
public val tileScale: Double by derivedStateOf { 2.0.pow(viewPoint.zoom - zoom) }
public val mapTiles: SnapshotStateList<MapTile> = mutableStateListOf()
public val centerCoordinates: WebMercatorCoordinates by derivedStateOf {
WebMercatorProjection.toMercator(
viewPoint.focus,
zoom
)
}
public fun DpOffset.toMercator(): WebMercatorCoordinates = WebMercatorCoordinates(
zoom,
(x - canvasSize.width / 2).value / tileScale + centerCoordinates.x,
(y - canvasSize.height / 2).value / tileScale + centerCoordinates.y,
)
/*
* Convert screen independent offset to GMC, adjusting for fractional zoom
*/
public fun DpOffset.toGeodetic(): GeodeticMapCoordinates =
with(this@MapViewState) { WebMercatorProjection.toGeodetic(toMercator()) }
// Selection rectangle. If null - no selection
public var selectRect: Rect? by mutableStateOf(null)
public fun WebMercatorCoordinates.toOffset(density: Density): Offset =
with(density) {
with(this@MapViewState) {
Offset(
(canvasSize.width / 2 + (x.dp - centerCoordinates.x.dp) * tileScale.toFloat()).toPx(),
(canvasSize.height / 2 + (y.dp - centerCoordinates.y.dp) * tileScale.toFloat()).toPx()
)
}
}
//Convert GMC to offset in pixels (not DP), adjusting for zoom
public fun GeodeticMapCoordinates.toOffset(density: Density): Offset =
WebMercatorProjection.toMercator(this, zoom).toOffset(density)
private val logger = KotlinLogging.logger("MapViewState")
public fun DrawScope.drawFeature(zoom: Int, feature: MapFeature) {
when (feature) {
is MapFeatureSelector -> drawFeature(zoom, feature.selector(zoom))
is MapCircleFeature -> drawCircle(
feature.color,
feature.size,
center = feature.center.toOffset(this@drawFeature)
)
is MapRectangleFeature -> drawRect(
feature.color,
topLeft = feature.center.toOffset(this@drawFeature) - Offset(
feature.size.width.toPx() / 2,
feature.size.height.toPx() / 2
),
size = feature.size.toSize()
)
is MapLineFeature -> drawLine(
feature.color,
feature.a.toOffset(this@drawFeature),
feature.b.toOffset(this@drawFeature)
)
is MapArcFeature -> {
val topLeft = feature.oval.topLeft.toOffset(this@drawFeature)
val bottomRight = feature.oval.bottomRight.toOffset(this@drawFeature)
val path = Path().apply {
addArcRad(Rect(topLeft, bottomRight), feature.startAngle, feature.endAngle - feature.startAngle)
}
drawPath(path, color = feature.color, style = Stroke())
}
is MapBitmapImageFeature -> drawImage(
image = feature.image,
topLeft = feature.position.toOffset(this@drawFeature)
)
is MapVectorImageFeature -> {
val offset = feature.position.toOffset(this@drawFeature)
val size = feature.size.toSize()
translate(offset.x - size.width / 2, offset.y - size.height / 2) {
with(feature.painter) {
draw(size)
}
}
}
is MapTextFeature -> drawIntoCanvas { canvas ->
val offset = feature.position.toOffset(this@drawFeature)
canvas.nativeCanvas.drawString(
feature.text,
offset.x + 5,
offset.y - 5,
Font().apply(feature.fontConfig),
feature.color
)
}
is MapDrawFeature -> {
val offset = feature.position.toOffset(this)
translate(offset.x, offset.y) {
feature.drawFeature(this)
}
}
is MapFeatureGroup -> {
feature.children.values.forEach {
drawFeature(zoom, it)
}
}
else -> {
logger.error { "Unrecognized feature type: ${feature::class}" }
}
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,23 @@
package center.sciprog.maps.compose
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.NativeCanvas
import androidx.compose.ui.graphics.toArgb
import org.jetbrains.skia.Paint
public actual typealias Font = org.jetbrains.skia.Font
public actual fun NativeCanvas.drawString(
text: String,
x: Float,
y: Float,
font: Font,
color: Color
) {
drawString(text, x, y, font, color.toPaint())
}
private fun Color.toPaint(): Paint = Paint().apply {
isAntiAlias = true
color = toArgb()
}

View File

@@ -0,0 +1,213 @@
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.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.*
import androidx.compose.ui.graphics.drawscope.*
import androidx.compose.ui.input.pointer.*
import androidx.compose.ui.unit.*
import center.sciprog.maps.coordinates.*
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.launch
import mu.KotlinLogging
import kotlin.math.*
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(
-MercatorProjection.MAXIMUM_LATITUDE,
MercatorProjection.MAXIMUM_LATITUDE
),
focus.longitude + deltaX / scaleFactor
)
return MapViewPoint(newCoordinates, zoom)
}
private val logger = KotlinLogging.logger("MapView")
/**
* A component that renders map and provides basic map manipulation capabilities
*/
@Composable
public actual fun MapView(
modifier: Modifier,
mapViewState: MapViewState,
mapViewConfig: MapViewConfig,
) {
with(mapViewState) {
@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 gmcBox = GmcBox(
rect.topLeft.toDpOffset().toGeodetic(),
rect.bottomRight.toDpOffset().toGeodetic()
)
mapViewConfig.onSelect(gmcBox)
if (mapViewConfig.zoomOnSelect) {
val newViewPoint = gmcBox.computeViewPoint(mapTileProvider).invoke(canvasSize)
mapViewConfig.onViewChange(newViewPoint)
viewPointInternal = newViewPoint
}
selectRect = null
}
} else {
val dragStart = change.position
val dpPos = DpOffset(dragStart.x.toDp(), dragStart.y.toDp())
mapViewConfig.onClick(
MapViewPoint(
dpPos.toGeodetic() ,
viewPoint.zoom
)
)
drag(change.id) { dragChange ->
val dragAmount = dragChange.position - dragChange.previousPosition
val dpStart =
DpOffset(
dragChange.previousPosition.x.toDp(),
dragChange.previousPosition.y.toDp()
)
val dpEnd = DpOffset(dragChange.position.x.toDp(), dragChange.position.y.toDp())
if (!mapViewConfig.onDrag(
this,
MapViewPoint(dpStart.toGeodetic(), viewPoint.zoom),
MapViewPoint(dpEnd.toGeodetic(), viewPoint.zoom)
)
) return@drag
val newViewPoint = viewPoint.move(
-dragAmount.x.toDp().value / tileScale,
+dragAmount.y.toDp().value / tileScale
)
mapViewConfig.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()).toGeodetic()
val newViewPoint = viewPoint.zoom(-change.scrollDelta.y.toDouble() * mapViewConfig.zoomSpeed, invariant)
mapViewConfig.onViewChange(newViewPoint)
viewPointInternal = newViewPoint
}.fillMaxSize()
// Load tiles asynchronously
LaunchedEffect(viewPoint, canvasSize) {
with(mapTileProvider) {
val indexRange = 0 until 2.0.pow(zoom).toInt()
val left = centerCoordinates.x - canvasSize.width.value / 2 / tileScale
val right = centerCoordinates.x + canvasSize.width.value / 2 / tileScale
val horizontalIndices: IntRange = (toIndex(left)..toIndex(right)).intersect(indexRange)
val top = (centerCoordinates.y + canvasSize.height.value / 2 / tileScale)
val bottom = (centerCoordinates.y - canvasSize.height.value / 2 / tileScale)
val verticalIndices: IntRange = (toIndex(bottom)..toIndex(top)).intersect(indexRange)
mapTiles.clear()
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 {
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" }
}
}
}
}
}
}
}
Canvas(canvasModifier) {
if (mapViewState.canvasSize != size.toDpSize()) {
mapViewState.canvasSize = size.toDpSize()
logger.debug { "Recalculate canvas. Size: $size" }
}
clipRect {
val tileSize = IntSize(
ceil((mapTileProvider.tileSize.dp * tileScale.toFloat()).toPx()).toInt(),
ceil((mapTileProvider.tileSize.dp * tileScale.toFloat()).toPx()).toInt()
)
mapTiles.forEach { (id, image) ->
//converting back from tile index to screen offset
val offset = IntOffset(
(canvasSize.width / 2 + (mapTileProvider.toCoordinate(id.i).dp - centerCoordinates.x.dp) * tileScale.toFloat()).roundToPx(),
(canvasSize.height / 2 + (mapTileProvider.toCoordinate(id.j).dp - centerCoordinates.y.dp) * tileScale.toFloat()).roundToPx()
)
drawImage(
image = image,
dstOffset = offset,
dstSize = tileSize
)
}
features.values.filter { zoom in it.zoomRange }.forEach { feature ->
drawFeature(zoom, 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)
)
)
}
}
}
}

View File

@@ -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,7 +21,7 @@ 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,
@@ -93,7 +93,7 @@ class OpenStreetMapTileProvider(
}
companion object {
public companion object {
private val logger = KotlinLogging.logger("OpenStreetMapCache")
}
}

View File

@@ -1,299 +0,0 @@
package centre.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.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.*
import centre.sciprog.maps.*
import io.ktor.utils.io.CancellationException
import kotlinx.coroutines.launch
import mu.KotlinLogging
import org.jetbrains.skia.Font
import org.jetbrains.skia.Paint
import kotlin.math.*
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)
internal fun MapViewPoint.move(deltaX: Double, deltaY: Double): MapViewPoint {
val newCoordinates = GeodeticMapCoordinates.ofRadians(
(focus.latitude + deltaY / scaleFactor).coerceIn(
-MercatorProjection.MAXIMUM_LATITUDE,
MercatorProjection.MAXIMUM_LATITUDE
),
focus.longitude + deltaX / scaleFactor
)
return MapViewPoint(newCoordinates, zoom)
}
private val logger = KotlinLogging.logger("MapView")
/**
* A component that renders map and provides basic map manipulation capabilities
*/
@Composable
actual fun MapView(
mapTileProvider: MapTileProvider,
computeViewPoint: (canvasSize: DpSize) -> MapViewPoint,
features: Map<FeatureId, MapFeature>,
config: MapViewConfig,
modifier: Modifier,
) {
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
}
)
}
val viewPoint by derivedStateOf { viewPointOverride ?: computeViewPoint(canvasSize) }
val zoom: Int by derivedStateOf { floor(viewPoint.zoom).toInt() }
val tileScale: Double by derivedStateOf { 2.0.pow(viewPoint.zoom - zoom) }
val mapTiles = remember { mutableStateListOf<MapTile>() }
val centerCoordinates by derivedStateOf { WebMercatorProjection.toMercator(viewPoint.focus, zoom) }
fun DpOffset.toMercator(): WebMercatorCoordinates = WebMercatorCoordinates(
zoom,
(x - canvasSize.width / 2).value / tileScale + centerCoordinates.x,
(y - canvasSize.height / 2).value / tileScale + centerCoordinates.y,
)
/*
* Convert screen independent offset to GMC, adjusting for fractional zoom
*/
fun DpOffset.toGeodetic() = WebMercatorProjection.toGeodetic(toMercator())
// 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 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
}
}
}
}
}
}
}.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()).toGeodetic()
val newViewPoint = viewPoint.zoom(-change.scrollDelta.y.toDouble() * config.zoomSpeed, invariant)
config.onViewChange(newViewPoint)
viewPointOverride = newViewPoint
}.fillMaxSize()
// Load tiles asynchronously
LaunchedEffect(viewPoint, canvasSize) {
with(mapTileProvider) {
val indexRange = 0 until 2.0.pow(zoom).toInt()
val left = centerCoordinates.x - canvasSize.width.value / 2 / tileScale
val right = centerCoordinates.x + canvasSize.width.value / 2 / tileScale
val horizontalIndices: IntRange = (toIndex(left)..toIndex(right)).intersect(indexRange)
val top = (centerCoordinates.y + canvasSize.height.value / 2 / tileScale)
val bottom = (centerCoordinates.y - canvasSize.height.value / 2 / tileScale)
val verticalIndices: IntRange = (toIndex(bottom)..toIndex(top)).intersect(indexRange)
mapTiles.clear()
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 {
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" }
}
}
}
}
}
}
}
Canvas(canvasModifier) {
fun WebMercatorCoordinates.toOffset(): Offset = Offset(
(canvasSize.width / 2 + (x.dp - centerCoordinates.x.dp) * tileScale.toFloat()).toPx(),
(canvasSize.height / 2 + (y.dp - centerCoordinates.y.dp) * tileScale.toFloat()).toPx()
)
//Convert GMC to offset in pixels (not DP), adjusting for zoom
fun GeodeticMapCoordinates.toOffset(): Offset = WebMercatorProjection.toMercator(this, zoom).toOffset()
fun DrawScope.drawFeature(zoom: Int, feature: MapFeature) {
when (feature) {
is MapFeatureSelector -> drawFeature(zoom, feature.selector(zoom))
is MapCircleFeature -> drawCircle(
feature.color,
feature.size,
center = feature.center.toOffset()
)
is MapLineFeature -> drawLine(feature.color, feature.a.toOffset(), feature.b.toOffset())
is MapBitmapImageFeature -> drawImage(feature.image, feature.position.toOffset())
is MapVectorImageFeature -> {
val offset = feature.position.toOffset()
val size = feature.size.toSize()
translate(offset.x - size.width / 2, offset.y - size.height / 2) {
with(feature.painter) {
draw(size)
}
}
}
is MapTextFeature -> 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 MapDrawFeature -> {
val offset = feature.position.toOffset()
translate(offset.x, offset.y) {
feature.drawFeature(this)
}
}
is MapFeatureGroup -> {
feature.children.values.forEach {
drawFeature(zoom, it)
}
}
}
}
if (canvasSize != size.toDpSize()) {
canvasSize = size.toDpSize()
logger.debug { "Recalculate canvas. Size: $size" }
}
clipRect {
val tileSize = IntSize(
ceil((mapTileProvider.tileSize.dp * tileScale.toFloat()).toPx()).toInt(),
ceil((mapTileProvider.tileSize.dp * tileScale.toFloat()).toPx()).toInt()
)
mapTiles.forEach { (id, image) ->
//converting back from tile index to screen offset
val offset = IntOffset(
(canvasSize.width / 2 + (mapTileProvider.toCoordinate(id.i).dp - centerCoordinates.x.dp) * tileScale.toFloat()).roundToPx(),
(canvasSize.height / 2 + (mapTileProvider.toCoordinate(id.j).dp - centerCoordinates.y.dp) * tileScale.toFloat()).roundToPx()
)
drawImage(
image = image,
dstOffset = offset,
dstSize = tileSize
)
}
features.values.filter { zoom in it.zoomRange }.forEach { feature ->
drawFeature(zoom, 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)
)
)
}
}
}

View File

@@ -6,21 +6,13 @@ 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{
dependencies{
api("io.github.microutils:kotlin-logging:2.1.23")
}
}
val jvmMain by getting
val jvmTest by getting
}
}

View File

@@ -0,0 +1,17 @@
package center.sciprog.maps.coordinates
import kotlin.jvm.JvmInline
@JvmInline
public value class Distance(public val kilometers: Double)
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

View File

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

View File

@@ -1,11 +1,14 @@
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 private constructor(
public val latitude: Double,
public val longitude: Double,
){
override fun equals(other: Any?): Boolean {
if (this === other) return true
@@ -43,9 +46,6 @@ public class GeodeticMapCoordinates private constructor(public val latitude: Dou
}
}
internal typealias Gmc = GeodeticMapCoordinates
//public interface GeoToScreenConversion {
// public fun getScreenX(gmc: GeodeticMapCoordinates): Double
// public fun getScreenY(gmc: GeodeticMapCoordinates): Double

View File

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

View File

@@ -1,18 +1,18 @@
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 {
public fun MapViewPoint.move(delta: GeodeticMapCoordinates): MapViewPoint {
val newCoordinates = GeodeticMapCoordinates.ofRadians(
(focus.latitude + delta.latitude).coerceIn(
-MercatorProjection.MAXIMUM_LATITUDE,
@@ -23,7 +23,7 @@ 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) {

View File

@@ -3,7 +3,7 @@
* 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.*

View File

@@ -3,7 +3,7 @@
* 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.*
@@ -14,7 +14,7 @@ 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())

View File

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

View 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
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 469 KiB

View File

@@ -23,6 +23,8 @@ pluginManagement {
include(
":maps-kt-core",
":maps-kt-compose",
":demo"
":demo:maps",
":maps-kt-scheme",
":demo:scheme"
)