Skip to content

Commit ca66e98

Browse files
kikosodkhawk
andauthored
feat: add WMS tile overlay support to maps-compose-utils (#884)
* feat: add WMS tile overlay support to maps-compose-utils (#880) * 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 --------- Co-authored-by: Dale Hawkins <107309+dkhawk@users.noreply.github.com>
1 parent ced4126 commit ca66e98

File tree

8 files changed

+351
-0
lines changed

8 files changed

+351
-0
lines changed

maps-app/src/main/AndroidManifest.xml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,9 @@
109109
<activity
110110
android:name=".TileOverlayActivity"
111111
android:exported="true" />
112+
<activity
113+
android:name=".WmsTileOverlayActivity"
114+
android:exported="true" />
112115
<activity
113116
android:name=".GroundOverlayActivity"
114117
android:exported="true" />

maps-app/src/main/java/com/google/maps/android/compose/Demo.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,11 @@ sealed class ActivityGroup(
116116
R.string.tile_overlay_activity_description,
117117
TileOverlayActivity::class
118118
),
119+
Activity(
120+
R.string.wms_tile_overlay_activity,
121+
R.string.wms_tile_overlay_activity_description,
122+
WmsTileOverlayActivity::class
123+
),
119124
Activity(
120125
R.string.ground_overlay_activity,
121126
R.string.ground_overlay_activity_description,
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
/*
2+
* Copyright 2026 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.google.maps.android.compose
18+
19+
import android.os.Bundle
20+
import androidx.activity.ComponentActivity
21+
import androidx.activity.compose.setContent
22+
import androidx.compose.foundation.layout.Arrangement
23+
import androidx.compose.foundation.layout.Box
24+
import androidx.compose.foundation.layout.Column
25+
import androidx.compose.foundation.layout.fillMaxSize
26+
import androidx.compose.foundation.layout.padding
27+
import androidx.compose.material3.Button
28+
import androidx.compose.material3.Text
29+
import androidx.compose.runtime.getValue
30+
import androidx.compose.runtime.mutableStateOf
31+
import androidx.compose.runtime.remember
32+
import androidx.compose.runtime.setValue
33+
import androidx.compose.ui.Alignment
34+
import androidx.compose.ui.Modifier
35+
import androidx.compose.ui.unit.dp
36+
import com.google.android.gms.maps.model.CameraPosition
37+
import com.google.android.gms.maps.model.LatLng
38+
import com.google.maps.android.compose.wms.WmsTileOverlay
39+
import androidx.core.net.toUri
40+
41+
/**
42+
* This activity demonstrates how to use [WmsTileOverlay] to display a Web Map Service (WMS)
43+
* layer on a map.
44+
*/
45+
class WmsTileOverlayActivity : ComponentActivity() {
46+
47+
override fun onCreate(savedInstanceState: Bundle?) {
48+
super.onCreate(savedInstanceState)
49+
setContent {
50+
val center = LatLng(39.50, -98.35) // Center of US
51+
val cameraPositionState = rememberCameraPositionState {
52+
position = CameraPosition.fromLatLngZoom(center, 4f)
53+
}
54+
var mapType by remember { mutableStateOf(MapType.NORMAL) }
55+
var overlayVisible by remember { mutableStateOf(true) }
56+
57+
Box(modifier = Modifier.fillMaxSize()) {
58+
GoogleMap(
59+
modifier = Modifier.fillMaxSize(),
60+
cameraPositionState = cameraPositionState,
61+
properties = MapProperties(mapType = mapType)
62+
) {
63+
// Example: USGS National Map Shaded Relief (WMS)
64+
WmsTileOverlay(
65+
urlFormatter = { xMin, yMin, xMax, yMax, _ ->
66+
"https://basemap.nationalmap.gov/arcgis/services/USGSShadedReliefOnly/MapServer/WmsServer".toUri()
67+
.buildUpon()
68+
.appendQueryParameter("SERVICE", "WMS")
69+
.appendQueryParameter("VERSION", "1.1.1")
70+
.appendQueryParameter("REQUEST", "GetMap")
71+
.appendQueryParameter("FORMAT", "image/png")
72+
.appendQueryParameter("TRANSPARENT", "true")
73+
.appendQueryParameter("LAYERS", "0")
74+
.appendQueryParameter("SRS", "EPSG:3857")
75+
.appendQueryParameter("WIDTH", "256")
76+
.appendQueryParameter("HEIGHT", "256")
77+
.appendQueryParameter("STYLES", "")
78+
.appendQueryParameter("BBOX", "$xMin,$yMin,$xMax,$yMax")
79+
.build()
80+
.toString()
81+
},
82+
transparency = 0.5f,
83+
visible = overlayVisible
84+
)
85+
}
86+
87+
Column(
88+
modifier = Modifier
89+
.align(Alignment.TopEnd)
90+
.padding(16.dp),
91+
verticalArrangement = Arrangement.spacedBy(8.dp)
92+
) {
93+
Button(
94+
onClick = {
95+
mapType = if (mapType == MapType.NONE) MapType.NORMAL else MapType.NONE
96+
}
97+
) {
98+
Text(if (mapType == MapType.NONE) "Show Base Map" else "Hide Base Map")
99+
}
100+
101+
Button(
102+
onClick = {
103+
overlayVisible = !overlayVisible
104+
}
105+
) {
106+
Text(if (overlayVisible) "Hide WMS Overlay" else "Show WMS Overlay")
107+
}
108+
}
109+
}
110+
}
111+
}
112+
}

maps-app/src/main/res/values/strings.xml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,9 @@
7575
<string name="tile_overlay_activity">Tile Overlay</string>
7676
<string name="tile_overlay_activity_description">Adding a tile overlay to the map.</string>
7777

78+
<string name="wms_tile_overlay_activity">WMS Tile Overlay</string>
79+
<string name="wms_tile_overlay_activity_description">Adding a WMS (EPSG:3857) tile overlay to the map.</string>
80+
7881
<string name="ground_overlay_activity">Ground Overlay</string>
7982
<string name="ground_overlay_activity_description">Adding a ground overlay to the map.</string>
8083

maps-compose-utils/build.gradle.kts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,4 +85,6 @@ dependencies {
8585
implementation(libs.kotlin)
8686
implementation(libs.kotlinx.coroutines.android)
8787
api(libs.maps.ktx.utils)
88+
89+
testImplementation(libs.test.junit)
8890
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/*
2+
* Copyright 2026 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.google.maps.android.compose.wms
18+
19+
import androidx.compose.runtime.Composable
20+
import androidx.compose.runtime.remember
21+
import com.google.android.gms.maps.model.TileOverlay
22+
import com.google.maps.android.compose.TileOverlay
23+
import com.google.maps.android.compose.TileOverlayState
24+
import com.google.maps.android.compose.rememberTileOverlayState
25+
26+
/**
27+
* A Composable that displays a Web Map Service (WMS) layer using the EPSG:3857 projection.
28+
*
29+
* @param urlFormatter a lambda that returns the WMS URL for the given bounding box coordinates.
30+
* @param state the [TileOverlayState] to be used to control the tile overlay.
31+
* @param fadeIn boolean indicating whether the tiles should fade in.
32+
* @param transparency the transparency of the tile overlay.
33+
* @param visible the visibility of the tile overlay.
34+
* @param zIndex the z-index of the tile overlay.
35+
* @param onClick a lambda invoked when the tile overlay is clicked.
36+
* @param tileWidth the width of the tiles in pixels (default 256).
37+
* @param tileHeight the height of the tiles in pixels (default 256).
38+
*/
39+
@Composable
40+
public fun WmsTileOverlay(
41+
urlFormatter: (xMin: Double, yMin: Double, xMax: Double, yMax: Double, zoom: Int) -> String,
42+
state: TileOverlayState = rememberTileOverlayState(),
43+
fadeIn: Boolean = true,
44+
transparency: Float = 0f,
45+
visible: Boolean = true,
46+
zIndex: Float = 0f,
47+
onClick: (TileOverlay) -> Unit = {},
48+
tileWidth: Int = 256,
49+
tileHeight: Int = 256
50+
) {
51+
val tileProvider = remember(urlFormatter, tileWidth, tileHeight) {
52+
WmsUrlTileProvider(
53+
width = tileWidth,
54+
height = tileHeight,
55+
urlFormatter = urlFormatter
56+
)
57+
}
58+
TileOverlay(
59+
tileProvider = tileProvider,
60+
state = state,
61+
fadeIn = fadeIn,
62+
transparency = transparency,
63+
visible = visible,
64+
zIndex = zIndex,
65+
onClick = onClick
66+
)
67+
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
/*
2+
* Copyright 2026 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.google.maps.android.compose.wms
18+
19+
import com.google.android.gms.maps.model.UrlTileProvider
20+
import java.net.MalformedURLException
21+
import java.net.URL
22+
import kotlin.math.PI
23+
import kotlin.math.pow
24+
25+
/**
26+
* A [UrlTileProvider] for Web Map Service (WMS) layers that use the EPSG:3857 (Web Mercator)
27+
* projection.
28+
*
29+
* @param width the width of the tile in pixels.
30+
* @param height the height of the tile in pixels.
31+
* @param urlFormatter a lambda that returns the WMS URL for the given bounding box coordinates
32+
* (xMin, yMin, xMax, yMax) and zoom level.
33+
*/
34+
public class WmsUrlTileProvider(
35+
width: Int = 256,
36+
height: Int = 256,
37+
private val urlFormatter: (
38+
xMin: Double,
39+
yMin: Double,
40+
xMax: Double,
41+
yMax: Double,
42+
zoom: Int
43+
) -> String
44+
) : UrlTileProvider(width, height) {
45+
46+
override fun getTileUrl(x: Int, y: Int, zoom: Int): URL? {
47+
val bbox = getBoundingBox(x, y, zoom)
48+
val urlString = urlFormatter(bbox[0], bbox[1], bbox[2], bbox[3], zoom)
49+
return try {
50+
URL(urlString)
51+
} catch (e: MalformedURLException) {
52+
null
53+
}
54+
}
55+
56+
private companion object {
57+
/**
58+
* The maximum extent of the Web Mercator projection (EPSG:3857) in meters.
59+
* This is the distance from the origin (0,0) to the edge of the world map.
60+
* Calculated as semi-major axis of Earth (6378137.0) * PI.
61+
*/
62+
private const val WORLD_EXTENT = (6378137.0) * PI
63+
64+
/**
65+
* The total width/height of the world map in meters.
66+
*/
67+
private const val WORLD_SIZE_METERS = 2 * WORLD_EXTENT
68+
}
69+
70+
/**
71+
* Calculates the bounding box for the given tile in EPSG:3857 coordinates.
72+
*
73+
* @return an array containing [xMin, yMin, xMax, yMax] in meters.
74+
*/
75+
internal fun getBoundingBox(x: Int, y: Int, zoom: Int): DoubleArray {
76+
// 1. Calculate how many tiles exist in each dimension at this zoom level (2^zoom).
77+
val tilesPerDimension = 1 shl zoom
78+
79+
// 2. Divide the total world span by the number of tiles to find the metric size of one tile.
80+
val tileSizeMeters = WORLD_SIZE_METERS / tilesPerDimension.toDouble()
81+
82+
// 3. X-axis: Starts at the far left (-WORLD_EXTENT) and moves East.
83+
val xMin = -WORLD_EXTENT + (x * tileSizeMeters)
84+
val xMax = -WORLD_EXTENT + ((x + 1) * tileSizeMeters)
85+
86+
// 4. Y-axis: Google Maps/TMS starts at the Top (y=0 is North) and moves South.
87+
// WMS Bounding Box expects yMin to be the southern-most latitude and yMax to be the northern-most.
88+
// Therefore, we subtract the tile distance from the northern-most edge (+WORLD_EXTENT).
89+
val yMax = WORLD_EXTENT - (y * tileSizeMeters)
90+
val yMin = WORLD_EXTENT - ((y + 1) * tileSizeMeters)
91+
92+
return doubleArrayOf(xMin, yMin, xMax, yMax)
93+
}
94+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
/*
2+
* Copyright 2026 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.google.maps.android.compose.wms
18+
19+
import org.junit.Assert.assertArrayEquals
20+
import org.junit.Test
21+
22+
public class WmsUrlTileProviderTest {
23+
24+
private val worldSize: Double = 6378137.0 * kotlin.math.PI
25+
26+
@Test
27+
public fun testGetBoundingBoxZoom0() {
28+
val provider = WmsUrlTileProvider { _, _, _, _, _ -> "" }
29+
val bbox = provider.getBoundingBox(0, 0, 0)
30+
31+
// Zoom 0, Tile 0,0 should cover the entire world
32+
val expected = doubleArrayOf(-worldSize, -worldSize, worldSize, worldSize)
33+
assertArrayEquals(expected, bbox, 0.001)
34+
}
35+
36+
@Test
37+
public fun testGetBoundingBoxZoom1() {
38+
val provider = WmsUrlTileProvider { _, _, _, _, _ -> "" }
39+
40+
// Zoom 1, Tile 0,0 (Top Left)
41+
val bbox00 = provider.getBoundingBox(0, 0, 1)
42+
val expected00 = doubleArrayOf(-worldSize, 0.0, 0.0, worldSize)
43+
assertArrayEquals(expected00, bbox00, 0.001)
44+
45+
// Zoom 1, Tile 1,1 (Bottom Right)
46+
val bbox11 = provider.getBoundingBox(1, 1, 1)
47+
val expected11 = doubleArrayOf(0.0, -worldSize, worldSize, 0.0)
48+
assertArrayEquals(expected11, bbox11, 0.001)
49+
}
50+
51+
@Test
52+
public fun testGetBoundingBoxSpecificTile() {
53+
val provider = WmsUrlTileProvider { _, _, _, _, _ -> "" }
54+
55+
// Zoom 2, Tile 1,1
56+
// Num tiles = 4x4. Tile size = 2 * worldSize / 4 = worldSize / 2
57+
// xMin = -worldSize + 1 * (worldSize/2) = -worldSize/2
58+
// xMax = -worldSize + 2 * (worldSize/2) = 0
59+
// yMax = worldSize - 1 * (worldSize/2) = worldSize/2
60+
// yMin = worldSize - 2 * (worldSize/2) = 0
61+
val bbox = provider.getBoundingBox(1, 1, 2)
62+
val expected = doubleArrayOf(-worldSize / 2, 0.0, 0.0, worldSize / 2)
63+
assertArrayEquals(expected, bbox, 0.001)
64+
}
65+
}

0 commit comments

Comments
 (0)