diff --git a/gradle.properties b/gradle.properties index 5d580af..ce44879 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,6 +1,6 @@ kotlin.code.style=official -compose.version=1.2.1 +compose.version=1.2.2 agp.version=4.2.2 android.useAndroidX=true diff --git a/maps-kt-compose/build.gradle.kts b/maps-kt-compose/build.gradle.kts index a6e2a70..d326e1b 100644 --- a/maps-kt-compose/build.gradle.kts +++ b/maps-kt-compose/build.gradle.kts @@ -1,5 +1,3 @@ -import org.jetbrains.compose.compose - plugins { kotlin("multiplatform") id("org.jetbrains.compose") diff --git a/maps-kt-compose/src/commonMain/kotlin/center/sciprog/maps/compose/attributes.kt b/maps-kt-compose/src/commonMain/kotlin/center/sciprog/maps/compose/AttributeMap.kt similarity index 100% rename from maps-kt-compose/src/commonMain/kotlin/center/sciprog/maps/compose/attributes.kt rename to maps-kt-compose/src/commonMain/kotlin/center/sciprog/maps/compose/AttributeMap.kt diff --git a/maps-kt-compose/src/commonMain/kotlin/center/sciprog/maps/compose/DragHandle.kt b/maps-kt-compose/src/commonMain/kotlin/center/sciprog/maps/compose/DragHandle.kt new file mode 100644 index 0000000..ef4362a --- /dev/null +++ b/maps-kt-compose/src/commonMain/kotlin/center/sciprog/maps/compose/DragHandle.kt @@ -0,0 +1,43 @@ +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 + } + } +} \ No newline at end of file diff --git a/maps-kt-compose/src/commonMain/kotlin/center/sciprog/maps/compose/MapFeature.kt b/maps-kt-compose/src/commonMain/kotlin/center/sciprog/maps/compose/MapFeature.kt index e3c880b..aeb8000 100644 --- a/maps-kt-compose/src/commonMain/kotlin/center/sciprog/maps/compose/MapFeature.kt +++ b/maps-kt-compose/src/commonMain/kotlin/center/sciprog/maps/compose/MapFeature.kt @@ -18,7 +18,7 @@ import kotlin.math.floor public interface MapFeature { public interface Attribute - public val zoomRange: IntRange + public val zoomRange: ClosedFloatingPointRange public var attributes: AttributeMap @@ -41,7 +41,7 @@ public fun Iterable.computeBoundingBox(zoom: Double): GmcRectangle? public fun Pair.toCoordinates(): GeodeticMapCoordinates = GeodeticMapCoordinates.ofDegrees(first.toDouble(), second.toDouble()) -internal val defaultZoomRange = 1..18 +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) @@ -50,14 +50,14 @@ public class MapFeatureSelector( override var attributes: AttributeMap = AttributeMap(), public val selector: (zoom: Int) -> MapFeature, ) : MapFeature { - override val zoomRange: IntRange get() = defaultZoomRange + override val zoomRange: ClosedFloatingPointRange get() = defaultZoomRange override fun getBoundingBox(zoom: Double): GmcRectangle? = selector(floor(zoom).toInt()).getBoundingBox(zoom) } public class MapDrawFeature( public val position: GeodeticMapCoordinates, - override val zoomRange: IntRange = defaultZoomRange, + override val zoomRange: ClosedFloatingPointRange = defaultZoomRange, override var attributes: AttributeMap = AttributeMap(), public val drawFeature: DrawScope.() -> Unit, ) : DraggableMapFeature { @@ -76,7 +76,7 @@ public class MapPathFeature( public val brush: Brush, public val style: DrawStyle = Fill, public val targetRect: Rect = path.getBounds(), - override val zoomRange: IntRange = defaultZoomRange, + override val zoomRange: ClosedFloatingPointRange = defaultZoomRange, override var attributes: AttributeMap = AttributeMap(), ) : DraggableMapFeature { override fun withCoordinates(newCoordinates: GeodeticMapCoordinates): MapFeature = @@ -88,7 +88,7 @@ public class MapPathFeature( public class MapPointsFeature( public val points: List, - override val zoomRange: IntRange = defaultZoomRange, + override val zoomRange: ClosedFloatingPointRange = defaultZoomRange, public val stroke: Float = 2f, public val color: Color = Color.Red, public val pointMode: PointMode = PointMode.Points, @@ -99,7 +99,7 @@ public class MapPointsFeature( public data class MapCircleFeature( public val center: GeodeticMapCoordinates, - override val zoomRange: IntRange = defaultZoomRange, + override val zoomRange: ClosedFloatingPointRange = defaultZoomRange, public val size: Float = 5f, public val color: Color = Color.Red, override var attributes: AttributeMap = AttributeMap(), @@ -115,7 +115,7 @@ public data class MapCircleFeature( public class MapRectangleFeature( public val center: GeodeticMapCoordinates, - override val zoomRange: IntRange = defaultZoomRange, + override val zoomRange: ClosedFloatingPointRange = defaultZoomRange, public val size: DpSize = DpSize(5.dp, 5.dp), public val color: Color = Color.Red, override var attributes: AttributeMap = AttributeMap(), @@ -132,7 +132,7 @@ public class MapRectangleFeature( public class MapLineFeature( public val a: GeodeticMapCoordinates, public val b: GeodeticMapCoordinates, - override val zoomRange: IntRange = defaultZoomRange, + override val zoomRange: ClosedFloatingPointRange = defaultZoomRange, public val color: Color = Color.Red, override var attributes: AttributeMap = AttributeMap(), ) : SelectableMapFeature { @@ -151,7 +151,7 @@ public class MapArcFeature( public val oval: GmcRectangle, public val startAngle: Angle, public val arcLength: Angle, - override val zoomRange: IntRange = defaultZoomRange, + override val zoomRange: ClosedFloatingPointRange = defaultZoomRange, public val color: Color = Color.Red, override var attributes: AttributeMap = AttributeMap(), ) : DraggableMapFeature { @@ -165,7 +165,7 @@ public class MapBitmapImageFeature( public val position: GeodeticMapCoordinates, public val image: ImageBitmap, public val size: IntSize = IntSize(15, 15), - override val zoomRange: IntRange = defaultZoomRange, + override val zoomRange: ClosedFloatingPointRange = defaultZoomRange, override var attributes: AttributeMap = AttributeMap(), ) : DraggableMapFeature { override fun getBoundingBox(zoom: Double): GmcRectangle = GmcRectangle(position, position) @@ -178,7 +178,7 @@ public class MapVectorImageFeature( public val position: GeodeticMapCoordinates, public val image: ImageVector, public val size: DpSize = DpSize(20.dp, 20.dp), - override val zoomRange: IntRange = defaultZoomRange, + override val zoomRange: ClosedFloatingPointRange = defaultZoomRange, override var attributes: AttributeMap = AttributeMap(), ) : DraggableMapFeature { override fun getBoundingBox(zoom: Double): GmcRectangle = GmcRectangle(position, position) @@ -195,7 +195,7 @@ public class MapVectorImageFeature( */ public class MapFeatureGroup( public val children: Map, MapFeature>, - override val zoomRange: IntRange = defaultZoomRange, + override val zoomRange: ClosedFloatingPointRange = defaultZoomRange, override var attributes: AttributeMap = AttributeMap(), ) : MapFeature { override fun getBoundingBox(zoom: Double): GmcRectangle? = @@ -205,7 +205,7 @@ public class MapFeatureGroup( public class MapTextFeature( public val position: GeodeticMapCoordinates, public val text: String, - override val zoomRange: IntRange = defaultZoomRange, + override val zoomRange: ClosedFloatingPointRange = defaultZoomRange, public val color: Color = Color.Black, override var attributes: AttributeMap = AttributeMap(), public val fontConfig: MapTextFeatureFont.() -> Unit, diff --git a/maps-kt-compose/src/commonMain/kotlin/center/sciprog/maps/compose/MapView.kt b/maps-kt-compose/src/commonMain/kotlin/center/sciprog/maps/compose/MapView.kt index f4e6e22..abc520d 100644 --- a/maps-kt-compose/src/commonMain/kotlin/center/sciprog/maps/compose/MapView.kt +++ b/maps-kt-compose/src/commonMain/kotlin/center/sciprog/maps/compose/MapView.kt @@ -6,7 +6,6 @@ import androidx.compose.runtime.key import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.input.pointer.PointerEvent -import androidx.compose.ui.input.pointer.isPrimaryPressed import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import center.sciprog.maps.coordinates.* @@ -15,44 +14,6 @@ import kotlin.math.log2 import kotlin.math.min -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 - } - } -} - //TODO consider replacing by modifier /** */ diff --git a/maps-kt-features/build.gradle.kts b/maps-kt-features/build.gradle.kts new file mode 100644 index 0000000..b6c1a61 --- /dev/null +++ b/maps-kt-features/build.gradle.kts @@ -0,0 +1,36 @@ +plugins { + kotlin("multiplatform") + id("org.jetbrains.compose") + `maven-publish` +} + + +kotlin { + explicitApi = org.jetbrains.kotlin.gradle.dsl.ExplicitApiMode.Warning + jvm { + compilations.all { + kotlinOptions.jvmTarget = space.kscience.gradle.KScienceVersions.JVM_TARGET.toString() + } + } + sourceSets { + commonMain { + dependencies { + api(compose.foundation) + } + } + val jvmTest by getting { + dependencies { + implementation(kotlin("test-junit5")) + implementation("org.junit.jupiter:junit-jupiter:5.8.2") + } + } + } +} + +java { + targetCompatibility = space.kscience.gradle.KScienceVersions.JVM_TARGET +} + +tasks.withType { + useJUnitPlatform() +} \ No newline at end of file diff --git a/maps-kt-features/src/commonMain/kotlin/center/sciprog/maps/features/Angle.kt b/maps-kt-features/src/commonMain/kotlin/center/sciprog/maps/features/Angle.kt new file mode 100644 index 0000000..08d8523 --- /dev/null +++ b/maps-kt-features/src/commonMain/kotlin/center/sciprog/maps/features/Angle.kt @@ -0,0 +1,94 @@ +/* + * Copyright 2018-2021 KMath contributors. + * Use of this source code is governed by the Apache 2.0 license that can be found in the license/LICENSE.txt file. + */ + +package center.sciprog.maps.features + +import kotlin.math.PI +import kotlin.math.floor + +// Taken from KMath dev version, to be used directly in the future + + +public sealed interface Angle : Comparable { + public val radians: Radians + public val degrees: Degrees + + public operator fun plus(other: Angle): Angle + public operator fun minus(other: Angle): Angle + + public operator fun times(other: Number): Angle + public operator fun div(other: Number): Angle + public operator fun div(other: Angle): Double + public operator fun unaryMinus(): Angle + + public companion object { + public val zero: Angle = 0.radians + public val pi: Angle = PI.radians + public val piTimes2: Angle = (2 * PI).radians + public val piDiv2: Angle = (PI / 2).radians + } +} + +/** + * Type safe radians + */ +@JvmInline +public value class Radians(public val value: Double) : Angle { + override val radians: Radians + get() = this + override val degrees: Degrees + get() = Degrees(value * 180 / PI) + + public override fun plus(other: Angle): Radians = Radians(value + other.radians.value) + public override fun minus(other: Angle): Radians = Radians(value - other.radians.value) + + public override fun times(other: Number): Radians = Radians(value * other.toDouble()) + public override fun div(other: Number): Radians = Radians(value / other.toDouble()) + override fun div(other: Angle): Double = value / other.radians.value + public override fun unaryMinus(): Radians = Radians(-value) + + override fun compareTo(other: Angle): Int = value.compareTo(other.radians.value) +} + +public fun sin(angle: Angle): Double = kotlin.math.sin(angle.radians.value) +public fun cos(angle: Angle): Double = kotlin.math.cos(angle.radians.value) +public fun tan(angle: Angle): Double = kotlin.math.tan(angle.radians.value) + +public val Number.radians: Radians get() = Radians(toDouble()) + +/** + * Type safe degrees + */ +@JvmInline +public value class Degrees(public val value: Double) : Angle { + override val radians: Radians + get() = Radians(value * PI / 180) + override val degrees: Degrees + get() = this + + public override fun plus(other: Angle): Degrees = Degrees(value + other.degrees.value) + public override fun minus(other: Angle): Degrees = Degrees(value - other.degrees.value) + + public override fun times(other: Number): Degrees = Degrees(value * other.toDouble()) + public override fun div(other: Number): Degrees = Degrees(value / other.toDouble()) + override fun div(other: Angle): Double = value / other.degrees.value + public override fun unaryMinus(): Degrees = Degrees(-value) + + override fun compareTo(other: Angle): Int = value.compareTo(other.degrees.value) +} + +public val Number.degrees: Degrees get() = Degrees(toDouble()) + +/** + * Normalized angle 2 PI range symmetric around [center]. By default, uses (0, 2PI) range. + */ +public fun Angle.normalized(center: Angle = Angle.pi): Angle = + this - Angle.piTimes2 * floor((radians.value + PI - center.radians.value) / PI/2) + +public fun abs(angle: Angle): Angle = if (angle < Angle.zero) -angle else angle + +public fun Radians.toFloat(): Float = value.toFloat() + +public fun Degrees.toFloat(): Float = value.toFloat() diff --git a/maps-kt-features/src/commonMain/kotlin/center/sciprog/maps/features/AttributeMap.kt b/maps-kt-features/src/commonMain/kotlin/center/sciprog/maps/features/AttributeMap.kt new file mode 100644 index 0000000..0d3eae1 --- /dev/null +++ b/maps-kt-features/src/commonMain/kotlin/center/sciprog/maps/features/AttributeMap.kt @@ -0,0 +1,43 @@ +package center.sciprog.maps.features + +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.ui.graphics.Color + +public object DraggableAttribute : Feature.Attribute> +public object SelectableAttribute : Feature.Attribute<(FeatureId<*>, SelectableFeature<*>) -> Unit> +public object VisibleAttribute : Feature.Attribute + +public object ColorAttribute : Feature.Attribute + +public class AttributeMap { + public val map: MutableMap, Any> = mutableStateMapOf() + + public fun > setAttribute( + attribute: A, + attrValue: T?, + ) { + if (attrValue == null) { + map.remove(attribute) + } else { + map[attribute] = attrValue + } + } + + @Suppress("UNCHECKED_CAST") + public operator fun get(attribute: Feature.Attribute): 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})" +} \ No newline at end of file diff --git a/maps-kt-features/src/commonMain/kotlin/center/sciprog/maps/features/DragHandle.kt b/maps-kt-features/src/commonMain/kotlin/center/sciprog/maps/features/DragHandle.kt new file mode 100644 index 0000000..3a81c1f --- /dev/null +++ b/maps-kt-features/src/commonMain/kotlin/center/sciprog/maps/features/DragHandle.kt @@ -0,0 +1,42 @@ +package center.sciprog.maps.features + +import androidx.compose.ui.input.pointer.PointerEvent +import androidx.compose.ui.input.pointer.isPrimaryPressed + +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: V, end: V): Boolean + + public companion object { + public val BYPASS: DragHandle<*> = DragHandle> { _, _, _ -> true } + + /** + * Process only events with primary button pressed + */ + public fun withPrimaryButton( + block: (event: PointerEvent, start: V, end: V) -> 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 + } + } +} \ No newline at end of file diff --git a/maps-kt-features/src/commonMain/kotlin/center/sciprog/maps/features/Feature.kt b/maps-kt-features/src/commonMain/kotlin/center/sciprog/maps/features/Feature.kt new file mode 100644 index 0000000..beb0d59 --- /dev/null +++ b/maps-kt-features/src/commonMain/kotlin/center/sciprog/maps/features/Feature.kt @@ -0,0 +1,232 @@ +package center.sciprog.maps.features + +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 kotlin.math.floor + + +/** + * @param T type of coordinates used for the view point + */ +public interface ViewPoint { + public val focus: T + public val zoom: Double +} + +public interface Rectangle{ + public val topLeft: T + public val bottomRight: T + + public operator fun contains(point: T): Boolean +} + +public interface Feature { + public interface Attribute + + public val zoomRange: ClosedFloatingPointRange + + public var attributes: AttributeMap + + public fun getBoundingBox(zoom: Double): Rectangle? +} + +public interface SelectableFeature : Feature { + public operator fun contains(point: ViewPoint): Boolean = getBoundingBox(point.zoom)?.let { + point.focus in it + } ?: false +} + +public interface DraggableFeature : SelectableFeature { + public fun withCoordinates(newCoordinates: T): Feature +} + +public fun Iterable>.computeBoundingBox(zoom: Double): Rectangle? = + mapNotNull { it.getBoundingBox(zoom) }.wrapAll() + +//public fun Pair.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 FeatureSelector( + override var attributes: AttributeMap = AttributeMap(), + public val selector: (zoom: Int) -> Feature, +) : Feature { + override val zoomRange: ClosedFloatingPointRange get() = defaultZoomRange + + override fun getBoundingBox(zoom: Double): Rectangle? = selector(floor(zoom).toInt()).getBoundingBox(zoom) +} + +public class DrawFeature( + public val position: T, + override val zoomRange: ClosedFloatingPointRange = defaultZoomRange, + override var attributes: AttributeMap = AttributeMap(), + public val drawFeature: DrawScope.() -> Unit, +) : DraggableFeature { + override fun getBoundingBox(zoom: Double): Rectangle { + //TODO add box computation + return GmcRectangle(position, position) + } + + override fun withCoordinates(newCoordinates: T): Feature = + DrawFeature(newCoordinates, zoomRange, attributes, drawFeature) +} + +public class PathFeature( + public val rectangle: Rectangle, + public val path: Path, + public val brush: Brush, + public val style: DrawStyle = Fill, + public val targetRect: Rect = path.getBounds(), + override val zoomRange: ClosedFloatingPointRange = defaultZoomRange, + override var attributes: AttributeMap = AttributeMap(), +) : DraggableFeature { + override fun withCoordinates(newCoordinates: T): Feature = + PathFeature(rectangle.moveTo(newCoordinates), path, brush, style, targetRect, zoomRange) + + override fun getBoundingBox(zoom: Double): Rectangle = rectangle + +} + +public class PointsFeature( + public val points: List, + override val zoomRange: ClosedFloatingPointRange = defaultZoomRange, + public val stroke: Float = 2f, + public val color: Color = Color.Red, + public val pointMode: PointMode = PointMode.Points, + override var attributes: AttributeMap = AttributeMap(), +) : Feature { + override fun getBoundingBox(zoom: Double): Rectangle = GmcRectangle(points.first(), points.last()) +} + +public data class CircleFeature( + public val center: T, + override val zoomRange: ClosedFloatingPointRange = defaultZoomRange, + public val size: Float = 5f, + public val color: Color = Color.Red, + override var attributes: AttributeMap = AttributeMap(), +) : DraggableFeature { + override fun getBoundingBox(zoom: Double): Rectangle { + val scale = WebMercatorProjection.scaleFactor(zoom) + return GmcRectangle.square(center, (size / scale).radians, (size / scale).radians) + } + + override fun withCoordinates(newCoordinates: T): Feature = + CircleFeature(newCoordinates, zoomRange, size, color, attributes) +} + +public class RectangleFeature( + public val center: T, + override val zoomRange: ClosedFloatingPointRange = defaultZoomRange, + public val size: DpSize = DpSize(5.dp, 5.dp), + public val color: Color = Color.Red, + override var attributes: AttributeMap = AttributeMap(), +) : DraggableFeature { + override fun getBoundingBox(zoom: Double): Rectangle { + val scale = WebMercatorProjection.scaleFactor(zoom) + return GmcRectangle.square(center, (size.height.value / scale).radians, (size.width.value / scale).radians) + } + + override fun withCoordinates(newCoordinates: T): Feature = + RectangleFeature(newCoordinates, zoomRange, size, color, attributes) +} + +public class LineFeature( + public val a: T, + public val b: T, + override val zoomRange: ClosedFloatingPointRange = defaultZoomRange, + public val color: Color = Color.Red, + override var attributes: AttributeMap = AttributeMap(), +) : SelectableFeature { + override fun getBoundingBox(zoom: Double): Rectangle = GmcRectangle(a, b) + + override fun contains(point: ViewPoint): Boolean { + return super.contains(point) + } +} + +/** + * @param startAngle the angle from parallel downwards for the start of the arc + * @param arcLength arc length + */ +public class ArcFeature( + public val oval: Rectangle, + public val startAngle: Angle, + public val arcLength: Angle, + override val zoomRange: ClosedFloatingPointRange = defaultZoomRange, + public val color: Color = Color.Red, + override var attributes: AttributeMap = AttributeMap(), +) : DraggableFeature { + override fun getBoundingBox(zoom: Double): Rectangle = oval + + override fun withCoordinates(newCoordinates: T): Feature = + ArcFeature(oval.moveTo(newCoordinates), startAngle, arcLength, zoomRange, color, attributes) +} + +public class BitmapImageFeature( + public val position: T, + public val image: ImageBitmap, + public val size: IntSize = IntSize(15, 15), + override val zoomRange: ClosedFloatingPointRange = defaultZoomRange, + override var attributes: AttributeMap = AttributeMap(), +) : DraggableFeature { + override fun getBoundingBox(zoom: Double): Rectangle = GmcRectangle(position, position) + + override fun withCoordinates(newCoordinates: T): Feature = + BitmapImageFeature(newCoordinates, image, size, zoomRange, attributes) +} + +public class VectorImageFeature( + public val position: T, + public val image: ImageVector, + public val size: DpSize = DpSize(20.dp, 20.dp), + override val zoomRange: ClosedFloatingPointRange = defaultZoomRange, + override var attributes: AttributeMap = AttributeMap(), +) : DraggableFeature { + override fun getBoundingBox(zoom: Double): Rectangle = GmcRectangle(position, position) + + override fun withCoordinates(newCoordinates: T): Feature = + VectorImageFeature(newCoordinates, image, size, zoomRange, attributes) + + @Composable + public fun painter(): VectorPainter = rememberVectorPainter(image) +} + +/** + * A group of other features + */ +public class FeatureGroup( + public val children: Map, Feature>, + override val zoomRange: ClosedFloatingPointRange = defaultZoomRange, + override var attributes: AttributeMap = AttributeMap(), +) : Feature { + override fun getBoundingBox(zoom: Double): Rectangle? = + children.values.mapNotNull { it.getBoundingBox(zoom) }.wrapAll() +} + +public class TextFeature( + public val position: T, + public val text: String, + override val zoomRange: ClosedFloatingPointRange = defaultZoomRange, + public val color: Color = Color.Black, + override var attributes: AttributeMap = AttributeMap(), + public val fontConfig: MapTextFeatureFont.() -> Unit, +) : DraggableFeature { + override fun getBoundingBox(zoom: Double): Rectangle = GmcRectangle(position, position) + + override fun withCoordinates(newCoordinates: T): Feature = + TextFeature(newCoordinates, text, zoomRange, color, attributes, fontConfig) +} diff --git a/maps-kt-features/src/commonMain/kotlin/center/sciprog/maps/features/MapFeaturesState.kt b/maps-kt-features/src/commonMain/kotlin/center/sciprog/maps/features/MapFeaturesState.kt new file mode 100644 index 0000000..8d397eb --- /dev/null +++ b/maps-kt-features/src/commonMain/kotlin/center/sciprog/maps/features/MapFeaturesState.kt @@ -0,0 +1,302 @@ +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(public val id: String) + +public class MapFeaturesState { + + @PublishedApi + internal val featureMap: MutableMap = mutableStateMapOf() + + //TODO use context receiver for that + public fun FeatureId.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 FeatureId.updated( + scope: CoroutineScope, + update: suspend (T) -> T, + ): Job = scope.launch { + while (isActive) { + feature(this@updated, update(getFeature(this@updated))) + } + } + + @Suppress("UNCHECKED_CAST") + public fun FeatureId.selectable( + onSelect: (FeatureId, T) -> Unit, + ) { + setAttribute(this, SelectableAttribute) { id, feature -> onSelect(id as FeatureId, feature as T) } + } + + + public val features: Map, Feature> + get() = featureMap.mapKeys { FeatureId(it.key) } + + @Suppress("UNCHECKED_CAST") + public fun getFeature(id: FeatureId): T = featureMap[id.id] as T + + + private fun generateID(feature: Feature): String = "@feature[${feature.hashCode().toUInt()}]" + + public fun feature(id: String?, feature: T): FeatureId { + val safeId = id ?: generateID(feature) + featureMap[safeId] = feature + return FeatureId(safeId) + } + + public fun feature(id: FeatureId?, feature: T): FeatureId = feature(id?.id, feature) + + public fun setAttribute(id: FeatureId, key: Feature.Attribute, value: T?) { + getFeature(id).attributes.setAttribute(key, value) + } + + @Suppress("UNCHECKED_CAST") + public fun getAttribute(id: FeatureId, key: Feature.Attribute): T? = + getFeature(id).attributes[key] + + +// @Suppress("UNCHECKED_CAST") +// public fun findAllWithAttribute(key: Attribute, condition: (T) -> Boolean): Set { +// return attributes.filterValues { +// condition(it[key] as T) +// }.keys +// } + + public inline fun forEachWithAttribute( + key: Feature.Attribute, + block: (id: FeatureId<*>, attributeValue: T) -> Unit, + ) { + featureMap.forEach { (id, feature) -> + feature.attributes[key]?.let { + block(FeatureId(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 = feature( + id, CircleFeature(center, zoomRange, size, color) +) + +public fun MapFeaturesState.circle( + centerCoordinates: Pair, + zoomRange: IntRange = defaultZoomRange, + size: Float = 5f, + color: Color = Color.Red, + id: String? = null, +): FeatureId = 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 = feature( + id, RectangleFeature(centerCoordinates, zoomRange, size, color) +) + +public fun MapFeaturesState.rectangle( + centerCoordinates: Pair, + zoomRange: IntRange = defaultZoomRange, + size: DpSize = DpSize(5.dp, 5.dp), + color: Color = Color.Red, + id: String? = null, +): FeatureId = feature( + id, RectangleFeature(centerCoordinates.toCoordinates(), zoomRange, size, color) +) + +public fun MapFeaturesState.draw( + position: Pair, + zoomRange: IntRange = defaultZoomRange, + id: String? = null, + draw: DrawScope.() -> Unit, +): FeatureId = 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 = feature( + id, + LineFeature(aCoordinates, bCoordinates, zoomRange, color) +) + +public fun MapFeaturesState.line( + curve: GmcCurve, + zoomRange: IntRange = defaultZoomRange, + color: Color = Color.Red, + id: String? = null, +): FeatureId = feature( + id, + LineFeature(curve.forward.coordinates, curve.backward.coordinates, zoomRange, color) +) + +public fun MapFeaturesState.line( + aCoordinates: Pair, + bCoordinates: Pair, + zoomRange: IntRange = defaultZoomRange, + color: Color = Color.Red, + id: String? = null, +): FeatureId = 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 = feature( + id, + ArcFeature(oval, startAngle, arcLength, zoomRange, color) +) + +public fun MapFeaturesState.arc( + center: Pair, + radius: Distance, + startAngle: Angle, + arcLength: Angle, + zoomRange: IntRange = defaultZoomRange, + color: Color = Color.Red, + id: String? = null, +): FeatureId = feature( + id, + ArcFeature( + oval = GmcRectangle.square(center.toCoordinates(), radius, radius), + startAngle = startAngle, + arcLength = arcLength, + zoomRange = zoomRange, + color = color + ) +) + +public fun MapFeaturesState.points( + points: List, + zoomRange: IntRange = defaultZoomRange, + stroke: Float = 2f, + color: Color = Color.Red, + pointMode: PointMode = PointMode.Points, + id: String? = null, +): FeatureId = feature(id, PointsFeature(points, zoomRange, stroke, color, pointMode)) + +@JvmName("pointsFromPairs") +public fun MapFeaturesState.points( + points: List>, + zoomRange: IntRange = defaultZoomRange, + stroke: Float = 2f, + color: Color = Color.Red, + pointMode: PointMode = PointMode.Points, + id: String? = null, +): FeatureId = + feature(id, PointsFeature(points.map { it.toCoordinates() }, zoomRange, stroke, color, pointMode)) + +public fun MapFeaturesState.image( + position: Pair, + image: ImageVector, + size: DpSize = DpSize(20.dp, 20.dp), + zoomRange: IntRange = defaultZoomRange, + id: String? = null, +): FeatureId = + feature(id, VectorImageFeature(position.toCoordinates(), image, size, zoomRange)) + +public fun MapFeaturesState.group( + zoomRange: IntRange = defaultZoomRange, + id: String? = null, + builder: MapFeaturesState.() -> Unit, +): FeatureId { + 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: MapTextFeatureFont.() -> Unit = { size = 16f }, + id: String? = null, +): FeatureId = feature( + id, + TextFeature(position, text, zoomRange, color, fontConfig = font) +) + +public fun MapFeaturesState.text( + position: Pair, + text: String, + zoomRange: IntRange = defaultZoomRange, + color: Color = Color.Red, + font: MapTextFeatureFont.() -> Unit = { size = 16f }, + id: String? = null, +): FeatureId = feature( + id, + TextFeature(position.toCoordinates(), text, zoomRange, color, fontConfig = font) +) diff --git a/settings.gradle.kts b/settings.gradle.kts index dd64150..9af02e2 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -46,6 +46,7 @@ dependencyResolutionManagement { include( ":maps-kt-core", + ":maps-kt-features", ":maps-kt-compose", ":demo:maps", ":maps-kt-scheme",