diff --git a/demo-app/build.gradle.kts b/demo-app/build.gradle.kts index 641c84ebf..04d016aa6 100644 --- a/demo-app/build.gradle.kts +++ b/demo-app/build.gradle.kts @@ -79,6 +79,7 @@ kotlin { implementation(compose.material3) implementation(compose.runtime) implementation(compose.ui) + implementation(libs.lifecycle.runtime.compose) implementation(libs.androidx.navigation.compose) implementation(libs.ktor.client.core) implementation(libs.ktor.client.contentNegotiation) diff --git a/demo-app/src/commonMain/kotlin/dev/sargunv/maplibrecompose/demoapp/app.kt b/demo-app/src/commonMain/kotlin/dev/sargunv/maplibrecompose/demoapp/app.kt index 631e8cbf1..13d3f90b5 100644 --- a/demo-app/src/commonMain/kotlin/dev/sargunv/maplibrecompose/demoapp/app.kt +++ b/demo-app/src/commonMain/kotlin/dev/sargunv/maplibrecompose/demoapp/app.kt @@ -44,6 +44,7 @@ import dev.sargunv.maplibrecompose.demoapp.demos.EdgeToEdgeDemo import dev.sargunv.maplibrecompose.demoapp.demos.FrameRateDemo import dev.sargunv.maplibrecompose.demoapp.demos.LocalTilesDemo import dev.sargunv.maplibrecompose.demoapp.demos.MarkersDemo +import dev.sargunv.maplibrecompose.demoapp.demos.SnapshotterDemo import dev.sargunv.maplibrecompose.demoapp.demos.StyleSwitcherDemo import dev.sargunv.maplibrecompose.demoapp.demos.platformDemos import dev.sargunv.maplibrecompose.demoapp.generated.Res @@ -68,6 +69,7 @@ private val DEMOS = buildList { if (!Platform.isDesktop) add(CameraStateDemo) if (Platform.usesMaplibreNative) add(CameraFollowDemo) if (!Platform.isDesktop) add(FrameRateDemo) + if (Platform.supportsSnapshotter) add(SnapshotterDemo) addAll(platformDemos) } diff --git a/demo-app/src/commonMain/kotlin/dev/sargunv/maplibrecompose/demoapp/demos/SnapshotterDemo.kt b/demo-app/src/commonMain/kotlin/dev/sargunv/maplibrecompose/demoapp/demos/SnapshotterDemo.kt new file mode 100644 index 000000000..82212f54b --- /dev/null +++ b/demo-app/src/commonMain/kotlin/dev/sargunv/maplibrecompose/demoapp/demos/SnapshotterDemo.kt @@ -0,0 +1,163 @@ +package dev.sargunv.maplibrecompose.demoapp.demos + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import dev.sargunv.maplibrecompose.compose.CameraState +import dev.sargunv.maplibrecompose.compose.MaplibreMap +import dev.sargunv.maplibrecompose.compose.rememberCameraState +import dev.sargunv.maplibrecompose.compose.rememberStyleState +import dev.sargunv.maplibrecompose.core.SnapshotException +import dev.sargunv.maplibrecompose.demoapp.DEFAULT_STYLE +import dev.sargunv.maplibrecompose.demoapp.Demo +import dev.sargunv.maplibrecompose.demoapp.DemoMapControls +import dev.sargunv.maplibrecompose.demoapp.DemoOrnamentSettings +import dev.sargunv.maplibrecompose.demoapp.DemoScaffold +import kotlin.coroutines.cancellation.CancellationException +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch + +object SnapshotterDemo : Demo { + override val name = "Snapshotter" + override val description = "Take a snapshot of the map" + + @Composable + override fun Component(navigateUp: () -> Unit) { + val cameraState = rememberCameraState() + val styleState = rememberStyleState() + val isLoading = remember { mutableStateOf(false) } + val snapshot = remember { mutableStateOf(null) } + val snapshotError = remember { mutableStateOf(null) } + val lifeCycleOwner = LocalLifecycleOwner.current + + DisposableEffect(lifeCycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_PAUSE) { + isLoading.value = false + } + } + lifeCycleOwner.lifecycle.addObserver(observer) + onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) } + } + + DemoScaffold(this, navigateUp) { + Column { + Box(modifier = Modifier.weight(1f)) { + MaplibreMap( + styleUri = DEFAULT_STYLE, + cameraState = cameraState, + styleState = styleState, + ornamentSettings = DemoOrnamentSettings(), + ) + DemoMapControls(cameraState, styleState) + + if (isLoading.value) { + CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + } + } + + SnapshotterControls(cameraState, isLoading, snapshot, snapshotError) + + snapshot.value?.let { + SnapshotDialog(snapshot = it, onDismissRequest = { snapshot.value = null }) + } + + snapshotError.value?.let { + AlertDialog( + onDismissRequest = { snapshotError.value = null }, + title = { Text(text = "Error during snapshot generation") }, + text = { Text(text = it) }, + confirmButton = { TextButton(onClick = { snapshotError.value = null }) { Text("OK") } }, + ) + } + } + } + } + + @Composable + private fun SnapshotterControls( + cameraState: CameraState, + isLoading: MutableState, + snapshot: MutableState, + snapshotError: MutableState, + ) { + val scope = rememberCoroutineScope() + var snapshotJob by remember { mutableStateOf(null) } + + Row( + modifier = Modifier.padding(16.dp).fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + Button( + onClick = { + snapshotJob = + scope.launch { + isLoading.value = true + try { + val response = + cameraState.snapshot( + width = 512, + height = 512, + cameraPosition = cameraState.position, + ) + + snapshot.value = response + } catch (error: SnapshotException) { + snapshotError.value = error.message + } catch (error: CancellationException) { + println("Snapshot generation cancelled") + } + + isLoading.value = false + snapshotJob = null + } + } + ) { + Text("Take snapshot") + } + Button(enabled = snapshotJob != null, onClick = { snapshotJob?.cancel() }) { + Text("Cancel snapshot") + } + } + } + + @Composable + fun SnapshotDialog(snapshot: ImageBitmap, onDismissRequest: () -> Unit) { + Dialog(onDismissRequest = { onDismissRequest() }) { + Card(modifier = Modifier.padding(16.dp), shape = RoundedCornerShape(16.dp)) { + Image( + modifier = Modifier.padding(16.dp), + bitmap = snapshot, + contentDescription = "Snapshot", + ) + } + } + } +} diff --git a/demo-app/src/commonMain/kotlin/dev/sargunv/maplibrecompose/demoapp/util.kt b/demo-app/src/commonMain/kotlin/dev/sargunv/maplibrecompose/demoapp/util.kt index 1d8c6005d..41172e467 100644 --- a/demo-app/src/commonMain/kotlin/dev/sargunv/maplibrecompose/demoapp/util.kt +++ b/demo-app/src/commonMain/kotlin/dev/sargunv/maplibrecompose/demoapp/util.kt @@ -91,5 +91,8 @@ val Platform.supportsLayers: Boolean val Platform.supportsBlending: Boolean get() = isAndroid || isIos +val Platform.supportsSnapshotter: Boolean + get() = isAndroid || isIos + val Platform.usesMaplibreNative: Boolean get() = isAndroid || isIos diff --git a/docs/docs/index.md b/docs/docs/index.md index 3d18eb421..19fbbc8da 100644 --- a/docs/docs/index.md +++ b/docs/docs/index.md @@ -38,8 +38,8 @@ We don't yet support Wasm because one of our dependencies, | Add data sources by URI or GeoJSON | :white_check_mark: | :white_check_mark: | :x: | :x: | :x: | | Add images to the style | :white_check_mark: | :white_check_mark: | :x: | :x: | :x: | | Add Material 3 controls | :white_check_mark: | :white_check_mark: | :x: | :x: | :x: | +| Snapshot the map as an image | :white_check_mark: | :white_check_mark: | :x: | :x: | :x: | | Add Compose UI annotations | :x: | :x: | :x: | :x: | :x: | -| Snapshot the map as an image | :x: | :x: | :x: | :x: | :x: | | Configure the offline cache | :x: | :x: | :x: | :x: | :x: | | Configure layer transitions | :x: | :x: | :x: | :x: | :x: | diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a6b5bcff4..dc0bc57d6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -32,6 +32,7 @@ gradle-mkdocs = "4.0.1" gradle-spotless = "7.0.3" tool-prettier = "3.5.3" +lifecycle = "2.9.0" [libraries] alchemist = { module = "io.github.kevincianfarini.alchemist:alchemist", version.ref = "alchemist" } @@ -52,6 +53,7 @@ ktor-serialization-kotlinxJson = { module = "io.ktor:ktor-serialization-kotlinx- maplibre-android = { module = "org.maplibre.gl:android-sdk", version.ref = "maplibre-android-sdk" } maplibre-android-scalebar = { module = "org.maplibre.gl:android-plugin-scalebar-v9", version.ref = "maplibre-android-plugins" } spatialk-geojson = { group = "io.github.dellisd.spatialk", name = "geojson", version.ref = "spatialk" } +lifecycle-runtime-compose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref="lifecycle" } [plugins] android-application = { id = "com.android.application", version.ref = "gradle-android" } diff --git a/lib/maplibre-compose/src/androidMain/kotlin/dev/sargunv/maplibrecompose/compose/AndroidMapView.kt b/lib/maplibre-compose/src/androidMain/kotlin/dev/sargunv/maplibrecompose/compose/AndroidMapView.kt index 73273d490..e6d81f78d 100644 --- a/lib/maplibre-compose/src/androidMain/kotlin/dev/sargunv/maplibrecompose/compose/AndroidMapView.kt +++ b/lib/maplibre-compose/src/androidMain/kotlin/dev/sargunv/maplibrecompose/compose/AndroidMapView.kt @@ -12,6 +12,7 @@ import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.viewinterop.AndroidView import co.touchlab.kermit.Logger import dev.sargunv.maplibrecompose.core.AndroidMap +import dev.sargunv.maplibrecompose.core.AndroidMapSnapshotter import dev.sargunv.maplibrecompose.core.AndroidScaleBar import dev.sargunv.maplibrecompose.core.MapOptions import dev.sargunv.maplibrecompose.core.MaplibreMap @@ -79,6 +80,7 @@ internal fun AndroidMapView( mapView = mapView, map = map, scaleBar = AndroidScaleBar(context, mapView, map), + mapSnapshotter = AndroidMapSnapshotter(context, layoutDir, density), layoutDir = layoutDir, density = density, callbacks = callbacks, @@ -92,6 +94,8 @@ internal fun AndroidMapView( }, update = { _ -> val map = currentMap ?: return@AndroidView + map.getMapSnapshotter().density = density + map.getMapSnapshotter().layoutDir = layoutDir map.layoutDir = layoutDir map.density = density map.callbacks = callbacks 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 607a46b6f..9a9d7daef 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 @@ -12,9 +12,11 @@ import androidx.compose.ui.unit.dp import co.touchlab.kermit.Logger import dev.sargunv.maplibrecompose.core.util.correctedAndroidUri import dev.sargunv.maplibrecompose.core.util.toBoundingBox +import dev.sargunv.maplibrecompose.core.util.toCameraPosition 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.toMLNCameraPosition import dev.sargunv.maplibrecompose.core.util.toMLNExpression import dev.sargunv.maplibrecompose.core.util.toOffset import dev.sargunv.maplibrecompose.core.util.toPointF @@ -30,7 +32,6 @@ import kotlin.coroutines.resume import kotlin.coroutines.suspendCoroutine import kotlin.time.Duration import kotlin.time.DurationUnit -import org.maplibre.android.camera.CameraPosition as MLNCameraPosition import org.maplibre.android.camera.CameraUpdateFactory import org.maplibre.android.geometry.VisibleRegion as MLNVisibleRegion import org.maplibre.android.gestures.MoveGestureDetector @@ -52,6 +53,7 @@ internal class AndroidMap( private val mapView: MapView, private val map: MapLibreMap, private val scaleBar: AndroidScaleBar, + private val mapSnapshotter: AndroidMapSnapshotter, layoutDir: LayoutDirection, density: Density, internal var callbacks: MaplibreMap.Callbacks, @@ -258,47 +260,14 @@ internal class AndroidMap( } } - private fun MLNCameraPosition.toCameraPosition(): CameraPosition = - with(density) { - CameraPosition( - target = target?.toPosition() ?: Position(0.0, 0.0), - zoom = zoom, - bearing = bearing, - tilt = tilt, - padding = - padding?.let { - PaddingValues.Absolute( - left = it[0].toInt().toDp(), - top = it[1].toInt().toDp(), - right = it[2].toInt().toDp(), - bottom = it[3].toInt().toDp(), - ) - } ?: PaddingValues.Absolute(0.dp), - ) - } - - private fun CameraPosition.toMLNCameraPosition(): MLNCameraPosition = - with(density) { - MLNCameraPosition.Builder() - .target(target.toLatLng()) - .zoom(zoom) - .tilt(tilt) - .bearing(bearing) - .padding( - left = padding.calculateLeftPadding(layoutDir).toPx().toDouble(), - top = padding.calculateTopPadding().toPx().toDouble(), - right = padding.calculateRightPadding(layoutDir).toPx().toDouble(), - bottom = padding.calculateBottomPadding().toPx().toDouble(), - ) - .build() - } - override fun getCameraPosition(): CameraPosition { - return map.cameraPosition.toCameraPosition() + return map.cameraPosition.toCameraPosition(density) } override fun setCameraPosition(cameraPosition: CameraPosition) { - map.moveCamera(CameraUpdateFactory.newCameraPosition(cameraPosition.toMLNCameraPosition())) + map.moveCamera( + CameraUpdateFactory.newCameraPosition(cameraPosition.toMLNCameraPosition(density, layoutDir)) + ) } private class CancelableCoroutineCallback(private val cont: Continuation) : @@ -308,10 +277,14 @@ internal class AndroidMap( override fun onFinish() = cont.resume(Unit) } + override fun getStyleUri() = lastStyleUri + override suspend fun animateCameraPosition(finalPosition: CameraPosition, duration: Duration) = suspendCoroutine { cont -> map.animateCamera( - CameraUpdateFactory.newCameraPosition(finalPosition.toMLNCameraPosition()), + CameraUpdateFactory.newCameraPosition( + finalPosition.toMLNCameraPosition(density, layoutDir) + ), duration.toInt(DurationUnit.MILLISECONDS), CancelableCoroutineCallback(cont), ) @@ -373,6 +346,8 @@ internal class AndroidMap( override fun metersPerDpAtLatitude(latitude: Double) = map.projection.getMetersPerPixelAtLatitude(latitude) + + override fun getMapSnapshotter() = mapSnapshotter } private fun MLNVisibleRegion.toVisibleRegion() = diff --git a/lib/maplibre-compose/src/androidMain/kotlin/dev/sargunv/maplibrecompose/core/AndroidMapSnapshotter.kt b/lib/maplibre-compose/src/androidMain/kotlin/dev/sargunv/maplibrecompose/core/AndroidMapSnapshotter.kt new file mode 100644 index 000000000..b52f26ed7 --- /dev/null +++ b/lib/maplibre-compose/src/androidMain/kotlin/dev/sargunv/maplibrecompose/core/AndroidMapSnapshotter.kt @@ -0,0 +1,49 @@ +package dev.sargunv.maplibrecompose.core + +import android.content.Context +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.LayoutDirection +import dev.sargunv.maplibrecompose.core.util.correctedAndroidUri +import dev.sargunv.maplibrecompose.core.util.toLatLngBounds +import dev.sargunv.maplibrecompose.core.util.toMLNCameraPosition +import io.github.dellisd.spatialk.geojson.BoundingBox +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlinx.coroutines.suspendCancellableCoroutine +import org.maplibre.android.maps.Style +import org.maplibre.android.snapshotter.MapSnapshotter as MLNMapSnapshotter + +internal class AndroidMapSnapshotter( + private val context: Context, + internal var layoutDir: LayoutDirection, + internal var density: Density, +) : MapSnapshotter { + + override suspend fun snapshot( + width: Int, + height: Int, + styleUri: String, + region: BoundingBox?, + cameraPosition: CameraPosition?, + showLogo: Boolean, + ): ImageBitmap { + val styleBuilder = Style.Builder().fromUri(styleUri.correctedAndroidUri()) + val options = + MLNMapSnapshotter.Options(width, height) + .withStyleBuilder(styleBuilder) + .withRegion(region?.toLatLngBounds()) + .withCameraPosition(cameraPosition?.toMLNCameraPosition(density, layoutDir)) + .withLogo(showLogo) + + val snapshotter = MLNMapSnapshotter(context, options) + + return suspendCancellableCoroutine { cont -> + snapshotter.start({ snapshot -> cont.resume(snapshot.bitmap.asImageBitmap()) }) { error -> + cont.resumeWithException(SnapshotException(error)) + } + cont.invokeOnCancellation { snapshotter.cancel() } + } + } +} 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 b8c6da282..9d8fc50ea 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 @@ -3,6 +3,7 @@ package dev.sargunv.maplibrecompose.core.util import android.graphics.PointF import android.graphics.RectF import android.view.Gravity +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.ui.Alignment import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.unit.Density @@ -10,11 +11,13 @@ import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.DpRect import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp import com.google.gson.JsonArray import com.google.gson.JsonElement import com.google.gson.JsonNull import com.google.gson.JsonObject import com.google.gson.JsonPrimitive +import dev.sargunv.maplibrecompose.core.CameraPosition import dev.sargunv.maplibrecompose.expressions.ast.BooleanLiteral import dev.sargunv.maplibrecompose.expressions.ast.ColorLiteral import dev.sargunv.maplibrecompose.expressions.ast.CompiledExpression @@ -31,6 +34,7 @@ import io.github.dellisd.spatialk.geojson.BoundingBox import io.github.dellisd.spatialk.geojson.Position import java.net.URI import java.net.URISyntaxException +import org.maplibre.android.camera.CameraPosition as MLNCameraPosition import org.maplibre.android.geometry.LatLng import org.maplibre.android.geometry.LatLngBounds import org.maplibre.android.style.expressions.Expression as MLNExpression @@ -76,6 +80,44 @@ internal fun BoundingBox.toLatLngBounds(): LatLngBounds = lonWest = southwest.longitude, ) +internal fun MLNCameraPosition.toCameraPosition(density: Density): CameraPosition = + with(density) { + CameraPosition( + target = target?.toPosition() ?: Position(0.0, 0.0), + zoom = zoom, + bearing = bearing, + tilt = tilt, + padding = + padding?.let { + PaddingValues.Absolute( + left = it[0].toInt().toDp(), + top = it[1].toInt().toDp(), + right = it[2].toInt().toDp(), + bottom = it[3].toInt().toDp(), + ) + } ?: PaddingValues.Absolute(0.dp), + ) + } + +internal fun CameraPosition.toMLNCameraPosition( + density: Density, + layoutDir: LayoutDirection, +): MLNCameraPosition = + with(density) { + MLNCameraPosition.Builder() + .target(target.toLatLng()) + .zoom(zoom) + .tilt(tilt) + .bearing(bearing) + .padding( + left = padding.calculateLeftPadding(layoutDir).toPx().toDouble(), + top = padding.calculateTopPadding().toPx().toDouble(), + right = padding.calculateRightPadding(layoutDir).toPx().toDouble(), + bottom = padding.calculateBottomPadding().toPx().toDouble(), + ) + .build() + } + 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 cb65a485c..0145d5999 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 @@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.DpRect import androidx.compose.ui.unit.dp @@ -203,4 +204,30 @@ public class CameraState(firstPosition: CameraPosition) { // TODO at some point, this should be refactored to State, just like the camera position return requireMap().getVisibleRegion() } + + /** + * Takes a snapshot of the map with the specified parameters. Functional on Android and iOS only. + * + * @param width The width of the snapshot in px. + * @param height The height of the snapshot in px. + * @param styleUri The URI of the style to use for the snapshot. + * @param region The bounding box of the region to capture in the snapshot. + * @param cameraPosition The camera position to use for the snapshot. The `CameraPosition.target` + * is overridden by `region` if set in conjunction. + * @param showLogo Whether to show the logo in the snapshot. Defaults to true. + * @return The resulting ImageBitmap of the snapshot. + * @throws IllegalStateException if the map is not initialized yet. See [awaitInitialized]. + * @throws SnapshotException if an error occurs during snapshot generation. + */ + public suspend fun snapshot( + width: Int, + height: Int, + styleUri: String = requireMap().getStyleUri(), + region: BoundingBox? = null, + cameraPosition: CameraPosition? = null, + showLogo: Boolean = true, + ): ImageBitmap = + requireMap() + .getMapSnapshotter() + .snapshot(width, height, styleUri, region, cameraPosition, showLogo) } diff --git a/lib/maplibre-compose/src/commonMain/kotlin/dev/sargunv/maplibrecompose/core/MapSnapshotter.kt b/lib/maplibre-compose/src/commonMain/kotlin/dev/sargunv/maplibrecompose/core/MapSnapshotter.kt new file mode 100644 index 000000000..2e1147c7b --- /dev/null +++ b/lib/maplibre-compose/src/commonMain/kotlin/dev/sargunv/maplibrecompose/core/MapSnapshotter.kt @@ -0,0 +1,15 @@ +package dev.sargunv.maplibrecompose.core + +import androidx.compose.ui.graphics.ImageBitmap +import io.github.dellisd.spatialk.geojson.BoundingBox + +internal interface MapSnapshotter { + suspend fun snapshot( + width: Int, + height: Int, + styleUri: String, + region: BoundingBox?, + cameraPosition: CameraPosition?, + showLogo: Boolean, + ): ImageBitmap +} 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 859534482..484edad45 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 @@ -11,6 +11,8 @@ import io.github.dellisd.spatialk.geojson.Position import kotlin.time.Duration internal interface MaplibreMap { + fun getStyleUri(): String + suspend fun animateCameraPosition(finalPosition: CameraPosition, duration: Duration) suspend fun animateCameraPosition( @@ -177,4 +179,6 @@ internal interface StandardMaplibreMap : MaplibreMap { ): List fun metersPerDpAtLatitude(latitude: Double): Double + + fun getMapSnapshotter(): MapSnapshotter } diff --git a/lib/maplibre-compose/src/commonMain/kotlin/dev/sargunv/maplibrecompose/core/SnapshotException.kt b/lib/maplibre-compose/src/commonMain/kotlin/dev/sargunv/maplibrecompose/core/SnapshotException.kt new file mode 100644 index 000000000..46d60b115 --- /dev/null +++ b/lib/maplibre-compose/src/commonMain/kotlin/dev/sargunv/maplibrecompose/core/SnapshotException.kt @@ -0,0 +1,3 @@ +package dev.sargunv.maplibrecompose.core + +public class SnapshotException(message: String) : Exception(message) 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 467f0f501..ac488a38f 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 @@ -114,6 +114,8 @@ internal class WebviewMap(private val bridge: WebviewBridge) : MaplibreMap { bridge.callVoid("setKeyboardGesturesEnabled", value.isKeyboardGesturesEnabled) } + override fun getStyleUri() = "" // todo + override suspend fun animateCameraPosition(finalPosition: CameraPosition, duration: Duration) {} override suspend fun animateCameraPosition( diff --git a/lib/maplibre-compose/src/iosMain/kotlin/dev/sargunv/maplibrecompose/compose/IosMapView.kt b/lib/maplibre-compose/src/iosMain/kotlin/dev/sargunv/maplibrecompose/compose/IosMapView.kt index 5cb5aa59c..8f36f8979 100644 --- a/lib/maplibre-compose/src/iosMain/kotlin/dev/sargunv/maplibrecompose/compose/IosMapView.kt +++ b/lib/maplibre-compose/src/iosMain/kotlin/dev/sargunv/maplibrecompose/compose/IosMapView.kt @@ -20,6 +20,7 @@ import androidx.compose.ui.viewinterop.UIKitView import co.touchlab.kermit.Logger import cocoapods.MapLibre.MLNMapView import dev.sargunv.maplibrecompose.core.IosMap +import dev.sargunv.maplibrecompose.core.IosMapSnapshotter import dev.sargunv.maplibrecompose.core.MapOptions import dev.sargunv.maplibrecompose.core.MaplibreMap import dev.sargunv.maplibrecompose.core.SafeStyle @@ -86,6 +87,7 @@ internal fun IosMapView( IosMap( mapView = mapView, size = CGSizeMake(width.value.toDouble(), height.value.toDouble()), + mapSnapshotter = IosMapSnapshotter(), layoutDir = layoutDir, density = density, insetPadding = insetPadding, 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 e890614e7..0347e7bf8 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 @@ -7,7 +7,6 @@ import androidx.compose.ui.unit.DpRect import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import co.touchlab.kermit.Logger -import cocoapods.MapLibre.MLNAltitudeForZoomLevel import cocoapods.MapLibre.MLNCameraChangeReason import cocoapods.MapLibre.MLNCameraChangeReasonGestureOneFingerZoom import cocoapods.MapLibre.MLNCameraChangeReasonGesturePan @@ -27,7 +26,6 @@ import cocoapods.MapLibre.MLNLoggingLevelFault import cocoapods.MapLibre.MLNLoggingLevelInfo import cocoapods.MapLibre.MLNLoggingLevelVerbose import cocoapods.MapLibre.MLNLoggingLevelWarning -import cocoapods.MapLibre.MLNMapCamera import cocoapods.MapLibre.MLNMapDebugCollisionBoxesMask import cocoapods.MapLibre.MLNMapDebugTileBoundariesMask import cocoapods.MapLibre.MLNMapDebugTileInfoMask @@ -49,6 +47,7 @@ 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.toMLNMapCamera import dev.sargunv.maplibrecompose.core.util.toMLNOrnamentPosition import dev.sargunv.maplibrecompose.core.util.toNSPredicate import dev.sargunv.maplibrecompose.core.util.toPosition @@ -86,6 +85,7 @@ import platform.darwin.sel_registerName internal class IosMap( private var mapView: MLNMapView, internal var size: CValue, + internal var mapSnapshotter: IosMapSnapshotter, internal var layoutDir: LayoutDirection, internal var density: Density, internal var insetPadding: PaddingValues, @@ -379,22 +379,6 @@ internal class IosMap( mapView.scaleBarMargins = calculateMargins(mapView.scaleBarPosition, value.padding) } - private fun CameraPosition.toMLNMapCamera(): MLNMapCamera { - return MLNMapCamera().let { - it.centerCoordinate = target.toCLLocationCoordinate2D() - it.pitch = tilt - it.heading = bearing - it.altitude = - MLNAltitudeForZoomLevel( - zoomLevel = zoom, - pitch = tilt, - latitude = target.latitude, - size = size, - ) - it - } - } - override fun getCameraPosition(): CameraPosition { return CameraPosition( target = mapView.camera.centerCoordinate.toPosition(), @@ -410,7 +394,7 @@ internal class IosMap( override fun setCameraPosition(cameraPosition: CameraPosition) { mapView.setCamera( - cameraPosition.toMLNMapCamera(), + cameraPosition.toMLNMapCamera(size), withDuration = 0.0, animationTimingFunction = null, edgePadding = cameraPosition.padding.toEdgeInsets(), @@ -426,10 +410,12 @@ internal class IosMap( right = calculateRightPadding(layoutDir).value.toDouble(), ) + override fun getStyleUri() = lastStyleUri + override suspend fun animateCameraPosition(finalPosition: CameraPosition, duration: Duration) = suspendCoroutine { cont -> mapView.flyToCamera( - camera = finalPosition.toMLNMapCamera(), + camera = finalPosition.toMLNMapCamera(size), withDuration = duration.toDouble(DurationUnit.SECONDS), edgePadding = finalPosition.padding.toEdgeInsets(), completionHandler = { cont.resume(Unit) }, @@ -497,4 +483,6 @@ internal class IosMap( .map { (it as MLNFeatureProtocol).toFeature() } override fun metersPerDpAtLatitude(latitude: Double) = mapView.metersPerPointAtLatitude(latitude) + + override fun getMapSnapshotter() = mapSnapshotter } diff --git a/lib/maplibre-compose/src/iosMain/kotlin/dev/sargunv/maplibrecompose/core/IosMapSnapshotter.kt b/lib/maplibre-compose/src/iosMain/kotlin/dev/sargunv/maplibrecompose/core/IosMapSnapshotter.kt new file mode 100644 index 000000000..af87ee276 --- /dev/null +++ b/lib/maplibre-compose/src/iosMain/kotlin/dev/sargunv/maplibrecompose/core/IosMapSnapshotter.kt @@ -0,0 +1,51 @@ +package dev.sargunv.maplibrecompose.core + +import androidx.compose.ui.graphics.ImageBitmap +import cocoapods.MapLibre.MLNMapCamera +import cocoapods.MapLibre.MLNMapSnapshotOptions +import cocoapods.MapLibre.MLNMapSnapshotter +import dev.sargunv.maplibrecompose.core.util.toImageBitmap +import dev.sargunv.maplibrecompose.core.util.toMLNCoordinateBounds +import dev.sargunv.maplibrecompose.core.util.toMLNMapCamera +import io.github.dellisd.spatialk.geojson.BoundingBox +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlinx.coroutines.suspendCancellableCoroutine +import platform.CoreGraphics.CGSizeMake +import platform.Foundation.NSURL + +internal class IosMapSnapshotter : MapSnapshotter { + override suspend fun snapshot( + width: Int, + height: Int, + styleUri: String, + region: BoundingBox?, + cameraPosition: CameraPosition?, + showLogo: Boolean, + ): ImageBitmap { + val size = CGSizeMake(width.toDouble(), height.toDouble()) + val options = + MLNMapSnapshotOptions( + styleURL = NSURL(string = styleUri), + camera = cameraPosition?.toMLNMapCamera(size) ?: MLNMapCamera(), + size = size, + ) + + cameraPosition?.zoom?.let { options.zoomLevel = it } + region?.toMLNCoordinateBounds()?.let { options.coordinateBounds = it } + options.showsLogo = showLogo + + val snapshotter = MLNMapSnapshotter(options) + + return suspendCancellableCoroutine { cont -> + snapshotter.startWithCompletionHandler { snapshot, error -> + if (snapshot != null) { + cont.resume(snapshot.image.toImageBitmap()) + } else { + cont.resumeWithException(SnapshotException(error?.description ?: "Unknown error")) + } + } + cont.invokeOnCancellation { snapshotter.cancel() } + } + } +} 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 1d04e1263..225a97eb5 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 @@ -3,15 +3,18 @@ package dev.sargunv.maplibrecompose.core.util import androidx.compose.ui.Alignment import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.asSkiaBitmap +import androidx.compose.ui.graphics.toComposeImageBitmap import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.DpRect import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp +import cocoapods.MapLibre.MLNAltitudeForZoomLevel import cocoapods.MapLibre.MLNCoordinateBounds import cocoapods.MapLibre.MLNCoordinateBoundsMake import cocoapods.MapLibre.MLNFeatureProtocol +import cocoapods.MapLibre.MLNMapCamera import cocoapods.MapLibre.MLNOrnamentPosition import cocoapods.MapLibre.MLNOrnamentPositionBottomLeft import cocoapods.MapLibre.MLNOrnamentPositionBottomRight @@ -20,6 +23,7 @@ import cocoapods.MapLibre.MLNOrnamentPositionTopRight import cocoapods.MapLibre.MLNShape import cocoapods.MapLibre.expressionWithMLNJSONObject import cocoapods.MapLibre.predicateWithMLNJSONObject +import dev.sargunv.maplibrecompose.core.CameraPosition import dev.sargunv.maplibrecompose.expressions.ast.BooleanLiteral import dev.sargunv.maplibrecompose.expressions.ast.ColorLiteral import dev.sargunv.maplibrecompose.expressions.ast.CompiledExpression @@ -39,6 +43,7 @@ import io.github.dellisd.spatialk.geojson.GeoJson import io.github.dellisd.spatialk.geojson.Position import kotlinx.cinterop.CValue import kotlinx.cinterop.addressOf +import kotlinx.cinterop.get import kotlinx.cinterop.useContents import kotlinx.cinterop.usePinned import kotlinx.serialization.json.JsonArray @@ -46,11 +51,28 @@ import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive +import org.jetbrains.skia.ColorAlphaType +import org.jetbrains.skia.ColorSpace +import org.jetbrains.skia.ColorType import org.jetbrains.skia.Image +import org.jetbrains.skia.ImageInfo +import platform.CoreFoundation.CFDataGetBytePtr +import platform.CoreFoundation.CFDataGetLength +import platform.CoreFoundation.CFRelease +import platform.CoreGraphics.CGColorSpaceCreateDeviceRGB +import platform.CoreGraphics.CGDataProviderCopyData +import platform.CoreGraphics.CGImageAlphaInfo +import platform.CoreGraphics.CGImageCreateCopyWithColorSpace +import platform.CoreGraphics.CGImageGetAlphaInfo +import platform.CoreGraphics.CGImageGetBytesPerRow +import platform.CoreGraphics.CGImageGetDataProvider +import platform.CoreGraphics.CGImageGetHeight +import platform.CoreGraphics.CGImageGetWidth import platform.CoreGraphics.CGPoint import platform.CoreGraphics.CGPointMake import platform.CoreGraphics.CGRect import platform.CoreGraphics.CGRectMake +import platform.CoreGraphics.CGSize import platform.CoreGraphics.CGVectorMake import platform.CoreLocation.CLLocationCoordinate2D import platform.CoreLocation.CLLocationCoordinate2DMake @@ -118,6 +140,22 @@ internal fun BoundingBox.toMLNCoordinateBounds(): CValue = sw = southwest.toCLLocationCoordinate2D(), ) +internal fun CameraPosition.toMLNMapCamera(size: CValue): MLNMapCamera { + return MLNMapCamera().let { + it.centerCoordinate = target.toCLLocationCoordinate2D() + it.pitch = tilt + it.heading = bearing + it.altitude = + MLNAltitudeForZoomLevel( + zoomLevel = zoom, + pitch = tilt, + latitude = target.latitude, + size = size, + ) + it + } +} + internal fun GeoJson.toMLNShape(): MLNShape { return MLNShape.shapeWithData( data = json().encodeToByteArray().toNSData(), @@ -218,3 +256,66 @@ internal fun ImageBitmap.toUIImage(scale: Float, sdf: Boolean) = if (sdf) UIImageRenderingMode.UIImageRenderingModeAlwaysTemplate else UIImageRenderingMode.UIImageRenderingModeAutomatic ) + +internal fun UIImage.toImageBitmap(): ImageBitmap { + val skiaImage = this.toSkiaImage() ?: return ImageBitmap(1, 1) + return skiaImage.toComposeImageBitmap() +} + +private fun UIImage.toSkiaImage(): Image? { + val imageRef = + CGImageCreateCopyWithColorSpace(this.CGImage, CGColorSpaceCreateDeviceRGB()) ?: return null + + val width = CGImageGetWidth(imageRef).toInt() + val height = CGImageGetHeight(imageRef).toInt() + + val bytesPerRow = CGImageGetBytesPerRow(imageRef) + val data = CGDataProviderCopyData(CGImageGetDataProvider(imageRef)) + val bytePointer = CFDataGetBytePtr(data) + val length = CFDataGetLength(data) + val alphaInfo = CGImageGetAlphaInfo(imageRef) + + val alphaType = + when (alphaInfo) { + CGImageAlphaInfo.kCGImageAlphaPremultipliedFirst, + CGImageAlphaInfo.kCGImageAlphaPremultipliedLast -> ColorAlphaType.PREMUL + CGImageAlphaInfo.kCGImageAlphaFirst, + CGImageAlphaInfo.kCGImageAlphaLast -> ColorAlphaType.UNPREMUL + CGImageAlphaInfo.kCGImageAlphaNone, + CGImageAlphaInfo.kCGImageAlphaNoneSkipFirst, + CGImageAlphaInfo.kCGImageAlphaNoneSkipLast -> ColorAlphaType.OPAQUE + else -> ColorAlphaType.UNKNOWN + } + + val byteArray = ByteArray(length.toInt()) { index -> bytePointer!![index].toByte() } + + CFRelease(data) + CFRelease(imageRef) + + val skiaColorSpace = ColorSpace.sRGB + val colorType = ColorType.RGBA_8888 + + // Convert RGBA to BGRA + for (i in byteArray.indices step 4) { + val r = byteArray[i] + val g = byteArray[i + 1] + val b = byteArray[i + 2] + val a = byteArray[i + 3] + + byteArray[i] = b + byteArray[i + 2] = r + } + + return Image.makeRaster( + imageInfo = + ImageInfo( + width = width, + height = height, + colorType = colorType, + alphaType = alphaType, + colorSpace = skiaColorSpace, + ), + bytes = byteArray, + rowBytes = bytesPerRow.toInt(), + ) +} 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 929caffa0..081272d69 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 @@ -242,6 +242,8 @@ internal class JsMap( ) } + override fun getStyleUri() = lastStyleUri + override suspend fun animateCameraPosition(finalPosition: CameraPosition, duration: Duration) { impl.easeTo( EaseToOptions( @@ -322,4 +324,6 @@ internal class JsMap( val point = impl.project(LngLat(impl.getCenter().lng, latitude)) return impl.unproject(point).distanceTo(impl.unproject(Point(point.x + 1, point.y))) } + + override fun getMapSnapshotter() = JsMapSnapshotter() } diff --git a/lib/maplibre-compose/src/jsMain/kotlin/dev/sargunv/maplibrecompose/core/JsMapSnapshotter.kt b/lib/maplibre-compose/src/jsMain/kotlin/dev/sargunv/maplibrecompose/core/JsMapSnapshotter.kt new file mode 100644 index 000000000..b78821bbc --- /dev/null +++ b/lib/maplibre-compose/src/jsMain/kotlin/dev/sargunv/maplibrecompose/core/JsMapSnapshotter.kt @@ -0,0 +1,18 @@ +package dev.sargunv.maplibrecompose.core + +import androidx.compose.ui.graphics.ImageBitmap +import io.github.dellisd.spatialk.geojson.BoundingBox + +internal class JsMapSnapshotter : MapSnapshotter { + override suspend fun snapshot( + width: Int, + height: Int, + styleUri: String, + region: BoundingBox?, + cameraPosition: CameraPosition?, + showLogo: Boolean, + ): ImageBitmap { + // missing feature in MapLibre GL JS + throw SnapshotException("Not supported") + } +}