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 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.material.icons.filled.Home
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.window.Window import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application import androidx.compose.ui.window.application
import center.sciprog.maps.compose.* import center.sciprog.maps.compose.*
import center.sciprog.maps.coordinates.Distance
import center.sciprog.maps.coordinates.GeodeticMapCoordinates import center.sciprog.maps.coordinates.GeodeticMapCoordinates
import center.sciprog.maps.coordinates.MapViewPoint import center.sciprog.maps.coordinates.MapViewPoint
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
@ -67,16 +67,15 @@ fun App() {
centerCoordinates = pointTwo, centerCoordinates = pointTwo,
) )
custom(position = pointThree) { draw(position = pointThree) {
drawRect( drawLine(start = Offset(-10f, -10f), end = Offset(10f, 10f), color = Color.Red)
color = Color.Red, drawLine(start = Offset(-10f, 10f), end = Offset(10f, -10f), color = Color.Red)
topLeft = Offset(-10f, -10f),
size = Size(20f, 20f)
)
} }
arc(pointOne, Distance(10.0), 0f, PI)
line(pointOne, pointTwo) line(pointOne, pointTwo)
text(pointOne, "Home") text(pointOne, "Home", font = { size = 32f })
centerCoordinates?.let { centerCoordinates?.let {
group(id = "center") { 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.GmcBox
import center.sciprog.maps.coordinates.wrapAll import center.sciprog.maps.coordinates.wrapAll
//TODO replace zoom range with zoom-based representation change public interface MapFeature {
public sealed interface MapFeature {
public val zoomRange: IntRange public val zoomRange: IntRange
public fun getBoundingBox(zoom: Int): GmcBox? public fun getBoundingBox(zoom: Int): GmcBox?
} }
@ -42,7 +41,7 @@ public class MapDrawFeature(
public val position: GeodeticMapCoordinates, public val position: GeodeticMapCoordinates,
override val zoomRange: IntRange = defaultZoomRange, override val zoomRange: IntRange = defaultZoomRange,
public val drawFeature: DrawScope.() -> Unit, public val drawFeature: DrawScope.() -> Unit,
) : MapFeature{ ) : MapFeature {
override fun getBoundingBox(zoom: Int): GmcBox { override fun getBoundingBox(zoom: Int): GmcBox {
//TODO add box computation //TODO add box computation
return GmcBox(position, position) return GmcBox(position, position)
@ -58,6 +57,15 @@ public class MapCircleFeature(
override fun getBoundingBox(zoom: Int): GmcBox = GmcBox(center, center) 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 class MapLineFeature(
public val a: GeodeticMapCoordinates, public val a: GeodeticMapCoordinates,
public val b: GeodeticMapCoordinates, public val b: GeodeticMapCoordinates,
@ -67,13 +75,14 @@ public class MapLineFeature(
override fun getBoundingBox(zoom: Int): GmcBox = GmcBox(a, b) override fun getBoundingBox(zoom: Int): GmcBox = GmcBox(a, b)
} }
public class MapTextFeature( public class MapArcFeature(
public val position: GeodeticMapCoordinates, public val oval: GmcBox,
public val text: String, public val startAngle: Float,
public val endAngle: Float,
override val zoomRange: IntRange = defaultZoomRange, override val zoomRange: IntRange = defaultZoomRange,
public val color: Color = Color.Red, public val color: Color = Color.Red,
) : MapFeature { ) : MapFeature {
override fun getBoundingBox(zoom: Int): GmcBox = GmcBox(position, position) override fun getBoundingBox(zoom: Int): GmcBox = oval
} }
public class MapBitmapImageFeature( public class MapBitmapImageFeature(
@ -81,7 +90,7 @@ public class MapBitmapImageFeature(
public val image: ImageBitmap, public val image: ImageBitmap,
public val size: IntSize = IntSize(15, 15), public val size: IntSize = IntSize(15, 15),
override val zoomRange: IntRange = defaultZoomRange, override val zoomRange: IntRange = defaultZoomRange,
) : MapFeature{ ) : MapFeature {
override fun getBoundingBox(zoom: Int): GmcBox = GmcBox(position, position) override fun getBoundingBox(zoom: Int): GmcBox = GmcBox(position, position)
} }
@ -108,6 +117,6 @@ public fun MapVectorImageFeature(
public class MapFeatureGroup( public class MapFeatureGroup(
public val children: Map<FeatureId, MapFeature>, public val children: Map<FeatureId, MapFeature>,
override val zoomRange: IntRange = defaultZoomRange, override val zoomRange: IntRange = defaultZoomRange,
) : MapFeature{ ) : MapFeature {
override fun getBoundingBox(zoom: Int): GmcBox? = children.values.mapNotNull { it.getBoundingBox(zoom) }.wrapAll() override fun getBoundingBox(zoom: Int): GmcBox? = children.values.mapNotNull { it.getBoundingBox(zoom) }.wrapAll()
} }

View File

@ -8,7 +8,9 @@ import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import center.sciprog.maps.coordinates.Distance
import center.sciprog.maps.coordinates.GeodeticMapCoordinates import center.sciprog.maps.coordinates.GeodeticMapCoordinates
import center.sciprog.maps.coordinates.GmcBox
public typealias FeatureId = String public typealias FeatureId = String
@ -55,7 +57,17 @@ public fun MapFeatureBuilder.circle(
id, MapCircleFeature(centerCoordinates.toCoordinates(), zoomRange, size, color) 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>, position: Pair<Double, Double>,
zoomRange: IntRange = defaultZoomRange, zoomRange: IntRange = defaultZoomRange,
id: FeatureId? = null, id: FeatureId? = null,
@ -68,23 +80,41 @@ public fun MapFeatureBuilder.line(
zoomRange: IntRange = defaultZoomRange, zoomRange: IntRange = defaultZoomRange,
color: Color = Color.Red, color: Color = Color.Red,
id: FeatureId? = null, 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( public fun MapFeatureBuilder.arc(
position: GeodeticMapCoordinates, oval: GmcBox,
text: String, startAngle: Number,
endAngle: Number,
zoomRange: IntRange = defaultZoomRange, zoomRange: IntRange = defaultZoomRange,
color: Color = Color.Red, color: Color = Color.Red,
id: FeatureId? = null, 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( public fun MapFeatureBuilder.arc(
position: Pair<Double, Double>, center: Pair<Double, Double>,
text: String, radius: Distance,
startAngle: Number,
endAngle: Number,
zoomRange: IntRange = defaultZoomRange, zoomRange: IntRange = defaultZoomRange,
color: Color = Color.Red, color: Color = Color.Red,
id: FeatureId? = null, 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 @Composable
public fun MapFeatureBuilder.image( public fun MapFeatureBuilder.image(

View File

@ -17,7 +17,8 @@ public data class MapViewConfig(
val onClick: MapViewPoint.() -> Unit = {}, val onClick: MapViewPoint.() -> Unit = {},
val onViewChange: MapViewPoint.() -> Unit = {}, val onViewChange: MapViewPoint.() -> Unit = {},
val onSelect: (GmcBox) -> Unit = {}, val onSelect: (GmcBox) -> Unit = {},
val zoomOnSelect: Boolean = true val zoomOnSelect: Boolean = true,
val resetViewPoint: Boolean = false
) )
@Composable @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.Modifier
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.*
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.drawscope.* 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.input.pointer.*
import androidx.compose.ui.unit.* import androidx.compose.ui.unit.*
import center.sciprog.maps.coordinates.* import center.sciprog.maps.coordinates.*
@ -63,6 +60,10 @@ public actual fun MapView(
mutableStateOf(null) mutableStateOf(null)
} }
if (config.resetViewPoint) {
viewPointInternal = null
}
val viewPoint: MapViewPoint by derivedStateOf { val viewPoint: MapViewPoint by derivedStateOf {
viewPointInternal ?: if (config.inferViewBoxFromFeatures) { viewPointInternal ?: if (config.inferViewBoxFromFeatures) {
features.values.computeBoundingBox(1)?.let { box -> features.values.computeBoundingBox(1)?.let { box ->
@ -223,7 +224,26 @@ public actual fun MapView(
feature.size, feature.size,
center = feature.center.toOffset() 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 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 MapBitmapImageFeature -> drawImage(feature.image, feature.position.toOffset())
is MapVectorImageFeature -> { is MapVectorImageFeature -> {
val offset = feature.position.toOffset() val offset = feature.position.toOffset()
@ -240,7 +260,7 @@ public actual fun MapView(
feature.text, feature.text,
offset.x + 5, offset.x + 5,
offset.y - 5, offset.y - 5,
Font().apply { size = 16f }, Font().apply(feature.fontConfig),
feature.color.toPaint() feature.color.toPaint()
) )
} }
@ -255,6 +275,9 @@ public actual fun MapView(
drawFeature(zoom, it) 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 * 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 client: HttpClient,
private val cacheDirectory: Path, private val cacheDirectory: Path,
parallelism: Int = 1, parallelism: Int = 1,
@ -93,7 +93,7 @@ class OpenStreetMapTileProvider(
} }
companion object { public companion object {
private val logger = KotlinLogging.logger("OpenStreetMapCache") 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 package center.sciprog.maps.coordinates
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.cos
import kotlin.math.max import kotlin.math.max
import kotlin.math.min import kotlin.math.min
public data class GmcBox( public data class GmcBox(
public val a: GeodeticMapCoordinates, public val a: GeodeticMapCoordinates,
public val b: GeodeticMapCoordinates, public val b: GeodeticMapCoordinates,
) ) {
public companion object {
public fun GmcBox( public fun withCenter(
latitudes: ClosedFloatingPointRange<Double>, center: GeodeticMapCoordinates,
longitudes: ClosedFloatingPointRange<Double>, width: Distance,
): GmcBox = GmcBox( height: Distance,
GeodeticMapCoordinates.ofRadians(latitudes.start, longitudes.start), ellipsoid: Ellipsoid = Ellipsoid.WGS84,
GeodeticMapCoordinates.ofRadians(latitudes.endInclusive, longitudes.endInclusive) ): 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 public val GmcBox.center: GeodeticMapCoordinates
get() = GeodeticMapCoordinates.ofRadians( get() = GeodeticMapCoordinates.ofRadians(
@ -23,16 +36,33 @@ public val GmcBox.center: GeodeticMapCoordinates
(a.longitude + b.longitude) / 2 (a.longitude + b.longitude) / 2
) )
/**
* Minimum longitude
*/
public val GmcBox.left: Double get() = min(a.longitude, b.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) public val GmcBox.right: Double get() = max(a.longitude, b.longitude)
/**
* Maximum latitude
*/
public val GmcBox.top: Double get() = max(a.latitude, b.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) public val GmcBox.bottom: Double get() = min(a.latitude, b.latitude)
//TODO take curvature into account //TODO take curvature into account
public val GmcBox.width: Double get() = abs(a.longitude - b.longitude) 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.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 * 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 maxLat = maxOf { it.top }
val minLong = minOf { it.left } val minLong = minOf { it.left }
val maxLong = maxOf { it.right } 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.compose
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
plugins { plugins {
kotlin("multiplatform") kotlin("multiplatform")
id("org.jetbrains.compose") 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 { kotlin {
jvm { jvm {
compilations.all { compilations.all {
@ -32,20 +23,8 @@ kotlin {
val jvmMain by getting { val jvmMain by getting {
dependencies { dependencies {
api(compose.desktop.currentOs) api(compose.desktop.currentOs)
implementation("ch.qos.logback:logback-classic:1.2.11")
} }
} }
val jvmTest by getting 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) id, SchemeCircleFeature(centerCoordinates.toCoordinates(), scaleRange, size, color)
) )
fun SchemeFeatureBuilder.custom( fun SchemeFeatureBuilder.draw(
position: Pair<Number, Number>, position: Pair<Number, Number>,
scaleRange: FloatRange = defaultScaleRange, scaleRange: FloatRange = defaultScaleRange,
id: FeatureId? = null, id: FeatureId? = null,