From 758f4bc6344ccdacc0679f146785ff0fe526df52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20L=C3=B3pez=20Ma=C3=B1as?= Date: Tue, 15 Apr 2025 23:35:06 +0200 Subject: [PATCH 01/67] chore: removed unnecessary check (#1494) --- .../com/google/maps/android/utils/demo/MainActivity.java | 8 -------- 1 file changed, 8 deletions(-) diff --git a/demo/src/main/java/com/google/maps/android/utils/demo/MainActivity.java b/demo/src/main/java/com/google/maps/android/utils/demo/MainActivity.java index 10ff9d5f7..4f22adbfc 100644 --- a/demo/src/main/java/com/google/maps/android/utils/demo/MainActivity.java +++ b/demo/src/main/java/com/google/maps/android/utils/demo/MainActivity.java @@ -16,15 +16,12 @@ package com.google.maps.android.utils.demo; -import static com.google.maps.android.utils.demo.ApiKeyValidatorKt.hasMapsApiKey; - import android.app.Activity; import android.content.Intent; import android.os.Bundle; import android.view.View; import android.view.ViewGroup; import android.widget.Button; -import android.widget.Toast; import androidx.appcompat.app.AppCompatActivity; @@ -35,11 +32,6 @@ public class MainActivity extends AppCompatActivity implements View.OnClickListe protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); - if (!hasMapsApiKey(this)) { - Toast.makeText(this, R.string.bad_maps_api_key, Toast.LENGTH_LONG).show(); - finish(); - } - setContentView(R.layout.main); mListView = findViewById(R.id.list); From 0c56a37433a73268d8c6369011c937aa092e462c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20L=C3=B3pez=20Ma=C3=B1as?= Date: Tue, 15 Apr 2025 23:35:51 +0200 Subject: [PATCH 02/67] chore: not run lint on push onto main (#1497) --- .github/workflows/lint-report.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/lint-report.yml b/.github/workflows/lint-report.yml index 6efcd6eb7..40b69877c 100644 --- a/.github/workflows/lint-report.yml +++ b/.github/workflows/lint-report.yml @@ -15,9 +15,6 @@ name: Lint and Upload SARIF on: - push: - branches: - - main pull_request: branches: - main From de58abfa7275480c9f572af6cdbd245480de5e64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20L=C3=B3pez=20Ma=C3=B1as?= Date: Thu, 17 Apr 2025 18:47:12 +0200 Subject: [PATCH 03/67] feat: added parameter to modify duration to AnimationUtil (#1505) * feat: added parameter to modify duration to AnimationUtil * feat: link as HTML * feat: added test * feat: headers * feat: headers --- gradle/libs.versions.toml | 1 + library/build.gradle.kts | 1 + .../google/maps/android/ui/AnimationUtil.java | 25 ++++-- .../google/maps/android/AnimationUtilTest.kt | 79 +++++++++++++++++++ 4 files changed, 101 insertions(+), 5 deletions(-) create mode 100644 library/src/test/java/com/google/maps/android/AnimationUtilTest.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 048ed935d..d46d8b828 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -26,6 +26,7 @@ dokka-gradle-plugin = { module = "org.jetbrains.dokka:dokka-gradle-plugin", vers gradle = { module = "com.android.tools.build:gradle", version.ref = "gradle" } jacoco-android = { module = "com.mxalbert.gradle:jacoco-android", version.ref = "jacoco-android" } kotlin-gradle-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } +kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } lifecycle-extensions = { module = "androidx.lifecycle:lifecycle-extensions", version.ref = "lifecycle-extensions" } lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycle-viewmodel-ktx" } kotlin-stdlib-jdk8 = { module = "org.jetbrains.kotlin:kotlin-stdlib-jdk8", version.ref = "kotlin" } diff --git a/library/build.gradle.kts b/library/build.gradle.kts index 6d23185ce..38f34bd7f 100644 --- a/library/build.gradle.kts +++ b/library/build.gradle.kts @@ -68,6 +68,7 @@ dependencies { testImplementation(libs.robolectric) testImplementation(libs.kxml2) testImplementation(libs.mockk) + testImplementation (libs.kotlin.test) implementation(libs.kotlin.stdlib.jdk8) } diff --git a/library/src/main/java/com/google/maps/android/ui/AnimationUtil.java b/library/src/main/java/com/google/maps/android/ui/AnimationUtil.java index f3dee5994..6aad46f0c 100644 --- a/library/src/main/java/com/google/maps/android/ui/AnimationUtil.java +++ b/library/src/main/java/com/google/maps/android/ui/AnimationUtil.java @@ -29,20 +29,35 @@ *

*/ public class AnimationUtil { - + /** * Animates a marker from it's current position to the provided finalPosition * * @param marker marker to animate * @param finalPosition the final position of the marker after the animation */ - public static void animateMarkerTo(final Marker marker, final LatLng finalPosition) { + public static void animateMarkerTo(final Marker marker, final LatLng finalPosition) { + animateMarkerTo(marker, finalPosition, 2000); // delegate to new version + } + + + /** + * Animates a marker from its current position to the provided finalPosition. + * + * @param marker marker to animate + * @param finalPosition the final position of the marker after the animation + * @param durationInMs the duration of the animation in milliseconds + */ + public static void animateMarkerTo( + final Marker marker, + final LatLng finalPosition, + final long durationInMs + ) { final LatLngInterpolator latLngInterpolator = new LatLngInterpolator.Linear(); final LatLng startPosition = marker.getPosition(); final Handler handler = new Handler(); final long start = SystemClock.uptimeMillis(); final Interpolator interpolator = new AccelerateDecelerateInterpolator(); - final float durationInMs = 2000; handler.post(new Runnable() { long elapsed; @@ -55,7 +70,7 @@ public static void animateMarkerTo(final Marker marker, final LatLng finalPositi public void run() { // Calculate progress using interpolator elapsed = SystemClock.uptimeMillis() - start; - t = elapsed / durationInMs; + t = elapsed / (float) durationInMs; v = interpolator.getInterpolation(t); marker.setPosition(latLngInterpolator.interpolate(v, startPosition, finalPosition)); @@ -70,7 +85,7 @@ public void run() { } /** - * For other LatLngInterpolator interpolators, see https://gist.github.com/broady/6314689 + * For other LatLngInterpolator interpolators, see link here */ interface LatLngInterpolator { diff --git a/library/src/test/java/com/google/maps/android/AnimationUtilTest.kt b/library/src/test/java/com/google/maps/android/AnimationUtilTest.kt new file mode 100644 index 000000000..b2816d61e --- /dev/null +++ b/library/src/test/java/com/google/maps/android/AnimationUtilTest.kt @@ -0,0 +1,79 @@ +/* + * Copyright 2025 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 + +import com.google.android.gms.maps.model.LatLng +import com.google.android.gms.maps.model.Marker +import com.google.maps.android.ui.AnimationUtil +import io.mockk.* +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows +import java.util.concurrent.TimeUnit +import kotlin.test.assertEquals + +@RunWith(RobolectricTestRunner::class) +class AnimationUtilTest { + + private lateinit var marker: Marker + private lateinit var currentPosition: LatLng + + @Before + fun setUp() { + marker = mockk(relaxed = true) + + // Initial position + currentPosition = LatLng(0.0, 0.0) + + // Mock the marker position getter and setter + every { marker.position } answers { currentPosition } + every { marker.setPosition(any()) } answers { + currentPosition = firstArg() + Unit + } + } + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun `animateMarkerTo moves marker to final position with a buffer tolerance`() { + val finalPosition = LatLng(10.0, 10.0) + val durationMs = 100L + + // Start the animation + AnimationUtil.animateMarkerTo(marker, finalPosition, durationMs) + + val mainLooper = Shadows.shadowOf(android.os.Looper.getMainLooper()) + + // Simulate time passing in 16ms increments until we exceed the animation duration + var timePassed = 0L + while (timePassed <= durationMs + 100) { // Allowing a little buffer for completion + mainLooper.idleFor(16, TimeUnit.MILLISECONDS) + timePassed += 16 + } + + // Check the final position — allowing a reasonable tolerance (0.5 or more) + assertEquals(10.0, currentPosition.latitude, 0.5) // 0.5 tolerance + assertEquals(10.0, currentPosition.longitude, 0.5) // 0.5 tolerance + } +} From 7427141b70325a5f954b895be49feefcc01f67cc Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Thu, 17 Apr 2025 16:53:40 +0000 Subject: [PATCH 04/67] chore(release): 3.13.0 [skip ci] # [3.13.0](https://github.com/googlemaps/android-maps-utils/compare/v3.12.0...v3.13.0) (2025-04-17) ### Features * added parameter to modify duration to AnimationUtil ([#1505](https://github.com/googlemaps/android-maps-utils/issues/1505)) ([de58abf](https://github.com/googlemaps/android-maps-utils/commit/de58abfa7275480c9f572af6cdbd245480de5e64)) --- README.md | 2 +- build.gradle.kts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f33a2ad3f..4feb5f45d 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ dependencies { // Utilities for Maps SDK for Android (requires Google Play Services) // You do not need to add a separate dependency for the Maps SDK for Android // since this library builds in the compatible version of the Maps SDK. - implementation 'com.google.maps.android:android-maps-utils:3.12.0' + implementation 'com.google.maps.android:android-maps-utils:3.13.0' // Optionally add the Kotlin Extensions (KTX) for full Kotlin language support // See latest version at https://github.com/googlemaps/android-maps-ktx diff --git a/build.gradle.kts b/build.gradle.kts index 8f0b774a5..ece75bdee 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -37,5 +37,5 @@ tasks.register("clean") { allprojects { group = "com.google.maps.android" - version = "3.12.0" + version = "3.13.0" } \ No newline at end of file From b9f8e2ca1a804f134a3a7a05450a10696b91916b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 6 May 2025 08:32:17 +0200 Subject: [PATCH 05/67] build(deps): bump lint from 31.9.1 to 31.9.2 (#1507) Bumps `lint` from 31.9.1 to 31.9.2. Updates `com.android.tools.lint:lint-api` from 31.9.1 to 31.9.2 Updates `com.android.tools.lint:lint-checks` from 31.9.1 to 31.9.2 Updates `com.android.tools.lint:lint` from 31.9.1 to 31.9.2 Updates `com.android.tools.lint:lint-tests` from 31.9.1 to 31.9.2 Updates `com.android.tools:testutils` from 31.9.1 to 31.9.2 --- updated-dependencies: - dependency-name: com.android.tools.lint:lint-api dependency-version: 31.9.2 dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: com.android.tools.lint:lint-checks dependency-version: 31.9.2 dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: com.android.tools.lint:lint dependency-version: 31.9.2 dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: com.android.tools.lint:lint-tests dependency-version: 31.9.2 dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: com.android.tools:testutils dependency-version: 31.9.2 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d46d8b828..5f8661368 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -16,7 +16,7 @@ core-ktx = "1.16.0" robolectric = "4.14.1" kxml2 = "2.3.0" mockk = "1.14.0" -lint = "31.9.1" +lint = "31.9.2" org-jacoco-core = "0.8.13" material = "1.12.0" From fa3de0ce00af6179b6b03affea21a54ec6159db3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 6 May 2025 08:50:55 +0200 Subject: [PATCH 06/67] build(deps): bump io.mockk:mockk from 1.14.0 to 1.14.2 (#1508) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [io.mockk:mockk](https://github.com/mockk/mockk) from 1.14.0 to 1.14.2. - [Release notes](https://github.com/mockk/mockk/releases) - [Commits](https://github.com/mockk/mockk/compare/1.14.0...1.14.2) --- updated-dependencies: - dependency-name: io.mockk:mockk dependency-version: 1.14.2 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Enrique López Mañas --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5f8661368..9323f40a9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -15,7 +15,7 @@ play-services-maps = "19.2.0" core-ktx = "1.16.0" robolectric = "4.14.1" kxml2 = "2.3.0" -mockk = "1.14.0" +mockk = "1.14.2" lint = "31.9.2" org-jacoco-core = "0.8.13" material = "1.12.0" From cfe7a365aa8aa0f874ff689ebf9fce5537b1ae60 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 13 May 2025 15:07:26 +0200 Subject: [PATCH 07/67] build(deps): bump lint from 31.9.2 to 31.10.0 (#1512) Bumps `lint` from 31.9.2 to 31.10.0. Updates `com.android.tools.lint:lint-api` from 31.9.2 to 31.10.0 Updates `com.android.tools.lint:lint-checks` from 31.9.2 to 31.10.0 Updates `com.android.tools.lint:lint` from 31.9.2 to 31.10.0 Updates `com.android.tools.lint:lint-tests` from 31.9.2 to 31.10.0 Updates `com.android.tools:testutils` from 31.9.2 to 31.10.0 --- updated-dependencies: - dependency-name: com.android.tools.lint:lint-api dependency-version: 31.10.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: com.android.tools.lint:lint-checks dependency-version: 31.10.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: com.android.tools.lint:lint dependency-version: 31.10.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: com.android.tools.lint:lint-tests dependency-version: 31.10.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: com.android.tools:testutils dependency-version: 31.10.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9323f40a9..b8ad2168d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -16,7 +16,7 @@ core-ktx = "1.16.0" robolectric = "4.14.1" kxml2 = "2.3.0" mockk = "1.14.2" -lint = "31.9.2" +lint = "31.10.0" org-jacoco-core = "0.8.13" material = "1.12.0" From 506759f0f1870babee8e9f8086ee85b111ec2678 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 13 May 2025 17:07:43 +0200 Subject: [PATCH 08/67] build(deps): bump androidx.lifecycle:lifecycle-viewmodel-ktx (#1513) Bumps androidx.lifecycle:lifecycle-viewmodel-ktx from 2.8.7 to 2.9.0. --- updated-dependencies: - dependency-name: androidx.lifecycle:lifecycle-viewmodel-ktx dependency-version: 2.9.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b8ad2168d..eb6e15d39 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,7 +4,7 @@ dokka-gradle-plugin = "2.0.0" gradle = "8.9.1" jacoco-android = "0.2.1" lifecycle-extensions = "2.2.0" -lifecycle-viewmodel-ktx = "2.8.7" +lifecycle-viewmodel-ktx = "2.9.0" kotlin = "2.1.20" kotlinx-coroutines = "1.10.2" junit = "4.13.2" From d035c4e146269faa0819f51a861556dc24e5ad9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20L=C3=B3pez-Ma=C3=B1as?= Date: Tue, 3 Jun 2025 16:40:00 +0200 Subject: [PATCH 09/67] docs: update README.md to remove mentions to the v1 migration (#1521) --- README.md | 219 ------------------------------------------------------ 1 file changed, 219 deletions(-) diff --git a/README.md b/README.md index 4feb5f45d..5fa18c816 100644 --- a/README.md +++ b/README.md @@ -123,225 +123,6 @@ By default, the `Source` is set to `Source.DEFAULT`, but you can also specify `S -

- Migration Guide from v0.x to 1.0 - -### Migrating from v0.x to 1.0 - -Improvements made in version [1.0.0](https://github.com/googlemaps/android-maps-utils/releases/tag/1.0.0) of the library to support multiple layers on the map caused breaking changes to versions prior to it. These changes also modify behaviors that are documented in the [Maps SDK for Android Maps documentation](https://developers.google.com/maps/documentation/android-sdk/intro) site. This section outlines all those changes and how you can migrate to use this library since version 1.0.0. - - -### Adding Click Events - -Click events originate in the layer-specific object that added the marker/ground overlay/polyline/polygon. In each layer, the click handlers are passed to the marker, ground overlay, polyline, or polygon `Collection` object. - -```java -// Clustering -ClusterManager clusterManager = // Initialize ClusterManager - if you're using multiple maps features, use the constructor that passes in Manager objects (see next section) -clusterManager.setOnClusterItemClickListener(item -> { - // Listen for clicks on a cluster item here - return false; -}); -clusterManager.setOnClusterClickListener(item -> { - // Listen for clicks on a cluster here - return false; -}); - -// GeoJson -GeoJsonLayer geoJsonLayer = // Initialize GeoJsonLayer - if you're using multiple maps features, use the constructor that passes in Manager objects (see next section) -geoJsonLayer.setOnFeatureClickListener(feature -> { - // Listen for clicks on GeoJson features here -}); - -// KML -KmlLayer kmlLayer = // Initialize KmlLayer - if you're using multiple maps features, use the constructor that passes in Manager objects (see next section) -kmlLayer.setOnFeatureClickListener(feature -> { - // Listen for clicks on KML features here -}); -``` - -#### Using Manager Objects - -If you use one of Manager objects in the package `com.google.maps.android` (e.g. `GroundOverlayManager`, `MarkerManager`, etc.), say from adding a KML layer, GeoJson layer, or Clustering, you will have to rely on the Collection specific to add an object to the map rather than adding that object directly to `GoogleMap`. This is because each Manager sets itself as a click listener so that it can manage click events coming from multiple layers. - -For example, if you have additional `GroundOverlay` objects: - -_New_ - -```java -GroundOverlayManager groundOverlayManager = // Initialize - -// Create a new collection first -GroundOverlayManager.Collection groundOverlayCollection = groundOverlayManager.newCollection(); - -// Add a new ground overlay -GroundOverlayOptions options = // ... -groundOverlayCollection.addGroundOverlay(options); -``` - -_Old_ - -```java -GroundOverlayOptions options = // ... -googleMap.addGroundOverlay(options); -``` - -This same pattern applies for `Marker`, `Circle`, `Polyline`, and `Polygon`. - -### Adding a Custom Info Window -If you use `MarkerManager`, adding an `InfoWindowAdapter` and/or an `OnInfoWindowClickListener` should be done on the `MarkerManager.Collection` object. - -_New_ -```java -CustomInfoWindowAdapter adapter = // ... -OnInfoWindowClickListener listener = // ... - -// Create a new Collection from a MarkerManager -MarkerManager markerManager = // ... -MarkerManager.Collection collection = markerManager.newCollection(); - -// Set InfoWindowAdapter and OnInfoWindowClickListener -collection.setInfoWindowAdapter(adapter); -collection.setOnInfoWindowClickListener(listener); - -// Alternatively, if you are using clustering -ClusterManager clusterManager = // ... -MarkerManager.Collection markerCollection = clusterManager.getMarkerCollection(); -markerCollection.setInfoWindowAdapter(adapter); -markerCollection.setOnInfoWindowClickListener(listener); -``` - -_Old_ -```java -CustomInfoWindowAdapter adapter = // ... -OnInfoWindowClickListener listener = // ... -googleMap.setInfoWindowAdapter(adapter); -googleMap.setOnInfoWindowClickListener(listener); -``` - -### Adding a Marker Drag Listener - -If you use `MarkerManager`, adding an `OnMarkerDragListener` should be done on the `MarkerManager.Collection` object. - -_New_ -```java -// Create a new Collection from a MarkerManager -MarkerManager markerManager = // ... -MarkerManager.Collection collection = markerManager.newCollection(); - -// Add markers to collection -MarkerOptions markerOptions = // ... -collection.addMarker(markerOptions); -// ... - -// Set OnMarkerDragListener -GoogleMap.OnMarkerDragListener listener = // ... -collection.setOnMarkerDragListener(listener); - -// Alternatively, if you are using clustering -ClusterManager clusterManager = // ... -MarkerManager.Collection markerCollection = clusterManager.getMarkerCollection(); -markerCollection.setOnMarkerDragListener(listener); -``` - -_Old_ -```java -// Add markers -MarkerOptions markerOptions = // ... -googleMap.addMarker(makerOptions); - -// Add listener -GoogleMap.OnMarkerDragListener listener = // ... -googleMap.setOnMarkerDragListener(listener); -``` - -### Clustering - -[A bug](https://github.com/googlemaps/android-maps-utils/issues/90) was fixed in v1 to properly clear and re-add markers via the `ClusterManager`. - -For example, this didn't work pre-v1, but works for v1 and later: - -```java -clusterManager.clearItems(); -clusterManager.addItems(items); -clusterManager.cluster(); -``` - -If you're using custom clustering (i.e, if you're extending `DefaultClusterRenderer`), you must override two additional methods in v1: -* `onClusterItemUpdated()` - should be the same* as your `onBeforeClusterItemRendered()` method -* `onClusterUpdated()` - should be the same* as your `onBeforeClusterRendered()` method - -**Note that these methods can't be identical, as you need to use a `Marker` instead of `MarkerOptions`* - -See the [`CustomMarkerClusteringDemoActivity`](demo/src/main/java/com/google/maps/android/utils/demo/CustomMarkerClusteringDemoActivity.java) in the demo app for a complete example. - -_New_ - -```java - private class PersonRenderer extends DefaultClusterRenderer { - ... - @Override - protected void onBeforeClusterItemRendered(Person person, MarkerOptions markerOptions) { - // Draw a single person - show their profile photo and set the info window to show their name - markerOptions - .icon(getItemIcon(person)) - .title(person.name); - } - - /** - * New in v1 - */ - @Override - protected void onClusterItemUpdated(Person person, Marker marker) { - // Same implementation as onBeforeClusterItemRendered() (to update cached markers) - marker.setIcon(getItemIcon(person)); - marker.setTitle(person.name); - } - - @Override - protected void onBeforeClusterRendered(Cluster cluster, MarkerOptions markerOptions) { - // Draw multiple people. - // Note: this method runs on the UI thread. Don't spend too much time in here (like in this example). - markerOptions.icon(getClusterIcon(cluster)); - } - - /** - * New in v1 - */ - @Override - protected void onClusterUpdated(Cluster cluster, Marker marker) { - // Same implementation as onBeforeClusterRendered() (to update cached markers) - marker.setIcon(getClusterIcon(cluster)); - } - ... - } -``` - -_Old_ - -```java - private class PersonRenderer extends DefaultClusterRenderer { - ... - @Override - protected void onBeforeClusterItemRendered(Person person, MarkerOptions markerOptions) { - // Draw a single person - show their profile photo and set the info window to show their name - markerOptions - .icon(getItemIcon(person)) - .title(person.name); - } - - @Override - protected void onBeforeClusterRendered(Cluster cluster, MarkerOptions markerOptions) { - // Draw multiple people. - // Note: this method runs on the UI thread. Don't spend too much time in here (like in this example). - markerOptions.icon(getClusterIcon(cluster)); - } - ... - } -``` - -
- ## Contributing Contributions are welcome and encouraged! If you'd like to contribute, send us a [pull request] and refer to our [code of conduct] and [contributing guide]. From 8b57c3a068a3e2a26174bd154161e491b60b04af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20L=C3=B3pez-Ma=C3=B1as?= Date: Wed, 4 Jun 2025 00:52:45 +0200 Subject: [PATCH 10/67] feat: added centroid algorithm (#1520) * feat: added CentroidNonHierarchicalDistanceBasedAlgorithm.java * feat: added a test * docs: header --- ...NonHierarchicalDistanceBasedAlgorithm.java | 82 +++++++++++++++++++ ...ierarchicalDistanceBasedAlgorithmTest.java | 80 ++++++++++++++++++ 2 files changed, 162 insertions(+) create mode 100644 library/src/main/java/com/google/maps/android/clustering/algo/CentroidNonHierarchicalDistanceBasedAlgorithm.java create mode 100644 library/src/test/java/com/google/maps/android/clustering/algo/CentroidNonHierarchicalDistanceBasedAlgorithmTest.java diff --git a/library/src/main/java/com/google/maps/android/clustering/algo/CentroidNonHierarchicalDistanceBasedAlgorithm.java b/library/src/main/java/com/google/maps/android/clustering/algo/CentroidNonHierarchicalDistanceBasedAlgorithm.java new file mode 100644 index 000000000..7e7ac329c --- /dev/null +++ b/library/src/main/java/com/google/maps/android/clustering/algo/CentroidNonHierarchicalDistanceBasedAlgorithm.java @@ -0,0 +1,82 @@ +/* + * Copyright 2025 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.clustering.algo; + +import com.google.android.gms.maps.model.LatLng; +import com.google.maps.android.clustering.Cluster; +import com.google.maps.android.clustering.ClusterItem; + +import java.util.Collection; +import java.util.HashSet; +import java.util.Set; + +/** + * A variant of {@link NonHierarchicalDistanceBasedAlgorithm} that clusters items + * based on distance but assigns cluster positions at the centroid of their items, + * instead of using the position of a single item as the cluster position. + * + *

This algorithm overrides {@link #getClusters(float)} to compute a geographic centroid + * for each cluster and creates {@link StaticCluster} instances positioned at these centroids. + * This can provide a more accurate visual representation of the cluster location.

+ * + * @param the type of cluster item + */ +public class CentroidNonHierarchicalDistanceBasedAlgorithm + extends NonHierarchicalDistanceBasedAlgorithm { + + /** + * Computes the centroid (average latitude and longitude) of a collection of cluster items. + * + * @param items the collection of cluster items to compute the centroid for + * @return the centroid {@link LatLng} of the items + */ + protected LatLng computeCentroid(Collection items) { + double latSum = 0; + double lngSum = 0; + int count = 0; + for (T item : items) { + latSum += item.getPosition().latitude; + lngSum += item.getPosition().longitude; + count++; + } + return new LatLng(latSum / count, lngSum / count); + } + + /** + * Returns clusters of items for the given zoom level, with cluster positions + * set to the centroid of their constituent items rather than the position of + * any single item. + * + * @param zoom the current zoom level + * @return a set of clusters with centroid positions + */ + @Override + public Set> getClusters(float zoom) { + Set> originalClusters = super.getClusters(zoom); + Set> newClusters = new HashSet<>(); + + for (Cluster cluster : originalClusters) { + LatLng centroid = computeCentroid(cluster.getItems()); + StaticCluster newCluster = new StaticCluster<>(centroid); + for (T item : cluster.getItems()) { + newCluster.add(item); + } + newClusters.add(newCluster); + } + return newClusters; + } +} diff --git a/library/src/test/java/com/google/maps/android/clustering/algo/CentroidNonHierarchicalDistanceBasedAlgorithmTest.java b/library/src/test/java/com/google/maps/android/clustering/algo/CentroidNonHierarchicalDistanceBasedAlgorithmTest.java new file mode 100644 index 000000000..ddcd89e6b --- /dev/null +++ b/library/src/test/java/com/google/maps/android/clustering/algo/CentroidNonHierarchicalDistanceBasedAlgorithmTest.java @@ -0,0 +1,80 @@ +/* + * Copyright 2025 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.clustering.algo; + +import com.google.android.gms.maps.model.LatLng; +import com.google.maps.android.clustering.ClusterItem; + +import org.junit.Test; + +import java.util.Arrays; +import java.util.Collection; + +import static org.junit.Assert.assertEquals; + +import androidx.annotation.NonNull; + +public class CentroidNonHierarchicalDistanceBasedAlgorithmTest { + + static class TestClusterItem implements ClusterItem { + private final LatLng position; + + TestClusterItem(double lat, double lng) { + this.position = new LatLng(lat, lng); + } + + @NonNull + @Override + public LatLng getPosition() { + return position; + } + + @Override + public String getTitle() { + return null; + } + + @Override + public String getSnippet() { + return null; + } + + @Override + public Float getZIndex() { + return 0f; + } + } + + + + @Test + public void testComputeCentroid() { + CentroidNonHierarchicalDistanceBasedAlgorithm algo = + new CentroidNonHierarchicalDistanceBasedAlgorithm<>(); + + Collection items = Arrays.asList( + new TestClusterItem(10.0, 20.0), + new TestClusterItem(20.0, 30.0), + new TestClusterItem(30.0, 40.0) + ); + + LatLng centroid = algo.computeCentroid(items); + + assertEquals(20.0, centroid.latitude, 0.0001); + assertEquals(30.0, centroid.longitude, 0.0001); + } +} From 65498bad9de5b13beb71674d1c7f4b40a07edb01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20L=C3=B3pez-Ma=C3=B1as?= Date: Wed, 4 Jun 2025 00:55:08 +0200 Subject: [PATCH 11/67] feat: added logger in ClusterRendererMultipleItems (#1519) * feat: added logger in ClusterRendererMultipleItems * docs: header * docs: header * fix: not needed --- .../google/maps/android/RendererLogger.java | 91 +++++++++++++++++++ .../clustering/algo/GridBasedAlgorithm.java | 8 +- .../view/ClusterRendererMultipleItems.java | 65 +++++++++++-- 3 files changed, 150 insertions(+), 14 deletions(-) create mode 100644 library/src/main/java/com/google/maps/android/RendererLogger.java diff --git a/library/src/main/java/com/google/maps/android/RendererLogger.java b/library/src/main/java/com/google/maps/android/RendererLogger.java new file mode 100644 index 000000000..2209ea45e --- /dev/null +++ b/library/src/main/java/com/google/maps/android/RendererLogger.java @@ -0,0 +1,91 @@ +/* + * Copyright 2025 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; + +import android.util.Log; + +/** + * Utility class for logging renderer-related debug output. + * + *

Use {@link #setEnabled(boolean)} to toggle logging globally. + * This class avoids the need for scattered conditionals in the codebase.

+ */ +public final class RendererLogger { + + private static boolean enabled = false; + + private RendererLogger() { + // Prevent instantiation + } + + /** + * Enables or disables logging. + * + * @param value {@code true} to enable logging; {@code false} to disable it. + */ + public static void setEnabled(boolean value) { + enabled = value; + } + + /** + * Logs a debug message if logging is enabled. + * + * @param tag Tag for the log message. + * @param message The debug message to log. + */ + public static void d(String tag, String message) { + if (enabled) { + Log.d(tag, message); + } + } + + /** + * Logs an info message if logging is enabled. + * + * @param tag Tag for the log message. + * @param message The info message to log. + */ + public static void i(String tag, String message) { + if (enabled) { + Log.i(tag, message); + } + } + + /** + * Logs a warning message if logging is enabled. + * + * @param tag Tag for the log message. + * @param message The warning message to log. + */ + public static void w(String tag, String message) { + if (enabled) { + Log.w(tag, message); + } + } + + /** + * Logs an error message if logging is enabled. + * + * @param tag Tag for the log message. + * @param message The error message to log. + */ + public static void e(String tag, String message) { + if (enabled) { + Log.e(tag, message); + } + } +} diff --git a/library/src/main/java/com/google/maps/android/clustering/algo/GridBasedAlgorithm.java b/library/src/main/java/com/google/maps/android/clustering/algo/GridBasedAlgorithm.java index 78daeff14..19f19880e 100644 --- a/library/src/main/java/com/google/maps/android/clustering/algo/GridBasedAlgorithm.java +++ b/library/src/main/java/com/google/maps/android/clustering/algo/GridBasedAlgorithm.java @@ -44,7 +44,7 @@ public class GridBasedAlgorithm extends AbstractAlgorithm private int mGridSize = DEFAULT_GRID_SIZE; - private final Set mItems = Collections.synchronizedSet(new HashSet()); + private final Set mItems = Collections.synchronizedSet(new HashSet<>()); /** * Adds an item to the algorithm @@ -126,8 +126,8 @@ public Set> getClusters(float zoom) { long numCells = (long) Math.ceil(256 * Math.pow(2, zoom) / mGridSize); SphericalMercatorProjection proj = new SphericalMercatorProjection(numCells); - HashSet> clusters = new HashSet>(); - LongSparseArray> sparseArray = new LongSparseArray>(); + HashSet> clusters = new HashSet<>(); + LongSparseArray> sparseArray = new LongSparseArray<>(); synchronized (mItems) { for (T item : mItems) { @@ -137,7 +137,7 @@ public Set> getClusters(float zoom) { StaticCluster cluster = sparseArray.get(coord); if (cluster == null) { - cluster = new StaticCluster(proj.toLatLng(new Point(Math.floor(p.x) + .5, Math.floor(p.y) + .5))); + cluster = new StaticCluster<>(proj.toLatLng(new Point(Math.floor(p.x) + .5, Math.floor(p.y) + .5))); sparseArray.put(coord, cluster); clusters.add(cluster); } diff --git a/library/src/main/java/com/google/maps/android/clustering/view/ClusterRendererMultipleItems.java b/library/src/main/java/com/google/maps/android/clustering/view/ClusterRendererMultipleItems.java index f5462587c..9b1c5adf2 100644 --- a/library/src/main/java/com/google/maps/android/clustering/view/ClusterRendererMultipleItems.java +++ b/library/src/main/java/com/google/maps/android/clustering/view/ClusterRendererMultipleItems.java @@ -52,6 +52,7 @@ import com.google.android.gms.maps.model.Marker; import com.google.android.gms.maps.model.MarkerOptions; import com.google.maps.android.R; +import com.google.maps.android.RendererLogger; import com.google.maps.android.clustering.Cluster; import com.google.maps.android.clustering.ClusterItem; import com.google.maps.android.clustering.ClusterManager; @@ -107,10 +108,7 @@ public enum AnimationType { public void setAnimationType(AnimationType type) { switch (type) { - case LINEAR: - animationInterp = new LinearInterpolator(); - break; - case EASE_IN: + case EASE_IN, ACCELERATE: animationInterp = new AccelerateInterpolator(); break; case EASE_OUT: @@ -125,9 +123,6 @@ public void setAnimationType(AnimationType type) { case BOUNCE: animationInterp = new BounceInterpolator(); break; - case ACCELERATE: - animationInterp = new AccelerateInterpolator(); - break; case DECELERATE: animationInterp = new DecelerateInterpolator(); break; @@ -196,35 +191,47 @@ public ClusterRendererMultipleItems(Context context, GoogleMap map, ClusterManag @Override public void onAdd() { - mClusterManager.getMarkerCollection().setOnMarkerClickListener(marker -> mItemClickListener != null && mItemClickListener.onClusterItemClick(mMarkerCache.get(marker))); + RendererLogger.d("ClusterRenderer", "Setting up MarkerCollection listeners"); + + mClusterManager.getMarkerCollection().setOnMarkerClickListener(marker -> { + RendererLogger.d("ClusterRenderer", "Marker clicked: " + marker); + return mItemClickListener != null && mItemClickListener.onClusterItemClick(mMarkerCache.get(marker)); + }); mClusterManager.getMarkerCollection().setOnInfoWindowClickListener(marker -> { + RendererLogger.d("ClusterRenderer", "Info window clicked for marker: " + marker); if (mItemInfoWindowClickListener != null) { mItemInfoWindowClickListener.onClusterItemInfoWindowClick(mMarkerCache.get(marker)); } }); mClusterManager.getMarkerCollection().setOnInfoWindowLongClickListener(marker -> { + RendererLogger.d("ClusterRenderer", "Info window long-clicked for marker: " + marker); if (mItemInfoWindowLongClickListener != null) { mItemInfoWindowLongClickListener.onClusterItemInfoWindowLongClick(mMarkerCache.get(marker)); } }); + RendererLogger.d("ClusterRenderer", "Setting up ClusterMarkerCollection listeners"); + mClusterManager.getClusterMarkerCollection().setOnMarkerClickListener(marker -> mClickListener != null && mClickListener.onClusterClick(mClusterMarkerCache.get(marker))); mClusterManager.getClusterMarkerCollection().setOnInfoWindowClickListener(marker -> { + RendererLogger.d("ClusterRenderer", "Info window clicked for cluster marker: " + marker); if (mInfoWindowClickListener != null) { mInfoWindowClickListener.onClusterInfoWindowClick(mClusterMarkerCache.get(marker)); } }); mClusterManager.getClusterMarkerCollection().setOnInfoWindowLongClickListener(marker -> { + RendererLogger.d("ClusterRenderer", "Info window long-clicked for cluster marker: " + marker); if (mInfoWindowLongClickListener != null) { mInfoWindowLongClickListener.onClusterInfoWindowLongClick(mClusterMarkerCache.get(marker)); } }); } + @Override public void onRemove() { mClusterManager.getMarkerCollection().setOnMarkerClickListener(null); @@ -270,6 +277,19 @@ public int getClusterTextAppearance(int clusterSize) { return R.style.amu_ClusterIcon_TextAppearance; // Default value } + /** + * Enables or disables logging for the cluster renderer. + * + *

When enabled, the renderer will log internal operations such as cluster rendering, + * marker updates, and other debug information. This is useful for development and debugging, + * but should typically be disabled in production builds.

+ * + * @param enabled {@code true} to enable logging; {@code false} to disable it. + */ + public void setLoggingEnabled(boolean enabled) { + RendererLogger.setEnabled(enabled); + } + @NonNull protected String getClusterText(int bucket) { if (bucket < BUCKETS[0]) { @@ -447,8 +467,9 @@ public void run() { try { visibleBounds = mProjection.getVisibleRegion().latLngBounds; + RendererLogger.d("ClusterRenderer", "Visible bounds calculated: " + visibleBounds); } catch (Exception e) { - e.printStackTrace(); + RendererLogger.e("ClusterRenderer", "Error getting visible bounds, defaulting to (0,0)"); visibleBounds = LatLngBounds.builder().include(new LatLng(0, 0)).build(); } @@ -462,6 +483,7 @@ public void run() { existingClustersOnScreen.add(point); } } + RendererLogger.d("ClusterRenderer", "Existing clusters on screen found: " + existingClustersOnScreen.size()); } // Create the new markers and animate them to their new positions. @@ -474,20 +496,25 @@ public void run() { if (closest != null) { LatLng animateFrom = mSphericalMercatorProjection.toLatLng(closest); markerModifier.add(true, new CreateMarkerTask(c, newMarkers, animateFrom)); + RendererLogger.d("ClusterRenderer", "Animating cluster from closest cluster: " + c.getPosition()); } else { markerModifier.add(true, new CreateMarkerTask(c, newMarkers, null)); + RendererLogger.d("ClusterRenderer", "Animating cluster without closest point: " + c.getPosition()); } } else { markerModifier.add(onScreen, new CreateMarkerTask(c, newMarkers, null)); + RendererLogger.d("ClusterRenderer", "Adding cluster without animation: " + c.getPosition()); } } // Wait for all markers to be added. markerModifier.waitUntilFree(); + RendererLogger.d("ClusterRenderer", "All new markers added, count: " + newMarkers.size()); // Don't remove any markers that were just added. This is basically anything that had a hit in the MarkerCache. markersToRemove.removeAll(newMarkers); + RendererLogger.d("ClusterRenderer", "Markers to remove after filtering new markers: " + markersToRemove.size()); // Find all of the new clusters that were added on-screen. These are candidates for markers to animate from. List newClustersOnScreen = null; @@ -499,6 +526,7 @@ public void run() { newClustersOnScreen.add(p); } } + RendererLogger.d("ClusterRenderer", "New clusters on screen found: " + newClustersOnScreen.size()); } for (final MarkerWithPosition marker : markersToRemove) { @@ -509,6 +537,7 @@ public void run() { if (closest != null) { LatLng animateTo = mSphericalMercatorProjection.toLatLng(closest); markerModifier.animateThenRemove(marker, marker.position, animateTo); + RendererLogger.d("ClusterRenderer", "Animating then removing marker at position: " + marker.position); } else if (mClusterMarkerCache.mCache.keySet().iterator().hasNext() && mClusterMarkerCache.mCache.keySet().iterator().next().getItems().contains(marker.clusterItem)) { T foundItem = null; for (Cluster cluster : mClusterMarkerCache.mCache.keySet()) { @@ -518,20 +547,23 @@ public void run() { break; } } - } // Remove it because it will join a cluster markerModifier.animateThenRemove(marker, marker.position, foundItem.getPosition()); + RendererLogger.d("ClusterRenderer", "Animating then removing marker joining cluster at position: " + marker.position); } else { markerModifier.remove(true, marker.marker); + RendererLogger.d("ClusterRenderer", "Removing marker without animation at position: " + marker.position); } } else { markerModifier.remove(onScreen, marker.marker); + RendererLogger.d("ClusterRenderer", "Removing marker (onScreen=" + onScreen + ") at position: " + marker.position); } } // Wait until all marker removal operations are completed. markerModifier.waitUntilFree(); + RendererLogger.d("ClusterRenderer", "All marker removal operations completed."); mMarkers = newMarkers; ClusterRendererMultipleItems.this.mClusters = clusters; @@ -539,6 +571,7 @@ public void run() { // Run the callback once everything is done. mCallback.run(); + RendererLogger.d("ClusterRenderer", "Cluster update callback executed."); } } @@ -1076,13 +1109,16 @@ public CreateMarkerTask(Cluster c, Set markersAdded, LatL private void perform(MarkerModifier markerModifier) { // Don't show small clusters. Render the markers inside, instead. if (!shouldRenderAsCluster(cluster)) { + RendererLogger.d("ClusterRenderer", "Rendering individual cluster items, count: " + cluster.getItems().size()); for (T item : cluster.getItems()) { Marker marker = mMarkerCache.get(item); MarkerWithPosition markerWithPosition; LatLng currentLocation = item.getPosition(); if (marker == null) { + RendererLogger.d("ClusterRenderer", "Creating new marker for cluster item at position: " + currentLocation); MarkerOptions markerOptions = new MarkerOptions(); if (animateFrom != null) { + RendererLogger.d("ClusterRenderer", "Animating from position: " + animateFrom); markerOptions.position(animateFrom); } else if (mClusterMarkerCache.mCache.keySet().iterator().hasNext() && mClusterMarkerCache.mCache.keySet().iterator().next().getItems().contains(item)) { T foundItem = null; @@ -1095,6 +1131,7 @@ private void perform(MarkerModifier markerModifier) { } } currentLocation = foundItem.getPosition(); + RendererLogger.d("ClusterRenderer", "Found item in cache for animation at position: " + currentLocation); markerOptions.position(currentLocation); } else { markerOptions.position(item.getPosition()); @@ -1108,13 +1145,17 @@ private void perform(MarkerModifier markerModifier) { mMarkerCache.put(item, marker); if (animateFrom != null) { markerModifier.animate(markerWithPosition, animateFrom, item.getPosition()); + RendererLogger.d("ClusterRenderer", "Animating marker from " + animateFrom + " to " + item.getPosition()); } else if (currentLocation != null) { markerModifier.animate(markerWithPosition, currentLocation, item.getPosition()); + RendererLogger.d("ClusterRenderer", "Animating marker from " + currentLocation + " to " + item.getPosition()); } } else { markerWithPosition = new MarkerWithPosition<>(marker, item); markerModifier.animate(markerWithPosition, marker.getPosition(), item.getPosition()); + RendererLogger.d("ClusterRenderer", "Animating existing marker from " + marker.getPosition() + " to " + item.getPosition()); if (!markerWithPosition.position.equals(item.getPosition())) { + RendererLogger.d("ClusterRenderer", "Updating cluster item marker position"); onClusterItemUpdated(item, marker); } } @@ -1125,9 +1166,11 @@ private void perform(MarkerModifier markerModifier) { } // Handle cluster markers + RendererLogger.d("ClusterRenderer", "Rendering cluster marker at position: " + cluster.getPosition()); Marker marker = mClusterMarkerCache.get(cluster); MarkerWithPosition markerWithPosition; if (marker == null) { + RendererLogger.d("ClusterRenderer", "Creating new cluster marker"); MarkerOptions markerOptions = new MarkerOptions().position(animateFrom == null ? cluster.getPosition() : animateFrom); onBeforeClusterRendered(cluster, markerOptions); marker = mClusterManager.getClusterMarkerCollection().addMarker(markerOptions); @@ -1135,9 +1178,11 @@ private void perform(MarkerModifier markerModifier) { markerWithPosition = new MarkerWithPosition(marker, null); if (animateFrom != null) { markerModifier.animate(markerWithPosition, animateFrom, cluster.getPosition()); + RendererLogger.d("ClusterRenderer", "Animating cluster marker from " + animateFrom + " to " + cluster.getPosition()); } } else { markerWithPosition = new MarkerWithPosition(marker, null); + RendererLogger.d("ClusterRenderer", "Updating existing cluster marker"); onClusterUpdated(cluster, marker); } onClusterRendered(cluster, marker); From 3ba4f98b7d56034e5c318968d81c107389739b99 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Tue, 3 Jun 2025 23:01:50 +0000 Subject: [PATCH 12/67] chore(release): 3.14.0 [skip ci] # [3.14.0](https://github.com/googlemaps/android-maps-utils/compare/v3.13.0...v3.14.0) (2025-06-03) ### Features * added centroid algorithm ([#1520](https://github.com/googlemaps/android-maps-utils/issues/1520)) ([8b57c3a](https://github.com/googlemaps/android-maps-utils/commit/8b57c3a068a3e2a26174bd154161e491b60b04af)) * added logger in ClusterRendererMultipleItems ([#1519](https://github.com/googlemaps/android-maps-utils/issues/1519)) ([65498ba](https://github.com/googlemaps/android-maps-utils/commit/65498bad9de5b13beb71674d1c7f4b40a07edb01)) --- README.md | 2 +- build.gradle.kts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 5fa18c816..d462401b5 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ dependencies { // Utilities for Maps SDK for Android (requires Google Play Services) // You do not need to add a separate dependency for the Maps SDK for Android // since this library builds in the compatible version of the Maps SDK. - implementation 'com.google.maps.android:android-maps-utils:3.13.0' + implementation 'com.google.maps.android:android-maps-utils:3.14.0' // Optionally add the Kotlin Extensions (KTX) for full Kotlin language support // See latest version at https://github.com/googlemaps/android-maps-ktx diff --git a/build.gradle.kts b/build.gradle.kts index ece75bdee..ab894b2dc 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -37,5 +37,5 @@ tasks.register("clean") { allprojects { group = "com.google.maps.android" - version = "3.13.0" + version = "3.14.0" } \ No newline at end of file From 6d2820e2a9148637ef9c6b6fee6f611e611f7776 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 10 Jun 2025 09:04:38 +0200 Subject: [PATCH 13/67] build(deps): bump androidx.lifecycle:lifecycle-viewmodel-ktx (#1523) Bumps androidx.lifecycle:lifecycle-viewmodel-ktx from 2.9.0 to 2.9.1. --- updated-dependencies: - dependency-name: androidx.lifecycle:lifecycle-viewmodel-ktx dependency-version: 2.9.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index eb6e15d39..73faebc5c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,7 +4,7 @@ dokka-gradle-plugin = "2.0.0" gradle = "8.9.1" jacoco-android = "0.2.1" lifecycle-extensions = "2.2.0" -lifecycle-viewmodel-ktx = "2.9.0" +lifecycle-viewmodel-ktx = "2.9.1" kotlin = "2.1.20" kotlinx-coroutines = "1.10.2" junit = "4.13.2" From 816471fb30a3bc89ee2f594343614852ef1fcd2d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 10 Jun 2025 09:11:34 +0200 Subject: [PATCH 14/67] build(deps): bump org.mockito:mockito-core from 5.17.0 to 5.18.0 (#1515) Bumps [org.mockito:mockito-core](https://github.com/mockito/mockito) from 5.17.0 to 5.18.0. - [Release notes](https://github.com/mockito/mockito/releases) - [Commits](https://github.com/mockito/mockito/compare/v5.17.0...v5.18.0) --- updated-dependencies: - dependency-name: org.mockito:mockito-core dependency-version: 5.18.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 73faebc5c..33b61fd6d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,7 +8,7 @@ lifecycle-viewmodel-ktx = "2.9.1" kotlin = "2.1.20" kotlinx-coroutines = "1.10.2" junit = "4.13.2" -mockito-core = "5.17.0" +mockito-core = "5.18.0" secrets-gradle-plugin = "2.0.1" truth = "1.4.4" play-services-maps = "19.2.0" From b0da0c8322d2ee471835cf97f29d5f00760332d0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 10 Jun 2025 09:23:23 +0200 Subject: [PATCH 15/67] build(deps): bump lint from 31.10.0 to 31.10.1 (#1517) Bumps `lint` from 31.10.0 to 31.10.1. Updates `com.android.tools.lint:lint-api` from 31.10.0 to 31.10.1 Updates `com.android.tools.lint:lint-checks` from 31.10.0 to 31.10.1 Updates `com.android.tools.lint:lint` from 31.10.0 to 31.10.1 Updates `com.android.tools.lint:lint-tests` from 31.10.0 to 31.10.1 Updates `com.android.tools:testutils` from 31.10.0 to 31.10.1 --- updated-dependencies: - dependency-name: com.android.tools.lint:lint-api dependency-version: 31.10.1 dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: com.android.tools.lint:lint-checks dependency-version: 31.10.1 dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: com.android.tools.lint:lint dependency-version: 31.10.1 dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: com.android.tools.lint:lint-tests dependency-version: 31.10.1 dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: com.android.tools:testutils dependency-version: 31.10.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 33b61fd6d..adf455c65 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -16,7 +16,7 @@ core-ktx = "1.16.0" robolectric = "4.14.1" kxml2 = "2.3.0" mockk = "1.14.2" -lint = "31.10.0" +lint = "31.10.1" org-jacoco-core = "0.8.13" material = "1.12.0" From 234f1f1848dc76ed5280a0d1fdc2c5df025a6f07 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 10 Jun 2025 16:52:48 +0200 Subject: [PATCH 16/67] build(deps): bump cycjimmy/semantic-release-action from 4.2.0 to 4.2.1 (#1522) Bumps [cycjimmy/semantic-release-action](https://github.com/cycjimmy/semantic-release-action) from 4.2.0 to 4.2.1. - [Release notes](https://github.com/cycjimmy/semantic-release-action/releases) - [Changelog](https://github.com/cycjimmy/semantic-release-action/blob/main/docs/CHANGELOG.md) - [Commits](https://github.com/cycjimmy/semantic-release-action/compare/v4.2.0...v4.2.1) --- updated-dependencies: - dependency-name: cycjimmy/semantic-release-action dependency-version: 4.2.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d4eeaacbe..9d504c314 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -56,7 +56,7 @@ jobs: node-version: '14' - name: Semantic Release - uses: cycjimmy/semantic-release-action@v4.2.0 + uses: cycjimmy/semantic-release-action@v4.2.1 with: extra_plugins: | "@semantic-release/commit-analyzer@8.0.1" From c982a64561823fccc0b8cf362028fe5c4129d061 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 10 Jun 2025 08:53:05 -0600 Subject: [PATCH 17/67] build(deps): bump com.android.tools.build:gradle from 8.9.1 to 8.10.1 (#1518) Bumps com.android.tools.build:gradle from 8.9.1 to 8.10.1. --- updated-dependencies: - dependency-name: com.android.tools.build:gradle dependency-version: 8.10.1 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index adf455c65..568996ae2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,7 +1,7 @@ [versions] appcompat = "1.7.0" dokka-gradle-plugin = "2.0.0" -gradle = "8.9.1" +gradle = "8.10.1" jacoco-android = "0.2.1" lifecycle-extensions = "2.2.0" lifecycle-viewmodel-ktx = "2.9.1" From 09d8141029be1282dda5d1ae8a96596555083c7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20L=C3=B3pez-Ma=C3=B1as?= Date: Wed, 25 Jun 2025 19:30:28 +0200 Subject: [PATCH 18/67] chore: updated versions (#1530) Co-authored-by: Dale Hawkins <107309+dkhawk@users.noreply.github.com> --- gradle/libs.versions.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 568996ae2..ef54291e6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,11 +1,11 @@ [versions] -appcompat = "1.7.0" +appcompat = "1.7.1" dokka-gradle-plugin = "2.0.0" gradle = "8.10.1" jacoco-android = "0.2.1" lifecycle-extensions = "2.2.0" lifecycle-viewmodel-ktx = "2.9.1" -kotlin = "2.1.20" +kotlin = "2.2.0" kotlinx-coroutines = "1.10.2" junit = "4.13.2" mockito-core = "5.18.0" From 2e68866eacfa383c7ed630241c97b5c89ebb879b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 27 Jun 2025 11:24:06 -0600 Subject: [PATCH 19/67] build(deps): bump madrapps/jacoco-report from 1.7.1 to 1.7.2 (#1509) Bumps [madrapps/jacoco-report](https://github.com/madrapps/jacoco-report) from 1.7.1 to 1.7.2. - [Release notes](https://github.com/madrapps/jacoco-report/releases) - [Commits](https://github.com/madrapps/jacoco-report/compare/v1.7.1...v1.7.2) --- updated-dependencies: - dependency-name: madrapps/jacoco-report dependency-version: 1.7.2 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/report.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/report.yml b/.github/workflows/report.yml index 61a65b959..930e60d97 100644 --- a/.github/workflows/report.yml +++ b/.github/workflows/report.yml @@ -49,7 +49,7 @@ jobs: - name: Jacoco Report to PR id: jacoco - uses: madrapps/jacoco-report@v1.7.1 + uses: madrapps/jacoco-report@v1.7.2 with: paths: | ${{ github.workspace }}/library/build/jacoco/jacoco.xml From e2854dcc1641c745f0127d4b3f189dc3b2a9c4d6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 1 Jul 2025 08:22:23 +0200 Subject: [PATCH 20/67] build(deps): bump lint from 31.10.1 to 31.11.0 (#1533) Bumps `lint` from 31.10.1 to 31.11.0. Updates `com.android.tools.lint:lint-api` from 31.10.1 to 31.11.0 Updates `com.android.tools.lint:lint-checks` from 31.10.1 to 31.11.0 Updates `com.android.tools.lint:lint` from 31.10.1 to 31.11.0 Updates `com.android.tools.lint:lint-tests` from 31.10.1 to 31.11.0 Updates `com.android.tools:testutils` from 31.10.1 to 31.11.0 --- updated-dependencies: - dependency-name: com.android.tools.lint:lint-api dependency-version: 31.11.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: com.android.tools.lint:lint-checks dependency-version: 31.11.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: com.android.tools.lint:lint dependency-version: 31.11.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: com.android.tools.lint:lint-tests dependency-version: 31.11.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: com.android.tools:testutils dependency-version: 31.11.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ef54291e6..6f75689d4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -16,7 +16,7 @@ core-ktx = "1.16.0" robolectric = "4.14.1" kxml2 = "2.3.0" mockk = "1.14.2" -lint = "31.10.1" +lint = "31.11.0" org-jacoco-core = "0.8.13" material = "1.12.0" From 7b9618b50b83b95e18d39721fe3400716ef29da1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 1 Jul 2025 08:23:04 +0200 Subject: [PATCH 21/67] build(deps): bump androidx.appcompat:appcompat from 1.7.0 to 1.7.1 (#1526) Bumps androidx.appcompat:appcompat from 1.7.0 to 1.7.1. --- updated-dependencies: - dependency-name: androidx.appcompat:appcompat dependency-version: 1.7.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> From b6d041ebe7a64af9c3a2c577e398c1768d2088b8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 1 Jul 2025 08:34:17 +0200 Subject: [PATCH 22/67] build(deps): bump io.mockk:mockk from 1.14.2 to 1.14.4 (#1529) Bumps [io.mockk:mockk](https://github.com/mockk/mockk) from 1.14.2 to 1.14.4. - [Release notes](https://github.com/mockk/mockk/releases) - [Commits](https://github.com/mockk/mockk/compare/1.14.2...1.14.4) --- updated-dependencies: - dependency-name: io.mockk:mockk dependency-version: 1.14.4 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6f75689d4..de1de78aa 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -15,7 +15,7 @@ play-services-maps = "19.2.0" core-ktx = "1.16.0" robolectric = "4.14.1" kxml2 = "2.3.0" -mockk = "1.14.2" +mockk = "1.14.4" lint = "31.11.0" org-jacoco-core = "0.8.13" material = "1.12.0" From 50da7d25441286737825c34827b4bc34d6a7114d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 1 Jul 2025 08:35:02 +0200 Subject: [PATCH 23/67] build(deps): bump org.robolectric:robolectric from 4.14.1 to 4.15.1 (#1527) Bumps [org.robolectric:robolectric](https://github.com/robolectric/robolectric) from 4.14.1 to 4.15.1. - [Release notes](https://github.com/robolectric/robolectric/releases) - [Commits](https://github.com/robolectric/robolectric/compare/robolectric-4.14.1...robolectric-4.15.1) --- updated-dependencies: - dependency-name: org.robolectric:robolectric dependency-version: 4.15.1 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index de1de78aa..6ca9336a6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,7 +13,7 @@ secrets-gradle-plugin = "2.0.1" truth = "1.4.4" play-services-maps = "19.2.0" core-ktx = "1.16.0" -robolectric = "4.14.1" +robolectric = "4.15.1" kxml2 = "2.3.0" mockk = "1.14.4" lint = "31.11.0" From a315e4628b36312427efe0711ad69d831f6f48df Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 1 Jul 2025 08:52:35 +0200 Subject: [PATCH 24/67] build(deps): bump kotlin from 2.1.20 to 2.2.0 (#1528) Bumps `kotlin` from 2.1.20 to 2.2.0. Updates `org.jetbrains.kotlin:kotlin-gradle-plugin` from 2.1.20 to 2.2.0 - [Release notes](https://github.com/JetBrains/kotlin/releases) - [Changelog](https://github.com/JetBrains/kotlin/blob/v2.2.0/ChangeLog.md) - [Commits](https://github.com/JetBrains/kotlin/compare/v2.1.20...v2.2.0) Updates `org.jetbrains.kotlin:kotlin-test` from 2.1.20 to 2.2.0 - [Release notes](https://github.com/JetBrains/kotlin/releases) - [Changelog](https://github.com/JetBrains/kotlin/blob/v2.2.0/ChangeLog.md) - [Commits](https://github.com/JetBrains/kotlin/compare/v2.1.20...v2.2.0) Updates `org.jetbrains.kotlin:kotlin-stdlib-jdk8` from 2.1.20 to 2.2.0 - [Release notes](https://github.com/JetBrains/kotlin/releases) - [Changelog](https://github.com/JetBrains/kotlin/blob/v2.2.0/ChangeLog.md) - [Commits](https://github.com/JetBrains/kotlin/compare/v2.1.20...v2.2.0) --- updated-dependencies: - dependency-name: org.jetbrains.kotlin:kotlin-gradle-plugin dependency-version: 2.2.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: org.jetbrains.kotlin:kotlin-test dependency-version: 2.2.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: org.jetbrains.kotlin:kotlin-stdlib-jdk8 dependency-version: 2.2.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> From 038172a1564e7f3e2d011b2aea13ae9614899313 Mon Sep 17 00:00:00 2001 From: Dale Hawkins <107309+dkhawk@users.noreply.github.com> Date: Wed, 2 Jul 2025 11:21:08 -0600 Subject: [PATCH 25/67] docs: Add EEA Terms of Service notice to README (#1536) --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index d462401b5..a3ea68db2 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,8 @@ Contributions are welcome and encouraged! If you'd like to contribute, send us a This library uses Google Maps Platform services. Use of Google Maps Platform services through this library is subject to the Google Maps Platform [Terms of Service]. +If your billing address is in the European Economic Area, effective on 8 July 2025, the [Google Maps Platform EEA Terms of Service](https://cloud.google.com/terms/maps-platform/eea) will apply to your use of the Services. Functionality varies by region. [Learn more](https://developers.google.com/maps/comms/eea/faq). + This library is not a Google Maps Platform Core Service. Therefore, the Google Maps Platform Terms of Service (e.g. Technical Support Services, Service Level Agreements, and Deprecation Policy) do not apply to the code in this library. ## Support From 4948bdaa9b51c2308f36a81b2632d83626029598 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 8 Jul 2025 11:40:51 -0600 Subject: [PATCH 26/67] build(deps): bump cycjimmy/semantic-release-action from 4.2.1 to 4.2.2 (#1538) Bumps [cycjimmy/semantic-release-action](https://github.com/cycjimmy/semantic-release-action) from 4.2.1 to 4.2.2. - [Release notes](https://github.com/cycjimmy/semantic-release-action/releases) - [Changelog](https://github.com/cycjimmy/semantic-release-action/blob/main/docs/CHANGELOG.md) - [Commits](https://github.com/cycjimmy/semantic-release-action/compare/v4.2.1...v4.2.2) --- updated-dependencies: - dependency-name: cycjimmy/semantic-release-action dependency-version: 4.2.2 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9d504c314..53201dd02 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -56,7 +56,7 @@ jobs: node-version: '14' - name: Semantic Release - uses: cycjimmy/semantic-release-action@v4.2.1 + uses: cycjimmy/semantic-release-action@v4.2.2 with: extra_plugins: | "@semantic-release/commit-analyzer@8.0.1" From d4efcc34c6de0df4e2621406f0da2e6282c0e060 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20L=C3=B3pez-Ma=C3=B1as?= Date: Wed, 16 Jul 2025 20:57:29 +0200 Subject: [PATCH 27/67] chore: migrated new Maven Central URL (#1537) --- .../convention/src/main/kotlin/PublishingConventionPlugin.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/build-logic/convention/src/main/kotlin/PublishingConventionPlugin.kt b/build-logic/convention/src/main/kotlin/PublishingConventionPlugin.kt index 2740add13..7a8055372 100644 --- a/build-logic/convention/src/main/kotlin/PublishingConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/PublishingConventionPlugin.kt @@ -111,9 +111,9 @@ class PublishingConventionPlugin : Plugin { repositories { maven { val releasesRepoUrl = - uri("https://oss.sonatype.org/service/local/staging/deploy/maven2/") + uri("https://ossrh-staging-api.central.sonatype.com/service/local/staging/deploy/maven2/") val snapshotsRepoUrl = - uri("https://oss.sonatype.org/content/repositories/snapshots/") + uri("https://central.sonatype.com/repository/maven-snapshots/") url = if (project.version.toString() .endsWith("SNAPSHOT") ) snapshotsRepoUrl else releasesRepoUrl From 52813810ca7488ad8551ee9fbff26a35dc4a906b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20L=C3=B3pez-Ma=C3=B1as?= Date: Thu, 17 Jul 2025 17:44:08 +0200 Subject: [PATCH 28/67] chore: migrating to vanniktech plugin (#1547) * chore: migrating to vanniktech plugin * chore: removed old properties --- .github/workflows/release.yml | 8 +- .releaserc | 2 +- build-logic/convention/build.gradle.kts | 1 + .../main/kotlin/PublishingConventionPlugin.kt | 114 ++++++------------ gradle.properties | 10 +- gradle/libs.versions.toml | 4 +- 6 files changed, 55 insertions(+), 84 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 53201dd02..69974aa23 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -32,20 +32,20 @@ jobs: java-version: '17' distribution: 'temurin' - uses: gradle/actions/wrapper-validation@v4 - - name: Create .gpg key + - name: Set up Gradle Publishing Environment Variables run: | echo $GPG_KEY_ARMOR | base64 --decode > ./release.asc gpg --quiet --output $GITHUB_WORKSPACE/release.gpg --dearmor ./release.asc echo "Build and publish" - sed -i -e "s,sonatypeToken=,sonatypeToken=$SONATYPE_TOKEN_USERNAME,g" gradle.properties + sed -i -e "s,mavenCentralUsername=,mavenCentralUsername=$SONATYPE_TOKEN_USERNAME,g" gradle.properties SONATYPE_TOKEN_PASSWORD_ESCAPED=$(printf '%s\n' "$SONATYPE_TOKEN_PASSWORD" | sed -e 's/[\/&]/\\&/g') - sed -i -e "s,sonatypeTokenPassword=,sonatypeTokenPassword=$SONATYPE_TOKEN_PASSWORD_ESCAPED,g" gradle.properties + sed -i -e "s,mavenCentralPassword=,mavenCentralPassword=$SONATYPE_TOKEN_PASSWORD_ESCAPED,g" gradle.properties sed -i -e "s,signing.keyId=,signing.keyId=$GPG_KEY_ID,g" gradle.properties sed -i -e "s,signing.password=,signing.password=$GPG_PASSWORD,g" gradle.properties sed -i -e "s,signing.secretKeyRingFile=,signing.secretKeyRingFile=$GITHUB_WORKSPACE/release.gpg,g" gradle.properties env: - GPG_KEY_ARMOR: "${{ secrets.SYNCED_GPG_KEY_ARMOR }}" + GPG_KEY_ARMOR: ${{ secrets.SYNCED_GPG_KEY_ARMOR }} GPG_KEY_ID: ${{ secrets.SYNCED_GPG_KEY_ID }} GPG_PASSWORD: ${{ secrets.SYNCED_GPG_KEY_PASSWORD }} SONATYPE_TOKEN_PASSWORD: ${{ secrets.SONATYPE_TOKEN_PASSWORD }} diff --git a/.releaserc b/.releaserc index 58210c177..5453d844b 100644 --- a/.releaserc +++ b/.releaserc @@ -20,7 +20,7 @@ plugins: to: ":${nextRelease.version}" - - "@semantic-release/exec" - prepareCmd: "./gradlew build --warn --stacktrace" - publishCmd: "./gradlew publish --warn --stacktrace --debug --info" + publishCmd: "./gradlew publishToMavenCentral --warn --stacktrace --debug --info" - - "@semantic-release/git" - assets: - "build.gradle.kts" diff --git a/build-logic/convention/build.gradle.kts b/build-logic/convention/build.gradle.kts index 0525b582a..e1fc0f723 100644 --- a/build-logic/convention/build.gradle.kts +++ b/build-logic/convention/build.gradle.kts @@ -30,6 +30,7 @@ dependencies { implementation(libs.gradle) implementation(libs.dokka.gradle.plugin) implementation(libs.org.jacoco.core) + implementation(libs.gradle.maven.publish.plugin) } gradlePlugin { diff --git a/build-logic/convention/src/main/kotlin/PublishingConventionPlugin.kt b/build-logic/convention/src/main/kotlin/PublishingConventionPlugin.kt index 7a8055372..a476a1584 100644 --- a/build-logic/convention/src/main/kotlin/PublishingConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/PublishingConventionPlugin.kt @@ -1,5 +1,5 @@ /* - * Copyright 2024 Google LLC + * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -15,40 +15,34 @@ */ // buildSrc/src/main/kotlin/PublishingConventionPlugin.kt +import com.vanniktech.maven.publish.AndroidSingleVariantLibrary +import com.vanniktech.maven.publish.MavenPublishBaseExtension import org.gradle.api.Plugin import org.gradle.api.Project -import org.gradle.api.publish.PublishingExtension -import org.gradle.api.publish.maven.MavenPublication import org.gradle.kotlin.dsl.* import org.gradle.testing.jacoco.plugins.JacocoPluginExtension import org.gradle.api.tasks.testing.Test import org.gradle.testing.jacoco.plugins.JacocoTaskExtension -import org.gradle.plugins.signing.SigningExtension -import org.gradle.api.publish.maven.* class PublishingConventionPlugin : Plugin { override fun apply(project: Project) { project.run { - applyPlugins() configureJacoco() - configurePublishing() - configureSigning() + configureVanniktechPublishing() } } private fun Project.applyPlugins() { apply(plugin = "com.android.library") apply(plugin = "com.mxalbert.gradle.jacoco-android") - apply(plugin = "maven-publish") apply(plugin = "org.jetbrains.dokka") - apply(plugin = "signing") + apply(plugin = "com.vanniktech.maven.publish") } private fun Project.configureJacoco() { configure { toolVersion = "0.8.7" - } tasks.withType().configureEach { @@ -59,76 +53,46 @@ class PublishingConventionPlugin : Plugin { } } - private fun Project.configurePublishing() { - extensions.configure { - publishing { - singleVariant("release") { - withSourcesJar() - withJavadocJar() - } - } - } - extensions.configure { - publications { - create("aar") { - artifactId = if (project.name == "library") { - "android-maps-utils" - } else { - null - } + private fun Project.configureVanniktechPublishing() { + extensions.configure { + configure( + AndroidSingleVariantLibrary( + variant = "release", + sourcesJar = true, + publishJavadocJar = true + ) + ) - afterEvaluate { - from(components["release"]) - } - pom { - name.set(project.name) - description.set("Handy extensions to the Google Maps Android API.") - url.set("https://github.com/googlemaps/android-maps-utils") - scm { - connection.set("scm:git@github.com:googlemaps/android-maps-utils.git") - developerConnection.set("scm:git@github.com:googlemaps/android-maps-utils.git") - url.set("scm:git@github.com:googlemaps/android-maps-utils.git") - } - licenses { - license { - name.set("The Apache Software License, Version 2.0") - url.set("http://www.apache.org/licenses/LICENSE-2.0.txt") - distribution.set("repo") - } - } - organization { - name.set("Google Inc") - url.set("http://developers.google.com/maps") - } - developers { - developer { - name.set("Google Inc.") - } - } + publishToMavenCentral() + signAllPublications() + + pom { + name.set(project.name) + description.set("Handy extensions to the Google Maps Android API.") + url.set("https://github.com/googlemaps/android-maps-utils") + licenses { + license { + name.set("The Apache Software License, Version 2.0") + url.set("http://www.apache.org/licenses/LICENSE-2.0.txt") + distribution.set("repo") } } - } - repositories { - maven { - val releasesRepoUrl = - uri("https://ossrh-staging-api.central.sonatype.com/service/local/staging/deploy/maven2/") - val snapshotsRepoUrl = - uri("https://central.sonatype.com/repository/maven-snapshots/") - url = if (project.version.toString() - .endsWith("SNAPSHOT") - ) snapshotsRepoUrl else releasesRepoUrl - credentials { - username = project.findProperty("sonatypeToken") as String? - password = project.findProperty("sonatypeTokenPassword") as String? + scm { + connection.set("scm:git@github.com:googlemaps/android-maps-utils.git") + developerConnection.set("scm:git@github.com:googlemaps/android-maps-utils.git") + url.set("https://github.com/googlemaps/android-maps-utils") + } + developers { + developer { + id.set("google") + name.set("Google Inc.") } } + organization { + name.set("Google Inc") + url.set("http://developers.google.com/maps") + } } } } - - private fun Project.configureSigning() { - configure { - sign(extensions.getByType().publications["aar"]) - } - } } diff --git a/gradle.properties b/gradle.properties index 247dde2dd..c0f003139 100644 --- a/gradle.properties +++ b/gradle.properties @@ -30,7 +30,11 @@ signing.keyId= signing.password= signing.secretKeyRingFile= -sonatypeToken= -sonatypeTokenPassword= - android.defaults.buildfeatures.buildconfig=true + +mavenCentralUsername= +mavenCentralPassword= + +# Add a property to enable automatic release to Maven Central (optional, but good for CI) +# If true, publishToMavenCentral will also close and release the staging repository +mavenCentralAutomaticRelease=false \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6ca9336a6..862036287 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -19,6 +19,7 @@ mockk = "1.14.4" lint = "31.11.0" org-jacoco-core = "0.8.13" material = "1.12.0" +gradleMavenPublishPlugin = "0.34.0" [libraries] appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" } @@ -47,4 +48,5 @@ lint = { module = "com.android.tools.lint:lint", version.ref = "lint" } lint-tests = { module = "com.android.tools.lint:lint-tests", version.ref = "lint" } testutils = { module = "com.android.tools:testutils", version.ref = "lint" } org-jacoco-core = { module = "org.jacoco:org.jacoco.core", version.ref = "org-jacoco-core" } -material = { group = "com.google.android.material", name = "material", version.ref = "material" } \ No newline at end of file +material = { group = "com.google.android.material", name = "material", version.ref = "material" } +gradle-maven-publish-plugin = { module = "com.vanniktech:gradle-maven-publish-plugin", version.ref = "gradleMavenPublishPlugin" } \ No newline at end of file From dbd633f7300ad942acd51c97f553c1945be1e09d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20L=C3=B3pez-Ma=C3=B1as?= Date: Wed, 23 Jul 2025 22:32:35 +0200 Subject: [PATCH 29/67] chore: fix SARIF upload by splitting per category (#1550) --- .github/workflows/lint-report.yml | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/.github/workflows/lint-report.yml b/.github/workflows/lint-report.yml index 40b69877c..87dc370ee 100644 --- a/.github/workflows/lint-report.yml +++ b/.github/workflows/lint-report.yml @@ -36,11 +36,14 @@ jobs: - name: Run Android Lint run: ./gradlew lint - - name: Merge SARIF files - run: | - jq -s '{ "$schema": "https://json.schemastore.org/sarif-2.1.0", "version": "2.1.0", "runs": map(.runs) | add }' library/build/reports/lint-results-debug.sarif demo/build/reports/lint-results-debug.sarif > merged.sarif + - name: Upload SARIF for library + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: library/build/reports/lint-results-debug.sarif + category: library - - name: Upload SARIF file + - name: Upload SARIF for demo uses: github/codeql-action/upload-sarif@v3 with: - sarif_file: merged.sarif + sarif_file: demo/build/reports/lint-results-debug.sarif + category: demo From 014c64e7d06c98864092b58f701b20e301ef8d30 Mon Sep 17 00:00:00 2001 From: Dale Hawkins <107309+dkhawk@users.noreply.github.com> Date: Wed, 6 Aug 2025 07:58:43 -0600 Subject: [PATCH 30/67] build(deps): update various dependencies (#1556) Updates include: - `compileSdk` and `targetSdk` to 36 - Lifecycle ViewModel KTX to 2.9.2 - MockK to 1.14.5 - Lint to 31.12.0 --- demo/build.gradle.kts | 4 ++-- gradle/libs.versions.toml | 10 ++++++---- library/build.gradle.kts | 4 ++-- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/demo/build.gradle.kts b/demo/build.gradle.kts index 9556a50af..e2c64060b 100644 --- a/demo/build.gradle.kts +++ b/demo/build.gradle.kts @@ -26,10 +26,10 @@ android { } defaultConfig { - compileSdk = 35 + compileSdk = libs.versions.compileSdk.get().toInt() applicationId = "com.google.maps.android.utils.demo" minSdk = 21 - targetSdk = 35 + targetSdk = libs.versions.targetSdk.get().toInt() versionCode = 1 versionName = "1.0" } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 862036287..61ddd0a0c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,10 +1,12 @@ [versions] +compileSdk = "36" +targetSdk = "36" appcompat = "1.7.1" dokka-gradle-plugin = "2.0.0" gradle = "8.10.1" jacoco-android = "0.2.1" lifecycle-extensions = "2.2.0" -lifecycle-viewmodel-ktx = "2.9.1" +lifecycle-viewmodel-ktx = "2.9.2" kotlin = "2.2.0" kotlinx-coroutines = "1.10.2" junit = "4.13.2" @@ -15,8 +17,8 @@ play-services-maps = "19.2.0" core-ktx = "1.16.0" robolectric = "4.15.1" kxml2 = "2.3.0" -mockk = "1.14.4" -lint = "31.11.0" +mockk = "1.14.5" +lint = "31.12.0" org-jacoco-core = "0.8.13" material = "1.12.0" gradleMavenPublishPlugin = "0.34.0" @@ -49,4 +51,4 @@ lint-tests = { module = "com.android.tools.lint:lint-tests", version.ref = "lint testutils = { module = "com.android.tools:testutils", version.ref = "lint" } org-jacoco-core = { module = "org.jacoco:org.jacoco.core", version.ref = "org-jacoco-core" } material = { group = "com.google.android.material", name = "material", version.ref = "material" } -gradle-maven-publish-plugin = { module = "com.vanniktech:gradle-maven-publish-plugin", version.ref = "gradleMavenPublishPlugin" } \ No newline at end of file +gradle-maven-publish-plugin = { module = "com.vanniktech:gradle-maven-publish-plugin", version.ref = "gradleMavenPublishPlugin" } diff --git a/library/build.gradle.kts b/library/build.gradle.kts index 38f34bd7f..9b0aa5b2e 100644 --- a/library/build.gradle.kts +++ b/library/build.gradle.kts @@ -24,9 +24,9 @@ android { sarifOutput = file("$buildDir/reports/lint-results.sarif") } defaultConfig { - compileSdk = 35 + compileSdk = libs.versions.compileSdk.get().toInt() minSdk = 21 - targetSdk = 35 + targetSdk = libs.versions.targetSdk.get().toInt() consumerProguardFiles("consumer-rules.pro") buildConfigField("String", "TRAVIS", "\"${System.getenv("TRAVIS")}\"") } From 6db1f7b8f73ef079608375800629b487e922302d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 14 Aug 2025 14:45:30 -0600 Subject: [PATCH 31/67] build(deps): bump actions/checkout from 4 to 5 (#1558) Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 5. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v4...v5) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '5' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/docs.yml | 2 +- .github/workflows/lint-report.yml | 2 +- .github/workflows/release.yml | 2 +- .github/workflows/report.yml | 2 +- .github/workflows/test.yml | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 2aea24c9f..3cb63dbed 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -29,7 +29,7 @@ jobs: steps: # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it - name: Checkout Repo - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Gradle Wrapper Validation uses: gradle/actions/wrapper-validation@v4 diff --git a/.github/workflows/lint-report.yml b/.github/workflows/lint-report.yml index 87dc370ee..6b1a5d53a 100644 --- a/.github/workflows/lint-report.yml +++ b/.github/workflows/lint-report.yml @@ -25,7 +25,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Set up JDK 17 uses: actions/setup-java@v3 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 69974aa23..f1ddafc8b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,7 +23,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: token: ${{ secrets.SYNCED_GITHUB_TOKEN_REPO }} - name: Set up JDK 17 diff --git a/.github/workflows/report.yml b/.github/workflows/report.yml index 930e60d97..85b3c0336 100644 --- a/.github/workflows/report.yml +++ b/.github/workflows/report.yml @@ -33,7 +33,7 @@ jobs: steps: # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it - name: Checkout Repo - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Gradle Wrapper Validation uses: gradle/actions/wrapper-validation@v4 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a30213709..372b972fc 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -33,7 +33,7 @@ jobs: steps: # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it - name: Checkout Repo - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Gradle Wrapper Validation uses: gradle/actions/wrapper-validation@v4 From b781ff8149ba1193e8294f393efd99804b641e1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20L=C3=B3pez-Ma=C3=B1as?= Date: Wed, 20 Aug 2025 00:15:09 +0200 Subject: [PATCH 32/67] feat: added ContinuousZoomEuclideanCentroidAlgorithm (#1559) * feat: added ContinuousZoomEuclideanAlgorithm * feat: added ContinuousZoomEuclideanAlgorithm * feat: added ContinuousZoomEuclideanAlgorithm * feat: replaced methods in NonHierarchicalDistanceBasedAlgorithm.java * feat: refactored to create ContinuousZoomEuclideanCentroidAlgorithm.java --- ...tinuousZoomEuclideanCentroidAlgorithm.java | 68 ++++++++++ ...NonHierarchicalDistanceBasedAlgorithm.java | 25 +++- ...ousZoomEuclideanCentroidAlgorithmTest.java | 117 ++++++++++++++++++ 3 files changed, 205 insertions(+), 5 deletions(-) create mode 100644 library/src/main/java/com/google/maps/android/clustering/algo/ContinuousZoomEuclideanCentroidAlgorithm.java create mode 100644 library/src/test/java/com/google/maps/android/clustering/algo/ContinuousZoomEuclideanCentroidAlgorithmTest.java diff --git a/library/src/main/java/com/google/maps/android/clustering/algo/ContinuousZoomEuclideanCentroidAlgorithm.java b/library/src/main/java/com/google/maps/android/clustering/algo/ContinuousZoomEuclideanCentroidAlgorithm.java new file mode 100644 index 000000000..c49dcb634 --- /dev/null +++ b/library/src/main/java/com/google/maps/android/clustering/algo/ContinuousZoomEuclideanCentroidAlgorithm.java @@ -0,0 +1,68 @@ +/* + * Copyright 2025 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.clustering.algo; + +import com.google.maps.android.clustering.ClusterItem; +import com.google.maps.android.geometry.Bounds; +import com.google.maps.android.quadtree.PointQuadTree; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashSet; +import java.util.Set; + +/** + * A variant of {@link CentroidNonHierarchicalDistanceBasedAlgorithm} that uses + * continuous zoom scaling and Euclidean distance for clustering. + * + *

This class overrides {@link #getClusteringItems(PointQuadTree, float)} to compute + * clusters with a zoom-dependent radius, while keeping the centroid-based cluster positions.

+ * + * @param the type of cluster item + */ +public class ContinuousZoomEuclideanCentroidAlgorithm + extends CentroidNonHierarchicalDistanceBasedAlgorithm { + + @Override + protected Collection> getClusteringItems(PointQuadTree> quadTree, float zoom) { + // Continuous zoom — no casting to int + final double zoomSpecificSpan = getMaxDistanceBetweenClusteredItems() / Math.pow(2, zoom) / 256; + + final Set> visitedCandidates = new HashSet<>(); + final Collection> result = new ArrayList<>(); + synchronized (mQuadTree) { + for (QuadItem candidate : mItems) { + if (visitedCandidates.contains(candidate)) continue; + + Bounds searchBounds = createBoundsFromSpan(candidate.getPoint(), zoomSpecificSpan); + Collection> clusterItems = new ArrayList<>(); + for (QuadItem clusterItem : mQuadTree.search(searchBounds)) { + double distance = distanceSquared(clusterItem.getPoint(), candidate.getPoint()); + double radiusSquared = Math.pow(zoomSpecificSpan / 2, 2); + if (distance < radiusSquared) { + clusterItems.add(clusterItem); + } + } + + visitedCandidates.addAll(clusterItems); + result.add(candidate); + } + } + return result; + } + +} diff --git a/library/src/main/java/com/google/maps/android/clustering/algo/NonHierarchicalDistanceBasedAlgorithm.java b/library/src/main/java/com/google/maps/android/clustering/algo/NonHierarchicalDistanceBasedAlgorithm.java index 894bb111f..f9bff856d 100644 --- a/library/src/main/java/com/google/maps/android/clustering/algo/NonHierarchicalDistanceBasedAlgorithm.java +++ b/library/src/main/java/com/google/maps/android/clustering/algo/NonHierarchicalDistanceBasedAlgorithm.java @@ -24,6 +24,7 @@ import com.google.maps.android.projection.SphericalMercatorProjection; import com.google.maps.android.quadtree.PointQuadTree; +import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.HashMap; @@ -53,12 +54,12 @@ public class NonHierarchicalDistanceBasedAlgorithm extend /** * Any modifications should be synchronized on mQuadTree. */ - private final Collection> mItems = new LinkedHashSet<>(); + protected final Collection> mItems = new LinkedHashSet<>(); /** * Any modifications should be synchronized on mQuadTree. */ - private final PointQuadTree> mQuadTree = new PointQuadTree<>(0, 1, 0, 1); + protected final PointQuadTree> mQuadTree = new PointQuadTree<>(0, 1, 0, 1); private static final SphericalMercatorProjection PROJECTION = new SphericalMercatorProjection(1); @@ -246,11 +247,25 @@ public int getMaxDistanceBetweenClusteredItems() { return mMaxDistance; } - private double distanceSquared(Point a, Point b) { + /** + * Calculates the squared Euclidean distance between two points. + * + * @param a the first point + * @param b the second point + * @return the squared Euclidean distance between {@code a} and {@code b} + */ + protected double distanceSquared(Point a, Point b) { return (a.x - b.x) * (a.x - b.x) + (a.y - b.y) * (a.y - b.y); } - private Bounds createBoundsFromSpan(Point p, double span) { + /** + * Creates a square bounding box centered at a point with the specified span. + * + * @param p the center point + * @param span the total width/height of the bounding box + * @return the {@link Bounds} object representing the search area + */ + protected Bounds createBoundsFromSpan(Point p, double span) { // TODO: Use a span that takes into account the visual size of the marker, not just its // LatLng. double halfSpan = span / 2; @@ -260,7 +275,7 @@ private Bounds createBoundsFromSpan(Point p, double span) { } protected static class QuadItem implements PointQuadTree.Item, Cluster { - private final T mClusterItem; + protected final T mClusterItem; private final Point mPoint; private final LatLng mPosition; private Set singletonSet; diff --git a/library/src/test/java/com/google/maps/android/clustering/algo/ContinuousZoomEuclideanCentroidAlgorithmTest.java b/library/src/test/java/com/google/maps/android/clustering/algo/ContinuousZoomEuclideanCentroidAlgorithmTest.java new file mode 100644 index 000000000..02a998e96 --- /dev/null +++ b/library/src/test/java/com/google/maps/android/clustering/algo/ContinuousZoomEuclideanCentroidAlgorithmTest.java @@ -0,0 +1,117 @@ +/* + * Copyright 2025 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.clustering.algo; + +import androidx.annotation.NonNull; + +import com.google.android.gms.maps.model.LatLng; +import com.google.maps.android.clustering.Cluster; +import com.google.maps.android.clustering.ClusterItem; + +import org.junit.Test; + +import java.util.Arrays; +import java.util.Collection; +import java.util.Set; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class ContinuousZoomEuclideanCentroidAlgorithmTest { + + static class TestClusterItem implements ClusterItem { + private final LatLng position; + + TestClusterItem(double lat, double lng) { + this.position = new LatLng(lat, lng); + } + + @NonNull + @Override + public LatLng getPosition() { + return position; + } + + @Override + public String getTitle() { + return null; + } + + @Override + public String getSnippet() { + return null; + } + + @Override + public Float getZIndex() { + return 0f; + } + } + + @Test + public void testContinuousZoomMergesClosePairAtLowZoomAndSeparatesAtHighZoom() { + ContinuousZoomEuclideanCentroidAlgorithm algo = + new ContinuousZoomEuclideanCentroidAlgorithm<>(); + + Collection items = Arrays.asList( + new TestClusterItem(10.0, 10.0), + new TestClusterItem(10.0001, 10.0001), // very close to the first + new TestClusterItem(20.0, 20.0) // far away + ); + + algo.addItems(items); + + // At a high zoom, the close pair should be separate (small radius) + Set> highZoom = algo.getClusters(20.0f); + assertEquals(3, highZoom.size()); + + // At a lower zoom, the close pair should merge (larger radius) + Set> lowZoom = algo.getClusters(5.0f); + assertTrue(lowZoom.size() < 3); + + // Specifically, we expect one cluster of size 2 and one singleton + boolean hasClusterOfTwo = lowZoom.stream().anyMatch(c -> c.getItems().size() == 2); + boolean hasClusterOfOne = lowZoom.stream().anyMatch(c -> c.getItems().size() == 1); + assertTrue(hasClusterOfTwo); + assertTrue(hasClusterOfOne); + } + + @Test + public void testClusterPositionsAreCentroids() { + ContinuousZoomEuclideanCentroidAlgorithm algo = + new ContinuousZoomEuclideanCentroidAlgorithm<>(); + + Collection items = Arrays.asList( + new TestClusterItem(0.0, 0.0), + new TestClusterItem(0.0, 2.0), + new TestClusterItem(2.0, 0.0) + ); + + algo.addItems(items); + + Set> clusters = algo.getClusters(1.0f); + + // Expect all items clustered into one + assertEquals(1, clusters.size()); + + Cluster cluster = clusters.iterator().next(); + + // The centroid should be approximately (0.6667, 0.6667) + LatLng centroid = cluster.getPosition(); + assertEquals(0.6667, centroid.latitude, 0.0001); + assertEquals(0.6667, centroid.longitude, 0.0001); + } +} From f3d721761d5fe804e4abbdf88fc2b5791ec9dabf Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Tue, 19 Aug 2025 22:20:42 +0000 Subject: [PATCH 33/67] chore(release): 3.15.0 [skip ci] # [3.15.0](https://github.com/googlemaps/android-maps-utils/compare/v3.14.0...v3.15.0) (2025-08-19) ### Features * added ContinuousZoomEuclideanCentroidAlgorithm ([#1559](https://github.com/googlemaps/android-maps-utils/issues/1559)) ([b781ff8](https://github.com/googlemaps/android-maps-utils/commit/b781ff8149ba1193e8294f393efd99804b641e1b)) --- README.md | 2 +- build.gradle.kts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a3ea68db2..5a8558de7 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ dependencies { // Utilities for Maps SDK for Android (requires Google Play Services) // You do not need to add a separate dependency for the Maps SDK for Android // since this library builds in the compatible version of the Maps SDK. - implementation 'com.google.maps.android:android-maps-utils:3.14.0' + implementation 'com.google.maps.android:android-maps-utils:3.15.0' // Optionally add the Kotlin Extensions (KTX) for full Kotlin language support // See latest version at https://github.com/googlemaps/android-maps-ktx diff --git a/build.gradle.kts b/build.gradle.kts index ab894b2dc..5f055b4e1 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -37,5 +37,5 @@ tasks.register("clean") { allprojects { group = "com.google.maps.android" - version = "3.14.0" + version = "3.15.0" } \ No newline at end of file From 0bb1d348290ee02c74c02fef13e0e560b4bd370f Mon Sep 17 00:00:00 2001 From: Dale Hawkins <107309+dkhawk@users.noreply.github.com> Date: Wed, 20 Aug 2025 13:56:59 -0600 Subject: [PATCH 34/67] Fix: #1424 - Added zIndex to markerOptions in onBeforeClusterRendered and onBeforeClusterItemRendered (#1566) Co-authored-by: Programmeister --- .../clustering/view/DefaultClusterRenderer.java | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/library/src/main/java/com/google/maps/android/clustering/view/DefaultClusterRenderer.java b/library/src/main/java/com/google/maps/android/clustering/view/DefaultClusterRenderer.java index 08ab0e081..0f0d6ba19 100644 --- a/library/src/main/java/com/google/maps/android/clustering/view/DefaultClusterRenderer.java +++ b/library/src/main/java/com/google/maps/android/clustering/view/DefaultClusterRenderer.java @@ -836,6 +836,9 @@ protected void onBeforeClusterItemRendered(@NonNull T item, @NonNull MarkerOptio } else if (item.getSnippet() != null) { markerOptions.title(item.getSnippet()); } + if (item.getZIndex() != null) { + markerOptions.zIndex(item.getZIndex()); + } } /** @@ -906,6 +909,13 @@ protected void onClusterItemUpdated(@NonNull T item, @NonNull Marker marker) { protected void onBeforeClusterRendered(@NonNull Cluster cluster, @NonNull MarkerOptions markerOptions) { // TODO: consider adding anchor(.5, .5) (Individual markers will overlap more often) markerOptions.icon(getDescriptorForCluster(cluster)); + ArrayList items = new ArrayList<>(cluster.getItems()); + if (!items.isEmpty()) { + Float zIndex = items.get(0).getZIndex(); + if (zIndex != null) { + markerOptions.zIndex(zIndex); + } + } } /** From 90c56df9e0fccf4004a4562d9647d5d2a99efe18 Mon Sep 17 00:00:00 2001 From: Dale Hawkins <107309+dkhawk@users.noreply.github.com> Date: Tue, 26 Aug 2025 10:07:12 -0600 Subject: [PATCH 35/67] feat(library): Port PolyUtil to Kotlin and enhance tests (#1565) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: port MathUtil to Kotlin This commit ports the `MathUtil` class from Java to idiomatic Kotlin. The existing tests were leveraged to ensure that the port was successful, and a new test was added for `MathUtil` to ensure that it is well-tested. * feat: port SphericalUtil to Kotlin This commit ports the `SphericalUtil` class from Java to idiomatic Kotlin. The existing tests were leveraged to ensure that the port was successful, and the tests were updated to use Google Truth assertions. * feat(library): Port PolyUtil to Kotlin and enhance tests This commit introduces a significant modernization of the `PolyUtil` class by porting it from Java to idiomatic Kotlin. This change improves the code's conciseness, readability, and null safety. The key changes in this commit are: - **Porting `PolyUtil` to Kotlin**: The `PolyUtil` class has been completely rewritten in Kotlin, taking advantage of features like top-level functions, default arguments, and extension functions. The public API has been preserved with `@JvmStatic` annotations to ensure backward compatibility for Java consumers. - **Updating tests to use Google Truth**: The corresponding `PolyUtilTest` has been updated to use Google Truth assertions. This makes the tests more readable and expressive. * fix: Add correct copyright header * refactor: minor changes to comments and a fix to use the correct Google truth dependency * Update library/src/main/java/com/google/maps/android/MathUtil.kt Co-authored-by: Enrique López-Mañas --------- Co-authored-by: Enrique López-Mañas --- library/build.gradle.kts | 3 +- .../android/{MathUtil.java => MathUtil.kt} | 89 ++- .../com/google/maps/android/PolyUtil.java | 580 ---------------- .../java/com/google/maps/android/PolyUtil.kt | 617 ++++++++++++++++++ .../google/maps/android/SphericalUtil.java | 265 -------- .../com/google/maps/android/SphericalUtil.kt | 287 ++++++++ .../com/google/maps/android/MathUtilTest.java | 100 +++ .../com/google/maps/android/PolyUtilTest.java | 364 +++++++---- .../maps/android/SphericalUtilTest.java | 282 +++----- 9 files changed, 1408 insertions(+), 1179 deletions(-) rename library/src/main/java/com/google/maps/android/{MathUtil.java => MathUtil.kt} (52%) delete mode 100644 library/src/main/java/com/google/maps/android/PolyUtil.java create mode 100644 library/src/main/java/com/google/maps/android/PolyUtil.kt delete mode 100644 library/src/main/java/com/google/maps/android/SphericalUtil.java create mode 100644 library/src/main/java/com/google/maps/android/SphericalUtil.kt create mode 100644 library/src/test/java/com/google/maps/android/MathUtilTest.java diff --git a/library/build.gradle.kts b/library/build.gradle.kts index 9b0aa5b2e..5b1f841a4 100644 --- a/library/build.gradle.kts +++ b/library/build.gradle.kts @@ -68,7 +68,8 @@ dependencies { testImplementation(libs.robolectric) testImplementation(libs.kxml2) testImplementation(libs.mockk) - testImplementation (libs.kotlin.test) + testImplementation(libs.kotlin.test) + testImplementation(libs.truth) implementation(libs.kotlin.stdlib.jdk8) } diff --git a/library/src/main/java/com/google/maps/android/MathUtil.java b/library/src/main/java/com/google/maps/android/MathUtil.kt similarity index 52% rename from library/src/main/java/com/google/maps/android/MathUtil.java rename to library/src/main/java/com/google/maps/android/MathUtil.kt index a7e2c7488..d47a5f505 100644 --- a/library/src/main/java/com/google/maps/android/MathUtil.java +++ b/library/src/main/java/com/google/maps/android/MathUtil.kt @@ -1,5 +1,5 @@ /* - * Copyright 2013 Google Inc. + * Copyright 2023 Google Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,25 +14,34 @@ * limitations under the License. */ -package com.google.maps.android; +package com.google.maps.android -import static java.lang.Math.*; +import kotlin.math.PI +import kotlin.math.asin +import kotlin.math.atan +import kotlin.math.cos +import kotlin.math.exp +import kotlin.math.ln +import kotlin.math.sin +import kotlin.math.sqrt +import kotlin.math.tan /** * Utility functions that are used my both PolyUtil and SphericalUtil. */ -class MathUtil { +object MathUtil { /** * The earth's radius, in meters. * Mean radius as defined by IUGG. */ - static final double EARTH_RADIUS = 6371009; + const val EARTH_RADIUS = 6_371_009.0 /** * Restrict x to the range [low, high]. */ - static double clamp(double x, double low, double high) { - return x < low ? low : (x > high ? high : x); + @JvmStatic + fun clamp(x: Double, low: Double, high: Double): Double { + return if (x < low) low else if (x > high) high else x } /** @@ -42,8 +51,9 @@ static double clamp(double x, double low, double high) { * @param min The minimum. * @param max The maximum. */ - static double wrap(double n, double min, double max) { - return (n >= min && n < max) ? n : (mod(n - min, max - min) + min); + @JvmStatic + fun wrap(n: Double, min: Double, max: Double): Double { + return if (n >= min && n < max) n else mod(n - min, max - min) + min } /** @@ -52,32 +62,42 @@ static double wrap(double n, double min, double max) { * @param x The operand. * @param m The modulus. */ - static double mod(double x, double m) { - return ((x % m) + m) % m; + @JvmStatic + fun mod(x: Double, m: Double): Double { + return (x % m + m) % m } /** * Returns mercator Y corresponding to latitude. * See http://en.wikipedia.org/wiki/Mercator_projection . */ - static double mercator(double lat) { - return log(tan(lat * 0.5 + PI / 4)); + @JvmStatic + fun mercator(lat: Double): Double { + if (lat > Math.PI / 2 - 1e-9) { + return Double.POSITIVE_INFINITY + } + if (lat < -Math.PI / 2 + 1e-9) { + return Double.NEGATIVE_INFINITY + } + return ln(tan(lat * 0.5 + PI / 4)) } /** * Returns latitude from mercator Y. */ - static double inverseMercator(double y) { - return 2 * atan(exp(y)) - PI / 2; + @JvmStatic + fun inverseMercator(y: Double): Double { + return 2 * atan(exp(y)) - PI / 2 } /** * Returns haversine(angle-in-radians). * hav(x) == (1 - cos(x)) / 2 == sin(x / 2)^2. */ - static double hav(double x) { - double sinHalf = sin(x * 0.5); - return sinHalf * sinHalf; + @JvmStatic + fun hav(x: Double): Double { + val sinHalf = sin(x * 0.5) + return sinHalf * sinHalf } /** @@ -85,32 +105,37 @@ static double hav(double x) { * arcHav(x) == acos(1 - 2 * x) == 2 * asin(sqrt(x)). * The argument must be in [0, 1], and the result is positive. */ - static double arcHav(double x) { - return 2 * asin(sqrt(x)); + @JvmStatic + fun arcHav(x: Double): Double { + return 2 * asin(sqrt(x)) } // Given h==hav(x), returns sin(abs(x)). - static double sinFromHav(double h) { - return 2 * sqrt(h * (1 - h)); + @JvmStatic + fun sinFromHav(h: Double): Double { + return 2 * sqrt(h * (1 - h)) } // Returns hav(asin(x)). - static double havFromSin(double x) { - double x2 = x * x; - return x2 / (1 + sqrt(1 - x2)) * .5; + @JvmStatic + fun havFromSin(x: Double): Double { + val x2 = x * x + return x2 / (1 + sqrt(1 - x2)) * .5 } // Returns sin(arcHav(x) + arcHav(y)). - static double sinSumFromHav(double x, double y) { - double a = sqrt(x * (1 - x)); - double b = sqrt(y * (1 - y)); - return 2 * (a + b - 2 * (a * y + b * x)); + @JvmStatic + fun sinSumFromHav(x: Double, y: Double): Double { + val a = sqrt(x * (1 - x)) + val b = sqrt(y * (1 - y)) + return 2 * (a + b - 2 * (a * y + b * x)) } /** * Returns hav() of distance from (lat1, lng1) to (lat2, lng2) on the unit sphere. */ - static double havDistance(double lat1, double lat2, double dLng) { - return hav(lat1 - lat2) + hav(dLng) * cos(lat1) * cos(lat2); + @JvmStatic + fun havDistance(lat1: Double, lat2: Double, dLng: Double): Double { + return hav(lat1 - lat2) + hav(dLng) * cos(lat1) * cos(lat2) } -} +} \ No newline at end of file diff --git a/library/src/main/java/com/google/maps/android/PolyUtil.java b/library/src/main/java/com/google/maps/android/PolyUtil.java deleted file mode 100644 index 1c32d5a38..000000000 --- a/library/src/main/java/com/google/maps/android/PolyUtil.java +++ /dev/null @@ -1,580 +0,0 @@ -/* - * Copyright 2008, 2013 Google Inc. - * - * 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; - -import com.google.android.gms.maps.model.LatLng; - -import java.util.ArrayList; -import java.util.List; -import java.util.Stack; - -import static com.google.maps.android.MathUtil.EARTH_RADIUS; -import static com.google.maps.android.MathUtil.clamp; -import static com.google.maps.android.MathUtil.hav; -import static com.google.maps.android.MathUtil.havDistance; -import static com.google.maps.android.MathUtil.havFromSin; -import static com.google.maps.android.MathUtil.inverseMercator; -import static com.google.maps.android.MathUtil.mercator; -import static com.google.maps.android.MathUtil.sinFromHav; -import static com.google.maps.android.MathUtil.sinSumFromHav; -import static com.google.maps.android.MathUtil.wrap; -import static com.google.maps.android.SphericalUtil.computeDistanceBetween; -import static java.lang.Math.PI; -import static java.lang.Math.cos; -import static java.lang.Math.max; -import static java.lang.Math.min; -import static java.lang.Math.sin; -import static java.lang.Math.sqrt; -import static java.lang.Math.tan; -import static java.lang.Math.toRadians; - -public class PolyUtil { - - private PolyUtil() { - } - - /** - * Returns tan(latitude-at-lng3) on the great circle (lat1, lng1) to (lat2, lng2). lng1==0. - * See http://williams.best.vwh.net/avform.htm . - */ - private static double tanLatGC(double lat1, double lat2, double lng2, double lng3) { - return (tan(lat1) * sin(lng2 - lng3) + tan(lat2) * sin(lng3)) / sin(lng2); - } - - /** - * Returns mercator(latitude-at-lng3) on the Rhumb line (lat1, lng1) to (lat2, lng2). lng1==0. - */ - private static double mercatorLatRhumb(double lat1, double lat2, double lng2, double lng3) { - return (mercator(lat1) * (lng2 - lng3) + mercator(lat2) * lng3) / lng2; - } - - /** - * Computes whether the vertical segment (lat3, lng3) to South Pole intersects the segment - * (lat1, lng1) to (lat2, lng2). - * Longitudes are offset by -lng1; the implicit lng1 becomes 0. - */ - private static boolean intersects(double lat1, double lat2, double lng2, - double lat3, double lng3, boolean geodesic) { - // Both ends on the same side of lng3. - if ((lng3 >= 0 && lng3 >= lng2) || (lng3 < 0 && lng3 < lng2)) { - return false; - } - // Point is South Pole. - if (lat3 <= -PI / 2) { - return false; - } - // Any segment end is a pole. - if (lat1 <= -PI / 2 || lat2 <= -PI / 2 || lat1 >= PI / 2 || lat2 >= PI / 2) { - return false; - } - if (lng2 <= -PI) { - return false; - } - double linearLat = (lat1 * (lng2 - lng3) + lat2 * lng3) / lng2; - // Northern hemisphere and point under lat-lng line. - if (lat1 >= 0 && lat2 >= 0 && lat3 < linearLat) { - return false; - } - // Southern hemisphere and point above lat-lng line. - if (lat1 <= 0 && lat2 <= 0 && lat3 >= linearLat) { - return true; - } - // North Pole. - if (lat3 >= PI / 2) { - return true; - } - // Compare lat3 with latitude on the GC/Rhumb segment corresponding to lng3. - // Compare through a strictly-increasing function (tan() or mercator()) as convenient. - return geodesic ? - tan(lat3) >= tanLatGC(lat1, lat2, lng2, lng3) : - mercator(lat3) >= mercatorLatRhumb(lat1, lat2, lng2, lng3); - } - - public static boolean containsLocation(LatLng point, List polygon, boolean geodesic) { - return containsLocation(point.latitude, point.longitude, polygon, geodesic); - } - - /** - * Computes whether the given point lies inside the specified polygon. - * The polygon is always considered closed, regardless of whether the last point equals - * the first or not. - * Inside is defined as not containing the South Pole -- the South Pole is always outside. - * The polygon is formed of great circle segments if geodesic is true, and of rhumb - * (loxodromic) segments otherwise. - */ - public static boolean containsLocation(double latitude, double longitude, List polygon, boolean geodesic) { - final int size = polygon.size(); - if (size == 0) { - return false; - } - double lat3 = toRadians(latitude); - double lng3 = toRadians(longitude); - LatLng prev = polygon.get(size - 1); - double lat1 = toRadians(prev.latitude); - double lng1 = toRadians(prev.longitude); - int nIntersect = 0; - for (LatLng point2 : polygon) { - double dLng3 = wrap(lng3 - lng1, -PI, PI); - // Special case: point equal to vertex is inside. - if (lat3 == lat1 && dLng3 == 0) { - return true; - } - double lat2 = toRadians(point2.latitude); - double lng2 = toRadians(point2.longitude); - // Offset longitudes by -lng1. - if (intersects(lat1, lat2, wrap(lng2 - lng1, -PI, PI), lat3, dLng3, geodesic)) { - ++nIntersect; - } - lat1 = lat2; - lng1 = lng2; - } - return (nIntersect & 1) != 0; - } - - public static final double DEFAULT_TOLERANCE = 0.1; // meters. - - /** - * Computes whether the given point lies on or near the edge of a polygon, within a specified - * tolerance in meters. The polygon edge is composed of great circle segments if geodesic - * is true, and of Rhumb segments otherwise. The polygon edge is implicitly closed -- the - * closing segment between the first point and the last point is included. - */ - public static boolean isLocationOnEdge(LatLng point, List polygon, boolean geodesic, - double tolerance) { - return isLocationOnEdgeOrPath(point, polygon, true, geodesic, tolerance); - } - - /** - * Same as {@link #isLocationOnEdge(LatLng, List, boolean, double)} - * with a default tolerance of 0.1 meters. - */ - public static boolean isLocationOnEdge(LatLng point, List polygon, boolean geodesic) { - return isLocationOnEdge(point, polygon, geodesic, DEFAULT_TOLERANCE); - } - - /** - * Computes whether the given point lies on or near a polyline, within a specified - * tolerance in meters. The polyline is composed of great circle segments if geodesic - * is true, and of Rhumb segments otherwise. The polyline is not closed -- the closing - * segment between the first point and the last point is not included. - */ - public static boolean isLocationOnPath(LatLng point, List polyline, - boolean geodesic, double tolerance) { - return isLocationOnEdgeOrPath(point, polyline, false, geodesic, tolerance); - } - - /** - * Same as {@link #isLocationOnPath(LatLng, List, boolean, double)} - *

- * with a default tolerance of 0.1 meters. - */ - public static boolean isLocationOnPath(LatLng point, List polyline, - boolean geodesic) { - return isLocationOnPath(point, polyline, geodesic, DEFAULT_TOLERANCE); - } - - private static boolean isLocationOnEdgeOrPath(LatLng point, List poly, boolean closed, - boolean geodesic, double toleranceEarth) { - int idx = locationIndexOnEdgeOrPath(point, poly, closed, geodesic, toleranceEarth); - - return (idx >= 0); - } - - /** - * Computes whether (and where) a given point lies on or near a polyline, within a specified tolerance. - * The polyline is not closed -- the closing segment between the first point and the last point is not included. - * - * @param point our needle - * @param poly our haystack - * @param geodesic the polyline is composed of great circle segments if geodesic - * is true, and of Rhumb segments otherwise - * @param tolerance tolerance (in meters) - * @return -1 if point does not lie on or near the polyline. - * 0 if point is between poly[0] and poly[1] (inclusive), - * 1 if between poly[1] and poly[2], - * ..., - * poly.size()-2 if between poly[poly.size() - 2] and poly[poly.size() - 1] - */ - public static int locationIndexOnPath(LatLng point, List poly, - boolean geodesic, double tolerance) { - return locationIndexOnEdgeOrPath(point, poly, false, geodesic, tolerance); - } - - /** - * Same as {@link #locationIndexOnPath(LatLng, List, boolean, double)} - *

- * with a default tolerance of 0.1 meters. - */ - public static int locationIndexOnPath(LatLng point, List polyline, - boolean geodesic) { - return locationIndexOnPath(point, polyline, geodesic, DEFAULT_TOLERANCE); - } - - /** - * Computes whether (and where) a given point lies on or near a polyline, within a specified tolerance. - * If closed, the closing segment between the last and first points of the polyline is not considered. - * - * @param point our needle - * @param poly our haystack - * @param closed whether the polyline should be considered closed by a segment connecting the last point back to the first one - * @param geodesic the polyline is composed of great circle segments if geodesic - * is true, and of Rhumb segments otherwise - * @param toleranceEarth tolerance (in meters) - * @return -1 if point does not lie on or near the polyline. - * 0 if point is between poly[0] and poly[1] (inclusive), - * 1 if between poly[1] and poly[2], - * ..., - * poly.size()-2 if between poly[poly.size() - 2] and poly[poly.size() - 1] - */ - public static int locationIndexOnEdgeOrPath(LatLng point, List poly, boolean closed, - boolean geodesic, double toleranceEarth) { - int size = poly.size(); - if (size == 0) { - return -1; - } - double tolerance = toleranceEarth / EARTH_RADIUS; - double havTolerance = hav(tolerance); - double lat3 = toRadians(point.latitude); - double lng3 = toRadians(point.longitude); - LatLng prev = poly.get(closed ? size - 1 : 0); - double lat1 = toRadians(prev.latitude); - double lng1 = toRadians(prev.longitude); - int idx = 0; - if (geodesic) { - for (LatLng point2 : poly) { - double lat2 = toRadians(point2.latitude); - double lng2 = toRadians(point2.longitude); - if (isOnSegmentGC(lat1, lng1, lat2, lng2, lat3, lng3, havTolerance)) { - return Math.max(0, idx - 1); - } - lat1 = lat2; - lng1 = lng2; - idx++; - } - } else { - // We project the points to mercator space, where the Rhumb segment is a straight line, - // and compute the geodesic distance between point3 and the closest point on the - // segment. This method is an approximation, because it uses "closest" in mercator - // space which is not "closest" on the sphere -- but the error is small because - // "tolerance" is small. - double minAcceptable = lat3 - tolerance; - double maxAcceptable = lat3 + tolerance; - double y1 = mercator(lat1); - double y3 = mercator(lat3); - double[] xTry = new double[3]; - for (LatLng point2 : poly) { - double lat2 = toRadians(point2.latitude); - double y2 = mercator(lat2); - double lng2 = toRadians(point2.longitude); - if (max(lat1, lat2) >= minAcceptable && min(lat1, lat2) <= maxAcceptable) { - // We offset longitudes by -lng1; the implicit x1 is 0. - double x2 = wrap(lng2 - lng1, -PI, PI); - double x3Base = wrap(lng3 - lng1, -PI, PI); - xTry[0] = x3Base; - // Also explore wrapping of x3Base around the world in both directions. - xTry[1] = x3Base + 2 * PI; - xTry[2] = x3Base - 2 * PI; - for (double x3 : xTry) { - double dy = y2 - y1; - double len2 = x2 * x2 + dy * dy; - double t = len2 <= 0 ? 0 : clamp((x3 * x2 + (y3 - y1) * dy) / len2, 0, 1); - double xClosest = t * x2; - double yClosest = y1 + t * dy; - double latClosest = inverseMercator(yClosest); - double havDist = havDistance(lat3, latClosest, x3 - xClosest); - if (havDist < havTolerance) { - return Math.max(0, idx - 1); - } - } - } - lat1 = lat2; - lng1 = lng2; - y1 = y2; - idx++; - } - } - return -1; - } - - /** - * Returns sin(initial bearing from (lat1,lng1) to (lat3,lng3) minus initial bearing - * from (lat1, lng1) to (lat2,lng2)). - */ - private static double sinDeltaBearing(double lat1, double lng1, double lat2, double lng2, - double lat3, double lng3) { - double sinLat1 = sin(lat1); - double cosLat2 = cos(lat2); - double cosLat3 = cos(lat3); - double lat31 = lat3 - lat1; - double lng31 = lng3 - lng1; - double lat21 = lat2 - lat1; - double lng21 = lng2 - lng1; - double a = sin(lng31) * cosLat3; - double c = sin(lng21) * cosLat2; - double b = sin(lat31) + 2 * sinLat1 * cosLat3 * hav(lng31); - double d = sin(lat21) + 2 * sinLat1 * cosLat2 * hav(lng21); - double denom = (a * a + b * b) * (c * c + d * d); - return denom <= 0 ? 1 : (a * d - b * c) / sqrt(denom); - } - - private static boolean isOnSegmentGC(double lat1, double lng1, double lat2, double lng2, - double lat3, double lng3, double havTolerance) { - double havDist13 = havDistance(lat1, lat3, lng1 - lng3); - if (havDist13 <= havTolerance) { - return true; - } - double havDist23 = havDistance(lat2, lat3, lng2 - lng3); - if (havDist23 <= havTolerance) { - return true; - } - double sinBearing = sinDeltaBearing(lat1, lng1, lat2, lng2, lat3, lng3); - double sinDist13 = sinFromHav(havDist13); - double havCrossTrack = havFromSin(sinDist13 * sinBearing); - if (havCrossTrack > havTolerance) { - return false; - } - double havDist12 = havDistance(lat1, lat2, lng1 - lng2); - double term = havDist12 + havCrossTrack * (1 - 2 * havDist12); - if (havDist13 > term || havDist23 > term) { - return false; - } - if (havDist12 < 0.74) { - return true; - } - double cosCrossTrack = 1 - 2 * havCrossTrack; - double havAlongTrack13 = (havDist13 - havCrossTrack) / cosCrossTrack; - double havAlongTrack23 = (havDist23 - havCrossTrack) / cosCrossTrack; - double sinSumAlongTrack = sinSumFromHav(havAlongTrack13, havAlongTrack23); - return sinSumAlongTrack > 0; // Compare with half-circle == PI using sign of sin(). - } - - /** - * Simplifies the given poly (polyline or polygon) using the Douglas-Peucker decimation - * algorithm. Increasing the tolerance will result in fewer points in the simplified polyline - * or polygon. - *

- * When the providing a polygon as input, the first and last point of the list MUST have the - * same latitude and longitude (i.e., the polygon must be closed). If the input polygon is not - * closed, the resulting polygon may not be fully simplified. - *

- * The time complexity of Douglas-Peucker is O(n^2), so take care that you do not call this - * algorithm too frequently in your code. - * - * @param poly polyline or polygon to be simplified. Polygon should be closed (i.e., - * first and last points should have the same latitude and longitude). - * @param tolerance in meters. Increasing the tolerance will result in fewer points in the - * simplified poly. - * @return a simplified poly produced by the Douglas-Peucker algorithm - */ - public static List simplify(List poly, double tolerance) { - final int n = poly.size(); - if (n < 1) { - throw new IllegalArgumentException("Polyline must have at least 1 point"); - } - if (tolerance <= 0) { - throw new IllegalArgumentException("Tolerance must be greater than zero"); - } - - boolean closedPolygon = isClosedPolygon(poly); - LatLng lastPoint = null; - - // Check if the provided poly is a closed polygon - if (closedPolygon) { - // Add a small offset to the last point for Douglas-Peucker on polygons (see #201) - final double OFFSET = 0.00000000001; - lastPoint = poly.get(poly.size() - 1); - // LatLng.latitude and .longitude are immutable, so replace the last point - poly.remove(poly.size() - 1); - poly.add(new LatLng(lastPoint.latitude + OFFSET, lastPoint.longitude + OFFSET)); - } - - int idx; - int maxIdx = 0; - Stack stack = new Stack<>(); - double[] dists = new double[n]; - dists[0] = 1; - dists[n - 1] = 1; - double maxDist; - double dist = 0.0; - int[] current; - - if (n > 2) { - int[] stackVal = new int[]{0, (n - 1)}; - stack.push(stackVal); - while (stack.size() > 0) { - current = stack.pop(); - maxDist = 0; - for (idx = current[0] + 1; idx < current[1]; ++idx) { - dist = distanceToLine(poly.get(idx), poly.get(current[0]), - poly.get(current[1])); - if (dist > maxDist) { - maxDist = dist; - maxIdx = idx; - } - } - if (maxDist > tolerance) { - dists[maxIdx] = maxDist; - int[] stackValCurMax = {current[0], maxIdx}; - stack.push(stackValCurMax); - int[] stackValMaxCur = {maxIdx, current[1]}; - stack.push(stackValMaxCur); - } - } - } - - if (closedPolygon) { - // Replace last point w/ offset with the original last point to re-close the polygon - poly.remove(poly.size() - 1); - poly.add(lastPoint); - } - - // Generate the simplified line - idx = 0; - ArrayList simplifiedLine = new ArrayList<>(); - for (LatLng l : poly) { - if (dists[idx] != 0) { - simplifiedLine.add(l); - } - idx++; - } - - return simplifiedLine; - } - - /** - * Returns true if the provided list of points is a closed polygon (i.e., the first and last - * points are the same), and false if it is not - * - * @param poly polyline or polygon - * @return true if the provided list of points is a closed polygon (i.e., the first and last - * points are the same), and false if it is not - */ - public static boolean isClosedPolygon(List poly) { - LatLng firstPoint = poly.get(0); - LatLng lastPoint = poly.get(poly.size() - 1); - return firstPoint.equals(lastPoint); - } - - /** - * Computes the distance on the sphere between the point p and the line segment start to end. - * - * @param p the point to be measured - * @param start the beginning of the line segment - * @param end the end of the line segment - * @return the distance in meters (assuming spherical earth) - */ - public static double distanceToLine(final LatLng p, final LatLng start, final LatLng end) { - if (start.equals(end)) { - return computeDistanceBetween(end, p); - } - - // Implementation of http://paulbourke.net/geometry/pointlineplane/ or http://geomalgorithms.com/a02-_lines.html - final double s0lat = toRadians(p.latitude); - final double s0lng = toRadians(p.longitude); - final double s1lat = toRadians(start.latitude); - final double s1lng = toRadians(start.longitude); - final double s2lat = toRadians(end.latitude); - final double s2lng = toRadians(end.longitude); - - double lonCorrection = Math.cos(s1lat); - double s2s1lat = s2lat - s1lat; - double s2s1lng = (s2lng - s1lng) * lonCorrection; - final double u = ((s0lat - s1lat) * s2s1lat + (s0lng - s1lng) * lonCorrection * s2s1lng) - / (s2s1lat * s2s1lat + s2s1lng * s2s1lng); - if (u <= 0) { - return computeDistanceBetween(p, start); - } - if (u >= 1) { - return computeDistanceBetween(p, end); - } - LatLng su = new LatLng(start.latitude + u * (end.latitude - start.latitude), start.longitude + u * (end.longitude - start.longitude)); - return computeDistanceBetween(p, su); - } - - /** - * Decodes an encoded path string into a sequence of LatLngs. - */ - public static List decode(final String encodedPath) { - int len = encodedPath.length(); - - // For speed we preallocate to an upper bound on the final length, then - // truncate the array before returning. - final List path = new ArrayList(); - int index = 0; - int lat = 0; - int lng = 0; - - while (index < len) { - int result = 1; - int shift = 0; - int b; - do { - b = encodedPath.charAt(index++) - 63 - 1; - result += b << shift; - shift += 5; - } while (b >= 0x1f); - lat += (result & 1) != 0 ? ~(result >> 1) : (result >> 1); - - result = 1; - shift = 0; - do { - b = encodedPath.charAt(index++) - 63 - 1; - result += b << shift; - shift += 5; - } while (b >= 0x1f); - lng += (result & 1) != 0 ? ~(result >> 1) : (result >> 1); - - path.add(new LatLng(lat * 1e-5, lng * 1e-5)); - } - - return path; - } - - /** - * Encodes a sequence of LatLngs into an encoded path string. - */ - public static String encode(final List path) { - long lastLat = 0; - long lastLng = 0; - - final StringBuffer result = new StringBuffer(); - - for (final LatLng point : path) { - long lat = Math.round(point.latitude * 1e5); - long lng = Math.round(point.longitude * 1e5); - - long dLat = lat - lastLat; - long dLng = lng - lastLng; - - encode(dLat, result); - encode(dLng, result); - - lastLat = lat; - lastLng = lng; - } - return result.toString(); - } - - private static void encode(long v, StringBuffer result) { - v = v < 0 ? ~(v << 1) : v << 1; - while (v >= 0x20) { - result.append(Character.toChars((int) ((0x20 | (v & 0x1f)) + 63))); - v >>= 5; - } - result.append(Character.toChars((int) (v + 63))); - } -} diff --git a/library/src/main/java/com/google/maps/android/PolyUtil.kt b/library/src/main/java/com/google/maps/android/PolyUtil.kt new file mode 100644 index 000000000..e6421b7a3 --- /dev/null +++ b/library/src/main/java/com/google/maps/android/PolyUtil.kt @@ -0,0 +1,617 @@ +/* + * Copyright 2025 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 + +import com.google.android.gms.maps.model.LatLng +import com.google.maps.android.MathUtil.clamp +import com.google.maps.android.MathUtil.hav +import com.google.maps.android.MathUtil.havDistance +import com.google.maps.android.MathUtil.havFromSin +import com.google.maps.android.MathUtil.inverseMercator +import com.google.maps.android.MathUtil.mercator +import com.google.maps.android.MathUtil.sinFromHav +import com.google.maps.android.MathUtil.sinSumFromHav +import com.google.maps.android.MathUtil.wrap +import com.google.maps.android.SphericalUtil.computeDistanceBetween +import java.util.Stack +import kotlin.math.cos +import kotlin.math.max +import kotlin.math.min +import kotlin.math.round +import kotlin.math.sin +import kotlin.math.sqrt +import kotlin.math.tan + +/** + * A utility class containing geometric calculations for polygons and polylines. + * This class provides methods for determining if a point is inside a polygon, + * on the edge of a polygon, simplifying polylines, and encoding/decoding polylines. + * + * The methods in this class are designed to be used with the Google Maps Android API, + * and they operate on {@link LatLng} objects. The calculations can be performed + * using either geodesic (great circle) or rhumb (loxodromic) paths. + */ +object PolyUtil { + private const val DEFAULT_TOLERANCE = 0.1 // meters + + /** + * Computes whether the given point lies inside the specified polygon. + * The polygon is always considered closed, regardless of whether the last point equals + * the first or not. + * Inside is defined as not containing the South Pole -- the South Pole is always outside. + * The polygon is formed of great circle segments if geodesic is true, and of rhumb + * (loxodromic) segments otherwise. + * + * @param point The point to check. + * @param polygon The polygon to check against. + * @param geodesic Whether to treat the polygon segments as geodesic or rhumb lines. + * @return `true` if the point is inside the polygon, `false` otherwise. + */ + @JvmStatic + fun containsLocation(point: LatLng, polygon: List, geodesic: Boolean): Boolean { + return containsLocation(point.latitude, point.longitude, polygon, geodesic) + } + + /** + * Overload of {@link #containsLocation(LatLng, List, boolean)} that takes latitude and + * longitude as separate arguments. + */ + @JvmStatic + fun containsLocation( + latitude: Double, + longitude: Double, + polygon: List, + geodesic: Boolean + ): Boolean { + if (polygon.isEmpty()) { + return false + } + + val lat3 = Math.toRadians(latitude) + val lng3 = Math.toRadians(longitude) + val prev = polygon.last() + var lat1 = Math.toRadians(prev.latitude) + var lng1 = Math.toRadians(prev.longitude) + var nIntersect = 0 + + for (point2 in polygon) { + val dLng3 = wrap(lng3 - lng1, -Math.PI, Math.PI) + // Special case: point equal to vertex is inside. + if (lat3 == lat1 && dLng3 == 0.0) { + return true + } + val lat2 = Math.toRadians(point2.latitude) + val lng2 = Math.toRadians(point2.longitude) + // Offset longitudes by -lng1. + if (intersects(lat1, lat2, wrap(lng2 - lng1, -Math.PI, Math.PI), lat3, dLng3, geodesic)) { + ++nIntersect + } + lat1 = lat2 + lng1 = lng2 + } + return (nIntersect and 1) != 0 + } + + /** + * Computes whether the given point lies on or near the edge of a polygon, within a specified + * tolerance in meters. The polygon edge is composed of great circle segments if geodesic + * is true, and of Rhumb segments otherwise. The polygon edge is implicitly closed -- the + * closing segment between the first point and the last point is included. + * + * @param point The point to check. + * @param polygon The polygon to check against. + * @param geodesic Whether to treat the polygon segments as geodesic or rhumb lines. + * @param tolerance The tolerance in meters. + * @return `true` if the point is on the edge of the polygon, `false` otherwise. + */ + @JvmStatic + @JvmOverloads + fun isLocationOnEdge( + point: LatLng, + polygon: List, + geodesic: Boolean, + tolerance: Double = DEFAULT_TOLERANCE + ): Boolean { + return isLocationOnEdgeOrPath(point, polygon, true, geodesic, tolerance) + } + + /** + * Computes whether the given point lies on or near a polyline, within a specified + * tolerance in meters. The polyline is composed of great circle segments if geodesic + * is true, and of Rhumb segments otherwise. The polyline is not closed -- the closing + * segment between the first point and the last point is not included. + * + * @param point The point to check. + * @param polyline The polyline to check against. + * @param geodesic Whether to treat the polyline segments as geodesic or rhumb lines. + * @param tolerance The tolerance in meters. + * @return `true` if the point is on the polyline, `false` otherwise. + */ + @JvmStatic + @JvmOverloads + fun isLocationOnPath( + point: LatLng, + polyline: List, + geodesic: Boolean, + tolerance: Double = DEFAULT_TOLERANCE + ): Boolean { + return isLocationOnEdgeOrPath(point, polyline, false, geodesic, tolerance) + } + + private fun isLocationOnEdgeOrPath( + point: LatLng, + poly: List, + closed: Boolean, + geodesic: Boolean, + toleranceEarth: Double + ): Boolean { + val idx = locationIndexOnEdgeOrPath(point, poly, closed, geodesic, toleranceEarth) + + return (idx >= 0) + } + + /** + * Computes whether (and where) a given point lies on or near a polyline, within a specified tolerance. + * The polyline is not closed -- the closing segment between the first point and the last point is not included. + * + * @param point our needle + * @param poly our haystack + * @param geodesic the polyline is composed of great circle segments if geodesic + * is true, and of Rhumb segments otherwise + * @param tolerance tolerance (in meters) + * @return -1 if point does not lie on or near the polyline. + * 0 if point is between poly[0] and poly[1] (inclusive), + * 1 if between poly[1] and poly[2], + * ..., + * poly.size()-2 if between poly[poly.size() - 2] and poly[poly.size() - 1] + */ + @JvmStatic + @JvmOverloads + fun locationIndexOnPath( + point: LatLng, + poly: List, + geodesic: Boolean, + tolerance: Double = DEFAULT_TOLERANCE + ): Int { + return locationIndexOnEdgeOrPath(point, poly, false, geodesic, tolerance) + } + + /** + * Computes whether (and where) a given point lies on or near a polyline, within a specified tolerance. + * If closed, the closing segment between the last and first points of the polyline is not considered. + * + * @param point our needle + * @param poly our haystack + * @param closed whether the polyline should be considered closed by a segment connecting the last point back to the first one + * @param geodesic the polyline is composed of great circle segments if geodesic + * is true, and of Rhumb segments otherwise + * @param toleranceEarth tolerance (in meters) + * @return -1 if point does not lie on or near the polyline. + * 0 if point is between poly[0] and poly[1] (inclusive), + * 1 if between poly[1] and poly[2], + * ..., + * poly.size()-2 if between poly[poly.size() - 2] and poly[poly.size() - 1] + */ + @JvmStatic + fun locationIndexOnEdgeOrPath( + point: LatLng, + poly: List, + closed: Boolean, + geodesic: Boolean, + toleranceEarth: Double + ): Int { + if (poly.isEmpty()) { + return -1 + } + val tolerance = toleranceEarth / MathUtil.EARTH_RADIUS + val havTolerance = hav(tolerance) + val lat3 = Math.toRadians(point.latitude) + val lng3 = Math.toRadians(point.longitude) + val prev = poly[if (closed) poly.size - 1 else 0] + var lat1 = Math.toRadians(prev.latitude) + var lng1 = Math.toRadians(prev.longitude) + var idx = 0 + if (geodesic) { + for (point2 in poly) { + val lat2 = Math.toRadians(point2.latitude) + val lng2 = Math.toRadians(point2.longitude) + if (isOnSegmentGC(lat1, lng1, lat2, lng2, lat3, lng3, havTolerance)) { + return max(0, idx - 1) + } + lat1 = lat2 + lng1 = lng2 + idx++ + } + } else { + // We project the points to mercator space, where the Rhumb segment is a straight line, + // and compute the geodesic distance between point3 and the closest point on the + // segment. This method is an approximation, because it uses "closest" in mercator + // space which is not "closest" on the sphere -- but the error is small because + // "tolerance" is small. + val minAcceptable = lat3 - tolerance + val maxAcceptable = lat3 + tolerance + var y1 = mercator(lat1) + val y3 = mercator(lat3) + val xTry = DoubleArray(3) + for (point2 in poly) { + val lat2 = Math.toRadians(point2.latitude) + val y2 = mercator(lat2) + val lng2 = Math.toRadians(point2.longitude) + if (max(lat1, lat2) >= minAcceptable && min(lat1, lat2) <= maxAcceptable) { + // We offset longitudes by -lng1; the implicit x1 is 0. + val x2 = wrap(lng2 - lng1, -Math.PI, Math.PI) + val x3Base = wrap(lng3 - lng1, -Math.PI, Math.PI) + xTry[0] = x3Base + // Also explore wrapping of x3Base around the world in both directions. + xTry[1] = x3Base + 2 * Math.PI + xTry[2] = x3Base - 2 * Math.PI + for (x3 in xTry) { + val dy = y2 - y1 + val len2 = x2 * x2 + dy * dy + val t = if (len2 <= 0) 0.0 else clamp((x3 * x2 + (y3 - y1) * dy) / len2, 0.0, 1.0) + val xClosest = t * x2 + val yClosest = y1 + t * dy + val latClosest = inverseMercator(yClosest) + val havDist = havDistance(lat3, latClosest, x3 - xClosest) + if (havDist < havTolerance) { + return max(0, idx - 1) + } + } + } + lat1 = lat2 + lng1 = lng2 + y1 = y2 + idx++ + } + } + return -1 + } + + /** + * Simplifies the given poly (polyline or polygon) using the Douglas-Peucker decimation + * algorithm. Increasing the tolerance will result in fewer points in the simplified polyline + * or polygon. + * + * When the providing a polygon as input, the first and last point of the list MUST have the + * same latitude and longitude (i.e., the polygon must be closed). If the input polygon is not + * closed, the resulting polygon may not be fully simplified. + * + * The time complexity of Douglas-Peucker is O(n^2), so take care that you do not call this + * algorithm too frequently in your code. + * + * @param poly polyline or polygon to be simplified. Polygon should be closed (i.e., + * first and last points should have the same latitude and longitude). + * @param tolerance in meters. Increasing the tolerance will result in fewer points in the + * simplified poly. + * @return a simplified poly produced by the Douglas-Peucker algorithm + */ + @JvmStatic + fun simplify(poly: MutableList, tolerance: Double): List { + val n = poly.size + require(n >= 1) { "Polyline must have at least 1 point" } + require(tolerance > 0) { "Tolerance must be greater than zero" } + + val closedPolygon = isClosedPolygon(poly) + var lastPoint: LatLng? = null + + // Check if the provided poly is a closed polygon + if (closedPolygon) { + // Add a small offset to the last point for Douglas-Peucker on polygons (see #201) + val OFFSET = 0.00000000001 + lastPoint = poly.last() + poly.removeAt(poly.size - 1) + poly.add(LatLng(lastPoint.latitude + OFFSET, lastPoint.longitude + OFFSET)) + } + + var maxIdx = 0 + val stack = Stack() + val dists = DoubleArray(n) + dists[0] = 1.0 + dists[n - 1] = 1.0 + var maxDist: Double + var dist: Double + var current: IntArray + + if (n > 2) { + val stackVal = intArrayOf(0, n - 1) + stack.push(stackVal) + while (stack.isNotEmpty()) { + current = stack.pop() + maxDist = 0.0 + for (idx in current[0] + 1 until current[1]) { + dist = distanceToLine(poly[idx], poly[current[0]], poly[current[1]]) + if (dist > maxDist) { + maxDist = dist + maxIdx = idx + } + } + if (maxDist > tolerance) { + dists[maxIdx] = maxDist + val stackValCurMax = intArrayOf(current[0], maxIdx) + stack.push(stackValCurMax) + val stackValMaxCur = intArrayOf(maxIdx, current[1]) + stack.push(stackValMaxCur) + } + } + } + + if (closedPolygon) { + // Replace last point w/ offset with the original last point to re-close the polygon + poly.removeAt(poly.size - 1) + if (lastPoint != null) { + poly.add(lastPoint) + } + } + + // Generate the simplified line + return poly.filterIndexed { idx, _ -> dists[idx] != 0.0 } + } + + /** + * Returns true if the provided list of points is a closed polygon (i.e., the first and last + * points are the same), and false if it is not + * + * @param poly polyline or polygon + * @return true if the provided list of points is a closed polygon (i.e., the first and last + * points are the same), and false if it is not + */ + @JvmStatic + fun isClosedPolygon(poly: List): Boolean { + return poly.isNotEmpty() && poly.first() == poly.last() + } + + /** + * Computes the distance on the sphere between the point p and the line segment start to end. + * + * @param p the point to be measured + * @param start the beginning of the line segment + * @param end the end of the line segment + * @return the distance in meters (assuming spherical earth) + */ + @JvmStatic + fun distanceToLine(p: LatLng, start: LatLng, end: LatLng): Double { + if (start == end) { + return computeDistanceBetween(end, p) + } + + val s0lat = Math.toRadians(p.latitude) + val s0lng = Math.toRadians(p.longitude) + val s1lat = Math.toRadians(start.latitude) + val s1lng = Math.toRadians(start.longitude) + val s2lat = Math.toRadians(end.latitude) + val s2lng = Math.toRadians(end.longitude) + + val lonCorrection = cos(s1lat) + val s2s1lat = s2lat - s1lat + val s2s1lng = (s2lng - s1lng) * lonCorrection + val u = ((s0lat - s1lat) * s2s1lat + (s0lng - s1lng) * lonCorrection * s2s1lng) / + (s2s1lat * s2s1lat + s2s1lng * s2s1lng) + + if (u <= 0) { + return computeDistanceBetween(p, start) + } + if (u >= 1) { + return computeDistanceBetween(p, end) + } + + val su = LatLng( + start.latitude + u * (end.latitude - start.latitude), + start.longitude + u * (end.longitude - start.longitude) + ) + return computeDistanceBetween(p, su) + } + + /** + * Decodes an encoded path string into a sequence of LatLngs. + */ + @JvmStatic + fun decode(encodedPath: String): List { + val len = encodedPath.length + val path = mutableListOf() + var index = 0 + var lat = 0 + var lng = 0 + + while (index < len) { + var result = 1 + var shift = 0 + var b: Int + do { + b = encodedPath[index++].code - 63 - 1 + result += b shl shift + shift += 5 + } while (b >= 0x1f) + lat += if ((result and 1) != 0) (result shr 1).inv() else (result shr 1) + + result = 1 + shift = 0 + do { + b = encodedPath[index++].code - 63 - 1 + result += b shl shift + shift += 5 + } while (b >= 0x1f) + lng += if ((result and 1) != 0) (result shr 1).inv() else (result shr 1) + + path.add(LatLng(lat * 1e-5, lng * 1e-5)) + } + + return path + } + + /** + * Encodes a sequence of LatLngs into an encoded path string. + */ + @JvmStatic + fun encode(path: List): String { + var lastLat: Long = 0 + var lastLng: Long = 0 + val result = StringBuilder() + + for (point in path) { + val lat = round(point.latitude * 1e5).toLong() + val lng = round(point.longitude * 1e5).toLong() + val dLat = lat - lastLat + val dLng = lng - lastLng + + encode(dLat, result) + encode(dLng, result) + + lastLat = lat + lastLng = lng + } + return result.toString() + } + + private fun encode(v: Long, result: StringBuilder) { + var value = if (v < 0) (v shl 1).inv() else (v shl 1) + while (value >= 0x20) { + result.append(Character.toChars(((0x20 or (value and 0x1f).toInt()) + 63))) + value = value shr 5 + } + result.append(Character.toChars((value + 63).toInt())) + } + + /** + * Returns tan(latitude-at-lng3) on the great circle (lat1, lng1) to (lat2, lng2). lng1==0. + * See http://williams.best.vwh.net/avform.htm . + */ + private fun tanLatGC(lat1: Double, lat2: Double, lng2: Double, lng3: Double): Double { + return (tan(lat1) * sin(lng2 - lng3) + tan(lat2) * sin(lng3)) / sin(lng2) + } + + /** + * Returns mercator(latitude-at-lng3) on the Rhumb line (lat1, lng1) to (lat2, lng2). lng1==0. + */ + private fun mercatorLatRhumb(lat1: Double, lat2: Double, lng2: Double, lng3: Double): Double { + return (mercator(lat1) * (lng2 - lng3) + mercator(lat2) * lng3) / lng2 + } + + /** + * Computes whether the vertical segment (lat3, lng3) to South Pole intersects the segment + * (lat1, lng1) to (lat2, lng2). + * Longitudes are offset by -lng1; the implicit lng1 becomes 0. + */ + private fun intersects( + lat1: Double, + lat2: Double, + lng2: Double, + lat3: Double, + lng3: Double, + geodesic: Boolean + ): Boolean { + // Both ends on the same side of lng3. + if ((lng3 >= 0 && lng3 >= lng2) || (lng3 < 0 && lng3 < lng2)) { + return false + } + // Point is South Pole. + if (lat3 <= -Math.PI / 2) { + return false + } + // Any segment end is a pole. + if (lat1 <= -Math.PI / 2 || lat2 <= -Math.PI / 2 || lat1 >= Math.PI / 2 || lat2 >= Math.PI / 2) { + return false + } + if (lng2 <= -Math.PI) { + return false + } + val linearLat = (lat1 * (lng2 - lng3) + lat2 * lng3) / lng2 + // Northern hemisphere and point under lat-lng line. + if (lat1 >= 0 && lat2 >= 0 && lat3 < linearLat) { + return false + } + // Southern hemisphere and point above lat-lng line. + if (lat1 <= 0 && lat2 <= 0 && lat3 >= linearLat) { + return true + } + // North Pole. + if (lat3 >= Math.PI / 2) { + return true + } + // Compare lat3 with latitude on the GC/Rhumb segment corresponding to lng3. + // Compare through a strictly-increasing function (tan() or mercator()) as convenient. + return if (geodesic) { + tan(lat3) >= tanLatGC(lat1, lat2, lng2, lng3) + } else { + mercator(lat3) >= mercatorLatRhumb(lat1, lat2, lng2, lng3) + } + } + + /** + * Returns sin(initial bearing from (lat1,lng1) to (lat3,lng3) minus initial bearing + * from (lat1, lng1) to (lat2,lng2)). + */ + private fun sinDeltaBearing( + lat1: Double, + lng1: Double, + lat2: Double, + lng2: Double, + lat3: Double, + lng3: Double + ): Double { + val sinLat1 = sin(lat1) + val cosLat2 = cos(lat2) + val cosLat3 = cos(lat3) + val lat31 = lat3 - lat1 + val lng31 = lng3 - lng1 + val lat21 = lat2 - lat1 + val lng21 = lng2 - lng1 + val a = sin(lng31) * cosLat3 + val c = sin(lng21) * cosLat2 + val b = sin(lat31) + 2 * sinLat1 * cosLat3 * hav(lng31) + val d = sin(lat21) + 2 * sinLat1 * cosLat2 * hav(lng21) + val denom = (a * a + b * b) * (c * c + d * d) + return if (denom <= 0) 1.0 else (a * d - b * c) / sqrt(denom) + } + + private fun isOnSegmentGC( + lat1: Double, + lng1: Double, + lat2: Double, + lng2: Double, + lat3: Double, + lng3: Double, + havTolerance: Double + ): Boolean { + val havDist13 = havDistance(lat1, lat3, lng1 - lng3) + if (havDist13 <= havTolerance) { + return true + } + val havDist23 = havDistance(lat2, lat3, lng2 - lng3) + if (havDist23 <= havTolerance) { + return true + } + val sinBearing = sinDeltaBearing(lat1, lng1, lat2, lng2, lat3, lng3) + val sinDist13 = sinFromHav(havDist13) + val havCrossTrack = havFromSin(sinDist13 * sinBearing) + if (havCrossTrack > havTolerance) { + return false + } + val havDist12 = havDistance(lat1, lat2, lng1 - lng2) + val term = havDist12 + havCrossTrack * (1 - 2 * havDist12) + if (havDist13 > term || havDist23 > term) { + return false + } + if (havDist12 < 0.74) { + return true + } + val cosCrossTrack = 1 - 2 * havCrossTrack + val havAlongTrack13 = (havDist13 - havCrossTrack) / cosCrossTrack + val havAlongTrack23 = (havDist23 - havCrossTrack) / cosCrossTrack + val sinSumAlongTrack = sinSumFromHav(havAlongTrack13, havAlongTrack23) + return sinSumAlongTrack > 0 // Compare with half-circle == PI using sign of sin(). + } +} diff --git a/library/src/main/java/com/google/maps/android/SphericalUtil.java b/library/src/main/java/com/google/maps/android/SphericalUtil.java deleted file mode 100644 index 53e03ecda..000000000 --- a/library/src/main/java/com/google/maps/android/SphericalUtil.java +++ /dev/null @@ -1,265 +0,0 @@ -/* - * Copyright 2013 Google Inc. - * - * 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; - -import com.google.android.gms.maps.model.LatLng; - -import java.util.List; - -import static com.google.maps.android.MathUtil.*; -import static java.lang.Math.*; - -public class SphericalUtil { - - private SphericalUtil() { - } - - /** - * Returns the heading from one LatLng to another LatLng. Headings are - * expressed in degrees clockwise from North within the range [-180,180). - * - * @return The heading in degrees clockwise from north. - */ - public static double computeHeading(LatLng from, LatLng to) { - // http://williams.best.vwh.net/avform.htm#Crs - double fromLat = toRadians(from.latitude); - double fromLng = toRadians(from.longitude); - double toLat = toRadians(to.latitude); - double toLng = toRadians(to.longitude); - double dLng = toLng - fromLng; - double heading = atan2( - sin(dLng) * cos(toLat), - cos(fromLat) * sin(toLat) - sin(fromLat) * cos(toLat) * cos(dLng)); - return wrap(toDegrees(heading), -180, 180); - } - - /** - * Returns the LatLng resulting from moving a distance from an origin - * in the specified heading (expressed in degrees clockwise from north). - * - * @param from The LatLng from which to start. - * @param distance The distance to travel. - * @param heading The heading in degrees clockwise from north. - */ - public static LatLng computeOffset(LatLng from, double distance, double heading) { - distance /= EARTH_RADIUS; - heading = toRadians(heading); - // http://williams.best.vwh.net/avform.htm#LL - double fromLat = toRadians(from.latitude); - double fromLng = toRadians(from.longitude); - double cosDistance = cos(distance); - double sinDistance = sin(distance); - double sinFromLat = sin(fromLat); - double cosFromLat = cos(fromLat); - double sinLat = cosDistance * sinFromLat + sinDistance * cosFromLat * cos(heading); - double dLng = atan2( - sinDistance * cosFromLat * sin(heading), - cosDistance - sinFromLat * sinLat); - return new LatLng(toDegrees(asin(sinLat)), toDegrees(fromLng + dLng)); - } - - /** - * Returns the location of origin when provided with a LatLng destination, - * meters travelled and original heading. Headings are expressed in degrees - * clockwise from North. This function returns null when no solution is - * available. - * - * @param to The destination LatLng. - * @param distance The distance travelled, in meters. - * @param heading The heading in degrees clockwise from north. - */ - public static LatLng computeOffsetOrigin(LatLng to, double distance, double heading) { - heading = toRadians(heading); - distance /= EARTH_RADIUS; - // http://lists.maptools.org/pipermail/proj/2008-October/003939.html - double n1 = cos(distance); - double n2 = sin(distance) * cos(heading); - double n3 = sin(distance) * sin(heading); - double n4 = sin(toRadians(to.latitude)); - // There are two solutions for b. b = n2 * n4 +/- sqrt(), one solution results - // in the latitude outside the [-90, 90] range. We first try one solution and - // back off to the other if we are outside that range. - double n12 = n1 * n1; - double discriminant = n2 * n2 * n12 + n12 * n12 - n12 * n4 * n4; - if (discriminant < 0) { - // No real solution which would make sense in LatLng-space. - return null; - } - double b = n2 * n4 + sqrt(discriminant); - b /= n1 * n1 + n2 * n2; - double a = (n4 - n2 * b) / n1; - double fromLatRadians = atan2(a, b); - if (fromLatRadians < -PI / 2 || fromLatRadians > PI / 2) { - b = n2 * n4 - sqrt(discriminant); - b /= n1 * n1 + n2 * n2; - fromLatRadians = atan2(a, b); - } - if (fromLatRadians < -PI / 2 || fromLatRadians > PI / 2) { - // No solution which would make sense in LatLng-space. - return null; - } - double fromLngRadians = toRadians(to.longitude) - - atan2(n3, n1 * cos(fromLatRadians) - n2 * sin(fromLatRadians)); - return new LatLng(toDegrees(fromLatRadians), toDegrees(fromLngRadians)); - } - - /** - * Returns the LatLng which lies the given fraction of the way between the - * origin LatLng and the destination LatLng. - * - * @param from The LatLng from which to start. - * @param to The LatLng toward which to travel. - * @param fraction A fraction of the distance to travel. - * @return The interpolated LatLng. - */ - public static LatLng interpolate(LatLng from, LatLng to, double fraction) { - // http://en.wikipedia.org/wiki/Slerp - double fromLat = toRadians(from.latitude); - double fromLng = toRadians(from.longitude); - double toLat = toRadians(to.latitude); - double toLng = toRadians(to.longitude); - double cosFromLat = cos(fromLat); - double cosToLat = cos(toLat); - - // Computes Spherical interpolation coefficients. - double angle = computeAngleBetween(from, to); - double sinAngle = sin(angle); - if (sinAngle < 1E-6) { - return new LatLng( - from.latitude + fraction * (to.latitude - from.latitude), - from.longitude + fraction * (to.longitude - from.longitude)); - } - double a = sin((1 - fraction) * angle) / sinAngle; - double b = sin(fraction * angle) / sinAngle; - - // Converts from polar to vector and interpolate. - double x = a * cosFromLat * cos(fromLng) + b * cosToLat * cos(toLng); - double y = a * cosFromLat * sin(fromLng) + b * cosToLat * sin(toLng); - double z = a * sin(fromLat) + b * sin(toLat); - - // Converts interpolated vector back to polar. - double lat = atan2(z, sqrt(x * x + y * y)); - double lng = atan2(y, x); - return new LatLng(toDegrees(lat), toDegrees(lng)); - } - - /** - * Returns distance on the unit sphere; the arguments are in radians. - */ - private static double distanceRadians(double lat1, double lng1, double lat2, double lng2) { - return arcHav(havDistance(lat1, lat2, lng1 - lng2)); - } - - /** - * Returns the angle between two LatLngs, in radians. This is the same as the distance - * on the unit sphere. - */ - static double computeAngleBetween(LatLng from, LatLng to) { - return distanceRadians(toRadians(from.latitude), toRadians(from.longitude), - toRadians(to.latitude), toRadians(to.longitude)); - } - - /** - * Returns the distance between two LatLngs, in meters. - */ - public static double computeDistanceBetween(LatLng from, LatLng to) { - return computeAngleBetween(from, to) * EARTH_RADIUS; - } - - /** - * Returns the length of the given path, in meters, on Earth. - */ - public static double computeLength(List path) { - if (path.size() < 2) { - return 0; - } - double length = 0; - LatLng prev = null; - for (LatLng point : path) { - if (prev != null) { - double prevLat = toRadians(prev.latitude); - double prevLng = toRadians(prev.longitude); - double lat = toRadians(point.latitude); - double lng = toRadians(point.longitude); - length += distanceRadians(prevLat, prevLng, lat, lng); - } - prev = point; - } - return length * EARTH_RADIUS; - } - - /** - * Returns the area of a closed path on Earth. - * - * @param path A closed path. - * @return The path's area in square meters. - */ - public static double computeArea(List path) { - return abs(computeSignedArea(path)); - } - - /** - * Returns the signed area of a closed path on Earth. The sign of the area may be used to - * determine the orientation of the path. - * "inside" is the surface that does not contain the South Pole. - * - * @param path A closed path. - * @return The loop's area in square meters. - */ - public static double computeSignedArea(List path) { - return computeSignedArea(path, EARTH_RADIUS); - } - - /** - * Returns the signed area of a closed path on a sphere of given radius. - * The computed area uses the same units as the radius squared. - * Used by SphericalUtilTest. - */ - static double computeSignedArea(List path, double radius) { - int size = path.size(); - if (size < 3) { - return 0; - } - double total = 0; - LatLng prev = path.get(size - 1); - double prevTanLat = tan((PI / 2 - toRadians(prev.latitude)) / 2); - double prevLng = toRadians(prev.longitude); - // For each edge, accumulate the signed area of the triangle formed by the North Pole - // and that edge ("polar triangle"). - for (LatLng point : path) { - double tanLat = tan((PI / 2 - toRadians(point.latitude)) / 2); - double lng = toRadians(point.longitude); - total += polarTriangleArea(tanLat, lng, prevTanLat, prevLng); - prevTanLat = tanLat; - prevLng = lng; - } - return total * (radius * radius); - } - - /** - * Returns the signed area of a triangle which has North Pole as a vertex. - * Formula derived from "Area of a spherical triangle given two edges and the included angle" - * as per "Spherical Trigonometry" by Todhunter, page 71, section 103, point 2. - * See http://books.google.com/books?id=3uBHAAAAIAAJ&pg=PA71 - * The arguments named "tan" are tan((pi/2 - latitude)/2). - */ - private static double polarTriangleArea(double tan1, double lng1, double tan2, double lng2) { - double deltaLng = lng1 - lng2; - double t = tan1 * tan2; - return 2 * atan2(t * sin(deltaLng), 1 + t * cos(deltaLng)); - } -} diff --git a/library/src/main/java/com/google/maps/android/SphericalUtil.kt b/library/src/main/java/com/google/maps/android/SphericalUtil.kt new file mode 100644 index 000000000..0557b47de --- /dev/null +++ b/library/src/main/java/com/google/maps/android/SphericalUtil.kt @@ -0,0 +1,287 @@ +/* + * Copyright 2025 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 + +import com.google.android.gms.maps.model.LatLng +import com.google.maps.android.MathUtil.EARTH_RADIUS +import com.google.maps.android.MathUtil.arcHav +import com.google.maps.android.MathUtil.havDistance +import com.google.maps.android.MathUtil.wrap +import kotlin.math.PI +import kotlin.math.abs +import kotlin.math.asin +import kotlin.math.atan2 +import kotlin.math.cos +import kotlin.math.sin +import kotlin.math.sqrt +import kotlin.math.tan + +object SphericalUtil { + /** + * Returns the heading from one LatLng to another LatLng. Headings are + * expressed in degrees clockwise from North within the range [-180,180). + * + * @return The heading in degrees clockwise from north. + */ + @JvmStatic + fun computeHeading(from: LatLng, to: LatLng): Double { + // http://williams.best.vwh.net/avform.htm#Crs + val fromLat = Math.toRadians(from.latitude) + val fromLng = Math.toRadians(from.longitude) + val toLat = Math.toRadians(to.latitude) + val toLng = Math.toRadians(to.longitude) + val dLng = toLng - fromLng + val heading = atan2( + sin(dLng) * cos(toLat), + cos(fromLat) * sin(toLat) - sin(fromLat) * cos(toLat) * cos(dLng) + ) + return wrap(Math.toDegrees(heading), -180.0, 180.0) + } + + /** + * Returns the LatLng resulting from moving a distance from an origin + * in the specified heading (expressed in degrees clockwise from north). + * + * @param from The LatLng from which to start. + * @param distance The distance to travel. + * @param heading The heading in degrees clockwise from north. + */ + @JvmStatic + fun computeOffset(from: LatLng, distance: Double, heading: Double): LatLng { + var distance = distance + var heading = heading + distance /= EARTH_RADIUS + heading = Math.toRadians(heading) + // http://williams.best.vwh.net/avform.htm#LL + val fromLat = Math.toRadians(from.latitude) + val fromLng = Math.toRadians(from.longitude) + val cosDistance = cos(distance) + val sinDistance = sin(distance) + val sinFromLat = sin(fromLat) + val cosFromLat = cos(fromLat) + val sinLat = cosDistance * sinFromLat + sinDistance * cosFromLat * cos(heading) + val dLng = atan2( + sinDistance * cosFromLat * sin(heading), + cosDistance - sinFromLat * sinLat + ) + return LatLng(Math.toDegrees(asin(sinLat)), Math.toDegrees(fromLng + dLng)) + } + + /** + * Returns the location of origin when provided with a LatLng destination, + * meters travelled and original heading. Headings are expressed in degrees + * clockwise from North. This function returns null when no solution is + * available. + * + * @param to The destination LatLng. + * @param distance The distance travelled, in meters. + * @param heading The heading in degrees clockwise from north. + */ + @JvmStatic + fun computeOffsetOrigin(to: LatLng, distance: Double, heading: Double): LatLng? { + var distance = distance + var heading = heading + heading = Math.toRadians(heading) + distance /= EARTH_RADIUS + // http://lists.maptools.org/pipermail/proj/2008-October/003939.html + val n1 = cos(distance) + val n2 = sin(distance) * cos(heading) + val n3 = sin(distance) * sin(heading) + val n4 = sin(Math.toRadians(to.latitude)) + // There are two solutions for b. b = n2 * n4 +/- sqrt(), one solution results + // in the latitude outside the [-90, 90] range. We first try one solution and + // back off to the other if we are outside that range. + val n12 = n1 * n1 + val discriminant = n2 * n2 * n12 + n12 * n12 - n12 * n4 * n4 + if (discriminant < 0) { + // No real solution which would make sense in LatLng-space. + return null + } + var b = n2 * n4 + sqrt(discriminant) + b /= n1 * n1 + n2 * n2 + val a = (n4 - n2 * b) / n1 + var fromLatRadians = atan2(a, b) + if (fromLatRadians < -PI / 2 || fromLatRadians > PI / 2) { + b = n2 * n4 - sqrt(discriminant) + b /= n1 * n1 + n2 * n2 + fromLatRadians = atan2(a, b) + } + if (fromLatRadians < -PI / 2 || fromLatRadians > PI / 2) { + // No solution which would make sense in LatLng-space. + return null + } + val fromLngRadians = Math.toRadians(to.longitude) - + atan2(n3, n1 * cos(fromLatRadians) - n2 * sin(fromLatRadians)) + return LatLng(Math.toDegrees(fromLatRadians), Math.toDegrees(fromLngRadians)) + } + + /** + * Returns the LatLng which lies the given fraction of the way between the + * origin LatLng and the destination LatLng. + * + * @param from The LatLng from which to start. + * @param to The LatLng toward which to travel. + * @param fraction A fraction of the distance to travel. + * @return The interpolated LatLng. + */ + @JvmStatic + fun interpolate(from: LatLng, to: LatLng, fraction: Double): LatLng { + // http://en.wikipedia.org/wiki/Slerp + val fromLat = Math.toRadians(from.latitude) + val fromLng = Math.toRadians(from.longitude) + val toLat = Math.toRadians(to.latitude) + val toLng = Math.toRadians(to.longitude) + val cosFromLat = cos(fromLat) + val cosToLat = cos(toLat) + + // Computes Spherical interpolation coefficients. + val angle = computeAngleBetween(from, to) + val sinAngle = sin(angle) + if (sinAngle < 1E-6) { + return LatLng( + from.latitude + fraction * (to.latitude - from.latitude), + from.longitude + fraction * (to.longitude - from.longitude) + ) + } + val a = sin((1 - fraction) * angle) / sinAngle + val b = sin(fraction * angle) / sinAngle + + // Converts from polar to vector and interpolate. + val x = a * cosFromLat * cos(fromLng) + b * cosToLat * cos(toLng) + val y = a * cosFromLat * sin(fromLng) + b * cosToLat * sin(toLng) + val z = a * sin(fromLat) + b * sin(toLat) + + // Converts interpolated vector back to polar. + val lat = atan2(z, sqrt(x * x + y * y)) + val lng = atan2(y, x) + return LatLng(Math.toDegrees(lat), Math.toDegrees(lng)) + } + + /** + * Returns distance on the unit sphere; the arguments are in radians. + */ + private fun distanceRadians(lat1: Double, lng1: Double, lat2: Double, lng2: Double): Double { + return arcHav(havDistance(lat1, lat2, lng1 - lng2)) + } + + /** + * Returns the angle between two LatLngs, in radians. This is the same as the distance + * on the unit sphere. + */ + @JvmStatic + fun computeAngleBetween(from: LatLng, to: LatLng): Double { + return distanceRadians( + Math.toRadians(from.latitude), Math.toRadians(from.longitude), + Math.toRadians(to.latitude), Math.toRadians(to.longitude) + ) + } + + /** + * Returns the distance between two LatLngs, in meters. + */ + @JvmStatic + fun computeDistanceBetween(from: LatLng, to: LatLng): Double { + return computeAngleBetween(from, to) * EARTH_RADIUS + } + + /** + * Returns the length of the given path, in meters, on Earth. + */ + @JvmStatic + fun computeLength(path: List): Double { + if (path.size < 2) { + return 0.0 + } + var length = 0.0 + var prev: LatLng? = null + for (point in path) { + if (prev != null) { + val prevLat = Math.toRadians(prev.latitude) + val prevLng = Math.toRadians(prev.longitude) + val lat = Math.toRadians(point.latitude) + val lng = Math.toRadians(point.longitude) + length += distanceRadians(prevLat, prevLng, lat, lng) + } + prev = point + } + return length * EARTH_RADIUS + } + + /** + * Returns the area of a closed path on Earth. + * + * @param path A closed path. + * @return The path's area in square meters. + */ + @JvmStatic + fun computeArea(path: List): Double { + return abs(computeSignedArea(path)) + } + + /** + * Returns the signed area of a closed path on Earth. The sign of the area may be used to + * determine the orientation of the path. + * "inside" is the surface that does not contain the South Pole. + * + * @param path A closed path. + * @return The loop's area in square meters. + */ + @JvmStatic + fun computeSignedArea(path: List): Double { + return computeSignedArea(path, EARTH_RADIUS) + } + + /** + * Returns the signed area of a closed path on a sphere of given radius. + * The computed area uses the same units as the radius squared. + * Used by SphericalUtilTest. + */ + @JvmStatic + fun computeSignedArea(path: List, radius: Double): Double { + val size = path.size + if (size < 3) { + return 0.0 + } + var total = 0.0 + val prev = path[size - 1] + var prevTanLat = tan((PI / 2 - Math.toRadians(prev.latitude)) / 2) + var prevLng = Math.toRadians(prev.longitude) + // For each edge, accumulate the signed area of the triangle formed by the North Pole + // and that edge ("polar triangle"). + for (point in path) { + val tanLat = tan((PI / 2 - Math.toRadians(point.latitude)) / 2) + val lng = Math.toRadians(point.longitude) + total += polarTriangleArea(tanLat, lng, prevTanLat, prevLng) + prevTanLat = tanLat + prevLng = lng + } + return total * (radius * radius) + } + + /** + * Returns the signed area of a triangle which has North Pole as a vertex. + * Formula derived from "Area of a spherical triangle given two edges and the included angle" + * as per "Spherical Trigonometry" by Todhunter, page 71, section 103, point 2. + * See http://books.google.com/books?id=3uBHAAAAIAAJ&pg=PA71 + * The arguments named "tan" are tan((pi/2 - latitude)/2). + */ + private fun polarTriangleArea(tan1: Double, lng1: Double, tan2: Double, lng2: Double): Double { + val deltaLng = lng1 - lng2 + val t = tan1 * tan2 + return 2 * atan2(t * sin(deltaLng), 1 + t * cos(deltaLng)) + } +} \ No newline at end of file diff --git a/library/src/test/java/com/google/maps/android/MathUtilTest.java b/library/src/test/java/com/google/maps/android/MathUtilTest.java new file mode 100644 index 000000000..496196850 --- /dev/null +++ b/library/src/test/java/com/google/maps/android/MathUtilTest.java @@ -0,0 +1,100 @@ +/* + * Copyright 2025 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; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.Test; + +public class MathUtilTest { + private static final double DELTA = 1e-15; + + @Test + public void testClamp() { + assertThat(MathUtil.clamp(1.0, 0.0, 2.0)).isWithin(DELTA).of(1.0); + assertThat(MathUtil.clamp(-1.0, 0.0, 2.0)).isWithin(DELTA).of(0.0); + assertThat(MathUtil.clamp(3.0, 0.0, 2.0)).isWithin(DELTA).of(2.0); + } + + @Test + public void testWrap() { + assertThat(MathUtil.wrap(1.0, 0.0, 2.0)).isWithin(DELTA).of(1.0); + assertThat(MathUtil.wrap(3.0, 0.0, 2.0)).isWithin(DELTA).of(1.0); + assertThat(MathUtil.wrap(-1.0, 0.0, 2.0)).isWithin(DELTA).of(1.0); + } + + @Test + public void testMod() { + assertThat(MathUtil.mod(1.0, 2.0)).isWithin(DELTA).of(1.0); + assertThat(MathUtil.mod(3.0, 2.0)).isWithin(DELTA).of(1.0); + assertThat(MathUtil.mod(-1.0, 2.0)).isWithin(DELTA).of(1.0); + } + + @Test + public void testMercator() { + assertThat(MathUtil.mercator(0.0)).isWithin(DELTA).of(0.0); + assertThat(MathUtil.mercator(Math.PI / 2)).isPositiveInfinity(); + assertThat(MathUtil.mercator(-Math.PI / 2)).isNegativeInfinity(); + } + + @Test + public void testInverseMercator() { + assertThat(MathUtil.inverseMercator(0.0)).isWithin(DELTA).of(0.0); + assertThat(MathUtil.inverseMercator(Double.POSITIVE_INFINITY)).isWithin(DELTA).of(Math.PI / 2); + assertThat(MathUtil.inverseMercator(Double.NEGATIVE_INFINITY)).isWithin(DELTA).of(-Math.PI / 2); + } + + @Test + public void testHav() { + assertThat(MathUtil.hav(0.0)).isWithin(DELTA).of(0.0); + assertThat(MathUtil.hav(Math.PI)).isWithin(DELTA).of(1.0); + assertThat(MathUtil.hav(Math.PI / 2)).isWithin(DELTA).of(0.5); + } + + @Test + public void testArcHav() { + assertThat(MathUtil.arcHav(0.0)).isWithin(DELTA).of(0.0); + assertThat(MathUtil.arcHav(1.0)).isWithin(DELTA).of(Math.PI); + assertThat(MathUtil.arcHav(0.5)).isWithin(DELTA).of(Math.PI / 2); + } + + @Test + public void testSinFromHav() { + assertThat(MathUtil.sinFromHav(0.0)).isWithin(DELTA).of(0.0); + assertThat(MathUtil.sinFromHav(1.0)).isWithin(DELTA).of(0.0); + assertThat(MathUtil.sinFromHav(0.5)).isWithin(DELTA).of(1.0); + } + + @Test + public void testHavFromSin() { + assertThat(MathUtil.havFromSin(0.0)).isWithin(DELTA).of(0.0); + assertThat(MathUtil.havFromSin(1.0)).isWithin(DELTA).of(0.5); + } + + @Test + public void testSinSumFromHav() { + assertThat(MathUtil.sinSumFromHav(0.0, 0.0)).isWithin(DELTA).of(0.0); + assertThat(MathUtil.sinSumFromHav(0.5, 0.0)).isWithin(DELTA).of(1.0); + assertThat(MathUtil.sinSumFromHav(0.0, 0.5)).isWithin(DELTA).of(1.0); + } + + @Test + public void testHavDistance() { + assertThat(MathUtil.havDistance(0.0, 0.0, 0.0)).isWithin(DELTA).of(0.0); + assertThat(MathUtil.havDistance(0.0, Math.PI, 0.0)).isWithin(DELTA).of(1.0); + } +} \ No newline at end of file diff --git a/library/src/test/java/com/google/maps/android/PolyUtilTest.java b/library/src/test/java/com/google/maps/android/PolyUtilTest.java index cff8c538e..5091f7192 100644 --- a/library/src/test/java/com/google/maps/android/PolyUtilTest.java +++ b/library/src/test/java/com/google/maps/android/PolyUtilTest.java @@ -1,8 +1,9 @@ /* - * Copyright 2013 Google Inc. + * Copyright 2013 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 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 @@ -17,21 +18,34 @@ package com.google.maps.android; import com.google.android.gms.maps.model.LatLng; - import org.junit.Test; import java.util.ArrayList; import java.util.List; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertSame; -import static org.junit.Assert.assertTrue; - +import static com.google.common.truth.Truth.assertThat; + +/** + * This class defines a series of tests for the {@link PolyUtil} class. + * Each test is designed to verify the correctness of a specific geometric utility function + * provided by {@link PolyUtil}, such as checking if a point is contained within a polygon, + * if it lies on an edge, or simplifying a polyline. + *

+ * The tests are structured to cover a wide range of scenarios, including edge cases like + * empty polygons, polygons that cross the international date line, and polygons near the poles. + * This comprehensive testing ensures that the geometric calculations are robust and reliable. + */ public class PolyUtilTest { private static final String TEST_LINE = - "_cqeFf~cjVf@p@fA}AtAoB`ArAx@hA`GbIvDiFv@gAh@t@X\\|@z@`@Z\\Xf@Vf@VpA\\tATJ@NBBkC"; + "_cqeFf~cjVf@p@fA}AtAoB`ArAx@hA`GbIvDiFv@gAh@t@X\\|@z@`@Z\\Xf@Vf@VpA\\tATJ@NBBkC"; + /** + * A helper method to construct a {@link List} of {@link LatLng} objects from a series of + * latitude and longitude coordinates. This simplifies the creation of test polygons and polylines. + * + * @param coords A varargs array of doubles, representing latitude and longitude pairs. + * @return A {@link List} of {@link LatLng} objects. + */ private static List makeList(double... coords) { int size = coords.length / 2; List list = new ArrayList<>(size); @@ -41,54 +55,111 @@ private static List makeList(double... coords) { return list; } + /** + * A helper method to test the {@link PolyUtil#containsLocation(LatLng, List, boolean)} method. + * It asserts that all points in the {@code yes} list are contained within the polygon, + * and all points in the {@code no} list are not. This is tested for both geodesic and rhumb line paths. + * + * @param poly The polygon to test against. + * @param yes A list of points that are expected to be inside the polygon. + * @param no A list of points that are expected to be outside the polygon. + */ private static void containsCase(List poly, List yes, List no) { for (LatLng point : yes) { - assertTrue(PolyUtil.containsLocation(point, poly, true)); - assertTrue(PolyUtil.containsLocation(point, poly, false)); + assertThat(PolyUtil.containsLocation(point, poly, true)).isTrue(); + assertThat(PolyUtil.containsLocation(point, poly, false)).isTrue(); } for (LatLng point : no) { - assertFalse(PolyUtil.containsLocation(point, poly, true)); - assertFalse(PolyUtil.containsLocation(point, poly, false)); + assertThat(PolyUtil.containsLocation(point, poly, true)).isFalse(); + assertThat(PolyUtil.containsLocation(point, poly, false)).isFalse(); } } + /** + * A helper method to test the {@link PolyUtil#isLocationOnEdge(LatLng, List, boolean)} and + * {@link PolyUtil#isLocationOnPath(LatLng, List, boolean)} methods. + * It asserts that all points in the {@code yes} list are on the edge of the polygon, + * and all points in the {@code no} list are not. + * + * @param geodesic Whether to use geodesic or rhumb line paths. + * @param poly The polygon or polyline to test against. + * @param yes A list of points that are expected to be on the edge. + * @param no A list of points that are expected to not be on the edge. + */ private static void onEdgeCase( - boolean geodesic, List poly, List yes, List no) { + boolean geodesic, List poly, List yes, List no) { for (LatLng point : yes) { - assertTrue(PolyUtil.isLocationOnEdge(point, poly, geodesic)); - assertTrue(PolyUtil.isLocationOnPath(point, poly, geodesic)); + assertThat(PolyUtil.isLocationOnEdge(point, poly, geodesic)).isTrue(); + assertThat(PolyUtil.isLocationOnPath(point, poly, geodesic)).isTrue(); } for (LatLng point : no) { - assertFalse(PolyUtil.isLocationOnEdge(point, poly, geodesic)); - assertFalse(PolyUtil.isLocationOnPath(point, poly, geodesic)); + assertThat(PolyUtil.isLocationOnEdge(point, poly, geodesic)).isFalse(); + assertThat(PolyUtil.isLocationOnPath(point, poly, geodesic)).isFalse(); } } + /** + * Overloaded helper method for {@link #onEdgeCase(boolean, List, List, List)} that tests for both + * geodesic and rhumb line paths. + */ private static void onEdgeCase(List poly, List yes, List no) { onEdgeCase(true, poly, yes, no); onEdgeCase(false, poly, yes, no); } + /** + * A helper method to test the {@link PolyUtil#locationIndexOnPath(LatLng, List, boolean)}. + * It asserts that the returned index for a given point on a polyline is as expected. + * + * @param geodesic Whether to use geodesic or rhumb line paths. + * @param poly The polyline to test against. + * @param point The point to find the index for. + * @param idx The expected index. + */ private static void locationIndexCase( - boolean geodesic, List poly, LatLng point, int idx) { - assertEquals(idx, PolyUtil.locationIndexOnPath(point, poly, geodesic)); + boolean geodesic, List poly, LatLng point, int idx) { + assertThat(PolyUtil.locationIndexOnPath(point, poly, geodesic)).isEqualTo(idx); } + /** + * Overloaded helper method for {@link #locationIndexCase(boolean, List, LatLng, int)} that tests for both + * geodesic and rhumb line paths. + */ private static void locationIndexCase(List poly, LatLng point, int idx) { locationIndexCase(true, poly, point, idx); locationIndexCase(false, poly, point, idx); } + /** + * A helper method to test {@link PolyUtil#locationIndexOnPath(LatLng, List, boolean, double)} + * with a specific tolerance. + * + * @param geodesic Whether to use geodesic or rhumb line paths. + * @param poly The polyline to test against. + * @param point The point to find the index for. + * @param idx The expected index. + */ private static void locationIndexToleranceCase( - boolean geodesic, List poly, LatLng point, int idx) { - assertEquals(idx, PolyUtil.locationIndexOnPath(point, poly, geodesic, 0.1)); + boolean geodesic, List poly, LatLng point, int idx) { + assertThat(PolyUtil.locationIndexOnPath(point, poly, geodesic, 0.1)).isEqualTo(idx); } + /** + * Overloaded helper method for {@link #locationIndexToleranceCase(boolean, List, LatLng, int)} that tests for both + * geodesic and rhumb line paths. + */ private static void locationIndexToleranceCase(List poly, LatLng point, int idx) { locationIndexToleranceCase(true, poly, point, idx); locationIndexToleranceCase(false, poly, point, idx); } + /** + * This test verifies the behavior of the `isLocationOnEdge` and `isLocationOnPath` methods. + * It covers a variety of scenarios, including empty polylines, endpoints, and segments on the equator, + * meridians, and slanted lines. It also tests cases near the poles and with long arcs. + * The test uses a small tolerance to check for points that are very close to the edge, and a larger + * tolerance to check for points that are further away. + */ @Test public void testOnEdge() { // Empty @@ -103,68 +174,73 @@ public void testOnEdge() { // On equator. onEdgeCase( - makeList(0, 90, 0, 180), - makeList(0, 90 - small, 0, 90 + small, 0 - small, 90, 0, 135, small, 135), - makeList(0, 90 - big, 0, 0, 0, -90, big, 135)); + makeList(0, 90, 0, 180), + makeList(0, 90 - small, 0, 90 + small, 0 - small, 90, 0, 135, small, 135), + makeList(0, 90 - big, 0, 0, 0, -90, big, 135)); // Ends on same latitude. onEdgeCase( - makeList(-45, -180, -45, -small), - makeList(-45, 180 + small, -45, 180 - small, -45 - small, 180 - small, -45, 0), - makeList(-45, big, -45, 180 - big, -45 + big, -90, -45, 90)); + makeList(-45, -180, -45, -small), + makeList(-45, 180 + small, -45, 180 - small, -45 - small, 180 - small, -45, 0), + makeList(-45, big, -45, 180 - big, -45 + big, -90, -45, 90)); // Meridian. onEdgeCase( - makeList(-10, 30, 45, 30), - makeList(10, 30 - small, 20, 30 + small, -10 - small, 30 + small), - makeList(-10 - big, 30, 10, -150, 0, 30 - big)); + makeList(-10, 30, 45, 30), + makeList(10, 30 - small, 20, 30 + small, -10 - small, 30 + small), + makeList(-10 - big, 30, 10, -150, 0, 30 - big)); // Slanted close to meridian, close to North pole. onEdgeCase( - makeList(0, 0, 90 - small, 0 + big), - makeList(1, 0 + small, 2, 0 - small, 90 - small, -90, 90 - small, 10), - makeList(-big, 0, 90 - big, 180, 10, big)); + makeList(0, 0, 90 - small, 0 + big), + makeList(1, 0 + small, 2, 0 - small, 90 - small, -90, 90 - small, 10), + makeList(-big, 0, 90 - big, 180, 10, big)); // Arc > 120 deg. onEdgeCase( - makeList(0, 0, 0, 179.999), - makeList(0, 90, 0, small, 0, 179, small, 90), - makeList(0, -90, small, -100, 0, 180, 0, -big, 90, 0, -90, 180)); + makeList(0, 0, 0, 179.999), + makeList(0, 90, 0, small, 0, 179, small, 90), + makeList(0, -90, small, -100, 0, 180, 0, -big, 90, 0, -90, 180)); onEdgeCase( - makeList(10, 5, 30, 15), - makeList(10 + 2 * big, 5 + big, 10 + big, 5 + big / 2, 30 - 2 * big, 15 - big), - makeList( - 20, - 10, - 10 - big, - 5 - big / 2, - 30 + 2 * big, - 15 + big, - 10 + 2 * big, - 5, - 10, - 5 + big)); + makeList(10, 5, 30, 15), + makeList(10 + 2 * big, 5 + big, 10 + big, 5 + big / 2, 30 - 2 * big, 15 - big), + makeList( + 20, + 10, + 10 - big, + 5 - big / 2, + 30 + 2 * big, + 15 + big, + 10 + 2 * big, + 5, + 10, + 5 + big)); onEdgeCase( - makeList(90 - small, 0, 0, 180 - small / 2), - makeList(big, -180 + small / 2, big, 180 - small / 4, big, 180 - small), - makeList(-big, -180 + small / 2, -big, 180, -big, 180 - small)); + makeList(90 - small, 0, 0, 180 - small / 2), + makeList(big, -180 + small / 2, big, 180 - small / 4, big, 180 - small), + makeList(-big, -180 + small / 2, -big, 180, -big, 180 - small)); // Reaching close to North pole. onEdgeCase( - true, - makeList(80, 0, 80, 180 - small), - makeList(90 - small, -90, 90, -135, 80 - small, 0, 80 + small, 0), - makeList(80, 90, 79, big)); + true, + makeList(80, 0, 80, 180 - small), + makeList(90 - small, -90, 90, -135, 80 - small, 0, 80 + small, 0), + makeList(80, 90, 79, big)); onEdgeCase( - false, - makeList(80, 0, 80, 180 - small), - makeList(80 - small, 0, 80 + small, 0, 80, 90), - makeList(79, big, 90 - small, -90, 90, -135)); + false, + makeList(80, 0, 80, 180 - small), + makeList(80 - small, 0, 80 + small, 0, 80, 90), + makeList(79, big, 90 - small, -90, 90, -135)); } + /** + * This test verifies the `locationIndexOnPath` method, which determines the index of the segment + * a point lies on. It tests empty polylines, single-point polylines, and multi-segment polylines, + * ensuring that the correct segment index is returned for points on and off the path. + */ @Test public void testLocationIndex() { // Empty. @@ -188,6 +264,11 @@ public void testLocationIndex() { locationIndexCase(makeList(0, 80, 0, 90, 0, 100), new LatLng(0, 110), -1); } + /** + * This test specifically focuses on the tolerance parameter of the `locationIndexOnPath` method. + * It verifies that the method correctly identifies points as being on a path segment within a given + * tolerance, and correctly identifies points as being off the path if they are outside the tolerance. + */ @Test public void testLocationIndexTolerance() { final double small = 5e-7; // About 5cm on equator, half the default tolerance. @@ -195,20 +276,26 @@ public void testLocationIndexTolerance() { // Test tolerance. locationIndexToleranceCase( - makeList(0, 90 - small, 0, 90, 0, 90 + small), new LatLng(0, 90), 0); + makeList(0, 90 - small, 0, 90, 0, 90 + small), new LatLng(0, 90), 0); locationIndexToleranceCase( - makeList(0, 90 - small, 0, 90, 0, 90 + small), new LatLng(0, 90 + small), 0); + makeList(0, 90 - small, 0, 90, 0, 90 + small), new LatLng(0, 90 + small), 0); locationIndexToleranceCase( - makeList(0, 90 - small, 0, 90, 0, 90 + small), new LatLng(0, 90 + 2 * small), 1); + makeList(0, 90 - small, 0, 90, 0, 90 + small), new LatLng(0, 90 + 2 * small), 1); locationIndexToleranceCase( - makeList(0, 90 - small, 0, 90, 0, 90 + small), new LatLng(0, 90 + 3 * small), -1); + makeList(0, 90 - small, 0, 90, 0, 90 + small), new LatLng(0, 90 + 3 * small), -1); locationIndexToleranceCase(makeList(0, 90 - big, 0, 90, 0, 90 + big), new LatLng(0, 90), 0); locationIndexToleranceCase( - makeList(0, 90 - big, 0, 90, 0, 90 + big), new LatLng(0, 90 + big), 1); + makeList(0, 90 - big, 0, 90, 0, 90 + big), new LatLng(0, 90 + big), 1); locationIndexToleranceCase( - makeList(0, 90 - big, 0, 90, 0, 90 + big), new LatLng(0, 90 + 2 * big), -1); + makeList(0, 90 - big, 0, 90, 0, 90 + big), new LatLng(0, 90 + 2 * big), -1); } + /** + * This test verifies the `containsLocation` method, which checks if a point is inside a polygon. + * It includes tests for empty polygons, single-point polygons, and various shapes of polygons. + * Special attention is given to polygons that are near the North and South poles, as these can be + * tricky edge cases for geometric calculations. + */ @Test public void testContainsLocation() { // Empty. @@ -222,38 +309,46 @@ public void testContainsLocation() { // Some arbitrary triangle. containsCase( - makeList(0., 0., 10., 12., 20., 5.), - makeList(10., 12., 10, 11, 19, 5), - makeList(0, 1, 11, 12, 30, 5, 0, -180, 0, 90)); + makeList(0., 0., 10., 12., 20., 5.), + makeList(10., 12., 10, 11, 19, 5), + makeList(0, 1, 11, 12, 30, 5, 0, -180, 0, 90)); // Around North Pole. containsCase( - makeList(89, 0, 89, 120, 89, -120), - makeList(90, 0, 90, 180, 90, -90), - makeList(-90, 0, 0, 0)); + makeList(89, 0, 89, 120, 89, -120), + makeList(90, 0, 90, 180, 90, -90), + makeList(-90, 0, 0, 0)); // Around South Pole. containsCase( - makeList(-89, 0, -89, 120, -89, -120), - makeList(90, 0, 90, 180, 90, -90, 0, 0), - makeList(-90, 0, -90, 90)); + makeList(-89, 0, -89, 120, -89, -120), + makeList(90, 0, 90, 180, 90, -90, 0, 0), + makeList(-90, 0, -90, 90)); // Over/under segment on meridian and equator. containsCase( - makeList(5, 10, 10, 10, 0, 20, 0, -10), - makeList(2.5, 10, 1, 0), - makeList(15, 10, 0, -15, 0, 25, -1, 0)); + makeList(5, 10, 10, 10, 0, 20, 0, -10), + makeList(2.5, 10, 1, 0), + makeList(15, 10, 0, -15, 0, 25, -1, 0)); } + /** + * This test verifies the `simplify` method, which uses the Douglas-Peucker algorithm to reduce + * the number of points in a polyline or polygon. The test checks the simplification at various + * tolerance levels, from small to large, and asserts that the simplified line has the expected + * number of points. It also verifies that the endpoints of the simplified line are the same as + * the original, that the simplified points are a subset of the original points, and that the + * length of the simplified line is less than or equal to the original. + */ @Test public void testSimplify() { /* * Polyline */ final String LINE = - "elfjD~a}uNOnFN~Em@fJv@tEMhGDjDe@hG^nF??@lA?n@IvAC`Ay@A{@DwCA{CF_EC{CEi@PBTFDJBJ?V?n@?D@?A@?@?F?F?LAf@?n@@`@@T@~@FpA?fA?p@?r@?vAH`@OR@^ETFJCLD?JA^?J?P?fAC`B@d@?b@A\\@`@Ad@@\\?`@?f@?V?H?DD@DDBBDBD?D?B?B@B@@@B@B@B@D?D?JAF@H@FCLADBDBDCFAN?b@Af@@x@@"; + "elfjD~a}uNOnFN~Em@fJv@tEMhGDjDe@hG^nF??@lA?n@IvAC`Ay@A{@DwCA{CF_EC{CEi@PBTFDJBJ?V?n@?D@?A@?@?F?F?LAf@?n@@`@@T@~@FpA?fA?p@?r@?vAH`@OR@^ETFJCLD?JA^?J?P?fAC`B@d@?b@A\\@`@Ad@@\\?`@?f@?V?H?DD@DDBBDBD?D?B?B@B@@@B@B@B@D?D?JAF@H@FCLADBDBDCFAN?b@Af@@x@@"; List line = PolyUtil.decode(LINE); - assertEquals(95, line.size()); + assertThat(line.size()).isEqualTo(95); List simplifiedLine; List copy; @@ -261,7 +356,7 @@ public void testSimplify() { double tolerance = 5; // meters copy = new ArrayList<>(line); simplifiedLine = PolyUtil.simplify(line, tolerance); - assertEquals(20, simplifiedLine.size()); + assertThat(simplifiedLine.size()).isEqualTo(20); assertEndPoints(line, simplifiedLine); assertSimplifiedPointsFromLine(line, simplifiedLine); assertLineLength(line, simplifiedLine); @@ -270,7 +365,7 @@ public void testSimplify() { tolerance = 10; // meters copy = new ArrayList<>(line); simplifiedLine = PolyUtil.simplify(line, tolerance); - assertEquals(14, simplifiedLine.size()); + assertThat(simplifiedLine.size()).isEqualTo(14); assertEndPoints(line, simplifiedLine); assertSimplifiedPointsFromLine(line, simplifiedLine); assertLineLength(line, simplifiedLine); @@ -279,7 +374,7 @@ public void testSimplify() { tolerance = 15; // meters copy = new ArrayList<>(line); simplifiedLine = PolyUtil.simplify(line, tolerance); - assertEquals(10, simplifiedLine.size()); + assertThat(simplifiedLine.size()).isEqualTo(10); assertEndPoints(line, simplifiedLine); assertSimplifiedPointsFromLine(line, simplifiedLine); assertLineLength(line, simplifiedLine); @@ -288,7 +383,7 @@ public void testSimplify() { tolerance = 20; // meters copy = new ArrayList<>(line); simplifiedLine = PolyUtil.simplify(line, tolerance); - assertEquals(8, simplifiedLine.size()); + assertThat(simplifiedLine.size()).isEqualTo(8); assertEndPoints(line, simplifiedLine); assertSimplifiedPointsFromLine(line, simplifiedLine); assertLineLength(line, simplifiedLine); @@ -297,7 +392,7 @@ public void testSimplify() { tolerance = 50; // meters copy = new ArrayList<>(line); simplifiedLine = PolyUtil.simplify(line, tolerance); - assertEquals(6, simplifiedLine.size()); + assertThat(simplifiedLine.size()).isEqualTo(6); assertEndPoints(line, simplifiedLine); assertSimplifiedPointsFromLine(line, simplifiedLine); assertLineLength(line, simplifiedLine); @@ -306,7 +401,7 @@ public void testSimplify() { tolerance = 500; // meters copy = new ArrayList<>(line); simplifiedLine = PolyUtil.simplify(line, tolerance); - assertEquals(3, simplifiedLine.size()); + assertThat(simplifiedLine.size()).isEqualTo(3); assertEndPoints(line, simplifiedLine); assertSimplifiedPointsFromLine(line, simplifiedLine); assertLineLength(line, simplifiedLine); @@ -315,7 +410,7 @@ public void testSimplify() { tolerance = 1000; // meters copy = new ArrayList<>(line); simplifiedLine = PolyUtil.simplify(line, tolerance); - assertEquals(2, simplifiedLine.size()); + assertThat(simplifiedLine.size()).isEqualTo(2); assertEndPoints(line, simplifiedLine); assertSimplifiedPointsFromLine(line, simplifiedLine); assertLineLength(line, simplifiedLine); @@ -332,12 +427,12 @@ public void testSimplify() { triangle.add(new LatLng(28.06125, -82.40850)); triangle.add(new LatLng(28.06035, -82.40834)); triangle.add(new LatLng(28.06038, -82.40924)); - assertFalse(PolyUtil.isClosedPolygon(triangle)); + assertThat(PolyUtil.isClosedPolygon(triangle)).isFalse(); copy = new ArrayList<>(triangle); tolerance = 88; // meters List simplifiedTriangle = PolyUtil.simplify(triangle, tolerance); - assertEquals(4, simplifiedTriangle.size()); + assertThat(simplifiedTriangle.size()).isEqualTo(4); assertEndPoints(triangle, simplifiedTriangle); assertSimplifiedPointsFromLine(triangle, simplifiedTriangle); assertLineLength(triangle, simplifiedTriangle); @@ -347,12 +442,12 @@ public void testSimplify() { LatLng p = triangle.get(0); LatLng closePoint = new LatLng(p.latitude, p.longitude); triangle.add(closePoint); - assertTrue(PolyUtil.isClosedPolygon(triangle)); + assertThat(PolyUtil.isClosedPolygon(triangle)).isTrue(); copy = new ArrayList<>(triangle); tolerance = 88; // meters simplifiedTriangle = PolyUtil.simplify(triangle, tolerance); - assertEquals(4, simplifiedTriangle.size()); + assertThat(simplifiedTriangle.size()).isEqualTo(4); assertEndPoints(triangle, simplifiedTriangle); assertSimplifiedPointsFromLine(triangle, simplifiedTriangle); assertLineLength(triangle, simplifiedTriangle); @@ -360,14 +455,14 @@ public void testSimplify() { // Open oval final String OVAL_POLYGON = - "}wgjDxw_vNuAd@}AN{A]w@_Au@kAUaA?{@Ke@@_@C]D[FULWFOLSNMTOVOXO\\I\\CX?VJXJTDTNXTVVLVJ`@FXA\\AVLZBTATBZ@ZAT?\\?VFT@XGZ"; + "}wgjDxw_vNuAd@}AN{A]w@_Au@kAUaA?{@Ke@@_@C]D[FULWFOLSNMTOVOXO\\I\\CX?VJXJTDTNXTVVLVJ`@FXA\\AVLZBTATBZ@ZAT?\\?VFT@XGZ"; List oval = PolyUtil.decode(OVAL_POLYGON); - assertFalse(PolyUtil.isClosedPolygon(oval)); + assertThat(PolyUtil.isClosedPolygon(oval)).isFalse(); copy = new ArrayList<>(oval); tolerance = 10; // meters List simplifiedOval = PolyUtil.simplify(oval, tolerance); - assertEquals(13, simplifiedOval.size()); + assertThat(simplifiedOval.size()).isEqualTo(13); assertEndPoints(oval, simplifiedOval); assertSimplifiedPointsFromLine(oval, simplifiedOval); assertLineLength(oval, simplifiedOval); @@ -377,12 +472,12 @@ public void testSimplify() { p = oval.get(0); closePoint = new LatLng(p.latitude, p.longitude); oval.add(closePoint); - assertTrue(PolyUtil.isClosedPolygon(oval)); + assertThat(PolyUtil.isClosedPolygon(oval)).isTrue(); copy = new ArrayList<>(oval); tolerance = 10; // meters simplifiedOval = PolyUtil.simplify(oval, tolerance); - assertEquals(13, simplifiedOval.size()); + assertThat(simplifiedOval.size()).isEqualTo(13); assertEndPoints(oval, simplifiedOval); assertSimplifiedPointsFromLine(oval, simplifiedOval); assertLineLength(oval, simplifiedOval); @@ -398,8 +493,8 @@ public void testSimplify() { * @param simplifiedLine simplified line */ private void assertEndPoints(List line, List simplifiedLine) { - assertEquals(line.get(0), simplifiedLine.get(0)); - assertEquals(line.get(line.size() - 1), simplifiedLine.get(simplifiedLine.size() - 1)); + assertThat(simplifiedLine.get(0)).isEqualTo(line.get(0)); + assertThat(simplifiedLine.get(simplifiedLine.size() - 1)).isEqualTo(line.get(line.size() - 1)); } /** @@ -410,7 +505,7 @@ private void assertEndPoints(List line, List simplifiedLine) { */ private void assertSimplifiedPointsFromLine(List line, List simplifiedLine) { for (LatLng l : simplifiedLine) { - assertTrue(line.contains(l)); + assertThat(line).contains(l); } } @@ -424,16 +519,14 @@ private void assertSimplifiedPointsFromLine(List line, List simp private void assertLineLength(List line, List simplifiedLine) { if (line.size() == simplifiedLine.size()) { // If no points were eliminated, then the length of both lines should be the same - assertEquals( - SphericalUtil.computeLength(simplifiedLine), - SphericalUtil.computeLength(line), - 0.0); + assertThat(SphericalUtil.computeLength(simplifiedLine)) + .isWithin(0.0) + .of(SphericalUtil.computeLength(line)); } else { - assertTrue(simplifiedLine.size() < line.size()); + assertThat(simplifiedLine.size()).isLessThan(line.size()); // If points were eliminated, then the simplified line should always be shorter - assertTrue( - SphericalUtil.computeLength(simplifiedLine) - < SphericalUtil.computeLength(line)); + assertThat(SphericalUtil.computeLength(simplifiedLine)) + .isLessThan(SphericalUtil.computeLength(line)); } } @@ -448,14 +541,18 @@ private void assertLineLength(List line, List simplifiedLine) { */ private void assertInputUnchanged(List afterInput, List beforeInput) { // Check values - assertEquals(beforeInput, afterInput); + assertThat(afterInput).isEqualTo(beforeInput); // Check references for (int i = 0; i < beforeInput.size(); i++) { - assertSame(afterInput.get(i), beforeInput.get(i)); + assertThat(afterInput.get(i)).isSameInstanceAs(beforeInput.get(i)); } } + /** + * This test verifies the `isClosedPolygon` method. It checks that the method correctly + * identifies a polygon as closed only when its first and last points are identical. + */ @Test public void testIsClosedPolygon() { ArrayList poly = new ArrayList<>(); @@ -465,20 +562,20 @@ public void testIsClosedPolygon() { poly.add(new LatLng(28.06125, -82.40850)); poly.add(new LatLng(28.06035, -82.40834)); - assertFalse(PolyUtil.isClosedPolygon(poly)); + assertThat(PolyUtil.isClosedPolygon(poly)).isFalse(); // Add the closing point that's same as the first poly.add(new LatLng(28.06025, -82.41030)); - assertTrue(PolyUtil.isClosedPolygon(poly)); + assertThat(PolyUtil.isClosedPolygon(poly)).isTrue(); } /** * The following method checks whether {@link PolyUtil#distanceToLine(LatLng, LatLng, LatLng) distanceToLine()} } * is determining the distance between a point and a segment accurately. - * + *

* Currently there are tests for different orders of magnitude (i.e., 1X, 10X, 100X, 1000X), as well as a test * where the segment and the point lie in different hemispheres. - * + *

* If further tests need to be added here, make sure that the distance has been verified with QGIS. * * @see QGIS @@ -490,46 +587,51 @@ public void testDistanceToLine() { LatLng p = new LatLng(28.05342, -82.41594); double distance = PolyUtil.distanceToLine(p, startLine, endLine); - assertEquals(37.94596795917082, distance, 1e-6); + assertThat(distance).isWithin(1e-6).of(37.94596795917082); startLine = new LatLng(49.321045, 12.097749); endLine = new LatLng(49.321016, 12.097795); p = new LatLng(49.3210674, 12.0978238); distance = PolyUtil.distanceToLine(p, startLine, endLine); - assertEquals(5.559443879999753, distance, 1e-6); + assertThat(distance).isWithin(1e-6).of(5.559443879999753); startLine = new LatLng(48.125961, 11.548998); endLine = new LatLng(48.125918, 11.549005); p = new LatLng(48.125941, 11.549028); distance = PolyUtil.distanceToLine(p, startLine, endLine); - assertEquals(1.9733966358947437, distance, 1e-6); + assertThat(distance).isWithin(1e-6).of(1.9733966358947437); startLine = new LatLng(78.924669, 11.925521); endLine = new LatLng(78.924707, 11.929060); p = new LatLng(78.923164, 11.924029); distance = PolyUtil.distanceToLine(p, startLine, endLine); - assertEquals(170.35662670453187, distance, 1e-6); + assertThat(distance).isWithin(1e-6).of(170.35662670453187); startLine = new LatLng(69.664036, 18.957124); endLine = new LatLng(69.664029, 18.957109); p = new LatLng(69.672901, 18.967911); distance = PolyUtil.distanceToLine(p, startLine, endLine); - assertEquals(1070.222749990837, distance, 1e-6); + assertThat(distance).isWithin(1e-6).of(1070.222749990837); startLine = new LatLng(-0.018200, 109.343282); endLine = new LatLng(-0.017877, 109.343537); p = new LatLng(0.058299, 109.408054); distance = PolyUtil.distanceToLine(p, startLine, endLine); - assertEquals(11100.157563150981, distance, 1e-6); + assertThat(distance).isWithin(1e-6).of(11100.157563150981); } + /** + * This test ensures that the distance from a point to a line segment is always less than or equal + * to the distance from the point to either of the segment's endpoints. This is a fundamental + * property of Euclidean geometry that should also hold true for spherical geometry for short distances. + */ @Test - public void testDistanceToLineLessThanDistanceToExtrems() { + public void testDistanceToLineLessThanDistanceToExtremes() { LatLng startLine = new LatLng(28.05359, -82.41632); LatLng endLine = new LatLng(28.05310, -82.41634); LatLng p = new LatLng(28.05342, -82.41594); @@ -538,25 +640,37 @@ public void testDistanceToLineLessThanDistanceToExtrems() { double distanceToStart = SphericalUtil.computeDistanceBetween(p, startLine); double distanceToEnd = SphericalUtil.computeDistanceBetween(p, endLine); - assertTrue("Wrong distance.", distance <= distanceToStart && distance <= distanceToEnd); + assertThat(distance).isAtMost(distanceToStart); + assertThat(distance).isAtMost(distanceToEnd); } + /** + * This test verifies the `decode` method, which decodes an encoded polyline string into a list + * of `LatLng` points. It checks that the decoded path has the correct number of points and that + * the last point has the expected latitude and longitude. + */ @Test public void testDecodePath() { List latLngs = PolyUtil.decode(TEST_LINE); int expectedLength = 21; - assertEquals("Wrong length.", expectedLength, latLngs.size()); + assertThat(latLngs.size()).isEqualTo(expectedLength); LatLng lastPoint = latLngs.get(expectedLength - 1); - assertEquals(37.76953, lastPoint.latitude, 1e-6); - assertEquals(-122.41488, lastPoint.longitude, 1e-6); + assertThat(lastPoint.latitude).isWithin(1e-6).of(37.76953); + assertThat(lastPoint.longitude).isWithin(1e-6).of(-122.41488); } + /** + * This test verifies the `encode` method, which encodes a list of `LatLng` points into a + * polyline string. It first decodes a test string, then re-encodes the resulting list of points, + * and finally asserts that the re-encoded string is identical to the original. This ensures the + * encode and decode methods are inverse operations. + */ @Test public void testEncodePath() { List path = PolyUtil.decode(TEST_LINE); String encoded = PolyUtil.encode(path); - assertEquals(TEST_LINE, encoded); + assertThat(encoded).isEqualTo(TEST_LINE); } } diff --git a/library/src/test/java/com/google/maps/android/SphericalUtilTest.java b/library/src/test/java/com/google/maps/android/SphericalUtilTest.java index 895bccd25..c0071331c 100644 --- a/library/src/test/java/com/google/maps/android/SphericalUtilTest.java +++ b/library/src/test/java/com/google/maps/android/SphericalUtilTest.java @@ -1,5 +1,5 @@ /* - * Copyright 2013 Google Inc. + * Copyright 2023 Google Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -18,16 +18,16 @@ import com.google.android.gms.maps.model.LatLng; +import org.junit.Assert; import org.junit.Test; import java.util.Arrays; import java.util.Collections; import java.util.List; +import java.util.Objects; +import static com.google.common.truth.Truth.assertThat; import static com.google.maps.android.MathUtil.EARTH_RADIUS; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertTrue; public class SphericalUtilTest { // The vertices of an octahedron, for testing @@ -42,10 +42,10 @@ public class SphericalUtilTest { * Tests for approximate equality. */ private static void expectLatLngApproxEquals(LatLng actual, LatLng expected) { - assertEquals(actual.latitude, expected.latitude, 1e-6); + assertThat(actual.latitude).isWithin(1e-6).of(expected.latitude); // Account for the convergence of longitude lines at the poles double cosLat = Math.cos(Math.toRadians(actual.latitude)); - assertEquals(cosLat * actual.longitude, cosLat * expected.longitude, 1e-6); + assertThat(cosLat * actual.longitude).isWithin(1e-6).of(cosLat * expected.longitude); } private static double computeSignedTriangleArea(LatLng a, LatLng b, LatLng c) { @@ -64,95 +64,83 @@ private static int isCCW(LatLng a, LatLng b, LatLng c) { @Test public void testAngles() { // Same vertex - assertEquals(SphericalUtil.computeAngleBetween(up, up), 0, 1e-6); - assertEquals(SphericalUtil.computeAngleBetween(down, down), 0, 1e-6); - assertEquals(SphericalUtil.computeAngleBetween(left, left), 0, 1e-6); - assertEquals(SphericalUtil.computeAngleBetween(right, right), 0, 1e-6); - assertEquals(SphericalUtil.computeAngleBetween(front, front), 0, 1e-6); - assertEquals(SphericalUtil.computeAngleBetween(back, back), 0, 1e-6); + assertThat(SphericalUtil.computeAngleBetween(up, up)).isWithin(1e-6).of(0); + assertThat(SphericalUtil.computeAngleBetween(down, down)).isWithin(1e-6).of(0); + assertThat(SphericalUtil.computeAngleBetween(left, left)).isWithin(1e-6).of(0); + assertThat(SphericalUtil.computeAngleBetween(right, right)).isWithin(1e-6).of(0); + assertThat(SphericalUtil.computeAngleBetween(front, front)).isWithin(1e-6).of(0); + assertThat(SphericalUtil.computeAngleBetween(back, back)).isWithin(1e-6).of(0); // Adjacent vertices - assertEquals(SphericalUtil.computeAngleBetween(up, front), Math.PI / 2, 1e-6); - assertEquals(SphericalUtil.computeAngleBetween(up, right), Math.PI / 2, 1e-6); - assertEquals(SphericalUtil.computeAngleBetween(up, back), Math.PI / 2, 1e-6); - assertEquals(SphericalUtil.computeAngleBetween(up, left), Math.PI / 2, 1e-6); + assertThat(SphericalUtil.computeAngleBetween(up, front)).isWithin(1e-6).of(Math.PI / 2); + assertThat(SphericalUtil.computeAngleBetween(up, right)).isWithin(1e-6).of(Math.PI / 2); + assertThat(SphericalUtil.computeAngleBetween(up, back)).isWithin(1e-6).of(Math.PI / 2); + assertThat(SphericalUtil.computeAngleBetween(up, left)).isWithin(1e-6).of(Math.PI / 2); - assertEquals(SphericalUtil.computeAngleBetween(down, front), Math.PI / 2, 1e-6); - assertEquals(SphericalUtil.computeAngleBetween(down, right), Math.PI / 2, 1e-6); - assertEquals(SphericalUtil.computeAngleBetween(down, back), Math.PI / 2, 1e-6); - assertEquals(SphericalUtil.computeAngleBetween(down, left), Math.PI / 2, 1e-6); + assertThat(SphericalUtil.computeAngleBetween(down, front)).isWithin(1e-6).of(Math.PI / 2); + assertThat(SphericalUtil.computeAngleBetween(down, right)).isWithin(1e-6).of(Math.PI / 2); + assertThat(SphericalUtil.computeAngleBetween(down, back)).isWithin(1e-6).of(Math.PI / 2); + assertThat(SphericalUtil.computeAngleBetween(down, left)).isWithin(1e-6).of(Math.PI / 2); - assertEquals(SphericalUtil.computeAngleBetween(back, up), Math.PI / 2, 1e-6); - assertEquals(SphericalUtil.computeAngleBetween(back, right), Math.PI / 2, 1e-6); - assertEquals(SphericalUtil.computeAngleBetween(back, down), Math.PI / 2, 1e-6); - assertEquals(SphericalUtil.computeAngleBetween(back, left), Math.PI / 2, 1e-6); + assertThat(SphericalUtil.computeAngleBetween(back, up)).isWithin(1e-6).of(Math.PI / 2); + assertThat(SphericalUtil.computeAngleBetween(back, right)).isWithin(1e-6).of(Math.PI / 2); + assertThat(SphericalUtil.computeAngleBetween(back, down)).isWithin(1e-6).of(Math.PI / 2); + assertThat(SphericalUtil.computeAngleBetween(back, left)).isWithin(1e-6).of(Math.PI / 2); // Opposite vertices - assertEquals(SphericalUtil.computeAngleBetween(up, down), Math.PI, 1e-6); - assertEquals(SphericalUtil.computeAngleBetween(front, back), Math.PI, 1e-6); - assertEquals(SphericalUtil.computeAngleBetween(left, right), Math.PI, 1e-6); + assertThat(SphericalUtil.computeAngleBetween(up, down)).isWithin(1e-6).of(Math.PI); + assertThat(SphericalUtil.computeAngleBetween(front, back)).isWithin(1e-6).of(Math.PI); + assertThat(SphericalUtil.computeAngleBetween(left, right)).isWithin(1e-6).of(Math.PI); } @Test public void testDistances() { - assertEquals(SphericalUtil.computeDistanceBetween(up, down), Math.PI * EARTH_RADIUS, 1e-6); + assertThat(SphericalUtil.computeDistanceBetween(up, down)).isWithin(1e-6).of(Math.PI * EARTH_RADIUS); } @Test public void testHeadings() { // Opposing vertices for which there is a result - assertEquals(SphericalUtil.computeHeading(up, down), -180, 1e-6); - assertEquals(SphericalUtil.computeHeading(down, up), 0, 1e-6); + assertThat(SphericalUtil.computeHeading(up, down)).isWithin(1e-6).of(-180); + assertThat(SphericalUtil.computeHeading(down, up)).isWithin(1e-6).of(0); // Adjacent vertices for which there is a result - assertEquals(SphericalUtil.computeHeading(front, up), 0, 1e-6); - assertEquals(SphericalUtil.computeHeading(right, up), 0, 1e-6); - assertEquals(SphericalUtil.computeHeading(back, up), 0, 1e-6); - assertEquals(SphericalUtil.computeHeading(down, up), 0, 1e-6); + assertThat(SphericalUtil.computeHeading(front, up)).isWithin(1e-6).of(0); + assertThat(SphericalUtil.computeHeading(right, up)).isWithin(1e-6).of(0); + assertThat(SphericalUtil.computeHeading(back, up)).isWithin(1e-6).of(0); + assertThat(SphericalUtil.computeHeading(down, up)).isWithin(1e-6).of(0); - assertEquals(SphericalUtil.computeHeading(front, down), -180, 1e-6); - assertEquals(SphericalUtil.computeHeading(right, down), -180, 1e-6); - assertEquals(SphericalUtil.computeHeading(back, down), -180, 1e-6); - assertEquals(SphericalUtil.computeHeading(left, down), -180, 1e-6); + assertThat(SphericalUtil.computeHeading(front, down)).isWithin(1e-6).of(-180); + assertThat(SphericalUtil.computeHeading(right, down)).isWithin(1e-6).of(-180); + assertThat(SphericalUtil.computeHeading(back, down)).isWithin(1e-6).of(-180); + assertThat(SphericalUtil.computeHeading(left, down)).isWithin(1e-6).of(-180); - assertEquals(SphericalUtil.computeHeading(right, front), -90, 1e-6); - assertEquals(SphericalUtil.computeHeading(left, front), 90, 1e-6); + assertThat(SphericalUtil.computeHeading(right, front)).isWithin(1e-6).of(-90); + assertThat(SphericalUtil.computeHeading(left, front)).isWithin(1e-6).of(90); - assertEquals(SphericalUtil.computeHeading(front, right), 90, 1e-6); - assertEquals(SphericalUtil.computeHeading(back, right), -90, 1e-6); + assertThat(SphericalUtil.computeHeading(front, right)).isWithin(1e-6).of(90); + assertThat(SphericalUtil.computeHeading(back, right)).isWithin(1e-6).of(-90); } @Test public void testComputeOffset() { // From front expectLatLngApproxEquals(front, SphericalUtil.computeOffset(front, 0, 0)); - expectLatLngApproxEquals( - up, SphericalUtil.computeOffset(front, Math.PI * EARTH_RADIUS / 2, 0)); - expectLatLngApproxEquals( - down, SphericalUtil.computeOffset(front, Math.PI * EARTH_RADIUS / 2, 180)); - expectLatLngApproxEquals( - left, SphericalUtil.computeOffset(front, Math.PI * EARTH_RADIUS / 2, -90)); - expectLatLngApproxEquals( - right, SphericalUtil.computeOffset(front, Math.PI * EARTH_RADIUS / 2, 90)); - expectLatLngApproxEquals( - back, SphericalUtil.computeOffset(front, Math.PI * EARTH_RADIUS, 0)); - expectLatLngApproxEquals( - back, SphericalUtil.computeOffset(front, Math.PI * EARTH_RADIUS, 90)); + expectLatLngApproxEquals(up, SphericalUtil.computeOffset(front, Math.PI * EARTH_RADIUS / 2, 0)); + expectLatLngApproxEquals(down, SphericalUtil.computeOffset(front, Math.PI * EARTH_RADIUS / 2, 180)); + expectLatLngApproxEquals(left, SphericalUtil.computeOffset(front, Math.PI * EARTH_RADIUS / 2, -90)); + expectLatLngApproxEquals(right, SphericalUtil.computeOffset(front, Math.PI * EARTH_RADIUS / 2, 90)); + expectLatLngApproxEquals(back, SphericalUtil.computeOffset(front, Math.PI * EARTH_RADIUS, 0)); + expectLatLngApproxEquals(back, SphericalUtil.computeOffset(front, Math.PI * EARTH_RADIUS, 90)); // From left expectLatLngApproxEquals(left, SphericalUtil.computeOffset(left, 0, 0)); - expectLatLngApproxEquals( - up, SphericalUtil.computeOffset(left, Math.PI * EARTH_RADIUS / 2, 0)); - expectLatLngApproxEquals( - down, SphericalUtil.computeOffset(left, Math.PI * EARTH_RADIUS / 2, 180)); - expectLatLngApproxEquals( - front, SphericalUtil.computeOffset(left, Math.PI * EARTH_RADIUS / 2, 90)); - expectLatLngApproxEquals( - back, SphericalUtil.computeOffset(left, Math.PI * EARTH_RADIUS / 2, -90)); - expectLatLngApproxEquals( - right, SphericalUtil.computeOffset(left, Math.PI * EARTH_RADIUS, 0)); - expectLatLngApproxEquals( - right, SphericalUtil.computeOffset(left, Math.PI * EARTH_RADIUS, 90)); + expectLatLngApproxEquals(up, SphericalUtil.computeOffset(left, Math.PI * EARTH_RADIUS / 2, 0)); + expectLatLngApproxEquals(down, SphericalUtil.computeOffset(left, Math.PI * EARTH_RADIUS / 2, 180)); + expectLatLngApproxEquals(front, SphericalUtil.computeOffset(left, Math.PI * EARTH_RADIUS / 2, 90)); + expectLatLngApproxEquals(back, SphericalUtil.computeOffset(left, Math.PI * EARTH_RADIUS / 2, -90)); + expectLatLngApproxEquals(right, SphericalUtil.computeOffset(left, Math.PI * EARTH_RADIUS, 0)); + expectLatLngApproxEquals(right, SphericalUtil.computeOffset(left, Math.PI * EARTH_RADIUS, 90)); // NOTE(appleton): Heading is undefined at the poles, so we do not test // from up/down. @@ -160,37 +148,19 @@ public void testComputeOffset() { @Test public void testComputeOffsetOrigin() { - expectLatLngApproxEquals(front, SphericalUtil.computeOffsetOrigin(front, 0, 0)); - - expectLatLngApproxEquals( - front, - SphericalUtil.computeOffsetOrigin( - new LatLng(0, 45), Math.PI * EARTH_RADIUS / 4, 90)); - expectLatLngApproxEquals( - front, - SphericalUtil.computeOffsetOrigin( - new LatLng(0, -45), Math.PI * EARTH_RADIUS / 4, -90)); - expectLatLngApproxEquals( - front, - SphericalUtil.computeOffsetOrigin( - new LatLng(45, 0), Math.PI * EARTH_RADIUS / 4, 0)); - expectLatLngApproxEquals( - front, - SphericalUtil.computeOffsetOrigin( - new LatLng(-45, 0), Math.PI * EARTH_RADIUS / 4, 180)); - /*expectLatLngApproxEquals( - front, SphericalUtil.computeOffsetOrigin(new LatLng(-45, 0), - Math.PI / 4, 180, 1)); */ + expectLatLngApproxEquals(front, Objects.requireNonNull(SphericalUtil.computeOffsetOrigin(front, 0, 0))); + + expectLatLngApproxEquals(front, Objects.requireNonNull(SphericalUtil.computeOffsetOrigin(new LatLng(0, 45), Math.PI * EARTH_RADIUS / 4, 90))); + expectLatLngApproxEquals(front, Objects.requireNonNull(SphericalUtil.computeOffsetOrigin(new LatLng(0, -45), Math.PI * EARTH_RADIUS / 4, -90))); + expectLatLngApproxEquals(front, Objects.requireNonNull(SphericalUtil.computeOffsetOrigin(new LatLng(45, 0), Math.PI * EARTH_RADIUS / 4, 0))); + expectLatLngApproxEquals(front, Objects.requireNonNull(SphericalUtil.computeOffsetOrigin(new LatLng(-45, 0), Math.PI * EARTH_RADIUS / 4, 180))); + // Situations with no solution, should return null. // // First 'over' the pole. - assertNull( - SphericalUtil.computeOffsetOrigin( - new LatLng(80, 0), Math.PI * EARTH_RADIUS / 4, 180)); + assertThat(SphericalUtil.computeOffsetOrigin(new LatLng(80, 0), Math.PI * EARTH_RADIUS / 4, 180)).isNull(); // Second a distance that doesn't fit on the earth. - assertNull( - SphericalUtil.computeOffsetOrigin( - new LatLng(80, 0), Math.PI * EARTH_RADIUS / 4, 90)); + assertThat(SphericalUtil.computeOffsetOrigin(new LatLng(80, 0), Math.PI * EARTH_RADIUS / 4, 90)).isNull(); } @Test @@ -203,37 +173,33 @@ public void testComputeOffsetAndBackToOrigin() { // Some semi-random values to demonstrate going forward and backward yields // the same location. end = SphericalUtil.computeOffset(start, distance, heading); - expectLatLngApproxEquals(start, SphericalUtil.computeOffsetOrigin(end, distance, heading)); + expectLatLngApproxEquals(start, Objects.requireNonNull(SphericalUtil.computeOffsetOrigin(end, distance, heading))); heading = -37; end = SphericalUtil.computeOffset(start, distance, heading); - expectLatLngApproxEquals(start, SphericalUtil.computeOffsetOrigin(end, distance, heading)); + expectLatLngApproxEquals(start, Objects.requireNonNull(SphericalUtil.computeOffsetOrigin(end, distance, heading))); distance = 3.8e+7; end = SphericalUtil.computeOffset(start, distance, heading); - expectLatLngApproxEquals(start, SphericalUtil.computeOffsetOrigin(end, distance, heading)); + expectLatLngApproxEquals(start, Objects.requireNonNull(SphericalUtil.computeOffsetOrigin(end, distance, heading))); start = new LatLng(-21, -73); end = SphericalUtil.computeOffset(start, distance, heading); - expectLatLngApproxEquals(start, SphericalUtil.computeOffsetOrigin(end, distance, heading)); + expectLatLngApproxEquals(start, Objects.requireNonNull(SphericalUtil.computeOffsetOrigin(end, distance, heading))); // computeOffsetOrigin with multiple solutions, all we care about is that // going from there yields the requested result. // // First, for this particular situation the latitude is completely arbitrary. - start = - SphericalUtil.computeOffsetOrigin( - new LatLng(0, 90), Math.PI * EARTH_RADIUS / 2, 90); - expectLatLngApproxEquals( - new LatLng(0, 90), - SphericalUtil.computeOffset(start, Math.PI * EARTH_RADIUS / 2, 90)); + start = SphericalUtil.computeOffsetOrigin(new LatLng(0, 90), Math.PI * EARTH_RADIUS / 2, 90); + Assert.assertNotNull(start); + expectLatLngApproxEquals(new LatLng(0, 90), SphericalUtil.computeOffset(start, Math.PI * EARTH_RADIUS / 2, 90)); // Second, for this particular situation the longitude is completely // arbitrary. start = SphericalUtil.computeOffsetOrigin(new LatLng(90, 0), Math.PI * EARTH_RADIUS / 4, 0); - expectLatLngApproxEquals( - new LatLng(90, 0), - SphericalUtil.computeOffset(start, Math.PI * EARTH_RADIUS / 4, 0)); + Assert.assertNotNull(start); + expectLatLngApproxEquals(new LatLng(90, 0), SphericalUtil.computeOffset(start, Math.PI * EARTH_RADIUS / 4, 0)); } @Test @@ -246,35 +212,24 @@ public void testInterpolate() { // Between front and up expectLatLngApproxEquals(new LatLng(1, 0), SphericalUtil.interpolate(front, up, 1 / 90.0)); expectLatLngApproxEquals(new LatLng(1, 0), SphericalUtil.interpolate(up, front, 89 / 90.0)); - expectLatLngApproxEquals( - new LatLng(89, 0), SphericalUtil.interpolate(front, up, 89 / 90.0)); + expectLatLngApproxEquals(new LatLng(89, 0), SphericalUtil.interpolate(front, up, 89 / 90.0)); expectLatLngApproxEquals(new LatLng(89, 0), SphericalUtil.interpolate(up, front, 1 / 90.0)); // Between front and down - expectLatLngApproxEquals( - new LatLng(-1, 0), SphericalUtil.interpolate(front, down, 1 / 90.0)); - expectLatLngApproxEquals( - new LatLng(-1, 0), SphericalUtil.interpolate(down, front, 89 / 90.0)); - expectLatLngApproxEquals( - new LatLng(-89, 0), SphericalUtil.interpolate(front, down, 89 / 90.0)); - expectLatLngApproxEquals( - new LatLng(-89, 0), SphericalUtil.interpolate(down, front, 1 / 90.0)); + expectLatLngApproxEquals(new LatLng(-1, 0), SphericalUtil.interpolate(front, down, 1 / 90.0)); + expectLatLngApproxEquals(new LatLng(-1, 0), SphericalUtil.interpolate(down, front, 89 / 90.0)); + expectLatLngApproxEquals(new LatLng(-89, 0), SphericalUtil.interpolate(front, down, 89 / 90.0)); + expectLatLngApproxEquals(new LatLng(-89, 0), SphericalUtil.interpolate(down, front, 1 / 90.0)); // Between left and back - expectLatLngApproxEquals( - new LatLng(0, -91), SphericalUtil.interpolate(left, back, 1 / 90.0)); - expectLatLngApproxEquals( - new LatLng(0, -91), SphericalUtil.interpolate(back, left, 89 / 90.0)); - expectLatLngApproxEquals( - new LatLng(0, -179), SphericalUtil.interpolate(left, back, 89 / 90.0)); - expectLatLngApproxEquals( - new LatLng(0, -179), SphericalUtil.interpolate(back, left, 1 / 90.0)); + expectLatLngApproxEquals(new LatLng(0, -91), SphericalUtil.interpolate(left, back, 1 / 90.0)); + expectLatLngApproxEquals(new LatLng(0, -91), SphericalUtil.interpolate(back, left, 89 / 90.0)); + expectLatLngApproxEquals(new LatLng(0, -179), SphericalUtil.interpolate(left, back, 89 / 90.0)); + expectLatLngApproxEquals(new LatLng(0, -179), SphericalUtil.interpolate(back, left, 1 / 90.0)); // geodesic crosses pole - expectLatLngApproxEquals( - up, SphericalUtil.interpolate(new LatLng(45, 0), new LatLng(45, 180), 1 / 2.0)); - expectLatLngApproxEquals( - down, SphericalUtil.interpolate(new LatLng(-45, 0), new LatLng(-45, 180), 1 / 2.0)); + expectLatLngApproxEquals(up, SphericalUtil.interpolate(new LatLng(45, 0), new LatLng(45, 180), 1 / 2.0)); + expectLatLngApproxEquals(down, SphericalUtil.interpolate(new LatLng(-45, 0), new LatLng(-45, 180), 1 / 2.0)); // boundary values for fraction, between left and back expectLatLngApproxEquals(left, SphericalUtil.interpolate(left, back, 0)); @@ -282,91 +237,66 @@ public void testInterpolate() { // two nearby points, separated by ~4m, for which the Slerp algorithm is not stable and we // have to fall back to linear interpolation. - expectLatLngApproxEquals( - new LatLng(-37.756872, 175.325252), - SphericalUtil.interpolate( - new LatLng(-37.756891, 175.325262), - new LatLng(-37.756853, 175.325242), - 0.5)); + expectLatLngApproxEquals(new LatLng(-37.756872, 175.325252), SphericalUtil.interpolate(new LatLng(-37.756891, 175.325262), new LatLng(-37.756853, 175.325242), 0.5)); } @Test public void testComputeLength() { List latLngs; - assertEquals(SphericalUtil.computeLength(Collections.emptyList()), 0, 1e-6); - assertEquals(SphericalUtil.computeLength(Arrays.asList(new LatLng(0, 0))), 0, 1e-6); + assertThat(SphericalUtil.computeLength(Collections.emptyList())).isWithin(1e-6).of(0); + assertThat(SphericalUtil.computeLength(List.of(new LatLng(0, 0)))).isWithin(1e-6).of(0); latLngs = Arrays.asList(new LatLng(0, 0), new LatLng(0.1, 0.1)); - assertEquals( - SphericalUtil.computeLength(latLngs), - Math.toRadians(0.1) * Math.sqrt(2) * EARTH_RADIUS, - 1); + assertThat(SphericalUtil.computeLength(latLngs)).isWithin(1).of(Math.toRadians(0.1) * Math.sqrt(2) * EARTH_RADIUS); latLngs = Arrays.asList(new LatLng(0, 0), new LatLng(90, 0), new LatLng(0, 90)); - assertEquals(SphericalUtil.computeLength(latLngs), Math.PI * EARTH_RADIUS, 1e-6); + assertThat(SphericalUtil.computeLength(latLngs)).isWithin(1e-6).of(Math.PI * EARTH_RADIUS); } @Test public void testIsCCW() { // One face of the octahedron - assertEquals(1, isCCW(right, up, front)); - assertEquals(1, isCCW(up, front, right)); - assertEquals(1, isCCW(front, right, up)); - assertEquals(-1, isCCW(front, up, right)); - assertEquals(-1, isCCW(up, right, front)); - assertEquals(-1, isCCW(right, front, up)); + assertThat(isCCW(right, up, front)).isEqualTo(1); + assertThat(isCCW(up, front, right)).isEqualTo(1); + assertThat(isCCW(front, right, up)).isEqualTo(1); + assertThat(isCCW(front, up, right)).isEqualTo(-1); + assertThat(isCCW(up, right, front)).isEqualTo(-1); + assertThat(isCCW(right, front, up)).isEqualTo(-1); } @Test public void testComputeTriangleArea() { - assertEquals(computeTriangleArea(right, up, front), Math.PI / 2, 1e-6); - assertEquals(computeTriangleArea(front, up, right), Math.PI / 2, 1e-6); + assertThat(computeTriangleArea(right, up, front)).isWithin(1e-6).of(Math.PI / 2); + assertThat(computeTriangleArea(front, up, right)).isWithin(1e-6).of(Math.PI / 2); // computeArea returns area of zero on small polys - double area = - computeTriangleArea( - new LatLng(0, 0), - new LatLng(0, Math.toDegrees(1E-6)), - new LatLng(Math.toDegrees(1E-6), 0)); + double area = computeTriangleArea(new LatLng(0, 0), new LatLng(0, Math.toDegrees(1E-6)), new LatLng(Math.toDegrees(1E-6), 0)); double expectedArea = 1E-12 / 2; - assertTrue(Math.abs(expectedArea - area) < 1e-20); + assertThat(Math.abs(expectedArea - area)).isLessThan(1e-20); } @Test public void testComputeSignedTriangleArea() { - assertEquals( - computeSignedTriangleArea( - new LatLng(0, 0), new LatLng(0, 0.1), new LatLng(0.1, 0.1)), - Math.toRadians(0.1) * Math.toRadians(0.1) / 2, - 1e-6); + assertThat(computeSignedTriangleArea(new LatLng(0, 0), new LatLng(0, 0.1), new LatLng(0.1, 0.1))).isWithin(1e-6).of(Math.toRadians(0.1) * Math.toRadians(0.1) / 2); - assertEquals(computeSignedTriangleArea(right, up, front), Math.PI / 2, 1e-6); + assertThat(computeSignedTriangleArea(right, up, front)).isWithin(1e-6).of(Math.PI / 2); - assertEquals(computeSignedTriangleArea(front, up, right), -Math.PI / 2, 1e-6); + assertThat(computeSignedTriangleArea(front, up, right)).isWithin(1e-6).of(-Math.PI / 2); } @Test public void testComputeArea() { - assertEquals( - SphericalUtil.computeArea(Arrays.asList(right, up, front, down, right)), - Math.PI * EARTH_RADIUS * EARTH_RADIUS, - .4); - - assertEquals( - SphericalUtil.computeArea(Arrays.asList(right, down, front, up, right)), - Math.PI * EARTH_RADIUS * EARTH_RADIUS, - .4); + assertThat(SphericalUtil.computeArea(Arrays.asList(right, up, front, down, right))).isWithin(.4).of(Math.PI * EARTH_RADIUS * EARTH_RADIUS); + + assertThat(SphericalUtil.computeArea(Arrays.asList(right, down, front, up, right))).isWithin(.4).of(Math.PI * EARTH_RADIUS * EARTH_RADIUS); } @Test public void testComputeSignedArea() { List path = Arrays.asList(right, up, front, down, right); List pathReversed = Arrays.asList(right, down, front, up, right); - assertEquals( - -SphericalUtil.computeSignedArea(path), - SphericalUtil.computeSignedArea(pathReversed), - 0); + assertThat(-SphericalUtil.computeSignedArea(path)).isWithin(0).of(SphericalUtil.computeSignedArea(pathReversed)); } -} +} \ No newline at end of file From 3ba52d407fcc985cfd23a4544878dd9e770087e8 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Tue, 26 Aug 2025 16:13:33 +0000 Subject: [PATCH 36/67] chore(release): 3.16.0 [skip ci] # [3.16.0](https://github.com/googlemaps/android-maps-utils/compare/v3.15.0...v3.16.0) (2025-08-26) ### Features * **library:** Port PolyUtil to Kotlin and enhance tests ([#1565](https://github.com/googlemaps/android-maps-utils/issues/1565)) ([90c56df](https://github.com/googlemaps/android-maps-utils/commit/90c56df9e0fccf4004a4562d9647d5d2a99efe18)) --- README.md | 2 +- build.gradle.kts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 5a8558de7..5b49d381e 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ dependencies { // Utilities for Maps SDK for Android (requires Google Play Services) // You do not need to add a separate dependency for the Maps SDK for Android // since this library builds in the compatible version of the Maps SDK. - implementation 'com.google.maps.android:android-maps-utils:3.15.0' + implementation 'com.google.maps.android:android-maps-utils:3.16.0' // Optionally add the Kotlin Extensions (KTX) for full Kotlin language support // See latest version at https://github.com/googlemaps/android-maps-ktx diff --git a/build.gradle.kts b/build.gradle.kts index 5f055b4e1..9aea1e904 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -37,5 +37,5 @@ tasks.register("clean") { allprojects { group = "com.google.maps.android" - version = "3.15.0" + version = "3.16.0" } \ No newline at end of file From 3471b4d7d1c592100012c7b6464eeed302857c2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20L=C3=B3pez-Ma=C3=B1as?= Date: Wed, 27 Aug 2025 15:39:49 +0200 Subject: [PATCH 37/67] build: run tests only on PR, not on push (#1571) * build: run tests only on PR, not on push * build: run tests only on PR, not on push --- .github/workflows/report.yml | 4 +--- .github/workflows/test.yml | 2 -- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/.github/workflows/report.yml b/.github/workflows/report.yml index 85b3c0336..c7a1a4b30 100644 --- a/.github/workflows/report.yml +++ b/.github/workflows/report.yml @@ -13,13 +13,11 @@ # limitations under the License. # A workflow that runs tests on every new pull request -name: Run unit tests +name: Report tests results on: repository_dispatch: types: [test] - push: - branches-ignore: ['gh-pages'] pull_request: branches-ignore: ['gh-pages'] workflow_dispatch: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 372b972fc..187ab55b0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -18,8 +18,6 @@ name: Run unit tests on: repository_dispatch: types: [test] - push: - branches-ignore: ['gh-pages'] pull_request: branches-ignore: ['gh-pages'] workflow_dispatch: From 12c5a3b7e2ebcede74addc28959f34ce8b0293b2 Mon Sep 17 00:00:00 2001 From: Dale Hawkins <107309+dkhawk@users.noreply.github.com> Date: Wed, 27 Aug 2025 09:07:35 -0600 Subject: [PATCH 38/67] build: Port geometry and quadtree packages to Kotlin (#1569) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Rename .java to .kt * feat(library): Port PointQuadTree and tests to Kotlin This commit migrates the `PointQuadTree` class and its corresponding test, `PointQuadTreeTest`, from Java to idiomatic Kotlin. This conversion modernizes the codebase, improving its conciseness, readability, and null safety. The key changes are: - **`PointQuadTree` to Kotlin**: The core `PointQuadTree` class has been rewritten in Kotlin, leveraging features like primary constructors, companion objects, and stricter nullability. - **`PointQuadTreeTest` to Kotlin**: The associated test class has also been ported to Kotlin, resulting in more streamlined test code. - **Copyright Header Update**: The copyright year has been updated in the converted files. * Rename .java to .kt * refactor(geometry): Port Point and Bounds classes to Kotlin This commit modernizes the `Point` and `Bounds` classes by converting them from Java to idiomatic Kotlin. This change improves code conciseness, readability, and null safety. Key changes include: - The `Point` and `Bounds` classes are now written in Kotlin. - `@JvmField` annotations are used on properties to maintain binary compatibility for Java consumers. - New unit tests, `PointTest.kt` and `BoundsTest.kt`, have been added to ensure the correctness of the ported classes. * feat: added headers --------- Co-authored-by: Enrique López Mañas --- .../google/maps/android/geometry/Bounds.java | 61 ----- .../google/maps/android/geometry/Bounds.kt | 54 ++++ .../google/maps/android/geometry/Point.java | 38 --- .../com/google/maps/android/geometry/Point.kt | 45 ++++ .../maps/android/quadtree/PointQuadTree.java | 226 ---------------- .../maps/android/quadtree/PointQuadTree.kt | 195 ++++++++++++++ .../maps/android/geometry/BoundsTest.kt | 85 ++++++ .../google/maps/android/geometry/PointTest.kt | 62 +++++ .../android/quadtree/PointQuadTreeTest.java | 232 ----------------- .../android/quadtree/PointQuadTreeTest.kt | 244 ++++++++++++++++++ 10 files changed, 685 insertions(+), 557 deletions(-) delete mode 100755 library/src/main/java/com/google/maps/android/geometry/Bounds.java create mode 100755 library/src/main/java/com/google/maps/android/geometry/Bounds.kt delete mode 100644 library/src/main/java/com/google/maps/android/geometry/Point.java create mode 100644 library/src/main/java/com/google/maps/android/geometry/Point.kt delete mode 100644 library/src/main/java/com/google/maps/android/quadtree/PointQuadTree.java create mode 100644 library/src/main/java/com/google/maps/android/quadtree/PointQuadTree.kt create mode 100644 library/src/test/java/com/google/maps/android/geometry/BoundsTest.kt create mode 100644 library/src/test/java/com/google/maps/android/geometry/PointTest.kt delete mode 100644 library/src/test/java/com/google/maps/android/quadtree/PointQuadTreeTest.java create mode 100644 library/src/test/java/com/google/maps/android/quadtree/PointQuadTreeTest.kt diff --git a/library/src/main/java/com/google/maps/android/geometry/Bounds.java b/library/src/main/java/com/google/maps/android/geometry/Bounds.java deleted file mode 100755 index dfb06ec8d..000000000 --- a/library/src/main/java/com/google/maps/android/geometry/Bounds.java +++ /dev/null @@ -1,61 +0,0 @@ -/* - * Copyright 2013 Google Inc. - * - * 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.geometry; - -/** - * Represents an area in the cartesian plane. - */ -public class Bounds { - public final double minX; - public final double minY; - - public final double maxX; - public final double maxY; - - public final double midX; - public final double midY; - - public Bounds(double minX, double maxX, double minY, double maxY) { - this.minX = minX; - this.minY = minY; - this.maxX = maxX; - this.maxY = maxY; - - midX = (minX + maxX) / 2; - midY = (minY + maxY) / 2; - } - - public boolean contains(double x, double y) { - return minX <= x && x <= maxX && minY <= y && y <= maxY; - } - - public boolean contains(Point point) { - return contains(point.x, point.y); - } - - public boolean intersects(double minX, double maxX, double minY, double maxY) { - return minX < this.maxX && this.minX < maxX && minY < this.maxY && this.minY < maxY; - } - - public boolean intersects(Bounds bounds) { - return intersects(bounds.minX, bounds.maxX, bounds.minY, bounds.maxY); - } - - public boolean contains(Bounds bounds) { - return bounds.minX >= minX && bounds.maxX <= maxX && bounds.minY >= minY && bounds.maxY <= maxY; - } -} diff --git a/library/src/main/java/com/google/maps/android/geometry/Bounds.kt b/library/src/main/java/com/google/maps/android/geometry/Bounds.kt new file mode 100755 index 000000000..e77fa75e2 --- /dev/null +++ b/library/src/main/java/com/google/maps/android/geometry/Bounds.kt @@ -0,0 +1,54 @@ +/* + * Copyright 2025 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.geometry + +/** + * Represents an area in the cartesian plane. + */ +class Bounds( + @JvmField val minX: Double, + @JvmField val maxX: Double, + @JvmField val minY: Double, + @JvmField val maxY: Double +) { + @JvmField + val midX: Double = (minX + maxX) / 2 + + @JvmField + val midY: Double = (minY + maxY) / 2 + + fun contains(x: Double, y: Double): Boolean { + return minX <= x && x <= maxX && minY <= y && y <= maxY + } + + fun contains(point: Point): Boolean { + return contains(point.x, point.y) + } + + fun intersects(minX: Double, maxX: Double, minY: Double, maxY: Double): Boolean { + return minX < this.maxX && this.minX < maxX && minY < this.maxY && this.minY < maxY + } + + fun intersects(bounds: Bounds): Boolean { + return intersects(bounds.minX, bounds.maxX, bounds.minY, bounds.maxY) + } + + fun contains(bounds: Bounds): Boolean { + return bounds.minX >= minX && bounds.maxX <= maxX && bounds.minY >= minY && bounds.maxY <= maxY + } +} \ No newline at end of file diff --git a/library/src/main/java/com/google/maps/android/geometry/Point.java b/library/src/main/java/com/google/maps/android/geometry/Point.java deleted file mode 100644 index 88a83d731..000000000 --- a/library/src/main/java/com/google/maps/android/geometry/Point.java +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Copyright 2023 Google Inc. - * - * 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.geometry; - -import androidx.annotation.NonNull; - -public class Point { - public final double x; - public final double y; - - public Point(double x, double y) { - this.x = x; - this.y = y; - } - - @NonNull - @Override - public String toString() { - return "Point{" + - "x=" + x + - ", y=" + y + - '}'; - } -} diff --git a/library/src/main/java/com/google/maps/android/geometry/Point.kt b/library/src/main/java/com/google/maps/android/geometry/Point.kt new file mode 100644 index 000000000..70de385c4 --- /dev/null +++ b/library/src/main/java/com/google/maps/android/geometry/Point.kt @@ -0,0 +1,45 @@ +/* + * Copyright 2025 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.geometry + +open class Point(@JvmField val x: Double, @JvmField val y: Double) { + override fun toString(): String { + return "Point(x=$x, y=$y)" + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as Point + + if (x != other.x) return false + if (y != other.y) return false + + return true + } + + override fun hashCode(): Int { + var result = x.hashCode() + result = 31 * result + y.hashCode() + return result + } + + fun copy(x: Double = this.x, y: Double = this.y): Point { + return Point(x, y) + } +} \ No newline at end of file diff --git a/library/src/main/java/com/google/maps/android/quadtree/PointQuadTree.java b/library/src/main/java/com/google/maps/android/quadtree/PointQuadTree.java deleted file mode 100644 index b3ee6fb48..000000000 --- a/library/src/main/java/com/google/maps/android/quadtree/PointQuadTree.java +++ /dev/null @@ -1,226 +0,0 @@ -/* - * Copyright 2013 Google Inc. - * - * 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.quadtree; - -import com.google.maps.android.geometry.Bounds; -import com.google.maps.android.geometry.Point; - -import java.util.ArrayList; -import java.util.Collection; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Set; - -/** - * A quad tree which tracks items with a Point geometry. - * See http://en.wikipedia.org/wiki/Quadtree for details on the data structure. - * This class is not thread safe. - */ -public class PointQuadTree { - public interface Item { - Point getPoint(); - } - - /** - * The bounds of this quad. - */ - private final Bounds mBounds; - - /** - * The depth of this quad in the tree. - */ - private final int mDepth; - - /** - * Maximum number of elements to store in a quad before splitting. - */ - private final static int MAX_ELEMENTS = 50; - - /** - * The elements inside this quad, if any. - */ - private Set mItems; - - /** - * Maximum depth. - */ - private final static int MAX_DEPTH = 40; - - /** - * Child quads. - */ - private List> mChildren = null; - - /** - * Creates a new quad tree with specified bounds. - * - * @param minX - * @param maxX - * @param minY - * @param maxY - */ - public PointQuadTree(double minX, double maxX, double minY, double maxY) { - this(new Bounds(minX, maxX, minY, maxY)); - } - - public PointQuadTree(Bounds bounds) { - this(bounds, 0); - } - - private PointQuadTree(double minX, double maxX, double minY, double maxY, int depth) { - this(new Bounds(minX, maxX, minY, maxY), depth); - } - - private PointQuadTree(Bounds bounds, int depth) { - mBounds = bounds; - mDepth = depth; - } - - /** - * Insert an item. - */ - public void add(T item) { - Point point = item.getPoint(); - if (this.mBounds.contains(point.x, point.y)) { - insert(point.x, point.y, item); - } - } - - private void insert(double x, double y, T item) { - if (this.mChildren != null) { - if (y < mBounds.midY) { - if (x < mBounds.midX) { // top left - mChildren.get(0).insert(x, y, item); - } else { // top right - mChildren.get(1).insert(x, y, item); - } - } else { - if (x < mBounds.midX) { // bottom left - mChildren.get(2).insert(x, y, item); - } else { - mChildren.get(3).insert(x, y, item); - } - } - return; - } - if (mItems == null) { - mItems = new LinkedHashSet<>(); - } - mItems.add(item); - if (mItems.size() > MAX_ELEMENTS && mDepth < MAX_DEPTH) { - split(); - } - } - - /** - * Split this quad. - */ - private void split() { - mChildren = new ArrayList>(4); - mChildren.add(new PointQuadTree(mBounds.minX, mBounds.midX, mBounds.minY, mBounds.midY, mDepth + 1)); - mChildren.add(new PointQuadTree(mBounds.midX, mBounds.maxX, mBounds.minY, mBounds.midY, mDepth + 1)); - mChildren.add(new PointQuadTree(mBounds.minX, mBounds.midX, mBounds.midY, mBounds.maxY, mDepth + 1)); - mChildren.add(new PointQuadTree(mBounds.midX, mBounds.maxX, mBounds.midY, mBounds.maxY, mDepth + 1)); - - Set items = mItems; - mItems = null; - - for (T item : items) { - // re-insert items into child quads. - insert(item.getPoint().x, item.getPoint().y, item); - } - } - - /** - * Remove the given item from the set. - * - * @return whether the item was removed. - */ - public boolean remove(T item) { - Point point = item.getPoint(); - if (this.mBounds.contains(point.x, point.y)) { - return remove(point.x, point.y, item); - } else { - return false; - } - } - - private boolean remove(double x, double y, T item) { - if (this.mChildren != null) { - if (y < mBounds.midY) { - if (x < mBounds.midX) { // top left - return mChildren.get(0).remove(x, y, item); - } else { // top right - return mChildren.get(1).remove(x, y, item); - } - } else { - if (x < mBounds.midX) { // bottom left - return mChildren.get(2).remove(x, y, item); - } else { - return mChildren.get(3).remove(x, y, item); - } - } - } else { - if (mItems == null) { - return false; - } else { - return mItems.remove(item); - } - } - } - - /** - * Removes all points from the quadTree - */ - public void clear() { - mChildren = null; - if (mItems != null) { - mItems.clear(); - } - } - - /** - * Search for all items within a given bounds. - */ - public Collection search(Bounds searchBounds) { - final List results = new ArrayList(); - search(searchBounds, results); - return results; - } - - private void search(Bounds searchBounds, Collection results) { - if (!mBounds.intersects(searchBounds)) { - return; - } - - if (this.mChildren != null) { - for (PointQuadTree quad : mChildren) { - quad.search(searchBounds, results); - } - } else if (mItems != null) { - if (searchBounds.contains(mBounds)) { - results.addAll(mItems); - } else { - for (T item : mItems) { - if (searchBounds.contains(item.getPoint())) { - results.add(item); - } - } - } - } - } -} diff --git a/library/src/main/java/com/google/maps/android/quadtree/PointQuadTree.kt b/library/src/main/java/com/google/maps/android/quadtree/PointQuadTree.kt new file mode 100644 index 000000000..46ca42a05 --- /dev/null +++ b/library/src/main/java/com/google/maps/android/quadtree/PointQuadTree.kt @@ -0,0 +1,195 @@ +/* + * Copyright 2025 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.quadtree + +import com.google.maps.android.geometry.Bounds +import com.google.maps.android.geometry.Point + +/** + * A quad tree which tracks items with a Point geometry. + * See http://en.wikipedia.org/wiki/Quadtree for details on the data structure. + * This class is not thread safe. + */ +class PointQuadTree @JvmOverloads constructor( + private val mBounds: Bounds, + private val mDepth: Int = 0 +) { + interface Item { + val point: Point + } + + /** + * The elements inside this quad, if any. + */ + private var mItems: MutableSet? = null + + /** + * Child quads. + */ + private var mChildren: MutableList>? = null + + constructor(minX: Double, maxX: Double, minY: Double, maxY: Double) : + this(Bounds(minX, maxX, minY, maxY)) + + /** + * Insert an item. + */ + fun add(item: T) { + val point = item.point + if (this.mBounds.contains(point.x, point.y)) { + insert(point.x, point.y, item) + } + } + + private fun insert(x: Double, y: Double, item: T) { + if (this.mChildren != null) { + if (y < mBounds.midY) { + if (x < mBounds.midX) { // top left + mChildren!![0].insert(x, y, item) + } else { // top right + mChildren!![1].insert(x, y, item) + } + } else { + if (x < mBounds.midX) { // bottom left + mChildren!![2].insert(x, y, item) + } else { + mChildren!![3].insert(x, y, item) + } + } + return + } + if (mItems == null) { + mItems = LinkedHashSet() + } + mItems!!.add(item) + if (mItems!!.size > MAX_ELEMENTS && mDepth < MAX_DEPTH) { + split() + } + } + + /** + * Split this quad. + */ + private fun split() { + mChildren = ArrayList(4) + mChildren!!.add(PointQuadTree(Bounds(mBounds.minX, mBounds.midX, mBounds.minY, mBounds.midY), mDepth + 1)) + mChildren!!.add(PointQuadTree(Bounds(mBounds.midX, mBounds.maxX, mBounds.minY, mBounds.midY), mDepth + 1)) + mChildren!!.add(PointQuadTree(Bounds(mBounds.minX, mBounds.midX, mBounds.midY, mBounds.maxY), mDepth + 1)) + mChildren!!.add(PointQuadTree(Bounds(mBounds.midX, mBounds.maxX, mBounds.midY, mBounds.maxY), mDepth + 1)) + + val items = mItems + mItems = null + + if (items != null) { + for (item in items) { + // re-insert items into child quads. + insert(item.point.x, item.point.y, item) + } + } + } + + /** + * Remove the given item from the set. + * + * @return whether the item was removed. + */ + fun remove(item: T): Boolean { + val point = item.point + return if (this.mBounds.contains(point.x, point.y)) { + remove(point.x, point.y, item) + } else { + false + } + } + + private fun remove(x: Double, y: Double, item: T): Boolean { + return if (this.mChildren != null) { + if (y < mBounds.midY) { + if (x < mBounds.midX) { // top left + mChildren!![0].remove(x, y, item) + } else { // top right + mChildren!![1].remove(x, y, item) + } + } else { + if (x < mBounds.midX) { // bottom left + mChildren!![2].remove(x, y, item) + } else { + mChildren!![3].remove(x, y, item) + } + } + } else { + if (mItems == null) { + false + } else { + mItems!!.remove(item) + } + } + } + + /** + * Removes all points from the quadTree + */ + fun clear() { + mChildren = null + if (mItems != null) { + mItems!!.clear() + } + } + + /** + * Search for all items within a given bounds. + */ + fun search(searchBounds: Bounds): Collection { + val results: MutableList = ArrayList() + search(searchBounds, results) + return results + } + + private fun search(searchBounds: Bounds, results: MutableCollection) { + if (!mBounds.intersects(searchBounds)) { + return + } + + if (this.mChildren != null) { + for (quad in mChildren!!) { + quad.search(searchBounds, results) + } + } else if (mItems != null) { + if (searchBounds.contains(mBounds)) { + results.addAll(mItems!!) + } else { + for (item in mItems!!) { + if (searchBounds.contains(item.point)) { + results.add(item) + } + } + } + } + } + + companion object { + /** + * Maximum number of elements to store in a quad before splitting. + */ + private const val MAX_ELEMENTS = 50 + + /** + * Maximum depth. + */ + private const val MAX_DEPTH = 40 + } +} \ No newline at end of file diff --git a/library/src/test/java/com/google/maps/android/geometry/BoundsTest.kt b/library/src/test/java/com/google/maps/android/geometry/BoundsTest.kt new file mode 100644 index 000000000..150f0e816 --- /dev/null +++ b/library/src/test/java/com/google/maps/android/geometry/BoundsTest.kt @@ -0,0 +1,85 @@ +/* + * Copyright 2025 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.geometry + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class BoundsTest { + + @Test + fun testInitialization() { + val bounds = Bounds(0.0, 10.0, 0.0, 20.0) + assertEquals(0.0, bounds.minX, 0.0) + assertEquals(10.0, bounds.maxX, 0.0) + assertEquals(0.0, bounds.minY, 0.0) + assertEquals(20.0, bounds.maxY, 0.0) + assertEquals(5.0, bounds.midX, 0.0) + assertEquals(10.0, bounds.midY, 0.0) + } + + @Test + fun testContainsPoint() { + val bounds = Bounds(0.0, 10.0, 0.0, 10.0) + assertTrue(bounds.contains(Point(5.0, 5.0))) + assertTrue(bounds.contains(Point(0.0, 0.0))) + assertTrue(bounds.contains(Point(10.0, 10.0))) + assertFalse(bounds.contains(Point(11.0, 5.0))) + assertFalse(bounds.contains(Point(5.0, 11.0))) + } + + @Test + fun testContainsCoordinates() { + val bounds = Bounds(0.0, 10.0, 0.0, 10.0) + assertTrue(bounds.contains(5.0, 5.0)) + assertTrue(bounds.contains(0.0, 0.0)) + assertTrue(bounds.contains(10.0, 10.0)) + assertFalse(bounds.contains(11.0, 5.0)) + assertFalse(bounds.contains(5.0, 11.0)) + } + + @Test + fun testIntersectsBounds() { + val bounds1 = Bounds(0.0, 10.0, 0.0, 10.0) + val bounds2 = Bounds(5.0, 15.0, 5.0, 15.0) + val bounds3 = Bounds(11.0, 20.0, 11.0, 20.0) + val bounds4 = Bounds(0.0, 10.0, 11.0, 20.0) + + assertTrue(bounds1.intersects(bounds2)) + assertFalse(bounds1.intersects(bounds3)) + assertFalse(bounds1.intersects(bounds4)) + } + + @Test + fun testIntersectsCoordinates() { + val bounds = Bounds(0.0, 10.0, 0.0, 10.0) + assertTrue(bounds.intersects(5.0, 15.0, 5.0, 15.0)) + assertFalse(bounds.intersects(11.0, 20.0, 11.0, 20.0)) + } + + @Test + fun testContainsBounds() { + val bounds1 = Bounds(0.0, 10.0, 0.0, 10.0) + val bounds2 = Bounds(2.0, 8.0, 2.0, 8.0) + val bounds3 = Bounds(5.0, 15.0, 5.0, 15.0) + + assertTrue(bounds1.contains(bounds2)) + assertFalse(bounds1.contains(bounds3)) + } +} diff --git a/library/src/test/java/com/google/maps/android/geometry/PointTest.kt b/library/src/test/java/com/google/maps/android/geometry/PointTest.kt new file mode 100644 index 000000000..6c6daa5f0 --- /dev/null +++ b/library/src/test/java/com/google/maps/android/geometry/PointTest.kt @@ -0,0 +1,62 @@ +/* + * Copyright 2025 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.geometry + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Test + +class PointTest { + + @Test + fun testInitialization() { + val point = Point(1.0, 2.0) + assertEquals(1.0, point.x, 0.0) + assertEquals(2.0, point.y, 0.0) + } + + @Test + fun testToString() { + val point = Point(1.0, 2.0) + assertEquals("Point(x=1.0, y=2.0)", point.toString()) + } + + @Test + fun testEqualsAndHashCode() { + val point1 = Point(1.0, 2.0) + val point2 = Point(1.0, 2.0) + val point3 = Point(2.0, 1.0) + + assertEquals(point1, point2) + assertEquals(point1.hashCode(), point2.hashCode()) + + assertNotEquals(point1, point3) + assertNotEquals(point1.hashCode(), point3.hashCode()) + } + + @Test + fun testCopy() { + val original = Point(1.0, 2.0) + val copy = original.copy() + val modifiedCopy = original.copy(y = 3.0) + + assertEquals(original, copy) + assertNotEquals(original, modifiedCopy) + assertEquals(1.0, modifiedCopy.x, 0.0) + assertEquals(3.0, modifiedCopy.y, 0.0) + } +} \ No newline at end of file diff --git a/library/src/test/java/com/google/maps/android/quadtree/PointQuadTreeTest.java b/library/src/test/java/com/google/maps/android/quadtree/PointQuadTreeTest.java deleted file mode 100644 index c5f2de5b7..000000000 --- a/library/src/test/java/com/google/maps/android/quadtree/PointQuadTreeTest.java +++ /dev/null @@ -1,232 +0,0 @@ -/* - * Copyright 2014 Google Inc. - * - * 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.quadtree; - -import com.google.maps.android.geometry.Bounds; -import com.google.maps.android.geometry.Point; - -import org.junit.Assert; -import org.junit.Before; -import org.junit.Test; - -import java.util.Collection; -import java.util.Random; - -public class PointQuadTreeTest { - - private PointQuadTree mTree; - - @Before - public void setUp() { - mTree = new PointQuadTree<>(0, 1, 0, 1); - } - - @Test - public void testAddOnePoint() { - Item item = new Item(0, 0); - mTree.add(item); - Collection items = searchAll(); - Assert.assertEquals(1, items.size()); - mTree.clear(); - } - - @Test - public void testEmpty() { - Collection items = searchAll(); - Assert.assertEquals(0, items.size()); - } - - @Test - public void testMultiplePoints() { - boolean response; - Item item1 = new Item(0, 0); - - // Remove item that isn't yet in the QuadTree - response = mTree.remove(item1); - Assert.assertFalse(response); - - mTree.add(item1); - Item item2 = new Item(.1, .1); - mTree.add(item2); - Item item3 = new Item(.2, .2); - mTree.add(item3); - - Collection items = searchAll(); - Assert.assertEquals(3, items.size()); - - Assert.assertTrue(items.contains(item1)); - Assert.assertTrue(items.contains(item2)); - Assert.assertTrue(items.contains(item3)); - - response = mTree.remove(item1); - Assert.assertTrue(response); - response = mTree.remove(item2); - Assert.assertTrue(response); - response = mTree.remove(item3); - Assert.assertTrue(response); - - Assert.assertEquals(0, searchAll().size()); - - // Remove item that is no longer in the QuadTree - response = mTree.remove(item1); - Assert.assertFalse(response); - mTree.clear(); - } - - @Test - public void testSameLocationDifferentPoint() { - mTree.add(new Item(0, 0)); - mTree.add(new Item(0, 0)); - - Assert.assertEquals(2, searchAll().size()); - mTree.clear(); - } - - @Test - public void testClear() { - mTree.add(new Item(.1, .1)); - mTree.add(new Item(.2, .2)); - mTree.add(new Item(.3, .3)); - - mTree.clear(); - Assert.assertEquals(0, searchAll().size()); - } - - @Test - public void testSearch() { - System.gc(); - for (int i = 0; i < 10000; i++) { - mTree.add(new Item(i / 20000.0, i / 20000.0)); - } - - Assert.assertEquals(10000, searchAll().size()); - Assert.assertEquals( - 1, mTree.search(new Bounds((double) 0, 0.00001, (double) 0, 0.00001)).size()); - Assert.assertEquals(0, mTree.search(new Bounds(.7, .8, .7, .8)).size()); - mTree.clear(); - System.gc(); - } - - @Test - public void testFourPoints() { - mTree.add(new Item(0.2, 0.2)); - mTree.add(new Item(0.7, 0.2)); - mTree.add(new Item(0.2, 0.7)); - mTree.add(new Item(0.7, 0.7)); - - Assert.assertEquals(2, mTree.search(new Bounds(0.0, 0.5, 0.0, 1.0)).size()); - mTree.clear(); - } - - /** - * Tests 30,000 items at the same point. Timing results are averaged. - */ - @Test - public void testVeryDeepTree() { - System.gc(); - for (int i = 0; i < 30000; i++) { - mTree.add(new Item(0, 0)); - } - - Assert.assertEquals(30000, searchAll().size()); - Assert.assertEquals(30000, mTree.search(new Bounds(0, .1, 0, .1)).size()); - Assert.assertEquals(0, mTree.search(new Bounds(.1, 1, .1, 1)).size()); - - mTree.clear(); - System.gc(); - } - - /** - * Tests 400,000 points relatively uniformly distributed across the space. Timing results are - * averaged. - */ - @Test - public void testManyPoints() { - System.gc(); - for (double i = 0; i < 200; i++) { - for (double j = 0; j < 2000; j++) { - mTree.add(new Item(i / 200.0, j / 2000.0)); - } - } - - // searching bounds that are exact subtrees of the main quadTree - Assert.assertEquals(400000, searchAll().size()); - Assert.assertEquals(100000, mTree.search(new Bounds(0, .5, 0, .5)).size()); - Assert.assertEquals(100000, mTree.search(new Bounds(.5, 1, 0, .5)).size()); - Assert.assertEquals(25000, mTree.search(new Bounds(0, .25, 0, .25)).size()); - Assert.assertEquals(25000, mTree.search(new Bounds(.75, 1, .75, 1)).size()); - - // searching bounds that do not line up with main quadTree - Assert.assertEquals(399800, mTree.search(new Bounds(0, 0.999, 0, 0.999)).size()); - Assert.assertEquals(4221, mTree.search(new Bounds(0.8, 0.9, 0.8, 0.9)).size()); - Assert.assertEquals(4200, mTree.search(new Bounds(0, 1, 0, 0.01)).size()); - Assert.assertEquals(16441, mTree.search(new Bounds(0.4, 0.6, 0.4, 0.6)).size()); - - // searching bounds that are small / have very exact end points - Assert.assertEquals(1, mTree.search(new Bounds(0, .001, 0, .0001)).size()); - Assert.assertEquals(26617, mTree.search(new Bounds(0.356, 0.574, 0.678, 0.987)).size()); - Assert.assertEquals(44689, mTree.search(new Bounds(0.123, 0.456, 0.456, 0.789)).size()); - Assert.assertEquals(4906, mTree.search(new Bounds(0.111, 0.222, 0.333, 0.444)).size()); - - mTree.clear(); - Assert.assertEquals(0, searchAll().size()); - System.gc(); - } - - /** - * Runs a test with 100,000 points. Timing results are averaged. - */ - @Test - public void testRandomPoints() { - System.gc(); - Random random = new Random(); - for (int i = 0; i < 100000; i++) { - mTree.add(new Item(random.nextDouble(), random.nextDouble())); - } - searchAll(); - - mTree.search(new Bounds(0, 0.5, 0, 0.5)); - mTree.search(new Bounds(0, 0.25, 0, 0.25)); - mTree.search(new Bounds(0, 0.125, 0, 0.125)); - mTree.search(new Bounds(0, 0.999, 0, 0.999)); - mTree.search(new Bounds(0, 1, 0, 0.01)); - mTree.search(new Bounds(0.4, 0.6, 0.4, 0.6)); - mTree.search(new Bounds(0.356, 0.574, 0.678, 0.987)); - mTree.search(new Bounds(0.123, 0.456, 0.456, 0.789)); - mTree.search(new Bounds(0.111, 0.222, 0.333, 0.444)); - - mTree.clear(); - System.gc(); - } - - private Collection searchAll() { - return mTree.search(new Bounds(0, 1, 0, 1)); - } - - private static class Item implements PointQuadTree.Item { - private final Point mPoint; - - private Item(double x, double y) { - this.mPoint = new Point(x, y); - } - - @Override - public Point getPoint() { - return mPoint; - } - } -} diff --git a/library/src/test/java/com/google/maps/android/quadtree/PointQuadTreeTest.kt b/library/src/test/java/com/google/maps/android/quadtree/PointQuadTreeTest.kt new file mode 100644 index 000000000..9ff4a7f2b --- /dev/null +++ b/library/src/test/java/com/google/maps/android/quadtree/PointQuadTreeTest.kt @@ -0,0 +1,244 @@ +/* + * Copyright 2025 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.quadtree + +import com.google.maps.android.geometry.Bounds +import com.google.maps.android.geometry.Point +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.util.Random + +class PointQuadTreeTest { + + private lateinit var mTree: PointQuadTree + + @Before + fun setUp() { + mTree = PointQuadTree(0.0, 1.0, 0.0, 1.0) + } + + @Test + fun testAddOnePoint() { + val item = Item(0.0, 0.0) + mTree.add(item) + val items = searchAll() + assertEquals(1, items.size) + mTree.clear() + } + + @Test + fun testEmpty() { + val items = searchAll() + assertEquals(0, items.size) + } + + @Test + fun testMultiplePoints() { + var response: Boolean + val item1 = Item(0.0, 0.0) + + // Remove item that isn't yet in the QuadTree + response = mTree.remove(item1) + assertFalse(response) + + mTree.add(item1) + val item2 = Item(.1, .1) + mTree.add(item2) + val item3 = Item(.2, .2) + mTree.add(item3) + + val items = searchAll() + assertEquals(3, items.size) + + assertTrue(items.contains(item1)) + assertTrue(items.contains(item2)) + assertTrue(items.contains(item3)) + + response = mTree.remove(item1) + assertTrue(response) + response = mTree.remove(item2) + assertTrue(response) + response = mTree.remove(item3) + assertTrue(response) + + assertEquals(0, searchAll().size) + + // Remove item that is no longer in the QuadTree + response = mTree.remove(item1) + assertFalse(response) + mTree.clear() + } + + @Test + fun testSameLocationDifferentPoint() { + mTree.add(Item(0.0, 0.0)) + mTree.add(Item(0.0, 0.0)) + + assertEquals(2, searchAll().size) + mTree.clear() + } + + @Test + fun testClear() { + mTree.add(Item(.1, .1)) + mTree.add(Item(.2, .2)) + mTree.add(Item(.3, .3)) + + mTree.clear() + assertEquals(0, searchAll().size) + } + + @Test + fun testSearch() { + System.gc() + for (i in 0..9999) { + mTree.add(Item(i / 20000.0, i / 20000.0)) + } + + assertEquals(10000, searchAll().size) + assertEquals( + 1, mTree.search(Bounds(0.0, 0.00001, 0.0, 0.00001)).size + ) + assertEquals(0, mTree.search(Bounds(.7, .8, .7, .8)).size) + mTree.clear() + System.gc() + } + + @Test + fun testFourPoints() { + mTree.add(Item(0.2, 0.2)) + mTree.add(Item(0.7, 0.2)) + mTree.add(Item(0.2, 0.7)) + mTree.add(Item(0.7, 0.7)) + + assertEquals(2, mTree.search(Bounds(0.0, 0.5, 0.0, 1.0)).size) + mTree.clear() + } + + /** + * Tests 30,000 items at the same point. Timing results are averaged. + */ + @Test + fun testVeryDeepTree() { + System.gc() + repeat(30000) { + mTree.add(Item(0.0, 0.0)) + } + + assertEquals(30000, searchAll().size) + assertEquals(30000, mTree.search(Bounds(0.0, .1, 0.0, .1)).size) + assertEquals(0, mTree.search(Bounds(.1, 1.0, .1, 1.0)).size) + + mTree.clear() + System.gc() + } + + /** + * Tests 400,000 points relatively uniformly distributed across the space. Timing results are + * averaged. + */ + @Test + fun testManyPoints() { + System.gc() + for (i in 0..199) { + for (j in 0..1999) { + mTree.add(Item(i / 200.0, j / 2000.0)) + } + } + + // searching bounds that are exact subtrees of the main quadTree + assertEquals(400000, searchAll().size) + assertEquals(100000, mTree.search(Bounds(0.0, .5, 0.0, .5)).size) + assertEquals(100000, mTree.search(Bounds(.5, 1.0, 0.0, .5)).size) + assertEquals(25000, mTree.search(Bounds(0.0, .25, 0.0, .25)).size) + assertEquals(25000, mTree.search(Bounds(.75, 1.0, .75, 1.0)).size) + + // searching bounds that do not line up with main quadTree + assertEquals(399800, mTree.search(Bounds(0.0, 0.999, 0.0, 0.999)).size) + assertEquals(4221, mTree.search(Bounds(0.8, 0.9, 0.8, 0.9)).size) + assertEquals(4200, mTree.search(Bounds(0.0, 1.0, 0.0, 0.01)).size) + assertEquals(16441, mTree.search(Bounds(0.4, 0.6, 0.4, 0.6)).size) + + // searching bounds that are small / have very exact end points + assertEquals(1, mTree.search(Bounds(0.0, .001, 0.0, .0001)).size) + assertEquals(26617, mTree.search(Bounds(0.356, 0.574, 0.678, 0.987)).size) + assertEquals(44689, mTree.search(Bounds(0.123, 0.456, 0.456, 0.789)).size) + assertEquals(4906, mTree.search(Bounds(0.111, 0.222, 0.333, 0.444)).size) + + mTree.clear() + assertEquals(0, searchAll().size) + System.gc() + } + + @Test + fun testAddOutsideBounds() { + mTree.add(Item(-1.0, -1.0)) + assertEquals(0, searchAll().size) + } + + @Test + fun testReAdd() { + val item = Item(0.0, 0.0) + mTree.add(item) + mTree.add(item) + assertEquals(1, searchAll().size) + } + + @Test + fun testSearchOutsideBounds() { + mTree.add(Item(0.0, 0.0)) + assertEquals(0, mTree.search(Bounds(10.0, 20.0, 10.0, 20.0)).size) + } + + /** + * Runs a test with 100,000 points. Timing results are averaged. + */ + @Test + fun testRandomPoints() { + System.gc() + Random().apply { + repeat(100000) { + mTree.add(Item(nextDouble(), nextDouble())) + } + } + searchAll() + + mTree.search(Bounds(0.0, 0.5, 0.0, 0.5)) + mTree.search(Bounds(0.0, 0.25, 0.0, 0.25)) + mTree.search(Bounds(0.0, 0.125, 0.0, 0.125)) + mTree.search(Bounds(0.0, 0.999, 0.0, 0.999)) + mTree.search(Bounds(0.0, 1.0, 0.0, 0.01)) + mTree.search(Bounds(0.4, 0.6, 0.4, 0.6)) + mTree.search(Bounds(0.356, 0.574, 0.678, 0.987)) + mTree.search(Bounds(0.123, 0.456, 0.456, 0.789)) + mTree.search(Bounds(0.111, 0.222, 0.333, 0.444)) + + mTree.clear() + System.gc() + } + + private fun searchAll(): Collection { + return mTree.search(Bounds(0.0, 1.0, 0.0, 1.0)) + } + + private class Item(x: Double, y: Double) : PointQuadTree.Item { + override val point: Point = Point(x, y) + } +} \ No newline at end of file From f891ddf45ac726f06d655ec58267f2de14a47386 Mon Sep 17 00:00:00 2001 From: Dale Hawkins <107309+dkhawk@users.noreply.github.com> Date: Wed, 27 Aug 2025 13:47:33 -0600 Subject: [PATCH 39/67] refactor: simplify switch and improve locking in ClusterRendererMultipleItems (#1557) * refactor: simplify switch and improve locking in ClusterRendererMultipleItems - Replaced a traditional switch statement with an enhanced switch expression in `setAnimationType`. - Added a `setAnimationInterpolator` method to allow custom interpolators. - Ensured `lock.unlock()` is always called in `finally` blocks within `MarkerModifier` and `AnimationTask` for better resource management. - Added `@Override` annotation to `ViewModifier.run()`. * refactor: Refactor MarkerModifier to use a withLock method This commit refactors the `MarkerModifier` class to use a `withLock` method. This reduces code repetition and improves readability. --- .../view/ClusterRendererMultipleItems.java | 140 ++++++++++-------- 1 file changed, 75 insertions(+), 65 deletions(-) diff --git a/library/src/main/java/com/google/maps/android/clustering/view/ClusterRendererMultipleItems.java b/library/src/main/java/com/google/maps/android/clustering/view/ClusterRendererMultipleItems.java index 9b1c5adf2..fd7abcb2a 100644 --- a/library/src/main/java/com/google/maps/android/clustering/view/ClusterRendererMultipleItems.java +++ b/library/src/main/java/com/google/maps/android/clustering/view/ClusterRendererMultipleItems.java @@ -107,31 +107,24 @@ public enum AnimationType { } public void setAnimationType(AnimationType type) { - switch (type) { - case EASE_IN, ACCELERATE: - animationInterp = new AccelerateInterpolator(); - break; - case EASE_OUT: - animationInterp = new DecelerateInterpolator(); - break; - case EASE_IN_OUT: - animationInterp = new AccelerateDecelerateInterpolator(); - break; - case FAST_OUT_SLOW_IN: - animationInterp = new FastOutSlowInInterpolator(); - break; - case BOUNCE: - animationInterp = new BounceInterpolator(); - break; - case DECELERATE: - animationInterp = new DecelerateInterpolator(); - break; - default: - animationInterp = new LinearInterpolator(); - break; - } + animationInterp = switch (type) { + case LINEAR -> new LinearInterpolator(); + case EASE_IN, ACCELERATE -> new AccelerateInterpolator(); + case EASE_OUT, DECELERATE -> new DecelerateInterpolator(); + case EASE_IN_OUT -> new AccelerateDecelerateInterpolator(); + case FAST_OUT_SLOW_IN -> new FastOutSlowInInterpolator(); + case BOUNCE -> new BounceInterpolator(); + }; } + /** + * Sets the interpolator for the animation. + * + * @param interpolator the interpolator to use for the animation. + */ + public void setAnimationInterpolator(TimeInterpolator interpolator) { + animationInterp = interpolator; + } /** * Markers that are currently on the map. @@ -459,6 +452,7 @@ public void setMapZoom(float zoom) { } @SuppressLint("NewApi") + @Override public void run() { final MarkerModifier markerModifier = new MarkerModifier(); final float zoom = mMapZoom; @@ -676,6 +670,15 @@ private class MarkerModifier extends Handler implements MessageQueue.IdleHandler */ private boolean mListenerAdded; + private void withLock(Runnable runnable) { + lock.lock(); + try { + runnable.run(); + } finally { + lock.unlock(); + } + } + private MarkerModifier() { super(Looper.getMainLooper()); } @@ -686,14 +689,14 @@ private MarkerModifier() { * @param priority whether this operation should have priority. */ public void add(boolean priority, CreateMarkerTask c) { - lock.lock(); - sendEmptyMessage(BLANK); - if (priority) { - mOnScreenCreateMarkerTasks.add(c); - } else { - mCreateMarkerTasks.add(c); - } - lock.unlock(); + withLock(() -> { + sendEmptyMessage(BLANK); + if (priority) { + mOnScreenCreateMarkerTasks.add(c); + } else { + mCreateMarkerTasks.add(c); + } + }); } /** @@ -703,14 +706,14 @@ public void add(boolean priority, CreateMarkerTask c) { * @param m the markerWithPosition to remove. */ public void remove(boolean priority, Marker m) { - lock.lock(); - sendEmptyMessage(BLANK); - if (priority) { - mOnScreenRemoveMarkerTasks.add(m); - } else { - mRemoveMarkerTasks.add(m); - } - lock.unlock(); + withLock(() -> { + sendEmptyMessage(BLANK); + if (priority) { + mOnScreenRemoveMarkerTasks.add(m); + } else { + mRemoveMarkerTasks.add(m); + } + }); } /** @@ -721,19 +724,19 @@ public void remove(boolean priority, Marker m) { * @param to the position to animate to. */ public void animate(MarkerWithPosition marker, LatLng from, LatLng to) { - lock.lock(); - AnimationTask task = new AnimationTask(marker, from, to, lock); + withLock(() -> { + AnimationTask task = new AnimationTask(marker, from, to, lock); - for (AnimationTask existingTask : ongoingAnimations) { - if (existingTask.marker.getId().equals(task.marker.getId())) { - existingTask.cancel(); - break; + for (AnimationTask existingTask : ongoingAnimations) { + if (existingTask.marker.getId().equals(task.marker.getId())) { + existingTask.cancel(); + break; + } } - } - mAnimationTasks.add(task); - ongoingAnimations.add(task); - lock.unlock(); + mAnimationTasks.add(task); + ongoingAnimations.add(task); + }); } /** @@ -745,19 +748,19 @@ public void animate(MarkerWithPosition marker, LatLng from, LatLng to) { * @param to the position to animate to. */ public void animateThenRemove(MarkerWithPosition marker, LatLng from, LatLng to) { - lock.lock(); - AnimationTask animationTask = new AnimationTask(marker, from, to, lock); - for (AnimationTask existingTask : ongoingAnimations) { - if (existingTask.marker.getId().equals(animationTask.marker.getId())) { - existingTask.cancel(); - break; + withLock(() -> { + AnimationTask animationTask = new AnimationTask(marker, from, to, lock); + for (AnimationTask existingTask : ongoingAnimations) { + if (existingTask.marker.getId().equals(animationTask.marker.getId())) { + existingTask.cancel(); + break; + } } - } - ongoingAnimations.add(animationTask); - animationTask.removeOnAnimationComplete(mClusterManager.getMarkerManager()); - mAnimationTasks.add(animationTask); - lock.unlock(); + ongoingAnimations.add(animationTask); + animationTask.removeOnAnimationComplete(mClusterManager.getMarkerManager()); + mAnimationTasks.add(animationTask); + }); } @Override @@ -821,9 +824,13 @@ private void removeMarker(Marker m) { * @return true if there is still work to be processed. */ public boolean isBusy() { + lock.lock(); try { - lock.lock(); - return !(mCreateMarkerTasks.isEmpty() && mOnScreenCreateMarkerTasks.isEmpty() && mOnScreenRemoveMarkerTasks.isEmpty() && mRemoveMarkerTasks.isEmpty() && mAnimationTasks.isEmpty()); + return !(mCreateMarkerTasks.isEmpty() + && mOnScreenCreateMarkerTasks.isEmpty() + && mOnScreenRemoveMarkerTasks.isEmpty() + && mRemoveMarkerTasks.isEmpty() + && mAnimationTasks.isEmpty()); } finally { lock.unlock(); } @@ -1257,11 +1264,11 @@ public void cancel() { new Handler(Looper.getMainLooper()).post(this::cancel); return; } + lock.lock(); try { markerWithPosition.position = to; mRemoveOnComplete = false; valueAnimator.cancel(); - lock.lock(); ongoingAnimations.remove(this); } finally { lock.unlock(); @@ -1279,8 +1286,11 @@ public void onAnimationEnd(Animator animation) { // Remove the task from the queue lock.lock(); - ongoingAnimations.remove(this); - lock.unlock(); + try { + ongoingAnimations.remove(this); + } finally { + lock.unlock(); + } } public void removeOnAnimationComplete(MarkerManager markerManager) { From 4ada6c942dff0e8297150d991c467e208a9fc6f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20L=C3=B3pez-Ma=C3=B1as?= Date: Thu, 28 Aug 2025 18:09:47 +0200 Subject: [PATCH 40/67] docs: adding sample with Clustering Algorithms (#1567) * docs: adding sample with Clustering Algorithms * docs: header * docs: comments from PR --- demo/src/main/AndroidManifest.xml | 3 + .../demo/ClusterAlgorithmsDemoActivity.kt | 126 ++++++++++++++++++ .../maps/android/utils/demo/MainActivity.java | 3 +- .../activity_cluster_algorithms_demo.xml | 41 ++++++ demo/src/main/res/values/arrays.xml | 27 ++++ 5 files changed, 199 insertions(+), 1 deletion(-) create mode 100644 demo/src/main/java/com/google/maps/android/utils/demo/ClusterAlgorithmsDemoActivity.kt create mode 100644 demo/src/main/res/layout/activity_cluster_algorithms_demo.xml create mode 100644 demo/src/main/res/values/arrays.xml diff --git a/demo/src/main/AndroidManifest.xml b/demo/src/main/AndroidManifest.xml index a504a7619..b1b387dce 100644 --- a/demo/src/main/AndroidManifest.xml +++ b/demo/src/main/AndroidManifest.xml @@ -124,6 +124,9 @@ + diff --git a/demo/src/main/java/com/google/maps/android/utils/demo/ClusterAlgorithmsDemoActivity.kt b/demo/src/main/java/com/google/maps/android/utils/demo/ClusterAlgorithmsDemoActivity.kt new file mode 100644 index 000000000..93e275907 --- /dev/null +++ b/demo/src/main/java/com/google/maps/android/utils/demo/ClusterAlgorithmsDemoActivity.kt @@ -0,0 +1,126 @@ +/* + * Copyright 2025 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.utils.demo + +import android.view.View +import android.widget.AdapterView +import android.widget.ArrayAdapter +import android.widget.Spinner +import com.google.android.gms.maps.CameraUpdateFactory +import com.google.android.gms.maps.MapView +import com.google.android.gms.maps.model.LatLng +import com.google.maps.android.clustering.ClusterManager +import com.google.maps.android.clustering.algo.AbstractAlgorithm +import com.google.maps.android.clustering.algo.CentroidNonHierarchicalDistanceBasedAlgorithm +import com.google.maps.android.clustering.algo.ContinuousZoomEuclideanCentroidAlgorithm +import com.google.maps.android.clustering.algo.GridBasedAlgorithm +import com.google.maps.android.clustering.algo.NonHierarchicalDistanceBasedAlgorithm +import com.google.maps.android.clustering.algo.NonHierarchicalViewBasedAlgorithm +import com.google.maps.android.utils.demo.model.MyItem +import kotlin.random.Random + +/** + * A demo activity that showcases the various clustering algorithms + * available in the library. + */ +class ClusterAlgorithmsDemoActivity : BaseDemoActivity() { + + private var clusterManager: ClusterManager? = null + private lateinit var mapView: MapView + + override fun getLayoutId(): Int { + return R.layout.activity_cluster_algorithms_demo + } + + override fun startDemo(isRestore: Boolean) { + + if (!isRestore) { + map.moveCamera( + CameraUpdateFactory.newLatLngZoom( + LatLng(51.503186, -0.126446), 10f + ) + ) + } + + setupSpinner() + + setupClusterer(0) + } + + private fun setupSpinner() { + val spinner: Spinner = findViewById(R.id.algorithm_spinner) + val adapter = ArrayAdapter.createFromResource( + this, R.array.clustering_algorithms, android.R.layout.simple_spinner_item + ) + adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) + spinner.adapter = adapter + spinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { + override fun onItemSelected( + parent: AdapterView<*>?, view: View?, position: Int, id: Long + ) { + setupClusterer(position) + } + + override fun onNothingSelected(parent: AdapterView<*>?) { + // Do nothing + } + } + } + + /** + * Sets up the ClusterManager with the chosen algorithm and populates it with items. + */ + private fun setupClusterer(algorithmPosition: Int) { + // 1. Clear the map and previous cluster manager + map.clear() + + // 2. Initialize a new ClusterManager, using getMap() from BaseDemoActivity + clusterManager = ClusterManager(this, map) + + // 3. Set the desired algorithm based on the spinner position + clusterManager?.algorithm = when (algorithmPosition) { + 1 -> GridBasedAlgorithm() + 2 -> NonHierarchicalDistanceBasedAlgorithm() + 3 -> CentroidNonHierarchicalDistanceBasedAlgorithm() + 4 -> NonHierarchicalViewBasedAlgorithm(mapView.width, mapView.height) + 5 -> ContinuousZoomEuclideanCentroidAlgorithm() + else -> error("Unsupported algorithm position: $algorithmPosition") + } + + // 4. Point the map's listeners to the ClusterManager + map.setOnCameraIdleListener(clusterManager) + map.setOnMarkerClickListener(clusterManager) + + // 5. Generate and add cluster items to the manager + val items = generateItems() + clusterManager?.addItems(items) + + // 6. Trigger the initial clustering + clusterManager?.cluster() + } + + private fun generateItems(): List { + val items = mutableListOf() + // Add 100 random items in the map region + for (i in 0 until 100) { + val lat = 51.5145 + (Random.nextDouble() - 0.5) / 2.0 + val lng = -0.1245 + (Random.nextDouble() - 0.5) / 2.0 + items.add(MyItem(lat, lng, "Marker #$i", "Snippet for marker #$i")) + } + return items + } +} \ No newline at end of file diff --git a/demo/src/main/java/com/google/maps/android/utils/demo/MainActivity.java b/demo/src/main/java/com/google/maps/android/utils/demo/MainActivity.java index 4f22adbfc..1d4b866fb 100644 --- a/demo/src/main/java/com/google/maps/android/utils/demo/MainActivity.java +++ b/demo/src/main/java/com/google/maps/android/utils/demo/MainActivity.java @@ -36,8 +36,9 @@ protected void onCreate(Bundle savedInstanceState) { mListView = findViewById(R.id.list); - addDemo("Clustering", ClusteringDemoActivity.class); addDemo("Advanced Markers Clustering Example", CustomAdvancedMarkerClusteringDemoActivity.class); + addDemo("Cluster Algorithms", ClusterAlgorithmsDemoActivity.class); + addDemo("Clustering", ClusteringDemoActivity.class); addDemo("Clustering: Custom Look", CustomMarkerClusteringDemoActivity.class); addDemo("Clustering: Diff", ClusteringDiffDemoActivity.class); addDemo("Clustering: 2K markers", BigClusteringDemoActivity.class); diff --git a/demo/src/main/res/layout/activity_cluster_algorithms_demo.xml b/demo/src/main/res/layout/activity_cluster_algorithms_demo.xml new file mode 100644 index 000000000..4d8d1ae98 --- /dev/null +++ b/demo/src/main/res/layout/activity_cluster_algorithms_demo.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/demo/src/main/res/values/arrays.xml b/demo/src/main/res/values/arrays.xml new file mode 100644 index 000000000..192ac984e --- /dev/null +++ b/demo/src/main/res/values/arrays.xml @@ -0,0 +1,27 @@ + + + + + + Default + Grid-based + Distance-based + Distance-based (Centroid) + View-based + Continuous Zoom (Centroid) + + \ No newline at end of file From 5d48344dfa8b583b569d271265982c50d16c6021 Mon Sep 17 00:00:00 2001 From: Dale Hawkins <107309+dkhawk@users.noreply.github.com> Date: Thu, 28 Aug 2025 10:27:22 -0600 Subject: [PATCH 41/67] chore(heatmaps): Port heatmaps package to Kotlin (#1573) * Rename .java to .kt * refactor(library): Convert projection.Point to Kotlin and add tests The `@Deprecated` annotation has been updated to the Kotlin syntax and now includes a `ReplaceWith` expression to provide IDE quick-fix support. Additionally, new unit tests using Google Truth have been added for the `Point` class to verify its construction and inheritance. * refactor(heatmaps): Port Gradient and WeightedLatLng to Kotlin This commit migrates the `Gradient` and `WeightedLatLng` classes and their corresponding tests from Java to idiomatic Kotlin. This conversion is part of the larger effort to modernize the library and prepare it for Kotlin Multiplatform (KMP) compatibility. Key changes include: - `WeightedLatLng` is now a `data class` with a private primary constructor to ensure correct instantiation while providing the benefits of auto-generated methods. The `@ExposedCopyVisibility` annotation has been added to maintain binary compatibility for the `copy()` method. - `Gradient` has been converted to a Kotlin class, using `@JvmOverloads` for constructor compatibility and a `companion object` for its static helper method. - The `internal` visibility of a method in `Gradient` was changed to `public` to ensure visibility to Java classes during the mixed-language phase of the migration. - Unit tests for both classes have been ported to Kotlin, using the Truth assertion framework. * feat(heatmaps): Port HeatmapTileProvider to Kotlin This commit completes the migration of the `heatmaps` package to Kotlin by porting the final and most complex class, `HeatmapTileProvider`. This conversion modernizes the class by introducing several idiomatic Kotlin features and improves its design by refactoring a key data structure. Key changes include: - The `HeatmapTileProvider` and its `Builder` are now written in Kotlin. - The `getMaxValue()` method was refactored to use a `Map` with a type-safe `Vector` key instead of the Android-specific `LongSparseArray`, improving readability and moving closer to KMP compatibility. - A new test suite was created from scratch for the provider, as none existed previously. This improves the code coverage and reliability of the module. - Necessary adjustments were made to the demo application to support the newly ported Kotlin class. * fix(tests): Correct copyright headers in new test files Updates the copyright headers in the test files that were created during the Kotlin porting process. - Corrects the copyright year to 2025. - Corrects the company name from "Google Inc." to "Google LLC". * chore: Add PLACES_API_KEY to local.defaults.properties * fix(heatmaps): Correct copyright headers Updates the copyright headers in the `heatmaps` package source and test files. - Corrects the copyright year to 2025. - Corrects the company name from "Google Inc." to "Google LLC". * refactor(heatmaps): Improve API clarity and documentation Addresses feedback to make the heatmap API more intuitive and robust. - Renames `setWeightedData` and `setData` in `HeatmapTileProvider` to `updateData` and `updateLatLngs` respectively. The `update` prefix better communicates that these methods perform expensive operations that rebuild internal state, rather than just setting a property. - The old method names are preserved and marked as `@Deprecated` to provide a smooth migration path for existing users and avoid a breaking change. - Adds a default value of 0.7 to the `opacity` parameter in `Gradient.generateColorMap`. This aligns with the default in `HeatmapTileProvider` and simplifies common use cases. - Annotates `generateColorMap` with `@JvmOverloads` to ensure the new default parameter is exposed correctly to Java clients. - Improves KDoc for all modified methods to be more descriptive, explaining the purpose of the code and the reasoning behind the design choices. * refactor(heatmaps): Use constant for default opacity Updates the `generateColorMap` function to use the `HeatmapTileProvider.DEFAULT_OPACITY` constant for its default opacity value. This removes a hardcoded value, improving maintainability and ensuring the default remains consistent with the provider. The accompanying KDoc has also been corrected. --- .../android/utils/demo/BaseDemoActivity.java | 4 +- .../demo/HeatmapsPlacesDemoActivity.java | 59 +- .../maps/android/heatmaps/Gradient.java | 193 ----- .../google/maps/android/heatmaps/Gradient.kt | 154 ++++ .../android/heatmaps/HeatmapTileProvider.java | 814 ------------------ .../android/heatmaps/HeatmapTileProvider.kt | 426 +++++++++ .../maps/android/heatmaps/WeightedLatLng.java | 81 -- .../maps/android/heatmaps/WeightedLatLng.kt | 46 + .../projection/{Point.java => Point.kt} | 12 +- .../maps/android/heatmaps/GradientTest.java | 125 --- .../maps/android/heatmaps/GradientTest.kt | 114 +++ .../heatmaps/HeatmapTileProviderTest.kt | 132 +++ .../android/heatmaps/WeightedLatLngTest.kt | 72 ++ .../maps/android/projection/PointTest.kt | 40 + local.defaults.properties | 3 +- 15 files changed, 1029 insertions(+), 1246 deletions(-) delete mode 100644 library/src/main/java/com/google/maps/android/heatmaps/Gradient.java create mode 100644 library/src/main/java/com/google/maps/android/heatmaps/Gradient.kt delete mode 100644 library/src/main/java/com/google/maps/android/heatmaps/HeatmapTileProvider.java create mode 100644 library/src/main/java/com/google/maps/android/heatmaps/HeatmapTileProvider.kt delete mode 100644 library/src/main/java/com/google/maps/android/heatmaps/WeightedLatLng.java create mode 100644 library/src/main/java/com/google/maps/android/heatmaps/WeightedLatLng.kt rename library/src/main/java/com/google/maps/android/projection/{Point.java => Point.kt} (63%) delete mode 100644 library/src/test/java/com/google/maps/android/heatmaps/GradientTest.java create mode 100644 library/src/test/java/com/google/maps/android/heatmaps/GradientTest.kt create mode 100644 library/src/test/java/com/google/maps/android/heatmaps/HeatmapTileProviderTest.kt create mode 100644 library/src/test/java/com/google/maps/android/heatmaps/WeightedLatLngTest.kt create mode 100644 library/src/test/java/com/google/maps/android/projection/PointTest.kt diff --git a/demo/src/main/java/com/google/maps/android/utils/demo/BaseDemoActivity.java b/demo/src/main/java/com/google/maps/android/utils/demo/BaseDemoActivity.java index 67cc61043..2281c8dfc 100644 --- a/demo/src/main/java/com/google/maps/android/utils/demo/BaseDemoActivity.java +++ b/demo/src/main/java/com/google/maps/android/utils/demo/BaseDemoActivity.java @@ -28,6 +28,8 @@ import com.google.android.gms.maps.OnMapReadyCallback; import com.google.android.gms.maps.SupportMapFragment; +import java.util.Objects; + public abstract class BaseDemoActivity extends FragmentActivity implements OnMapReadyCallback { private GoogleMap mMap; private boolean mIsRestore; @@ -60,7 +62,7 @@ public void onMapReady(@NonNull GoogleMap map) { } private void setUpMap() { - ((SupportMapFragment) getSupportFragmentManager().findFragmentById(R.id.map)).getMapAsync(this); + ((SupportMapFragment) Objects.requireNonNull(getSupportFragmentManager().findFragmentById(R.id.map))).getMapAsync(this); } /** diff --git a/demo/src/main/java/com/google/maps/android/utils/demo/HeatmapsPlacesDemoActivity.java b/demo/src/main/java/com/google/maps/android/utils/demo/HeatmapsPlacesDemoActivity.java index 7a9c9f7d4..1f2aa3c35 100644 --- a/demo/src/main/java/com/google/maps/android/utils/demo/HeatmapsPlacesDemoActivity.java +++ b/demo/src/main/java/com/google/maps/android/utils/demo/HeatmapsPlacesDemoActivity.java @@ -19,6 +19,7 @@ import android.content.Context; import android.graphics.Color; import android.os.AsyncTask; +import android.os.Bundle; import android.util.Log; import android.view.View; import android.view.inputmethod.EditorInfo; @@ -63,6 +64,9 @@ public class HeatmapsPlacesDemoActivity extends BaseDemoActivity { private final String TAG = "HeatmapPlacesDemo"; private final LatLng SYDNEY = new LatLng(-33.873651, 151.2058896); + private final LatLng BOULDER = new LatLng(40.0216437819216, -105.25471683073081); + + private final LatLng FOCUS = BOULDER; /** * The base URL for the radar search request. @@ -78,27 +82,28 @@ public class HeatmapsPlacesDemoActivity extends BaseDemoActivity { /** * Places API server key. */ - private static final String API_KEY = "YOUR_KEY_HERE"; // TODO place your own here! + private static final String API_KEY = BuildConfig.PLACES_API_KEY; /** * The colors to be used for the different heatmap layers. */ private static final int[] HEATMAP_COLORS = { - HeatmapColors.RED.color, - HeatmapColors.BLUE.color, - HeatmapColors.GREEN.color, - HeatmapColors.PINK.color, - HeatmapColors.GREY.color + HeatmapColors.RED.color, + HeatmapColors.BLUE.color, + HeatmapColors.GREEN.color, + HeatmapColors.PINK.color, + HeatmapColors.GREY.color }; public enum HeatmapColors { - RED (Color.rgb(238, 44, 44)), - BLUE (Color.rgb(60, 80, 255)), - GREEN (Color.rgb(20, 170, 50)), - PINK (Color.rgb(255, 80, 255)), - GREY (Color.rgb(100, 100, 100)); + RED(Color.rgb(238, 44, 44)), + BLUE(Color.rgb(60, 80, 255)), + GREEN(Color.rgb(20, 170, 50)), + PINK(Color.rgb(255, 80, 255)), + GREY(Color.rgb(100, 100, 100)); private final int color; + HeatmapColors(int color) { this.color = color; } @@ -115,7 +120,7 @@ public enum HeatmapColors { /** * Stores the TileOverlay corresponding to each of the keywords that have been searched for. */ - private Hashtable mOverlays = new Hashtable(); + private final Hashtable mOverlays = new Hashtable(); /** * A layout containing checkboxes for each of the heatmaps rendered. @@ -132,6 +137,19 @@ public enum HeatmapColors { */ private int mOverlaysInput = 0; + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + if ("YOUR_API_KEY".equals(API_KEY)) { + Toast.makeText( + this, + "Please sign up for a Places API key and add it to HeatmapsPlacesDemoActivity.API_KEY", + Toast.LENGTH_LONG + ).show(); + finish(); + } + } + @Override protected int getLayoutId() { return R.layout.places_demo; @@ -152,11 +170,11 @@ protected void startDemo(boolean isRestore) { mCheckboxLayout = findViewById(R.id.checkboxes); GoogleMap map = getMap(); if (!isRestore) { - map.moveCamera(CameraUpdateFactory.newLatLngZoom(SYDNEY, 11)); + map.moveCamera(CameraUpdateFactory.newLatLngZoom(FOCUS, 11)); } - // Add a circle around Sydney to roughly encompass the results + // Add a circle around FOCUS to roughly encompass the results map.addCircle(new CircleOptions() - .center(SYDNEY) + .center(FOCUS) .radius(SEARCH_RADIUS * 1.2) .strokeColor(Color.RED) .strokeWidth(4)); @@ -167,18 +185,13 @@ protected void startDemo(boolean isRestore) { * Called when a search query is submitted */ public void submit(View view) { - if ("YOUR_KEY_HERE".equals(API_KEY)) { - Toast.makeText(this, "Please sign up for a Places API key and add it to HeatmapsPlacesDemoActivity.API_KEY", - Toast.LENGTH_LONG).show(); - return; - } EditText editText = findViewById(R.id.input_text); String keyword = editText.getText().toString(); if (mOverlays.contains(keyword)) { Toast.makeText(this, "This keyword has already been inputted :(", Toast.LENGTH_SHORT).show(); } else if (mOverlaysRendered == MAX_CHECKBOXES) { Toast.makeText(this, "You can only input " + MAX_CHECKBOXES + " keywords. :(", Toast.LENGTH_SHORT).show(); - } else if (keyword.length() != 0) { + } else if (!keyword.isEmpty()) { mOverlaysInput++; ProgressBar progressBar = findViewById(R.id.progress_bar); progressBar.setVisibility(View.VISIBLE); @@ -202,11 +215,11 @@ public void submit(View view) { private Collection getPoints(String keyword) { HashMap results = new HashMap<>(); - // Calculate four equidistant points around Sydney to use as search centers + // Calculate four equidistant points around FOCUS to use as search centers // so that four searches can be done. ArrayList searchCenters = new ArrayList<>(4); for (int heading = 45; heading < 360; heading += 90) { - searchCenters.add(SphericalUtil.computeOffset(SYDNEY, SEARCH_RADIUS / 2, heading)); + searchCenters.add(SphericalUtil.computeOffset(FOCUS, (double) SEARCH_RADIUS / 2, heading)); } for (int j = 0; j < 4; j++) { diff --git a/library/src/main/java/com/google/maps/android/heatmaps/Gradient.java b/library/src/main/java/com/google/maps/android/heatmaps/Gradient.java deleted file mode 100644 index 559b5f0bc..000000000 --- a/library/src/main/java/com/google/maps/android/heatmaps/Gradient.java +++ /dev/null @@ -1,193 +0,0 @@ -/* - * Copyright 2014 Google Inc. - * - * 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.heatmaps; - -import android.graphics.Color; - -import java.util.HashMap; - -/** - * A class to generate a color map from a given array of colors and the fractions - * that the colors represent by interpolating between their HSV values. - * This color map is to be used in the HeatmapTileProvider. - */ -public class Gradient { - - private class ColorInterval { - private final int color1; - private final int color2; - - /** - * The period over which the color changes from color1 to color2. - * This is given as the number of elements it represents in the colorMap. - */ - private final float duration; - - private ColorInterval(int color1, int color2, float duration) { - this.color1 = color1; - this.color2 = color2; - this.duration = duration; - } - } - - private static final int DEFAULT_COLOR_MAP_SIZE = 1000; - - /** - * Size of a color map for the heatmap - */ - public final int mColorMapSize; - - /** - * The colors to be used in the gradient - */ - public int[] mColors; - - /** - * The starting point for each color, given as a percentage of the maximum intensity - */ - public float[] mStartPoints; - - /** - * Creates a Gradient with the given colors and starting points. - * These are given as parallel arrays. - * - * @param colors The colors to be used in the gradient - * @param startPoints The starting point for each color, given as a percentage of the maximum intensity - * This is given as an array of floats with values in the interval [0,1] - */ - public Gradient(int[] colors, float[] startPoints) { - this(colors, startPoints, DEFAULT_COLOR_MAP_SIZE); - } - - /** - * Creates a Gradient with the given colors and starting points which creates a colorMap of given size. - * The colors and starting points are given as parallel arrays. - * - * @param colors The colors to be used in the gradient - * @param startPoints The starting point for each color, given as a percentage of the maximum intensity - * This is given as an array of floats with values in the interval [0,1] - * @param colorMapSize The size of the colorMap to be generated by the Gradient - */ - public Gradient(int[] colors, float[] startPoints, int colorMapSize) { - if (colors.length != startPoints.length) { - throw new IllegalArgumentException("colors and startPoints should be same length"); - } else if (colors.length == 0) { - throw new IllegalArgumentException("No colors have been defined"); - } - for (int i = 1; i < startPoints.length; i++) { - if (startPoints[i] <= startPoints[i - 1]) { - throw new IllegalArgumentException("startPoints should be in increasing order"); - } - } - mColorMapSize = colorMapSize; - mColors = new int[colors.length]; - mStartPoints = new float[startPoints.length]; - System.arraycopy(colors, 0, mColors, 0, colors.length); - System.arraycopy(startPoints, 0, mStartPoints, 0, startPoints.length); - } - - private HashMap generateColorIntervals() { - HashMap colorIntervals = new HashMap(); - // Create first color if not already created - // The initial color is transparent by default - if (mStartPoints[0] != 0) { - int initialColor = Color.argb( - 0, Color.red(mColors[0]), Color.green(mColors[0]), Color.blue(mColors[0])); - colorIntervals.put(0, new ColorInterval(initialColor, mColors[0], mColorMapSize * mStartPoints[0])); - } - // Generate color intervals - for (int i = 1; i < mColors.length; i++) { - colorIntervals.put(((int) (mColorMapSize * mStartPoints[i - 1])), - new ColorInterval(mColors[i - 1], mColors[i], - (mColorMapSize * (mStartPoints[i] - mStartPoints[i - 1])))); - } - // Extend to a final color - // If color for 100% intensity is not given, the color of highest intensity is used. - if (mStartPoints[mStartPoints.length - 1] != 1) { - int i = mStartPoints.length - 1; - colorIntervals.put(((int) (mColorMapSize * mStartPoints[i])), - new ColorInterval(mColors[i], mColors[i], mColorMapSize * (1 - mStartPoints[i]))); - } - return colorIntervals; - } - - /** - * Generates the color map to use with a provided gradient. - * - * @param opacity Overall opacity of entire image: every individual alpha value will be - * multiplied by this opacity. - * @return the generated color map based on the gradient - */ - int[] generateColorMap(double opacity) { - HashMap colorIntervals = generateColorIntervals(); - int[] colorMap = new int[mColorMapSize]; - ColorInterval interval = colorIntervals.get(0); - int start = 0; - for (int i = 0; i < mColorMapSize; i++) { - if (colorIntervals.containsKey(i)) { - interval = colorIntervals.get(i); - start = i; - } - float ratio = (i - start) / interval.duration; - colorMap[i] = interpolateColor(interval.color1, interval.color2, ratio); - } - if (opacity != 1) { - for (int i = 0; i < mColorMapSize; i++) { - int c = colorMap[i]; - colorMap[i] = Color.argb((int) (Color.alpha(c) * opacity), - Color.red(c), Color.green(c), Color.blue(c)); - } - } - - return colorMap; - } - - /** - * Helper function for creation of color map - * Interpolates between two given colors using their HSV values. - * - * @param color1 First color - * @param color2 Second color - * @param ratio Between 0 to 1. Fraction of the distance between color1 and color2 - * @return Color associated with x2 - */ - static int interpolateColor(int color1, int color2, float ratio) { - - int alpha = (int) ((Color.alpha(color2) - Color.alpha(color1)) * ratio + Color.alpha(color1)); - - float[] hsv1 = new float[3]; - Color.RGBToHSV(Color.red(color1), Color.green(color1), Color.blue(color1), hsv1); - float[] hsv2 = new float[3]; - Color.RGBToHSV(Color.red(color2), Color.green(color2), Color.blue(color2), hsv2); - - // adjust so that the shortest path on the color wheel will be taken - if (hsv1[0] - hsv2[0] > 180) { - hsv2[0] += 360; - } else if (hsv2[0] - hsv1[0] > 180) { - hsv1[0] += 360; - } - - // Interpolate using calculated ratio - float[] result = new float[3]; - for (int i = 0; i < 3; i++) { - result[i] = (hsv2[i] - hsv1[i]) * (ratio) + hsv1[i]; - } - - return Color.HSVToColor(alpha, result); - } - -} diff --git a/library/src/main/java/com/google/maps/android/heatmaps/Gradient.kt b/library/src/main/java/com/google/maps/android/heatmaps/Gradient.kt new file mode 100644 index 000000000..0a5b0dd0a --- /dev/null +++ b/library/src/main/java/com/google/maps/android/heatmaps/Gradient.kt @@ -0,0 +1,154 @@ +/* + * Copyright 2025 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.heatmaps + +import android.graphics.Color +import java.util.HashMap + +/** + * A class to generate a color map from a given array of colors and the fractions + * that the colors represent by interpolating between their HSV values. + * This color map is to be used in the HeatmapTileProvider. + * + * @param colors The colors to be used in the gradient. + * @param startPoints The starting point for each color, given as a percentage of the maximum + * intensity. This is given as an array of floats with values in the interval [0, 1]. + * @param colorMapSize The size of the colorMap to be generated by the Gradient. + * Default value is 1000. + */ +class Gradient @JvmOverloads constructor( + val colors: IntArray, + val startPoints: FloatArray, + val colorMapSize: Int = DEFAULT_COLOR_MAP_SIZE +) { + private data class ColorInterval( + val color1: Int, + val color2: Int, + /** + * The period over which the color changes from color1 to color2. + * This is given as the number of elements it represents in the colorMap. + */ + val duration: Float + ) + + init { + require(colors.size == startPoints.size) { "colors and startPoints should be same length" } + require(colors.isNotEmpty()) { "No colors have been defined" } + for (i in 1 until startPoints.size) { + require(startPoints[i] > startPoints[i - 1]) { "startPoints should be in increasing order" } + } + } + + private fun generateColorIntervals(): HashMap { + val colorIntervals = HashMap() + // Create first color if not already created + // The initial color is transparent by default + if (startPoints[0] != 0f) { + val initialColor = Color.argb( + 0, Color.red(colors[0]), Color.green(colors[0]), Color.blue(colors[0]) + ) + colorIntervals[0] = ColorInterval(initialColor, colors[0], colorMapSize * startPoints[0]) + } + // Generate color intervals + for (i in 1 until colors.size) { + val start = (colorMapSize * startPoints[i - 1]).toInt() + val duration = colorMapSize * (startPoints[i] - startPoints[i - 1]) + colorIntervals[start] = ColorInterval(colors[i - 1], colors[i], duration) + } + // Extend to a final color + // If color for 100% intensity is not given, the color of highest intensity is used. + if (startPoints.last() != 1f) { + val i = startPoints.size - 1 + val start = (colorMapSize * startPoints[i]).toInt() + colorIntervals[start] = ColorInterval(colors[i], colors[i], colorMapSize * (1 - startPoints[i])) + } + return colorIntervals + } + + /** + * Generates a color map array from the gradient's colors and start points. This map is a key + * component for rendering the heatmap, where each color corresponds to a different intensity + * level. + * + * The process involves interpolating between the specified colors in the HSV color space to create + * a smooth transition. + * + * @param opacity The overall opacity of the entire color map. Each color's alpha value will be + * multiplied by this factor. The default value is [HeatmapTileProvider.DEFAULT_OPACITY]. + * @return An integer array representing the color map, where each element is a color integer. + */ + @JvmOverloads + fun generateColorMap(opacity: Double = HeatmapTileProvider.DEFAULT_OPACITY): IntArray { + val colorIntervals = generateColorIntervals() + val colorMap = IntArray(colorMapSize) + var interval = colorIntervals[0] + var start = 0 + for (i in 0 until colorMapSize) { + if (colorIntervals.containsKey(i)) { + interval = colorIntervals[i] + start = i + } + val ratio = (i - start) / interval!!.duration + colorMap[i] = interpolateColor(interval.color1, interval.color2, ratio) + } + if (opacity != 1.0) { + for (i in 0 until colorMapSize) { + val c = colorMap[i] + colorMap[i] = Color.argb( + (Color.alpha(c) * opacity).toInt(), + Color.red(c), Color.green(c), Color.blue(c) + ) + } + } + return colorMap + } + + companion object { + private const val DEFAULT_COLOR_MAP_SIZE = 1000 + + /** + * Helper function for creation of color map. + * Interpolates between two given colors using their HSV values. + * + * @param color1 First color + * @param color2 Second color + * @param ratio Between 0 to 1. Fraction of the distance between color1 and color2 + * @return Color associated with x2 + */ + @JvmStatic + fun interpolateColor(color1: Int, color2: Int, ratio: Float): Int { + val alpha = ((Color.alpha(color2) - Color.alpha(color1)) * ratio + Color.alpha(color1)).toInt() + val hsv1 = FloatArray(3) + Color.RGBToHSV(Color.red(color1), Color.green(color1), Color.blue(color1), hsv1) + val hsv2 = FloatArray(3) + Color.RGBToHSV(Color.red(color2), Color.green(color2), Color.blue(color2), hsv2) + + // adjust so that the shortest path on the color wheel will be taken + if (hsv1[0] - hsv2[0] > 180) { + hsv2[0] += 360f + } else if (hsv2[0] - hsv1[0] > 180) { + hsv1[0] += 360f + } + + // Interpolate using calculated ratio + val result = FloatArray(3) + for (i in 0..2) { + result[i] = (hsv2[i] - hsv1[i]) * ratio + hsv1[i] + } + return Color.HSVToColor(alpha, result) + } + } +} \ No newline at end of file diff --git a/library/src/main/java/com/google/maps/android/heatmaps/HeatmapTileProvider.java b/library/src/main/java/com/google/maps/android/heatmaps/HeatmapTileProvider.java deleted file mode 100644 index 396a1900c..000000000 --- a/library/src/main/java/com/google/maps/android/heatmaps/HeatmapTileProvider.java +++ /dev/null @@ -1,814 +0,0 @@ -/* - * Copyright 2014 Google Inc. - * - * 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.heatmaps; - -import android.graphics.Bitmap; -import android.graphics.Color; - -import androidx.collection.LongSparseArray; - -import com.google.android.gms.maps.model.LatLng; -import com.google.android.gms.maps.model.Tile; -import com.google.android.gms.maps.model.TileProvider; -import com.google.maps.android.geometry.Bounds; -import com.google.maps.android.geometry.Point; -import com.google.maps.android.quadtree.PointQuadTree; - -import java.io.ByteArrayOutputStream; -import java.util.ArrayList; -import java.util.Collection; -import java.util.Iterator; - -/** - * Tile provider that creates heatmap tiles. - */ -public class HeatmapTileProvider implements TileProvider { - - /** - * Default radius for convolution - */ - public static final int DEFAULT_RADIUS = 20; - - /** - * Default opacity of heatmap overlay - */ - public static final double DEFAULT_OPACITY = 0.7; - - /** - * Colors for default gradient. - * Array of colors, represented by ints. - */ - private static final int[] DEFAULT_GRADIENT_COLORS = { - Color.rgb(102, 225, 0), - Color.rgb(255, 0, 0) - }; - - /** - * Starting fractions for default gradient. - * This defines which percentages the above colors represent. - * These should be a sorted array of floats in the interval [0, 1]. - */ - private static final float[] DEFAULT_GRADIENT_START_POINTS = { - 0.2f, 1f - }; - - /** - * Default gradient for heatmap. - */ - public static final Gradient DEFAULT_GRADIENT = new Gradient(DEFAULT_GRADIENT_COLORS, DEFAULT_GRADIENT_START_POINTS); - - /** - * Size of the world (arbitrary). - * Used to measure distances relative to the total world size. - * Package access for WeightedLatLng. - */ - static final double WORLD_WIDTH = 1; - - /** - * Tile dimension, in pixels. - */ - private static final int TILE_DIM = 512; - - /** - * Assumed screen size (pixels) - */ - private static final int SCREEN_SIZE = 1280; - - /** - * Default (and minimum possible) minimum zoom level at which to calculate maximum intensities - */ - private static final int DEFAULT_MIN_ZOOM = 5; - - /** - * Default (and maximum possible) maximum zoom level at which to calculate maximum intensities - */ - private static final int DEFAULT_MAX_ZOOM = 11; - - /** - * Maximum zoom level possible on a map. - */ - private static final int MAX_ZOOM_LEVEL = 22; - - /** - * Minimum radius value. - */ - private static final int MIN_RADIUS = 10; - - /** - * Maximum radius value. - */ - private static final int MAX_RADIUS = 50; - - /** - * Quad tree of all the points to display in the heatmap - */ - private PointQuadTree mTree; - - /** - * Collection of all the data. - */ - private Collection mData; - - /** - * Bounds of the quad tree - */ - private Bounds mBounds; - - /** - * Heatmap point radius. - */ - private int mRadius; - - /** - * Gradient of the color map - */ - private Gradient mGradient; - - /** - * Color map to use to color tiles - */ - private int[] mColorMap; - - /** - * Kernel to use for convolution - */ - private double[] mKernel; - - /** - * Opacity of the overall heatmap overlay [0...1] - */ - private double mOpacity; - - /** - * Maximum intensity estimates for heatmap - */ - private double[] mMaxIntensity; - - /** - * Optional user defined maximum intensity for heatmap - */ - private double mCustomMaxIntensity; - - /** - * Builder class for the HeatmapTileProvider. - */ - public static class Builder { - // Required parameters - not final, as there are 2 ways to set it - private Collection data; - - // Optional, initialised to default values - private int radius = DEFAULT_RADIUS; - private Gradient gradient = DEFAULT_GRADIENT; - private double opacity = DEFAULT_OPACITY; - private double intensity = 0; - - /** - * Constructor for builder. - * No required parameters here, but user must call either data() or weightedData(). - */ - public Builder() { - } - - /** - * Setter for data in builder. Must call this or weightedData - * - * @param val Collection of LatLngs to put into quadtree. - * Should be non-empty. - * @return updated builder object - */ - public Builder data(Collection val) { - return weightedData(wrapData(val)); - } - - /** - * Setter for data in builder. Must call this or data - * - * @param val Collection of WeightedLatLngs to put into quadtree. - * Should be non-empty. - * @return updated builder object - */ - public Builder weightedData(Collection val) { - this.data = val; - - // Check that points is non empty - if (this.data.isEmpty()) { - throw new IllegalArgumentException("No input points."); - } - return this; - } - - /** - * Setter for radius in builder - * - * @param val Radius of convolution to use, in terms of pixels. - * Must be within minimum and maximum values of 10 to 50 inclusive. - * @return updated builder object - */ - public Builder radius(int val) { - radius = val; - // Check that radius is within bounds. - if (radius < MIN_RADIUS || radius > MAX_RADIUS) { - throw new IllegalArgumentException("Radius not within bounds."); - } - return this; - } - - /** - * Setter for gradient in builder - * - * @param val Gradient to color heatmap with. - * @return updated builder object - */ - public Builder gradient(Gradient val) { - gradient = val; - return this; - } - - /** - * Setter for opacity in builder - * - * @param val Opacity of the entire heatmap in range [0, 1] - * @return updated builder object - */ - public Builder opacity(double val) { - opacity = val; - // Check that opacity is in range - if (opacity < 0 || opacity > 1) { - throw new IllegalArgumentException("Opacity must be in range [0, 1]"); - } - return this; - } - - /** - * Setter for Max Intensity in builder - * - * @param val maximum intensity of pixel density - * @return updated builder object - */ - public Builder maxIntensity(double val) { - intensity = val; - return this; - } - - /** - * Call when all desired options have been set. - * Note: you must set data using data or weightedData before this! - * - * @return HeatmapTileProvider created with desired options. - */ - public HeatmapTileProvider build() { - // Check if data or weightedData has been called - if (data == null) { - throw new IllegalStateException("No input data: you must use either .data or " + - ".weightedData before building"); - } - - return new HeatmapTileProvider(this); - } - } - - private HeatmapTileProvider(Builder builder) { - // Get parameters from builder - mData = builder.data; - - mRadius = builder.radius; - mGradient = builder.gradient; - mOpacity = builder.opacity; - mCustomMaxIntensity = builder.intensity; - - // Compute kernel density function (sd = 1/3rd of radius) - mKernel = generateKernel(mRadius, mRadius / 3.0); - - // Generate color map - setGradient(mGradient); - - // Set the data - setWeightedData(mData); - } - - /** - * Changes the dataset the heatmap is portraying. Weighted. - * User should clear overlay's tile cache (using clearTileCache()) after calling this. - * - * @param data Data set of points to use in the heatmap, as LatLngs. - * Note: Editing data without calling setWeightedData again will not update the data - * displayed on the map, but will impact calculation of max intensity values, - * as the collection you pass in is stored. - * Outside of changing the data, max intensity values are calculated only upon - * changing the radius. - */ - public void setWeightedData(Collection data) { - // Change point set - mData = data; - - // Check point set is OK - if (mData.isEmpty()) { - throw new IllegalArgumentException("No input points."); - } - - // Because quadtree bounds are final once the quadtree is created, we cannot add - // points outside of those bounds to the quadtree after creation. - // As quadtree creation is actually quite lightweight/fast as compared to other functions - // called in heatmap creation, re-creating the quadtree is an acceptable solution here. - - // Make the quad tree - mBounds = getBounds(mData); - - mTree = new PointQuadTree(mBounds); - - // Add points to quad tree - for (WeightedLatLng l : mData) { - mTree.add(l); - } - - // Calculate reasonable maximum intensity for color scale (user can also specify) - // Get max intensities - mMaxIntensity = getMaxIntensities(mRadius); - } - - /** - * Changes the dataset the heatmap is portraying. Unweighted. - * User should clear overlay's tile cache (using clearTileCache()) after calling this. - * - * @param data Data set of points to use in the heatmap, as LatLngs. - */ - public void setData(Collection data) { - // Turn them into WeightedLatLngs and delegate. - setWeightedData(wrapData(data)); - } - - /** - * Helper function - wraps LatLngs into WeightedLatLngs. - * - * @param data Data to wrap (LatLng) - * @return Data, in WeightedLatLng form - */ - private static Collection wrapData(Collection data) { - // Use an ArrayList as it is a nice collection - ArrayList weightedData = new ArrayList(); - - for (LatLng l : data) { - weightedData.add(new WeightedLatLng(l)); - } - - return weightedData; - } - - /** - * Creates tile. - * - * @param x X coordinate of tile. - * @param y Y coordinate of tile. - * @param zoom Zoom level. - * @return image in Tile format - */ - public Tile getTile(int x, int y, int zoom) { - // Convert tile coordinates and zoom into Point/Bounds format - // Know that at zoom level 0, there is one tile: (0, 0) (arbitrary width 512) - // Each zoom level multiplies number of tiles by 2 - // Width of the world = WORLD_WIDTH = 1 - // x = [0, 1) corresponds to [-180, 180) - - // calculate width of one tile, given there are 2 ^ zoom tiles in that zoom level - // In terms of world width units - double tileWidth = WORLD_WIDTH / Math.pow(2, zoom); - - // how much padding to include in search - // is to tileWidth as mRadius (padding in terms of pixels) is to TILE_DIM - // In terms of world width units - double padding = tileWidth * mRadius / TILE_DIM; - - // padded tile width - // In terms of world width units - double tileWidthPadded = tileWidth + 2 * padding; - - // padded bucket width - divided by number of buckets - // In terms of world width units - double bucketWidth = tileWidthPadded / (TILE_DIM + mRadius * 2); - - // Make bounds: minX, maxX, minY, maxY - double minX = x * tileWidth - padding; - double maxX = (x + 1) * tileWidth + padding; - double minY = y * tileWidth - padding; - double maxY = (y + 1) * tileWidth + padding; - - // Deal with overlap across lat = 180 - // Need to make it wrap around both ways - // However, maximum tile size is such that you wont ever have to deal with both, so - // hence, the else - // Note: Tile must remain square, so cant optimise by editing bounds - double xOffset = 0; - Collection wrappedPoints = new ArrayList(); - if (minX < 0) { - // Need to consider "negative" points - // (minX to 0) -> (512+minX to 512) ie +512 - // add 512 to search bounds and subtract 512 from actual points - Bounds overlapBounds = new Bounds(minX + WORLD_WIDTH, WORLD_WIDTH, minY, maxY); - xOffset = -WORLD_WIDTH; - wrappedPoints = mTree.search(overlapBounds); - } else if (maxX > WORLD_WIDTH) { - // Cant both be true as then tile covers whole world - // Need to consider "overflow" points - // (512 to maxX) -> (0 to maxX-512) ie -512 - // subtract 512 from search bounds and add 512 to actual points - Bounds overlapBounds = new Bounds(0, maxX - WORLD_WIDTH, minY, maxY); - xOffset = WORLD_WIDTH; - wrappedPoints = mTree.search(overlapBounds); - } - - // Main tile bounds to search - Bounds tileBounds = new Bounds(minX, maxX, minY, maxY); - - // If outside of *padded* quadtree bounds, return blank tile - // This is comparing our bounds to the padded bounds of all points in the quadtree - // ie tiles that don't touch the heatmap at all - Bounds paddedBounds = new Bounds(mBounds.minX - padding, mBounds.maxX + padding, - mBounds.minY - padding, mBounds.maxY + padding); - if (!tileBounds.intersects(paddedBounds)) { - return TileProvider.NO_TILE; - } - - // Search for all points within tile bounds - Collection points = mTree.search(tileBounds); - - // If no points, return blank tile - if (points.isEmpty()) { - return TileProvider.NO_TILE; - } - - // Quantize points - double[][] intensity = new double[TILE_DIM + mRadius * 2][TILE_DIM + mRadius * 2]; - for (WeightedLatLng w : points) { - Point p = w.getPoint(); - int bucketX = (int) ((p.x - minX) / bucketWidth); - int bucketY = (int) ((p.y - minY) / bucketWidth); - intensity[bucketX][bucketY] += w.getIntensity(); - } - // Quantize wraparound points (taking xOffset into account) - for (WeightedLatLng w : wrappedPoints) { - Point p = w.getPoint(); - int bucketX = (int) ((p.x + xOffset - minX) / bucketWidth); - int bucketY = (int) ((p.y - minY) / bucketWidth); - intensity[bucketX][bucketY] += w.getIntensity(); - } - - // Convolve it ("smoothen" it out) - double[][] convolved = convolve(intensity, mKernel); - - // Color it into a bitmap - Bitmap bitmap = colorize(convolved, mColorMap, mMaxIntensity[zoom]); - - // Convert bitmap to tile and return - return convertBitmap(bitmap); - } - - /** - * Setter for gradient/color map. - * User should clear overlay's tile cache (using clearTileCache()) after calling this. - * - * @param gradient Gradient to set - */ - public void setGradient(Gradient gradient) { - mGradient = gradient; - mColorMap = gradient.generateColorMap(mOpacity); - } - - /** - * Setter for radius. - * User should clear overlay's tile cache (using clearTileCache()) after calling this. - * - * @param radius Radius to set - */ - public void setRadius(int radius) { - mRadius = radius; - // need to recompute kernel - mKernel = generateKernel(mRadius, mRadius / 3.0); - // need to recalculate max intensity - mMaxIntensity = getMaxIntensities(mRadius); - } - - /** - * Setter for opacity - * User should clear overlay's tile cache (using clearTileCache()) after calling this. - * - * @param opacity opacity to set - */ - public void setOpacity(double opacity) { - mOpacity = opacity; - // need to recompute kernel color map - setGradient(mGradient); - } - - /** - * Setter for max intensity - * User should clear overlay's tile cache (using clearTileCache()) after calling this. - * - * @param intensity intensity to set - */ - public void setMaxIntensity(double intensity) { - mCustomMaxIntensity = intensity; - // need to recompute data convolution - setWeightedData(mData); - } - - /** - * Gets array of maximum intensity values to use with the heatmap for each zoom level - * This is the value that the highest color on the color map corresponds to - * - * @param radius radius of the heatmap - * @return array of maximum intensities - */ - private double[] getMaxIntensities(int radius) { - // Can go from zoom level 3 to zoom level 22 - double[] maxIntensityArray = new double[MAX_ZOOM_LEVEL]; - - // A custom max intensity has been specified by user - // Set all zoom levels with intensity value - if(mCustomMaxIntensity != 0.0) { - for(int i = 0; i < MAX_ZOOM_LEVEL; i++) { - maxIntensityArray[i] = mCustomMaxIntensity; - } - - return maxIntensityArray; - } - - // Calculate max intensity for each zoom level - for (int i = DEFAULT_MIN_ZOOM; i < DEFAULT_MAX_ZOOM; i++) { - // Each zoom level multiplies viewable size by 2 - maxIntensityArray[i] = getMaxValue(mData, mBounds, radius, - (int) (SCREEN_SIZE * Math.pow(2, i - 3))); - if (i == DEFAULT_MIN_ZOOM) { - for (int j = 0; j < i; j++) maxIntensityArray[j] = maxIntensityArray[i]; - } - } - for (int i = DEFAULT_MAX_ZOOM; i < MAX_ZOOM_LEVEL; i++) { - maxIntensityArray[i] = maxIntensityArray[DEFAULT_MAX_ZOOM - 1]; - } - - return maxIntensityArray; - } - - /** - * helper function - convert a bitmap into a tile - * - * @param bitmap bitmap to convert into a tile - * @return the tile - */ - private static Tile convertBitmap(Bitmap bitmap) { - // Convert it into byte array (required for tile creation) - ByteArrayOutputStream stream = new ByteArrayOutputStream(); - bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream); - byte[] bitmapdata = stream.toByteArray(); - return new Tile(TILE_DIM, TILE_DIM, bitmapdata); - } - - /* Utility functions below */ - - /** - * Helper function for quadtree creation - * - * @param points Collection of WeightedLatLng to calculate bounds for - * @return Bounds that enclose the listed WeightedLatLng points - */ - static Bounds getBounds(Collection points) { - - // Use an iterator, need to access any one point of the collection for starting bounds - Iterator iter = points.iterator(); - - WeightedLatLng first = iter.next(); - - double minX = first.getPoint().x; - double maxX = first.getPoint().x; - double minY = first.getPoint().y; - double maxY = first.getPoint().y; - - while (iter.hasNext()) { - WeightedLatLng l = iter.next(); - double x = l.getPoint().x; - double y = l.getPoint().y; - // Extend bounds if necessary - if (x < minX) minX = x; - if (x > maxX) maxX = x; - if (y < minY) minY = y; - if (y > maxY) maxY = y; - } - - return new Bounds(minX, maxX, minY, maxY); - } - - /** - * Generates 1D Gaussian kernel density function, as a double array of size radius * 2 + 1 - * Normalised with central value of 1. - * - * @param radius radius of the kernel - * @param sd standard deviation of the Gaussian function - * @return generated Gaussian kernel - */ - static double[] generateKernel(int radius, double sd) { - double[] kernel = new double[radius * 2 + 1]; - for (int i = -radius; i <= radius; i++) { - kernel[i + radius] = (Math.exp(-i * i / (2 * sd * sd))); - } - return kernel; - } - - /** - * Applies a 2D Gaussian convolution to the input grid, returning a 2D grid cropped of padding. - * - * @param grid Raw input grid to convolve: dimension (dim + 2 * radius) x (dim + 2 * radius) - * ie dim * dim with padding of size radius - * @param kernel Pre-computed Gaussian kernel of size radius * 2 + 1 - * @return the smoothened grid - */ - static double[][] convolve(double[][] grid, double[] kernel) { - // Calculate radius size - int radius = (int) Math.floor((double) kernel.length / 2.0); - // Padded dimension - int dimOld = grid.length; - // Calculate final (non padded) dimension - int dim = dimOld - 2 * radius; - - // Upper and lower limits of non padded (inclusive) - int lowerLimit = radius; - int upperLimit = radius + dim - 1; - - // Convolve horizontally - double[][] intermediate = new double[dimOld][dimOld]; - - // Need to convolve every point (including those outside of non-padded area) - // but only need to add to points within non-padded area - int x, y, x2, xUpperLimit, initial; - double val; - for (x = 0; x < dimOld; x++) { - for (y = 0; y < dimOld; y++) { - // for each point (x, y) - val = grid[x][y]; - // only bother if something there - if (val != 0) { - // need to "apply" convolution from that point to every point in - // (max(lowerLimit, x - radius), y) to (min(upperLimit, x + radius), y) - xUpperLimit = ((upperLimit < x + radius) ? upperLimit : x + radius) + 1; - // Replace Math.max - initial = (lowerLimit > x - radius) ? lowerLimit : x - radius; - for (x2 = initial; x2 < xUpperLimit; x2++) { - // multiplier for x2 = x - radius is kernel[0] - // x2 = x + radius is kernel[radius * 2] - // so multiplier for x2 in general is kernel[x2 - (x - radius)] - intermediate[x2][y] += val * kernel[x2 - (x - radius)]; - } - } - } - } - - // Convolve vertically - double[][] outputGrid = new double[dim][dim]; - - // Similarly, need to convolve every point, but only add to points within non-padded area - // However, we are adding to a smaller grid here (previously, was to a grid of same size) - int y2, yUpperLimit; - - // Don't care about convolving parts in horizontal padding - wont impact inner - for (x = lowerLimit; x < upperLimit + 1; x++) { - for (y = 0; y < dimOld; y++) { - // for each point (x, y) - val = intermediate[x][y]; - // only bother if something there - if (val != 0) { - // need to "apply" convolution from that point to every point in - // (x, max(lowerLimit, y - radius) to (x, min(upperLimit, y + radius)) - // Don't care about - yUpperLimit = ((upperLimit < y + radius) ? upperLimit : y + radius) + 1; - // replace math.max - initial = (lowerLimit > y - radius) ? lowerLimit : y - radius; - for (y2 = initial; y2 < yUpperLimit; y2++) { - // Similar logic to above - // subtract, as adding to a smaller grid - outputGrid[x - radius][y2 - radius] += val * kernel[y2 - (y - radius)]; - } - } - } - } - - return outputGrid; - } - - /** - * Converts a grid of intensity values to a colored Bitmap, using a given color map - * - * @param grid the input grid (assumed to be square) - * @param colorMap color map (created by generateColorMap) - * @param max Maximum intensity value: maps to 100% on gradient - * @return the colorized grid in Bitmap form, with same dimensions as grid - */ - static Bitmap colorize(double[][] grid, int[] colorMap, double max) { - // Maximum color value - int maxColor = colorMap[colorMap.length - 1]; - // Multiplier to "scale" intensity values with, to map to appropriate color - double colorMapScaling = (colorMap.length - 1) / max; - // Dimension of the input grid (and dimension of output bitmap) - int dim = grid.length; - - int i, j, index, col; - double val; - // Array of colors - int colors[] = new int[dim * dim]; - for (i = 0; i < dim; i++) { - for (j = 0; j < dim; j++) { - // [x][y] - // need to enter each row of x coordinates sequentially (x first) - // -> [j][i] - val = grid[j][i]; - index = i * dim + j; - col = (int) (val * colorMapScaling); - - if (val != 0) { - // Make it more resilient: cant go outside colorMap - if (col < colorMap.length) colors[index] = colorMap[col]; - else colors[index] = maxColor; - } else { - colors[index] = Color.TRANSPARENT; - } - } - } - - // Now turn these colors into a bitmap - Bitmap tile = Bitmap.createBitmap(dim, dim, Bitmap.Config.ARGB_8888); - // (int[] pixels, int offset, int stride, int x, int y, int width, int height) - tile.setPixels(colors, 0, dim, 0, 0, dim, dim); - return tile; - } - - /** - * Calculate a reasonable maximum intensity value to map to maximum color intensity - * - * @param points Collection of LatLngs to put into buckets - * @param bounds Bucket boundaries - * @param radius radius of convolution - * @param screenDim larger dimension of screen in pixels (for scale) - * @return Approximate max value - */ - static double getMaxValue(Collection points, Bounds bounds, int radius, - int screenDim) { - // Approximate scale as if entire heatmap is on the screen - // ie scale dimensions to larger of width or height (screenDim) - double minX = bounds.minX; - double maxX = bounds.maxX; - double minY = bounds.minY; - double maxY = bounds.maxY; - double boundsDim = (maxX - minX > maxY - minY) ? maxX - minX : maxY - minY; - - // Number of buckets: have diameter sized buckets - int nBuckets = (int) (screenDim / (2 * radius) + 0.5); - // Scaling factor to convert width in terms of point distance, to which bucket - double scale = nBuckets / boundsDim; - - // Make buckets - // Use a sparse array - use LongSparseArray just in case - LongSparseArray> buckets = new LongSparseArray>(); - //double[][] buckets = new double[nBuckets][nBuckets]; - - // Assign into buckets + find max value as we go along - double x, y; - double max = 0; - for (WeightedLatLng l : points) { - x = l.getPoint().x; - y = l.getPoint().y; - - int xBucket = (int) ((x - minX) * scale); - int yBucket = (int) ((y - minY) * scale); - - // Check if x bucket exists, if not make it - LongSparseArray column = buckets.get(xBucket); - if (column == null) { - column = new LongSparseArray(); - buckets.put(xBucket, column); - } - // Check if there is already a y value there - Double value = column.get(yBucket); - if (value == null) { - value = 0.0; - } - value += l.getIntensity(); - // Yes, do need to update it, despite it being a Double. - column.put(yBucket, value); - - if (value > max) max = value; - } - - return max; - } -} diff --git a/library/src/main/java/com/google/maps/android/heatmaps/HeatmapTileProvider.kt b/library/src/main/java/com/google/maps/android/heatmaps/HeatmapTileProvider.kt new file mode 100644 index 000000000..ba172423e --- /dev/null +++ b/library/src/main/java/com/google/maps/android/heatmaps/HeatmapTileProvider.kt @@ -0,0 +1,426 @@ +/* + * Copyright 2025 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.heatmaps + +import android.graphics.Bitmap +import android.graphics.Color +import com.google.android.gms.maps.model.LatLng +import com.google.android.gms.maps.model.Tile +import com.google.android.gms.maps.model.TileProvider +import com.google.maps.android.geometry.Bounds +import com.google.maps.android.quadtree.PointQuadTree +import java.io.ByteArrayOutputStream +import androidx.core.graphics.createBitmap +import kotlin.math.exp +import kotlin.math.floor +import kotlin.math.pow + +/** + * Tile provider that creates heatmap tiles. + */ +class HeatmapTileProvider private constructor(builder: Builder) : TileProvider { + + private var data: Collection + private var radius: Int + private var gradient: Gradient + private var opacity: Double + private var customMaxIntensity: Double + + private lateinit var tree: PointQuadTree + private lateinit var bounds: Bounds + private lateinit var colorMap: IntArray + private var kernel: DoubleArray + private lateinit var maxIntensity: DoubleArray + + init { + data = builder.weightedData!! + radius = builder.radius + gradient = builder.gradient + opacity = builder.opacity + customMaxIntensity = builder.intensity + + // Don't compute anything till data is set + kernel = generateKernel(radius, radius / 3.0) + setGradient(gradient) + updateData(data) + } + + /** + * Builder class for the HeatmapTileProvider. + */ + class Builder { + internal var weightedData: Collection? = null + internal var radius = DEFAULT_RADIUS + internal var gradient = DEFAULT_GRADIENT + internal var opacity = DEFAULT_OPACITY + internal var intensity = 0.0 + + /** + * Specifies the dataset to use for the heatmap, accepting unweighted LatLngs. + * + * @param latLngs A collection of LatLngs. + * @return This builder. + */ + fun data(latLngs: Collection): Builder = apply { + this.weightedData(wrapData(latLngs)) + require(this.weightedData?.isNotEmpty() == true) { "No input points." } + } + + /** + * Specifies the dataset to use for the heatmap, accepting WeightedLatLngs. + * + * @param weightedData A collection of WeightedLatLngs. + * @return This builder. + */ + fun weightedData(weightedData: Collection): Builder = apply { + this.weightedData = weightedData + require(this.weightedData?.isNotEmpty() == true) { "No input points." } + } + + /** + * Specifies the radius of the heatmap blur, in pixels. + * + * @param radius The radius. Must be between 10 and 50, inclusive. + * @return This builder. + */ + fun radius(radius: Int): Builder = apply { + this.radius = radius + require(this.radius in MIN_RADIUS..MAX_RADIUS) { "Radius not within bounds." } + } + + /** + * Specifies the color gradient of the heatmap. + * + * @param gradient The gradient to use. + * @return This builder. + */ + fun gradient(gradient: Gradient): Builder = apply { + this.gradient = gradient + } + + /** + * Specifies the opacity of the heatmap layer. + * + * @param opacity The opacity. Must be between 0 and 1, inclusive. + * @return This builder. + */ + fun opacity(opacity: Double): Builder = apply { + this.opacity = opacity + require(this.opacity in 0.0..1.0) { "Opacity must be in range [0, 1]" } + } + + /** + * Specifies a custom maximum intensity value for the heatmap. + * + * @param intensity The maximum intensity. + * @return This builder. + */ + fun maxIntensity(intensity: Double): Builder = apply { + this.intensity = intensity + } + + /** + * Creates a new HeatmapTileProvider instance from the builder's properties. + * + * @return A new HeatmapTileProvider. + */ + fun build(): HeatmapTileProvider { + check(this.weightedData?.isNotEmpty() == true) { "No input data: you must use either .data or .weightedData before building." } + return HeatmapTileProvider(this) + } + } + + @Deprecated("Use updateData(Collection) instead.", ReplaceWith("updateData(data)")) + fun setWeightedData(data: Collection) { + updateData(data) + } + + /** + * Refreshes the heatmap with a new collection of weighted data points. + * + * This is an expensive operation. It involves rebuilding the quadtree index and recalculating + * the bounds and maximum intensity values for the new dataset. This method should be used when + * the underlying data for the heatmap has changed. + * + * @param data The new collection of [WeightedLatLng] points. + */ + fun updateData(data: Collection) { + this.data = data + require(this.data.isNotEmpty()) { "No input points." } + this.bounds = getBounds(this.data) + this.tree = PointQuadTree(this.bounds) + for (l in this.data) { + this.tree.add(l) + } + this.maxIntensity = getMaxIntensities(this.radius) + } + + @Deprecated("Use updateLatLngs(Collection) instead.", ReplaceWith("updateLatLngs(latLngs)")) + fun setData(latLngs: Collection) { + updateLatLngs(latLngs) + } + + /** + * Refreshes the heatmap with a new collection of unweighted data points. + * Each point is assigned a default weight of 1.0. + * + * This is a convenience method that wraps the data in [WeightedLatLng] objects before + * calling [updateData]. + * + * @param latLngs The new collection of [LatLng] points. + */ + fun updateLatLngs(latLngs: Collection) { + updateData(wrapData(latLngs)) + } + + fun setGradient(gradient: Gradient) { + this.gradient = gradient + this.colorMap = gradient.generateColorMap(this.opacity) + } + + fun setRadius(radius: Int) { + this.radius = radius + this.kernel = generateKernel(this.radius, this.radius / 3.0) + this.maxIntensity = getMaxIntensities(this.radius) + } + + fun setOpacity(opacity: Double) { + this.opacity = opacity + setGradient(this.gradient) + } + + fun setMaxIntensity(intensity: Double) { + this.customMaxIntensity = intensity + updateData(this.data) + } + + override fun getTile(x: Int, y: Int, zoom: Int): Tile { + val tileWidth = WORLD_WIDTH / 2.0.pow(zoom.toDouble()) + val padding = tileWidth * radius / TILE_DIM + val tileWidthPadded = tileWidth + 2 * padding + val bucketWidth = tileWidthPadded / (TILE_DIM + radius * 2) + val minX = x * tileWidth - padding + val maxX = (x + 1) * tileWidth + padding + val minY = y * tileWidth - padding + val maxY = (y + 1) * tileWidth + padding + + var xOffset = 0.0 + var wrappedPoints: Collection = emptyList() + + if (minX < 0) { + val overlapBounds = Bounds(minX + WORLD_WIDTH, WORLD_WIDTH, minY, maxY) + xOffset = -WORLD_WIDTH + wrappedPoints = tree.search(overlapBounds) + } else if (maxX > WORLD_WIDTH) { + val overlapBounds = Bounds(0.0, maxX - WORLD_WIDTH, minY, maxY) + xOffset = WORLD_WIDTH + wrappedPoints = tree.search(overlapBounds) + } + + val tileBounds = Bounds(minX, maxX, minY, maxY) + val paddedBounds = Bounds( + bounds.minX - padding, bounds.maxX + padding, + bounds.minY - padding, bounds.maxY + padding + ) + if (!tileBounds.intersects(paddedBounds)) { + return TileProvider.NO_TILE + } + + val points = tree.search(tileBounds) + if (points.isEmpty()) { + return TileProvider.NO_TILE + } + + val intensity = Array(TILE_DIM + radius * 2) { DoubleArray(TILE_DIM + radius * 2) } + points.forEach { w -> + val p = w.point + val bucketX = ((p.x - minX) / bucketWidth).toInt() + val bucketY = ((p.y - minY) / bucketWidth).toInt() + intensity[bucketX][bucketY] += w.intensity + } + wrappedPoints.forEach { w -> + val p = w.point + val bucketX = ((p.x + xOffset - minX) / bucketWidth).toInt() + val bucketY = ((p.y - minY) / bucketWidth).toInt() + intensity[bucketX][bucketY] += w.intensity + } + + val convolved = convolve(intensity, kernel) + val bitmap = colorize(convolved, colorMap, maxIntensity[zoom]) + return convertBitmap(bitmap) + } + + private fun getMaxIntensities(radius: Int): DoubleArray { + val maxIntensityArray = DoubleArray(MAX_ZOOM_LEVEL) + if (customMaxIntensity != 0.0) { + for (i in 0 until MAX_ZOOM_LEVEL) { + maxIntensityArray[i] = customMaxIntensity + } + return maxIntensityArray + } + for (i in DEFAULT_MIN_ZOOM until DEFAULT_MAX_ZOOM) { + maxIntensityArray[i] = getMaxValue(data, bounds, radius, (SCREEN_SIZE * Math.pow(2.0, (i - 3).toDouble())).toInt()) + if (i == DEFAULT_MIN_ZOOM) { + for (j in 0 until i) maxIntensityArray[j] = maxIntensityArray[i] + } + } + for (i in DEFAULT_MAX_ZOOM until MAX_ZOOM_LEVEL) { + maxIntensityArray[i] = maxIntensityArray[DEFAULT_MAX_ZOOM - 1] + } + return maxIntensityArray + } + + companion object { + const val DEFAULT_RADIUS = 20 + const val DEFAULT_OPACITY = 0.7 + private val DEFAULT_GRADIENT_COLORS = intArrayOf(Color.rgb(102, 225, 0), Color.rgb(255, 0, 0)) + private val DEFAULT_GRADIENT_START_POINTS = floatArrayOf(0.2f, 1f) + @JvmField + val DEFAULT_GRADIENT = Gradient(DEFAULT_GRADIENT_COLORS, DEFAULT_GRADIENT_START_POINTS) + internal const val WORLD_WIDTH = 1.0 + private const val TILE_DIM = 512 + private const val SCREEN_SIZE = 1280 + private const val DEFAULT_MIN_ZOOM = 5 + private const val DEFAULT_MAX_ZOOM = 11 + private const val MAX_ZOOM_LEVEL = 22 + const val MIN_RADIUS = 10 + const val MAX_RADIUS = 50 + + private data class Vector(val x: Int, val y: Int) + + private fun wrapData(data: Collection): Collection = data.map { WeightedLatLng(it) } + + private fun convertBitmap(bitmap: Bitmap): Tile { + val stream = ByteArrayOutputStream() + bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream) + val bitmapData = stream.toByteArray() + return Tile(TILE_DIM, TILE_DIM, bitmapData) + } + + @JvmStatic + fun getBounds(points: Collection): Bounds { + val firstPoint = points.first().point + var minX = firstPoint.x + var maxX = firstPoint.x + var minY = firstPoint.y + var maxY = firstPoint.y + + points.drop(1).forEach { + val x = it.point.x + val y = it.point.y + if (x < minX) minX = x + if (x > maxX) maxX = x + if (y < minY) minY = y + if (y > maxY) maxY = y + } + return Bounds(minX, maxX, minY, maxY) + } + + @JvmStatic + fun generateKernel(radius: Int, sd: Double): DoubleArray { + val kernel = DoubleArray(radius * 2 + 1) + for (i in -radius..radius) { + kernel[i + radius] = exp(-i * i / (2 * sd * sd)) + } + return kernel + } + + @JvmStatic + fun convolve(grid: Array, kernel: DoubleArray): Array { + val radius = floor(kernel.size / 2.0).toInt() + val dimOld = grid.size + val dim = dimOld - 2 * radius + val lowerLimit = radius + val upperLimit = radius + dim - 1 + val intermediate = Array(dimOld) { DoubleArray(dimOld) } + + for (x in 0 until dimOld) { + for (y in 0 until dimOld) { + val value = grid[x][y] + if (value != 0.0) { + val xUpperLimit = (x + radius).coerceAtMost(upperLimit) + for (x2 in (x - radius).coerceAtLeast(lowerLimit)..xUpperLimit) { + intermediate[x2][y] += value * kernel[x2 - (x - radius)] + } + } + } + } + + val outputGrid = Array(dim) { DoubleArray(dim) } + for (x in lowerLimit..upperLimit) { + for (y in 0 until dimOld) { + val value = intermediate[x][y] + if (value != 0.0) { + val yUpperLimit = (y + radius).coerceAtMost(upperLimit) + for (y2 in (y - radius).coerceAtLeast(lowerLimit)..yUpperLimit) { + outputGrid[x - radius][y2 - radius] += value * kernel[y2 - (y - radius)] + } + } + } + } + return outputGrid + } + + internal fun colorize(grid: Array, colorMap: IntArray, max: Double): Bitmap { + val maxColor = colorMap.last() + val colorMapScaling = (colorMap.size - 1) / max + val dim = grid.size + val colors = IntArray(dim * dim) + for (i in 0 until dim) { + for (j in 0 until dim) { + val value = grid[j][i] + val index = i * dim + j + val col = (value * colorMapScaling).toInt() + colors[index] = if (value != 0.0) { + if (col < colorMap.size) colorMap[col] else maxColor + } else { + Color.TRANSPARENT + } + } + } + val tile = createBitmap(dim, dim) + tile.setPixels(colors, 0, dim, 0, 0, dim, dim) + return tile + } + + internal fun getMaxValue( + points: Collection, + bounds: Bounds, + radius: Int, + screenDim: Int + ): Double { + val minX = bounds.minX + val maxX = bounds.maxX + val minY = bounds.minY + val maxY = bounds.maxY + val boundsDim = (maxX - minX).coerceAtLeast(maxY - minY) + val nBuckets = (screenDim / (2 * radius) + 0.5).toInt() + val scale = nBuckets / boundsDim + val buckets = mutableMapOf() + + points.forEach { l -> + val x = l.point.x + val y = l.point.y + val xBucket = ((x - minX) * scale).toInt() + val yBucket = ((y - minY) * scale).toInt() + val bucket = Vector(xBucket, yBucket) + val currentValue = buckets.getOrPut(bucket) { 0.0 } + buckets[bucket] = currentValue + l.intensity + } + return buckets.values.maxOrNull() ?: 0.0 + } + } +} diff --git a/library/src/main/java/com/google/maps/android/heatmaps/WeightedLatLng.java b/library/src/main/java/com/google/maps/android/heatmaps/WeightedLatLng.java deleted file mode 100644 index 18c29550a..000000000 --- a/library/src/main/java/com/google/maps/android/heatmaps/WeightedLatLng.java +++ /dev/null @@ -1,81 +0,0 @@ -/* - * Copyright 2014 Google Inc. - * - * 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.heatmaps; - -import com.google.android.gms.maps.model.LatLng; -import com.google.maps.android.geometry.Point; -import com.google.maps.android.projection.SphericalMercatorProjection; -import com.google.maps.android.quadtree.PointQuadTree; - -/** - * A wrapper class that can be used in a PointQuadTree - * Created from a LatLng and optional intensity: point coordinates of the LatLng and the intensity - * value can be accessed from it later. - */ -public class WeightedLatLng implements PointQuadTree.Item { - - /** - * Default intensity to use when intensity not specified - */ - public static final double DEFAULT_INTENSITY = 1; - - /** - * Projection to use for points - * Converts LatLng to (x, y) coordinates using a SphericalMercatorProjection - */ - private static final SphericalMercatorProjection sProjection = - new SphericalMercatorProjection(HeatmapTileProvider.WORLD_WIDTH); - - private Point mPoint; - - private double mIntensity; - - /** - * Constructor - * - * @param latLng LatLng to add to wrapper - * @param intensity Intensity to use: should be greater than 0 - * Default value is 1. - * This represents the "importance" or "value" of this particular point - * Higher intensity values map to higher colours. - * Intensity is additive: having two points of intensity 1 at the same - * location is identical to having one of intensity 2. - */ - public WeightedLatLng(LatLng latLng, double intensity) { - mPoint = sProjection.toPoint(latLng); - if (intensity >= 0) mIntensity = intensity; - else mIntensity = DEFAULT_INTENSITY; - } - - /** - * Constructor that uses default value for intensity - * - * @param latLng LatLng to add to wrapper - */ - public WeightedLatLng(LatLng latLng) { - this(latLng, DEFAULT_INTENSITY); - } - - public Point getPoint() { - return mPoint; - } - - public double getIntensity() { - return mIntensity; - } - -} diff --git a/library/src/main/java/com/google/maps/android/heatmaps/WeightedLatLng.kt b/library/src/main/java/com/google/maps/android/heatmaps/WeightedLatLng.kt new file mode 100644 index 000000000..c7b55712b --- /dev/null +++ b/library/src/main/java/com/google/maps/android/heatmaps/WeightedLatLng.kt @@ -0,0 +1,46 @@ +/* + * Copyright 2025 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.heatmaps + +import com.google.android.gms.maps.model.LatLng +import com.google.maps.android.geometry.Point +import com.google.maps.android.projection.SphericalMercatorProjection +import com.google.maps.android.quadtree.PointQuadTree + +data class WeightedLatLng( + val latLng: LatLng, + override val point: Point, + val intensity: Double +) : PointQuadTree.Item { + + /** + * Constructor that uses default value for intensity + * + * @param latLng LatLng to add to wrapper + */ + @JvmOverloads + constructor(latLng: LatLng, intensity: Double = DEFAULT_INTENSITY) : this( + latLng, + sProjection.toPoint(latLng), + if (intensity >= 0) intensity else DEFAULT_INTENSITY + ) + + companion object { + const val DEFAULT_INTENSITY = 1.0 + private val sProjection = SphericalMercatorProjection(HeatmapTileProvider.WORLD_WIDTH) + } +} diff --git a/library/src/main/java/com/google/maps/android/projection/Point.java b/library/src/main/java/com/google/maps/android/projection/Point.kt similarity index 63% rename from library/src/main/java/com/google/maps/android/projection/Point.java rename to library/src/main/java/com/google/maps/android/projection/Point.kt index 75ca7fe20..9cecbb098 100644 --- a/library/src/main/java/com/google/maps/android/projection/Point.java +++ b/library/src/main/java/com/google/maps/android/projection/Point.kt @@ -14,14 +14,10 @@ * limitations under the License. */ -package com.google.maps.android.projection; +package com.google.maps.android.projection /** - * @deprecated since 0.2. Use {@link com.google.maps.android.geometry.Point} instead. + * @deprecated since 0.2. Use [com.google.maps.android.geometry.Point] instead. */ -@Deprecated -public class Point extends com.google.maps.android.geometry.Point { - public Point(double x, double y) { - super(x, y); - } -} +@Deprecated("since 0.2. Use com.google.maps.android.geometry.Point instead.", ReplaceWith("com.google.maps.android.geometry.Point(x, y)")) +public class Point(x: Double, y: Double) : com.google.maps.android.geometry.Point(x, y) \ No newline at end of file diff --git a/library/src/test/java/com/google/maps/android/heatmaps/GradientTest.java b/library/src/test/java/com/google/maps/android/heatmaps/GradientTest.java deleted file mode 100644 index 118910678..000000000 --- a/library/src/test/java/com/google/maps/android/heatmaps/GradientTest.java +++ /dev/null @@ -1,125 +0,0 @@ -/* - * Copyright 2014 Google Inc. - * - * 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.heatmaps; - -import org.junit.Test; -import org.junit.runner.RunWith; -import org.robolectric.RobolectricTestRunner; - -import android.graphics.Color; -import android.os.Build; - -import static android.graphics.Color.BLUE; -import static android.graphics.Color.GREEN; -import static android.graphics.Color.RED; -import static org.junit.Assert.assertEquals; - -@RunWith(RobolectricTestRunner.class) -public class GradientTest { - @Test - public void testInterpolateColor() { - // Expect itself - assertEquals(RED, Gradient.interpolateColor(RED, RED, 0.5f)); - assertEquals(BLUE, Gradient.interpolateColor(BLUE, BLUE, 0.5f)); - assertEquals(GREEN, Gradient.interpolateColor(GREEN, GREEN, 0.5f)); - - // Expect first to be returned - int result = Gradient.interpolateColor(RED, BLUE, 0); - assertEquals(RED, result); - - // Expect second to be returned - result = Gradient.interpolateColor(RED, BLUE, 1); - assertEquals(BLUE, result); - - // Expect same value (should wraparound correctly, shortest path both times) - assertEquals( - Gradient.interpolateColor(BLUE, RED, 0.5f), - Gradient.interpolateColor(RED, BLUE, 0.5f)); - assertEquals( - Gradient.interpolateColor(BLUE, RED, 0.8f), - Gradient.interpolateColor(RED, BLUE, 0.2f)); - assertEquals( - Gradient.interpolateColor(BLUE, RED, 0.2f), - Gradient.interpolateColor(RED, BLUE, 0.8f)); - - // Due to issue with Color.RGBToHSV() below only works on Android O and greater (#573) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - assertEquals(-65434, Gradient.interpolateColor(RED, BLUE, 0.2f)); - assertEquals(Color.MAGENTA, Gradient.interpolateColor(RED, BLUE, 0.5f)); - assertEquals(-10092289, Gradient.interpolateColor(RED, BLUE, 0.8f)); - assertEquals(Color.YELLOW, Gradient.interpolateColor(RED, GREEN, 0.5f)); - assertEquals(Color.CYAN, Gradient.interpolateColor(BLUE, GREEN, 0.5f)); - } - } - - @Test - public void testSimpleColorMap() { - int[] colors = {RED, BLUE}; - float[] startPoints = {0f, 1.0f}; - - Gradient g = new Gradient(colors, startPoints, 2); - int[] colorMap = g.generateColorMap(1.0); - assertEquals(RED, colorMap[0]); - assertEquals(Gradient.interpolateColor(RED, BLUE, 0.5f), colorMap[1]); - } - - @Test - public void testLargerColorMap() { - int[] colors = {RED, GREEN}; - float[] startPoints = {0f, 1.0f}; - - Gradient g = new Gradient(colors, startPoints, 10); - int[] colorMap = g.generateColorMap(1.0); - assertEquals(RED, colorMap[0]); - for (int i = 1; i < 10; i++) { - assertEquals(Gradient.interpolateColor(RED, GREEN, (i * 0.1f)), colorMap[i]); - } - } - - @Test - public void testOpacityInterpolation() { - int[] colors = {Color.argb(0, 0, 255, 0), GREEN, RED}; - float[] startPoints = {0f, 0.2f, 1f}; - Gradient g = new Gradient(colors, startPoints, 10); - int[] colorMap = g.generateColorMap(1.0); - assertEquals(Color.argb(0, 0, 255, 0), colorMap[0]); - assertEquals(Color.argb(127, 0, 255, 0), colorMap[1]); - assertEquals(GREEN, colorMap[2]); - assertEquals(Gradient.interpolateColor(GREEN, RED, 0.125f), colorMap[3]); - assertEquals(Gradient.interpolateColor(GREEN, RED, 0.25f), colorMap[4]); - assertEquals(Gradient.interpolateColor(GREEN, RED, 0.375f), colorMap[5]); - assertEquals(Gradient.interpolateColor(GREEN, RED, 0.5f), colorMap[6]); - assertEquals(Gradient.interpolateColor(GREEN, RED, 0.625f), colorMap[7]); - assertEquals(Gradient.interpolateColor(GREEN, RED, 0.75f), colorMap[8]); - assertEquals(Gradient.interpolateColor(GREEN, RED, 0.875f), colorMap[9]); - - colorMap = g.generateColorMap(0.5); - assertEquals(Color.argb(0, 0, 255, 0), colorMap[0]); - assertEquals(Color.argb(63, 0, 255, 0), colorMap[1]); - assertEquals(Color.argb(127, 0, 255, 0), colorMap[2]); - } - - @Test - public void testMoreColorsThanColorMap() { - int[] colors = {Color.argb(0, 0, 255, 0), GREEN, RED, BLUE}; - float[] startPoints = {0f, 0.2f, 0.5f, 1f}; - Gradient g = new Gradient(colors, startPoints, 2); - int[] colorMap = g.generateColorMap(1.0); - assertEquals(GREEN, colorMap[0]); - assertEquals(RED, colorMap[1]); - } -} diff --git a/library/src/test/java/com/google/maps/android/heatmaps/GradientTest.kt b/library/src/test/java/com/google/maps/android/heatmaps/GradientTest.kt new file mode 100644 index 000000000..143d0a2d7 --- /dev/null +++ b/library/src/test/java/com/google/maps/android/heatmaps/GradientTest.kt @@ -0,0 +1,114 @@ +/* + * Copyright 2025 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.heatmaps + +import android.graphics.Color +import android.os.Build +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class GradientTest { + @Test + fun testInterpolateColor() { + // Expect itself + assertThat(Gradient.interpolateColor(Color.RED, Color.RED, 0.5f)).isEqualTo(Color.RED) + assertThat(Gradient.interpolateColor(Color.BLUE, Color.BLUE, 0.5f)).isEqualTo(Color.BLUE) + assertThat(Gradient.interpolateColor(Color.GREEN, Color.GREEN, 0.5f)).isEqualTo(Color.GREEN) + + // Expect first to be returned + val result = Gradient.interpolateColor(Color.RED, Color.BLUE, 0f) + assertThat(result).isEqualTo(Color.RED) + + // Expect second to be returned + val result2 = Gradient.interpolateColor(Color.RED, Color.BLUE, 1f) + assertThat(result2).isEqualTo(Color.BLUE) + + // Expect same value (should wraparound correctly, shortest path both times) + assertThat(Gradient.interpolateColor(Color.BLUE, Color.RED, 0.5f)) + .isEqualTo(Gradient.interpolateColor(Color.RED, Color.BLUE, 0.5f)) + assertThat(Gradient.interpolateColor(Color.BLUE, Color.RED, 0.8f)) + .isEqualTo(Gradient.interpolateColor(Color.RED, Color.BLUE, 0.2f)) + assertThat(Gradient.interpolateColor(Color.BLUE, Color.RED, 0.2f)) + .isEqualTo(Gradient.interpolateColor(Color.RED, Color.BLUE, 0.8f)) + + // Due to issue with Color.RGBToHSV() below only works on Android O and greater (#573) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + assertThat(Gradient.interpolateColor(Color.RED, Color.BLUE, 0.2f)).isEqualTo(-65434) + assertThat(Gradient.interpolateColor(Color.RED, Color.BLUE, 0.5f)).isEqualTo(Color.MAGENTA) + assertThat(Gradient.interpolateColor(Color.RED, Color.BLUE, 0.8f)).isEqualTo(-10092289) + assertThat(Gradient.interpolateColor(Color.RED, Color.GREEN, 0.5f)).isEqualTo(Color.YELLOW) + assertThat(Gradient.interpolateColor(Color.BLUE, Color.GREEN, 0.5f)).isEqualTo(Color.CYAN) + } + } + + @Test + fun testSimpleColorMap() { + val colors = intArrayOf(Color.RED, Color.BLUE) + val startPoints = floatArrayOf(0f, 1.0f) + val g = Gradient(colors, startPoints, 2) + val colorMap = g.generateColorMap(1.0) + assertThat(colorMap[0]).isEqualTo(Color.RED) + assertThat(colorMap[1]).isEqualTo(Gradient.interpolateColor(Color.RED, Color.BLUE, 0.5f)) + } + + @Test + fun testLargerColorMap() { + val colors = intArrayOf(Color.RED, Color.GREEN) + val startPoints = floatArrayOf(0f, 1.0f) + val g = Gradient(colors, startPoints, 10) + val colorMap = g.generateColorMap(1.0) + assertThat(colorMap[0]).isEqualTo(Color.RED) + for (i in 1..9) { + assertThat(colorMap[i]).isEqualTo(Gradient.interpolateColor(Color.RED, Color.GREEN, i * 0.1f)) + } + } + + @Test + fun testOpacityInterpolation() { + val colors = intArrayOf(Color.argb(0, 0, 255, 0), Color.GREEN, Color.RED) + val startPoints = floatArrayOf(0f, 0.2f, 1f) + val g = Gradient(colors, startPoints, 10) + var colorMap = g.generateColorMap(1.0) + assertThat(colorMap[0]).isEqualTo(Color.argb(0, 0, 255, 0)) + assertThat(colorMap[1]).isEqualTo(Color.argb(127, 0, 255, 0)) + assertThat(colorMap[2]).isEqualTo(Color.GREEN) + assertThat(colorMap[3]).isEqualTo(Gradient.interpolateColor(Color.GREEN, Color.RED, 0.125f)) + assertThat(colorMap[4]).isEqualTo(Gradient.interpolateColor(Color.GREEN, Color.RED, 0.25f)) + assertThat(colorMap[5]).isEqualTo(Gradient.interpolateColor(Color.GREEN, Color.RED, 0.375f)) + assertThat(colorMap[6]).isEqualTo(Gradient.interpolateColor(Color.GREEN, Color.RED, 0.5f)) + assertThat(colorMap[7]).isEqualTo(Gradient.interpolateColor(Color.GREEN, Color.RED, 0.625f)) + assertThat(colorMap[8]).isEqualTo(Gradient.interpolateColor(Color.GREEN, Color.RED, 0.75f)) + assertThat(colorMap[9]).isEqualTo(Gradient.interpolateColor(Color.GREEN, Color.RED, 0.875f)) + colorMap = g.generateColorMap(0.5) + assertThat(colorMap[0]).isEqualTo(Color.argb(0, 0, 255, 0)) + assertThat(colorMap[1]).isEqualTo(Color.argb(63, 0, 255, 0)) + assertThat(colorMap[2]).isEqualTo(Color.argb(127, 0, 255, 0)) + } + + @Test + fun testMoreColorsThanColorMap() { + val colors = intArrayOf(Color.argb(0, 0, 255, 0), Color.GREEN, Color.RED, Color.BLUE) + val startPoints = floatArrayOf(0f, 0.2f, 0.5f, 1f) + val g = Gradient(colors, startPoints, 2) + val colorMap = g.generateColorMap(1.0) + assertThat(colorMap[0]).isEqualTo(Color.GREEN) + assertThat(colorMap[1]).isEqualTo(Color.RED) + } +} \ No newline at end of file diff --git a/library/src/test/java/com/google/maps/android/heatmaps/HeatmapTileProviderTest.kt b/library/src/test/java/com/google/maps/android/heatmaps/HeatmapTileProviderTest.kt new file mode 100644 index 000000000..ccde0afbc --- /dev/null +++ b/library/src/test/java/com/google/maps/android/heatmaps/HeatmapTileProviderTest.kt @@ -0,0 +1,132 @@ +/* + * Copyright 2025 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.heatmaps + +import com.google.android.gms.maps.model.LatLng +import com.google.android.gms.maps.model.TileProvider +import com.google.common.truth.Truth.assertThat +import org.junit.Assert.fail +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class HeatmapTileProviderTest { + + @Test + fun testBuilder_weightedData() { + val data = listOf(WeightedLatLng(LatLng(0.0, 0.0))) + val provider = HeatmapTileProvider.Builder().weightedData(data).build() + assertThat(provider).isNotNull() + } + + @Test + fun testBuilder_data() { + val data = listOf(LatLng(0.0, 0.0)) + val provider = HeatmapTileProvider.Builder().data(data).build() + assertThat(provider).isNotNull() + } + + @Test + fun testBuilder_noData() { + try { + HeatmapTileProvider.Builder().build() + fail("Should have thrown IllegalStateException") + } catch (e: IllegalStateException) { + // success + } + } + + @Test + fun testBuilder_emptyData() { + try { + HeatmapTileProvider.Builder().data(emptyList()) + fail("Should have thrown IllegalArgumentException") + } catch (e: IllegalArgumentException) { + // success + } + } + + @Test + fun testBuilder_radius() { + val data = listOf(LatLng(0.0, 0.0)) + val provider = HeatmapTileProvider.Builder().data(data).radius(20).build() + assertThat(provider).isNotNull() + } + + @Test + fun testBuilder_invalidRadius() { + val data = listOf(LatLng(0.0, 0.0)) + try { + HeatmapTileProvider.Builder().data(data).radius(0) + fail("Should have thrown IllegalArgumentException") + } catch (e: IllegalArgumentException) { + // success + } + try { + HeatmapTileProvider.Builder().data(data).radius(100) + fail("Should have thrown IllegalArgumentException") + } catch (e: IllegalArgumentException) { + // success + } + } + + @Test + fun testBuilder_opacity() { + val data = listOf(LatLng(0.0, 0.0)) + val provider = HeatmapTileProvider.Builder().data(data).opacity(0.5).build() + assertThat(provider).isNotNull() + } + + @Test + fun testBuilder_invalidOpacity() { + val data = listOf(LatLng(0.0, 0.0)) + try { + HeatmapTileProvider.Builder().data(data).opacity(-1.0) + fail("Should have thrown IllegalArgumentException") + } catch (e: IllegalArgumentException) { + // success + } + try { + HeatmapTileProvider.Builder().data(data).opacity(2.0) + fail("Should have thrown IllegalArgumentException") + } catch (e: IllegalArgumentException) { + // success + } + } + + @Test + fun testGetTile() { + val data = listOf(LatLng(0.0, 0.0)) + val provider = HeatmapTileProvider.Builder().data(data).build() + val tile = provider.getTile(512, 512, 10) + assertThat(tile).isNotNull() + assertThat(tile).isNotEqualTo(TileProvider.NO_TILE) + assertThat(tile.width).isEqualTo(512) + assertThat(tile.height).isEqualTo(512) + } + + @Test + fun testGetTile_noPointsInTile() { + // Point is at (0,0), so tile at (1,1) should be empty + val data = listOf(LatLng(0.0, 0.0)) + val provider = HeatmapTileProvider.Builder().data(data).build() + // A zoom level high enough that (0,0) and (1,1) are far apart + val tile = provider.getTile(1, 1, 20) + assertThat(tile).isEqualTo(TileProvider.NO_TILE) + } +} diff --git a/library/src/test/java/com/google/maps/android/heatmaps/WeightedLatLngTest.kt b/library/src/test/java/com/google/maps/android/heatmaps/WeightedLatLngTest.kt new file mode 100644 index 000000000..254a525e1 --- /dev/null +++ b/library/src/test/java/com/google/maps/android/heatmaps/WeightedLatLngTest.kt @@ -0,0 +1,72 @@ +/* + * Copyright 2025 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.heatmaps + +import com.google.android.gms.maps.model.LatLng +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class WeightedLatLngTest { + + @Test + fun testConstructorWithIntensity() { + val latLng = LatLng(10.0, 20.0) + val weighted = WeightedLatLng(latLng, 5.0) + assertThat(weighted.latLng).isEqualTo(latLng) + assertThat(weighted.intensity).isEqualTo(5.0) + assertThat(weighted.point).isNotNull() + } + + @Test + fun testConstructorWithDefaultIntensity() { + val latLng = LatLng(10.0, 20.0) + val weighted = WeightedLatLng(latLng) + assertThat(weighted.latLng).isEqualTo(latLng) + assertThat(weighted.intensity).isEqualTo(WeightedLatLng.DEFAULT_INTENSITY) + } + + @Test + fun testConstructorWithNegativeIntensity() { + val latLng = LatLng(10.0, 20.0) + val weighted = WeightedLatLng(latLng, -1.0) + assertThat(weighted.intensity).isEqualTo(WeightedLatLng.DEFAULT_INTENSITY) + } + + @Test + fun testDataClassMethods() { + val latLng1 = LatLng(1.0, 2.0) + val weighted1 = WeightedLatLng(latLng1, 3.0) + val weighted2 = WeightedLatLng(latLng1, 3.0) + val weighted3 = WeightedLatLng(latLng1, 4.0) + + // Test equals() + assertThat(weighted1).isEqualTo(weighted2) + assertThat(weighted1).isNotEqualTo(weighted3) + + // Test hashCode() + assertThat(weighted1.hashCode()).isEqualTo(weighted2.hashCode()) + assertThat(weighted1.hashCode()).isNotEqualTo(weighted3.hashCode()) + + // Test copy() + val weightedCopy = weighted1.copy(intensity = 5.0) + assertThat(weightedCopy.latLng).isEqualTo(latLng1) + assertThat(weightedCopy.intensity).isEqualTo(5.0) + } +} diff --git a/library/src/test/java/com/google/maps/android/projection/PointTest.kt b/library/src/test/java/com/google/maps/android/projection/PointTest.kt new file mode 100644 index 000000000..85ae49c0e --- /dev/null +++ b/library/src/test/java/com/google/maps/android/projection/PointTest.kt @@ -0,0 +1,40 @@ +/* + * Copyright 2025 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.projection + +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class PointTest { + + @Test + fun testPointConstruction() { + val point = Point(1.0, 2.0) + assertThat(point).isNotNull() + assertThat(point.x).isEqualTo(1.0) + assertThat(point.y).isEqualTo(2.0) + } + + @Test + fun testPointIsInstanceOfGeometryPoint() { + val point = Point(1.0, 2.0) + assertThat(point).isInstanceOf(com.google.maps.android.geometry.Point::class.java) + } +} diff --git a/local.defaults.properties b/local.defaults.properties index 818d21b20..251530fa0 100644 --- a/local.defaults.properties +++ b/local.defaults.properties @@ -1 +1,2 @@ -MAPS_API_KEY="YOUR_API_KEY" \ No newline at end of file +MAPS_API_KEY=YOUR_API_KEY +PLACES_API_KEY=YOUR_API_KEY From f005d29194cfa077dbd0bd76a5a959a2e82da30f Mon Sep 17 00:00:00 2001 From: Dale Hawkins <107309+dkhawk@users.noreply.github.com> Date: Thu, 28 Aug 2025 12:32:21 -0600 Subject: [PATCH 42/67] chore: Implement edge-to-edge UI (#1574) * Rename .java to .kt * refactor(library): Convert projection.Point to Kotlin and add tests The `@Deprecated` annotation has been updated to the Kotlin syntax and now includes a `ReplaceWith` expression to provide IDE quick-fix support. Additionally, new unit tests using Google Truth have been added for the `Point` class to verify its construction and inheritance. * refactor(heatmaps): Port Gradient and WeightedLatLng to Kotlin This commit migrates the `Gradient` and `WeightedLatLng` classes and their corresponding tests from Java to idiomatic Kotlin. This conversion is part of the larger effort to modernize the library and prepare it for Kotlin Multiplatform (KMP) compatibility. Key changes include: - `WeightedLatLng` is now a `data class` with a private primary constructor to ensure correct instantiation while providing the benefits of auto-generated methods. The `@ExposedCopyVisibility` annotation has been added to maintain binary compatibility for the `copy()` method. - `Gradient` has been converted to a Kotlin class, using `@JvmOverloads` for constructor compatibility and a `companion object` for its static helper method. - The `internal` visibility of a method in `Gradient` was changed to `public` to ensure visibility to Java classes during the mixed-language phase of the migration. - Unit tests for both classes have been ported to Kotlin, using the Truth assertion framework. * feat(heatmaps): Port HeatmapTileProvider to Kotlin This commit completes the migration of the `heatmaps` package to Kotlin by porting the final and most complex class, `HeatmapTileProvider`. This conversion modernizes the class by introducing several idiomatic Kotlin features and improves its design by refactoring a key data structure. Key changes include: - The `HeatmapTileProvider` and its `Builder` are now written in Kotlin. - The `getMaxValue()` method was refactored to use a `Map` with a type-safe `Vector` key instead of the Android-specific `LongSparseArray`, improving readability and moving closer to KMP compatibility. - A new test suite was created from scratch for the provider, as none existed previously. This improves the code coverage and reliability of the module. - Necessary adjustments were made to the demo application to support the newly ported Kotlin class. * fix(tests): Correct copyright headers in new test files Updates the copyright headers in the test files that were created during the Kotlin porting process. - Corrects the copyright year to 2025. - Corrects the company name from "Google Inc." to "Google LLC". * chore: Add PLACES_API_KEY to local.defaults.properties * fix(heatmaps): Correct copyright headers Updates the copyright headers in the `heatmaps` package source and test files. - Corrects the copyright year to 2025. - Corrects the company name from "Google Inc." to "Google LLC". * feat: Implement edge-to-edge UI This commit implements edge-to-edge UI for the demo application. Changes include: - Updating the app theme to be edge-to-edge. - Applying window insets to the base and main activities. - Adding a separate styles.xml for API 27+ to handle display cutouts. * fix: Update copyright headers This commit updates the copyright headers in the following files: - demo/build.gradle.kts - demo/src/main/java/com/google/maps/android/utils/demo/BaseDemoActivity.java - demo/src/main/java/com/google/maps/android/utils/demo/MainActivity.java - demo/src/main/res/values/styles.xml - demo/src/main/res/values-v27/styles.xml * chore: fix copyright headers * Fix copyright year * Fix copyright header in main.xml * refactor(heatmaps): Replace check with require in HeatmapTileProvider.Builder This commit replaces the `check` function with `require` for validating input data in the `HeatmapTileProvider.Builder`. Using `require` is more idiomatic for precondition checks, throwing an `IllegalArgumentException` which is more appropriate in this context than the `IllegalStateException` thrown by `check`. * chore(heatmaps): Correct expected exception in HeatmapTileProviderTest This commit updates `HeatmapTileProviderTest` to expect an `IllegalArgumentException` instead of `IllegalStateException` when building a provider with no data. It also cleans up unused exception variables in catch blocks. --- demo/build.gradle.kts | 3 +- demo/src/main/AndroidManifest.xml | 2 +- .../android/utils/demo/BaseDemoActivity.java | 34 +++++++++++++- .../maps/android/utils/demo/MainActivity.java | 32 +++++++++++++- demo/src/main/res/layout/main.xml | 26 +++++------ demo/src/main/res/values-v27/styles.xml | 40 +++++++++++++++++ demo/src/main/res/values/styles.xml | 44 +++++++++++-------- .../android/heatmaps/HeatmapTileProvider.kt | 4 +- .../heatmaps/HeatmapTileProviderTest.kt | 12 ++--- 9 files changed, 153 insertions(+), 44 deletions(-) create mode 100644 demo/src/main/res/values-v27/styles.xml diff --git a/demo/build.gradle.kts b/demo/build.gradle.kts index e2c64060b..16baebf16 100644 --- a/demo/build.gradle.kts +++ b/demo/build.gradle.kts @@ -1,5 +1,5 @@ /** - * Copyright 2024 Google Inc. + * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -65,6 +65,7 @@ dependencies { implementation(libs.kotlinx.coroutines.android) implementation(libs.kotlinx.coroutines.core) implementation(libs.material) + implementation(libs.core.ktx) testImplementation(libs.junit) testImplementation(libs.truth) diff --git a/demo/src/main/AndroidManifest.xml b/demo/src/main/AndroidManifest.xml index b1b387dce..fcf52fa22 100644 --- a/demo/src/main/AndroidManifest.xml +++ b/demo/src/main/AndroidManifest.xml @@ -1,5 +1,5 @@ + + + + + + + diff --git a/demo/src/main/res/values/styles.xml b/demo/src/main/res/values/styles.xml index b26d61819..e6a9deae1 100644 --- a/demo/src/main/res/values/styles.xml +++ b/demo/src/main/res/values/styles.xml @@ -1,26 +1,34 @@ + - diff --git a/library/src/main/java/com/google/maps/android/heatmaps/HeatmapTileProvider.kt b/library/src/main/java/com/google/maps/android/heatmaps/HeatmapTileProvider.kt index ba172423e..1ee1731e1 100644 --- a/library/src/main/java/com/google/maps/android/heatmaps/HeatmapTileProvider.kt +++ b/library/src/main/java/com/google/maps/android/heatmaps/HeatmapTileProvider.kt @@ -138,7 +138,7 @@ class HeatmapTileProvider private constructor(builder: Builder) : TileProvider { * @return A new HeatmapTileProvider. */ fun build(): HeatmapTileProvider { - check(this.weightedData?.isNotEmpty() == true) { "No input data: you must use either .data or .weightedData before building." } + require(this.weightedData?.isNotEmpty() == true) { "No input data: you must use either .data or .weightedData before building." } return HeatmapTileProvider(this) } } @@ -423,4 +423,4 @@ class HeatmapTileProvider private constructor(builder: Builder) : TileProvider { return buckets.values.maxOrNull() ?: 0.0 } } -} +} \ No newline at end of file diff --git a/library/src/test/java/com/google/maps/android/heatmaps/HeatmapTileProviderTest.kt b/library/src/test/java/com/google/maps/android/heatmaps/HeatmapTileProviderTest.kt index ccde0afbc..327bbb54e 100644 --- a/library/src/test/java/com/google/maps/android/heatmaps/HeatmapTileProviderTest.kt +++ b/library/src/test/java/com/google/maps/android/heatmaps/HeatmapTileProviderTest.kt @@ -46,7 +46,7 @@ class HeatmapTileProviderTest { try { HeatmapTileProvider.Builder().build() fail("Should have thrown IllegalStateException") - } catch (e: IllegalStateException) { + } catch (_: IllegalArgumentException) { // success } } @@ -56,7 +56,7 @@ class HeatmapTileProviderTest { try { HeatmapTileProvider.Builder().data(emptyList()) fail("Should have thrown IllegalArgumentException") - } catch (e: IllegalArgumentException) { + } catch (_: IllegalArgumentException) { // success } } @@ -74,13 +74,13 @@ class HeatmapTileProviderTest { try { HeatmapTileProvider.Builder().data(data).radius(0) fail("Should have thrown IllegalArgumentException") - } catch (e: IllegalArgumentException) { + } catch (_: IllegalArgumentException) { // success } try { HeatmapTileProvider.Builder().data(data).radius(100) fail("Should have thrown IllegalArgumentException") - } catch (e: IllegalArgumentException) { + } catch (_: IllegalArgumentException) { // success } } @@ -98,13 +98,13 @@ class HeatmapTileProviderTest { try { HeatmapTileProvider.Builder().data(data).opacity(-1.0) fail("Should have thrown IllegalArgumentException") - } catch (e: IllegalArgumentException) { + } catch (_: IllegalArgumentException) { // success } try { HeatmapTileProvider.Builder().data(data).opacity(2.0) fail("Should have thrown IllegalArgumentException") - } catch (e: IllegalArgumentException) { + } catch (_: IllegalArgumentException) { // success } } From d1ed72b31c3b77481caa2d9ce5c7dfbcce894dfc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20L=C3=B3pez-Ma=C3=B1as?= Date: Tue, 2 Sep 2025 19:34:31 +0200 Subject: [PATCH 43/67] build: removed HTML extension from header check (#1577) --- .github/header-checker-lint.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/header-checker-lint.yml b/.github/header-checker-lint.yml index a2b0a15dd..c3a0fa81d 100644 --- a/.github/header-checker-lint.yml +++ b/.github/header-checker-lint.yml @@ -27,7 +27,6 @@ sourceFileExtensions: - 'ts' - 'js' - 'java' - - 'html' - 'txt' - 'kt' - 'kts' From 68d7d9d85b83a8e80ef80cd3e7aa2e49d8cd99d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20L=C3=B3pez-Ma=C3=B1as?= Date: Tue, 2 Sep 2025 19:54:00 +0200 Subject: [PATCH 44/67] chore: fixed algorithm samples (#1579) * chore: fixed algorithm samples * Removed --- .../demo/ClusterAlgorithmsDemoActivity.kt | 34 ++++++++++++++++--- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/demo/src/main/java/com/google/maps/android/utils/demo/ClusterAlgorithmsDemoActivity.kt b/demo/src/main/java/com/google/maps/android/utils/demo/ClusterAlgorithmsDemoActivity.kt index 93e275907..d1f27fbe5 100644 --- a/demo/src/main/java/com/google/maps/android/utils/demo/ClusterAlgorithmsDemoActivity.kt +++ b/demo/src/main/java/com/google/maps/android/utils/demo/ClusterAlgorithmsDemoActivity.kt @@ -16,15 +16,16 @@ package com.google.maps.android.utils.demo +import android.os.Build +import android.util.DisplayMetrics import android.view.View +import android.view.WindowManager import android.widget.AdapterView import android.widget.ArrayAdapter import android.widget.Spinner import com.google.android.gms.maps.CameraUpdateFactory -import com.google.android.gms.maps.MapView import com.google.android.gms.maps.model.LatLng import com.google.maps.android.clustering.ClusterManager -import com.google.maps.android.clustering.algo.AbstractAlgorithm import com.google.maps.android.clustering.algo.CentroidNonHierarchicalDistanceBasedAlgorithm import com.google.maps.android.clustering.algo.ContinuousZoomEuclideanCentroidAlgorithm import com.google.maps.android.clustering.algo.GridBasedAlgorithm @@ -40,7 +41,6 @@ import kotlin.random.Random class ClusterAlgorithmsDemoActivity : BaseDemoActivity() { private var clusterManager: ClusterManager? = null - private lateinit var mapView: MapView override fun getLayoutId(): Int { return R.layout.activity_cluster_algorithms_demo @@ -85,6 +85,28 @@ class ClusterAlgorithmsDemoActivity : BaseDemoActivity() { * Sets up the ClusterManager with the chosen algorithm and populates it with items. */ private fun setupClusterer(algorithmPosition: Int) { + + val windowManager = getSystemService(WINDOW_SERVICE) as WindowManager + val metrics = DisplayMetrics() + val width: Int + val height: Int + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + // For devices with Android 11 (API 30) and above + val windowMetrics = windowManager.currentWindowMetrics + width = windowMetrics.bounds.width() + height = windowMetrics.bounds.height() + metrics.density = resources.displayMetrics.density + } else { + // For devices below Android 11 + windowManager.defaultDisplay.getMetrics(metrics) + width = metrics.widthPixels + height = metrics.heightPixels + } + + val widthDp = (width / metrics.density).toInt() + val heightDp = (height / metrics.density).toInt() + // 1. Clear the map and previous cluster manager map.clear() @@ -96,9 +118,11 @@ class ClusterAlgorithmsDemoActivity : BaseDemoActivity() { 1 -> GridBasedAlgorithm() 2 -> NonHierarchicalDistanceBasedAlgorithm() 3 -> CentroidNonHierarchicalDistanceBasedAlgorithm() - 4 -> NonHierarchicalViewBasedAlgorithm(mapView.width, mapView.height) + 4 -> NonHierarchicalViewBasedAlgorithm(widthDp, heightDp) 5 -> ContinuousZoomEuclideanCentroidAlgorithm() - else -> error("Unsupported algorithm position: $algorithmPosition") + else -> { + GridBasedAlgorithm() + } } // 4. Point the map's listeners to the ClusterManager From 12e0a88b39a3582e87a0e236f4a5175e723aa72f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20L=C3=B3pez-Ma=C3=B1as?= Date: Tue, 2 Sep 2025 22:55:33 +0200 Subject: [PATCH 45/67] chore: update Maven configuration (#1583) --- .../convention/src/main/kotlin/PublishingConventionPlugin.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build-logic/convention/src/main/kotlin/PublishingConventionPlugin.kt b/build-logic/convention/src/main/kotlin/PublishingConventionPlugin.kt index a476a1584..5f4716190 100644 --- a/build-logic/convention/src/main/kotlin/PublishingConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/PublishingConventionPlugin.kt @@ -67,7 +67,7 @@ class PublishingConventionPlugin : Plugin { signAllPublications() pom { - name.set(project.name) + name.set("android-maps-utils") description.set("Handy extensions to the Google Maps Android API.") url.set("https://github.com/googlemaps/android-maps-utils") licenses { From d51930c1e719ad7fb4733a55ad912f58538628e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20L=C3=B3pez-Ma=C3=B1as?= Date: Tue, 2 Sep 2025 23:57:16 +0200 Subject: [PATCH 46/67] chore: added artifactId (#1584) --- .../convention/src/main/kotlin/PublishingConventionPlugin.kt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/build-logic/convention/src/main/kotlin/PublishingConventionPlugin.kt b/build-logic/convention/src/main/kotlin/PublishingConventionPlugin.kt index 5f4716190..0b588ec1e 100644 --- a/build-logic/convention/src/main/kotlin/PublishingConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/PublishingConventionPlugin.kt @@ -66,6 +66,10 @@ class PublishingConventionPlugin : Plugin { publishToMavenCentral() signAllPublications() + coordinates( + artifactId = "android-maps-utils", + ) + pom { name.set("android-maps-utils") description.set("Handy extensions to the Google Maps Android API.") From 2f2374d72588e289d15efae0466efa829ed0b747 Mon Sep 17 00:00:00 2001 From: Dale Hawkins <107309+dkhawk@users.noreply.github.com> Date: Wed, 3 Sep 2025 07:42:58 -0600 Subject: [PATCH 47/67] fix(PolyUtil): Refactored the `PolyUtil.simplify` function to not mutate its inputs (#1585) * refactor(PolyUtil): make simplify more idiomatic Refactored the `simplify` function to be more idiomatic Kotlin. - Extracted the core Douglas-Peucker algorithm into a private helper function. - Replaced `java.util.Stack` with `ArrayDeque`. - Improved variable scoping. - The function no longer modifies the input list. * refactor: Improve variable name in PolyUtil.simplify --- .../java/com/google/maps/android/PolyUtil.kt | 114 +++++++++++------- 1 file changed, 71 insertions(+), 43 deletions(-) diff --git a/library/src/main/java/com/google/maps/android/PolyUtil.kt b/library/src/main/java/com/google/maps/android/PolyUtil.kt index e6421b7a3..d6e3cd5e6 100644 --- a/library/src/main/java/com/google/maps/android/PolyUtil.kt +++ b/library/src/main/java/com/google/maps/android/PolyUtil.kt @@ -27,7 +27,7 @@ import com.google.maps.android.MathUtil.sinFromHav import com.google.maps.android.MathUtil.sinSumFromHav import com.google.maps.android.MathUtil.wrap import com.google.maps.android.SphericalUtil.computeDistanceBetween -import java.util.Stack +import kotlin.collections.ArrayDeque import kotlin.math.cos import kotlin.math.max import kotlin.math.min @@ -300,65 +300,93 @@ object PolyUtil { * @return a simplified poly produced by the Douglas-Peucker algorithm */ @JvmStatic - fun simplify(poly: MutableList, tolerance: Double): List { - val n = poly.size - require(n >= 1) { "Polyline must have at least 1 point" } + fun simplify(poly: List, tolerance: Double): List { + require(poly.isNotEmpty()) { "Polyline must have at least 1 point" } require(tolerance > 0) { "Tolerance must be greater than zero" } - val closedPolygon = isClosedPolygon(poly) - var lastPoint: LatLng? = null - - // Check if the provided poly is a closed polygon - if (closedPolygon) { - // Add a small offset to the last point for Douglas-Peucker on polygons (see #201) - val OFFSET = 0.00000000001 - lastPoint = poly.last() - poly.removeAt(poly.size - 1) - poly.add(LatLng(lastPoint.latitude + OFFSET, lastPoint.longitude + OFFSET)) + // The simplification process is handled by the Douglas-Peucker algorithm, + // which is implemented in a separate private function for clarity. + // Before we can apply the algorithm, we need to handle a special case for closed polygons. + val workingPoly = if (isClosedPolygon(poly)) { + // For closed polygons, the Douglas-Peucker algorithm needs to "see" the connection + // between the last and first points. A common trick to achieve this is to temporarily + // open the polygon and add a point that is very close to the last point. This ensures + // that the simplification process takes the closing segment into account. + val lastPoint = poly.last() + val offset = 0.00000000001 + poly.toMutableList().apply { + removeAt(size - 1) + add(LatLng(lastPoint.latitude + offset, lastPoint.longitude + offset)) + } + } else { + poly } - var maxIdx = 0 - val stack = Stack() - val dists = DoubleArray(n) - dists[0] = 1.0 - dists[n - 1] = 1.0 - var maxDist: Double - var dist: Double - var current: IntArray + // The douglasPeucker function returns a boolean array indicating which points to keep. + val pointsToKeep = douglasPeucker(workingPoly, tolerance) + + // We then filter the original, unmodified polyline based on the results of the + // simplification algorithm. This ensures that the original points are preserved in the + // final output. + return poly.filterIndexed { index, _ -> pointsToKeep[index] } + } + + /** + * Implements the Douglas-Peucker algorithm for simplifying a polyline. + * + * The algorithm works by recursively dividing the polyline into smaller segments and finding + * the point that is farthest from the line segment connecting the start and end points. + * If this point is farther than the specified tolerance, it is kept, and the algorithm is + * applied recursively to the two new segments. + * + * @param poly The polyline to be simplified. + * @param tolerance The tolerance in meters. + * @return A boolean array where `true` indicates that the point at the corresponding index + * should be kept in the simplified polyline. + */ + private fun douglasPeucker(poly: List, tolerance: Double): BooleanArray { + val n = poly.size + // We start with a boolean array that will mark the points to keep. + // Initially, only the first and last points are marked for keeping. + val keepPoint = BooleanArray(n) { false } + keepPoint[0] = true + keepPoint[n - 1] = true + // The algorithm is only needed if the polyline has more than 2 points. if (n > 2) { - val stackVal = intArrayOf(0, n - 1) - stack.push(stackVal) + // We use a stack (implemented with ArrayDeque for efficiency) to manage the + // segments that we need to process. Initially, this contains the entire polyline. + val stack = ArrayDeque>() + stack.addLast(0 to n - 1) + + // We process segments from the stack until it's empty. while (stack.isNotEmpty()) { - current = stack.pop() - maxDist = 0.0 - for (idx in current[0] + 1 until current[1]) { - dist = distanceToLine(poly[idx], poly[current[0]], poly[current[1]]) + val (start, end) = stack.removeLast() + var maxDist = 0.0 + var maxIdx = 0 + + // For the current segment, we find the point that is farthest from the line + // connecting the start and end points. + for (idx in start + 1 until end) { + val dist = distanceToLine(poly[idx], poly[start], poly[end]) if (dist > maxDist) { maxDist = dist maxIdx = idx } } + + // If the farthest point is farther than the tolerance, we mark it to be kept. + // We then push two new segments onto the stack to be processed recursively: + // one from the start to the farthest point, and one from the farthest point to the end. if (maxDist > tolerance) { - dists[maxIdx] = maxDist - val stackValCurMax = intArrayOf(current[0], maxIdx) - stack.push(stackValCurMax) - val stackValMaxCur = intArrayOf(maxIdx, current[1]) - stack.push(stackValMaxCur) + keepPoint[maxIdx] = true + stack.addLast(start to maxIdx) + stack.addLast(maxIdx to end) } } } - if (closedPolygon) { - // Replace last point w/ offset with the original last point to re-close the polygon - poly.removeAt(poly.size - 1) - if (lastPoint != null) { - poly.add(lastPoint) - } - } - - // Generate the simplified line - return poly.filterIndexed { idx, _ -> dists[idx] != 0.0 } + return keepPoint } /** From e0516c288315905d79823b381f265c33b117cdc0 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Wed, 3 Sep 2025 13:50:18 +0000 Subject: [PATCH 48/67] chore(release): 3.16.1 [skip ci] ## [3.16.1](https://github.com/googlemaps/android-maps-utils/compare/v3.16.0...v3.16.1) (2025-09-03) ### Bug Fixes * **PolyUtil:** Refactored the `PolyUtil.simplify` function to not mutate its inputs ([#1585](https://github.com/googlemaps/android-maps-utils/issues/1585)) ([2f2374d](https://github.com/googlemaps/android-maps-utils/commit/2f2374d72588e289d15efae0466efa829ed0b747)) --- README.md | 2 +- build.gradle.kts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 5b49d381e..5e48fe8f3 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ dependencies { // Utilities for Maps SDK for Android (requires Google Play Services) // You do not need to add a separate dependency for the Maps SDK for Android // since this library builds in the compatible version of the Maps SDK. - implementation 'com.google.maps.android:android-maps-utils:3.16.0' + implementation 'com.google.maps.android:android-maps-utils:3.16.1' // Optionally add the Kotlin Extensions (KTX) for full Kotlin language support // See latest version at https://github.com/googlemaps/android-maps-ktx diff --git a/build.gradle.kts b/build.gradle.kts index 9aea1e904..37e66ef76 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -37,5 +37,5 @@ tasks.register("clean") { allprojects { group = "com.google.maps.android" - version = "3.16.0" + version = "3.16.1" } \ No newline at end of file From 1329850418c48e7c37836f4e7e47365f5bfd4650 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20L=C3=B3pez-Ma=C3=B1as?= Date: Fri, 5 Sep 2025 20:48:29 +0200 Subject: [PATCH 49/67] fix: Correct continuous zoom clustering implementation (#1587) --- ...tinuousZoomEuclideanCentroidAlgorithm.java | 64 ++++++++++++++++--- 1 file changed, 54 insertions(+), 10 deletions(-) diff --git a/library/src/main/java/com/google/maps/android/clustering/algo/ContinuousZoomEuclideanCentroidAlgorithm.java b/library/src/main/java/com/google/maps/android/clustering/algo/ContinuousZoomEuclideanCentroidAlgorithm.java index c49dcb634..b42a65599 100644 --- a/library/src/main/java/com/google/maps/android/clustering/algo/ContinuousZoomEuclideanCentroidAlgorithm.java +++ b/library/src/main/java/com/google/maps/android/clustering/algo/ContinuousZoomEuclideanCentroidAlgorithm.java @@ -16,20 +16,23 @@ package com.google.maps.android.clustering.algo; +import com.google.android.gms.maps.model.LatLng; +import com.google.maps.android.clustering.Cluster; import com.google.maps.android.clustering.ClusterItem; import com.google.maps.android.geometry.Bounds; -import com.google.maps.android.quadtree.PointQuadTree; import java.util.ArrayList; import java.util.Collection; +import java.util.HashMap; import java.util.HashSet; +import java.util.Map; import java.util.Set; /** * A variant of {@link CentroidNonHierarchicalDistanceBasedAlgorithm} that uses * continuous zoom scaling and Euclidean distance for clustering. * - *

This class overrides {@link #getClusteringItems(PointQuadTree, float)} to compute + *

This class overrides {@link #getClusters(float)} to compute * clusters with a zoom-dependent radius, while keeping the centroid-based cluster positions.

* * @param the type of cluster item @@ -38,15 +41,21 @@ public class ContinuousZoomEuclideanCentroidAlgorithm extends CentroidNonHierarchicalDistanceBasedAlgorithm { @Override - protected Collection> getClusteringItems(PointQuadTree> quadTree, float zoom) { + public Set> getClusters(float zoom) { // Continuous zoom — no casting to int final double zoomSpecificSpan = getMaxDistanceBetweenClusteredItems() / Math.pow(2, zoom) / 256; final Set> visitedCandidates = new HashSet<>(); - final Collection> result = new ArrayList<>(); + final Set> results = new HashSet<>(); + final Map, Double> distanceToCluster = new HashMap<>(); + final Map, StaticCluster> itemToCluster = new HashMap<>(); + synchronized (mQuadTree) { - for (QuadItem candidate : mItems) { - if (visitedCandidates.contains(candidate)) continue; + for (QuadItem candidate : getClusteringItems(mQuadTree, zoom)) { + if (visitedCandidates.contains(candidate)) { + // Candidate is already part of another cluster. + continue; + } Bounds searchBounds = createBoundsFromSpan(candidate.getPoint(), zoomSpecificSpan); Collection> clusterItems = new ArrayList<>(); @@ -58,11 +67,46 @@ protected Collection> getClusteringItems(PointQuadTree> } } + if (clusterItems.size() == 1) { + // Only the current marker is in range. Just add the single item to the results. + results.add(candidate); + visitedCandidates.add(candidate); + distanceToCluster.put(candidate, 0d); + continue; + } + StaticCluster cluster = new StaticCluster<>(candidate.mClusterItem.getPosition()); + results.add(cluster); + + for (QuadItem clusterItem : clusterItems) { + Double existingDistance = distanceToCluster.get(clusterItem); + double distance = distanceSquared(clusterItem.getPoint(), candidate.getPoint()); + if (existingDistance != null) { + // Item already belongs to another cluster. Check if it's closer to this cluster. + if (existingDistance < distance) { + continue; + } + // Move item to the closer cluster. + itemToCluster.get(clusterItem).remove(clusterItem.mClusterItem); + } + distanceToCluster.put(clusterItem, distance); + cluster.add(clusterItem.mClusterItem); + itemToCluster.put(clusterItem, cluster); + } visitedCandidates.addAll(clusterItems); - result.add(candidate); } } - return result; - } -} + // Now, apply the centroid logic from CentroidNonHierarchicalDistanceBasedAlgorithm + Set> newClusters = new HashSet<>(); + for (Cluster cluster : results) { + LatLng centroid = computeCentroid(cluster.getItems()); + StaticCluster newCluster = new StaticCluster<>(centroid); + for (T item : cluster.getItems()) { + newCluster.add(item); + } + newClusters.add(newCluster); + } + + return newClusters; + } +} \ No newline at end of file From 31937c902d84a401a18125b85a36784c72eeedff Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Fri, 5 Sep 2025 18:55:42 +0000 Subject: [PATCH 50/67] chore(release): 3.16.2 [skip ci] ## [3.16.2](https://github.com/googlemaps/android-maps-utils/compare/v3.16.1...v3.16.2) (2025-09-05) ### Bug Fixes * Correct continuous zoom clustering implementation ([#1587](https://github.com/googlemaps/android-maps-utils/issues/1587)) ([1329850](https://github.com/googlemaps/android-maps-utils/commit/1329850418c48e7c37836f4e7e47365f5bfd4650)) --- README.md | 2 +- build.gradle.kts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 5e48fe8f3..5a9afab6f 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ dependencies { // Utilities for Maps SDK for Android (requires Google Play Services) // You do not need to add a separate dependency for the Maps SDK for Android // since this library builds in the compatible version of the Maps SDK. - implementation 'com.google.maps.android:android-maps-utils:3.16.1' + implementation 'com.google.maps.android:android-maps-utils:3.16.2' // Optionally add the Kotlin Extensions (KTX) for full Kotlin language support // See latest version at https://github.com/googlemaps/android-maps-ktx diff --git a/build.gradle.kts b/build.gradle.kts index 37e66ef76..85a3ddc19 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -37,5 +37,5 @@ tasks.register("clean") { allprojects { group = "com.google.maps.android" - version = "3.16.1" + version = "3.16.2" } \ No newline at end of file From db84504da4b874de4ca50988b374941cdd95b3a8 Mon Sep 17 00:00:00 2001 From: Dale Hawkins <107309+dkhawk@users.noreply.github.com> Date: Tue, 9 Sep 2025 15:27:34 -0600 Subject: [PATCH 51/67] chore(deps): Bump dependency versions (#1594) * chore(deps): Bump dependency versions * chore: Upgrade Gradle wrapper to 8.14.3 --- gradle/libs.versions.toml | 14 +++++++------- gradle/wrapper/gradle-wrapper.properties | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 61ddd0a0c..376ce95d1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,24 +3,24 @@ compileSdk = "36" targetSdk = "36" appcompat = "1.7.1" dokka-gradle-plugin = "2.0.0" -gradle = "8.10.1" +gradle = "8.13.0" jacoco-android = "0.2.1" lifecycle-extensions = "2.2.0" -lifecycle-viewmodel-ktx = "2.9.2" +lifecycle-viewmodel-ktx = "2.9.3" kotlin = "2.2.0" kotlinx-coroutines = "1.10.2" junit = "4.13.2" -mockito-core = "5.18.0" +mockito-core = "5.19.0" secrets-gradle-plugin = "2.0.1" truth = "1.4.4" play-services-maps = "19.2.0" -core-ktx = "1.16.0" -robolectric = "4.15.1" +core-ktx = "1.17.0" +robolectric = "4.16" kxml2 = "2.3.0" mockk = "1.14.5" -lint = "31.12.0" +lint = "31.13.0" org-jacoco-core = "0.8.13" -material = "1.12.0" +material = "1.13.0" gradleMavenPublishPlugin = "0.34.0" [libraries] diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index e2847c820..d4081da47 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME From 23a4bb58229d0f81733afc1a42377e9d60f29f16 Mon Sep 17 00:00:00 2001 From: Dale Hawkins <107309+dkhawk@users.noreply.github.com> Date: Wed, 10 Sep 2025 11:28:17 -0600 Subject: [PATCH 52/67] feat(demo): Add StreetViewJavaHelper and demo and enable edge-to-edge (#1595) * feat(demo): Add Java demo for Street View utility This commit adds a new demo activity written in Java to demonstrate the usage of the Street View utility. To facilitate this, a Kotlin helper has been introduced to bridge the gap between suspend functions and Java's callback-based asynchronous model. The key changes are: - **`StreetViewDemoActivityJava`**: A new demo activity that shows how to check for Street View availability from Java. - **`StreetViewHelper`**: A Kotlin object that wraps the `suspend` function `StreetViewUtils.fetchStreetViewData` and exposes it to Java consumers via a callback interface. - **`ApiKeyValidator`**: A new utility class to check for a valid Maps API key within the demo application. - **Unit Tests**: Added tests for `StreetViewHelper` using Robolectric, MockK, and coroutine testing libraries to verify its behavior. - **Dependencies**: Added `mockk`, `kotlinx-coroutines-test`, and `robolectric` to the demo module's test dependencies. * feat(demo): Enable edge-to-edge for StreetView demo activities This commit refactors the `StreetViewDemoActivity` in both its Kotlin and Java versions to support edge-to-edge display. The key changes include: - Changing the base class from `Activity` to `AppCompatActivity`. - Using `WindowCompat.setDecorFitsSystemWindows` to allow the app to draw behind the system bars. - Adding an `OnApplyWindowInsetsListener` to the root view to apply padding for the system bars, preventing content from being obscured. - Updating the `street_view_demo.xml` layout with an ID for the root view to facilitate the insets listener. - Updating the copyright year. * refactor(library): Move StreetViewJavaHelper to library module This commit moves the `StreetViewHelper` from the `demo` module to the `library` module, making it an officially supported utility for Java consumers. The key changes include: - Renaming `StreetViewHelper` to `StreetViewJavaHelper` for better clarity and moving it to the `com.google.maps.android` package within the library. - Migrating the corresponding unit tests from the `demo` to the `library` module and updating them to reflect the class rename. - Adjusting `build.gradle.kts` files to move test dependencies (`mockk`, `coroutines-test`, `robolectric`) from the `demo` to the `library` module. - Renaming `StreetViewDemoActivityJava` to `StreetViewDemoJavaActivity` and updating it to use the new helper from the library. - Improving the warning message for invalid or missing API keys in the demo app. * refactor: Remove redundant Javadoc in StreetViewDemoJavaActivity --- demo/src/main/AndroidManifest.xml | 3 + .../android/utils/demo/ApiKeyValidator.java | 63 +++++++++++ .../maps/android/utils/demo/MainActivity.java | 1 + .../utils/demo/StreetViewDemoActivity.kt | 40 +++++-- .../demo/StreetViewDemoJavaActivity.java | 104 ++++++++++++++++++ demo/src/main/res/layout/street_view_demo.xml | 1 + demo/src/main/res/values/strings.xml | 2 +- gradle/libs.versions.toml | 1 + library/build.gradle.kts | 4 + .../maps/android/StreetViewJavaHelper.kt | 64 +++++++++++ .../maps/android/StreetViewHelperTest.kt | 94 ++++++++++++++++ 11 files changed, 369 insertions(+), 8 deletions(-) create mode 100644 demo/src/main/java/com/google/maps/android/utils/demo/ApiKeyValidator.java create mode 100644 demo/src/main/java/com/google/maps/android/utils/demo/StreetViewDemoJavaActivity.java create mode 100644 library/src/main/java/com/google/maps/android/StreetViewJavaHelper.kt create mode 100644 library/src/test/java/com/google/maps/android/StreetViewHelperTest.kt diff --git a/demo/src/main/AndroidManifest.xml b/demo/src/main/AndroidManifest.xml index fcf52fa22..4d359efca 100644 --- a/demo/src/main/AndroidManifest.xml +++ b/demo/src/main/AndroidManifest.xml @@ -124,6 +124,9 @@ + diff --git a/demo/src/main/java/com/google/maps/android/utils/demo/ApiKeyValidator.java b/demo/src/main/java/com/google/maps/android/utils/demo/ApiKeyValidator.java new file mode 100644 index 000000000..9acd9a066 --- /dev/null +++ b/demo/src/main/java/com/google/maps/android/utils/demo/ApiKeyValidator.java @@ -0,0 +1,63 @@ +/* + * Copyright 2025 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.utils.demo; + +import android.content.Context; +import android.content.pm.PackageManager; +import android.os.Bundle; + +import java.util.regex.Pattern; + +/** + * A utility class to validate the Maps API key. The purpose of this check is to ensure that a + * developer has set a valid-looking key. This is not a definitive check, and there is a + * possibility of a false negative. If you are sure your key is correct, you can remove this + * check. + */ +class ApiKeyValidator { + private static final String REGEX = "^AIza[0-9A-Za-z-_]{35}$"; + private static final Pattern PATTERN = Pattern.compile(REGEX); + + /** + * Checks if the provided context has a valid Google Maps API key in its metadata. + * + * @param context The context to check for the API key. + * @return `true` if the context has a valid API key, `false` otherwise. + */ + static boolean hasMapsApiKey(Context context) { + String mapsApiKey = getMapsApiKey(context); + return mapsApiKey != null && PATTERN.matcher(mapsApiKey).matches(); + } + + /** + * Retrieves the Google Maps API key from the application metadata. + * + * @param context The context to retrieve the API key from. + * @return The API key if found, `null` otherwise. + */ + private static String getMapsApiKey(Context context) { + try { + Bundle bundle = context.getPackageManager() + .getApplicationInfo(context.getPackageName(), PackageManager.GET_META_DATA) + .metaData; + return bundle.getString("com.google.android.geo.API_KEY"); + } catch (PackageManager.NameNotFoundException e) { + e.printStackTrace(); + return null; + } + } +} diff --git a/demo/src/main/java/com/google/maps/android/utils/demo/MainActivity.java b/demo/src/main/java/com/google/maps/android/utils/demo/MainActivity.java index df2579e69..fa0ec0e42 100644 --- a/demo/src/main/java/com/google/maps/android/utils/demo/MainActivity.java +++ b/demo/src/main/java/com/google/maps/android/utils/demo/MainActivity.java @@ -87,6 +87,7 @@ protected void onCreate(Bundle savedInstanceState) { addDemo("Multi Layer", MultiLayerDemoActivity.class); addDemo("AnimationUtil sample", AnimationUtilDemoActivity.class); addDemo("Street View Demo", StreetViewDemoActivity.class); + addDemo("Street View Demo (Java)", StreetViewDemoJavaActivity.class); } private void addDemo(String demoName, Class activityClass) { diff --git a/demo/src/main/java/com/google/maps/android/utils/demo/StreetViewDemoActivity.kt b/demo/src/main/java/com/google/maps/android/utils/demo/StreetViewDemoActivity.kt index 432be2416..20c7c4418 100644 --- a/demo/src/main/java/com/google/maps/android/utils/demo/StreetViewDemoActivity.kt +++ b/demo/src/main/java/com/google/maps/android/utils/demo/StreetViewDemoActivity.kt @@ -1,5 +1,5 @@ /* - * Copyright 2024 Google LLC + * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,35 +17,61 @@ package com.google.maps.android.utils.demo import android.annotation.SuppressLint -import android.app.Activity import android.os.Bundle import android.widget.TextView import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.ViewCompat +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.lifecycle.lifecycleScope import com.google.android.gms.maps.model.LatLng import com.google.maps.android.StreetViewUtils -import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch -class StreetViewDemoActivity : Activity() { +/** + * An activity that demonstrates how to use the [StreetViewUtils] to check for Street View + * availability at different locations. + * + * This activity performs the following actions: + * 1. Sets up the layout to fit system windows. + * 2. Checks if a valid Google Maps API key is present. + * 3. Launches a coroutine to fetch Street View data for two predefined locations. + * 4. Displays the results of the Street View data fetch on the screen. + */ +class StreetViewDemoActivity : AppCompatActivity() { @SuppressLint("SetTextI18n") - @OptIn(DelicateCoroutinesApi::class) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + // Make the activity content fit behind the system bars. + WindowCompat.setDecorFitsSystemWindows(window, false) setContentView(R.layout.street_view_demo) + // Apply window insets to the main view to avoid content overlapping with system bars. + ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v, insets -> + val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom) + insets + } + + // Check for a valid Maps API key before proceeding. if (!hasMapsApiKey(this)) { Toast.makeText(this, R.string.bad_maps_api_key, Toast.LENGTH_LONG).show() finish() + return // Return early to prevent further execution } - GlobalScope.launch(Dispatchers.Main) { + // Launch a coroutine in the Main dispatcher to fetch Street View data and update the UI. + lifecycleScope.launch(Dispatchers.Main) { + // Fetch Street View data for the first location (which is expected to be supported). val response1 = StreetViewUtils.fetchStreetViewData(LatLng(48.1425918, 11.5386121), BuildConfig.MAPS_API_KEY) + // Fetch Street View data for the second location (which is expected to be unsupported). val response2 = StreetViewUtils.fetchStreetViewData(LatLng(8.1425918, 11.5386121), BuildConfig.MAPS_API_KEY) + // Update the UI with the results. findViewById(R.id.textViewFirstLocation).text = "Location 1 is supported in StreetView: $response1" findViewById(R.id.textViewSecondLocation).text = "Location 2 is supported in StreetView: $response2" } diff --git a/demo/src/main/java/com/google/maps/android/utils/demo/StreetViewDemoJavaActivity.java b/demo/src/main/java/com/google/maps/android/utils/demo/StreetViewDemoJavaActivity.java new file mode 100644 index 000000000..2260dca4f --- /dev/null +++ b/demo/src/main/java/com/google/maps/android/utils/demo/StreetViewDemoJavaActivity.java @@ -0,0 +1,104 @@ +/* + * Copyright 2025 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.utils.demo; + +import android.annotation.SuppressLint; +import android.os.Bundle; +import android.util.Log; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AppCompatActivity; +import androidx.core.graphics.Insets; +import androidx.core.view.ViewCompat; +import androidx.core.view.WindowCompat; +import androidx.core.view.WindowInsetsCompat; + +import com.google.android.gms.maps.model.LatLng; +import com.google.maps.android.Status; +import com.google.maps.android.StreetViewJavaHelper; + +/** + * An activity that demonstrates how to use the Street View utility in Java to check for Street View + * availability at different locations. + *

+ * This activity performs the following actions: + * 1. Sets up the layout to fit system windows. + * 2. Checks if a valid Google Maps API key is present. + * 3. Fetches Street View data for two predefined locations using asynchronous callbacks. + * 4. Displays the results of the Street View data fetch on the screen. + */ +public class StreetViewDemoJavaActivity extends AppCompatActivity { + + @SuppressLint("SetTextI18n") + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + // Make the activity content fit behind the system bars. + WindowCompat.setDecorFitsSystemWindows(getWindow(), false); + setContentView(R.layout.street_view_demo); + + // Apply window insets to the main view to avoid content overlapping with system bars. + ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main), (v, insetsCompat) -> { + Insets insets = insetsCompat.getInsets(WindowInsetsCompat.Type.systemBars()); + v.setPadding(insets.left, insets.top, insets.right, insets.bottom); + return insetsCompat; + }); + + // Check for a valid Maps API key before proceeding. + if (!ApiKeyValidator.hasMapsApiKey(this)) { + Toast.makeText(this, R.string.bad_maps_api_key, Toast.LENGTH_LONG).show(); + finish(); + return; // Return early to prevent further execution + } + + // Fetches Street View data for the first location (expected to be supported). + StreetViewJavaHelper.fetchStreetViewData( + new LatLng(48.1425918, 11.5386121), + BuildConfig.MAPS_API_KEY, new StreetViewJavaHelper.StreetViewCallback() { + @Override + public void onStreetViewResult(@NonNull Status status) { + // Updates the UI with the result on the UI thread. + runOnUiThread(() -> ((TextView) findViewById(R.id.textViewFirstLocation)).setText("Location 1 is supported in StreetView: " + status)); + } + + @Override + public void onStreetViewError(@NonNull Exception e) { + // Handles the error by printing stack trace and showing a toast. + Log.w("SVJDemo", "Error fetching Street View data: " + e.getMessage()); + Toast.makeText(StreetViewDemoJavaActivity.this, "Error fetching Street View data: " + e.getMessage(), Toast.LENGTH_SHORT).show(); + } + }); + + // Fetches Street View data for the second location (expected to be unsupported). + StreetViewJavaHelper.fetchStreetViewData(new LatLng(8.1425918, 11.5386121), BuildConfig.MAPS_API_KEY, new StreetViewJavaHelper.StreetViewCallback() { + @Override + public void onStreetViewResult(@NonNull Status status) { + // Updates the UI with the result on the UI thread. + runOnUiThread(() -> ((TextView) findViewById(R.id.textViewSecondLocation)).setText("Location 2 is supported in StreetView: " + status)); + } + + @Override + public void onStreetViewError(@NonNull Exception e) { + // Handles the error by printing stack trace and showing a toast. + Log.w("SVJDemo", "Error fetching Street View data: " + e.getMessage()); + Toast.makeText(StreetViewDemoJavaActivity.this, "Error fetching Street View data: " + e.getMessage(), Toast.LENGTH_SHORT).show(); + } + }); + } +} \ No newline at end of file diff --git a/demo/src/main/res/layout/street_view_demo.xml b/demo/src/main/res/layout/street_view_demo.xml index df7b93a39..5a948f6ca 100644 --- a/demo/src/main/res/layout/street_view_demo.xml +++ b/demo/src/main/res/layout/street_view_demo.xml @@ -16,6 +16,7 @@ --> diff --git a/demo/src/main/res/values/strings.xml b/demo/src/main/res/values/strings.xml index f910616d2..a0c3d7efb 100644 --- a/demo/src/main/res/values/strings.xml +++ b/demo/src/main/res/values/strings.xml @@ -33,5 +33,5 @@ Radius Gradient Opacity - Invalid or missing Google Maps API key + The Google Maps API key looks invalid or is missing. If you are sure your key is correct, you can remove this check from the demo. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 376ce95d1..f08d27766 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -35,6 +35,7 @@ lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx kotlin-stdlib-jdk8 = { module = "org.jetbrains.kotlin:kotlin-stdlib-jdk8", version.ref = "kotlin" } kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinx-coroutines" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } +kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } junit = { module = "junit:junit", version.ref = "junit" } mockito-core = { module = "org.mockito:mockito-core", version.ref = "mockito-core" } secrets-gradle-plugin = { module = "com.google.android.libraries.mapsplatform.secrets-gradle-plugin:secrets-gradle-plugin", version.ref = "secrets-gradle-plugin" } diff --git a/library/build.gradle.kts b/library/build.gradle.kts index 5b1f841a4..dd4edcc74 100644 --- a/library/build.gradle.kts +++ b/library/build.gradle.kts @@ -71,6 +71,10 @@ dependencies { testImplementation(libs.kotlin.test) testImplementation(libs.truth) implementation(libs.kotlin.stdlib.jdk8) + + testImplementation(libs.mockk) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.robolectric) } tasks.register("instrumentTest") { diff --git a/library/src/main/java/com/google/maps/android/StreetViewJavaHelper.kt b/library/src/main/java/com/google/maps/android/StreetViewJavaHelper.kt new file mode 100644 index 000000000..0816a6a2e --- /dev/null +++ b/library/src/main/java/com/google/maps/android/StreetViewJavaHelper.kt @@ -0,0 +1,64 @@ +/* + * Copyright 2025 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 + +import com.google.android.gms.maps.model.LatLng +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +/** + * A helper object to call the suspend function `fetchStreetViewData` from Java. + */ +object StreetViewJavaHelper { + /** + * A callback interface to receive the result of the Street View data fetch. + */ + interface StreetViewCallback { + /** + * Called when the Street View data is fetched successfully. + * + * @param status The status of the Street View data. + */ + fun onStreetViewResult(status: Status) + + /** + * Called when there is an error fetching the Street View data. + * + * @param e The exception that occurred. + */ + fun onStreetViewError(e: Exception) + } + + /** + * Fetches Street View data for the given location and returns the result via a callback. + * + * @param latLng The location to fetch Street View data for. + * @param apiKey The API key to use for the request. + * @param callback The callback to receive the result. + */ + @JvmStatic + fun fetchStreetViewData(latLng: LatLng, apiKey: String, callback: StreetViewCallback) { + CoroutineScope(Dispatchers.Main).launch { + try { + val status = StreetViewUtils.fetchStreetViewData(latLng, apiKey) + callback.onStreetViewResult(status) + } catch (e: Exception) { + callback.onStreetViewError(e) + } + } + } +} diff --git a/library/src/test/java/com/google/maps/android/StreetViewHelperTest.kt b/library/src/test/java/com/google/maps/android/StreetViewHelperTest.kt new file mode 100644 index 000000000..9071e7640 --- /dev/null +++ b/library/src/test/java/com/google/maps/android/StreetViewHelperTest.kt @@ -0,0 +1,94 @@ +/* + * Copyright 2025 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 + +import com.google.android.gms.maps.model.LatLng +import io.mockk.coEvery +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.verify +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * Tests for [StreetViewJavaHelper]. + */ +@ExperimentalCoroutinesApi +@RunWith(RobolectricTestRunner::class) +class StreetViewHelperTest { + + private val testDispatcher = StandardTestDispatcher() + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + mockkObject(StreetViewUtils) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + /** + * Tests that [StreetViewJavaHelper.fetchStreetViewData] calls the onStreetViewResult callback with the OK status when the call is successful. + */ + @Test + fun `fetchStreetViewData should call onStreetViewResult with OK status`() = runTest { + // Arrange + val latLng = LatLng(1.0, 2.0) + val apiKey = "some_api_key" + val callback = mockk(relaxed = true) + coEvery { StreetViewUtils.fetchStreetViewData(latLng, apiKey) } returns Status.OK + + // Act + StreetViewJavaHelper.fetchStreetViewData(latLng, apiKey, callback) + testDispatcher.scheduler.advanceUntilIdle() + + // Assert + verify { callback.onStreetViewResult(Status.OK) } + } + + /** + * Tests that [StreetViewJavaHelper.fetchStreetViewData] calls the onStreetViewError callback when an exception occurs. + */ + @Test + fun `fetchStreetViewData should call onStreetViewError when an exception occurs`() = runTest { + // Arrange + val latLng = LatLng(1.0, 2.0) + val apiKey = "some_api_key" + val callback = mockk(relaxed = true) + val exception = Exception("some_error") + coEvery { StreetViewUtils.fetchStreetViewData(latLng, apiKey) } throws exception + + // Act + StreetViewJavaHelper.fetchStreetViewData(latLng, apiKey, callback) + testDispatcher.scheduler.advanceUntilIdle() + + // Assert + verify { callback.onStreetViewError(exception) } + } +} \ No newline at end of file From 49c06c7a219e6665a6bd53bed85ae02a7e3c0e15 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Wed, 10 Sep 2025 17:34:46 +0000 Subject: [PATCH 53/67] chore(release): 3.17.0 [skip ci] # [3.17.0](https://github.com/googlemaps/android-maps-utils/compare/v3.16.2...v3.17.0) (2025-09-10) ### Features * **demo:** Add StreetViewJavaHelper and demo and enable edge-to-edge ([#1595](https://github.com/googlemaps/android-maps-utils/issues/1595)) ([23a4bb5](https://github.com/googlemaps/android-maps-utils/commit/23a4bb58229d0f81733afc1a42377e9d60f29f16)) --- README.md | 2 +- build.gradle.kts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 5a9afab6f..a66e9537f 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ dependencies { // Utilities for Maps SDK for Android (requires Google Play Services) // You do not need to add a separate dependency for the Maps SDK for Android // since this library builds in the compatible version of the Maps SDK. - implementation 'com.google.maps.android:android-maps-utils:3.16.2' + implementation 'com.google.maps.android:android-maps-utils:3.17.0' // Optionally add the Kotlin Extensions (KTX) for full Kotlin language support // See latest version at https://github.com/googlemaps/android-maps-ktx diff --git a/build.gradle.kts b/build.gradle.kts index 85a3ddc19..640f47758 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -37,5 +37,5 @@ tasks.register("clean") { allprojects { group = "com.google.maps.android" - version = "3.16.2" + version = "3.17.0" } \ No newline at end of file From 03e3620a6f3401417053d49686562d6c0da643d2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 11 Sep 2025 10:49:09 -0600 Subject: [PATCH 54/67] build(deps): bump actions/setup-node from 4 to 5 (#1591) Bumps [actions/setup-node](https://github.com/actions/setup-node) from 4 to 5. - [Release notes](https://github.com/actions/setup-node/releases) - [Commits](https://github.com/actions/setup-node/compare/v4...v5) --- updated-dependencies: - dependency-name: actions/setup-node dependency-version: '5' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f1ddafc8b..0cea08ebd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -51,7 +51,7 @@ jobs: SONATYPE_TOKEN_PASSWORD: ${{ secrets.SONATYPE_TOKEN_PASSWORD }} SONATYPE_TOKEN_USERNAME: ${{ secrets.SONATYPE_TOKEN }} - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v5 with: node-version: '14' From 77fecaafa26e0e710564a73dea15aca33dde62dd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 11 Sep 2025 10:49:45 -0600 Subject: [PATCH 55/67] build(deps): bump actions/setup-go from 5 to 6 (#1592) Bumps [actions/setup-go](https://github.com/actions/setup-go) from 5 to 6. - [Release notes](https://github.com/actions/setup-go/releases) - [Commits](https://github.com/actions/setup-go/compare/v5...v6) --- updated-dependencies: - dependency-name: actions/setup-go dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/docs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 3cb63dbed..1dabc41f8 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -40,7 +40,7 @@ jobs: java-version: '17' distribution: 'temurin' - name: Install Go - uses: actions/setup-go@v5 + uses: actions/setup-go@v6 with: go-version: '1.20' From 686774f997877d70ac3eb4c31cbd796b1f3dbd5a Mon Sep 17 00:00:00 2001 From: Dale Hawkins <107309+dkhawk@users.noreply.github.com> Date: Mon, 15 Sep 2025 10:58:23 -0600 Subject: [PATCH 56/67] feat(library): Add polyline progress utilities to SphericalUtil (#1588) * feat(library): Add polyline progress utilities to SphericalUtil This commit introduces two new utility functions to `SphericalUtil` for calculating points and prefixes on a polyline based on a percentage of its total length. A new demo has also been added to showcase this functionality. The key changes are: - **`SphericalUtil.getPointOnPolyline()`**: A new function that returns a `LatLng` at a specified percentage along a given polyline. - **`SphericalUtil.getPolylinePrefix()`**: A new function that returns a new list of `LatLng`s representing a prefix of the original polyline up to a specified percentage. - **New Demo**: A `PolylineProgressDemoActivity` has been added to the demo application. It demonstrates how to animate progress along a polyline using the new utility functions, complete with a `SeekBar` for user control. - **Tests**: Added comprehensive unit tests for `getPointOnPolyline` and `getPolylinePrefix` to ensure correctness and handle edge cases. * refactor(demo): Modernize PolylineProgressDemoActivity This commit significantly refactors the `PolylineProgressDemoActivity` to align with modern Android development practices and better showcase the library's features. Key changes include: - **View Binding**: Replaced `findViewById` with View Binding for type-safe and more concise access to UI components. This required enabling `viewBinding` in the demo's `build.gradle.kts`. - **Lifecycle-Aware Coroutines**: The animation now uses `lifecycleScope`, ensuring the coroutine is automatically canceled when the Activity is destroyed, preventing memory leaks. - **State Management**: Replaced multiple `MutableLiveData` instances with a single `LiveData` data class. This creates a single source of truth for the animation's state, leading to more predictable and maintainable UI updates. - **Code Structure and Documentation**: The code has been reorganized into smaller, more focused functions. Extensive KDoc comments have been added to explain the implementation, highlight the use of `SphericalUtil`, and document the modern Android patterns being used. * Add missing copyright header * chore: Add missing copyright headers --- demo/build.gradle.kts | 4 + demo/src/main/AndroidManifest.xml | 3 + .../maps/android/utils/demo/MainActivity.java | 1 + .../utils/demo/PolySimplifyDemoActivity.java | 13 + .../demo/PolylineProgressDemoActivity.kt | 231 ++++++++++++++++++ .../baseline_airplanemode_active_24.xml | 22 ++ .../activity_polyline_progress_demo.xml | 80 ++++++ demo/src/main/res/values/strings.xml | 1 + .../com/google/maps/android/SphericalUtil.kt | 55 +++++ .../maps/android/SphericalUtilTest.java | 71 ++++++ 10 files changed, 481 insertions(+) create mode 100644 demo/src/main/java/com/google/maps/android/utils/demo/PolylineProgressDemoActivity.kt create mode 100644 demo/src/main/res/drawable/baseline_airplanemode_active_24.xml create mode 100644 demo/src/main/res/layout/activity_polyline_progress_demo.xml diff --git a/demo/build.gradle.kts b/demo/build.gradle.kts index 16baebf16..d8ddb0c5f 100644 --- a/demo/build.gradle.kts +++ b/demo/build.gradle.kts @@ -44,6 +44,10 @@ android { } } + buildFeatures { + viewBinding = true + } + kotlinOptions { jvmTarget = "17" } diff --git a/demo/src/main/AndroidManifest.xml b/demo/src/main/AndroidManifest.xml index 4d359efca..5a2120d51 100644 --- a/demo/src/main/AndroidManifest.xml +++ b/demo/src/main/AndroidManifest.xml @@ -70,6 +70,9 @@ + diff --git a/demo/src/main/java/com/google/maps/android/utils/demo/MainActivity.java b/demo/src/main/java/com/google/maps/android/utils/demo/MainActivity.java index fa0ec0e42..b7a6d0c7d 100644 --- a/demo/src/main/java/com/google/maps/android/utils/demo/MainActivity.java +++ b/demo/src/main/java/com/google/maps/android/utils/demo/MainActivity.java @@ -77,6 +77,7 @@ protected void onCreate(Bundle savedInstanceState) { addDemo("Clustering: Force on Zoom", ZoomClusteringDemoActivity.class); addDemo("PolyUtil.decode", PolyDecodeDemoActivity.class); addDemo("PolyUtil.simplify", PolySimplifyDemoActivity.class); + addDemo("Polyline Progress", PolylineProgressDemoActivity.class); addDemo("IconGenerator", IconGeneratorDemoActivity.class); addDemo("SphericalUtil.computeDistanceBetween", DistanceDemoActivity.class); addDemo("Generating tiles", TileProviderAndProjectionDemo.class); diff --git a/demo/src/main/java/com/google/maps/android/utils/demo/PolySimplifyDemoActivity.java b/demo/src/main/java/com/google/maps/android/utils/demo/PolySimplifyDemoActivity.java index 65cf52056..c021077bb 100644 --- a/demo/src/main/java/com/google/maps/android/utils/demo/PolySimplifyDemoActivity.java +++ b/demo/src/main/java/com/google/maps/android/utils/demo/PolySimplifyDemoActivity.java @@ -1,3 +1,16 @@ +// Copyright 2025 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. /* * Copyright 2015 Sean J. Barbeau * diff --git a/demo/src/main/java/com/google/maps/android/utils/demo/PolylineProgressDemoActivity.kt b/demo/src/main/java/com/google/maps/android/utils/demo/PolylineProgressDemoActivity.kt new file mode 100644 index 000000000..a36694b77 --- /dev/null +++ b/demo/src/main/java/com/google/maps/android/utils/demo/PolylineProgressDemoActivity.kt @@ -0,0 +1,231 @@ +/* + * Copyright 2025 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.utils.demo + +import android.graphics.Canvas +import android.graphics.Color +import android.view.ViewGroup +import android.widget.SeekBar +import androidx.core.content.ContextCompat +import androidx.core.graphics.createBitmap +import androidx.core.graphics.toColorInt +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.lifecycleScope +import com.google.android.gms.maps.CameraUpdateFactory +import com.google.android.gms.maps.model.BitmapDescriptor +import com.google.android.gms.maps.model.BitmapDescriptorFactory +import com.google.android.gms.maps.model.LatLng +import com.google.android.gms.maps.model.LatLngBounds +import com.google.android.gms.maps.model.Marker +import com.google.android.gms.maps.model.MarkerOptions +import com.google.android.gms.maps.model.Polyline +import com.google.android.gms.maps.model.PolylineOptions +import com.google.maps.android.SphericalUtil +import com.google.maps.android.utils.demo.databinding.ActivityPolylineProgressDemoBinding +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +/** + * This demo showcases how to animate a marker along a geodesic polyline, illustrating + * key features of the Android Maps Utils library and modern Android development practices. + */ +class PolylineProgressDemoActivity : BaseDemoActivity(), SeekBar.OnSeekBarChangeListener { + + companion object { + private const val POLYLINE_WIDTH = 15f + private const val PROGRESS_POLYLINE_WIDTH = 7f + private const val ANIMATION_STEP_SIZE = 1 + private const val ANIMATION_DELAY_MS = 75L + } + + private lateinit var binding: ActivityPolylineProgressDemoBinding + private lateinit var originalPolyline: Polyline + private var progressPolyline: Polyline? = null + private var progressMarker: Marker? = null + + private val planeIcon: BitmapDescriptor by lazy { + bitmapDescriptorFromVector(R.drawable.baseline_airplanemode_active_24, "#FFD700".toColorInt()) + } + + private data class AnimationState(val progress: Int, val direction: Int) + + private val animationState = MutableLiveData() + private var animationJob: Job? = null + + private val polylinePoints = listOf( + LatLng(40.7128, -74.0060), // New York + LatLng(47.6062, -122.3321), // Seattle + LatLng(39.7392, -104.9903), // Denver + LatLng(37.7749, -122.4194), // San Francisco + LatLng(34.0522, -118.2437), // Los Angeles + LatLng(41.8781, -87.6298), // Chicago + LatLng(29.7604, -95.3698), // Houston + LatLng(39.9526, -75.1652) // Philadelphia + ) + + override fun getLayoutId(): Int = R.layout.activity_polyline_progress_demo + + /** + * This is where the demo begins. It is called from the base activity's `onMapReady` callback. + */ + override fun startDemo(isRestore: Boolean) { + // The layout is already inflated by the base class. We can now bind to it. + val rootView = (findViewById(android.R.id.content)).getChildAt(0) + binding = ActivityPolylineProgressDemoBinding.bind(rootView) + + setupMap() + setupUI() + // Set the initial state. The observer in setupUI will handle the first UI update. + animationState.value = AnimationState(progress = 0, direction = 1) + startAnimation() + } + + private fun setupMap() { + originalPolyline = map.addPolyline( + PolylineOptions() + .addAll(polylinePoints) + .color(Color.GRAY) + .width(POLYLINE_WIDTH) + .geodesic(true) // A geodesic polyline follows the curvature of the Earth. + ) + + val bounds = LatLngBounds.builder().apply { + polylinePoints.forEach { include(it) } + }.build() + map.moveCamera(CameraUpdateFactory.newLatLngBounds(bounds, 100)) + } + + private fun setupUI() { + binding.seekBar.setOnSeekBarChangeListener(this) + binding.resetButton.setOnClickListener { + stopAnimation() + animationState.value = AnimationState(progress = 0, direction = 1) + startAnimation() + } + binding.pauseButton.setOnClickListener { + if (animationJob?.isActive == true) { + stopAnimation() + } else { + startAnimation() + } + } + + animationState.observe(this) { state -> + binding.seekBar.progress = state.progress + binding.percentageTextView.text = getString(R.string.percentage_format, state.progress) + updateProgressOnMap(state.progress / 100.0, state.direction) + } + } + + private fun startAnimation() { + stopAnimation() + val currentState = animationState.value ?: return + + animationJob = lifecycleScope.launch { + var progress = currentState.progress + var direction = currentState.direction + while (true) { + progress = when { + progress > 100 -> { + direction = -1 + 100 + } + progress < 0 -> { + direction = 1 + 0 + } + else -> progress + direction * ANIMATION_STEP_SIZE + } + + animationState.postValue(AnimationState(progress, direction)) + delay(ANIMATION_DELAY_MS) + } + } + } + + private fun stopAnimation() { + animationJob?.cancel() + animationJob = null + } + + override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) { + if (fromUser) { + stopAnimation() + animationState.value = AnimationState(progress, animationState.value?.direction ?: 1) + } + } + + override fun onStartTrackingTouch(seekBar: SeekBar?) { /* No-op */ } + + override fun onStopTrackingTouch(seekBar: SeekBar?) { /* No-op */ } + + private fun updateProgressOnMap(percentage: Double, direction: Int) { + progressPolyline?.remove() + + val prefix = SphericalUtil.getPolylinePrefix(polylinePoints, percentage) + if (prefix.isNotEmpty()) { + progressPolyline = map.addPolyline( + PolylineOptions() + .addAll(prefix) + .color(Color.BLUE) + .width(PROGRESS_POLYLINE_WIDTH) + .zIndex(1f) + .geodesic(true) + ) + } + + SphericalUtil.getPointOnPolyline(polylinePoints, percentage)?.let { point -> + updateMarker(point, percentage, direction) + } + } + + private fun updateMarker(point: LatLng, percentage: Double, direction: Int) { + val heading = SphericalUtil.getPointOnPolyline(polylinePoints, percentage + 0.0001) + ?.let { SphericalUtil.computeHeading(point, it) } + ?.let { if (direction == -1) it + 180 else it } // Adjust for reverse direction. + + if (progressMarker == null) { + progressMarker = map.addMarker( + MarkerOptions() + .position(point) + .flat(true) + .draggable(false) + .icon(planeIcon) + .apply { heading?.let { rotation(it.toFloat()) } } + ) + } else { + progressMarker?.also { + it.position = point + heading?.let { newHeading -> it.rotation = newHeading.toFloat() } + } + } + } + + private fun bitmapDescriptorFromVector(vectorResId: Int, color: Int): BitmapDescriptor { + val vectorDrawable = ContextCompat.getDrawable(this, vectorResId)!! + vectorDrawable.setTint(color) + val bitmap = createBitmap( + vectorDrawable.intrinsicWidth, + vectorDrawable.intrinsicHeight + ) + val canvas = Canvas(bitmap) + vectorDrawable.setBounds(0, 0, canvas.width, canvas.height) + vectorDrawable.draw(canvas) + return BitmapDescriptorFactory.fromBitmap(bitmap) + } +} diff --git a/demo/src/main/res/drawable/baseline_airplanemode_active_24.xml b/demo/src/main/res/drawable/baseline_airplanemode_active_24.xml new file mode 100644 index 000000000..fefccaeaa --- /dev/null +++ b/demo/src/main/res/drawable/baseline_airplanemode_active_24.xml @@ -0,0 +1,22 @@ + + + + + + + + diff --git a/demo/src/main/res/layout/activity_polyline_progress_demo.xml b/demo/src/main/res/layout/activity_polyline_progress_demo.xml new file mode 100644 index 000000000..4247ce089 --- /dev/null +++ b/demo/src/main/res/layout/activity_polyline_progress_demo.xml @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + +