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) + } +}