From 85db3abd065a1c639160144ce9bec26dc359be46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20Lo=CC=81pez=20Man=CC=83as?= Date: Tue, 8 Sep 2026 22:01:16 +0700 Subject: [PATCH 1/5] feat: experimental KMP support for clustering module Introduces a maps-model multiplatform module with common LatLng and CameraPosition types (typealiased to the Play Services classes on Android, plain value holders on iOS) and converts the clustering module to Kotlin Multiplatform: the full algorithm layer (quadtree, geometry, projection, all clustering algorithms) now lives in commonMain and compiles for Android and iOS, while ClusterManager and the renderers remain Android-only in androidMain. JVM-only constructs in common code were replaced with multiplatform equivalents: an expect/actual PlatformLock replaces synchronized blocks and ReentrantReadWriteLock, java.util collections were swapped for Kotlin stdlib ones, and Math.* calls for kotlin.math. The three remaining Java test files were converted to Kotlin because KMP compilations do not compile Java host-test sources. Known gaps (prototype): publishing, jacoco, lint-checks and consumer proguard rules are not yet wired for the KMP module layout. Claude-Session: https://claude.ai/code/session_01225X6MnAqkyCF7Xones6WY --- clustering/build.gradle.kts | 111 ++++---- .../android/clustering/ClusterManagerTest.kt | 0 .../maps/android/clustering/QuadItemTest.kt | 227 ++++++++++++++++ .../android/clustering/StaticClusterTest.kt | 0 .../clustering/algo/AbstractAlgorithmTest.kt | 0 .../android/clustering/algo/BenchmarkTest.kt | 0 ...nHierarchicalDistanceBasedAlgorithmTest.kt | 56 ++++ ...nuousZoomEuclideanCentroidAlgorithmTest.kt | 88 ++++++ .../clustering/algo/GridBasedAlgorithmTest.kt | 0 .../NonHierarchicalViewBasedAlgorithmTest.kt | 0 .../algo/PreCachingAlgorithmDecoratorTest.kt | 0 .../algo/ScreenBasedAlgorithmAdapterTest.kt | 0 .../maps/android/geometry/BoundsTest.kt | 0 .../google/maps/android/geometry/PointTest.kt | 0 .../maps/android/projection/PointTest.kt | 0 .../SphericalMercatorProjectionTest.kt | 0 .../android/quadtree/PointQuadTreeTest.kt | 0 .../resources/robolectric.properties | 0 .../{main => androidMain}/AndroidManifest.xml | 0 .../maps/android/clustering/ClusterManager.kt | 0 .../clustering/algo/PlatformLock.android.kt | 26 ++ .../algo/PreCachingAlgorithmDecorator.kt | 0 .../clustering/view/ClusterRenderer.kt | 0 .../view/ClusterRendererMultipleItems.kt | 0 .../DefaultAdvancedMarkersClusterRenderer.kt | 0 .../clustering/view/DefaultClusterRenderer.kt | 0 .../google/maps/android/clustering/Cluster.kt | 2 +- .../maps/android/clustering/ClusterItem.kt | 2 +- .../clustering/algo/AbstractAlgorithm.kt | 8 +- .../maps/android/clustering/algo/Algorithm.kt | 0 ...idNonHierarchicalDistanceBasedAlgorithm.kt | 5 +- ...ontinuousZoomEuclideanCentroidAlgorithm.kt | 5 +- .../clustering/algo/GridBasedAlgorithm.kt | 20 +- .../NonHierarchicalDistanceBasedAlgorithm.kt | 36 +-- .../algo/NonHierarchicalViewBasedAlgorithm.kt | 8 +- .../android/clustering/algo/PlatformLock.kt | 43 +++ .../clustering/algo/ScreenBasedAlgorithm.kt | 2 +- .../algo/ScreenBasedAlgorithmAdapter.kt | 2 +- .../android/clustering/algo/StaticCluster.kt | 2 +- .../google/maps/android/geometry/Bounds.kt | 2 + .../com/google/maps/android/geometry/Point.kt | 4 +- .../google/maps/android/projection/Point.kt | 0 .../projection/SphericalMercatorProjection.kt | 8 +- .../maps/android/quadtree/PointQuadTree.kt | 1 + .../clustering/algo/PlatformLock.ios.kt | 26 ++ .../maps/android/clustering/QuadItemTest.java | 252 ------------------ ...ierarchicalDistanceBasedAlgorithmTest.java | 74 ----- ...ousZoomEuclideanCentroidAlgorithmTest.java | 115 -------- gradle/libs.versions.toml | 1 + maps-model/build.gradle.kts | 41 +++ .../maps/android/model/Model.android.kt | 34 +++ .../maps/android/model/CameraPosition.kt | 31 +++ .../com/google/maps/android/model/LatLng.kt | 35 +++ .../google/maps/android/model/Model.ios.kt | 60 +++++ settings.gradle.kts | 2 +- 55 files changed, 772 insertions(+), 557 deletions(-) rename clustering/src/{test/java => androidHostTest/kotlin}/com/google/maps/android/clustering/ClusterManagerTest.kt (100%) create mode 100644 clustering/src/androidHostTest/kotlin/com/google/maps/android/clustering/QuadItemTest.kt rename clustering/src/{test/java => androidHostTest/kotlin}/com/google/maps/android/clustering/StaticClusterTest.kt (100%) rename clustering/src/{test/java => androidHostTest/kotlin}/com/google/maps/android/clustering/algo/AbstractAlgorithmTest.kt (100%) rename clustering/src/{test/java => androidHostTest/kotlin}/com/google/maps/android/clustering/algo/BenchmarkTest.kt (100%) create mode 100644 clustering/src/androidHostTest/kotlin/com/google/maps/android/clustering/algo/CentroidNonHierarchicalDistanceBasedAlgorithmTest.kt create mode 100644 clustering/src/androidHostTest/kotlin/com/google/maps/android/clustering/algo/ContinuousZoomEuclideanCentroidAlgorithmTest.kt rename clustering/src/{test/java => androidHostTest/kotlin}/com/google/maps/android/clustering/algo/GridBasedAlgorithmTest.kt (100%) rename clustering/src/{test/java => androidHostTest/kotlin}/com/google/maps/android/clustering/algo/NonHierarchicalViewBasedAlgorithmTest.kt (100%) rename clustering/src/{test/java => androidHostTest/kotlin}/com/google/maps/android/clustering/algo/PreCachingAlgorithmDecoratorTest.kt (100%) rename clustering/src/{test/java => androidHostTest/kotlin}/com/google/maps/android/clustering/algo/ScreenBasedAlgorithmAdapterTest.kt (100%) rename clustering/src/{test/java => androidHostTest/kotlin}/com/google/maps/android/geometry/BoundsTest.kt (100%) rename clustering/src/{test/java => androidHostTest/kotlin}/com/google/maps/android/geometry/PointTest.kt (100%) rename clustering/src/{test/java => androidHostTest/kotlin}/com/google/maps/android/projection/PointTest.kt (100%) rename clustering/src/{test/java => androidHostTest/kotlin}/com/google/maps/android/projection/SphericalMercatorProjectionTest.kt (100%) rename clustering/src/{test/java => androidHostTest/kotlin}/com/google/maps/android/quadtree/PointQuadTreeTest.kt (100%) rename clustering/src/{test => androidHostTest}/resources/robolectric.properties (100%) rename clustering/src/{main => androidMain}/AndroidManifest.xml (100%) rename clustering/src/{main/java => androidMain/kotlin}/com/google/maps/android/clustering/ClusterManager.kt (100%) create mode 100644 clustering/src/androidMain/kotlin/com/google/maps/android/clustering/algo/PlatformLock.android.kt rename clustering/src/{main/java => androidMain/kotlin}/com/google/maps/android/clustering/algo/PreCachingAlgorithmDecorator.kt (100%) rename clustering/src/{main/java => androidMain/kotlin}/com/google/maps/android/clustering/view/ClusterRenderer.kt (100%) rename clustering/src/{main/java => androidMain/kotlin}/com/google/maps/android/clustering/view/ClusterRendererMultipleItems.kt (100%) rename clustering/src/{main/java => androidMain/kotlin}/com/google/maps/android/clustering/view/DefaultAdvancedMarkersClusterRenderer.kt (100%) rename clustering/src/{main/java => androidMain/kotlin}/com/google/maps/android/clustering/view/DefaultClusterRenderer.kt (100%) rename clustering/src/{main/java => commonMain/kotlin}/com/google/maps/android/clustering/Cluster.kt (94%) rename clustering/src/{main/java => commonMain/kotlin}/com/google/maps/android/clustering/ClusterItem.kt (95%) rename clustering/src/{main/java => commonMain/kotlin}/com/google/maps/android/clustering/algo/AbstractAlgorithm.kt (79%) rename clustering/src/{main/java => commonMain/kotlin}/com/google/maps/android/clustering/algo/Algorithm.kt (100%) rename clustering/src/{main/java => commonMain/kotlin}/com/google/maps/android/clustering/algo/CentroidNonHierarchicalDistanceBasedAlgorithm.kt (95%) rename clustering/src/{main/java => commonMain/kotlin}/com/google/maps/android/clustering/algo/ContinuousZoomEuclideanCentroidAlgorithm.kt (97%) rename clustering/src/{main/java => commonMain/kotlin}/com/google/maps/android/clustering/algo/GridBasedAlgorithm.kt (87%) rename clustering/src/{main/java => commonMain/kotlin}/com/google/maps/android/clustering/algo/NonHierarchicalDistanceBasedAlgorithm.kt (91%) rename clustering/src/{main/java => commonMain/kotlin}/com/google/maps/android/clustering/algo/NonHierarchicalViewBasedAlgorithm.kt (94%) create mode 100644 clustering/src/commonMain/kotlin/com/google/maps/android/clustering/algo/PlatformLock.kt rename clustering/src/{main/java => commonMain/kotlin}/com/google/maps/android/clustering/algo/ScreenBasedAlgorithm.kt (95%) rename clustering/src/{main/java => commonMain/kotlin}/com/google/maps/android/clustering/algo/ScreenBasedAlgorithmAdapter.kt (97%) rename clustering/src/{main/java => commonMain/kotlin}/com/google/maps/android/clustering/algo/StaticCluster.kt (97%) rename clustering/src/{main/java => commonMain/kotlin}/com/google/maps/android/geometry/Bounds.kt (98%) rename clustering/src/{main/java => commonMain/kotlin}/com/google/maps/android/geometry/Point.kt (92%) rename clustering/src/{main/java => commonMain/kotlin}/com/google/maps/android/projection/Point.kt (100%) rename clustering/src/{main/java => commonMain/kotlin}/com/google/maps/android/projection/SphericalMercatorProjection.kt (83%) rename clustering/src/{main/java => commonMain/kotlin}/com/google/maps/android/quadtree/PointQuadTree.kt (99%) create mode 100644 clustering/src/iosMain/kotlin/com/google/maps/android/clustering/algo/PlatformLock.ios.kt delete mode 100644 clustering/src/test/java/com/google/maps/android/clustering/QuadItemTest.java delete mode 100644 clustering/src/test/java/com/google/maps/android/clustering/algo/CentroidNonHierarchicalDistanceBasedAlgorithmTest.java delete mode 100644 clustering/src/test/java/com/google/maps/android/clustering/algo/ContinuousZoomEuclideanCentroidAlgorithmTest.java create mode 100644 maps-model/build.gradle.kts create mode 100644 maps-model/src/androidMain/kotlin/com/google/maps/android/model/Model.android.kt create mode 100644 maps-model/src/commonMain/kotlin/com/google/maps/android/model/CameraPosition.kt create mode 100644 maps-model/src/commonMain/kotlin/com/google/maps/android/model/LatLng.kt create mode 100644 maps-model/src/iosMain/kotlin/com/google/maps/android/model/Model.ios.kt diff --git a/clustering/build.gradle.kts b/clustering/build.gradle.kts index a17bcc2f1..c915ca9ec 100644 --- a/clustering/build.gradle.kts +++ b/clustering/build.gradle.kts @@ -1,5 +1,3 @@ -import org.jetbrains.kotlin.gradle.dsl.JvmTarget - /** * Copyright 2026 Google LLC * @@ -16,79 +14,60 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget * limitations under the License. */ plugins { - + id("org.jetbrains.kotlin.multiplatform") + id("com.android.kotlin.multiplatform.library") id("org.jetbrains.dokka") - id("android.maps.utils.PublishingConventionPlugin") } -android { - lint { - sarifOutput = layout.buildDirectory.file("reports/lint-results.sarif").get().asFile - } - defaultConfig { +// NOTE (KMP prototype): the module previously applied android.maps.utils.PublishingConventionPlugin, +// which is hard-wired to com.android.library + AndroidSingleVariantLibrary publishing. A KMP-aware +// variant (vanniktech KotlinMultiplatform() publishing + jacoco for the android target) is needed +// before this module can be released from this branch. Lint publishing (lint-checks), the amu_ +// resourcePrefix and consumer proguard rules from the old build also need re-wiring. + +kotlin { + jvmToolchain(17) + + androidLibrary { + namespace = "com.google.maps.android.clustering" compileSdk = libs.versions.compileSdk.get().toInt() minSdk = 23 - testOptions.targetSdk = libs.versions.targetSdk.get().toInt() - consumerProguardFiles("consumer-rules.pro") - } - buildTypes { - release { - isMinifyEnabled = false - proguardFiles( - getDefaultProguardFile("proguard-android-optimize.txt"), - "proguard-rules.pro" - ) + + withHostTestBuilder { + }.configure { + isIncludeAndroidResources = true + isReturnDefaultValues = true } } - resourcePrefix = "amu_" - installation { - timeOutInMs = 10 * 60 * 1000 // 10 minutes - installOptions += listOf("-d", "-t") - } + iosArm64() + iosSimulatorArm64() + iosX64() - kotlin { - compilerOptions { - jvmTarget.set(JvmTarget.JVM_17) + sourceSets { + commonMain.dependencies { + api(project(":maps-model")) + // androidx.collection is multiplatform; LongSparseArray/LruCache work in common code + implementation(libs.androidx.collection) + } + androidMain.dependencies { + implementation(project(":ui")) + implementation(project(":library")) + implementation(project(":data")) + api(libs.play.services.maps) + implementation(libs.kotlinx.coroutines.android) + implementation(libs.appcompat) + implementation(libs.core.ktx) + } + getByName("androidHostTest").dependencies { + implementation(libs.junit) + implementation(libs.robolectric) + implementation(libs.kxml2) + implementation(libs.mockk) + implementation(libs.kotlin.test) + implementation(libs.truth) + implementation(libs.kotlinx.coroutines.test) + implementation(libs.mockito.core) } - jvmToolchain(17) - } - - testOptions { - animationsDisabled = true - unitTests.isIncludeAndroidResources = true - unitTests.isReturnDefaultValues = true } - namespace = "com.google.maps.android.clustering" -} - -dependencies { - implementation(project(":ui")) - implementation(project(":library")) - implementation(project(":data")) - api(libs.play.services.maps) - implementation(libs.kotlinx.coroutines.android) - implementation(libs.appcompat) - implementation(libs.core.ktx) - lintPublish(project(":lint-checks")) - testImplementation(libs.junit) - testImplementation(libs.robolectric) - testImplementation(libs.kxml2) - testImplementation(libs.mockk) - testImplementation(libs.kotlin.test) - testImplementation(libs.truth) - implementation(libs.kotlin.stdlib.jdk8) - - testImplementation(libs.mockk) - testImplementation(libs.kotlinx.coroutines.test) - testImplementation(libs.robolectric) - testImplementation(libs.mockito.core) -} - -tasks.register("instrumentTest") { - dependsOn("connectedCheck") -} - -if (System.getenv("JITPACK") != null) { - apply(plugin = "maven") } diff --git a/clustering/src/test/java/com/google/maps/android/clustering/ClusterManagerTest.kt b/clustering/src/androidHostTest/kotlin/com/google/maps/android/clustering/ClusterManagerTest.kt similarity index 100% rename from clustering/src/test/java/com/google/maps/android/clustering/ClusterManagerTest.kt rename to clustering/src/androidHostTest/kotlin/com/google/maps/android/clustering/ClusterManagerTest.kt diff --git a/clustering/src/androidHostTest/kotlin/com/google/maps/android/clustering/QuadItemTest.kt b/clustering/src/androidHostTest/kotlin/com/google/maps/android/clustering/QuadItemTest.kt new file mode 100644 index 000000000..92b89ce48 --- /dev/null +++ b/clustering/src/androidHostTest/kotlin/com/google/maps/android/clustering/QuadItemTest.kt @@ -0,0 +1,227 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.maps.android.clustering + +import com.google.android.gms.maps.model.LatLng +import com.google.maps.android.clustering.algo.NonHierarchicalDistanceBasedAlgorithm +import com.google.maps.android.geometry.Bounds +import com.google.maps.android.projection.SphericalMercatorProjection +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class QuadItemTest { + @Test + fun testAddRemoveUpdateClear() { + val item15: ClusterItem = TestingItem("title1", 0.1, 0.5) + val item23 = TestingItem("title2", 0.2, 0.3) + + val algo = NonHierarchicalDistanceBasedAlgorithm() + assertTrue(algo.addItem(item15)) + assertTrue(algo.addItem(item23)) + + assertEquals(2, algo.items.size) + + assertTrue(algo.removeItem(item15)) + + assertEquals(1, algo.items.size) + + assertFalse(algo.items.contains(item15)) + assertTrue(algo.items.contains(item23)) + + // Update the item still in the algorithm + item23.title = "newTitle" + assertTrue(algo.updateItem(item23)) + + // Try to remove the item that was already removed + assertFalse(algo.removeItem(item15)) + + // Try to update the item that was already removed + assertFalse(algo.updateItem(item15)) + + algo.clearItems() + assertEquals(0, algo.items.size) + + // Test bulk operations + val items = listOf(item15, item23) + assertTrue(algo.addItems(items)) + + // Try to bulk add items that were already added + assertFalse(algo.addItems(items)) + + assertTrue(algo.removeItems(items)) + + // Try to bulk remove items that were already removed + assertFalse(algo.removeItems(items)) + } + + /** + * Test if insertion order into the algorithm is the same as returned item order. This matters + * because we want repeatable clustering behavior when updating model values and re-clustering. + */ + @Test + fun testInsertionOrder() { + val algo = NonHierarchicalDistanceBasedAlgorithm() + for (i in 0 until 100) { + algo.addItem(TestingItem(i.toString(), 0.0, 0.0)) + } + + assertEquals(100, algo.items.size) + + var counter = 0 + for (item in algo.items) { + assertEquals(counter.toString(), item.title) + counter++ + } + } + + @Test + fun testUpdateItemAfterPositionChange() { + val algo = NonHierarchicalDistanceBasedAlgorithm() + val item = TestingItem("title1", 0.0, 0.0) + algo.addItem(item) + assertEquals(1, algo.items.size) + + // Update the position of the mutable item + item.setPosition(10.0, 10.0) + + // Call updateItem + assertTrue("updateItem should return true after position change", algo.updateItem(item)) + assertEquals(1, algo.items.size) + + // Verify that the old QuadItem at (0, 0) was removed from the tree + // and only the new position (10, 10) is indexed + val clusters = algo.getClusters(4.0f) + assertEquals(1, clusters.size) + val cluster = clusters.iterator().next() + assertEquals(10.0, cluster.position.latitude, 0.001) + assertEquals(10.0, cluster.position.longitude, 0.001) + } + + @Test + fun testRemoveItemAfterPositionChange() { + val algo = NonHierarchicalDistanceBasedAlgorithm() + val item = TestingItem("title1", 0.0, 0.0) + algo.addItem(item) + assertEquals(1, algo.items.size) + + // Update the position of the mutable item + item.setPosition(10.0, 10.0) + + // Removing the item should succeed and remove it from the tree + assertTrue("removeItem should return true after position change", algo.removeItem(item)) + assertEquals(0, algo.items.size) + assertEquals(0, algo.getClusters(4.0f).size) + } + + @Test + fun testUpdateItemPreventsStaleQuadTreeEntries() { + val algo = TestAlgorithm() + + // Add 60 filler items to force PointQuadTree to split (MAX_ELEMENTS = 50) + for (i in 0 until 60) { + algo.addItem(TestingItem("filler$i", 10.0 + i * 0.001, 10.0 + i * 0.001)) + } + + // Add item1 in top-left quadrant + val item1 = TestingItem("item1", 1.0, 1.0) + algo.addItem(item1) + + assertEquals( + "QuadTree should contain item1 at (1, 1)", + 1, + algo.getQuadTreeItemCount(1.0, 1.0, 0.001), + ) + + // Move item1 far across quadrant boundary to (50.0, 50.0) and update + item1.setPosition(50.0, 50.0) + algo.updateItem(item1) + + // Without fix, old QuadItem remains at (1.0, 1.0) in mQuadTree because remove traversed the new coordinates + assertEquals( + "QuadTree should NOT contain stale entry at (1, 1) after update", + 0, + algo.getQuadTreeItemCount(1.0, 1.0, 0.001), + ) + assertEquals( + "QuadTree should contain item1 at (50, 50)", + 1, + algo.getQuadTreeItemCount(50.0, 50.0, 0.001), + ) + } + + @Test + fun testRemoveItemsAfterPositionChange() { + val algo = NonHierarchicalDistanceBasedAlgorithm() + val item1 = TestingItem("title1", 0.0, 0.0) + val item2 = TestingItem("title2", 1.0, 1.0) + algo.addItems(listOf(item1, item2)) + assertEquals(2, algo.items.size) + + // Update the position of both items + item1.setPosition(10.0, 10.0) + item2.setPosition(20.0, 20.0) + + assertTrue( + "removeItems should return true after position change", + algo.removeItems(listOf(item1, item2)), + ) + assertEquals(0, algo.items.size) + assertEquals(0, algo.getClusters(4.0f).size) + } + + @Test + fun testClearItemsAfterPositionChange() { + val algo = NonHierarchicalDistanceBasedAlgorithm() + val item1 = TestingItem("title1", 0.0, 0.0) + algo.addItem(item1) + item1.setPosition(10.0, 10.0) + + algo.clearItems() + assertEquals(0, algo.items.size) + assertEquals(0, algo.getClusters(4.0f).size) + } + + private class TestAlgorithm : NonHierarchicalDistanceBasedAlgorithm() { + fun getQuadTreeItemCount(lat: Double, lng: Double, span: Double): Int { + val p = PROJ.toPoint(LatLng(lat, lng)) + val bounds = Bounds(p.x - span, p.x + span, p.y - span, p.y + span) + return mQuadTree.search(bounds).size + } + + companion object { + private val PROJ = SphericalMercatorProjection(1.0) + } + } + + private class TestingItem( + override var title: String, + lat: Double, + lng: Double, + ) : ClusterItem { + override var position: LatLng = LatLng(lat, lng) + private set + + fun setPosition(lat: Double, lng: Double) { + position = LatLng(lat, lng) + } + + override val snippet: String? = null + + override val zIndex: Float? = null + } +} diff --git a/clustering/src/test/java/com/google/maps/android/clustering/StaticClusterTest.kt b/clustering/src/androidHostTest/kotlin/com/google/maps/android/clustering/StaticClusterTest.kt similarity index 100% rename from clustering/src/test/java/com/google/maps/android/clustering/StaticClusterTest.kt rename to clustering/src/androidHostTest/kotlin/com/google/maps/android/clustering/StaticClusterTest.kt diff --git a/clustering/src/test/java/com/google/maps/android/clustering/algo/AbstractAlgorithmTest.kt b/clustering/src/androidHostTest/kotlin/com/google/maps/android/clustering/algo/AbstractAlgorithmTest.kt similarity index 100% rename from clustering/src/test/java/com/google/maps/android/clustering/algo/AbstractAlgorithmTest.kt rename to clustering/src/androidHostTest/kotlin/com/google/maps/android/clustering/algo/AbstractAlgorithmTest.kt diff --git a/clustering/src/test/java/com/google/maps/android/clustering/algo/BenchmarkTest.kt b/clustering/src/androidHostTest/kotlin/com/google/maps/android/clustering/algo/BenchmarkTest.kt similarity index 100% rename from clustering/src/test/java/com/google/maps/android/clustering/algo/BenchmarkTest.kt rename to clustering/src/androidHostTest/kotlin/com/google/maps/android/clustering/algo/BenchmarkTest.kt diff --git a/clustering/src/androidHostTest/kotlin/com/google/maps/android/clustering/algo/CentroidNonHierarchicalDistanceBasedAlgorithmTest.kt b/clustering/src/androidHostTest/kotlin/com/google/maps/android/clustering/algo/CentroidNonHierarchicalDistanceBasedAlgorithmTest.kt new file mode 100644 index 000000000..f4811e49c --- /dev/null +++ b/clustering/src/androidHostTest/kotlin/com/google/maps/android/clustering/algo/CentroidNonHierarchicalDistanceBasedAlgorithmTest.kt @@ -0,0 +1,56 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.maps.android.clustering.algo + +import com.google.android.gms.maps.model.LatLng +import com.google.maps.android.clustering.ClusterItem +import org.junit.Assert.assertEquals +import org.junit.Test + +class CentroidNonHierarchicalDistanceBasedAlgorithmTest { + class TestClusterItem(lat: Double, lng: Double) : ClusterItem { + override val position: LatLng = LatLng(lat, lng) + + override val title: String? = null + + override val snippet: String? = null + + override val zIndex: Float = 0f + } + + // computeCentroid is protected; the previous Java test relied on JVM package-level + // access, which Kotlin does not have. + private class ExposedAlgorithm : CentroidNonHierarchicalDistanceBasedAlgorithm() { + fun centroidOf(items: Collection): LatLng = computeCentroid(items) + } + + @Test + fun testComputeCentroid() { + val algo = ExposedAlgorithm() + + val items = + listOf( + TestClusterItem(10.0, 20.0), + TestClusterItem(20.0, 30.0), + TestClusterItem(30.0, 40.0), + ) + + val centroid = algo.centroidOf(items) + + assertEquals(20.0, centroid.latitude, 0.0001) + assertEquals(30.0, centroid.longitude, 0.0001) + } +} diff --git a/clustering/src/androidHostTest/kotlin/com/google/maps/android/clustering/algo/ContinuousZoomEuclideanCentroidAlgorithmTest.kt b/clustering/src/androidHostTest/kotlin/com/google/maps/android/clustering/algo/ContinuousZoomEuclideanCentroidAlgorithmTest.kt new file mode 100644 index 000000000..73f88fdcd --- /dev/null +++ b/clustering/src/androidHostTest/kotlin/com/google/maps/android/clustering/algo/ContinuousZoomEuclideanCentroidAlgorithmTest.kt @@ -0,0 +1,88 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.maps.android.clustering.algo + +import com.google.android.gms.maps.model.LatLng +import com.google.maps.android.clustering.ClusterItem +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ContinuousZoomEuclideanCentroidAlgorithmTest { + class TestClusterItem(lat: Double, lng: Double) : ClusterItem { + override val position: LatLng = LatLng(lat, lng) + + override val title: String? = null + + override val snippet: String? = null + + override val zIndex: Float = 0f + } + + @Test + fun testContinuousZoomMergesClosePairAtLowZoomAndSeparatesAtHighZoom() { + val algo = ContinuousZoomEuclideanCentroidAlgorithm() + + val items = + listOf( + TestClusterItem(10.0, 10.0), + // very close to the first + TestClusterItem(10.0001, 10.0001), + // far away + TestClusterItem(20.0, 20.0), + ) + + algo.addItems(items) + + // At a high zoom, the close pair should be separate (small radius) + val highZoom = algo.getClusters(20.0f) + assertEquals(3, highZoom.size) + + // At a lower zoom, the close pair should merge (larger radius) + val lowZoom = algo.getClusters(5.0f) + assertTrue(lowZoom.size < 3) + + // Specifically, we expect one cluster of size 2 and one singleton + assertTrue(lowZoom.any { it.items.size == 2 }) + assertTrue(lowZoom.any { it.items.size == 1 }) + } + + @Test + fun testClusterPositionsAreCentroids() { + val algo = ContinuousZoomEuclideanCentroidAlgorithm() + + val items = + listOf( + TestClusterItem(0.0, 0.0), + TestClusterItem(0.0, 2.0), + TestClusterItem(2.0, 0.0), + ) + + algo.addItems(items) + + val clusters = algo.getClusters(1.0f) + + // Expect all items clustered into one + assertEquals(1, clusters.size) + + val cluster = clusters.iterator().next() + + // The centroid should be approximately (0.6667, 0.6667) + val centroid = cluster.position + assertEquals(0.6667, centroid.latitude, 0.0001) + assertEquals(0.6667, centroid.longitude, 0.0001) + } +} diff --git a/clustering/src/test/java/com/google/maps/android/clustering/algo/GridBasedAlgorithmTest.kt b/clustering/src/androidHostTest/kotlin/com/google/maps/android/clustering/algo/GridBasedAlgorithmTest.kt similarity index 100% rename from clustering/src/test/java/com/google/maps/android/clustering/algo/GridBasedAlgorithmTest.kt rename to clustering/src/androidHostTest/kotlin/com/google/maps/android/clustering/algo/GridBasedAlgorithmTest.kt diff --git a/clustering/src/test/java/com/google/maps/android/clustering/algo/NonHierarchicalViewBasedAlgorithmTest.kt b/clustering/src/androidHostTest/kotlin/com/google/maps/android/clustering/algo/NonHierarchicalViewBasedAlgorithmTest.kt similarity index 100% rename from clustering/src/test/java/com/google/maps/android/clustering/algo/NonHierarchicalViewBasedAlgorithmTest.kt rename to clustering/src/androidHostTest/kotlin/com/google/maps/android/clustering/algo/NonHierarchicalViewBasedAlgorithmTest.kt diff --git a/clustering/src/test/java/com/google/maps/android/clustering/algo/PreCachingAlgorithmDecoratorTest.kt b/clustering/src/androidHostTest/kotlin/com/google/maps/android/clustering/algo/PreCachingAlgorithmDecoratorTest.kt similarity index 100% rename from clustering/src/test/java/com/google/maps/android/clustering/algo/PreCachingAlgorithmDecoratorTest.kt rename to clustering/src/androidHostTest/kotlin/com/google/maps/android/clustering/algo/PreCachingAlgorithmDecoratorTest.kt diff --git a/clustering/src/test/java/com/google/maps/android/clustering/algo/ScreenBasedAlgorithmAdapterTest.kt b/clustering/src/androidHostTest/kotlin/com/google/maps/android/clustering/algo/ScreenBasedAlgorithmAdapterTest.kt similarity index 100% rename from clustering/src/test/java/com/google/maps/android/clustering/algo/ScreenBasedAlgorithmAdapterTest.kt rename to clustering/src/androidHostTest/kotlin/com/google/maps/android/clustering/algo/ScreenBasedAlgorithmAdapterTest.kt diff --git a/clustering/src/test/java/com/google/maps/android/geometry/BoundsTest.kt b/clustering/src/androidHostTest/kotlin/com/google/maps/android/geometry/BoundsTest.kt similarity index 100% rename from clustering/src/test/java/com/google/maps/android/geometry/BoundsTest.kt rename to clustering/src/androidHostTest/kotlin/com/google/maps/android/geometry/BoundsTest.kt diff --git a/clustering/src/test/java/com/google/maps/android/geometry/PointTest.kt b/clustering/src/androidHostTest/kotlin/com/google/maps/android/geometry/PointTest.kt similarity index 100% rename from clustering/src/test/java/com/google/maps/android/geometry/PointTest.kt rename to clustering/src/androidHostTest/kotlin/com/google/maps/android/geometry/PointTest.kt diff --git a/clustering/src/test/java/com/google/maps/android/projection/PointTest.kt b/clustering/src/androidHostTest/kotlin/com/google/maps/android/projection/PointTest.kt similarity index 100% rename from clustering/src/test/java/com/google/maps/android/projection/PointTest.kt rename to clustering/src/androidHostTest/kotlin/com/google/maps/android/projection/PointTest.kt diff --git a/clustering/src/test/java/com/google/maps/android/projection/SphericalMercatorProjectionTest.kt b/clustering/src/androidHostTest/kotlin/com/google/maps/android/projection/SphericalMercatorProjectionTest.kt similarity index 100% rename from clustering/src/test/java/com/google/maps/android/projection/SphericalMercatorProjectionTest.kt rename to clustering/src/androidHostTest/kotlin/com/google/maps/android/projection/SphericalMercatorProjectionTest.kt diff --git a/clustering/src/test/java/com/google/maps/android/quadtree/PointQuadTreeTest.kt b/clustering/src/androidHostTest/kotlin/com/google/maps/android/quadtree/PointQuadTreeTest.kt similarity index 100% rename from clustering/src/test/java/com/google/maps/android/quadtree/PointQuadTreeTest.kt rename to clustering/src/androidHostTest/kotlin/com/google/maps/android/quadtree/PointQuadTreeTest.kt diff --git a/clustering/src/test/resources/robolectric.properties b/clustering/src/androidHostTest/resources/robolectric.properties similarity index 100% rename from clustering/src/test/resources/robolectric.properties rename to clustering/src/androidHostTest/resources/robolectric.properties diff --git a/clustering/src/main/AndroidManifest.xml b/clustering/src/androidMain/AndroidManifest.xml similarity index 100% rename from clustering/src/main/AndroidManifest.xml rename to clustering/src/androidMain/AndroidManifest.xml diff --git a/clustering/src/main/java/com/google/maps/android/clustering/ClusterManager.kt b/clustering/src/androidMain/kotlin/com/google/maps/android/clustering/ClusterManager.kt similarity index 100% rename from clustering/src/main/java/com/google/maps/android/clustering/ClusterManager.kt rename to clustering/src/androidMain/kotlin/com/google/maps/android/clustering/ClusterManager.kt diff --git a/clustering/src/androidMain/kotlin/com/google/maps/android/clustering/algo/PlatformLock.android.kt b/clustering/src/androidMain/kotlin/com/google/maps/android/clustering/algo/PlatformLock.android.kt new file mode 100644 index 000000000..358feab81 --- /dev/null +++ b/clustering/src/androidMain/kotlin/com/google/maps/android/clustering/algo/PlatformLock.android.kt @@ -0,0 +1,26 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.maps.android.clustering.algo + +import java.util.concurrent.locks.ReentrantLock + +internal actual class PlatformLock { + private val delegate = ReentrantLock() + + actual fun lock(): Unit = delegate.lock() + + actual fun unlock(): Unit = delegate.unlock() +} diff --git a/clustering/src/main/java/com/google/maps/android/clustering/algo/PreCachingAlgorithmDecorator.kt b/clustering/src/androidMain/kotlin/com/google/maps/android/clustering/algo/PreCachingAlgorithmDecorator.kt similarity index 100% rename from clustering/src/main/java/com/google/maps/android/clustering/algo/PreCachingAlgorithmDecorator.kt rename to clustering/src/androidMain/kotlin/com/google/maps/android/clustering/algo/PreCachingAlgorithmDecorator.kt diff --git a/clustering/src/main/java/com/google/maps/android/clustering/view/ClusterRenderer.kt b/clustering/src/androidMain/kotlin/com/google/maps/android/clustering/view/ClusterRenderer.kt similarity index 100% rename from clustering/src/main/java/com/google/maps/android/clustering/view/ClusterRenderer.kt rename to clustering/src/androidMain/kotlin/com/google/maps/android/clustering/view/ClusterRenderer.kt diff --git a/clustering/src/main/java/com/google/maps/android/clustering/view/ClusterRendererMultipleItems.kt b/clustering/src/androidMain/kotlin/com/google/maps/android/clustering/view/ClusterRendererMultipleItems.kt similarity index 100% rename from clustering/src/main/java/com/google/maps/android/clustering/view/ClusterRendererMultipleItems.kt rename to clustering/src/androidMain/kotlin/com/google/maps/android/clustering/view/ClusterRendererMultipleItems.kt diff --git a/clustering/src/main/java/com/google/maps/android/clustering/view/DefaultAdvancedMarkersClusterRenderer.kt b/clustering/src/androidMain/kotlin/com/google/maps/android/clustering/view/DefaultAdvancedMarkersClusterRenderer.kt similarity index 100% rename from clustering/src/main/java/com/google/maps/android/clustering/view/DefaultAdvancedMarkersClusterRenderer.kt rename to clustering/src/androidMain/kotlin/com/google/maps/android/clustering/view/DefaultAdvancedMarkersClusterRenderer.kt diff --git a/clustering/src/main/java/com/google/maps/android/clustering/view/DefaultClusterRenderer.kt b/clustering/src/androidMain/kotlin/com/google/maps/android/clustering/view/DefaultClusterRenderer.kt similarity index 100% rename from clustering/src/main/java/com/google/maps/android/clustering/view/DefaultClusterRenderer.kt rename to clustering/src/androidMain/kotlin/com/google/maps/android/clustering/view/DefaultClusterRenderer.kt diff --git a/clustering/src/main/java/com/google/maps/android/clustering/Cluster.kt b/clustering/src/commonMain/kotlin/com/google/maps/android/clustering/Cluster.kt similarity index 94% rename from clustering/src/main/java/com/google/maps/android/clustering/Cluster.kt rename to clustering/src/commonMain/kotlin/com/google/maps/android/clustering/Cluster.kt index e239cec37..c007f6214 100644 --- a/clustering/src/main/java/com/google/maps/android/clustering/Cluster.kt +++ b/clustering/src/commonMain/kotlin/com/google/maps/android/clustering/Cluster.kt @@ -15,7 +15,7 @@ */ package com.google.maps.android.clustering -import com.google.android.gms.maps.model.LatLng +import com.google.maps.android.model.LatLng /** * A collection of ClusterItems that are nearby each other. diff --git a/clustering/src/main/java/com/google/maps/android/clustering/ClusterItem.kt b/clustering/src/commonMain/kotlin/com/google/maps/android/clustering/ClusterItem.kt similarity index 95% rename from clustering/src/main/java/com/google/maps/android/clustering/ClusterItem.kt rename to clustering/src/commonMain/kotlin/com/google/maps/android/clustering/ClusterItem.kt index e3409f893..2c50d975e 100644 --- a/clustering/src/main/java/com/google/maps/android/clustering/ClusterItem.kt +++ b/clustering/src/commonMain/kotlin/com/google/maps/android/clustering/ClusterItem.kt @@ -15,7 +15,7 @@ */ package com.google.maps.android.clustering -import com.google.android.gms.maps.model.LatLng +import com.google.maps.android.model.LatLng /** * ClusterItem represents a marker on the map. diff --git a/clustering/src/main/java/com/google/maps/android/clustering/algo/AbstractAlgorithm.kt b/clustering/src/commonMain/kotlin/com/google/maps/android/clustering/algo/AbstractAlgorithm.kt similarity index 79% rename from clustering/src/main/java/com/google/maps/android/clustering/algo/AbstractAlgorithm.kt rename to clustering/src/commonMain/kotlin/com/google/maps/android/clustering/algo/AbstractAlgorithm.kt index 8ba76a038..4916f901b 100644 --- a/clustering/src/main/java/com/google/maps/android/clustering/algo/AbstractAlgorithm.kt +++ b/clustering/src/commonMain/kotlin/com/google/maps/android/clustering/algo/AbstractAlgorithm.kt @@ -16,20 +16,18 @@ package com.google.maps.android.clustering.algo import com.google.maps.android.clustering.ClusterItem -import java.util.concurrent.locks.ReadWriteLock -import java.util.concurrent.locks.ReentrantReadWriteLock /** * Base Algorithm class that implements lock/unlock functionality. */ abstract class AbstractAlgorithm : Algorithm { - private val mLock: ReadWriteLock = ReentrantReadWriteLock() + private val mLock = PlatformLock() override fun lock() { - mLock.writeLock().lock() + mLock.lock() } override fun unlock() { - mLock.writeLock().unlock() + mLock.unlock() } } diff --git a/clustering/src/main/java/com/google/maps/android/clustering/algo/Algorithm.kt b/clustering/src/commonMain/kotlin/com/google/maps/android/clustering/algo/Algorithm.kt similarity index 100% rename from clustering/src/main/java/com/google/maps/android/clustering/algo/Algorithm.kt rename to clustering/src/commonMain/kotlin/com/google/maps/android/clustering/algo/Algorithm.kt diff --git a/clustering/src/main/java/com/google/maps/android/clustering/algo/CentroidNonHierarchicalDistanceBasedAlgorithm.kt b/clustering/src/commonMain/kotlin/com/google/maps/android/clustering/algo/CentroidNonHierarchicalDistanceBasedAlgorithm.kt similarity index 95% rename from clustering/src/main/java/com/google/maps/android/clustering/algo/CentroidNonHierarchicalDistanceBasedAlgorithm.kt rename to clustering/src/commonMain/kotlin/com/google/maps/android/clustering/algo/CentroidNonHierarchicalDistanceBasedAlgorithm.kt index 521e7971f..0967908ea 100644 --- a/clustering/src/main/java/com/google/maps/android/clustering/algo/CentroidNonHierarchicalDistanceBasedAlgorithm.kt +++ b/clustering/src/commonMain/kotlin/com/google/maps/android/clustering/algo/CentroidNonHierarchicalDistanceBasedAlgorithm.kt @@ -15,10 +15,11 @@ */ package com.google.maps.android.clustering.algo -import com.google.android.gms.maps.model.LatLng +import com.google.maps.android.model.LatLng +import com.google.maps.android.model.longitude +import com.google.maps.android.model.latitude import com.google.maps.android.clustering.Cluster import com.google.maps.android.clustering.ClusterItem -import java.util.HashSet /** * A variant of [NonHierarchicalDistanceBasedAlgorithm] that clusters items diff --git a/clustering/src/main/java/com/google/maps/android/clustering/algo/ContinuousZoomEuclideanCentroidAlgorithm.kt b/clustering/src/commonMain/kotlin/com/google/maps/android/clustering/algo/ContinuousZoomEuclideanCentroidAlgorithm.kt similarity index 97% rename from clustering/src/main/java/com/google/maps/android/clustering/algo/ContinuousZoomEuclideanCentroidAlgorithm.kt rename to clustering/src/commonMain/kotlin/com/google/maps/android/clustering/algo/ContinuousZoomEuclideanCentroidAlgorithm.kt index b9c048aca..76e01900c 100644 --- a/clustering/src/main/java/com/google/maps/android/clustering/algo/ContinuousZoomEuclideanCentroidAlgorithm.kt +++ b/clustering/src/commonMain/kotlin/com/google/maps/android/clustering/algo/ContinuousZoomEuclideanCentroidAlgorithm.kt @@ -17,9 +17,6 @@ package com.google.maps.android.clustering.algo import com.google.maps.android.clustering.Cluster import com.google.maps.android.clustering.ClusterItem -import java.util.ArrayList -import java.util.HashMap -import java.util.HashSet import kotlin.math.pow /** @@ -41,7 +38,7 @@ class ContinuousZoomEuclideanCentroidAlgorithm : CentroidNonHie val distanceToCluster = HashMap, Double>() val itemToCluster = HashMap, StaticCluster>() - synchronized(mQuadTree) { + quadTreeLock.withLock { for (candidate in getClusteringItems(mQuadTree, zoom)) { if (visitedCandidates.contains(candidate)) { // Candidate is already part of another cluster. diff --git a/clustering/src/main/java/com/google/maps/android/clustering/algo/GridBasedAlgorithm.kt b/clustering/src/commonMain/kotlin/com/google/maps/android/clustering/algo/GridBasedAlgorithm.kt similarity index 87% rename from clustering/src/main/java/com/google/maps/android/clustering/algo/GridBasedAlgorithm.kt rename to clustering/src/commonMain/kotlin/com/google/maps/android/clustering/algo/GridBasedAlgorithm.kt index 4a77032b8..e97af5183 100644 --- a/clustering/src/main/java/com/google/maps/android/clustering/algo/GridBasedAlgorithm.kt +++ b/clustering/src/commonMain/kotlin/com/google/maps/android/clustering/algo/GridBasedAlgorithm.kt @@ -20,7 +20,6 @@ import com.google.maps.android.clustering.Cluster import com.google.maps.android.clustering.ClusterItem import com.google.maps.android.geometry.Point import com.google.maps.android.projection.SphericalMercatorProjection -import java.util.Collections import kotlin.math.ceil import kotlin.math.floor import kotlin.math.pow @@ -37,23 +36,24 @@ import kotlin.math.pow */ class GridBasedAlgorithm : AbstractAlgorithm() { private var mGridSize = DEFAULT_GRID_SIZE - private val mItems: MutableSet = Collections.synchronizedSet(HashSet()) + private val mItemsLock = PlatformLock() + private val mItems: MutableSet = HashSet() - override fun addItem(item: T): Boolean = mItems.add(item) + override fun addItem(item: T): Boolean = mItemsLock.withLock { mItems.add(item) } - override fun addItems(items: Collection): Boolean = mItems.addAll(items) + override fun addItems(items: Collection): Boolean = mItemsLock.withLock { mItems.addAll(items) } override fun clearItems() { - mItems.clear() + mItemsLock.withLock { mItems.clear() } } - override fun removeItem(item: T): Boolean = mItems.remove(item) + override fun removeItem(item: T): Boolean = mItemsLock.withLock { mItems.remove(item) } - override fun removeItems(items: Collection): Boolean = mItems.removeAll(items.toSet()) + override fun removeItems(items: Collection): Boolean = mItemsLock.withLock { mItems.removeAll(items.toSet()) } override fun updateItem(item: T): Boolean { var result: Boolean - synchronized(mItems) { + mItemsLock.withLock { result = removeItem(item) if (result) { // Only add the item if it was removed (to help prevent accidental duplicates on map) @@ -76,7 +76,7 @@ class GridBasedAlgorithm : AbstractAlgorithm() { val clusters = HashSet>() val sparseArray = LongSparseArray>() - synchronized(mItems) { + mItemsLock.withLock { for (item in mItems) { val p = proj.toPoint(item.position) val coord = getCoord(numCells, p.x, p.y) @@ -101,7 +101,7 @@ class GridBasedAlgorithm : AbstractAlgorithm() { } override val items: Collection - get() = mItems + get() = mItemsLock.withLock { mItems.toSet() } companion object { private const val DEFAULT_GRID_SIZE = 100 diff --git a/clustering/src/main/java/com/google/maps/android/clustering/algo/NonHierarchicalDistanceBasedAlgorithm.kt b/clustering/src/commonMain/kotlin/com/google/maps/android/clustering/algo/NonHierarchicalDistanceBasedAlgorithm.kt similarity index 91% rename from clustering/src/main/java/com/google/maps/android/clustering/algo/NonHierarchicalDistanceBasedAlgorithm.kt rename to clustering/src/commonMain/kotlin/com/google/maps/android/clustering/algo/NonHierarchicalDistanceBasedAlgorithm.kt index 5fcac43e9..f560655a0 100644 --- a/clustering/src/main/java/com/google/maps/android/clustering/algo/NonHierarchicalDistanceBasedAlgorithm.kt +++ b/clustering/src/commonMain/kotlin/com/google/maps/android/clustering/algo/NonHierarchicalDistanceBasedAlgorithm.kt @@ -15,17 +15,15 @@ */ package com.google.maps.android.clustering.algo -import com.google.android.gms.maps.model.LatLng +import com.google.maps.android.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.geometry.Point import com.google.maps.android.projection.SphericalMercatorProjection import com.google.maps.android.quadtree.PointQuadTree -import java.util.Collections -import java.util.HashMap -import java.util.HashSet -import java.util.LinkedHashSet +import kotlin.jvm.JvmField +import kotlin.math.pow /** * A simple clustering algorithm with O(nlog n) performance. Resulting clusters are not @@ -42,14 +40,20 @@ import java.util.LinkedHashSet */ open class NonHierarchicalDistanceBasedAlgorithm : AbstractAlgorithm() { /** - * Any modifications should be synchronized on mQuadTree. + * Guards [mItems], [mItemMap] and [mQuadTree]. Replaces the pre-multiplatform + * `synchronized(mQuadTree)` blocks; reentrant so nested locking keeps working. + */ + internal val quadTreeLock = PlatformLock() + + /** + * Any modifications must hold [quadTreeLock]. */ @JvmField protected val mItems: MutableCollection> = LinkedHashSet() protected val mItemMap = HashMap>() /** - * Any modifications should be synchronized on mQuadTree. + * Any modifications must hold [quadTreeLock]. */ @JvmField protected val mQuadTree: PointQuadTree> = PointQuadTree(0.0, 1.0, 0.0, 1.0) @@ -58,7 +62,7 @@ open class NonHierarchicalDistanceBasedAlgorithm : AbstractAlgo override fun addItem(item: T): Boolean { val quadItem = QuadItem(item) - synchronized(mQuadTree) { + quadTreeLock.withLock { val result = mItems.add(quadItem) if (result) { mItemMap[item] = quadItem @@ -80,7 +84,7 @@ open class NonHierarchicalDistanceBasedAlgorithm : AbstractAlgo } override fun clearItems() { - synchronized(mQuadTree) { + quadTreeLock.withLock { mItems.clear() mItemMap.clear() mQuadTree.clear() @@ -90,7 +94,7 @@ open class NonHierarchicalDistanceBasedAlgorithm : AbstractAlgo override fun removeItem(item: T): Boolean { // QuadItem delegates hashcode() and equals() to its item so, // removing any QuadItem to that item will remove the item - synchronized(mQuadTree) { + quadTreeLock.withLock { val quadItem = mItemMap.remove(item) ?: QuadItem(item) val result = mItems.remove(quadItem) if (result) { @@ -102,7 +106,7 @@ open class NonHierarchicalDistanceBasedAlgorithm : AbstractAlgo override fun removeItems(items: Collection): Boolean { var result = false - synchronized(mQuadTree) { + quadTreeLock.withLock { for (item in items) { // QuadItem delegates hashcode() and equals() to its item so, // removing any QuadItem to that item will remove the item @@ -119,7 +123,7 @@ open class NonHierarchicalDistanceBasedAlgorithm : AbstractAlgo override fun updateItem(item: T): Boolean { // TODO - Can this be optimized to update the item in-place if the location hasn't changed? - synchronized(mQuadTree) { + quadTreeLock.withLock { var result = removeItem(item) if (result) { // Only add the item if it was removed (to help prevent accidental duplicates on map) @@ -132,14 +136,14 @@ open class NonHierarchicalDistanceBasedAlgorithm : AbstractAlgo override fun getClusters(zoom: Float): Set> { val discreteZoom = zoom.toInt() - val zoomSpecificSpan = maxDistanceBetweenClusteredItems.toDouble() / Math.pow(2.0, discreteZoom.toDouble()) / 256.0 + val zoomSpecificSpan = maxDistanceBetweenClusteredItems.toDouble() / 2.0.pow(discreteZoom.toDouble()) / 256.0 val visitedCandidates = HashSet>() val results = HashSet>() val distanceToCluster = HashMap, Double>() val itemToCluster = HashMap, StaticCluster>() - synchronized(mQuadTree) { + quadTreeLock.withLock { for (candidate in getClusteringItems(mQuadTree, zoom)) { if (visitedCandidates.contains(candidate)) { // Candidate is already part of another cluster. @@ -187,7 +191,7 @@ open class NonHierarchicalDistanceBasedAlgorithm : AbstractAlgo override val items: Collection get() { val items = LinkedHashSet() - synchronized(mQuadTree) { + quadTreeLock.withLock { for (quadItem in mItems) { items.add(quadItem.mClusterItem) } @@ -235,7 +239,7 @@ open class NonHierarchicalDistanceBasedAlgorithm : AbstractAlgo Cluster { private val mPoint: Point = PROJECTION.toPoint(mClusterItem.position) private val mPosition: LatLng = mClusterItem.position - private val singletonSet: Set = Collections.singleton(mClusterItem) + private val singletonSet: Set = setOf(mClusterItem) override val point: Point get() = mPoint diff --git a/clustering/src/main/java/com/google/maps/android/clustering/algo/NonHierarchicalViewBasedAlgorithm.kt b/clustering/src/commonMain/kotlin/com/google/maps/android/clustering/algo/NonHierarchicalViewBasedAlgorithm.kt similarity index 94% rename from clustering/src/main/java/com/google/maps/android/clustering/algo/NonHierarchicalViewBasedAlgorithm.kt rename to clustering/src/commonMain/kotlin/com/google/maps/android/clustering/algo/NonHierarchicalViewBasedAlgorithm.kt index bdd28d678..82bfa4d4f 100644 --- a/clustering/src/main/java/com/google/maps/android/clustering/algo/NonHierarchicalViewBasedAlgorithm.kt +++ b/clustering/src/commonMain/kotlin/com/google/maps/android/clustering/algo/NonHierarchicalViewBasedAlgorithm.kt @@ -15,13 +15,15 @@ */ package com.google.maps.android.clustering.algo -import com.google.android.gms.maps.model.CameraPosition -import com.google.android.gms.maps.model.LatLng +import com.google.maps.android.model.CameraPosition +import com.google.maps.android.model.LatLng +import com.google.maps.android.model.target +import com.google.maps.android.model.longitude +import com.google.maps.android.model.latitude import com.google.maps.android.clustering.ClusterItem import com.google.maps.android.geometry.Bounds import com.google.maps.android.projection.SphericalMercatorProjection import com.google.maps.android.quadtree.PointQuadTree -import java.util.ArrayList import kotlin.math.pow /** diff --git a/clustering/src/commonMain/kotlin/com/google/maps/android/clustering/algo/PlatformLock.kt b/clustering/src/commonMain/kotlin/com/google/maps/android/clustering/algo/PlatformLock.kt new file mode 100644 index 000000000..c195f8e87 --- /dev/null +++ b/clustering/src/commonMain/kotlin/com/google/maps/android/clustering/algo/PlatformLock.kt @@ -0,0 +1,43 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.maps.android.clustering.algo + +import kotlin.contracts.ExperimentalContracts +import kotlin.contracts.InvocationKind +import kotlin.contracts.contract + +/** + * A reentrant mutual-exclusion lock usable from common code. Replaces the JVM-only + * `synchronized` blocks and `ReentrantReadWriteLock` the algorithms used before the + * multiplatform migration. Exclusive locking is a strict (safe) narrowing of the previous + * read/write locking. + */ +internal expect class PlatformLock() { + fun lock() + + fun unlock() +} + +@OptIn(ExperimentalContracts::class) +internal inline fun PlatformLock.withLock(block: () -> T): T { + contract { callsInPlace(block, InvocationKind.EXACTLY_ONCE) } + lock() + try { + return block() + } finally { + unlock() + } +} diff --git a/clustering/src/main/java/com/google/maps/android/clustering/algo/ScreenBasedAlgorithm.kt b/clustering/src/commonMain/kotlin/com/google/maps/android/clustering/algo/ScreenBasedAlgorithm.kt similarity index 95% rename from clustering/src/main/java/com/google/maps/android/clustering/algo/ScreenBasedAlgorithm.kt rename to clustering/src/commonMain/kotlin/com/google/maps/android/clustering/algo/ScreenBasedAlgorithm.kt index f6aab988f..fd83ba064 100644 --- a/clustering/src/main/java/com/google/maps/android/clustering/algo/ScreenBasedAlgorithm.kt +++ b/clustering/src/commonMain/kotlin/com/google/maps/android/clustering/algo/ScreenBasedAlgorithm.kt @@ -15,7 +15,7 @@ */ package com.google.maps.android.clustering.algo -import com.google.android.gms.maps.model.CameraPosition +import com.google.maps.android.model.CameraPosition import com.google.maps.android.clustering.ClusterItem /** diff --git a/clustering/src/main/java/com/google/maps/android/clustering/algo/ScreenBasedAlgorithmAdapter.kt b/clustering/src/commonMain/kotlin/com/google/maps/android/clustering/algo/ScreenBasedAlgorithmAdapter.kt similarity index 97% rename from clustering/src/main/java/com/google/maps/android/clustering/algo/ScreenBasedAlgorithmAdapter.kt rename to clustering/src/commonMain/kotlin/com/google/maps/android/clustering/algo/ScreenBasedAlgorithmAdapter.kt index 52f08c02d..88c620672 100644 --- a/clustering/src/main/java/com/google/maps/android/clustering/algo/ScreenBasedAlgorithmAdapter.kt +++ b/clustering/src/commonMain/kotlin/com/google/maps/android/clustering/algo/ScreenBasedAlgorithmAdapter.kt @@ -15,7 +15,7 @@ */ package com.google.maps.android.clustering.algo -import com.google.android.gms.maps.model.CameraPosition +import com.google.maps.android.model.CameraPosition import com.google.maps.android.clustering.Cluster import com.google.maps.android.clustering.ClusterItem diff --git a/clustering/src/main/java/com/google/maps/android/clustering/algo/StaticCluster.kt b/clustering/src/commonMain/kotlin/com/google/maps/android/clustering/algo/StaticCluster.kt similarity index 97% rename from clustering/src/main/java/com/google/maps/android/clustering/algo/StaticCluster.kt rename to clustering/src/commonMain/kotlin/com/google/maps/android/clustering/algo/StaticCluster.kt index e292b6e33..86d51bf16 100644 --- a/clustering/src/main/java/com/google/maps/android/clustering/algo/StaticCluster.kt +++ b/clustering/src/commonMain/kotlin/com/google/maps/android/clustering/algo/StaticCluster.kt @@ -15,7 +15,7 @@ */ package com.google.maps.android.clustering.algo -import com.google.android.gms.maps.model.LatLng +import com.google.maps.android.model.LatLng import com.google.maps.android.clustering.Cluster import com.google.maps.android.clustering.ClusterItem diff --git a/clustering/src/main/java/com/google/maps/android/geometry/Bounds.kt b/clustering/src/commonMain/kotlin/com/google/maps/android/geometry/Bounds.kt similarity index 98% rename from clustering/src/main/java/com/google/maps/android/geometry/Bounds.kt rename to clustering/src/commonMain/kotlin/com/google/maps/android/geometry/Bounds.kt index cafd350b2..4c34178a0 100755 --- a/clustering/src/main/java/com/google/maps/android/geometry/Bounds.kt +++ b/clustering/src/commonMain/kotlin/com/google/maps/android/geometry/Bounds.kt @@ -15,6 +15,8 @@ */ package com.google.maps.android.geometry +import kotlin.jvm.JvmField + /** * Represents an area in the cartesian plane. */ diff --git a/clustering/src/main/java/com/google/maps/android/geometry/Point.kt b/clustering/src/commonMain/kotlin/com/google/maps/android/geometry/Point.kt similarity index 92% rename from clustering/src/main/java/com/google/maps/android/geometry/Point.kt rename to clustering/src/commonMain/kotlin/com/google/maps/android/geometry/Point.kt index 14038e200..049d562e4 100644 --- a/clustering/src/main/java/com/google/maps/android/geometry/Point.kt +++ b/clustering/src/commonMain/kotlin/com/google/maps/android/geometry/Point.kt @@ -15,6 +15,8 @@ */ package com.google.maps.android.geometry +import kotlin.jvm.JvmField + open class Point( @JvmField val x: Double, @JvmField val y: Double, @@ -23,7 +25,7 @@ open class Point( override fun equals(other: Any?): Boolean { if (this === other) return true - if (javaClass != other?.javaClass) return false + if (other == null || this::class != other::class) return false other as Point diff --git a/clustering/src/main/java/com/google/maps/android/projection/Point.kt b/clustering/src/commonMain/kotlin/com/google/maps/android/projection/Point.kt similarity index 100% rename from clustering/src/main/java/com/google/maps/android/projection/Point.kt rename to clustering/src/commonMain/kotlin/com/google/maps/android/projection/Point.kt diff --git a/clustering/src/main/java/com/google/maps/android/projection/SphericalMercatorProjection.kt b/clustering/src/commonMain/kotlin/com/google/maps/android/projection/SphericalMercatorProjection.kt similarity index 83% rename from clustering/src/main/java/com/google/maps/android/projection/SphericalMercatorProjection.kt rename to clustering/src/commonMain/kotlin/com/google/maps/android/projection/SphericalMercatorProjection.kt index 971a81bdd..9d3f53d4d 100644 --- a/clustering/src/main/java/com/google/maps/android/projection/SphericalMercatorProjection.kt +++ b/clustering/src/commonMain/kotlin/com/google/maps/android/projection/SphericalMercatorProjection.kt @@ -15,7 +15,9 @@ */ package com.google.maps.android.projection -import com.google.android.gms.maps.model.LatLng +import com.google.maps.android.model.LatLng +import com.google.maps.android.model.longitude +import com.google.maps.android.model.latitude import kotlin.math.* class SphericalMercatorProjection( @@ -23,7 +25,7 @@ class SphericalMercatorProjection( ) { fun toPoint(latLng: LatLng): com.google.maps.android.geometry.Point { val x = latLng.longitude / 360 + .5 - val siny = sin(Math.toRadians(latLng.latitude)) + val siny = sin(latLng.latitude * PI / 180.0) val y = 0.5 * ln((1 + siny) / (1 - siny)) / -(2 * PI) + .5 return com.google.maps.android.geometry.Point(x * worldWidth, y * worldWidth) @@ -34,7 +36,7 @@ class SphericalMercatorProjection( val lng = x * 360 val y = .5 - (point.y / worldWidth) - val lat = 90 - Math.toDegrees(atan(exp(-y * 2 * PI)) * 2) + val lat = 90 - atan(exp(-y * 2 * PI)) * 2 * 180.0 / PI return LatLng(lat, lng) } diff --git a/clustering/src/main/java/com/google/maps/android/quadtree/PointQuadTree.kt b/clustering/src/commonMain/kotlin/com/google/maps/android/quadtree/PointQuadTree.kt similarity index 99% rename from clustering/src/main/java/com/google/maps/android/quadtree/PointQuadTree.kt rename to clustering/src/commonMain/kotlin/com/google/maps/android/quadtree/PointQuadTree.kt index 2227e5c9d..d79bfab84 100644 --- a/clustering/src/main/java/com/google/maps/android/quadtree/PointQuadTree.kt +++ b/clustering/src/commonMain/kotlin/com/google/maps/android/quadtree/PointQuadTree.kt @@ -17,6 +17,7 @@ package com.google.maps.android.quadtree import com.google.maps.android.geometry.Bounds import com.google.maps.android.geometry.Point +import kotlin.jvm.JvmOverloads /** * A quad tree which tracks items with a Point geometry. diff --git a/clustering/src/iosMain/kotlin/com/google/maps/android/clustering/algo/PlatformLock.ios.kt b/clustering/src/iosMain/kotlin/com/google/maps/android/clustering/algo/PlatformLock.ios.kt new file mode 100644 index 000000000..39f9cca9a --- /dev/null +++ b/clustering/src/iosMain/kotlin/com/google/maps/android/clustering/algo/PlatformLock.ios.kt @@ -0,0 +1,26 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.maps.android.clustering.algo + +import platform.Foundation.NSRecursiveLock + +internal actual class PlatformLock { + private val delegate = NSRecursiveLock() + + actual fun lock(): Unit = delegate.lock() + + actual fun unlock(): Unit = delegate.unlock() +} diff --git a/clustering/src/test/java/com/google/maps/android/clustering/QuadItemTest.java b/clustering/src/test/java/com/google/maps/android/clustering/QuadItemTest.java deleted file mode 100644 index 8d6b12ef4..000000000 --- a/clustering/src/test/java/com/google/maps/android/clustering/QuadItemTest.java +++ /dev/null @@ -1,252 +0,0 @@ -/* - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.google.maps.android.clustering; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import com.google.android.gms.maps.model.LatLng; -import com.google.maps.android.clustering.algo.NonHierarchicalDistanceBasedAlgorithm; -import java.util.Arrays; -import java.util.Collection; -import java.util.List; -import org.junit.Test; - -public class QuadItemTest { - - @Test - public void testAddRemoveUpdateClear() { - ClusterItem item_1_5 = new TestingItem("title1", 0.1, 0.5); - TestingItem item_2_3 = new TestingItem("title2", 0.2, 0.3); - - NonHierarchicalDistanceBasedAlgorithm algo = - new NonHierarchicalDistanceBasedAlgorithm<>(); - assertTrue(algo.addItem(item_1_5)); - assertTrue(algo.addItem(item_2_3)); - - assertEquals(2, algo.getItems().size()); - - assertTrue(algo.removeItem(item_1_5)); - - assertEquals(1, algo.getItems().size()); - - assertFalse(algo.getItems().contains(item_1_5)); - assertTrue(algo.getItems().contains(item_2_3)); - - // Update the item still in the algorithm - item_2_3.setTitle("newTitle"); - assertTrue(algo.updateItem(item_2_3)); - - // Try to remove the item that was already removed - assertFalse(algo.removeItem(item_1_5)); - - // Try to update the item that was already removed - assertFalse(algo.updateItem(item_1_5)); - - algo.clearItems(); - assertEquals(0, algo.getItems().size()); - - // Test bulk operations - List items = Arrays.asList(item_1_5, item_2_3); - assertTrue(algo.addItems(items)); - - // Try to bulk add items that were already added - assertFalse(algo.addItems(items)); - - assertTrue(algo.removeItems(items)); - - // Try to bulk remove items that were already removed - assertFalse(algo.removeItems(items)); - } - - /** - * Test if insertion order into the algorithm is the same as returned item order. This matters - * because we want repeatable clustering behavior when updating model values and re-clustering. - */ - @Test - public void testInsertionOrder() { - NonHierarchicalDistanceBasedAlgorithm algo = - new NonHierarchicalDistanceBasedAlgorithm<>(); - for (int i = 0; i < 100; i++) { - algo.addItem(new TestingItem(Integer.toString(i), 0.0, 0.0)); - } - - assertEquals(100, algo.getItems().size()); - - Collection items = algo.getItems(); - int counter = 0; - for (ClusterItem item : items) { - assertEquals(Integer.toString(counter), item.getTitle()); - counter++; - } - } - - @Test - public void testUpdateItemAfterPositionChange() { - NonHierarchicalDistanceBasedAlgorithm algo = - new NonHierarchicalDistanceBasedAlgorithm<>(); - TestingItem item = new TestingItem("title1", 0.0, 0.0); - algo.addItem(item); - assertEquals(1, algo.getItems().size()); - - // Update the position of the mutable item - item.setPosition(10.0, 10.0); - - // Call updateItem - assertTrue("updateItem should return true after position change", algo.updateItem(item)); - assertEquals(1, algo.getItems().size()); - - // Verify that the old QuadItem at (0, 0) was removed from the tree - // and only the new position (10, 10) is indexed - java.util.Set> clusters = algo.getClusters(4.0f); - assertEquals(1, clusters.size()); - Cluster cluster = clusters.iterator().next(); - assertEquals(10.0, cluster.getPosition().latitude, 0.001); - assertEquals(10.0, cluster.getPosition().longitude, 0.001); - } - - @Test - public void testRemoveItemAfterPositionChange() { - NonHierarchicalDistanceBasedAlgorithm algo = - new NonHierarchicalDistanceBasedAlgorithm<>(); - TestingItem item = new TestingItem("title1", 0.0, 0.0); - algo.addItem(item); - assertEquals(1, algo.getItems().size()); - - // Update the position of the mutable item - item.setPosition(10.0, 10.0); - - // Removing the item should succeed and remove it from the tree - assertTrue("removeItem should return true after position change", algo.removeItem(item)); - assertEquals(0, algo.getItems().size()); - assertEquals(0, algo.getClusters(4.0f).size()); - } - - @Test - public void testUpdateItemPreventsStaleQuadTreeEntries() { - TestAlgorithm algo = new TestAlgorithm<>(); - - // Add 60 filler items to force PointQuadTree to split (MAX_ELEMENTS = 50) - for (int i = 0; i < 60; i++) { - algo.addItem(new TestingItem("filler" + i, 10.0 + i * 0.001, 10.0 + i * 0.001)); - } - - // Add item1 in top-left quadrant - TestingItem item1 = new TestingItem("item1", 1.0, 1.0); - algo.addItem(item1); - - assertEquals("QuadTree should contain item1 at (1, 1)", 1, algo.getQuadTreeItemCount(1.0, 1.0, 0.001)); - - // Move item1 far across quadrant boundary to (50.0, 50.0) and update - item1.setPosition(50.0, 50.0); - algo.updateItem(item1); - - // Without fix, old QuadItem remains at (1.0, 1.0) in mQuadTree because remove traversed the new coordinates - assertEquals("QuadTree should NOT contain stale entry at (1, 1) after update", 0, algo.getQuadTreeItemCount(1.0, 1.0, 0.001)); - assertEquals("QuadTree should contain item1 at (50, 50)", 1, algo.getQuadTreeItemCount(50.0, 50.0, 0.001)); - } - - @Test - public void testRemoveItemsAfterPositionChange() { - NonHierarchicalDistanceBasedAlgorithm algo = - new NonHierarchicalDistanceBasedAlgorithm<>(); - TestingItem item1 = new TestingItem("title1", 0.0, 0.0); - TestingItem item2 = new TestingItem("title2", 1.0, 1.0); - algo.addItems(java.util.Arrays.asList(item1, item2)); - assertEquals(2, algo.getItems().size()); - - // Update the position of both items - item1.setPosition(10.0, 10.0); - item2.setPosition(20.0, 20.0); - - assertTrue("removeItems should return true after position change", - algo.removeItems(java.util.Arrays.asList(item1, item2))); - assertEquals(0, algo.getItems().size()); - assertEquals(0, algo.getClusters(4.0f).size()); - } - - @Test - public void testClearItemsAfterPositionChange() { - NonHierarchicalDistanceBasedAlgorithm algo = - new NonHierarchicalDistanceBasedAlgorithm<>(); - TestingItem item1 = new TestingItem("title1", 0.0, 0.0); - algo.addItem(item1); - item1.setPosition(10.0, 10.0); - - algo.clearItems(); - assertEquals(0, algo.getItems().size()); - assertEquals(0, algo.getClusters(4.0f).size()); - } - - private static class TestAlgorithm extends NonHierarchicalDistanceBasedAlgorithm { - private static final com.google.maps.android.projection.SphericalMercatorProjection PROJ = - new com.google.maps.android.projection.SphericalMercatorProjection(1.0); - - public int getQuadTreeItemCount(double lat, double lng, double span) { - com.google.maps.android.geometry.Point p = PROJ.toPoint(new LatLng(lat, lng)); - com.google.maps.android.geometry.Bounds bounds = new com.google.maps.android.geometry.Bounds( - p.x - span, p.x + span, p.y - span, p.y + span); - return mQuadTree.search(bounds).size(); - } - } - - private static class TestingItem implements ClusterItem { - private LatLng mPosition; - private String mTitle; - - TestingItem(String title, double lat, double lng) { - mTitle = title; - mPosition = new LatLng(lat, lng); - } - - TestingItem(double lat, double lng) { - mTitle = ""; - mPosition = new LatLng(lat, lng); - } - public void setPosition(double lat, double lng) { - mPosition = new LatLng(lat, lng); - } - - @NonNull - @Override - public LatLng getPosition() { - return mPosition; - } - - @Override - public String getTitle() { - return mTitle; - } - - @Override - public String getSnippet() { - return null; - } - - @Nullable - @Override - public Float getZIndex() { - return null; - } - - public void setTitle(String title) { - mTitle = title; - } - } -} diff --git a/clustering/src/test/java/com/google/maps/android/clustering/algo/CentroidNonHierarchicalDistanceBasedAlgorithmTest.java b/clustering/src/test/java/com/google/maps/android/clustering/algo/CentroidNonHierarchicalDistanceBasedAlgorithmTest.java deleted file mode 100644 index 33a43b1ca..000000000 --- a/clustering/src/test/java/com/google/maps/android/clustering/algo/CentroidNonHierarchicalDistanceBasedAlgorithmTest.java +++ /dev/null @@ -1,74 +0,0 @@ -/* - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.google.maps.android.clustering.algo; - -import static org.junit.Assert.assertEquals; - -import androidx.annotation.NonNull; -import com.google.android.gms.maps.model.LatLng; -import com.google.maps.android.clustering.ClusterItem; -import java.util.Arrays; -import java.util.Collection; -import org.junit.Test; - -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); - } -} diff --git a/clustering/src/test/java/com/google/maps/android/clustering/algo/ContinuousZoomEuclideanCentroidAlgorithmTest.java b/clustering/src/test/java/com/google/maps/android/clustering/algo/ContinuousZoomEuclideanCentroidAlgorithmTest.java deleted file mode 100644 index f6b5d7c61..000000000 --- a/clustering/src/test/java/com/google/maps/android/clustering/algo/ContinuousZoomEuclideanCentroidAlgorithmTest.java +++ /dev/null @@ -1,115 +0,0 @@ -/* - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.google.maps.android.clustering.algo; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertTrue; - -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 java.util.Arrays; -import java.util.Collection; -import java.util.Set; -import org.junit.Test; - -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); - } -} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2aec7119c..a5d6794f2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -66,6 +66,7 @@ serialization = "1.0.1" [libraries] # --- Core Android & Kotlin --- # Essential libraries for Android development, providing backwards compatibility, lifecycle management, and Kotlin support. +androidx-collection = { module = "androidx.collection:collection", version = "1.5.0" } appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" } core-ktx = { module = "androidx.core:core-ktx", version.ref = "core-ktx" } kotlin-gradle-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } diff --git a/maps-model/build.gradle.kts b/maps-model/build.gradle.kts new file mode 100644 index 000000000..6aec57ddc --- /dev/null +++ b/maps-model/build.gradle.kts @@ -0,0 +1,41 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +plugins { + id("org.jetbrains.kotlin.multiplatform") + id("com.android.kotlin.multiplatform.library") +} + +kotlin { + jvmToolchain(17) + + androidLibrary { + namespace = "com.google.maps.android.model" + compileSdk = libs.versions.compileSdk.get().toInt() + minSdk = 23 + } + + iosArm64() + iosSimulatorArm64() + iosX64() + + sourceSets { + androidMain.dependencies { + // The Android actuals are typealiases to the Play Services types, so + // this must be api(): consumers see GMS LatLng in our public API. + api(libs.play.services.maps) + } + } +} diff --git a/maps-model/src/androidMain/kotlin/com/google/maps/android/model/Model.android.kt b/maps-model/src/androidMain/kotlin/com/google/maps/android/model/Model.android.kt new file mode 100644 index 000000000..c3e2bbf3f --- /dev/null +++ b/maps-model/src/androidMain/kotlin/com/google/maps/android/model/Model.android.kt @@ -0,0 +1,34 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.maps.android.model + +actual typealias LatLng = com.google.android.gms.maps.model.LatLng + +// These getters bind to the Java fields of the Play Services classes (member resolution +// wins over the extension inside the getter body, so this is not self-recursive). +actual val LatLng.latitude: Double get() = this.latitude + +actual val LatLng.longitude: Double get() = this.longitude + +actual typealias CameraPosition = com.google.android.gms.maps.model.CameraPosition + +actual val CameraPosition.target: LatLng get() = this.target + +actual val CameraPosition.zoom: Float get() = this.zoom + +actual val CameraPosition.tilt: Float get() = this.tilt + +actual val CameraPosition.bearing: Float get() = this.bearing diff --git a/maps-model/src/commonMain/kotlin/com/google/maps/android/model/CameraPosition.kt b/maps-model/src/commonMain/kotlin/com/google/maps/android/model/CameraPosition.kt new file mode 100644 index 000000000..ca6042c6a --- /dev/null +++ b/maps-model/src/commonMain/kotlin/com/google/maps/android/model/CameraPosition.kt @@ -0,0 +1,31 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.maps.android.model + +/** + * An immutable camera position: target location, zoom, tilt and bearing. + * + * On Android this is a typealias for [com.google.android.gms.maps.model.CameraPosition]. + */ +expect class CameraPosition(target: LatLng, zoom: Float, tilt: Float, bearing: Float) + +expect val CameraPosition.target: LatLng + +expect val CameraPosition.zoom: Float + +expect val CameraPosition.tilt: Float + +expect val CameraPosition.bearing: Float diff --git a/maps-model/src/commonMain/kotlin/com/google/maps/android/model/LatLng.kt b/maps-model/src/commonMain/kotlin/com/google/maps/android/model/LatLng.kt new file mode 100644 index 000000000..dd71523ef --- /dev/null +++ b/maps-model/src/commonMain/kotlin/com/google/maps/android/model/LatLng.kt @@ -0,0 +1,35 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.maps.android.model + +/** + * An immutable pair of latitude and longitude, in degrees. + * + * On Android this is a typealias for [com.google.android.gms.maps.model.LatLng], so existing + * code that uses the Maps SDK type keeps working unchanged. On other platforms it is a plain + * value holder with the same clamping/wrapping semantics: latitude is clamped to [-90, 90] and + * longitude is wrapped to [-180, 180). + * + * The coordinates are exposed as extension properties rather than members because the Play + * Services class exposes them as Java fields, and Java fields cannot actualize `expect` + * member properties. On each platform, member resolution wins over these extensions, so + * platform code binds directly to the underlying field/property with no indirection. + */ +expect class LatLng(latitude: Double, longitude: Double) + +expect val LatLng.latitude: Double + +expect val LatLng.longitude: Double diff --git a/maps-model/src/iosMain/kotlin/com/google/maps/android/model/Model.ios.kt b/maps-model/src/iosMain/kotlin/com/google/maps/android/model/Model.ios.kt new file mode 100644 index 000000000..7ca37fd7e --- /dev/null +++ b/maps-model/src/iosMain/kotlin/com/google/maps/android/model/Model.ios.kt @@ -0,0 +1,60 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.maps.android.model + +/** + * Mirrors the clamping/wrapping behavior of the Play Services LatLng constructor so that + * algorithms behave identically across platforms. + */ +actual class LatLng actual constructor(latitude: Double, longitude: Double) { + val latitude: Double = latitude.coerceIn(-90.0, 90.0) + val longitude: Double = + if (longitude in -180.0..180.0) { + if (longitude == 180.0) -180.0 else longitude + } else { + (longitude - 180.0).mod(360.0) - 180.0 + } + + override fun equals(other: Any?): Boolean = + other is LatLng && latitude == other.latitude && longitude == other.longitude + + override fun hashCode(): Int { + var result = 31 + latitude.toRawBits().let { (it xor (it ushr 32)).toInt() } + result = 31 * result + longitude.toRawBits().let { (it xor (it ushr 32)).toInt() } + return result + } + + override fun toString(): String = "lat/lng: ($latitude,$longitude)" +} + +actual val LatLng.latitude: Double get() = this.latitude + +actual val LatLng.longitude: Double get() = this.longitude + +actual class CameraPosition actual constructor( + val target: LatLng, + val zoom: Float, + val tilt: Float, + val bearing: Float +) + +actual val CameraPosition.target: LatLng get() = this.target + +actual val CameraPosition.zoom: Float get() = this.zoom + +actual val CameraPosition.tilt: Float get() = this.tilt + +actual val CameraPosition.bearing: Float get() = this.bearing diff --git a/settings.gradle.kts b/settings.gradle.kts index cb268f75b..db5b8ff3d 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -29,4 +29,4 @@ pluginManagement { } } -include("demo", "clustering", "heatmaps", "ui", "data", "lint-checks", "library", "visual-testing", "maps-utils") +include("demo", "clustering", "maps-model", "heatmaps", "ui", "data", "lint-checks", "library", "visual-testing", "maps-utils") From e75f03edfd574ecc1ced99a348fbe6b0aeea8f58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20Lo=CC=81pez=20Man=CC=83as?= Date: Tue, 8 Sep 2026 22:29:28 +0700 Subject: [PATCH 2/5] feat: enable mavenLocal publishing of KMP modules and bump AGP to 9.4.0 Apply maven-publish to maps-model and clustering so their multiplatform publications can be published to mavenLocal for consumption by the android-maps-compose KMP branch. Bump AGP 9.3.1 -> 9.4.0: AGP forbids mixing versions across composite builds, and android-maps-compose is already on 9.4.0. Claude-Session: https://claude.ai/code/session_01225X6MnAqkyCF7Xones6WY --- clustering/build.gradle.kts | 3 +++ gradle/libs.versions.toml | 2 +- maps-model/build.gradle.kts | 3 +++ 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/clustering/build.gradle.kts b/clustering/build.gradle.kts index c915ca9ec..18c4a4241 100644 --- a/clustering/build.gradle.kts +++ b/clustering/build.gradle.kts @@ -17,6 +17,9 @@ plugins { id("org.jetbrains.kotlin.multiplatform") id("com.android.kotlin.multiplatform.library") id("org.jetbrains.dokka") + // Prototype publishing: KMP auto-creates multiplatform publications, enabling + // publishToMavenLocal so android-maps-compose can consume this via -PuseMavenLocal=true. + id("maven-publish") } // NOTE (KMP prototype): the module previously applied android.maps.utils.PublishingConventionPlugin, diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a5d6794f2..e9d3fc21d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -57,7 +57,7 @@ org-jacoco-core = "0.8.15" # --- Gradle Plugins --- # Versions for Gradle plugins used in the build process. dokka-gradle-plugin = "2.2.0" -gradle = "9.3.1" +gradle = "9.4.0" gradleMavenPublishPlugin = "0.37.0" secrets-gradle-plugin = "2.0.1" serialization = "1.0.1" diff --git a/maps-model/build.gradle.kts b/maps-model/build.gradle.kts index 6aec57ddc..cf0d7a8c7 100644 --- a/maps-model/build.gradle.kts +++ b/maps-model/build.gradle.kts @@ -16,6 +16,9 @@ plugins { id("org.jetbrains.kotlin.multiplatform") id("com.android.kotlin.multiplatform.library") + // Prototype publishing: KMP auto-creates multiplatform publications, enabling + // publishToMavenLocal so android-maps-compose can consume this via -PuseMavenLocal=true. + id("maven-publish") } kotlin { From 4526aab6f1eeacf6d8a8161e619542c216ae38c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20Lo=CC=81pez=20Man=CC=83as?= Date: Tue, 8 Sep 2026 22:48:10 +0700 Subject: [PATCH 3/5] fix: publish KMP modules under public android-maps-utils artifactIds Publishing the KMP clustering module as com.google.maps.android:clustering gave the same classes a second module identity next to the android-maps-utils-clustering AAR on Maven Central, producing duplicate class errors in apps that pull both (e.g. android-maps-compose's maps-app). Remap the publication artifactIds to the repo's public android-maps-utils- scheme so both dependency paths conflict-resolve to a single module. Claude-Session: https://claude.ai/code/session_01225X6MnAqkyCF7Xones6WY --- clustering/build.gradle.kts | 9 +++++++++ maps-model/build.gradle.kts | 9 +++++++++ 2 files changed, 18 insertions(+) diff --git a/clustering/build.gradle.kts b/clustering/build.gradle.kts index 18c4a4241..3c5cf7845 100644 --- a/clustering/build.gradle.kts +++ b/clustering/build.gradle.kts @@ -74,3 +74,12 @@ kotlin { } } } + +// Publish under the repo's public artifactId scheme (android-maps-utils-) so these +// coordinates conflict-resolve against the AARs already on Maven Central instead of +// duplicating their classes under a second module identity. +publishing { + publications.withType().configureEach { + artifactId = artifactId.replace(project.name, "android-maps-utils-${project.name}") + } +} diff --git a/maps-model/build.gradle.kts b/maps-model/build.gradle.kts index cf0d7a8c7..b5b7ccc92 100644 --- a/maps-model/build.gradle.kts +++ b/maps-model/build.gradle.kts @@ -42,3 +42,12 @@ kotlin { } } } + +// Publish under the repo's public artifactId scheme (android-maps-utils-) so these +// coordinates conflict-resolve against the AARs already on Maven Central instead of +// duplicating their classes under a second module identity. +publishing { + publications.withType().configureEach { + artifactId = artifactId.replace(project.name, "android-maps-utils-${project.name}") + } +} From 56aab16c01e009d1241f7671448e2e51ff602c98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20Lo=CC=81pez=20Man=CC=83as?= Date: Tue, 8 Sep 2026 23:34:42 +0700 Subject: [PATCH 4/5] feat: migrate library and heatmaps modules to Kotlin Multiplatform Converts the library module (PolyUtil, SphericalUtil, MathUtil in commonMain; StreetView utilities, collections managers and the attribution initializer in androidMain) and the heatmaps module (WeightedLatLng, Gradient and shared constants in commonMain; the Bitmap/Tile-based HeatmapTileProvider in androidMain) following the pattern established by the clustering migration. Gradient's android.graphics.Color usage is replaced by a common ColorUtils that reproduces the Android/Skia RGB<->HSV conversions exactly; GradientTest's hardcoded Android color values verify parity. The AttributionId codegen task is ported into the KMP build and wired into androidMain. Math.toRadians/toDegrees become common helpers. The Java test suites (PolyUtilTest, SphericalUtilTest, MathUtilTest, heatmaps UtilTest) are converted to Kotlin; the three math suites move to commonTest and now also run on iOS (36 tests green on the iOS simulator, 60 android host tests for library, 24 for heatmaps). robolectric.properties pins sdk=28 for host tests as Robolectric does not support targetSdk 37. Claude-Session: https://claude.ai/code/session_01225X6MnAqkyCF7Xones6WY --- heatmaps/build.gradle.kts | 112 ++- .../maps/android/heatmaps/GradientTest.kt | 0 .../heatmaps/HeatmapTileProviderTest.kt | 0 .../google/maps/android/heatmaps/UtilTest.kt | 167 +++++ .../android/heatmaps/WeightedLatLngTest.kt | 0 .../resources/robolectric.properties | 1 + .../{main => androidMain}/AndroidManifest.xml | 0 .../android/heatmaps/HeatmapTileProvider.kt | 4 +- .../maps/android/heatmaps/ColorUtils.kt | 100 +++ .../google/maps/android/heatmaps/Gradient.kt | 34 +- .../maps/android/heatmaps/HeatmapConstants.kt | 29 + .../maps/android/heatmaps/WeightedLatLng.kt | 5 +- .../maps/android/heatmaps/UtilTest.java | 154 ---- library/build.gradle.kts | 140 ++-- .../maps/android/StreetViewHelperTest.kt | 0 .../android/collections/CircleManagerTest.kt | 0 .../collections/GroundOverlayManagerTest.kt | 0 .../collections/MapObjectManagerTest.kt | 0 .../android/collections/MarkerManagerTest.kt | 0 .../android/collections/PolygonManagerTest.kt | 0 .../collections/PolylineManagerTest.kt | 0 .../AttributionIdInitializerTest.kt | 0 .../resources/robolectric.properties | 1 + .../{main => androidMain}/AndroidManifest.xml | 0 .../maps/android/StreetViewJavaHelper.kt | 0 .../com/google/maps/android/StreetViewUtil.kt | 0 .../maps/android/collections/CircleManager.kt | 0 .../collections/GroundOverlayManager.kt | 0 .../android/collections/MapObjectManager.kt | 0 .../maps/android/collections/MarkerManager.kt | 0 .../android/collections/PolygonManager.kt | 0 .../android/collections/PolylineManager.kt | 0 .../attribution/AttributionIdInitializer.kt | 0 .../google/maps/android/AngleConversions.kt | 23 + .../com/google/maps/android/MathUtil.kt | 5 +- .../com/google/maps/android/PolyUtil.kt | 71 +- .../com/google/maps/android/SphericalUtil.kt | 65 +- .../com/google/maps/android/MathUtilTest.kt | 101 +++ .../com/google/maps/android/PolyUtilTest.kt | 640 +++++++++++++++++ .../google/maps/android/SphericalUtilTest.kt | 405 +++++++++++ .../com/google/maps/android/MathUtilTest.java | 99 --- .../com/google/maps/android/PolyUtilTest.java | 665 ------------------ .../maps/android/SphericalUtilTest.java | 423 ----------- 43 files changed, 1672 insertions(+), 1572 deletions(-) rename heatmaps/src/{test/java => androidHostTest/kotlin}/com/google/maps/android/heatmaps/GradientTest.kt (100%) rename heatmaps/src/{test/java => androidHostTest/kotlin}/com/google/maps/android/heatmaps/HeatmapTileProviderTest.kt (100%) create mode 100644 heatmaps/src/androidHostTest/kotlin/com/google/maps/android/heatmaps/UtilTest.kt rename heatmaps/src/{test/java => androidHostTest/kotlin}/com/google/maps/android/heatmaps/WeightedLatLngTest.kt (100%) create mode 100644 heatmaps/src/androidHostTest/resources/robolectric.properties rename heatmaps/src/{main => androidMain}/AndroidManifest.xml (100%) rename heatmaps/src/{main/java => androidMain/kotlin}/com/google/maps/android/heatmaps/HeatmapTileProvider.kt (99%) create mode 100644 heatmaps/src/commonMain/kotlin/com/google/maps/android/heatmaps/ColorUtils.kt rename heatmaps/src/{main/java => commonMain/kotlin}/com/google/maps/android/heatmaps/Gradient.kt (85%) create mode 100644 heatmaps/src/commonMain/kotlin/com/google/maps/android/heatmaps/HeatmapConstants.kt rename heatmaps/src/{main/java => commonMain/kotlin}/com/google/maps/android/heatmaps/WeightedLatLng.kt (93%) delete mode 100644 heatmaps/src/test/java/com/google/maps/android/heatmaps/UtilTest.java rename library/src/{test/java => androidHostTest/kotlin}/com/google/maps/android/StreetViewHelperTest.kt (100%) rename library/src/{test/java => androidHostTest/kotlin}/com/google/maps/android/collections/CircleManagerTest.kt (100%) rename library/src/{test/java => androidHostTest/kotlin}/com/google/maps/android/collections/GroundOverlayManagerTest.kt (100%) rename library/src/{test/java => androidHostTest/kotlin}/com/google/maps/android/collections/MapObjectManagerTest.kt (100%) rename library/src/{test/java => androidHostTest/kotlin}/com/google/maps/android/collections/MarkerManagerTest.kt (100%) rename library/src/{test/java => androidHostTest/kotlin}/com/google/maps/android/collections/PolygonManagerTest.kt (100%) rename library/src/{test/java => androidHostTest/kotlin}/com/google/maps/android/collections/PolylineManagerTest.kt (100%) rename library/src/{test/java => androidHostTest/kotlin}/com/google/maps/android/utils/attribution/AttributionIdInitializerTest.kt (100%) create mode 100644 library/src/androidHostTest/resources/robolectric.properties rename library/src/{main => androidMain}/AndroidManifest.xml (100%) rename library/src/{main/java => androidMain/kotlin}/com/google/maps/android/StreetViewJavaHelper.kt (100%) rename library/src/{main/java => androidMain/kotlin}/com/google/maps/android/StreetViewUtil.kt (100%) rename library/src/{main/java => androidMain/kotlin}/com/google/maps/android/collections/CircleManager.kt (100%) rename library/src/{main/java => androidMain/kotlin}/com/google/maps/android/collections/GroundOverlayManager.kt (100%) rename library/src/{main/java => androidMain/kotlin}/com/google/maps/android/collections/MapObjectManager.kt (100%) rename library/src/{main/java => androidMain/kotlin}/com/google/maps/android/collections/MarkerManager.kt (100%) rename library/src/{main/java => androidMain/kotlin}/com/google/maps/android/collections/PolygonManager.kt (100%) rename library/src/{main/java => androidMain/kotlin}/com/google/maps/android/collections/PolylineManager.kt (100%) rename library/src/{main/java => androidMain/kotlin}/com/google/maps/android/utils/attribution/AttributionIdInitializer.kt (100%) create mode 100644 library/src/commonMain/kotlin/com/google/maps/android/AngleConversions.kt rename library/src/{main/java => commonMain/kotlin}/com/google/maps/android/MathUtil.kt (97%) rename library/src/{main/java => commonMain/kotlin}/com/google/maps/android/PolyUtil.kt (92%) rename library/src/{main/java => commonMain/kotlin}/com/google/maps/android/SphericalUtil.kt (87%) create mode 100644 library/src/commonTest/kotlin/com/google/maps/android/MathUtilTest.kt create mode 100644 library/src/commonTest/kotlin/com/google/maps/android/PolyUtilTest.kt create mode 100644 library/src/commonTest/kotlin/com/google/maps/android/SphericalUtilTest.kt delete mode 100644 library/src/test/java/com/google/maps/android/MathUtilTest.java delete mode 100644 library/src/test/java/com/google/maps/android/PolyUtilTest.java delete mode 100644 library/src/test/java/com/google/maps/android/SphericalUtilTest.java diff --git a/heatmaps/build.gradle.kts b/heatmaps/build.gradle.kts index 08b2f81e1..9f6e7a6e0 100644 --- a/heatmaps/build.gradle.kts +++ b/heatmaps/build.gradle.kts @@ -1,5 +1,3 @@ -import org.jetbrains.kotlin.gradle.dsl.JvmTarget - /** * Copyright 2026 Google LLC * @@ -16,78 +14,66 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget * limitations under the License. */ plugins { - + id("org.jetbrains.kotlin.multiplatform") + id("com.android.kotlin.multiplatform.library") id("org.jetbrains.dokka") - id("android.maps.utils.PublishingConventionPlugin") + // Prototype publishing: KMP auto-creates multiplatform publications, enabling + // publishToMavenLocal so android-maps-compose can consume this via -PuseMavenLocal=true. + id("maven-publish") } -android { - lint { - sarifOutput = layout.buildDirectory.file("reports/lint-results.sarif").get().asFile - } - defaultConfig { +// NOTE (KMP prototype): see clustering/build.gradle.kts — release publishing (vanniktech), +// jacoco, lint-checks and the amu_ resourcePrefix still need KMP-aware re-wiring. + +kotlin { + jvmToolchain(17) + + androidLibrary { + namespace = "com.google.maps.android.heatmaps" compileSdk = libs.versions.compileSdk.get().toInt() minSdk = 23 - testOptions.targetSdk = libs.versions.targetSdk.get().toInt() - consumerProguardFiles("consumer-rules.pro") - } - buildTypes { - release { - isMinifyEnabled = false - proguardFiles( - getDefaultProguardFile("proguard-android-optimize.txt"), - "proguard-rules.pro" - ) + + withHostTestBuilder { + }.configure { + isIncludeAndroidResources = true + isReturnDefaultValues = true } } - resourcePrefix = "amu_" - installation { - timeOutInMs = 10 * 60 * 1000 // 10 minutes - installOptions += listOf("-d", "-t") - } + iosArm64() + iosSimulatorArm64() + iosX64() - kotlin { - compilerOptions { - jvmTarget.set(JvmTarget.JVM_17) + sourceSets { + commonMain.dependencies { + api(project(":maps-model")) + // Quadtree, geometry and Mercator projection live in the clustering module. + api(project(":clustering")) + } + commonTest.dependencies { + implementation(libs.kotlin.test) + } + androidMain.dependencies { + implementation(project(":data")) + api(libs.play.services.maps) + implementation(libs.kotlinx.coroutines.android) + implementation(libs.appcompat) + implementation(libs.core.ktx) + } + getByName("androidHostTest").dependencies { + implementation(libs.junit) + implementation(libs.robolectric) + implementation(libs.kxml2) + implementation(libs.mockk) + implementation(libs.truth) } - jvmToolchain(17) - } - - testOptions { - animationsDisabled = true - unitTests.isIncludeAndroidResources = true - unitTests.isReturnDefaultValues = true } - namespace = "com.google.maps.android.heatmaps" } -dependencies { - implementation(project(":clustering")) - implementation(project(":data")) - api(libs.play.services.maps) - implementation(libs.kotlinx.coroutines.android) - implementation(libs.appcompat) - implementation(libs.core.ktx) - lintPublish(project(":lint-checks")) - testImplementation(libs.junit) - testImplementation(libs.robolectric) - testImplementation(libs.kxml2) - testImplementation(libs.mockk) - testImplementation(libs.kotlin.test) - testImplementation(libs.truth) - implementation(libs.kotlin.stdlib.jdk8) - - testImplementation(libs.mockk) - testImplementation(libs.kotlinx.coroutines.test) - testImplementation(libs.robolectric) - testImplementation(libs.mockito.core) -} - -tasks.register("instrumentTest") { - dependsOn("connectedCheck") -} - -if (System.getenv("JITPACK") != null) { - apply(plugin = "maven") +// Publish under the repo's public artifactId scheme so these coordinates conflict-resolve +// against the AARs already on Maven Central instead of duplicating their classes. +publishing { + publications.withType().configureEach { + artifactId = artifactId.replace(project.name, "android-maps-utils-${project.name}") + } } diff --git a/heatmaps/src/test/java/com/google/maps/android/heatmaps/GradientTest.kt b/heatmaps/src/androidHostTest/kotlin/com/google/maps/android/heatmaps/GradientTest.kt similarity index 100% rename from heatmaps/src/test/java/com/google/maps/android/heatmaps/GradientTest.kt rename to heatmaps/src/androidHostTest/kotlin/com/google/maps/android/heatmaps/GradientTest.kt diff --git a/heatmaps/src/test/java/com/google/maps/android/heatmaps/HeatmapTileProviderTest.kt b/heatmaps/src/androidHostTest/kotlin/com/google/maps/android/heatmaps/HeatmapTileProviderTest.kt similarity index 100% rename from heatmaps/src/test/java/com/google/maps/android/heatmaps/HeatmapTileProviderTest.kt rename to heatmaps/src/androidHostTest/kotlin/com/google/maps/android/heatmaps/HeatmapTileProviderTest.kt diff --git a/heatmaps/src/androidHostTest/kotlin/com/google/maps/android/heatmaps/UtilTest.kt b/heatmaps/src/androidHostTest/kotlin/com/google/maps/android/heatmaps/UtilTest.kt new file mode 100644 index 000000000..ed6460031 --- /dev/null +++ b/heatmaps/src/androidHostTest/kotlin/com/google/maps/android/heatmaps/UtilTest.kt @@ -0,0 +1,167 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.maps.android.heatmaps + +import com.google.android.gms.maps.model.LatLng +import com.google.maps.android.geometry.Bounds +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** Tests for heatmap utility functions */ +@RunWith(RobolectricTestRunner::class) +class UtilTest { + @Test + fun testGenerateKernel() { + val testKernel = HeatmapTileProvider.generateKernel(5, 1.5) + val expectedKernel = + doubleArrayOf( + 0.0038659201394728076, + 0.028565500784550377, + 0.1353352832366127, + 0.41111229050718745, + 0.8007374029168081, + 1.0, + 0.8007374029168081, + 0.41111229050718745, + 0.1353352832366127, + 0.028565500784550377, + 0.0038659201394728076, + ) + + assertArrayEquals(expectedKernel, testKernel, 0.0) + } + + @Test + fun testConvolveCorners() { + /* + 1 0 0 0 1 + 0 0 0 0 0 + 0 0 0 0 0 + 0 0 0 0 0 + 1 0 0 0 1 + */ + val grid = Array(5) { DoubleArray(5) } + grid[0][0] = 1.0 + grid[4][4] = 1.0 + grid[4][0] = 1.0 + grid[0][4] = 1.0 + val testKernel = doubleArrayOf(0.5, 1.0, 0.5) + val convolved = HeatmapTileProvider.convolve(grid, testKernel) + val expected = + arrayOf( + doubleArrayOf(0.25, 0.0, 0.25), + doubleArrayOf(0.0, 0.0, 0.0), + doubleArrayOf(0.25, 0.0, 0.25), + ) + assertArrayEquals(expected, convolved) + } + + @Test + fun testConvolveEdges() { + /* + 0 0 1 0 0 + 0 0 0 0 0 + 1 0 0 0 1 + 0 0 0 0 0 + 0 0 1 0 0 + */ + val grid = Array(5) { DoubleArray(5) } + grid[0][2] = 1.0 + grid[2][0] = 1.0 + grid[2][4] = 1.0 + grid[4][2] = 1.0 + val testKernel = doubleArrayOf(0.5, 1.0, 0.5) + val convolved = HeatmapTileProvider.convolve(grid, testKernel) + val expected = + arrayOf( + doubleArrayOf(0.5, 0.5, 0.5), + doubleArrayOf(0.5, 0.0, 0.5), + doubleArrayOf(0.5, 0.5, 0.5), + ) + assertArrayEquals(expected, convolved) + } + + @Test + fun testConvolveCentre() { + /* + 0 0 0 0 0 + 0 0 1 0 0 + 0 1 2 1 0 + 0 0 1 0 0 + 0 0 0 0 0 + */ + val grid = Array(5) { DoubleArray(5) } + grid[2][2] = 2.0 + grid[2][1] = 1.0 + grid[1][2] = 1.0 + grid[2][3] = 1.0 + grid[3][2] = 1.0 + val testKernel = doubleArrayOf(0.5, 1.0, 0.5) + val convolved = HeatmapTileProvider.convolve(grid, testKernel) + val expected = + arrayOf( + doubleArrayOf(1.5, 2.5, 1.5), + doubleArrayOf(2.5, 4.0, 2.5), + doubleArrayOf(1.5, 2.5, 1.5), + ) + assertArrayEquals(expected, convolved) + } + + @Test + fun testGetBounds() { + /* + y + ^ + | 3 + | 1 + | 2 + ------------> x + */ + + val data = ArrayList() + val first = WeightedLatLng(LatLng(10.0, 20.0)) + data.add(first) + val x1 = first.point.x + val y1 = first.point.y + + var bounds = HeatmapTileProvider.getBounds(data) + var expected = Bounds(x1, x1, y1, y1) + + assertTrue(bounds.contains(expected) && expected.contains(bounds)) + + val second = WeightedLatLng(LatLng(20.0, 30.0)) + data.add(second) + val x2 = second.point.x + val y2 = second.point.y + + bounds = HeatmapTileProvider.getBounds(data) + expected = Bounds(x1, x2, y2, y1) + + assertTrue(bounds.contains(expected) && expected.contains(bounds)) + + val third = WeightedLatLng(LatLng(5.0, 10.0)) + data.add(third) + val x3 = third.point.x + val y3 = third.point.y + + bounds = HeatmapTileProvider.getBounds(data) + expected = Bounds(x3, x2, y2, y3) + assertTrue(bounds.contains(expected) && expected.contains(bounds)) + } +} diff --git a/heatmaps/src/test/java/com/google/maps/android/heatmaps/WeightedLatLngTest.kt b/heatmaps/src/androidHostTest/kotlin/com/google/maps/android/heatmaps/WeightedLatLngTest.kt similarity index 100% rename from heatmaps/src/test/java/com/google/maps/android/heatmaps/WeightedLatLngTest.kt rename to heatmaps/src/androidHostTest/kotlin/com/google/maps/android/heatmaps/WeightedLatLngTest.kt diff --git a/heatmaps/src/androidHostTest/resources/robolectric.properties b/heatmaps/src/androidHostTest/resources/robolectric.properties new file mode 100644 index 000000000..932b01b9e --- /dev/null +++ b/heatmaps/src/androidHostTest/resources/robolectric.properties @@ -0,0 +1 @@ +sdk=28 diff --git a/heatmaps/src/main/AndroidManifest.xml b/heatmaps/src/androidMain/AndroidManifest.xml similarity index 100% rename from heatmaps/src/main/AndroidManifest.xml rename to heatmaps/src/androidMain/AndroidManifest.xml diff --git a/heatmaps/src/main/java/com/google/maps/android/heatmaps/HeatmapTileProvider.kt b/heatmaps/src/androidMain/kotlin/com/google/maps/android/heatmaps/HeatmapTileProvider.kt similarity index 99% rename from heatmaps/src/main/java/com/google/maps/android/heatmaps/HeatmapTileProvider.kt rename to heatmaps/src/androidMain/kotlin/com/google/maps/android/heatmaps/HeatmapTileProvider.kt index d643e2bc6..8d91e0be9 100644 --- a/heatmaps/src/main/java/com/google/maps/android/heatmaps/HeatmapTileProvider.kt +++ b/heatmaps/src/androidMain/kotlin/com/google/maps/android/heatmaps/HeatmapTileProvider.kt @@ -301,13 +301,13 @@ class HeatmapTileProvider private constructor( companion object { const val DEFAULT_RADIUS = 20 - const val DEFAULT_OPACITY = 0.7 + const val DEFAULT_OPACITY = HeatmapConstants.DEFAULT_OPACITY 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 + internal const val WORLD_WIDTH = HeatmapConstants.WORLD_WIDTH private const val TILE_DIM = 512 private const val SCREEN_SIZE = 1280 private const val DEFAULT_MIN_ZOOM = 5 diff --git a/heatmaps/src/commonMain/kotlin/com/google/maps/android/heatmaps/ColorUtils.kt b/heatmaps/src/commonMain/kotlin/com/google/maps/android/heatmaps/ColorUtils.kt new file mode 100644 index 000000000..acf5e0ba9 --- /dev/null +++ b/heatmaps/src/commonMain/kotlin/com/google/maps/android/heatmaps/ColorUtils.kt @@ -0,0 +1,100 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.maps.android.heatmaps + +import kotlin.math.floor +import kotlin.math.roundToInt + +/** + * Multiplatform replacements for the `android.graphics.Color` operations the heatmap gradient + * uses. The HSV conversions reproduce the Android/Skia implementations exactly (as of Android O), + * which GradientTest verifies against hardcoded Android color values. + */ +internal object ColorUtils { + fun alpha(color: Int): Int = color ushr 24 + + fun red(color: Int): Int = (color shr 16) and 0xFF + + fun green(color: Int): Int = (color shr 8) and 0xFF + + fun blue(color: Int): Int = color and 0xFF + + fun argb(alpha: Int, red: Int, green: Int, blue: Int): Int = + (alpha shl 24) or (red shl 16) or (green shl 8) or blue + + fun rgbToHsv(red: Int, green: Int, blue: Int, hsv: FloatArray) { + require(hsv.size >= 3) { "3 components required for hsv" } + val max = maxOf(red, green, blue) + val min = minOf(red, green, blue) + val delta = max - min + + val v = max / 255f + if (delta == 0) { + hsv[0] = 0f + hsv[1] = 0f + hsv[2] = v + return + } + + val s = delta / max.toFloat() + var h = + when (max) { + red -> (green - blue) / delta.toFloat() + green -> 2f + (blue - red) / delta.toFloat() + else -> 4f + (red - green) / delta.toFloat() + } + h *= 60f + if (h < 0f) { + h += 360f + } + + hsv[0] = h + hsv[1] = s + hsv[2] = v + } + + fun hsvToColor(alpha: Int, hsv: FloatArray): Int { + require(hsv.size >= 3) { "3 components required for hsv" } + val h = hsv[0] + val s = hsv[1].coerceIn(0f, 1f) + val v = hsv[2].coerceIn(0f, 1f) + + if (s <= 0f) { + val unit = (v * 255f).roundToInt() + return argb(alpha, unit, unit, unit) + } + + val hx = if (h < 0f || h >= 360f) 0f else h / 60f + val w = floor(hx).toInt() + val f = hx - w + + val p = v * (1f - s) + val q = v * (1f - s * f) + val t = v * (1f - s * (1f - f)) + + val (r, g, b) = + when (w) { + 0 -> Triple(v, t, p) + 1 -> Triple(q, v, p) + 2 -> Triple(p, v, t) + 3 -> Triple(p, q, v) + 4 -> Triple(t, p, v) + else -> Triple(v, p, q) + } + + return argb(alpha, (r * 255f).roundToInt(), (g * 255f).roundToInt(), (b * 255f).roundToInt()) + } +} diff --git a/heatmaps/src/main/java/com/google/maps/android/heatmaps/Gradient.kt b/heatmaps/src/commonMain/kotlin/com/google/maps/android/heatmaps/Gradient.kt similarity index 85% rename from heatmaps/src/main/java/com/google/maps/android/heatmaps/Gradient.kt rename to heatmaps/src/commonMain/kotlin/com/google/maps/android/heatmaps/Gradient.kt index bcdcedc05..610eb425d 100644 --- a/heatmaps/src/main/java/com/google/maps/android/heatmaps/Gradient.kt +++ b/heatmaps/src/commonMain/kotlin/com/google/maps/android/heatmaps/Gradient.kt @@ -15,8 +15,8 @@ */ package com.google.maps.android.heatmaps -import android.graphics.Color -import java.util.HashMap +import kotlin.jvm.JvmOverloads +import kotlin.jvm.JvmStatic /** * A class to generate a color map from a given array of colors and the fractions @@ -60,11 +60,11 @@ class Gradient // The initial color is transparent by default if (startPoints[0] != 0f) { val initialColor = - Color.argb( + ColorUtils.argb( 0, - Color.red(colors[0]), - Color.green(colors[0]), - Color.blue(colors[0]), + ColorUtils.red(colors[0]), + ColorUtils.green(colors[0]), + ColorUtils.blue(colors[0]), ) colorIntervals[0] = ColorInterval(initialColor, colors[0], colorMapSize * startPoints[0]) } @@ -93,11 +93,11 @@ class Gradient * 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]. + * multiplied by this factor. The default value is [HeatmapConstants.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 { + fun generateColorMap(opacity: Double = HeatmapConstants.DEFAULT_OPACITY): IntArray { val colorIntervals = generateColorIntervals() val colorMap = IntArray(colorMapSize) var interval = colorIntervals[0] @@ -114,11 +114,11 @@ class Gradient 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), + ColorUtils.argb( + (ColorUtils.alpha(c) * opacity).toInt(), + ColorUtils.red(c), + ColorUtils.green(c), + ColorUtils.blue(c), ) } } @@ -143,11 +143,11 @@ class Gradient color2: Int, ratio: Float, ): Int { - val alpha = ((Color.alpha(color2) - Color.alpha(color1)) * ratio + Color.alpha(color1)).toInt() + val alpha = ((ColorUtils.alpha(color2) - ColorUtils.alpha(color1)) * ratio + ColorUtils.alpha(color1)).toInt() val hsv1 = FloatArray(3) - Color.RGBToHSV(Color.red(color1), Color.green(color1), Color.blue(color1), hsv1) + ColorUtils.rgbToHsv(ColorUtils.red(color1), ColorUtils.green(color1), ColorUtils.blue(color1), hsv1) val hsv2 = FloatArray(3) - Color.RGBToHSV(Color.red(color2), Color.green(color2), Color.blue(color2), hsv2) + ColorUtils.rgbToHsv(ColorUtils.red(color2), ColorUtils.green(color2), ColorUtils.blue(color2), hsv2) // adjust so that the shortest path on the color wheel will be taken if (hsv1[0] - hsv2[0] > 180) { @@ -161,7 +161,7 @@ class Gradient for (i in 0..2) { result[i] = (hsv2[i] - hsv1[i]) * ratio + hsv1[i] } - return Color.HSVToColor(alpha, result) + return ColorUtils.hsvToColor(alpha, result) } } } diff --git a/heatmaps/src/commonMain/kotlin/com/google/maps/android/heatmaps/HeatmapConstants.kt b/heatmaps/src/commonMain/kotlin/com/google/maps/android/heatmaps/HeatmapConstants.kt new file mode 100644 index 000000000..dea89e3b4 --- /dev/null +++ b/heatmaps/src/commonMain/kotlin/com/google/maps/android/heatmaps/HeatmapConstants.kt @@ -0,0 +1,29 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.maps.android.heatmaps + +/** + * Constants shared between the multiplatform heatmap model and the platform tile providers. + * They were historically defined on HeatmapTileProvider, which remains Android-only; its + * companion aliases these values to preserve the public API. + */ +object HeatmapConstants { + /** Default opacity of heatmap overlay. */ + const val DEFAULT_OPACITY: Double = 0.7 + + /** Width of the world (in the Mercator projection used by the heatmap quadtree). */ + internal const val WORLD_WIDTH: Double = 1.0 +} diff --git a/heatmaps/src/main/java/com/google/maps/android/heatmaps/WeightedLatLng.kt b/heatmaps/src/commonMain/kotlin/com/google/maps/android/heatmaps/WeightedLatLng.kt similarity index 93% rename from heatmaps/src/main/java/com/google/maps/android/heatmaps/WeightedLatLng.kt rename to heatmaps/src/commonMain/kotlin/com/google/maps/android/heatmaps/WeightedLatLng.kt index 8601dd082..c4d1d1710 100644 --- a/heatmaps/src/main/java/com/google/maps/android/heatmaps/WeightedLatLng.kt +++ b/heatmaps/src/commonMain/kotlin/com/google/maps/android/heatmaps/WeightedLatLng.kt @@ -15,7 +15,8 @@ */ package com.google.maps.android.heatmaps -import com.google.android.gms.maps.model.LatLng +import com.google.maps.android.model.LatLng +import kotlin.jvm.JvmOverloads import com.google.maps.android.geometry.Point import com.google.maps.android.projection.SphericalMercatorProjection import com.google.maps.android.quadtree.PointQuadTree @@ -39,6 +40,6 @@ data class WeightedLatLng( companion object { const val DEFAULT_INTENSITY = 1.0 - private val sProjection = SphericalMercatorProjection(HeatmapTileProvider.WORLD_WIDTH) + private val sProjection = SphericalMercatorProjection(HeatmapConstants.WORLD_WIDTH) } } diff --git a/heatmaps/src/test/java/com/google/maps/android/heatmaps/UtilTest.java b/heatmaps/src/test/java/com/google/maps/android/heatmaps/UtilTest.java deleted file mode 100644 index 85a87817b..000000000 --- a/heatmaps/src/test/java/com/google/maps/android/heatmaps/UtilTest.java +++ /dev/null @@ -1,154 +0,0 @@ -/* - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.google.maps.android.heatmaps; - -import static org.junit.Assert.assertArrayEquals; -import static org.junit.Assert.assertTrue; - -import com.google.android.gms.maps.model.LatLng; -import com.google.maps.android.geometry.Bounds; -import java.util.ArrayList; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.robolectric.RobolectricTestRunner; - -/** Tests for heatmap utility functions */ -@RunWith(RobolectricTestRunner.class) -public class UtilTest { - @Test - public void testGenerateKernel() { - double[] testKernel = HeatmapTileProvider.generateKernel(5, 1.5); - double[] expectedKernel = { - 0.0038659201394728076, - 0.028565500784550377, - 0.1353352832366127, - 0.41111229050718745, - 0.8007374029168081, - 1.0, - 0.8007374029168081, - 0.41111229050718745, - 0.1353352832366127, - 0.028565500784550377, - 0.0038659201394728076 - }; - - assertArrayEquals(expectedKernel, testKernel, 0.0); - } - - @Test - public void testConvolveCorners() { - /* - 1 0 0 0 1 - 0 0 0 0 0 - 0 0 0 0 0 - 0 0 0 0 0 - 1 0 0 0 1 - */ - double[][] grid = new double[5][5]; - grid[0][0] = 1; - grid[4][4] = 1; - grid[4][0] = 1; - grid[0][4] = 1; - double[] testKernel = {0.5, 1, 0.5}; - double[][] convolved = HeatmapTileProvider.convolve(grid, testKernel); - double[][] expected = {{0.25, 0, 0.25}, {0, 0, 0}, {0.25, 0, 0.25}}; - assertArrayEquals(expected, convolved); - } - - @Test - public void testConvolveEdges() { - /* - 0 0 1 0 0 - 0 0 0 0 0 - 1 0 0 0 1 - 0 0 0 0 0 - 0 0 1 0 0 - */ - double[][] grid = new double[5][5]; - grid[0][2] = 1; - grid[2][0] = 1; - grid[2][4] = 1; - grid[4][2] = 1; - double[] testKernel = {0.5, 1, 0.5}; - double[][] convolved = HeatmapTileProvider.convolve(grid, testKernel); - double[][] expected = {{0.5, 0.5, 0.5}, {0.5, 0, 0.5}, {0.5, 0.5, 0.5}}; - assertArrayEquals(expected, convolved); - } - - @Test - public void testConvolveCentre() { - /* - 0 0 0 0 0 - 0 0 1 0 0 - 0 1 2 1 0 - 0 0 1 0 0 - 0 0 0 0 0 - */ - double[][] grid = new double[5][5]; - grid[2][2] = 2; - grid[2][1] = 1; - grid[1][2] = 1; - grid[2][3] = 1; - grid[3][2] = 1; - double[] testKernel = {0.5, 1, 0.5}; - double[][] convolved = HeatmapTileProvider.convolve(grid, testKernel); - double[][] expected = {{1.5, 2.5, 1.5}, {2.5, 4.0, 2.5}, {1.5, 2.5, 1.5}}; - assertArrayEquals(expected, convolved); - } - - @Test - public void testGetBounds() { - - /* - y - ^ - | 3 - | 1 - | 2 - ------------> x - */ - - ArrayList data = new ArrayList<>(); - WeightedLatLng first = new WeightedLatLng(new LatLng(10, 20)); - data.add(first); - double x1 = first.getPoint().x; - double y1 = first.getPoint().y; - - Bounds bounds = HeatmapTileProvider.getBounds(data); - Bounds expected = new Bounds(x1, x1, y1, y1); - - assertTrue(bounds.contains(expected) && expected.contains(bounds)); - - WeightedLatLng second = new WeightedLatLng(new LatLng(20, 30)); - data.add(second); - double x2 = second.getPoint().x; - double y2 = second.getPoint().y; - - bounds = HeatmapTileProvider.getBounds(data); - expected = new Bounds(x1, x2, y2, y1); - - assertTrue(bounds.contains(expected) && expected.contains(bounds)); - - WeightedLatLng third = new WeightedLatLng(new LatLng(5, 10)); - data.add(third); - double x3 = third.getPoint().x; - double y3 = third.getPoint().y; - - bounds = HeatmapTileProvider.getBounds(data); - expected = new Bounds(x3, x2, y2, y3); - assertTrue(bounds.contains(expected) && expected.contains(bounds)); - } -} diff --git a/library/build.gradle.kts b/library/build.gradle.kts index 88119e578..befac9d11 100644 --- a/library/build.gradle.kts +++ b/library/build.gradle.kts @@ -1,5 +1,3 @@ -import org.jetbrains.kotlin.gradle.dsl.JvmTarget - /** * Copyright 2026 Google LLC * @@ -16,80 +14,16 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget * limitations under the License. */ plugins { + id("org.jetbrains.kotlin.multiplatform") + id("com.android.kotlin.multiplatform.library") id("org.jetbrains.dokka") - id("android.maps.utils.PublishingConventionPlugin") -} - -android { - lint { - sarifOutput = layout.buildDirectory.file("reports/lint-results.sarif").get().asFile - } - defaultConfig { - compileSdk = libs.versions.compileSdk.get().toInt() - minSdk = libs.versions.minimumSdk.get().toInt() - testOptions.targetSdk = libs.versions.targetSdk.get().toInt() - consumerProguardFiles("consumer-rules.pro") - } - buildTypes { - release { - isMinifyEnabled = false - proguardFiles( - getDefaultProguardFile("proguard-android-optimize.txt"), - "proguard-rules.pro" - ) - } - } - resourcePrefix = "amu_" - - installation { - timeOutInMs = 10 * 60 * 1000 // 10 minutes - installOptions += listOf("-d", "-t") - } - - kotlin { - compilerOptions { - jvmTarget.set(JvmTarget.JVM_17) - } - jvmToolchain(17) - } - - testOptions { - animationsDisabled = true - unitTests.isIncludeAndroidResources = true - unitTests.isReturnDefaultValues = true - } - namespace = "com.google.maps.android" + // Prototype publishing: KMP auto-creates multiplatform publications, enabling + // publishToMavenLocal so android-maps-compose can consume this via -PuseMavenLocal=true. + id("maven-publish") } -dependencies { - api(libs.play.services.maps) - implementation(libs.kotlinx.coroutines.android) - implementation(libs.appcompat) - implementation(libs.core.ktx) - implementation(libs.startup.runtime) - lintPublish(project(":lint-checks")) - testImplementation(libs.junit) - testImplementation(libs.robolectric) - testImplementation(libs.kxml2) - testImplementation(libs.mockk) - testImplementation(libs.kotlin.test) - testImplementation(libs.androidx.test.core) - testImplementation(libs.truth) - implementation(libs.kotlin.stdlib.jdk8) - - testImplementation(libs.mockk) - testImplementation(libs.kotlinx.coroutines.test) - testImplementation(libs.robolectric) - testImplementation(libs.mockito.core) -} - -tasks.register("instrumentTest") { - dependsOn("connectedCheck") -} - -if (System.getenv("JITPACK") != null) { - apply(plugin = "maven") -} +// NOTE (KMP prototype): see clustering/build.gradle.kts — release publishing (vanniktech), +// jacoco, lint-checks and the amu_ resourcePrefix still need KMP-aware re-wiring. abstract class GenerateArtifactIdTask : DefaultTask() { @get:OutputDirectory @@ -127,15 +61,59 @@ val generateArtifactIdFile = tasks.register("generateArt version.set(project.version.toString()) } -androidComponents { - onVariants { variant -> - variant.sources.java?.addGeneratedSourceDirectory( - generateArtifactIdFile, - GenerateArtifactIdTask::outputDir - ) +kotlin { + jvmToolchain(17) + + androidLibrary { + namespace = "com.google.maps.android" + compileSdk = libs.versions.compileSdk.get().toInt() + minSdk = 23 + + withHostTestBuilder { + }.configure { + isIncludeAndroidResources = true + isReturnDefaultValues = true + } + } + + iosArm64() + iosSimulatorArm64() + iosX64() + + sourceSets { + commonMain.dependencies { + api(project(":maps-model")) + } + commonTest.dependencies { + implementation(libs.kotlin.test) + } + androidMain { + kotlin.srcDir(generateArtifactIdFile) + } + androidMain.dependencies { + api(libs.play.services.maps) + implementation(libs.kotlinx.coroutines.android) + implementation(libs.appcompat) + implementation(libs.core.ktx) + implementation(libs.startup.runtime) + } + getByName("androidHostTest").dependencies { + implementation(libs.junit) + implementation(libs.robolectric) + implementation(libs.kxml2) + implementation(libs.mockk) + implementation(libs.androidx.test.core) + implementation(libs.truth) + implementation(libs.kotlinx.coroutines.test) + } } } -tasks.named("dokkaGeneratePublicationHtml") { - dependsOn(generateArtifactIdFile) +// Publish under the repo's public artifactId scheme so these coordinates conflict-resolve +// against the AARs already on Maven Central instead of duplicating their classes. +// This module's public artifactId is android-maps-utils-core. +publishing { + publications.withType().configureEach { + artifactId = artifactId.replace(project.name, "android-maps-utils-core") + } } diff --git a/library/src/test/java/com/google/maps/android/StreetViewHelperTest.kt b/library/src/androidHostTest/kotlin/com/google/maps/android/StreetViewHelperTest.kt similarity index 100% rename from library/src/test/java/com/google/maps/android/StreetViewHelperTest.kt rename to library/src/androidHostTest/kotlin/com/google/maps/android/StreetViewHelperTest.kt diff --git a/library/src/test/java/com/google/maps/android/collections/CircleManagerTest.kt b/library/src/androidHostTest/kotlin/com/google/maps/android/collections/CircleManagerTest.kt similarity index 100% rename from library/src/test/java/com/google/maps/android/collections/CircleManagerTest.kt rename to library/src/androidHostTest/kotlin/com/google/maps/android/collections/CircleManagerTest.kt diff --git a/library/src/test/java/com/google/maps/android/collections/GroundOverlayManagerTest.kt b/library/src/androidHostTest/kotlin/com/google/maps/android/collections/GroundOverlayManagerTest.kt similarity index 100% rename from library/src/test/java/com/google/maps/android/collections/GroundOverlayManagerTest.kt rename to library/src/androidHostTest/kotlin/com/google/maps/android/collections/GroundOverlayManagerTest.kt diff --git a/library/src/test/java/com/google/maps/android/collections/MapObjectManagerTest.kt b/library/src/androidHostTest/kotlin/com/google/maps/android/collections/MapObjectManagerTest.kt similarity index 100% rename from library/src/test/java/com/google/maps/android/collections/MapObjectManagerTest.kt rename to library/src/androidHostTest/kotlin/com/google/maps/android/collections/MapObjectManagerTest.kt diff --git a/library/src/test/java/com/google/maps/android/collections/MarkerManagerTest.kt b/library/src/androidHostTest/kotlin/com/google/maps/android/collections/MarkerManagerTest.kt similarity index 100% rename from library/src/test/java/com/google/maps/android/collections/MarkerManagerTest.kt rename to library/src/androidHostTest/kotlin/com/google/maps/android/collections/MarkerManagerTest.kt diff --git a/library/src/test/java/com/google/maps/android/collections/PolygonManagerTest.kt b/library/src/androidHostTest/kotlin/com/google/maps/android/collections/PolygonManagerTest.kt similarity index 100% rename from library/src/test/java/com/google/maps/android/collections/PolygonManagerTest.kt rename to library/src/androidHostTest/kotlin/com/google/maps/android/collections/PolygonManagerTest.kt diff --git a/library/src/test/java/com/google/maps/android/collections/PolylineManagerTest.kt b/library/src/androidHostTest/kotlin/com/google/maps/android/collections/PolylineManagerTest.kt similarity index 100% rename from library/src/test/java/com/google/maps/android/collections/PolylineManagerTest.kt rename to library/src/androidHostTest/kotlin/com/google/maps/android/collections/PolylineManagerTest.kt diff --git a/library/src/test/java/com/google/maps/android/utils/attribution/AttributionIdInitializerTest.kt b/library/src/androidHostTest/kotlin/com/google/maps/android/utils/attribution/AttributionIdInitializerTest.kt similarity index 100% rename from library/src/test/java/com/google/maps/android/utils/attribution/AttributionIdInitializerTest.kt rename to library/src/androidHostTest/kotlin/com/google/maps/android/utils/attribution/AttributionIdInitializerTest.kt diff --git a/library/src/androidHostTest/resources/robolectric.properties b/library/src/androidHostTest/resources/robolectric.properties new file mode 100644 index 000000000..932b01b9e --- /dev/null +++ b/library/src/androidHostTest/resources/robolectric.properties @@ -0,0 +1 @@ +sdk=28 diff --git a/library/src/main/AndroidManifest.xml b/library/src/androidMain/AndroidManifest.xml similarity index 100% rename from library/src/main/AndroidManifest.xml rename to library/src/androidMain/AndroidManifest.xml diff --git a/library/src/main/java/com/google/maps/android/StreetViewJavaHelper.kt b/library/src/androidMain/kotlin/com/google/maps/android/StreetViewJavaHelper.kt similarity index 100% rename from library/src/main/java/com/google/maps/android/StreetViewJavaHelper.kt rename to library/src/androidMain/kotlin/com/google/maps/android/StreetViewJavaHelper.kt diff --git a/library/src/main/java/com/google/maps/android/StreetViewUtil.kt b/library/src/androidMain/kotlin/com/google/maps/android/StreetViewUtil.kt similarity index 100% rename from library/src/main/java/com/google/maps/android/StreetViewUtil.kt rename to library/src/androidMain/kotlin/com/google/maps/android/StreetViewUtil.kt diff --git a/library/src/main/java/com/google/maps/android/collections/CircleManager.kt b/library/src/androidMain/kotlin/com/google/maps/android/collections/CircleManager.kt similarity index 100% rename from library/src/main/java/com/google/maps/android/collections/CircleManager.kt rename to library/src/androidMain/kotlin/com/google/maps/android/collections/CircleManager.kt diff --git a/library/src/main/java/com/google/maps/android/collections/GroundOverlayManager.kt b/library/src/androidMain/kotlin/com/google/maps/android/collections/GroundOverlayManager.kt similarity index 100% rename from library/src/main/java/com/google/maps/android/collections/GroundOverlayManager.kt rename to library/src/androidMain/kotlin/com/google/maps/android/collections/GroundOverlayManager.kt diff --git a/library/src/main/java/com/google/maps/android/collections/MapObjectManager.kt b/library/src/androidMain/kotlin/com/google/maps/android/collections/MapObjectManager.kt similarity index 100% rename from library/src/main/java/com/google/maps/android/collections/MapObjectManager.kt rename to library/src/androidMain/kotlin/com/google/maps/android/collections/MapObjectManager.kt diff --git a/library/src/main/java/com/google/maps/android/collections/MarkerManager.kt b/library/src/androidMain/kotlin/com/google/maps/android/collections/MarkerManager.kt similarity index 100% rename from library/src/main/java/com/google/maps/android/collections/MarkerManager.kt rename to library/src/androidMain/kotlin/com/google/maps/android/collections/MarkerManager.kt diff --git a/library/src/main/java/com/google/maps/android/collections/PolygonManager.kt b/library/src/androidMain/kotlin/com/google/maps/android/collections/PolygonManager.kt similarity index 100% rename from library/src/main/java/com/google/maps/android/collections/PolygonManager.kt rename to library/src/androidMain/kotlin/com/google/maps/android/collections/PolygonManager.kt diff --git a/library/src/main/java/com/google/maps/android/collections/PolylineManager.kt b/library/src/androidMain/kotlin/com/google/maps/android/collections/PolylineManager.kt similarity index 100% rename from library/src/main/java/com/google/maps/android/collections/PolylineManager.kt rename to library/src/androidMain/kotlin/com/google/maps/android/collections/PolylineManager.kt diff --git a/library/src/main/java/com/google/maps/android/utils/attribution/AttributionIdInitializer.kt b/library/src/androidMain/kotlin/com/google/maps/android/utils/attribution/AttributionIdInitializer.kt similarity index 100% rename from library/src/main/java/com/google/maps/android/utils/attribution/AttributionIdInitializer.kt rename to library/src/androidMain/kotlin/com/google/maps/android/utils/attribution/AttributionIdInitializer.kt diff --git a/library/src/commonMain/kotlin/com/google/maps/android/AngleConversions.kt b/library/src/commonMain/kotlin/com/google/maps/android/AngleConversions.kt new file mode 100644 index 000000000..cbc4bf1b7 --- /dev/null +++ b/library/src/commonMain/kotlin/com/google/maps/android/AngleConversions.kt @@ -0,0 +1,23 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.maps.android + +import kotlin.math.PI + +/** Multiplatform replacements for JVM-only Math.toRadians/Math.toDegrees. */ +internal fun toRadians(degrees: Double): Double = degrees * PI / 180.0 + +internal fun toDegrees(radians: Double): Double = radians * 180.0 / PI diff --git a/library/src/main/java/com/google/maps/android/MathUtil.kt b/library/src/commonMain/kotlin/com/google/maps/android/MathUtil.kt similarity index 97% rename from library/src/main/java/com/google/maps/android/MathUtil.kt rename to library/src/commonMain/kotlin/com/google/maps/android/MathUtil.kt index 62728d59b..b38612cec 100644 --- a/library/src/main/java/com/google/maps/android/MathUtil.kt +++ b/library/src/commonMain/kotlin/com/google/maps/android/MathUtil.kt @@ -24,6 +24,7 @@ import kotlin.math.ln import kotlin.math.sin import kotlin.math.sqrt import kotlin.math.tan +import kotlin.jvm.JvmStatic /** * Utility functions that are used my both PolyUtil and SphericalUtil. @@ -84,10 +85,10 @@ object MathUtil { */ @JvmStatic fun mercator(lat: Double): Double { - if (lat > Math.PI / 2 - 1e-9) { + if (lat > PI / 2 - 1e-9) { return Double.POSITIVE_INFINITY } - if (lat < -Math.PI / 2 + 1e-9) { + if (lat < -PI / 2 + 1e-9) { return Double.NEGATIVE_INFINITY } return ln(tan(lat * 0.5 + PI / 4)) diff --git a/library/src/main/java/com/google/maps/android/PolyUtil.kt b/library/src/commonMain/kotlin/com/google/maps/android/PolyUtil.kt similarity index 92% rename from library/src/main/java/com/google/maps/android/PolyUtil.kt rename to library/src/commonMain/kotlin/com/google/maps/android/PolyUtil.kt index d2ab855c0..18b41681e 100644 --- a/library/src/main/java/com/google/maps/android/PolyUtil.kt +++ b/library/src/commonMain/kotlin/com/google/maps/android/PolyUtil.kt @@ -15,7 +15,9 @@ */ package com.google.maps.android -import com.google.android.gms.maps.model.LatLng +import com.google.maps.android.model.LatLng +import com.google.maps.android.model.latitude +import com.google.maps.android.model.longitude import com.google.maps.android.MathUtil.clamp import com.google.maps.android.MathUtil.hav import com.google.maps.android.MathUtil.havDistance @@ -33,6 +35,9 @@ import kotlin.math.round import kotlin.math.sin import kotlin.math.sqrt import kotlin.math.tan +import kotlin.math.PI +import kotlin.jvm.JvmStatic +import kotlin.jvm.JvmOverloads /** * A utility class containing geometric calculations for polygons and polylines. @@ -81,23 +86,23 @@ object PolyUtil { return false } - val lat3 = Math.toRadians(latitude) - val lng3 = Math.toRadians(longitude) + val lat3 = toRadians(latitude) + val lng3 = toRadians(longitude) val prev = polygon.last() - var lat1 = Math.toRadians(prev.latitude) - var lng1 = Math.toRadians(prev.longitude) + var lat1 = toRadians(prev.latitude) + var lng1 = toRadians(prev.longitude) var nIntersect = 0 for (point2 in polygon) { - val dLng3 = wrap(lng3 - lng1, -Math.PI, Math.PI) + val dLng3 = wrap(lng3 - lng1, -PI, 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) + val lat2 = toRadians(point2.latitude) + val lng2 = toRadians(point2.longitude) // Offset longitudes by -lng1. - if (intersects(lat1, lat2, wrap(lng2 - lng1, -Math.PI, Math.PI), lat3, dLng3, geodesic)) { + if (intersects(lat1, lat2, wrap(lng2 - lng1, -PI, PI), lat3, dLng3, geodesic)) { ++nIntersect } lat1 = lat2 @@ -213,16 +218,16 @@ object PolyUtil { } val tolerance = toleranceEarth / MathUtil.EARTH_RADIUS val havTolerance = hav(tolerance) - val lat3 = Math.toRadians(point.latitude) - val lng3 = Math.toRadians(point.longitude) + val lat3 = toRadians(point.latitude) + val lng3 = 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 lat1 = toRadians(prev.latitude) + var lng1 = toRadians(prev.longitude) var idx = 0 if (geodesic) { for (point2 in poly) { - val lat2 = Math.toRadians(point2.latitude) - val lng2 = Math.toRadians(point2.longitude) + val lat2 = toRadians(point2.latitude) + val lng2 = toRadians(point2.longitude) if (isOnSegmentGC(lat1, lng1, lat2, lng2, lat3, lng3, havTolerance)) { return max(0, idx - 1) } @@ -242,17 +247,17 @@ object PolyUtil { val y3 = mercator(lat3) val xTry = DoubleArray(3) for (point2 in poly) { - val lat2 = Math.toRadians(point2.latitude) + val lat2 = toRadians(point2.latitude) val y2 = mercator(lat2) - val lng2 = Math.toRadians(point2.longitude) + val lng2 = 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) + val x2 = wrap(lng2 - lng1, -PI, PI) + val x3Base = wrap(lng3 - lng1, -PI, 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 + xTry[1] = x3Base + 2 * PI + xTry[2] = x3Base - 2 * PI for (x3 in xTry) { val dy = y2 - y1 val len2 = x2 * x2 + dy * dy @@ -419,12 +424,12 @@ object PolyUtil { 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 s0lat = toRadians(p.latitude) + val s0lng = toRadians(p.longitude) + val s1lat = toRadians(start.latitude) + val s1lng = toRadians(start.longitude) + val s2lat = toRadians(end.latitude) + val s2lng = toRadians(end.longitude) val lonCorrection = cos(s1lat) val s2s1lat = s2lat - s1lat @@ -515,10 +520,10 @@ object PolyUtil { ) { 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))) + result.append((((0x20 or (value and 0x1f).toInt()) + 63)).toChar()) value = value shr 5 } - result.append(Character.toChars((value + 63).toInt())) + result.append(((value + 63).toInt()).toChar()) } /** @@ -560,14 +565,14 @@ object PolyUtil { return false } // Point is South Pole. - if (lat3 <= -Math.PI / 2) { + if (lat3 <= -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) { + if (lat1 <= -PI / 2 || lat2 <= -PI / 2 || lat1 >= PI / 2 || lat2 >= PI / 2) { return false } - if (lng2 <= -Math.PI) { + if (lng2 <= -PI) { return false } val linearLat = (lat1 * (lng2 - lng3) + lat2 * lng3) / lng2 @@ -580,7 +585,7 @@ object PolyUtil { return true } // North Pole. - if (lat3 >= Math.PI / 2) { + if (lat3 >= PI / 2) { return true } // Compare lat3 with latitude on the GC/Rhumb segment corresponding to lng3. diff --git a/library/src/main/java/com/google/maps/android/SphericalUtil.kt b/library/src/commonMain/kotlin/com/google/maps/android/SphericalUtil.kt similarity index 87% rename from library/src/main/java/com/google/maps/android/SphericalUtil.kt rename to library/src/commonMain/kotlin/com/google/maps/android/SphericalUtil.kt index 504b4f693..64cbddec9 100644 --- a/library/src/main/java/com/google/maps/android/SphericalUtil.kt +++ b/library/src/commonMain/kotlin/com/google/maps/android/SphericalUtil.kt @@ -15,7 +15,9 @@ */ package com.google.maps.android -import com.google.android.gms.maps.model.LatLng +import com.google.maps.android.model.LatLng +import com.google.maps.android.model.latitude +import com.google.maps.android.model.longitude import com.google.maps.android.MathUtil.EARTH_RADIUS import com.google.maps.android.MathUtil.arcHav import com.google.maps.android.MathUtil.havDistance @@ -28,6 +30,7 @@ import kotlin.math.cos import kotlin.math.sin import kotlin.math.sqrt import kotlin.math.tan +import kotlin.jvm.JvmStatic object SphericalUtil { /** @@ -42,17 +45,17 @@ object SphericalUtil { 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 fromLat = toRadians(from.latitude) + val fromLng = toRadians(from.longitude) + val toLat = toRadians(to.latitude) + val toLng = 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) + return wrap(toDegrees(heading), -180.0, 180.0) } /** @@ -72,10 +75,10 @@ object SphericalUtil { var distance = distance var heading = heading distance /= EARTH_RADIUS - heading = Math.toRadians(heading) + heading = toRadians(heading) // http://williams.best.vwh.net/avform.htm#LL - val fromLat = Math.toRadians(from.latitude) - val fromLng = Math.toRadians(from.longitude) + val fromLat = toRadians(from.latitude) + val fromLng = toRadians(from.longitude) val cosDistance = cos(distance) val sinDistance = sin(distance) val sinFromLat = sin(fromLat) @@ -86,7 +89,7 @@ object SphericalUtil { sinDistance * cosFromLat * sin(heading), cosDistance - sinFromLat * sinLat, ) - return LatLng(Math.toDegrees(asin(sinLat)), Math.toDegrees(fromLng + dLng)) + return LatLng(toDegrees(asin(sinLat)), toDegrees(fromLng + dLng)) } /** @@ -107,13 +110,13 @@ object SphericalUtil { ): LatLng? { var distance = distance var heading = heading - heading = Math.toRadians(heading) + heading = 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)) + val 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. @@ -137,9 +140,9 @@ object SphericalUtil { return null } val fromLngRadians = - Math.toRadians(to.longitude) - + toRadians(to.longitude) - atan2(n3, n1 * cos(fromLatRadians) - n2 * sin(fromLatRadians)) - return LatLng(Math.toDegrees(fromLatRadians), Math.toDegrees(fromLngRadians)) + return LatLng(toDegrees(fromLatRadians), toDegrees(fromLngRadians)) } /** @@ -158,10 +161,10 @@ object SphericalUtil { 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 fromLat = toRadians(from.latitude) + val fromLng = toRadians(from.longitude) + val toLat = toRadians(to.latitude) + val toLng = toRadians(to.longitude) val cosFromLat = cos(fromLat) val cosToLat = cos(toLat) @@ -185,7 +188,7 @@ object SphericalUtil { // 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)) + return LatLng(toDegrees(lat), toDegrees(lng)) } /** @@ -208,10 +211,10 @@ object SphericalUtil { to: LatLng, ): Double = distanceRadians( - Math.toRadians(from.latitude), - Math.toRadians(from.longitude), - Math.toRadians(to.latitude), - Math.toRadians(to.longitude), + toRadians(from.latitude), + toRadians(from.longitude), + toRadians(to.latitude), + toRadians(to.longitude), ) /** @@ -235,10 +238,10 @@ object SphericalUtil { 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) + val prevLat = toRadians(prev.latitude) + val prevLng = toRadians(prev.longitude) + val lat = toRadians(point.latitude) + val lng = toRadians(point.longitude) length += distanceRadians(prevLat, prevLng, lat, lng) } prev = point @@ -282,13 +285,13 @@ object SphericalUtil { } 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) + var prevTanLat = tan((PI / 2 - toRadians(prev.latitude)) / 2) + var 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 (point in path) { - val tanLat = tan((PI / 2 - Math.toRadians(point.latitude)) / 2) - val lng = Math.toRadians(point.longitude) + val tanLat = tan((PI / 2 - toRadians(point.latitude)) / 2) + val lng = toRadians(point.longitude) total += polarTriangleArea(tanLat, lng, prevTanLat, prevLng) prevTanLat = tanLat prevLng = lng diff --git a/library/src/commonTest/kotlin/com/google/maps/android/MathUtilTest.kt b/library/src/commonTest/kotlin/com/google/maps/android/MathUtilTest.kt new file mode 100644 index 000000000..a29c9e05d --- /dev/null +++ b/library/src/commonTest/kotlin/com/google/maps/android/MathUtilTest.kt @@ -0,0 +1,101 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.maps.android + +import kotlin.math.PI +import kotlin.test.Test +import kotlin.test.assertEquals + +class MathUtilTest { + @Test + fun testClamp() { + assertEquals(1.0, MathUtil.clamp(1.0, 0.0, 2.0), DELTA) + assertEquals(0.0, MathUtil.clamp(-1.0, 0.0, 2.0), DELTA) + assertEquals(2.0, MathUtil.clamp(3.0, 0.0, 2.0), DELTA) + } + + @Test + fun testWrap() { + assertEquals(1.0, MathUtil.wrap(1.0, 0.0, 2.0), DELTA) + assertEquals(1.0, MathUtil.wrap(3.0, 0.0, 2.0), DELTA) + assertEquals(1.0, MathUtil.wrap(-1.0, 0.0, 2.0), DELTA) + } + + @Test + fun testMod() { + assertEquals(1.0, MathUtil.mod(1.0, 2.0), DELTA) + assertEquals(1.0, MathUtil.mod(3.0, 2.0), DELTA) + assertEquals(1.0, MathUtil.mod(-1.0, 2.0), DELTA) + } + + @Test + fun testMercator() { + assertEquals(0.0, MathUtil.mercator(0.0), DELTA) + assertEquals(Double.POSITIVE_INFINITY, MathUtil.mercator(PI / 2)) + assertEquals(Double.NEGATIVE_INFINITY, MathUtil.mercator(-PI / 2)) + } + + @Test + fun testInverseMercator() { + assertEquals(0.0, MathUtil.inverseMercator(0.0), DELTA) + assertEquals(PI / 2, MathUtil.inverseMercator(Double.POSITIVE_INFINITY), DELTA) + assertEquals(-PI / 2, MathUtil.inverseMercator(Double.NEGATIVE_INFINITY), DELTA) + } + + @Test + fun testHav() { + assertEquals(0.0, MathUtil.hav(0.0), DELTA) + assertEquals(1.0, MathUtil.hav(PI), DELTA) + assertEquals(0.5, MathUtil.hav(PI / 2), DELTA) + } + + @Test + fun testArcHav() { + assertEquals(0.0, MathUtil.arcHav(0.0), DELTA) + assertEquals(PI, MathUtil.arcHav(1.0), DELTA) + assertEquals(PI / 2, MathUtil.arcHav(0.5), DELTA) + } + + @Test + fun testSinFromHav() { + assertEquals(0.0, MathUtil.sinFromHav(0.0), DELTA) + assertEquals(0.0, MathUtil.sinFromHav(1.0), DELTA) + assertEquals(1.0, MathUtil.sinFromHav(0.5), DELTA) + } + + @Test + fun testHavFromSin() { + assertEquals(0.0, MathUtil.havFromSin(0.0), DELTA) + assertEquals(0.5, MathUtil.havFromSin(1.0), DELTA) + } + + @Test + fun testSinSumFromHav() { + assertEquals(0.0, MathUtil.sinSumFromHav(0.0, 0.0), DELTA) + assertEquals(1.0, MathUtil.sinSumFromHav(0.5, 0.0), DELTA) + assertEquals(1.0, MathUtil.sinSumFromHav(0.0, 0.5), DELTA) + } + + @Test + fun testHavDistance() { + assertEquals(0.0, MathUtil.havDistance(0.0, 0.0, 0.0), DELTA) + assertEquals(1.0, MathUtil.havDistance(0.0, PI, 0.0), DELTA) + } + + companion object { + private const val DELTA = 1e-15 + } +} diff --git a/library/src/commonTest/kotlin/com/google/maps/android/PolyUtilTest.kt b/library/src/commonTest/kotlin/com/google/maps/android/PolyUtilTest.kt new file mode 100644 index 000000000..459779fff --- /dev/null +++ b/library/src/commonTest/kotlin/com/google/maps/android/PolyUtilTest.kt @@ -0,0 +1,640 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.maps.android + +import com.google.maps.android.model.LatLng +import com.google.maps.android.model.latitude +import com.google.maps.android.model.longitude +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertSame +import kotlin.test.assertTrue + +/** + * This class defines a series of tests for the [PolyUtil] class. Each test is designed to + * verify the correctness of a specific geometric utility function provided by [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. + */ +class PolyUtilTest { + /** + * 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 + fun testOnEdge() { + // Empty + onEdgeCase(makeList(), makeList(), makeList(0.0, 0.0)) + + val small = 5e-7 // About 5cm on equator, half the default tolerance. + val big = 2e-6 // About 10cm on equator, double the default tolerance. + + // Endpoints + onEdgeCase(makeList(1.0, 2.0), makeList(1.0, 2.0), makeList(3.0, 5.0)) + onEdgeCase(makeList(1.0, 2.0, 3.0, 5.0), makeList(1.0, 2.0, 3.0, 5.0), makeList(0.0, 0.0)) + + // On equator. + onEdgeCase( + makeList(0.0, 90.0, 0.0, 180.0), + makeList(0.0, 90 - small, 0.0, 90 + small, 0 - small, 90.0, 0.0, 135.0, small, 135.0), + makeList(0.0, 90 - big, 0.0, 0.0, 0.0, -90.0, big, 135.0), + ) + + // Ends on same latitude. + onEdgeCase( + makeList(-45.0, -180.0, -45.0, -small), + makeList(-45.0, 180 + small, -45.0, 180 - small, -45 - small, 180 - small, -45.0, 0.0), + makeList(-45.0, big, -45.0, 180 - big, -45 + big, -90.0, -45.0, 90.0), + ) + + // Meridian. + onEdgeCase( + makeList(-10.0, 30.0, 45.0, 30.0), + makeList(10.0, 30 - small, 20.0, 30 + small, -10 - small, 30 + small), + makeList(-10 - big, 30.0, 10.0, -150.0, 0.0, 30 - big), + ) + + // Slanted close to meridian, close to North pole. + onEdgeCase( + makeList(0.0, 0.0, 90 - small, 0 + big), + makeList(1.0, 0 + small, 2.0, 0 - small, 90 - small, -90.0, 90 - small, 10.0), + makeList(-big, 0.0, 90 - big, 180.0, 10.0, big), + ) + + // Arc > 120 deg. + onEdgeCase( + makeList(0.0, 0.0, 0.0, 179.999), + makeList(0.0, 90.0, 0.0, small, 0.0, 179.0, small, 90.0), + makeList(0.0, -90.0, small, -100.0, 0.0, 180.0, 0.0, -big, 90.0, 0.0, -90.0, 180.0), + ) + + onEdgeCase( + makeList(10.0, 5.0, 30.0, 15.0), + makeList(10 + 2 * big, 5 + big, 10 + big, 5 + big / 2, 30 - 2 * big, 15 - big), + makeList( + 20.0, 10.0, 10 - big, 5 - big / 2, 30 + 2 * big, 15 + big, 10 + 2 * big, 5.0, 10.0, 5 + big, + ), + ) + + onEdgeCase( + makeList(90 - small, 0.0, 0.0, 180 - small / 2), + makeList(big, -180 + small / 2, big, 180 - small / 4, big, 180 - small), + makeList(-big, -180 + small / 2, -big, 180.0, -big, 180 - small), + ) + + // Reaching close to North pole. + onEdgeCase( + true, + makeList(80.0, 0.0, 80.0, 180 - small), + makeList(90 - small, -90.0, 90.0, -135.0, 80 - small, 0.0, 80 + small, 0.0), + makeList(80.0, 90.0, 79.0, big), + ) + + onEdgeCase( + false, + makeList(80.0, 0.0, 80.0, 180 - small), + makeList(80 - small, 0.0, 80 + small, 0.0, 80.0, 90.0), + makeList(79.0, big, 90 - small, -90.0, 90.0, -135.0), + ) + } + + /** + * 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 + fun testLocationIndex() { + // Empty. + locationIndexCase(makeList(), LatLng(0.0, 0.0), -1) + + // One point. + locationIndexCase(makeList(1.0, 2.0), LatLng(1.0, 2.0), 0) + locationIndexCase(makeList(1.0, 2.0), LatLng(3.0, 5.0), -1) + + // Two points. + locationIndexCase(makeList(1.0, 2.0, 3.0, 5.0), LatLng(1.0, 2.0), 0) + locationIndexCase(makeList(1.0, 2.0, 3.0, 5.0), LatLng(3.0, 5.0), 0) + locationIndexCase(makeList(1.0, 2.0, 3.0, 5.0), LatLng(4.0, 6.0), -1) + + // Three points. + locationIndexCase(makeList(0.0, 80.0, 0.0, 90.0, 0.0, 100.0), LatLng(0.0, 80.0), 0) + locationIndexCase(makeList(0.0, 80.0, 0.0, 90.0, 0.0, 100.0), LatLng(0.0, 85.0), 0) + locationIndexCase(makeList(0.0, 80.0, 0.0, 90.0, 0.0, 100.0), LatLng(0.0, 90.0), 0) + locationIndexCase(makeList(0.0, 80.0, 0.0, 90.0, 0.0, 100.0), LatLng(0.0, 95.0), 1) + locationIndexCase(makeList(0.0, 80.0, 0.0, 90.0, 0.0, 100.0), LatLng(0.0, 100.0), 1) + locationIndexCase(makeList(0.0, 80.0, 0.0, 90.0, 0.0, 100.0), LatLng(0.0, 110.0), -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 + fun testLocationIndexTolerance() { + val small = 5e-7 // About 5cm on equator, half the default tolerance. + val big = 2e-6 // About 10cm on equator, double the default tolerance. + + // Test tolerance. + locationIndexToleranceCase(makeList(0.0, 90 - small, 0.0, 90.0, 0.0, 90 + small), LatLng(0.0, 90.0), 0) + locationIndexToleranceCase( + makeList(0.0, 90 - small, 0.0, 90.0, 0.0, 90 + small), + LatLng(0.0, 90 + small), + 0, + ) + locationIndexToleranceCase( + makeList(0.0, 90 - small, 0.0, 90.0, 0.0, 90 + small), + LatLng(0.0, 90 + 2 * small), + 1, + ) + locationIndexToleranceCase( + makeList(0.0, 90 - small, 0.0, 90.0, 0.0, 90 + small), + LatLng(0.0, 90 + 3 * small), + -1, + ) + locationIndexToleranceCase(makeList(0.0, 90 - big, 0.0, 90.0, 0.0, 90 + big), LatLng(0.0, 90.0), 0) + locationIndexToleranceCase( + makeList(0.0, 90 - big, 0.0, 90.0, 0.0, 90 + big), + LatLng(0.0, 90 + big), + 1, + ) + locationIndexToleranceCase( + makeList(0.0, 90 - big, 0.0, 90.0, 0.0, 90 + big), + LatLng(0.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 + fun testContainsLocation() { + // Empty. + containsCase(makeList(), makeList(), makeList(0.0, 0.0)) + + // One point. + containsCase(makeList(1.0, 2.0), makeList(1.0, 2.0), makeList(0.0, 0.0)) + + // Two points. + containsCase(makeList(1.0, 2.0, 3.0, 5.0), makeList(1.0, 2.0, 3.0, 5.0), makeList(0.0, 0.0, 40.0, 4.0)) + + // Some arbitrary triangle. + containsCase( + makeList(0.0, 0.0, 10.0, 12.0, 20.0, 5.0), + makeList(10.0, 12.0, 10.0, 11.0, 19.0, 5.0), + makeList(0.0, 1.0, 11.0, 12.0, 30.0, 5.0, 0.0, -180.0, 0.0, 90.0), + ) + + // Around North Pole. + containsCase( + makeList(89.0, 0.0, 89.0, 120.0, 89.0, -120.0), + makeList(90.0, 0.0, 90.0, 180.0, 90.0, -90.0), + makeList(-90.0, 0.0, 0.0, 0.0), + ) + + // Around South Pole. + containsCase( + makeList(-89.0, 0.0, -89.0, 120.0, -89.0, -120.0), + makeList(90.0, 0.0, 90.0, 180.0, 90.0, -90.0, 0.0, 0.0), + makeList(-90.0, 0.0, -90.0, 90.0), + ) + + // Over/under segment on meridian and equator. + containsCase( + makeList(5.0, 10.0, 10.0, 10.0, 0.0, 20.0, 0.0, -10.0), + makeList(2.5, 10.0, 1.0, 0.0), + makeList(15.0, 10.0, 0.0, -15.0, 0.0, 25.0, -1.0, 0.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 + fun testSimplify() { + /* + * Polyline + */ + val encodedLine = + "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@@" + val line = PolyUtil.decode(encodedLine) + assertEquals(95, line.size) + + var simplifiedLine: List + var copy: List + + var tolerance = 5.0 // meters + copy = ArrayList(line) + simplifiedLine = PolyUtil.simplify(line, tolerance) + assertEquals(20, simplifiedLine.size) + assertEndPoints(line, simplifiedLine) + assertSimplifiedPointsFromLine(line, simplifiedLine) + assertLineLength(line, simplifiedLine) + assertInputUnchanged(line, copy) + + tolerance = 10.0 // meters + copy = ArrayList(line) + simplifiedLine = PolyUtil.simplify(line, tolerance) + assertEquals(14, simplifiedLine.size) + assertEndPoints(line, simplifiedLine) + assertSimplifiedPointsFromLine(line, simplifiedLine) + assertLineLength(line, simplifiedLine) + assertInputUnchanged(line, copy) + + tolerance = 15.0 // meters + copy = ArrayList(line) + simplifiedLine = PolyUtil.simplify(line, tolerance) + assertEquals(10, simplifiedLine.size) + assertEndPoints(line, simplifiedLine) + assertSimplifiedPointsFromLine(line, simplifiedLine) + assertLineLength(line, simplifiedLine) + assertInputUnchanged(line, copy) + + tolerance = 20.0 // meters + copy = ArrayList(line) + simplifiedLine = PolyUtil.simplify(line, tolerance) + assertEquals(8, simplifiedLine.size) + assertEndPoints(line, simplifiedLine) + assertSimplifiedPointsFromLine(line, simplifiedLine) + assertLineLength(line, simplifiedLine) + assertInputUnchanged(line, copy) + + tolerance = 50.0 // meters + copy = ArrayList(line) + simplifiedLine = PolyUtil.simplify(line, tolerance) + assertEquals(6, simplifiedLine.size) + assertEndPoints(line, simplifiedLine) + assertSimplifiedPointsFromLine(line, simplifiedLine) + assertLineLength(line, simplifiedLine) + assertInputUnchanged(line, copy) + + tolerance = 500.0 // meters + copy = ArrayList(line) + simplifiedLine = PolyUtil.simplify(line, tolerance) + assertEquals(3, simplifiedLine.size) + assertEndPoints(line, simplifiedLine) + assertSimplifiedPointsFromLine(line, simplifiedLine) + assertLineLength(line, simplifiedLine) + assertInputUnchanged(line, copy) + + tolerance = 1000.0 // meters + copy = ArrayList(line) + simplifiedLine = PolyUtil.simplify(line, tolerance) + assertEquals(2, simplifiedLine.size) + assertEndPoints(line, simplifiedLine) + assertSimplifiedPointsFromLine(line, simplifiedLine) + assertLineLength(line, simplifiedLine) + assertInputUnchanged(line, copy) + + /* + * Polygons + */ + // Open triangle + val triangle = ArrayList() + triangle.add(LatLng(28.06025, -82.41030)) + triangle.add(LatLng(28.06129, -82.40945)) + triangle.add(LatLng(28.06206, -82.40917)) + triangle.add(LatLng(28.06125, -82.40850)) + triangle.add(LatLng(28.06035, -82.40834)) + triangle.add(LatLng(28.06038, -82.40924)) + assertFalse(PolyUtil.isClosedPolygon(triangle)) + + copy = ArrayList(triangle) + tolerance = 88.0 // meters + var simplifiedTriangle = PolyUtil.simplify(triangle, tolerance) + assertEquals(4, simplifiedTriangle.size) + assertEndPoints(triangle, simplifiedTriangle) + assertSimplifiedPointsFromLine(triangle, simplifiedTriangle) + assertLineLength(triangle, simplifiedTriangle) + assertInputUnchanged(triangle, copy) + + // Close the triangle + var p = triangle[0] + var closePoint = LatLng(p.latitude, p.longitude) + triangle.add(closePoint) + assertTrue(PolyUtil.isClosedPolygon(triangle)) + + copy = ArrayList(triangle) + tolerance = 88.0 // meters + simplifiedTriangle = PolyUtil.simplify(triangle, tolerance) + assertEquals(4, simplifiedTriangle.size) + assertEndPoints(triangle, simplifiedTriangle) + assertSimplifiedPointsFromLine(triangle, simplifiedTriangle) + assertLineLength(triangle, simplifiedTriangle) + assertInputUnchanged(triangle, copy) + + // Open oval + val encodedOvalPolygon = + "}wgjDxw_vNuAd@}AN{A]w@_Au@kAUaA?{@Ke@@_@C]D[FULWFOLSNMTOVOXO\\I\\CX?VJXJTDTNXTVVLVJ`@FXA\\AVLZBTATBZ@ZAT?\\?VFT@XGZ" + val oval = PolyUtil.decode(encodedOvalPolygon).toMutableList() + assertFalse(PolyUtil.isClosedPolygon(oval)) + + copy = ArrayList(oval) + tolerance = 10.0 // meters + var simplifiedOval = PolyUtil.simplify(oval, tolerance) + assertEquals(13, simplifiedOval.size) + assertEndPoints(oval, simplifiedOval) + assertSimplifiedPointsFromLine(oval, simplifiedOval) + assertLineLength(oval, simplifiedOval) + assertInputUnchanged(oval, copy) + + // Close the oval + p = oval[0] + closePoint = LatLng(p.latitude, p.longitude) + oval.add(closePoint) + assertTrue(PolyUtil.isClosedPolygon(oval)) + + copy = ArrayList(oval) + tolerance = 10.0 // meters + simplifiedOval = PolyUtil.simplify(oval, tolerance) + assertEquals(13, simplifiedOval.size) + assertEndPoints(oval, simplifiedOval) + assertSimplifiedPointsFromLine(oval, simplifiedOval) + assertLineLength(oval, simplifiedOval) + assertInputUnchanged(oval, copy) + } + + /** + * 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 + fun testIsClosedPolygon() { + val poly = ArrayList() + poly.add(LatLng(28.06025, -82.41030)) + poly.add(LatLng(28.06129, -82.40945)) + poly.add(LatLng(28.06206, -82.40917)) + poly.add(LatLng(28.06125, -82.40850)) + poly.add(LatLng(28.06035, -82.40834)) + + assertFalse(PolyUtil.isClosedPolygon(poly)) + + // Add the closing point that's same as the first + poly.add(LatLng(28.06025, -82.41030)) + assertTrue(PolyUtil.isClosedPolygon(poly)) + } + + /** + * The following method checks whether [PolyUtil.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](https://www.qgis.org/). + */ + @Test + fun testDistanceToLine() { + var startLine = LatLng(28.05359, -82.41632) + var endLine = LatLng(28.05310, -82.41634) + var p = LatLng(28.05342, -82.41594) + + var distance = PolyUtil.distanceToLine(p, startLine, endLine) + assertEquals(37.94596795917082, distance, 1e-6) + + startLine = LatLng(49.321045, 12.097749) + endLine = LatLng(49.321016, 12.097795) + p = LatLng(49.3210674, 12.0978238) + + distance = PolyUtil.distanceToLine(p, startLine, endLine) + assertEquals(5.559443879999753, distance, 1e-6) + + startLine = LatLng(48.125961, 11.548998) + endLine = LatLng(48.125918, 11.549005) + p = LatLng(48.125941, 11.549028) + + distance = PolyUtil.distanceToLine(p, startLine, endLine) + assertEquals(1.9733966358947437, distance, 1e-6) + + startLine = LatLng(78.924669, 11.925521) + endLine = LatLng(78.924707, 11.929060) + p = LatLng(78.923164, 11.924029) + + distance = PolyUtil.distanceToLine(p, startLine, endLine) + assertEquals(170.35662670453187, distance, 1e-6) + + startLine = LatLng(69.664036, 18.957124) + endLine = LatLng(69.664029, 18.957109) + p = LatLng(69.672901, 18.967911) + + distance = PolyUtil.distanceToLine(p, startLine, endLine) + assertEquals(1070.222749990837, distance, 1e-6) + + startLine = LatLng(-0.018200, 109.343282) + endLine = LatLng(-0.017877, 109.343537) + p = LatLng(0.058299, 109.408054) + + distance = PolyUtil.distanceToLine(p, startLine, endLine) + assertEquals(11100.157563150981, distance, 1e-6) + } + + /** + * 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 + fun testDistanceToLineLessThanDistanceToExtremes() { + val startLine = LatLng(28.05359, -82.41632) + val endLine = LatLng(28.05310, -82.41634) + val p = LatLng(28.05342, -82.41594) + + val distance = PolyUtil.distanceToLine(p, startLine, endLine) + val distanceToStart = SphericalUtil.computeDistanceBetween(p, startLine) + val distanceToEnd = SphericalUtil.computeDistanceBetween(p, endLine) + + assertTrue(distance <= distanceToStart) + assertTrue(distance <= 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 + fun testDecodePath() { + val latLngs = PolyUtil.decode(TEST_LINE) + + val expectedLength = 21 + assertEquals(expectedLength, latLngs.size) + + val lastPoint = latLngs[expectedLength - 1] + assertEquals(37.76953, lastPoint.latitude, 1e-6) + assertEquals(-122.41488, lastPoint.longitude, 1e-6) + } + + /** + * 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 + fun testEncodePath() { + val path = PolyUtil.decode(TEST_LINE) + val encoded = PolyUtil.encode(path) + assertEquals(TEST_LINE, encoded) + } + + companion object { + private const val TEST_LINE = + "_cqeFf~cjVf@p@fA}AtAoB`ArAx@hA`GbIvDiFv@gAh@t@X\\|@z@`@Z\\Xf@Vf@VpA\\tATJ@NBBkC" + + /** + * A helper method to construct a [List] of [LatLng] objects from a series of latitude + * and longitude coordinates. This simplifies the creation of test polygons and polylines. + */ + private fun makeList(vararg coords: Double): List { + val size = coords.size / 2 + val list = ArrayList(size) + for (i in 0 until size) { + list.add(LatLng(coords[i + i], coords[i + i + 1])) + } + return list + } + + /** + * A helper method to test [PolyUtil.containsLocation]. It asserts that all points in the + * [yes] list are contained within the polygon, and all points in the [no] list are not. + * This is tested for both geodesic and rhumb line paths. + */ + private fun containsCase(poly: List, yes: List, no: List) { + for (point in yes) { + assertTrue(PolyUtil.containsLocation(point, poly, true)) + assertTrue(PolyUtil.containsLocation(point, poly, false)) + } + for (point in no) { + assertFalse(PolyUtil.containsLocation(point, poly, true)) + assertFalse(PolyUtil.containsLocation(point, poly, false)) + } + } + + /** + * A helper method to test [PolyUtil.isLocationOnEdge] and [PolyUtil.isLocationOnPath]. + * It asserts that all points in the [yes] list are on the edge of the polygon, and all + * points in the [no] list are not. + */ + private fun onEdgeCase(geodesic: Boolean, poly: List, yes: List, no: List) { + for (point in yes) { + assertTrue(PolyUtil.isLocationOnEdge(point, poly, geodesic)) + assertTrue(PolyUtil.isLocationOnPath(point, poly, geodesic)) + } + for (point in no) { + assertFalse(PolyUtil.isLocationOnEdge(point, poly, geodesic)) + assertFalse(PolyUtil.isLocationOnPath(point, poly, geodesic)) + } + } + + /** Overload of [onEdgeCase] that tests for both geodesic and rhumb line paths. */ + private fun onEdgeCase(poly: List, yes: List, no: List) { + onEdgeCase(true, poly, yes, no) + onEdgeCase(false, poly, yes, no) + } + + /** + * A helper method to test [PolyUtil.locationIndexOnPath]. It asserts that the returned + * index for a given point on a polyline is as expected. + */ + private fun locationIndexCase(geodesic: Boolean, poly: List, point: LatLng, idx: Int) { + assertEquals(idx, PolyUtil.locationIndexOnPath(point, poly, geodesic)) + } + + /** Overload of [locationIndexCase] that tests for both geodesic and rhumb line paths. */ + private fun locationIndexCase(poly: List, point: LatLng, idx: Int) { + locationIndexCase(true, poly, point, idx) + locationIndexCase(false, poly, point, idx) + } + + /** A helper method to test [PolyUtil.locationIndexOnPath] with a specific tolerance. */ + private fun locationIndexToleranceCase(geodesic: Boolean, poly: List, point: LatLng, idx: Int) { + assertEquals(idx, PolyUtil.locationIndexOnPath(point, poly, geodesic, 0.1)) + } + + /** Overload of [locationIndexToleranceCase] that tests both geodesic and rhumb line paths. */ + private fun locationIndexToleranceCase(poly: List, point: LatLng, idx: Int) { + locationIndexToleranceCase(true, poly, point, idx) + locationIndexToleranceCase(false, poly, point, idx) + } + + /** + * Asserts that the beginning and end points of the original line match those of the + * simplified line. + */ + private fun assertEndPoints(line: List, simplifiedLine: List) { + assertEquals(line[0], simplifiedLine[0]) + assertEquals(line[line.size - 1], simplifiedLine[simplifiedLine.size - 1]) + } + + /** Asserts that the simplified line is composed of points from the original line. */ + private fun assertSimplifiedPointsFromLine(line: List, simplifiedLine: List) { + for (l in simplifiedLine) { + assertTrue(line.contains(l)) + } + } + + /** + * Asserts that the length of the simplified line is always equal to or less than the length + * of the original line, if simplification has eliminated any points from the original line. + */ + private fun assertLineLength(line: List, simplifiedLine: List) { + if (line.size == simplifiedLine.size) { + // If no points were eliminated, then the length of both lines should be the same + assertEquals(SphericalUtil.computeLength(line), SphericalUtil.computeLength(simplifiedLine), 0.0) + } else { + assertTrue(simplifiedLine.size < line.size) + // If points were eliminated, then the simplified line should always be shorter + assertTrue(SphericalUtil.computeLength(simplifiedLine) < SphericalUtil.computeLength(line)) + } + } + + /** + * Asserts that the contents of the original List passed into the PolyUtil.simplify() method + * doesn't change after the method is executed. We test for this because the poly is modified + * (a small offset is added to the last point) to allow for polygon simplification. + */ + private fun assertInputUnchanged(afterInput: List, beforeInput: List) { + // Check values + assertEquals(beforeInput, afterInput) + + // Check references + for (i in beforeInput.indices) { + assertSame(beforeInput[i], afterInput[i]) + } + } + } +} diff --git a/library/src/commonTest/kotlin/com/google/maps/android/SphericalUtilTest.kt b/library/src/commonTest/kotlin/com/google/maps/android/SphericalUtilTest.kt new file mode 100644 index 000000000..f2fd879ea --- /dev/null +++ b/library/src/commonTest/kotlin/com/google/maps/android/SphericalUtilTest.kt @@ -0,0 +1,405 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.maps.android + +import com.google.maps.android.MathUtil.EARTH_RADIUS +import com.google.maps.android.model.LatLng +import com.google.maps.android.model.latitude +import com.google.maps.android.model.longitude +import kotlin.math.PI +import kotlin.math.abs +import kotlin.math.cos +import kotlin.math.sqrt +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class SphericalUtilTest { + // The vertices of an octahedron, for testing + private val up = LatLng(90.0, 0.0) + private val down = LatLng(-90.0, 0.0) + private val front = LatLng(0.0, 0.0) + private val right = LatLng(0.0, 90.0) + private val back = LatLng(0.0, -180.0) + private val left = LatLng(0.0, -90.0) + + @Test + fun testAngles() { + // Same vertex + assertEquals(0.0, SphericalUtil.computeAngleBetween(up, up), 1e-6) + assertEquals(0.0, SphericalUtil.computeAngleBetween(down, down), 1e-6) + assertEquals(0.0, SphericalUtil.computeAngleBetween(left, left), 1e-6) + assertEquals(0.0, SphericalUtil.computeAngleBetween(right, right), 1e-6) + assertEquals(0.0, SphericalUtil.computeAngleBetween(front, front), 1e-6) + assertEquals(0.0, SphericalUtil.computeAngleBetween(back, back), 1e-6) + + // Adjacent vertices + assertEquals(PI / 2, SphericalUtil.computeAngleBetween(up, front), 1e-6) + assertEquals(PI / 2, SphericalUtil.computeAngleBetween(up, right), 1e-6) + assertEquals(PI / 2, SphericalUtil.computeAngleBetween(up, back), 1e-6) + assertEquals(PI / 2, SphericalUtil.computeAngleBetween(up, left), 1e-6) + + assertEquals(PI / 2, SphericalUtil.computeAngleBetween(down, front), 1e-6) + assertEquals(PI / 2, SphericalUtil.computeAngleBetween(down, right), 1e-6) + assertEquals(PI / 2, SphericalUtil.computeAngleBetween(down, back), 1e-6) + assertEquals(PI / 2, SphericalUtil.computeAngleBetween(down, left), 1e-6) + + assertEquals(PI / 2, SphericalUtil.computeAngleBetween(back, up), 1e-6) + assertEquals(PI / 2, SphericalUtil.computeAngleBetween(back, right), 1e-6) + assertEquals(PI / 2, SphericalUtil.computeAngleBetween(back, down), 1e-6) + assertEquals(PI / 2, SphericalUtil.computeAngleBetween(back, left), 1e-6) + + // Opposite vertices + assertEquals(PI, SphericalUtil.computeAngleBetween(up, down), 1e-6) + assertEquals(PI, SphericalUtil.computeAngleBetween(front, back), 1e-6) + assertEquals(PI, SphericalUtil.computeAngleBetween(left, right), 1e-6) + } + + @Test + fun testDistances() { + assertEquals(PI * EARTH_RADIUS, SphericalUtil.computeDistanceBetween(up, down), 1e-6) + } + + @Test + fun testHeadings() { + // Opposing vertices for which there is a result + assertEquals(-180.0, SphericalUtil.computeHeading(up, down), 1e-6) + assertEquals(0.0, SphericalUtil.computeHeading(down, up), 1e-6) + + // Adjacent vertices for which there is a result + assertEquals(0.0, SphericalUtil.computeHeading(front, up), 1e-6) + assertEquals(0.0, SphericalUtil.computeHeading(right, up), 1e-6) + assertEquals(0.0, SphericalUtil.computeHeading(back, up), 1e-6) + assertEquals(0.0, SphericalUtil.computeHeading(down, up), 1e-6) + + assertEquals(-180.0, SphericalUtil.computeHeading(front, down), 1e-6) + assertEquals(-180.0, SphericalUtil.computeHeading(right, down), 1e-6) + assertEquals(-180.0, SphericalUtil.computeHeading(back, down), 1e-6) + assertEquals(-180.0, SphericalUtil.computeHeading(left, down), 1e-6) + + assertEquals(-90.0, SphericalUtil.computeHeading(right, front), 1e-6) + assertEquals(90.0, SphericalUtil.computeHeading(left, front), 1e-6) + + assertEquals(90.0, SphericalUtil.computeHeading(front, right), 1e-6) + assertEquals(-90.0, SphericalUtil.computeHeading(back, right), 1e-6) + } + + @Test + fun testComputeOffset() { + // From front + expectLatLngApproxEquals(front, SphericalUtil.computeOffset(front, 0.0, 0.0)) + expectLatLngApproxEquals(up, SphericalUtil.computeOffset(front, PI * EARTH_RADIUS / 2, 0.0)) + expectLatLngApproxEquals(down, SphericalUtil.computeOffset(front, PI * EARTH_RADIUS / 2, 180.0)) + expectLatLngApproxEquals(left, SphericalUtil.computeOffset(front, PI * EARTH_RADIUS / 2, -90.0)) + expectLatLngApproxEquals(right, SphericalUtil.computeOffset(front, PI * EARTH_RADIUS / 2, 90.0)) + expectLatLngApproxEquals(back, SphericalUtil.computeOffset(front, PI * EARTH_RADIUS, 0.0)) + expectLatLngApproxEquals(back, SphericalUtil.computeOffset(front, PI * EARTH_RADIUS, 90.0)) + + // From left + expectLatLngApproxEquals(left, SphericalUtil.computeOffset(left, 0.0, 0.0)) + expectLatLngApproxEquals(up, SphericalUtil.computeOffset(left, PI * EARTH_RADIUS / 2, 0.0)) + expectLatLngApproxEquals(down, SphericalUtil.computeOffset(left, PI * EARTH_RADIUS / 2, 180.0)) + expectLatLngApproxEquals(front, SphericalUtil.computeOffset(left, PI * EARTH_RADIUS / 2, 90.0)) + expectLatLngApproxEquals(back, SphericalUtil.computeOffset(left, PI * EARTH_RADIUS / 2, -90.0)) + expectLatLngApproxEquals(right, SphericalUtil.computeOffset(left, PI * EARTH_RADIUS, 0.0)) + expectLatLngApproxEquals(right, SphericalUtil.computeOffset(left, PI * EARTH_RADIUS, 90.0)) + + // NOTE(appleton): Heading is undefined at the poles, so we do not test + // from up/down. + } + + @Test + fun testComputeOffsetOrigin() { + expectLatLngApproxEquals(front, assertNotNull(SphericalUtil.computeOffsetOrigin(front, 0.0, 0.0))) + + expectLatLngApproxEquals( + front, + assertNotNull(SphericalUtil.computeOffsetOrigin(LatLng(0.0, 45.0), PI * EARTH_RADIUS / 4, 90.0)), + ) + expectLatLngApproxEquals( + front, + assertNotNull(SphericalUtil.computeOffsetOrigin(LatLng(0.0, -45.0), PI * EARTH_RADIUS / 4, -90.0)), + ) + expectLatLngApproxEquals( + front, + assertNotNull(SphericalUtil.computeOffsetOrigin(LatLng(45.0, 0.0), PI * EARTH_RADIUS / 4, 0.0)), + ) + expectLatLngApproxEquals( + front, + assertNotNull(SphericalUtil.computeOffsetOrigin(LatLng(-45.0, 0.0), PI * EARTH_RADIUS / 4, 180.0)), + ) + + // Situations with no solution, should return null. + // + // First 'over' the pole. + assertNull(SphericalUtil.computeOffsetOrigin(LatLng(80.0, 0.0), PI * EARTH_RADIUS / 4, 180.0)) + // Second a distance that doesn't fit on the earth. + assertNull(SphericalUtil.computeOffsetOrigin(LatLng(80.0, 0.0), PI * EARTH_RADIUS / 4, 90.0)) + } + + @Test + fun testComputeOffsetAndBackToOrigin() { + var start = LatLng(40.0, 40.0) + var distance = 1e5 + var heading = 15.0 + var end: LatLng + + // Some semi-random values to demonstrate going forward and backward yields + // the same location. + end = SphericalUtil.computeOffset(start, distance, heading) + expectLatLngApproxEquals(start, assertNotNull(SphericalUtil.computeOffsetOrigin(end, distance, heading))) + + heading = -37.0 + end = SphericalUtil.computeOffset(start, distance, heading) + expectLatLngApproxEquals(start, assertNotNull(SphericalUtil.computeOffsetOrigin(end, distance, heading))) + + distance = 3.8e+7 + end = SphericalUtil.computeOffset(start, distance, heading) + expectLatLngApproxEquals(start, assertNotNull(SphericalUtil.computeOffsetOrigin(end, distance, heading))) + + start = LatLng(-21.0, -73.0) + end = SphericalUtil.computeOffset(start, distance, heading) + expectLatLngApproxEquals(start, assertNotNull(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. + val start1 = SphericalUtil.computeOffsetOrigin(LatLng(0.0, 90.0), PI * EARTH_RADIUS / 2, 90.0) + assertNotNull(start1) + expectLatLngApproxEquals(LatLng(0.0, 90.0), SphericalUtil.computeOffset(start1, PI * EARTH_RADIUS / 2, 90.0)) + + // Second, for this particular situation the longitude is completely + // arbitrary. + val start2 = SphericalUtil.computeOffsetOrigin(LatLng(90.0, 0.0), PI * EARTH_RADIUS / 4, 0.0) + assertNotNull(start2) + expectLatLngApproxEquals(LatLng(90.0, 0.0), SphericalUtil.computeOffset(start2, PI * EARTH_RADIUS / 4, 0.0)) + } + + @Test + fun testInterpolate() { + // Same point + expectLatLngApproxEquals(up, SphericalUtil.interpolate(up, up, 1 / 2.0)) + expectLatLngApproxEquals(down, SphericalUtil.interpolate(down, down, 1 / 2.0)) + expectLatLngApproxEquals(left, SphericalUtil.interpolate(left, left, 1 / 2.0)) + + // Between front and up + expectLatLngApproxEquals(LatLng(1.0, 0.0), SphericalUtil.interpolate(front, up, 1 / 90.0)) + expectLatLngApproxEquals(LatLng(1.0, 0.0), SphericalUtil.interpolate(up, front, 89 / 90.0)) + expectLatLngApproxEquals(LatLng(89.0, 0.0), SphericalUtil.interpolate(front, up, 89 / 90.0)) + expectLatLngApproxEquals(LatLng(89.0, 0.0), SphericalUtil.interpolate(up, front, 1 / 90.0)) + + // Between front and down + expectLatLngApproxEquals(LatLng(-1.0, 0.0), SphericalUtil.interpolate(front, down, 1 / 90.0)) + expectLatLngApproxEquals(LatLng(-1.0, 0.0), SphericalUtil.interpolate(down, front, 89 / 90.0)) + expectLatLngApproxEquals(LatLng(-89.0, 0.0), SphericalUtil.interpolate(front, down, 89 / 90.0)) + expectLatLngApproxEquals(LatLng(-89.0, 0.0), SphericalUtil.interpolate(down, front, 1 / 90.0)) + + // Between left and back + expectLatLngApproxEquals(LatLng(0.0, -91.0), SphericalUtil.interpolate(left, back, 1 / 90.0)) + expectLatLngApproxEquals(LatLng(0.0, -91.0), SphericalUtil.interpolate(back, left, 89 / 90.0)) + expectLatLngApproxEquals(LatLng(0.0, -179.0), SphericalUtil.interpolate(left, back, 89 / 90.0)) + expectLatLngApproxEquals(LatLng(0.0, -179.0), SphericalUtil.interpolate(back, left, 1 / 90.0)) + + // geodesic crosses pole + expectLatLngApproxEquals(up, SphericalUtil.interpolate(LatLng(45.0, 0.0), LatLng(45.0, 180.0), 1 / 2.0)) + expectLatLngApproxEquals(down, SphericalUtil.interpolate(LatLng(-45.0, 0.0), LatLng(-45.0, 180.0), 1 / 2.0)) + + // boundary values for fraction, between left and back + expectLatLngApproxEquals(left, SphericalUtil.interpolate(left, back, 0.0)) + expectLatLngApproxEquals(back, SphericalUtil.interpolate(left, back, 1.0)) + + // two nearby points, separated by ~4m, for which the Slerp algorithm is not stable and we + // have to fall back to linear interpolation. + expectLatLngApproxEquals( + LatLng(-37.756872, 175.325252), + SphericalUtil.interpolate(LatLng(-37.756891, 175.325262), LatLng(-37.756853, 175.325242), 0.5), + ) + } + + @Test + fun testComputeLength() { + assertEquals(0.0, SphericalUtil.computeLength(emptyList()), 1e-6) + assertEquals(0.0, SphericalUtil.computeLength(listOf(LatLng(0.0, 0.0))), 1e-6) + + var latLngs = listOf(LatLng(0.0, 0.0), LatLng(0.1, 0.1)) + assertEquals(toRadians(0.1) * sqrt(2.0) * EARTH_RADIUS, SphericalUtil.computeLength(latLngs), 1.0) + + latLngs = listOf(LatLng(0.0, 0.0), LatLng(90.0, 0.0), LatLng(0.0, 90.0)) + assertEquals(PI * EARTH_RADIUS, SphericalUtil.computeLength(latLngs), 1e-6) + } + + @Test + fun 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)) + } + + @Test + fun testComputeTriangleArea() { + assertEquals(PI / 2, computeTriangleArea(right, up, front), 1e-6) + assertEquals(PI / 2, computeTriangleArea(front, up, right), 1e-6) + + // computeArea returns area of zero on small polys + val area = + computeTriangleArea( + LatLng(0.0, 0.0), + LatLng(0.0, toDegrees(1E-6)), + LatLng(toDegrees(1E-6), 0.0), + ) + val expectedArea = 1E-12 / 2 + + assertTrue(abs(expectedArea - area) < 1e-20) + } + + @Test + fun testComputeSignedTriangleArea() { + assertEquals( + toRadians(0.1) * toRadians(0.1) / 2, + computeSignedTriangleArea(LatLng(0.0, 0.0), LatLng(0.0, 0.1), LatLng(0.1, 0.1)), + 1e-6, + ) + + assertEquals(PI / 2, computeSignedTriangleArea(right, up, front), 1e-6) + + assertEquals(-PI / 2, computeSignedTriangleArea(front, up, right), 1e-6) + } + + @Test + fun testComputeArea() { + assertEquals( + PI * EARTH_RADIUS * EARTH_RADIUS, + SphericalUtil.computeArea(listOf(right, up, front, down, right)), + .4, + ) + + assertEquals( + PI * EARTH_RADIUS * EARTH_RADIUS, + SphericalUtil.computeArea(listOf(right, down, front, up, right)), + .4, + ) + } + + @Test + fun testComputeSignedArea() { + val path = listOf(right, up, front, down, right) + val pathReversed = listOf(right, down, front, up, right) + assertEquals(SphericalUtil.computeSignedArea(pathReversed), -SphericalUtil.computeSignedArea(path), 0.0) + } + + @Test + fun testGetPointOnPolyline() { + val a = LatLng(0.0, 0.0) + val b = LatLng(0.0, 10.0) + val c = LatLng(10.0, 10.0) + val d = LatLng(10.0, 0.0) + val polyline = listOf(a, b, c, d) + + // Test for null cases + assertNull(SphericalUtil.getPointOnPolyline(emptyList(), 0.5)) + assertNull(SphericalUtil.getPointOnPolyline(polyline, -0.1)) + assertNull(SphericalUtil.getPointOnPolyline(polyline, 1.1)) + + // Test for start and end points + expectLatLngApproxEquals(a, assertNotNull(SphericalUtil.getPointOnPolyline(polyline, 0.0))) + expectLatLngApproxEquals(d, assertNotNull(SphericalUtil.getPointOnPolyline(polyline, 1.0))) + + // Test for a point in the middle of a segment + val midAB = LatLng(0.0, 5.0) + val totalLength = SphericalUtil.computeLength(polyline) + val abLength = SphericalUtil.computeDistanceBetween(a, b) + expectLatLngApproxEquals( + midAB, + assertNotNull(SphericalUtil.getPointOnPolyline(polyline, (abLength / 2) / totalLength)), + ) + + // Test for a point on a vertex + val aToCLength = SphericalUtil.computeLength(listOf(a, b, c)) + expectLatLngApproxEquals( + c, + assertNotNull(SphericalUtil.getPointOnPolyline(polyline, aToCLength / totalLength)), + ) + } + + @Test + fun testGetPolylinePrefix() { + val a = LatLng(0.0, 0.0) + val b = LatLng(0.0, 10.0) + val c = LatLng(10.0, 10.0) + val d = LatLng(10.0, 0.0) + val polyline = listOf(a, b, c, d) + + // Test for empty list cases + assertTrue(SphericalUtil.getPolylinePrefix(emptyList(), 0.5).isEmpty()) + assertTrue(SphericalUtil.getPolylinePrefix(polyline, -0.1).isEmpty()) + assertTrue(SphericalUtil.getPolylinePrefix(polyline, 1.1).isEmpty()) + + // Test for 0% + val prefix0 = SphericalUtil.getPolylinePrefix(polyline, 0.0) + assertEquals(1, prefix0.size) + expectLatLngApproxEquals(a, prefix0[0]) + + // Test for 100% + val prefix100 = SphericalUtil.getPolylinePrefix(polyline, 1.0) + assertEquals(polyline.size, prefix100.size) + for (i in polyline.indices) { + expectLatLngApproxEquals(polyline[i], prefix100[i]) + } + + // Test for a prefix that ends in the middle of a segment + val midAB = LatLng(0.0, 5.0) + val totalLength = SphericalUtil.computeLength(polyline) + val abLength = SphericalUtil.computeDistanceBetween(a, b) + val prefixMidAB = SphericalUtil.getPolylinePrefix(polyline, (abLength / 2) / totalLength) + assertEquals(2, prefixMidAB.size) + expectLatLngApproxEquals(a, prefixMidAB[0]) + expectLatLngApproxEquals(midAB, prefixMidAB[1]) + + // Test for a prefix that ends on a vertex + val aToCLength = SphericalUtil.computeLength(listOf(a, b, c)) + val prefixC = SphericalUtil.getPolylinePrefix(polyline, aToCLength / totalLength) + assertEquals(3, prefixC.size) + expectLatLngApproxEquals(a, prefixC[0]) + expectLatLngApproxEquals(b, prefixC[1]) + expectLatLngApproxEquals(c, prefixC[2]) + } + + companion object { + /** Tests for approximate equality. */ + private fun expectLatLngApproxEquals(expected: LatLng, actual: LatLng) { + assertEquals(expected.latitude, actual.latitude, 1e-6) + // Account for the convergence of longitude lines at the poles + val cosLat = cos(toRadians(actual.latitude)) + assertEquals(cosLat * expected.longitude, cosLat * actual.longitude, 1e-6) + } + + private fun computeSignedTriangleArea(a: LatLng, b: LatLng, c: LatLng): Double = + SphericalUtil.computeSignedArea(listOf(a, b, c), 1.0) + + private fun computeTriangleArea(a: LatLng, b: LatLng, c: LatLng): Double = + abs(computeSignedTriangleArea(a, b, c)) + + private fun isCCW(a: LatLng, b: LatLng, c: LatLng): Int = + if (computeSignedTriangleArea(a, b, c) > 0) 1 else -1 + } +} diff --git a/library/src/test/java/com/google/maps/android/MathUtilTest.java b/library/src/test/java/com/google/maps/android/MathUtilTest.java deleted file mode 100644 index 5371eddc9..000000000 --- a/library/src/test/java/com/google/maps/android/MathUtilTest.java +++ /dev/null @@ -1,99 +0,0 @@ -/* - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.google.maps.android; - -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); - } -} diff --git a/library/src/test/java/com/google/maps/android/PolyUtilTest.java b/library/src/test/java/com/google/maps/android/PolyUtilTest.java deleted file mode 100644 index 42a80207f..000000000 --- a/library/src/test/java/com/google/maps/android/PolyUtilTest.java +++ /dev/null @@ -1,665 +0,0 @@ -/* - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.google.maps.android; - -import static com.google.common.truth.Truth.assertThat; - -import com.google.android.gms.maps.model.LatLng; -import java.util.ArrayList; -import java.util.List; -import org.junit.Test; - -/** - * 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"; - - /** - * 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); - for (int i = 0; i < size; ++i) { - list.add(new LatLng(coords[i + i], coords[i + i + 1])); - } - 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) { - assertThat(PolyUtil.containsLocation(point, poly, true)).isTrue(); - assertThat(PolyUtil.containsLocation(point, poly, false)).isTrue(); - } - for (LatLng point : no) { - 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) { - for (LatLng point : yes) { - assertThat(PolyUtil.isLocationOnEdge(point, poly, geodesic)).isTrue(); - assertThat(PolyUtil.isLocationOnPath(point, poly, geodesic)).isTrue(); - } - for (LatLng point : no) { - 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) { - 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) { - 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 - onEdgeCase(makeList(), makeList(), makeList(0, 0)); - - final double small = 5e-7; // About 5cm on equator, half the default tolerance. - final double big = 2e-6; // About 10cm on equator, double the default tolerance. - - // Endpoints - onEdgeCase(makeList(1, 2), makeList(1, 2), makeList(3, 5)); - onEdgeCase(makeList(1, 2, 3, 5), makeList(1, 2, 3, 5), makeList(0, 0)); - - // 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)); - - // 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)); - - // 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)); - - // 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)); - - // 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)); - - 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)); - - 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)); - - // 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)); - - 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)); - } - - /** - * 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. - locationIndexCase(makeList(), new LatLng(0, 0), -1); - - // One point. - locationIndexCase(makeList(1, 2), new LatLng(1, 2), 0); - locationIndexCase(makeList(1, 2), new LatLng(3, 5), -1); - - // Two points. - locationIndexCase(makeList(1, 2, 3, 5), new LatLng(1, 2), 0); - locationIndexCase(makeList(1, 2, 3, 5), new LatLng(3, 5), 0); - locationIndexCase(makeList(1, 2, 3, 5), new LatLng(4, 6), -1); - - // Three points. - locationIndexCase(makeList(0, 80, 0, 90, 0, 100), new LatLng(0, 80), 0); - locationIndexCase(makeList(0, 80, 0, 90, 0, 100), new LatLng(0, 85), 0); - locationIndexCase(makeList(0, 80, 0, 90, 0, 100), new LatLng(0, 90), 0); - locationIndexCase(makeList(0, 80, 0, 90, 0, 100), new LatLng(0, 95), 1); - locationIndexCase(makeList(0, 80, 0, 90, 0, 100), new LatLng(0, 100), 1); - 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. - final double big = 2e-6; // About 10cm on equator, double the default tolerance. - - // Test tolerance. - locationIndexToleranceCase(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); - locationIndexToleranceCase( - 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); - 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); - locationIndexToleranceCase( - 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. - containsCase(makeList(), makeList(), makeList(0, 0)); - - // One point. - containsCase(makeList(1, 2), makeList(1, 2), makeList(0, 0)); - - // Two points. - containsCase(makeList(1, 2, 3, 5), makeList(1, 2, 3, 5), makeList(0, 0, 40, 4)); - - // 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)); - - // Around North Pole. - containsCase( - 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)); - - // 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)); - } - - /** - * 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@@"; - List line = PolyUtil.decode(LINE); - assertThat(line.size()).isEqualTo(95); - - List simplifiedLine; - List copy; - - double tolerance = 5; // meters - copy = new ArrayList<>(line); - simplifiedLine = PolyUtil.simplify(line, tolerance); - assertThat(simplifiedLine.size()).isEqualTo(20); - assertEndPoints(line, simplifiedLine); - assertSimplifiedPointsFromLine(line, simplifiedLine); - assertLineLength(line, simplifiedLine); - assertInputUnchanged(line, copy); - - tolerance = 10; // meters - copy = new ArrayList<>(line); - simplifiedLine = PolyUtil.simplify(line, tolerance); - assertThat(simplifiedLine.size()).isEqualTo(14); - assertEndPoints(line, simplifiedLine); - assertSimplifiedPointsFromLine(line, simplifiedLine); - assertLineLength(line, simplifiedLine); - assertInputUnchanged(line, copy); - - tolerance = 15; // meters - copy = new ArrayList<>(line); - simplifiedLine = PolyUtil.simplify(line, tolerance); - assertThat(simplifiedLine.size()).isEqualTo(10); - assertEndPoints(line, simplifiedLine); - assertSimplifiedPointsFromLine(line, simplifiedLine); - assertLineLength(line, simplifiedLine); - assertInputUnchanged(line, copy); - - tolerance = 20; // meters - copy = new ArrayList<>(line); - simplifiedLine = PolyUtil.simplify(line, tolerance); - assertThat(simplifiedLine.size()).isEqualTo(8); - assertEndPoints(line, simplifiedLine); - assertSimplifiedPointsFromLine(line, simplifiedLine); - assertLineLength(line, simplifiedLine); - assertInputUnchanged(line, copy); - - tolerance = 50; // meters - copy = new ArrayList<>(line); - simplifiedLine = PolyUtil.simplify(line, tolerance); - assertThat(simplifiedLine.size()).isEqualTo(6); - assertEndPoints(line, simplifiedLine); - assertSimplifiedPointsFromLine(line, simplifiedLine); - assertLineLength(line, simplifiedLine); - assertInputUnchanged(line, copy); - - tolerance = 500; // meters - copy = new ArrayList<>(line); - simplifiedLine = PolyUtil.simplify(line, tolerance); - assertThat(simplifiedLine.size()).isEqualTo(3); - assertEndPoints(line, simplifiedLine); - assertSimplifiedPointsFromLine(line, simplifiedLine); - assertLineLength(line, simplifiedLine); - assertInputUnchanged(line, copy); - - tolerance = 1000; // meters - copy = new ArrayList<>(line); - simplifiedLine = PolyUtil.simplify(line, tolerance); - assertThat(simplifiedLine.size()).isEqualTo(2); - assertEndPoints(line, simplifiedLine); - assertSimplifiedPointsFromLine(line, simplifiedLine); - assertLineLength(line, simplifiedLine); - assertInputUnchanged(line, copy); - - /* - * Polygons - */ - // Open triangle - ArrayList triangle = new ArrayList<>(); - triangle.add(new LatLng(28.06025, -82.41030)); - triangle.add(new LatLng(28.06129, -82.40945)); - triangle.add(new LatLng(28.06206, -82.40917)); - triangle.add(new LatLng(28.06125, -82.40850)); - triangle.add(new LatLng(28.06035, -82.40834)); - triangle.add(new LatLng(28.06038, -82.40924)); - assertThat(PolyUtil.isClosedPolygon(triangle)).isFalse(); - - copy = new ArrayList<>(triangle); - tolerance = 88; // meters - List simplifiedTriangle = PolyUtil.simplify(triangle, tolerance); - assertThat(simplifiedTriangle.size()).isEqualTo(4); - assertEndPoints(triangle, simplifiedTriangle); - assertSimplifiedPointsFromLine(triangle, simplifiedTriangle); - assertLineLength(triangle, simplifiedTriangle); - assertInputUnchanged(triangle, copy); - - // Close the triangle - LatLng p = triangle.get(0); - LatLng closePoint = new LatLng(p.latitude, p.longitude); - triangle.add(closePoint); - assertThat(PolyUtil.isClosedPolygon(triangle)).isTrue(); - - copy = new ArrayList<>(triangle); - tolerance = 88; // meters - simplifiedTriangle = PolyUtil.simplify(triangle, tolerance); - assertThat(simplifiedTriangle.size()).isEqualTo(4); - assertEndPoints(triangle, simplifiedTriangle); - assertSimplifiedPointsFromLine(triangle, simplifiedTriangle); - assertLineLength(triangle, simplifiedTriangle); - assertInputUnchanged(triangle, copy); - - // 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"; - List oval = PolyUtil.decode(OVAL_POLYGON); - assertThat(PolyUtil.isClosedPolygon(oval)).isFalse(); - - copy = new ArrayList<>(oval); - tolerance = 10; // meters - List simplifiedOval = PolyUtil.simplify(oval, tolerance); - assertThat(simplifiedOval.size()).isEqualTo(13); - assertEndPoints(oval, simplifiedOval); - assertSimplifiedPointsFromLine(oval, simplifiedOval); - assertLineLength(oval, simplifiedOval); - assertInputUnchanged(oval, copy); - - // Close the oval - p = oval.get(0); - closePoint = new LatLng(p.latitude, p.longitude); - oval.add(closePoint); - assertThat(PolyUtil.isClosedPolygon(oval)).isTrue(); - - copy = new ArrayList<>(oval); - tolerance = 10; // meters - simplifiedOval = PolyUtil.simplify(oval, tolerance); - assertThat(simplifiedOval.size()).isEqualTo(13); - assertEndPoints(oval, simplifiedOval); - assertSimplifiedPointsFromLine(oval, simplifiedOval); - assertLineLength(oval, simplifiedOval); - assertInputUnchanged(oval, copy); - } - - /** - * Asserts that the beginning point of the original line matches the beginning point of the - * simplified line, and that the end point of the original line matches the end point of the - * simplified line. - * - * @param line original line - * @param simplifiedLine simplified line - */ - private void assertEndPoints(List line, List simplifiedLine) { - assertThat(simplifiedLine.get(0)).isEqualTo(line.get(0)); - assertThat(simplifiedLine.get(simplifiedLine.size() - 1)).isEqualTo(line.get(line.size() - 1)); - } - - /** - * Asserts that the simplified line is composed of points from the original line. - * - * @param line original line - * @param simplifiedLine simplified line - */ - private void assertSimplifiedPointsFromLine(List line, List simplifiedLine) { - for (LatLng l : simplifiedLine) { - assertThat(line).contains(l); - } - } - - /** - * Asserts that the length of the simplified line is always equal to or less than the length of - * the original line, if simplification has eliminated any points from the original line - * - * @param line original line - * @param simplifiedLine simplified line - */ - 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 - assertThat(SphericalUtil.computeLength(simplifiedLine)) - .isWithin(0.0) - .of(SphericalUtil.computeLength(line)); - } else { - assertThat(simplifiedLine.size()).isLessThan(line.size()); - // If points were eliminated, then the simplified line should always be shorter - assertThat(SphericalUtil.computeLength(simplifiedLine)) - .isLessThan(SphericalUtil.computeLength(line)); - } - } - - /** - * Asserts that the contents of the original List passed into the PolyUtil.simplify() method - * doesn't change after the method is executed. We test for this because the poly is modified (a - * small offset is added to the last point) to allow for polygon simplification. - * - * @param afterInput the list passed into PolyUtil.simplify(), after PolyUtil.simplify() has - * finished executing - * @param beforeInput a copy of the list before it is passed into PolyUtil.simplify() - */ - private void assertInputUnchanged(List afterInput, List beforeInput) { - // Check values - assertThat(afterInput).isEqualTo(beforeInput); - - // Check references - for (int i = 0; i < beforeInput.size(); 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<>(); - poly.add(new LatLng(28.06025, -82.41030)); - poly.add(new LatLng(28.06129, -82.40945)); - poly.add(new LatLng(28.06206, -82.40917)); - poly.add(new LatLng(28.06125, -82.40850)); - poly.add(new LatLng(28.06035, -82.40834)); - - assertThat(PolyUtil.isClosedPolygon(poly)).isFalse(); - - // Add the closing point that's same as the first - poly.add(new LatLng(28.06025, -82.41030)); - 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 - */ - @Test - public void testDistanceToLine() { - LatLng startLine = new LatLng(28.05359, -82.41632); - LatLng endLine = new LatLng(28.05310, -82.41634); - LatLng p = new LatLng(28.05342, -82.41594); - - double distance = PolyUtil.distanceToLine(p, startLine, endLine); - 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); - 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); - 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); - 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); - 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); - 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 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); - - double distance = PolyUtil.distanceToLine(p, startLine, endLine); - double distanceToStart = SphericalUtil.computeDistanceBetween(p, startLine); - double distanceToEnd = SphericalUtil.computeDistanceBetween(p, endLine); - - 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; - assertThat(latLngs.size()).isEqualTo(expectedLength); - - LatLng lastPoint = latLngs.get(expectedLength - 1); - 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); - 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 deleted file mode 100644 index 520a66a83..000000000 --- a/library/src/test/java/com/google/maps/android/SphericalUtilTest.java +++ /dev/null @@ -1,423 +0,0 @@ -/* - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.google.maps.android; - -import static com.google.common.truth.Truth.assertThat; -import static com.google.maps.android.MathUtil.EARTH_RADIUS; - -import com.google.android.gms.maps.model.LatLng; -import java.util.Arrays; -import java.util.Collections; -import java.util.List; -import java.util.Objects; -import org.junit.Assert; -import org.junit.Test; - -public class SphericalUtilTest { - // The vertices of an octahedron, for testing - private final LatLng up = new LatLng(90, 0); - private final LatLng down = new LatLng(-90, 0); - private final LatLng front = new LatLng(0, 0); - private final LatLng right = new LatLng(0, 90); - private final LatLng back = new LatLng(0, -180); - private final LatLng left = new LatLng(0, -90); - - /** Tests for approximate equality. */ - private static void expectLatLngApproxEquals(LatLng actual, LatLng expected) { - 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)); - assertThat(cosLat * actual.longitude).isWithin(1e-6).of(cosLat * expected.longitude); - } - - private static double computeSignedTriangleArea(LatLng a, LatLng b, LatLng c) { - List path = Arrays.asList(a, b, c); - return SphericalUtil.computeSignedArea(path, 1); - } - - private static double computeTriangleArea(LatLng a, LatLng b, LatLng c) { - return Math.abs(computeSignedTriangleArea(a, b, c)); - } - - private static int isCCW(LatLng a, LatLng b, LatLng c) { - return computeSignedTriangleArea(a, b, c) > 0 ? 1 : -1; - } - - @Test - public void testAngles() { - // Same vertex - 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 - 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); - - 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); - - 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 - 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() { - 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 - 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 - 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); - - 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); - - assertThat(SphericalUtil.computeHeading(right, front)).isWithin(1e-6).of(-90); - assertThat(SphericalUtil.computeHeading(left, front)).isWithin(1e-6).of(90); - - 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)); - - // 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)); - - // NOTE(appleton): Heading is undefined at the poles, so we do not test - // from up/down. - } - - @Test - public void testComputeOffsetOrigin() { - 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. - assertThat( - SphericalUtil.computeOffsetOrigin(new LatLng(80, 0), Math.PI * EARTH_RADIUS / 4, 180)) - .isNull(); - // Second a distance that doesn't fit on the earth. - assertThat(SphericalUtil.computeOffsetOrigin(new LatLng(80, 0), Math.PI * EARTH_RADIUS / 4, 90)) - .isNull(); - } - - @Test - public void testComputeOffsetAndBackToOrigin() { - LatLng start = new LatLng(40, 40); - double distance = 1e5; - double heading = 15; - LatLng end; - - // Some semi-random values to demonstrate going forward and backward yields - // the same location. - end = SphericalUtil.computeOffset(start, distance, heading); - expectLatLngApproxEquals( - start, Objects.requireNonNull(SphericalUtil.computeOffsetOrigin(end, distance, heading))); - - heading = -37; - end = SphericalUtil.computeOffset(start, distance, heading); - expectLatLngApproxEquals( - start, Objects.requireNonNull(SphericalUtil.computeOffsetOrigin(end, distance, heading))); - - distance = 3.8e+7; - end = SphericalUtil.computeOffset(start, distance, heading); - expectLatLngApproxEquals( - start, Objects.requireNonNull(SphericalUtil.computeOffsetOrigin(end, distance, heading))); - - start = new LatLng(-21, -73); - end = SphericalUtil.computeOffset(start, 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); - 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); - Assert.assertNotNull(start); - expectLatLngApproxEquals( - new LatLng(90, 0), SphericalUtil.computeOffset(start, Math.PI * EARTH_RADIUS / 4, 0)); - } - - @Test - public void testInterpolate() { - // Same point - expectLatLngApproxEquals(up, SphericalUtil.interpolate(up, up, 1 / 2.0)); - expectLatLngApproxEquals(down, SphericalUtil.interpolate(down, down, 1 / 2.0)); - expectLatLngApproxEquals(left, SphericalUtil.interpolate(left, left, 1 / 2.0)); - - // 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(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)); - - // 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)); - - // 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)); - - // boundary values for fraction, between left and back - expectLatLngApproxEquals(left, SphericalUtil.interpolate(left, back, 0)); - expectLatLngApproxEquals(back, SphericalUtil.interpolate(left, back, 1.0)); - - // 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)); - } - - @Test - public void testComputeLength() { - List latLngs; - - 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)); - 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)); - assertThat(SphericalUtil.computeLength(latLngs)).isWithin(1e-6).of(Math.PI * EARTH_RADIUS); - } - - @Test - public void testIsCCW() { - // One face of the octahedron - 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() { - 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 expectedArea = 1E-12 / 2; - - assertThat(Math.abs(expectedArea - area)).isLessThan(1e-20); - } - - @Test - public void testComputeSignedTriangleArea() { - 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); - - assertThat(computeSignedTriangleArea(right, up, front)).isWithin(1e-6).of(Math.PI / 2); - - assertThat(computeSignedTriangleArea(front, up, right)).isWithin(1e-6).of(-Math.PI / 2); - } - - @Test - public void testComputeArea() { - 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); - assertThat(-SphericalUtil.computeSignedArea(path)) - .isWithin(0) - .of(SphericalUtil.computeSignedArea(pathReversed)); - } - - @Test - public void testGetPointOnPolyline() { - final LatLng a = new LatLng(0, 0); - final LatLng b = new LatLng(0, 10); - final LatLng c = new LatLng(10, 10); - final LatLng d = new LatLng(10, 0); - final List polyline = Arrays.asList(a, b, c, d); - - // Test for null cases - Assert.assertNull(SphericalUtil.getPointOnPolyline(Collections.emptyList(), 0.5)); - Assert.assertNull(SphericalUtil.getPointOnPolyline(polyline, -0.1)); - Assert.assertNull(SphericalUtil.getPointOnPolyline(polyline, 1.1)); - - // Test for start and end points - expectLatLngApproxEquals(a, SphericalUtil.getPointOnPolyline(polyline, 0)); - expectLatLngApproxEquals(d, SphericalUtil.getPointOnPolyline(polyline, 1)); - - // Test for a point in the middle of a segment - final LatLng midAB = new LatLng(0, 5); - final double totalLength = SphericalUtil.computeLength(polyline); - final double abLength = SphericalUtil.computeDistanceBetween(a, b); - expectLatLngApproxEquals( - midAB, SphericalUtil.getPointOnPolyline(polyline, (abLength / 2) / totalLength)); - - // Test for a point on a vertex - final double aToCLength = SphericalUtil.computeLength(Arrays.asList(a, b, c)); - expectLatLngApproxEquals( - c, SphericalUtil.getPointOnPolyline(polyline, aToCLength / totalLength)); - } - - @Test - public void testGetPolylinePrefix() { - final LatLng a = new LatLng(0, 0); - final LatLng b = new LatLng(0, 10); - final LatLng c = new LatLng(10, 10); - final LatLng d = new LatLng(10, 0); - final List polyline = Arrays.asList(a, b, c, d); - - // Test for empty list cases - Assert.assertTrue(SphericalUtil.getPolylinePrefix(Collections.emptyList(), 0.5).isEmpty()); - Assert.assertTrue(SphericalUtil.getPolylinePrefix(polyline, -0.1).isEmpty()); - Assert.assertTrue(SphericalUtil.getPolylinePrefix(polyline, 1.1).isEmpty()); - - // Test for 0% - List prefix0 = SphericalUtil.getPolylinePrefix(polyline, 0); - Assert.assertEquals(1, prefix0.size()); - expectLatLngApproxEquals(a, prefix0.get(0)); - - // Test for 100% - List prefix100 = SphericalUtil.getPolylinePrefix(polyline, 1); - Assert.assertEquals(polyline.size(), prefix100.size()); - for (int i = 0; i < polyline.size(); i++) { - expectLatLngApproxEquals(polyline.get(i), prefix100.get(i)); - } - - // Test for a prefix that ends in the middle of a segment - final LatLng midAB = new LatLng(0, 5); - final double totalLength = SphericalUtil.computeLength(polyline); - final double abLength = SphericalUtil.computeDistanceBetween(a, b); - List prefixMidAB = - SphericalUtil.getPolylinePrefix(polyline, (abLength / 2) / totalLength); - Assert.assertEquals(2, prefixMidAB.size()); - expectLatLngApproxEquals(a, prefixMidAB.get(0)); - expectLatLngApproxEquals(midAB, prefixMidAB.get(1)); - - // Test for a prefix that ends on a vertex - final double aToCLength = SphericalUtil.computeLength(Arrays.asList(a, b, c)); - List prefixC = SphericalUtil.getPolylinePrefix(polyline, aToCLength / totalLength); - Assert.assertEquals(3, prefixC.size()); - expectLatLngApproxEquals(a, prefixC.get(0)); - expectLatLngApproxEquals(b, prefixC.get(1)); - expectLatLngApproxEquals(c, prefixC.get(2)); - } -} From deb93a2b73f27993338eb534a17e1e62ec107ee5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20Lo=CC=81pez=20Man=CC=83as?= Date: Tue, 8 Sep 2026 23:46:31 +0700 Subject: [PATCH 5/5] ci: lint remaining Android modules instead of the KMP library module The KMP Android library plugin does not create lintDebug/SARIF reporting tasks, so :library:lintDebug no longer exists after the multiplatform migration. Lint data, ui and demo instead; KMP-module lint reporting is tracked as a known gap of the migration. --- .github/workflows/lint-report.yml | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/.github/workflows/lint-report.yml b/.github/workflows/lint-report.yml index 84c0a0eb0..18792225b 100644 --- a/.github/workflows/lint-report.yml +++ b/.github/workflows/lint-report.yml @@ -43,14 +43,23 @@ jobs: - name: Create dummy secrets.properties run: echo "MAPS_API_KEY=dummy" > secrets.properties + # The library module is now Kotlin Multiplatform; the KMP Android library plugin + # does not create lintDebug/SARIF reporting tasks yet (tracked as a known gap of + # the KMP migration). Lint the remaining plain Android modules instead. - name: Run Android Lint - run: ./gradlew :library:lintDebug :demo:lintStandardDebug + run: ./gradlew :data:lintDebug :ui:lintDebug :demo:lintStandardDebug - - name: Upload SARIF for library + - name: Upload SARIF for data uses: github/codeql-action/upload-sarif@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6 with: - sarif_file: library/build/reports/lint-results.sarif - category: library + sarif_file: data/build/reports/lint-results.sarif + category: data + + - name: Upload SARIF for ui + uses: github/codeql-action/upload-sarif@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6 + with: + sarif_file: ui/build/reports/lint-results.sarif + category: ui - name: Upload SARIF for demo uses: github/codeql-action/upload-sarif@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6