diff --git a/README.md b/README.md index ff2306a..9ae1731 100644 --- a/README.md +++ b/README.md @@ -1 +1,13 @@ -This repository is a work-in-progress implementation of Map-with-markers component for Compose-Multiplatform \ No newline at end of file +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 \ No newline at end of file diff --git a/demo/maps/src/jvmMain/kotlin/Main.kt b/demo/maps/src/jvmMain/kotlin/Main.kt index c32de21..574dc0e 100644 --- a/demo/maps/src/jvmMain/kotlin/Main.kt +++ b/demo/maps/src/jvmMain/kotlin/Main.kt @@ -5,11 +5,11 @@ 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 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 @@ -67,16 +67,15 @@ fun App() { 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) } + arc(pointOne, Distance(10.0), 0f, PI) + line(pointOne, pointTwo) - text(pointOne, "Home") + text(pointOne, "Home", font = { size = 32f }) centerCoordinates?.let { group(id = "center") { diff --git a/maps-kt-compose/src/commonMain/kotlin/center/sciprog/maps/compose/MapFeature.kt b/maps-kt-compose/src/commonMain/kotlin/center/sciprog/maps/compose/MapFeature.kt index 5602a54..2180674 100644 --- a/maps-kt-compose/src/commonMain/kotlin/center/sciprog/maps/compose/MapFeature.kt +++ b/maps-kt-compose/src/commonMain/kotlin/center/sciprog/maps/compose/MapFeature.kt @@ -14,8 +14,7 @@ import center.sciprog.maps.coordinates.GeodeticMapCoordinates import center.sciprog.maps.coordinates.GmcBox import center.sciprog.maps.coordinates.wrapAll -//TODO replace zoom range with zoom-based representation change -public sealed interface MapFeature { +public interface MapFeature { public val zoomRange: IntRange public fun getBoundingBox(zoom: Int): GmcBox? } @@ -42,7 +41,7 @@ public class MapDrawFeature( public val position: GeodeticMapCoordinates, override val zoomRange: IntRange = defaultZoomRange, public val drawFeature: DrawScope.() -> Unit, -) : MapFeature{ +) : MapFeature { override fun getBoundingBox(zoom: Int): GmcBox { //TODO add box computation return GmcBox(position, position) @@ -58,6 +57,15 @@ public class MapCircleFeature( 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, @@ -67,13 +75,14 @@ public class MapLineFeature( override fun getBoundingBox(zoom: Int): GmcBox = GmcBox(a, b) } -public class MapTextFeature( - public val position: GeodeticMapCoordinates, - public val text: String, +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 = GmcBox(position, position) + override fun getBoundingBox(zoom: Int): GmcBox = oval } public class MapBitmapImageFeature( @@ -81,7 +90,7 @@ public class MapBitmapImageFeature( public val image: ImageBitmap, public val size: IntSize = IntSize(15, 15), override val zoomRange: IntRange = defaultZoomRange, -) : MapFeature{ +) : MapFeature { override fun getBoundingBox(zoom: Int): GmcBox = GmcBox(position, position) } @@ -108,6 +117,6 @@ public fun MapVectorImageFeature( public class MapFeatureGroup( public val children: Map, override val zoomRange: IntRange = defaultZoomRange, -) : MapFeature{ +) : MapFeature { override fun getBoundingBox(zoom: Int): GmcBox? = children.values.mapNotNull { it.getBoundingBox(zoom) }.wrapAll() -} +} \ No newline at end of file diff --git a/maps-kt-compose/src/commonMain/kotlin/center/sciprog/maps/compose/MapFeatureBuilder.kt b/maps-kt-compose/src/commonMain/kotlin/center/sciprog/maps/compose/MapFeatureBuilder.kt index a39783c..55f18ee 100644 --- a/maps-kt-compose/src/commonMain/kotlin/center/sciprog/maps/compose/MapFeatureBuilder.kt +++ b/maps-kt-compose/src/commonMain/kotlin/center/sciprog/maps/compose/MapFeatureBuilder.kt @@ -8,7 +8,9 @@ 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 @@ -55,7 +57,17 @@ public fun MapFeatureBuilder.circle( id, MapCircleFeature(centerCoordinates.toCoordinates(), zoomRange, size, color) ) -public fun MapFeatureBuilder.custom( +public fun MapFeatureBuilder.rectangle( + centerCoordinates: Pair, + 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, zoomRange: IntRange = defaultZoomRange, id: FeatureId? = null, @@ -68,23 +80,41 @@ public fun MapFeatureBuilder.line( zoomRange: IntRange = defaultZoomRange, color: Color = Color.Red, id: FeatureId? = null, -): FeatureId = addFeature(id, MapLineFeature(aCoordinates.toCoordinates(), bCoordinates.toCoordinates(), zoomRange, color)) +): FeatureId = addFeature( + id, + MapLineFeature(aCoordinates.toCoordinates(), bCoordinates.toCoordinates(), zoomRange, color) +) -public fun MapFeatureBuilder.text( - position: GeodeticMapCoordinates, - text: String, +public fun MapFeatureBuilder.arc( + oval: GmcBox, + startAngle: Number, + endAngle: Number, zoomRange: IntRange = defaultZoomRange, color: Color = Color.Red, id: FeatureId? = null, -): FeatureId = addFeature(id, MapTextFeature(position, text, zoomRange, color)) +): FeatureId = addFeature( + id, + MapArcFeature(oval, startAngle.toFloat(), endAngle.toFloat(), zoomRange, color) +) -public fun MapFeatureBuilder.text( - position: Pair, - text: String, +public fun MapFeatureBuilder.arc( + center: Pair, + radius: Distance, + startAngle: Number, + endAngle: Number, zoomRange: IntRange = defaultZoomRange, color: Color = Color.Red, id: FeatureId? = null, -): FeatureId = addFeature(id, MapTextFeature(position.toCoordinates(), text, zoomRange, color)) +): FeatureId = addFeature( + id, + MapArcFeature( + GmcBox.withCenter(center.toCoordinates(), radius, radius), + startAngle.toFloat(), + endAngle.toFloat(), + zoomRange, + color + ) +) @Composable public fun MapFeatureBuilder.image( diff --git a/maps-kt-compose/src/commonMain/kotlin/center/sciprog/maps/compose/MapView.kt b/maps-kt-compose/src/commonMain/kotlin/center/sciprog/maps/compose/MapView.kt index d35bf76..80e5287 100644 --- a/maps-kt-compose/src/commonMain/kotlin/center/sciprog/maps/compose/MapView.kt +++ b/maps-kt-compose/src/commonMain/kotlin/center/sciprog/maps/compose/MapView.kt @@ -17,7 +17,8 @@ public data class MapViewConfig( val onClick: MapViewPoint.() -> Unit = {}, val onViewChange: MapViewPoint.() -> Unit = {}, val onSelect: (GmcBox) -> Unit = {}, - val zoomOnSelect: Boolean = true + val zoomOnSelect: Boolean = true, + val resetViewPoint: Boolean = false ) @Composable diff --git a/maps-kt-compose/src/jvmMain/kotlin/center/sciprog/maps/compose/MapTextFeature.kt b/maps-kt-compose/src/jvmMain/kotlin/center/sciprog/maps/compose/MapTextFeature.kt new file mode 100644 index 0000000..fef40fb --- /dev/null +++ b/maps-kt-compose/src/jvmMain/kotlin/center/sciprog/maps/compose/MapTextFeature.kt @@ -0,0 +1,35 @@ +package center.sciprog.maps.compose + +import androidx.compose.ui.graphics.Color +import center.sciprog.maps.coordinates.GeodeticMapCoordinates +import center.sciprog.maps.coordinates.GmcBox +import org.jetbrains.skia.Font + + +public class MapTextFeature( + public val position: GeodeticMapCoordinates, + public val text: String, + override val zoomRange: IntRange = defaultZoomRange, + public val color: Color, + public val fontConfig: Font.() -> Unit, +) : MapFeature { + override fun getBoundingBox(zoom: Int): GmcBox = GmcBox(position, position) +} + +public fun MapFeatureBuilder.text( + position: GeodeticMapCoordinates, + text: String, + zoomRange: IntRange = defaultZoomRange, + color: Color = Color.Red, + font: Font.() -> Unit = { size = 16f }, + id: FeatureId? = null, +): FeatureId = addFeature(id, MapTextFeature(position, text, zoomRange, color, font)) + +public fun MapFeatureBuilder.text( + position: Pair, + 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)) diff --git a/maps-kt-compose/src/jvmMain/kotlin/center/sciprog/maps/compose/MapViewJvm.kt b/maps-kt-compose/src/jvmMain/kotlin/center/sciprog/maps/compose/MapViewJvm.kt index b1d2bfd..d12d568 100644 --- a/maps-kt-compose/src/jvmMain/kotlin/center/sciprog/maps/compose/MapViewJvm.kt +++ b/maps-kt-compose/src/jvmMain/kotlin/center/sciprog/maps/compose/MapViewJvm.kt @@ -9,11 +9,8 @@ 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 center.sciprog.maps.coordinates.* @@ -63,6 +60,10 @@ public actual fun MapView( mutableStateOf(null) } + if (config.resetViewPoint) { + viewPointInternal = null + } + val viewPoint: MapViewPoint by derivedStateOf { viewPointInternal ?: if (config.inferViewBoxFromFeatures) { features.values.computeBoundingBox(1)?.let { box -> @@ -223,7 +224,26 @@ public 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() @@ -240,7 +260,7 @@ public actual fun MapView( feature.text, offset.x + 5, offset.y - 5, - Font().apply { size = 16f }, + Font().apply(feature.fontConfig), feature.color.toPaint() ) } @@ -255,6 +275,9 @@ public actual fun MapView( drawFeature(zoom, it) } } + else -> { + logger.error { "Unrecognized feature type: ${feature::class}" } + } } } diff --git a/maps-kt-compose/src/jvmMain/kotlin/center/sciprog/maps/compose/OpenStreetMapTileProvider.kt b/maps-kt-compose/src/jvmMain/kotlin/center/sciprog/maps/compose/OpenStreetMapTileProvider.kt index f7eb053..4929254 100644 --- a/maps-kt-compose/src/jvmMain/kotlin/center/sciprog/maps/compose/OpenStreetMapTileProvider.kt +++ b/maps-kt-compose/src/jvmMain/kotlin/center/sciprog/maps/compose/OpenStreetMapTileProvider.kt @@ -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") } } diff --git a/maps-kt-core/src/commonMain/kotlin/center/sciprog/maps/coordinates/Distance.kt b/maps-kt-core/src/commonMain/kotlin/center/sciprog/maps/coordinates/Distance.kt new file mode 100644 index 0000000..dbe35c8 --- /dev/null +++ b/maps-kt-core/src/commonMain/kotlin/center/sciprog/maps/coordinates/Distance.kt @@ -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 \ No newline at end of file diff --git a/maps-kt-core/src/commonMain/kotlin/center/sciprog/maps/coordinates/Ellipsoid.kt b/maps-kt-core/src/commonMain/kotlin/center/sciprog/maps/coordinates/Ellipsoid.kt new file mode 100644 index 0000000..d99aab1 --- /dev/null +++ b/maps-kt-core/src/commonMain/kotlin/center/sciprog/maps/coordinates/Ellipsoid.kt @@ -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) \ No newline at end of file diff --git a/maps-kt-core/src/commonMain/kotlin/center/sciprog/maps/coordinates/GmcBox.kt b/maps-kt-core/src/commonMain/kotlin/center/sciprog/maps/coordinates/GmcBox.kt index 58db19a..4eb1ddc 100644 --- a/maps-kt-core/src/commonMain/kotlin/center/sciprog/maps/coordinates/GmcBox.kt +++ b/maps-kt-core/src/commonMain/kotlin/center/sciprog/maps/coordinates/GmcBox.kt @@ -1,21 +1,34 @@ 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 fun GmcBox( - latitudes: ClosedFloatingPointRange, - longitudes: ClosedFloatingPointRange, -): GmcBox = GmcBox( - GeodeticMapCoordinates.ofRadians(latitudes.start, longitudes.start), - GeodeticMapCoordinates.ofRadians(latitudes.endInclusive, longitudes.endInclusive) -) +) { + 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( @@ -23,16 +36,33 @@ public val GmcBox.center: GeodeticMapCoordinates (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 */ @@ -43,5 +73,5 @@ public fun Collection.wrapAll(): GmcBox? { val maxLat = maxOf { it.top } val minLong = minOf { it.left } val maxLong = maxOf { it.right } - return GmcBox(minLat..maxLat, minLong..maxLong) + return GmcBox(GeodeticMapCoordinates.ofRadians(minLat, minLong), GeodeticMapCoordinates.ofRadians(maxLat, maxLong)) } \ No newline at end of file diff --git a/maps-kt-scheme/build.gradle.kts b/maps-kt-scheme/build.gradle.kts index eb34d8e..203422e 100644 --- a/maps-kt-scheme/build.gradle.kts +++ b/maps-kt-scheme/build.gradle.kts @@ -1,20 +1,11 @@ import org.jetbrains.compose.compose -import org.jetbrains.compose.desktop.application.dsl.TargetFormat + plugins { kotlin("multiplatform") id("org.jetbrains.compose") } -group = "center.sciprog" -version = "1.0-SNAPSHOT" - -repositories { - google() - mavenCentral() - maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") -} - kotlin { jvm { compilations.all { @@ -32,20 +23,8 @@ kotlin { val jvmMain by getting { dependencies { api(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 = "compose-scheme" - packageVersion = "1.0.0" - } - } -} diff --git a/maps-kt-scheme/src/commonMain/kotlin/center/sciprog/maps/scheme/SchemeFeatureBuilderImpl.kt b/maps-kt-scheme/src/commonMain/kotlin/center/sciprog/maps/scheme/SchemeFeatureBuilder.kt similarity index 99% rename from maps-kt-scheme/src/commonMain/kotlin/center/sciprog/maps/scheme/SchemeFeatureBuilderImpl.kt rename to maps-kt-scheme/src/commonMain/kotlin/center/sciprog/maps/scheme/SchemeFeatureBuilder.kt index 0f76dcf..af1e273 100644 --- a/maps-kt-scheme/src/commonMain/kotlin/center/sciprog/maps/scheme/SchemeFeatureBuilderImpl.kt +++ b/maps-kt-scheme/src/commonMain/kotlin/center/sciprog/maps/scheme/SchemeFeatureBuilder.kt @@ -82,7 +82,7 @@ fun SchemeFeatureBuilder.circle( id, SchemeCircleFeature(centerCoordinates.toCoordinates(), scaleRange, size, color) ) -fun SchemeFeatureBuilder.custom( +fun SchemeFeatureBuilder.draw( position: Pair, scaleRange: FloatRange = defaultScaleRange, id: FeatureId? = null,