diff --git a/demo-app/src/androidMain/kotlin/org/maplibre/compose/demoapp/demos/GmsLocationDemo.kt b/demo-app/src/androidMain/kotlin/org/maplibre/compose/demoapp/demos/GmsLocationDemo.kt index 5307c370..a6e813d8 100644 --- a/demo-app/src/androidMain/kotlin/org/maplibre/compose/demoapp/demos/GmsLocationDemo.kt +++ b/demo-app/src/androidMain/kotlin/org/maplibre/compose/demoapp/demos/GmsLocationDemo.kt @@ -15,8 +15,10 @@ import org.maplibre.compose.demoapp.DemoState import org.maplibre.compose.demoapp.design.CardColumn import org.maplibre.compose.gms.rememberFusedLocationProvider import org.maplibre.compose.location.LocationPuck +import org.maplibre.compose.location.LocationRequest import org.maplibre.compose.location.rememberUserLocationState import org.maplibre.compose.material3.LocationPuckDefaults +import org.maplibre.spatialk.units.extensions.degrees object GmsLocationDemo : Demo { override val name = "Gms Location" @@ -32,19 +34,31 @@ object GmsLocationDemo : Demo { // this if _is_ a permission check Lint just doesn't know that @SuppressLint("MissingPermission") if (state.locationPermissionState.hasPermission) { - val locationProvider = rememberFusedLocationProvider() + val locationProvider = rememberFusedLocationProvider(LocationRequest(orientation = true)) val locationState = rememberUserLocationState(locationProvider) LocationPuck( idPrefix = "gms-location", - locationState = locationState, + location = locationState.location, + bearing = + locationState.location?.let { location -> + val courseAccuracy = location.course?.inaccuracy ?: 180.degrees + val orientationAccuracy = location.orientation?.inaccuracy ?: 180.degrees + if (courseAccuracy < orientationAccuracy) { + location.course + } else { + location.orientation + } + }, cameraState = state.cameraState, accuracyThreshold = 0f, colors = LocationPuckDefaults.colors(), onClick = { location -> locationClickedCount++ coroutineScope.launch { - state.cameraState.animateTo(CameraPosition(target = location.position, zoom = 16.0)) + state.cameraState.animateTo( + CameraPosition(target = location.position!!.position, zoom = 16.0) + ) } }, ) diff --git a/demo-app/src/commonMain/kotlin/org/maplibre/compose/demoapp/DemoState.kt b/demo-app/src/commonMain/kotlin/org/maplibre/compose/demoapp/DemoState.kt index a841d2ae..011c4767 100644 --- a/demo-app/src/commonMain/kotlin/org/maplibre/compose/demoapp/DemoState.kt +++ b/demo-app/src/commonMain/kotlin/org/maplibre/compose/demoapp/DemoState.kt @@ -23,6 +23,7 @@ import org.maplibre.compose.demoapp.demos.StyleSelectorDemo import org.maplibre.compose.demoapp.demos.UserLocationDemo import org.maplibre.compose.demoapp.util.Platform import org.maplibre.compose.demoapp.util.PlatformFeature +import org.maplibre.compose.location.LocationRequest import org.maplibre.compose.location.UserLocationState import org.maplibre.compose.location.rememberDefaultLocationProvider import org.maplibre.compose.location.rememberNullLocationProvider @@ -127,7 +128,7 @@ fun rememberDemoState(): DemoState { val locationProvider = key(locationPermissionState.hasPermission) { if (locationPermissionState.hasPermission) { - rememberDefaultLocationProvider() + rememberDefaultLocationProvider(request = LocationRequest(orientation = true)) } else { rememberNullLocationProvider() } diff --git a/demo-app/src/commonMain/kotlin/org/maplibre/compose/demoapp/demos/UserLocationDemo.kt b/demo-app/src/commonMain/kotlin/org/maplibre/compose/demoapp/demos/UserLocationDemo.kt index 92922121..73d069c2 100644 --- a/demo-app/src/commonMain/kotlin/org/maplibre/compose/demoapp/demos/UserLocationDemo.kt +++ b/demo-app/src/commonMain/kotlin/org/maplibre/compose/demoapp/demos/UserLocationDemo.kt @@ -38,7 +38,7 @@ object UserLocationDemo : Demo { if (!isOpen) return LocationTrackingEffect( - locationState = state.locationState, + location = state.locationState.location, enabled = trackLocation, trackBearing = bearingUpdate == BearingUpdate.TRACK_LOCATION, ) { @@ -58,6 +58,7 @@ object UserLocationDemo : Demo { BearingUpdate.IGNORE -> GestureOptions.PositionLocked BearingUpdate.ALWAYS_NORTH -> GestureOptions.ZoomOnly BearingUpdate.TRACK_LOCATION -> GestureOptions.ZoomOnly + else -> TODO() } } else { GestureOptions.Standard @@ -74,11 +75,12 @@ object UserLocationDemo : Demo { LocationPuck( idPrefix = "user-location", - locationState = state.locationState, + location = state.locationState.location, cameraState = state.cameraState, accuracyThreshold = 0f, - showBearing = bearingUpdate != BearingUpdate.IGNORE, - showBearingAccuracy = bearingUpdate != BearingUpdate.IGNORE, + bearing = + if (bearingUpdate != BearingUpdate.IGNORE) state.locationState.location?.orientation + else null, colors = LocationPuckDefaults.colors(), onClick = { location -> locationClickedCount++ @@ -91,6 +93,7 @@ object UserLocationDemo : Demo { trackLocation = false BearingUpdate.IGNORE } + else -> TODO() } } else { bearingUpdate = BearingUpdate.TRACK_LOCATION @@ -100,7 +103,8 @@ object UserLocationDemo : Demo { state.cameraState.position = CameraPosition( target = - state.locationState.location?.position ?: state.cameraState.position.target, + state.locationState.location?.position?.position + ?: state.cameraState.position.target, zoom = 16.0, ) } @@ -127,6 +131,8 @@ object UserLocationDemo : Demo { BearingUpdate.IGNORE -> "ignoring bearing" BearingUpdate.ALWAYS_NORTH -> "locked to north bearing" BearingUpdate.TRACK_LOCATION -> "bearing" + BearingUpdate.TRACK_COURSE -> "course" + BearingUpdate.TRACK_ORIENTATION -> "orientation" } ) } else { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4ab2aa30..2124a499 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -66,6 +66,7 @@ playServices-location = { module = "com.google.android.gms:play-services-locatio simplejni-annotations = { module = "io.github.gershnik:smjni-jnigen-annotations", version.ref = "simplejni" } simplejni-kprocessor = { module = "io.github.gershnik:smjni-jnigen-kprocessor", version.ref = "simplejni" } spatialk-geojson = { group = "org.maplibre.spatialk", name = "geojson", version.ref = "spatialk" } +spatialk-units = { group = "org.maplibre.spatialk", name = "units", version.ref = "spatialk" } [plugins] android-application = { id = "com.android.application", version.ref = "gradle-android" } diff --git a/lib/maplibre-compose-gms/src/androidMain/kotlin/org/maplibre/compose/gms/FusedLocationProvider.kt b/lib/maplibre-compose-gms/src/androidMain/kotlin/org/maplibre/compose/gms/FusedLocationProvider.kt index 51f4653b..0c99982d 100644 --- a/lib/maplibre-compose-gms/src/androidMain/kotlin/org/maplibre/compose/gms/FusedLocationProvider.kt +++ b/lib/maplibre-compose-gms/src/androidMain/kotlin/org/maplibre/compose/gms/FusedLocationProvider.kt @@ -7,16 +7,21 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.platform.LocalContext +import com.google.android.gms.location.DeviceOrientation +import com.google.android.gms.location.DeviceOrientationRequest import com.google.android.gms.location.FusedLocationProviderClient +import com.google.android.gms.location.FusedOrientationProviderClient import com.google.android.gms.location.Granularity import com.google.android.gms.location.LastLocationRequest import com.google.android.gms.location.LocationAvailability import com.google.android.gms.location.LocationCallback -import com.google.android.gms.location.LocationRequest +import com.google.android.gms.location.LocationRequest as GMSLocationRequest import com.google.android.gms.location.LocationResult import com.google.android.gms.location.LocationServices import com.google.android.gms.location.Priority import java.util.concurrent.Executors +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.TimeSource import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.channels.awaitClose @@ -25,10 +30,15 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.zip import kotlinx.coroutines.tasks.await +import org.maplibre.compose.location.BearingMeasurement import org.maplibre.compose.location.Location import org.maplibre.compose.location.LocationProvider +import org.maplibre.compose.location.LocationRequest import org.maplibre.compose.location.asMapLibreLocation +import org.maplibre.spatialk.units.Bearing +import org.maplibre.spatialk.units.extensions.degrees /** * A [LocationProvider] based on a [LocationRequest] for [FusedLocationProviderClient] @@ -43,8 +53,10 @@ public class FusedLocationProvider anyOf = [Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION] ) constructor( + private val request: LocationRequest, private val locationClient: FusedLocationProviderClient, - private val locationRequest: LocationRequest, + private val orientationClient: FusedOrientationProviderClient, + private val locationRequest: GMSLocationRequest, coroutineScope: CoroutineScope, sharingStarted: SharingStarted, ) : LocationProvider { @@ -52,6 +64,21 @@ constructor( override val location: StateFlow init { + val orientation = callbackFlow { + val request = + DeviceOrientationRequest.Builder( + locationRequest.intervalMillis.milliseconds.inWholeMicroseconds + ) + .build() + val callback: (DeviceOrientation) -> Unit = { orientation -> + trySend(orientation.headingDegrees to orientation.headingErrorDegrees) + } + + orientationClient.requestOrientationUpdates(request, dispatcher.executor, callback) + + awaitClose { orientationClient.removeOrientationUpdates(callback) } + } + location = callbackFlow { val callback = @@ -80,6 +107,20 @@ constructor( awaitClose { locationClient.removeLocationUpdates(callback) } } + .let { flow -> + if (!request.orientation) { + return@let flow + } + flow.zip(orientation) { location, (orientation, orientationAccuracy) -> + (location ?: Location(timestamp = TimeSource.Monotonic.markNow())).copy( + orientation = + BearingMeasurement( + bearing = Bearing.North + orientation.toDouble().degrees, + inaccuracy = orientationAccuracy.toDouble().degrees, + ) + ) + } + } .stateIn(coroutineScope, sharingStarted, null) } @@ -98,12 +139,15 @@ constructor( anyOf = [Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION] ) public fun rememberFusedLocationProvider( - locationRequest: LocationRequest = defaultLocationRequest, + request: LocationRequest, + locationRequest: GMSLocationRequest = defaultLocationRequest, context: Context = LocalContext.current, ): FusedLocationProvider { val locationClient = remember(context) { LocationServices.getFusedLocationProviderClient(context) } - return rememberFusedLocationProvider(locationClient, locationRequest) + val orientationClient = + remember(context) { LocationServices.getFusedOrientationProviderClient(context) } + return rememberFusedLocationProvider(request, locationClient, orientationClient, locationRequest) } /** @@ -115,14 +159,18 @@ public fun rememberFusedLocationProvider( anyOf = [Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION] ) public fun rememberFusedLocationProvider( + request: LocationRequest, fusedLocationProviderClient: FusedLocationProviderClient, - locationRequest: LocationRequest = defaultLocationRequest, + fusedOrientationProviderClient: FusedOrientationProviderClient, + locationRequest: GMSLocationRequest = defaultLocationRequest, coroutineScope: CoroutineScope = rememberCoroutineScope(), sharingStarted: SharingStarted = SharingStarted.WhileSubscribed(stopTimeoutMillis = 1000), ): FusedLocationProvider { return remember(fusedLocationProviderClient) { FusedLocationProvider( + request = request, locationClient = fusedLocationProviderClient, + orientationClient = fusedOrientationProviderClient, locationRequest = locationRequest, coroutineScope = coroutineScope, sharingStarted = sharingStarted, @@ -131,6 +179,6 @@ public fun rememberFusedLocationProvider( } private val defaultLocationRequest = - LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, 1000) + GMSLocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, 1000) .setMinUpdateIntervalMillis(1000) .build() diff --git a/lib/maplibre-compose/build.gradle.kts b/lib/maplibre-compose/build.gradle.kts index 2e102e4f..cd699ef2 100644 --- a/lib/maplibre-compose/build.gradle.kts +++ b/lib/maplibre-compose/build.gradle.kts @@ -54,6 +54,7 @@ kotlin { implementation(libs.lifecycle.runtime.compose) api(libs.kermit) api(libs.spatialk.geojson) + api(libs.spatialk.units) } // used to share some implementation on targets where Compose UI is backed by Skia directly diff --git a/lib/maplibre-compose/src/androidMain/kotlin/org/maplibre/compose/location/AndroidLocationProvider.kt b/lib/maplibre-compose/src/androidMain/kotlin/org/maplibre/compose/location/AndroidLocationProvider.kt index 4fe19ff1..481986bd 100644 --- a/lib/maplibre-compose/src/androidMain/kotlin/org/maplibre/compose/location/AndroidLocationProvider.kt +++ b/lib/maplibre-compose/src/androidMain/kotlin/org/maplibre/compose/location/AndroidLocationProvider.kt @@ -2,11 +2,16 @@ package org.maplibre.compose.location import android.Manifest import android.content.Context +import android.content.Context.SENSOR_SERVICE import android.content.pm.PackageManager +import android.hardware.Sensor +import android.hardware.SensorEvent +import android.hardware.SensorEventListener +import android.hardware.SensorManager import android.location.Criteria import android.location.LocationListener import android.location.LocationManager -import android.location.LocationRequest +import android.location.LocationRequest as AndroidLocationRequest import android.os.Build import android.os.Handler import android.os.HandlerThread @@ -17,13 +22,21 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.platform.LocalContext import kotlin.time.Duration +import kotlin.time.TimeSource import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.trySendBlocking +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.zip +import org.maplibre.spatialk.units.Bearing +import org.maplibre.spatialk.units.Length +import org.maplibre.spatialk.units.extensions.degrees +import org.maplibre.spatialk.units.extensions.inMeters /** * A [LocationProvider] built on the [LocationManager] platform APIs. @@ -44,8 +57,9 @@ public class AndroidLocationProvider ) constructor( private val context: Context, + private val request: LocationRequest, updateInterval: Duration, - private val minDistanceMeters: Float, + private val minDistance: Length, private val desiredAccuracy: DesiredAccuracy, coroutineScope: CoroutineScope, sharingStarted: SharingStarted = SharingStarted.WhileSubscribed(stopTimeoutMillis = 1000), @@ -67,6 +81,41 @@ constructor( } val locationManager = context.getSystemService(LocationManager::class.java) + val sensorManager = context.getSystemService(SENSOR_SERVICE) as SensorManager + + @OptIn(FlowPreview::class) + val orientation: Flow = callbackFlow { + val rotationMatrix = FloatArray(9) + val orientationAngles = FloatArray(3) + + val listener = + object : SensorEventListener { + override fun onSensorChanged(event: SensorEvent?) { + if (event?.sensor?.type == Sensor.TYPE_ROTATION_VECTOR) { + SensorManager.getRotationMatrixFromVector(rotationMatrix, event.values) + SensorManager.getOrientation(rotationMatrix, orientationAngles) + + trySend(Math.toDegrees(orientationAngles[0].toDouble())) + } + } + + override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {} + } + + val sensor = + sensorManager.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR) + ?: throw IllegalStateException("Rotation vector sensor is not available") + + sensorManager.registerListener( + listener, + sensor, + SensorManager.SENSOR_DELAY_NORMAL, + updateInterval.inWholeMicroseconds.toInt(), + Handler(handlerThread.looper), + ) + + awaitClose { sensorManager.unregisterListener(listener) } + } location = callbackFlow { @@ -92,6 +141,18 @@ constructor( awaitClose { locationManager.removeUpdates(listener) } } + .let { flow -> + if (!request.orientation) { + return@let flow + } + flow.zip(orientation) { location, facing -> + (location ?: Location(timestamp = TimeSource.Monotonic.markNow())).copy( + orientation = + // SensorManager does not provide accuracy in degrees + BearingMeasurement(bearing = Bearing.North + facing.degrees, inaccuracy = null) + ) + } + } .stateIn(coroutineScope, sharingStarted, null) } @@ -147,7 +208,7 @@ constructor( locationManager.requestLocationUpdates( LocationManager.PASSIVE_PROVIDER, updateInterval.inWholeMilliseconds, - minDistanceMeters, + minDistance.inMeters.toFloat(), listener, handlerThread.looper, ) @@ -165,17 +226,17 @@ constructor( ) { locationManager.requestLocationUpdates( LocationManager.FUSED_PROVIDER, - LocationRequest.Builder(updateInterval.inWholeMilliseconds) + AndroidLocationRequest.Builder(updateInterval.inWholeMilliseconds) .setQuality( when (desiredAccuracy) { - DesiredAccuracy.Highest -> LocationRequest.QUALITY_HIGH_ACCURACY - DesiredAccuracy.High -> LocationRequest.QUALITY_HIGH_ACCURACY - DesiredAccuracy.Balanced -> LocationRequest.QUALITY_BALANCED_POWER_ACCURACY - DesiredAccuracy.Low -> LocationRequest.QUALITY_LOW_POWER + DesiredAccuracy.Highest -> AndroidLocationRequest.QUALITY_HIGH_ACCURACY + DesiredAccuracy.High -> AndroidLocationRequest.QUALITY_HIGH_ACCURACY + DesiredAccuracy.Balanced -> AndroidLocationRequest.QUALITY_BALANCED_POWER_ACCURACY + DesiredAccuracy.Low -> AndroidLocationRequest.QUALITY_LOW_POWER DesiredAccuracy.Lowest -> error("unreachable") } ) - .setMinUpdateDistanceMeters(minDistanceMeters) + .setMinUpdateDistanceMeters(minDistance.inMeters.toFloat()) .build(), HandlerExecutor(Handler(handlerThread.looper)), listener, @@ -199,7 +260,7 @@ constructor( locationManager.requestLocationUpdates( provider, updateInterval.inWholeMilliseconds, - minDistanceMeters, + minDistance.inMeters.toFloat(), listener, handlerThread.looper, ) @@ -237,14 +298,16 @@ constructor( anyOf = [Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION] ) public actual fun rememberDefaultLocationProvider( + request: LocationRequest, updateInterval: Duration, desiredAccuracy: DesiredAccuracy, - minDistanceMeters: Double, + minDistance: Length, ): LocationProvider { return rememberAndroidLocationProvider( updateInterval = updateInterval, desiredAccuracy = desiredAccuracy, - minDistanceMeters = minDistanceMeters.toFloat(), + minDistance = minDistance, + request = request, ) } @@ -254,9 +317,10 @@ public actual fun rememberDefaultLocationProvider( anyOf = [Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION] ) public fun rememberAndroidLocationProvider( + request: LocationRequest, updateInterval: Duration, desiredAccuracy: DesiredAccuracy, - minDistanceMeters: Float, + minDistance: Length, context: Context = LocalContext.current, coroutineScope: CoroutineScope = rememberCoroutineScope(), sharingStarted: SharingStarted = SharingStarted.WhileSubscribed(stopTimeoutMillis = 1000), @@ -265,15 +329,16 @@ public fun rememberAndroidLocationProvider( context, updateInterval, desiredAccuracy, - minDistanceMeters, + minDistance, coroutineScope, sharingStarted, ) { AndroidLocationProvider( context = context, + request = request, updateInterval = updateInterval, desiredAccuracy = desiredAccuracy, - minDistanceMeters = minDistanceMeters, + minDistance = minDistance, coroutineScope = coroutineScope, sharingStarted = sharingStarted, ) diff --git a/lib/maplibre-compose/src/androidMain/kotlin/org/maplibre/compose/location/Location.android.kt b/lib/maplibre-compose/src/androidMain/kotlin/org/maplibre/compose/location/Location.android.kt index 184d2f4d..1fd84a80 100644 --- a/lib/maplibre-compose/src/androidMain/kotlin/org/maplibre/compose/location/Location.android.kt +++ b/lib/maplibre-compose/src/androidMain/kotlin/org/maplibre/compose/location/Location.android.kt @@ -6,25 +6,46 @@ import android.os.SystemClock import kotlin.time.Duration.Companion.nanoseconds import kotlin.time.TimeSource import org.maplibre.spatialk.geojson.Position +import org.maplibre.spatialk.units.Bearing +import org.maplibre.spatialk.units.extensions.degrees +import org.maplibre.spatialk.units.extensions.meters public fun AndroidLocation.asMapLibreLocation(): Location = Location( - position = Position(longitude = longitude, latitude = latitude, altitude = altitude), - accuracy = accuracy.toDouble(), - bearing = if (hasBearing()) bearing.toDouble() else null, - bearingAccuracy = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && hasBearingAccuracy()) { - bearingAccuracyDegrees.toDouble() + position = + PositionMeasurement( + position = Position(longitude = longitude, latitude = latitude, altitude = altitude), + inaccuracy = if (hasAccuracy()) accuracy.toDouble().meters else null, + ), + speed = + if (hasSpeed()) { + SpeedMeasurement( + distancePerSecond = speed.toDouble().meters, + inaccuracy = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && hasSpeedAccuracy()) { + speedAccuracyMetersPerSecond.toDouble().meters + } else { + null + }, + ) } else { null }, - speed = if (hasSpeed()) speed.toDouble() else null, - speedAccuracy = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && hasSpeedAccuracy()) { - speedAccuracyMetersPerSecond.toDouble() + course = + if (hasBearing()) { + BearingMeasurement( + bearing = Bearing.North + bearing.toDouble().degrees, + inaccuracy = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && hasBearingAccuracy()) { + bearingAccuracyDegrees.toDouble().degrees + } else { + null + }, + ) } else { null }, + orientation = null, timestamp = (SystemClock.elapsedRealtimeNanos() - elapsedRealtimeNanos).nanoseconds.let { age -> TimeSource.Monotonic.markNow() - age diff --git a/lib/maplibre-compose/src/commonMain/kotlin/org/maplibre/compose/location/Location.kt b/lib/maplibre-compose/src/commonMain/kotlin/org/maplibre/compose/location/Location.kt index 2bc4427b..86c3fc81 100644 --- a/lib/maplibre-compose/src/commonMain/kotlin/org/maplibre/compose/location/Location.kt +++ b/lib/maplibre-compose/src/commonMain/kotlin/org/maplibre/compose/location/Location.kt @@ -1,7 +1,11 @@ package org.maplibre.compose.location import kotlin.time.TimeMark +import kotlinx.serialization.Serializable import org.maplibre.spatialk.geojson.Position +import org.maplibre.spatialk.units.Bearing +import org.maplibre.spatialk.units.Length +import org.maplibre.spatialk.units.Rotation /** * Describes a user's location @@ -13,6 +17,10 @@ import org.maplibre.spatialk.geojson.Position * degrees east of true north, i.e. 0° being north, 90° being east, etc. * @property bearingAccuracy the accuracy of [bearing], i.e. the true bearing is within +/- * [bearingAccuracy] degrees of [bearing] + * @property heading The heading of the user, i.e., which direction the user's device is pointing, + * in degrees east of true north. For example, 0° is north, 90° is east. + * @property headingAccuracy The accuracy of [heading], i.e., the true heading is within +/- + * [headingAccuracy] degrees of [heading]. * @property speed the current speed of the user in meters per second * @property speedAccuracy the accuracy of [speed], i.e. the true speed is within +/- * [speedAccuracy] m/s of [speed] @@ -20,12 +28,18 @@ import org.maplibre.spatialk.geojson.Position * instead of e.g. [kotlin.time.Instant], to allow calculating how old a location is, even if the * system clock changes. */ +@Serializable public data class Location( - val position: Position, - val accuracy: Double, - val bearing: Double?, - val bearingAccuracy: Double?, - val speed: Double?, - val speedAccuracy: Double?, + val position: PositionMeasurement? = null, + val speed: SpeedMeasurement? = null, + val course: BearingMeasurement? = null, + val orientation: BearingMeasurement? = null, val timestamp: TimeMark, ) + +@Serializable public data class PositionMeasurement(val position: Position, val inaccuracy: Length?) + +@Serializable public data class BearingMeasurement(val bearing: Bearing, val inaccuracy: Rotation?) + +@Serializable +public data class SpeedMeasurement(val distancePerSecond: Length, val inaccuracy: Length?) diff --git a/lib/maplibre-compose/src/commonMain/kotlin/org/maplibre/compose/location/LocationProvider.kt b/lib/maplibre-compose/src/commonMain/kotlin/org/maplibre/compose/location/LocationProvider.kt index b75ada71..d2538304 100644 --- a/lib/maplibre-compose/src/commonMain/kotlin/org/maplibre/compose/location/LocationProvider.kt +++ b/lib/maplibre-compose/src/commonMain/kotlin/org/maplibre/compose/location/LocationProvider.kt @@ -8,6 +8,8 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import org.maplibre.compose.location.DesiredAccuracy.Balanced import org.maplibre.compose.location.DesiredAccuracy.High +import org.maplibre.spatialk.units.Length +import org.maplibre.spatialk.units.extensions.meters /** * This is an intentionally very limited abstraction over the various platform APIs for geolocation. @@ -100,9 +102,10 @@ public class PermissionException : Exception() */ @Composable public expect fun rememberDefaultLocationProvider( + request: LocationRequest = LocationRequest(), updateInterval: Duration = 1.seconds, desiredAccuracy: DesiredAccuracy = DesiredAccuracy.High, - minDistanceMeters: Double = 1.0, + minDistance: Length = 1.meters, ): LocationProvider /** diff --git a/lib/maplibre-compose/src/commonMain/kotlin/org/maplibre/compose/location/LocationPuck.kt b/lib/maplibre-compose/src/commonMain/kotlin/org/maplibre/compose/location/LocationPuck.kt index 5dd085f0..41bb5098 100644 --- a/lib/maplibre-compose/src/commonMain/kotlin/org/maplibre/compose/location/LocationPuck.kt +++ b/lib/maplibre-compose/src/commonMain/kotlin/org/maplibre/compose/location/LocationPuck.kt @@ -49,59 +49,62 @@ import org.maplibre.compose.util.ClickResult import org.maplibre.spatialk.geojson.Feature import org.maplibre.spatialk.geojson.FeatureCollection import org.maplibre.spatialk.geojson.Point +import org.maplibre.spatialk.units.Bearing +import org.maplibre.spatialk.units.Rotation +import org.maplibre.spatialk.units.extensions.inDegrees +import org.maplibre.spatialk.units.extensions.inMeters /** * Adds multiple layers to form a location puck. * - * A location puck is a dot at the users current location according to [locationState] and - * optionally a circle for the location accuracy. If supported and enabled, indicators for the - * current bearing and bearing accuracy are shown as well. + * A location puck is a dot at the user's current location according to [location] and optionally a + * circle for the location accuracy. If supported and enabled, indicators for the current bearing + * and bearing accuracy are shown as well. * - * @param idPrefix the prefix used for the layers to display the location indicator - * @param locationState a [UserLocationState] holding the location to display - * @param cameraState the [CameraState] of the map, used only for [CameraState.metersPerDpAtTarget] - * to correctly draw the accuracy circle. The camera state is not modified by this composable, if - * you want the camera to track the current location use [LocationTrackingEffect]. - * @param oldLocationThreshold locations with a [timestamp][Location.timestamp] older than this will - * be considered old locations - * @param accuracyThreshold a circle showing the accuracy range will be drawn, when - * [Location.accuracy] is larger than this value. Use [Float.POSITIVE_INFINITY] to never show the - * accuracy range. - * @param showBearing whether to show an indicator for [Location.bearing] - * @param showBearingAccuracy whether to show an indicator for [Location.bearingAccuracy] - * @param onClick a [LocationClickHandler] to invoke when the main location indicator dot is clicked - * @param onClick a [LocationClickHandler] to invoke when the main location indicator dot is - * long-clicked + * @param idPrefix The prefix used for the layers to display the location indicator. + * @param location A [Location] holding the location to display. + * @param cameraState The [CameraState] of the map, used only for [CameraState.metersPerDpAtTarget] + * to correctly draw the accuracy circle. The camera state is not modified by this composable; if + * you want the camera to track the current location, use [LocationTrackingEffect]. + * @param bearing The bearing of the location puck, which determines the rotation of the bearing + * indicator. Defaults to `location.course`, which is the direction of travel. + * @param oldLocationThreshold Locations with a [timestamp][Location.timestamp] older than this will + * be considered old and will be styled differently. + * @param accuracyThreshold A circle showing the accuracy range will be drawn when + * [PositionMeasurement.inaccuracy] is larger than this value. Use [Float.POSITIVE_INFINITY] to + * never show the accuracy range. + * @param colors The colors to use for the location puck. + * @param sizes The sizes to use for the location puck. + * @param onClick A [LocationClickHandler] to invoke when the main location indicator dot is + * clicked. + * @param onLongClick A [LocationClickHandler] to invoke when the main location indicator dot is + * long-clicked. */ @Composable public fun LocationPuck( idPrefix: String, - locationState: UserLocationState, + location: Location?, cameraState: CameraState, + bearing: BearingMeasurement? = location?.course, oldLocationThreshold: Duration = 30.seconds, accuracyThreshold: Float = 50f, colors: LocationPuckColors = LocationPuckColors(), sizes: LocationPuckSizes = LocationPuckSizes(), - showBearing: Boolean = true, - showBearingAccuracy: Boolean = true, onClick: LocationClickHandler? = null, onLongClick: LocationClickHandler? = null, ) { val bearingPainter = rememberBearingPainter(sizes, colors) - val bearingAccuracyPainter = - rememberBearingAccuracyPainter( - sizes = sizes, - colors = colors, - bearingAccuracy = locationState.location?.bearingAccuracy ?: 0.0, - ) - val locationSource = rememberLocationSource(locationState) + val locationSource = rememberLocationSource(location, bearing) + + val positionAccuracy = location?.position?.inaccuracy?.inMeters?.toFloat() ?: 0f CircleLayer( id = "$idPrefix-accuracy", source = locationSource, visible = accuracyThreshold <= Float.POSITIVE_INFINITY && - locationState.location.let { it != null && it.accuracy > accuracyThreshold }, + location?.position != null && + positionAccuracy > accuracyThreshold, radius = switch( condition( @@ -120,7 +123,7 @@ public fun LocationPuck( CircleLayer( id = "$idPrefix-shadow", source = locationSource, - visible = sizes.shadowSize > 0.dp && locationState.location != null, + visible = sizes.shadowSize > 0.dp && location?.position != null, radius = const(sizes.dotRadius + sizes.dotStrokeWidth + sizes.shadowSize), color = const(colors.shadowColor), blur = const(sizes.shadowBlur), @@ -130,7 +133,7 @@ public fun LocationPuck( CircleLayer( id = "$idPrefix-dot", source = locationSource, - visible = locationState.location != null, + visible = location?.position != null, radius = const(sizes.dotRadius), color = switch( @@ -144,11 +147,11 @@ public fun LocationPuck( strokeColor = const(colors.dotStrokeColor), strokeWidth = const(sizes.dotStrokeWidth), onClick = { - locationState.location?.let { onClick?.invoke(it) } + location?.let { onClick?.invoke(it) } ClickResult.Consume }, onLongClick = { - locationState.location?.let { onLongClick?.invoke(it) } + location?.let { onLongClick?.invoke(it) } ClickResult.Consume }, ) @@ -156,7 +159,7 @@ public fun LocationPuck( SymbolLayer( id = "$idPrefix-bearing", source = locationSource, - visible = showBearing && locationState.location?.bearing != null, + visible = bearing != null, iconImage = image(bearingPainter), iconAnchor = const(SymbolAnchor.Center), iconRotate = feature["bearing"].asNumber(const(0f)) + const(45f), @@ -169,22 +172,28 @@ public fun LocationPuck( iconAllowOverlap = const(true), ) - SymbolLayer( - id = "$idPrefix-bearingAccuracy", - source = locationSource, - visible = - showBearingAccuracy && - locationState.location?.bearing != null && - locationState.location?.bearingAccuracy != null, - iconImage = image(bearingAccuracyPainter), - iconAnchor = const(SymbolAnchor.Center), - iconRotate = - feature["bearing"].asNumber(const(0f)) - - const(90f) - - feature["bearingAccuracy"].asNumber(const(0f)), - iconRotationAlignment = const(IconRotationAlignment.Map), - iconAllowOverlap = const(true), - ) + if (bearing?.inaccuracy != null) { + val bearingAccuracyPainter = + rememberBearingAccuracyPainter( + sizes = sizes, + colors = colors, + bearingAccuracy = bearing.inaccuracy, + ) + + SymbolLayer( + id = "$idPrefix-bearingAccuracy", + source = locationSource, + visible = true, + iconImage = image(bearingAccuracyPainter), + iconAnchor = const(SymbolAnchor.Center), + iconRotate = + feature["bearing"].asNumber(const(0f)) - + const(90f) - + feature["bearingAccuracy"].asNumber(const(0f)), + iconRotationAlignment = const(IconRotationAlignment.Map), + iconAllowOverlap = const(true), + ) + } } @Composable @@ -214,7 +223,7 @@ private fun rememberBearingPainter( private fun rememberBearingAccuracyPainter( sizes: LocationPuckSizes, colors: LocationPuckColors, - bearingAccuracy: Double, + bearingAccuracy: Rotation, ): VectorPainter { val density by rememberUpdatedState(LocalDensity.current) @@ -228,7 +237,7 @@ private fun rememberBearingAccuracyPainter( derivedStateOf { val radius = with(density) { Offset(dotRadius.toPx(), dotRadius.toPx()) } - val deltaDegrees = 2 * bearingAccuracy + val deltaDegrees = 2 * bearingAccuracy.inDegrees val delta = (PI * deltaDegrees / 180.0).toFloat() val width = 2 * dotRadius + 2 * dotStrokeWidth @@ -263,21 +272,27 @@ private fun rememberBearingAccuracyPainter( } @Composable -private fun rememberLocationSource(locationState: UserLocationState): GeoJsonSource { +private fun rememberLocationSource( + location: Location?, + bearing: BearingMeasurement?, +): GeoJsonSource { val features = - remember(locationState.location) { - val location = locationState.location - if (location == null) { + remember(location, bearing) { + if (location?.position == null) { FeatureCollection() } else { + val accuracy = location.position.inaccuracy?.inMeters?.toFloat() ?: 0f + FeatureCollection( Feature( - geometry = Point(location.position), + geometry = Point(location.position.position), properties = buildJsonObject { - put("accuracy", location.accuracy) - put("bearing", location.bearing) - put("bearingAccuracy", location.bearingAccuracy) + put("accuracy", accuracy) + bearing?.bearing?.let { + put("bearing", it.clockwiseRotationTo(Bearing.North).inDegrees) + } + bearing?.inaccuracy?.let { put("bearingAccuracy", it.inDegrees.toFloat()) } put("age", location.timestamp.elapsedNow().inWholeNanoseconds) }, ) diff --git a/lib/maplibre-compose/src/commonMain/kotlin/org/maplibre/compose/location/LocationRequest.kt b/lib/maplibre-compose/src/commonMain/kotlin/org/maplibre/compose/location/LocationRequest.kt new file mode 100644 index 00000000..ce8c8a99 --- /dev/null +++ b/lib/maplibre-compose/src/commonMain/kotlin/org/maplibre/compose/location/LocationRequest.kt @@ -0,0 +1,20 @@ +package org.maplibre.compose.location + +/** + * A request for a location update. This class is used to configure the location provider. + * + * @param position True if the position should be included in the location update, false otherwise. + * Defaults to true. + * @param speed True if the speed should be included in the location update, false otherwise. + * Defaults to true. + * @param course True if the course should be included in the location update, false otherwise. + * Defaults to true. + * @param orientation True if the orientation should be included in the location update, false + * otherwise. Defaults to false. + */ +public data class LocationRequest( + val position: Boolean = true, + val speed: Boolean = true, + val course: Boolean = true, + val orientation: Boolean = false, +) diff --git a/lib/maplibre-compose/src/commonMain/kotlin/org/maplibre/compose/location/LocationTrackingEffect.kt b/lib/maplibre-compose/src/commonMain/kotlin/org/maplibre/compose/location/LocationTrackingEffect.kt index a5544af9..cbc67a99 100644 --- a/lib/maplibre-compose/src/commonMain/kotlin/org/maplibre/compose/location/LocationTrackingEffect.kt +++ b/lib/maplibre-compose/src/commonMain/kotlin/org/maplibre/compose/location/LocationTrackingEffect.kt @@ -11,23 +11,24 @@ import kotlinx.coroutines.flow.FlowCollector import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filterNotNull import org.maplibre.compose.camera.CameraState +import org.maplibre.spatialk.units.Bearing +import org.maplibre.spatialk.units.extensions.inDegrees /** * A form of [LaunchedEffect] that is specialized for tracking user location. * - * [onLocationChange] will be called, whenever the [location][UserLocationState.location] of - * [locationState] changes according to the given parameters. Only [Location]s whose `latitude` or - * `longitude` changes by at least [precision] compared to the previous location will result in a - * call to [onLocationChange]. If [trackBearing] is `true`, the `bearing` must change by at least - * [precision] as well (or change between null/non-null). + * [onLocationChange] will be called, whenever the [location] changes according to the given + * parameters. Only [Location]s whose position `latitude` or `longitude` changes by at least + * [precision] compared to the previous location will result in a call to [onLocationChange]. If + * [trackBearing] is `true`, the bearing must change by at least [precision] as well (or change + * between null/non-null). * * If [enabled] is `false` [onLocationChange] will never be called and location is not monitored, - * i.e. the [LocationProvider] underneath [locationState] may stop requesting location updates from - * the platform. + * i.e. the [LocationProvider] may stop requesting location updates from the platform. */ @Composable public fun LocationTrackingEffect( - locationState: UserLocationState, + location: Location?, enabled: Boolean = true, trackBearing: Boolean = true, precision: Double = 0.00001, // approx 1 m @@ -38,17 +39,35 @@ public fun LocationTrackingEffect( LaunchedEffect(enabled, trackBearing, changeCollector) { if (!enabled) return@LaunchedEffect - snapshotFlow { locationState.location } + snapshotFlow { location } .filterNotNull() .distinctUntilChanged equal@{ old, new -> - if (trackBearing && (old.bearing != null || new.bearing != null)) { - if (old.bearing == null) return@equal false - if (new.bearing == null) return@equal false - if (abs(old.bearing - new.bearing) >= precision) return@equal false + // Check bearing changes if tracking is enabled + if (trackBearing) { + val oldBearing = old.course?.bearing ?: old.orientation?.bearing + val newBearing = new.course?.bearing ?: new.orientation?.bearing + + if ((oldBearing != null) || (newBearing != null)) { + if (oldBearing == null) return@equal false + if (newBearing == null) return@equal false + if ( + abs( + oldBearing.clockwiseRotationTo(Bearing.North).inDegrees - + newBearing.clockwiseRotationTo(Bearing.North).inDegrees + ) >= precision + ) + return@equal false + } } - if (abs(old.position.latitude - new.position.latitude) >= precision) return@equal false - if (abs(old.position.longitude - new.position.longitude) >= precision) return@equal false + // Check position changes + val oldPos = old.position?.position + val newPos = new.position?.position + + if (oldPos == null || newPos == null) return@equal oldPos == newPos + + if (abs(oldPos.latitude - newPos.latitude) >= precision) return@equal false + if (abs(oldPos.longitude - newPos.longitude) >= precision) return@equal false true } @@ -73,7 +92,7 @@ public interface LocationChangeScope { * * @param animationDuration if `null` updates [org.maplibre.compose.camera.CameraState.position] * directly without animation, otherwise specifies the duration of the camera animation - * @param updateBearing determines how the [Location.bearing] affects the camera state + * @param updateBearing determines how the bearing affects the camera state */ public suspend fun CameraState.updateFromLocation( animationDuration: Duration? = 300.milliseconds, @@ -88,7 +107,13 @@ public enum class BearingUpdate { /** ignore changes in bearing and reset orientation to point north */ ALWAYS_NORTH, - /** update camera orientation based on location bearing */ + /** update camera orientation based on location course (direction of movement) */ + TRACK_COURSE, + + /** update camera orientation based on device orientation (heading) */ + TRACK_ORIENTATION, + + /** update camera orientation based on course if available, otherwise orientation */ TRACK_LOCATION, } @@ -108,21 +133,44 @@ internal class LocationChangeCollector(private val onEmit: suspend LocationChang updateBearing: BearingUpdate, ) { val cameraState = this + val position = currentLocation.position?.position + + if (position == null) return val newPosition = when (updateBearing) { BearingUpdate.IGNORE -> { - cameraState.position.copy(target = currentLocation.position) + cameraState.position.copy(target = position) } BearingUpdate.ALWAYS_NORTH -> { - cameraState.position.copy(target = currentLocation.position, bearing = 0.0) + cameraState.position.copy(target = position, bearing = 0.0) + } + + BearingUpdate.TRACK_COURSE -> { + cameraState.position.copy( + target = position, + bearing = + currentLocation.course?.bearing?.clockwiseRotationTo(Bearing.North)?.inDegrees + ?: cameraState.position.bearing, + ) + } + + BearingUpdate.TRACK_ORIENTATION -> { + cameraState.position.copy( + target = position, + bearing = + currentLocation.orientation?.bearing?.clockwiseRotationTo(Bearing.North)?.inDegrees + ?: cameraState.position.bearing, + ) } BearingUpdate.TRACK_LOCATION -> { + val bearing = currentLocation.course?.bearing ?: currentLocation.orientation?.bearing cameraState.position.copy( - target = currentLocation.position, - bearing = currentLocation.bearing ?: cameraState.position.bearing, + target = position, + bearing = + bearing?.clockwiseRotationTo(Bearing.North)?.inDegrees ?: cameraState.position.bearing, ) } } diff --git a/lib/maplibre-compose/src/desktopMain/kotlin/org/maplibre/compose/location/DesktopLocationProvider.kt b/lib/maplibre-compose/src/desktopMain/kotlin/org/maplibre/compose/location/DesktopLocationProvider.kt index 8b3db043..232ffee8 100644 --- a/lib/maplibre-compose/src/desktopMain/kotlin/org/maplibre/compose/location/DesktopLocationProvider.kt +++ b/lib/maplibre-compose/src/desktopMain/kotlin/org/maplibre/compose/location/DesktopLocationProvider.kt @@ -8,6 +8,7 @@ public actual fun rememberDefaultLocationProvider( updateInterval: Duration, desiredAccuracy: DesiredAccuracy, minDistanceMeters: Double, + enableHeading: Boolean, ): LocationProvider { throw NotImplementedError("no default implementation for desktop") } diff --git a/lib/maplibre-compose/src/iosMain/kotlin/org/maplibre/compose/location/IosLocationProvider.kt b/lib/maplibre-compose/src/iosMain/kotlin/org/maplibre/compose/location/IosLocationProvider.kt index fede6dab..b89fcf98 100644 --- a/lib/maplibre-compose/src/iosMain/kotlin/org/maplibre/compose/location/IosLocationProvider.kt +++ b/lib/maplibre-compose/src/iosMain/kotlin/org/maplibre/compose/location/IosLocationProvider.kt @@ -88,6 +88,7 @@ public actual fun rememberDefaultLocationProvider( updateInterval: Duration, desiredAccuracy: DesiredAccuracy, minDistanceMeters: Double, + enableHeading: Boolean, ): LocationProvider { return rememberIosLocationProvider(minDistanceMeters, desiredAccuracy) } diff --git a/lib/maplibre-compose/src/jsMain/kotlin/org/maplibre/compose/location/WebLocationProvider.kt b/lib/maplibre-compose/src/jsMain/kotlin/org/maplibre/compose/location/WebLocationProvider.kt index 8b3db043..cee0ffc0 100644 --- a/lib/maplibre-compose/src/jsMain/kotlin/org/maplibre/compose/location/WebLocationProvider.kt +++ b/lib/maplibre-compose/src/jsMain/kotlin/org/maplibre/compose/location/WebLocationProvider.kt @@ -8,6 +8,7 @@ public actual fun rememberDefaultLocationProvider( updateInterval: Duration, desiredAccuracy: DesiredAccuracy, minDistanceMeters: Double, + enableHeading: Boolean, ): LocationProvider { - throw NotImplementedError("no default implementation for desktop") + throw NotImplementedError("no default implementation for web") }