Add arc feature. Add (approximate) ellipsoid

This commit is contained in:
Alexander Nozik 2022-07-23 18:37:14 +03:00
parent 4c3aefcfae
commit 14acd88358
No known key found for this signature in database
GPG Key ID: F7FCF2DD25C71357
13 changed files with 219 additions and 70 deletions

View File

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

@ -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") {

View File

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

View File

@ -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<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,
@ -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<Double, Double>,
text: String,
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, 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(

View File

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

View File

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

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

View File

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

@ -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,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 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
)
public fun GmcBox(
latitudes: ClosedFloatingPointRange<Double>,
longitudes: ClosedFloatingPointRange<Double>,
): GmcBox = GmcBox(
GeodeticMapCoordinates.ofRadians(latitudes.start, longitudes.start),
GeodeticMapCoordinates.ofRadians(latitudes.endInclusive, longitudes.endInclusive)
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<GmcBox>.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))
}

View File

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

View File

@ -82,7 +82,7 @@ fun SchemeFeatureBuilder.circle(
id, SchemeCircleFeature(centerCoordinates.toCoordinates(), scaleRange, size, color)
)
fun SchemeFeatureBuilder.custom(
fun SchemeFeatureBuilder.draw(
position: Pair<Number, Number>,
scaleRange: FloatRange = defaultScaleRange,
id: FeatureId? = null,