From 9d895ff784aca57aa1f979e5a64a3d1be633f9d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20Lo=CC=81pez=20Man=CC=83as?= Date: Tue, 31 Mar 2026 08:51:05 +0200 Subject: [PATCH 1/2] feat: add WMS tile overlay support to maps-compose-utils (#880) --- maps-app/src/main/AndroidManifest.xml | 3 + .../com/google/maps/android/compose/Demo.kt | 5 ++ .../android/compose/WmsTileOverlayActivity.kt | 60 ++++++++++++++ maps-app/src/main/res/values/strings.xml | 3 + maps-compose-utils/build.gradle.kts | 2 + .../android/compose/wms/WmsTileOverlay.kt | 67 +++++++++++++++ .../android/compose/wms/WmsUrlTileProvider.kt | 81 +++++++++++++++++++ .../compose/wms/WmsUrlTileProviderTest.kt | 65 +++++++++++++++ 8 files changed, 286 insertions(+) create mode 100644 maps-app/src/main/java/com/google/maps/android/compose/WmsTileOverlayActivity.kt create mode 100644 maps-compose-utils/src/main/java/com/google/maps/android/compose/wms/WmsTileOverlay.kt create mode 100644 maps-compose-utils/src/main/java/com/google/maps/android/compose/wms/WmsUrlTileProvider.kt create mode 100644 maps-compose-utils/src/test/java/com/google/maps/android/compose/wms/WmsUrlTileProviderTest.kt diff --git a/maps-app/src/main/AndroidManifest.xml b/maps-app/src/main/AndroidManifest.xml index a8922937..d312b735 100644 --- a/maps-app/src/main/AndroidManifest.xml +++ b/maps-app/src/main/AndroidManifest.xml @@ -109,6 +109,9 @@ + diff --git a/maps-app/src/main/java/com/google/maps/android/compose/Demo.kt b/maps-app/src/main/java/com/google/maps/android/compose/Demo.kt index 45925c82..2931e60f 100644 --- a/maps-app/src/main/java/com/google/maps/android/compose/Demo.kt +++ b/maps-app/src/main/java/com/google/maps/android/compose/Demo.kt @@ -116,6 +116,11 @@ sealed class ActivityGroup( R.string.tile_overlay_activity_description, TileOverlayActivity::class ), + Activity( + R.string.wms_tile_overlay_activity, + R.string.wms_tile_overlay_activity_description, + WmsTileOverlayActivity::class + ), Activity( R.string.ground_overlay_activity, R.string.ground_overlay_activity_description, diff --git a/maps-app/src/main/java/com/google/maps/android/compose/WmsTileOverlayActivity.kt b/maps-app/src/main/java/com/google/maps/android/compose/WmsTileOverlayActivity.kt new file mode 100644 index 00000000..9bc11a7e --- /dev/null +++ b/maps-app/src/main/java/com/google/maps/android/compose/WmsTileOverlayActivity.kt @@ -0,0 +1,60 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.maps.android.compose + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.ui.Modifier +import com.google.android.gms.maps.model.CameraPosition +import com.google.android.gms.maps.model.LatLng +import com.google.maps.android.compose.wms.WmsTileOverlay + +/** + * This activity demonstrates how to use [WmsTileOverlay] to display a Web Map Service (WMS) + * layer on a map. + */ +class WmsTileOverlayActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + val center = LatLng(39.50, -98.35) // Center of US + val cameraPositionState = rememberCameraPositionState { + position = CameraPosition.fromLatLngZoom(center, 4f) + } + + GoogleMap( + modifier = Modifier.fillMaxSize(), + cameraPositionState = cameraPositionState + ) { + // Example: USGS National Map Shaded Relief (WMS) + WmsTileOverlay( + urlFormatter = { xMin, yMin, xMax, yMax, _ -> + "https://basemap.nationalmap.gov/arcgis/services/USGSShadedReliefOnly/MapServer/WmsServer?" + + "SERVICE=WMS&VERSION=1.1.1&REQUEST=GetMap" + + "&FORMAT=image/png&TRANSPARENT=true&LAYERS=0" + + "&SRS=EPSG:3857&WIDTH=256&HEIGHT=256" + + "&BBOX=$xMin,$yMin,$xMax,$yMax" + }, + transparency = 0.5f + ) + } + } + } +} diff --git a/maps-app/src/main/res/values/strings.xml b/maps-app/src/main/res/values/strings.xml index 2a30105c..1147de14 100644 --- a/maps-app/src/main/res/values/strings.xml +++ b/maps-app/src/main/res/values/strings.xml @@ -75,6 +75,9 @@ Tile Overlay Adding a tile overlay to the map. + WMS Tile Overlay + Adding a WMS (EPSG:3857) tile overlay to the map. + Ground Overlay Adding a ground overlay to the map. diff --git a/maps-compose-utils/build.gradle.kts b/maps-compose-utils/build.gradle.kts index d9b9b437..b3d56458 100644 --- a/maps-compose-utils/build.gradle.kts +++ b/maps-compose-utils/build.gradle.kts @@ -85,4 +85,6 @@ dependencies { implementation(libs.kotlin) implementation(libs.kotlinx.coroutines.android) api(libs.maps.ktx.utils) + + testImplementation(libs.test.junit) } diff --git a/maps-compose-utils/src/main/java/com/google/maps/android/compose/wms/WmsTileOverlay.kt b/maps-compose-utils/src/main/java/com/google/maps/android/compose/wms/WmsTileOverlay.kt new file mode 100644 index 00000000..52f8bcbf --- /dev/null +++ b/maps-compose-utils/src/main/java/com/google/maps/android/compose/wms/WmsTileOverlay.kt @@ -0,0 +1,67 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.maps.android.compose.wms + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import com.google.android.gms.maps.model.TileOverlay +import com.google.maps.android.compose.TileOverlay +import com.google.maps.android.compose.TileOverlayState +import com.google.maps.android.compose.rememberTileOverlayState + +/** + * A Composable that displays a Web Map Service (WMS) layer using the EPSG:3857 projection. + * + * @param urlFormatter a lambda that returns the WMS URL for the given bounding box coordinates. + * @param state the [TileOverlayState] to be used to control the tile overlay. + * @param fadeIn boolean indicating whether the tiles should fade in. + * @param transparency the transparency of the tile overlay. + * @param visible the visibility of the tile overlay. + * @param zIndex the z-index of the tile overlay. + * @param onClick a lambda invoked when the tile overlay is clicked. + * @param tileWidth the width of the tiles in pixels (default 256). + * @param tileHeight the height of the tiles in pixels (default 256). + */ +@Composable +public fun WmsTileOverlay( + urlFormatter: (xMin: Double, yMin: Double, xMax: Double, yMax: Double, zoom: Int) -> String, + state: TileOverlayState = rememberTileOverlayState(), + fadeIn: Boolean = true, + transparency: Float = 0f, + visible: Boolean = true, + zIndex: Float = 0f, + onClick: (TileOverlay) -> Unit = {}, + tileWidth: Int = 256, + tileHeight: Int = 256 +) { + val tileProvider = remember(urlFormatter, tileWidth, tileHeight) { + WmsUrlTileProvider( + width = tileWidth, + height = tileHeight, + urlFormatter = urlFormatter + ) + } + TileOverlay( + tileProvider = tileProvider, + state = state, + fadeIn = fadeIn, + transparency = transparency, + visible = visible, + zIndex = zIndex, + onClick = onClick + ) +} diff --git a/maps-compose-utils/src/main/java/com/google/maps/android/compose/wms/WmsUrlTileProvider.kt b/maps-compose-utils/src/main/java/com/google/maps/android/compose/wms/WmsUrlTileProvider.kt new file mode 100644 index 00000000..925ff33a --- /dev/null +++ b/maps-compose-utils/src/main/java/com/google/maps/android/compose/wms/WmsUrlTileProvider.kt @@ -0,0 +1,81 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.maps.android.compose.wms + +import com.google.android.gms.maps.model.UrlTileProvider +import java.net.MalformedURLException +import java.net.URL +import kotlin.math.pow + +/** + * A [UrlTileProvider] for Web Map Service (WMS) layers that use the EPSG:3857 (Web Mercator) + * projection. + * + * @param width the width of the tile in pixels. + * @param height the height of the tile in pixels. + * @param urlFormatter a lambda that returns the WMS URL for the given bounding box coordinates + * (xMin, yMin, xMax, yMax) and zoom level. + */ +public class WmsUrlTileProvider( + width: Int = 256, + height: Int = 256, + private val urlFormatter: ( + xMin: Double, + yMin: Double, + xMax: Double, + yMax: Double, + zoom: Int + ) -> String +) : UrlTileProvider(width, height) { + + override fun getTileUrl(x: Int, y: Int, zoom: Int): URL? { + val bbox = getBoundingBox(x, y, zoom) + val urlString = urlFormatter(bbox[0], bbox[1], bbox[2], bbox[3], zoom) + return try { + URL(urlString) + } catch (e: MalformedURLException) { + null + } + } + + private companion object { + /** + * The Earth's circumference in meters at the equator according to EPSG:3857. + */ + private const val EARTH_CIRCUMFERENCE = 2 * 20037508.34789244 + } + + /** + * Calculates the bounding box for the given tile in EPSG:3857 coordinates. + * + * @return an array containing [xMin, yMin, xMax, yMax] in meters. + */ + internal fun getBoundingBox(x: Int, y: Int, zoom: Int): DoubleArray { + val numTiles = 2.0.pow(zoom.toDouble()) + val tileSizeMeters = EARTH_CIRCUMFERENCE / numTiles + + val xMin = -20037508.34789244 + (x * tileSizeMeters) + val xMax = -20037508.34789244 + ((x + 1) * tileSizeMeters) + + // Y is inverted in TMS/Google Maps tiles vs WMS BBOX + // Top of map (y=0) is +20037508.34789244 + val yMax = 20037508.34789244 - (y * tileSizeMeters) + val yMin = 20037508.34789244 - ((y + 1) * tileSizeMeters) + + return doubleArrayOf(xMin, yMin, xMax, yMax) + } +} diff --git a/maps-compose-utils/src/test/java/com/google/maps/android/compose/wms/WmsUrlTileProviderTest.kt b/maps-compose-utils/src/test/java/com/google/maps/android/compose/wms/WmsUrlTileProviderTest.kt new file mode 100644 index 00000000..751405e7 --- /dev/null +++ b/maps-compose-utils/src/test/java/com/google/maps/android/compose/wms/WmsUrlTileProviderTest.kt @@ -0,0 +1,65 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.maps.android.compose.wms + +import org.junit.Assert.assertArrayEquals +import org.junit.Test + +public class WmsUrlTileProviderTest { + + private val worldSize: Double = 20037508.34789244 + + @Test + public fun testGetBoundingBoxZoom0() { + val provider = WmsUrlTileProvider { _, _, _, _, _ -> "" } + val bbox = provider.getBoundingBox(0, 0, 0) + + // Zoom 0, Tile 0,0 should cover the entire world + val expected = doubleArrayOf(-worldSize, -worldSize, worldSize, worldSize) + assertArrayEquals(expected, bbox, 0.001) + } + + @Test + public fun testGetBoundingBoxZoom1() { + val provider = WmsUrlTileProvider { _, _, _, _, _ -> "" } + + // Zoom 1, Tile 0,0 (Top Left) + val bbox00 = provider.getBoundingBox(0, 0, 1) + val expected00 = doubleArrayOf(-worldSize, 0.0, 0.0, worldSize) + assertArrayEquals(expected00, bbox00, 0.001) + + // Zoom 1, Tile 1,1 (Bottom Right) + val bbox11 = provider.getBoundingBox(1, 1, 1) + val expected11 = doubleArrayOf(0.0, -worldSize, worldSize, 0.0) + assertArrayEquals(expected11, bbox11, 0.001) + } + + @Test + public fun testGetBoundingBoxSpecificTile() { + val provider = WmsUrlTileProvider { _, _, _, _, _ -> "" } + + // Zoom 2, Tile 1,1 + // Num tiles = 4x4. Tile size = 2 * worldSize / 4 = worldSize / 2 + // xMin = -worldSize + 1 * (worldSize/2) = -worldSize/2 + // xMax = -worldSize + 2 * (worldSize/2) = 0 + // yMax = worldSize - 1 * (worldSize/2) = worldSize/2 + // yMin = worldSize - 2 * (worldSize/2) = 0 + val bbox = provider.getBoundingBox(1, 1, 2) + val expected = doubleArrayOf(-worldSize / 2, 0.0, 0.0, worldSize / 2) + assertArrayEquals(expected, bbox, 0.001) + } +} From 884b14246c51cdf332d9b490dbb00a5eaa4a7bf4 Mon Sep 17 00:00:00 2001 From: dkhawk <107309+dkhawk@users.noreply.github.com> Date: Thu, 2 Apr 2026 16:02:48 -0600 Subject: [PATCH 2/2] feat(wms): implement WMS tile overlay with math optimizations and dynamic toggles - Extract hardcoded Web Mercator extent into precise PI-derived constants - Use efficient bitwise shifts for tile count calculations - Document Web Mercator projection mechanics step-by-step - Add interactive toggle buttons for base map and overlay visibility --- .../android/compose/WmsTileOverlayActivity.kt | 72 ++++++++++++++++--- .../android/compose/wms/WmsUrlTileProvider.kt | 33 ++++++--- .../compose/wms/WmsUrlTileProviderTest.kt | 2 +- 3 files changed, 86 insertions(+), 21 deletions(-) diff --git a/maps-app/src/main/java/com/google/maps/android/compose/WmsTileOverlayActivity.kt b/maps-app/src/main/java/com/google/maps/android/compose/WmsTileOverlayActivity.kt index 9bc11a7e..092479cb 100644 --- a/maps-app/src/main/java/com/google/maps/android/compose/WmsTileOverlayActivity.kt +++ b/maps-app/src/main/java/com/google/maps/android/compose/WmsTileOverlayActivity.kt @@ -19,11 +19,24 @@ package com.google.maps.android.compose import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp import com.google.android.gms.maps.model.CameraPosition import com.google.android.gms.maps.model.LatLng import com.google.maps.android.compose.wms.WmsTileOverlay +import androidx.core.net.toUri /** * This activity demonstrates how to use [WmsTileOverlay] to display a Web Map Service (WMS) @@ -38,23 +51,62 @@ class WmsTileOverlayActivity : ComponentActivity() { val cameraPositionState = rememberCameraPositionState { position = CameraPosition.fromLatLngZoom(center, 4f) } + var mapType by remember { mutableStateOf(MapType.NORMAL) } + var overlayVisible by remember { mutableStateOf(true) } - GoogleMap( - modifier = Modifier.fillMaxSize(), - cameraPositionState = cameraPositionState - ) { + Box(modifier = Modifier.fillMaxSize()) { + GoogleMap( + modifier = Modifier.fillMaxSize(), + cameraPositionState = cameraPositionState, + properties = MapProperties(mapType = mapType) + ) { // Example: USGS National Map Shaded Relief (WMS) WmsTileOverlay( urlFormatter = { xMin, yMin, xMax, yMax, _ -> - "https://basemap.nationalmap.gov/arcgis/services/USGSShadedReliefOnly/MapServer/WmsServer?" + - "SERVICE=WMS&VERSION=1.1.1&REQUEST=GetMap" + - "&FORMAT=image/png&TRANSPARENT=true&LAYERS=0" + - "&SRS=EPSG:3857&WIDTH=256&HEIGHT=256" + - "&BBOX=$xMin,$yMin,$xMax,$yMax" + "https://basemap.nationalmap.gov/arcgis/services/USGSShadedReliefOnly/MapServer/WmsServer".toUri() + .buildUpon() + .appendQueryParameter("SERVICE", "WMS") + .appendQueryParameter("VERSION", "1.1.1") + .appendQueryParameter("REQUEST", "GetMap") + .appendQueryParameter("FORMAT", "image/png") + .appendQueryParameter("TRANSPARENT", "true") + .appendQueryParameter("LAYERS", "0") + .appendQueryParameter("SRS", "EPSG:3857") + .appendQueryParameter("WIDTH", "256") + .appendQueryParameter("HEIGHT", "256") + .appendQueryParameter("STYLES", "") + .appendQueryParameter("BBOX", "$xMin,$yMin,$xMax,$yMax") + .build() + .toString() }, - transparency = 0.5f + transparency = 0.5f, + visible = overlayVisible ) } + + Column( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Button( + onClick = { + mapType = if (mapType == MapType.NONE) MapType.NORMAL else MapType.NONE + } + ) { + Text(if (mapType == MapType.NONE) "Show Base Map" else "Hide Base Map") + } + + Button( + onClick = { + overlayVisible = !overlayVisible + } + ) { + Text(if (overlayVisible) "Hide WMS Overlay" else "Show WMS Overlay") + } + } } } + } } diff --git a/maps-compose-utils/src/main/java/com/google/maps/android/compose/wms/WmsUrlTileProvider.kt b/maps-compose-utils/src/main/java/com/google/maps/android/compose/wms/WmsUrlTileProvider.kt index 925ff33a..e6d01182 100644 --- a/maps-compose-utils/src/main/java/com/google/maps/android/compose/wms/WmsUrlTileProvider.kt +++ b/maps-compose-utils/src/main/java/com/google/maps/android/compose/wms/WmsUrlTileProvider.kt @@ -19,6 +19,7 @@ package com.google.maps.android.compose.wms import com.google.android.gms.maps.model.UrlTileProvider import java.net.MalformedURLException import java.net.URL +import kotlin.math.PI import kotlin.math.pow /** @@ -54,9 +55,16 @@ public class WmsUrlTileProvider( private companion object { /** - * The Earth's circumference in meters at the equator according to EPSG:3857. + * The maximum extent of the Web Mercator projection (EPSG:3857) in meters. + * This is the distance from the origin (0,0) to the edge of the world map. + * Calculated as semi-major axis of Earth (6378137.0) * PI. */ - private const val EARTH_CIRCUMFERENCE = 2 * 20037508.34789244 + private const val WORLD_EXTENT = (6378137.0) * PI + + /** + * The total width/height of the world map in meters. + */ + private const val WORLD_SIZE_METERS = 2 * WORLD_EXTENT } /** @@ -65,16 +73,21 @@ public class WmsUrlTileProvider( * @return an array containing [xMin, yMin, xMax, yMax] in meters. */ internal fun getBoundingBox(x: Int, y: Int, zoom: Int): DoubleArray { - val numTiles = 2.0.pow(zoom.toDouble()) - val tileSizeMeters = EARTH_CIRCUMFERENCE / numTiles + // 1. Calculate how many tiles exist in each dimension at this zoom level (2^zoom). + val tilesPerDimension = 1 shl zoom + + // 2. Divide the total world span by the number of tiles to find the metric size of one tile. + val tileSizeMeters = WORLD_SIZE_METERS / tilesPerDimension.toDouble() - val xMin = -20037508.34789244 + (x * tileSizeMeters) - val xMax = -20037508.34789244 + ((x + 1) * tileSizeMeters) + // 3. X-axis: Starts at the far left (-WORLD_EXTENT) and moves East. + val xMin = -WORLD_EXTENT + (x * tileSizeMeters) + val xMax = -WORLD_EXTENT + ((x + 1) * tileSizeMeters) - // Y is inverted in TMS/Google Maps tiles vs WMS BBOX - // Top of map (y=0) is +20037508.34789244 - val yMax = 20037508.34789244 - (y * tileSizeMeters) - val yMin = 20037508.34789244 - ((y + 1) * tileSizeMeters) + // 4. Y-axis: Google Maps/TMS starts at the Top (y=0 is North) and moves South. + // WMS Bounding Box expects yMin to be the southern-most latitude and yMax to be the northern-most. + // Therefore, we subtract the tile distance from the northern-most edge (+WORLD_EXTENT). + val yMax = WORLD_EXTENT - (y * tileSizeMeters) + val yMin = WORLD_EXTENT - ((y + 1) * tileSizeMeters) return doubleArrayOf(xMin, yMin, xMax, yMax) } diff --git a/maps-compose-utils/src/test/java/com/google/maps/android/compose/wms/WmsUrlTileProviderTest.kt b/maps-compose-utils/src/test/java/com/google/maps/android/compose/wms/WmsUrlTileProviderTest.kt index 751405e7..55ddadee 100644 --- a/maps-compose-utils/src/test/java/com/google/maps/android/compose/wms/WmsUrlTileProviderTest.kt +++ b/maps-compose-utils/src/test/java/com/google/maps/android/compose/wms/WmsUrlTileProviderTest.kt @@ -21,7 +21,7 @@ import org.junit.Test public class WmsUrlTileProviderTest { - private val worldSize: Double = 20037508.34789244 + private val worldSize: Double = 6378137.0 * kotlin.math.PI @Test public fun testGetBoundingBoxZoom0() {