From d795983fad89d22ec62e84ae0db8e345f15d676d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Gw=C3=B3=C5=BAd=C5=BA?= <114614618+michalgwo@users.noreply.github.com> Date: Mon, 27 Jan 2025 07:07:03 +0100 Subject: [PATCH] Animate camera to LatLngBounds (#258) Co-authored-by: Sargun Vohra --- .../kotlin/dev/sargunv/maplibrejs/external.kt | 11 ++++++ .../kotlin/dev/sargunv/maplibrejs/util.kt | 30 +++++++++++++++ .../maplibrecompose/core/AndroidMap.kt | 37 +++++++++++++++++-- .../sargunv/maplibrecompose/core/util/util.kt | 8 ++++ .../maplibrecompose/compose/CameraState.kt | 23 ++++++++++++ .../maplibrecompose/core/MaplibreMap.kt | 9 +++++ .../maplibrecompose/core/WebviewMap.kt | 9 +++++ .../sargunv/maplibrecompose/core/IosMap.kt | 24 +++++++++++- .../sargunv/maplibrecompose/core/util/util.kt | 7 ++++ .../dev/sargunv/maplibrecompose/core/JsMap.kt | 22 +++++++++++ .../sargunv/maplibrecompose/core/util/util.kt | 3 ++ 11 files changed, 178 insertions(+), 5 deletions(-) diff --git a/lib/kotlin-maplibre-js/src/commonMain/kotlin/dev/sargunv/maplibrejs/external.kt b/lib/kotlin-maplibre-js/src/commonMain/kotlin/dev/sargunv/maplibrejs/external.kt index a0b0ef9f..283c3bab 100644 --- a/lib/kotlin-maplibre-js/src/commonMain/kotlin/dev/sargunv/maplibrejs/external.kt +++ b/lib/kotlin-maplibre-js/src/commonMain/kotlin/dev/sargunv/maplibrejs/external.kt @@ -61,6 +61,8 @@ public external class Map public constructor(options: MapOptions) { public fun easeTo(options: EaseToOptions) + public fun fitBounds(bounds: LngLatBounds, options: FitBoundsOptions?) + public fun flyTo(options: FlyToOptions) public fun addControl(control: IControl, position: String) @@ -383,6 +385,15 @@ public sealed external interface EaseToOptions : CameraOptions { public var easing: (t: Double) -> Double? } +/** + * [FitBoundsOptions](https://maplibre.org/maplibre-gl-js/docs/API/type-aliases/FitBoundsOptions/) + */ +public sealed external interface FitBoundsOptions : FlyToOptions { + public var linear: Boolean? + public var maxZoom: Double? + public var offset: Point? +} + /** [FlyToOptions](https://maplibre.org/maplibre-gl-js/docs/API/type-aliases/FlyToOptions/) */ public sealed external interface FlyToOptions : CameraOptions { public var curve: Double? diff --git a/lib/kotlin-maplibre-js/src/commonMain/kotlin/dev/sargunv/maplibrejs/util.kt b/lib/kotlin-maplibre-js/src/commonMain/kotlin/dev/sargunv/maplibrejs/util.kt index 6802f3d7..6ebc6854 100644 --- a/lib/kotlin-maplibre-js/src/commonMain/kotlin/dev/sargunv/maplibrejs/util.kt +++ b/lib/kotlin-maplibre-js/src/commonMain/kotlin/dev/sargunv/maplibrejs/util.kt @@ -72,6 +72,36 @@ public fun PaddingOptions( right?.let { this.right = it } } +public fun FitBoundsOptions( + linear: Boolean? = null, + maxZoom: Double? = null, + offset: Point? = null, + center: LngLat? = null, + zoom: Double? = null, + bearing: Double? = null, + pitch: Double? = null, + speed: Double? = null, + curve: Double? = null, + maxDuration: Double? = null, + minZoom: Double? = null, + padding: PaddingOptions? = null, + screenSpeed: Double? = null, +): FitBoundsOptions = jso { + linear?.let { this.linear = it } + maxZoom?.let { this.maxZoom = it } + offset?.let { this.offset = it } + center?.let { this.center = it } + zoom?.let { this.zoom = it } + bearing?.let { this.bearing = it } + pitch?.let { this.pitch = it } + speed?.let { this.speed = it } + curve?.let { this.curve = it } + maxDuration?.let { this.maxDuration = it } + minZoom?.let { this.minZoom = it } + padding?.let { this.padding = it } + screenSpeed?.let { this.screenSpeed = it } +} + public fun FlyToOptions( center: LngLat? = null, zoom: Double? = null, diff --git a/lib/maplibre-compose/src/androidMain/kotlin/dev/sargunv/maplibrecompose/core/AndroidMap.kt b/lib/maplibre-compose/src/androidMain/kotlin/dev/sargunv/maplibrecompose/core/AndroidMap.kt index 52db21a8..eaec4792 100644 --- a/lib/maplibre-compose/src/androidMain/kotlin/dev/sargunv/maplibrecompose/core/AndroidMap.kt +++ b/lib/maplibre-compose/src/androidMain/kotlin/dev/sargunv/maplibrecompose/core/AndroidMap.kt @@ -14,6 +14,7 @@ import dev.sargunv.maplibrecompose.core.util.correctedAndroidUri import dev.sargunv.maplibrecompose.core.util.toBoundingBox import dev.sargunv.maplibrecompose.core.util.toGravity import dev.sargunv.maplibrecompose.core.util.toLatLng +import dev.sargunv.maplibrecompose.core.util.toLatLngBounds import dev.sargunv.maplibrecompose.core.util.toMLNExpression import dev.sargunv.maplibrecompose.core.util.toOffset import dev.sargunv.maplibrecompose.core.util.toPointF @@ -24,6 +25,7 @@ import dev.sargunv.maplibrecompose.expressions.value.BooleanValue import io.github.dellisd.spatialk.geojson.BoundingBox import io.github.dellisd.spatialk.geojson.Feature import io.github.dellisd.spatialk.geojson.Position +import kotlin.coroutines.Continuation import kotlin.coroutines.resume import kotlin.coroutines.suspendCoroutine import kotlin.time.Duration @@ -292,18 +294,45 @@ internal class AndroidMap( map.moveCamera(CameraUpdateFactory.newCameraPosition(cameraPosition.toMLNCameraPosition())) } + private class CancelableCoroutineCallback(private val cont: Continuation) : + MLNMap.CancelableCallback { + override fun onCancel() = cont.resume(Unit) + + override fun onFinish() = cont.resume(Unit) + } + override suspend fun animateCameraPosition(finalPosition: CameraPosition, duration: Duration) = suspendCoroutine { cont -> map.animateCamera( CameraUpdateFactory.newCameraPosition(finalPosition.toMLNCameraPosition()), duration.toInt(DurationUnit.MILLISECONDS), - object : MLNMap.CancelableCallback { - override fun onFinish() = cont.resume(Unit) + CancelableCoroutineCallback(cont), + ) + } - override fun onCancel() = cont.resume(Unit) - }, + override suspend fun animateCameraPosition( + boundingBox: BoundingBox, + bearing: Double, + tilt: Double, + padding: PaddingValues, + duration: Duration, + ) = suspendCoroutine { cont -> + with(density) { + map.animateCamera( + CameraUpdateFactory.newLatLngBounds( + bounds = boundingBox.toLatLngBounds(), + bearing = bearing, + tilt = tilt, + paddingLeft = padding.calculateLeftPadding(layoutDir).roundToPx(), + paddingTop = padding.calculateTopPadding().roundToPx(), + paddingRight = padding.calculateRightPadding(layoutDir).roundToPx(), + paddingBottom = padding.calculateBottomPadding().roundToPx(), + ), + duration.toInt(DurationUnit.MILLISECONDS), + CancelableCoroutineCallback(cont), ) } + } override fun positionFromScreenLocation(offset: DpOffset): Position = map.projection.fromScreenLocation(offset.toPointF(density)).toPosition() diff --git a/lib/maplibre-compose/src/androidMain/kotlin/dev/sargunv/maplibrecompose/core/util/util.kt b/lib/maplibre-compose/src/androidMain/kotlin/dev/sargunv/maplibrecompose/core/util/util.kt index 41be0750..f5499aec 100644 --- a/lib/maplibre-compose/src/androidMain/kotlin/dev/sargunv/maplibrecompose/core/util/util.kt +++ b/lib/maplibre-compose/src/androidMain/kotlin/dev/sargunv/maplibrecompose/core/util/util.kt @@ -62,6 +62,14 @@ internal fun Position.toLatLng(): LatLng = LatLng(latitude = latitude, longitude internal fun LatLngBounds.toBoundingBox(): BoundingBox = BoundingBox(northeast = northEast.toPosition(), southwest = southWest.toPosition()) +internal fun BoundingBox.toLatLngBounds(): LatLngBounds = + LatLngBounds.from( + latNorth = northeast.latitude, + lonEast = northeast.longitude, + latSouth = southwest.latitude, + lonWest = southwest.longitude, + ) + internal fun CompiledExpression<*>.toMLNExpression(): MLNExpression? = if (this == NullLiteral) null else MLNExpression.Converter.convert(normalizeJsonLike(false)) diff --git a/lib/maplibre-compose/src/commonMain/kotlin/dev/sargunv/maplibrecompose/compose/CameraState.kt b/lib/maplibre-compose/src/commonMain/kotlin/dev/sargunv/maplibrecompose/compose/CameraState.kt index db4799bb..d76a82ad 100644 --- a/lib/maplibre-compose/src/commonMain/kotlin/dev/sargunv/maplibrecompose/compose/CameraState.kt +++ b/lib/maplibre-compose/src/commonMain/kotlin/dev/sargunv/maplibrecompose/compose/CameraState.kt @@ -1,10 +1,12 @@ package dev.sargunv.maplibrecompose.compose +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.DpRect +import androidx.compose.ui.unit.dp import dev.sargunv.maplibrecompose.core.CameraMoveReason import dev.sargunv.maplibrecompose.core.CameraPosition import dev.sargunv.maplibrecompose.core.MaplibreMap @@ -75,6 +77,27 @@ public class CameraState internal constructor(firstPosition: CameraPosition) { map.animateCameraPosition(finalPosition, duration) } + /** + * Animates the camera towards the specified [boundingBox] in the given [duration] time with the + * specified [bearing], [tilt], and [padding]. + * + * @param boundingBox The bounds to animate the camera to. + * @param bearing The bearing to set during the animation. Defaults to 0.0. + * @param tilt The tilt to set during the animation. Defaults to 0.0. + * @param padding The padding to apply during the animation. Defaults to no padding. + * @param duration The duration of the animation. Defaults to 300 ms. Has no effect on JS. + */ + public suspend fun animateTo( + boundingBox: BoundingBox, + bearing: Double = 0.0, + tilt: Double = 0.0, + padding: PaddingValues = PaddingValues(0.dp), + duration: Duration = 300.milliseconds, + ) { + val map = map ?: mapAttachSignal.receive() + map.animateCameraPosition(boundingBox, bearing, tilt, padding, duration) + } + private fun requireMap(): StandardMaplibreMap { check(map != null) { "Map requested before it was initialized; try calling awaitInitialization() first" diff --git a/lib/maplibre-compose/src/commonMain/kotlin/dev/sargunv/maplibrecompose/core/MaplibreMap.kt b/lib/maplibre-compose/src/commonMain/kotlin/dev/sargunv/maplibrecompose/core/MaplibreMap.kt index 84711a11..df0a7074 100644 --- a/lib/maplibre-compose/src/commonMain/kotlin/dev/sargunv/maplibrecompose/core/MaplibreMap.kt +++ b/lib/maplibre-compose/src/commonMain/kotlin/dev/sargunv/maplibrecompose/core/MaplibreMap.kt @@ -1,5 +1,6 @@ package dev.sargunv.maplibrecompose.core +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.DpRect import dev.sargunv.maplibrecompose.expressions.ast.CompiledExpression @@ -12,6 +13,14 @@ import kotlin.time.Duration internal interface MaplibreMap { suspend fun animateCameraPosition(finalPosition: CameraPosition, duration: Duration) + suspend fun animateCameraPosition( + boundingBox: BoundingBox, + bearing: Double, + tilt: Double, + padding: PaddingValues, + duration: Duration, + ) + suspend fun asyncSetStyleUri(styleUri: String) suspend fun asyncSetDebugEnabled(enabled: Boolean) diff --git a/lib/maplibre-compose/src/desktopMain/kotlin/dev/sargunv/maplibrecompose/core/WebviewMap.kt b/lib/maplibre-compose/src/desktopMain/kotlin/dev/sargunv/maplibrecompose/core/WebviewMap.kt index b522c935..467f0f50 100644 --- a/lib/maplibre-compose/src/desktopMain/kotlin/dev/sargunv/maplibrecompose/core/WebviewMap.kt +++ b/lib/maplibre-compose/src/desktopMain/kotlin/dev/sargunv/maplibrecompose/core/WebviewMap.kt @@ -1,5 +1,6 @@ package dev.sargunv.maplibrecompose.core +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.DpRect import androidx.compose.ui.unit.LayoutDirection @@ -115,6 +116,14 @@ internal class WebviewMap(private val bridge: WebviewBridge) : MaplibreMap { override suspend fun animateCameraPosition(finalPosition: CameraPosition, duration: Duration) {} + override suspend fun animateCameraPosition( + boundingBox: BoundingBox, + bearing: Double, + tilt: Double, + padding: PaddingValues, + duration: Duration, + ) {} + override suspend fun asyncGetPosFromScreenLocation(offset: DpOffset): Position { return Position(0.0, 0.0) } diff --git a/lib/maplibre-compose/src/iosMain/kotlin/dev/sargunv/maplibrecompose/core/IosMap.kt b/lib/maplibre-compose/src/iosMain/kotlin/dev/sargunv/maplibrecompose/core/IosMap.kt index 20d503e3..ff60e0c2 100644 --- a/lib/maplibre-compose/src/iosMain/kotlin/dev/sargunv/maplibrecompose/core/IosMap.kt +++ b/lib/maplibre-compose/src/iosMain/kotlin/dev/sargunv/maplibrecompose/core/IosMap.kt @@ -48,6 +48,7 @@ import dev.sargunv.maplibrecompose.core.util.toCGRect import dev.sargunv.maplibrecompose.core.util.toCLLocationCoordinate2D import dev.sargunv.maplibrecompose.core.util.toDpOffset import dev.sargunv.maplibrecompose.core.util.toFeature +import dev.sargunv.maplibrecompose.core.util.toMLNCoordinateBounds import dev.sargunv.maplibrecompose.core.util.toMLNOrnamentPosition import dev.sargunv.maplibrecompose.core.util.toNSPredicate import dev.sargunv.maplibrecompose.core.util.toPosition @@ -433,13 +434,34 @@ internal class IosMap( override suspend fun animateCameraPosition(finalPosition: CameraPosition, duration: Duration) = suspendCoroutine { cont -> mapView.flyToCamera( - finalPosition.toMLNMapCamera(), + camera = finalPosition.toMLNMapCamera(), withDuration = duration.toDouble(DurationUnit.SECONDS), edgePadding = finalPosition.padding.toEdgeInsets(), completionHandler = { cont.resume(Unit) }, ) } + override suspend fun animateCameraPosition( + boundingBox: BoundingBox, + bearing: Double, + tilt: Double, + padding: PaddingValues, + duration: Duration, + ) { + suspendCoroutine { cont -> + mapView.flyToCamera( + camera = + mapView.cameraThatFitsCoordinateBounds(boundingBox.toMLNCoordinateBounds()).apply { + heading = bearing + pitch = tilt + }, + withDuration = duration.toDouble(DurationUnit.SECONDS), + edgePadding = padding.toEdgeInsets(), + completionHandler = { cont.resume(Unit) }, + ) + } + } + override fun positionFromScreenLocation(offset: DpOffset): Position = mapView.convertPoint(point = offset.toCGPoint(), toCoordinateFromView = null).toPosition() diff --git a/lib/maplibre-compose/src/iosMain/kotlin/dev/sargunv/maplibrecompose/core/util/util.kt b/lib/maplibre-compose/src/iosMain/kotlin/dev/sargunv/maplibrecompose/core/util/util.kt index 2718ebf4..1d04e126 100644 --- a/lib/maplibre-compose/src/iosMain/kotlin/dev/sargunv/maplibrecompose/core/util/util.kt +++ b/lib/maplibre-compose/src/iosMain/kotlin/dev/sargunv/maplibrecompose/core/util/util.kt @@ -10,6 +10,7 @@ import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import cocoapods.MapLibre.MLNCoordinateBounds +import cocoapods.MapLibre.MLNCoordinateBoundsMake import cocoapods.MapLibre.MLNFeatureProtocol import cocoapods.MapLibre.MLNOrnamentPosition import cocoapods.MapLibre.MLNOrnamentPositionBottomLeft @@ -111,6 +112,12 @@ internal fun CValue.toBoundingBox(): BoundingBox = useConte BoundingBox(northeast = ne.toPosition(), southwest = sw.toPosition()) } +internal fun BoundingBox.toMLNCoordinateBounds(): CValue = + MLNCoordinateBoundsMake( + ne = northeast.toCLLocationCoordinate2D(), + sw = southwest.toCLLocationCoordinate2D(), + ) + internal fun GeoJson.toMLNShape(): MLNShape { return MLNShape.shapeWithData( data = json().encodeToByteArray().toNSData(), diff --git a/lib/maplibre-compose/src/jsMain/kotlin/dev/sargunv/maplibrecompose/core/JsMap.kt b/lib/maplibre-compose/src/jsMain/kotlin/dev/sargunv/maplibrecompose/core/JsMap.kt index 87c7fafb..929caffa 100644 --- a/lib/maplibre-compose/src/jsMain/kotlin/dev/sargunv/maplibrecompose/core/JsMap.kt +++ b/lib/maplibre-compose/src/jsMain/kotlin/dev/sargunv/maplibrecompose/core/JsMap.kt @@ -1,5 +1,6 @@ package dev.sargunv.maplibrecompose.core +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.DpRect @@ -8,6 +9,7 @@ import co.touchlab.kermit.Logger import dev.sargunv.maplibrecompose.core.util.toBoundingBox import dev.sargunv.maplibrecompose.core.util.toControlPosition import dev.sargunv.maplibrecompose.core.util.toDpOffset +import dev.sargunv.maplibrecompose.core.util.toLatLngBounds import dev.sargunv.maplibrecompose.core.util.toLngLat import dev.sargunv.maplibrecompose.core.util.toPaddingOptions import dev.sargunv.maplibrecompose.core.util.toPaddingValuesAbsolute @@ -17,6 +19,7 @@ import dev.sargunv.maplibrecompose.expressions.ast.CompiledExpression import dev.sargunv.maplibrecompose.expressions.value.BooleanValue import dev.sargunv.maplibrejs.AttributionControl import dev.sargunv.maplibrejs.EaseToOptions +import dev.sargunv.maplibrejs.FitBoundsOptions import dev.sargunv.maplibrejs.JumpToOptions import dev.sargunv.maplibrejs.LngLat import dev.sargunv.maplibrejs.LogoControl @@ -253,6 +256,25 @@ internal class JsMap( ) } + override suspend fun animateCameraPosition( + boundingBox: BoundingBox, + bearing: Double, + tilt: Double, + padding: PaddingValues, + duration: Duration, + ) { + impl.fitBounds( + bounds = boundingBox.toLatLngBounds(), + options = + FitBoundsOptions( + linear = true, + bearing = bearing, + pitch = tilt, + padding = padding.toPaddingOptions(layoutDir), + ), + ) + } + override fun positionFromScreenLocation(offset: DpOffset): Position { return impl.unproject(offset.toPoint()).toPosition() } diff --git a/lib/maplibre-compose/src/jsMain/kotlin/dev/sargunv/maplibrecompose/core/util/util.kt b/lib/maplibre-compose/src/jsMain/kotlin/dev/sargunv/maplibrecompose/core/util/util.kt index d9fe1a7e..47ba7e13 100644 --- a/lib/maplibre-compose/src/jsMain/kotlin/dev/sargunv/maplibrecompose/core/util/util.kt +++ b/lib/maplibre-compose/src/jsMain/kotlin/dev/sargunv/maplibrecompose/core/util/util.kt @@ -51,3 +51,6 @@ internal fun PaddingValues.toPaddingOptions(layoutDir: LayoutDirection) = internal fun LngLatBounds.toBoundingBox() = BoundingBox(south = getSouth(), west = getWest(), north = getNorth(), east = getEast()) + +internal fun BoundingBox.toLatLngBounds() = + LngLatBounds(sw = southwest.toLngLat(), ne = northeast.toLngLat())