diff --git a/.gitignore b/.gitignore index ae20dee..7880ee5 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,4 @@ xcuserdata **/Pods/ sample/kcef-bundle/ kmp-maps/core/bin +.claude diff --git a/kmp-maps/core/src/androidMain/kotlin/com/swmansion/kmpmaps/core/Extensions.kt b/kmp-maps/core/src/androidMain/kotlin/com/swmansion/kmpmaps/core/Extensions.kt index 41b4e4c..c362239 100644 --- a/kmp-maps/core/src/androidMain/kotlin/com/swmansion/kmpmaps/core/Extensions.kt +++ b/kmp-maps/core/src/androidMain/kotlin/com/swmansion/kmpmaps/core/Extensions.kt @@ -9,12 +9,14 @@ import androidx.core.graphics.toColorInt import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.PermissionState import com.google.accompanist.permissions.isGranted +import com.google.android.gms.maps.CameraUpdateFactory import com.google.android.gms.maps.GoogleMap import com.google.android.gms.maps.model.CameraPosition as GoogleCameraPosition import com.google.android.gms.maps.model.Dash import com.google.android.gms.maps.model.Dot import com.google.android.gms.maps.model.Gap import com.google.android.gms.maps.model.LatLng +import com.google.android.gms.maps.model.LatLngBounds import com.google.android.gms.maps.model.MapStyleOptions import com.google.android.gms.maps.model.PatternItem import com.google.maps.android.clustering.Cluster as GoogleMapCluster @@ -35,27 +37,75 @@ import com.google.maps.android.data.geojson.GeoJsonPolygonStyle import org.json.JSONObject /** - * Converts CameraPosition to Google Maps CameraPosition. + * Converts [MapBounds] to Google Maps [LatLngBounds]. * - * @return GoogleCameraPosition with coordinates, zoom, bearing, and tilt + * @return [LatLngBounds] with the original southwest and northeast corners intact */ -internal fun CameraPosition.toGoogleMapsCameraPosition() = - GoogleCameraPosition.Builder() - .target(LatLng(coordinates.latitude, coordinates.longitude)) - .zoom(zoom) +internal fun MapBounds.toLatLngBounds() = + LatLngBounds( + LatLng(southwest.latitude, southwest.longitude), + LatLng(northeast.latitude, northeast.longitude), + ) + +/** + * Converts [CameraPosition] to [GoogleCameraPosition]. + * + * When [CameraPosition.bounds] is set and viewport dimensions are provided, computes the zoom level + * to fit the bounds using the Mercator projection formula. Otherwise, uses [CameraPosition.zoom]. + * + * @param viewportWidthPx Map viewport width in pixels (used to compute zoom for bounds) + * @param viewportHeightPx Map viewport height in pixels (used to compute zoom for bounds) + * @return [GoogleCameraPosition] with coordinates, zoom, bearing, and tilt + */ +internal fun CameraPosition.toGoogleMapsCameraPosition( + viewportWidthPx: Int = 0, + viewportHeightPx: Int = 0, +): GoogleCameraPosition { + val target = + bounds?.toLatLngBounds()?.center ?: LatLng(coordinates.latitude, coordinates.longitude) + val computedZoom = + if (bounds != null && viewportWidthPx > 0 && viewportHeightPx > 0) { + calculateZoomFromViewport(viewportWidthPx, viewportHeightPx, bounds) + } else { + zoom + } + return GoogleCameraPosition.Builder() + .target(target) + .zoom(computedZoom) .bearing(androidCameraPosition?.bearing ?: 0f) .tilt(androidCameraPosition?.tilt ?: 0f) .build() +} + +/** + * Fits bounds if available; otherwise uses coordinates and zoom. + * + * @param padding Bounds padding (px). + */ +internal fun CameraPosition.toCameraUpdate(padding: Int = 0) = + if (bounds != null) { + CameraUpdateFactory.newLatLngBounds(bounds.toLatLngBounds(), padding) + } else { + CameraUpdateFactory.newCameraPosition(toGoogleMapsCameraPosition()) + } /** * Converts Google Maps CameraPosition back to CameraPosition. * - * @return CameraPosition with coordinates, zoom, bearing, and tilt + * @param latLngBounds Optional visible region bounds to include in the result + * @return CameraPosition with coordinates, zoom, bearing, tilt, and optional bounds */ -internal fun GoogleCameraPosition.toCameraPosition() = +internal fun GoogleCameraPosition.toCameraPosition(latLngBounds: LatLngBounds? = null) = CameraPosition( coordinates = Coordinates(latitude = target.latitude, longitude = target.longitude), zoom = zoom, + bounds = + latLngBounds?.let { + MapBounds( + northeast = Coordinates(it.northeast.latitude, it.northeast.longitude), + southwest = Coordinates(it.southwest.latitude, it.southwest.longitude), + ) + }, androidCameraPosition = AndroidCameraPosition(bearing = bearing, tilt = tilt), ) diff --git a/kmp-maps/core/src/androidMain/kotlin/com/swmansion/kmpmaps/core/Map.kt b/kmp-maps/core/src/androidMain/kotlin/com/swmansion/kmpmaps/core/Map.kt index f8a233b..9eafc90 100644 --- a/kmp-maps/core/src/androidMain/kotlin/com/swmansion/kmpmaps/core/Map.kt +++ b/kmp-maps/core/src/androidMain/kotlin/com/swmansion/kmpmaps/core/Map.kt @@ -2,6 +2,7 @@ package com.swmansion.kmpmaps.core import android.Manifest import android.util.Log +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect @@ -14,10 +15,10 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalDensity import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.rememberPermissionState -import com.google.android.gms.maps.CameraUpdateFactory import com.google.maps.android.compose.Circle import com.google.maps.android.compose.GoogleMap import com.google.maps.android.compose.MapEffect @@ -61,9 +62,6 @@ public actual fun Map( ) { var mapLoaded by remember { mutableStateOf(false) } val locationPermissionState = rememberPermissionState(Manifest.permission.ACCESS_FINE_LOCATION) - val cameraPositionState = rememberCameraPositionState { - cameraPosition?.let { position = it.toGoogleMapsCameraPosition() } - } LaunchedEffect(properties.isMyLocationEnabled) { if (properties.isMyLocationEnabled && !locationPermissionState.status.isGranted) { @@ -71,224 +69,241 @@ public actual fun Map( } } - LaunchedEffect(cameraPosition, mapLoaded) { - if (mapLoaded && cameraPosition != null) { - cameraPositionState.move( - CameraUpdateFactory.newCameraPosition(cameraPosition.toGoogleMapsCameraPosition()) - ) - } - } + BoxWithConstraints(modifier = modifier.fillMaxSize()) { + val density = LocalDensity.current + val viewportWidthPx = with(density) { maxWidth.roundToPx() } + val viewportHeightPx = with(density) { maxHeight.roundToPx() } - GoogleMap( - mapColorScheme = properties.mapTheme.toGoogleMapsTheme(), - modifier = modifier.fillMaxSize(), - cameraPositionState = cameraPositionState, - properties = properties.toGoogleMapsProperties(locationPermissionState), - uiSettings = uiSettings.toGoogleMapsUiSettings(), - onMapClick = - onMapClick?.let { callback -> - { latLng -> callback(Coordinates(latLng.latitude, latLng.longitude)) } - }, - onMapLongClick = - onMapLongClick?.let { callback -> - { latLng -> callback(Coordinates(latLng.latitude, latLng.longitude)) } - }, - onPOIClick = - onPOIClick?.let { callback -> - { poi -> callback(Coordinates(poi.latLng.latitude, poi.latLng.longitude)) } - }, - onMapLoaded = { - mapLoaded = true - onMapLoaded?.invoke() - }, - ) { - MapEffect(properties.contentPadding) { map -> - properties.contentPadding?.run { - map.setPadding(start.toInt(), top.toInt(), end.toInt(), bottom.toInt()) + val cameraPositionState = rememberCameraPositionState { + cameraPosition?.let { + position = it.toGoogleMapsCameraPosition(viewportWidthPx, viewportHeightPx) } } - var androidGeoJsonLayers by remember { - mutableStateOf>(emptyMap()) + LaunchedEffect(cameraPosition, mapLoaded) { + if (mapLoaded && cameraPosition != null) { + cameraPositionState.move(cameraPosition.toCameraUpdate()) + } } - var geoJsonExtractedMarkers by remember { - mutableStateOf>>(emptyMap()) - } + GoogleMap( + mapColorScheme = properties.mapTheme.toGoogleMapsTheme(), + modifier = modifier.fillMaxSize(), + cameraPositionState = cameraPositionState, + properties = properties.toGoogleMapsProperties(locationPermissionState), + uiSettings = uiSettings.toGoogleMapsUiSettings(), + onMapClick = + onMapClick?.let { callback -> + { latLng -> callback(Coordinates(latLng.latitude, latLng.longitude)) } + }, + onMapLongClick = + onMapLongClick?.let { callback -> + { latLng -> callback(Coordinates(latLng.latitude, latLng.longitude)) } + }, + onPOIClick = + onPOIClick?.let { callback -> + { poi -> callback(Coordinates(poi.latLng.latitude, poi.latLng.longitude)) } + }, + onMapLoaded = { + mapLoaded = true + onMapLoaded?.invoke() + }, + ) { + MapEffect(properties.contentPadding) { map -> + properties.contentPadding?.run { + map.setPadding(start.toInt(), top.toInt(), end.toInt(), bottom.toInt()) + } + } + + var androidGeoJsonLayers by remember { + mutableStateOf>(emptyMap()) + } - MapEffect(geoJsonLayers) { map -> - runCatching { - val desiredKeys = geoJsonLayers.indices.toSet() - val keysToRemove = androidGeoJsonLayers.keys - desiredKeys - keysToRemove.forEach { k -> androidGeoJsonLayers[k]?.removeLayerFromMap() } + var geoJsonExtractedMarkers by remember { + mutableStateOf>>(emptyMap()) + } - androidGeoJsonLayers = androidGeoJsonLayers.filterKeys(desiredKeys::contains) - geoJsonExtractedMarkers = - geoJsonExtractedMarkers.filterKeys(desiredKeys::contains) + MapEffect(geoJsonLayers) { map -> + runCatching { + val desiredKeys = geoJsonLayers.indices.toSet() + val keysToRemove = androidGeoJsonLayers.keys - desiredKeys + keysToRemove.forEach { k -> androidGeoJsonLayers[k]?.removeLayerFromMap() } - geoJsonLayers.forEachIndexed { index, geo -> - if (geo.visible == false) { - androidGeoJsonLayers[index]?.removeLayerFromMap() - androidGeoJsonLayers = androidGeoJsonLayers - index - geoJsonExtractedMarkers = geoJsonExtractedMarkers - index - return@forEachIndexed - } + androidGeoJsonLayers = + androidGeoJsonLayers.filterKeys(desiredKeys::contains) + geoJsonExtractedMarkers = + geoJsonExtractedMarkers.filterKeys(desiredKeys::contains) - androidGeoJsonLayers[index]?.removeLayerFromMap() + geoJsonLayers.forEachIndexed { index, geo -> + if (geo.visible == false) { + androidGeoJsonLayers[index]?.removeLayerFromMap() + androidGeoJsonLayers = androidGeoJsonLayers - index + geoJsonExtractedMarkers = geoJsonExtractedMarkers - index + return@forEachIndexed + } + + androidGeoJsonLayers[index]?.removeLayerFromMap() - map.renderGeoJsonLayer(geo, clusterSettings, onMarkerClick)?.let { - androidGeoJsonLayers = androidGeoJsonLayers + (index to it.layer) - geoJsonExtractedMarkers = - geoJsonExtractedMarkers + (index to it.extractedMarkers) + map.renderGeoJsonLayer(geo, clusterSettings, onMarkerClick)?.let { + androidGeoJsonLayers = androidGeoJsonLayers + (index to it.layer) + geoJsonExtractedMarkers = + geoJsonExtractedMarkers + (index to it.extractedMarkers) + } } } - } - .onFailure { t -> Log.e("KMPMaps", "Failed to render GeoJSON layers", t) } - } - - DisposableEffect(Unit) { - onDispose { androidGeoJsonLayers.values.forEach(Layer::removeLayerFromMap) } - } + .onFailure { t -> Log.e("KMPMaps", "Failed to render GeoJSON layers", t) } + } - if (clusterSettings.enabled) { - val clusterItems = - remember(markers, geoJsonExtractedMarkers) { - (markers + geoJsonExtractedMarkers.values.flatten()).map(::MarkerClusterItem) - } + DisposableEffect(Unit) { + onDispose { androidGeoJsonLayers.values.forEach(Layer::removeLayerFromMap) } + } - Clustering( - items = clusterItems, - onClusterClick = { androidCluster -> - clusterSettings.onClusterClick?.invoke(androidCluster.toNativeCluster()) - ?: false - }, - onClusterItemClick = { clusterItem -> - onMarkerClick?.invoke(clusterItem.marker) - onMarkerClick == null - }, - clusterContent = { androidCluster -> - if (clusterSettings.clusterContent != null) { - clusterSettings.clusterContent.invoke(androidCluster.toNativeCluster()) - } else { - DefaultCluster(size = androidCluster.size) + if (clusterSettings.enabled) { + val clusterItems = + remember(markers, geoJsonExtractedMarkers) { + (markers + geoJsonExtractedMarkers.values.flatten()).map( + ::MarkerClusterItem + ) } - }, - clusterItemContent = { clusterItem -> - customMarkerContent[clusterItem.marker.contentId]?.invoke(clusterItem.marker) - ?: DefaultPin(clusterItem.marker) - }, - ) - } else { - markers.forEach { marker -> - key(marker.getId(), marker.contentId) { - val markerState = - remember(marker.getId()) { - MarkerState(marker.coordinates.toGoogleMapsLatLng()) + + Clustering( + items = clusterItems, + onClusterClick = { androidCluster -> + clusterSettings.onClusterClick?.invoke(androidCluster.toNativeCluster()) + ?: false + }, + onClusterItemClick = { clusterItem -> + onMarkerClick?.invoke(clusterItem.marker) + onMarkerClick == null + }, + clusterContent = { androidCluster -> + if (clusterSettings.clusterContent != null) { + clusterSettings.clusterContent.invoke(androidCluster.toNativeCluster()) + } else { + DefaultCluster(size = androidCluster.size) } + }, + clusterItemContent = { clusterItem -> + customMarkerContent[clusterItem.marker.contentId]?.invoke( + clusterItem.marker + ) ?: DefaultPin(clusterItem.marker) + }, + ) + } else { + markers.forEach { marker -> + key(marker.getId(), marker.contentId) { + val markerState = + remember(marker.getId()) { + MarkerState(marker.coordinates.toGoogleMapsLatLng()) + } - LaunchedEffect(marker.coordinates) { - val newLatLng = marker.coordinates.toGoogleMapsLatLng() - if (markerState.position != newLatLng) { - markerState.position = newLatLng + LaunchedEffect(marker.coordinates) { + val newLatLng = marker.coordinates.toGoogleMapsLatLng() + if (markerState.position != newLatLng) { + markerState.position = newLatLng + } } - } - val content = customMarkerContent[marker.contentId] + val content = customMarkerContent[marker.contentId] - if (marker.androidMarkerOptions.draggable) { - LaunchedEffect(markerState.isDragging) { - if (!markerState.isDragging) { - marker.coordinates = markerState.position.toCoordinates() - onMarkerDragEnd?.invoke(marker) + if (marker.androidMarkerOptions.draggable) { + LaunchedEffect(markerState.isDragging) { + if (!markerState.isDragging) { + marker.coordinates = markerState.position.toCoordinates() + onMarkerDragEnd?.invoke(marker) + } } } - } - if (content != null) { - MarkerComposable( - state = markerState, - title = marker.title, - anchor = marker.androidMarkerOptions.anchor.toOffset(), - draggable = marker.androidMarkerOptions.draggable, - snippet = marker.androidMarkerOptions.snippet, - zIndex = marker.androidMarkerOptions.zIndex ?: 0.0f, - onClick = { - onMarkerClick?.invoke(marker) - onMarkerClick == null - }, - content = { content(marker) }, - ) - } else { - Marker( - state = markerState, - title = marker.title, - anchor = marker.androidMarkerOptions.anchor.toOffset(), - draggable = marker.androidMarkerOptions.draggable, - snippet = marker.androidMarkerOptions.snippet, - zIndex = marker.androidMarkerOptions.zIndex ?: 0.0f, - onClick = { - onMarkerClick?.invoke(marker) - onMarkerClick == null - }, - ) + if (content != null) { + MarkerComposable( + state = markerState, + title = marker.title, + anchor = marker.androidMarkerOptions.anchor.toOffset(), + draggable = marker.androidMarkerOptions.draggable, + snippet = marker.androidMarkerOptions.snippet, + zIndex = marker.androidMarkerOptions.zIndex ?: 0.0f, + onClick = { + onMarkerClick?.invoke(marker) + onMarkerClick == null + }, + content = { content(marker) }, + ) + } else { + Marker( + state = markerState, + title = marker.title, + anchor = marker.androidMarkerOptions.anchor.toOffset(), + draggable = marker.androidMarkerOptions.draggable, + snippet = marker.androidMarkerOptions.snippet, + zIndex = marker.androidMarkerOptions.zIndex ?: 0.0f, + onClick = { + onMarkerClick?.invoke(marker) + onMarkerClick == null + }, + ) + } } } } - } - circles.forEach { circle -> - Circle( - center = circle.center.toGoogleMapsLatLng(), - radius = circle.radius.toDouble(), - strokeColor = Color(circle.lineColor?.toArgb() ?: android.graphics.Color.BLACK), - strokeWidth = circle.lineWidth ?: 10f, - fillColor = Color(circle.color?.toArgb() ?: android.graphics.Color.TRANSPARENT), - clickable = true, - onClick = { - if (onCircleClick != null) { - onCircleClick(circle) - } else { - onMapClick?.invoke(circle.center) - } - }, - ) - } + circles.forEach { circle -> + Circle( + center = circle.center.toGoogleMapsLatLng(), + radius = circle.radius.toDouble(), + strokeColor = Color(circle.lineColor?.toArgb() ?: android.graphics.Color.BLACK), + strokeWidth = circle.lineWidth ?: 10f, + fillColor = Color(circle.color?.toArgb() ?: android.graphics.Color.TRANSPARENT), + clickable = true, + onClick = { + if (onCircleClick != null) { + onCircleClick(circle) + } else { + onMapClick?.invoke(circle.center) + } + }, + ) + } - polygons.forEach { polygon -> - Polygon( - points = polygon.coordinates.map(Coordinates::toGoogleMapsLatLng), - strokeColor = Color(polygon.lineColor?.toArgb() ?: android.graphics.Color.BLACK), - strokeWidth = polygon.lineWidth, - fillColor = Color(polygon.color?.toArgb() ?: android.graphics.Color.TRANSPARENT), - clickable = true, - onClick = { - if (onPolygonClick != null) { - onPolygonClick(polygon) - } else { - onMapClick?.invoke(polygon.coordinates[0]) - } - }, - ) - } + polygons.forEach { polygon -> + Polygon( + points = polygon.coordinates.map(Coordinates::toGoogleMapsLatLng), + strokeColor = + Color(polygon.lineColor?.toArgb() ?: android.graphics.Color.BLACK), + strokeWidth = polygon.lineWidth, + fillColor = + Color(polygon.color?.toArgb() ?: android.graphics.Color.TRANSPARENT), + clickable = true, + onClick = { + if (onPolygonClick != null) { + onPolygonClick(polygon) + } else { + onMapClick?.invoke(polygon.coordinates[0]) + } + }, + ) + } - polylines.forEach { polyline -> - Polyline( - points = polyline.coordinates.map(Coordinates::toGoogleMapsLatLng), - color = Color(polyline.lineColor?.toArgb() ?: android.graphics.Color.BLACK), - width = polyline.width, - clickable = true, - onClick = { - if (onPolylineClick != null) { - onPolylineClick(polyline) - } else { - onMapClick?.invoke(polyline.coordinates[0]) - } - }, - ) - } + polylines.forEach { polyline -> + Polyline( + points = polyline.coordinates.map(Coordinates::toGoogleMapsLatLng), + color = Color(polyline.lineColor?.toArgb() ?: android.graphics.Color.BLACK), + width = polyline.width, + clickable = true, + onClick = { + if (onPolylineClick != null) { + onPolylineClick(polyline) + } else { + onMapClick?.invoke(polyline.coordinates[0]) + } + }, + ) + } - LaunchedEffect(cameraPositionState.position) { - onCameraMove?.invoke(cameraPositionState.position.toCameraPosition()) + LaunchedEffect(cameraPositionState.position) { + val bounds = cameraPositionState.projection?.visibleRegion?.latLngBounds + onCameraMove?.invoke(cameraPositionState.position.toCameraPosition(bounds)) + } } } } diff --git a/kmp-maps/core/src/androidMain/kotlin/com/swmansion/kmpmaps/core/Utils.kt b/kmp-maps/core/src/androidMain/kotlin/com/swmansion/kmpmaps/core/Utils.kt new file mode 100644 index 0000000..fdda0d9 --- /dev/null +++ b/kmp-maps/core/src/androidMain/kotlin/com/swmansion/kmpmaps/core/Utils.kt @@ -0,0 +1,48 @@ +package com.swmansion.kmpmaps.core + +import kotlin.math.PI +import kotlin.math.ln +import kotlin.math.max +import kotlin.math.min +import kotlin.math.sin + +/** + * Calculates the zoom level required to fit [bounds] within the given viewport using the Mercator + * projection formula. Returns a value clamped to [0, 21]. + * + * The zoom is computed independently for latitude and longitude, then the minimum of the two is + * taken so that the entire bounds are visible. Latitude uses the Mercator (non-linear) projection, + * longitude is linear. + * + * @param viewportWidthPx Viewport width in pixels + * @param viewportHeightPx Viewport height in pixels + * @param bounds Geographic bounds to fit + * @return Zoom level in [0, 21] that fits the bounds within the viewport + */ +internal fun calculateZoomFromViewport( + viewportWidthPx: Int, + viewportHeightPx: Int, + bounds: MapBounds, +): Float { + val latFraction = (latRad(bounds.northeast.latitude) - latRad(bounds.southwest.latitude)) / PI + val lngDiff = bounds.northeast.longitude - bounds.southwest.longitude + val lngFraction = if (lngDiff < 0) (lngDiff + 360.0) / 360.0 else lngDiff / 360.0 + val latZoom = + if (latFraction > 0.0) ln(viewportHeightPx / 256.0 / latFraction) / ln(2.0) else 21.0 + val lngZoom = + if (lngFraction > 0.0) ln(viewportWidthPx / 256.0 / lngFraction) / ln(2.0) else 21.0 + return min(latZoom, lngZoom).toFloat().coerceIn(0f, 21f) +} + +/** + * Converts a latitude in decimal degrees to its Mercator projection radian value, clamped to the + * valid Mercator range [-π/2, π/2]. + * + * @param lat Latitude in decimal degrees + * @return Mercator radian value + */ +private fun latRad(lat: Double): Double { + val s = sin(lat * PI / 180.0) + val r = ln((1.0 + s) / (1.0 - s)) / 2.0 + return max(min(r, PI), -PI) / 2.0 +} diff --git a/kmp-maps/core/src/commonMain/kotlin/com/swmansion/kmpmaps/core/MapTypes.kt b/kmp-maps/core/src/commonMain/kotlin/com/swmansion/kmpmaps/core/MapTypes.kt index e2b430e..cfcade2 100644 --- a/kmp-maps/core/src/commonMain/kotlin/com/swmansion/kmpmaps/core/MapTypes.kt +++ b/kmp-maps/core/src/commonMain/kotlin/com/swmansion/kmpmaps/core/MapTypes.kt @@ -193,11 +193,20 @@ public fun Polyline.getId(): String = "polyline_${hashCode()}" */ @Serializable public data class Coordinates(val latitude: Double, val longitude: Double) +/** + * Represents the visible geographic bounds of the map. + * + * @property northeast The northeast corner coordinates of the visible region + * @property southwest The southwest corner coordinates of the visible region + */ +@Serializable public data class MapBounds(val northeast: Coordinates, val southwest: Coordinates) + /** * Represents the camera position and orientation of the map. * * @property coordinates The center coordinates of the camera view * @property zoom The zoom level of the map (typically 0-20) + * @property bounds The visible geographic bounds of the map, or null if not available * @property androidCameraPosition Android-specific options for the camera position and orientation * @property iosCameraPosition iOS-specific options for the camera position and orientation */ @@ -205,6 +214,7 @@ public fun Polyline.getId(): String = "polyline_${hashCode()}" public data class CameraPosition( val coordinates: Coordinates, val zoom: Float, + val bounds: MapBounds? = null, val androidCameraPosition: AndroidCameraPosition? = null, val iosCameraPosition: IosCameraPosition? = null, ) diff --git a/kmp-maps/core/src/iosMain/kotlin/com/swmansion/kmpmaps/core/Extensions.kt b/kmp-maps/core/src/iosMain/kotlin/com/swmansion/kmpmaps/core/Extensions.kt index 82d2365..d89239f 100644 --- a/kmp-maps/core/src/iosMain/kotlin/com/swmansion/kmpmaps/core/Extensions.kt +++ b/kmp-maps/core/src/iosMain/kotlin/com/swmansion/kmpmaps/core/Extensions.kt @@ -126,10 +126,15 @@ import platform.posix.memcpy /** * Converts a CameraPosition to Apple MapKit's MKCoordinateRegion. * + * When [CameraPosition.bounds] is set, the region is derived from the bounds (center and span are + * computed from northeast/southwest corners). Otherwise, [CameraPosition.coordinates] and + * [CameraPosition.zoom] are used. + * * @return MKCoordinateRegion representing the camera's view area */ @OptIn(ExperimentalForeignApi::class) internal fun CameraPosition.toMKCoordinateRegion(): CValue { + if (bounds != null) return bounds.toMKCoordinateRegion() val coordinate = CLLocationCoordinate2DMake(coordinates.latitude, coordinates.longitude) val span = MKCoordinateSpanMake( @@ -139,10 +144,28 @@ internal fun CameraPosition.toMKCoordinateRegion(): CValue { return MKCoordinateRegionMake(coordinate, span) } +/** Converts [MapBounds] to [MKCoordinateRegion] */ +@OptIn(ExperimentalForeignApi::class) +private fun MapBounds.toMKCoordinateRegion(): CValue { + val centerLat = (northeast.latitude + southwest.latitude) / 2.0 + val rawDiff = northeast.longitude - southwest.longitude + val lngDelta = + when { + rawDiff > 180.0 -> rawDiff - 360.0 + rawDiff < -180.0 -> rawDiff + 360.0 + else -> rawDiff + } + val centerLng = wrapLng(southwest.longitude + lngDelta / 2.0) + val latDelta = kotlin.math.abs(northeast.latitude - southwest.latitude) + val coordinate = CLLocationCoordinate2DMake(centerLat, centerLng) + val span = MKCoordinateSpanMake(latDelta, kotlin.math.abs(lngDelta)) + return MKCoordinateRegionMake(coordinate, span) +} + /** * Converts Apple MapKit's MKCoordinateRegion back to CameraPosition. * - * @return CameraPosition with calculated zoom level and coordinates + * @return CameraPosition with calculated zoom level, coordinates, and visible bounds */ @OptIn(ExperimentalForeignApi::class) internal fun CValue.toCameraPosition() = useContents { @@ -150,9 +173,26 @@ internal fun CValue.toCameraPosition() = useContents { val lngZoom = ln(360.0 / span.longitudeDelta) / ln(2.0) val zoom = min(latZoom, lngZoom).toFloat() - CameraPosition(coordinates = Coordinates(center.latitude, center.longitude), zoom = zoom) + val halfLat = span.latitudeDelta / 2.0 + val halfLng = span.longitudeDelta / 2.0 + val neLng = wrapLng(center.longitude + halfLng) + val swLng = wrapLng(center.longitude - halfLng) + val bounds = + MapBounds( + northeast = Coordinates(center.latitude + halfLat, neLng), + southwest = Coordinates(center.latitude - halfLat, swLng), + ) + + CameraPosition( + coordinates = Coordinates(center.latitude, center.longitude), + zoom = zoom, + bounds = bounds, + ) } +/** Normalizes [lng] to the range [-180, 180]. */ +private fun wrapLng(lng: Double): Double = ((lng + 180.0) % 360.0 + 360.0) % 360.0 - 180.0 + /** * Updates Apple Maps markers by removing existing annotations and adding new ones. * diff --git a/kmp-maps/core/src/jvmMain/kotlin/com/swmansion/kmpmaps/core/MapContentLoader.kt b/kmp-maps/core/src/jvmMain/kotlin/com/swmansion/kmpmaps/core/MapContentLoader.kt index c265e28..e4ba4ee 100644 --- a/kmp-maps/core/src/jvmMain/kotlin/com/swmansion/kmpmaps/core/MapContentLoader.kt +++ b/kmp-maps/core/src/jvmMain/kotlin/com/swmansion/kmpmaps/core/MapContentLoader.kt @@ -19,13 +19,23 @@ internal fun loadHTMLContent( properties: MapProperties?, ): String { val html = readResource("web/google_map.html") + val bounds = cameraPosition?.bounds + val fitBoundsCall = + if (bounds != null) { + "map.fitBounds(new google.maps.LatLngBounds(" + + "{lat: ${bounds.southwest.latitude}, lng: ${bounds.southwest.longitude}}, " + + "{lat: ${bounds.northeast.latitude}, lng: ${bounds.northeast.longitude}}));" + } else { + "" + } val js = readResource("web/google_map.js") .replace("{{INITIAL_MAP_ID}}", properties?.webMapProperties?.mapId ?: "DEMO_MAP_ID") .replace("{{INITIAL_COLOR_SCHEME}}", properties?.mapTheme?.name ?: MapTheme.SYSTEM.name) - .replace("{{INITIAL_LAT}}", cameraPosition?.coordinates?.latitude.toString()) - .replace("{{INITIAL_LNG}}", cameraPosition?.coordinates?.longitude.toString()) - .replace("{{INITIAL_ZOOM}}", cameraPosition?.zoom.toString()) + .replace("{{INITIAL_LAT}}", (cameraPosition?.coordinates?.latitude ?: 0f).toString()) + .replace("{{INITIAL_LNG}}", (cameraPosition?.coordinates?.longitude ?: 0f).toString()) + .replace("{{INITIAL_ZOOM}}", (cameraPosition?.zoom ?: 0f).toString()) + .replace("{{FIT_BOUNDS_CALL}}", fitBoundsCall) return html.replace("{{API_KEY}}", apiKey).replace("{{LOCAL_JS_CONTENT}}", js) } diff --git a/kmp-maps/core/src/jvmMain/resources/web/google_map.js b/kmp-maps/core/src/jvmMain/resources/web/google_map.js index eef2208..7af7e46 100644 --- a/kmp-maps/core/src/jvmMain/resources/web/google_map.js +++ b/kmp-maps/core/src/jvmMain/resources/web/google_map.js @@ -42,6 +42,8 @@ async function initMap() { disableDefaultUI: true, }); + {{FIT_BOUNDS_CALL}} + if (window.markerClusterer) { markerCluster = new markerClusterer.MarkerClusterer({ map, markers: [] }); } diff --git a/kmp-maps/google-maps/src/iosMain/kotlin/com/swmansion/kmpmaps/googlemaps/Extensions.kt b/kmp-maps/google-maps/src/iosMain/kotlin/com/swmansion/kmpmaps/googlemaps/Extensions.kt index bcbb556..2016da3 100644 --- a/kmp-maps/google-maps/src/iosMain/kotlin/com/swmansion/kmpmaps/googlemaps/Extensions.kt +++ b/kmp-maps/google-maps/src/iosMain/kotlin/com/swmansion/kmpmaps/googlemaps/Extensions.kt @@ -12,6 +12,8 @@ import cocoapods.GoogleMaps.kGMSTypeNormal import cocoapods.GoogleMaps.kGMSTypeSatellite import cocoapods.GoogleMaps.kGMSTypeTerrain import cocoapods.Google_Maps_iOS_Utils.GMSCameraPosition +import cocoapods.Google_Maps_iOS_Utils.GMSCameraUpdate +import cocoapods.Google_Maps_iOS_Utils.GMSCoordinateBounds import cocoapods.Google_Maps_iOS_Utils.GMSMapStyle import cocoapods.Google_Maps_iOS_Utils.GMSMapView as UtilsGMSMapView import cocoapods.Google_Maps_iOS_Utils.GMSMarker @@ -23,7 +25,9 @@ import cocoapods.Google_Maps_iOS_Utils.GMUGeometryRenderer import cocoapods.Google_Maps_iOS_Utils.GMUNonHierarchicalDistanceBasedAlgorithm import com.swmansion.kmpmaps.core.CameraPosition import com.swmansion.kmpmaps.core.Circle +import com.swmansion.kmpmaps.core.Coordinates import com.swmansion.kmpmaps.core.GoogleMapsMapStyleOptions +import com.swmansion.kmpmaps.core.MapBounds import com.swmansion.kmpmaps.core.MapType import com.swmansion.kmpmaps.core.MapUISettings import com.swmansion.kmpmaps.core.Marker @@ -33,6 +37,7 @@ import com.swmansion.kmpmaps.core.getId import com.swmansion.kmpmaps.core.toAppleMapsColor import kotlin.collections.set import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.useContents import platform.CoreGraphics.CGPointMake import platform.CoreLocation.CLLocationCoordinate2DMake import platform.Foundation.NSDictionary @@ -259,26 +264,80 @@ public fun UtilsGMSMapView.renderGeoJson(geoJson: String): GMUGeometryRenderer? } /** - * Converts the [CameraPosition] to a native [GMSCameraPosition] and applies it to this map view. + * Converts the [CameraPosition] to a native camera update and applies it to this map view. * - * @param position The camera position to convert + * When [CameraPosition.bounds] is set, uses [GMSCameraUpdate.fitBounds] so the camera zooms to fit + * the entire region. Otherwise, falls back to a direct [GMSCameraPosition]. + * + * @param position The camera position to apply */ @OptIn(ExperimentalForeignApi::class) public fun UtilsGMSMapView.setUpGMSCameraPosition(position: CameraPosition) { - setCamera( - GMSCameraPosition.cameraWithTarget( - target = - CLLocationCoordinate2DMake( - position.coordinates.latitude, - position.coordinates.longitude, + val bounds = position.bounds + if (bounds != null) { + moveCamera( + GMSCameraUpdate.fitBounds( + GMSCoordinateBounds( + CLLocationCoordinate2DMake( + bounds.northeast.latitude, + bounds.northeast.longitude, + ), + CLLocationCoordinate2DMake( + bounds.southwest.latitude, + bounds.southwest.longitude, + ), ), - zoom = position.zoom, - bearing = (position.iosCameraPosition?.gmsBearing ?: 0f).toDouble(), - viewingAngle = (position.iosCameraPosition?.gmsViewingAngle ?: 0f).toDouble(), + withPadding = 0.0, + ) ) - ) + } else { + setCamera( + GMSCameraPosition.cameraWithTarget( + target = + CLLocationCoordinate2DMake( + position.coordinates.latitude, + position.coordinates.longitude, + ), + zoom = position.zoom, + bearing = (position.iosCameraPosition?.gmsBearing ?: 0f).toDouble(), + viewingAngle = (position.iosCameraPosition?.gmsViewingAngle ?: 0f).toDouble(), + ) + ) + } } +/** + * Returns the current visible geographic bounds of the map view. + * + * Uses [GMSCoordinateBounds] built from all four corners of the visible region so that + * antimeridian-crossing regions are handled correctly by the SDK rather than by naive longitude + * min/max arithmetic. + * + * @return The current visible geographic bounds of the map. + */ +@OptIn(ExperimentalForeignApi::class) +internal fun UtilsGMSMapView.getVisibleMapBounds(): MapBounds = + projection().let { proj -> + proj.visibleRegion().useContents { + val gmsBounds = + GMSCoordinateBounds( + CLLocationCoordinate2DMake(nearLeft.latitude, nearLeft.longitude), + CLLocationCoordinate2DMake(nearRight.latitude, nearRight.longitude), + ) + .includingCoordinate( + CLLocationCoordinate2DMake(farLeft.latitude, farLeft.longitude) + ) + .includingCoordinate( + CLLocationCoordinate2DMake(farRight.latitude, farRight.longitude) + ) + val neLat = gmsBounds.northEast().useContents { latitude } + val neLon = gmsBounds.northEast().useContents { longitude } + val swLat = gmsBounds.southWest().useContents { latitude } + val swLon = gmsBounds.southWest().useContents { longitude } + MapBounds(northeast = Coordinates(neLat, neLon), southwest = Coordinates(swLat, swLon)) + } + } + /** * Converts an optional Compose Color to a UIColor, using the provided fallback when null. * diff --git a/kmp-maps/google-maps/src/iosMain/kotlin/com/swmansion/kmpmaps/googlemaps/MapDelegate.kt b/kmp-maps/google-maps/src/iosMain/kotlin/com/swmansion/kmpmaps/googlemaps/MapDelegate.kt index b9d57d9..39adf71 100644 --- a/kmp-maps/google-maps/src/iosMain/kotlin/com/swmansion/kmpmaps/googlemaps/MapDelegate.kt +++ b/kmp-maps/google-maps/src/iosMain/kotlin/com/swmansion/kmpmaps/googlemaps/MapDelegate.kt @@ -57,10 +57,12 @@ internal class MapDelegate( override fun mapView(mapView: GMSMapView, didChangeCameraPosition: GMSCameraPosition) { onCameraMove?.let { didChangeCameraPosition.target().useContents { + val visibleBounds = mapView.getVisibleMapBounds() val cameraPosition = CameraPosition( coordinates = Coordinates(latitude = latitude, longitude = longitude), zoom = didChangeCameraPosition.zoom(), + bounds = visibleBounds, ) it(cameraPosition) }