diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/StaticMapView.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/StaticMapView.kt index 7d7752ba62..0657bae634 100644 --- a/features/location/api/src/main/kotlin/io/element/android/features/location/api/StaticMapView.kt +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/StaticMapView.kt @@ -19,7 +19,6 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity @@ -32,12 +31,10 @@ import io.element.android.compound.theme.ElementTheme import io.element.android.features.location.api.internal.StaticMapPlaceholder import io.element.android.features.location.api.internal.StaticMapUrlBuilder import io.element.android.features.location.api.internal.centerBottomEdge -import io.element.android.libraries.designsystem.components.LocationPinMarker +import io.element.android.libraries.designsystem.components.LocationPin import io.element.android.libraries.designsystem.components.PinVariant import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight -import io.element.android.libraries.designsystem.theme.components.Icon -import io.element.android.libraries.designsystem.utils.CommonDrawables /** * Shows a static map image downloaded via a third party service's static maps API. @@ -98,7 +95,7 @@ fun StaticMapView( // We apply ContentScale.Fit to scale the image to fill the AsyncImage should this be the case. contentScale = ContentScale.Fit, ) - LocationPinMarker(variant = pinVariant, modifier = Modifier.centerBottomEdge(this)) + LocationPin(variant = pinVariant, modifier = Modifier.centerBottomEdge(this)) } else { StaticMapPlaceholder( showProgress = collectedState.value.isLoading(), diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationPinMarkers.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationPinMarkers.kt new file mode 100644 index 0000000000..ad3a4c48e1 --- /dev/null +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationPinMarkers.kt @@ -0,0 +1,188 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.location.impl.common.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import io.element.android.compound.theme.ElementTheme +import io.element.android.features.location.api.Location +import io.element.android.libraries.designsystem.components.LocationPin +import io.element.android.libraries.designsystem.components.PinVariant +import kotlinx.serialization.json.JsonPrimitive +import org.maplibre.compose.expressions.dsl.and +import org.maplibre.compose.expressions.dsl.asNumber +import org.maplibre.compose.expressions.dsl.asString +import org.maplibre.compose.expressions.dsl.const +import org.maplibre.compose.expressions.dsl.eq +import org.maplibre.compose.expressions.dsl.feature +import org.maplibre.compose.expressions.dsl.image +import org.maplibre.compose.expressions.dsl.not +import org.maplibre.compose.expressions.dsl.step +import org.maplibre.compose.expressions.value.SymbolAnchor +import org.maplibre.compose.expressions.value.SymbolPlacement +import org.maplibre.compose.layers.CircleLayer +import org.maplibre.compose.layers.SymbolLayer +import org.maplibre.compose.sources.GeoJsonData +import org.maplibre.compose.sources.GeoJsonOptions +import org.maplibre.compose.sources.GeoJsonSource +import org.maplibre.compose.sources.rememberGeoJsonSource +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.geojson.Position +import org.maplibre.spatialk.geojson.toJson + +/** + * Data class representing a marker on the map. + * + * @param id Unique identifier for the marker + * @param location The geographic location of the marker + * @param variant The visual variant of the pin (user location, pinned, stale) + */ +data class LocationMarkerData( + val id: String, + val location: Location, + val variant: PinVariant, +) + +/** + * A composable that renders location markers on a MapLibre map with clustering support. + * + * Uses GeoJSON source with clustering enabled to group nearby markers. + * Individual markers are rendered using [LocationPin] composable converted to bitmaps. + * Clusters are rendered as circles with point counts. + * + * Must be used within a MaplibreMap content block. + * + * @param markers List of markers to display on the map + * @param clusterRadius Radius of each cluster when clustering points (default 50) + * @param clusterMaxZoom Maximum zoom level at which to cluster points (default 14) + * @param onMarkerClick Callback when a marker is clicked + * @param onClusterClick Callback when a cluster is clicked, provides cluster center position + */ +@Composable +fun LocationPinMarkers( + markers: List, + onMarkerClick: ((LocationMarkerData) -> Unit)? = null, + onClusterClick: ((Position) -> Unit)? = null, +) { + if (markers.isEmpty()) return + val clusterColor = ElementTheme.colors.bgAccentRest + val clusterStrokeColor = ElementTheme.colors.iconOnSolidPrimary + val clusterTextColor = ElementTheme.colors.textOnSolidPrimary + val clusterTextStyle = ElementTheme.typography.fontBodyMdMedium + + // Convert markers to GeoJSON + val geoJsonString = remember(markers) { + val features = markers.map { marker -> + Feature( + id = JsonPrimitive(marker.id), + geometry = Point(Position(marker.location.lon, marker.location.lat)), + properties = mapOf( + "id" to JsonPrimitive(marker.id), + ) + ) + } + FeatureCollection(features).toJson() + } + + // Create GeoJSON source with clustering + val markersSource = rememberGeoJsonSource( + data = GeoJsonData.JsonString(geoJsonString), + options = GeoJsonOptions( + cluster = true, + clusterMinPoints = 3, + clusterRadius = 30 + ), + ) + + // Cluster circle layer + CircleLayer( + id = "cluster-circles", + source = markersSource, + filter = feature.has("point_count"), + color = const(clusterColor), + radius = const(24.dp), + strokeWidth = const(1.dp), + strokeColor = const(clusterStrokeColor), + onClick = { features -> + features.firstOrNull()?.let { feat -> + val point = feat.geometry as? Point + if (point != null && onClusterClick != null) { + onClusterClick(point.coordinates) + ClickResult.Consume + } else { + ClickResult.Pass + } + } ?: ClickResult.Pass + }, + ) + + // Cluster count text layer + SymbolLayer( + id = "cluster-count", + source = markersSource, + filter = feature.has("point_count"), + textField = feature["point_count_abbreviated"].asString(), + textColor = const(clusterTextColor), + textSize = const(clusterTextStyle.fontSize), + textFont = const(listOfNotNull(clusterTextStyle.fontFamily?.toString())), + textLetterSpacing = const(clusterTextStyle.letterSpacing), + ) + + // Individual marker layers - one per marker for unique avatars + markers.forEach { marker -> + LocationPinMarkerLayer( + marker = marker, + source = markersSource, + onMarkerClick = onMarkerClick, + ) + } +} + +@Composable +private fun LocationPinMarkerLayer( + marker: LocationMarkerData, + source: GeoJsonSource, + onMarkerClick: ((LocationMarkerData) -> Unit)?, +) { + val imageBitmap = rememberLocationPinImage(marker.variant) + SymbolLayer( + id = "pin-marker-${marker.id}", + source = source, + filter = !feature.has("point_count") and (feature["id"].asString() eq const(marker.id)), + iconImage = image(imageBitmap), + iconAnchor = const(SymbolAnchor.Bottom), + iconAllowOverlap = const(true), + onClick = { features -> + if (features.isNotEmpty() && onMarkerClick != null) { + onMarkerClick(marker) + ClickResult.Consume + } else { + ClickResult.Pass + } + }, + ) +} + +/** + * Renders a LocationPin composable to an ImageBitmap for use in SymbolLayer. + */ +@Composable +private fun rememberLocationPinImage(variant: PinVariant): ImageBitmap { + val bitmap = rememberMarkerBitmap(variant) { + LocationPin(variant = variant) + } + return bitmap.asImageBitmap() +} diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/MapProjected.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/MapProjected.kt deleted file mode 100644 index 503e6ea3d7..0000000000 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/MapProjected.kt +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Copyright (c) 2026 Element Creations Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.features.location.impl.common.ui - -import androidx.compose.foundation.layout.Box -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.graphicsLayer -import org.maplibre.compose.camera.CameraState -import org.maplibre.spatialk.geojson.Position - -@Composable -fun MapProjected( - target: Position, - cameraState: CameraState, - modifier: Modifier = Modifier, - content: @Composable () -> Unit, -) { - Box( - modifier = modifier - .graphicsLayer { - cameraState.position - val offset = cameraState.projection?.screenLocationFromPosition(target) - if (offset != null) { - translationX = offset.x.toPx() - size.width / 2 - translationY = offset.y.toPx() - size.height - } - } - ) { - content() - } -} diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/RememberMarkerBitmap.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/RememberMarkerBitmap.kt new file mode 100644 index 0000000000..6c083e2a45 --- /dev/null +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/RememberMarkerBitmap.kt @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.location.impl.common.ui + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.os.Build +import android.view.View +import android.view.ViewGroup +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionContext +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCompositionContext +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalView +import androidx.core.graphics.createBitmap + +/** + * Renders a composable [content] to an Android [Bitmap]. + * Useful for MapLibre SymbolLayer rendering. + * + * Uses a temporary ComposeView to render off-screen without + * adding to the visible composition tree. + * + * Note: This function provides a software-only ImageLoader to avoid + * "Software rendering doesn't support hardware bitmaps" errors when + * rendering Coil images to a Canvas. + * + * @param keys to trigger recomposition. + * @return The rendered Android [Bitmap]. + */ +@Composable +fun rememberMarkerBitmap( + vararg keys: Any, + content: @Composable () -> Unit, +): Bitmap { + val parent = LocalView.current as ViewGroup + val compositionContext = rememberCompositionContext() + return remember(parent, compositionContext, *keys) { + renderComposableToBitmap(parent, compositionContext, content) + } +} + +private fun renderComposableToBitmap( + parent: ViewGroup, + compositionContext: CompositionContext, + content: @Composable () -> Unit, +): Bitmap { + val composeView = ComposeView(parent.context).apply { + setParentCompositionContext(compositionContext) + setContent(content) + } + // Temporarily add to parent for measurement + parent.addView( + composeView, + ViewGroup.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) + ) + // Measure + composeView.measure( + View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED), + View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED) + ) + + val width = composeView.measuredWidth + val height = composeView.measuredHeight + + // Layout + composeView.layout(0, 0, width, height) + + // Draw to bitmap + val bitmap = createBitmap(width, height) + val canvas = Canvas(bitmap) + composeView.draw(canvas) + + // Cleanup + parent.removeView(composeView) + + return bitmap +} diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt index 02b209ae43..a0705221c0 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt @@ -40,7 +40,7 @@ import io.element.android.features.location.impl.common.PermissionRationaleDialo import io.element.android.features.location.impl.common.ui.LocationFloatingActionButton import io.element.android.features.location.impl.common.ui.MapBottomSheetScaffold import io.element.android.features.location.impl.common.ui.UserLocationPuck -import io.element.android.libraries.designsystem.components.LocationPinMarker +import io.element.android.libraries.designsystem.components.LocationPin import io.element.android.libraries.designsystem.components.PinVariant import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.button.BackButton @@ -160,7 +160,7 @@ fun ShareLocationView( } else { PinVariant.PinnedLocation } - LocationPinMarker( + LocationPin( variant = variant, modifier = Modifier.centerBottomEdge(this), ) diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationView.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationView.kt index 69ac04e0c5..d1a3537d7a 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationView.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationView.kt @@ -8,8 +8,6 @@ package io.element.android.features.location.impl.show -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.SheetValue @@ -17,25 +15,23 @@ import androidx.compose.material3.rememberBottomSheetScaffoldState import androidx.compose.material3.rememberStandardBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp -import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.CompoundIcons -import io.element.android.compound.tokens.generated.TypographyTokens +import io.element.android.features.location.api.Location import io.element.android.features.location.api.ShowLocationMode import io.element.android.features.location.impl.common.MapDefaults import io.element.android.features.location.impl.common.PermissionDeniedDialog import io.element.android.features.location.impl.common.PermissionRationaleDialog import io.element.android.features.location.impl.common.ui.LocationFloatingActionButton +import io.element.android.features.location.impl.common.ui.LocationMarkerData +import io.element.android.features.location.impl.common.ui.LocationPinMarkers import io.element.android.features.location.impl.common.ui.MapBottomSheetScaffold -import io.element.android.features.location.impl.common.ui.MapProjected import io.element.android.features.location.impl.common.ui.UserLocationPuck -import io.element.android.libraries.designsystem.components.LocationPinMarker import io.element.android.libraries.designsystem.components.PinVariant import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarSize @@ -44,7 +40,6 @@ import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.IconButton -import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.matrix.api.room.location.AssetType import io.element.android.libraries.ui.strings.CommonStrings @@ -56,6 +51,10 @@ import org.maplibre.compose.location.rememberDefaultLocationProvider import org.maplibre.compose.location.rememberNullLocationProvider import org.maplibre.compose.location.rememberUserLocationState import org.maplibre.spatialk.geojson.Position +import kotlin.math.cos +import kotlin.math.sin +import kotlin.math.sqrt +import kotlin.random.Random import kotlin.time.Duration.Companion.minutes @OptIn(ExperimentalMaterial3Api::class) @@ -138,8 +137,6 @@ fun ShowLocationView( locationState = userLocationState, trackUserLocation = state.isTrackMyLocation ) - }, - overlayContent = { when (val mode = state.mode) { is ShowLocationMode.Static -> { val pinVariant = if (mode.assetType == AssetType.PIN) { @@ -147,23 +144,64 @@ fun ShowLocationView( } else { PinVariant.UserLocation( avatarData = AvatarData(mode.senderId.value, mode.senderName, mode.senderAvatarUrl, AvatarSize.UserListItem), - isLive = false + isLive = true ) } - val position = Position( - latitude = mode.location.lat, - longitude = mode.location.lon - ) - MapProjected(target = position, cameraState = cameraState) { - LocationPinMarker(variant = pinVariant) + // Generate test markers around the original location + val testMarkers = remember { + buildList { + // Add the original marker + add( + LocationMarkerData( + id = "original", + location = mode.location, + variant = pinVariant + ) + ) + // Generate 10 random points within 50 meters + val radiusInMeters = 50.0 + val metersPerDegreeLat = 111_320.0 + val metersPerDegreeLon = 111_320.0 * cos(Math.toRadians(mode.location.lat)) + val variants = listOf( + PinVariant.StaleLocation, + PinVariant.UserLocation(AvatarData("@alice", "Alice", null, AvatarSize.TimelineSender), isLive = true), + PinVariant.UserLocation(AvatarData("@bob", "Bob", null, AvatarSize.TimelineSender), isLive = true), + PinVariant.UserLocation(AvatarData("@cassy", "Cassy", null, AvatarSize.TimelineSender), isLive = true), + PinVariant.UserLocation(AvatarData("@daisy", "Daisy", null, AvatarSize.TimelineSender), isLive = true), + PinVariant.UserLocation(AvatarData("@en", "G", null, AvatarSize.TimelineSender), isLive = true), + PinVariant.UserLocation(AvatarData("@f", "H", null, AvatarSize.TimelineSender), isLive = true), + PinVariant.UserLocation(AvatarData("@g", "I", null, AvatarSize.TimelineSender), isLive = true), + PinVariant.UserLocation(AvatarData("@h", "J", null, AvatarSize.TimelineSender), isLive = true), + PinVariant.UserLocation(AvatarData("@i", "K", null, AvatarSize.TimelineSender), isLive = true), + ) + repeat(10) { index -> + // Random point in a circle using sqrt for uniform distribution + val angle = Random.nextDouble() * 2 * Math.PI + val distance = sqrt(Random.nextDouble()) * radiusInMeters + val latOffset = (distance * cos(angle)) / metersPerDegreeLat + val lonOffset = (distance * sin(angle)) / metersPerDegreeLon + add( + LocationMarkerData( + id = "test_$index", + location = Location( + lat = mode.location.lat + latOffset, + lon = mode.location.lon + lonOffset + ), + variant = variants[index % (variants.size-1)] + ) + ) + } + } } + LocationPinMarkers(testMarkers) } ShowLocationMode.Live -> { // TODO: Show pins for all active live location sharers } } - + }, + overlayContent = { LocationFloatingActionButton( isMapCenteredOnUser = state.isTrackMyLocation, onClick = { state.eventSink(ShowLocationEvents.TrackMyLocation(true)) }, diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/LocationPinMarker.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/LocationPin.kt similarity index 96% rename from libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/LocationPinMarker.kt rename to libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/LocationPin.kt index e0550911f8..60bccc1228 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/LocationPinMarker.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/LocationPin.kt @@ -53,12 +53,12 @@ private val DOT_RADIUS = 6.dp private val CONTENT_OFFSET = 5.dp /** - * A location pin marker composable that supports multiple variants. + * A location pin composable that supports multiple variants. * * Based on Figma design: https://www.figma.com/design/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?node-id=4665-2890&m=dev */ @Composable -fun LocationPinMarker( +fun LocationPin( variant: PinVariant, modifier: Modifier = Modifier, ) { @@ -195,7 +195,7 @@ private fun DrawScope.drawPinShape( @PreviewsDayNight @Composable -internal fun LocationPinMarkerPreview() = ElementPreview { +internal fun LocationPinPreview() = ElementPreview { val sampleAvatarData = AvatarData( id = "@alice:matrix.org", name = "Alice", @@ -208,18 +208,18 @@ internal fun LocationPinMarkerPreview() = ElementPreview { horizontalAlignment = Alignment.CenterHorizontally, ) { Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { - LocationPinMarker( + LocationPin( variant = PinVariant.UserLocation(avatarData = sampleAvatarData, isLive = false), ) - LocationPinMarker( + LocationPin( variant = PinVariant.UserLocation(avatarData = sampleAvatarData, isLive = true), ) } Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { - LocationPinMarker( + LocationPin( variant = PinVariant.PinnedLocation, ) - LocationPinMarker( + LocationPin( variant = PinVariant.StaleLocation, ) }