Generic features

This commit is contained in:
Alexander Nozik 2022-12-23 22:16:16 +03:00
parent c2157c8351
commit 9b8ba884e1
No known key found for this signature in database
GPG Key ID: F7FCF2DD25C71357
22 changed files with 601 additions and 1117 deletions

View File

@ -12,6 +12,7 @@ import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application
import center.sciprog.maps.compose.*
import center.sciprog.maps.coordinates.*
import center.sciprog.maps.features.*
import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import kotlinx.coroutines.delay
@ -129,7 +130,7 @@ fun App() {
centerCoordinates?.let {
group(id = "center") {
circle(center = it, color = Color.Blue, id = "circle", size = 1f)
circle(center = it, color = Color.Blue, id = "circle", size = 1.dp)
text(position = it, it.toShortString(), id = "text", color = Color.Blue)
}
}

View File

@ -16,6 +16,7 @@ kotlin {
commonMain {
dependencies {
api(projects.mapsKtCore)
api(projects.mapsKtFeatures)
api(compose.foundation)
api(project.dependencies.platform(spclibs.ktor.bom))
api("io.ktor:ktor-client-core")

View File

@ -1,43 +0,0 @@
package center.sciprog.maps.compose
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.ui.graphics.Color
public object DraggableAttribute : MapFeature.Attribute<DragHandle>
public object SelectableAttribute : MapFeature.Attribute<(FeatureId<*>, SelectableMapFeature) -> Unit>
public object VisibleAttribute : MapFeature.Attribute<Boolean>
public object ColorAttribute : MapFeature.Attribute<Color>
public class AttributeMap {
public val map: MutableMap<MapFeature.Attribute<*>, Any> = mutableStateMapOf()
public fun <T, A : MapFeature.Attribute<T>> setAttribute(
attribute: A,
attrValue: T?,
) {
if (attrValue == null) {
map.remove(attribute)
} else {
map[attribute] = attrValue
}
}
@Suppress("UNCHECKED_CAST")
public operator fun <T> get(attribute: MapFeature.Attribute<T>): T? = map[attribute] as? T
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as AttributeMap
if (map != other.map) return false
return true
}
override fun hashCode(): Int = map.hashCode()
override fun toString(): String = "AttributeMap(value=${map.entries})"
}

View File

@ -1,43 +0,0 @@
package center.sciprog.maps.compose
import androidx.compose.ui.input.pointer.PointerEvent
import androidx.compose.ui.input.pointer.isPrimaryPressed
import center.sciprog.maps.coordinates.MapViewPoint
public fun interface DragHandle {
/**
* @param event - qualifiers of the event used for drag
* @param start - is a point where drag begins, end is a point where drag ends
* @param end - end point of the drag
*
* @return true if default event processors should be used after this one
*/
public fun handle(event: PointerEvent, start: MapViewPoint, end: MapViewPoint): Boolean
public companion object {
public val BYPASS: DragHandle = DragHandle { _, _, _ -> true }
/**
* Process only events with primary button pressed
*/
public fun withPrimaryButton(
block: (event: PointerEvent, start: MapViewPoint, end: MapViewPoint) -> Boolean,
): DragHandle = DragHandle { event, start, end ->
if (event.buttons.isPrimaryPressed) {
block(event, start, end)
} else {
true
}
}
/**
* Combine several handles into one
*/
public fun combine(vararg handles: DragHandle): DragHandle = DragHandle { event, start, end ->
handles.forEach {
if (!it.handle(event, start, end)) return@DragHandle false
}
return@DragHandle true
}
}
}

View File

@ -0,0 +1,71 @@
package center.sciprog.maps.compose
import androidx.compose.ui.unit.DpSize
import center.sciprog.maps.coordinates.*
import center.sciprog.maps.features.CoordinateSpace
import center.sciprog.maps.features.Rectangle
public object GmcCoordinateSpace : CoordinateSpace<Gmc> {
override fun buildRectangle(first: Gmc, second: Gmc): GmcRectangle = GmcRectangle(first, second)
override fun buildRectangle(center: Gmc, zoom: Double, size: DpSize): GmcRectangle{
val scale = WebMercatorProjection.scaleFactor(zoom)
return buildRectangle(center, (size.width.value/scale).radians, (size.height.value / scale).radians)
}
override fun Rectangle<Gmc>.withCenter(center: Gmc): GmcRectangle {
return buildRectangle(center, height = latitudeDelta, width = longitudeDelta)
}
override fun Collection<Rectangle<Gmc>>.wrapRectangles(): Rectangle<Gmc>? {
if (isEmpty()) return null
//TODO optimize computation
val minLat = minOf { it.bottom }
val maxLat = maxOf { it.top }
val minLong = minOf { it.left }
val maxLong = maxOf { it.right }
return GmcRectangle(GeodeticMapCoordinates(minLat, minLong), GeodeticMapCoordinates(maxLat, maxLong))
}
override fun Collection<Gmc>.wrapPoints(): Rectangle<Gmc>? {
if (isEmpty()) return null
//TODO optimize computation
val minLat = minOf { it.latitude }
val maxLat = maxOf { it.latitude }
val minLong = minOf { it.longitude }
val maxLong = maxOf { it.longitude }
return GmcRectangle(GeodeticMapCoordinates(minLat, minLong), GeodeticMapCoordinates(maxLat, maxLong))
}
}
/**
* A quasi-square section. Note that latitudinal distance could be imprecise for large distances
*/
public fun CoordinateSpace<Gmc>.buildRectangle(
center: Gmc,
height: Distance,
width: Distance,
ellipsoid: GeoEllipsoid = GeoEllipsoid.WGS84
): GmcRectangle {
val reducedRadius = ellipsoid.reducedRadius(center.latitude)
return buildRectangle(center, (height / ellipsoid.polarRadius).radians, (width / reducedRadius).radians)
}
/**
* A quasi-square section.
*/
public fun CoordinateSpace<Gmc>.buildRectangle(
center: GeodeticMapCoordinates,
height: Angle,
width: Angle,
): GmcRectangle {
val a = GeodeticMapCoordinates(
center.latitude - (height / 2),
center.longitude - (width / 2)
)
val b = GeodeticMapCoordinates(
center.latitude + (height / 2),
center.longitude + (width / 2)
)
return GmcRectangle(a, b)
}

View File

@ -0,0 +1,73 @@
package center.sciprog.maps.compose
import center.sciprog.maps.coordinates.Angle
import center.sciprog.maps.coordinates.GeodeticMapCoordinates
import center.sciprog.maps.coordinates.Gmc
import center.sciprog.maps.coordinates.abs
import center.sciprog.maps.features.Rectangle
/**
* A section of the map between two parallels and two meridians. The figure represents a square in a Mercator projection.
* Params are two opposing "corners" of quasi-square.
*
* Note that this is a rectangle only on a Mercator projection.
*/
public data class GmcRectangle(
override val a: GeodeticMapCoordinates,
override val b: GeodeticMapCoordinates,
) : Rectangle<Gmc> {
override fun contains(point: Gmc): Boolean =
point.latitude in a.latitude..b.latitude
&& point.longitude in a.longitude..b.longitude
}
public val Rectangle<Gmc>.center: GeodeticMapCoordinates
get() = GeodeticMapCoordinates(
(a.latitude + b.latitude) / 2,
(a.longitude + b.longitude) / 2
)
/**
* Minimum longitude
*/
public val Rectangle<Gmc>.left: Angle get() = minOf(a.longitude, b.longitude)
/**
* maximum longitude
*/
public val Rectangle<Gmc>.right: Angle get() = maxOf(a.longitude, b.longitude)
/**
* Maximum latitude
*/
public val Rectangle<Gmc>.top: Angle get() = maxOf(a.latitude, b.latitude)
/**
* Minimum latitude
*/
public val Rectangle<Gmc>.bottom: Angle get() = minOf(a.latitude, b.latitude)
public val Rectangle<Gmc>.longitudeDelta: Angle get() = abs(a.longitude - b.longitude)
public val Rectangle<Gmc>.latitudeDelta: Angle get() = abs(a.latitude - b.latitude)
public val Rectangle<Gmc>.topLeft: GeodeticMapCoordinates get() = GeodeticMapCoordinates(top, left)
public val Rectangle<Gmc>.bottomRight: GeodeticMapCoordinates get() = GeodeticMapCoordinates(bottom, right)
//public fun GmcRectangle.enlarge(
// top: Distance,
// bottom: Distance = top,
// left: Distance = top,
// right: Distance = left,
//): GmcRectangle {
//
//}
//
//public fun GmcRectangle.enlarge(
// top: Angle,
// bottom: Angle = top,
// left: Angle = top,
// right: Angle = left,
//): GmcRectangle {
//
//}

View File

@ -1,217 +0,0 @@
package center.sciprog.maps.compose
import androidx.compose.runtime.Composable
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.*
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.DrawStyle
import androidx.compose.ui.graphics.drawscope.Fill
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.VectorPainter
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import center.sciprog.maps.coordinates.*
import kotlin.math.floor
public interface MapFeature {
public interface Attribute<T>
public val zoomRange: ClosedFloatingPointRange<Double>
public var attributes: AttributeMap
public fun getBoundingBox(zoom: Double): GmcRectangle?
}
public interface SelectableMapFeature : MapFeature {
public operator fun contains(point: MapViewPoint): Boolean = getBoundingBox(point.zoom)?.let {
point.focus in it
} ?: false
}
public interface DraggableMapFeature : SelectableMapFeature {
public fun withCoordinates(newCoordinates: GeodeticMapCoordinates): MapFeature
}
public fun Iterable<MapFeature>.computeBoundingBox(zoom: Double): GmcRectangle? =
mapNotNull { it.getBoundingBox(zoom) }.wrapAll()
public fun Pair<Number, Number>.toCoordinates(): GeodeticMapCoordinates =
GeodeticMapCoordinates.ofDegrees(first.toDouble(), second.toDouble())
internal val defaultZoomRange = 1.0..Double.POSITIVE_INFINITY
/**
* A feature that decides what to show depending on the zoom value (it could change size of shape)
*/
public class MapFeatureSelector(
override var attributes: AttributeMap = AttributeMap(),
public val selector: (zoom: Int) -> MapFeature,
) : MapFeature {
override val zoomRange: ClosedFloatingPointRange<Double> get() = defaultZoomRange
override fun getBoundingBox(zoom: Double): GmcRectangle? = selector(floor(zoom).toInt()).getBoundingBox(zoom)
}
public class MapDrawFeature(
public val position: GeodeticMapCoordinates,
override val zoomRange: ClosedFloatingPointRange<Double> = defaultZoomRange,
override var attributes: AttributeMap = AttributeMap(),
public val drawFeature: DrawScope.() -> Unit,
) : DraggableMapFeature {
override fun getBoundingBox(zoom: Double): GmcRectangle {
//TODO add box computation
return GmcRectangle(position, position)
}
override fun withCoordinates(newCoordinates: GeodeticMapCoordinates): MapFeature =
MapDrawFeature(newCoordinates, zoomRange, attributes, drawFeature)
}
public class MapPathFeature(
public val rectangle: GmcRectangle,
public val path: Path,
public val brush: Brush,
public val style: DrawStyle = Fill,
public val targetRect: Rect = path.getBounds(),
override val zoomRange: ClosedFloatingPointRange<Double> = defaultZoomRange,
override var attributes: AttributeMap = AttributeMap(),
) : DraggableMapFeature {
override fun withCoordinates(newCoordinates: GeodeticMapCoordinates): MapFeature =
MapPathFeature(rectangle.moveTo(newCoordinates), path, brush, style, targetRect, zoomRange)
override fun getBoundingBox(zoom: Double): GmcRectangle = rectangle
}
public class MapPointsFeature(
public val points: List<GeodeticMapCoordinates>,
override val zoomRange: ClosedFloatingPointRange<Double> = defaultZoomRange,
public val stroke: Float = 2f,
public val color: Color = Color.Red,
public val pointMode: PointMode = PointMode.Points,
override var attributes: AttributeMap = AttributeMap(),
) : MapFeature {
override fun getBoundingBox(zoom: Double): GmcRectangle = GmcRectangle(points.first(), points.last())
}
public data class MapCircleFeature(
public val center: GeodeticMapCoordinates,
override val zoomRange: ClosedFloatingPointRange<Double> = defaultZoomRange,
public val size: Float = 5f,
public val color: Color = Color.Red,
override var attributes: AttributeMap = AttributeMap(),
) : DraggableMapFeature {
override fun getBoundingBox(zoom: Double): GmcRectangle {
val scale = WebMercatorProjection.scaleFactor(zoom)
return GmcRectangle.square(center, (size / scale).radians, (size / scale).radians)
}
override fun withCoordinates(newCoordinates: GeodeticMapCoordinates): MapFeature =
MapCircleFeature(newCoordinates, zoomRange, size, color, attributes)
}
public class MapRectangleFeature(
public val center: GeodeticMapCoordinates,
override val zoomRange: ClosedFloatingPointRange<Double> = defaultZoomRange,
public val size: DpSize = DpSize(5.dp, 5.dp),
public val color: Color = Color.Red,
override var attributes: AttributeMap = AttributeMap(),
) : DraggableMapFeature {
override fun getBoundingBox(zoom: Double): GmcRectangle {
val scale = WebMercatorProjection.scaleFactor(zoom)
return GmcRectangle.square(center, (size.height.value / scale).radians, (size.width.value / scale).radians)
}
override fun withCoordinates(newCoordinates: GeodeticMapCoordinates): MapFeature =
MapRectangleFeature(newCoordinates, zoomRange, size, color, attributes)
}
public class MapLineFeature(
public val a: GeodeticMapCoordinates,
public val b: GeodeticMapCoordinates,
override val zoomRange: ClosedFloatingPointRange<Double> = defaultZoomRange,
public val color: Color = Color.Red,
override var attributes: AttributeMap = AttributeMap(),
) : SelectableMapFeature {
override fun getBoundingBox(zoom: Double): GmcRectangle = GmcRectangle(a, b)
override fun contains(point: MapViewPoint): Boolean {
return super.contains(point)
}
}
/**
* @param startAngle the angle from parallel downwards for the start of the arc
* @param arcLength arc length
*/
public class MapArcFeature(
public val oval: GmcRectangle,
public val startAngle: Angle,
public val arcLength: Angle,
override val zoomRange: ClosedFloatingPointRange<Double> = defaultZoomRange,
public val color: Color = Color.Red,
override var attributes: AttributeMap = AttributeMap(),
) : DraggableMapFeature {
override fun getBoundingBox(zoom: Double): GmcRectangle = oval
override fun withCoordinates(newCoordinates: GeodeticMapCoordinates): MapFeature =
MapArcFeature(oval.moveTo(newCoordinates), startAngle, arcLength, zoomRange, color, attributes)
}
public class MapBitmapImageFeature(
public val position: GeodeticMapCoordinates,
public val image: ImageBitmap,
public val size: IntSize = IntSize(15, 15),
override val zoomRange: ClosedFloatingPointRange<Double> = defaultZoomRange,
override var attributes: AttributeMap = AttributeMap(),
) : DraggableMapFeature {
override fun getBoundingBox(zoom: Double): GmcRectangle = GmcRectangle(position, position)
override fun withCoordinates(newCoordinates: GeodeticMapCoordinates): MapFeature =
MapBitmapImageFeature(newCoordinates, image, size, zoomRange, attributes)
}
public class MapVectorImageFeature(
public val position: GeodeticMapCoordinates,
public val image: ImageVector,
public val size: DpSize = DpSize(20.dp, 20.dp),
override val zoomRange: ClosedFloatingPointRange<Double> = defaultZoomRange,
override var attributes: AttributeMap = AttributeMap(),
) : DraggableMapFeature {
override fun getBoundingBox(zoom: Double): GmcRectangle = GmcRectangle(position, position)
override fun withCoordinates(newCoordinates: GeodeticMapCoordinates): MapFeature =
MapVectorImageFeature(newCoordinates, image, size, zoomRange, attributes)
@Composable
public fun painter(): VectorPainter = rememberVectorPainter(image)
}
/**
* A group of other features
*/
public class MapFeatureGroup(
public val children: Map<FeatureId<*>, MapFeature>,
override val zoomRange: ClosedFloatingPointRange<Double> = defaultZoomRange,
override var attributes: AttributeMap = AttributeMap(),
) : MapFeature {
override fun getBoundingBox(zoom: Double): GmcRectangle? =
children.values.mapNotNull { it.getBoundingBox(zoom) }.wrapAll()
}
public class MapTextFeature(
public val position: GeodeticMapCoordinates,
public val text: String,
override val zoomRange: ClosedFloatingPointRange<Double> = defaultZoomRange,
public val color: Color = Color.Black,
override var attributes: AttributeMap = AttributeMap(),
public val fontConfig: MapTextFeatureFont.() -> Unit,
) : DraggableMapFeature {
override fun getBoundingBox(zoom: Double): GmcRectangle = GmcRectangle(position, position)
override fun withCoordinates(newCoordinates: GeodeticMapCoordinates): MapFeature =
MapTextFeature(newCoordinates, text, zoomRange, color, attributes, fontConfig)
}

View File

@ -1,302 +0,0 @@
package center.sciprog.maps.compose
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.remember
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.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
@JvmInline
public value class FeatureId<out MapFeature>(public val id: String)
public class MapFeaturesState {
@PublishedApi
internal val featureMap: MutableMap<String, MapFeature> = mutableStateMapOf()
//TODO use context receiver for that
public fun FeatureId<DraggableMapFeature>.draggable(
//TODO add constraints
callback: DragHandle = DragHandle.BYPASS,
) {
val handle = DragHandle.withPrimaryButton { event, start, end ->
val feature = featureMap[id] as? DraggableMapFeature ?: return@withPrimaryButton true
val boundingBox = feature.getBoundingBox(start.zoom) ?: return@withPrimaryButton true
if (start.focus in boundingBox) {
feature(id, feature.withCoordinates(end.focus))
callback.handle(event, start, end)
false
} else {
true
}
}
setAttribute(this, DraggableAttribute, handle)
}
/**
* Cyclic update of a feature. Called infinitely until canceled.
*/
public fun <T : MapFeature> FeatureId<T>.updated(
scope: CoroutineScope,
update: suspend (T) -> T,
): Job = scope.launch {
while (isActive) {
feature(this@updated, update(getFeature(this@updated)))
}
}
@Suppress("UNCHECKED_CAST")
public fun <T : SelectableMapFeature> FeatureId<T>.selectable(
onSelect: (FeatureId<T>, T) -> Unit,
) {
setAttribute(this, SelectableAttribute) { id, feature -> onSelect(id as FeatureId<T>, feature as T) }
}
public val features: Map<FeatureId<*>, MapFeature>
get() = featureMap.mapKeys { FeatureId<MapFeature>(it.key) }
@Suppress("UNCHECKED_CAST")
public fun <T : MapFeature> getFeature(id: FeatureId<T>): T = featureMap[id.id] as T
private fun generateID(feature: MapFeature): String = "@feature[${feature.hashCode().toUInt()}]"
public fun <T : MapFeature> feature(id: String?, feature: T): FeatureId<T> {
val safeId = id ?: generateID(feature)
featureMap[safeId] = feature
return FeatureId(safeId)
}
public fun <T : MapFeature> feature(id: FeatureId<T>?, feature: T): FeatureId<T> = feature(id?.id, feature)
public fun <T> setAttribute(id: FeatureId<MapFeature>, key: MapFeature.Attribute<T>, value: T?) {
getFeature(id).attributes.setAttribute(key, value)
}
@Suppress("UNCHECKED_CAST")
public fun <T> getAttribute(id: FeatureId<MapFeature>, key: MapFeature.Attribute<T>): T? =
getFeature(id).attributes[key]
// @Suppress("UNCHECKED_CAST")
// public fun <T> findAllWithAttribute(key: Attribute<T>, condition: (T) -> Boolean): Set<FeatureId> {
// return attributes.filterValues {
// condition(it[key] as T)
// }.keys
// }
public inline fun <T> forEachWithAttribute(
key: MapFeature.Attribute<T>,
block: (id: FeatureId<*>, attributeValue: T) -> Unit,
) {
featureMap.forEach { (id, feature) ->
feature.attributes[key]?.let {
block(FeatureId<MapFeature>(id), it)
}
}
}
public companion object {
/**
* Build, but do not remember map feature state
*/
public fun build(
builder: MapFeaturesState.() -> Unit = {},
): MapFeaturesState = MapFeaturesState().apply(builder)
/**
* Build and remember map feature state
*/
@Composable
public fun remember(
builder: MapFeaturesState.() -> Unit = {},
): MapFeaturesState = remember(builder) {
build(builder)
}
}
}
public fun MapFeaturesState.circle(
center: GeodeticMapCoordinates,
zoomRange: IntRange = defaultZoomRange,
size: Float = 5f,
color: Color = Color.Red,
id: String? = null,
): FeatureId<MapCircleFeature> = feature(
id, MapCircleFeature(center, zoomRange, size, color)
)
public fun MapFeaturesState.circle(
centerCoordinates: Pair<Double, Double>,
zoomRange: IntRange = defaultZoomRange,
size: Float = 5f,
color: Color = Color.Red,
id: String? = null,
): FeatureId<MapCircleFeature> = feature(
id, MapCircleFeature(centerCoordinates.toCoordinates(), zoomRange, size, color)
)
public fun MapFeaturesState.rectangle(
centerCoordinates: Gmc,
zoomRange: IntRange = defaultZoomRange,
size: DpSize = DpSize(5.dp, 5.dp),
color: Color = Color.Red,
id: String? = null,
): FeatureId<MapRectangleFeature> = feature(
id, MapRectangleFeature(centerCoordinates, zoomRange, size, color)
)
public fun MapFeaturesState.rectangle(
centerCoordinates: Pair<Double, Double>,
zoomRange: IntRange = defaultZoomRange,
size: DpSize = DpSize(5.dp, 5.dp),
color: Color = Color.Red,
id: String? = null,
): FeatureId<MapRectangleFeature> = feature(
id, MapRectangleFeature(centerCoordinates.toCoordinates(), zoomRange, size, color)
)
public fun MapFeaturesState.draw(
position: Pair<Double, Double>,
zoomRange: IntRange = defaultZoomRange,
id: String? = null,
draw: DrawScope.() -> Unit,
): FeatureId<MapDrawFeature> = feature(id, MapDrawFeature(position.toCoordinates(), zoomRange, drawFeature = draw))
public fun MapFeaturesState.line(
aCoordinates: Gmc,
bCoordinates: Gmc,
zoomRange: IntRange = defaultZoomRange,
color: Color = Color.Red,
id: String? = null,
): FeatureId<MapLineFeature> = feature(
id,
MapLineFeature(aCoordinates, bCoordinates, zoomRange, color)
)
public fun MapFeaturesState.line(
curve: GmcCurve,
zoomRange: IntRange = defaultZoomRange,
color: Color = Color.Red,
id: String? = null,
): FeatureId<MapLineFeature> = feature(
id,
MapLineFeature(curve.forward.coordinates, curve.backward.coordinates, zoomRange, color)
)
public fun MapFeaturesState.line(
aCoordinates: Pair<Double, Double>,
bCoordinates: Pair<Double, Double>,
zoomRange: IntRange = defaultZoomRange,
color: Color = Color.Red,
id: String? = null,
): FeatureId<MapLineFeature> = feature(
id,
MapLineFeature(aCoordinates.toCoordinates(), bCoordinates.toCoordinates(), zoomRange, color)
)
public fun MapFeaturesState.arc(
oval: GmcRectangle,
startAngle: Angle,
arcLength: Angle,
zoomRange: IntRange = defaultZoomRange,
color: Color = Color.Red,
id: String? = null,
): FeatureId<MapArcFeature> = feature(
id,
MapArcFeature(oval, startAngle, arcLength, zoomRange, color)
)
public fun MapFeaturesState.arc(
center: Pair<Double, Double>,
radius: Distance,
startAngle: Angle,
arcLength: Angle,
zoomRange: IntRange = defaultZoomRange,
color: Color = Color.Red,
id: String? = null,
): FeatureId<MapArcFeature> = feature(
id,
MapArcFeature(
oval = GmcRectangle.square(center.toCoordinates(), radius, radius),
startAngle = startAngle,
arcLength = arcLength,
zoomRange = zoomRange,
color = color
)
)
public fun MapFeaturesState.points(
points: List<Gmc>,
zoomRange: IntRange = defaultZoomRange,
stroke: Float = 2f,
color: Color = Color.Red,
pointMode: PointMode = PointMode.Points,
id: String? = null,
): FeatureId<MapPointsFeature> = feature(id, MapPointsFeature(points, zoomRange, stroke, color, pointMode))
@JvmName("pointsFromPairs")
public fun MapFeaturesState.points(
points: List<Pair<Double, Double>>,
zoomRange: IntRange = defaultZoomRange,
stroke: Float = 2f,
color: Color = Color.Red,
pointMode: PointMode = PointMode.Points,
id: String? = null,
): FeatureId<MapPointsFeature> =
feature(id, MapPointsFeature(points.map { it.toCoordinates() }, zoomRange, stroke, color, pointMode))
public fun MapFeaturesState.image(
position: Pair<Double, Double>,
image: ImageVector,
size: DpSize = DpSize(20.dp, 20.dp),
zoomRange: IntRange = defaultZoomRange,
id: String? = null,
): FeatureId<MapVectorImageFeature> =
feature(id, MapVectorImageFeature(position.toCoordinates(), image, size, zoomRange))
public fun MapFeaturesState.group(
zoomRange: IntRange = defaultZoomRange,
id: String? = null,
builder: MapFeaturesState.() -> Unit,
): FeatureId<MapFeatureGroup> {
val map = MapFeaturesState().apply(builder).features
val feature = MapFeatureGroup(map, zoomRange)
return feature(id, feature)
}
public fun MapFeaturesState.text(
position: GeodeticMapCoordinates,
text: String,
zoomRange: IntRange = defaultZoomRange,
color: Color = Color.Red,
font: MapTextFeatureFont.() -> Unit = { size = 16f },
id: String? = null,
): FeatureId<MapTextFeature> = feature(
id,
MapTextFeature(position, text, zoomRange, color, fontConfig = font)
)
public fun MapFeaturesState.text(
position: Pair<Double, Double>,
text: String,
zoomRange: IntRange = defaultZoomRange,
color: Color = Color.Red,
font: MapTextFeatureFont.() -> Unit = { size = 16f },
id: String? = null,
): FeatureId<MapTextFeature> = feature(
id,
MapTextFeature(position.toCoordinates(), text, zoomRange, color, fontConfig = font)
)

View File

@ -1,5 +0,0 @@
package center.sciprog.maps.compose
public expect class MapTextFeatureFont {
public var size: Float
}

View File

@ -8,7 +8,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.PointerEvent
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import center.sciprog.maps.coordinates.*
import center.sciprog.maps.coordinates.Gmc
import center.sciprog.maps.features.*
import kotlin.math.PI
import kotlin.math.log2
import kotlin.math.min
@ -20,7 +21,7 @@ import kotlin.math.min
public data class MapViewConfig(
val zoomSpeed: Double = 1.0 / 3.0,
val onClick: MapViewPoint.(PointerEvent) -> Unit = {},
val dragHandle: DragHandle = DragHandle.BYPASS,
val dragHandle: DragHandle<Gmc> = DragHandle.bypass(),
val onViewChange: MapViewPoint.() -> Unit = {},
val onSelect: (GmcRectangle) -> Unit = {},
val zoomOnSelect: Boolean = true,
@ -31,14 +32,14 @@ public data class MapViewConfig(
public expect fun MapView(
mapTileProvider: MapTileProvider,
initialViewPoint: MapViewPoint,
featuresState: MapFeaturesState,
featuresState: FeaturesState<Gmc>,
config: MapViewConfig = MapViewConfig(),
modifier: Modifier = Modifier.fillMaxSize(),
)
internal val defaultCanvasSize = DpSize(512.dp, 512.dp)
public fun GmcRectangle.computeViewPoint(
public fun Rectangle<Gmc>.computeViewPoint(
mapTileProvider: MapTileProvider,
canvasSize: DpSize = defaultCanvasSize,
): MapViewPoint {
@ -64,7 +65,7 @@ public fun MapView(
modifier: Modifier = Modifier.fillMaxSize(),
) {
val featuresState = key(featureMap) {
MapFeaturesState.build {
FeaturesState.build(GmcCoordinateSpace) {
featureMap.forEach { feature(it.key.id, it.value) }
}
}
@ -72,7 +73,7 @@ public fun MapView(
val viewPointOverride: MapViewPoint = remember(initialViewPoint, initialRectangle) {
initialViewPoint
?: initialRectangle?.computeViewPoint(mapTileProvider)
?: featureMap.values.computeBoundingBox(1.0)?.computeViewPoint(mapTileProvider)
?: featureMap.values.computeBoundingBox(GmcCoordinateSpace, 1.0)?.computeViewPoint(mapTileProvider)
?: MapViewPoint.globe
}
@ -93,19 +94,20 @@ public fun MapView(
initialRectangle: GmcRectangle? = null,
config: MapViewConfig = MapViewConfig(),
modifier: Modifier = Modifier.fillMaxSize(),
buildFeatures: MapFeaturesState.() -> Unit = {},
buildFeatures: FeaturesState<Gmc>.() -> Unit = {},
) {
val featureState = MapFeaturesState.remember(buildFeatures)
val featureState = FeaturesState.remember(GmcCoordinateSpace, buildFeatures)
val viewPointOverride: MapViewPoint = remember(initialViewPoint, initialRectangle) {
initialViewPoint
?: initialRectangle?.computeViewPoint(mapTileProvider)
?: featureState.features.values.computeBoundingBox(1.0)?.computeViewPoint(mapTileProvider)
?: featureState.features.values.computeBoundingBox(GmcCoordinateSpace,1.0)?.computeViewPoint(mapTileProvider)
?: MapViewPoint.globe
}
val featureDrag: DragHandle = DragHandle.withPrimaryButton { event, start, end ->
val featureDrag: DragHandle<Gmc> = DragHandle.withPrimaryButton { event, start: ViewPoint<Gmc>, end: ViewPoint<Gmc> ->
featureState.forEachWithAttribute(DraggableAttribute) { _, handle ->
handle as DragHandle<Gmc>
if (!handle.handle(event, start, end)) return@withPrimaryButton false
}
true

View File

@ -1,14 +1,16 @@
package center.sciprog.maps.coordinates
package center.sciprog.maps.compose
import center.sciprog.maps.coordinates.*
import center.sciprog.maps.features.ViewPoint
import kotlin.math.pow
/**
* Observable position on the map. Includes observation coordinate and [zoom] factor
*/
public data class MapViewPoint(
val focus: GeodeticMapCoordinates,
val zoom: Double,
) {
override val focus: GeodeticMapCoordinates,
override val zoom: Double,
) : ViewPoint<Gmc>{
val scaleFactor: Double by lazy { WebMercatorProjection.scaleFactor(zoom) }
public companion object{

View File

@ -0,0 +1,132 @@
package center.sciprog.maps.compose
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.Dp
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import center.sciprog.maps.coordinates.*
import center.sciprog.maps.features.*
import center.sciprog.maps.features.Feature.Companion.defaultZoomRange
internal fun FeaturesState<Gmc>.coordinatesOf(pair: Pair<Number, Number>) =
GeodeticMapCoordinates.ofDegrees(pair.first.toDouble(), pair.second.toDouble())
public typealias MapFeature = Feature<Gmc>
public fun FeaturesState<Gmc>.circle(
centerCoordinates: Pair<Number, Number>,
zoomRange: DoubleRange = defaultZoomRange,
size: Dp = 5.dp,
color: Color = Color.Red,
id: String? = null,
): FeatureId<CircleFeature<Gmc>> = feature(
id, CircleFeature(coordinateSpace, coordinatesOf(centerCoordinates), zoomRange, size, color)
)
public fun FeaturesState<Gmc>.rectangle(
centerCoordinates: Pair<Number, Number>,
zoomRange: DoubleRange = defaultZoomRange,
size: DpSize = DpSize(5.dp, 5.dp),
color: Color = Color.Red,
id: String? = null,
): FeatureId<RectangleFeature<Gmc>> = feature(
id, RectangleFeature(coordinateSpace, coordinatesOf(centerCoordinates), zoomRange, size, color)
)
public fun FeaturesState<Gmc>.draw(
position: Pair<Number, Number>,
zoomRange: DoubleRange = defaultZoomRange,
id: String? = null,
draw: DrawScope.() -> Unit,
): FeatureId<DrawFeature<Gmc>> = feature(
id,
DrawFeature(coordinateSpace, coordinatesOf(position), zoomRange, drawFeature = draw)
)
public fun FeaturesState<Gmc>.line(
curve: GmcCurve,
zoomRange: DoubleRange = defaultZoomRange,
color: Color = Color.Red,
id: String? = null,
): FeatureId<LineFeature<Gmc>> = feature(
id,
LineFeature(coordinateSpace, curve.forward.coordinates, curve.backward.coordinates, zoomRange, color)
)
public fun FeaturesState<Gmc>.line(
aCoordinates: Pair<Double, Double>,
bCoordinates: Pair<Double, Double>,
zoomRange: DoubleRange = defaultZoomRange,
color: Color = Color.Red,
id: String? = null,
): FeatureId<LineFeature<Gmc>> = feature(
id,
LineFeature(coordinateSpace, coordinatesOf(aCoordinates), coordinatesOf(bCoordinates), zoomRange, color)
)
public fun FeaturesState<Gmc>.arc(
center: Pair<Double, Double>,
radius: Distance,
startAngle: Angle,
arcLength: Angle,
zoomRange: DoubleRange = defaultZoomRange,
color: Color = Color.Red,
id: String? = null,
): FeatureId<ArcFeature<Gmc>> = feature(
id,
ArcFeature(
coordinateSpace,
oval = buildRectangle(coordinatesOf(center), radius, radius),
startAngle = startAngle.radians.toFloat(),
arcLength = arcLength.radians.toFloat(),
zoomRange = zoomRange,
color = color
)
)
public fun FeaturesState<Gmc>.points(
points: List<Pair<Double, Double>>,
zoomRange: DoubleRange = defaultZoomRange,
stroke: Float = 2f,
color: Color = Color.Red,
pointMode: PointMode = PointMode.Points,
id: String? = null,
): FeatureId<PointsFeature<Gmc>> =
feature(id, PointsFeature(coordinateSpace, points.map(::coordinatesOf), zoomRange, stroke, color, pointMode))
public fun FeaturesState<Gmc>.image(
position: Pair<Double, Double>,
image: ImageVector,
size: DpSize = DpSize(20.dp, 20.dp),
zoomRange: DoubleRange = defaultZoomRange,
id: String? = null,
): FeatureId<VectorImageFeature<Gmc>> = feature(
id,
VectorImageFeature(
coordinateSpace,
coordinatesOf(position),
size,
image,
zoomRange
)
)
public fun FeaturesState<Gmc>.text(
position: Pair<Double, Double>,
text: String,
zoomRange: DoubleRange = defaultZoomRange,
color: Color = Color.Red,
font: FeatureFont.() -> Unit = { size = 16f },
id: String? = null,
): FeatureId<TextFeature<Gmc>> = feature(
id,
TextFeature(coordinateSpace, coordinatesOf(position), text, zoomRange, color, fontConfig = font)
)

View File

@ -1,5 +0,0 @@
package center.sciprog.maps.compose
import org.jetbrains.skia.Font
public actual typealias MapTextFeatureFont = Font

View File

@ -15,6 +15,7 @@ import androidx.compose.ui.graphics.drawscope.*
import androidx.compose.ui.input.pointer.*
import androidx.compose.ui.unit.*
import center.sciprog.maps.coordinates.*
import center.sciprog.maps.features.*
import kotlinx.coroutines.launch
import kotlinx.coroutines.supervisorScope
import mu.KotlinLogging
@ -50,7 +51,7 @@ private val logger = KotlinLogging.logger("MapView")
public actual fun MapView(
mapTileProvider: MapTileProvider,
initialViewPoint: MapViewPoint,
featuresState: MapFeaturesState,
featuresState: FeaturesState<Gmc>,
config: MapViewConfig,
modifier: Modifier,
): Unit = key(initialViewPoint) {
@ -214,7 +215,7 @@ public actual fun MapView(
}
val painterCache = key(featuresState) {
featuresState.features.values.filterIsInstance<MapVectorImageFeature>().associateWith { it.painter() }
featuresState.features.values.filterIsInstance<VectorImageFeature<Gmc>>().associateWith { it.painter() }
}
Canvas(canvasModifier) {
@ -229,14 +230,14 @@ public actual fun MapView(
fun DrawScope.drawFeature(zoom: Int, feature: MapFeature) {
when (feature) {
is MapFeatureSelector -> drawFeature(zoom, feature.selector(zoom))
is MapCircleFeature -> drawCircle(
is FeatureSelector -> drawFeature(zoom, feature.selector(zoom))
is CircleFeature -> drawCircle(
feature.color,
feature.size,
feature.size.toPx(),
center = feature.center.toOffset()
)
is MapRectangleFeature -> drawRect(
is RectangleFeature -> drawRect(
feature.color,
topLeft = feature.center.toOffset() - Offset(
feature.size.width.toPx() / 2,
@ -245,8 +246,8 @@ public actual fun MapView(
size = feature.size.toSize()
)
is MapLineFeature -> drawLine(feature.color, feature.a.toOffset(), feature.b.toOffset())
is MapArcFeature -> {
is LineFeature -> drawLine(feature.color, feature.a.toOffset(), feature.b.toOffset())
is ArcFeature -> {
val topLeft = feature.oval.topLeft.toOffset()
val bottomRight = feature.oval.bottomRight.toOffset()
@ -254,8 +255,8 @@ public actual fun MapView(
drawArc(
color = feature.color,
startAngle = feature.startAngle.degrees.toFloat(),
sweepAngle = feature.arcLength.degrees.toFloat(),
startAngle = feature.startAngle.radians.degrees.toFloat(),
sweepAngle = feature.arcLength.radians.degrees.toFloat(),
useCenter = false,
topLeft = topLeft,
size = size,
@ -264,9 +265,9 @@ public actual fun MapView(
}
is MapBitmapImageFeature -> drawImage(feature.image, feature.position.toOffset())
is BitmapImageFeature -> drawImage(feature.image, feature.position.toOffset())
is MapVectorImageFeature -> {
is VectorImageFeature -> {
val offset = feature.position.toOffset()
val size = feature.size.toSize()
translate(offset.x - size.width / 2, offset.y - size.height / 2) {
@ -276,7 +277,7 @@ public actual fun MapView(
}
}
is MapTextFeature -> drawIntoCanvas { canvas ->
is TextFeature -> drawIntoCanvas { canvas ->
val offset = feature.position.toOffset()
canvas.nativeCanvas.drawString(
feature.text,
@ -287,20 +288,20 @@ public actual fun MapView(
)
}
is MapDrawFeature -> {
is DrawFeature -> {
val offset = feature.position.toOffset()
translate(offset.x, offset.y) {
feature.drawFeature(this)
}
}
is MapFeatureGroup -> {
is FeatureGroup -> {
feature.children.values.forEach {
drawFeature(zoom, it)
}
}
is MapPathFeature -> {
is PathFeature -> {
TODO("MapPathFeature not implemented")
// val offset = feature.rectangle.center.toOffset() - feature.targetRect.center
// translate(offset.x, offset.y) {
@ -309,7 +310,7 @@ public actual fun MapView(
// }
}
is MapPointsFeature -> {
is PointsFeature -> {
val points = feature.points.map { it.toOffset() }
drawPoints(
points = points,
@ -349,7 +350,7 @@ public actual fun MapView(
)
}
featuresState.features.values.filter { zoom in it.zoomRange }.forEach { feature ->
featuresState.features.values.filter { viewPoint.zoom in it.zoomRange }.forEach { feature ->
drawFeature(zoom, feature)
}
}

View File

@ -1,6 +1,5 @@
package center.sciprog.maps.coordinates
import kotlin.math.acos
import kotlin.math.pow
import kotlin.math.sqrt
@ -48,7 +47,7 @@ public class GeoEllipsoid(public val equatorRadius: Distance, public val polarRa
/**
* A radius of circle normal to the axis of the ellipsoid at given latitude
*/
internal fun GeoEllipsoid.reducedRadius(latitude: Angle): Distance {
public fun GeoEllipsoid.reducedRadius(latitude: Angle): Distance {
val reducedLatitudeTan = (1 - f) * tan(latitude)
return equatorRadius / sqrt(1.0 + reducedLatitudeTan.pow(2))
}

View File

@ -1,118 +0,0 @@
package center.sciprog.maps.coordinates
/**
* A section of the map between two parallels and two meridians. The figure represents a square in a Mercator projection.
* Params are two opposing "corners" of quasi-square.
*
* Note that this is a rectangle only on a Mercator projection.
*/
public data class GmcRectangle(
public val a: GeodeticMapCoordinates,
public val b: GeodeticMapCoordinates,
) {
public companion object {
/**
* A quasi-square section.
*/
public fun square(
center: GeodeticMapCoordinates,
height: Angle,
width: Angle,
): GmcRectangle {
val a = GeodeticMapCoordinates(
center.latitude - (height / 2),
center.longitude - (width / 2)
)
val b = GeodeticMapCoordinates(
center.latitude + (height / 2),
center.longitude + (width / 2)
)
return GmcRectangle(a, b)
}
/**
* A quasi-square section. Note that latitudinal distance could be imprecise for large distances
*/
public fun square(
center: GeodeticMapCoordinates,
height: Distance,
width: Distance,
ellipsoid: GeoEllipsoid = GeoEllipsoid.WGS84,
): GmcRectangle {
val reducedRadius = ellipsoid.reducedRadius(center.latitude)
return square(center, (height / ellipsoid.polarRadius).radians, (width / reducedRadius).radians)
}
}
}
public fun GmcRectangle.moveTo(newCenter: Gmc): GmcRectangle = GmcRectangle.square(newCenter, height = latitudeDelta, width = longitudeDelta)
public val GmcRectangle.center: GeodeticMapCoordinates
get() = GeodeticMapCoordinates(
(a.latitude + b.latitude) / 2,
(a.longitude + b.longitude) / 2
)
/**
* Minimum longitude
*/
public val GmcRectangle.left: Angle get() = minOf(a.longitude, b.longitude)
/**
* maximum longitude
*/
public val GmcRectangle.right: Angle get() = maxOf(a.longitude, b.longitude)
/**
* Maximum latitude
*/
public val GmcRectangle.top: Angle get() = maxOf(a.latitude, b.latitude)
/**
* Minimum latitude
*/
public val GmcRectangle.bottom: Angle get() = minOf(a.latitude, b.latitude)
public val GmcRectangle.longitudeDelta: Angle get() = abs(a.longitude - b.longitude)
public val GmcRectangle.latitudeDelta: Angle get() = abs(a.latitude - b.latitude)
public val GmcRectangle.topLeft: GeodeticMapCoordinates get() = GeodeticMapCoordinates(top, left)
public val GmcRectangle.bottomRight: GeodeticMapCoordinates get() = GeodeticMapCoordinates(bottom, right)
//public fun GmcRectangle.enlarge(
// top: Distance,
// bottom: Distance = top,
// left: Distance = top,
// right: Distance = left,
//): GmcRectangle {
//
//}
//
//public fun GmcRectangle.enlarge(
// top: Angle,
// bottom: Angle = top,
// left: Angle = top,
// right: Angle = left,
//): GmcRectangle {
//
//}
/**
* Check if coordinate is inside the box
*/
public operator fun GmcRectangle.contains(coordinate: Gmc): Boolean =
coordinate.latitude in (bottom..top) && coordinate.longitude in (left..right)
/**
* Compute a minimal bounding box including all given boxes. Return null if collection is empty
*/
public fun Collection<GmcRectangle>.wrapAll(): GmcRectangle? {
if (isEmpty()) return null
//TODO optimize computation
val minLat = minOf { it.bottom }
val maxLat = maxOf { it.top }
val minLong = minOf { it.left }
val maxLong = maxOf { it.right }
return GmcRectangle(GeodeticMapCoordinates(minLat, minLong), GeodeticMapCoordinates(maxLat, maxLong))
}

View File

@ -4,7 +4,9 @@ import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.ui.graphics.Color
public object DraggableAttribute : Feature.Attribute<DragHandle<*>>
public object SelectableAttribute : Feature.Attribute<(FeatureId<*>, SelectableFeature<*>) -> Unit>
public object VisibleAttribute : Feature.Attribute<Boolean>
public object ColorAttribute : Feature.Attribute<Color>

View File

@ -3,13 +3,22 @@ package center.sciprog.maps.features
import androidx.compose.ui.unit.DpSize
public interface Rectangle<T : Any> {
public val topLeft: T
public val bottomRight: T
public interface Area<T: Any>{
public operator fun contains(point: T): Boolean
}
/**
* A map coordinates rectangle. [a] and [b] represent opposing angles
* of the rectangle without specifying which ones.
*/
public interface Rectangle<T : Any>: Area<T> {
public val a: T
public val b: T
}
/**
* A context for map/scheme coordinates manipulation
*/
public interface CoordinateSpace<T : Any> {
/**
@ -21,14 +30,13 @@ public interface CoordinateSpace<T : Any> {
* Build a rectangle of visual size [size]
*/
public fun buildRectangle(center: T, zoom: Double, size: DpSize): Rectangle<T>
//GmcRectangle.square(center, (size.height.value / scale).radians, (size.width.value / scale).radians)
/**
* Move given rectangle to be centered at [center]
*/
public fun Rectangle<T>.withCenter(center: T): Rectangle<T>
public fun Iterable<Rectangle<T>>.computeRectangle(): Rectangle<T>?
public fun Collection<Rectangle<T>>.wrapRectangles(): Rectangle<T>?
public fun Iterable<T>.computeRectangle(): Rectangle<T>?
public fun Collection<T>.wrapPoints(): Rectangle<T>?
}

View File

@ -3,7 +3,7 @@ package center.sciprog.maps.features
import androidx.compose.ui.input.pointer.PointerEvent
import androidx.compose.ui.input.pointer.isPrimaryPressed
public fun interface DragHandle<in V: ViewPoint<*>> {
public fun interface DragHandle<T: Any>{
/**
* @param event - qualifiers of the event used for drag
* @param start - is a point where drag begins, end is a point where drag ends
@ -11,17 +11,17 @@ public fun interface DragHandle<in V: ViewPoint<*>> {
*
* @return true if default event processors should be used after this one
*/
public fun handle(event: PointerEvent, start: V, end: V): Boolean
public fun handle(event: PointerEvent, start: ViewPoint<T>, end: ViewPoint<T>): Boolean
public companion object {
public val BYPASS: DragHandle<*> = DragHandle<ViewPoint<*>> { _, _, _ -> true }
public fun <T: Any> bypass(): DragHandle<T> = DragHandle<T> { _, _, _ -> true }
/**
* Process only events with primary button pressed
*/
public fun <V> withPrimaryButton(
block: (event: PointerEvent, start: V, end: V) -> Boolean,
): DragHandle<V> = DragHandle { event, start, end ->
public fun <T: Any> withPrimaryButton(
block: (event: PointerEvent, start: ViewPoint<T>, end: ViewPoint<T>) -> Boolean,
): DragHandle<T> = DragHandle { event, start, end ->
if (event.buttons.isPrimaryPressed) {
block(event, start, end)
} else {
@ -32,7 +32,7 @@ public fun interface DragHandle<in V: ViewPoint<*>> {
/**
* Combine several handles into one
*/
public fun <V> combine(vararg handles: DragHandle<V>): DragHandle<V> = DragHandle { event, start, end ->
public fun <T: Any> combine(vararg handles: DragHandle<T>): DragHandle<T> = DragHandle { event, start, end ->
handles.forEach {
if (!it.handle(event, start, end)) return@DragHandle false
}

View File

@ -12,19 +12,25 @@ import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import center.sciprog.maps.features.Feature.Companion.defaultZoomRange
import kotlin.math.floor
public typealias DoubleRange = ClosedFloatingPointRange<Double>
public interface Feature<T : Any> {
public interface Attribute<T>
public val space: CoordinateSpace<T>
public val zoomRange: ClosedFloatingPointRange<Double>
public val zoomRange: DoubleRange
public var attributes: AttributeMap
public fun getBoundingBox(zoom: Double): Rectangle<T>?
public companion object {
public val defaultZoomRange: ClosedFloatingPointRange<Double> = 1.0..Double.POSITIVE_INFINITY
}
}
public interface SelectableFeature<T : Any> : Feature<T> {
@ -41,13 +47,12 @@ public fun <T : Any> Iterable<Feature<T>>.computeBoundingBox(
space: CoordinateSpace<T>,
zoom: Double,
): Rectangle<T>? = with(space) {
mapNotNull { it.getBoundingBox(zoom) }.computeRectangle()
mapNotNull { it.getBoundingBox(zoom) }.wrapRectangles()
}
//public fun Pair<Number, Number>.toCoordinates(): GeodeticMapCoordinates =
// GeodeticMapCoordinates.ofDegrees(first.toDouble(), second.toDouble())
internal val defaultZoomRange = 1.0..Double.POSITIVE_INFINITY
/**
* A feature that decides what to show depending on the zoom value (it could change size of shape)
@ -62,20 +67,6 @@ public class FeatureSelector<T : Any>(
override fun getBoundingBox(zoom: Double): Rectangle<T>? = selector(floor(zoom).toInt()).getBoundingBox(zoom)
}
public class DrawFeature<T : Any>(
override val space: CoordinateSpace<T>,
public val rectangle: Rectangle<T>,
override val zoomRange: ClosedFloatingPointRange<Double> = defaultZoomRange,
override var attributes: AttributeMap = AttributeMap(),
public val drawFeature: DrawScope.() -> Unit,
) : DraggableFeature<T> {
override fun getBoundingBox(zoom: Double): Rectangle<T> = rectangle
override fun withCoordinates(newCoordinates: T): Feature<T> = with(space) {
DrawFeature(space, rectangle.withCenter(newCoordinates), zoomRange, attributes, drawFeature)
}
}
public class PathFeature<T : Any>(
override val space: CoordinateSpace<T>,
public val rectangle: Rectangle<T>,
@ -112,7 +103,7 @@ public class PointsFeature<T : Any>(
override var attributes: AttributeMap = AttributeMap(),
) : Feature<T> {
override fun getBoundingBox(zoom: Double): Rectangle<T>? = with(space) {
points.computeRectangle()
points.wrapPoints()
}
}
@ -182,32 +173,43 @@ public class ArcFeature<T : Any>(
}
}
public class BitmapImageFeature<T : Any>(
public data class DrawFeature<T : Any>(
override val space: CoordinateSpace<T>,
public val rectangle: Rectangle<T>,
public val position: T,
override val zoomRange: ClosedFloatingPointRange<Double> = defaultZoomRange,
override var attributes: AttributeMap = AttributeMap(),
public val drawFeature: DrawScope.() -> Unit,
) : DraggableFeature<T> {
override fun getBoundingBox(zoom: Double): Rectangle<T> = space.buildRectangle(position, position)
override fun withCoordinates(newCoordinates: T): Feature<T> = copy(position = newCoordinates)
}
public data class BitmapImageFeature<T : Any>(
override val space: CoordinateSpace<T>,
public val position: T,
public val size: DpSize,
public val image: ImageBitmap,
override val zoomRange: ClosedFloatingPointRange<Double> = defaultZoomRange,
override var attributes: AttributeMap = AttributeMap(),
) : DraggableFeature<T> {
override fun getBoundingBox(zoom: Double): Rectangle<T> = rectangle
override fun getBoundingBox(zoom: Double): Rectangle<T> = space.buildRectangle(position, zoom, size)
override fun withCoordinates(newCoordinates: T): Feature<T> = with(space) {
BitmapImageFeature(space, rectangle.withCenter(newCoordinates), image, zoomRange, attributes)
}
override fun withCoordinates(newCoordinates: T): Feature<T> = copy(position = newCoordinates)
}
public class VectorImageFeature<T : Any>(
public data class VectorImageFeature<T : Any>(
override val space: CoordinateSpace<T>,
public val rectangle: Rectangle<T>,
public val position: T,
public val size: DpSize,
public val image: ImageVector,
override val zoomRange: ClosedFloatingPointRange<Double> = defaultZoomRange,
override var attributes: AttributeMap = AttributeMap(),
) : DraggableFeature<T> {
override fun getBoundingBox(zoom: Double): Rectangle<T> = rectangle
override fun getBoundingBox(zoom: Double): Rectangle<T> = space.buildRectangle(position, zoom, size)
override fun withCoordinates(newCoordinates: T): Feature<T> = with(space) {
VectorImageFeature(space, rectangle.withCenter(newCoordinates), image, zoomRange, attributes)
}
override fun withCoordinates(newCoordinates: T): Feature<T> = copy(position = newCoordinates)
@Composable
public fun painter(): VectorPainter = rememberVectorPainter(image)
@ -223,11 +225,12 @@ public class FeatureGroup<T : Any>(
override var attributes: AttributeMap = AttributeMap(),
) : Feature<T> {
override fun getBoundingBox(zoom: Double): Rectangle<T>? = with(space) {
children.values.mapNotNull { it.getBoundingBox(zoom) }.computeRectangle()
children.values.mapNotNull { it.getBoundingBox(zoom) }.wrapRectangles()
}
}
public class TextFeature<T : Any>(
override val space: CoordinateSpace<T>,
public val position: T,
public val text: String,
override val zoomRange: ClosedFloatingPointRange<Double> = defaultZoomRange,
@ -235,8 +238,8 @@ public class TextFeature<T : Any>(
override var attributes: AttributeMap = AttributeMap(),
public val fontConfig: FeatureFont.() -> Unit,
) : DraggableFeature<T> {
override fun getBoundingBox(zoom: Double): Rectangle<T> = GmcRectangle(position, position)
override fun getBoundingBox(zoom: Double): Rectangle<T> = space.buildRectangle(position, position)
override fun withCoordinates(newCoordinates: T): Feature<T> =
TextFeature(newCoordinates, text, zoomRange, color, attributes, fontConfig)
TextFeature(space, newCoordinates, text, zoomRange, color, attributes, fontConfig)
}

View File

@ -0,0 +1,224 @@
package center.sciprog.maps.features
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.PointMode
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import center.sciprog.maps.features.Feature.Companion.defaultZoomRange
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
@JvmInline
public value class FeatureId<out MapFeature>(public val id: String)
public class FeaturesState<T : Any>(public val coordinateSpace: CoordinateSpace<T>) :
CoordinateSpace<T> by coordinateSpace {
@PublishedApi
internal val featureMap: MutableMap<String, Feature<T>> = mutableStateMapOf()
public val features: Map<FeatureId<*>, Feature<T>>
get() = featureMap.mapKeys { FeatureId<Feature<T>>(it.key) }
@Suppress("UNCHECKED_CAST")
public fun <F : Feature<T>> getFeature(id: FeatureId<F>): F = featureMap[id.id] as F
private fun generateID(feature: Feature<T>): String = "@feature[${feature.hashCode().toUInt()}]"
public fun <F : Feature<T>> feature(id: String?, feature: F): FeatureId<F> {
val safeId = id ?: generateID(feature)
featureMap[safeId] = feature
return FeatureId(safeId)
}
public fun <F : Feature<T>> feature(id: FeatureId<F>?, feature: F): FeatureId<F> = feature(id?.id, feature)
public fun <F : Feature<T>, V> setAttribute(id: FeatureId<F>, key: Feature.Attribute<V>, value: V?) {
getFeature(id).attributes.setAttribute(key, value)
}
//TODO use context receiver for that
public fun FeatureId<DraggableFeature<T>>.draggable(
//TODO add constraints
callback: DragHandle<T> = DragHandle.bypass(),
) {
val handle = DragHandle.withPrimaryButton<T> { event, start, end ->
val feature = featureMap[id] as? DraggableFeature ?: return@withPrimaryButton true
val boundingBox = feature.getBoundingBox(start.zoom) ?: return@withPrimaryButton true
if (start.focus in boundingBox) {
feature(id, feature.withCoordinates(end.focus))
callback.handle(event, start, end)
false
} else {
true
}
}
setAttribute(this, DraggableAttribute, handle)
}
/**
* Cyclic update of a feature. Called infinitely until canceled.
*/
public fun <F : Feature<T>> FeatureId<F>.updated(
scope: CoroutineScope,
update: suspend (F) -> F,
): Job = scope.launch {
while (isActive) {
feature(this@updated, update(getFeature(this@updated)))
}
}
@Suppress("UNCHECKED_CAST")
public fun <F : SelectableFeature<T>> FeatureId<F>.selectable(
onSelect: (FeatureId<F>, F) -> Unit,
) {
setAttribute(this, SelectableAttribute) { id, feature -> onSelect(id as FeatureId<F>, feature as F) }
}
@Suppress("UNCHECKED_CAST")
public fun <A> getAttribute(id: FeatureId<Feature<T>>, key: Feature.Attribute<A>): A? =
getFeature(id).attributes[key]
// @Suppress("UNCHECKED_CAST")
// public fun <T> findAllWithAttribute(key: Attribute<T>, condition: (T) -> Boolean): Set<FeatureId> {
// return attributes.filterValues {
// condition(it[key] as T)
// }.keys
// }
public inline fun <A> forEachWithAttribute(
key: Feature.Attribute<A>,
block: (id: FeatureId<*>, attributeValue: A) -> Unit,
) {
featureMap.forEach { (id, feature) ->
feature.attributes[key]?.let {
block(FeatureId<Feature<T>>(id), it)
}
}
}
public companion object {
/**
* Build, but do not remember map feature state
*/
public fun <T : Any> build(
coordinateSpace: CoordinateSpace<T>,
builder: FeaturesState<T>.() -> Unit = {},
): FeaturesState<T> = FeaturesState(coordinateSpace).apply(builder)
/**
* Build and remember map feature state
*/
@Composable
public fun <T : Any> remember(
coordinateSpace: CoordinateSpace<T>,
builder: FeaturesState<T>.() -> Unit = {},
): FeaturesState<T> = remember(builder) {
build(coordinateSpace, builder)
}
}
}
public fun <T : Any> FeaturesState<T>.circle(
center: T,
zoomRange: DoubleRange = defaultZoomRange,
size: Dp = 5.dp,
color: Color = Color.Red,
id: String? = null,
): FeatureId<CircleFeature<T>> = feature(
id, CircleFeature(coordinateSpace, center, zoomRange, size, color)
)
public fun <T : Any> FeaturesState<T>.rectangle(
centerCoordinates: T,
zoomRange: DoubleRange = defaultZoomRange,
size: DpSize = DpSize(5.dp, 5.dp),
color: Color = Color.Red,
id: String? = null,
): FeatureId<RectangleFeature<T>> = feature(
id, RectangleFeature(coordinateSpace, centerCoordinates, zoomRange, size, color)
)
public fun <T : Any> FeaturesState<T>.line(
aCoordinates: T,
bCoordinates: T,
zoomRange: DoubleRange = defaultZoomRange,
color: Color = Color.Red,
id: String? = null,
): FeatureId<LineFeature<T>> = feature(
id,
LineFeature(coordinateSpace, aCoordinates, bCoordinates, zoomRange, color)
)
public fun <T : Any> FeaturesState<T>.arc(
oval: Rectangle<T>,
startAngle: Float,
arcLength: Float,
zoomRange: DoubleRange = defaultZoomRange,
color: Color = Color.Red,
id: String? = null,
): FeatureId<ArcFeature<T>> = feature(
id,
ArcFeature(coordinateSpace, oval, startAngle, arcLength, zoomRange, color)
)
public fun <T : Any> FeaturesState<T>.points(
points: List<T>,
zoomRange: DoubleRange = defaultZoomRange,
stroke: Float = 2f,
color: Color = Color.Red,
pointMode: PointMode = PointMode.Points,
id: String? = null,
): FeatureId<PointsFeature<T>> =
feature(id, PointsFeature(coordinateSpace, points, zoomRange, stroke, color, pointMode))
public fun <T : Any> FeaturesState<T>.image(
position: T,
image: ImageVector,
zoomRange: DoubleRange = defaultZoomRange,
id: String? = null,
): FeatureId<VectorImageFeature<T>> =
feature(
id,
VectorImageFeature(
coordinateSpace,
position,
DpSize(image.defaultWidth, image.defaultHeight),
image,
zoomRange
)
)
public fun <T : Any> FeaturesState<T>.group(
zoomRange: DoubleRange = defaultZoomRange,
id: String? = null,
builder: FeaturesState<T>.() -> Unit,
): FeatureId<FeatureGroup<T>> {
val map = FeaturesState(coordinateSpace).apply(builder).features
val feature = FeatureGroup(coordinateSpace, map, zoomRange)
return feature(id, feature)
}
public fun <T : Any> FeaturesState<T>.text(
position: T,
text: String,
zoomRange: DoubleRange = defaultZoomRange,
color: Color = Color.Red,
font: FeatureFont.() -> Unit = { size = 16f },
id: String? = null,
): FeatureId<TextFeature<T>> = feature(
id,
TextFeature(coordinateSpace, position, text, zoomRange, color, fontConfig = font)
)

View File

@ -1,302 +0,0 @@
package center.sciprog.maps.features
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.remember
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.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
@JvmInline
public value class FeatureId<out MapFeature>(public val id: String)
public class MapFeaturesState {
@PublishedApi
internal val featureMap: MutableMap<String, Feature> = mutableStateMapOf()
//TODO use context receiver for that
public fun FeatureId<DraggableFeature>.draggable(
//TODO add constraints
callback: DragHandle = DragHandle.BYPASS,
) {
val handle = DragHandle.withPrimaryButton { event, start, end ->
val feature = featureMap[id] as? DraggableFeature ?: return@withPrimaryButton true
val boundingBox = feature.getBoundingBox(start.zoom) ?: return@withPrimaryButton true
if (start.focus in boundingBox) {
feature(id, feature.withCoordinates(end.focus))
callback.handle(event, start, end)
false
} else {
true
}
}
setAttribute(this, DraggableAttribute, handle)
}
/**
* Cyclic update of a feature. Called infinitely until canceled.
*/
public fun <T : Feature> FeatureId<T>.updated(
scope: CoroutineScope,
update: suspend (T) -> T,
): Job = scope.launch {
while (isActive) {
feature(this@updated, update(getFeature(this@updated)))
}
}
@Suppress("UNCHECKED_CAST")
public fun <T : SelectableFeature> FeatureId<T>.selectable(
onSelect: (FeatureId<T>, T) -> Unit,
) {
setAttribute(this, SelectableAttribute) { id, feature -> onSelect(id as FeatureId<T>, feature as T) }
}
public val features: Map<FeatureId<*>, Feature>
get() = featureMap.mapKeys { FeatureId<Feature>(it.key) }
@Suppress("UNCHECKED_CAST")
public fun <T : Feature> getFeature(id: FeatureId<T>): T = featureMap[id.id] as T
private fun generateID(feature: Feature): String = "@feature[${feature.hashCode().toUInt()}]"
public fun <T : Feature> feature(id: String?, feature: T): FeatureId<T> {
val safeId = id ?: generateID(feature)
featureMap[safeId] = feature
return FeatureId(safeId)
}
public fun <T : Feature> feature(id: FeatureId<T>?, feature: T): FeatureId<T> = feature(id?.id, feature)
public fun <T> setAttribute(id: FeatureId<Feature>, key: Feature.Attribute<T>, value: T?) {
getFeature(id).attributes.setAttribute(key, value)
}
@Suppress("UNCHECKED_CAST")
public fun <T> getAttribute(id: FeatureId<Feature>, key: Feature.Attribute<T>): T? =
getFeature(id).attributes[key]
// @Suppress("UNCHECKED_CAST")
// public fun <T> findAllWithAttribute(key: Attribute<T>, condition: (T) -> Boolean): Set<FeatureId> {
// return attributes.filterValues {
// condition(it[key] as T)
// }.keys
// }
public inline fun <T> forEachWithAttribute(
key: Feature.Attribute<T>,
block: (id: FeatureId<*>, attributeValue: T) -> Unit,
) {
featureMap.forEach { (id, feature) ->
feature.attributes[key]?.let {
block(FeatureId<Feature>(id), it)
}
}
}
public companion object {
/**
* Build, but do not remember map feature state
*/
public fun build(
builder: MapFeaturesState.() -> Unit = {},
): MapFeaturesState = MapFeaturesState().apply(builder)
/**
* Build and remember map feature state
*/
@Composable
public fun remember(
builder: MapFeaturesState.() -> Unit = {},
): MapFeaturesState = remember(builder) {
build(builder)
}
}
}
public fun MapFeaturesState.circle(
center: GeodeticMapCoordinates,
zoomRange: IntRange = defaultZoomRange,
size: Float = 5f,
color: Color = Color.Red,
id: String? = null,
): FeatureId<CircleFeature> = feature(
id, CircleFeature(center, zoomRange, size, color)
)
public fun MapFeaturesState.circle(
centerCoordinates: Pair<Double, Double>,
zoomRange: IntRange = defaultZoomRange,
size: Float = 5f,
color: Color = Color.Red,
id: String? = null,
): FeatureId<CircleFeature> = feature(
id, CircleFeature(centerCoordinates.toCoordinates(), zoomRange, size, color)
)
public fun MapFeaturesState.rectangle(
centerCoordinates: Gmc,
zoomRange: IntRange = defaultZoomRange,
size: DpSize = DpSize(5.dp, 5.dp),
color: Color = Color.Red,
id: String? = null,
): FeatureId<RectangleFeature> = feature(
id, RectangleFeature(centerCoordinates, zoomRange, size, color)
)
public fun MapFeaturesState.rectangle(
centerCoordinates: Pair<Double, Double>,
zoomRange: IntRange = defaultZoomRange,
size: DpSize = DpSize(5.dp, 5.dp),
color: Color = Color.Red,
id: String? = null,
): FeatureId<RectangleFeature> = feature(
id, RectangleFeature(centerCoordinates.toCoordinates(), zoomRange, size, color)
)
public fun MapFeaturesState.draw(
position: Pair<Double, Double>,
zoomRange: IntRange = defaultZoomRange,
id: String? = null,
draw: DrawScope.() -> Unit,
): FeatureId<DrawFeature> = feature(id, DrawFeature(position.toCoordinates(), zoomRange, drawFeature = draw))
public fun MapFeaturesState.line(
aCoordinates: Gmc,
bCoordinates: Gmc,
zoomRange: IntRange = defaultZoomRange,
color: Color = Color.Red,
id: String? = null,
): FeatureId<LineFeature> = feature(
id,
LineFeature(aCoordinates, bCoordinates, zoomRange, color)
)
public fun MapFeaturesState.line(
curve: GmcCurve,
zoomRange: IntRange = defaultZoomRange,
color: Color = Color.Red,
id: String? = null,
): FeatureId<LineFeature> = feature(
id,
LineFeature(curve.forward.coordinates, curve.backward.coordinates, zoomRange, color)
)
public fun MapFeaturesState.line(
aCoordinates: Pair<Double, Double>,
bCoordinates: Pair<Double, Double>,
zoomRange: IntRange = defaultZoomRange,
color: Color = Color.Red,
id: String? = null,
): FeatureId<LineFeature> = feature(
id,
LineFeature(aCoordinates.toCoordinates(), bCoordinates.toCoordinates(), zoomRange, color)
)
public fun MapFeaturesState.arc(
oval: GmcRectangle,
startAngle: Angle,
arcLength: Angle,
zoomRange: IntRange = defaultZoomRange,
color: Color = Color.Red,
id: String? = null,
): FeatureId<ArcFeature> = feature(
id,
ArcFeature(oval, startAngle, arcLength, zoomRange, color)
)
public fun MapFeaturesState.arc(
center: Pair<Double, Double>,
radius: Distance,
startAngle: Angle,
arcLength: Angle,
zoomRange: IntRange = defaultZoomRange,
color: Color = Color.Red,
id: String? = null,
): FeatureId<ArcFeature> = feature(
id,
ArcFeature(
oval = GmcRectangle.square(center.toCoordinates(), radius, radius),
startAngle = startAngle,
arcLength = arcLength,
zoomRange = zoomRange,
color = color
)
)
public fun MapFeaturesState.points(
points: List<Gmc>,
zoomRange: IntRange = defaultZoomRange,
stroke: Float = 2f,
color: Color = Color.Red,
pointMode: PointMode = PointMode.Points,
id: String? = null,
): FeatureId<PointsFeature> = feature(id, PointsFeature(points, zoomRange, stroke, color, pointMode))
@JvmName("pointsFromPairs")
public fun MapFeaturesState.points(
points: List<Pair<Double, Double>>,
zoomRange: IntRange = defaultZoomRange,
stroke: Float = 2f,
color: Color = Color.Red,
pointMode: PointMode = PointMode.Points,
id: String? = null,
): FeatureId<PointsFeature> =
feature(id, PointsFeature(points.map { it.toCoordinates() }, zoomRange, stroke, color, pointMode))
public fun MapFeaturesState.image(
position: Pair<Double, Double>,
image: ImageVector,
size: DpSize = DpSize(20.dp, 20.dp),
zoomRange: IntRange = defaultZoomRange,
id: String? = null,
): FeatureId<VectorImageFeature> =
feature(id, VectorImageFeature(position.toCoordinates(), image, size, zoomRange))
public fun MapFeaturesState.group(
zoomRange: IntRange = defaultZoomRange,
id: String? = null,
builder: MapFeaturesState.() -> Unit,
): FeatureId<FeatureGroup> {
val map = MapFeaturesState().apply(builder).features
val feature = FeatureGroup(map, zoomRange)
return feature(id, feature)
}
public fun MapFeaturesState.text(
position: GeodeticMapCoordinates,
text: String,
zoomRange: IntRange = defaultZoomRange,
color: Color = Color.Red,
font: FeatureFont.() -> Unit = { size = 16f },
id: String? = null,
): FeatureId<TextFeature> = feature(
id,
TextFeature(position, text, zoomRange, color, fontConfig = font)
)
public fun MapFeaturesState.text(
position: Pair<Double, Double>,
text: String,
zoomRange: IntRange = defaultZoomRange,
color: Color = Color.Red,
font: FeatureFont.() -> Unit = { size = 16f },
id: String? = null,
): FeatureId<TextFeature> = feature(
id,
TextFeature(position.toCoordinates(), text, zoomRange, color, fontConfig = font)
)