diff --git a/.gemini/skills/android-maps-utils/SKILL.md b/.gemini/skills/android-maps-utils/SKILL.md new file mode 100644 index 000000000..c094ae05e --- /dev/null +++ b/.gemini/skills/android-maps-utils/SKILL.md @@ -0,0 +1,50 @@ +--- +name: maps-utils-android +description: Guide for integrating the Google Maps Utility Library for Android (android-maps-utils) into an application. Use when users ask to add features like Marker Clustering, Heatmaps, GeoJSON, KML, or Polyline encoding/decoding. +--- + +# Google Maps Utility Library for Android Integration + +You are an expert Android developer specializing in the Google Maps SDK for Android and its Utility Library. Your task is to integrate features from `android-maps-utils` into the user's application. + +## 1. Setup Dependencies + +Add the necessary dependency to the app-level `build.gradle.kts` file: + +```kotlin +dependencies { + // Google Maps Utility Library + implementation("com.google.maps.android:android-maps-utils:4.1.1") // x-release-please-version +} +``` + +## 2. Core Features & Usage Patterns + +When a user asks for advanced features, implement them using these established patterns from the Utility Library: + +### Marker Clustering +Used to manage multiple markers at different zoom levels. +1. Create a `ClusterItem` data class. +2. Initialize a `ClusterManager` inside `onMapReady`. +3. Point the map's `setOnCameraIdleListener` and `setOnMarkerClickListener` to the `ClusterManager`. +4. Add items using `clusterManager.addItem()`. + +### Heatmaps +Used to represent the density of data points. +1. Provide a `Collection` or `Collection`. +2. Create a `HeatmapTileProvider` with the builder `HeatmapTileProvider.Builder().data(list).build()`. +3. Add the overlay to the map: `map.addTileOverlay(TileOverlayOptions().tileProvider(provider))`. + +### GeoJSON and KML +Used to import geographic data from external files. +* **GeoJSON:** `val layer = GeoJsonLayer(map, R.raw.geojson_file, context); layer.addLayerToMap()` +* **KML:** `val layer = KmlLayer(map, R.raw.kml_file, context); layer.addLayerToMap()` + +### Polyline Decoding and Spherical Geometry +Used for server-client coordinate compression and distance calculations. +* **Decoding:** `PolyUtil.decode(encodedPathString)` +* **Distance:** `SphericalUtil.computeDistanceBetween(latLng1, latLng2)` + +## 3. Best Practices +1. **Performance:** For massive datasets (e.g., parsing huge GeoJSON files), do not block the main thread. Parse on a background dispatcher and only call `layer.addLayerToMap()` on the UI thread. +2. **Custom Renderers:** If the user wants custom cluster icons, extend `DefaultClusterRenderer` and override `onBeforeClusterItemRendered` and `onBeforeClusterRendered`. diff --git a/.geminiignore b/.geminiignore new file mode 100644 index 000000000..9079d3bc3 --- /dev/null +++ b/.geminiignore @@ -0,0 +1,40 @@ +# Ignore build and generated directories +build/ +**/build/ +.gradle/ +.idea/ +.kotlin/ +.vscode/ + +# Ignore outputs +*.apk +*.ap_ +*.aab +*.dex +*.class + +# Ignore large media assets (images, fonts, etc) unless specifically required +**/*.png +**/*.jpg +**/*.jpeg +**/*.gif +**/*.webp +**/*.svg +**/*.mp4 +**/*.mp3 +**/*.wav +**/*.ttf +**/*.woff +**/*.woff2 +**/*.otf + +# Ignore temporary and cache files +tmp/ +**/tmp/ +*.log +*.tmp +*.bak +*.swp +*~.nib +local.properties +.DS_Store diff --git a/.github/blunderbuss.yml b/.github/blunderbuss.yml index d8312bc32..79b324486 100644 --- a/.github/blunderbuss.yml +++ b/.github/blunderbuss.yml @@ -1,4 +1,4 @@ -# Copyright 2024 Google LLC +# 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. diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 05e87f450..171e9e7da 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,4 +1,4 @@ -# Copyright 2024 Google LLC +# 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. diff --git a/.github/header-checker-lint.yml b/.github/header-checker-lint.yml index c3a0fa81d..7f98cab93 100644 --- a/.github/header-checker-lint.yml +++ b/.github/header-checker-lint.yml @@ -1,4 +1,4 @@ -# Copyright 2024 Google LLC +# 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. diff --git a/.github/stale.yml b/.github/stale.yml index ffdc7ff4a..ea380441f 100644 --- a/.github/stale.yml +++ b/.github/stale.yml @@ -1,4 +1,4 @@ -# Copyright 2024 Google LLC +# 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. diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 496342d08..7874b386f 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -1,4 +1,4 @@ -# Copyright 2024 Google LLC +# 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. diff --git a/.github/workflows/lint-report.yml b/.github/workflows/lint-report.yml index 5f6938051..2632a762a 100644 --- a/.github/workflows/lint-report.yml +++ b/.github/workflows/lint-report.yml @@ -1,4 +1,4 @@ -# Copyright 2024 Google LLC +# 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. @@ -36,6 +36,9 @@ jobs: - name: Setup Gradle uses: gradle/actions/setup-gradle@v4 + - name: Create dummy secrets.properties + run: echo "MAPS_API_KEY=dummy" > secrets.properties + - name: Run Android Lint run: ./gradlew lint diff --git a/.github/workflows/report.yml b/.github/workflows/report.yml index f05a53e70..c51d1634e 100644 --- a/.github/workflows/report.yml +++ b/.github/workflows/report.yml @@ -1,4 +1,4 @@ -# Copyright 2024 Google LLC +# 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. @@ -42,6 +42,9 @@ jobs: - name: Setup Gradle uses: gradle/actions/setup-gradle@v4 + - name: Create dummy secrets.properties + run: echo "MAPS_API_KEY=dummy" > secrets.properties + - name: Build modules run: ./gradlew build jacocoTestDebugUnitTestReport --stacktrace @@ -56,4 +59,4 @@ jobs: min-coverage-changed-files: 60 title: Code Coverage debug-mode: false - update-comment: true \ No newline at end of file + update-comment: true diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f03a4f1f7..7702ef850 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -42,5 +42,8 @@ jobs: - name: Setup Gradle uses: gradle/actions/setup-gradle@v4 + - name: Create dummy secrets.properties + run: echo "MAPS_API_KEY=dummy" > secrets.properties + - name: Build modules - run: ./gradlew build jacocoTestDebugUnitTestReport --stacktrace \ No newline at end of file + run: ./gradlew build jacocoTestDebugUnitTestReport --stacktrace diff --git a/.gitignore b/.gitignore index f1a2f557c..7059d4e09 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,10 @@ project.properties secrets.properties .kotlin +# Build artifacts +build-logic/convention/bin +lint-checks/bin + # This covers new IDEs, like Antigravity .vscode/ -**/bin/ \ No newline at end of file +**/bin/ diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..e01206508 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,4 @@ +{ + "java.compile.nullAnalysis.mode": "automatic", + "java.configuration.updateBuildConfiguration": "interactive" +} \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9a86ba025..fa09ca821 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -26,3 +26,9 @@ information on using pull requests. This project follows [Google's Open Source Community Guidelines](https://opensource.google/conduct/). + +## Using AI to Contribute + +This repository provides an official Gemini Skill to help AI agents navigate and contribute to the project effectively. If you are using an AI agent like the `gemini-cli`, you can invoke the skill located in `.gemini/skills/android-maps-utils` to learn how to interact with the codebase. + +Additionally, the `.geminiignore` file prevents AI tools from consuming large or irrelevant files to preserve context limits. You can also reference the generic `llm-integration-prompt.md` to feed into web-based LLMs for general assistance. diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 000000000..36db0f69d --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,101 @@ +# Migration Guide: Upgrading from v4.x to v5.x (Modular Kotlin Rewrite) + +This guide outlines the major architectural changes, breaking changes, and source migrations required when upgrading the **Google Maps Android Utility Library** from `v4.x` to the new Kotlin-rewritten `v5.x` release. + +--- + +## 1. Multi-Module Architecture + +To improve build times and allow modern modular applications to import only what they need, the library has been split from a monolithic structure into several submodules: + +* **`android-maps-utils-core` (`:library`)**: The base utility code, including `PolyUtil`, `SphericalUtil`, `MathUtil`, and base collection managers (`MarkerManager`, `PolygonManager`, `PolylineManager`, etc.). +* **`android-maps-utils-data` (`:data`)**: KML and GeoJSON parsing and rendering. +* **`android-maps-utils-clustering` (`:clustering`)**: High-performance marker clustering and algorithm utilities. +* **`android-maps-utils-heatmaps` (`:heatmaps`)**: Heatmap overlay tile providers. +* **`android-maps-utils-ui` (`:ui`)**: Custom markers, bubble drawables, and rotation layouts. + +### How to Import: +If your project uses all utilities, you can import the aggregator artifact directly: +```toml +# gradle/libs.versions.toml +[versions] +androidMapsUtils = "5.0.0-rc1" # x-release-please-version + +[libraries] +android-maps-utils = { group = "com.google.maps.android", name = "android-maps-utils", version.ref = "androidMapsUtils" } +``` +This aggregator dynamically and transitively pulls all individual submodules. Alternatively, you can choose to import only the specific submodules your app requires (e.g., `android-maps-utils-core` and `android-maps-utils-clustering`). + +--- + +## 2. Kotlin Property Syntax Overrides (Breaking Changes for Kotlin Callers) + +Many core classes have been rewritten in **idiomatic Kotlin**. Because Kotlin does not synthesize property getter/setter methods for Kotlin-defined functions, **Kotlin callers** must migrate from calling traditional Java-style getter/setter methods to accessing **properties** directly. + +### A. `ClusterItem` Property Overrides +The `ClusterItem` interface is now written in Kotlin. Custom cluster item classes (such as data classes) can now directly override the properties in the constructor, eliminating verbose method overrides. + +#### **Before (v4.x Workaround):** +```kotlin +data class MyItem( + val latLng: LatLng, + val myTitle: String?, + val mySnippet: String?, + val myZIndex: Float? +) : ClusterItem { + override fun getPosition() = latLng + override fun getTitle() = myTitle + override fun getSnippet() = mySnippet + override fun getZIndex() = myZIndex +} +``` + +#### **After (v5.x Idiomatic Kotlin):** +```kotlin +data class MyItem( + override val position: LatLng, + override val title: String?, + override val snippet: String?, + override val zIndex: Float? +) : ClusterItem +``` + +### B. `Layer.features` +The abstract method `getFeatures()` in `Layer` has been migrated to a read-only property `features`. +- **Kotlin callers**: Access `layer.features` instead of `layer.getFeatures()`. +- **Java callers**: Seamlessly continue calling `layer.getFeatures()` (supported via Kotlin JVM bytecode generation). + +### C. `GeoJsonFeature` Style Properties +Styles on `GeoJsonFeature` have been converted to first-class properties: +- **Kotlin callers**: Use `.pointStyle`, `.lineStringStyle`, and `.polygonStyle` directly instead of calling `.getPointStyle()` / `.setPointStyle(...)`. + +#### **Example:** +```kotlin +// Before +feature.setLineStringStyle(GeoJsonLineStringStyle().apply { + setColor(Color.RED) +}) + +// After +feature.lineStringStyle = GeoJsonLineStringStyle().apply { + color = Color.RED +} +``` + +--- + +## 3. Lambda and SAM Conversions for Feature Clicks + +The `Layer.OnFeatureClickListener` interface has been converted to a Kotlin **`fun interface`** (functional interface): +```kotlin +public fun interface OnFeatureClickListener { + public fun onFeatureClick(feature: Feature) +} +``` +This enables Kotlin developers to seamlessly pass clean click lambdas using standard SAM conversion: +```kotlin +geoJsonLayer.setOnFeatureClickListener { feature -> + Toast.makeText(context, "Clicked feature: ${feature.id}", Toast.LENGTH_SHORT).show() +} +``` +Unlike the previous version, this SAM conversion is fully supported for native Kotlin callers without requiring anonymous object syntax (`object : Layer.OnFeatureClickListener { ... }`). diff --git a/README.md b/README.md index cbed94e74..400de9b7c 100644 --- a/README.md +++ b/README.md @@ -176,6 +176,10 @@ If you wish to disable this, you can do so by removing the initializer in your ` Contributions are welcome and encouraged! If you'd like to contribute, send us a [pull request] and refer to our [code of conduct] and [contributing guide]. +### Using AI +This repository provides an official Gemini Skill and an `llm-integration-prompt.md` to help AI agents navigate the codebase and provide assistance. Refer to the [contributing guide] for more details on AI usage. + + ## Terms of Service This library uses Google Maps Platform services. Use of Google Maps Platform services through this library is subject to the Google Maps Platform [Terms of Service]. diff --git a/bom/build.gradle.kts b/bom/build.gradle.kts new file mode 100644 index 000000000..1411c85b6 --- /dev/null +++ b/bom/build.gradle.kts @@ -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. + */ + +plugins { + id("android.maps.utils.BomPublishingConventionPlugin") +} + +dependencies { + constraints { + api(project(":clustering")) + api(project(":data")) + api(project(":heatmaps")) + api(project(":library")) + api(project(":onion")) + api(project(":ui")) + api(project(":visual-testing")) + } +} diff --git a/build-logic/convention/build.gradle.kts b/build-logic/convention/build.gradle.kts index e1fc0f723..0be1941a7 100644 --- a/build-logic/convention/build.gradle.kts +++ b/build-logic/convention/build.gradle.kts @@ -1,5 +1,5 @@ /* - * Copyright 2024 Google LLC + * 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. @@ -24,7 +24,6 @@ repositories { gradlePluginPortal() } - dependencies { implementation(libs.kotlin.gradle.plugin) implementation(libs.gradle) @@ -39,5 +38,9 @@ gradlePlugin { id = "android.maps.utils.PublishingConventionPlugin" implementationClass = "PublishingConventionPlugin" } + register("bomPublishingConventionPlugin") { + id = "android.maps.utils.BomPublishingConventionPlugin" + implementationClass = "BomPublishingConventionPlugin" + } } -} \ No newline at end of file +} diff --git a/build-logic/convention/src/main/kotlin/BomPublishingConventionPlugin.kt b/build-logic/convention/src/main/kotlin/BomPublishingConventionPlugin.kt new file mode 100644 index 000000000..374890256 --- /dev/null +++ b/build-logic/convention/src/main/kotlin/BomPublishingConventionPlugin.kt @@ -0,0 +1,66 @@ +/* + * 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. + */ + +import com.vanniktech.maven.publish.MavenPublishBaseExtension +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.* + +class BomPublishingConventionPlugin : Plugin { + override fun apply(project: Project) { + project.run { + apply(plugin = "java-platform") + apply(plugin = "com.vanniktech.maven.publish") + + extensions.configure { + publishToMavenCentral() + signAllPublications() + + coordinates( + artifactId = "maps-utils-bom", + ) + + pom { + name.set("android-maps-utils-bom") + description.set("BoM for android-maps-utils") + url.set("https://github.com/googlemaps/android-maps-utils") + licenses { + license { + name.set("The Apache Software License, Version 2.0") + url.set("http://www.apache.org/licenses/LICENSE-2.0.txt") + distribution.set("repo") + } + } + scm { + connection.set("scm:git@github.com:googlemaps/android-maps-utils.git") + developerConnection.set("scm:git@github.com:googlemaps/android-maps-utils.git") + url.set("https://github.com/googlemaps/android-maps-utils") + } + developers { + developer { + id.set("google") + name.set("Google LLC") + } + } + organization { + name.set("Google Inc") + url.set("http://developers.google.com/maps") + } + } + } + } + } +} diff --git a/build-logic/convention/src/main/kotlin/PublishingConventionPlugin.kt b/build-logic/convention/src/main/kotlin/PublishingConventionPlugin.kt index 0b588ec1e..43e3f5898 100644 --- a/build-logic/convention/src/main/kotlin/PublishingConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/PublishingConventionPlugin.kt @@ -1,5 +1,5 @@ /* - * Copyright 2025 Google LLC + * 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. @@ -42,7 +42,7 @@ class PublishingConventionPlugin : Plugin { private fun Project.configureJacoco() { configure { - toolVersion = "0.8.7" + toolVersion = "0.8.12" } tasks.withType().configureEach { @@ -66,8 +66,13 @@ class PublishingConventionPlugin : Plugin { publishToMavenCentral() signAllPublications() + val artifactIdName = when (project.name) { + "maps-utils" -> "android-maps-utils" + "library" -> "android-maps-utils-core" + else -> "android-maps-utils-${project.name}" + } coordinates( - artifactId = "android-maps-utils", + artifactId = artifactIdName, ) pom { @@ -89,7 +94,7 @@ class PublishingConventionPlugin : Plugin { developers { developer { id.set("google") - name.set("Google Inc.") + name.set("Google LLC") } } organization { diff --git a/build-logic/settings.gradle.kts b/build-logic/settings.gradle.kts index d674100b2..a2af13035 100644 --- a/build-logic/settings.gradle.kts +++ b/build-logic/settings.gradle.kts @@ -1,5 +1,5 @@ /* - * Copyright 2024 Google LLC + * 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. diff --git a/build.gradle.kts b/build.gradle.kts index 5b8f1e99c..521394e3b 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,5 +1,5 @@ /* - * Copyright 2024 Google LLC + * 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. @@ -47,4 +47,12 @@ allprojects { // {x-release-please-start-version} version = "4.3.0" // {x-release-please-end} -} \ No newline at end of file + + plugins.withId("java") { + configure { + toolchain { + languageVersion.set(JavaLanguageVersion.of(17)) + } + } + } +} diff --git a/clustering/build.gradle.kts b/clustering/build.gradle.kts new file mode 100644 index 000000000..90511e192 --- /dev/null +++ b/clustering/build.gradle.kts @@ -0,0 +1,94 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +/** + * 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("kotlin-android") + 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 = 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" + ) + } + } + resourcePrefix = "amu_" + + installation { + timeOutInMs = 10 * 60 * 1000 // 10 minutes + installOptions("-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.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/consumer-rules.pro b/clustering/consumer-rules.pro new file mode 100644 index 000000000..e69de29bb diff --git a/clustering/proguard-rules.pro b/clustering/proguard-rules.pro new file mode 100644 index 000000000..f1b424510 --- /dev/null +++ b/clustering/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile diff --git a/clustering/src/main/AndroidManifest.xml b/clustering/src/main/AndroidManifest.xml new file mode 100644 index 000000000..7edf00bd5 --- /dev/null +++ b/clustering/src/main/AndroidManifest.xml @@ -0,0 +1,19 @@ + + + + + diff --git a/library/src/main/java/com/google/maps/android/clustering/Cluster.java b/clustering/src/main/java/com/google/maps/android/clustering/Cluster.kt similarity index 64% rename from library/src/main/java/com/google/maps/android/clustering/Cluster.java rename to clustering/src/main/java/com/google/maps/android/clustering/Cluster.kt index ebc1bafff..e239cec37 100644 --- a/library/src/main/java/com/google/maps/android/clustering/Cluster.java +++ b/clustering/src/main/java/com/google/maps/android/clustering/Cluster.kt @@ -1,11 +1,11 @@ /* - * Copyright 2013 Google Inc. + * 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 + * 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, @@ -13,20 +13,17 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +package com.google.maps.android.clustering -package com.google.maps.android.clustering; - -import com.google.android.gms.maps.model.LatLng; - -import java.util.Collection; +import com.google.android.gms.maps.model.LatLng /** * A collection of ClusterItems that are nearby each other. */ -public interface Cluster { - LatLng getPosition(); +interface Cluster { + val position: LatLng - Collection getItems(); + val items: Collection - int getSize(); + val size: Int } diff --git a/library/src/main/java/com/google/maps/android/clustering/ClusterItem.java b/clustering/src/main/java/com/google/maps/android/clustering/ClusterItem.kt similarity index 66% rename from library/src/main/java/com/google/maps/android/clustering/ClusterItem.java rename to clustering/src/main/java/com/google/maps/android/clustering/ClusterItem.kt index d45e6d12b..e3409f893 100644 --- a/library/src/main/java/com/google/maps/android/clustering/ClusterItem.java +++ b/clustering/src/main/java/com/google/maps/android/clustering/ClusterItem.kt @@ -1,11 +1,11 @@ /* - * Copyright 2013 Google Inc. + * 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 + * 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, @@ -13,36 +13,31 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +package com.google.maps.android.clustering -package com.google.maps.android.clustering; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import com.google.android.gms.maps.model.LatLng; +import com.google.android.gms.maps.model.LatLng /** * ClusterItem represents a marker on the map. */ -public interface ClusterItem { - +interface ClusterItem { /** * The position of this marker. This must always return the same value. */ - @NonNull LatLng getPosition(); + val position: LatLng /** * The title of this marker. */ - @Nullable String getTitle(); + val title: String? /** * The description of this marker. */ - @Nullable String getSnippet(); + val snippet: String? /** * The z-index of this marker. */ - @Nullable Float getZIndex(); + val zIndex: Float? } diff --git a/clustering/src/main/java/com/google/maps/android/clustering/ClusterManager.kt b/clustering/src/main/java/com/google/maps/android/clustering/ClusterManager.kt new file mode 100644 index 000000000..25c113012 --- /dev/null +++ b/clustering/src/main/java/com/google/maps/android/clustering/ClusterManager.kt @@ -0,0 +1,435 @@ +/* + * 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 android.content.Context +import android.os.AsyncTask +import com.google.android.gms.maps.GoogleMap +import com.google.android.gms.maps.GoogleMap.OnCameraIdleListener +import com.google.android.gms.maps.GoogleMap.OnInfoWindowClickListener +import com.google.android.gms.maps.GoogleMap.OnMarkerClickListener +import com.google.android.gms.maps.model.CameraPosition +import com.google.android.gms.maps.model.Marker +import com.google.maps.android.clustering.algo.Algorithm +import com.google.maps.android.clustering.algo.NonHierarchicalDistanceBasedAlgorithm +import com.google.maps.android.clustering.algo.PreCachingAlgorithmDecorator +import com.google.maps.android.clustering.algo.ScreenBasedAlgorithm +import com.google.maps.android.clustering.algo.ScreenBasedAlgorithmAdapter +import com.google.maps.android.clustering.view.ClusterRenderer +import com.google.maps.android.clustering.view.DefaultClusterRenderer +import com.google.maps.android.collections.MarkerManager +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.util.concurrent.locks.ReadWriteLock +import java.util.concurrent.locks.ReentrantReadWriteLock + +/** + * Groups many items on a map based on zoom level. + * + * + * ClusterManager should be added to the map as an: * [com.google.android.gms.maps.GoogleMap.OnCameraIdleListener] + * * [com.google.android.gms.maps.GoogleMap.OnMarkerClickListener] + */ +class ClusterManager + @JvmOverloads + constructor( + context: Context, + private val mMap: GoogleMap, + val markerManager: MarkerManager = MarkerManager(mMap), + ) : OnCameraIdleListener, + OnMarkerClickListener, + OnInfoWindowClickListener { + val markerCollection: MarkerManager.Collection = markerManager.newCollection() + val clusterMarkerCollection: MarkerManager.Collection = markerManager.newCollection() + + private var mAlgorithm: ScreenBasedAlgorithm + private var mRenderer: ClusterRenderer + + private var mPreviousCameraPosition: CameraPosition? = null + private var mClusterTask: Job? = null + private val mClusterTaskLock: ReadWriteLock = ReentrantReadWriteLock() + + private var mOnClusterItemClickListener: OnClusterItemClickListener? = null + private var mOnClusterInfoWindowClickListener: OnClusterInfoWindowClickListener? = null + private var mOnClusterInfoWindowLongClickListener: OnClusterInfoWindowLongClickListener? = null + private var mOnClusterItemInfoWindowClickListener: OnClusterItemInfoWindowClickListener? = null + private var mOnClusterItemInfoWindowLongClickListener: OnClusterItemInfoWindowLongClickListener? = null + private var mOnClusterClickListener: OnClusterClickListener? = null + + private val scope = CoroutineScope(Dispatchers.Main) + + init { + mRenderer = DefaultClusterRenderer(context, mMap, this) + mAlgorithm = + ScreenBasedAlgorithmAdapter( + PreCachingAlgorithmDecorator( + NonHierarchicalDistanceBasedAlgorithm(), + ), + ) + mRenderer.onAdd() + } + + var renderer: ClusterRenderer + get() = mRenderer + set(value) { + mRenderer.setOnClusterClickListener(null) + mRenderer.setOnClusterItemClickListener(null) + clusterMarkerCollection.clear() + markerCollection.clear() + mRenderer.onRemove() + mRenderer = value + mRenderer.onAdd() + mRenderer.setOnClusterClickListener(mOnClusterClickListener) + mRenderer.setOnClusterInfoWindowClickListener(mOnClusterInfoWindowClickListener) + mRenderer.setOnClusterInfoWindowLongClickListener(mOnClusterInfoWindowLongClickListener) + mRenderer.setOnClusterItemClickListener(mOnClusterItemClickListener) + mRenderer.setOnClusterItemInfoWindowClickListener(mOnClusterItemInfoWindowClickListener) + mRenderer.setOnClusterItemInfoWindowLongClickListener(mOnClusterItemInfoWindowLongClickListener) + cluster() + } + + var algorithm: Algorithm + get() = mAlgorithm + set(value) { + if (value is ScreenBasedAlgorithm<*>) { + setAlgorithm(value as ScreenBasedAlgorithm) + } else { + setAlgorithm(ScreenBasedAlgorithmAdapter(value)) + } + } + + fun setAlgorithm(algorithm: ScreenBasedAlgorithm) { + algorithm.lock() + try { + val oldAlgorithm = this.algorithm + mAlgorithm = algorithm + + oldAlgorithm.lock() + try { + algorithm.addItems(oldAlgorithm.items) + } finally { + oldAlgorithm.unlock() + } + } finally { + algorithm.unlock() + } + + if (mAlgorithm.shouldReclusterOnMapMovement()) { + mAlgorithm.onCameraChange(mMap.cameraPosition) + } + + cluster() + } + + fun setAnimation(animate: Boolean) { + mRenderer.setAnimation(animate) + } + + /** + * Removes all items from the cluster manager. After calling this method you must invoke + * [.cluster] for the map to be cleared. + */ + fun clearItems() { + val algorithm = algorithm + algorithm.lock() + try { + algorithm.clearItems() + } finally { + algorithm.unlock() + } + } + + /** + * Adds items to clusters. After calling this method you must invoke [.cluster] for the + * state of the clusters to be updated on the map. + * @param items items to add to clusters + * @return true if the cluster manager contents changed as a result of the call + */ + fun addItems(items: Collection?): Boolean { + val algorithm = algorithm + algorithm.lock() + try { + return algorithm.addItems(items!!) + } finally { + algorithm.unlock() + } + } + + /** + * Adds an item to a cluster. After calling this method you must invoke [.cluster] for + * the state of the clusters to be updated on the map. + * @param myItem item to add to clusters + * @return true if the cluster manager contents changed as a result of the call + */ + fun addItem(myItem: T): Boolean { + val algorithm = algorithm + algorithm.lock() + try { + return algorithm.addItem(myItem) + } finally { + algorithm.unlock() + } + } + + fun diff( + add: Collection?, + remove: Collection?, + modify: Collection?, + ) { + val algorithm = algorithm + algorithm.lock() + try { + // Add items + if (add != null) { + for (item in add) { + algorithm.addItem(item) + } + } + + // Remove items + if (remove != null) { + algorithm.removeItems(remove) + } + + // Modify items + if (modify != null) { + for (item in modify) { + updateItem(item) + } + } + } finally { + algorithm.unlock() + } + } + + /** + * Removes items from clusters. After calling this method you must invoke [.cluster] for + * the state of the clusters to be updated on the map. + * @param items items to remove from clusters + * @return true if the cluster manager contents changed as a result of the call + */ + fun removeItems(items: Collection?): Boolean { + val algorithm = algorithm + algorithm.lock() + try { + return algorithm.removeItems(items!!) + } finally { + algorithm.unlock() + } + } + + /** + * Removes an item from clusters. After calling this method you must invoke [.cluster] + * for the state of the clusters to be updated on the map. + * @param item item to remove from clusters + * @return true if the item was removed from the cluster manager as a result of this call + */ + fun removeItem(item: T): Boolean { + val algorithm = algorithm + algorithm.lock() + try { + return algorithm.removeItem(item) + } finally { + algorithm.unlock() + } + } + + /** + * Updates an item in clusters. After calling this method you must invoke [.cluster] for + * the state of the clusters to be updated on the map. + * @param item item to update in clusters + * @return true if the item was updated in the cluster manager, false if the item is not + * contained within the cluster manager and the cluster manager contents are unchanged + */ + fun updateItem(item: T): Boolean { + val algorithm = algorithm + algorithm.lock() + try { + return algorithm.updateItem(item) + } finally { + algorithm.unlock() + } + } + + /** + * Force a re-cluster on the map. You should call this after adding, removing, updating, + * or clearing item(s). + */ + fun cluster() { + mClusterTaskLock.writeLock().lock() + try { + // Attempt to cancel the in-flight request. + mClusterTask?.cancel() + mClusterTask = + scope.launch { + val param = mMap.cameraPosition.zoom + val clusters = + withContext(Dispatchers.Default) { + val algorithm = this@ClusterManager.algorithm + algorithm.lock() + try { + algorithm.getClusters(param) + } finally { + algorithm.unlock() + } + } + mRenderer.onClustersChanged(clusters) + } + } finally { + mClusterTaskLock.writeLock().unlock() + } + } + + /** + * Might re-cluster. + */ + override fun onCameraIdle() { + if (mRenderer is OnCameraIdleListener) { + (mRenderer as OnCameraIdleListener).onCameraIdle() + } + + mAlgorithm.onCameraChange(mMap.cameraPosition) + + // delegate clustering to the algorithm + if (mAlgorithm.shouldReclusterOnMapMovement()) { + cluster() + + // Don't re-compute clusters if the map has just been panned/tilted/rotated. + } else if (mPreviousCameraPosition == null || mPreviousCameraPosition!!.zoom != mMap.cameraPosition.zoom) { + mPreviousCameraPosition = mMap.cameraPosition + cluster() + } + } + + override fun onMarkerClick(marker: Marker): Boolean = markerManager.onMarkerClick(marker) + + override fun onInfoWindowClick(marker: Marker) { + markerManager.onInfoWindowClick(marker) + } + + /** + * Sets a callback that's invoked when a Cluster is tapped. Note: For this listener to function, + * the ClusterManager must be added as a click listener to the map. + */ + fun setOnClusterClickListener(listener: OnClusterClickListener?) { + mOnClusterClickListener = listener + mRenderer.setOnClusterClickListener(listener) + } + + /** + * Sets a callback that's invoked when a Cluster info window is tapped. Note: For this listener to function, + * the ClusterManager must be added as a info window click listener to the map. + */ + fun setOnClusterInfoWindowClickListener(listener: OnClusterInfoWindowClickListener?) { + mOnClusterInfoWindowClickListener = listener + mRenderer.setOnClusterInfoWindowClickListener(listener) + } + + /** + * Sets a callback that's invoked when a Cluster info window is long-pressed. Note: For this listener to function, + * the ClusterManager must be added as a info window click listener to the map. + */ + fun setOnClusterInfoWindowLongClickListener(listener: OnClusterInfoWindowLongClickListener?) { + mOnClusterInfoWindowLongClickListener = listener + mRenderer.setOnClusterInfoWindowLongClickListener(listener) + } + + /** + * Sets a callback that's invoked when an individual ClusterItem is tapped. Note: For this + * listener to function, the ClusterManager must be added as a click listener to the map. + */ + fun setOnClusterItemClickListener(listener: OnClusterItemClickListener?) { + mOnClusterItemClickListener = listener + mRenderer.setOnClusterItemClickListener(listener) + } + + /** + * Sets a callback that's invoked when an individual ClusterItem's Info Window is tapped. Note: For this + * listener to function, the ClusterManager must be added as a info window click listener to the map. + */ + fun setOnClusterItemInfoWindowClickListener(listener: OnClusterItemInfoWindowClickListener?) { + mOnClusterItemInfoWindowClickListener = listener + mRenderer.setOnClusterItemInfoWindowClickListener(listener) + } + + /** + * Sets a callback that's invoked when an individual ClusterItem's Info Window is long-pressed. Note: For this + * listener to function, the ClusterManager must be added as a info window click listener to the map. + */ + fun setOnClusterItemInfoWindowLongClickListener(listener: OnClusterItemInfoWindowLongClickListener?) { + mOnClusterItemInfoWindowLongClickListener = listener + mRenderer.setOnClusterItemInfoWindowLongClickListener(listener) + } + + /** + * Called when a Cluster is clicked. + */ + fun interface OnClusterClickListener { + /** + * Called when cluster is clicked. + * Return true if click has been handled + * Return false and the click will dispatched to the next listener + */ + fun onClusterClick(cluster: Cluster): Boolean + } + + /** + * Called when a Cluster's Info Window is clicked. + */ + fun interface OnClusterInfoWindowClickListener { + fun onClusterInfoWindowClick(cluster: Cluster) + } + + /** + * Called when a Cluster's Info Window is long clicked. + */ + fun interface OnClusterInfoWindowLongClickListener { + fun onClusterInfoWindowLongClick(cluster: Cluster) + } + + /** + * Called when an individual ClusterItem is clicked. + */ + fun interface OnClusterItemClickListener { + /** + * Called when `item` is clicked. + * + * @param item the item clicked + * + * @return true if the listener consumed the event (i.e. the default behavior should not + * occur), false otherwise (i.e. the default behavior should occur). The default behavior + * is for the camera to move to the marker and an info window to appear. + */ + fun onClusterItemClick(item: T): Boolean + } + + /** + * Called when an individual ClusterItem's Info Window is clicked. + */ + fun interface OnClusterItemInfoWindowClickListener { + fun onClusterItemInfoWindowClick(item: T) + } + + /** + * Called when an individual ClusterItem's Info Window is long clicked. + */ + fun interface OnClusterItemInfoWindowLongClickListener { + fun onClusterItemInfoWindowLongClick(item: T) + } + } diff --git a/library/src/main/java/com/google/maps/android/clustering/algo/AbstractAlgorithm.java b/clustering/src/main/java/com/google/maps/android/clustering/algo/AbstractAlgorithm.kt similarity index 50% rename from library/src/main/java/com/google/maps/android/clustering/algo/AbstractAlgorithm.java rename to clustering/src/main/java/com/google/maps/android/clustering/algo/AbstractAlgorithm.kt index 29ba73e8d..8ba76a038 100644 --- a/library/src/main/java/com/google/maps/android/clustering/algo/AbstractAlgorithm.java +++ b/clustering/src/main/java/com/google/maps/android/clustering/algo/AbstractAlgorithm.kt @@ -1,11 +1,11 @@ /* - * Copyright 2020 Google Inc. + * 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 + * 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, @@ -13,27 +13,23 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.google.maps.android.clustering.algo; +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; +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. */ -public abstract class AbstractAlgorithm implements Algorithm { - - private final ReadWriteLock mLock = new ReentrantReadWriteLock(); +abstract class AbstractAlgorithm : Algorithm { + private val mLock: ReadWriteLock = ReentrantReadWriteLock() - @Override - public void lock() { - mLock.writeLock().lock(); + override fun lock() { + mLock.writeLock().lock() } - @Override - public void unlock() { - mLock.writeLock().unlock(); + override fun unlock() { + mLock.writeLock().unlock() } } diff --git a/library/src/main/java/com/google/maps/android/clustering/algo/Algorithm.java b/clustering/src/main/java/com/google/maps/android/clustering/algo/Algorithm.kt similarity index 67% rename from library/src/main/java/com/google/maps/android/clustering/algo/Algorithm.java rename to clustering/src/main/java/com/google/maps/android/clustering/algo/Algorithm.kt index 8784afe1c..ab6b67e58 100644 --- a/library/src/main/java/com/google/maps/android/clustering/algo/Algorithm.java +++ b/clustering/src/main/java/com/google/maps/android/clustering/algo/Algorithm.kt @@ -1,11 +1,11 @@ /* - * Copyright 2013 Google Inc. + * 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 + * 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, @@ -13,35 +13,30 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +package com.google.maps.android.clustering.algo -package com.google.maps.android.clustering.algo; - -import com.google.maps.android.clustering.Cluster; -import com.google.maps.android.clustering.ClusterItem; - -import java.util.Collection; -import java.util.Set; +import com.google.maps.android.clustering.Cluster +import com.google.maps.android.clustering.ClusterItem /** * Logic for computing clusters */ -public interface Algorithm { - +interface Algorithm { /** * Adds an item to the algorithm * @param item the item to be added * @return true if the algorithm contents changed as a result of the call */ - boolean addItem(T item); + fun addItem(item: T): Boolean /** * Adds a collection of items to the algorithm * @param items the items to be added * @return true if the algorithm contents changed as a result of the call */ - boolean addItems(Collection items); + fun addItems(items: Collection): Boolean - void clearItems(); + fun clearItems() /** * Removes an item from the algorithm @@ -49,7 +44,7 @@ public interface Algorithm { * @return true if this algorithm contained the specified element (or equivalently, if this * algorithm changed as a result of the call). */ - boolean removeItem(T item); + fun removeItem(item: T): Boolean /** * Updates the provided item in the algorithm @@ -57,24 +52,22 @@ public interface Algorithm { * @return true if the item existed in the algorithm and was updated, or false if the item did * not exist in the algorithm and the algorithm contents remain unchanged. */ - boolean updateItem(T item); + fun updateItem(item: T): Boolean /** * Removes a collection of items from the algorithm * @param items the items to be removed * @return true if this algorithm contents changed as a result of the call */ - boolean removeItems(Collection items); - - Set> getClusters(float zoom); + fun removeItems(items: Collection): Boolean - Collection getItems(); + fun getClusters(zoom: Float): Set> - void setMaxDistanceBetweenClusteredItems(int maxDistance); + val items: Collection - int getMaxDistanceBetweenClusteredItems(); + var maxDistanceBetweenClusteredItems: Int - void lock(); + fun lock() - void unlock(); + fun unlock() } diff --git a/clustering/src/main/java/com/google/maps/android/clustering/algo/CentroidNonHierarchicalDistanceBasedAlgorithm.kt b/clustering/src/main/java/com/google/maps/android/clustering/algo/CentroidNonHierarchicalDistanceBasedAlgorithm.kt new file mode 100644 index 000000000..521e7971f --- /dev/null +++ b/clustering/src/main/java/com/google/maps/android/clustering/algo/CentroidNonHierarchicalDistanceBasedAlgorithm.kt @@ -0,0 +1,75 @@ +/* + * 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.Cluster +import com.google.maps.android.clustering.ClusterItem +import java.util.HashSet + +/** + * A variant of [NonHierarchicalDistanceBasedAlgorithm] that clusters items + * based on distance but assigns cluster positions at the centroid of their items, + * instead of using the position of a single item as the cluster position. + * + * This algorithm overrides [.getClusters] to compute a geographic centroid + * for each cluster and creates [StaticCluster] instances positioned at these centroids. + * This can provide a more accurate visual representation of the cluster location. + * + * @param the type of cluster item + */ +open class CentroidNonHierarchicalDistanceBasedAlgorithm : NonHierarchicalDistanceBasedAlgorithm() { + /** + * Computes the centroid (average latitude and longitude) of a collection of cluster items. + * + * @param items the collection of cluster items to compute the centroid for + * @return the centroid [LatLng] of the items + */ + protected fun computeCentroid(items: Collection): LatLng { + var latSum = 0.0 + var lngSum = 0.0 + var count = 0 + for (item in items) { + latSum += item.position.latitude + lngSum += item.position.longitude + count++ + } + return LatLng(latSum / count, lngSum / count) + } + + /** + * Returns clusters of items for the given zoom level, with cluster positions + * set to the centroid of their constituent items rather than the position of + * any single item. + * + * @param zoom the current zoom level + * @return a set of clusters with centroid positions + */ + override fun getClusters(zoom: Float): Set> { + val originalClusters = super.getClusters(zoom) + val newClusters = HashSet>() + + for (cluster in originalClusters) { + val centroid = computeCentroid(cluster.items) + val newCluster = StaticCluster(centroid) + for (item in cluster.items) { + newCluster.add(item) + } + newClusters.add(newCluster) + } + return newClusters + } +} diff --git a/clustering/src/main/java/com/google/maps/android/clustering/algo/ContinuousZoomEuclideanCentroidAlgorithm.kt b/clustering/src/main/java/com/google/maps/android/clustering/algo/ContinuousZoomEuclideanCentroidAlgorithm.kt new file mode 100644 index 000000000..b9c048aca --- /dev/null +++ b/clustering/src/main/java/com/google/maps/android/clustering/algo/ContinuousZoomEuclideanCentroidAlgorithm.kt @@ -0,0 +1,103 @@ +/* + * 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.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 + +/** + * A variant of [CentroidNonHierarchicalDistanceBasedAlgorithm] that uses + * continuous zoom scaling and Euclidean distance for clustering. + * + * This class overrides [.getClusters] to compute + * clusters with a zoom-dependent radius, while keeping the centroid-based cluster positions. + * + * @param the type of cluster item + */ +class ContinuousZoomEuclideanCentroidAlgorithm : CentroidNonHierarchicalDistanceBasedAlgorithm() { + override fun getClusters(zoom: Float): Set> { + // Continuous zoom — no casting to int + val zoomSpecificSpan = maxDistanceBetweenClusteredItems.toDouble() / 2.0.pow(zoom.toDouble()) / 256.0 + + val visitedCandidates = HashSet>() + val results = HashSet>() + val distanceToCluster = HashMap, Double>() + val itemToCluster = HashMap, StaticCluster>() + + synchronized(mQuadTree) { + for (candidate in getClusteringItems(mQuadTree, zoom)) { + if (visitedCandidates.contains(candidate)) { + // Candidate is already part of another cluster. + continue + } + + val searchBounds = createBoundsFromSpan(candidate.point, zoomSpecificSpan) + val clusterItems = ArrayList>() + for (clusterItem in mQuadTree.search(searchBounds)) { + val distance = distanceSquared(clusterItem.point, candidate.point) + val radiusSquared = (zoomSpecificSpan / 2).pow(2.0) + if (distance < radiusSquared) { + clusterItems.add(clusterItem) + } + } + + if (clusterItems.size == 1) { + // Only the current marker is in range. Just add the single item to the results. + results.add(candidate) + visitedCandidates.add(candidate) + distanceToCluster[candidate] = 0.0 + continue + } + val cluster = StaticCluster(candidate.mClusterItem.position) + results.add(cluster) + + for (clusterItem in clusterItems) { + val existingDistance = distanceToCluster[clusterItem] + val distance = distanceSquared(clusterItem.point, candidate.point) + if (existingDistance != null) { + // Item already belongs to another cluster. Check if it's closer to this cluster. + if (existingDistance < distance) { + continue + } + // Move item to the closer cluster. + itemToCluster[clusterItem]?.remove(clusterItem.mClusterItem) + } + distanceToCluster[clusterItem] = distance + cluster.add(clusterItem.mClusterItem) + itemToCluster[clusterItem] = cluster + } + visitedCandidates.addAll(clusterItems) + } + } + + // Now, apply the centroid logic from CentroidNonHierarchicalDistanceBasedAlgorithm + val newClusters = HashSet>() + for (cluster in results) { + val centroid = computeCentroid(cluster.items) + val newCluster = StaticCluster(centroid) + for (item in cluster.items) { + newCluster.add(item) + } + newClusters.add(newCluster) + } + + return newClusters + } +} diff --git a/clustering/src/main/java/com/google/maps/android/clustering/algo/GridBasedAlgorithm.kt b/clustering/src/main/java/com/google/maps/android/clustering/algo/GridBasedAlgorithm.kt new file mode 100644 index 000000000..4a77032b8 --- /dev/null +++ b/clustering/src/main/java/com/google/maps/android/clustering/algo/GridBasedAlgorithm.kt @@ -0,0 +1,115 @@ +/* + * 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 androidx.collection.LongSparseArray +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 + +/** + * Groups markers into a grid for clustering. This algorithm organizes items into a two-dimensional grid, + * facilitating the formation of clusters based on proximity within each grid cell. The grid size determines + * the spatial granularity of clustering, and clusters are created by aggregating items within the same grid cell. + * + * The effectiveness of clustering is influenced by the specified grid size, which determines the spatial resolution of the grid. + * Smaller grid sizes result in more localized clusters, whereas larger grid sizes lead to broader clusters covering larger areas. + * + * @param The type of {@link ClusterItem} to be clustered. + */ +class GridBasedAlgorithm : AbstractAlgorithm() { + private var mGridSize = DEFAULT_GRID_SIZE + private val mItems: MutableSet = Collections.synchronizedSet(HashSet()) + + override fun addItem(item: T): Boolean = mItems.add(item) + + override fun addItems(items: Collection): Boolean = mItems.addAll(items) + + override fun clearItems() { + mItems.clear() + } + + override fun removeItem(item: T): Boolean = mItems.remove(item) + + override fun removeItems(items: Collection): Boolean = mItems.removeAll(items.toSet()) + + override fun updateItem(item: T): Boolean { + var result: Boolean + synchronized(mItems) { + result = removeItem(item) + if (result) { + // Only add the item if it was removed (to help prevent accidental duplicates on map) + result = addItem(item) + } + } + return result + } + + override var maxDistanceBetweenClusteredItems: Int + get() = mGridSize + set(maxDistance) { + mGridSize = maxDistance + } + + override fun getClusters(zoom: Float): Set> { + val numCells = ceil(256 * 2.0.pow(zoom.toDouble()) / mGridSize).toLong() + val proj = SphericalMercatorProjection(numCells.toDouble()) + + val clusters = HashSet>() + val sparseArray = LongSparseArray>() + + synchronized(mItems) { + for (item in mItems) { + val p = proj.toPoint(item.position) + val coord = getCoord(numCells, p.x, p.y) + + var cluster = sparseArray.get(coord) + if (cluster == null) { + cluster = + StaticCluster( + proj.toLatLng( + com.google.maps.android.geometry + .Point(floor(p.x) + .5, floor(p.y) + .5), + ), + ) + sparseArray.put(coord, cluster) + clusters.add(cluster) + } + cluster.add(item) + } + } + + return clusters + } + + override val items: Collection + get() = mItems + + companion object { + private const val DEFAULT_GRID_SIZE = 100 + + private fun getCoord( + numCells: Long, + x: Double, + y: Double, + ): Long = (numCells * floor(x) + floor(y)).toLong() + } +} diff --git a/clustering/src/main/java/com/google/maps/android/clustering/algo/NonHierarchicalDistanceBasedAlgorithm.kt b/clustering/src/main/java/com/google/maps/android/clustering/algo/NonHierarchicalDistanceBasedAlgorithm.kt new file mode 100644 index 000000000..7a8901081 --- /dev/null +++ b/clustering/src/main/java/com/google/maps/android/clustering/algo/NonHierarchicalDistanceBasedAlgorithm.kt @@ -0,0 +1,266 @@ +/* + * 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.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.ArrayList +import java.util.Collections +import java.util.HashMap +import java.util.HashSet +import java.util.LinkedHashSet + +/** + * A simple clustering algorithm with O(nlog n) performance. Resulting clusters are not + * hierarchical. + * + * High level algorithm: + * 1. Iterate over items in the order they were added (candidate clusters). + * 2. Create a cluster with the center of the item. + * 3. Add all items that are within a certain distance to the cluster. + * 4. Move any items out of an existing cluster if they are closer to another cluster. + * 5. Remove those items from the list of candidate clusters. + * + * Clusters have the center of the first element (not the centroid of the items within it). + */ +open class NonHierarchicalDistanceBasedAlgorithm : AbstractAlgorithm() { + /** + * Any modifications should be synchronized on mQuadTree. + */ + @JvmField + protected val mItems: MutableCollection> = LinkedHashSet() + + /** + * Any modifications should be synchronized on mQuadTree. + */ + @JvmField + protected val mQuadTree: PointQuadTree> = PointQuadTree(0.0, 1.0, 0.0, 1.0) + + override var maxDistanceBetweenClusteredItems: Int = DEFAULT_MAX_DISTANCE_AT_ZOOM + + override fun addItem(item: T): Boolean { + val quadItem = QuadItem(item) + synchronized(mQuadTree) { + val result = mItems.add(quadItem) + if (result) { + mQuadTree.add(quadItem) + } + return result + } + } + + override fun addItems(items: Collection): Boolean { + var result = false + for (item in items) { + val individualResult = addItem(item) + if (individualResult) { + result = true + } + } + return result + } + + override fun clearItems() { + synchronized(mQuadTree) { + mItems.clear() + mQuadTree.clear() + } + } + + 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 + val quadItem = QuadItem(item) + synchronized(mQuadTree) { + val result = mItems.remove(quadItem) + if (result) { + mQuadTree.remove(quadItem) + } + return result + } + } + + override fun removeItems(items: Collection): Boolean { + var result = false + synchronized(mQuadTree) { + for (item in items) { + // QuadItem delegates hashcode() and equals() to its item so, + // removing any QuadItem to that item will remove the item + val quadItem = QuadItem(item) + val individualResult = mItems.remove(quadItem) + if (individualResult) { + mQuadTree.remove(quadItem) + result = true + } + } + } + return result + } + + 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) { + var result = removeItem(item) + if (result) { + // Only add the item if it was removed (to help prevent accidental duplicates on map) + result = addItem(item) + } + return result + } + } + + override fun getClusters(zoom: Float): Set> { + val discreteZoom = zoom.toInt() + + val zoomSpecificSpan = maxDistanceBetweenClusteredItems.toDouble() / Math.pow(2.0, discreteZoom.toDouble()) / 256.0 + + val visitedCandidates = HashSet>() + val results = HashSet>() + val distanceToCluster = HashMap, Double>() + val itemToCluster = HashMap, StaticCluster>() + + synchronized(mQuadTree) { + for (candidate in getClusteringItems(mQuadTree, zoom)) { + if (visitedCandidates.contains(candidate)) { + // Candidate is already part of another cluster. + continue + } + + val searchBounds = createBoundsFromSpan(candidate.point, zoomSpecificSpan) + val mClusterItems = mQuadTree.search(searchBounds) + if (mClusterItems.size == 1) { + // Only the current marker is in range. Just add the single item to the results. + results.add(candidate) + visitedCandidates.add(candidate) + distanceToCluster[candidate] = 0.0 + continue + } + val cluster = StaticCluster(candidate.mClusterItem.position) + results.add(cluster) + + for (mClusterItem in mClusterItems) { + val existingDistance = distanceToCluster[mClusterItem] + val distance = distanceSquared(mClusterItem.point, candidate.point) + if (existingDistance != null) { + // Item already belongs to another cluster. Check if it's closer to this cluster. + if (existingDistance < distance) { + continue + } + // Move item to the closer cluster. + itemToCluster[mClusterItem]?.remove(mClusterItem.mClusterItem) + } + distanceToCluster[mClusterItem] = distance + cluster.add(mClusterItem.mClusterItem) + itemToCluster[mClusterItem] = cluster + } + visitedCandidates.addAll(mClusterItems) + } + } + return results + } + + protected open fun getClusteringItems( + quadTree: PointQuadTree>, + zoom: Float, + ): Collection> = mItems + + override val items: Collection + get() { + val items = LinkedHashSet() + synchronized(mQuadTree) { + for (quadItem in mItems) { + items.add(quadItem.mClusterItem) + } + } + return items + } + + /** + * Calculates the squared Euclidean distance between two points. + * + * @param a the first point + * @param b the second point + * @return the squared Euclidean distance between [a] and [b] + */ + protected fun distanceSquared( + a: Point, + b: Point, + ): Double = (a.x - b.x) * (a.x - b.x) + (a.y - b.y) * (a.y - b.y) + + /** + * Creates a square bounding box centered at a point with the specified span. + * + * @param p the center point + * @param span the total width/height of the bounding box + * @return the [Bounds] object representing the search area + */ + protected fun createBoundsFromSpan( + p: Point, + span: Double, + ): Bounds { + // TODO: Use a span that takes into account the visual size of the marker, not just its + // LatLng. + val halfSpan = span / 2 + return Bounds( + p.x - halfSpan, + p.x + halfSpan, + p.y - halfSpan, + p.y + halfSpan, + ) + } + + protected class QuadItem( + @JvmField val mClusterItem: T, + ) : PointQuadTree.Item, + Cluster { + private val mPoint: Point = PROJECTION.toPoint(mClusterItem.position) + private val mPosition: LatLng = mClusterItem.position + private val singletonSet: Set = Collections.singleton(mClusterItem) + + override val point: Point + get() = mPoint + + override val position: LatLng + get() = mPosition + + override val items: Collection + get() = singletonSet + + override val size: Int + get() = 1 + + override fun hashCode(): Int = mClusterItem.hashCode() + + override fun equals(other: Any?): Boolean { + if (other !is QuadItem<*>) { + return false + } + + return other.mClusterItem == mClusterItem + } + } + + companion object { + private const val DEFAULT_MAX_DISTANCE_AT_ZOOM = 100 // essentially 100 dp. + + private val PROJECTION = SphericalMercatorProjection(1.0) + } +} diff --git a/clustering/src/main/java/com/google/maps/android/clustering/algo/NonHierarchicalViewBasedAlgorithm.kt b/clustering/src/main/java/com/google/maps/android/clustering/algo/NonHierarchicalViewBasedAlgorithm.kt new file mode 100644 index 000000000..bdd28d678 --- /dev/null +++ b/clustering/src/main/java/com/google/maps/android/clustering/algo/NonHierarchicalViewBasedAlgorithm.kt @@ -0,0 +1,102 @@ +/* + * 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.CameraPosition +import com.google.android.gms.maps.model.LatLng +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 + +/** + * Algorithm that can be used for managing large numbers of items (>1000 markers). This algorithm works the same way as [NonHierarchicalDistanceBasedAlgorithm] + * but works, only in visible area. It requires [ ][.shouldReclusterOnMapMovement] to be true in order to re-render clustering + * when camera movement changes the visible area. + * + * @param The [ClusterItem] type + */ +class NonHierarchicalViewBasedAlgorithm +/** + * @param screenWidth map width in dp + * @param screenHeight map height in dp + */ + (private var mViewWidth: Int, private var mViewHeight: Int) : NonHierarchicalDistanceBasedAlgorithm(), ScreenBasedAlgorithm { + + private var mMapCenter: LatLng? = null + + override fun onCameraChange(position: CameraPosition) { + mMapCenter = position.target + } + + override fun getClusteringItems(quadTree: PointQuadTree>, zoom: Float): Collection> { + var visibleBounds = getVisibleBounds(zoom) + val items = ArrayList>() + + // Handle wrapping around international date line + if (visibleBounds.minX < 0) { + val wrappedBounds = Bounds(visibleBounds.minX + 1, 1.0, visibleBounds.minY, visibleBounds.maxY) + items.addAll(quadTree.search(wrappedBounds)) + visibleBounds = Bounds(0.0, visibleBounds.maxX, visibleBounds.minY, visibleBounds.maxY) + } + if (visibleBounds.maxX > 1) { + val wrappedBounds = Bounds(0.0, visibleBounds.maxX - 1, visibleBounds.minY, visibleBounds.maxY) + items.addAll(quadTree.search(wrappedBounds)) + visibleBounds = Bounds(visibleBounds.minX, 1.0, visibleBounds.minY, visibleBounds.maxY) + } + items.addAll(quadTree.search(visibleBounds)) + + return items + } + + override fun shouldReclusterOnMapMovement(): Boolean { + return true + } + + /** + * Update view width and height in case map size was changed. + * You need to recluster all the clusters, to update view state after view size changes. + * + * @param width map width in dp + * @param height map height in dp + */ + fun updateViewSize(width: Int, height: Int) { + mViewWidth = width + mViewHeight = height + } + + private fun getVisibleBounds(zoom: Float): Bounds { + if (mMapCenter == null) { + return Bounds(0.0, 0.0, 0.0, 0.0) + } + + val p = PROJECTION.toPoint(mMapCenter!!) + + val halfWidthSpan = mViewWidth.toDouble() / 2.0.pow(zoom.toDouble()) / 256.0 / 2.0 + val halfHeightSpan = mViewHeight.toDouble() / 2.0.pow(zoom.toDouble()) / 256.0 / 2.0 + + return Bounds( + p.x - halfWidthSpan, p.x + halfWidthSpan, + p.y - halfHeightSpan, p.y + halfHeightSpan + ) + } + + companion object { + private val PROJECTION = SphericalMercatorProjection(1.0) + } +} diff --git a/clustering/src/main/java/com/google/maps/android/clustering/algo/PreCachingAlgorithmDecorator.kt b/clustering/src/main/java/com/google/maps/android/clustering/algo/PreCachingAlgorithmDecorator.kt new file mode 100644 index 000000000..8e7d3045f --- /dev/null +++ b/clustering/src/main/java/com/google/maps/android/clustering/algo/PreCachingAlgorithmDecorator.kt @@ -0,0 +1,143 @@ +/* + * 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 androidx.collection.LruCache +import com.google.maps.android.clustering.Cluster +import com.google.maps.android.clustering.ClusterItem +import java.util.concurrent.Executor +import java.util.concurrent.Executors +import java.util.concurrent.locks.ReadWriteLock +import java.util.concurrent.locks.ReentrantReadWriteLock + +/** + * Optimistically fetch clusters for adjacent zoom levels, caching them as necessary. + */ +class PreCachingAlgorithmDecorator( + private val algorithm: Algorithm, +) : AbstractAlgorithm() { + // TODO: evaluate maxSize parameter for LruCache. + private val mCache = LruCache>>(5) + private val mCacheLock: ReadWriteLock = ReentrantReadWriteLock() + private val mExecutor: Executor = Executors.newCachedThreadPool() + + override fun addItem(item: T): Boolean { + val result = algorithm.addItem(item) + if (result) { + clearCache() + } + return result + } + + override fun addItems(items: Collection): Boolean { + val result = algorithm.addItems(items) + if (result) { + clearCache() + } + return result + } + + override fun clearItems() { + algorithm.clearItems() + clearCache() + } + + override fun removeItem(item: T): Boolean { + val result = algorithm.removeItem(item) + if (result) { + clearCache() + } + return result + } + + override fun removeItems(items: Collection): Boolean { + val result = algorithm.removeItems(items) + if (result) { + clearCache() + } + return result + } + + override fun updateItem(item: T): Boolean { + val result = algorithm.updateItem(item) + if (result) { + clearCache() + } + return result + } + + private fun clearCache() { + mCache.evictAll() + } + + override fun getClusters(zoom: Float): Set> { + val discreteZoom = zoom.toInt() + val results = getClustersInternal(discreteZoom) + // TODO: Check if requests are already in-flight. + if (mCache.get(discreteZoom + 1) == null) { + mExecutor.execute(PrecacheRunnable(discreteZoom + 1)) + } + if (mCache.get(discreteZoom - 1) == null) { + mExecutor.execute(PrecacheRunnable(discreteZoom - 1)) + } + return results + } + + override val items: Collection + get() = algorithm.items + + override var maxDistanceBetweenClusteredItems: Int + get() = algorithm.maxDistanceBetweenClusteredItems + set(maxDistance) { + algorithm.maxDistanceBetweenClusteredItems = maxDistance + clearCache() + } + + private fun getClustersInternal(discreteZoom: Int): Set> { + var results: Set>? + mCacheLock.readLock().lock() + results = mCache.get(discreteZoom) + mCacheLock.readLock().unlock() + + if (results == null) { + mCacheLock.writeLock().lock() + try { + results = mCache.get(discreteZoom) + if (results == null) { + results = algorithm.getClusters(discreteZoom.toFloat()) + mCache.put(discreteZoom, results) + } + } finally { + mCacheLock.writeLock().unlock() + } + } + return results!! + } + + private inner class PrecacheRunnable( + private val zoom: Int, + ) : Runnable { + override fun run() { + try { + // Wait between 500 - 1000 ms. + Thread.sleep((Math.random() * 500 + 500).toLong()) + } catch (e: InterruptedException) { + // ignore. keep going. + } + getClustersInternal(zoom) + } + } +} diff --git a/library/src/main/java/com/google/maps/android/clustering/algo/ScreenBasedAlgorithm.java b/clustering/src/main/java/com/google/maps/android/clustering/algo/ScreenBasedAlgorithm.kt similarity index 66% rename from library/src/main/java/com/google/maps/android/clustering/algo/ScreenBasedAlgorithm.java rename to clustering/src/main/java/com/google/maps/android/clustering/algo/ScreenBasedAlgorithm.kt index fcf76ff72..f6aab988f 100644 --- a/library/src/main/java/com/google/maps/android/clustering/algo/ScreenBasedAlgorithm.java +++ b/clustering/src/main/java/com/google/maps/android/clustering/algo/ScreenBasedAlgorithm.kt @@ -1,11 +1,11 @@ /* - * Copyright 2024 Google Inc. + * 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 + * 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, @@ -13,11 +13,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +package com.google.maps.android.clustering.algo -package com.google.maps.android.clustering.algo; - -import com.google.android.gms.maps.model.CameraPosition; -import com.google.maps.android.clustering.ClusterItem; +import com.google.android.gms.maps.model.CameraPosition +import com.google.maps.android.clustering.ClusterItem /** * This abstract interface provides two methods: one to determine if the map should recluster when @@ -26,10 +25,8 @@ * * @param The {@link ClusterItem} type */ +interface ScreenBasedAlgorithm : Algorithm { + fun shouldReclusterOnMapMovement(): Boolean -public interface ScreenBasedAlgorithm extends Algorithm { - - boolean shouldReclusterOnMapMovement(); - - void onCameraChange(CameraPosition position); + fun onCameraChange(position: CameraPosition) } diff --git a/clustering/src/main/java/com/google/maps/android/clustering/algo/ScreenBasedAlgorithmAdapter.kt b/clustering/src/main/java/com/google/maps/android/clustering/algo/ScreenBasedAlgorithmAdapter.kt new file mode 100644 index 000000000..52f08c02d --- /dev/null +++ b/clustering/src/main/java/com/google/maps/android/clustering/algo/ScreenBasedAlgorithmAdapter.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.CameraPosition +import com.google.maps.android.clustering.Cluster +import com.google.maps.android.clustering.ClusterItem + +class ScreenBasedAlgorithmAdapter( + private val algorithm: Algorithm, +) : AbstractAlgorithm(), + ScreenBasedAlgorithm { + override fun shouldReclusterOnMapMovement(): Boolean = false + + override fun addItem(item: T): Boolean = algorithm.addItem(item) + + override fun addItems(items: Collection): Boolean = algorithm.addItems(items) + + override fun clearItems() { + algorithm.clearItems() + } + + override fun removeItem(item: T): Boolean = algorithm.removeItem(item) + + override fun removeItems(items: Collection): Boolean = algorithm.removeItems(items) + + override fun updateItem(item: T): Boolean = algorithm.updateItem(item) + + override fun getClusters(zoom: Float): Set> = algorithm.getClusters(zoom) + + override val items: Collection + get() = algorithm.items + + override var maxDistanceBetweenClusteredItems: Int + get() = algorithm.maxDistanceBetweenClusteredItems + set(maxDistance) { + algorithm.maxDistanceBetweenClusteredItems = maxDistance + } + + override fun onCameraChange(position: CameraPosition) { + // stub + } +} diff --git a/clustering/src/main/java/com/google/maps/android/clustering/algo/StaticCluster.kt b/clustering/src/main/java/com/google/maps/android/clustering/algo/StaticCluster.kt new file mode 100644 index 000000000..e292b6e33 --- /dev/null +++ b/clustering/src/main/java/com/google/maps/android/clustering/algo/StaticCluster.kt @@ -0,0 +1,58 @@ +/* + * 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.Cluster +import com.google.maps.android.clustering.ClusterItem + +/** + * A cluster whose center is determined upon creation. + */ +class StaticCluster( + private val mCenter: LatLng, +) : Cluster { + private val mItems: MutableCollection = LinkedHashSet() + + fun add(t: T): Boolean = mItems.add(t) + + override val position: LatLng + get() = mCenter + + fun remove(t: T): Boolean = mItems.remove(t) + + override val items: Collection + get() = mItems + + override val size: Int + get() = mItems.size + + override fun toString(): String = + "StaticCluster{" + + "mCenter=" + mCenter + + ", mItems.size=" + mItems.size + + '}' + + override fun hashCode(): Int = mCenter.hashCode() + mItems.hashCode() + + override fun equals(other: Any?): Boolean { + if (other !is StaticCluster<*>) { + return false + } + + return other.mCenter == mCenter && other.mItems == mItems + } +} diff --git a/clustering/src/main/java/com/google/maps/android/clustering/view/ClusterRenderer.kt b/clustering/src/main/java/com/google/maps/android/clustering/view/ClusterRenderer.kt new file mode 100644 index 000000000..a081b8080 --- /dev/null +++ b/clustering/src/main/java/com/google/maps/android/clustering/view/ClusterRenderer.kt @@ -0,0 +1,82 @@ +/* + * 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.view + +import androidx.annotation.StyleRes +import com.google.maps.android.clustering.Cluster +import com.google.maps.android.clustering.ClusterItem +import com.google.maps.android.clustering.ClusterManager +import com.google.maps.android.clustering.ClusterManager.OnClusterClickListener +import com.google.maps.android.clustering.ClusterManager.OnClusterInfoWindowClickListener +import com.google.maps.android.clustering.ClusterManager.OnClusterInfoWindowLongClickListener +import com.google.maps.android.clustering.ClusterManager.OnClusterItemClickListener +import com.google.maps.android.clustering.ClusterManager.OnClusterItemInfoWindowClickListener +import com.google.maps.android.clustering.ClusterManager.OnClusterItemInfoWindowLongClickListener + +/** + * Renders clusters. + */ +interface ClusterRenderer { + /** + * Called when the view needs to be updated because new clusters need to be displayed. + * + * @param clusters the clusters to be displayed. + */ + fun onClustersChanged(clusters: Set>) + + fun setOnClusterClickListener(listener: OnClusterClickListener?) + + fun setOnClusterInfoWindowClickListener(listener: OnClusterInfoWindowClickListener?) + + fun setOnClusterInfoWindowLongClickListener(listener: OnClusterInfoWindowLongClickListener?) + + fun setOnClusterItemClickListener(listener: OnClusterItemClickListener?) + + fun setOnClusterItemInfoWindowClickListener(listener: OnClusterItemInfoWindowClickListener?) + + fun setOnClusterItemInfoWindowLongClickListener(listener: OnClusterItemInfoWindowLongClickListener?) + + /** + * Called to set animation on or off + */ + fun setAnimation(animate: Boolean) + + /** + * Sets the length of the animation in milliseconds. + */ + fun setAnimationDuration(animationDurationMs: Long) + + /** + * Called when the view is added. + */ + fun onAdd() + + /** + * Called when the view is removed. + */ + fun onRemove() + + /** + * Called to determine the color of a Cluster. + */ + fun getColor(clusterSize: Int): Int + + /** + * Called to determine the text appearance of a cluster. + */ + @StyleRes + fun getClusterTextAppearance(clusterSize: Int): Int +} diff --git a/clustering/src/main/java/com/google/maps/android/clustering/view/ClusterRendererMultipleItems.kt b/clustering/src/main/java/com/google/maps/android/clustering/view/ClusterRendererMultipleItems.kt new file mode 100644 index 000000000..74bd91190 --- /dev/null +++ b/clustering/src/main/java/com/google/maps/android/clustering/view/ClusterRendererMultipleItems.kt @@ -0,0 +1,1275 @@ +/* + * 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.view + +import android.animation.Animator +import android.animation.AnimatorListenerAdapter +import android.animation.TimeInterpolator +import android.animation.ValueAnimator +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.Color +import android.graphics.drawable.Drawable +import android.graphics.drawable.LayerDrawable +import android.graphics.drawable.ShapeDrawable +import android.graphics.drawable.shapes.OvalShape +import android.os.Handler +import android.os.Looper +import android.os.Message +import android.os.MessageQueue +import android.util.SparseArray +import android.view.ViewGroup +import android.view.animation.AccelerateDecelerateInterpolator +import android.view.animation.AccelerateInterpolator +import android.view.animation.BounceInterpolator +import android.view.animation.DecelerateInterpolator +import android.view.animation.LinearInterpolator +import androidx.annotation.StyleRes +import androidx.interpolator.view.animation.FastOutSlowInInterpolator +import com.google.android.gms.maps.GoogleMap +import com.google.android.gms.maps.Projection +import com.google.android.gms.maps.model.BitmapDescriptor +import com.google.android.gms.maps.model.BitmapDescriptorFactory +import com.google.android.gms.maps.model.LatLng +import com.google.android.gms.maps.model.LatLngBounds +import com.google.android.gms.maps.model.Marker +import com.google.android.gms.maps.model.MarkerOptions +import com.google.maps.android.clustering.Cluster +import com.google.maps.android.clustering.ClusterItem +import com.google.maps.android.clustering.ClusterManager +import com.google.maps.android.collections.MarkerManager +import com.google.maps.android.data.RendererLogger +import com.google.maps.android.geometry.Point +import com.google.maps.android.projection.SphericalMercatorProjection +import com.google.maps.android.ui.IconGenerator +import com.google.maps.android.ui.R +import com.google.maps.android.ui.SquareTextView +import java.util.Collections +import java.util.LinkedList +import java.util.Queue +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.Executor +import java.util.concurrent.Executors +import java.util.concurrent.locks.Condition +import java.util.concurrent.locks.Lock +import java.util.concurrent.locks.ReentrantLock +import kotlin.math.abs +import kotlin.math.min +import kotlin.math.pow +import kotlin.math.sign + +/** + * The default view for a ClusterManager. Markers are animated in and out of clusters. + */ +open class ClusterRendererMultipleItems @JvmOverloads constructor( + context: Context, + private val mMap: GoogleMap, + private val mClusterManager: ClusterManager, + private val mExecutor: Executor = Executors.newSingleThreadExecutor(), +) : ClusterRenderer { + private val mIconGenerator: IconGenerator = IconGenerator(context) + private val mDensity: Float = context.resources.displayMetrics.density + private var mAnimate: Boolean = true + private var mAnimationDurationMs: Long = 300 + private val ongoingAnimations: Queue = LinkedList() + + private var mColoredCircleBackground: ShapeDrawable? = null + + // Default interpolator + private var animationInterp: TimeInterpolator = DecelerateInterpolator() + + enum class AnimationType { + LINEAR, + EASE_IN, + EASE_OUT, + EASE_IN_OUT, + FAST_OUT_SLOW_IN, + BOUNCE, + ACCELERATE, + DECELERATE, + } + + fun setAnimationType(type: AnimationType) { + animationInterp = + when (type) { + AnimationType.LINEAR -> LinearInterpolator() + AnimationType.EASE_IN, AnimationType.ACCELERATE -> AccelerateInterpolator() + AnimationType.EASE_OUT, AnimationType.DECELERATE -> DecelerateInterpolator() + AnimationType.EASE_IN_OUT -> AccelerateDecelerateInterpolator() + AnimationType.FAST_OUT_SLOW_IN -> FastOutSlowInInterpolator() + AnimationType.BOUNCE -> BounceInterpolator() + } + } + + /** + * Sets the interpolator for the animation. + * + * @param interpolator the interpolator to use for the animation. + */ + fun setAnimationInterpolator(interpolator: TimeInterpolator) { + animationInterp = interpolator + } + + /** + * Markers that are currently on the map. + */ + private var mMarkers: MutableSet> = Collections.newSetFromMap(ConcurrentHashMap()) + + /** + * Icons for each bucket. + */ + private val mIcons = SparseArray() + + /** + * Markers for single ClusterItems. + */ + private val mMarkerCache = MarkerCache() + + /** + * If cluster size is less than this size, display individual markers. + */ + var minClusterSize: Int = 2 + + /** + * The currently displayed set of clusters. + */ + private var mClusters: Set>? = null + + /** + * Markers for Clusters. + */ + private val mClusterMarkerCache = MarkerCache>() + + /** + * The target zoom level for the current set of clusters. + */ + private var mZoom: Float = 0f + + private val mViewModifier = ViewModifier(Looper.getMainLooper()) + + private var mClickListener: ClusterManager.OnClusterClickListener? = null + private var mInfoWindowClickListener: ClusterManager.OnClusterInfoWindowClickListener? = null + private var mInfoWindowLongClickListener: ClusterManager.OnClusterInfoWindowLongClickListener? = null + private var mItemClickListener: ClusterManager.OnClusterItemClickListener? = null + private var mItemInfoWindowClickListener: ClusterManager.OnClusterItemInfoWindowClickListener? = null + private var mItemInfoWindowLongClickListener: ClusterManager.OnClusterItemInfoWindowLongClickListener? = null + + init { + mIconGenerator.setContentView(makeSquareTextView(context)) + mIconGenerator.setTextAppearance(R.style.amu_ClusterIcon_TextAppearance) + mIconGenerator.setBackground(makeClusterBackground()) + } + + override fun onAdd() { + RendererLogger.d("ClusterRenderer", "Setting up MarkerCollection listeners") + + mClusterManager.markerCollection.setOnMarkerClickListener { marker -> + RendererLogger.d("ClusterRenderer", "Marker clicked: $marker") + mMarkerCache[marker]?.let { mItemClickListener?.onClusterItemClick(it) } ?: false + } + + mClusterManager.markerCollection.setOnInfoWindowClickListener { marker -> + RendererLogger.d("ClusterRenderer", "Info window clicked for marker: $marker") + mMarkerCache[marker]?.let { mItemInfoWindowClickListener?.onClusterItemInfoWindowClick(it) } + } + + mClusterManager.markerCollection.setOnInfoWindowLongClickListener { marker -> + RendererLogger.d("ClusterRenderer", "Info window long-clicked for marker: $marker") + mMarkerCache[marker]?.let { mItemInfoWindowLongClickListener?.onClusterItemInfoWindowLongClick(it) } + } + + RendererLogger.d("ClusterRenderer", "Setting up ClusterMarkerCollection listeners") + + mClusterManager.clusterMarkerCollection.setOnMarkerClickListener { marker -> + mClusterMarkerCache[marker]?.let { mClickListener?.onClusterClick(it) } ?: false + } + + mClusterManager.clusterMarkerCollection.setOnInfoWindowClickListener { marker -> + RendererLogger.d("ClusterRenderer", "Info window clicked for cluster marker: $marker") + mClusterMarkerCache[marker]?.let { mInfoWindowClickListener?.onClusterInfoWindowClick(it) } + } + + mClusterManager.clusterMarkerCollection.setOnInfoWindowLongClickListener { marker -> + RendererLogger.d("ClusterRenderer", "Info window long-clicked for cluster marker: $marker") + mClusterMarkerCache[marker]?.let { mInfoWindowLongClickListener?.onClusterInfoWindowLongClick(it) } + } + } + + override fun onRemove() { + mClusterManager.markerCollection.setOnMarkerClickListener(null) + mClusterManager.markerCollection.setOnInfoWindowClickListener(null) + mClusterManager.markerCollection.setOnInfoWindowLongClickListener(null) + mClusterManager.clusterMarkerCollection.setOnMarkerClickListener(null) + mClusterManager.clusterMarkerCollection.setOnInfoWindowClickListener(null) + mClusterManager.clusterMarkerCollection.setOnInfoWindowLongClickListener(null) + } + + private fun makeClusterBackground(): LayerDrawable { + mColoredCircleBackground = ShapeDrawable(OvalShape()) + val outline = ShapeDrawable(OvalShape()) + outline.paint.color = -0x7f000001 // Transparent white. + val background = LayerDrawable(arrayOf(outline, mColoredCircleBackground!!)) + val strokeWidth = (mDensity * 3).toInt() + background.setLayerInset(1, strokeWidth, strokeWidth, strokeWidth, strokeWidth) + return background + } + + private fun makeSquareTextView(context: Context): SquareTextView { + val squareTextView = SquareTextView(context) + val layoutParams = + ViewGroup.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ) + squareTextView.layoutParams = layoutParams + squareTextView.id = R.id.amu_text + val twelveDpi = (12 * mDensity).toInt() + squareTextView.setPadding(twelveDpi, twelveDpi, twelveDpi, twelveDpi) + return squareTextView + } + + override fun getColor(clusterSize: Int): Int { + val hueRange = 220f + val sizeRange = 300f + val size = min(clusterSize.toFloat(), sizeRange) + val hue = (sizeRange - size) * (sizeRange - size) / (sizeRange * sizeRange) * hueRange + return Color.HSVToColor(floatArrayOf(hue, 1f, .6f)) + } + + @StyleRes + override fun getClusterTextAppearance(clusterSize: Int): Int { + return R.style.amu_ClusterIcon_TextAppearance // Default value + } + + /** + * Enables or disables logging for the cluster renderer. + * + * + * When enabled, the renderer will log internal operations such as cluster rendering, + * marker updates, and other debug information. This is useful for development and debugging, + * but should typically be disabled in production builds. + * + * @param enabled `true` to enable logging; `false` to disable it. + */ + fun setLoggingEnabled(enabled: Boolean) { + RendererLogger.setEnabled(enabled) + } + + protected fun getClusterText(bucket: Int): String = + if (bucket < BUCKETS[0]) { + bucket.toString() + } else { + "$bucket+" + } + + /** + * Gets the "bucket" for a particular cluster. By default, uses the number of points within the + * cluster, bucketed to some set points. + */ + protected fun getBucket(cluster: Cluster): Int { + val size = cluster.size + if (size <= BUCKETS[0]) { + return size + } + for (i in 0 until BUCKETS.size - 1) { + if (size < BUCKETS[i + 1]) { + return BUCKETS[i] + } + } + return BUCKETS[BUCKETS.size - 1] + } + + /** + * ViewModifier ensures only one re-rendering of the view occurs at a time, and schedules + * re-rendering, which is performed by the RenderTask. + */ + @SuppressLint("HandlerLeak") + private inner class ViewModifier( + looper: Looper, + ) : Handler(looper) { + private var mViewModificationInProgress = false + private var mNextClusters: RenderTask? = null + + override fun handleMessage(msg: Message) { + if (msg.what == TASK_FINISHED) { + mViewModificationInProgress = false + if (mNextClusters != null) { + // Run the task that was queued up. + sendEmptyMessage(RUN_TASK) + } + return + } + removeMessages(RUN_TASK) + + if (mViewModificationInProgress) { + // Busy - wait for the callback. + return + } + + if (mNextClusters == null) { + // Nothing to do. + return + } + val projection = mMap.projection + + var renderTask: RenderTask? + synchronized(this) { + renderTask = mNextClusters + mNextClusters = null + mViewModificationInProgress = true + } + + renderTask!!.setCallback { sendEmptyMessage(TASK_FINISHED) } + renderTask!!.setProjection(projection) + renderTask!!.setMapZoom(mMap.cameraPosition.zoom) + mExecutor.execute(renderTask) + } + + fun queue(clusters: Set>) { + synchronized(this) { + // Overwrite any pending cluster tasks - we don't care about intermediate states. + mNextClusters = RenderTask(clusters) + } + sendEmptyMessage(RUN_TASK) + } + } + + /** + * Determine whether the cluster should be rendered as individual markers or a cluster. + * + * @param cluster cluster to examine for rendering + * @return true if the provided cluster should be rendered as a single marker on the map, false + * if the items within this cluster should be rendered as individual markers instead. + */ + protected open fun shouldRenderAsCluster(cluster: Cluster): Boolean = cluster.size >= minClusterSize + + /** + * Transforms the current view (represented by DefaultClusterRenderer.mClusters and DefaultClusterRenderer.mZoom) to a + * new zoom level and set of clusters. + * + * + * This must be run off the UI thread. Work is coordinated in the RenderTask, then queued up to + * be executed by a MarkerModifier. + * + * + * There are three stages for the render: + * + * + * 1. Markers are added to the map + * + * + * 2. Markers are animated to their final position + * + * + * 3. Any old markers are removed from the map + * + * + * When zooming in, markers are animated out from the nearest existing cluster. When zooming + * out, existing clusters are animated to the nearest new cluster. + */ + private inner class RenderTask( + val clusters: Set>, + ) : Runnable { + private var mCallback: Runnable? = null + private var mProjection: Projection? = null + private var mSphericalMercatorProjection: SphericalMercatorProjection? = null + private var mMapZoom: Float = 0f + + fun setCallback(callback: Runnable?) { + mCallback = callback + } + + fun setProjection(projection: Projection?) { + mProjection = projection + } + + fun setMapZoom(zoom: Float) { + mMapZoom = zoom + mSphericalMercatorProjection = + SphericalMercatorProjection( + 256 * 2.0.pow(min(zoom.toDouble(), mZoom.toDouble())), + ) + } + + @SuppressLint("NewApi") + override fun run() { + val markerModifier = MarkerModifier() + val zoom = mMapZoom + val markersToRemove = mMarkers + var visibleBounds: LatLngBounds + + try { + visibleBounds = mProjection!!.visibleRegion.latLngBounds + RendererLogger.d("ClusterRenderer", "Visible bounds calculated: $visibleBounds") + } catch (e: Exception) { + RendererLogger.e("ClusterRenderer", "Error getting visible bounds, defaulting to (0,0)") + visibleBounds = LatLngBounds.builder().include(LatLng(0.0, 0.0)).build() + } + + // Find all of the existing clusters that are on-screen. These are candidates for markers to animate from. + var existingClustersOnScreen: MutableList? = null + if (this@ClusterRendererMultipleItems.mClusters != null && mAnimate) { + existingClustersOnScreen = ArrayList() + for (c in this@ClusterRendererMultipleItems.mClusters!!) { + if (shouldRenderAsCluster(c) && visibleBounds.contains(c.position)) { + val point = mSphericalMercatorProjection!!.toPoint(c.position) + existingClustersOnScreen.add(point) + } + } + RendererLogger.d("ClusterRenderer", "Existing clusters on screen found: " + existingClustersOnScreen.size) + } + + // Create the new markers and animate them to their new positions. + val newMarkers: MutableSet> = Collections.newSetFromMap(ConcurrentHashMap()) + for (c in clusters) { + val onScreen = visibleBounds.contains(c.position) + if (mAnimate) { + val point = mSphericalMercatorProjection!!.toPoint(c.position) + val closest = findClosestCluster(existingClustersOnScreen, point) + if (closest != null) { + val animateFrom = mSphericalMercatorProjection!!.toLatLng(closest) + markerModifier.add(true, CreateMarkerTask(c, newMarkers, animateFrom)) + RendererLogger.d("ClusterRenderer", "Animating cluster from closest cluster: " + c.position) + } else { + markerModifier.add(true, CreateMarkerTask(c, newMarkers, null)) + RendererLogger.d("ClusterRenderer", "Animating cluster without closest point: " + c.position) + } + } else { + markerModifier.add(onScreen, CreateMarkerTask(c, newMarkers, null)) + RendererLogger.d("ClusterRenderer", "Adding cluster without animation: " + c.position) + } + } + + // Wait for all markers to be added. + markerModifier.waitUntilFree() + RendererLogger.d("ClusterRenderer", "All new markers added, count: " + newMarkers.size) + + // Don't remove any markers that were just added. This is basically anything that had a hit in the MarkerCache. + markersToRemove.removeAll(newMarkers) + RendererLogger.d("ClusterRenderer", "Markers to remove after filtering new markers: " + markersToRemove.size) + + // Find all of the new clusters that were added on-screen. These are candidates for markers to animate from. + var newClustersOnScreen: MutableList? = null + if (mAnimate) { + newClustersOnScreen = ArrayList() + for (c in clusters) { + if (shouldRenderAsCluster(c) && visibleBounds.contains(c.position)) { + val p = mSphericalMercatorProjection!!.toPoint(c.position) + newClustersOnScreen.add(p) + } + } + RendererLogger.d("ClusterRenderer", "New clusters on screen found: " + newClustersOnScreen.size) + } + + for (marker in markersToRemove) { + val onScreen = marker.position?.let { visibleBounds.contains(it) } ?: false + + if (onScreen && mAnimate) { + val point = mSphericalMercatorProjection!!.toPoint(marker.position!!) + val closest = findClosestCluster(newClustersOnScreen, point) + if (closest != null) { + val animateTo = mSphericalMercatorProjection!!.toLatLng(closest) + markerModifier.animateThenRemove(marker, marker.position!!, animateTo!!) + RendererLogger.d("ClusterRenderer", "Animating then removing marker at position: " + marker.position) + } else if (mClusterMarkerCache.mCache.keys + .iterator() + .hasNext() && + mClusterMarkerCache.mCache.keys + .iterator() + .next() + .items + .contains(marker.clusterItem) + ) { + var foundItem: T? = null + for (cluster in mClusterMarkerCache.mCache.keys) { + for (clusterItem in cluster.items) { + if (clusterItem == marker.clusterItem) { + foundItem = clusterItem + break + } + } + } + // Remove it because it will join a cluster + markerModifier.animateThenRemove(marker, marker.position!!, foundItem!!.position) + RendererLogger.d( + "ClusterRenderer", + "Animating then removing marker joining cluster at position: " + marker.position, + ) + } else { + markerModifier.remove(true, marker.marker) + RendererLogger.d("ClusterRenderer", "Removing marker without animation at position: " + marker.position) + } + } else { + markerModifier.remove(onScreen, marker.marker) + RendererLogger.d("ClusterRenderer", "Removing marker (onScreen=" + onScreen + ") at position: " + marker.position) + } + } + + // Wait until all marker removal operations are completed. + markerModifier.waitUntilFree() + RendererLogger.d("ClusterRenderer", "All marker removal operations completed.") + + mMarkers = newMarkers + this@ClusterRendererMultipleItems.mClusters = clusters + mZoom = zoom + + // Run the callback once everything is done. + mCallback?.run() + RendererLogger.d("ClusterRenderer", "Cluster update callback executed.") + } + } + + override fun onClustersChanged(clusters: Set>) { + mViewModifier.queue(clusters) + } + + override fun setOnClusterClickListener(listener: ClusterManager.OnClusterClickListener?) { + mClickListener = listener + } + + override fun setOnClusterInfoWindowClickListener(listener: ClusterManager.OnClusterInfoWindowClickListener?) { + mInfoWindowClickListener = listener + } + + override fun setOnClusterInfoWindowLongClickListener(listener: ClusterManager.OnClusterInfoWindowLongClickListener?) { + mInfoWindowLongClickListener = listener + } + + override fun setOnClusterItemClickListener(listener: ClusterManager.OnClusterItemClickListener?) { + mItemClickListener = listener + } + + override fun setOnClusterItemInfoWindowClickListener(listener: ClusterManager.OnClusterItemInfoWindowClickListener?) { + mItemInfoWindowClickListener = listener + } + + override fun setOnClusterItemInfoWindowLongClickListener(listener: ClusterManager.OnClusterItemInfoWindowLongClickListener?) { + mItemInfoWindowLongClickListener = listener + } + + override fun setAnimation(animate: Boolean) { + mAnimate = animate + } + + /** + * [.setAnimationDuration] The default duration is 300 milliseconds. + * + * @param animationDurationMs long: The length of the animation, in milliseconds. This value cannot be negative. + */ + override fun setAnimationDuration(animationDurationMs: Long) { + mAnimationDurationMs = animationDurationMs + } + + fun stopAnimation() { + for (animation in ongoingAnimations) { + animation.cancel() + } + } + + private fun findClosestCluster( + markers: List?, + point: Point, + ): Point? { + if (markers == null || markers.isEmpty()) return null + + val maxDistance = mClusterManager.algorithm.maxDistanceBetweenClusteredItems + var minDistSquared = (maxDistance * maxDistance).toDouble() + var closest: Point? = null + for (candidate in markers) { + val dist = distanceSquared(candidate, point) + if (dist < minDistSquared) { + closest = candidate + minDistSquared = dist + } + } + return closest + } + + /** + * Handles all markerWithPosition manipulations on the map. Work (such as adding, removing, or + * animating a markerWithPosition) is performed while trying not to block the rest of the app's + * UI. + */ + @SuppressLint("HandlerLeak") + private inner class MarkerModifier : + Handler(Looper.getMainLooper()), + MessageQueue.IdleHandler { + private val lock = ReentrantLock() + private val busyCondition = lock.newCondition() + + private val mCreateMarkerTasks: Queue = LinkedList() + private val mOnScreenCreateMarkerTasks: Queue = LinkedList() + private val mRemoveMarkerTasks: Queue = LinkedList() + private val mOnScreenRemoveMarkerTasks: Queue = LinkedList() + private val mAnimationTasks: Queue = LinkedList() + + /** + * Whether the idle listener has been added to the UI thread's MessageQueue. + */ + private var mListenerAdded: Boolean = false + + private fun withLock(runnable: Runnable) { + lock.lock() + try { + runnable.run() + } finally { + lock.unlock() + } + } + + /** + * Creates markers for a cluster some time in the future. + * + * @param priority whether this operation should have priority. + */ + fun add( + priority: Boolean, + c: CreateMarkerTask, + ) { + withLock { + sendEmptyMessage(BLANK) + if (priority) { + mOnScreenCreateMarkerTasks.add(c) + } else { + mCreateMarkerTasks.add(c) + } + } + } + + /** + * Removes a markerWithPosition some time in the future. + * + * @param priority whether this operation should have priority. + * @param m the markerWithPosition to remove. + */ + fun remove( + priority: Boolean, + m: Marker, + ) { + withLock { + sendEmptyMessage(BLANK) + if (priority) { + mOnScreenRemoveMarkerTasks.add(m) + } else { + mRemoveMarkerTasks.add(m) + } + } + } + + /** + * Animates a markerWithPosition some time in the future. + * + * @param marker the markerWithPosition to animate. + * @param from the position to animate from. + * @param to the position to animate to. + */ + fun animate( + marker: MarkerWithPosition, + from: LatLng, + to: LatLng, + ) { + withLock { + val task = AnimationTask(marker, from, to, lock) + + for (existingTask in ongoingAnimations) { + if (existingTask.marker.id == task.marker.id) { + existingTask.cancel() + break + } + } + + mAnimationTasks.add(task) + ongoingAnimations.add(task) + } + } + + /** + * Animates a markerWithPosition some time in the future, and removes it when the animation + * is complete. + * + * @param marker the markerWithPosition to animate. + * @param from the position to animate from. + * @param to the position to animate to. + */ + fun animateThenRemove( + marker: MarkerWithPosition, + from: LatLng, + to: LatLng, + ) { + withLock { + val animationTask = AnimationTask(marker, from, to, lock) + for (existingTask in ongoingAnimations) { + if (existingTask.marker.id == animationTask.marker.id) { + existingTask.cancel() + break + } + } + + ongoingAnimations.add(animationTask) + animationTask.removeOnAnimationComplete(mClusterManager.markerManager) + mAnimationTasks.add(animationTask) + } + } + + override fun handleMessage(msg: Message) { + if (!mListenerAdded) { + Looper.myQueue().addIdleHandler(this) + mListenerAdded = true + } + removeMessages(BLANK) + + lock.lock() + try { + // Perform up to 10 tasks at once. + // Consider only performing 10 remove tasks, not adds and animations. + // Removes are relatively slow and are much better when batched. + for (i in 0..9) { + performNextTask() + } + + if (!isBusy) { + mListenerAdded = false + Looper.myQueue().removeIdleHandler(this) + // Signal any other threads that are waiting. + busyCondition.signalAll() + } else { + // Sometimes the idle queue may not be called - schedule up some work regardless + // of whether the UI thread is busy or not. + // TODO: try to remove this. + sendEmptyMessageDelayed(BLANK, 10) + } + } finally { + lock.unlock() + } + } + + /** + * Perform the next task. Prioritise any on-screen work. + */ + private fun performNextTask() { + if (!mOnScreenRemoveMarkerTasks.isEmpty()) { + removeMarker(mOnScreenRemoveMarkerTasks.poll()) + } else if (!mAnimationTasks.isEmpty()) { + mAnimationTasks.poll()?.perform() + } else if (!mOnScreenCreateMarkerTasks.isEmpty()) { + mOnScreenCreateMarkerTasks.poll()?.perform(this) + } else if (!mCreateMarkerTasks.isEmpty()) { + mCreateMarkerTasks.poll()?.perform(this) + } else if (!mRemoveMarkerTasks.isEmpty()) { + removeMarker(mRemoveMarkerTasks.poll()) + } + } + + private fun removeMarker(m: Marker?) { + mMarkerCache.remove(m) + mClusterMarkerCache.remove(m) + mClusterManager.markerManager.remove(m) + } + + /** + * @return true if there is still work to be processed. + */ + val isBusy: Boolean + get() { + lock.lock() + try { + return !( + mCreateMarkerTasks.isEmpty() && mOnScreenCreateMarkerTasks.isEmpty() && + mOnScreenRemoveMarkerTasks.isEmpty() && mRemoveMarkerTasks.isEmpty() && + mAnimationTasks.isEmpty() + ) + } finally { + lock.unlock() + } + } + + /** + * Blocks the calling thread until all work has been processed. + */ + fun waitUntilFree() { + while (isBusy) { + // Sometimes the idle queue may not be called - schedule up some work regardless + // of whether the UI thread is busy or not. + // TODO: try to remove this. + sendEmptyMessage(BLANK) + lock.lock() + try { + if (isBusy) { + busyCondition.await() + } + } catch (e: InterruptedException) { + throw RuntimeException(e) + } finally { + lock.unlock() + } + } + } + + override fun queueIdle(): Boolean { + // When the UI is not busy, schedule some work. + sendEmptyMessage(BLANK) + return true + } + } + + /** + * A cache of markers representing individual ClusterItems. + */ + private class MarkerCache { + val mCache: MutableMap = HashMap() + private val mCacheReverse: MutableMap = HashMap() + + operator fun get(item: T): Marker? = mCache[item] + + operator fun get(m: Marker): T? = mCacheReverse[m] + + fun put( + item: T, + m: Marker, + ) { + mCache[item] = m + mCacheReverse[m] = item + } + + fun remove(m: Marker?) { + val item = mCacheReverse[m] + mCacheReverse.remove(m) + mCache.remove(item) + } + } + + /** + * Called before the marker for a ClusterItem is added to the map. The default implementation + * sets the marker and snippet text based on the respective item text if they are both + * available, otherwise it will set the title if available, and if not it will set the marker + * title to the item snippet text if that is available. + * + * + * The first time [ClusterManager.cluster] is invoked on a set of items + * [.onBeforeClusterItemRendered] will be called and + * [.onClusterItemUpdated] will not be called. + * If an item is removed and re-added (or updated) and [ClusterManager.cluster] is + * invoked again, then [.onClusterItemUpdated] will be called and + * [.onBeforeClusterItemRendered] will not be called. + * + * @param item item to be rendered + * @param markerOptions the markerOptions representing the provided item + */ + protected open fun onBeforeClusterItemRendered( + item: T, + markerOptions: MarkerOptions, + ) { + if (item.title != null && item.snippet != null) { + markerOptions.title(item.title) + markerOptions.snippet(item.snippet) + } else if (item.title != null) { + markerOptions.title(item.title) + } else if (item.snippet != null) { + markerOptions.title(item.snippet) + } + } + + /** + * Called when a cached marker for a ClusterItem already exists on the map so the marker may + * be updated to the latest item values. Default implementation updates the title and snippet + * of the marker if they have changed and refreshes the info window of the marker if it is open. + * Note that the contents of the item may not have changed since the cached marker was created - + * implementations of this method are responsible for checking if something changed (if that + * matters to the implementation). + * + * + * The first time [ClusterManager.cluster] is invoked on a set of items + * [.onBeforeClusterItemRendered] will be called and + * [.onClusterItemUpdated] will not be called. + * If an item is removed and re-added (or updated) and [ClusterManager.cluster] is + * invoked again, then [.onClusterItemUpdated] will be called and + * [.onBeforeClusterItemRendered] will not be called. + * + * @param item item being updated + * @param marker cached marker that contains a potentially previous state of the item. + */ + protected open fun onClusterItemUpdated( + item: T, + marker: Marker, + ) { + var changed = false + // Update marker text if the item text changed - same logic as adding marker in CreateMarkerTask.perform() + if (item.title != null && item.snippet != null) { + if (item.title != marker.title) { + marker.title = item.title + changed = true + } + if (item.snippet != marker.snippet) { + marker.snippet = item.snippet + changed = true + } + } else if (item.snippet != null && item.snippet != marker.title) { + marker.title = item.snippet + changed = true + } else if (item.title != null && item.title != marker.title) { + marker.title = item.title + changed = true + } + // Update marker position if the item changed position + if (marker.position != item.position) { + marker.position = item.position + if (item.zIndex != null) { + marker.zIndex = item.zIndex!! + } + changed = true + } + if (changed && marker.isInfoWindowShown) { + // Force a refresh of marker info window contents + marker.showInfoWindow() + } + } + + /** + * Called before the marker for a Cluster is added to the map. + * The default implementation draws a circle with a rough count of the number of items. + * + * + * The first time [ClusterManager.cluster] is invoked on a set of items + * [.onBeforeClusterRendered] will be called and + * [.onClusterUpdated] will not be called. If an item is removed and + * re-added (or updated) and [ClusterManager.cluster] is invoked + * again, then [.onClusterUpdated] will be called and + * [.onBeforeClusterRendered] will not be called. + * + * @param cluster cluster to be rendered + * @param markerOptions markerOptions representing the provided cluster + */ + protected open fun onBeforeClusterRendered( + cluster: Cluster, + markerOptions: MarkerOptions, + ) { + // TODO: consider adding anchor(.5, .5) (Individual markers will overlap more often) + markerOptions.icon(getDescriptorForCluster(cluster)) + } + + /** + * Gets a BitmapDescriptor for the given cluster that contains a rough count of the number of + * items. Used to set the cluster marker icon in the default implementations of + * [.onBeforeClusterRendered] and + * [.onClusterUpdated]. + * + * @param cluster cluster to get BitmapDescriptor for + * @return a BitmapDescriptor for the marker icon for the given cluster that contains a rough + * count of the number of items. + */ + protected fun getDescriptorForCluster(cluster: Cluster): BitmapDescriptor { + val bucket = getBucket(cluster) + var descriptor = mIcons[bucket] + if (descriptor == null) { + mColoredCircleBackground!!.paint.color = getColor(bucket) + mIconGenerator.setTextAppearance(getClusterTextAppearance(bucket)) + descriptor = BitmapDescriptorFactory.fromBitmap(mIconGenerator.makeIcon(getClusterText(bucket))) + mIcons.put(bucket, descriptor) + } + return descriptor + } + + /** + * Called after the marker for a Cluster has been added to the map. + * + * @param cluster the cluster that was just added to the map + * @param marker the marker representing the cluster that was just added to the map + */ + protected fun onClusterRendered( + cluster: Cluster, + marker: Marker, + ) {} + + /** + * Called when a cached marker for a Cluster already exists on the map so the marker may + * be updated to the latest cluster values. Default implementation updated the icon with a + * circle with a rough count of the number of items. Note that the contents of the cluster may + * not have changed since the cached marker was created - implementations of this method are + * responsible for checking if something changed (if that matters to the implementation). + * + * + * The first time [ClusterManager.cluster] is invoked on a set of items + * [.onBeforeClusterRendered] will be called and + * [.onClusterUpdated] will not be called. If an item is removed and + * re-added (or updated) and [ClusterManager.cluster] is invoked + * again, then [.onClusterUpdated] will be called and + * [.onBeforeClusterRendered] will not be called. + * + * @param cluster cluster being updated + * @param marker cached marker that contains a potentially previous state of the cluster + */ + protected open fun onClusterUpdated( + cluster: Cluster, + marker: Marker, + ) { + // TODO: consider adding anchor(.5, .5) (Individual markers will overlap more often) + marker.setIcon(getDescriptorForCluster(cluster)) + } + + /** + * Called after the marker for a ClusterItem has been added to the map. + * + * @param clusterItem the item that was just added to the map + * @param marker the marker representing the item that was just added to the map + */ + protected fun onClusterItemRendered( + clusterItem: T, + marker: Marker, + ) {} + + /** + * Get the marker from a ClusterItem + * + * @param clusterItem ClusterItem which you will obtain its marker + * @return a marker from a ClusterItem or null if it does not exists + */ + fun getMarker(clusterItem: T): Marker? = mMarkerCache[clusterItem] + + /** + * Get the ClusterItem from a marker + * + * @param marker which you will obtain its ClusterItem + * @return a ClusterItem from a marker or null if it does not exists + */ + fun getClusterItem(marker: Marker): T? = mMarkerCache[marker] + + /** + * Get the marker from a Cluster + * + * @param cluster which you will obtain its marker + * @return a marker from a cluster or null if it does not exists + */ + fun getMarker(cluster: Cluster): Marker? = mClusterMarkerCache[cluster] + + /** + * Get the Cluster from a marker + * + * @param marker which you will obtain its Cluster + * @return a Cluster from a marker or null if it does not exists + */ + fun getCluster(marker: Marker): Cluster? = mClusterMarkerCache[marker] + + /** + * Creates markerWithPosition(s) for a particular cluster, animating it if necessary. + */ + private inner class CreateMarkerTask( + private val cluster: Cluster, + private val newMarkers: MutableSet>, + private val animateFrom: LatLng?, + ) { + fun perform(markerModifier: MarkerModifier) { + // Don't show small clusters. Render the markers inside, instead. + if (!shouldRenderAsCluster(cluster)) { + RendererLogger.d("ClusterRenderer", "Rendering individual cluster items, count: " + cluster.items.size) + for (item in cluster.items) { + var marker = mMarkerCache[item] + val markerWithPosition: MarkerWithPosition + var currentLocation: LatLng? = item.position + if (marker == null) { + RendererLogger.d("ClusterRenderer", "Creating new marker for cluster item at position: $currentLocation") + val markerOptions = MarkerOptions() + if (animateFrom != null) { + RendererLogger.d("ClusterRenderer", "Animating from position: $animateFrom") + markerOptions.position(animateFrom) + } else if (mClusterMarkerCache.mCache.keys + .iterator() + .hasNext() && + mClusterMarkerCache.mCache.keys + .iterator() + .next() + .items + .contains(item) + ) { + var foundItem: T? = null + for (cluster in mClusterMarkerCache.mCache.keys) { + for (clusterItem in cluster.items) { + if (clusterItem == item) { + foundItem = clusterItem + break + } + } + } + currentLocation = foundItem?.position + RendererLogger.d("ClusterRenderer", "Found item in cache for animation at position: $currentLocation") + markerOptions.position(currentLocation!!) + } else { + markerOptions.position(item.position) + if (item.zIndex != null) { + markerOptions.zIndex(item.zIndex!!) + } + } + onBeforeClusterItemRendered(item, markerOptions) + marker = mClusterManager.markerCollection.addMarker(markerOptions) + markerWithPosition = MarkerWithPosition(marker, item) + mMarkerCache.put(item, marker) + if (animateFrom != null) { + markerModifier.animate(markerWithPosition, animateFrom, item.position) + RendererLogger.d("ClusterRenderer", "Animating marker from " + animateFrom + " to " + item.position) + } else if (currentLocation != null) { + markerModifier.animate(markerWithPosition, currentLocation, item.position) + RendererLogger.d("ClusterRenderer", "Animating marker from " + currentLocation + " to " + item.position) + } + } else { + markerWithPosition = MarkerWithPosition(marker, item) + markerModifier.animate(markerWithPosition, marker.position, item.position) + RendererLogger.d("ClusterRenderer", "Animating existing marker from " + marker.position + " to " + item.position) + if (markerWithPosition.position != item.position) { + RendererLogger.d("ClusterRenderer", "Updating cluster item marker position") + onClusterItemUpdated(item, marker) + } + } + onClusterItemRendered(item, marker) + newMarkers.add(markerWithPosition) + } + return + } + + // Handle cluster markers + RendererLogger.d("ClusterRenderer", "Rendering cluster marker at position: " + cluster.position) + var marker = mClusterMarkerCache[cluster] + val markerWithPosition: MarkerWithPosition + if (marker == null) { + RendererLogger.d("ClusterRenderer", "Creating new cluster marker") + val markerOptions = MarkerOptions().position(if (animateFrom == null) cluster.position else animateFrom) + onBeforeClusterRendered(cluster, markerOptions) + marker = mClusterManager.clusterMarkerCollection.addMarker(markerOptions) + mClusterMarkerCache.put(cluster, marker) + markerWithPosition = MarkerWithPosition(marker, null) + if (animateFrom != null) { + markerModifier.animate(markerWithPosition, animateFrom, cluster.position) + RendererLogger.d("ClusterRenderer", "Animating cluster marker from " + animateFrom + " to " + cluster.position) + } + } else { + markerWithPosition = MarkerWithPosition(marker, null) + RendererLogger.d("ClusterRenderer", "Updating existing cluster marker") + onClusterUpdated(cluster, marker) + } + onClusterRendered(cluster, marker) + newMarkers.add(markerWithPosition) + } + } + + /** + * A Marker and its position. [Marker.getPosition] must be called from the UI thread, so this + * object allows lookup from other threads. + */ + private class MarkerWithPosition( + val marker: Marker, + val clusterItem: T?, + ) { + var position: LatLng? = marker.position + + override fun equals(other: Any?): Boolean = + if (other is MarkerWithPosition<*>) { + marker == other.marker + } else { + false + } + + override fun hashCode(): Int = marker.hashCode() + } + + /** + * Animates a markerWithPosition from one position to another. TODO: improve performance for + * slow devices (e.g. Nexus S). + */ + private inner class AnimationTask( + val markerWithPosition: MarkerWithPosition, + val from: LatLng, + val to: LatLng, + val lock: Lock, + ) : AnimatorListenerAdapter(), + ValueAnimator.AnimatorUpdateListener { + val marker: Marker = markerWithPosition.marker + private var mRemoveOnComplete: Boolean = false + private var mMarkerManager: MarkerManager? = null + private var valueAnimator: ValueAnimator? = null + + fun perform() { + valueAnimator = ValueAnimator.ofFloat(0.0f, 1.0f) + valueAnimator!!.interpolator = animationInterp + valueAnimator!!.duration = mAnimationDurationMs + valueAnimator!!.addUpdateListener(this) + valueAnimator!!.addListener(this) + valueAnimator!!.start() + } + + fun cancel() { + if (Looper.myLooper() != Looper.getMainLooper()) { + Handler(Looper.getMainLooper()).post { cancel() } + return + } + lock.lock() + try { + markerWithPosition.position = to + mRemoveOnComplete = false + valueAnimator!!.cancel() + ongoingAnimations.remove(this) + } finally { + lock.unlock() + } + } + + override fun onAnimationEnd(animation: Animator) { + if (mRemoveOnComplete) { + mMarkerCache.remove(marker) + mClusterMarkerCache.remove(marker) + mMarkerManager!!.remove(marker) + } + markerWithPosition.position = to + + // Remove the task from the queue + lock.lock() + try { + ongoingAnimations.remove(this) + } finally { + lock.unlock() + } + } + + fun removeOnAnimationComplete(markerManager: MarkerManager) { + mMarkerManager = markerManager + mRemoveOnComplete = true + } + + override fun onAnimationUpdate(valueAnimator: ValueAnimator) { + val fraction = valueAnimator.animatedFraction + val lat = (to.latitude - from.latitude) * fraction + from.latitude + var lngDelta = to.longitude - from.longitude + + // Take the shortest path across the 180th meridian. + if (abs(lngDelta) > 180) { + lngDelta -= sign(lngDelta) * 360 + } + val lng = lngDelta * fraction + from.longitude + val position = LatLng(lat, lng) + marker.position = position + markerWithPosition.position = position + } + } + + companion object { + private val BUCKETS = intArrayOf(10, 20, 50, 100, 200, 500, 1000) + private const val RUN_TASK = 0 + private const val TASK_FINISHED = 1 + private const val BLANK = 0 + + private fun distanceSquared( + a: Point, + b: Point, + ): Double = (a.x - b.x) * (a.x - b.x) + (a.y - b.y) * (a.y - b.y) + } +} diff --git a/clustering/src/main/java/com/google/maps/android/clustering/view/DefaultAdvancedMarkersClusterRenderer.kt b/clustering/src/main/java/com/google/maps/android/clustering/view/DefaultAdvancedMarkersClusterRenderer.kt new file mode 100644 index 000000000..84637073f --- /dev/null +++ b/clustering/src/main/java/com/google/maps/android/clustering/view/DefaultAdvancedMarkersClusterRenderer.kt @@ -0,0 +1,1139 @@ +/* + * 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.view + +import android.animation.Animator +import android.animation.AnimatorListenerAdapter +import android.animation.TimeInterpolator +import android.animation.ValueAnimator +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.Color +import android.graphics.drawable.Drawable +import android.graphics.drawable.LayerDrawable +import android.graphics.drawable.ShapeDrawable +import android.graphics.drawable.shapes.OvalShape +import android.os.Handler +import android.os.Looper +import android.os.Message +import android.os.MessageQueue +import android.util.SparseArray +import android.view.ViewGroup +import android.view.animation.DecelerateInterpolator +import androidx.annotation.StyleRes +import com.google.android.gms.maps.GoogleMap +import com.google.android.gms.maps.Projection +import com.google.android.gms.maps.model.AdvancedMarker +import com.google.android.gms.maps.model.AdvancedMarkerOptions +import com.google.android.gms.maps.model.BitmapDescriptor +import com.google.android.gms.maps.model.BitmapDescriptorFactory +import com.google.android.gms.maps.model.LatLng +import com.google.android.gms.maps.model.LatLngBounds +import com.google.android.gms.maps.model.Marker +import com.google.maps.android.clustering.Cluster +import com.google.maps.android.clustering.ClusterItem +import com.google.maps.android.clustering.ClusterManager +import com.google.maps.android.collections.MarkerManager +import com.google.maps.android.geometry.Point +import com.google.maps.android.projection.SphericalMercatorProjection +import com.google.maps.android.ui.IconGenerator +import com.google.maps.android.ui.R +import com.google.maps.android.ui.SquareTextView +import java.util.ArrayList +import java.util.Collections +import java.util.HashMap +import java.util.LinkedList +import java.util.Queue +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.Executor +import java.util.concurrent.Executors +import java.util.concurrent.locks.Condition +import java.util.concurrent.locks.ReentrantLock +import kotlin.math.abs +import kotlin.math.min +import kotlin.math.pow +import kotlin.math.sign + +/** + * The default view for a ClusterManager. Markers are animated in and out of clusters. + */ +open class DefaultAdvancedMarkersClusterRenderer @JvmOverloads constructor( + context: Context, + private val mMap: GoogleMap, + private val mClusterManager: ClusterManager, + private val mExecutor: Executor = Executors.newSingleThreadExecutor(), +) : ClusterRenderer { + private val mIconGenerator: IconGenerator = IconGenerator(context) + private val mDensity: Float = context.resources.displayMetrics.density + private var mAnimate: Boolean = true + private var mAnimationDurationMs: Long = 300 + private var mColoredCircleBackground: ShapeDrawable? = null + + /** + * Markers that are currently on the map. + */ + private var mMarkers: MutableSet = + Collections.newSetFromMap( + ConcurrentHashMap(), + ) + + /** + * Icons for each bucket. + */ + private val mIcons = SparseArray() + + /** + * Markers for single ClusterItems. + */ + private val mMarkerCache = MarkerCache() + + /** + * If cluster size is less than this size, display individual markers. + */ + var minClusterSize: Int = 4 + + /** + * The currently displayed set of clusters. + */ + private var mClusters: Set>? = null + + /** + * Markers for Clusters. + */ + private val mClusterMarkerCache = MarkerCache>() + + /** + * The target zoom level for the current set of clusters. + */ + private var mZoom: Float = 0f + + private val mViewModifier = ViewModifier() + + private var mClickListener: ClusterManager.OnClusterClickListener? = null + private var mInfoWindowClickListener: ClusterManager.OnClusterInfoWindowClickListener? = null + private var mInfoWindowLongClickListener: ClusterManager.OnClusterInfoWindowLongClickListener? = null + private var mItemClickListener: ClusterManager.OnClusterItemClickListener? = null + private var mItemInfoWindowClickListener: ClusterManager.OnClusterItemInfoWindowClickListener? = null + private var mItemInfoWindowLongClickListener: ClusterManager.OnClusterItemInfoWindowLongClickListener? = null + + init { + val squareTextView = makeSquareTextView(context) + mIconGenerator.setContentView(squareTextView) + mIconGenerator.setTextAppearance(R.style.amu_ClusterIcon_TextAppearance) + mIconGenerator.setBackground(makeClusterBackground()) + } + + override fun onAdd() { + mClusterManager.markerCollection.setOnMarkerClickListener { marker -> + mMarkerCache[marker]?.let { mItemClickListener?.onClusterItemClick(it) } ?: false + } + + mClusterManager.markerCollection.setOnInfoWindowClickListener { marker -> + mMarkerCache[marker]?.let { mItemInfoWindowClickListener?.onClusterItemInfoWindowClick(it) } + } + + mClusterManager.markerCollection.setOnInfoWindowLongClickListener { marker -> + mMarkerCache[marker]?.let { mItemInfoWindowLongClickListener?.onClusterItemInfoWindowLongClick(it) } + } + + mClusterManager.clusterMarkerCollection.setOnMarkerClickListener { marker -> + mClusterMarkerCache[marker]?.let { mClickListener?.onClusterClick(it) } ?: false + } + + mClusterManager.clusterMarkerCollection.setOnInfoWindowClickListener { marker -> + mClusterMarkerCache[marker]?.let { mInfoWindowClickListener?.onClusterInfoWindowClick(it) } + } + + mClusterManager.clusterMarkerCollection.setOnInfoWindowLongClickListener { marker -> + mClusterMarkerCache[marker]?.let { mInfoWindowLongClickListener?.onClusterInfoWindowLongClick(it) } + } + } + + override fun onRemove() { + mClusterManager.markerCollection.setOnMarkerClickListener(null) + mClusterManager.markerCollection.setOnInfoWindowClickListener(null) + mClusterManager.markerCollection.setOnInfoWindowLongClickListener(null) + mClusterManager.clusterMarkerCollection.setOnMarkerClickListener(null) + mClusterManager.clusterMarkerCollection.setOnInfoWindowClickListener(null) + mClusterManager.clusterMarkerCollection.setOnInfoWindowLongClickListener(null) + } + + private fun makeClusterBackground(): LayerDrawable { + mColoredCircleBackground = ShapeDrawable(OvalShape()) + val outline = ShapeDrawable(OvalShape()) + outline.paint.color = -0x7f000001 // Transparent white. + val background = LayerDrawable(arrayOf(outline, mColoredCircleBackground!!)) + val strokeWidth = (mDensity * 3).toInt() + background.setLayerInset(1, strokeWidth, strokeWidth, strokeWidth, strokeWidth) + return background + } + + private fun makeSquareTextView(context: Context): SquareTextView { + val squareTextView = SquareTextView(context) + val layoutParams = + ViewGroup.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ) + squareTextView.layoutParams = layoutParams + squareTextView.id = R.id.amu_text + val twelveDpi = (12 * mDensity).toInt() + squareTextView.setPadding(twelveDpi, twelveDpi, twelveDpi, twelveDpi) + return squareTextView + } + + override fun getColor(clusterSize: Int): Int { + val hueRange = 220f + val sizeRange = 300f + val size = min(clusterSize.toFloat(), sizeRange) + val hue = (sizeRange - size) * (sizeRange - size) / (sizeRange * sizeRange) * hueRange + return Color.HSVToColor( + floatArrayOf( + hue, + 1f, + .6f, + ), + ) + } + + @StyleRes + override fun getClusterTextAppearance(clusterSize: Int): Int { + return R.style.amu_ClusterIcon_TextAppearance // Default value + } + + protected open fun getClusterText(bucket: Int): String = + if (bucket < BUCKETS[0]) { + bucket.toString() + } else { + "$bucket+" + } + + /** + * Gets the "bucket" for a particular cluster. By default, uses the number of points within the + * cluster, bucketed to some set points. + */ + protected open fun getBucket(cluster: Cluster): Int { + val size = cluster.size + if (size <= BUCKETS[0]) { + return size + } + for (i in 0 until BUCKETS.size - 1) { + if (size < BUCKETS[i + 1]) { + return BUCKETS[i] + } + } + return BUCKETS[BUCKETS.size - 1] + } + + /** + * ViewModifier ensures only one re-rendering of the view occurs at a time, and schedules + * re-rendering, which is performed by the RenderTask. + */ + @SuppressLint("HandlerLeak") + private inner class ViewModifier : Handler(Looper.getMainLooper()) { + private var mViewModificationInProgress = false + private var mNextClusters: RenderTask? = null + + override fun handleMessage(msg: Message) { + if (msg.what == TASK_FINISHED) { + mViewModificationInProgress = false + if (mNextClusters != null) { + // Run the task that was queued up. + sendEmptyMessage(RUN_TASK) + } + return + } + removeMessages(RUN_TASK) + + if (mViewModificationInProgress) { + // Busy - wait for the callback. + return + } + + if (mNextClusters == null) { + // Nothing to do. + return + } + val projection = mMap.projection + + var renderTask: RenderTask? + synchronized(this) { + renderTask = mNextClusters + mNextClusters = null + mViewModificationInProgress = true + } + + renderTask!!.setCallback { sendEmptyMessage(TASK_FINISHED) } + renderTask!!.setProjection(projection) + renderTask!!.setMapZoom(mMap.cameraPosition.zoom) + mExecutor.execute(renderTask) + } + + fun queue(clusters: Set>) { + synchronized(this) { + // Overwrite any pending cluster tasks - we don't care about intermediate states. + mNextClusters = RenderTask(clusters) + } + sendEmptyMessage(RUN_TASK) + } + } + + /** + * Determine whether the cluster should be rendered as individual markers or a cluster. + * + * @param cluster cluster to examine for rendering + * @return true if the provided cluster should be rendered as a single marker on the map, false + * if the items within this cluster should be rendered as individual markers instead. + */ + protected open fun shouldRenderAsCluster(cluster: Cluster): Boolean = cluster.size >= minClusterSize + + /** + * Determines if the new clusters should be rendered on the map, given the old clusters. This + * method is primarily for optimization of performance, and the default implementation simply + * checks if the new clusters are equal to the old clusters, and if so, it returns false. + * + * + * However, there are cases where you may want to re-render the clusters even if they didn't + * change. For example, if you want a cluster with one item to render as a cluster above + * a certain zoom level and as a marker below a certain zoom level (even if the contents of the + * clusters themselves did not change). In this case, you could check the zoom level in an + * implementation of this method and if that zoom level threshold is crossed return true, else + * `return super.shouldRender(oldClusters, newClusters)`. + * + * + * Note that always returning true from this method could potentially have negative performance + * implications as clusters will be re-rendered on each pass even if they don't change. + * + * @param oldClusters The clusters from the previous iteration of the clustering algorithm + * @param newClusters The clusters from the current iteration of the clustering algorithm + * @return true if the new clusters should be rendered on the map, and false if they should not. This + * method is primarily for optimization of performance, and the default implementation simply + * checks if the new clusters are equal to the old clusters, and if so, it returns false. + */ + protected open fun shouldRender( + oldClusters: Set>, + newClusters: Set>, + ): Boolean = newClusters != oldClusters + + /** + * Transforms the current view (represented by DefaultAdvancedMarkersClusterRenderer.mClusters and DefaultAdvancedMarkersClusterRenderer.mZoom) to a + * new zoom level and set of clusters. + * + * + * This must be run off the UI thread. Work is coordinated in the RenderTask, then queued up to + * be executed by a MarkerModifier. + * + * + * There are three stages for the render: + * + * + * 1. Markers are added to the map + * + * + * 2. Markers are animated to their final position + * + * + * 3. Any old markers are removed from the map + * + * + * When zooming in, markers are animated out from the nearest existing cluster. When zooming + * out, existing clusters are animated to the nearest new cluster. + */ + private inner class RenderTask( + val clusters: Set>, + ) : Runnable { + private var mCallback: Runnable? = null + private var mProjection: Projection? = null + private var mSphericalMercatorProjection: SphericalMercatorProjection? = null + private var mMapZoom: Float = 0f + + /** + * A callback to be run when all work has been completed. + * + * @param callback + */ + fun setCallback(callback: Runnable) { + mCallback = callback + } + + fun setProjection(projection: Projection) { + this.mProjection = projection + } + + fun setMapZoom(zoom: Float) { + this.mMapZoom = zoom + this.mSphericalMercatorProjection = + SphericalMercatorProjection( + 256 * 2.0.pow(min(zoom.toDouble(), mZoom.toDouble())), + ) + } + + @SuppressLint("NewApi") + override fun run() { + if (!shouldRender(immutableOf(this@DefaultAdvancedMarkersClusterRenderer.mClusters), immutableOf(clusters))) { + mCallback!!.run() + return + } + + val markerModifier = MarkerModifier() + + val zoom = mMapZoom + val zoomingIn = zoom > mZoom + val zoomDelta = zoom - mZoom + + val markersToRemove = mMarkers + // Prevent crashes: https://issuetracker.google.com/issues/35827242 + var visibleBounds: LatLngBounds + try { + visibleBounds = mProjection!!.visibleRegion.latLngBounds + } catch (e: Exception) { + e.printStackTrace() + visibleBounds = + LatLngBounds + .builder() + .include(LatLng(0.0, 0.0)) + .build() + } + // TODO: Add some padding, so that markers can animate in from off-screen. + + // Find all of the existing clusters that are on-screen. These are candidates for + // markers to animate from. + var existingClustersOnScreen: MutableList? = null + if (this@DefaultAdvancedMarkersClusterRenderer.mClusters != null && mAnimate) { + existingClustersOnScreen = ArrayList() + for (c in this@DefaultAdvancedMarkersClusterRenderer.mClusters!!) { + if (shouldRenderAsCluster(c) && visibleBounds.contains(c.position)) { + val point = mSphericalMercatorProjection!!.toPoint(c.position) + existingClustersOnScreen.add(point) + } + } + } + + // Create the new markers and animate them to their new positions. + val newMarkers = + Collections.newSetFromMap( + ConcurrentHashMap(), + ) + for (c in clusters) { + val onScreen = visibleBounds.contains(c.position) + if (zoomingIn && onScreen && mAnimate) { + val point = mSphericalMercatorProjection!!.toPoint(c.position) + val closest = findClosestCluster(existingClustersOnScreen, point) + if (closest != null) { + val animateTo = mSphericalMercatorProjection!!.toLatLng(closest) + markerModifier.add(true, CreateMarkerTask(c, newMarkers, animateTo)) + } else { + markerModifier.add(true, CreateMarkerTask(c, newMarkers, null)) + } + } else { + markerModifier.add(onScreen, CreateMarkerTask(c, newMarkers, null)) + } + } + + // Wait for all markers to be added. + markerModifier.waitUntilFree() + + // Don't remove any markers that were just added. This is basically anything that had + // a hit in the MarkerCache. + markersToRemove.removeAll(newMarkers) + + // Find all of the new clusters that were added on-screen. These are candidates for + // markers to animate from. + var newClustersOnScreen: MutableList? = null + if (mAnimate) { + newClustersOnScreen = ArrayList() + for (c in clusters) { + if (shouldRenderAsCluster(c) && visibleBounds.contains(c.position)) { + val p = mSphericalMercatorProjection!!.toPoint(c.position) + newClustersOnScreen.add(p) + } + } + } + + // Remove the old markers, animating them into clusters if zooming out. + for (marker in markersToRemove) { + val onScreen = visibleBounds.contains(marker.position) + // Don't animate when zooming out more than 3 zoom levels. + // TODO: drop animation based on speed of device & number of markers to animate. + if (!zoomingIn && zoomDelta > -3 && onScreen && mAnimate) { + val point = mSphericalMercatorProjection!!.toPoint(marker.position) + val closest = findClosestCluster(newClustersOnScreen, point) + if (closest != null) { + val animateTo = mSphericalMercatorProjection!!.toLatLng(closest) + markerModifier.animateThenRemove(marker, marker.position, animateTo!!) + } else { + markerModifier.remove(true, marker.marker) + } + } else { + markerModifier.remove(onScreen, marker.marker) + } + } + + markerModifier.waitUntilFree() + + mMarkers = newMarkers + this@DefaultAdvancedMarkersClusterRenderer.mClusters = clusters + mZoom = zoom + + mCallback!!.run() + } + } + + override fun onClustersChanged(clusters: Set>) { + mViewModifier.queue(clusters) + } + + override fun setOnClusterClickListener(listener: ClusterManager.OnClusterClickListener?) { + mClickListener = listener + } + + override fun setOnClusterInfoWindowClickListener(listener: ClusterManager.OnClusterInfoWindowClickListener?) { + mInfoWindowClickListener = listener + } + + override fun setOnClusterInfoWindowLongClickListener(listener: ClusterManager.OnClusterInfoWindowLongClickListener?) { + mInfoWindowLongClickListener = listener + } + + override fun setOnClusterItemClickListener(listener: ClusterManager.OnClusterItemClickListener?) { + mItemClickListener = listener + } + + override fun setOnClusterItemInfoWindowClickListener(listener: ClusterManager.OnClusterItemInfoWindowClickListener?) { + mItemInfoWindowClickListener = listener + } + + override fun setOnClusterItemInfoWindowLongClickListener(listener: ClusterManager.OnClusterItemInfoWindowLongClickListener?) { + mItemInfoWindowLongClickListener = listener + } + + override fun setAnimation(animate: Boolean) { + mAnimate = animate + } + + /** + * [.setAnimationDuration] The default duration is 300 milliseconds. + * + * @param animationDurationMs long: The length of the animation, in milliseconds. This value cannot be negative. + */ + override fun setAnimationDuration(animationDurationMs: Long) { + mAnimationDurationMs = animationDurationMs + } + + private fun immutableOf(clusters: Set>?): Set> = if (clusters != null) Collections.unmodifiableSet(clusters) else Collections.emptySet() + + private fun findClosestCluster( + markers: List?, + point: Point, + ): Point? { + if (markers == null || markers.isEmpty()) return null + + val maxDistance = mClusterManager.algorithm.maxDistanceBetweenClusteredItems + var minDistSquared = (maxDistance * maxDistance).toDouble() + var closest: Point? = null + for (candidate in markers) { + val dist = distanceSquared(candidate, point) + if (dist < minDistSquared) { + closest = candidate + minDistSquared = dist + } + } + return closest + } + + /** + * Handles all markerWithPosition manipulations on the map. Work (such as adding, removing, or + * animating a markerWithPosition) is performed while trying not to block the rest of the app's + * UI. + */ + @SuppressLint("HandlerLeak") + private inner class MarkerModifier : + Handler(Looper.getMainLooper()), + MessageQueue.IdleHandler { + private val lock = ReentrantLock() + private val busyCondition = lock.newCondition() + + private val mCreateMarkerTasks: Queue = LinkedList() + private val mOnScreenCreateMarkerTasks: Queue = LinkedList() + private val mRemoveMarkerTasks: Queue = LinkedList() + private val mOnScreenRemoveMarkerTasks: Queue = LinkedList() + private val mAnimationTasks: Queue = LinkedList() + + /** + * Whether the idle listener has been added to the UI thread's MessageQueue. + */ + private var mListenerAdded: Boolean = false + + /** + * Creates markers for a cluster some time in the future. + * + * @param priority whether this operation should have priority. + */ + fun add( + priority: Boolean, + c: CreateMarkerTask, + ) { + lock.lock() + sendEmptyMessage(BLANK) + if (priority) { + mOnScreenCreateMarkerTasks.add(c) + } else { + mCreateMarkerTasks.add(c) + } + lock.unlock() + } + + /** + * Removes a markerWithPosition some time in the future. + * + * @param priority whether this operation should have priority. + * @param m the markerWithPosition to remove. + */ + fun remove( + priority: Boolean, + m: Marker, + ) { + lock.lock() + sendEmptyMessage(BLANK) + if (priority) { + mOnScreenRemoveMarkerTasks.add(m) + } else { + mRemoveMarkerTasks.add(m) + } + lock.unlock() + } + + /** + * Animates a markerWithPosition some time in the future. + * + * @param marker the markerWithPosition to animate. + * @param from the position to animate from. + * @param to the position to animate to. + */ + fun animate( + marker: MarkerWithPosition, + from: LatLng, + to: LatLng, + ) { + lock.lock() + mAnimationTasks.add(AnimationTask(marker, from, to)) + lock.unlock() + } + + /** + * Animates a markerWithPosition some time in the future, and removes it when the animation + * is complete. + * + * @param marker the markerWithPosition to animate. + * @param from the position to animate from. + * @param to the position to animate to. + */ + fun animateThenRemove( + marker: MarkerWithPosition, + from: LatLng, + to: LatLng, + ) { + lock.lock() + val animationTask = AnimationTask(marker, from, to) + animationTask.removeOnAnimationComplete(mClusterManager.markerManager) + mAnimationTasks.add(animationTask) + lock.unlock() + } + + override fun handleMessage(msg: Message) { + if (!mListenerAdded) { + Looper.myQueue().addIdleHandler(this) + mListenerAdded = true + } + removeMessages(BLANK) + + lock.lock() + try { + // Perform up to 10 tasks at once. + // Consider only performing 10 remove tasks, not adds and animations. + // Removes are relatively slow and are much better when batched. + for (i in 0..9) { + performNextTask() + } + + if (!isBusy) { + mListenerAdded = false + Looper.myQueue().removeIdleHandler(this) + // Signal any other threads that are waiting. + busyCondition.signalAll() + } else { + // Sometimes the idle queue may not be called - schedule up some work regardless + // of whether the UI thread is busy or not. + // TODO: try to remove this. + sendEmptyMessageDelayed(BLANK, 10) + } + } finally { + lock.unlock() + } + } + + /** + * Perform the next task. Prioritise any on-screen work. + */ + private fun performNextTask() { + if (!mOnScreenRemoveMarkerTasks.isEmpty()) { + removeMarker(mOnScreenRemoveMarkerTasks.poll()) + } else if (!mAnimationTasks.isEmpty()) { + mAnimationTasks.poll().perform() + } else if (!mOnScreenCreateMarkerTasks.isEmpty()) { + mOnScreenCreateMarkerTasks.poll().perform(this) + } else if (!mCreateMarkerTasks.isEmpty()) { + mCreateMarkerTasks.poll().perform(this) + } else if (!mRemoveMarkerTasks.isEmpty()) { + removeMarker(mRemoveMarkerTasks.poll()) + } + } + + private fun removeMarker(m: Marker?) { + mMarkerCache.remove(m) + mClusterMarkerCache.remove(m) + mClusterManager.markerManager.remove(m) + } + + /** + * @return true if there is still work to be processed. + */ + val isBusy: Boolean + get() { + try { + lock.lock() + return !( + mCreateMarkerTasks.isEmpty() && mOnScreenCreateMarkerTasks.isEmpty() && + mOnScreenRemoveMarkerTasks.isEmpty() && mRemoveMarkerTasks.isEmpty() && + mAnimationTasks.isEmpty() + ) + } finally { + lock.unlock() + } + } + + /** + * Blocks the calling thread until all work has been processed. + */ + fun waitUntilFree() { + while (isBusy) { + // Sometimes the idle queue may not be called - schedule up some work regardless + // of whether the UI thread is busy or not. + // TODO: try to remove this. + sendEmptyMessage(BLANK) + lock.lock() + try { + if (isBusy) { + busyCondition.await() + } + } catch (e: InterruptedException) { + throw RuntimeException(e) + } finally { + lock.unlock() + } + } + } + + override fun queueIdle(): Boolean { + // When the UI is not busy, schedule some work. + sendEmptyMessage(BLANK) + return true + } + } + + /** + * A cache of markers representing individual ClusterItems. + */ + private class MarkerCache { + private val mCache: MutableMap = HashMap() + private val mCacheReverse: MutableMap = HashMap() + + operator fun get(item: T): Marker? = mCache[item] + + operator fun get(m: Marker): T? = mCacheReverse[m] + + fun put( + item: T, + m: Marker, + ) { + mCache[item] = m + mCacheReverse[m] = item + } + + fun remove(m: Marker?) { + val item = mCacheReverse[m] + mCacheReverse.remove(m) + mCache.remove(item) + } + } + + /** + * Called before the marker for a ClusterItem is added to the map. The default implementation + * sets the marker and snippet text based on the respective item text if they are both + * available, otherwise it will set the title if available, and if not it will set the marker + * title to the item snippet text if that is available. + * + * + * The first time [ClusterManager.cluster] is invoked on a set of items + * [.onBeforeClusterItemRendered] will be called and + * [.onClusterItemUpdated] will not be called. + * If an item is removed and re-added (or updated) and [ClusterManager.cluster] is + * invoked again, then [.onClusterItemUpdated] will be called and + * [.onBeforeClusterItemRendered] will not be called. + * + * @param item item to be rendered + * @param advancedMarkerOptions the AdvancedMarkerOptions representing the provided item + */ + protected open fun onBeforeClusterItemRendered( + item: T, + advancedMarkerOptions: AdvancedMarkerOptions, + ) { + if (item.title != null && item.snippet != null) { + advancedMarkerOptions.title(item.title) + advancedMarkerOptions.snippet(item.snippet) + } else if (item.title != null) { + advancedMarkerOptions.title(item.title) + } else if (item.snippet != null) { + advancedMarkerOptions.title(item.snippet) + } + } + + /** + * Called when a cached marker for a ClusterItem already exists on the map so the marker may + * be updated to the latest item values. Default implementation updates the title and snippet + * of the marker if they have changed and refreshes the info window of the marker if it is open. + * Note that the contents of the item may not have changed since the cached marker was created - + * implementations of this method are responsible for checking if something changed (if that + * matters to the implementation). + * + * + * The first time [ClusterManager.cluster] is invoked on a set of items + * [.onBeforeClusterItemRendered] will be called and + * [.onClusterItemUpdated] will not be called. + * If an item is removed and re-added (or updated) and [ClusterManager.cluster] is + * invoked again, then [.onClusterItemUpdated] will be called and + * [.onBeforeClusterItemRendered] will not be called. + * + * @param item item being updated + * @param marker cached marker that contains a potentially previous state of the item. + */ + protected open fun onClusterItemUpdated( + item: T, + marker: Marker, + ) { + var changed = false + // Update marker text if the item text changed - same logic as adding marker in CreateMarkerTask.perform() + if (item.title != null && item.snippet != null) { + if (item.title != marker.title) { + marker.title = item.title + changed = true + } + if (item.snippet != marker.snippet) { + marker.snippet = item.snippet + changed = true + } + } else if (item.snippet != null && item.snippet != marker.title) { + marker.title = item.snippet + changed = true + } else if (item.title != null && item.title != marker.title) { + marker.title = item.title + changed = true + } + // Update marker position if the item changed position + if (marker.position != item.position) { + marker.position = item.position + if (item.zIndex != null) { + marker.zIndex = item.zIndex!! + } + changed = true + } + if (changed && marker.isInfoWindowShown) { + // Force a refresh of marker info window contents + marker.showInfoWindow() + } + } + + /** + * Called before the marker for a Cluster is added to the map. + * The default implementation draws a circle with a rough count of the number of items. + * + * + * The first time [ClusterManager.cluster] is invoked on a set of items + * [.onBeforeClusterRendered] will be called and + * [.onClusterUpdated] will not be called. If an item is removed and + * re-added (or updated) and [ClusterManager.cluster] is invoked + * again, then [.onClusterUpdated] will be called and + * [.onBeforeClusterRendered] will not be called. + * + * @param cluster cluster to be rendered + * @param advancedMarkerOptions markerOptions representing the provided cluster + */ + protected open fun onBeforeClusterRendered( + cluster: Cluster, + advancedMarkerOptions: AdvancedMarkerOptions, + ) { + // TODO: consider adding anchor(.5, .5) (Individual markers will overlap more often) + advancedMarkerOptions.icon(getDescriptorForCluster(cluster)) + } + + /** + * Gets a BitmapDescriptor for the given cluster that contains a rough count of the number of + * items. Used to set the cluster marker icon in the default implementations of + * [.onBeforeClusterRendered] and + * [.onClusterUpdated]. + * + * @param cluster cluster to get BitmapDescriptor for + * @return a BitmapDescriptor for the marker icon for the given cluster that contains a rough + * count of the number of items. + */ + protected open fun getDescriptorForCluster(cluster: Cluster): BitmapDescriptor { + val bucket = getBucket(cluster) + var descriptor = mIcons[bucket] + if (descriptor == null) { + mColoredCircleBackground!!.paint.color = getColor(bucket) + mIconGenerator.setTextAppearance(getClusterTextAppearance(bucket)) + descriptor = BitmapDescriptorFactory.fromBitmap(mIconGenerator.makeIcon(getClusterText(bucket))) + mIcons.put(bucket, descriptor) + } + return descriptor + } + + /** + * Called after the marker for a Cluster has been added to the map. + * + * @param cluster the cluster that was just added to the map + * @param marker the marker representing the cluster that was just added to the map + */ + protected open fun onClusterRendered( + cluster: Cluster, + marker: Marker, + ) {} + + /** + * Called when a cached marker for a Cluster already exists on the map so the marker may + * be updated to the latest cluster values. Default implementation updated the icon with a + * circle with a rough count of the number of items. Note that the contents of the cluster may + * not have changed since the cached marker was created - implementations of this method are + * responsible for checking if something changed (if that matters to the implementation). + * + * + * The first time [ClusterManager.cluster] is invoked on a set of items + * [.onBeforeClusterRendered] will be called and + * [.onClusterUpdated] will not be called. If an item is removed and + * re-added (or updated) and [ClusterManager.cluster] is invoked + * again, then [.onClusterUpdated] will be called and + * [.onBeforeClusterRendered] will not be called. + * + * @param cluster cluster being updated + * @param marker cached marker that contains a potentially previous state of the cluster + */ + protected open fun onClusterUpdated( + cluster: Cluster, + marker: AdvancedMarker, + ) { + // TODO: consider adding anchor(.5, .5) (Individual markers will overlap more often) + marker.setIcon(getDescriptorForCluster(cluster)) + } + + /** + * Called after the marker for a ClusterItem has been added to the map. + * + * @param clusterItem the item that was just added to the map + * @param marker the marker representing the item that was just added to the map + */ + protected open fun onClusterItemRendered( + clusterItem: T, + marker: Marker, + ) {} + + /** + * Get the marker from a ClusterItem + * + * @param clusterItem ClusterItem which you will obtain its marker + * @return a marker from a ClusterItem or null if it does not exists + */ + fun getMarker(clusterItem: T): Marker? = mMarkerCache[clusterItem] + + /** + * Get the ClusterItem from a marker + * + * @param marker which you will obtain its ClusterItem + * @return a ClusterItem from a marker or null if it does not exists + */ + fun getClusterItem(marker: Marker): T? = mMarkerCache[marker] + + /** + * Get the marker from a Cluster + * + * @param cluster which you will obtain its marker + * @return a marker from a cluster or null if it does not exists + */ + fun getMarker(cluster: Cluster): Marker? = mClusterMarkerCache[cluster] + + /** + * Get the Cluster from a marker + * + * @param marker which you will obtain its Cluster + * @return a Cluster from a marker or null if it does not exists + */ + fun getCluster(marker: Marker): Cluster? = mClusterMarkerCache[marker] + + /** + * Creates markerWithPosition(s) for a particular cluster, animating it if necessary. + */ + private inner class CreateMarkerTask( + private val cluster: Cluster, + private val newMarkers: MutableSet, + private val animateFrom: LatLng?, + ) { + fun perform(markerModifier: MarkerModifier) { + // Don't show small clusters. Render the markers inside, instead. + if (!shouldRenderAsCluster(cluster)) { + for (item in cluster.items) { + var marker = mMarkerCache[item] as AdvancedMarker? + var markerWithPosition: MarkerWithPosition + if (marker == null) { + val advancedMarkerOptions = AdvancedMarkerOptions() + if (animateFrom != null) { + advancedMarkerOptions.position(animateFrom) + } else { + advancedMarkerOptions.position(item.position) + if (item.zIndex != null) { + advancedMarkerOptions.zIndex(item.zIndex!!) + } + } + onBeforeClusterItemRendered(item, advancedMarkerOptions) + marker = mClusterManager.markerCollection.addMarker(advancedMarkerOptions) as AdvancedMarker? + markerWithPosition = MarkerWithPosition(marker!!) + mMarkerCache.put(item, marker!!) + if (animateFrom != null) { + markerModifier.animate(markerWithPosition, animateFrom, item.position) + } + } else { + markerWithPosition = MarkerWithPosition(marker) + onClusterItemUpdated(item, marker) + } + onClusterItemRendered(item, marker) + newMarkers.add(markerWithPosition) + } + return + } + + var marker = mClusterMarkerCache[cluster] as AdvancedMarker? + var markerWithPosition: MarkerWithPosition + if (marker == null) { + val advancedMarkerOptions = AdvancedMarkerOptions().position(if (animateFrom == null) cluster.position else animateFrom) + onBeforeClusterRendered(cluster, advancedMarkerOptions) + val `object` = mClusterManager.clusterMarkerCollection.addMarker(advancedMarkerOptions) + marker = `object` as AdvancedMarker? + mClusterMarkerCache.put(cluster, marker!!) + markerWithPosition = MarkerWithPosition(marker) + if (animateFrom != null) { + markerModifier.animate(markerWithPosition, animateFrom, cluster.position) + } + } else { + markerWithPosition = MarkerWithPosition(marker) + onClusterUpdated(cluster, marker) + } + onClusterRendered(cluster, marker!!) + newMarkers.add(markerWithPosition) + } + } + + /** + * A Marker and its position. [Marker.getPosition] must be called from the UI thread, so this + * object allows lookup from other threads. + */ + private class MarkerWithPosition( + val marker: Marker, + ) { + var position: LatLng = marker.position + + override fun equals(other: Any?): Boolean = + if (other is MarkerWithPosition) { + marker == other.marker + } else { + false + } + + override fun hashCode(): Int = marker.hashCode() + } + + /** + * Animates a markerWithPosition from one position to another. TODO: improve performance for + * slow devices (e.g. Nexus S). + */ + private inner class AnimationTask( + private val markerWithPosition: MarkerWithPosition, + private val from: LatLng, + private val to: LatLng, + ) : AnimatorListenerAdapter(), + ValueAnimator.AnimatorUpdateListener { + private val marker: Marker = markerWithPosition.marker + private var mRemoveOnComplete: Boolean = false + private var mMarkerManager: MarkerManager? = null + + fun perform() { + val valueAnimator = ValueAnimator.ofFloat(0.0f, 1.0f) + valueAnimator.interpolator = ANIMATION_INTERP + valueAnimator.duration = mAnimationDurationMs + valueAnimator.addUpdateListener(this) + valueAnimator.addListener(this) + valueAnimator.start() + } + + override fun onAnimationEnd(animation: Animator) { + if (mRemoveOnComplete) { + mMarkerCache.remove(marker) + mClusterMarkerCache.remove(marker) + mMarkerManager!!.remove(marker) + } + markerWithPosition.position = to + } + + fun removeOnAnimationComplete(markerManager: MarkerManager) { + mMarkerManager = markerManager + mRemoveOnComplete = true + } + + override fun onAnimationUpdate(valueAnimator: ValueAnimator) { + val fraction = valueAnimator.animatedFraction + val lat = (to.latitude - from.latitude) * fraction + from.latitude + var lngDelta = to.longitude - from.longitude + + // Take the shortest path across the 180th meridian. + if (abs(lngDelta) > 180) { + lngDelta -= sign(lngDelta) * 360 + } + val lng = lngDelta * fraction + from.longitude + val position = LatLng(lat, lng) + marker.position = position + } + } + + companion object { + private val BUCKETS = intArrayOf(10, 20, 50, 100, 200, 500, 1000) + private val ANIMATION_INTERP: TimeInterpolator = DecelerateInterpolator() + private const val RUN_TASK = 0 + private const val TASK_FINISHED = 1 + private const val BLANK = 0 + + private fun distanceSquared( + a: Point, + b: Point, + ): Double = (a.x - b.x) * (a.x - b.x) + (a.y - b.y) * (a.y - b.y) + } +} diff --git a/clustering/src/main/java/com/google/maps/android/clustering/view/DefaultClusterRenderer.kt b/clustering/src/main/java/com/google/maps/android/clustering/view/DefaultClusterRenderer.kt new file mode 100644 index 000000000..1e278ea83 --- /dev/null +++ b/clustering/src/main/java/com/google/maps/android/clustering/view/DefaultClusterRenderer.kt @@ -0,0 +1,1147 @@ +/* + * 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.view + +import android.animation.Animator +import android.animation.AnimatorListenerAdapter +import android.animation.TimeInterpolator +import android.animation.ValueAnimator +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.Color +import android.graphics.drawable.Drawable +import android.graphics.drawable.LayerDrawable +import android.graphics.drawable.ShapeDrawable +import android.graphics.drawable.shapes.OvalShape +import android.os.Handler +import android.os.Looper +import android.os.Message +import android.os.MessageQueue +import android.util.SparseArray +import android.view.ViewGroup +import android.view.animation.DecelerateInterpolator +import androidx.annotation.StyleRes +import com.google.android.gms.maps.GoogleMap +import com.google.android.gms.maps.Projection +import com.google.android.gms.maps.model.BitmapDescriptor +import com.google.android.gms.maps.model.BitmapDescriptorFactory +import com.google.android.gms.maps.model.LatLng +import com.google.android.gms.maps.model.LatLngBounds +import com.google.android.gms.maps.model.Marker +import com.google.android.gms.maps.model.MarkerOptions +import com.google.maps.android.clustering.Cluster +import com.google.maps.android.clustering.ClusterItem +import com.google.maps.android.clustering.ClusterManager +import com.google.maps.android.collections.MarkerManager +import com.google.maps.android.geometry.Point +import com.google.maps.android.projection.SphericalMercatorProjection +import com.google.maps.android.ui.IconGenerator +import com.google.maps.android.ui.R +import com.google.maps.android.ui.SquareTextView +import java.util.ArrayList +import java.util.Collections +import java.util.HashMap +import java.util.LinkedList +import java.util.Queue +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.Executor +import java.util.concurrent.Executors +import java.util.concurrent.locks.Condition +import java.util.concurrent.locks.ReentrantLock +import kotlin.math.abs +import kotlin.math.min +import kotlin.math.pow +import kotlin.math.sign + +/** + * The default view for a ClusterManager. Markers are animated in and out of clusters. + */ +open class DefaultClusterRenderer @JvmOverloads constructor( + context: Context, + private val mMap: GoogleMap, + private val mClusterManager: ClusterManager, + private val mExecutor: Executor = Executors.newSingleThreadExecutor(), +) : ClusterRenderer { + private val mIconGenerator: IconGenerator = IconGenerator(context) + private val mDensity: Float = context.resources.displayMetrics.density + private var mAnimate: Boolean = true + private var mAnimationDurationMs: Long = 300 + private var mColoredCircleBackground: ShapeDrawable? = null + + /** + * Markers that are currently on the map. + */ + private var mMarkers: MutableSet = + Collections.newSetFromMap( + ConcurrentHashMap(), + ) + + /** + * Icons for each bucket. + */ + private val mIcons = SparseArray() + + /** + * Markers for single ClusterItems. + */ + private val mMarkerCache = MarkerCache() + + /** + * If cluster size is less than this size, display individual markers. + */ + var minClusterSize: Int = 4 + + /** + * The currently displayed set of clusters. + */ + private var mClusters: Set>? = null + + /** + * Markers for Clusters. + */ + private val mClusterMarkerCache = MarkerCache>() + + /** + * The target zoom level for the current set of clusters. + */ + private var mZoom: Float = 0f + + private val mViewModifier = ViewModifier() + + private var mClickListener: ClusterManager.OnClusterClickListener? = null + private var mInfoWindowClickListener: ClusterManager.OnClusterInfoWindowClickListener? = null + private var mInfoWindowLongClickListener: ClusterManager.OnClusterInfoWindowLongClickListener? = null + private var mItemClickListener: ClusterManager.OnClusterItemClickListener? = null + private var mItemInfoWindowClickListener: ClusterManager.OnClusterItemInfoWindowClickListener? = null + private var mItemInfoWindowLongClickListener: ClusterManager.OnClusterItemInfoWindowLongClickListener? = null + + init { + val squareTextView = makeSquareTextView(context) + mIconGenerator.setContentView(squareTextView) + mIconGenerator.setTextAppearance(R.style.amu_ClusterIcon_TextAppearance) + mIconGenerator.setBackground(makeClusterBackground()) + } + + override fun onAdd() { + mClusterManager.markerCollection.setOnMarkerClickListener { marker -> + mMarkerCache[marker]?.let { mItemClickListener?.onClusterItemClick(it) } ?: false + } + + mClusterManager.markerCollection.setOnInfoWindowClickListener { marker -> + mMarkerCache[marker]?.let { mItemInfoWindowClickListener?.onClusterItemInfoWindowClick(it) } + } + + mClusterManager.markerCollection.setOnInfoWindowLongClickListener { marker -> + mMarkerCache[marker]?.let { mItemInfoWindowLongClickListener?.onClusterItemInfoWindowLongClick(it) } + } + + mClusterManager.clusterMarkerCollection.setOnMarkerClickListener { marker -> + mClusterMarkerCache[marker]?.let { mClickListener?.onClusterClick(it) } ?: false + } + + mClusterManager.clusterMarkerCollection.setOnInfoWindowClickListener { marker -> + mClusterMarkerCache[marker]?.let { mInfoWindowClickListener?.onClusterInfoWindowClick(it) } + } + + mClusterManager.clusterMarkerCollection.setOnInfoWindowLongClickListener { marker -> + mClusterMarkerCache[marker]?.let { mInfoWindowLongClickListener?.onClusterInfoWindowLongClick(it) } + } + } + + override fun onRemove() { + mClusterManager.markerCollection.setOnMarkerClickListener(null) + mClusterManager.markerCollection.setOnInfoWindowClickListener(null) + mClusterManager.markerCollection.setOnInfoWindowLongClickListener(null) + mClusterManager.clusterMarkerCollection.setOnMarkerClickListener(null) + mClusterManager.clusterMarkerCollection.setOnInfoWindowClickListener(null) + mClusterManager.clusterMarkerCollection.setOnInfoWindowLongClickListener(null) + } + + private fun makeClusterBackground(): LayerDrawable { + mColoredCircleBackground = ShapeDrawable(OvalShape()) + val outline = ShapeDrawable(OvalShape()) + outline.paint.color = -0x7f000001 // Transparent white. + val background = LayerDrawable(arrayOf(outline, mColoredCircleBackground!!)) + val strokeWidth = (mDensity * 3).toInt() + background.setLayerInset(1, strokeWidth, strokeWidth, strokeWidth, strokeWidth) + return background + } + + private fun makeSquareTextView(context: Context): SquareTextView { + val squareTextView = SquareTextView(context) + val layoutParams = + ViewGroup.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ) + squareTextView.layoutParams = layoutParams + squareTextView.id = R.id.amu_text + val twelveDpi = (12 * mDensity).toInt() + squareTextView.setPadding(twelveDpi, twelveDpi, twelveDpi, twelveDpi) + return squareTextView + } + + override fun getColor(clusterSize: Int): Int { + val hueRange = 220f + val sizeRange = 300f + val size = min(clusterSize.toFloat(), sizeRange) + val hue = (sizeRange - size) * (sizeRange - size) / (sizeRange * sizeRange) * hueRange + return Color.HSVToColor( + floatArrayOf( + hue, + 1f, + .6f, + ), + ) + } + + @StyleRes + override fun getClusterTextAppearance(clusterSize: Int): Int { + return R.style.amu_ClusterIcon_TextAppearance // Default value + } + + protected open fun getClusterText(bucket: Int): String = + if (bucket < BUCKETS[0]) { + bucket.toString() + } else { + "$bucket+" + } + + /** + * Gets the "bucket" for a particular cluster. By default, uses the number of points within the + * cluster, bucketed to some set points. + */ + protected open fun getBucket(cluster: Cluster): Int { + val size = cluster.size + if (size <= BUCKETS[0]) { + return size + } + for (i in 0 until BUCKETS.size - 1) { + if (size < BUCKETS[i + 1]) { + return BUCKETS[i] + } + } + return BUCKETS[BUCKETS.size - 1] + } + + /** + * ViewModifier ensures only one re-rendering of the view occurs at a time, and schedules + * re-rendering, which is performed by the RenderTask. + */ + @SuppressLint("HandlerLeak") + private inner class ViewModifier : Handler(Looper.getMainLooper()) { + private var mViewModificationInProgress = false + private var mNextClusters: RenderTask? = null + + override fun handleMessage(msg: Message) { + if (msg.what == TASK_FINISHED) { + mViewModificationInProgress = false + if (mNextClusters != null) { + // Run the task that was queued up. + sendEmptyMessage(RUN_TASK) + } + return + } + removeMessages(RUN_TASK) + + if (mViewModificationInProgress) { + // Busy - wait for the callback. + return + } + + if (mNextClusters == null) { + // Nothing to do. + return + } + val projection = mMap.projection + + var renderTask: RenderTask? + synchronized(this) { + renderTask = mNextClusters + mNextClusters = null + mViewModificationInProgress = true + } + + renderTask!!.setCallback { sendEmptyMessage(TASK_FINISHED) } + renderTask!!.setProjection(projection) + renderTask!!.setMapZoom(mMap.cameraPosition.zoom) + mExecutor.execute(renderTask) + } + + fun queue(clusters: Set>) { + synchronized(this) { + // Overwrite any pending cluster tasks - we don't care about intermediate states. + mNextClusters = RenderTask(clusters) + } + sendEmptyMessage(RUN_TASK) + } + } + + /** + * Determine whether the cluster should be rendered as individual markers or a cluster. + * + * @param cluster cluster to examine for rendering + * @return true if the provided cluster should be rendered as a single marker on the map, false + * if the items within this cluster should be rendered as individual markers instead. + */ + protected open fun shouldRenderAsCluster(cluster: Cluster): Boolean = cluster.size >= minClusterSize + + /** + * Determines if the new clusters should be rendered on the map, given the old clusters. This + * method is primarily for optimization of performance, and the default implementation simply + * checks if the new clusters are equal to the old clusters, and if so, it returns false. + * + * + * However, there are cases where you may want to re-render the clusters even if they didn't + * change. For example, if you want a cluster with one item to render as a cluster above + * a certain zoom level and as a marker below a certain zoom level (even if the contents of the + * clusters themselves did not change). In this case, you could check the zoom level in an + * implementation of this method and if that zoom level threshold is crossed return true, else + * `return super.shouldRender(oldClusters, newClusters)`. + * + * + * Note that always returning true from this method could potentially have negative performance + * implications as clusters will be re-rendered on each pass even if they don't change. + * + * @param oldClusters The clusters from the previous iteration of the clustering algorithm + * @param newClusters The clusters from the current iteration of the clustering algorithm + * @return true if the new clusters should be rendered on the map, and false if they should not. This + * method is primarily for optimization of performance, and the default implementation simply + * checks if the new clusters are equal to the old clusters, and if so, it returns false. + */ + protected open fun shouldRender( + oldClusters: Set>, + newClusters: Set>, + ): Boolean = newClusters != oldClusters + + /** + * Transforms the current view (represented by DefaultClusterRenderer.mClusters and DefaultClusterRenderer.mZoom) to a + * new zoom level and set of clusters. + * + * + * This must be run off the UI thread. Work is coordinated in the RenderTask, then queued up to + * be executed by a MarkerModifier. + * + * + * There are three stages for the render: + * + * + * 1. Markers are added to the map + * + * + * 2. Markers are animated to their final position + * + * + * 3. Any old markers are removed from the map + * + * + * When zooming in, markers are animated out from the nearest existing cluster. When zooming + * out, existing clusters are animated to the nearest new cluster. + */ + private inner class RenderTask( + val clusters: Set>, + ) : Runnable { + private var mCallback: Runnable? = null + private var mProjection: Projection? = null + private var mSphericalMercatorProjection: SphericalMercatorProjection? = null + private var mMapZoom: Float = 0f + + /** + * A callback to be run when all work has been completed. + * + * @param callback + */ + fun setCallback(callback: Runnable) { + mCallback = callback + } + + fun setProjection(projection: Projection) { + this.mProjection = projection + } + + fun setMapZoom(zoom: Float) { + this.mMapZoom = zoom + this.mSphericalMercatorProjection = + SphericalMercatorProjection( + 256 * 2.0.pow(min(zoom.toDouble(), mZoom.toDouble())), + ) + } + + @SuppressLint("NewApi") + override fun run() { + if (!shouldRender(immutableOf(this@DefaultClusterRenderer.mClusters), immutableOf(clusters))) { + mCallback!!.run() + return + } + + val markerModifier = MarkerModifier() + + val zoom = mMapZoom + val zoomingIn = zoom > mZoom + val zoomDelta = zoom - mZoom + + val markersToRemove = mMarkers + // Prevent crashes: https://issuetracker.google.com/issues/35827242 + var visibleBounds: LatLngBounds + try { + visibleBounds = mProjection!!.visibleRegion.latLngBounds + } catch (e: Exception) { + e.printStackTrace() + visibleBounds = + LatLngBounds + .builder() + .include(LatLng(0.0, 0.0)) + .build() + } + // TODO: Add some padding, so that markers can animate in from off-screen. + + // Find all of the existing clusters that are on-screen. These are candidates for + // markers to animate from. + var existingClustersOnScreen: MutableList? = null + if (this@DefaultClusterRenderer.mClusters != null && mAnimate) { + existingClustersOnScreen = ArrayList() + for (c in this@DefaultClusterRenderer.mClusters!!) { + if (shouldRenderAsCluster(c) && visibleBounds.contains(c.position)) { + val point = mSphericalMercatorProjection!!.toPoint(c.position) + existingClustersOnScreen.add(point) + } + } + } + + // Create the new markers and animate them to their new positions. + val newMarkers = + Collections.newSetFromMap( + ConcurrentHashMap(), + ) + for (c in clusters) { + val onScreen = visibleBounds.contains(c.position) + if (zoomingIn && onScreen && mAnimate) { + val point = mSphericalMercatorProjection!!.toPoint(c.position) + val closest = findClosestCluster(existingClustersOnScreen, point) + if (closest != null) { + val animateTo = mSphericalMercatorProjection!!.toLatLng(closest) + markerModifier.add(true, CreateMarkerTask(c, newMarkers, animateTo)) + } else { + markerModifier.add(true, CreateMarkerTask(c, newMarkers, null)) + } + } else { + markerModifier.add(onScreen, CreateMarkerTask(c, newMarkers, null)) + } + } + + // Wait for all markers to be added. + markerModifier.waitUntilFree() + + // Don't remove any markers that were just added. This is basically anything that had + // a hit in the MarkerCache. + markersToRemove.removeAll(newMarkers) + + // Find all of the new clusters that were added on-screen. These are candidates for + // markers to animate from. + var newClustersOnScreen: MutableList? = null + if (mAnimate) { + newClustersOnScreen = ArrayList() + for (c in clusters) { + if (shouldRenderAsCluster(c) && visibleBounds.contains(c.position)) { + val p = mSphericalMercatorProjection!!.toPoint(c.position) + newClustersOnScreen.add(p) + } + } + } + + // Remove the old markers, animating them into clusters if zooming out. + for (marker in markersToRemove) { + val onScreen = visibleBounds.contains(marker.position) + // Don't animate when zooming out more than 3 zoom levels. + // TODO: drop animation based on speed of device & number of markers to animate. + if (!zoomingIn && zoomDelta > -3 && onScreen && mAnimate) { + val point = mSphericalMercatorProjection!!.toPoint(marker.position) + val closest = findClosestCluster(newClustersOnScreen, point) + if (closest != null) { + val animateTo = mSphericalMercatorProjection!!.toLatLng(closest) + markerModifier.animateThenRemove(marker, marker.position, animateTo!!) + } else { + markerModifier.remove(true, marker.marker) + } + } else { + markerModifier.remove(onScreen, marker.marker) + } + } + + markerModifier.waitUntilFree() + + mMarkers = newMarkers + this@DefaultClusterRenderer.mClusters = clusters + mZoom = zoom + + mCallback!!.run() + } + } + + override fun onClustersChanged(clusters: Set>) { + mViewModifier.queue(clusters) + } + + override fun setOnClusterClickListener(listener: ClusterManager.OnClusterClickListener?) { + mClickListener = listener + } + + override fun setOnClusterInfoWindowClickListener(listener: ClusterManager.OnClusterInfoWindowClickListener?) { + mInfoWindowClickListener = listener + } + + override fun setOnClusterInfoWindowLongClickListener(listener: ClusterManager.OnClusterInfoWindowLongClickListener?) { + mInfoWindowLongClickListener = listener + } + + override fun setOnClusterItemClickListener(listener: ClusterManager.OnClusterItemClickListener?) { + mItemClickListener = listener + } + + override fun setOnClusterItemInfoWindowClickListener(listener: ClusterManager.OnClusterItemInfoWindowClickListener?) { + mItemInfoWindowClickListener = listener + } + + override fun setOnClusterItemInfoWindowLongClickListener(listener: ClusterManager.OnClusterItemInfoWindowLongClickListener?) { + mItemInfoWindowLongClickListener = listener + } + + override fun setAnimation(animate: Boolean) { + mAnimate = animate + } + + /** + * [.setAnimationDuration] The default duration is 300 milliseconds. + * + * @param animationDurationMs long: The length of the animation, in milliseconds. This value cannot be negative. + */ + override fun setAnimationDuration(animationDurationMs: Long) { + mAnimationDurationMs = animationDurationMs + } + + private fun immutableOf(clusters: Set>?): Set> = if (clusters != null) Collections.unmodifiableSet(clusters) else Collections.emptySet() + + private fun findClosestCluster( + markers: List?, + point: Point, + ): Point? { + if (markers == null || markers.isEmpty()) return null + + val maxDistance = mClusterManager.algorithm.maxDistanceBetweenClusteredItems + var minDistSquared = (maxDistance * maxDistance).toDouble() + var closest: Point? = null + for (candidate in markers) { + val dist = distanceSquared(candidate, point) + if (dist < minDistSquared) { + closest = candidate + minDistSquared = dist + } + } + return closest + } + + /** + * Handles all markerWithPosition manipulations on the map. Work (such as adding, removing, or + * animating a markerWithPosition) is performed while trying not to block the rest of the app's + * UI. + */ + @SuppressLint("HandlerLeak") + private inner class MarkerModifier : + Handler(Looper.getMainLooper()), + MessageQueue.IdleHandler { + private val lock = ReentrantLock() + private val busyCondition = lock.newCondition() + + private val mCreateMarkerTasks: Queue = LinkedList() + private val mOnScreenCreateMarkerTasks: Queue = LinkedList() + private val mRemoveMarkerTasks: Queue = LinkedList() + private val mOnScreenRemoveMarkerTasks: Queue = LinkedList() + private val mAnimationTasks: Queue = LinkedList() + + /** + * Whether the idle listener has been added to the UI thread's MessageQueue. + */ + private var mListenerAdded: Boolean = false + + /** + * Creates markers for a cluster some time in the future. + * + * @param priority whether this operation should have priority. + */ + fun add( + priority: Boolean, + c: CreateMarkerTask, + ) { + lock.lock() + sendEmptyMessage(BLANK) + if (priority) { + mOnScreenCreateMarkerTasks.add(c) + } else { + mCreateMarkerTasks.add(c) + } + lock.unlock() + } + + /** + * Removes a markerWithPosition some time in the future. + * + * @param priority whether this operation should have priority. + * @param m the markerWithPosition to remove. + */ + fun remove( + priority: Boolean, + m: Marker, + ) { + lock.lock() + sendEmptyMessage(BLANK) + if (priority) { + mOnScreenRemoveMarkerTasks.add(m) + } else { + mRemoveMarkerTasks.add(m) + } + lock.unlock() + } + + /** + * Animates a markerWithPosition some time in the future. + * + * @param marker the markerWithPosition to animate. + * @param from the position to animate from. + * @param to the position to animate to. + */ + fun animate( + marker: MarkerWithPosition, + from: LatLng, + to: LatLng, + ) { + lock.lock() + mAnimationTasks.add(AnimationTask(marker, from, to)) + lock.unlock() + } + + /** + * Animates a markerWithPosition some time in the future, and removes it when the animation + * is complete. + * + * @param marker the markerWithPosition to animate. + * @param from the position to animate from. + * @param to the position to animate to. + */ + fun animateThenRemove( + marker: MarkerWithPosition, + from: LatLng, + to: LatLng, + ) { + lock.lock() + val animationTask = AnimationTask(marker, from, to) + animationTask.removeOnAnimationComplete(mClusterManager.markerManager) + mAnimationTasks.add(animationTask) + lock.unlock() + } + + override fun handleMessage(msg: Message) { + if (!mListenerAdded) { + Looper.myQueue().addIdleHandler(this) + mListenerAdded = true + } + removeMessages(BLANK) + + lock.lock() + try { + // Perform up to 10 tasks at once. + // Consider only performing 10 remove tasks, not adds and animations. + // Removes are relatively slow and are much better when batched. + for (i in 0..9) { + performNextTask() + } + + if (!isBusy) { + mListenerAdded = false + Looper.myQueue().removeIdleHandler(this) + // Signal any other threads that are waiting. + busyCondition.signalAll() + } else { + // Sometimes the idle queue may not be called - schedule up some work regardless + // of whether the UI thread is busy or not. + // TODO: try to remove this. + sendEmptyMessageDelayed(BLANK, 10) + } + } finally { + lock.unlock() + } + } + + /** + * Perform the next task. Prioritise any on-screen work. + */ + private fun performNextTask() { + if (!mOnScreenRemoveMarkerTasks.isEmpty()) { + removeMarker(mOnScreenRemoveMarkerTasks.poll()) + } else if (!mAnimationTasks.isEmpty()) { + mAnimationTasks.poll().perform() + } else if (!mOnScreenCreateMarkerTasks.isEmpty()) { + mOnScreenCreateMarkerTasks.poll().perform(this) + } else if (!mCreateMarkerTasks.isEmpty()) { + mCreateMarkerTasks.poll().perform(this) + } else if (!mRemoveMarkerTasks.isEmpty()) { + removeMarker(mRemoveMarkerTasks.poll()) + } + } + + private fun removeMarker(m: Marker?) { + mMarkerCache.remove(m) + mClusterMarkerCache.remove(m) + mClusterManager.markerManager.remove(m) + } + + /** + * @return true if there is still work to be processed. + */ + val isBusy: Boolean + get() { + try { + lock.lock() + return !( + mCreateMarkerTasks.isEmpty() && mOnScreenCreateMarkerTasks.isEmpty() && + mOnScreenRemoveMarkerTasks.isEmpty() && mRemoveMarkerTasks.isEmpty() && + mAnimationTasks.isEmpty() + ) + } finally { + lock.unlock() + } + } + + /** + * Blocks the calling thread until all work has been processed. + */ + fun waitUntilFree() { + while (isBusy) { + // Sometimes the idle queue may not be called - schedule up some work regardless + // of whether the UI thread is busy or not. + // TODO: try to remove this. + sendEmptyMessage(BLANK) + lock.lock() + try { + if (isBusy) { + busyCondition.await() + } + } catch (e: InterruptedException) { + throw RuntimeException(e) + } finally { + lock.unlock() + } + } + } + + override fun queueIdle(): Boolean { + // When the UI is not busy, schedule some work. + sendEmptyMessage(BLANK) + return true + } + } + + /** + * A cache of markers representing individual ClusterItems. + */ + private class MarkerCache { + private val mCache: MutableMap = HashMap() + private val mCacheReverse: MutableMap = HashMap() + + operator fun get(item: T): Marker? = mCache[item] + + operator fun get(m: Marker): T? = mCacheReverse[m] + + fun put( + item: T, + m: Marker, + ) { + mCache[item] = m + mCacheReverse[m] = item + } + + fun remove(m: Marker?) { + val item = mCacheReverse[m] + mCacheReverse.remove(m) + mCache.remove(item) + } + } + + /** + * Called before the marker for a ClusterItem is added to the map. The default implementation + * sets the marker and snippet text based on the respective item text if they are both + * available, otherwise it will set the title if available, and if not it will set the marker + * title to the item snippet text if that is available. + * + * + * The first time [ClusterManager.cluster] is invoked on a set of items + * [.onBeforeClusterItemRendered] will be called and + * [.onClusterItemUpdated] will not be called. + * If an item is removed and re-added (or updated) and [ClusterManager.cluster] is + * invoked again, then [.onClusterItemUpdated] will be called and + * [.onBeforeClusterItemRendered] will not be called. + * + * @param item item to be rendered + * @param markerOptions the markerOptions representing the provided item + */ + protected open fun onBeforeClusterItemRendered( + item: T, + markerOptions: MarkerOptions, + ) { + if (item.title != null && item.snippet != null) { + markerOptions.title(item.title) + markerOptions.snippet(item.snippet) + } else if (item.title != null) { + markerOptions.title(item.title) + } else if (item.snippet != null) { + markerOptions.title(item.snippet) + } + if (item.zIndex != null) { + markerOptions.zIndex(item.zIndex!!) + } + } + + /** + * Called when a cached marker for a ClusterItem already exists on the map so the marker may + * be updated to the latest item values. Default implementation updates the title and snippet + * of the marker if they have changed and refreshes the info window of the marker if it is open. + * Note that the contents of the item may not have changed since the cached marker was created - + * implementations of this method are responsible for checking if something changed (if that + * matters to the implementation). + * + * + * The first time [ClusterManager.cluster] is invoked on a set of items + * [.onBeforeClusterItemRendered] will be called and + * [.onClusterItemUpdated] will not be called. + * If an item is removed and re-added (or updated) and [ClusterManager.cluster] is + * invoked again, then [.onClusterItemUpdated] will be called and + * [.onBeforeClusterItemRendered] will not be called. + * + * @param item item being updated + * @param marker cached marker that contains a potentially previous state of the item. + */ + protected open fun onClusterItemUpdated( + item: T, + marker: Marker, + ) { + var changed = false + // Update marker text if the item text changed - same logic as adding marker in CreateMarkerTask.perform() + if (item.title != null && item.snippet != null) { + if (item.title != marker.title) { + marker.title = item.title + changed = true + } + if (item.snippet != marker.snippet) { + marker.snippet = item.snippet + changed = true + } + } else if (item.snippet != null && item.snippet != marker.title) { + marker.title = item.snippet + changed = true + } else if (item.title != null && item.title != marker.title) { + marker.title = item.title + changed = true + } + // Update marker position if the item changed position + if (marker.position != item.position) { + marker.position = item.position + if (item.zIndex != null) { + marker.zIndex = item.zIndex!! + } + changed = true + } + if (changed && marker.isInfoWindowShown) { + // Force a refresh of marker info window contents + marker.showInfoWindow() + } + } + + /** + * Called before the marker for a Cluster is added to the map. + * The default implementation draws a circle with a rough count of the number of items. + * + * + * The first time [ClusterManager.cluster] is invoked on a set of items + * [.onBeforeClusterRendered] will be called and + * [.onClusterUpdated] will not be called. If an item is removed and + * re-added (or updated) and [ClusterManager.cluster] is invoked + * again, then [.onClusterUpdated] will be called and + * [.onBeforeClusterRendered] will not be called. + * + * @param cluster cluster to be rendered + * @param markerOptions markerOptions representing the provided cluster + */ + protected open fun onBeforeClusterRendered( + cluster: Cluster, + markerOptions: MarkerOptions, + ) { + // TODO: consider adding anchor(.5, .5) (Individual markers will overlap more often) + markerOptions.icon(getDescriptorForCluster(cluster)) + val items = ArrayList(cluster.items) + if (!items.isEmpty()) { + val zIndex = items[0].zIndex + if (zIndex != null) { + markerOptions.zIndex(zIndex) + } + } + } + + /** + * Gets a BitmapDescriptor for the given cluster that contains a rough count of the number of + * items. Used to set the cluster marker icon in the default implementations of + * [.onBeforeClusterRendered] and + * [.onClusterUpdated]. + * + * @param cluster cluster to get BitmapDescriptor for + * @return a BitmapDescriptor for the marker icon for the given cluster that contains a rough + * count of the number of items. + */ + protected open fun getDescriptorForCluster(cluster: Cluster): BitmapDescriptor { + val bucket = getBucket(cluster) + var descriptor = mIcons[bucket] + if (descriptor == null) { + mColoredCircleBackground!!.paint.color = getColor(bucket) + mIconGenerator.setTextAppearance(getClusterTextAppearance(bucket)) + descriptor = BitmapDescriptorFactory.fromBitmap(mIconGenerator.makeIcon(getClusterText(bucket))) + mIcons.put(bucket, descriptor) + } + return descriptor + } + + /** + * Called after the marker for a Cluster has been added to the map. + * + * @param cluster the cluster that was just added to the map + * @param marker the marker representing the cluster that was just added to the map + */ + protected open fun onClusterRendered( + cluster: Cluster, + marker: Marker, + ) {} + + /** + * Called when a cached marker for a Cluster already exists on the map so the marker may + * be updated to the latest cluster values. Default implementation updated the icon with a + * circle with a rough count of the number of items. Note that the contents of the cluster may + * not have changed since the cached marker was created - implementations of this method are + * responsible for checking if something changed (if that matters to the implementation). + * + * + * The first time [ClusterManager.cluster] is invoked on a set of items + * [.onBeforeClusterRendered] will be called and + * [.onClusterUpdated] will not be called. If an item is removed and + * re-added (or updated) and [ClusterManager.cluster] is invoked + * again, then [.onClusterUpdated] will be called and + * [.onBeforeClusterRendered] will not be called. + * + * @param cluster cluster being updated + * @param marker cached marker that contains a potentially previous state of the cluster + */ + protected open fun onClusterUpdated( + cluster: Cluster, + marker: Marker, + ) { + // TODO: consider adding anchor(.5, .5) (Individual markers will overlap more often) + marker.setIcon(getDescriptorForCluster(cluster)) + } + + /** + * Called after the marker for a ClusterItem has been added to the map. + * + * @param clusterItem the item that was just added to the map + * @param marker the marker representing the item that was just added to the map + */ + protected open fun onClusterItemRendered( + clusterItem: T, + marker: Marker, + ) {} + + /** + * Get the marker from a ClusterItem + * + * @param clusterItem ClusterItem which you will obtain its marker + * @return a marker from a ClusterItem or null if it does not exists + */ + fun getMarker(clusterItem: T): Marker? = mMarkerCache[clusterItem] + + /** + * Get the ClusterItem from a marker + * + * @param marker which you will obtain its ClusterItem + * @return a ClusterItem from a marker or null if it does not exists + */ + fun getClusterItem(marker: Marker): T? = mMarkerCache[marker] + + /** + * Get the marker from a Cluster + * + * @param cluster which you will obtain its marker + * @return a marker from a cluster or null if it does not exists + */ + fun getMarker(cluster: Cluster): Marker? = mClusterMarkerCache[cluster] + + /** + * Get the Cluster from a marker + * + * @param marker which you will obtain its Cluster + * @return a Cluster from a marker or null if it does not exists + */ + fun getCluster(marker: Marker): Cluster? = mClusterMarkerCache[marker] + + /** + * Creates markerWithPosition(s) for a particular cluster, animating it if necessary. + */ + private inner class CreateMarkerTask( + private val cluster: Cluster, + private val newMarkers: MutableSet, + private val animateFrom: LatLng?, + ) { + fun perform(markerModifier: MarkerModifier) { + // Don't show small clusters. Render the markers inside, instead. + if (!shouldRenderAsCluster(cluster)) { + for (item in cluster.items) { + var marker = mMarkerCache[item] + var markerWithPosition: MarkerWithPosition + if (marker == null) { + val markerOptions = MarkerOptions() + if (animateFrom != null) { + markerOptions.position(animateFrom) + } else { + markerOptions.position(item.position) + if (item.zIndex != null) { + markerOptions.zIndex(item.zIndex!!) + } + } + onBeforeClusterItemRendered(item, markerOptions) + marker = mClusterManager.markerCollection.addMarker(markerOptions) + markerWithPosition = MarkerWithPosition(marker) + mMarkerCache.put(item, marker) + if (animateFrom != null) { + markerModifier.animate(markerWithPosition, animateFrom, item.position) + } + } else { + markerWithPosition = MarkerWithPosition(marker) + onClusterItemUpdated(item, marker) + } + onClusterItemRendered(item, marker) + newMarkers.add(markerWithPosition) + } + return + } + + var marker = mClusterMarkerCache[cluster] + var markerWithPosition: MarkerWithPosition + if (marker == null) { + val markerOptions = MarkerOptions().position(if (animateFrom == null) cluster.position else animateFrom) + onBeforeClusterRendered(cluster, markerOptions) + marker = mClusterManager.clusterMarkerCollection.addMarker(markerOptions) + mClusterMarkerCache.put(cluster, marker) + markerWithPosition = MarkerWithPosition(marker) + if (animateFrom != null) { + markerModifier.animate(markerWithPosition, animateFrom, cluster.position) + } + } else { + markerWithPosition = MarkerWithPosition(marker) + onClusterUpdated(cluster, marker) + } + onClusterRendered(cluster, marker) + newMarkers.add(markerWithPosition) + } + } + + /** + * A Marker and its position. [Marker.getPosition] must be called from the UI thread, so this + * object allows lookup from other threads. + */ + private class MarkerWithPosition( + val marker: Marker, + ) { + var position: LatLng = marker.position + + override fun equals(other: Any?): Boolean = + if (other is MarkerWithPosition) { + marker == other.marker + } else { + false + } + + override fun hashCode(): Int = marker.hashCode() + } + + /** + * Animates a markerWithPosition from one position to another. TODO: improve performance for + * slow devices (e.g. Nexus S). + */ + private inner class AnimationTask( + private val markerWithPosition: MarkerWithPosition, + private val from: LatLng, + private val to: LatLng, + ) : AnimatorListenerAdapter(), + ValueAnimator.AnimatorUpdateListener { + private val marker: Marker = markerWithPosition.marker + private var mRemoveOnComplete: Boolean = false + private var mMarkerManager: MarkerManager? = null + + fun perform() { + val valueAnimator = ValueAnimator.ofFloat(0.0f, 1.0f) + valueAnimator.interpolator = ANIMATION_INTERP + valueAnimator.duration = mAnimationDurationMs + valueAnimator.addUpdateListener(this) + valueAnimator.addListener(this) + valueAnimator.start() + } + + override fun onAnimationEnd(animation: Animator) { + if (mRemoveOnComplete) { + mMarkerCache.remove(marker) + mClusterMarkerCache.remove(marker) + mMarkerManager!!.remove(marker) + } + markerWithPosition.position = to + } + + fun removeOnAnimationComplete(markerManager: MarkerManager) { + mMarkerManager = markerManager + mRemoveOnComplete = true + } + + override fun onAnimationUpdate(valueAnimator: ValueAnimator) { + val fraction = valueAnimator.animatedFraction + val lat = (to.latitude - from.latitude) * fraction + from.latitude + var lngDelta = to.longitude - from.longitude + + // Take the shortest path across the 180th meridian. + if (abs(lngDelta) > 180) { + lngDelta -= sign(lngDelta) * 360 + } + val lng = lngDelta * fraction + from.longitude + val position = LatLng(lat, lng) + marker.position = position + } + } + + companion object { + private val BUCKETS = intArrayOf(10, 20, 50, 100, 200, 500, 1000) + private val ANIMATION_INTERP: TimeInterpolator = DecelerateInterpolator() + private const val RUN_TASK = 0 + private const val TASK_FINISHED = 1 + private const val BLANK = 0 + + private fun distanceSquared( + a: Point, + b: Point, + ): Double = (a.x - b.x) * (a.x - b.x) + (a.y - b.y) * (a.y - b.y) + } +} diff --git a/library/src/main/java/com/google/maps/android/geometry/Bounds.kt b/clustering/src/main/java/com/google/maps/android/geometry/Bounds.kt similarity index 54% rename from library/src/main/java/com/google/maps/android/geometry/Bounds.kt rename to clustering/src/main/java/com/google/maps/android/geometry/Bounds.kt index e77fa75e2..cafd350b2 100755 --- a/library/src/main/java/com/google/maps/android/geometry/Bounds.kt +++ b/clustering/src/main/java/com/google/maps/android/geometry/Bounds.kt @@ -1,5 +1,5 @@ /* - * Copyright 2025 Google LLC + * 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. @@ -13,8 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - - package com.google.maps.android.geometry /** @@ -24,7 +22,7 @@ class Bounds( @JvmField val minX: Double, @JvmField val maxX: Double, @JvmField val minY: Double, - @JvmField val maxY: Double + @JvmField val maxY: Double, ) { @JvmField val midX: Double = (minX + maxX) / 2 @@ -32,23 +30,21 @@ class Bounds( @JvmField val midY: Double = (minY + maxY) / 2 - fun contains(x: Double, y: Double): Boolean { - return minX <= x && x <= maxX && minY <= y && y <= maxY - } + fun contains( + x: Double, + y: Double, + ): Boolean = minX <= x && x <= maxX && minY <= y && y <= maxY - fun contains(point: Point): Boolean { - return contains(point.x, point.y) - } + fun contains(point: Point): Boolean = contains(point.x, point.y) - fun intersects(minX: Double, maxX: Double, minY: Double, maxY: Double): Boolean { - return minX < this.maxX && this.minX < maxX && minY < this.maxY && this.minY < maxY - } + fun intersects( + minX: Double, + maxX: Double, + minY: Double, + maxY: Double, + ): Boolean = minX < this.maxX && this.minX < maxX && minY < this.maxY && this.minY < maxY - fun intersects(bounds: Bounds): Boolean { - return intersects(bounds.minX, bounds.maxX, bounds.minY, bounds.maxY) - } + fun intersects(bounds: Bounds): Boolean = intersects(bounds.minX, bounds.maxX, bounds.minY, bounds.maxY) - fun contains(bounds: Bounds): Boolean { - return bounds.minX >= minX && bounds.maxX <= maxX && bounds.minY >= minY && bounds.maxY <= maxY - } -} \ No newline at end of file + fun contains(bounds: Bounds): Boolean = bounds.minX >= minX && bounds.maxX <= maxX && bounds.minY >= minY && bounds.maxY <= maxY +} diff --git a/library/src/main/java/com/google/maps/android/geometry/Point.kt b/clustering/src/main/java/com/google/maps/android/geometry/Point.kt similarity index 74% rename from library/src/main/java/com/google/maps/android/geometry/Point.kt rename to clustering/src/main/java/com/google/maps/android/geometry/Point.kt index 70de385c4..14038e200 100644 --- a/library/src/main/java/com/google/maps/android/geometry/Point.kt +++ b/clustering/src/main/java/com/google/maps/android/geometry/Point.kt @@ -1,11 +1,11 @@ /* - * Copyright 2025 Google LLC. + * 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 + * 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, @@ -13,13 +13,13 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - package com.google.maps.android.geometry -open class Point(@JvmField val x: Double, @JvmField val y: Double) { - override fun toString(): String { - return "Point(x=$x, y=$y)" - } +open class Point( + @JvmField val x: Double, + @JvmField val y: Double, +) { + override fun toString(): String = "Point(x=$x, y=$y)" override fun equals(other: Any?): Boolean { if (this === other) return true @@ -39,7 +39,8 @@ open class Point(@JvmField val x: Double, @JvmField val y: Double) { return result } - fun copy(x: Double = this.x, y: Double = this.y): Point { - return Point(x, y) - } -} \ No newline at end of file + fun copy( + x: Double = this.x, + y: Double = this.y, + ): Point = Point(x, y) +} diff --git a/library/src/main/java/com/google/maps/android/projection/Point.kt b/clustering/src/main/java/com/google/maps/android/projection/Point.kt similarity index 81% rename from library/src/main/java/com/google/maps/android/projection/Point.kt rename to clustering/src/main/java/com/google/maps/android/projection/Point.kt index 9cecbb098..059f497c5 100644 --- a/library/src/main/java/com/google/maps/android/projection/Point.kt +++ b/clustering/src/main/java/com/google/maps/android/projection/Point.kt @@ -1,11 +1,11 @@ /* - * Copyright 2013 Google Inc. + * 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 + * 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, @@ -13,11 +13,13 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - package com.google.maps.android.projection /** * @deprecated since 0.2. Use [com.google.maps.android.geometry.Point] instead. */ @Deprecated("since 0.2. Use com.google.maps.android.geometry.Point instead.", ReplaceWith("com.google.maps.android.geometry.Point(x, y)")) -public class Point(x: Double, y: Double) : com.google.maps.android.geometry.Point(x, y) \ No newline at end of file +public class Point( + x: Double, + y: Double, +) : com.google.maps.android.geometry.Point(x, y) diff --git a/clustering/src/main/java/com/google/maps/android/projection/SphericalMercatorProjection.kt b/clustering/src/main/java/com/google/maps/android/projection/SphericalMercatorProjection.kt new file mode 100644 index 000000000..071ebbd55 --- /dev/null +++ b/clustering/src/main/java/com/google/maps/android/projection/SphericalMercatorProjection.kt @@ -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. + */ +package com.google.maps.android.projection + +import com.google.android.gms.maps.model.LatLng +import kotlin.math.* + +class SphericalMercatorProjection( + private val worldWidth: Double, +) { + fun toPoint(latLng: LatLng): Point { + val x = latLng.longitude / 360 + .5 + val siny = sin(Math.toRadians(latLng.latitude)) + val y = 0.5 * ln((1 + siny) / (1 - siny)) / -(2 * PI) + .5 + + return Point(x * worldWidth, y * worldWidth) + } + + fun toLatLng(point: com.google.maps.android.geometry.Point): LatLng { + val x = point.x / worldWidth - 0.5 + val lng = x * 360 + + val y = .5 - (point.y / worldWidth) + val lat = 90 - Math.toDegrees(atan(exp(-y * 2 * PI)) * 2) + + return LatLng(lat, lng) + } +} diff --git a/clustering/src/main/java/com/google/maps/android/quadtree/PointQuadTree.kt b/clustering/src/main/java/com/google/maps/android/quadtree/PointQuadTree.kt new file mode 100644 index 000000000..2227e5c9d --- /dev/null +++ b/clustering/src/main/java/com/google/maps/android/quadtree/PointQuadTree.kt @@ -0,0 +1,206 @@ +/* + * 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.quadtree + +import com.google.maps.android.geometry.Bounds +import com.google.maps.android.geometry.Point + +/** + * A quad tree which tracks items with a Point geometry. + * See http://en.wikipedia.org/wiki/Quadtree for details on the data structure. + * This class is not thread safe. + */ +class PointQuadTree + @JvmOverloads + constructor( + private val mBounds: Bounds, + private val mDepth: Int = 0, + ) { + interface Item { + val point: Point + } + + /** + * The elements inside this quad, if any. + */ + private var mItems: MutableSet? = null + + /** + * Child quads. + */ + private var mChildren: MutableList>? = null + + constructor(minX: Double, maxX: Double, minY: Double, maxY: Double) : + this(Bounds(minX, maxX, minY, maxY)) + + /** + * Insert an item. + */ + fun add(item: T) { + val point = item.point + if (this.mBounds.contains(point.x, point.y)) { + insert(point.x, point.y, item) + } + } + + private fun insert( + x: Double, + y: Double, + item: T, + ) { + if (this.mChildren != null) { + if (y < mBounds.midY) { + if (x < mBounds.midX) { // top left + mChildren!![0].insert(x, y, item) + } else { // top right + mChildren!![1].insert(x, y, item) + } + } else { + if (x < mBounds.midX) { // bottom left + mChildren!![2].insert(x, y, item) + } else { + mChildren!![3].insert(x, y, item) + } + } + return + } + if (mItems == null) { + mItems = LinkedHashSet() + } + mItems!!.add(item) + if (mItems!!.size > MAX_ELEMENTS && mDepth < MAX_DEPTH) { + split() + } + } + + /** + * Split this quad. + */ + private fun split() { + mChildren = ArrayList(4) + mChildren!!.add(PointQuadTree(Bounds(mBounds.minX, mBounds.midX, mBounds.minY, mBounds.midY), mDepth + 1)) + mChildren!!.add(PointQuadTree(Bounds(mBounds.midX, mBounds.maxX, mBounds.minY, mBounds.midY), mDepth + 1)) + mChildren!!.add(PointQuadTree(Bounds(mBounds.minX, mBounds.midX, mBounds.midY, mBounds.maxY), mDepth + 1)) + mChildren!!.add(PointQuadTree(Bounds(mBounds.midX, mBounds.maxX, mBounds.midY, mBounds.maxY), mDepth + 1)) + + val items = mItems + mItems = null + + if (items != null) { + for (item in items) { + // re-insert items into child quads. + insert(item.point.x, item.point.y, item) + } + } + } + + /** + * Remove the given item from the set. + * + * @return whether the item was removed. + */ + fun remove(item: T): Boolean { + val point = item.point + return if (this.mBounds.contains(point.x, point.y)) { + remove(point.x, point.y, item) + } else { + false + } + } + + private fun remove( + x: Double, + y: Double, + item: T, + ): Boolean = + if (this.mChildren != null) { + if (y < mBounds.midY) { + if (x < mBounds.midX) { // top left + mChildren!![0].remove(x, y, item) + } else { // top right + mChildren!![1].remove(x, y, item) + } + } else { + if (x < mBounds.midX) { // bottom left + mChildren!![2].remove(x, y, item) + } else { + mChildren!![3].remove(x, y, item) + } + } + } else { + if (mItems == null) { + false + } else { + mItems!!.remove(item) + } + } + + /** + * Removes all points from the quadTree + */ + fun clear() { + mChildren = null + if (mItems != null) { + mItems!!.clear() + } + } + + /** + * Search for all items within a given bounds. + */ + fun search(searchBounds: Bounds): Collection { + val results: MutableList = ArrayList() + search(searchBounds, results) + return results + } + + private fun search( + searchBounds: Bounds, + results: MutableCollection, + ) { + if (!mBounds.intersects(searchBounds)) { + return + } + + if (this.mChildren != null) { + for (quad in mChildren!!) { + quad.search(searchBounds, results) + } + } else if (mItems != null) { + if (searchBounds.contains(mBounds)) { + results.addAll(mItems!!) + } else { + for (item in mItems!!) { + if (searchBounds.contains(item.point)) { + results.add(item) + } + } + } + } + } + + companion object { + /** + * Maximum number of elements to store in a quad before splitting. + */ + private const val MAX_ELEMENTS = 50 + + /** + * Maximum depth. + */ + private const val MAX_DEPTH = 40 + } + } 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 new file mode 100644 index 000000000..7661da02a --- /dev/null +++ b/clustering/src/test/java/com/google/maps/android/clustering/QuadItemTest.java @@ -0,0 +1,140 @@ +/* + * 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++; + } + } + + private static class TestingItem implements ClusterItem { + private final 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); + } + + @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/library/src/test/java/com/google/maps/android/clustering/StaticClusterTest.java b/clustering/src/test/java/com/google/maps/android/clustering/StaticClusterTest.java similarity index 52% rename from library/src/test/java/com/google/maps/android/clustering/StaticClusterTest.java rename to clustering/src/test/java/com/google/maps/android/clustering/StaticClusterTest.java index 402e50c35..235d3ddda 100644 --- a/library/src/test/java/com/google/maps/android/clustering/StaticClusterTest.java +++ b/clustering/src/test/java/com/google/maps/android/clustering/StaticClusterTest.java @@ -1,11 +1,11 @@ /* - * Copyright 2015 Google Inc. + * 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 + * 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, @@ -13,35 +13,33 @@ * 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.StaticCluster; - -import org.junit.Test; - import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotEquals; import static org.junit.Assert.assertNotSame; -public class StaticClusterTest { - @Test - public void testEquality() { - StaticCluster cluster1 = new StaticCluster<>(new LatLng(0.1, 0.5)); - StaticCluster cluster2 = new StaticCluster<>(new LatLng(0.1, 0.5)); - - assertEquals(cluster1, cluster2); - assertNotSame(cluster1, cluster2); - assertEquals(cluster1.hashCode(), cluster2.hashCode()); - } - - @Test - public void testUnequality() { - StaticCluster cluster1 = new StaticCluster<>(new LatLng(0.1, 0.5)); - StaticCluster cluster2 = new StaticCluster<>(new LatLng(0.2, 0.3)); +import com.google.android.gms.maps.model.LatLng; +import com.google.maps.android.clustering.algo.StaticCluster; +import org.junit.Test; - assertNotEquals(cluster1, cluster2); - assertNotEquals(cluster1.hashCode(), cluster2.hashCode()); - } +public class StaticClusterTest { + @Test + public void testEquality() { + StaticCluster cluster1 = new StaticCluster<>(new LatLng(0.1, 0.5)); + StaticCluster cluster2 = new StaticCluster<>(new LatLng(0.1, 0.5)); + + assertEquals(cluster1, cluster2); + assertNotSame(cluster1, cluster2); + assertEquals(cluster1.hashCode(), cluster2.hashCode()); + } + + @Test + public void testUnequality() { + StaticCluster cluster1 = new StaticCluster<>(new LatLng(0.1, 0.5)); + StaticCluster cluster2 = new StaticCluster<>(new LatLng(0.2, 0.3)); + + assertNotEquals(cluster1, cluster2); + assertNotEquals(cluster1.hashCode(), cluster2.hashCode()); + } } diff --git a/clustering/src/test/java/com/google/maps/android/clustering/algo/BenchmarkTest.kt b/clustering/src/test/java/com/google/maps/android/clustering/algo/BenchmarkTest.kt new file mode 100644 index 000000000..5d9e20551 --- /dev/null +++ b/clustering/src/test/java/com/google/maps/android/clustering/algo/BenchmarkTest.kt @@ -0,0 +1,105 @@ +/* + * 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.Cluster +import com.google.maps.android.clustering.ClusterItem +import org.junit.Test +import java.util.Random + +class BenchmarkTest { + private class MyItem( + 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? = null + } + + private fun generateItems(count: Int): List { + val random = Random(12345) // Seed for consistency + return List(count) { + val lat = (random.nextDouble() - 0.5) * 170 // -85 to 85 + val lng = (random.nextDouble() - 0.5) * 360 // -180 to 180 + MyItem(lat, lng) + } + } + + @Test + fun benchmarkGridBasedAlgorithm() { + runBenchmark(GridBasedAlgorithm(), "GridBasedAlgorithm") + } + + @Test + fun benchmarkNonHierarchicalDistanceBasedAlgorithm() { + runBenchmark(NonHierarchicalDistanceBasedAlgorithm(), "NonHierarchicalDistanceBasedAlgorithm") + } + + @Test + fun benchmarkCentroidNonHierarchicalDistanceBasedAlgorithm() { + runBenchmark(CentroidNonHierarchicalDistanceBasedAlgorithm(), "CentroidNonHierarchicalDistanceBasedAlgorithm") + } + + @Test + fun benchmarkContinuousZoomEuclideanCentroidAlgorithm() { + runBenchmark(ContinuousZoomEuclideanCentroidAlgorithm(), "ContinuousZoomEuclideanCentroidAlgorithm") + } + + @Test + fun benchmarkPreCachingAlgorithmDecorator() { + runBenchmark(PreCachingAlgorithmDecorator(NonHierarchicalDistanceBasedAlgorithm()), "PreCachingAlgorithmDecorator") + } + + private fun runBenchmark( + algorithm: Algorithm, + name: String, + ) { + println("--- Benchmarking $name ---") + val count = 50000 + val items = generateItems(count) + + // Warmup + algorithm.addItems(items.take(1000)) + algorithm.getClusters(10f) + algorithm.clearItems() + + System.gc() + + // 1. Benchmark Adding Items + val startAdd = System.nanoTime() + algorithm.addItems(items) + val endAdd = System.nanoTime() + System.out.printf("addItems(%,d) took %.2f ms%n", count, (endAdd - startAdd) / 1000000.0) + + // 2. Benchmark getClusters at various zoom levels + val zoomLevels = floatArrayOf(4f, 8f, 12f, 16f) + for (zoom in zoomLevels) { + System.gc() + val startCluster = System.nanoTime() + val clusters = algorithm.getClusters(zoom) + val endCluster = System.nanoTime() + System.out.printf( + "getClusters(zoom=%.1f) created %,d clusters in %.2f ms%n", + zoom, + clusters.size, + (endCluster - startCluster) / 1000000.0, + ) + } + } +} 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 new file mode 100644 index 000000000..33a43b1ca --- /dev/null +++ b/clustering/src/test/java/com/google/maps/android/clustering/algo/CentroidNonHierarchicalDistanceBasedAlgorithmTest.java @@ -0,0 +1,74 @@ +/* + * 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 new file mode 100644 index 000000000..f6b5d7c61 --- /dev/null +++ b/clustering/src/test/java/com/google/maps/android/clustering/algo/ContinuousZoomEuclideanCentroidAlgorithmTest.java @@ -0,0 +1,115 @@ +/* + * 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/library/src/test/java/com/google/maps/android/geometry/BoundsTest.kt b/clustering/src/test/java/com/google/maps/android/geometry/BoundsTest.kt similarity index 97% rename from library/src/test/java/com/google/maps/android/geometry/BoundsTest.kt rename to clustering/src/test/java/com/google/maps/android/geometry/BoundsTest.kt index 150f0e816..a3c52fef5 100644 --- a/library/src/test/java/com/google/maps/android/geometry/BoundsTest.kt +++ b/clustering/src/test/java/com/google/maps/android/geometry/BoundsTest.kt @@ -1,11 +1,11 @@ /* - * Copyright 2025 Google LLC. + * 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 + * 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, @@ -13,7 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - package com.google.maps.android.geometry import org.junit.Assert.assertEquals @@ -22,7 +21,6 @@ import org.junit.Assert.assertTrue import org.junit.Test class BoundsTest { - @Test fun testInitialization() { val bounds = Bounds(0.0, 10.0, 0.0, 20.0) diff --git a/library/src/test/java/com/google/maps/android/geometry/PointTest.kt b/clustering/src/test/java/com/google/maps/android/geometry/PointTest.kt similarity index 95% rename from library/src/test/java/com/google/maps/android/geometry/PointTest.kt rename to clustering/src/test/java/com/google/maps/android/geometry/PointTest.kt index 6c6daa5f0..d09237ece 100644 --- a/library/src/test/java/com/google/maps/android/geometry/PointTest.kt +++ b/clustering/src/test/java/com/google/maps/android/geometry/PointTest.kt @@ -1,11 +1,11 @@ /* - * Copyright 2025 Google LLC. + * 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 + * 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, @@ -13,7 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - package com.google.maps.android.geometry import org.junit.Assert.assertEquals @@ -21,7 +20,6 @@ import org.junit.Assert.assertNotEquals import org.junit.Test class PointTest { - @Test fun testInitialization() { val point = Point(1.0, 2.0) @@ -59,4 +57,4 @@ class PointTest { assertEquals(1.0, modifiedCopy.x, 0.0) assertEquals(3.0, modifiedCopy.y, 0.0) } -} \ No newline at end of file +} diff --git a/library/src/test/java/com/google/maps/android/projection/PointTest.kt b/clustering/src/test/java/com/google/maps/android/projection/PointTest.kt similarity index 93% rename from library/src/test/java/com/google/maps/android/projection/PointTest.kt rename to clustering/src/test/java/com/google/maps/android/projection/PointTest.kt index 85ae49c0e..1e4ca3444 100644 --- a/library/src/test/java/com/google/maps/android/projection/PointTest.kt +++ b/clustering/src/test/java/com/google/maps/android/projection/PointTest.kt @@ -1,11 +1,11 @@ /* - * Copyright 2025 Google LLC + * 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 + * 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, @@ -13,7 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - package com.google.maps.android.projection import com.google.common.truth.Truth.assertThat @@ -23,7 +22,6 @@ import org.robolectric.RobolectricTestRunner @RunWith(RobolectricTestRunner::class) class PointTest { - @Test fun testPointConstruction() { val point = Point(1.0, 2.0) diff --git a/library/src/test/java/com/google/maps/android/quadtree/PointQuadTreeTest.kt b/clustering/src/test/java/com/google/maps/android/quadtree/PointQuadTreeTest.kt similarity index 95% rename from library/src/test/java/com/google/maps/android/quadtree/PointQuadTreeTest.kt rename to clustering/src/test/java/com/google/maps/android/quadtree/PointQuadTreeTest.kt index 9ff4a7f2b..5109734b6 100644 --- a/library/src/test/java/com/google/maps/android/quadtree/PointQuadTreeTest.kt +++ b/clustering/src/test/java/com/google/maps/android/quadtree/PointQuadTreeTest.kt @@ -1,11 +1,11 @@ /* - * Copyright 2025 Google LLC. + * 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 + * 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, @@ -13,7 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - package com.google.maps.android.quadtree import com.google.maps.android.geometry.Bounds @@ -26,7 +25,6 @@ import org.junit.Test import java.util.Random class PointQuadTreeTest { - private lateinit var mTree: PointQuadTree @Before @@ -114,7 +112,8 @@ class PointQuadTreeTest { assertEquals(10000, searchAll().size) assertEquals( - 1, mTree.search(Bounds(0.0, 0.00001, 0.0, 0.00001)).size + 1, + mTree.search(Bounds(0.0, 0.00001, 0.0, 0.00001)).size, ) assertEquals(0, mTree.search(Bounds(.7, .8, .7, .8)).size) mTree.clear() @@ -234,11 +233,12 @@ class PointQuadTreeTest { System.gc() } - private fun searchAll(): Collection { - return mTree.search(Bounds(0.0, 1.0, 0.0, 1.0)) - } + private fun searchAll(): Collection = mTree.search(Bounds(0.0, 1.0, 0.0, 1.0)) - private class Item(x: Double, y: Double) : PointQuadTree.Item { + private class Item( + x: Double, + y: Double, + ) : PointQuadTree.Item { override val point: Point = Point(x, y) } -} \ No newline at end of file +} diff --git a/clustering/src/test/resources/robolectric.properties b/clustering/src/test/resources/robolectric.properties new file mode 100644 index 000000000..89a6c8b4c --- /dev/null +++ b/clustering/src/test/resources/robolectric.properties @@ -0,0 +1 @@ +sdk=28 \ No newline at end of file diff --git a/clustering_benchmark_dashboard.html b/clustering_benchmark_dashboard.html new file mode 100644 index 000000000..220db456e --- /dev/null +++ b/clustering_benchmark_dashboard.html @@ -0,0 +1,95 @@ + + + + + + Clustering Benchmark Dashboard + + + + +
+

Android Maps Utils Clustering Benchmarks

+

Performance comparison between the original Java implementation (main) and the Kotlin rewrite (feat/rewrite-android-maps-utils).

+ +
+ +
+

GridBasedAlgorithm

+ +
+ + +
+

NonHierarchicalDistanceBasedAlgorithm

+ +
+ + +
+

CentroidNonHierarchicalDistanceBased

+ +
+ + +
+

ContinuousZoomEuclideanCentroid

+ +
+ + +
+

PreCachingAlgorithmDecorator

+ +
+
+ +
+ + + + \ No newline at end of file diff --git a/collections/build.gradle.kts b/collections/build.gradle.kts new file mode 100644 index 000000000..4e998675e --- /dev/null +++ b/collections/build.gradle.kts @@ -0,0 +1,91 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +/** + * 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("kotlin-android") + 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 = 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" + ) + } + } + resourcePrefix = "amu_" + + installation { + timeOutInMs = 10 * 60 * 1000 // 10 minutes + installOptions("-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.collections" +} + +dependencies { + 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/collections/consumer-rules.pro b/collections/consumer-rules.pro new file mode 100644 index 000000000..e69de29bb diff --git a/collections/proguard-rules.pro b/collections/proguard-rules.pro new file mode 100644 index 000000000..f1b424510 --- /dev/null +++ b/collections/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile diff --git a/collections/src/main/AndroidManifest.xml b/collections/src/main/AndroidManifest.xml new file mode 100644 index 000000000..7edf00bd5 --- /dev/null +++ b/collections/src/main/AndroidManifest.xml @@ -0,0 +1,19 @@ + + + + + diff --git a/data/README.md b/data/README.md new file mode 100644 index 000000000..2c5ee01f0 --- /dev/null +++ b/data/README.md @@ -0,0 +1,71 @@ +# Data Module: Unified Geospatial Renderer + +The `data` module provides a unified, platform-agnostic architecture for parsing and rendering geospatial data (KML, GeoJSON, GPX) on Google Maps Android SDK. + +## Architecture + +The architecture follows a "Clean Architecture" approach, separating data parsing, internal modeling, and rendering: + +1. **Parsers (`com.google.maps.android.data.parser`)**: + * Responsible for parsing raw file formats (KML, GeoJSON, GPX) into intermediate objects. + * **Key Classes**: `KmlParser`, `GeoJsonParser`, `GpxParser`. + +2. **Mappers (`com.google.maps.android.data.renderer.mapper`)**: + * Transform parsed objects into a unified, platform-agnostic internal model (`DataScene`, `DataLayer`, `Feature`). + * **Key Classes**: `KmlMapper`, `GeoJsonMapper`, `GpxMapper`. + +3. **Internal Model (`com.google.maps.android.data.renderer.model`)**: + * A unified representation of geospatial data. + * **`DataScene`**: The top-level container for a map scene. + * **`DataLayer`**: A collection of features (e.g., "Peaks", "Ranges"). + * **`Feature`**: A single entity with `Geometry`, `Style`, and `Properties`. + * **`Geometry`**: `Point`, `LineString`, `Polygon`, `MultiGeometry`, `GroundOverlay`. + * **`Style`**: `PointStyle`, `LineStyle`, `PolygonStyle`, `GroundOverlayStyle`. + +4. **Renderer (`com.google.maps.android.data.renderer.mapview`)**: + * Renders the internal model onto a `GoogleMap`. + * **`MapViewRenderer`**: The main class that handles adding/removing Markers, Polylines, Polygons, and GroundOverlays. + * Supports **Advanced Markers** via `useAdvancedMarkers` property. + * Handles asynchronous icon loading via `IconProvider`. + +## Usage + +### 1. Parsing and Mapping + +```kotlin +// Load KML +val kml = KmlParser().parse(inputStream) +val kmlLayer = KmlMapper.toLayer(kml) + +// Load GeoJSON +val geoJson = GeoJsonParser().parse(inputStream) +val geoJsonLayer = GeoJsonMapper.toLayer(geoJson) + +// Load GPX +val gpx = GpxParser().parse(inputStream) +val gpxLayer = GpxMapper.toLayer(gpx) +``` + +### 2. Rendering + +```kotlin +// Initialize Renderer +val renderer = MapViewRenderer(googleMap, UrlIconProvider(lifecycleScope)) + +// Add Layer +renderer.addLayer(kmlLayer) + +// Remove Layer +renderer.removeLayer(kmlLayer) + +// Enable Advanced Markers +renderer.useAdvancedMarkers = true +``` + +## Key Features + +* **Unified API**: Treat KML, GeoJSON, and GPX identically once parsed. +* **Separation of Concerns**: Parsers don't know about Google Maps; Renderers don't know about file formats. +* **Async Icon Loading**: Icons are loaded asynchronously using Coroutines, preventing UI jank. +* **Ground Overlays**: Full support for KML GroundOverlays (rotated, styled). +* **Advanced Markers**: Ready for the latest Google Maps features. diff --git a/data/build.gradle.kts b/data/build.gradle.kts new file mode 100644 index 000000000..91026191d --- /dev/null +++ b/data/build.gradle.kts @@ -0,0 +1,111 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +/** + * 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("kotlin-android") + id("org.jetbrains.dokka") + id("android.maps.utils.PublishingConventionPlugin") + id("org.jetbrains.kotlin.plugin.serialization") version libs.versions.kotlin.get() + id("jacoco") +} + +android { + lint { + sarifOutput = layout.buildDirectory.file("reports/lint-results.sarif").get().asFile + } + defaultConfig { + 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" + ) + } + debug { + enableUnitTestCoverage = true + } + } + resourcePrefix = "amu_" + + kotlin { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + } + jvmToolchain(17) + } + + testOptions { + animationsDisabled = true + unitTests.isIncludeAndroidResources = true + unitTests.isReturnDefaultValues = true + } + namespace = "com.google.maps.android.data" +} + +dependencies { + api(project(":library")) + implementation(project(":ui")) + api(libs.play.services.maps) + implementation(libs.kotlinx.coroutines.android) + implementation(libs.kotlinx.serialization.json) + implementation(libs.serialization) + 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) + testImplementation(libs.androidx.test.core) + 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") +} + +tasks.register("jacocoDebugReport") { + dependsOn("testDebugUnitTest") + reports { + xml.required.set(true) + html.required.set(true) + } + val debugTree = fileTree(layout.buildDirectory.dir("tmp/kotlin-classes/debug")) { + exclude("**/R.class", "**/R$*.class", "**/BuildConfig.*", "**/Manifest*.*", "**/*Test*.*", "android/**/*.*") + } + val mainSrc = layout.projectDirectory.dir("src/main/java") + sourceDirectories.setFrom(files(mainSrc)) + classDirectories.setFrom(files(debugTree)) + executionData.setFrom(files(layout.buildDirectory.file("outputs/unit_test_code_coverage/debugUnitTest/testDebugUnitTest.exec"))) +} diff --git a/data/consumer-rules.pro b/data/consumer-rules.pro new file mode 100644 index 000000000..e69de29bb diff --git a/data/proguard-rules.pro b/data/proguard-rules.pro new file mode 100644 index 000000000..f1b424510 --- /dev/null +++ b/data/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile diff --git a/data/src/main/AndroidManifest.xml b/data/src/main/AndroidManifest.xml new file mode 100644 index 000000000..7edf00bd5 --- /dev/null +++ b/data/src/main/AndroidManifest.xml @@ -0,0 +1,19 @@ + + + + + diff --git a/data/src/main/java/com/google/maps/android/data/DataLayerLoader.kt b/data/src/main/java/com/google/maps/android/data/DataLayerLoader.kt new file mode 100644 index 000000000..d1ff29131 --- /dev/null +++ b/data/src/main/java/com/google/maps/android/data/DataLayerLoader.kt @@ -0,0 +1,216 @@ +/* + * 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.data + +import android.content.Context +import com.google.maps.android.data.parser.geojson.GeoJsonParser +import com.google.maps.android.data.parser.gpx.GpxParser +import com.google.maps.android.data.parser.kml.KmlParser +import com.google.maps.android.data.parser.kml.KmzParser +import com.google.maps.android.data.renderer.mapper.GeoJsonMapper +import com.google.maps.android.data.renderer.mapper.GpxMapper +import com.google.maps.android.data.renderer.mapper.KmlMapper +import com.google.maps.android.data.renderer.mapper.toLayer +import com.google.maps.android.data.renderer.model.DataLayer +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.InputStream +import java.nio.charset.StandardCharsets + +/** + * Unified loader for DataLayers. + * + * Handles loading from assets or streams, determining the correct parser based on + * file extension or content sniffing. + */ +class DataLayerLoader( + private val context: Context, + private val dispatcher: CoroutineDispatcher = Dispatchers.IO, +) { + /** + * Loads a DataLayer from an asset file. + * + * @param context The context to access assets. + * @param assetName The name of the asset file. + * @return The loaded DataLayer, or null if loading failed. + */ + suspend fun loadAsset(assetName: String): DataLayer? = + withContext(dispatcher) { + try { + val inputStream = context.assets.open(assetName) + loadInputStream(inputStream, assetName) + } catch (e: Exception) { + e.printStackTrace() + null + } + } + + /** + * Loads a DataLayer from an InputStream. + * + * @param inputStream The input stream to read from. + * @param fileName The name of the file (optional), used for extension matching. + * @return The loaded DataLayer, or null if loading failed. + */ + suspend fun loadInputStream( + inputStream: InputStream, + fileName: String?, + ): DataLayer? { + return withContext(dispatcher) { + try { + // 1. Try to match by extension + if (fileName != null) { + val extension = fileName.substringAfterLast('.', "").lowercase() + if (extension.isNotEmpty()) { + val layer = tryParseByExtension(inputStream, extension) + if (layer != null) return@withContext layer + } + } + + // 2. If extension match failed or no extension, try sniffing + // We need a fresh stream or a reset stream for sniffing if the previous attempt consumed it. + // However, the previous tryParseByExtension likely consumed the stream if it matched but failed. + // Ideally, we should peek or buffer. For now, let's assume if extension matched, we committed to that parser. + // But if we want robust fallback, we might need to handle stream resetting. + // Given the current architecture, let's assume if extension matches, that's the intended format. + // If extension didn't match any known parser, we fall back to sniffing. + + // Note: InputStreams from assets/content resolvers might not support mark/reset. + // If we really need to sniff after a failed extension parse, we'd need to buffer the whole stream. + // For this implementation, we'll rely on extension first. If no extension match, we sniff. + + if (fileName == null || !isKnownExtension(fileName.substringAfterLast('.', "").lowercase())) { + return@withContext sniffAndParse(inputStream) + } + + null + } catch (e: Exception) { + e.printStackTrace() + null + } + } + } + + private fun isKnownExtension(extension: String): Boolean = + GpxParser.SUPPORTED_EXTENSIONS.contains(extension) || + KmlParser.SUPPORTED_EXTENSIONS.contains(extension) || + KmzParser.SUPPORTED_EXTENSIONS.contains(extension) || + GeoJsonParser.SUPPORTED_EXTENSIONS.contains(extension) + + private fun tryParseByExtension( + inputStream: InputStream, + extension: String, + ): DataLayer? = + when { + GpxParser.SUPPORTED_EXTENSIONS.contains(extension) -> { + GpxParser().parse(inputStream).toLayer() + } + + KmlParser.SUPPORTED_EXTENSIONS.contains(extension) -> { + KmlParser().parse(inputStream).toLayer() + } + + KmzParser.SUPPORTED_EXTENSIONS.contains(extension) -> { + KmzParser().parse(inputStream).toLayer() + } + + GeoJsonParser.SUPPORTED_EXTENSIONS.contains(extension) -> { + GeoJsonParser().parse(inputStream)?.toLayer() + } + + else -> { + null + } + } + + private fun sniffAndParse(inputStream: InputStream): DataLayer? { + // This is tricky without buffering. We need to read the header. + // If the stream supports mark/reset, we can use that. + // Otherwise, we might consume the stream. + // For now, let's read a small header buffer. + + if (!inputStream.markSupported()) { + // If we can't mark, we can't reliably sniff and then parse with the same stream + // unless we wrap it in a BufferedInputStream. + // Let's assume the caller might have passed a BufferedInputStream or we wrap it. + // But we can't easily wrap it here and pass it to parsers if they expect a specific subclass? + // Actually parsers just take InputStream. + + // Let's wrap in BufferedInputStream if not already + val bufferedStream = java.io.BufferedInputStream(inputStream) + bufferedStream.mark(1024) + val headerBytes = ByteArray(1024) + val read = bufferedStream.read(headerBytes) + bufferedStream.reset() + + if (read <= 0) return null + val header = String(headerBytes, 0, read, StandardCharsets.UTF_8) + + return when { + GpxParser.canParse(header) -> { + GpxParser().parse(bufferedStream).toLayer() + } + + KmlParser.canParse(header) -> { + KmlParser().parse(bufferedStream).toLayer() + } + + KmzParser.canParse(header) -> { + KmzParser().parse(bufferedStream).toLayer() + } + + GeoJsonParser.canParse(header) -> { + GeoJsonParser().parse(bufferedStream)?.toLayer() + } + + else -> { + null + } + } + } else { + inputStream.mark(1024) + val headerBytes = ByteArray(1024) + val read = inputStream.read(headerBytes) + inputStream.reset() + + if (read <= 0) return null + val header = String(headerBytes, 0, read, StandardCharsets.UTF_8) + + return when { + GpxParser.canParse(header) -> { + GpxParser().parse(inputStream).toLayer() + } + + KmlParser.canParse(header) -> { + KmlParser().parse(inputStream).toLayer() + } + + KmzParser.canParse(header) -> { + KmzParser().parse(inputStream).toLayer() + } + + GeoJsonParser.canParse(header) -> { + GeoJsonParser().parse(inputStream)?.toLayer() + } + + else -> { + null + } + } + } + } +} diff --git a/data/src/main/java/com/google/maps/android/data/DataPolygon.kt b/data/src/main/java/com/google/maps/android/data/DataPolygon.kt new file mode 100644 index 000000000..4fa5fb99e --- /dev/null +++ b/data/src/main/java/com/google/maps/android/data/DataPolygon.kt @@ -0,0 +1,28 @@ +/* + * 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.data + +import com.google.android.gms.maps.model.LatLng + +/** + * An interface containing the common properties of GeoJsonPolygon and KmlPolygon. + */ +@Deprecated("Use the new platform-agnostic data layer and renderer instead.") +public interface DataPolygon : Geometry { + public fun getOuterBoundaryCoordinates(): List + + public fun getInnerBoundaryCoordinates(): List> +} diff --git a/data/src/main/java/com/google/maps/android/data/Feature.kt b/data/src/main/java/com/google/maps/android/data/Feature.kt new file mode 100644 index 000000000..9a9c3544f --- /dev/null +++ b/data/src/main/java/com/google/maps/android/data/Feature.kt @@ -0,0 +1,57 @@ +/* + * 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.data + +import java.util.Observable + +/** + * An abstraction that shares the common properties of KmlPlacemark and GeoJsonFeature. + */ +@Deprecated("Use the new platform-agnostic data layer and renderer instead.") +public open class Feature( + private var geometry: Geometry?, + protected var mId: String?, + properties: Map?, +) : Observable() { + private val mProperties: MutableMap = properties?.toMutableMap() ?: HashMap() + + public fun getPropertyKeys(): Iterable = mProperties.keys + + public fun getProperties(): Iterable> = mProperties.entries + + public fun getProperty(property: String): String? = mProperties[property] + + public fun getId(): String? = mId + + public fun hasProperty(property: String): Boolean = mProperties.containsKey(property) + + public fun getGeometry(): Geometry? = geometry + + public fun hasProperties(): Boolean = mProperties.isNotEmpty() + + public fun hasGeometry(): Boolean = geometry != null + + protected open fun setProperty( + property: String, + propertyValue: String, + ): String? = mProperties.put(property, propertyValue) + + protected open fun removeProperty(property: String): String? = mProperties.remove(property) + + protected open fun setGeometry(geometry: Geometry?) { + this.geometry = geometry + } +} diff --git a/data/src/main/java/com/google/maps/android/data/Geometry.kt b/data/src/main/java/com/google/maps/android/data/Geometry.kt new file mode 100644 index 000000000..05c1b2b67 --- /dev/null +++ b/data/src/main/java/com/google/maps/android/data/Geometry.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.data + +/** + * Common interface for all legacy Geometry types, bridging to the new data layer. + */ +@Deprecated("Use the new platform-agnostic data layer and renderer instead.") +public interface Geometry { + public fun getGeometryType(): String + + public fun getGeometryObject(): Any? +} diff --git a/data/src/main/java/com/google/maps/android/data/Layer.kt b/data/src/main/java/com/google/maps/android/data/Layer.kt new file mode 100644 index 000000000..0ae01ff74 --- /dev/null +++ b/data/src/main/java/com/google/maps/android/data/Layer.kt @@ -0,0 +1,59 @@ +/* + * 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.data + +import com.google.android.gms.maps.GoogleMap +import com.google.android.gms.maps.model.LatLng +import com.google.maps.android.data.geojson.GeoJsonLineStringStyle +import com.google.maps.android.data.geojson.GeoJsonPointStyle +import com.google.maps.android.data.geojson.GeoJsonPolygonStyle + +/** + * Common base class for GeoJsonLayer and KmlLayer, bridging to the new data layer. + */ +@Deprecated("Use the new platform-agnostic data layer and renderer instead.") +public abstract class Layer { + protected var mGoogleMap: GoogleMap? = null + + // Default styles + protected var mDefaultPointStyle = GeoJsonPointStyle() + protected var mDefaultLineStringStyle = GeoJsonLineStringStyle() + protected var mDefaultPolygonStyle = GeoJsonPolygonStyle() + + public abstract fun getMap(): GoogleMap? + + public abstract fun setMap(map: GoogleMap?) + + public abstract fun addLayerToMap() + + public abstract fun removeLayerFromMap() + + public fun interface OnFeatureClickListener { + public fun onFeatureClick(feature: Feature) + } + + public open fun setOnFeatureClickListener(listener: OnFeatureClickListener) { + // Will be overridden or implemented to hook into MapViewRenderer + } + + public abstract val features: Iterable + + public open fun getDefaultPointStyle(): GeoJsonPointStyle = mDefaultPointStyle + + public open fun getDefaultLineStringStyle(): GeoJsonLineStringStyle = mDefaultLineStringStyle + + public open fun getDefaultPolygonStyle(): GeoJsonPolygonStyle = mDefaultPolygonStyle +} diff --git a/data/src/main/java/com/google/maps/android/data/LineString.kt b/data/src/main/java/com/google/maps/android/data/LineString.kt new file mode 100644 index 000000000..8cfac9bd4 --- /dev/null +++ b/data/src/main/java/com/google/maps/android/data/LineString.kt @@ -0,0 +1,30 @@ +/* + * 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.data + +import com.google.android.gms.maps.model.LatLng + +/** + * Legacy LineString wrapper bridging to the new data layer. + */ +@Deprecated("Use the new platform-agnostic data layer and renderer instead.") +public open class LineString( + private val points: List, +) : Geometry { + override fun getGeometryType(): String = "LineString" + + override fun getGeometryObject(): List = points +} diff --git a/data/src/main/java/com/google/maps/android/data/MultiGeometry.kt b/data/src/main/java/com/google/maps/android/data/MultiGeometry.kt new file mode 100644 index 000000000..8d638ad08 --- /dev/null +++ b/data/src/main/java/com/google/maps/android/data/MultiGeometry.kt @@ -0,0 +1,53 @@ +/* + * 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.data + +/** + * An abstraction that shares the common properties of KmlMultiGeometry, GeoJsonMultiLineString, + * GeoJsonMultiPoint, and GeoJsonMultiPolygon. + */ +@Deprecated("Use the new platform-agnostic data layer and renderer instead.") +public open class MultiGeometry( + private val geometries: List, +) : Geometry { + private var _geometryType = "MultiGeometry" + + override fun getGeometryType(): String = _geometryType + + override fun getGeometryObject(): List = geometries + + public fun setGeometryType(type: String) { + _geometryType = type + } + + override fun toString(): String { + var typeString = "Geometries=" + if (_geometryType == "MultiPoint") { + typeString = "LineStrings=" + } + if (_geometryType == "MultiLineString") { + typeString = "points=" + } + if (_geometryType == "MultiPolygon") { + typeString = "Polygons=" + } + + val sb = StringBuilder(getGeometryType()).append("{") + sb.append("\n ").append(typeString).append(getGeometryObject()) + sb.append("\n}\n") + return sb.toString() + } +} diff --git a/library/src/main/java/com/google/maps/android/data/Geometry.java b/data/src/main/java/com/google/maps/android/data/Point.kt similarity index 51% rename from library/src/main/java/com/google/maps/android/data/Geometry.java rename to data/src/main/java/com/google/maps/android/data/Point.kt index 857f97f98..b50ee0f20 100644 --- a/library/src/main/java/com/google/maps/android/data/Geometry.java +++ b/data/src/main/java/com/google/maps/android/data/Point.kt @@ -1,11 +1,11 @@ /* - * Copyright 2017 Google Inc. + * 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 + * 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, @@ -13,27 +13,18 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +package com.google.maps.android.data -package com.google.maps.android.data; +import com.google.android.gms.maps.model.LatLng /** - * An abstraction that represents a Geometry object - * - * @param the type of Geometry object + * Legacy Point wrapper bridging to the new data layer. */ -public interface Geometry { - /** - * Gets the type of geometry - * - * @return type of geometry - */ - String getGeometryType(); - - /** - * Gets the stored KML Geometry object - * - * @return geometry object - */ - T getGeometryObject(); +@Deprecated("Use the new platform-agnostic data layer and renderer instead.") +public open class Point( + private val latLng: LatLng, +) : Geometry { + override fun getGeometryType(): String = "Point" + override fun getGeometryObject(): LatLng = latLng } diff --git a/data/src/main/java/com/google/maps/android/data/Renderer.kt b/data/src/main/java/com/google/maps/android/data/Renderer.kt new file mode 100644 index 000000000..e9c48004d --- /dev/null +++ b/data/src/main/java/com/google/maps/android/data/Renderer.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.data + +import android.graphics.Bitmap +import com.google.android.gms.maps.model.BitmapDescriptor + +/** + * A legacy bridge class representing the old Renderer, used to provide backward + * compatibility for type-level references like [ImagesCache]. + */ +@Deprecated("Use the new platform-agnostic data layer and renderer instead.") +public open class Renderer { + /** + * A lightweight, backward-compatible class representing the old [ImagesCache]. + */ + public class ImagesCache { + internal val markerImagesCache: MutableMap> = HashMap() + internal val groundOverlayImagesCache: MutableMap = HashMap() + internal val bitmapCache: MutableMap = HashMap() + } +} diff --git a/data/src/main/java/com/google/maps/android/data/RendererLogger.kt b/data/src/main/java/com/google/maps/android/data/RendererLogger.kt new file mode 100644 index 000000000..7b5da5c67 --- /dev/null +++ b/data/src/main/java/com/google/maps/android/data/RendererLogger.kt @@ -0,0 +1,50 @@ +/* + * 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.data + +import android.util.Log + +/** + * Simple logging utility for geospatial renderers and managers. + */ +public object RendererLogger { + private var isEnabled = false + + @JvmStatic + public fun d( + tag: String, + msg: String, + ) { + if (isEnabled) { + Log.d(tag, msg) + } + } + + @JvmStatic + public fun e( + tag: String, + msg: String, + ) { + if (isEnabled) { + Log.e(tag, msg) + } + } + + @JvmStatic + public fun setEnabled(enabled: Boolean) { + isEnabled = enabled + } +} diff --git a/data/src/main/java/com/google/maps/android/data/Style.kt b/data/src/main/java/com/google/maps/android/data/Style.kt new file mode 100644 index 000000000..f827bc5d2 --- /dev/null +++ b/data/src/main/java/com/google/maps/android/data/Style.kt @@ -0,0 +1,82 @@ +/* + * 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.data + +import android.util.Log +import com.google.android.gms.maps.model.MarkerOptions +import com.google.android.gms.maps.model.PolygonOptions +import com.google.android.gms.maps.model.PolylineOptions +import java.util.Observable + +/** + * An abstraction that shares the common properties of KmlStyle, GeoJsonPointStyle, + * GeoJsonLineStringStyle and GeoJsonPolygonStyle. + */ +public abstract class Style : Observable() { + @JvmField + internal var mMarkerOptions: MarkerOptions = MarkerOptions() + + @JvmField + internal var mPolylineOptions: PolylineOptions = PolylineOptions().clickable(true) + + @JvmField + internal var mPolygonOptions: PolygonOptions = PolygonOptions().clickable(true) + + public open fun getRotation(): Float = mMarkerOptions.rotation + + public open fun setMarkerRotation(rotation: Float) { + mMarkerOptions.rotation(rotation) + } + + public open fun setMarkerHotSpot( + x: Float, + y: Float, + xUnits: String, + yUnits: String, + ) { + var xAnchor = 0.5f + var yAnchor = 1.0f + + if (xUnits == "fraction") { + xAnchor = x + } else { + Log.w(LOG_TAG, "Hotspot xUnits other than \"fraction\" are not supported.") + } + if (yUnits == "fraction") { + yAnchor = y + } else { + Log.w(LOG_TAG, "Hotspot yUnits other than \"fraction\" are not supported.") + } + + mMarkerOptions.anchor(xAnchor, yAnchor) + } + + public open fun setLineStringWidth(width: Float) { + mPolylineOptions.width(width) + } + + public open fun setPolygonStrokeWidth(strokeWidth: Float) { + mPolygonOptions.strokeWidth(strokeWidth) + } + + public open fun setPolygonFillColor(fillColor: Int) { + mPolygonOptions.fillColor(fillColor) + } + + companion object { + private const val LOG_TAG = "Style" + } +} diff --git a/data/src/main/java/com/google/maps/android/data/geojson/GeoJsonFeature.kt b/data/src/main/java/com/google/maps/android/data/geojson/GeoJsonFeature.kt new file mode 100644 index 000000000..16996ca28 --- /dev/null +++ b/data/src/main/java/com/google/maps/android/data/geojson/GeoJsonFeature.kt @@ -0,0 +1,123 @@ +/* + * 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.data.geojson + +import com.google.android.gms.maps.model.LatLngBounds +import com.google.android.gms.maps.model.MarkerOptions +import com.google.android.gms.maps.model.PolygonOptions +import com.google.android.gms.maps.model.PolylineOptions +import com.google.maps.android.data.Feature +import com.google.maps.android.data.Geometry +import java.util.Observable +import java.util.Observer + +/** + * A GeoJsonFeature has a geometry, bounding box, id and set of properties. Styles are also stored + * in this class. + */ +@Deprecated("Use the new platform-agnostic data layer and renderer instead.") +public class GeoJsonFeature( + geometry: Geometry?, + id: String?, + properties: Map?, + private val boundingBox: LatLngBounds?, +) : Feature(geometry, id, properties), + Observer { + public var pointStyle: GeoJsonPointStyle? = null + set(value) { + field?.deleteObserver(this) + field = value + field?.addObserver(this) + value?.let { checkRedrawFeature(it) } + } + + public var lineStringStyle: GeoJsonLineStringStyle? = null + set(value) { + field?.deleteObserver(this) + field = value + field?.addObserver(this) + value?.let { checkRedrawFeature(it) } + } + + public var polygonStyle: GeoJsonPolygonStyle? = null + set(value) { + field?.deleteObserver(this) + field = value + field?.addObserver(this) + value?.let { checkRedrawFeature(it) } + } + + override fun setProperty( + property: String, + propertyValue: String, + ): String? { + val old = super.setProperty(property, propertyValue) + setChanged() + notifyObservers() + return old + } + + override fun removeProperty(property: String): String? { + val old = super.removeProperty(property) + setChanged() + notifyObservers() + return old + } + + public fun getPolygonOptions(): PolygonOptions? = polygonStyle?.toPolygonOptions() + + public fun getMarkerOptions(): MarkerOptions? = pointStyle?.toMarkerOptions() + + public fun getPolylineOptions(): PolylineOptions? = lineStringStyle?.toPolylineOptions() + + private fun checkRedrawFeature(style: GeoJsonStyle) { + val currentGeometry = getGeometry() + if (currentGeometry != null && style.getGeometryType().contains(currentGeometry.getGeometryType())) { + setChanged() + notifyObservers() + } + } + + override fun setGeometry(geometry: Geometry?) { + super.setGeometry(geometry) + setChanged() + notifyObservers() + } + + public fun getBoundingBox(): LatLngBounds? = boundingBox + + override fun toString(): String = + StringBuilder("Feature{") + .apply { + append("\n bounding box=").append(boundingBox) + append(",\n geometry=").append(getGeometry()) + append(",\n point style=").append(pointStyle) + append(",\n line string style=").append(lineStringStyle) + append(",\n polygon style=").append(polygonStyle) + append(",\n id=").append(mId) + append(",\n properties=").append(getProperties()) + append("\n}\n") + }.toString() + + override fun update( + observable: Observable?, + data: Any?, + ) { + if (observable is GeoJsonStyle) { + checkRedrawFeature(observable) + } + } +} diff --git a/data/src/main/java/com/google/maps/android/data/geojson/GeoJsonGeometryCollection.kt b/data/src/main/java/com/google/maps/android/data/geojson/GeoJsonGeometryCollection.kt new file mode 100644 index 000000000..8688fca0c --- /dev/null +++ b/data/src/main/java/com/google/maps/android/data/geojson/GeoJsonGeometryCollection.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.data.geojson + +import com.google.maps.android.data.Geometry +import com.google.maps.android.data.MultiGeometry + +/** + * A GeoJsonGeometryCollection geometry contains a number of GeoJson Geometry objects. + */ +@Deprecated("Use the new platform-agnostic data layer and renderer instead.") +public class GeoJsonGeometryCollection( + geometries: List, +) : MultiGeometry(geometries) { + init { + setGeometryType("GeometryCollection") + } + + public fun getType(): String = getGeometryType() + + public fun getGeometries(): List = getGeometryObject() +} diff --git a/data/src/main/java/com/google/maps/android/data/geojson/GeoJsonLayer.kt b/data/src/main/java/com/google/maps/android/data/geojson/GeoJsonLayer.kt new file mode 100644 index 000000000..07c2c4442 --- /dev/null +++ b/data/src/main/java/com/google/maps/android/data/geojson/GeoJsonLayer.kt @@ -0,0 +1,411 @@ +/* + * 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.data.geojson + +import android.content.Context +import com.google.android.gms.maps.GoogleMap +import com.google.android.gms.maps.model.LatLng +import com.google.android.gms.maps.model.LatLngBounds +import com.google.maps.android.collections.GroundOverlayManager +import com.google.maps.android.collections.MarkerManager +import com.google.maps.android.collections.PolygonManager +import com.google.maps.android.collections.PolylineManager +import com.google.maps.android.data.Geometry +import com.google.maps.android.data.Layer +import com.google.maps.android.data.parser.geojson.GeoJsonParser +import com.google.maps.android.data.renderer.UrlIconProvider +import com.google.maps.android.data.renderer.mapview.MapViewRenderer +import org.json.JSONException +import org.json.JSONObject +import java.io.IOException +import java.io.InputStream + +@Deprecated("Use the new platform-agnostic data layer and renderer instead.") +public class GeoJsonLayer : Layer { + private val mFeatures = mutableListOf() + private var mBoundingBox: LatLngBounds? = null + private var mRenderer: MapViewRenderer? = null + private var mIsLayerOnMap = false + private val mFeatureMap = HashMap() + private val mModelToLegacyFeatures = HashMap() + private var mFeatureClickListener: OnFeatureClickListener? = null + + public interface GeoJsonOnFeatureClickListener : OnFeatureClickListener + + @JvmOverloads + @Throws(JSONException::class) + public constructor( + map: GoogleMap?, + geoJsonFile: JSONObject, + markerManager: MarkerManager? = null, + polygonManager: PolygonManager? = null, + polylineManager: PolylineManager? = null, + groundOverlayManager: GroundOverlayManager? = null, + ) { + mGoogleMap = map + val stream = geoJsonFile.toString().byteInputStream() + parseGeoJson(stream) + initializeRenderer(map) + } + + @JvmOverloads + @Throws(IOException::class, JSONException::class) + public constructor( + map: GoogleMap?, + resourceId: Int, + context: Context, + markerManager: MarkerManager? = null, + polygonManager: PolygonManager? = null, + polylineManager: PolylineManager? = null, + groundOverlayManager: GroundOverlayManager? = null, + ) { + mGoogleMap = map + val stream = context.resources.openRawResource(resourceId) + parseGeoJson(stream) + initializeRenderer(map) + } + + private fun parseGeoJson(stream: InputStream) { + val parsed = GeoJsonParser().parse(stream) ?: throw JSONException("Failed to parse GeoJSON") + + // Map parsed object to our legacy GeoJsonFeature instances + when (parsed) { + is com.google.maps.android.data.parser.geojson.GeoJsonFeatureCollection -> { + parsed.features.forEach { feature -> + val legacyGeometry = feature.geometry?.let { toLegacyGeometry(it) } + val legacyProps = feature.properties?.filterValues { it != null }?.mapValues { it.value as String } + val legacyFeature = GeoJsonFeature(legacyGeometry, feature.id, legacyProps, null) + + // Wire default styles + legacyFeature.pointStyle = mDefaultPointStyle + legacyFeature.lineStringStyle = mDefaultLineStringStyle + legacyFeature.polygonStyle = mDefaultPolygonStyle + + mFeatures.add(legacyFeature) + } + } + + is com.google.maps.android.data.parser.geojson.GeoJsonFeature -> { + val legacyGeometry = parsed.geometry?.let { toLegacyGeometry(it) } + val legacyProps = parsed.properties?.filterValues { it != null }?.mapValues { it.value as String } + val legacyFeature = GeoJsonFeature(legacyGeometry, parsed.id, legacyProps, null) + + legacyFeature.pointStyle = mDefaultPointStyle + legacyFeature.lineStringStyle = mDefaultLineStringStyle + legacyFeature.polygonStyle = mDefaultPolygonStyle + + mFeatures.add(legacyFeature) + } + + is com.google.maps.android.data.parser.geojson.GeoJsonGeometry -> { + val legacyGeometry = toLegacyGeometry(parsed) + val legacyFeature = GeoJsonFeature(legacyGeometry, null, null, null) + + legacyFeature.pointStyle = mDefaultPointStyle + legacyFeature.lineStringStyle = mDefaultLineStringStyle + legacyFeature.polygonStyle = mDefaultPolygonStyle + + mFeatures.add(legacyFeature) + } + } + + // Calculate bounding box + calculateBoundingBox() + } + + private fun initializeRenderer(map: GoogleMap?) { + mRenderer = map?.let { MapViewRenderer(it, UrlIconProvider()) } + } + + private fun toLegacyGeometry(geometry: com.google.maps.android.data.parser.geojson.GeoJsonGeometry): Geometry = + when (geometry) { + is com.google.maps.android.data.parser.geojson.GeoJsonPoint -> { + GeoJsonPoint(LatLng(geometry.coordinates.lat, geometry.coordinates.lng), geometry.coordinates.alt) + } + + is com.google.maps.android.data.parser.geojson.GeoJsonLineString -> { + GeoJsonLineString(geometry.coordinates.map { LatLng(it.lat, it.lng) }, geometry.coordinates.mapNotNull { it.alt }) + } + + is com.google.maps.android.data.parser.geojson.GeoJsonPolygon -> { + GeoJsonPolygon(geometry.coordinates.map { ring -> ring.map { LatLng(it.lat, it.lng) } }) + } + + is com.google.maps.android.data.parser.geojson.GeoJsonMultiPoint -> { + GeoJsonMultiPoint(geometry.coordinates.map { GeoJsonPoint(LatLng(it.lat, it.lng), it.alt) }) + } + + is com.google.maps.android.data.parser.geojson.GeoJsonMultiLineString -> { + GeoJsonMultiLineString(geometry.coordinates.map { GeoJsonLineString(it.map { c -> LatLng(c.lat, c.lng) }) }) + } + + is com.google.maps.android.data.parser.geojson.GeoJsonMultiPolygon -> { + GeoJsonMultiPolygon(geometry.coordinates.map { GeoJsonPolygon(it.map { ring -> ring.map { c -> LatLng(c.lat, c.lng) } }) }) + } + + is com.google.maps.android.data.parser.geojson.GeoJsonGeometryCollection -> { + GeoJsonGeometryCollection(geometry.geometries.map { toLegacyGeometry(it) }) + } + } + + private fun calculateBoundingBox() { + val boundsBuilder = LatLngBounds.builder() + var hasPoints = false + + mFeatures.forEach { feature -> + val geometry = feature.getGeometry() ?: return@forEach + when (geometry) { + is GeoJsonPoint -> { + boundsBuilder.include(geometry.getCoordinates()) + hasPoints = true + } + + is GeoJsonLineString -> { + geometry.getCoordinates().forEach { + boundsBuilder.include(it) + hasPoints = true + } + } + + is GeoJsonPolygon -> { + geometry.getOuterBoundaryCoordinates().forEach { + boundsBuilder.include(it) + hasPoints = true + } + } + + is com.google.maps.android.data.MultiGeometry -> { + includeMultiGeometryBounds(geometry, boundsBuilder) + hasPoints = true + } + } + } + if (hasPoints) { + mBoundingBox = boundsBuilder.build() + } + } + + private fun includeMultiGeometryBounds( + multiGeometry: com.google.maps.android.data.MultiGeometry, + builder: LatLngBounds.Builder, + ) { + multiGeometry.getGeometryObject().forEach { geom -> + when (geom) { + is GeoJsonPoint -> builder.include(geom.getCoordinates()) + is GeoJsonLineString -> geom.getCoordinates().forEach { builder.include(it) } + is GeoJsonPolygon -> geom.getOuterBoundaryCoordinates().forEach { builder.include(it) } + is com.google.maps.android.data.MultiGeometry -> includeMultiGeometryBounds(geom, builder) + } + } + } + + override fun getMap(): GoogleMap? = mGoogleMap + + override fun setMap(map: GoogleMap?) { + mGoogleMap = map + if (map == null) { + removeLayerFromMap() + mRenderer = null + } else { + mRenderer = MapViewRenderer(map, UrlIconProvider()) + if (mIsLayerOnMap) { + addLayerToMap() + } + } + } + + override fun addLayerToMap() { + val renderer = mRenderer ?: return + mFeatures.forEach { feature -> + val modelFeature = toModelFeature(feature) + renderer.addFeature(modelFeature) + } + mIsLayerOnMap = true + } + + override fun removeLayerFromMap() { + val renderer = mRenderer ?: return + mFeatures.forEach { feature -> + val modelFeature = toModelFeature(feature) + renderer.removeFeature(modelFeature) + } + mIsLayerOnMap = false + } + + private fun toModelFeature(feature: GeoJsonFeature): com.google.maps.android.data.renderer.model.Feature { + val existing = mFeatureMap[feature] + if (existing != null) return existing + + val geometry = feature.getGeometry()!! + val modelGeometry = toModelGeometry(geometry) + val properties = feature.getPropertyKeys().associateWith { feature.getProperty(it) as Any } + + val style = + when (modelGeometry) { + is com.google.maps.android.data.renderer.model.PointGeometry -> { + val pointStyle = feature.pointStyle ?: mDefaultPointStyle + com.google.maps.android.data.renderer.model.PointStyle( + color = 0, + anchorU = pointStyle.getAnchorU(), + anchorV = pointStyle.getAnchorV(), + heading = pointStyle.getRotation(), + ) + } + + is com.google.maps.android.data.renderer.model.LineString -> { + val lineStyle = feature.lineStringStyle ?: mDefaultLineStringStyle + com.google.maps.android.data.renderer.model.LineStyle( + color = lineStyle.color, + width = lineStyle.getWidth(), + geodesic = lineStyle.isGeodesic(), + ) + } + + is com.google.maps.android.data.renderer.model.Polygon -> { + val polygonStyle = feature.polygonStyle ?: mDefaultPolygonStyle + com.google.maps.android.data.renderer.model.PolygonStyle( + fillColor = polygonStyle.fillColor, + strokeColor = polygonStyle.getStrokeColor(), + strokeWidth = polygonStyle.getStrokeWidth(), + geodesic = polygonStyle.isGeodesic(), + ) + } + + else -> { + null + } + } + + val modelFeature = + com.google.maps.android.data.renderer.model + .Feature(modelGeometry, style, properties) + mFeatureMap[feature] = modelFeature + mModelToLegacyFeatures[modelFeature] = feature + return modelFeature + } + + private fun toModelGeometry(geometry: Geometry): com.google.maps.android.data.renderer.model.Geometry = + when (geometry) { + is GeoJsonPoint -> { + com.google.maps.android.data.renderer.model.PointGeometry( + com.google.maps.android.data.renderer.model.Point( + geometry.getCoordinates().latitude, + geometry.getCoordinates().longitude, + geometry.getAltitude(), + ), + ) + } + + is GeoJsonLineString -> { + com.google.maps.android.data.renderer.model.LineString( + geometry.getCoordinates().map { + com.google.maps.android.data.renderer.model + .Point(it.latitude, it.longitude) + }, + ) + } + + is GeoJsonPolygon -> { + com.google.maps.android.data.renderer.model.Polygon( + geometry.getOuterBoundaryCoordinates().map { + com.google.maps.android.data.renderer.model.Point( + it.latitude, + it.longitude, + ) + }, + geometry.getInnerBoundaryCoordinates().map { inner -> + inner.map { + com.google.maps.android.data.renderer.model + .Point(it.latitude, it.longitude) + } + }, + ) + } + + is com.google.maps.android.data.MultiGeometry -> { + com.google.maps.android.data.renderer.model.MultiGeometry( + geometry.getGeometryObject().map { toModelGeometry(it) }, + ) + } + + else -> { + throw IllegalArgumentException("Unknown geometry type") + } + } + + override val features: Iterable + get() = mFeatures + + public fun addFeature(feature: GeoJsonFeature) { + mFeatures.add(feature) + if (mIsLayerOnMap) { + mRenderer?.addFeature(toModelFeature(feature)) + } + } + + public fun removeFeature(feature: GeoJsonFeature) { + mFeatures.remove(feature) + val modelFeature = mFeatureMap.remove(feature) + if (modelFeature != null) { + mModelToLegacyFeatures.remove(modelFeature) + if (mIsLayerOnMap) { + mRenderer?.removeFeature(modelFeature) + } + } + } + + override fun setOnFeatureClickListener(listener: OnFeatureClickListener) { + mFeatureClickListener = listener + mGoogleMap?.let { map -> + map.setOnMarkerClickListener { marker -> + val feature = findLegacyFeatureForMapObject(marker) + if (feature != null) { + mFeatureClickListener?.onFeatureClick(feature) + } + false + } + map.setOnPolygonClickListener { polygon -> + val feature = findLegacyFeatureForMapObject(polygon) + if (feature != null) { + mFeatureClickListener?.onFeatureClick(feature) + } + } + map.setOnPolylineClickListener { polyline -> + val feature = findLegacyFeatureForMapObject(polyline) + if (feature != null) { + mFeatureClickListener?.onFeatureClick(feature) + } + } + } + } + + private fun findLegacyFeatureForMapObject(mapObject: Any): GeoJsonFeature? { + val renderer = mRenderer ?: return null + val modelFeature = renderer.getFeatureForMapObject(mapObject) ?: return null + return mModelToLegacyFeatures[modelFeature] + } + + public fun getBoundingBox(): LatLngBounds? = mBoundingBox + + public fun isLayerOnMap(): Boolean = mIsLayerOnMap + + override fun toString(): String = + StringBuilder("Collection{") + .apply { + append("\n Bounding box=").append(mBoundingBox) + append("\n}\n") + }.toString() +} diff --git a/data/src/main/java/com/google/maps/android/data/geojson/GeoJsonLineString.kt b/data/src/main/java/com/google/maps/android/data/geojson/GeoJsonLineString.kt new file mode 100644 index 000000000..5c69abfde --- /dev/null +++ b/data/src/main/java/com/google/maps/android/data/geojson/GeoJsonLineString.kt @@ -0,0 +1,36 @@ +/* + * 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.data.geojson + +import com.google.android.gms.maps.model.LatLng +import com.google.maps.android.data.LineString + +/** + * A GeoJsonLineString geometry represents a number of connected LatLngs. + */ +@Deprecated("Use the new platform-agnostic data layer and renderer instead.") +public class GeoJsonLineString + @JvmOverloads + constructor( + coordinates: List, + private val altitudes: List? = null, + ) : LineString(coordinates) { + public fun getType(): String = getGeometryType() + + public fun getCoordinates(): List = getGeometryObject() + + public fun getAltitudes(): List? = altitudes + } diff --git a/data/src/main/java/com/google/maps/android/data/geojson/GeoJsonLineStringStyle.kt b/data/src/main/java/com/google/maps/android/data/geojson/GeoJsonLineStringStyle.kt new file mode 100644 index 000000000..a7865c7fb --- /dev/null +++ b/data/src/main/java/com/google/maps/android/data/geojson/GeoJsonLineStringStyle.kt @@ -0,0 +1,133 @@ +/* + * 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.data.geojson + +import com.google.android.gms.maps.model.Cap +import com.google.android.gms.maps.model.PatternItem +import com.google.android.gms.maps.model.PolylineOptions +import com.google.maps.android.data.Style + +/** + * A class that allows for GeoJsonLineString objects to be styled and for these styles to be + * translated into a PolylineOptions object. + */ +@Deprecated("Use the new platform-agnostic data layer and renderer instead.") +public class GeoJsonLineStringStyle : + Style(), + GeoJsonStyle { + override fun getGeometryType(): Array = GEOMETRY_TYPE + + public var color: Int + get() = mPolylineOptions.color + set(color) { + mPolylineOptions.color(color) + styleChanged() + } + + public fun isClickable(): Boolean = mPolylineOptions.isClickable + + public fun setClickable(clickable: Boolean) { + mPolylineOptions.clickable(clickable) + styleChanged() + } + + public fun isGeodesic(): Boolean = mPolylineOptions.isGeodesic + + public fun setGeodesic(geodesic: Boolean) { + mPolylineOptions.geodesic(geodesic) + styleChanged() + } + + public fun getWidth(): Float = mPolylineOptions.width + + public fun setWidth(width: Float) { + setLineStringWidth(width) + styleChanged() + } + + public fun getZIndex(): Float = mPolylineOptions.zIndex + + public fun setZIndex(zIndex: Float) { + mPolylineOptions.zIndex(zIndex) + styleChanged() + } + + override fun isVisible(): Boolean = mPolylineOptions.isVisible + + override fun setVisible(visible: Boolean) { + mPolylineOptions.visible(visible) + styleChanged() + } + + private fun styleChanged() { + setChanged() + notifyObservers() + } + + public fun toPolylineOptions(): PolylineOptions = + PolylineOptions().apply { + color(mPolylineOptions.color) + clickable(mPolylineOptions.isClickable) + geodesic(mPolylineOptions.isGeodesic) + visible(mPolylineOptions.isVisible) + width(mPolylineOptions.width) + zIndex(mPolylineOptions.zIndex) + pattern(getPattern()) + startCap(getStartCap()) + endCap(getEndCap()) + } + + override fun toString(): String = + StringBuilder("LineStringStyle{") + .apply { + append("\n geometry type=").append(GEOMETRY_TYPE.contentToString()) + append(",\n color=").append(color) + append(",\n clickable=").append(isClickable()) + append(",\n geodesic=").append(isGeodesic()) + append(",\n visible=").append(isVisible()) + append(",\n width=").append(getWidth()) + append(",\n z index=").append(getZIndex()) + append(",\n pattern=").append(getPattern()) + append(",\n startCap=").append(getStartCap()) + append(",\n endCap=").append(getEndCap()) + append("\n}\n") + }.toString() + + public fun getPattern(): List? = mPolylineOptions.pattern + + public fun setPattern(pattern: List?) { + mPolylineOptions.pattern(pattern) + styleChanged() + } + + public fun setStartCap(cap: Cap) { + mPolylineOptions.startCap(cap) + styleChanged() + } + + public fun setEndCap(cap: Cap) { + mPolylineOptions.endCap(cap) + styleChanged() + } + + public fun getStartCap(): Cap = mPolylineOptions.startCap + + public fun getEndCap(): Cap = mPolylineOptions.endCap + + companion object { + private val GEOMETRY_TYPE = arrayOf("LineString", "MultiLineString", "GeometryCollection") + } +} diff --git a/data/src/main/java/com/google/maps/android/data/geojson/GeoJsonMultiLineString.kt b/data/src/main/java/com/google/maps/android/data/geojson/GeoJsonMultiLineString.kt new file mode 100644 index 000000000..3e6215efa --- /dev/null +++ b/data/src/main/java/com/google/maps/android/data/geojson/GeoJsonMultiLineString.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.data.geojson + +import com.google.maps.android.data.MultiGeometry + +/** + * A GeoJsonMultiLineString geometry contains a number of GeoJsonLineStrings. + */ +@Deprecated("Use the new platform-agnostic data layer and renderer instead.") +public class GeoJsonMultiLineString( + geoJsonLineStrings: List, +) : MultiGeometry(geoJsonLineStrings) { + init { + setGeometryType("MultiLineString") + } + + public fun getType(): String = getGeometryType() + + public fun getLineStrings(): List = getGeometryObject().filterIsInstance() +} diff --git a/data/src/main/java/com/google/maps/android/data/geojson/GeoJsonMultiPoint.kt b/data/src/main/java/com/google/maps/android/data/geojson/GeoJsonMultiPoint.kt new file mode 100644 index 000000000..6634ada38 --- /dev/null +++ b/data/src/main/java/com/google/maps/android/data/geojson/GeoJsonMultiPoint.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.data.geojson + +import com.google.maps.android.data.MultiGeometry + +/** + * A GeoJsonMultiPoint geometry contains a number of GeoJsonPoints. + */ +@Deprecated("Use the new platform-agnostic data layer and renderer instead.") +public class GeoJsonMultiPoint( + geoJsonPoints: List, +) : MultiGeometry(geoJsonPoints) { + init { + setGeometryType("MultiPoint") + } + + public fun getType(): String = getGeometryType() + + public fun getPoints(): List = getGeometryObject().filterIsInstance() +} diff --git a/data/src/main/java/com/google/maps/android/data/geojson/GeoJsonMultiPolygon.kt b/data/src/main/java/com/google/maps/android/data/geojson/GeoJsonMultiPolygon.kt new file mode 100644 index 000000000..1f1c77841 --- /dev/null +++ b/data/src/main/java/com/google/maps/android/data/geojson/GeoJsonMultiPolygon.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.data.geojson + +import com.google.maps.android.data.MultiGeometry + +/** + * A GeoJsonMultiPolygon geometry contains a number of GeoJsonPolygons. + */ +@Deprecated("Use the new platform-agnostic data layer and renderer instead.") +public class GeoJsonMultiPolygon( + geoJsonPolygons: List, +) : MultiGeometry(geoJsonPolygons) { + init { + setGeometryType("MultiPolygon") + } + + public fun getType(): String = getGeometryType() + + public fun getPolygons(): List = getGeometryObject().filterIsInstance() +} diff --git a/data/src/main/java/com/google/maps/android/data/geojson/GeoJsonPoint.kt b/data/src/main/java/com/google/maps/android/data/geojson/GeoJsonPoint.kt new file mode 100644 index 000000000..1ed0ff75f --- /dev/null +++ b/data/src/main/java/com/google/maps/android/data/geojson/GeoJsonPoint.kt @@ -0,0 +1,36 @@ +/* + * 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.data.geojson + +import com.google.android.gms.maps.model.LatLng +import com.google.maps.android.data.Point + +/** + * A GeoJsonPoint geometry contains a single LatLng. + */ +@Deprecated("Use the new platform-agnostic data layer and renderer instead.") +public class GeoJsonPoint + @JvmOverloads + constructor( + coordinates: LatLng, + private val altitude: Double? = null, + ) : Point(coordinates) { + public fun getType(): String = getGeometryType() + + public fun getCoordinates(): LatLng = getGeometryObject() + + public fun getAltitude(): Double? = altitude + } diff --git a/data/src/main/java/com/google/maps/android/data/geojson/GeoJsonPointStyle.kt b/data/src/main/java/com/google/maps/android/data/geojson/GeoJsonPointStyle.kt new file mode 100644 index 000000000..baf238370 --- /dev/null +++ b/data/src/main/java/com/google/maps/android/data/geojson/GeoJsonPointStyle.kt @@ -0,0 +1,161 @@ +/* + * 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.data.geojson + +import com.google.android.gms.maps.model.BitmapDescriptor +import com.google.android.gms.maps.model.MarkerOptions +import com.google.maps.android.data.Style + +/** + * A class that allows for GeoJsonPoint objects to be styled and for these styles to be translated + * into a MarkerOptions object. + */ +@Deprecated("Use the new platform-agnostic data layer and renderer instead.") +public class GeoJsonPointStyle : + Style(), + GeoJsonStyle { + override fun getGeometryType(): Array = GEOMETRY_TYPE + + public fun getAlpha(): Float = mMarkerOptions.alpha + + public fun setAlpha(alpha: Float) { + mMarkerOptions.alpha(alpha) + styleChanged() + } + + public fun getAnchorU(): Float = mMarkerOptions.anchorU + + public fun getAnchorV(): Float = mMarkerOptions.anchorV + + public fun setAnchor( + anchorU: Float, + anchorV: Float, + ) { + setMarkerHotSpot(anchorU, anchorV, "fraction", "fraction") + styleChanged() + } + + public fun isDraggable(): Boolean = mMarkerOptions.isDraggable + + public fun setDraggable(draggable: Boolean) { + mMarkerOptions.draggable(draggable) + styleChanged() + } + + public fun isFlat(): Boolean = mMarkerOptions.isFlat + + public fun setFlat(flat: Boolean) { + mMarkerOptions.flat(flat) + styleChanged() + } + + public fun getIcon(): BitmapDescriptor? = mMarkerOptions.icon + + public fun setIcon(bitmap: BitmapDescriptor?) { + mMarkerOptions.icon(bitmap) + styleChanged() + } + + public fun getInfoWindowAnchorU(): Float = mMarkerOptions.infoWindowAnchorU + + public fun getInfoWindowAnchorV(): Float = mMarkerOptions.infoWindowAnchorV + + public fun setInfoWindowAnchor( + infoWindowAnchorU: Float, + infoWindowAnchorV: Float, + ) { + mMarkerOptions.infoWindowAnchor(infoWindowAnchorU, infoWindowAnchorV) + styleChanged() + } + + override fun getRotation(): Float = mMarkerOptions.rotation + + public fun setRotation(rotation: Float) { + setMarkerRotation(rotation) + styleChanged() + } + + public fun getSnippet(): String? = mMarkerOptions.snippet + + public fun setSnippet(snippet: String?) { + mMarkerOptions.snippet(snippet) + styleChanged() + } + + public fun getTitle(): String? = mMarkerOptions.title + + public fun setTitle(title: String?) { + mMarkerOptions.title(title) + styleChanged() + } + + override fun isVisible(): Boolean = mMarkerOptions.isVisible + + override fun setVisible(visible: Boolean) { + mMarkerOptions.visible(visible) + styleChanged() + } + + public fun getZIndex(): Float = mMarkerOptions.zIndex + + public fun setZIndex(zIndex: Float) { + mMarkerOptions.zIndex(zIndex) + styleChanged() + } + + private fun styleChanged() { + setChanged() + notifyObservers() + } + + public fun toMarkerOptions(): MarkerOptions = + MarkerOptions().apply { + alpha(mMarkerOptions.alpha) + anchor(mMarkerOptions.anchorU, mMarkerOptions.anchorV) + draggable(mMarkerOptions.isDraggable) + flat(mMarkerOptions.isFlat) + icon(mMarkerOptions.icon) + infoWindowAnchor(mMarkerOptions.infoWindowAnchorU, mMarkerOptions.infoWindowAnchorV) + rotation(mMarkerOptions.rotation) + snippet(mMarkerOptions.snippet) + title(mMarkerOptions.title) + visible(mMarkerOptions.isVisible) + zIndex(mMarkerOptions.zIndex) + } + + override fun toString(): String = + StringBuilder("PointStyle{") + .apply { + append("\n geometry type=").append(GEOMETRY_TYPE.contentToString()) + append(",\n alpha=").append(getAlpha()) + append(",\n anchor U=").append(getAnchorU()) + append(",\n anchor V=").append(getAnchorV()) + append(",\n draggable=").append(isDraggable()) + append(",\n flat=").append(isFlat()) + append(",\n info window anchor U=").append(getInfoWindowAnchorU()) + append(",\n info window anchor V=").append(getInfoWindowAnchorV()) + append(",\n rotation=").append(getRotation()) + append(",\n snippet=").append(getSnippet()) + append(",\n title=").append(getTitle()) + append(",\n visible=").append(isVisible()) + append(",\n z index=").append(getZIndex()) + append("\n}\n") + }.toString() + + companion object { + private val GEOMETRY_TYPE = arrayOf("Point", "MultiPoint", "GeometryCollection") + } +} diff --git a/data/src/main/java/com/google/maps/android/data/geojson/GeoJsonPolygon.kt b/data/src/main/java/com/google/maps/android/data/geojson/GeoJsonPolygon.kt new file mode 100644 index 000000000..af75c1908 --- /dev/null +++ b/data/src/main/java/com/google/maps/android/data/geojson/GeoJsonPolygon.kt @@ -0,0 +1,63 @@ +/* + * 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.data.geojson + +import com.google.android.gms.maps.model.LatLng +import com.google.maps.android.data.DataPolygon + +/** + * A GeoJsonPolygon geometry contains an array of arrays of LatLngs. + * The first array is the polygon exterior boundary. Subsequent arrays are holes. + */ +@Deprecated("Use the new platform-agnostic data layer and renderer instead.") +public class GeoJsonPolygon( + private val coordinates: List>, +) : DataPolygon { + init { + requireNotNull(coordinates) { "Coordinates cannot be null" } + } + + public fun getType(): String = GEOMETRY_TYPE + + public fun getCoordinates(): List> = coordinates + + override fun getGeometryObject(): List> = getCoordinates() + + override fun getGeometryType(): String = getType() + + override fun getOuterBoundaryCoordinates(): List = coordinates[POLYGON_OUTER_COORDINATE_INDEX] + + override fun getInnerBoundaryCoordinates(): List> { + val innerBoundary = ArrayList>() + for (i in POLYGON_INNER_COORDINATE_INDEX until coordinates.size) { + innerBoundary.add(coordinates[i]) + } + return innerBoundary + } + + override fun toString(): String { + val sb = StringBuilder(GEOMETRY_TYPE).append("{") + sb.append("\n coordinates=").append(coordinates) + sb.append("\n}\n") + return sb.toString() + } + + companion object { + private const val GEOMETRY_TYPE = "Polygon" + private const val POLYGON_OUTER_COORDINATE_INDEX = 0 + private const val POLYGON_INNER_COORDINATE_INDEX = 1 + } +} diff --git a/data/src/main/java/com/google/maps/android/data/geojson/GeoJsonPolygonStyle.kt b/data/src/main/java/com/google/maps/android/data/geojson/GeoJsonPolygonStyle.kt new file mode 100644 index 000000000..af04087d0 --- /dev/null +++ b/data/src/main/java/com/google/maps/android/data/geojson/GeoJsonPolygonStyle.kt @@ -0,0 +1,132 @@ +/* + * 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.data.geojson + +import com.google.android.gms.maps.model.PatternItem +import com.google.android.gms.maps.model.PolygonOptions +import com.google.maps.android.data.Style + +/** + * A class that allows for GeoJsonPolygon objects to be styled and for these styles to be + * translated into a PolygonOptions object. + */ +@Deprecated("Use the new platform-agnostic data layer and renderer instead.") +public class GeoJsonPolygonStyle : + Style(), + GeoJsonStyle { + override fun getGeometryType(): Array = GEOMETRY_TYPE + + public var fillColor: Int + get() = mPolygonOptions.fillColor + set(fillColor) { + setPolygonFillColor(fillColor) + styleChanged() + } + + public fun isGeodesic(): Boolean = mPolygonOptions.isGeodesic + + public fun setGeodesic(geodesic: Boolean) { + mPolygonOptions.geodesic(geodesic) + styleChanged() + } + + public fun getStrokeColor(): Int = mPolygonOptions.strokeColor + + public fun setStrokeColor(strokeColor: Int) { + mPolygonOptions.strokeColor(strokeColor) + styleChanged() + } + + public fun getStrokeJointType(): Int = mPolygonOptions.strokeJointType + + public fun setStrokeJointType(strokeJointType: Int) { + mPolygonOptions.strokeJointType(strokeJointType) + styleChanged() + } + + public fun getStrokePattern(): List? = mPolygonOptions.strokePattern + + public fun setStrokePattern(strokePattern: List?) { + mPolygonOptions.strokePattern(strokePattern) + styleChanged() + } + + public fun getStrokeWidth(): Float = mPolygonOptions.strokeWidth + + public fun setStrokeWidth(strokeWidth: Float) { + setPolygonStrokeWidth(strokeWidth) + styleChanged() + } + + public fun getZIndex(): Float = mPolygonOptions.zIndex + + public fun setZIndex(zIndex: Float) { + mPolygonOptions.zIndex(zIndex) + styleChanged() + } + + override fun isVisible(): Boolean = mPolygonOptions.isVisible + + override fun setVisible(visible: Boolean) { + mPolygonOptions.visible(visible) + styleChanged() + } + + private fun styleChanged() { + setChanged() + notifyObservers() + } + + public fun toPolygonOptions(): PolygonOptions = + PolygonOptions().apply { + fillColor(mPolygonOptions.fillColor) + geodesic(mPolygonOptions.isGeodesic) + strokeColor(mPolygonOptions.strokeColor) + strokeJointType(mPolygonOptions.strokeJointType) + strokePattern(mPolygonOptions.strokePattern) + strokeWidth(mPolygonOptions.strokeWidth) + visible(mPolygonOptions.isVisible) + zIndex(mPolygonOptions.zIndex) + clickable(mPolygonOptions.isClickable) + } + + override fun toString(): String = + StringBuilder("PolygonStyle{") + .apply { + append("\n geometry type=").append(GEOMETRY_TYPE.contentToString()) + append(",\n fill color=").append(fillColor) + append(",\n geodesic=").append(isGeodesic()) + append(",\n stroke color=").append(getStrokeColor()) + append(",\n stroke joint type=").append(getStrokeJointType()) + append(",\n stroke pattern=").append(getStrokePattern()) + append(",\n stroke width=").append(getStrokeWidth()) + append(",\n visible=").append(isVisible()) + append(",\n z index=").append(getZIndex()) + append(",\n clickable=").append(isClickable()) + append("\n}\n") + }.toString() + + public fun setClickable(clickable: Boolean) { + mPolygonOptions.clickable(clickable) + styleChanged() + } + + public fun isClickable(): Boolean = mPolygonOptions.isClickable + + companion object { + private val GEOMETRY_TYPE = arrayOf("Polygon", "MultiPolygon", "GeometryCollection") + } +} diff --git a/data/src/main/java/com/google/maps/android/data/geojson/GeoJsonStyle.kt b/data/src/main/java/com/google/maps/android/data/geojson/GeoJsonStyle.kt new file mode 100644 index 000000000..517f9089e --- /dev/null +++ b/data/src/main/java/com/google/maps/android/data/geojson/GeoJsonStyle.kt @@ -0,0 +1,25 @@ +/* + * 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.data.geojson + +@Deprecated("Use the new platform-agnostic data layer and renderer instead.") +public interface GeoJsonStyle { + public fun getGeometryType(): Array + + public fun isVisible(): Boolean + + public fun setVisible(visible: Boolean) +} diff --git a/data/src/main/java/com/google/maps/android/data/kml/KmlContainer.kt b/data/src/main/java/com/google/maps/android/data/kml/KmlContainer.kt new file mode 100644 index 000000000..e7b32d78c --- /dev/null +++ b/data/src/main/java/com/google/maps/android/data/kml/KmlContainer.kt @@ -0,0 +1,84 @@ +/* + * 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.data.kml + +import com.google.android.gms.maps.model.GroundOverlay + +/** + * Represents a KML Document or Folder. + */ +@Deprecated("Use the new platform-agnostic data layer and renderer instead.") +public class KmlContainer( + private val properties: HashMap, + private val styles: HashMap, + private val placemarks: HashMap, + private val styleMap: HashMap, + private val containers: ArrayList, + private val groundOverlays: HashMap, + private val containerId: String?, +) { + internal fun getStyles(): HashMap = styles + + internal fun setPlacemark( + placemark: KmlPlacemark, + obj: Any?, + ) { + placemarks[placemark] = obj + } + + internal fun getStyleMap(): HashMap = styleMap + + internal fun getGroundOverlayHashMap(): HashMap = groundOverlays + + public fun getContainerId(): String? = containerId + + public fun getStyle(styleID: String): KmlStyle? = styles[styleID] + + public fun getStyleIdFromMap(styleID: String): String? = styleMap[styleID] + + internal fun getPlacemarksHashMap(): HashMap = placemarks + + public fun getProperty(propertyName: String): String? = properties[propertyName] + + public fun hasProperties(): Boolean = properties.isNotEmpty() + + public fun hasProperty(keyValue: String): Boolean = properties.containsKey(keyValue) + + public fun hasContainers(): Boolean = containers.isNotEmpty() + + public fun getContainers(): Iterable = containers + + public fun getProperties(): Iterable = properties.keys + + public fun getPlacemarks(): Iterable = placemarks.keys + + public fun hasPlacemarks(): Boolean = placemarks.isNotEmpty() + + public fun getGroundOverlays(): Iterable = groundOverlays.keys + + override fun toString(): String = + StringBuilder("Container") + .apply { + append("{") + append("\n properties=").append(properties) + append(",\n placemarks=").append(placemarks) + append(",\n containers=").append(containers) + append(",\n ground overlays=").append(groundOverlays) + append(",\n style maps=").append(styleMap) + append(",\n styles=").append(styles) + append("\n}\n") + }.toString() +} diff --git a/data/src/main/java/com/google/maps/android/data/kml/KmlGroundOverlay.kt b/data/src/main/java/com/google/maps/android/data/kml/KmlGroundOverlay.kt new file mode 100644 index 000000000..de4bcd2a2 --- /dev/null +++ b/data/src/main/java/com/google/maps/android/data/kml/KmlGroundOverlay.kt @@ -0,0 +1,64 @@ +/* + * 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.data.kml + +import com.google.android.gms.maps.model.GroundOverlayOptions +import com.google.android.gms.maps.model.LatLngBounds + +/** + * Represents a KML Ground Overlay. + */ +@Deprecated("Use the new platform-agnostic data layer and renderer instead.") +public class KmlGroundOverlay( + private val imageUrl: String, + private val latLngBox: LatLngBounds, + drawOrder: Float, + visibility: Int, + private val properties: Map, + rotation: Float, +) { + private val groundOverlayOptions = GroundOverlayOptions() + + init { + requireNotNull(latLngBox) { "No LatLonBox given" } + groundOverlayOptions.positionFromBounds(latLngBox) + groundOverlayOptions.bearing(rotation) + groundOverlayOptions.zIndex(drawOrder) + groundOverlayOptions.visible(visibility != 0) + } + + public fun getImageUrl(): String = imageUrl + + public fun getLatLngBox(): LatLngBounds = latLngBox + + public fun getProperties(): Iterable = properties.keys + + public fun getProperty(keyValue: String): String? = properties[keyValue] + + public fun hasProperty(keyValue: String): Boolean = properties.containsKey(keyValue) + + internal fun getGroundOverlayOptions(): GroundOverlayOptions = groundOverlayOptions + + override fun toString(): String = + StringBuilder("GroundOverlay") + .apply { + append("{") + append("\n properties=").append(properties) + append(",\n image url=").append(imageUrl) + append(",\n LatLngBox=").append(latLngBox) + append("\n}\n") + }.toString() +} diff --git a/data/src/main/java/com/google/maps/android/data/kml/KmlLayer.kt b/data/src/main/java/com/google/maps/android/data/kml/KmlLayer.kt new file mode 100644 index 000000000..9cf5787b4 --- /dev/null +++ b/data/src/main/java/com/google/maps/android/data/kml/KmlLayer.kt @@ -0,0 +1,538 @@ +/* + * 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.data.kml + +import android.content.Context +import com.google.android.gms.maps.GoogleMap +import com.google.android.gms.maps.model.LatLng +import com.google.android.gms.maps.model.LatLngBounds +import com.google.maps.android.collections.GroundOverlayManager +import com.google.maps.android.collections.MarkerManager +import com.google.maps.android.collections.PolygonManager +import com.google.maps.android.collections.PolylineManager +import com.google.maps.android.data.Geometry +import com.google.maps.android.data.Layer +import com.google.maps.android.data.Renderer +import com.google.maps.android.data.parser.kml.KmlParser +import com.google.maps.android.data.parser.kml.KmzParser +import com.google.maps.android.data.renderer.UrlIconProvider +import com.google.maps.android.data.renderer.mapview.MapViewRenderer +import org.xmlpull.v1.XmlPullParserException +import java.io.BufferedInputStream +import java.io.IOException +import java.io.InputStream +import java.nio.charset.StandardCharsets + +@Deprecated("Use the new platform-agnostic data layer and renderer instead.") +public class KmlLayer : Layer { + private val mPlacemarks = mutableListOf() + private val mContainers = mutableListOf() + private val mGroundOverlays = mutableListOf() + private var mRenderer: MapViewRenderer? = null + private var mIsLayerOnMap = false + private val mPlacemarkMap = HashMap() + private val mModelToLegacyPlacemarks = HashMap() + private var mFeatureClickListener: OnFeatureClickListener? = null + + @JvmOverloads + @Throws(XmlPullParserException::class, IOException::class) + public constructor( + map: GoogleMap?, + resourceId: Int, + context: Context, + markerManager: MarkerManager? = null, + polygonManager: PolygonManager? = null, + polylineManager: PolylineManager? = null, + groundOverlayManager: GroundOverlayManager? = null, + cache: Renderer.ImagesCache? = null, + maxKmzEntryCount: Int = 200, + maxKmzUncompressedTotalSize: Long = 50 * 1024 * 1024, + ) : this( + map, + context.resources.openRawResource(resourceId), + context, + markerManager, + polygonManager, + polylineManager, + groundOverlayManager, + cache, + maxKmzEntryCount, + maxKmzUncompressedTotalSize, + ) + + @JvmOverloads + @Throws(XmlPullParserException::class, IOException::class) + public constructor( + map: GoogleMap?, + stream: InputStream, + context: Context, + markerManager: MarkerManager? = null, + polygonManager: PolygonManager? = null, + polylineManager: PolylineManager? = null, + groundOverlayManager: GroundOverlayManager? = null, + cache: Renderer.ImagesCache? = null, + maxKmzEntryCount: Int = 200, + maxKmzUncompressedTotalSize: Long = 50 * 1024 * 1024, + ) { + mGoogleMap = map + mRenderer = map?.let { MapViewRenderer(it, UrlIconProvider()) } + + val bis = BufferedInputStream(stream) + bis.mark(1024) + val headerBytes = ByteArray(1024) + val read = bis.read(headerBytes) + bis.reset() + + val isKmz = read > 0 && String(headerBytes, 0, read, StandardCharsets.UTF_8).startsWith("PK") + val kmlObj = + if (isKmz) { + KmzParser( + maxKmzEntryCount = maxKmzEntryCount, + maxKmzUncompressedTotalSize = maxKmzUncompressedTotalSize + ).parse(bis) + } else { + KmlParser().parse(bis) + } + + // Register KMZ cached images to renderer if any + kmlObj.images.forEach { (name, bitmap) -> + mRenderer?.cacheImageData(name, bitmap) + } + + // Parse Document + kmlObj.document?.let { doc -> + val styles = doc.styles.associate { it.id!! to toLegacyStyle(it) } + val styleMaps = doc.styleMaps.associate { it.id!! to (it.pairs.firstOrNull { p -> p.key == "normal" }?.styleUrl ?: "") } + + val placemarksMap = HashMap() + doc.placemarks.forEach { p -> + placemarksMap[toLegacyPlacemark(p, styles, styleMaps)] = null + } + + val childContainers = ArrayList() + doc.folders.forEach { f -> + childContainers.add(toLegacyContainer(f, styles, styleMaps)) + } + + val groundOverlaysMap = HashMap() + doc.groundOverlays.forEach { g -> + groundOverlaysMap[toLegacyGroundOverlay(g)] = null + } + + val properties = HashMap() + doc.name?.let { properties["name"] = it } + doc.description?.let { properties["description"] = it } + + val rootContainer = + KmlContainer( + properties, + HashMap(styles), + placemarksMap, + HashMap(styleMaps), + childContainers, + groundOverlaysMap, + doc.name ?: "Document", + ) + + mContainers.add(rootContainer) + } + + // Parse top-level placemark or folder if Document is null + if (kmlObj.document == null) { + kmlObj.placemark?.let { mPlacemarks.add(toLegacyPlacemark(it, emptyMap(), emptyMap())) } + kmlObj.folder?.let { mContainers.add(toLegacyContainer(it, emptyMap(), emptyMap())) } + kmlObj.groundOverlay?.let { mGroundOverlays.add(toLegacyGroundOverlay(it)) } + } + } + + private fun toLegacyStyle(style: com.google.maps.android.data.parser.kml.Style): KmlStyle { + val legacy = KmlStyle() + legacy.setStyleId(style.id) + style.iconStyle?.let { iconStyle -> + legacy.setIconScale(iconStyle.scale.toDouble()) + iconStyle.icon?.href?.let { legacy.setIconUrl(it) } + iconStyle.hotSpot?.let { hotSpot -> + legacy.setHotSpot(hotSpot.x.toFloat(), hotSpot.y.toFloat(), hotSpot.xunits, hotSpot.yunits) + } + } + style.lineStyle?.let { lineStyle -> + lineStyle.color?.let { legacy.mPolylineOptions.color(abgrToArgb(it)) } + lineStyle.width?.let { legacy.setWidth(it) } + } + style.polyStyle?.let { polyStyle -> + legacy.setFill(polyStyle.fill) + legacy.setOutline(polyStyle.outline) + polyStyle.color?.let { legacy.mPolygonOptions.fillColor(abgrToArgb(it)) } + } + return legacy + } + + private fun toLegacyPlacemark( + placemark: com.google.maps.android.data.parser.kml.Placemark, + styles: Map, + styleMaps: Map, + ): KmlPlacemark { + val geometry = toLegacyGeometry(placemark) + val properties = mutableMapOf() + placemark.name?.let { properties["name"] = it } + placemark.description?.let { properties["description"] = it } + placemark.extendedData?.data?.forEach { d -> + if (d.name != null && d.value != null) { + properties[d.name] = d.value + } + } + + val styleUrl = placemark.styleUrl?.substringAfter("#") ?: "" + val resolvedStyleUrl = styleMaps[styleUrl] ?: styleUrl + val inlineStyle = placemark.style?.let { toLegacyStyle(it) } ?: styles[resolvedStyleUrl] + + return KmlPlacemark(geometry, resolvedStyleUrl, inlineStyle, properties) + } + + private fun toLegacyContainer( + folder: com.google.maps.android.data.parser.kml.Folder, + styles: Map, + styleMaps: Map, + ): KmlContainer { + val properties = HashMap() + folder.name?.let { properties["name"] = it } + folder.description?.let { properties["description"] = it } + + val placemarksMap = HashMap() + folder.placemarks.forEach { p -> + placemarksMap[toLegacyPlacemark(p, styles, styleMaps)] = null + } + + val nestedContainers = ArrayList() + folder.folders.forEach { f -> + nestedContainers.add(toLegacyContainer(f, styles, styleMaps)) + } + + val groundOverlaysMap = HashMap() + folder.groundOverlays.forEach { g -> + groundOverlaysMap[toLegacyGroundOverlay(g)] = null + } + + return KmlContainer( + properties, + HashMap(styles), + placemarksMap, + HashMap(styleMaps), + nestedContainers, + groundOverlaysMap, + folder.name, + ) + } + + private fun toLegacyGroundOverlay(groundOverlay: com.google.maps.android.data.parser.kml.GroundOverlay): KmlGroundOverlay { + val properties = mutableMapOf() + groundOverlay.name?.let { properties["name"] = it } + + val bounds = + LatLngBounds( + LatLng(groundOverlay.latLonBox!!.south, groundOverlay.latLonBox.west), + LatLng(groundOverlay.latLonBox.north, groundOverlay.latLonBox.east), + ) + + return KmlGroundOverlay( + imageUrl = groundOverlay.icon?.href ?: "", + latLngBox = bounds, + drawOrder = groundOverlay.drawOrder?.toFloat() ?: 0f, + visibility = if (groundOverlay.visibility) 1 else 0, + properties = properties, + rotation = groundOverlay.latLonBox.rotation?.toFloat() ?: 0f, + ) + } + + private fun toLegacyGeometry(placemark: com.google.maps.android.data.parser.kml.Placemark): Geometry? = + when { + placemark.point != null -> { + KmlPoint( + LatLng(placemark.point.coordinates.latitude, placemark.point.coordinates.longitude), + placemark.point.coordinates.altitude, + ) + } + + placemark.lineString != null -> { + KmlLineString( + ArrayList(placemark.lineString.coordinates.map { LatLng(it.latitude, it.longitude) }), + ArrayList(placemark.lineString.coordinates.mapNotNull { it.altitude }), + ) + } + + placemark.polygon != null -> { + KmlPolygon( + placemark.polygon.outerBoundaryIs.linearRing.coordinates + .map { LatLng(it.latitude, it.longitude) }, + placemark.polygon.innerBoundaryIs.map { ring -> ring.linearRing.coordinates.map { LatLng(it.latitude, it.longitude) } }, + ) + } + + placemark.multiGeometry != null -> { + val list = ArrayList() + placemark.multiGeometry.points.forEach { + list.add(KmlPoint(LatLng(it.coordinates.latitude, it.coordinates.longitude), it.coordinates.altitude)) + } + placemark.multiGeometry.lineStrings.forEach { + list.add( + KmlLineString( + ArrayList( + it.coordinates.map { c -> + LatLng(c.latitude, c.longitude) + }, + ), + ), + ) + } + placemark.multiGeometry.polygons.forEach { + list.add( + KmlPolygon( + it.outerBoundaryIs.linearRing.coordinates.map { c -> + LatLng(c.latitude, c.longitude) + }, + it.innerBoundaryIs.map { ring -> + ring.linearRing.coordinates.map { c -> LatLng(c.latitude, c.longitude) } + }, + ), + ) + } + KmlMultiGeometry(list) + } + + else -> { + null + } + } + + private fun abgrToArgb(color: Int): Int { + val a = (color shr 24) and 0xFF + val b = (color shr 16) and 0xFF + val g = (color shr 8) and 0xFF + val r = color and 0xFF + return (a shl 24) or (r shl 16) or (g shl 8) or b + } + + override fun getMap(): GoogleMap? = mGoogleMap + + override fun setMap(map: GoogleMap?) { + mGoogleMap = map + if (map == null) { + removeLayerFromMap() + mRenderer = null + } else { + mRenderer = MapViewRenderer(map, UrlIconProvider()) + if (mIsLayerOnMap) { + addLayerToMap() + } + } + } + + override fun addLayerToMap() { + val renderer = mRenderer ?: return + mPlacemarks.forEach { p -> renderer.addFeature(toModelFeature(p)) } + mContainers.forEach { c -> addContainerToMap(c, renderer) } + mGroundOverlays.forEach { g -> renderer.addFeature(toModelFeature(g)) } + mIsLayerOnMap = true + } + + override fun removeLayerFromMap() { + val renderer = mRenderer ?: return + mPlacemarks.forEach { p -> renderer.removeFeature(toModelFeature(p)) } + mContainers.forEach { c -> removeContainerFromMap(c, renderer) } + mGroundOverlays.forEach { g -> renderer.removeFeature(toModelFeature(g)) } + mIsLayerOnMap = false + } + + private fun addContainerToMap( + container: KmlContainer, + renderer: MapViewRenderer, + ) { + container.getPlacemarks().forEach { p -> renderer.addFeature(toModelFeature(p)) } + container.getGroundOverlays().forEach { g -> renderer.addFeature(toModelFeature(g)) } + container.getContainers().forEach { c -> addContainerToMap(c, renderer) } + } + + private fun removeContainerFromMap( + container: KmlContainer, + renderer: MapViewRenderer, + ) { + container.getPlacemarks().forEach { p -> renderer.removeFeature(toModelFeature(p)) } + container.getGroundOverlays().forEach { g -> renderer.removeFeature(toModelFeature(g)) } + container.getContainers().forEach { c -> removeContainerFromMap(c, renderer) } + } + + private fun toModelFeature(placemark: KmlPlacemark): com.google.maps.android.data.renderer.model.Feature { + val existing = mPlacemarkMap[placemark] + if (existing != null) return existing + + val geometry = placemark.getGeometry()!! + val modelGeometry = toModelGeometry(geometry) + val properties = placemark.getPropertyKeys().associateWith { placemark.getProperty(it) as Any } + + val inline = placemark.getInlineStyle() + val style = + when (modelGeometry) { + is com.google.maps.android.data.renderer.model.PointGeometry -> { + com.google.maps.android.data.renderer.model.PointStyle( + color = inline?.mMarkerColor?.toInt() ?: 0, + iconUrl = inline?.getIconUrl(), + ) + } + + is com.google.maps.android.data.renderer.model.LineString -> { + com.google.maps.android.data.renderer.model.LineStyle( + color = inline?.mPolylineOptions?.color ?: 0xFF000000.toInt(), + width = inline?.mPolylineOptions?.width ?: 1.0f, + ) + } + + is com.google.maps.android.data.renderer.model.Polygon -> { + com.google.maps.android.data.renderer.model.PolygonStyle( + fillColor = inline?.mPolygonOptions?.fillColor ?: 0x00000000, + strokeColor = inline?.mPolygonOptions?.strokeColor ?: 0xFF000000.toInt(), + strokeWidth = inline?.mPolygonOptions?.strokeWidth ?: 1.0f, + ) + } + + else -> { + null + } + } + + val modelFeature = + com.google.maps.android.data.renderer.model + .Feature(modelGeometry, style, properties) + mPlacemarkMap[placemark] = modelFeature + mModelToLegacyPlacemarks[modelFeature] = placemark + return modelFeature + } + + private fun toModelFeature(groundOverlay: KmlGroundOverlay): com.google.maps.android.data.renderer.model.Feature { + val bounds = groundOverlay.getLatLngBox() + val modelGeometry = + com.google.maps.android.data.renderer.model.GroundOverlay( + north = bounds.northeast.latitude, + south = bounds.southwest.latitude, + east = bounds.northeast.longitude, + west = bounds.southwest.longitude, + rotation = groundOverlay.getGroundOverlayOptions().bearing, + ) + val style = + com.google.maps.android.data.renderer.model.GroundOverlayStyle( + iconUrl = groundOverlay.getImageUrl(), + zIndex = groundOverlay.getGroundOverlayOptions().zIndex, + visibility = groundOverlay.getGroundOverlayOptions().isVisible, + ) + val properties = groundOverlay.getProperties().associateWith { groundOverlay.getProperty(it) as Any } + return com.google.maps.android.data.renderer.model + .Feature(modelGeometry, style, properties) + } + + private fun toModelGeometry(geometry: Geometry): com.google.maps.android.data.renderer.model.Geometry = + when (geometry) { + is KmlPoint -> { + com.google.maps.android.data.renderer.model.PointGeometry( + com.google.maps.android.data.renderer.model.Point( + geometry.getGeometryObject().latitude, + geometry.getGeometryObject().longitude, + geometry.getAltitude(), + ), + ) + } + + is KmlLineString -> { + com.google.maps.android.data.renderer.model.LineString( + geometry.getGeometryObject().map { + com.google.maps.android.data.renderer.model + .Point(it.latitude, it.longitude) + }, + ) + } + + is KmlPolygon -> { + com.google.maps.android.data.renderer.model.Polygon( + geometry.getOuterBoundaryCoordinates().map { + com.google.maps.android.data.renderer.model.Point( + it.latitude, + it.longitude, + ) + }, + geometry.getInnerBoundaryCoordinates().map { inner -> + inner.map { + com.google.maps.android.data.renderer.model + .Point(it.latitude, it.longitude) + } + }, + ) + } + + is KmlMultiGeometry -> { + com.google.maps.android.data.renderer.model.MultiGeometry( + geometry.getGeometryObject().map { toModelGeometry(it) }, + ) + } + + else -> { + throw IllegalArgumentException("Unknown geometry type") + } + } + + public fun hasPlacemarks(): Boolean = mPlacemarks.isNotEmpty() + + public fun getPlacemarks(): Iterable = mPlacemarks + + public fun hasContainers(): Boolean = mContainers.isNotEmpty() + + public fun getContainers(): Iterable = mContainers + + public fun getGroundOverlays(): Iterable = mGroundOverlays + + override val features: Iterable + get() = mPlacemarks + + public fun isLayerOnMap(): Boolean = mIsLayerOnMap + + override fun setOnFeatureClickListener(listener: OnFeatureClickListener) { + mFeatureClickListener = listener + mGoogleMap?.let { map -> + map.setOnMarkerClickListener { marker -> + val feature = findLegacyFeatureForMapObject(marker) + if (feature != null) { + mFeatureClickListener?.onFeatureClick(feature) + } + false + } + map.setOnPolygonClickListener { polygon -> + val feature = findLegacyFeatureForMapObject(polygon) + if (feature != null) { + mFeatureClickListener?.onFeatureClick(feature) + } + } + map.setOnPolylineClickListener { polyline -> + val feature = findLegacyFeatureForMapObject(polyline) + if (feature != null) { + mFeatureClickListener?.onFeatureClick(feature) + } + } + } + } + + private fun findLegacyFeatureForMapObject(mapObject: Any): KmlPlacemark? { + val renderer = mRenderer ?: return null + val modelFeature = renderer.getFeatureForMapObject(mapObject) ?: return null + return mModelToLegacyPlacemarks[modelFeature] + } +} diff --git a/data/src/main/java/com/google/maps/android/data/kml/KmlLineString.kt b/data/src/main/java/com/google/maps/android/data/kml/KmlLineString.kt new file mode 100644 index 000000000..92dbfb36e --- /dev/null +++ b/data/src/main/java/com/google/maps/android/data/kml/KmlLineString.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.data.kml + +import com.google.android.gms.maps.model.LatLng +import com.google.maps.android.data.LineString + +/** + * Represents a KML LineString. Contains a single array of coordinates. + */ +@Deprecated("Use the new platform-agnostic data layer and renderer instead.") +public class KmlLineString + @JvmOverloads + constructor( + coordinates: ArrayList, + private val altitudes: ArrayList? = null, + ) : LineString(coordinates) { + public fun getAltitudes(): ArrayList? = altitudes + + override fun getGeometryObject(): ArrayList = ArrayList(super.getGeometryObject()) + } diff --git a/data/src/main/java/com/google/maps/android/data/kml/KmlMultiGeometry.kt b/data/src/main/java/com/google/maps/android/data/kml/KmlMultiGeometry.kt new file mode 100644 index 000000000..68a533962 --- /dev/null +++ b/data/src/main/java/com/google/maps/android/data/kml/KmlMultiGeometry.kt @@ -0,0 +1,37 @@ +/* + * 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.data.kml + +import com.google.maps.android.data.Geometry +import com.google.maps.android.data.MultiGeometry + +/** + * Represents a KML MultiGeometry. Contains an array of Geometry objects. + */ +@Deprecated("Use the new platform-agnostic data layer and renderer instead.") +public class KmlMultiGeometry( + geometries: ArrayList, +) : MultiGeometry(geometries) { + override fun getGeometryObject(): ArrayList = ArrayList(super.getGeometryObject()) + + override fun toString(): String = + StringBuilder(getGeometryType()) + .apply { + append("{") + append("\n geometries=").append(getGeometryObject()) + append("\n}\n") + }.toString() +} diff --git a/data/src/main/java/com/google/maps/android/data/kml/KmlPlacemark.kt b/data/src/main/java/com/google/maps/android/data/kml/KmlPlacemark.kt new file mode 100644 index 000000000..c48c5ea40 --- /dev/null +++ b/data/src/main/java/com/google/maps/android/data/kml/KmlPlacemark.kt @@ -0,0 +1,53 @@ +/* + * 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.data.kml + +import com.google.android.gms.maps.model.MarkerOptions +import com.google.android.gms.maps.model.PolygonOptions +import com.google.android.gms.maps.model.PolylineOptions +import com.google.maps.android.data.Feature +import com.google.maps.android.data.Geometry + +/** + * Represents a placemark which is either a KmlPoint, KmlLineString, KmlPolygon or a + * KmlMultiGeometry. Stores the properties and styles of the place. + */ +@Deprecated("Use the new platform-agnostic data layer and renderer instead.") +public class KmlPlacemark( + geometry: Geometry?, + private val style: String?, + private val inlineStyle: KmlStyle?, + properties: Map?, +) : Feature(geometry, style, properties) { + public fun getStyleId(): String? = getId() + + public fun getInlineStyle(): KmlStyle? = inlineStyle + + public fun getPolygonOptions(): PolygonOptions? = inlineStyle?.getPolygonOptions() + + public fun getMarkerOptions(): MarkerOptions? = inlineStyle?.getMarkerOptions() + + public fun getPolylineOptions(): PolylineOptions? = inlineStyle?.getPolylineOptions() + + override fun toString(): String = + StringBuilder("Placemark") + .apply { + append("{") + append("\n style id=").append(style) + append(",\n inline style=").append(inlineStyle) + append("\n}\n") + }.toString() +} diff --git a/data/src/main/java/com/google/maps/android/data/kml/KmlPoint.kt b/data/src/main/java/com/google/maps/android/data/kml/KmlPoint.kt new file mode 100644 index 000000000..067c6f79e --- /dev/null +++ b/data/src/main/java/com/google/maps/android/data/kml/KmlPoint.kt @@ -0,0 +1,32 @@ +/* + * 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.data.kml + +import com.google.android.gms.maps.model.LatLng +import com.google.maps.android.data.Point + +/** + * Represents a KML Point. Contains a single coordinate. + */ +@Deprecated("Use the new platform-agnostic data layer and renderer instead.") +public class KmlPoint + @JvmOverloads + constructor( + coordinates: LatLng, + private val altitude: Double? = null, + ) : Point(coordinates) { + public fun getAltitude(): Double? = altitude + } diff --git a/data/src/main/java/com/google/maps/android/data/kml/KmlPolygon.kt b/data/src/main/java/com/google/maps/android/data/kml/KmlPolygon.kt new file mode 100644 index 000000000..d90df7d63 --- /dev/null +++ b/data/src/main/java/com/google/maps/android/data/kml/KmlPolygon.kt @@ -0,0 +1,61 @@ +/* + * 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.data.kml + +import com.google.android.gms.maps.model.LatLng +import com.google.maps.android.data.DataPolygon + +/** + * Represents a KML Polygon. Contains a single array of outer boundary coordinates and an array of + * arrays for the inner boundary coordinates. + */ +@Deprecated("Use the new platform-agnostic data layer and renderer instead.") +public class KmlPolygon( + private val outerBoundaryCoordinates: List, + private val innerBoundaryCoordinates: List>?, +) : DataPolygon>> { + init { + requireNotNull(outerBoundaryCoordinates) { "Outer boundary coordinates cannot be null" } + } + + override fun getGeometryType(): String = GEOMETRY_TYPE + + override fun getGeometryObject(): List> { + val coordinates = ArrayList>() + coordinates.add(outerBoundaryCoordinates) + if (innerBoundaryCoordinates != null) { + coordinates.addAll(innerBoundaryCoordinates) + } + return coordinates + } + + override fun getOuterBoundaryCoordinates(): List = outerBoundaryCoordinates + + override fun getInnerBoundaryCoordinates(): List> = innerBoundaryCoordinates ?: emptyList() + + override fun toString(): String = + StringBuilder(GEOMETRY_TYPE) + .apply { + append("{") + append("\n outer coordinates=").append(outerBoundaryCoordinates) + append(",\n inner coordinates=").append(innerBoundaryCoordinates) + append("\n}\n") + }.toString() + + companion object { + public const val GEOMETRY_TYPE = "Polygon" + } +} diff --git a/data/src/main/java/com/google/maps/android/data/kml/KmlStyle.kt b/data/src/main/java/com/google/maps/android/data/kml/KmlStyle.kt new file mode 100644 index 000000000..6f676d21a --- /dev/null +++ b/data/src/main/java/com/google/maps/android/data/kml/KmlStyle.kt @@ -0,0 +1,231 @@ +/* + * 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.data.kml + +import android.graphics.Color +import com.google.android.gms.maps.model.BitmapDescriptorFactory +import com.google.android.gms.maps.model.MarkerOptions +import com.google.android.gms.maps.model.PolygonOptions +import com.google.android.gms.maps.model.PolylineOptions +import com.google.maps.android.data.Style +import java.util.Random + +/** + * Represents the defined styles in the KML document. + */ +@Deprecated("Use the new platform-agnostic data layer and renderer instead.") +public class KmlStyle : Style() { + private val mBalloonOptions = HashMap() + private val mStylesSet = HashSet() + private var mFill = true + private var mOutline = true + private var mIconUrl: String? = null + private var mScale = 1.0 + private var mStyleId: String? = null + private var mIconRandomColorMode = false + private var mLineRandomColorMode = false + private var mPolyRandomColorMode = false + internal var mMarkerColor = 0f + + public fun setInfoWindowText(text: String) { + mBalloonOptions["text"] = text + } + + public fun getStyleId(): String? = mStyleId + + public fun setStyleId(styleId: String?) { + mStyleId = styleId + } + + public fun isStyleSet(style: String): Boolean = mStylesSet.contains(style) + + public fun hasFill(): Boolean = mFill + + public fun setFill(fill: Boolean) { + mFill = fill + } + + public fun getIconScale(): Double = mScale + + public fun setIconScale(scale: Double) { + mScale = scale + mStylesSet.add("iconScale") + } + + public fun hasOutline(): Boolean = mOutline + + public fun hasBalloonStyle(): Boolean = mBalloonOptions.isNotEmpty() + + public fun setOutline(outline: Boolean) { + mOutline = outline + mStylesSet.add("outline") + } + + public fun getIconUrl(): String? = mIconUrl + + public fun setIconUrl(iconUrl: String?) { + mIconUrl = iconUrl + mStylesSet.add("iconUrl") + } + + public fun setFillColor(color: String) { + val polygonColorNum = Color.parseColor("#" + convertColor(color)) + setPolygonFillColor(polygonColorNum) + mStylesSet.add("fillColor") + } + + public fun setMarkerColor(color: String) { + val integerColor = Color.parseColor("#" + convertColor(color)) + mMarkerColor = getHueValue(integerColor) + mMarkerOptions.icon(BitmapDescriptorFactory.defaultMarker(mMarkerColor)) + mStylesSet.add("markerColor") + } + + public fun setHeading(heading: Float) { + setMarkerRotation(heading) + mStylesSet.add("heading") + } + + public fun setHotSpot( + x: Float, + y: Float, + xUnits: String, + yUnits: String, + ) { + setMarkerHotSpot(x, y, xUnits, yUnits) + mStylesSet.add("hotSpot") + } + + public fun setIconColorMode(colorMode: String) { + mIconRandomColorMode = colorMode == "random" + mStylesSet.add("iconColorMode") + } + + public fun isIconRandomColorMode(): Boolean = mIconRandomColorMode + + public fun setLineColorMode(colorMode: String) { + mLineRandomColorMode = colorMode == "random" + mStylesSet.add("lineColorMode") + } + + public fun isLineRandomColorMode(): Boolean = mLineRandomColorMode + + public fun setPolyColorMode(colorMode: String) { + mPolyRandomColorMode = colorMode == "random" + mStylesSet.add("polyColorMode") + } + + public fun isPolyRandomColorMode(): Boolean = mPolyRandomColorMode + + public fun setOutlineColor(color: String) { + mPolylineOptions.color(Color.parseColor("#" + convertColor(color))) + mPolygonOptions.strokeColor(Color.parseColor("#" + convertColor(color))) + mStylesSet.add("outlineColor") + } + + public fun setWidth(width: Float) { + setLineStringWidth(width) + setPolygonStrokeWidth(width) + mStylesSet.add("width") + } + + public fun getBalloonOptions(): HashMap = mBalloonOptions + + public fun getMarkerOptions(): MarkerOptions { + val newMarkerOption = MarkerOptions() + newMarkerOption.rotation(mMarkerOptions.rotation) + newMarkerOption.anchor(mMarkerOptions.anchorU, mMarkerOptions.anchorV) + if (mIconRandomColorMode) { + val hue = getHueValue(computeRandomColor(mMarkerColor.toInt())) + mMarkerOptions.icon(BitmapDescriptorFactory.defaultMarker(hue)) + } + newMarkerOption.icon(mMarkerOptions.icon) + return newMarkerOption + } + + public fun getPolylineOptions(): PolylineOptions = + PolylineOptions().apply { + color(mPolylineOptions.color) + width(mPolylineOptions.width) + clickable(mPolylineOptions.isClickable) + } + + public fun getPolygonOptions(): PolygonOptions = + PolygonOptions().apply { + if (mFill) { + fillColor(mPolygonOptions.fillColor) + } + var strokeW = 0.0f + if (mOutline) { + strokeColor(mPolygonOptions.strokeColor) + strokeW = mPolygonOptions.strokeWidth + } + strokeWidth(strokeW) + clickable(mPolygonOptions.isClickable) + } + + override fun toString(): String = + StringBuilder("Style") + .apply { + append("{") + append("\n balloon options=").append(mBalloonOptions) + append(",\n fill=").append(mFill) + append(",\n outline=").append(mOutline) + append(",\n icon url=").append(mIconUrl) + append(",\n scale=").append(mScale) + append(",\n style id=").append(mStyleId) + append("\n}\n") + }.toString() + + companion object { + private const val HSV_VALUES = 3 + private const val HUE_VALUE = 0 + + private fun getHueValue(integerColor: Int): Float { + val hsvValues = FloatArray(HSV_VALUES) + Color.colorToHSV(integerColor, hsvValues) + return hsvValues[HUE_VALUE] + } + + private fun convertColor(color: String): String { + val trimmed = color.trim() + return if (trimmed.length > 6) { + trimmed.substring(0, 2) + trimmed.substring(6, 8) + + trimmed.substring(4, 6) + trimmed.substring(2, 4) + } else { + trimmed.substring(4, 6) + trimmed.substring(2, 4) + + trimmed.substring(0, 2) + } + } + + public fun computeRandomColor(color: Int): Int { + val random = Random() + var red = Color.red(color) + var green = Color.green(color) + var blue = Color.blue(color) + if (red != 0) { + red = random.nextInt(red) + } + if (blue != 0) { + blue = random.nextInt(blue) + } + if (green != 0) { + green = random.nextInt(green) + } + return Color.rgb(red, green, blue) + } + } +} diff --git a/library/src/main/java/com/google/maps/android/data/kml/KmlUrlSanitizer.java b/data/src/main/java/com/google/maps/android/data/kml/KmlUrlSanitizer.java similarity index 100% rename from library/src/main/java/com/google/maps/android/data/kml/KmlUrlSanitizer.java rename to data/src/main/java/com/google/maps/android/data/kml/KmlUrlSanitizer.java diff --git a/data/src/main/java/com/google/maps/android/data/parser/GeoFileParser.kt b/data/src/main/java/com/google/maps/android/data/parser/GeoFileParser.kt new file mode 100644 index 000000000..f70a3353d --- /dev/null +++ b/data/src/main/java/com/google/maps/android/data/parser/GeoFileParser.kt @@ -0,0 +1,24 @@ +/* + * 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.data.parser + +import java.io.InputStream + +/** + * Interface for a file parser that transforms an input stream + * from a specific format into the unified [GeoData] model. + */ +interface GeoFileParser diff --git a/data/src/main/java/com/google/maps/android/data/parser/Model.kt b/data/src/main/java/com/google/maps/android/data/parser/Model.kt new file mode 100644 index 000000000..640f01061 --- /dev/null +++ b/data/src/main/java/com/google/maps/android/data/parser/Model.kt @@ -0,0 +1,70 @@ +/* + * 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.data.parser + +import com.google.maps.android.data.parser.kml.Style + +/** + * A generic container for geographic data parsed from any file. + * It holds a collection of features, each representing a distinct entity on the map. + */ +data class GeoData( + val features: List, +) + +/** + * Represents a single, distinct geographic entity, such as a placemark, a route, or a defined area. + * It combines geometry (the 'what' and 'where') with properties (the 'metadata') and styling. + */ +data class Feature( + val geometry: Geometry, + val properties: Map = emptyMap(), // For metadata like name, description, etc. + val style: Style? = null, +) + +/** + * A sealed interface representing the geometric shape of a feature. + */ +sealed interface Geometry { + data class Point( + val lat: Double, + val lon: Double, + val alt: Double?, + ) : Geometry + + data class LineString( + val points: List, + ) : Geometry + + data class Polygon( + val shell: List, + val holes: List> = emptyList(), + ) : Geometry + + data class GeometryCollection( + val geometries: List, + ) : Geometry +} + +/** + * Represents styling information that can be applied to a feature. + * Properties are nullable as not all formats or features will specify them. + */ +data class Style( + val strokeColor: String?, // e.g., "#RRGGBB" or "#AARRGGBB" + val strokeWidth: Float?, + val fillColor: String?, +) diff --git a/data/src/main/java/com/google/maps/android/data/parser/geojson/GeoJsonObjects.kt b/data/src/main/java/com/google/maps/android/data/parser/geojson/GeoJsonObjects.kt new file mode 100644 index 000000000..cea7bcaef --- /dev/null +++ b/data/src/main/java/com/google/maps/android/data/parser/geojson/GeoJsonObjects.kt @@ -0,0 +1,93 @@ +/* + * 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.data.parser.geojson + +/** + * A data class representing a single geographical coordinate. + * + * This class holds the latitude, longitude, and an optional altitude value. + * The order of properties is (latitude, longitude) to align with common mapping SDKs, + * even though GeoJSON specifies (longitude, latitude). + * + * @property lat The latitude of the coordinate. + * @property lng The longitude of the coordinate. + * @property alt The altitude of the coordinate, in meters. Optional. + */ +data class Coordinates(val lat: Double, val lng: Double, val alt: Double? = null) + +// Using a sealed interface for all GeoJSON objects +sealed interface GeoJsonObject { + val type: String +} + +// Sealed interface for Geometry objects +sealed interface GeoJsonGeometry : GeoJsonObject + +data class GeoJsonPoint( + val coordinates: Coordinates +) : GeoJsonGeometry { + override val type: String = "Point" +} + +data class GeoJsonMultiPoint( + val coordinates: List +) : GeoJsonGeometry { + override val type: String = "MultiPoint" +} + +data class GeoJsonLineString( + val coordinates: List +) : GeoJsonGeometry { + override val type: String = "LineString" +} + +data class GeoJsonMultiLineString( + val coordinates: List> +) : GeoJsonGeometry { + override val type: String = "MultiLineString" +} + +data class GeoJsonPolygon( + val coordinates: List> +) : GeoJsonGeometry { + override val type: String = "Polygon" +} + +data class GeoJsonMultiPolygon( + val coordinates: List>> +) : GeoJsonGeometry { + override val type: String = "MultiPolygon" +} + +data class GeoJsonGeometryCollection( + val geometries: List +) : GeoJsonGeometry { + override val type: String = "GeometryCollection" +} + +data class GeoJsonFeature( + val geometry: GeoJsonGeometry?, + val properties: Map?, + val id: String? = null // id is optional and can be string or number +) : GeoJsonObject { + override val type: String = "Feature" +} + +data class GeoJsonFeatureCollection( + val features: List +) : GeoJsonObject { + override val type: String = "FeatureCollection" +} \ No newline at end of file diff --git a/data/src/main/java/com/google/maps/android/data/parser/geojson/GeoJsonParser.kt b/data/src/main/java/com/google/maps/android/data/parser/geojson/GeoJsonParser.kt new file mode 100644 index 000000000..4040a40f6 --- /dev/null +++ b/data/src/main/java/com/google/maps/android/data/parser/geojson/GeoJsonParser.kt @@ -0,0 +1,149 @@ +/* + * 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.data.parser.geojson + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import java.io.InputStream + +class GeoJsonParser { + fun parse(inputStream: InputStream): GeoJsonObject? { + val json = inputStream.bufferedReader().use { it.readText() } + val jsonElement = Json.parseToJsonElement(json) + + return when (jsonElement.jsonObject["type"]?.jsonPrimitive?.content) { + "FeatureCollection" -> { + parseFeatureCollection(jsonElement) + } + + "Feature" -> { + parseFeature(jsonElement) + } + + "Point", "LineString", "Polygon", "MultiPoint", "MultiLineString", "MultiPolygon", "GeometryCollection" -> { + parseGeometry( + jsonElement, + ) + } + + else -> { + null + } + } + } + + companion object { + val SUPPORTED_EXTENSIONS = setOf("json", "geojson") + + fun canParse(header: String): Boolean = header.trimStart().startsWith("{") + } +} + +private fun parseFeatureCollection(json: JsonElement): GeoJsonFeatureCollection { + val featuresJson = json.jsonObject["features"]?.jsonArray + val features = featuresJson?.map { parseFeature(it) } ?: emptyList() + return GeoJsonFeatureCollection(features) +} + +private fun parseFeature(json: JsonElement): GeoJsonFeature { + val geometryJson = json.jsonObject["geometry"] + val propertiesJson = json.jsonObject["properties"]?.jsonObject + val id = json.jsonObject["id"]?.jsonPrimitive?.content + + val geometry = + if (geometryJson != null && geometryJson !is JsonNull) { + parseGeometry(geometryJson) + } else { + null + } + + val properties = + propertiesJson?.let { + it.entries.associate { (key, value) -> + val propertyValue = + when (value) { + is JsonPrimitive -> value.contentOrNull + is JsonNull -> null + else -> value.toString() + } + key to propertyValue + } + } + + return GeoJsonFeature(geometry, properties, id) +} + +private fun parseGeometry(json: JsonElement): GeoJsonGeometry? { + val type = json.jsonObject["type"]?.jsonPrimitive?.content ?: return null + val coordinates = json.jsonObject["coordinates"]?.jsonArray + val geometries = json.jsonObject["geometries"]?.jsonArray + + return when (type) { + "Point" -> coordinates?.let { GeoJsonPoint(parsePoint(it)) } + "LineString" -> coordinates?.let { GeoJsonLineString(parseLineString(it)) } + "Polygon" -> coordinates?.let { GeoJsonPolygon(parsePolygon(it)) } + "MultiPoint" -> coordinates?.let { GeoJsonMultiPoint(parseMultiPoint(it)) } + "MultiLineString" -> coordinates?.let { GeoJsonMultiLineString(parseMultiLineString(it)) } + "MultiPolygon" -> coordinates?.let { GeoJsonMultiPolygon(parseMultiPolygon(it)) } + "GeometryCollection" -> geometries?.let { GeoJsonGeometryCollection(parseGeometryCollection(it)) } + else -> null + } +} + +private fun parseCoordinates(coordinates: List): Coordinates { + val lng = coordinates[0].jsonPrimitive.content.toDouble() + val lat = coordinates[1].jsonPrimitive.content.toDouble() + val alt = if (coordinates.size > 2) coordinates[2].jsonPrimitive.content.toDouble() else null + return Coordinates(lat, lng, alt) +} + +private fun parsePoint(coordinates: List): Coordinates = parseCoordinates(coordinates) + +private fun parseLineString(coordinates: List): List = + coordinates.map { + parseCoordinates(it.jsonArray.toList()) + } + +private fun parsePolygon(coordinates: List): List> = + coordinates.map { + parseLineString(it.jsonArray.toList()) + } + +private fun parseMultiPoint(coordinates: List): List = + coordinates.map { + parseCoordinates(it.jsonArray.toList()) + } + +private fun parseMultiLineString(coordinates: List): List> = + coordinates.map { + parseLineString(it.jsonArray.toList()) + } + +private fun parseMultiPolygon(coordinates: List): List>> = + coordinates.map { + parsePolygon(it.jsonArray.toList()) + } + +private fun parseGeometryCollection(geometries: List): List = + geometries.mapNotNull { + parseGeometry(it) + } diff --git a/data/src/main/java/com/google/maps/android/data/parser/gpx/GpxModel.kt b/data/src/main/java/com/google/maps/android/data/parser/gpx/GpxModel.kt new file mode 100644 index 000000000..a59fc6840 --- /dev/null +++ b/data/src/main/java/com/google/maps/android/data/parser/gpx/GpxModel.kt @@ -0,0 +1,117 @@ +/* + * 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.data.parser.gpx + +import kotlinx.serialization.Serializable +import nl.adaptivity.xmlutil.serialization.XmlElement +import nl.adaptivity.xmlutil.serialization.XmlSerialName + +private const val GPX_NAMESPACE = "http://www.topografix.com/GPX/1/1" + +@Serializable +@XmlSerialName("gpx", namespace = GPX_NAMESPACE, prefix = "") +data class Gpx( + @XmlSerialName("version") + val version: String = "1.1", + @XmlSerialName("creator") + val creator: String? = null, + @XmlElement(true) + @XmlSerialName("metadata", namespace = GPX_NAMESPACE, prefix = "") + val metadata: Metadata? = null, + @XmlElement(true) + @XmlSerialName("wpt", namespace = GPX_NAMESPACE, prefix = "") + val waypoints: List = emptyList(), + @XmlElement(true) + @XmlSerialName("rte", namespace = GPX_NAMESPACE, prefix = "") + val routes: List = emptyList(), + @XmlElement(true) + @XmlSerialName("trk", namespace = GPX_NAMESPACE, prefix = "") + val tracks: List = emptyList(), +) + +@Serializable +@XmlSerialName("metadata", namespace = GPX_NAMESPACE, prefix = "") +data class Metadata( + @XmlElement(true) + @XmlSerialName("name", namespace = GPX_NAMESPACE, prefix = "") + val name: String? = null, + @XmlElement(true) + @XmlSerialName("desc", namespace = GPX_NAMESPACE, prefix = "") + val desc: String? = null, + @XmlElement(true) + @XmlSerialName("time", namespace = GPX_NAMESPACE, prefix = "") + val time: String? = null, +) + +@Serializable +@XmlSerialName("wpt", namespace = GPX_NAMESPACE, prefix = "") +data class Wpt( + @XmlSerialName("lat") + val lat: Double, + @XmlSerialName("lon") + val lon: Double, + @XmlElement(true) + @XmlSerialName("ele", namespace = GPX_NAMESPACE, prefix = "") + val ele: Double? = null, + @XmlElement(true) + @XmlSerialName("time", namespace = GPX_NAMESPACE, prefix = "") + val time: String? = null, + @XmlElement(true) + @XmlSerialName("name", namespace = GPX_NAMESPACE, prefix = "") + val name: String? = null, + @XmlElement(true) + @XmlSerialName("desc", namespace = GPX_NAMESPACE, prefix = "") + val desc: String? = null, + @XmlElement(true) + @XmlSerialName("sym", namespace = GPX_NAMESPACE, prefix = "") + val sym: String? = null, +) + +@Serializable +@XmlSerialName("rte", namespace = GPX_NAMESPACE, prefix = "") +data class Rte( + @XmlElement(true) + @XmlSerialName("name", namespace = GPX_NAMESPACE, prefix = "") + val name: String? = null, + @XmlElement(true) + @XmlSerialName("desc", namespace = GPX_NAMESPACE, prefix = "") + val desc: String? = null, + @XmlElement(true) + @XmlSerialName("rtept", namespace = GPX_NAMESPACE, prefix = "") + val routePoints: List = emptyList(), +) + +@Serializable +@XmlSerialName("trk", namespace = GPX_NAMESPACE, prefix = "") +data class Trk( + @XmlElement(true) + @XmlSerialName("name", namespace = GPX_NAMESPACE, prefix = "") + val name: String? = null, + @XmlElement(true) + @XmlSerialName("desc", namespace = GPX_NAMESPACE, prefix = "") + val desc: String? = null, + @XmlElement(true) + @XmlSerialName("trkseg", namespace = GPX_NAMESPACE, prefix = "") + val trackSegments: List = emptyList(), +) + +@Serializable +@XmlSerialName("trkseg", namespace = GPX_NAMESPACE, prefix = "") +data class TrkSeg( + @XmlElement(true) + @XmlSerialName("trkpt", namespace = GPX_NAMESPACE, prefix = "") + val trackPoints: List = emptyList(), +) diff --git a/data/src/main/java/com/google/maps/android/data/parser/gpx/GpxParser.kt b/data/src/main/java/com/google/maps/android/data/parser/gpx/GpxParser.kt new file mode 100644 index 000000000..62b812b62 --- /dev/null +++ b/data/src/main/java/com/google/maps/android/data/parser/gpx/GpxParser.kt @@ -0,0 +1,51 @@ +/* + * 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.data.parser.gpx + +import kotlinx.serialization.decodeFromString +import nl.adaptivity.xmlutil.serialization.XML +import java.io.InputStream +import java.nio.charset.StandardCharsets + +/** + * A parser for GPX (GPS Exchange Format) files. + * + * Uses the `pdvrieze/xmlutil` library for XML parsing. + * Supports parsing GPX 1.0 and 1.1 formats (normalizing 1.0 to 1.1). + */ +class GpxParser { + private val xml = + XML { + defaultPolicy { + ignoreUnknownChildren() + isCollectingNSAttributes = true + } + } + + fun parse(inputStream: InputStream): Gpx { + val xmlContent = inputStream.bufferedReader(StandardCharsets.UTF_8).use { it.readText() } + // Normalize GPX 1.0 namespace to 1.1 to allow parsing with the same model + // (The structure for waypoints and tracks is compatible enough for our needs) + val normalizedXml = xmlContent.replace("http://www.topografix.com/GPX/1/0", "http://www.topografix.com/GPX/1/1") + return xml.decodeFromString(normalizedXml) + } + + companion object { + val SUPPORTED_EXTENSIONS = setOf("gpx") + + fun canParse(header: String): Boolean = header.contains(" { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("Boolean", PrimitiveKind.STRING) + + override fun serialize( + encoder: Encoder, + value: Boolean, + ) { + encoder.encodeString(if (value) "1" else "0") + } + + override fun deserialize(decoder: Decoder): Boolean = + decoder.decodeString().let { + it.equals("true", ignoreCase = true) || it == "1" + } +} diff --git a/data/src/main/java/com/google/maps/android/data/parser/kml/ColorSerializer.kt b/data/src/main/java/com/google/maps/android/data/parser/kml/ColorSerializer.kt new file mode 100644 index 000000000..659a64ab0 --- /dev/null +++ b/data/src/main/java/com/google/maps/android/data/parser/kml/ColorSerializer.kt @@ -0,0 +1,45 @@ +/* + * 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.data.parser.kml + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder + +internal object ColorSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("Color", PrimitiveKind.STRING) + + override fun serialize( + encoder: Encoder, + value: Int, + ) { + val argb = + String.format("#%08x", value) // returns aabbggrr, e.g. #ff0000ff for blue + val a = argb.substring(1..2) + val r = argb.substring(3..4) + val g = argb.substring(5..6) + val b = argb.substring(7..8) + encoder.encodeString("$a$b$g$r") + } + + override fun deserialize(decoder: Decoder): Int { + val abgr = decoder.decodeString() + return abgr.toLong(16).toInt() + } +} diff --git a/data/src/main/java/com/google/maps/android/data/parser/kml/KmlModel.kt b/data/src/main/java/com/google/maps/android/data/parser/kml/KmlModel.kt new file mode 100644 index 000000000..8830b74e8 --- /dev/null +++ b/data/src/main/java/com/google/maps/android/data/parser/kml/KmlModel.kt @@ -0,0 +1,457 @@ +/* + * 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.data.parser.kml + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import nl.adaptivity.xmlutil.serialization.XmlElement +import nl.adaptivity.xmlutil.serialization.XmlSerialName + +/** + * This file contains the data classes for serializing KML files. + * The structure is based on the KML 2.2 standard. + * + * This model is designed to be resilient to variations in KML files + * by defining common but currently unused elements like Style and StyleMap, + * preventing the parser from failing on them. + */ + +private const val KML_NAMESPACE = "http://www.opengis.net/kml/2.2" +private const val GOOGLE_KML_NAMESPACE = "http://www.google.com/kml/ext/2.2" + +@Serializable +@XmlSerialName("altitudeMode", namespace = GOOGLE_KML_NAMESPACE, prefix = "gx") +enum class AltitudeMode { + @SerialName("relativeToGround") + RELATIVE_TO_GROUND, + + @SerialName("absolute") + ABSOLUTE, + + @SerialName("relativeToSeaFloor") + RELATIVE_TO_SEA_FLOOR, + + @SerialName("clampToGround") + CLAMP_TO_GROUND, + + @SerialName("clampToSeaFloor") + CLAMP_TO_SEA_FLOOR, +} + +sealed interface KmlFeature + +@Serializable +@XmlSerialName("kml", namespace = KML_NAMESPACE, prefix = "") +data class Kml( + @XmlElement(true) + @XmlSerialName("Document", namespace = KML_NAMESPACE, prefix = "") + val document: Document? = null, + @XmlElement(true) + @XmlSerialName("Folder", namespace = KML_NAMESPACE, prefix = "") + val folder: Folder? = null, + @XmlElement(true) + @XmlSerialName("Placemark", namespace = KML_NAMESPACE, prefix = "") + val placemark: Placemark? = null, + // Include Style and StyleMap to prevent parsing errors on files that contain them. + @XmlElement(true) + @XmlSerialName("Style", namespace = KML_NAMESPACE, prefix = "") + val style: Style? = null, + @XmlElement(true) + @XmlSerialName("StyleMap", namespace = KML_NAMESPACE, prefix = "") + val styleMap: StyleMap? = null, + @XmlElement(true) + @XmlSerialName("GroundOverlay", namespace = KML_NAMESPACE, prefix = "") + val groundOverlay: GroundOverlay? = null, + @kotlinx.serialization.Transient + val images: Map = emptyMap(), +) + +@Serializable +@XmlSerialName("GroundOverlay", namespace = KML_NAMESPACE, prefix = "") +data class GroundOverlay( + @XmlElement(true) + @XmlSerialName("name", namespace = KML_NAMESPACE, prefix = "") + val name: String? = null, + @XmlElement(true) + @XmlSerialName("drawOrder", namespace = KML_NAMESPACE, prefix = "") + val drawOrder: Int? = null, + @XmlElement(true) + @XmlSerialName("visibility", namespace = KML_NAMESPACE, prefix = "") + @Serializable(with = BooleanSerializer::class) + val visibility: Boolean = true, + @XmlElement(true) + @XmlSerialName("color", namespace = KML_NAMESPACE, prefix = "") + @Serializable(with = ColorSerializer::class) + val color: Int? = null, + @XmlElement(true) + @XmlSerialName("Icon", namespace = KML_NAMESPACE, prefix = "") + val icon: Icon? = null, + @XmlElement(true) + @XmlSerialName("LatLonBox", namespace = KML_NAMESPACE, prefix = "") + val latLonBox: LatLonBox? = null, +) + +@Serializable +@XmlSerialName("Icon", namespace = KML_NAMESPACE, prefix = "") +data class Icon( + @XmlElement(true) + @XmlSerialName("href", namespace = KML_NAMESPACE, prefix = "") + val href: String? = null, +) + +@Serializable +@XmlSerialName("LatLonBox", namespace = KML_NAMESPACE, prefix = "") +data class LatLonBox( + @XmlElement(true) + @XmlSerialName("north", namespace = KML_NAMESPACE, prefix = "") + val north: Double, + @XmlElement(true) + @XmlSerialName("south", namespace = KML_NAMESPACE, prefix = "") + val south: Double, + @XmlElement(true) + @XmlSerialName("east", namespace = KML_NAMESPACE, prefix = "") + val east: Double, + @XmlElement(true) + @XmlSerialName("west", namespace = KML_NAMESPACE, prefix = "") + val west: Double, + @XmlElement(true) + @XmlSerialName("rotation", namespace = KML_NAMESPACE, prefix = "") + val rotation: Double? = null, +) + +fun Kml.findByPlacemarksById(id: String): List = + buildList { + document?.placemarks?.filter { it.id == id }?.let { addAll(it) } + } + +@Serializable +@XmlSerialName("Document", namespace = KML_NAMESPACE, prefix = "") +data class Document( + @XmlElement(true) + @XmlSerialName("name", namespace = KML_NAMESPACE, prefix = "") + val name: String? = null, + @XmlElement(true) + @XmlSerialName("description", namespace = KML_NAMESPACE, prefix = "") + val description: String? = null, + @XmlElement(true) + @XmlSerialName("Folder", namespace = KML_NAMESPACE, prefix = "") + val folders: List = emptyList(), + @XmlElement(true) + @XmlSerialName("Placemark", namespace = KML_NAMESPACE, prefix = "") + val placemarks: List = emptyList(), + @XmlElement(true) + @XmlSerialName("Style", namespace = KML_NAMESPACE, prefix = "") + val styles: List + + + 60A + Empty Hotspot + 1 + + 0 + 1 + 0 + -111.957935372607,44.0811954480368,0 + + #emptyHotspot + + + 60B + Valid Hotspot + 1 + + 0 + 1 + 0 + -112.057935372607,44.1811954480368,0 + + #validHotspot + + + \ No newline at end of file diff --git a/library/src/test/resources/amu_extended_data.kml b/data/src/test/resources/amu_extended_data.kml similarity index 80% rename from library/src/test/resources/amu_extended_data.kml rename to data/src/test/resources/amu_extended_data.kml index c7c22f8d5..ffec9ae12 100644 --- a/library/src/test/resources/amu_extended_data.kml +++ b/data/src/test/resources/amu_extended_data.kml @@ -1,3 +1,5 @@ + + Club house @@ -15,3 +17,4 @@ -111.956,33.5043 + diff --git a/library/src/test/resources/amu_ground_overlay.kml b/data/src/test/resources/amu_ground_overlay.kml similarity index 100% rename from library/src/test/resources/amu_ground_overlay.kml rename to data/src/test/resources/amu_ground_overlay.kml diff --git a/library/src/test/resources/amu_ground_overlay_color.kml b/data/src/test/resources/amu_ground_overlay_color.kml similarity index 59% rename from library/src/test/resources/amu_ground_overlay_color.kml rename to data/src/test/resources/amu_ground_overlay_color.kml index 72024ee8d..1aea66aea 100644 --- a/library/src/test/resources/amu_ground_overlay_color.kml +++ b/data/src/test/resources/amu_ground_overlay_color.kml @@ -5,10 +5,10 @@ 99 7f000000 - 37.91904192681665 - 37.46543388598137 - 15.35832653742206 - 14.60128369746704 + 37.91904192681665 + 37.46543388598137 + 15.35832653742206 + 14.60128369746704 diff --git a/library/src/test/resources/amu_inline_style.kml b/data/src/test/resources/amu_inline_style.kml similarity index 100% rename from library/src/test/resources/amu_inline_style.kml rename to data/src/test/resources/amu_inline_style.kml diff --git a/data/src/test/resources/amu_multigeometry_placemarks.kml b/data/src/test/resources/amu_multigeometry_placemarks.kml new file mode 100644 index 000000000..5c5c117d6 --- /dev/null +++ b/data/src/test/resources/amu_multigeometry_placemarks.kml @@ -0,0 +1,55 @@ + + + + Placemark Test + + + -3.6726,40.4308 + + + 1 + + -3.6655,40.4364 + -3.6726,40.4308 + + + + 1 + relativeToGround + + + + -122.366278,37.818844,30 + -122.365248,37.819267,30 + -122.365640,37.819861,30 + -122.366669,37.819429,30 + -122.366278,37.818844,30 + + + + + + + -122.366212,37.818977,30 + -122.365424,37.819294,30 + -122.365704,37.819731,30 + -122.366488,37.819402,30 + -122.366212,37.818977,30 + + + + + + + -122.366212,37.818977,42 + -122.365424,37.819294,42 + -122.365704,37.819731,42 + -122.366488,37.819402,42 + -122.366212,37.818977,42 + + + + + + + \ No newline at end of file diff --git a/library/src/test/resources/amu_multiple_placemarks.kml b/data/src/test/resources/amu_multiple_placemarks.kml similarity index 90% rename from library/src/test/resources/amu_multiple_placemarks.kml rename to data/src/test/resources/amu_multiple_placemarks.kml index 95f8c6b7d..ddea23cd7 100644 --- a/library/src/test/resources/amu_multiple_placemarks.kml +++ b/data/src/test/resources/amu_multiple_placemarks.kml @@ -1,4 +1,5 @@ - + + My Golf Course Example @@ -36,3 +37,4 @@ + diff --git a/data/src/test/resources/amu_nested_folders.kml b/data/src/test/resources/amu_nested_folders.kml new file mode 100644 index 000000000..d1949a592 --- /dev/null +++ b/data/src/test/resources/amu_nested_folders.kml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/library/src/test/resources/amu_nested_multigeometry.kml b/data/src/test/resources/amu_nested_multigeometry.kml similarity index 100% rename from library/src/test/resources/amu_nested_multigeometry.kml rename to data/src/test/resources/amu_nested_multigeometry.kml diff --git a/library/src/test/resources/amu_poly_style_boolean_alpha.kml b/data/src/test/resources/amu_poly_style_boolean_alpha.kml similarity index 98% rename from library/src/test/resources/amu_poly_style_boolean_alpha.kml rename to data/src/test/resources/amu_poly_style_boolean_alpha.kml index ffe958ffb..6ce710b44 100644 --- a/library/src/test/resources/amu_poly_style_boolean_alpha.kml +++ b/data/src/test/resources/amu_poly_style_boolean_alpha.kml @@ -1,5 +1,6 @@ - + + + + + + + Styled Point + This point uses a shared style definition. + #examplePointStyle + + -122.0839597,37.4222899,0 + + + + + + Polygon with a Hole + A polygon demonstrating an inner boundary (hole). + #examplePolygonStyle + + clampToGround + + + + + -122.085,37.425,0 + -122.087,37.425,0 + -122.087,37.427,0 + -122.085,37.427,0 + -122.085,37.425,0 + + + + + + + + + -122.0865,37.4255,0 + -122.0855,37.4255,0 + -122.0855,37.4265,0 + -122.0865,37.4265,0 + -122.0865,37.4255,0 + + + + + + + + + + MultiGeometry Example + Combines a LineString and a Point into one feature. + + + -122.0845,37.4215,0 + + + + -122.084,37.421,0 + -122.085,37.421,0 + -122.0845,37.4215,0 + + + + + + + diff --git a/data/src/test/resources/medicare.json b/data/src/test/resources/medicare.json new file mode 100644 index 000000000..e53c3a308 --- /dev/null +++ b/data/src/test/resources/medicare.json @@ -0,0 +1,247 @@ +[ +{"lat" : -34.92417, "lng" : 138.59846}, +{"lat" : -37.71393, "lng" : 144.88732}, +{"lat" : -19.28337, "lng" : 146.76456}, +{"lat" : -35.02591, "lng" : 117.88466}, +{"lat" : -36.07727, "lng" : 146.92370}, +{"lat" : -23.69590, "lng" : 133.88036}, +{"lat" : -37.828011, "lng" : 144.848706}, +{"lat" : -37.28326, "lng" : 142.93661}, +{"lat" : -32.15567, "lng" : 116.01372}, +{"lat" : -30.51246, "lng" : 151.66191}, +{"lat" : -27.977129, "lng" : 153.380844}, +{"lat" : -17.26322, "lng" : 145.47927}, +{"lat" : -19.57682, "lng" : 147.40660}, +{"lat" : -37.82543, "lng" : 147.62941}, +{"lat" : -37.56082, "lng" : 143.85623}, +{"lat" : -28.86713, "lng" : 153.55671}, +{"lat" : -33.917178, "lng" : 151.040138}, +{"lat" : -35.70976, "lng" : 150.17865}, +{"lat" : -33.41674, "lng" : 149.57715}, +{"lat" : -27.71801, "lng" : 153.20747}, +{"lat" : -36.67464, "lng" : 149.84076}, +{"lat" : -35.23839, "lng" : 149.06585}, +{"lat" : -33.036597, "lng" : 151.659139}, +{"lat" : -31.965845, "lng" : 115.933677}, +{"lat" : -36.75950, "lng" : 144.28262}, +{"lat" : -37.90369, "lng" : 145.03786}, +{"lat" : -34.28789, "lng" : 140.60191}, +{"lat" : -33.77007, "lng" : 150.906374}, +{"lat" : -33.89365, "lng" : 151.25128}, +{"lat" : -32.03582, "lng" : 115.83292}, +{"lat" : -20.19004, "lng" : 147.99167}, +{"lat" : -34.47863, "lng" : 150.41925}, +{"lat" : -37.82124, "lng" : 145.12382}, +{"lat" : -35.27414, "lng" : 149.13236}, +{"lat" : -37.68202, "lng" : 144.91612}, +{"lat" : -31.95784, "lng" : 141.46209}, +{"lat" : -33.76771, "lng" : 151.26733}, +{"lat" : -17.95464, "lng" : 122.24006}, +{"lat" : -33.32394, "lng" : 115.63525}, +{"lat" : -24.86614, "lng" : 152.35411}, +{"lat" : -41.05064, "lng" : 145.90517}, +{"lat" : -33.874246, "lng" : 151.104635}, +{"lat" : -16.92524, "lng" : 145.77168}, +{"lat" : -26.80473, "lng" : 153.13579}, +{"lat" : -37.83132, "lng" : 145.05795}, +{"lat" : -34.05617, "lng" : 150.69418}, +{"lat" : -34.06518, "lng" : 150.81721}, +{"lat" : -32.01518, "lng" : 115.92928}, +{"lat" : -27.52153, "lng" : 153.19009}, +{"lat" : -27.50332, "lng" : 153.10151}, +{"lat" : -28.86344, "lng" : 153.05052}, +{"lat" : -33.731878, "lng" : 151.005526}, +{"lat" : -12.37626, "lng" : 130.88163}, +{"lat" : -32.83752, "lng" : 151.35582}, +{"lat" : -37.886396, "lng" : 145.082314}, +{"lat" : -32.96341, "lng" : 151.69514}, +{"lat" : -33.79735, "lng" : 151.18038}, +{"lat" : -27.38591, "lng" : 153.03099}, +{"lat" : -37.81477, "lng" : 144.9547}, +{"lat" : -27.52917, "lng" : 153.26674}, +{"lat" : -27.56454, "lng" : 151.93238}, +{"lat" : -30.2967, "lng" : 153.1187}, +{"lat" : -38.34112, "lng" : 143.58917}, +{"lat" : -36.23622, "lng" : 149.12952}, +{"lat" : -38.07526, "lng" : 144.35551}, +{"lat" : -33.83584, "lng" : 148.69135}, +{"lat" : -38.10918, "lng" : 145.28225}, +{"lat" : -27.18026, "lng" : 151.26456}, +{"lat" : -37.990225, "lng" : 145.21956}, +{"lat" : -12.46155, "lng" : 130.84338}, +{"lat" : -27.19272, "lng" : 153.03027}, +{"lat" : -41.17479, "lng" : 146.35092}, +{"lat" : -37.78412, "lng" : 145.1257}, +{"lat" : -32.24898, "lng" : 148.60454}, +{"lat" : -33.79168, "lng" : 151.08183}, +{"lat" : -36.12498, "lng" : 144.74620}, +{"lat" : -34.71774, "lng" : 138.66852}, +{"lat" : -37.884814, "lng" : 145.004204}, +{"lat" : -23.52423, "lng" : 148.15922}, +{"lat" : -34.06578, "lng" : 151.01439}, +{"lat" : -33.437331, "lng" : 151.393275}, +{"lat" : -33.868529, "lng" : 150.955512}, +{"lat" : -37.83470, "lng" : 145.16532}, +{"lat" : -38.01791, "lng" : 145.30451}, +{"lat" : -38.14058, "lng" : 145.12483}, +{"lat" : -32.05182, "lng" : 115.75081}, +{"lat" : -37.81497, "lng" : 144.96307}, +{"lat" : -27.56004, "lng" : 153.08108}, +{"lat" : -34.60007, "lng" : 138.75007}, +{"lat" : -38.14794, "lng" : 144.36176}, +{"lat" : -28.77082, "lng" : 114.61273}, +{"lat" : -23.85461, "lng" : 151.24919}, +{"lat" : -37.876681, "lng" : 145.166304}, +{"lat" : -42.83193, "lng" : 147.27257}, +{"lat" : -33.42396, "lng" : 151.34402}, +{"lat" : -34.75572, "lng" : 149.71729}, +{"lat" : -29.68991, "lng" : 152.93267}, +{"lat" : -37.70343, "lng" : 145.10227}, +{"lat" : -34.28791, "lng" : 146.04412}, +{"lat" : -35.18451, "lng" : 149.13492}, +{"lat" : -30.97749, "lng" : 150.25019}, +{"lat" : -26.18736, "lng" : 152.66149}, +{"lat" : -37.74150, "lng" : 142.02759}, +{"lat" : -38.30781, "lng" : 145.18694}, +{"lat" : -25.28279, "lng" : 152.83923}, +{"lat" : -37.770177, "lng" : 144.886593}, +{"lat" : -31.814814, "lng" : 115.738305}, +{"lat" : -42.88260, "lng" : 147.32840}, +{"lat" : -33.70474, "lng" : 151.09994}, +{"lat" : -36.71317, "lng" : 142.19716}, +{"lat" : -33.965065, "lng" : 151.104756}, +{"lat" : -27.49491, "lng" : 152.9803}, +{"lat" : -18.64973, "lng" : 146.15938}, +{"lat" : -17.524061, "lng" : 146.031315}, +{"lat" : -29.77744, "lng" : 151.11445}, +{"lat" : -27.60968, "lng" : 152.76016}, +{"lat" : -31.74185, "lng" : 115.77141}, +{"lat" : -30.74538, "lng" : 121.47710}, +{"lat" : -31.877945, "lng" : 115.776288}, +{"lat" : -33.717119, "lng" : 150.309526}, +{"lat" : -26.69948, "lng" : 153.13196}, +{"lat" : -31.08100, "lng" : 152.82870}, +{"lat" : -33.86911, "lng" : 151.20874}, +{"lat" : -26.54301, "lng" : 151.83797}, +{"lat" : -42.97473, "lng" : 147.30946}, +{"lat" : -37.868979, "lng" : 145.235211}, +{"lat" : -32.94833, "lng" : 151.71205}, +{"lat" : -33.24171, "lng" : 151.50363}, +{"lat" : -41.43835, "lng" : 147.13929}, +{"lat" : -33.88706, "lng" : 151.15933}, +{"lat" : -37.75600, "lng" : 145.35500}, +{"lat" : -28.80998, "lng" : 153.28698}, +{"lat" : -33.48130, "lng" : 150.16031}, +{"lat" : -33.91987, "lng" : 150.93122}, +{"lat" : -27.64292, "lng" : 153.11354}, +{"lat" : -21.13998, "lng" : 149.17836}, +{"lat" : -32.73445, "lng" : 151.55353}, +{"lat" : -32.536809, "lng" : 115.74208}, +{"lat" : -27.24345, "lng" : 153.10666}, +{"lat" : -35.01371, "lng" : 138.54266}, +{"lat" : -26.654233, "lng" : 153.090664}, +{"lat" : -25.53740, "lng" : 152.70120}, +{"lat" : -37.68724, "lng" : 144.56585}, +{"lat" : -36.88753, "lng" : 149.90961}, +{"lat" : -31.88985, "lng" : 116.01043}, +{"lat" : -34.18742, "lng" : 142.16028}, +{"lat" : -34.03461, "lng" : 151.10034}, +{"lat" : -27.41219, "lng" : 152.97728}, +{"lat" : -34.83428, "lng" : 138.68773}, +{"lat" : -37.766646, "lng" : 144.921037}, +{"lat" : -27.102884, "lng" : 152.94826}, +{"lat" : -29.46090, "lng" : 149.83977}, +{"lat" : -37.75575, "lng" : 144.96322}, +{"lat" : -31.89503, "lng" : 115.90202}, +{"lat" : -38.22484, "lng" : 145.06154}, +{"lat" : -38.23986, "lng" : 146.39936}, +{"lat" : -37.82840, "lng" : 140.78276}, +{"lat" : -20.72491, "lng" : 139.49098}, +{"lat" : -27.54713, "lng" : 152.93959}, +{"lat" : -32.59183, "lng" : 149.58680}, +{"lat" : -32.26281, "lng" : 150.89091}, +{"lat" : -26.628406, "lng" : 152.959571}, +{"lat" : -30.64258, "lng" : 153.00279}, +{"lat" : -36.22382, "lng" : 150.12716}, +{"lat" : -30.32616, "lng" : 149.78312}, +{"lat" : -32.92869, "lng" : 151.76895}, +{"lat" : -35.13848, "lng" : 138.49880}, +{"lat" : -23.35249, "lng" : 150.52380}, +{"lat" : -33.77698, "lng" : 151.12029}, +{"lat" : -33.837958, "lng" : 151.206677}, +{"lat" : -37.76960, "lng" : 145.00140}, +{"lat" : -37.7402, "lng" : 145.025241}, +{"lat" : -34.87401, "lng" : 150.60030}, +{"lat" : -33.28449, "lng" : 149.09856}, +{"lat" : -28.03654, "lng" : 153.42783}, +{"lat" : -33.945879, "lng" : 151.226913}, +{"lat" : -12.480801, "lng" : 130.98591}, +{"lat" : -33.13433, "lng" : 148.17627}, +{"lat" : -33.82322, "lng" : 151.0031}, +{"lat" : -33.75207, "lng" : 150.69229}, +{"lat" : -31.95516, "lng" : 115.85766}, +{"lat" : -32.49421, "lng" : 137.76421}, +{"lat" : -34.72478, "lng" : 135.85972}, +{"lat" : -31.42974, "lng" : 152.90668}, +{"lat" : -33.17637, "lng" : 138.00701}, +{"lat" : -38.35023, "lng" : 141.60513}, +{"lat" : -37.847036, "lng" : 144.993863}, +{"lat" : -35.352484, "lng" : 149.23532}, +{"lat" : -27.47069, "lng" : 153.02478}, +{"lat" : -32.76431, "lng" : 151.74240}, +{"lat" : -33.60189, "lng" : 150.75762}, +{"lat" : -37.815148, "lng" : 145.229523}, +{"lat" : -28.07749, "lng" : 153.38539}, +{"lat" : -23.22863, "lng" : 150.47191}, +{"lat" : -32.29114, "lng" : 115.74341}, +{"lat" : -38.354983, "lng" : 144.907529}, +{"lat" : -33.935046, "lng" : 151.068843}, +{"lat" : -42.86619, "lng" : 147.37231}, +{"lat" : -38.10682, "lng" : 147.06536}, +{"lat" : -34.56290, "lng" : 150.84041}, +{"lat" : -36.3851, "lng" : 145.39969}, +{"lat" : -32.56411, "lng" : 151.16969}, +{"lat" : -16.83731, "lng" : 145.69267}, +{"lat" : -37.95912, "lng" : 145.05421}, +{"lat" : -27.96795, "lng" : 153.41653}, +{"lat" : -33.82385, "lng" : 151.24027}, +{"lat" : -33.69894, "lng" : 150.56761}, +{"lat" : -27.30889, "lng" : 152.98989}, +{"lat" : -31.94752, "lng" : 115.82157}, +{"lat" : -34.031886, "lng" : 151.058952}, +{"lat" : -35.34161, "lng" : 143.55917}, +{"lat" : -31.08799, "lng" : 150.92714}, +{"lat" : -31.913555, "lng" : 152.461071}, +{"lat" : -33.34330, "lng" : 151.49634}, +{"lat" : -27.4084292, "lng" : 153.059964}, +{"lat" : -27.56095, "lng" : 151.94969}, +{"lat" : -33.01121, "lng" : 151.59433}, +{"lat" : -33.87305, "lng" : 151.20552}, +{"lat" : -19.31855, "lng" : 146.72523}, +{"lat" : -38.197385, "lng" : 146.536736}, +{"lat" : -33.306914, "lng" : 151.415573}, +{"lat" : -35.41634, "lng" : 149.06658}, +{"lat" : -32.17190, "lng" : 152.49830}, +{"lat" : -28.20001, "lng" : 153.54444}, +{"lat" : -35.35716, "lng" : 150.47256}, +{"lat" : -35.117135, "lng" : 147.369055}, +{"lat" : -32.90136, "lng" : 151.67182}, +{"lat" : -36.35343, "lng" : 146.32727}, +{"lat" : -38.16340, "lng" : 145.92890}, +{"lat" : -34.485522, "lng" : 150.888213}, +{"lat" : -33.69585, "lng" : 151.29577}, +{"lat" : -38.38200, "lng" : 142.48540}, +{"lat" : -28.21337, "lng" : 152.03436}, +{"lat" : -38.19917, "lng" : 144.31934}, +{"lat" : -37.93664, "lng" : 145.18465}, +{"lat" : -37.90590, "lng" : 144.65726}, +{"lat" : -34.87722, "lng" : 138.493552}, +{"lat" : -33.02747, "lng" : 137.53575}, +{"lat" : -35.34571, "lng" : 149.08651}, +{"lat" : -36.12169, "lng" : 146.88802}, +{"lat" : -34.42703, "lng" : 150.89806}, +{"lat" : -33.48747, "lng" : 151.32514}, +{"lat" : -27.44666, "lng" : 153.17245}, +{"lat" : -33.86489, "lng" : 151.20701}, +{"lat" : -34.31094, "lng" : 148.29214} +] diff --git a/data/src/test/resources/mountain_ranges.kml b/data/src/test/resources/mountain_ranges.kml new file mode 100644 index 000000000..05d1934d4 --- /dev/null +++ b/data/src/test/resources/mountain_ranges.kml @@ -0,0 +1,652 @@ + + + + Colorado Mountain Ranges + + + + + + normal + #poly-000000-1200-77-nodesc-normal + + + highlight + #poly-000000-1200-77-nodesc-highlight + + + + + + + normal + #poly-0288D1-1200-77-nodesc-normal + + + highlight + #poly-0288D1-1200-77-nodesc-highlight + + + + + + + normal + #poly-0F9D58-1200-77-nodesc-normal + + + highlight + #poly-0F9D58-1200-77-nodesc-highlight + + + + + + + normal + #poly-1A237E-1200-77-nodesc-normal + + + highlight + #poly-1A237E-1200-77-nodesc-highlight + + + + + + + normal + #poly-673AB7-1200-77-nodesc-normal + + + highlight + #poly-673AB7-1200-77-nodesc-highlight + + + + + + + normal + #poly-A52714-1200-77-nodesc-normal + + + highlight + #poly-A52714-1200-77-nodesc-highlight + + + + + + + normal + #poly-F9A825-1200-77-nodesc-normal + + + highlight + #poly-F9A825-1200-77-nodesc-highlight + + + + + + + normal + #poly-FF5252-1200-77-nodesc-normal + + + highlight + #poly-FF5252-1200-77-nodesc-highlight + + + + + + + normal + #poly-FFEA00-1200-77-nodesc-normal + + + highlight + #poly-FFEA00-1200-77-nodesc-highlight + + + + Untitled layer + + Sangre de Cristo Mountains + #poly-A52714-1200-77-nodesc + + + + 1 + + -106.1049364,38.5109349,0 + -105.857744,38.2137389,0 + -105.7478808,37.9456537,0 + -105.5666064,37.8069029,0 + -105.5281542,37.7374295,0 + -105.6599901,37.5024754,0 + -105.4127978,37.3759901,0 + -105.5830858,36.9820902,0 + -104.2537401,37.0259582,0 + -104.7646044,37.6026392,0 + -104.7646044,38.0365668,0 + -105.0470444,38.3272364,0 + -105.3908251,38.4679386,0 + -105.7199862,38.3890096,0 + -105.8797167,38.5367203,0 + -106.1049364,38.5109349,0 + + + + + + + San Juan Mountains + #poly-1A237E-1200-77-nodesc + + + + 1 + + -106.2367724,38.0062749,0 + -106.7311571,38.5238288,0 + -107.1211718,38.4550348,0 + -107.4727343,38.4033962,0 + -107.8078173,38.4937395,0 + -107.7748583,38.1662472,0 + -108.0769823,38.0452193,0 + -108.434038,38.1964728,0 + -108.5329149,38.0884666,0 + -108.7251757,38.0235861,0 + -108.867998,38.0322402,0 + -108.9558886,37.9283241,0 + -108.9284228,37.7851995,0 + -108.6867235,37.4981174,0 + -108.4505175,37.4370786,0 + -108.2582567,37.3323249,0 + -107.9616259,37.2624078,0 + -107.6759814,37.1617881,0 + -107.5661181,37.008414,0 + -106.2177623,37.0041902,0 + -106.2410398,37.1979388,0 + -106.2523359,37.4625755,0 + -106.537846,37.696343,0 + -106.2367724,38.0062749,0 + + + + + + + Sawatch Range + #poly-FFEA00-1200-77-nodesc + + + + 1 + + -106.7311571,38.5238288,0 + -106.1653613,37.9803005,0 + -106.0886704,38.1055014,0 + -105.9785937,38.2180549,0 + -106.1104296,38.4421286,0 + -106.1049364,38.5109349,0 + -106.1049365,38.7169595,0 + -106.357622,39.2294284,0 + -106.3313518,39.5180394,0 + -106.3710554,39.8066499,0 + -106.5287369,40.3753686,0 + -106.948583,40.322879,0 + -106.8255531,40.0758137,0 + -106.626787,39.8285259,0 + -106.6817187,39.6976262,0 + -107.0222948,39.6595765,0 + -107.2255419,39.5664778,0 + -106.8465136,39.2779272,0 + -106.9660516,38.9146229,0 + -106.9918711,38.8268309,0 + -107.3573779,38.6569302,0 + -107.5386522,38.5968504,0 + -107.5880907,38.5195311,0 + -107.3683642,38.4765399,0 + -107.0662401,38.5023377,0 + -106.8629931,38.541017,0 + -106.7311571,38.5238288,0 + + + + + + + Front Range + #poly-000000-1200-77-nodesc + + + + 1 + + -104.885454,38.3603358,0 + -104.7289927,38.4633943,0 + -104.7000252,38.588018,0 + -104.8140429,38.7383864,0 + -104.8744677,39.0846055,0 + -105.0227831,39.4927493,0 + -105.1985644,39.7043812,0 + -105.2095507,40.0332345,0 + -105.1436327,40.4441719,0 + -105.1765917,40.5736428,0 + -105.3853319,40.6362001,0 + -105.687456,40.7031749,0 + -105.7973193,40.5652973,0 + -105.8687304,40.4734291,0 + -106.0252269,40.4776794,0 + -106.1598681,40.6323432,0 + -106.1706277,40.5619543,0 + -106.0827158,40.2743716,0 + -105.9731005,40.1257028,0 + -106.06113,40.010281,0 + -106.0994433,39.9406406,0 + -106.1323651,39.7824251,0 + -106.0554979,39.7086072,0 + -106.0005663,39.5478358,0 + -105.6819628,39.2634611,0 + -105.6325243,39.0590176,0 + -105.7620847,38.8968684,0 + -106.0609911,38.7683732,0 + -106.0603998,38.5757672,0 + -105.8797167,38.5367203,0 + -105.7199862,38.3890096,0 + -105.3798388,38.5195311,0 + -105.1543465,38.3885439,0 + -104.885454,38.3603358,0 + + + + + + + Park Range + #poly-0F9D58-1200-77-nodesc + + + + 1 + + -107.664995,40.9978777,0 + -107.6045702,40.7819397,0 + -107.4782274,40.6362001,0 + -107.5606249,40.5235546,0 + -107.296953,40.4650712,0 + -107.0113085,40.4358104,0 + -106.8849657,40.3730657,0 + -106.6158007,40.3563239,0 + -106.4729784,40.4316292,0 + -106.357622,40.6653736,0 + -106.357622,40.8650761,0 + -106.4070605,41.0061689,0 + -107.664995,40.9978777,0 + + + + + + + Southern Wyoming Range + #poly-FF5252-1200-77-nodesc + + + + 1 + + -106.4070605,41.0061689,0 + -106.324663,40.7198302,0 + -106.1598681,40.6323432,0 + -105.98958,40.5029859,0 + -105.890703,40.5029859,0 + -105.8137987,40.6073258,0 + -105.687456,40.7031749,0 + -105.4622362,40.7031749,0 + -105.3084276,40.6698517,0 + -105.0996874,40.5447413,0 + -105.0887011,40.7281563,0 + -105.3029345,40.9193693,0 + -105.4018114,40.9729977,0 + -106.4070605,41.0061689,0 + + + + + + + Flat Tops Area + #poly-F9A825-1200-77-nodesc + + + + 1 + + -106.8849657,40.3730657,0 + -107.0113085,40.399851,0 + -107.1101855,40.399851,0 + -107.4068163,40.4416707,0 + -107.5716112,40.4751077,0 + -107.763872,40.5377572,0 + -107.9890917,40.6545463,0 + -108.2252978,40.6211988,0 + -108.5274218,40.4792861,0 + -108.6208056,40.3245102,0 + -108.5658739,40.1567847,0 + -108.2362841,40.0727662,0 + -107.9616259,39.9970608,0 + -107.8792284,39.562243,0 + -107.7748583,39.5283559,0 + -107.6045702,39.5368292,0 + -107.3463915,39.5537728,0 + -107.2255419,39.5664778,0 + -107.0222948,39.6595765,0 + -106.6817187,39.6976262,0 + -106.626787,39.8285259,0 + -106.7531298,39.954966,0 + -106.8794726,40.1021845,0 + -106.994829,40.3245102,0 + -106.8849657,40.3730657,0 + + + + + + + Elk Range + #poly-673AB7-1200-77-nodesc + + + + 1 + + -107.1541308,39.5029297,0 + -107.3463915,39.5537728,0 + -107.9286669,39.5114061,0 + -108.0159674,39.3490894,0 + -108.2125915,39.2460574,0 + -108.3208052,39.1513768,0 + -108.5493944,39.0479339,0 + -108.0220507,38.7658107,0 + -107.8187868,38.8109172,0 + -107.6540087,38.7143952,0 + -107.3189257,38.7058223,0 + -107.0442674,38.8257487,0 + -106.8684862,39.1630246,0 + -106.8465136,39.2779272,0 + -107.0332812,39.4223518,0 + -107.1541308,39.5029297,0 + + + + + + + Central Colorado Range + #poly-0288D1-1200-77-nodesc + + + + 1 + + -106.0610137,38.794089,0 + -105.8772053,38.8583454,0 + -105.7259506,38.9651288,0 + -105.6709991,39.0759262,0 + -105.7479224,39.2634563,0 + -106.0280573,39.560519,0 + -106.1708711,39.755042,0 + -106.0994646,40.1928125,0 + -106.2752353,40.8275776,0 + -106.5287369,40.3753686,0 + -106.4125557,40.1634351,0 + -106.3026989,39.5689878,0 + -106.297206,39.2166596,0 + -106.0610137,38.794089,0 + + + + + + + + diff --git a/data/src/test/resources/police.json b/data/src/test/resources/police.json new file mode 100644 index 000000000..eb898ea29 --- /dev/null +++ b/data/src/test/resources/police.json @@ -0,0 +1,347 @@ +[ +{"lat" : -37.1886, "lng" : 145.708 } , +{"lat" : -37.8361, "lng" : 144.845 } , +{"lat" : -38.4034, "lng" : 144.192 } , +{"lat" : -38.7597, "lng" : 143.67 } , +{"lat" : -36.9672, "lng" : 141.083 } , +{"lat" : -37.2843, "lng" : 142.927 } , +{"lat" : -37.8629, "lng" : 145.08 } , +{"lat" : -37.0871, "lng" : 143.474 } , +{"lat" : -37.7557, "lng" : 144.859 } , +{"lat" : -36.787, "lng" : 144.502 } , +{"lat" : -37.6758, "lng" : 144.438 } , +{"lat" : -37.826, "lng" : 147.636 } , +{"lat" : -37.5999, "lng" : 144.221 } , +{"lat" : -37.5642, "lng" : 143.859 } , +{"lat" : -37.2488, "lng" : 141.843 } , +{"lat" : -38.05, "lng" : 144.168 } , +{"lat" : -37.4313, "lng" : 143.381 } , +{"lat" : -38.1949, "lng" : 143.639 } , +{"lat" : -36.3585, "lng" : 146.689 } , +{"lat" : -37.9081, "lng" : 145.355 } , +{"lat" : -38.2667, "lng" : 144.522 } , +{"lat" : -36.5558, "lng" : 145.975 } , +{"lat" : -36.7669, "lng" : 144.267 } , +{"lat" : -37.1472, "lng" : 148.887 } , +{"lat" : -36.1259, "lng" : 147.099 } , +{"lat" : -35.943, "lng" : 142.419 } , +{"lat" : -35.9798, "lng" : 142.917 } , +{"lat" : -38.3363, "lng" : 143.783 } , +{"lat" : -38.3805, "lng" : 146.272 } , +{"lat" : -36.1158, "lng" : 143.724 } , +{"lat" : -37.8599, "lng" : 145.286 } , +{"lat" : -37.7997, "lng" : 145.052 } , +{"lat" : -37.8181, "lng" : 145.127 } , +{"lat" : -37.8584, "lng" : 141.799 } , +{"lat" : -37.8434, "lng" : 147.07 } , +{"lat" : -36.6017, "lng" : 143.94 } , +{"lat" : -36.7318, "lng" : 146.962 } , +{"lat" : -37.2034, "lng" : 145.055 } , +{"lat" : -37.6832, "lng" : 144.917 } , +{"lat" : -37.7631, "lng" : 144.963 } , +{"lat" : -37.7075, "lng" : 147.831 } , +{"lat" : -37.4999, "lng" : 148.171 } , +{"lat" : -37.6515, "lng" : 143.884 } , +{"lat" : -38.0975, "lng" : 145.718 } , +{"lat" : -37.8509, "lng" : 145.098 } , +{"lat" : -37.8332, "lng" : 145.059 } , +{"lat" : -38.2305, "lng" : 143.146 } , +{"lat" : -37.5654, "lng" : 149.152 } , +{"lat" : -37.8003, "lng" : 144.955 } , +{"lat" : -37.7307, "lng" : 144.741 } , +{"lat" : -37.5853, "lng" : 141.406 } , +{"lat" : -37.0648, "lng" : 144.218 } , +{"lat" : -37.8813, "lng" : 145.023 } , +{"lat" : -37.5272, "lng" : 142.04 } , +{"lat" : -36.2697, "lng" : 143.348 } , +{"lat" : -38.0508, "lng" : 145.116 } , +{"lat" : -37.9652, "lng" : 145.057 } , +{"lat" : -36.1476, "lng" : 146.611 } , +{"lat" : -38.3077, "lng" : 146.418 } , +{"lat" : -37.9201, "lng" : 145.12 } , +{"lat" : -37.2953, "lng" : 143.784 } , +{"lat" : -38.3282, "lng" : 143.076 } , +{"lat" : -35.9208, "lng" : 145.651 } , +{"lat" : -35.8114, "lng" : 144.222 } , +{"lat" : -38.3381, "lng" : 143.593 } , +{"lat" : -37.5999, "lng" : 141.693 } , +{"lat" : -37.8042, "lng" : 144.993 } , +{"lat" : -38.0739, "lng" : 144.358 } , +{"lat" : -36.1945, "lng" : 147.904 } , +{"lat" : -38.4501, "lng" : 145.236 } , +{"lat" : -37.5984, "lng" : 144.933 } , +{"lat" : -38.1134, "lng" : 145.283 } , +{"lat" : -37.4299, "lng" : 143.892 } , +{"lat" : -37.7992, "lng" : 145.279 } , +{"lat" : -35.7175, "lng" : 143.106 } , +{"lat" : -37.9909, "lng" : 145.218 } , +{"lat" : -37.9217, "lng" : 141.283 } , +{"lat" : -37.3421, "lng" : 144.147 } , +{"lat" : -36.4708, "lng" : 147.017 } , +{"lat" : -37.6741, "lng" : 145.161 } , +{"lat" : -36.4567, "lng" : 142.028 } , +{"lat" : -36.3736, "lng" : 142.984 } , +{"lat" : -37.7884, "lng" : 145.158 } , +{"lat" : -36.3304, "lng" : 145.686 } , +{"lat" : -38.3348, "lng" : 144.961 } , +{"lat" : -38.1302, "lng" : 145.849 } , +{"lat" : -38.175, "lng" : 144.57 } , +{"lat" : -37.65, "lng" : 142.345 } , +{"lat" : -36.8575, "lng" : 143.733 } , +{"lat" : -36.7205, "lng" : 144.256 } , +{"lat" : -36.1191, "lng" : 144.745 } , +{"lat" : -37.0364, "lng" : 141.293 } , +{"lat" : -37.2322, "lng" : 145.91 } , +{"lat" : -37.1803, "lng" : 143.251 } , +{"lat" : -36.496, "lng" : 144.611 } , +{"lat" : -37.7134, "lng" : 145.151 } , +{"lat" : -37.934, "lng" : 145.441 } , +{"lat" : -37.9757, "lng" : 145.262 } , +{"lat" : -37.6467, "lng" : 145.026 } , +{"lat" : -36.7517, "lng" : 145.572 } , +{"lat" : -36.8635, "lng" : 147.28 } , +{"lat" : -37.7183, "lng" : 144.962 } , +{"lat" : -37.8022, "lng" : 144.979 } , +{"lat" : -37.7849, "lng" : 144.932 } , +{"lat" : -37.8039, "lng" : 144.901 } , +{"lat" : -38.5211, "lng" : 143.717 } , +{"lat" : -38.6521, "lng" : 146.202 } , +{"lat" : -38.1389, "lng" : 145.125 } , +{"lat" : -38.1454, "lng" : 144.357 } , +{"lat" : -37.4854, "lng" : 144.586 } , +{"lat" : -37.9036, "lng" : 145.164 } , +{"lat" : -36.4635, "lng" : 146.227 } , +{"lat" : -36.6141, "lng" : 144.509 } , +{"lat" : -37.5794, "lng" : 144.102 } , +{"lat" : -36.7183, "lng" : 141.473 } , +{"lat" : -37.704, "lng" : 145.096 } , +{"lat" : -35.9558, "lng" : 144.367 } , +{"lat" : -37.1364, "lng" : 142.519 } , +{"lat" : -37.748, "lng" : 142.026 } , +{"lat" : -37.1642, "lng" : 141.594 } , +{"lat" : -38.3079, "lng" : 145.185 } , +{"lat" : -37.6566, "lng" : 145.511 } , +{"lat" : -36.9217, "lng" : 144.709 } , +{"lat" : -37.7579, "lng" : 145.071 } , +{"lat" : -37.7463, "lng" : 145.047 } , +{"lat" : -37.9815, "lng" : 146.786 } , +{"lat" : -38.1335, "lng" : 141.629 } , +{"lat" : -35.7293, "lng" : 142.365 } , +{"lat" : -36.7141, "lng" : 142.201 } , +{"lat" : -37.6402, "lng" : 145.193 } , +{"lat" : -36.5767, "lng" : 143.871 } , +{"lat" : -38.1015, "lng" : 144.051 } , +{"lat" : -38.6311, "lng" : 145.727 } , +{"lat" : -37.3027, "lng" : 146.138 } , +{"lat" : -36.1432, "lng" : 141.989 } , +{"lat" : -36.3789, "lng" : 141.242 } , +{"lat" : -36.0806, "lng" : 145.69 } , +{"lat" : -37.7238, "lng" : 144.808 } , +{"lat" : -35.7339, "lng" : 143.922 } , +{"lat" : -37.3073, "lng" : 144.95 } , +{"lat" : -37.5315, "lng" : 145.34 } , +{"lat" : -37.8697, "lng" : 145.237 } , +{"lat" : -38.2002, "lng" : 145.489 } , +{"lat" : -35.6447, "lng" : 144.131 } , +{"lat" : -38.2926, "lng" : 142.368 } , +{"lat" : -38.4346, "lng" : 145.824 } , +{"lat" : -36.3137, "lng" : 145.053 } , +{"lat" : -37.2425, "lng" : 144.457 } , +{"lat" : -35.4596, "lng" : 143.632 } , +{"lat" : -37.7121, "lng" : 142.841 } , +{"lat" : -37.8772, "lng" : 147.995 } , +{"lat" : -37.2786, "lng" : 144.736 } , +{"lat" : -37.0046, "lng" : 143.136 } , +{"lat" : -38.2653, "lng" : 145.563 } , +{"lat" : -38.0234, "lng" : 144.396 } , +{"lat" : -38.6823, "lng" : 143.386 } , +{"lat" : -37.8633, "lng" : 144.771 } , +{"lat" : -37.4319, "lng" : 143.729 } , +{"lat" : -38.4753, "lng" : 145.946 } , +{"lat" : -37.2743, "lng" : 143.517 } , +{"lat" : -37.7556, "lng" : 145.342 } , +{"lat" : -37.6847, "lng" : 143.56 } , +{"lat" : -37.9533, "lng" : 143.34 } , +{"lat" : -38.3686, "lng" : 145.703 } , +{"lat" : -38.5409, "lng" : 143.974 } , +{"lat" : -38.0324, "lng" : 142.001 } , +{"lat" : -37.4229, "lng" : 144.568 } , +{"lat" : -37.9651, "lng" : 146.973 } , +{"lat" : -36.9952, "lng" : 144.065 } , +{"lat" : -37.5602, "lng" : 149.751 } , +{"lat" : -37.1884, "lng" : 144.385 } , +{"lat" : -37.8559, "lng" : 145.03 } , +{"lat" : -35.0504, "lng" : 142.883 } , +{"lat" : -37.0523, "lng" : 146.087 } , +{"lat" : -37.0464, "lng" : 143.735 } , +{"lat" : -37.5107, "lng" : 145.748 } , +{"lat" : -38.58, "lng" : 146.011 } , +{"lat" : -37.67, "lng" : 144.849 } , +{"lat" : -37.8165, "lng" : 144.966 } , +{"lat" : -37.822, "lng" : 144.953 } , +{"lat" : -37.6858, "lng" : 144.578 } , +{"lat" : -34.1674, "lng" : 142.061 } , +{"lat" : -37.8466, "lng" : 144.076 } , +{"lat" : -37.7203, "lng" : 141.55 } , +{"lat" : -34.186, "lng" : 142.162 } , +{"lat" : -37.6574, "lng" : 145.075 } , +{"lat" : -36.4582, "lng" : 142.589 } , +{"lat" : -38.4009, "lng" : 146.159 } , +{"lat" : -36.5371, "lng" : 147.378 } , +{"lat" : -38.1779, "lng" : 146.261 } , +{"lat" : -37.8752, "lng" : 145.408 } , +{"lat" : -37.7647, "lng" : 144.924 } , +{"lat" : -37.9374, "lng" : 145.038 } , +{"lat" : -37.7895, "lng" : 145.311 } , +{"lat" : -36.3955, "lng" : 145.356 } , +{"lat" : -38.0038, "lng" : 145.086 } , +{"lat" : -38.2164, "lng" : 145.037 } , +{"lat" : -38.0816, "lng" : 142.808 } , +{"lat" : -38.2373, "lng" : 146.394 } , +{"lat" : -36.7442, "lng" : 147.171 } , +{"lat" : -37.1465, "lng" : 146.452 } , +{"lat" : -37.7871, "lng" : 145.381 } , +{"lat" : -36.9921, "lng" : 147.15 } , +{"lat" : -37.8805, "lng" : 145.128 } , +{"lat" : -36.5782, "lng" : 146.375 } , +{"lat" : -36.6177, "lng" : 145.221 } , +{"lat" : -35.2642, "lng" : 141.183 } , +{"lat" : -37.8907, "lng" : 145.067 } , +{"lat" : -36.6167, "lng" : 142.47 } , +{"lat" : -36.5611, "lng" : 146.725 } , +{"lat" : -36.7873, "lng" : 145.155 } , +{"lat" : -38.026, "lng" : 145.311 } , +{"lat" : -36.0598, "lng" : 145.203 } , +{"lat" : -36.7399, "lng" : 141.947 } , +{"lat" : -38.0185, "lng" : 145.955 } , +{"lat" : -37.1055, "lng" : 144.064 } , +{"lat" : -36.3346, "lng" : 141.652 } , +{"lat" : -37.7661, "lng" : 145.002 } , +{"lat" : -36.0886, "lng" : 145.444 } , +{"lat" : -37.8175, "lng" : 145.183 } , +{"lat" : -35.1719, "lng" : 143.378 } , +{"lat" : -37.8983, "lng" : 145.088 } , +{"lat" : -37.8562, "lng" : 145.365 } , +{"lat" : -37.102, "lng" : 147.593 } , +{"lat" : -37.7066, "lng" : 148.456 } , +{"lat" : -35.07, "lng" : 142.315 } , +{"lat" : -38.0618, "lng" : 145.453 } , +{"lat" : -37.8746, "lng" : 142.29 } , +{"lat" : -35.054, "lng" : 143.314 } , +{"lat" : -38.6178, "lng" : 142.998 } , +{"lat" : -38.3877, "lng" : 142.239 } , +{"lat" : -38.1153, "lng" : 144.658 } , +{"lat" : -38.3525, "lng" : 141.609 } , +{"lat" : -37.8478, "lng" : 145 } , +{"lat" : -37.7392, "lng" : 145.006 } , +{"lat" : -37.7404, "lng" : 145.028 } , +{"lat" : -37.1229, "lng" : 144.857 } , +{"lat" : -36.0546, "lng" : 144.113 } , +{"lat" : -35.8523, "lng" : 143.521 } , +{"lat" : -38.2702, "lng" : 144.661 } , +{"lat" : -35.8995, "lng" : 141.995 } , +{"lat" : -37.9561, "lng" : 146.398 } , +{"lat" : -36.5387, "lng" : 144.204 } , +{"lat" : -34.3041, "lng" : 142.187 } , +{"lat" : -37.7165, "lng" : 145.005 } , +{"lat" : -37.8174, "lng" : 145 } , +{"lat" : -37.4621, "lng" : 144.678 } , +{"lat" : -37.8131, "lng" : 145.227 } , +{"lat" : -34.584, "lng" : 142.771 } , +{"lat" : -36.3631, "lng" : 144.699 } , +{"lat" : -37.901, "lng" : 143.722 } , +{"lat" : -37.3438, "lng" : 144.742 } , +{"lat" : -38.3698, "lng" : 144.89 } , +{"lat" : -38.1503, "lng" : 146.789 } , +{"lat" : -37.9189, "lng" : 145.239 } , +{"lat" : -36.6331, "lng" : 142.63 } , +{"lat" : -36.5907, "lng" : 145.017 } , +{"lat" : -36.0565, "lng" : 146.459 } , +{"lat" : -38.3706, "lng" : 144.819 } , +{"lat" : -38.1123, "lng" : 147.069 } , +{"lat" : -38.5211, "lng" : 145.38 } , +{"lat" : -37.9486, "lng" : 145.004 } , +{"lat" : -35.5024, "lng" : 142.85 } , +{"lat" : -36.406, "lng" : 143.974 } , +{"lat" : -37.02, "lng" : 145.13 } , +{"lat" : -36.3815, "lng" : 145.398 } , +{"lat" : -37.684, "lng" : 143.361 } , +{"lat" : -37.6433, "lng" : 143.687 } , +{"lat" : -38.3361, "lng" : 144.742 } , +{"lat" : -37.8348, "lng" : 144.959 } , +{"lat" : -35.4012, "lng" : 142.441 } , +{"lat" : -37.9551, "lng" : 145.151 } , +{"lat" : -36.6169, "lng" : 143.26 } , +{"lat" : -37.8679, "lng" : 144.991 } , +{"lat" : -37.835, "lng" : 144.974 } , +{"lat" : -36.4464, "lng" : 144.985 } , +{"lat" : -37.0557, "lng" : 142.784 } , +{"lat" : -37.9635, "lng" : 147.08 } , +{"lat" : -37.5799, "lng" : 144.736 } , +{"lat" : -37.7776, "lng" : 144.831 } , +{"lat" : -35.3561, "lng" : 143.563 } , +{"lat" : -37.27, "lng" : 147.726 } , +{"lat" : -36.2161, "lng" : 147.176 } , +{"lat" : -36.2513, "lng" : 147.035 } , +{"lat" : -36.77, "lng" : 143.833 } , +{"lat" : -36.4404, "lng" : 145.233 } , +{"lat" : -38.241, "lng" : 142.919 } , +{"lat" : -38.4834, "lng" : 142.971 } , +{"lat" : -36.2491, "lng" : 144.951 } , +{"lat" : -38.6616, "lng" : 146.325 } , +{"lat" : -38.3256, "lng" : 144.318 } , +{"lat" : -38.2127, "lng" : 146.154 } , +{"lat" : -38.1948, "lng" : 146.536 } , +{"lat" : -37.3907, "lng" : 144.322 } , +{"lat" : -36.1649, "lng" : 145.881 } , +{"lat" : -35.1709, "lng" : 141.81 } , +{"lat" : -36.6362, "lng" : 145.715 } , +{"lat" : -37.4165, "lng" : 144.982 } , +{"lat" : -35.965, "lng" : 147.734 } , +{"lat" : -36.361, "lng" : 146.314 } , +{"lat" : -37.7548, "lng" : 145.688 } , +{"lat" : -36.25, "lng" : 142.396 } , +{"lat" : -38.1618, "lng" : 145.933 } , +{"lat" : -37.7409, "lng" : 145.213 } , +{"lat" : -38.381, "lng" : 142.478 } , +{"lat" : -36.4244, "lng" : 143.616 } , +{"lat" : -37.8945, "lng" : 144.68 } , +{"lat" : -34.3847, "lng" : 141.597 } , +{"lat" : -36.7655, "lng" : 146.414 } , +{"lat" : -37.5102, "lng" : 145.119 } , +{"lat" : -37.546, "lng" : 142.741 } , +{"lat" : -37.8634, "lng" : 144.906 } , +{"lat" : -38.2442, "lng" : 143.99 } , +{"lat" : -36.122, "lng" : 146.89 } , +{"lat" : -38.6076, "lng" : 145.59 } , +{"lat" : -37.3544, "lng" : 144.527 } , +{"lat" : -37.5676, "lng" : 146.251 } , +{"lat" : -35.681, "lng" : 142.665 } , +{"lat" : -36.0744, "lng" : 143.226 } , +{"lat" : -36.3106, "lng" : 146.843 } , +{"lat" : -37.6602, "lng" : 145.373 } , +{"lat" : -37.7813, "lng" : 145.609 } , +{"lat" : -38.56, "lng" : 146.677 } , +{"lat" : -36.0193, "lng" : 145.995 } , +{"lat" : -37.2104, "lng" : 145.427 } , +{"lat" : -37.8915, "lng" : 145.175 } , +{"lat" : -37.7229, "lng" : 144.893 } , +{"lat" : -37.8193, "lng" : 144.96 } , +{"lat" : -37.5609, "lng" : 143.866 } , +{"lat" : -37.6015, "lng" : 143.842 } , +{"lat" : -36.7573, "lng" : 144.28 } , +{"lat" : -37.7708, "lng" : 144.958 } , +{"lat" : -37.7265, "lng" : 144.892 } , +{"lat" : -37.725, "lng" : 145.058 } , +{"lat" : -37.8035, "lng" : 144.986 } , +{"lat" : -37.8308, "lng" : 144.945 } , +{"lat" : -37.6607, "lng" : 144.884 } , +{"lat" : -37.7379, "lng" : 145.075 } , +{"lat" : -37.8183, "lng" : 145.186 } , +{"lat" : -37.8132, "lng" : 144.958 } , +{"lat" : -37.8134, "lng" : 144.957 } , +{"lat" : -37.8478, "lng" : 144.687 } , +{"lat" : -38.1149, "lng" : 145.173 } , +{"lat" : -38.0315, "lng" : 143.633 } , +{"lat" : -38.0572, "lng" : 147.569 } +] diff --git a/data/src/test/resources/radar_search.json b/data/src/test/resources/radar_search.json new file mode 100644 index 000000000..2dc4f5917 --- /dev/null +++ b/data/src/test/resources/radar_search.json @@ -0,0 +1,202 @@ +[ +{ "lat" : 51.5145160, "lng" : -0.1270060 }, +{ "lat" : 51.5064490, "lng" : -0.1244260, "title" : "Corinthia Hotel London", "snippet": "Whitehall Pl"}, +{ "lat" : 51.5097080, "lng" : -0.1200450, "title" : "Savoy Place", "snippet" : "Covent Garden"}, +{ "lat" : 51.5090680, "lng" : -0.1421420, "title" : "Albemarle St", "snippet": "Mayfair"}, +{ "lat" : 51.4976080, "lng" : -0.1456320, "title" : " Victoria Square", "snippet": " Belgravia" }, +{ "lat" : 51.5046150, "lng" : -0.1473780}, +{ "lat" : 51.5077540, "lng" : -0.1378760, "title" : "Jermyn Street", "snippet": "St. James's" }, +{ "lat" : 51.5074250, "lng" : -0.1323230 , "title" : "Pall Mall", "snippet": "Westminster"}, +{ "lat" : 51.5070030, "lng" : -0.125560 }, +{ "lat" : 51.5061590, "lng" : -0.140280 }, +{ "lat" : 51.5047420, "lng" : -0.1470490 }, +{ "lat" : 51.5126760, "lng" : -0.1189760 }, +{ "lat" : 51.5108480, "lng" : -0.1208480 }, +{ "lat" : 51.5099460, "lng" : -0.1300150 }, +{ "lat" : 51.5076580, "lng" : -0.1424490 }, +{ "lat" : 51.5097160, "lng" : -0.1555350 }, +{ "lat" : 51.5215190, "lng" : -0.1621160 }, +{ "lat" : 51.5177960, "lng" : -0.1438760 }, +{ "lat" : 51.5071840, "lng" : -0.1415940 }, +{ "lat" : 51.5008150, "lng" : -0.1520910 }, +{ "lat" : 51.5179170, "lng" : -0.142740 }, +{ "lat" : 51.50360, "lng" : -0.14980 }, +{ "lat" : 51.512620, "lng" : -0.1476950 }, +{ "lat" : 51.5051890, "lng" : -0.08813600000000001 }, +{ "lat" : 51.4969390, "lng" : -0.1594880 }, +{ "lat" : 51.506020, "lng" : -0.1241340 }, +{ "lat" : 51.5143270, "lng" : -0.1318940 }, +{ "lat" : 51.5070480, "lng" : -0.1521930 }, +{ "lat" : 51.5101640, "lng" : -0.1495920 }, +{ "lat" : 51.5144240, "lng" : -0.1392980 }, +{ "lat" : 51.4816380, "lng" : -0.1489180 }, +{ "lat" : 51.519340, "lng" : -0.1209080 }, +{ "lat" : 51.4982420, "lng" : -0.1435160 }, +{ "lat" : 51.5104310, "lng" : -0.1267850 }, +{ "lat" : 51.504340, "lng" : -0.149940 }, +{ "lat" : 51.5174490, "lng" : -0.1370170 }, +{ "lat" : 51.524370, "lng" : -0.1281460 }, +{ "lat" : 51.5117770, "lng" : -0.1192630 }, +{ "lat" : 51.5026220, "lng" : -0.1527130 }, +{ "lat" : 51.500210, "lng" : -0.1798050 }, +{ "lat" : 51.5293450, "lng" : -0.1260930 }, +{ "lat" : 51.514070, "lng" : -0.0854980 }, +{ "lat" : 51.5154290, "lng" : -0.1571420 }, +{ "lat" : 51.5158980, "lng" : -0.1202010 }, +{ "lat" : 51.5081370, "lng" : -0.1438340 }, +{ "lat" : 51.4990650, "lng" : -0.1343110 }, +{ "lat" : 51.5059290, "lng" : -0.1491020 }, +{ "lat" : 51.5017470, "lng" : -0.1848540 }, +{ "lat" : 51.510820, "lng" : -0.1511460 }, +{ "lat" : 51.5128620, "lng" : -0.192130 }, +{ "lat" : 51.49850, "lng" : -0.1583950 }, +{ "lat" : 51.5094440, "lng" : -0.1362880 }, +{ "lat" : 51.5239250, "lng" : -0.1249180 }, +{ "lat" : 51.4930180, "lng" : -0.159680 }, +{ "lat" : 51.5055380, "lng" : -0.1396880 }, +{ "lat" : 51.4892760, "lng" : -0.180180 }, +{ "lat" : 51.4999860, "lng" : -0.161490 }, +{ "lat" : 51.5183870, "lng" : -0.1350570 }, +{ "lat" : 51.5025980, "lng" : -0.1883030 }, +{ "lat" : 51.4702570, "lng" : -0.1776650 }, +{ "lat" : 51.5184940, "lng" : -0.1452920 }, +{ "lat" : 51.4943450, "lng" : -0.1145180 }, +{ "lat" : 51.5174860, "lng" : -0.1307490 }, +{ "lat" : 51.47570, "lng" : -0.1819760 }, +{ "lat" : 51.4917980, "lng" : -0.161740 }, +{ "lat" : 51.5081480, "lng" : -0.1653260 }, +{ "lat" : 51.5241310, "lng" : -0.18460 }, +{ "lat" : 51.5255790, "lng" : -0.0828410 }, +{ "lat" : 51.4944410, "lng" : -0.1360670 }, +{ "lat" : 51.4924780, "lng" : -0.1483010 }, +{ "lat" : 51.5101220, "lng" : -0.1967860 }, +{ "lat" : 51.4947680, "lng" : -0.1186810 }, +{ "lat" : 51.5108440, "lng" : -0.131580 }, +{ "lat" : 51.4906890, "lng" : -0.1386160 }, +{ "lat" : 51.4991350, "lng" : -0.1125320 }, +{ "lat" : 51.5113950, "lng" : -0.1427780 }, +{ "lat" : 51.4905960, "lng" : -0.1388970 }, +{ "lat" : 51.4908430, "lng" : -0.1440980 }, +{ "lat" : 51.4900210, "lng" : -0.1376870 }, +{ "lat" : 51.5102170, "lng" : -0.1315040 }, +{ "lat" : 51.4903170, "lng" : -0.1377590 }, +{ "lat" : 51.5101040, "lng" : -0.1322540 }, +{ "lat" : 51.5156830, "lng" : -0.1240560 }, +{ "lat" : 51.5116380, "lng" : -0.1384670 }, +{ "lat" : 51.4973190, "lng" : -0.156080 }, +{ "lat" : 51.5180390, "lng" : -0.1497690 }, +{ "lat" : 51.4930840, "lng" : -0.1443660 }, +{ "lat" : 51.498970, "lng" : -0.1062350 }, +{ "lat" : 51.5113380, "lng" : -0.1300990 }, +{ "lat" : 51.4920160, "lng" : -0.1419380 }, +{ "lat" : 51.507070, "lng" : -0.1049530 }, +{ "lat" : 51.5059030, "lng" : -0.1403950 }, +{ "lat" : 51.5160770, "lng" : -0.1353810 }, +{ "lat" : 51.494140, "lng" : -0.1411990 }, +{ "lat" : 51.5225950, "lng" : -0.1253190 }, +{ "lat" : 51.4957540, "lng" : -0.1476890 }, +{ "lat" : 51.5052860, "lng" : -0.150260 }, +{ "lat" : 51.4966970, "lng" : -0.1122920 }, +{ "lat" : 51.5201680, "lng" : -0.1256960 }, +{ "lat" : 51.4929010, "lng" : -0.1572410 }, +{ "lat" : 51.5019440, "lng" : -0.1562350 }, +{ "lat" : 51.489530, "lng" : -0.1366590 }, +{ "lat" : 51.5134410, "lng" : -0.1508490 }, +{ "lat" : 51.5025440, "lng" : -0.118110 }, +{ "lat" : 51.491640, "lng" : -0.1415830 }, +{ "lat" : 51.5115110, "lng" : -0.1183470 }, +{ "lat" : 51.4909510, "lng" : -0.1438210 }, +{ "lat" : 51.5071040, "lng" : -0.1461620 }, +{ "lat" : 51.5186970, "lng" : -0.1355270 }, +{ "lat" : 51.5178830, "lng" : -0.1185430 }, +{ "lat" : 51.492470, "lng" : -0.1455250 }, +{ "lat" : 51.5015740, "lng" : -0.1624430 }, +{ "lat" : 51.5135820, "lng" : -0.1086290 }, +{ "lat" : 51.4920260, "lng" : -0.1418470 }, +{ "lat" : 51.4907580, "lng" : -0.1445780 }, +{ "lat" : 51.4913140, "lng" : -0.144820 }, +{ "lat" : 51.490830, "lng" : -0.1443390 }, +{ "lat" : 51.5139170, "lng" : -0.122090 }, +{ "lat" : 51.4922580, "lng" : -0.1418670 }, +{ "lat" : 51.5160140, "lng" : -0.1578750 }, +{ "lat" : 51.5109460, "lng" : -0.076930 }, +{ "lat" : 51.4930270, "lng" : -0.142960 }, +{ "lat" : 51.49990, "lng" : -0.1781820 }, +{ "lat" : 51.5035550, "lng" : -0.1113130 }, +{ "lat" : 51.4903060, "lng" : -0.1403020 }, +{ "lat" : 51.4936240, "lng" : -0.1500960 }, +{ "lat" : 51.4919830, "lng" : -0.1414270 }, +{ "lat" : 51.5050970, "lng" : -0.104710 }, +{ "lat" : 51.4950920, "lng" : -0.1838080 }, +{ "lat" : 51.5259280, "lng" : -0.1358780 }, +{ "lat" : 51.5057060, "lng" : -0.1221970 }, +{ "lat" : 51.4952410, "lng" : -0.1817070 }, +{ "lat" : 51.4940420, "lng" : -0.1492270 }, +{ "lat" : 51.490370, "lng" : -0.1458720 }, +{ "lat" : 51.5077260, "lng" : -0.1471730 }, +{ "lat" : 51.4651970, "lng" : -0.114940 }, +{ "lat" : 51.5138860, "lng" : -0.1012070 }, +{ "lat" : 51.518720, "lng" : -0.153870 }, +{ "lat" : 51.4913010, "lng" : -0.1428370 }, +{ "lat" : 51.5151350, "lng" : -0.1615710 }, +{ "lat" : 51.5188840, "lng" : -0.1318310 }, +{ "lat" : 51.5020890, "lng" : -0.186790 }, +{ "lat" : 51.4907170, "lng" : -0.1386760 }, +{ "lat" : 51.5182760, "lng" : -0.1582390 }, +{ "lat" : 51.5195190, "lng" : -0.1430740 }, +{ "lat" : 51.5241420, "lng" : -0.1378130 }, +{ "lat" : 51.5077230, "lng" : -0.1279620 }, +{ "lat" : 51.4938750, "lng" : -0.1497910 }, +{ "lat" : 51.5237550, "lng" : -0.1403980 }, +{ "lat" : 51.4933780, "lng" : -0.1500050 }, +{ "lat" : 51.4901580, "lng" : -0.1387650 }, +{ "lat" : 51.5159630, "lng" : -0.1718890 }, +{ "lat" : 51.4908250, "lng" : -0.1450190 }, +{ "lat" : 51.5264680, "lng" : -0.1353710 }, +{ "lat" : 51.5173210, "lng" : -0.156650 }, +{ "lat" : 51.5140620, "lng" : -0.1469230 }, +{ "lat" : 51.5028270, "lng" : -0.0720070 }, +{ "lat" : 51.5206650, "lng" : -0.1003340 }, +{ "lat" : 51.502060, "lng" : -0.1599840 }, +{ "lat" : 51.5277390, "lng" : -0.135320 }, +{ "lat" : 51.5219420, "lng" : -0.1321670 }, +{ "lat" : 51.5152990, "lng" : -0.1600470 }, +{ "lat" : 51.4907870, "lng" : -0.1388240 }, +{ "lat" : 51.4945090, "lng" : -0.174880 }, +{ "lat" : 51.5219720, "lng" : -0.1322210 }, +{ "lat" : 51.4900030, "lng" : -0.0977280 }, +{ "lat" : 51.5222270, "lng" : -0.1425630 }, +{ "lat" : 51.4977620, "lng" : -0.08125599999999999 }, +{ "lat" : 51.5081250, "lng" : -0.07108100000000001 }, +{ "lat" : 51.5179610, "lng" : -0.1529610 }, +{ "lat" : 51.5129390, "lng" : -0.1577410 }, +{ "lat" : 51.4935420, "lng" : -0.1594010 }, +{ "lat" : 51.5157770, "lng" : -0.173090 }, +{ "lat" : 51.4948150, "lng" : -0.1778260 }, +{ "lat" : 51.4964460, "lng" : -0.1557010 }, +{ "lat" : 51.4916430, "lng" : -0.1006060 }, +{ "lat" : 51.5122920, "lng" : -0.1750390 }, +{ "lat" : 51.5202170, "lng" : -0.1025320 }, +{ "lat" : 51.5076930, "lng" : -0.1413620 }, +{ "lat" : 51.491520, "lng" : -0.1426860 }, +{ "lat" : 51.5138990, "lng" : -0.1787940 }, +{ "lat" : 51.5221330, "lng" : -0.1325740 }, +{ "lat" : 51.5135120, "lng" : -0.1631850 }, +{ "lat" : 51.5067690, "lng" : -0.0740210 }, +{ "lat" : 51.5201120, "lng" : -0.1238560 }, +{ "lat" : 51.4925250, "lng" : -0.1451160 }, +{ "lat" : 51.5162150, "lng" : -0.1733980 }, +{ "lat" : 51.5228320, "lng" : -0.155350 }, +{ "lat" : 51.5212420, "lng" : -0.1558640 }, +{ "lat" : 51.5284530, "lng" : -0.1226970 }, +{ "lat" : 51.5223030, "lng" : -0.1325830 }, +{ "lat" : 51.5197430, "lng" : -0.1431740 }, +{ "lat" : 51.5216440, "lng" : -0.1318630 }, +{ "lat" : 51.5172640, "lng" : -0.08090 }, +{ "lat" : 51.474050, "lng" : -0.0895920 }, +{ "lat" : 51.5181490, "lng" : -0.1581730 }, +{ "lat" : 51.5159250, "lng" : -0.1600310 }, +{ "lat" : 51.5243350, "lng" : -0.1165120 }, +{ "lat" : 51.4907560, "lng" : -0.1434930 }, +{ "lat" : 51.4900980, "lng" : -0.1373290 } +] diff --git a/data/src/test/resources/robolectric.properties b/data/src/test/resources/robolectric.properties new file mode 100644 index 000000000..89a6c8b4c --- /dev/null +++ b/data/src/test/resources/robolectric.properties @@ -0,0 +1 @@ +sdk=28 \ No newline at end of file diff --git a/data/src/test/resources/south_london_line_geojson.json b/data/src/test/resources/south_london_line_geojson.json new file mode 100644 index 000000000..4a1a0f359 --- /dev/null +++ b/data/src/test/resources/south_london_line_geojson.json @@ -0,0 +1,40 @@ +{ + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "properties": { + "title": "South London Line GeoJSON" + }, + "geometry": { + "type": "LineString", + "coordinates": [ + [ + -0.2245330810546875, + 51.402204296190476 + ], + [ + -0.18058776855468747, + 51.37520943448463 + ], + [ + -0.1174163818359375, + 51.36149165915505 + ], + [ + -0.05767822265625, + 51.37178037591737 + ], + [ + -0.0336456298828125, + 51.40820099168391 + ], + [ + -0.03570556640625, + 51.45229536554372 + ] + ] + } + } + ] +} \ No newline at end of file diff --git a/data/src/test/resources/south_london_square_geojson.json b/data/src/test/resources/south_london_square_geojson.json new file mode 100644 index 000000000..021e97890 --- /dev/null +++ b/data/src/test/resources/south_london_square_geojson.json @@ -0,0 +1,38 @@ +{ + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "properties": { + "title": "South London Polygon GeoJSON" + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + -0.196380615234375, + 51.41034247807634 + ], + [ + -0.09716033935546874, + 51.41034247807634 + ], + [ + -0.09716033935546874, + 51.44159675846268 + ], + [ + -0.196380615234375, + 51.44159675846268 + ], + [ + -0.196380615234375, + 51.41034247807634 + ] + ] + ] + } + } + ] +} \ No newline at end of file diff --git a/data/src/test/resources/top_peaks.kml b/data/src/test/resources/top_peaks.kml new file mode 100644 index 000000000..0162202cc --- /dev/null +++ b/data/src/test/resources/top_peaks.kml @@ -0,0 +1,1034 @@ + + + + + + + 14ers (14,000 ft and above) + + Mount Elbert ☝️ + + -106.4454,39.1178,4401.2 + + #14erStyle + + + Mount Massive + + -106.4757,39.1875,4398.0 + + #14erStyle + + + Mount Harvard 🎓 + + -106.3207,38.9244,4395.6 + + #14erStyle + + + Blanca Peak + + -105.4856,37.5775,4374.0 + + #14erStyle + + + La Plata Peak + + -106.4729,39.0294,4372.0 + + #14erStyle + + + Uncompahgre Peak ⭐ + + -107.4621,38.0717,4365.0 + + #14erStyle + + + Crestone Peak + + -105.5855,37.9669,4359.0 + + #14erStyle + + + Mount Lincoln + + -106.1116,39.3515,4356.5 + + #14erStyle + + + Castle Peak 🏰 + + -106.8614,39.0097,4352.2 + + #14erStyle + + + Grays Peak 🐺 + + -105.8176,39.6339,4352.0 + + #14erStyle + + + Mount Antero 💎 + + -106.2462,38.6741,4351.4 + + #14erStyle + + + Torreys Peak 🗼 + + -105.8212,39.6428,4351.0 + + #14erStyle + + + Mount Blue Sky 🟦 + + -105.6438,39.5883,4350.0 + + #14erStyle + + + Quandary Peak 🤔 + + -106.1064,39.3973,4349.9 + + #14erStyle + + + Longs Peak 📏 + + -105.6151,40.255,4346.0 + + #14erStyle + + + Mount Wilson 🏐 + + -107.9916,37.8391,4344.0 + + #14erStyle + + + Mount Shavano 🏹 + + -106.2393,38.6192,4337.7 + + #14erStyle + + + Mount Princeton 🎓 + + -106.2424,38.7492,4329.3 + + #14erStyle + + + Mount Belford 🔔 + + -106.3607,38.9607,4329.1 + + #14erStyle + + + Crestone Needle 💉 + + -105.5766,37.9647,4329.0 + + #14erStyle + + + Mount Yale 🎓 + + -106.3138,38.8442,4328.2 + + #14erStyle + + + Mount Bross 👨‍🎨 + + -106.1077,39.3354,4321.6 + + #14erStyle + + + Kit Carson Mountain 🤠 + + -105.6026,37.9797,4319.0 + + #14erStyle + + + Maroon Peak 🟫 + + -106.989,39.0708,4317.0 + + #14erStyle + + + Tabeguache Peak 🌮 + + -106.2509,38.6255,4316.7 + + #14erStyle + + + Mount Oxford 🎓 + + -106.3388,38.9648,4315.9 + + #14erStyle + + + Mount Sneffels 🤧 + + -107.7923,38.0038,4315.4 + + #14erStyle + + + Mount Democrat 🇺🇸 + + -106.14,39.3396,4314.5 + + #14erStyle + + + Capitol Peak 🏛️ + + -107.0829,39.1503,4309.0 + + #14erStyle + + + Pikes Peak 🏔️ + + -105.0442,38.8405,4302.31 + + #14erStyle + + + Snowmass Mountain ❄️ + + -107.0665,39.1188,4297.3 + + #14erStyle + + + Windom Peak 🌬️ + + -107.5919,37.6212,4296.0 + + #14erStyle + + + Mount Eolus 🌬️ + + -107.6227,37.6218,4295.0 + + #14erStyle + + + Challenger Point 🚀 + + -105.6066,37.9804,4294.0 + + #14erStyle + + + Mount Columbia 🇨🇴 + + -106.2975,38.9039,4290.8 + + #14erStyle + + + Missouri Mountain 🚢 + + -106.3785,38.9476,4289.8 + + #14erStyle + + + Humboldt Peak 🐧 + + -105.5552,37.9762,4289.0 + + #14erStyle + + + Mount Bierstadt 🍺 + + -105.6688,39.5826,4287.0 + + #14erStyle + + + Sunlight Peak ☀️ + + -107.5959,37.6274,4287.0 + + #14erStyle + + + Handies Peak 🖐️ + + -107.5044,37.913,4284.8 + + #14erStyle + + + Culebra Peak 🐍 + + -105.1858,37.1224,4283.0 + + #14erStyle + + + Ellingwood Point 👉 + + -105.4927,37.5826,4282.0 + + #14erStyle + + + Mount Lindsey 💃 + + -105.4449,37.5837,4282.0 + + #14erStyle + + + Little Bear Peak 🐻 + + -105.4972,37.5666,4280.0 + + #14erStyle + + + Mount Sherman 🎖️ + + -106.1699,39.225,4280.0 + + #14erStyle + + + Redcloud Peak ☁️ + + -107.4219,37.941,4280.0 + + #14erStyle + + + Pyramid Peak 🔺 + + -106.9502,39.0717,4274.7 + + #14erStyle + + + Wilson Peak 🏐 + + -107.9847,37.8603,4274.0 + + #14erStyle + + + Wetterhorn Peak 📯 + + -107.5109,38.0607,4274.0 + + #14erStyle + + + San Luis Peak ⚜️ + + -106.9313,37.9868,4273.8 + + #14erStyle + + + Mount of the Holy Cross ✝️ + + -106.4817,39.4668,4270.5 + + #14erStyle + + + Huron Peak 🏞️ + + -106.4381,38.9455,4270.2 + + #14erStyle + + + Sunshine Peak ☀️ + + -107.4256,37.9228,4269.0 + + #14erStyle + + + + Other Peaks + + Grizzly Peak 🐻 + + -106.5976,39.0425,4265.6 + + #shortPeakStyle + + + Mount Ouray + + -106.2247,38.4227,4255.4 + + #shortPeakStyle + + + Vermilion Peak + + -107.8285,37.7993,4237.0 + + #shortPeakStyle + + + Mount Silverheels + + -106.0054,39.3394,4215.0 + + #shortPeakStyle + + + Rio Grande Pyramid + + -107.3924,37.6797,4214.4 + + #shortPeakStyle + + + Bald Mountain 👨‍🦲 + + -105.9705,39.4448,4173.0 + + #shortPeakStyle + + + Mount Oso 🐻 + + -107.4936,37.607,4173.0 + + #shortPeakStyle + + + Mount Jackson 🕺 + + -106.5367,39.4853,4168.5 + + #shortPeakStyle + + + Bard Peak ✍️ + + -105.8044,39.7204,4159.0 + + #shortPeakStyle + + + West Spanish Peak 🇪🇸 + + -104.9934,37.3756,4155.0 + + #shortPeakStyle + + + Mount Powell 💪 + + -106.3407,39.7601,4141.0 + + #shortPeakStyle + + + Hagues Peak ☁️ + + -105.6464,40.4845,4137.0 + + #shortPeakStyle + + + Tower Mountain 🗼 + + -107.623,37.8573,4132.0 + + #shortPeakStyle + + + Treasure Mountain 💎 + + -107.1228,39.0244,4125.0 + + #shortPeakStyle + + + North Arapaho Peak 🫎 + + -105.6504,40.0265,4117.0 + + #shortPeakStyle + + + Parry Peak 🤺 + + -105.7132,39.8381,4083.0 + + #shortPeakStyle + + + Bill Williams Peak 🤠 + + -106.6102,39.1806,4081.0 + + #shortPeakStyle + + + Sultan Mountain 👑 + + -107.7038,37.7859,4076.0 + + #shortPeakStyle + + + Mount Herard 🗣️ + + -105.4949,37.8492,4068.0 + + #shortPeakStyle + + + West Buffalo Peak 🐃 + + -106.1249,38.9917,4064.0 + + #shortPeakStyle + + + Summit Peak 🏆 + + -106.6968,37.3506,4056.2 + + #shortPeakStyle + + + Middle Peak 🎯 + + -108.1082,37.8536,4056.0 + + #shortPeakStyle + + + Antora Peak 🦌 + + -106.218,38.325,4046.0 + + #shortPeakStyle + + + Henry Mountain 👑 + + -106.6211,38.6856,4042.0 + + #shortPeakStyle + + + Hesperus Mountain 🌟 + + -108.089,37.4451,4035.0 + + #shortPeakStyle + + + Jacque Peak 👨‍🍳 + + -106.197,39.4549,4027.0 + + #shortPeakStyle + + + Bennett Peak 👨‍🔬 + + -106.4343,37.4833,4026.0 + + #shortPeakStyle + + + Conejos Peak 🐇 + + -106.5709,37.2887,4017.0 + + #shortPeakStyle + + + Twilight Peak 🌆 + + -107.727,37.663,4012.0 + + #shortPeakStyle + + + South River Peak 🏞️ + + -106.9815,37.5741,4009.4 + + #shortPeakStyle + + + Bushnell Peak 🔭 + + -105.8892,38.3412,3995.8 + + #shortPeakStyle + + + West Elk Peak 🦌 + + -107.1994,38.7179,3975.2 + + #shortPeakStyle + + + Mount Centennial 💯 + + -107.2446,37.6062,3967.0 + + #shortPeakStyle + + + Clark Peak 🦸 + + -105.93,40.6068,3948.4 + + #shortPeakStyle + + + Mount Richthofen ✈️ + + -105.8945,40.4695,3946.0 + + #shortPeakStyle + + + Chair Mountain 🪑 + + -107.2822,39.0581,3879.1 + + #shortPeakStyle + + + Mount Gunnison 🔫 + + -107.3826,38.8121,3878.7 + + #shortPeakStyle + + + East Spanish Peak 🇪🇸 + + -104.9201,37.3934,3867.0 + + #shortPeakStyle + + + Gothic Mountain 🦇 + + -107.0107,38.9562,3850.0 + + #shortPeakStyle + + + Lone Cone 🍦 + + -108.2556,37.888,3846.1 + + #shortPeakStyle + + + Graham Peak 🥣 + + -107.3761,37.4972,3821.1 + + #shortPeakStyle + + + Whetstone Mountain 🔪 + + -106.9799,38.8223,3818.1 + + #shortPeakStyle + + + Specimen Mountain 🔬 + + -105.8081,40.4449,3808.0 + + #shortPeakStyle + + + East Beckwith Mountain 🌄 + + -107.2233,38.8464,3792.1 + + #shortPeakStyle + + + Knobby Crest 🪢 + + -105.605,39.3681,3790.0 + + #shortPeakStyle + + + Bison Mountain 🦬 + + -105.4978,39.2384,3789.4 + + #shortPeakStyle + + + Anthracite Range High Point 🔥 + + -107.1445,38.8145,3777.8 + + #shortPeakStyle + + + Matchless Mountain 🔥 + + -106.6451,38.834,3776.0 + + #shortPeakStyle + + + Flat Top Mountain 🔝 + + -107.0833,40.0147,3767.7 + + #shortPeakStyle + + + Greenhorn Mountain 🌿 + + -105.0133,37.8815,3765.0 + + #shortPeakStyle + + + Elliott Mountain 🌊 + + -108.058,37.7344,3763.0 + + #shortPeakStyle + + + Parkview Mountain 🏞️ + + -106.1363,40.3303,3749.4 + + #shortPeakStyle + + + Cornwall Mountain 🌽 + + -106.492,37.3811,3746.0 + + #shortPeakStyle + + + Mount Zirkel ⭕ + + -106.6631,40.8313,3714.0 + + #shortPeakStyle + + + Crested Butte 🧈 + + -106.9436,38.8835,3709.0 + + #shortPeakStyle + + + Sawtooth Mountain 🦷 + + -106.867,38.274,3704.2 + + #shortPeakStyle + + + Park Cone 🍦 + + -106.6028,38.7967,3690.0 + + #shortPeakStyle + + + Carbon Peak ⚫ + + -107.0431,38.7943,3684.3 + + #shortPeakStyle + + + Mount Guero 💡 + + -107.3861,38.7196,3675.4 + + #shortPeakStyle + + + Red Table Mountain 🟥 + + -106.7712,39.4181,3670.7 + + #shortPeakStyle + + + Chalk Benchmark 🖍️ + + -106.75,37.1418,3669.3 + + #shortPeakStyle + + + Mount Zwischen ↔️ + + -105.4554,37.7913,3661.0 + + #shortPeakStyle + + + Little Cone 🍦 + + -108.0908,37.9275,3654.0 + + #shortPeakStyle + + + Huntsman Ridge Peak 🏹 + + -107.3668,39.192,3614.0 + + #shortPeakStyle + + + Sheep Mountain 🐏 + + -106.2658,40.361,3604.2 + + #shortPeakStyle + + + Waugh Mountain 😢 + + -105.6955,38.6022,3571.0 + + #shortPeakStyle + + + Coal Mountain ⚫ + + -107.4837,38.787,3569.0 + + #shortPeakStyle + + + Williams Peak 🎾 + + -106.1854,39.8552,3541.8 + + #shortPeakStyle + + + Puma Peak 🐆 + + -105.5815,39.1572,3528.0 + + #shortPeakStyle + + + Mount Mestas ✨ + + -105.1474,37.583,3528.0 + + #shortPeakStyle + + + Thirtynine Mile Mountain 📏 + + -105.5553,38.8324,3521.0 + + #shortPeakStyle + + + Tomichi Dome ⛰️ + + -106.5291,38.4849,3496.0 + + #shortPeakStyle + + + Blair Mountain 🧙‍♀️ + + -107.4176,39.7943,3495.0 + + #shortPeakStyle + + + Twin Sisters Peaks 👯‍♀️ + + -105.5175,40.2886,3485.0 + + #shortPeakStyle + + + Elk Mountain 🦌 + + -106.1285,40.1619,3482.1 + + #shortPeakStyle + + + Iron Mountain 🔩 + + -105.2538,37.6375,3480.0 + + #shortPeakStyle + + + Marcellina Mountain 💃 + + -107.2438,38.9299,3461.0 + + #shortPeakStyle + + + Crater Peak 🌋 + + -107.6628,39.0396,3454.2 + + #shortPeakStyle + + + Hardscrabble Mountain ⛏️ + + -106.8021,39.5171,3405.0 + + #shortPeakStyle + + + Cochetopa Dome ⛰️ + + -106.7147,38.2267,3395.0 + + #shortPeakStyle + + + North Mamm Peak 🐘 + + -107.866,39.3865,3391.3 + + #shortPeakStyle + + + Laramie Mountains 💻 + + -105.7162,40.7704,3360.0 + + #shortPeakStyle + + + Sand Mountain North ⌛ + + -107.0575,40.7636,3317.0 + + #shortPeakStyle + + + Black Mountain ⚫ + + -107.3691,40.7835,3312.0 + + #shortPeakStyle + + + Sleepy Cat Peak 😴🐈 + + -107.5338,40.1275,3308.0 + + #shortPeakStyle + + + Spruce Mountain 🌲 + + -107.522,39.1973,3303.5 + + #shortPeakStyle + + + Green Mountain 🌲 + + -105.3001,39.3053,3178.3 + + #shortPeakStyle + + + Columbus Mountain 🗺️ + + -107.1921,40.8799,3126.0 + + #shortPeakStyle + + + Ute Peak 🏞️ + + -108.7787,37.2841,3043.0 + + #shortPeakStyle + + + Horse Mountain 🐴 + + -107.2864,37.308,3033.0 + + #shortPeakStyle + + + + diff --git a/data/src/test/resources/usa.json b/data/src/test/resources/usa.json new file mode 100644 index 000000000..5d97bb928 --- /dev/null +++ b/data/src/test/resources/usa.json @@ -0,0 +1,311 @@ +{ + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "properties": { + "stroke": "#f53b3b", + "stroke-width": 2, + "stroke-opacity": 1, + "fill": "#555555", + "fill-opacity": 0.5 + }, + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + -124.45312499999999, + 48.22467264956519 + ], + [ + -123.57421875, + 39.436192999314095 + ], + [ + -120.32226562500001, + 34.45221847282654 + ], + [ + -116.89453125, + 32.54681317351514 + ], + [ + -114.873046875, + 32.69486597787505 + ], + [ + -110.56640625, + 31.27855085894653 + ], + [ + -108.720703125, + 31.50362930577303 + ], + [ + -106.5234375, + 31.653381399664 + ], + [ + -104.853515625, + 30.221101852485987 + ], + [ + -103.095703125, + 29.152161283318915 + ], + [ + -102.65625, + 29.6880527498568 + ], + [ + -101.689453125, + 29.76437737516313 + ], + [ + -97.294921875, + 25.799891182088334 + ], + [ + -96.94335937499999, + 28.304380682962783 + ], + [ + -93.779296875, + 29.458731185355344 + ], + [ + -89.736328125, + 29.305561325527698 + ], + [ + -88.76953125, + 30.372875188118016 + ], + [ + -83.84765625, + 29.916852233070173 + ], + [ + -81.298828125, + 25.3241665257384 + ], + [ + -80.068359375, + 26.43122806450644 + ], + [ + -82.001953125, + 30.977609093348686 + ], + [ + -75.498046875, + 35.817813158696616 + ], + [ + -73.564453125, + 40.84706035607122 + ], + [ + -66.97265625, + 44.5278427984555 + ], + [ + -68.5546875, + 47.21956811231547 + ], + [ + -79.189453125, + 43.13306116240612 + ], + [ + -83.3203125, + 41.83682786072714 + ], + [ + -82.353515625, + 44.902577996288876 + ], + [ + -88.24218749999999, + 47.81315451752768 + ], + [ + -95.09765625, + 48.922499263758255 + ], + [ + -124.45312499999999, + 48.22467264956519 + ] + ] + ], + [ + [ + [ + -141.064453125, + 69.62651016802958 + ], + [ + -152.9296875, + 70.64176873584621 + ], + [ + -157.32421875, + 70.90226826757711 + ], + [ + -166.376953125, + 68.43151284537514 + ], + [ + -160.6640625, + 66.30220547599842 + ], + [ + -164.53125, + 66.40795547978848 + ], + [ + -168.22265625, + 65.62202261510642 + ], + [ + -165.322265625, + 64.35893097894458 + ], + [ + -161.19140625, + 64.66151739623564 + ], + [ + -161.19140625, + 63.35212928507874 + ], + [ + -164.53125, + 63.11463763252091 + ], + [ + -166.11328125, + 61.60639637138628 + ], + [ + -164.53125, + 60.71619779357714 + ], + [ + -167.080078125, + 60.108670463036 + ], + [ + -162.24609375, + 59.7563950493563 + ], + [ + -161.806640625, + 58.63121664342478 + ], + [ + -158.115234375, + 58.6769376725869 + ], + [ + -168.3984375, + 52.908902047770255 + ], + [ + -157.1484375, + 56.992882804633986 + ], + [ + -153.80859375, + 56.70450561416937 + ], + [ + -151.962890625, + 57.938183012205315 + ], + [ + -148.7109375, + 60.19615576604439 + ], + [ + -145.810546875, + 60.326947742998414 + ], + [ + -140.9765625, + 60.1524422143808 + ], + [ + -141.064453125, + 69.62651016802958 + ] + ] + ], + [ + [ + [ + -160.20263671875, + 21.80030805097259 + ], + [ + -159.63134765625, + 22.248428704383624 + ], + [ + -159.30175781249997, + 22.14670778001263 + ], + [ + -156.005859375, + 20.715015145512087 + ], + [ + -154.75341796875, + 19.518375478601566 + ], + [ + -155.76416015625, + 18.93746442964186 + ], + [ + -156.02783203124997, + 19.766703551716976 + ], + [ + -155.76416015625, + 20.076570104545173 + ], + [ + -156.4892578125, + 20.591652120829167 + ], + [ + -156.99462890624997, + 20.756113874762082 + ], + [ + -158.115234375, + 21.37124437061831 + ], + [ + -159.45556640625, + 21.820707853875017 + ], + [ + -160.20263671875, + 21.80030805097259 + ] + ] + ] + ] + }, + "properties": { + "title": "MultiPolygon United States of America" + } + } + ] +} diff --git a/demo/build.gradle.kts b/demo/build.gradle.kts index 8148ff11c..852e823f2 100644 --- a/demo/build.gradle.kts +++ b/demo/build.gradle.kts @@ -1,7 +1,5 @@ -import org.jetbrains.kotlin.gradle.dsl.JvmTarget - /** - * Copyright 2025 Google LLC + * 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. @@ -16,13 +14,56 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget * limitations under the License. */ +import org.gradle.api.GradleException +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + plugins { id("com.android.application") id("com.google.android.libraries.mapsplatform.secrets-gradle-plugin") id("kotlin-android") + id("org.jetbrains.kotlin.plugin.serialization") version "2.2.21" alias(libs.plugins.compose.compiler) } +val secretsFile = rootProject.file("secrets.properties") +if (!secretsFile.exists()) { + val taskNames = gradle.startParameter.taskNames + // 1. Allow IDE Sync (which runs with empty tasks) + if (taskNames.isEmpty()) { + println("⚠️ Warning: secrets.properties missing. IDE sync will succeed, but builds will fail.") + } else { + // 2. Normalize task names to handle ":demo:assembleDebug" -> "assembleDebug" + val simpleTaskNames = taskNames.map { it.substringAfterLast(":") } + + // 3. Identify if the user is explicitly asking for an app build + val isExplicitBuild = simpleTaskNames.any { + it == "build" || + it.startsWith("assemble") || + it.startsWith("install") || + it.startsWith("bundle") + } + + // 4. Identify if the user is running tests/lint + // (We check for "Test" to allow tasks like 'assembleAndroidTest' to proceed if desired) + val isTestOrLint = simpleTaskNames.any { + val lower = it.lowercase() + lower.contains("test") || lower.contains("lint") + } + + // 5. Fail ONLY if it's a build task that isn't also a test task + if (isExplicitBuild && !isTestOrLint) { + throw GradleException("Build Blocked: 'secrets.properties' is missing.\n\n" + + "🛑 To build the demo app, you must create the 'secrets.properties' file in the root directory:\n\n" + + " MAPS_API_KEY=AIza...\n" + + " PLACES_API_KEY=AIza... # Only needed for certain demos (e.g., HeatmapsPlacesDemoActivity.java)\n" + + " MAP_ID=...\n\n" + + "Or run unit tests only: ./gradlew test") + } else { + println("⚠️ Warning: secrets.properties missing. Building/Running the demo app will fail, but testing is allowed.") + } + } +} + android { lint { sarifOutput = layout.buildDirectory.file("reports/lint-results.sarif").get().asFile @@ -35,6 +76,7 @@ android { targetSdk = libs.versions.targetSdk.get().toInt() versionCode = 1 versionName = "1.0" + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } buildTypes { @@ -62,10 +104,21 @@ android { namespace = "com.google.maps.android.utils.demo" } +configurations.all { + resolutionStrategy { + force(libs.kotlinx.coroutines.core) + force(libs.kotlinx.coroutines.android) + force(libs.kotlinx.serialization.json) + } +} + // [START maps_android_utils_install_snippet] dependencies { // [START_EXCLUDE silent] - implementation(project(":library")) + implementation(project(":clustering")) + implementation(project(":heatmaps")) + implementation(project(":ui")) + implementation(project(":data")) implementation(libs.appcompat) implementation(libs.lifecycle.extensions) @@ -75,10 +128,22 @@ dependencies { implementation(libs.kotlinx.coroutines.core) implementation(libs.material) implementation(libs.core.ktx) + implementation(libs.ktor.client.core) + implementation(libs.ktor.client.cio) + implementation(libs.ktor.client.content.negotiation) + implementation(libs.ktor.serialization.kotlinx.json) + implementation(libs.kotlinx.serialization.json) testImplementation(libs.junit) testImplementation(libs.truth) + androidTestImplementation(libs.androidx.test.ext.junit) + androidTestImplementation(libs.espresso.core) + androidTestImplementation(libs.truth) + + implementation(project(":visual-testing")) + implementation(libs.uiautomator) + implementation(platform(libs.compose.bom)) implementation(libs.activity.compose) implementation(libs.ui) diff --git a/demo/src/androidTest/java/com/google/maps/android/utils/demo/BaseVisualTest.kt b/demo/src/androidTest/java/com/google/maps/android/utils/demo/BaseVisualTest.kt new file mode 100644 index 000000000..b3cad2dee --- /dev/null +++ b/demo/src/androidTest/java/com/google/maps/android/utils/demo/BaseVisualTest.kt @@ -0,0 +1,70 @@ +/* + * 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.utils.demo + +import android.app.Instrumentation +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.UiDevice +import com.google.maps.android.visualtesting.GeminiVisualTestHelper +import org.junit.Assert.assertTrue +import java.io.File + +abstract class BaseVisualTest { + protected val instrumentation: Instrumentation = InstrumentationRegistry.getInstrumentation() + protected val uiDevice = UiDevice.getInstance(instrumentation) + protected val context: Context = instrumentation.targetContext + protected val helper = GeminiVisualTestHelper() + + protected val geminiApiKey: String by lazy { + val key = BuildConfig.GEMINI_API_KEY + assertTrue( + "GEMINI_API_KEY is not set in secrets.properties. Please add GEMINI_API_KEY=YOUR_API_KEY to your secrets.properties file.", + key != "YOUR_GEMINI_API_KEY", + ) + key + } + + protected fun captureScreenshot(filename: String = "screenshot_${System.currentTimeMillis()}.png"): Bitmap { + val screenshotFile = File(context.cacheDir, filename) + val screenshotTaken = uiDevice.takeScreenshot(screenshotFile) + assertTrue("Failed to take screenshot: $filename", screenshotTaken) + + val bitmap = BitmapFactory.decodeFile(screenshotFile.absolutePath) + assertTrue("Failed to decode screenshot file: $filename", bitmap != null) + return bitmap + } + + /** + * Waits for the map to render. + * Since MapView content (tiles, markers) is rendered on a GL surface and not exposed as + * accessibility nodes, we cannot rely on UiAutomator looking for text/markers. + * We use a stable delay to ensure rendering is complete. + */ + protected fun waitForMapRendering(seconds: Long = 3) { + // Optional: Wait for map container if possible + // uiDevice.wait(Until.hasObject(By.descContains("Google Map")), 5000) + + try { + java.util.concurrent.TimeUnit.SECONDS + .sleep(seconds) + } catch (e: InterruptedException) { + e.printStackTrace() + } + } +} diff --git a/demo/src/androidTest/java/com/google/maps/android/utils/demo/ClusteringVisualTest.kt b/demo/src/androidTest/java/com/google/maps/android/utils/demo/ClusteringVisualTest.kt new file mode 100644 index 000000000..aa5827391 --- /dev/null +++ b/demo/src/androidTest/java/com/google/maps/android/utils/demo/ClusteringVisualTest.kt @@ -0,0 +1,113 @@ +/* + * 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.utils.demo + +import android.util.Log +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.uiautomator.By +import androidx.test.uiautomator.Until +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.io.ByteArrayOutputStream +import java.io.File +import java.util.concurrent.TimeUnit + +@RunWith(AndroidJUnit4::class) +class ClusteringVisualTest : BaseVisualTest() { + @Before + fun setup() { + // Launch the app + val intent = context.packageManager.getLaunchIntentForPackage(context.packageName) + context.startActivity(intent) + uiDevice.wait(Until.hasObject(By.pkg(context.packageName).depth(0)), 10000) + } + + @Test + fun naturalLanguageClickTest() = + runBlocking { + // Use a natural language prompt to perform the click action + helper.performActionFromPrompt("Click the CLUSTERING button", uiDevice, geminiApiKey) + + // Wait for the clustering screen to load and map to render + TimeUnit.SECONDS.sleep(5) + + // Capture a screenshot to verify the result of the action + val screenshotBitmap = captureScreenshot("natural_lang_click_screenshot.png") + + // --- Perform a visual assertion on the new screen --- + val prompt = "Does this image show a map with several markers clustered together? Answer only YES or NO." + val geminiResponse = helper.analyzeImage(screenshotBitmap, prompt, geminiApiKey) + + println("Gemini's analysis after natural language click: $geminiResponse") + assertTrue( + "Visual verification failed. Gemini did not confirm the presence of a map with clusters.", + geminiResponse?.contains("YES", ignoreCase = true) == true, + ) + } + + @Test + fun verifyClusteringScreenContent() = + runBlocking { + // Wait for the app to load and find the "Clustering" button + val clusteringButton = uiDevice.wait(Until.findObject(By.text("CLUSTERING")), 10000) + + if (clusteringButton == null) { + // Dump window hierarchy to logcat for debugging + val outputStream = ByteArrayOutputStream() + uiDevice.dumpWindowHierarchy(outputStream) + Log.e("ClusteringVisualTest", "Could not find clustering button. UI Hierarchy:\n${outputStream.toString("UTF-8")}") + + // Take a screenshot for visual inspection + val screenshotFile = File(context.cacheDir, "test_failure_screenshot.png") + uiDevice.takeScreenshot(screenshotFile) + Log.e("ClusteringVisualTest", "Debug screenshot saved to device cache.") + } + + assertNotNull("Clustering button not found. Check logcat for UI hierarchy dump and debug screenshot.", clusteringButton) + clusteringButton.click() + + // Wait for the clustering screen to load and map to render + TimeUnit.SECONDS.sleep(5) + + // Capture a screenshot + val screenshotBitmap = captureScreenshot("clustering_screenshot.png") + + // --- STEP 2: Define your verification prompt --- + val prompt = + """ + Please act as a UI tester and analyze this screenshot to verify the application is rendering correctly. Check the image against the following three acceptance criteria: + Geographic Bounds: Confirm the map is centered on North London and Hertfordshire, specifically showing landmarks like St Albans, Enfield, and the M25 ring road. + Primary Cluster: Verify the presence of either a Red cluster marker labeled '200+' or a Green cluster marker labeled '100+' located centrally over the North London area. + Secondary Cluster: Verify the presence of a Blue cluster marker labeled '10+' located to the southwest of the green marker. + If all three elements are present and legible, just confirm that the visual test has PASSED. If any element is missing or incorrect, please detail the discrepancy. + """.trimIndent() + + // --- STEP 3: Analyze the image using Gemini --- + val geminiResponse = helper.analyzeImage(screenshotBitmap, prompt, geminiApiKey) + + // --- STEP 4: Assert on Gemini's response --- + println("Gemini's analysis: $geminiResponse") + // Example assertion: Check if Gemini confirms the presence of clusters + assertTrue( + "PASSED", + geminiResponse!!.contains("PASSED", ignoreCase = true), + ) + } +} diff --git a/demo/src/androidTest/java/com/google/maps/android/utils/demo/IconGeneratorVisualTest.kt b/demo/src/androidTest/java/com/google/maps/android/utils/demo/IconGeneratorVisualTest.kt new file mode 100644 index 000000000..32681407d --- /dev/null +++ b/demo/src/androidTest/java/com/google/maps/android/utils/demo/IconGeneratorVisualTest.kt @@ -0,0 +1,105 @@ +/* + * 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.utils.demo + +import android.content.Intent +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.uiautomator.By +import androidx.test.uiautomator.Until +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +private val prompt = + """ + Task: Act as a Visual QA system to verify an Android application screenshot. Compare the + provided image against the following technical specifications. Your goal is to determine if the + rendering is correct. + + 1. Map Verification + Confirm that a map is visible in the background. The map should be centered on the Sydney, + Australia area. Major landmarks or labels like Sydney, Hornsby, and Cronulla should be visible. + Be flexible with minor labels as they may change, but the general geographic layout must match the reference. + 2. Marker Content and Styling + There must be exactly six markers on the screen. Verify each of the following: + + * Marker A: Text is "Default". Background is white. Orientation is standard horizontal. + * Marker B: Text is "Custom color". Background is cyan/light blue. Orientation is standard horizontal. + * Marker C: Text is "Rotated 90 degrees". Background is red. Both the bubble and the text are rotated 90 degrees clockwise. + * Marker D: Text is "Rotate=90, ContentRotate=-90". Background is purple. The bubble is rotated 90 degrees clockwise, but the text inside remains horizontal. + * Marker E: Text is "ContentRotate=90". Background is green. The bubble is horizontal, but the text inside is rotated 90 degrees clockwise. + * Marker F: Text is "Mixing different fonts". Background is orange. The word "Mixing" must be in italics. The words "different fonts" must be in bold. + + 3. Spatial Grid Verification + Verify that the markers are positioned correctly relative to one another in a rough grid layout: + + * Top-Left: The Orange marker ("Mixing different fonts"). + * Top-Right: The Green marker ("ContentRotate=90"). + * Middle-Left: The Red marker ("Rotated 90 degrees"). + * Middle-Center: The White marker ("Default"). + * Bottom-Left: The Purple marker ("Rotate=90, ContentRotate=-90"). + * Bottom-Right: The Cyan marker ("Custom color"). + + 4. Output Requirements + Provide your response in the following format: + + * Status: [PASSED or FAILED] + * Justification: Provide a concise explanation for the classification. If FAILED, list the + specific markers or map elements that do not meet the criteria. + """.trimIndent() + +@RunWith(AndroidJUnit4::class) +class IconGeneratorVisualTest : BaseVisualTest() { + @Before + fun setup() { + // Launch the app + val intent = context.packageManager.getLaunchIntentForPackage(context.packageName) + context.startActivity(intent) + uiDevice.wait(Until.hasObject(By.pkg(context.packageName).depth(0)), 10000) + } + + @Test + fun testIconMarkers() = + runBlocking { + // Launch IconGeneratorDemoActivity directly + val intent = + Intent(context, IconGeneratorDemoActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + } + context.startActivity(intent) + uiDevice.wait(Until.hasObject(By.pkg(context.packageName).depth(0)), 10000) + + // Wait for map rendering (Markers are bitmaps, so we can't search for text "Default") + waitForMapRendering(5) + + // Capture a screenshot + val screenshotBitmap = captureScreenshot("clustering_screenshot.png") + + // --- STEP 3: Analyze the image using Gemini --- + val geminiResponse = helper.analyzeImage(screenshotBitmap, prompt, geminiApiKey) + + // --- STEP 4: Assert on Gemini's response --- + println("Gemini's analysis: $geminiResponse") + + requireNotNull(geminiResponse) { "Gemini response was null, check API key or network connection." } + assertTrue( + "Gemini validation failed. Response: $geminiResponse", + geminiResponse.contains("PASSED", ignoreCase = true), + ) + } +} diff --git a/demo/src/androidTest/java/com/google/maps/android/utils/demo/KmlVisualTest.kt b/demo/src/androidTest/java/com/google/maps/android/utils/demo/KmlVisualTest.kt new file mode 100644 index 000000000..e33b035a4 --- /dev/null +++ b/demo/src/androidTest/java/com/google/maps/android/utils/demo/KmlVisualTest.kt @@ -0,0 +1,76 @@ +/* + * 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.utils.demo + +import android.content.Intent +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.uiautomator.By +import androidx.test.uiautomator.Until +import com.google.common.truth.Truth.assertWithMessage +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import org.junit.Test +import org.junit.runner.RunWith +import kotlin.time.Duration.Companion.seconds + +@RunWith(AndroidJUnit4::class) +class KmlVisualTest : BaseVisualTest() { + @Test + fun verifyKmlLayerOverlay() = + runBlocking { + // Launch KmlDemoActivity directly + val intent = Intent(context, KmlDemoActivity::class.java) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(intent) + uiDevice.wait(Until.hasObject(By.pkg(context.packageName).depth(0)), 15000) + + // Wait for the KML screen to load and map to render + delay(10.seconds) + + // Capture a screenshot + val screenshotBitmap = captureScreenshot("kml_screenshot.png") + + // --- STEP 2: Define your verification prompt --- + val prompt = + """ + Task: Analyze the provided image and verify it against the following three strict criteria. + Criteria Checklist: + Location: The image must display a map of the Googleplex (look for text labels such as "Googleplex", "Amphitheatre Pkwy", or "Charleston Rd"). + Subject Matter: The map must feature highlighted building footprints (polygonal shapes overlaying the buildings). + Color Palette: The building footprints must explicitly include all four of the following colors: Blue, Red, Green, and Yellow. + + Decision Logic: + If ALL criteria are met, the test passes. + If ANY criterion is not met, the test fails. + + Required Output Format: + Provide your response in the following format: + Test Result: [PASS / FAIL] + Verification Details: + Location Check: [State if Googleplex is confirmed] + Footprint Check: [State if footprints are visible] + Color Check: [List the colors found] + Failure Explanation: [If FAIL, you must explain exactly which specific criterion was not met. If PASS, write "None".] + """.trimIndent() + + // --- STEP 3: Analyze the image using Gemini --- + val geminiResponse = helper.analyzeImage(screenshotBitmap, prompt, geminiApiKey) + + // --- STEP 4: Assert on Gemini's response --- + assertWithMessage("Gemini's analysis failed: $geminiResponse").that(geminiResponse).contains("Test Result: PASS") + assertWithMessage("Gemini's analysis failed: $geminiResponse").that(geminiResponse).doesNotContain("Test Result: FAIL") + } +} diff --git a/demo/src/androidTest/java/com/google/maps/android/utils/demo/RendererVisualTest.kt b/demo/src/androidTest/java/com/google/maps/android/utils/demo/RendererVisualTest.kt new file mode 100644 index 000000000..8844e5967 --- /dev/null +++ b/demo/src/androidTest/java/com/google/maps/android/utils/demo/RendererVisualTest.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.utils.demo + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.runBlocking +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class RendererVisualTest : RendererVisualTestBase() { + @Test + fun testPeaksLayer() = + runBlocking { + launchActivity() + clickButton("Peaks") + collapseBottomSheet() + verifyMapContent( + "Does the map show a map of Colorado/Rocky Mountains area with many yellow pushpin markers and red pushpins with stars scattered across the mountainous areas?", + ) + } + + @Test + fun testRangesLayer() = + runBlocking { + launchActivity() + clickButton("Ranges") + collapseBottomSheet() + verifyMapContent( + "Did the rendering operation successfully display a mosaic of semi-transparent colored and outlined polygons that together cover the majority of the Colorado map area?", + ) + } + + @Test + fun testComplexKmlLayer() = + runBlocking { + launchActivity() + clickButton("Complex KML") + collapseBottomSheet() + verifyMapContent( + "Does the map show a green box with a hole in it in the upper left area and several push pins in the bottom right area of view?", + ) + } + + @Test + fun testComplexGeoJsonLayer() = + runBlocking { + launchActivity() + clickButton("Complex GeoJSON") + collapseBottomSheet() + verifyMapContent( + "Does the map show at least two red push pins a black line drawn near Lower Manhattan, a black trapezoid drawn around the Central Park Zoo and a short black line connected to the southern most red push pin?", + ) + } + + @Test + fun testGroundOverlayLayer() = + runBlocking { + launchActivity() + clickButton("Ground Overlay") + collapseBottomSheet() + verifyMapContent("Does the map show an image overlay of Mount Etna, a volcano, superimposed on the base map near Sicily?") + } + + @Test + fun testBrightAngelLayer() = + runBlocking { + launchActivity() + clickButton("Bright Angel") + collapseBottomSheet() + verifyMapContent("Does the map show a the Bright Angel Trail in Grand Canyon represented by a winding line?") + } + + @Test + fun testClearMap() = + runBlocking { + launchActivity() + clickButton("Peaks") + // Wait for peaks to load + java.util.concurrent.TimeUnit.SECONDS + .sleep(2) + clickButton("Clear") + collapseBottomSheet() + verifyMapContent( + "Is the map mostly empty, showing only the base map and potentially one default red marker (Googleplex), without the many yellow/red peak markers?", + ) + } +} diff --git a/demo/src/androidTest/java/com/google/maps/android/utils/demo/RendererVisualTestBase.kt b/demo/src/androidTest/java/com/google/maps/android/utils/demo/RendererVisualTestBase.kt new file mode 100644 index 000000000..66c5aeb17 --- /dev/null +++ b/demo/src/androidTest/java/com/google/maps/android/utils/demo/RendererVisualTestBase.kt @@ -0,0 +1,97 @@ +/* + * 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.utils.demo + +import android.content.Intent +import androidx.test.uiautomator.By +import androidx.test.uiautomator.Until +import org.junit.Assert.assertTrue +import java.util.concurrent.TimeUnit + +abstract class RendererVisualTestBase : BaseVisualTest() { + protected fun launchActivity() { + val intent = + Intent(context, RendererDemoActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + } + context.startActivity(intent) + uiDevice.wait(Until.hasObject(By.pkg(context.packageName).depth(0)), 10000) + // Wait for map initialization + TimeUnit.SECONDS.sleep(3) + } + + protected suspend fun clickButton(label: String) { + // Ensure bottom sheet is expanded enough to find the button + expandBottomSheet() + + // Try to find by text first for speed + val element = uiDevice.findObject(By.textContains(label)) + if (element != null) { + element.click() + } else { + // Fallback to AI if standard selector fails + helper.performActionFromPrompt("Click the $label button or chip", uiDevice, geminiApiKey) + } + + // Wait for action to settle (bottom sheet collapse, map render) + TimeUnit.SECONDS.sleep(2) + } + + protected fun expandBottomSheet() { + val bottomSheet = uiDevice.findObject(By.res(context.packageName, "bottom_sheet")) + if (bottomSheet != null) { + val startY = uiDevice.displayHeight - 100 + val endY = uiDevice.displayHeight / 2 + uiDevice.swipe(uiDevice.displayWidth / 2, startY, uiDevice.displayWidth / 2, endY, 10) + TimeUnit.SECONDS.sleep(1) + } + } + + protected fun collapseBottomSheet() { + val bottomSheet = uiDevice.findObject(By.res(context.packageName, "bottom_sheet")) + if (bottomSheet != null) { + val startY = uiDevice.displayHeight - 200 + val endY = uiDevice.displayHeight - 100 + uiDevice.swipe(uiDevice.displayWidth / 2, startY, uiDevice.displayWidth / 2, endY, 10) + TimeUnit.SECONDS.sleep(1) + } + } + + protected suspend fun verifyMapContent(description: String) { + val screenshotBitmap = captureScreenshot("visual_test_${System.currentTimeMillis()}.png") + + val prompt = + """ + Analyze this screenshot of a map app. + Check if the following condition is met: + "$description" + + Also check if the bottom sheet is collapsed (showing only the top "peek" area with "Load File"/"Clear" buttons and "Presets" header, but NOT the full list of chips). + + CRITICAL: Start your response with YES if BOTH the condition is met AND the bottom sheet is collapsed. + Start your response with NO if ANY condition is not met. + Then explain your reasoning. + """.trimIndent() + + val response = helper.analyzeImage(screenshotBitmap, prompt, geminiApiKey) + println("Gemini Verification for '$description': $response") + + assertTrue( + "Visual verification failed. Response: $response", + response?.trim()?.startsWith("YES", ignoreCase = true) == true, + ) + } +} diff --git a/demo/src/main/AndroidManifest.xml b/demo/src/main/AndroidManifest.xml index 5b9ce9fa9..645cf8ebc 100644 --- a/demo/src/main/AndroidManifest.xml +++ b/demo/src/main/AndroidManifest.xml @@ -1,5 +1,5 @@ + + + + + + + Styled Point + This point uses a shared style definition. + #examplePointStyle + + -122.0839597,37.4222899,0 + + + + + + Polygon with a Hole + A polygon demonstrating an inner boundary (hole). + #examplePolygonStyle + + clampToGround + + + + + -122.085,37.425,0 + -122.087,37.425,0 + -122.087,37.427,0 + -122.085,37.427,0 + -122.085,37.425,0 + + + + + + + + + -122.0865,37.4255,0 + -122.0855,37.4255,0 + -122.0855,37.4265,0 + -122.0865,37.4265,0 + -122.0865,37.4255,0 + + + + + + + + + + MultiGeometry Example + Combines a LineString and a Point into one feature. + + + -122.0845,37.4215,0 + + + + -122.084,37.421,0 + -122.085,37.421,0 + -122.0845,37.4215,0 + + + + + + + diff --git a/demo/src/main/assets/mountain_ranges.kml b/demo/src/main/assets/mountain_ranges.kml new file mode 100644 index 000000000..05d1934d4 --- /dev/null +++ b/demo/src/main/assets/mountain_ranges.kml @@ -0,0 +1,652 @@ + + + + Colorado Mountain Ranges + + + + + + normal + #poly-000000-1200-77-nodesc-normal + + + highlight + #poly-000000-1200-77-nodesc-highlight + + + + + + + normal + #poly-0288D1-1200-77-nodesc-normal + + + highlight + #poly-0288D1-1200-77-nodesc-highlight + + + + + + + normal + #poly-0F9D58-1200-77-nodesc-normal + + + highlight + #poly-0F9D58-1200-77-nodesc-highlight + + + + + + + normal + #poly-1A237E-1200-77-nodesc-normal + + + highlight + #poly-1A237E-1200-77-nodesc-highlight + + + + + + + normal + #poly-673AB7-1200-77-nodesc-normal + + + highlight + #poly-673AB7-1200-77-nodesc-highlight + + + + + + + normal + #poly-A52714-1200-77-nodesc-normal + + + highlight + #poly-A52714-1200-77-nodesc-highlight + + + + + + + normal + #poly-F9A825-1200-77-nodesc-normal + + + highlight + #poly-F9A825-1200-77-nodesc-highlight + + + + + + + normal + #poly-FF5252-1200-77-nodesc-normal + + + highlight + #poly-FF5252-1200-77-nodesc-highlight + + + + + + + normal + #poly-FFEA00-1200-77-nodesc-normal + + + highlight + #poly-FFEA00-1200-77-nodesc-highlight + + + + Untitled layer + + Sangre de Cristo Mountains + #poly-A52714-1200-77-nodesc + + + + 1 + + -106.1049364,38.5109349,0 + -105.857744,38.2137389,0 + -105.7478808,37.9456537,0 + -105.5666064,37.8069029,0 + -105.5281542,37.7374295,0 + -105.6599901,37.5024754,0 + -105.4127978,37.3759901,0 + -105.5830858,36.9820902,0 + -104.2537401,37.0259582,0 + -104.7646044,37.6026392,0 + -104.7646044,38.0365668,0 + -105.0470444,38.3272364,0 + -105.3908251,38.4679386,0 + -105.7199862,38.3890096,0 + -105.8797167,38.5367203,0 + -106.1049364,38.5109349,0 + + + + + + + San Juan Mountains + #poly-1A237E-1200-77-nodesc + + + + 1 + + -106.2367724,38.0062749,0 + -106.7311571,38.5238288,0 + -107.1211718,38.4550348,0 + -107.4727343,38.4033962,0 + -107.8078173,38.4937395,0 + -107.7748583,38.1662472,0 + -108.0769823,38.0452193,0 + -108.434038,38.1964728,0 + -108.5329149,38.0884666,0 + -108.7251757,38.0235861,0 + -108.867998,38.0322402,0 + -108.9558886,37.9283241,0 + -108.9284228,37.7851995,0 + -108.6867235,37.4981174,0 + -108.4505175,37.4370786,0 + -108.2582567,37.3323249,0 + -107.9616259,37.2624078,0 + -107.6759814,37.1617881,0 + -107.5661181,37.008414,0 + -106.2177623,37.0041902,0 + -106.2410398,37.1979388,0 + -106.2523359,37.4625755,0 + -106.537846,37.696343,0 + -106.2367724,38.0062749,0 + + + + + + + Sawatch Range + #poly-FFEA00-1200-77-nodesc + + + + 1 + + -106.7311571,38.5238288,0 + -106.1653613,37.9803005,0 + -106.0886704,38.1055014,0 + -105.9785937,38.2180549,0 + -106.1104296,38.4421286,0 + -106.1049364,38.5109349,0 + -106.1049365,38.7169595,0 + -106.357622,39.2294284,0 + -106.3313518,39.5180394,0 + -106.3710554,39.8066499,0 + -106.5287369,40.3753686,0 + -106.948583,40.322879,0 + -106.8255531,40.0758137,0 + -106.626787,39.8285259,0 + -106.6817187,39.6976262,0 + -107.0222948,39.6595765,0 + -107.2255419,39.5664778,0 + -106.8465136,39.2779272,0 + -106.9660516,38.9146229,0 + -106.9918711,38.8268309,0 + -107.3573779,38.6569302,0 + -107.5386522,38.5968504,0 + -107.5880907,38.5195311,0 + -107.3683642,38.4765399,0 + -107.0662401,38.5023377,0 + -106.8629931,38.541017,0 + -106.7311571,38.5238288,0 + + + + + + + Front Range + #poly-000000-1200-77-nodesc + + + + 1 + + -104.885454,38.3603358,0 + -104.7289927,38.4633943,0 + -104.7000252,38.588018,0 + -104.8140429,38.7383864,0 + -104.8744677,39.0846055,0 + -105.0227831,39.4927493,0 + -105.1985644,39.7043812,0 + -105.2095507,40.0332345,0 + -105.1436327,40.4441719,0 + -105.1765917,40.5736428,0 + -105.3853319,40.6362001,0 + -105.687456,40.7031749,0 + -105.7973193,40.5652973,0 + -105.8687304,40.4734291,0 + -106.0252269,40.4776794,0 + -106.1598681,40.6323432,0 + -106.1706277,40.5619543,0 + -106.0827158,40.2743716,0 + -105.9731005,40.1257028,0 + -106.06113,40.010281,0 + -106.0994433,39.9406406,0 + -106.1323651,39.7824251,0 + -106.0554979,39.7086072,0 + -106.0005663,39.5478358,0 + -105.6819628,39.2634611,0 + -105.6325243,39.0590176,0 + -105.7620847,38.8968684,0 + -106.0609911,38.7683732,0 + -106.0603998,38.5757672,0 + -105.8797167,38.5367203,0 + -105.7199862,38.3890096,0 + -105.3798388,38.5195311,0 + -105.1543465,38.3885439,0 + -104.885454,38.3603358,0 + + + + + + + Park Range + #poly-0F9D58-1200-77-nodesc + + + + 1 + + -107.664995,40.9978777,0 + -107.6045702,40.7819397,0 + -107.4782274,40.6362001,0 + -107.5606249,40.5235546,0 + -107.296953,40.4650712,0 + -107.0113085,40.4358104,0 + -106.8849657,40.3730657,0 + -106.6158007,40.3563239,0 + -106.4729784,40.4316292,0 + -106.357622,40.6653736,0 + -106.357622,40.8650761,0 + -106.4070605,41.0061689,0 + -107.664995,40.9978777,0 + + + + + + + Southern Wyoming Range + #poly-FF5252-1200-77-nodesc + + + + 1 + + -106.4070605,41.0061689,0 + -106.324663,40.7198302,0 + -106.1598681,40.6323432,0 + -105.98958,40.5029859,0 + -105.890703,40.5029859,0 + -105.8137987,40.6073258,0 + -105.687456,40.7031749,0 + -105.4622362,40.7031749,0 + -105.3084276,40.6698517,0 + -105.0996874,40.5447413,0 + -105.0887011,40.7281563,0 + -105.3029345,40.9193693,0 + -105.4018114,40.9729977,0 + -106.4070605,41.0061689,0 + + + + + + + Flat Tops Area + #poly-F9A825-1200-77-nodesc + + + + 1 + + -106.8849657,40.3730657,0 + -107.0113085,40.399851,0 + -107.1101855,40.399851,0 + -107.4068163,40.4416707,0 + -107.5716112,40.4751077,0 + -107.763872,40.5377572,0 + -107.9890917,40.6545463,0 + -108.2252978,40.6211988,0 + -108.5274218,40.4792861,0 + -108.6208056,40.3245102,0 + -108.5658739,40.1567847,0 + -108.2362841,40.0727662,0 + -107.9616259,39.9970608,0 + -107.8792284,39.562243,0 + -107.7748583,39.5283559,0 + -107.6045702,39.5368292,0 + -107.3463915,39.5537728,0 + -107.2255419,39.5664778,0 + -107.0222948,39.6595765,0 + -106.6817187,39.6976262,0 + -106.626787,39.8285259,0 + -106.7531298,39.954966,0 + -106.8794726,40.1021845,0 + -106.994829,40.3245102,0 + -106.8849657,40.3730657,0 + + + + + + + Elk Range + #poly-673AB7-1200-77-nodesc + + + + 1 + + -107.1541308,39.5029297,0 + -107.3463915,39.5537728,0 + -107.9286669,39.5114061,0 + -108.0159674,39.3490894,0 + -108.2125915,39.2460574,0 + -108.3208052,39.1513768,0 + -108.5493944,39.0479339,0 + -108.0220507,38.7658107,0 + -107.8187868,38.8109172,0 + -107.6540087,38.7143952,0 + -107.3189257,38.7058223,0 + -107.0442674,38.8257487,0 + -106.8684862,39.1630246,0 + -106.8465136,39.2779272,0 + -107.0332812,39.4223518,0 + -107.1541308,39.5029297,0 + + + + + + + Central Colorado Range + #poly-0288D1-1200-77-nodesc + + + + 1 + + -106.0610137,38.794089,0 + -105.8772053,38.8583454,0 + -105.7259506,38.9651288,0 + -105.6709991,39.0759262,0 + -105.7479224,39.2634563,0 + -106.0280573,39.560519,0 + -106.1708711,39.755042,0 + -106.0994646,40.1928125,0 + -106.2752353,40.8275776,0 + -106.5287369,40.3753686,0 + -106.4125557,40.1634351,0 + -106.3026989,39.5689878,0 + -106.297206,39.2166596,0 + -106.0610137,38.794089,0 + + + + + + + + diff --git a/demo/src/main/assets/top_peaks.kml b/demo/src/main/assets/top_peaks.kml new file mode 100644 index 000000000..0162202cc --- /dev/null +++ b/demo/src/main/assets/top_peaks.kml @@ -0,0 +1,1034 @@ + + + + + + + 14ers (14,000 ft and above) + + Mount Elbert ☝️ + + -106.4454,39.1178,4401.2 + + #14erStyle + + + Mount Massive + + -106.4757,39.1875,4398.0 + + #14erStyle + + + Mount Harvard 🎓 + + -106.3207,38.9244,4395.6 + + #14erStyle + + + Blanca Peak + + -105.4856,37.5775,4374.0 + + #14erStyle + + + La Plata Peak + + -106.4729,39.0294,4372.0 + + #14erStyle + + + Uncompahgre Peak ⭐ + + -107.4621,38.0717,4365.0 + + #14erStyle + + + Crestone Peak + + -105.5855,37.9669,4359.0 + + #14erStyle + + + Mount Lincoln + + -106.1116,39.3515,4356.5 + + #14erStyle + + + Castle Peak 🏰 + + -106.8614,39.0097,4352.2 + + #14erStyle + + + Grays Peak 🐺 + + -105.8176,39.6339,4352.0 + + #14erStyle + + + Mount Antero 💎 + + -106.2462,38.6741,4351.4 + + #14erStyle + + + Torreys Peak 🗼 + + -105.8212,39.6428,4351.0 + + #14erStyle + + + Mount Blue Sky 🟦 + + -105.6438,39.5883,4350.0 + + #14erStyle + + + Quandary Peak 🤔 + + -106.1064,39.3973,4349.9 + + #14erStyle + + + Longs Peak 📏 + + -105.6151,40.255,4346.0 + + #14erStyle + + + Mount Wilson 🏐 + + -107.9916,37.8391,4344.0 + + #14erStyle + + + Mount Shavano 🏹 + + -106.2393,38.6192,4337.7 + + #14erStyle + + + Mount Princeton 🎓 + + -106.2424,38.7492,4329.3 + + #14erStyle + + + Mount Belford 🔔 + + -106.3607,38.9607,4329.1 + + #14erStyle + + + Crestone Needle 💉 + + -105.5766,37.9647,4329.0 + + #14erStyle + + + Mount Yale 🎓 + + -106.3138,38.8442,4328.2 + + #14erStyle + + + Mount Bross 👨‍🎨 + + -106.1077,39.3354,4321.6 + + #14erStyle + + + Kit Carson Mountain 🤠 + + -105.6026,37.9797,4319.0 + + #14erStyle + + + Maroon Peak 🟫 + + -106.989,39.0708,4317.0 + + #14erStyle + + + Tabeguache Peak 🌮 + + -106.2509,38.6255,4316.7 + + #14erStyle + + + Mount Oxford 🎓 + + -106.3388,38.9648,4315.9 + + #14erStyle + + + Mount Sneffels 🤧 + + -107.7923,38.0038,4315.4 + + #14erStyle + + + Mount Democrat 🇺🇸 + + -106.14,39.3396,4314.5 + + #14erStyle + + + Capitol Peak 🏛️ + + -107.0829,39.1503,4309.0 + + #14erStyle + + + Pikes Peak 🏔️ + + -105.0442,38.8405,4302.31 + + #14erStyle + + + Snowmass Mountain ❄️ + + -107.0665,39.1188,4297.3 + + #14erStyle + + + Windom Peak 🌬️ + + -107.5919,37.6212,4296.0 + + #14erStyle + + + Mount Eolus 🌬️ + + -107.6227,37.6218,4295.0 + + #14erStyle + + + Challenger Point 🚀 + + -105.6066,37.9804,4294.0 + + #14erStyle + + + Mount Columbia 🇨🇴 + + -106.2975,38.9039,4290.8 + + #14erStyle + + + Missouri Mountain 🚢 + + -106.3785,38.9476,4289.8 + + #14erStyle + + + Humboldt Peak 🐧 + + -105.5552,37.9762,4289.0 + + #14erStyle + + + Mount Bierstadt 🍺 + + -105.6688,39.5826,4287.0 + + #14erStyle + + + Sunlight Peak ☀️ + + -107.5959,37.6274,4287.0 + + #14erStyle + + + Handies Peak 🖐️ + + -107.5044,37.913,4284.8 + + #14erStyle + + + Culebra Peak 🐍 + + -105.1858,37.1224,4283.0 + + #14erStyle + + + Ellingwood Point 👉 + + -105.4927,37.5826,4282.0 + + #14erStyle + + + Mount Lindsey 💃 + + -105.4449,37.5837,4282.0 + + #14erStyle + + + Little Bear Peak 🐻 + + -105.4972,37.5666,4280.0 + + #14erStyle + + + Mount Sherman 🎖️ + + -106.1699,39.225,4280.0 + + #14erStyle + + + Redcloud Peak ☁️ + + -107.4219,37.941,4280.0 + + #14erStyle + + + Pyramid Peak 🔺 + + -106.9502,39.0717,4274.7 + + #14erStyle + + + Wilson Peak 🏐 + + -107.9847,37.8603,4274.0 + + #14erStyle + + + Wetterhorn Peak 📯 + + -107.5109,38.0607,4274.0 + + #14erStyle + + + San Luis Peak ⚜️ + + -106.9313,37.9868,4273.8 + + #14erStyle + + + Mount of the Holy Cross ✝️ + + -106.4817,39.4668,4270.5 + + #14erStyle + + + Huron Peak 🏞️ + + -106.4381,38.9455,4270.2 + + #14erStyle + + + Sunshine Peak ☀️ + + -107.4256,37.9228,4269.0 + + #14erStyle + + + + Other Peaks + + Grizzly Peak 🐻 + + -106.5976,39.0425,4265.6 + + #shortPeakStyle + + + Mount Ouray + + -106.2247,38.4227,4255.4 + + #shortPeakStyle + + + Vermilion Peak + + -107.8285,37.7993,4237.0 + + #shortPeakStyle + + + Mount Silverheels + + -106.0054,39.3394,4215.0 + + #shortPeakStyle + + + Rio Grande Pyramid + + -107.3924,37.6797,4214.4 + + #shortPeakStyle + + + Bald Mountain 👨‍🦲 + + -105.9705,39.4448,4173.0 + + #shortPeakStyle + + + Mount Oso 🐻 + + -107.4936,37.607,4173.0 + + #shortPeakStyle + + + Mount Jackson 🕺 + + -106.5367,39.4853,4168.5 + + #shortPeakStyle + + + Bard Peak ✍️ + + -105.8044,39.7204,4159.0 + + #shortPeakStyle + + + West Spanish Peak 🇪🇸 + + -104.9934,37.3756,4155.0 + + #shortPeakStyle + + + Mount Powell 💪 + + -106.3407,39.7601,4141.0 + + #shortPeakStyle + + + Hagues Peak ☁️ + + -105.6464,40.4845,4137.0 + + #shortPeakStyle + + + Tower Mountain 🗼 + + -107.623,37.8573,4132.0 + + #shortPeakStyle + + + Treasure Mountain 💎 + + -107.1228,39.0244,4125.0 + + #shortPeakStyle + + + North Arapaho Peak 🫎 + + -105.6504,40.0265,4117.0 + + #shortPeakStyle + + + Parry Peak 🤺 + + -105.7132,39.8381,4083.0 + + #shortPeakStyle + + + Bill Williams Peak 🤠 + + -106.6102,39.1806,4081.0 + + #shortPeakStyle + + + Sultan Mountain 👑 + + -107.7038,37.7859,4076.0 + + #shortPeakStyle + + + Mount Herard 🗣️ + + -105.4949,37.8492,4068.0 + + #shortPeakStyle + + + West Buffalo Peak 🐃 + + -106.1249,38.9917,4064.0 + + #shortPeakStyle + + + Summit Peak 🏆 + + -106.6968,37.3506,4056.2 + + #shortPeakStyle + + + Middle Peak 🎯 + + -108.1082,37.8536,4056.0 + + #shortPeakStyle + + + Antora Peak 🦌 + + -106.218,38.325,4046.0 + + #shortPeakStyle + + + Henry Mountain 👑 + + -106.6211,38.6856,4042.0 + + #shortPeakStyle + + + Hesperus Mountain 🌟 + + -108.089,37.4451,4035.0 + + #shortPeakStyle + + + Jacque Peak 👨‍🍳 + + -106.197,39.4549,4027.0 + + #shortPeakStyle + + + Bennett Peak 👨‍🔬 + + -106.4343,37.4833,4026.0 + + #shortPeakStyle + + + Conejos Peak 🐇 + + -106.5709,37.2887,4017.0 + + #shortPeakStyle + + + Twilight Peak 🌆 + + -107.727,37.663,4012.0 + + #shortPeakStyle + + + South River Peak 🏞️ + + -106.9815,37.5741,4009.4 + + #shortPeakStyle + + + Bushnell Peak 🔭 + + -105.8892,38.3412,3995.8 + + #shortPeakStyle + + + West Elk Peak 🦌 + + -107.1994,38.7179,3975.2 + + #shortPeakStyle + + + Mount Centennial 💯 + + -107.2446,37.6062,3967.0 + + #shortPeakStyle + + + Clark Peak 🦸 + + -105.93,40.6068,3948.4 + + #shortPeakStyle + + + Mount Richthofen ✈️ + + -105.8945,40.4695,3946.0 + + #shortPeakStyle + + + Chair Mountain 🪑 + + -107.2822,39.0581,3879.1 + + #shortPeakStyle + + + Mount Gunnison 🔫 + + -107.3826,38.8121,3878.7 + + #shortPeakStyle + + + East Spanish Peak 🇪🇸 + + -104.9201,37.3934,3867.0 + + #shortPeakStyle + + + Gothic Mountain 🦇 + + -107.0107,38.9562,3850.0 + + #shortPeakStyle + + + Lone Cone 🍦 + + -108.2556,37.888,3846.1 + + #shortPeakStyle + + + Graham Peak 🥣 + + -107.3761,37.4972,3821.1 + + #shortPeakStyle + + + Whetstone Mountain 🔪 + + -106.9799,38.8223,3818.1 + + #shortPeakStyle + + + Specimen Mountain 🔬 + + -105.8081,40.4449,3808.0 + + #shortPeakStyle + + + East Beckwith Mountain 🌄 + + -107.2233,38.8464,3792.1 + + #shortPeakStyle + + + Knobby Crest 🪢 + + -105.605,39.3681,3790.0 + + #shortPeakStyle + + + Bison Mountain 🦬 + + -105.4978,39.2384,3789.4 + + #shortPeakStyle + + + Anthracite Range High Point 🔥 + + -107.1445,38.8145,3777.8 + + #shortPeakStyle + + + Matchless Mountain 🔥 + + -106.6451,38.834,3776.0 + + #shortPeakStyle + + + Flat Top Mountain 🔝 + + -107.0833,40.0147,3767.7 + + #shortPeakStyle + + + Greenhorn Mountain 🌿 + + -105.0133,37.8815,3765.0 + + #shortPeakStyle + + + Elliott Mountain 🌊 + + -108.058,37.7344,3763.0 + + #shortPeakStyle + + + Parkview Mountain 🏞️ + + -106.1363,40.3303,3749.4 + + #shortPeakStyle + + + Cornwall Mountain 🌽 + + -106.492,37.3811,3746.0 + + #shortPeakStyle + + + Mount Zirkel ⭕ + + -106.6631,40.8313,3714.0 + + #shortPeakStyle + + + Crested Butte 🧈 + + -106.9436,38.8835,3709.0 + + #shortPeakStyle + + + Sawtooth Mountain 🦷 + + -106.867,38.274,3704.2 + + #shortPeakStyle + + + Park Cone 🍦 + + -106.6028,38.7967,3690.0 + + #shortPeakStyle + + + Carbon Peak ⚫ + + -107.0431,38.7943,3684.3 + + #shortPeakStyle + + + Mount Guero 💡 + + -107.3861,38.7196,3675.4 + + #shortPeakStyle + + + Red Table Mountain 🟥 + + -106.7712,39.4181,3670.7 + + #shortPeakStyle + + + Chalk Benchmark 🖍️ + + -106.75,37.1418,3669.3 + + #shortPeakStyle + + + Mount Zwischen ↔️ + + -105.4554,37.7913,3661.0 + + #shortPeakStyle + + + Little Cone 🍦 + + -108.0908,37.9275,3654.0 + + #shortPeakStyle + + + Huntsman Ridge Peak 🏹 + + -107.3668,39.192,3614.0 + + #shortPeakStyle + + + Sheep Mountain 🐏 + + -106.2658,40.361,3604.2 + + #shortPeakStyle + + + Waugh Mountain 😢 + + -105.6955,38.6022,3571.0 + + #shortPeakStyle + + + Coal Mountain ⚫ + + -107.4837,38.787,3569.0 + + #shortPeakStyle + + + Williams Peak 🎾 + + -106.1854,39.8552,3541.8 + + #shortPeakStyle + + + Puma Peak 🐆 + + -105.5815,39.1572,3528.0 + + #shortPeakStyle + + + Mount Mestas ✨ + + -105.1474,37.583,3528.0 + + #shortPeakStyle + + + Thirtynine Mile Mountain 📏 + + -105.5553,38.8324,3521.0 + + #shortPeakStyle + + + Tomichi Dome ⛰️ + + -106.5291,38.4849,3496.0 + + #shortPeakStyle + + + Blair Mountain 🧙‍♀️ + + -107.4176,39.7943,3495.0 + + #shortPeakStyle + + + Twin Sisters Peaks 👯‍♀️ + + -105.5175,40.2886,3485.0 + + #shortPeakStyle + + + Elk Mountain 🦌 + + -106.1285,40.1619,3482.1 + + #shortPeakStyle + + + Iron Mountain 🔩 + + -105.2538,37.6375,3480.0 + + #shortPeakStyle + + + Marcellina Mountain 💃 + + -107.2438,38.9299,3461.0 + + #shortPeakStyle + + + Crater Peak 🌋 + + -107.6628,39.0396,3454.2 + + #shortPeakStyle + + + Hardscrabble Mountain ⛏️ + + -106.8021,39.5171,3405.0 + + #shortPeakStyle + + + Cochetopa Dome ⛰️ + + -106.7147,38.2267,3395.0 + + #shortPeakStyle + + + North Mamm Peak 🐘 + + -107.866,39.3865,3391.3 + + #shortPeakStyle + + + Laramie Mountains 💻 + + -105.7162,40.7704,3360.0 + + #shortPeakStyle + + + Sand Mountain North ⌛ + + -107.0575,40.7636,3317.0 + + #shortPeakStyle + + + Black Mountain ⚫ + + -107.3691,40.7835,3312.0 + + #shortPeakStyle + + + Sleepy Cat Peak 😴🐈 + + -107.5338,40.1275,3308.0 + + #shortPeakStyle + + + Spruce Mountain 🌲 + + -107.522,39.1973,3303.5 + + #shortPeakStyle + + + Green Mountain 🌲 + + -105.3001,39.3053,3178.3 + + #shortPeakStyle + + + Columbus Mountain 🗺️ + + -107.1921,40.8799,3126.0 + + #shortPeakStyle + + + Ute Peak 🏞️ + + -108.7787,37.2841,3043.0 + + #shortPeakStyle + + + Horse Mountain 🐴 + + -107.2864,37.308,3033.0 + + #shortPeakStyle + + + + diff --git a/demo/src/main/java/com/google/maps/android/utils/demo/AnimationUtilDemoActivity.java b/demo/src/main/java/com/google/maps/android/utils/demo/AnimationUtilDemoActivity.java index 56a407602..80a39db7e 100644 --- a/demo/src/main/java/com/google/maps/android/utils/demo/AnimationUtilDemoActivity.java +++ b/demo/src/main/java/com/google/maps/android/utils/demo/AnimationUtilDemoActivity.java @@ -1,11 +1,11 @@ /* - * Copyright 2023 Google Inc. + * 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 + * 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, @@ -13,9 +13,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - package com.google.maps.android.utils.demo; +import androidx.annotation.NonNull; import com.google.android.gms.maps.CameraUpdateFactory; import com.google.android.gms.maps.GoogleMap; import com.google.android.gms.maps.model.LatLng; @@ -23,36 +23,32 @@ import com.google.android.gms.maps.model.MarkerOptions; import com.google.maps.android.ui.AnimationUtil; -import androidx.annotation.NonNull; - -/** - * Simple activity demonstrating the AnimationUtil. - */ -public class AnimationUtilDemoActivity extends BaseDemoActivity implements GoogleMap.OnMarkerClickListener { - - LatLng sydney = new LatLng(-33.852, 151.211); - Marker currentMarker; - - @Override - protected void startDemo(boolean isRestore) { - getMap().setOnMarkerClickListener(this); - - // Add a marker in Sydney, Australia, - // and move the map's camera to the same location. - currentMarker = getMap().addMarker(new MarkerOptions() - .position(sydney) - .title("Marker in Sydney")); - getMap().moveCamera(CameraUpdateFactory.newLatLng(sydney)); - } - - public double getRandomNumber(int min, int max) { - return ((Math.random() * (max - min)) + min); - } - - @Override - public boolean onMarkerClick(@NonNull Marker marker) { - LatLng newRandomLat = new LatLng(getRandomNumber(-34, -33), getRandomNumber(150, 151)); - AnimationUtil.animateMarkerTo(currentMarker, newRandomLat); - return false; - } -} \ No newline at end of file +/** Simple activity demonstrating the AnimationUtil. */ +public class AnimationUtilDemoActivity extends BaseDemoActivity + implements GoogleMap.OnMarkerClickListener { + + LatLng sydney = new LatLng(-33.852, 151.211); + Marker currentMarker; + + @Override + protected void startDemo(boolean isRestore) { + getMap().setOnMarkerClickListener(this); + + // Add a marker in Sydney, Australia, + // and move the map's camera to the same location. + currentMarker = + getMap().addMarker(new MarkerOptions().position(sydney).title("Marker in Sydney")); + getMap().moveCamera(CameraUpdateFactory.newLatLng(sydney)); + } + + public double getRandomNumber(int min, int max) { + return ((Math.random() * (max - min)) + min); + } + + @Override + public boolean onMarkerClick(@NonNull Marker marker) { + LatLng newRandomLat = new LatLng(getRandomNumber(-34, -33), getRandomNumber(150, 151)); + AnimationUtil.animateMarkerTo(currentMarker, newRandomLat); + return false; + } +} diff --git a/demo/src/main/java/com/google/maps/android/utils/demo/ApiKeyValidator.java b/demo/src/main/java/com/google/maps/android/utils/demo/ApiKeyValidator.java index 9acd9a066..52a8eddca 100644 --- a/demo/src/main/java/com/google/maps/android/utils/demo/ApiKeyValidator.java +++ b/demo/src/main/java/com/google/maps/android/utils/demo/ApiKeyValidator.java @@ -1,5 +1,5 @@ /* - * Copyright 2025 Google LLC + * 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. @@ -9,55 +9,54 @@ * * 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. + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ - package com.google.maps.android.utils.demo; import android.content.Context; import android.content.pm.PackageManager; import android.os.Bundle; - import java.util.regex.Pattern; /** * A utility class to validate the Maps API key. The purpose of this check is to ensure that a - * developer has set a valid-looking key. This is not a definitive check, and there is a - * possibility of a false negative. If you are sure your key is correct, you can remove this - * check. + * developer has set a valid-looking key. This is not a definitive check, and there is a possibility + * of a false negative. If you are sure your key is correct, you can remove this check. */ class ApiKeyValidator { - private static final String REGEX = "^AIza[0-9A-Za-z-_]{35}$"; - private static final Pattern PATTERN = Pattern.compile(REGEX); + private static final String REGEX = "^AIza[0-9A-Za-z-_]{35}$"; + private static final Pattern PATTERN = Pattern.compile(REGEX); - /** - * Checks if the provided context has a valid Google Maps API key in its metadata. - * - * @param context The context to check for the API key. - * @return `true` if the context has a valid API key, `false` otherwise. - */ - static boolean hasMapsApiKey(Context context) { - String mapsApiKey = getMapsApiKey(context); - return mapsApiKey != null && PATTERN.matcher(mapsApiKey).matches(); - } + /** + * Checks if the provided context has a valid Google Maps API key in its metadata. + * + * @param context The context to check for the API key. + * @return `true` if the context has a valid API key, `false` otherwise. + */ + static boolean hasMapsApiKey(Context context) { + String mapsApiKey = getMapsApiKey(context); + return mapsApiKey != null && PATTERN.matcher(mapsApiKey).matches(); + } - /** - * Retrieves the Google Maps API key from the application metadata. - * - * @param context The context to retrieve the API key from. - * @return The API key if found, `null` otherwise. - */ - private static String getMapsApiKey(Context context) { - try { - Bundle bundle = context.getPackageManager() - .getApplicationInfo(context.getPackageName(), PackageManager.GET_META_DATA) - .metaData; - return bundle.getString("com.google.android.geo.API_KEY"); - } catch (PackageManager.NameNotFoundException e) { - e.printStackTrace(); - return null; - } + /** + * Retrieves the Google Maps API key from the application metadata. + * + * @param context The context to retrieve the API key from. + * @return The API key if found, `null` otherwise. + */ + private static String getMapsApiKey(Context context) { + try { + Bundle bundle = + context + .getPackageManager() + .getApplicationInfo(context.getPackageName(), PackageManager.GET_META_DATA) + .metaData; + return bundle.getString("com.google.android.geo.API_KEY"); + } catch (PackageManager.NameNotFoundException e) { + e.printStackTrace(); + return null; } + } } diff --git a/demo/src/main/java/com/google/maps/android/utils/demo/ApiKeyValidator.kt b/demo/src/main/java/com/google/maps/android/utils/demo/ApiKeyValidator.kt index 8dcb036e6..75920ccc0 100644 --- a/demo/src/main/java/com/google/maps/android/utils/demo/ApiKeyValidator.kt +++ b/demo/src/main/java/com/google/maps/android/utils/demo/ApiKeyValidator.kt @@ -1,11 +1,11 @@ /* - * Copyright 2024 Google LLC + * 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 + * 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, @@ -13,7 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - package com.google.maps.android.utils.demo import android.content.Context @@ -60,9 +59,10 @@ internal fun keyHasValidFormat(apiKey: String): Boolean { */ private fun getMapsApiKey(context: Context): String? { try { - val bundle = context.packageManager - .getApplicationInfo(context.packageName, PackageManager.GET_META_DATA) - .metaData + val bundle = + context.packageManager + .getApplicationInfo(context.packageName, PackageManager.GET_META_DATA) + .metaData return bundle.getString("com.google.android.geo.API_KEY") } catch (e: PackageManager.NameNotFoundException) { e.printStackTrace() diff --git a/demo/src/main/java/com/google/maps/android/utils/demo/BaseDemoActivity.java b/demo/src/main/java/com/google/maps/android/utils/demo/BaseDemoActivity.java index 079eca2a6..f2bc3fc2f 100644 --- a/demo/src/main/java/com/google/maps/android/utils/demo/BaseDemoActivity.java +++ b/demo/src/main/java/com/google/maps/android/utils/demo/BaseDemoActivity.java @@ -1,11 +1,11 @@ /* - * Copyright 2025 Google LLC + * 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 + * 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, @@ -13,7 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - package com.google.maps.android.utils.demo; import static com.google.maps.android.utils.demo.ApiKeyValidatorKt.hasMapsApiKey; @@ -21,114 +20,103 @@ import android.os.Bundle; import android.view.View; import android.widget.Toast; - import androidx.annotation.NonNull; +import androidx.core.graphics.Insets; +import androidx.core.view.ViewCompat; +import androidx.core.view.WindowCompat; +import androidx.core.view.WindowInsetsCompat; import androidx.fragment.app.FragmentActivity; - import com.google.android.gms.maps.GoogleMap; import com.google.android.gms.maps.GoogleMapOptions; import com.google.android.gms.maps.OnMapReadyCallback; import com.google.android.gms.maps.SupportMapFragment; -import androidx.core.graphics.Insets; -import androidx.core.view.ViewCompat; -import androidx.core.view.WindowCompat; -import androidx.core.view.WindowInsetsCompat; - public abstract class BaseDemoActivity extends FragmentActivity implements OnMapReadyCallback { - private GoogleMap mMap; - private boolean mIsRestore; + private GoogleMap mMap; + private boolean mIsRestore; + + protected int getLayoutId() { + return R.layout.map; + } + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); - protected int getLayoutId() { - return R.layout.map; + if (!hasMapsApiKey(this)) { + Toast.makeText(this, R.string.bad_maps_api_key, Toast.LENGTH_LONG).show(); + finish(); } - @Override - public void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - - if (!hasMapsApiKey(this)) { - Toast.makeText(this, R.string.bad_maps_api_key, Toast.LENGTH_LONG).show(); - finish(); - } - - mIsRestore = savedInstanceState != null; - setContentView(getLayoutId()); - // This tells the system that the app will handle drawing behind the system bars. - WindowCompat.setDecorFitsSystemWindows(getWindow(), false); - - // This is the root view of my layout. - // Make sure to replace R.id.root_layout with the actual ID of your root view. - final View rootView = findViewById(android.R.id.content); - - // Add a listener to handle window insets. - ViewCompat.setOnApplyWindowInsetsListener(rootView, (view, windowInsets) -> { - final Insets insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()); - - // Apply the insets as padding to the view. - // This will push the content down from behind the status bar and up from - // behind the navigation bar. - view.setPadding( - insets.left, - insets.top, - insets.right, - insets.bottom - ); - - // Return CONSUMED to signal that we've handled the insets. - return WindowInsetsCompat.CONSUMED; + mIsRestore = savedInstanceState != null; + setContentView(getLayoutId()); + // This tells the system that the app will handle drawing behind the system bars. + WindowCompat.setDecorFitsSystemWindows(getWindow(), false); + + // This is the root view of my layout. + // Make sure to replace R.id.root_layout with the actual ID of your root view. + final View rootView = findViewById(android.R.id.content); + + // Add a listener to handle window insets. + ViewCompat.setOnApplyWindowInsetsListener( + rootView, + (view, windowInsets) -> { + final Insets insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()); + + // Apply the insets as padding to the view. + // This will push the content down from behind the status bar and up from + // behind the navigation bar. + view.setPadding(insets.left, insets.top, insets.right, insets.bottom); + + // Return CONSUMED to signal that we've handled the insets. + return WindowInsetsCompat.CONSUMED; }); - setUpMap(savedInstanceState); - } + setUpMap(savedInstanceState); + } - @Override - public void onMapReady(@NonNull GoogleMap map) { - if (mMap != null) { - return; - } - mMap = map; - startDemo(mIsRestore); + @Override + public void onMapReady(@NonNull GoogleMap map) { + if (mMap != null) { + return; + } + mMap = map; + startDemo(mIsRestore); + } + + private void setUpMap(Bundle savedInstanceState) { + // 1. Get the Application instance and cast it + DemoApplication app = (DemoApplication) getApplication(); + + // 2. Call the getMapId() method + String mapId = app.getMapId(); + + // Create a new SupportMapFragment instance + SupportMapFragment mapFragment; + + if (mapId == null) { + mapFragment = SupportMapFragment.newInstance(); + } else { + // Create the map options + GoogleMapOptions mapOptions = new GoogleMapOptions(); + mapOptions.mapId(mapId); + // Create a new SupportMapFragment instance + mapFragment = SupportMapFragment.newInstance(mapOptions); } - private void setUpMap(Bundle savedInstanceState) { - // 1. Get the Application instance and cast it - DemoApplication app = (DemoApplication) getApplication(); - - // 2. Call the getMapId() method - String mapId = app.getMapId(); - - // Create a new SupportMapFragment instance - SupportMapFragment mapFragment; - - if (mapId == null) { - mapFragment = SupportMapFragment.newInstance(); - } else { - // Create the map options - GoogleMapOptions mapOptions = new GoogleMapOptions(); - mapOptions.mapId(mapId); - // Create a new SupportMapFragment instance - mapFragment = SupportMapFragment.newInstance(mapOptions); - } - - // Add the fragment to the container (R.id.map) - // Check savedInstanceState to prevent re-adding on rotation - if (savedInstanceState == null) { - getSupportFragmentManager() - .beginTransaction() - .add(R.id.map, mapFragment) - .commit(); - } - - // Get the map - mapFragment.getMapAsync(this); + // Add the fragment to the container (R.id.map) + // Check savedInstanceState to prevent re-adding on rotation + if (savedInstanceState == null) { + getSupportFragmentManager().beginTransaction().add(R.id.map, mapFragment).commit(); } - /** - * Run the demo-specific code. - */ - protected abstract void startDemo(boolean isRestore); + // Get the map + mapFragment.getMapAsync(this); + } - protected GoogleMap getMap() { - return mMap; - } -} \ No newline at end of file + /** Run the demo-specific code. */ + protected abstract void startDemo(boolean isRestore); + + protected GoogleMap getMap() { + return mMap; + } +} diff --git a/demo/src/main/java/com/google/maps/android/utils/demo/BigClusteringDemoActivity.java b/demo/src/main/java/com/google/maps/android/utils/demo/BigClusteringDemoActivity.java index 2346247c4..d7b2eb089 100644 --- a/demo/src/main/java/com/google/maps/android/utils/demo/BigClusteringDemoActivity.java +++ b/demo/src/main/java/com/google/maps/android/utils/demo/BigClusteringDemoActivity.java @@ -1,11 +1,11 @@ /* - * Copyright 2013 Google Inc. + * 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 + * 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, @@ -13,52 +13,48 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - package com.google.maps.android.utils.demo; import android.widget.Toast; - import com.google.android.gms.maps.CameraUpdateFactory; import com.google.android.gms.maps.model.LatLng; import com.google.maps.android.clustering.ClusterManager; import com.google.maps.android.utils.demo.model.MyItem; - -import org.json.JSONException; - import java.io.InputStream; import java.util.List; +import org.json.JSONException; public class BigClusteringDemoActivity extends BaseDemoActivity { - private ClusterManager mClusterManager; + private ClusterManager mClusterManager; - @Override - protected void startDemo(boolean isRestore) { - if (!isRestore) { - getMap().moveCamera(CameraUpdateFactory.newLatLngZoom(new LatLng(51.503186, -0.126446), 10)); - } + @Override + protected void startDemo(boolean isRestore) { + if (!isRestore) { + getMap().moveCamera(CameraUpdateFactory.newLatLngZoom(new LatLng(51.503186, -0.126446), 10)); + } - mClusterManager = new ClusterManager<>(this, getMap()); + mClusterManager = new ClusterManager<>(this, getMap()); - getMap().setOnCameraIdleListener(mClusterManager); - try { - readItems(); - } catch (JSONException e) { - Toast.makeText(this, getString(R.string.error_reading_markers), Toast.LENGTH_LONG).show(); - } + getMap().setOnCameraIdleListener(mClusterManager); + try { + readItems(); + } catch (JSONException e) { + Toast.makeText(this, getString(R.string.error_reading_markers), Toast.LENGTH_LONG).show(); } - - private void readItems() throws JSONException { - InputStream inputStream = getResources().openRawResource(R.raw.radar_search); - List items = new MyItemReader().read(inputStream); - for (int i = 0; i < 10; i++) { - double offset = i / 60d; - for (MyItem item : items) { - LatLng position = item.getPosition(); - double lat = position.latitude + offset; - double lng = position.longitude + offset; - MyItem offsetItem = new MyItem(lat, lng); - mClusterManager.addItem(offsetItem); - } - } + } + + private void readItems() throws JSONException { + InputStream inputStream = getResources().openRawResource(R.raw.radar_search); + List items = new MyItemReader().read(inputStream); + for (int i = 0; i < 10; i++) { + double offset = i / 60d; + for (MyItem item : items) { + LatLng position = item.getPosition(); + double lat = position.latitude + offset; + double lng = position.longitude + offset; + MyItem offsetItem = new MyItem(lat, lng); + mClusterManager.addItem(offsetItem); + } } + } } diff --git a/demo/src/main/java/com/google/maps/android/utils/demo/ClusterAlgorithmsDemoActivity.kt b/demo/src/main/java/com/google/maps/android/utils/demo/ClusterAlgorithmsDemoActivity.kt index fa1549a82..3cd91c21d 100644 --- a/demo/src/main/java/com/google/maps/android/utils/demo/ClusterAlgorithmsDemoActivity.kt +++ b/demo/src/main/java/com/google/maps/android/utils/demo/ClusterAlgorithmsDemoActivity.kt @@ -1,5 +1,5 @@ /* - * Copyright 2025 Google LLC + * 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. @@ -13,7 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - package com.google.maps.android.utils.demo import android.os.Build @@ -39,20 +38,17 @@ import kotlin.random.Random * available in the library. */ class ClusterAlgorithmsDemoActivity : BaseDemoActivity() { - private var clusterManager: ClusterManager? = null - override fun getLayoutId(): Int { - return R.layout.activity_cluster_algorithms_demo - } + override fun getLayoutId(): Int = R.layout.activity_cluster_algorithms_demo override fun startDemo(isRestore: Boolean) { - if (!isRestore) { map.moveCamera( CameraUpdateFactory.newLatLngZoom( - LatLng(51.503186, -0.126446), 10f - ) + LatLng(51.503186, -0.126446), + 10f, + ), ) } @@ -63,29 +59,35 @@ class ClusterAlgorithmsDemoActivity : BaseDemoActivity() { private fun setupSpinner() { val spinner: Spinner = findViewById(R.id.algorithm_spinner) - val adapter = ArrayAdapter.createFromResource( - this, R.array.clustering_algorithms, R.layout.text_view_spinner_item - ) + val adapter = + ArrayAdapter.createFromResource( + this, + R.array.clustering_algorithms, + R.layout.text_view_spinner_item, + ) adapter.setDropDownViewResource(R.layout.text_view_spinner_dropdown_item) spinner.adapter = adapter - spinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { - override fun onItemSelected( - parent: AdapterView<*>?, view: View?, position: Int, id: Long - ) { - setupClusterer(position) + spinner.onItemSelectedListener = + object : AdapterView.OnItemSelectedListener { + override fun onItemSelected( + parent: AdapterView<*>?, + view: View?, + position: Int, + id: Long, + ) { + setupClusterer(position) + } + + override fun onNothingSelected(parent: AdapterView<*>?) { + // Do nothing + } } - - override fun onNothingSelected(parent: AdapterView<*>?) { - // Do nothing - } - } } /** * Sets up the ClusterManager with the chosen algorithm and populates it with items. */ private fun setupClusterer(algorithmPosition: Int) { - val windowManager = getSystemService(WINDOW_SERVICE) as WindowManager val metrics = DisplayMetrics() val width: Int @@ -114,16 +116,32 @@ class ClusterAlgorithmsDemoActivity : BaseDemoActivity() { clusterManager = ClusterManager(this, map) // 3. Set the desired algorithm based on the spinner position - clusterManager?.algorithm = when (algorithmPosition) { - 1 -> GridBasedAlgorithm() - 2 -> NonHierarchicalDistanceBasedAlgorithm() - 3 -> CentroidNonHierarchicalDistanceBasedAlgorithm() - 4 -> NonHierarchicalViewBasedAlgorithm(widthDp, heightDp) - 5 -> ContinuousZoomEuclideanCentroidAlgorithm() - else -> { - GridBasedAlgorithm() + clusterManager?.algorithm = + when (algorithmPosition) { + 1 -> { + GridBasedAlgorithm() + } + + 2 -> { + NonHierarchicalDistanceBasedAlgorithm() + } + + 3 -> { + CentroidNonHierarchicalDistanceBasedAlgorithm() + } + + 4 -> { + NonHierarchicalViewBasedAlgorithm(widthDp, heightDp) + } + + 5 -> { + ContinuousZoomEuclideanCentroidAlgorithm() + } + + else -> { + GridBasedAlgorithm() + } } - } // 4. Point the map's listeners to the ClusterManager map.setOnCameraIdleListener(clusterManager) @@ -147,4 +165,4 @@ class ClusterAlgorithmsDemoActivity : BaseDemoActivity() { } return items } -} \ No newline at end of file +} diff --git a/demo/src/main/java/com/google/maps/android/utils/demo/ClusteringDemoActivity.java b/demo/src/main/java/com/google/maps/android/utils/demo/ClusteringDemoActivity.java index 76c4d58b0..0d3137e55 100644 --- a/demo/src/main/java/com/google/maps/android/utils/demo/ClusteringDemoActivity.java +++ b/demo/src/main/java/com/google/maps/android/utils/demo/ClusteringDemoActivity.java @@ -1,11 +1,11 @@ /* - * Copyright 2023 Google Inc. + * 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 + * 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, @@ -13,80 +13,77 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - package com.google.maps.android.utils.demo; import android.view.LayoutInflater; import android.view.View; import android.widget.TextView; import android.widget.Toast; - +import androidx.annotation.NonNull; import com.google.android.gms.maps.CameraUpdateFactory; import com.google.android.gms.maps.GoogleMap; import com.google.android.gms.maps.model.LatLng; import com.google.android.gms.maps.model.Marker; import com.google.maps.android.clustering.ClusterManager; import com.google.maps.android.utils.demo.model.MyItem; - -import org.json.JSONException; - import java.io.InputStream; import java.util.List; +import org.json.JSONException; -import androidx.annotation.NonNull; - -/** - * Simple activity demonstrating ClusterManager. - */ +/** Simple activity demonstrating ClusterManager. */ public class ClusteringDemoActivity extends BaseDemoActivity { - private ClusterManager mClusterManager; + private ClusterManager mClusterManager; - @Override - protected void startDemo(boolean isRestore) { - if (!isRestore) { - getMap().moveCamera(CameraUpdateFactory.newLatLngZoom(new LatLng(51.503186, -0.126446), 10)); - } + @Override + protected void startDemo(boolean isRestore) { + if (!isRestore) { + getMap().moveCamera(CameraUpdateFactory.newLatLngZoom(new LatLng(51.503186, -0.126446), 10)); + } - mClusterManager = new ClusterManager<>(this, getMap()); - getMap().setOnCameraIdleListener(mClusterManager); + mClusterManager = new ClusterManager<>(this, getMap()); + getMap().setOnCameraIdleListener(mClusterManager); - // Add a custom InfoWindowAdapter by setting it to the MarkerManager.Collection object from - // ClusterManager rather than from GoogleMap.setInfoWindowAdapter - mClusterManager.getMarkerCollection().setInfoWindowAdapter(new GoogleMap.InfoWindowAdapter() { - @Override - public View getInfoWindow(@NonNull Marker marker) { + // Add a custom InfoWindowAdapter by setting it to the MarkerManager.Collection object from + // ClusterManager rather than from GoogleMap.setInfoWindowAdapter + mClusterManager + .getMarkerCollection() + .setInfoWindowAdapter( + new GoogleMap.InfoWindowAdapter() { + @Override + public View getInfoWindow(@NonNull Marker marker) { final LayoutInflater inflater = LayoutInflater.from(ClusteringDemoActivity.this); final View view = inflater.inflate(R.layout.custom_info_window, null); final TextView textView = view.findViewById(R.id.textViewTitle); String text = (marker.getTitle() != null) ? marker.getTitle() : "Cluster Item"; textView.setText(text); return view; - } + } - @Override - public View getInfoContents(@NonNull Marker marker) { + @Override + public View getInfoContents(@NonNull Marker marker) { return null; - } - }); - mClusterManager.setOnClusterItemInfoWindowLongClickListener(marker -> - Toast.makeText(ClusteringDemoActivity.this, - "Info window clicked.", - Toast.LENGTH_SHORT).show()); - mClusterManager.setOnClusterItemInfoWindowLongClickListener(marker -> - Toast.makeText(ClusteringDemoActivity.this, - "Info window long pressed.", - Toast.LENGTH_SHORT).show()); + } + }); + mClusterManager.setOnClusterItemInfoWindowLongClickListener( + marker -> + Toast.makeText(ClusteringDemoActivity.this, "Info window clicked.", Toast.LENGTH_SHORT) + .show()); + mClusterManager.setOnClusterItemInfoWindowLongClickListener( + marker -> + Toast.makeText( + ClusteringDemoActivity.this, "Info window long pressed.", Toast.LENGTH_SHORT) + .show()); - try { - readItems(); - } catch (JSONException e) { - Toast.makeText(this, "Problem reading list of markers.", Toast.LENGTH_LONG).show(); - } + try { + readItems(); + } catch (JSONException e) { + Toast.makeText(this, "Problem reading list of markers.", Toast.LENGTH_LONG).show(); } + } - private void readItems() throws JSONException { - InputStream inputStream = getResources().openRawResource(R.raw.radar_search); - List items = new MyItemReader().read(inputStream); - mClusterManager.addItems(items); - } + private void readItems() throws JSONException { + InputStream inputStream = getResources().openRawResource(R.raw.radar_search); + List items = new MyItemReader().read(inputStream); + mClusterManager.addItems(items); + } } diff --git a/demo/src/main/java/com/google/maps/android/utils/demo/ClusteringDiffDemoActivity.java b/demo/src/main/java/com/google/maps/android/utils/demo/ClusteringDiffDemoActivity.java index 3e745c8f8..900bdaa3a 100644 --- a/demo/src/main/java/com/google/maps/android/utils/demo/ClusteringDiffDemoActivity.java +++ b/demo/src/main/java/com/google/maps/android/utils/demo/ClusteringDiffDemoActivity.java @@ -1,11 +1,11 @@ /* - * Copyright 2025 Google LLC + * 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 + * 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, @@ -13,7 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - package com.google.maps.android.utils.demo; import android.annotation.SuppressLint; @@ -24,10 +23,8 @@ import android.view.ViewGroup; import android.widget.ImageView; import android.widget.Toast; - import androidx.annotation.NonNull; import androidx.core.content.res.ResourcesCompat; - import com.google.android.gms.maps.CameraUpdateFactory; import com.google.android.gms.maps.GoogleMap; import com.google.android.gms.maps.model.BitmapDescriptor; @@ -42,252 +39,256 @@ import com.google.maps.android.clustering.view.ClusterRendererMultipleItems; import com.google.maps.android.ui.IconGenerator; import com.google.maps.android.utils.demo.model.Person; - import java.util.ArrayList; import java.util.List; enum City { - ENFIELD(new LatLng(51.6524, -0.0838), "Enfield"), - ILFORD(new LatLng(51.5590, -0.0815), "Ilford"), - LONDON(new LatLng(51.5074, -0.1278), "London"); + ENFIELD(new LatLng(51.6524, -0.0838), "Enfield"), + ILFORD(new LatLng(51.5590, -0.0815), "Ilford"), + LONDON(new LatLng(51.5074, -0.1278), "London"); - public final LatLng latLng; - public final String label; + public final LatLng latLng; + public final String label; - City(LatLng latLng, String label) { - this.latLng = latLng; - this.label = label; - } + City(LatLng latLng, String label) { + this.latLng = latLng; + this.label = label; + } } -/** - * Demonstrates how to apply a diff to the current Cluster - */ +/** Demonstrates how to apply a diff to the current Cluster */ public class ClusteringDiffDemoActivity extends BaseDemoActivity - implements ClusterManager.OnClusterClickListener, + implements ClusterManager.OnClusterClickListener, ClusterManager.OnClusterInfoWindowClickListener, ClusterManager.OnClusterItemClickListener, ClusterManager.OnClusterItemInfoWindowClickListener { - private final LatLng midpoint = getMidpoint(); - private ClusterManager mClusterManager; - private Person itemToUpdate = new Person(City.ENFIELD.latLng, "Teach", R.drawable.teacher); - private int currentLocationIndex = 0; - - protected int getLayoutId() { - return R.layout.map_with_floating_button; + private final LatLng midpoint = getMidpoint(); + private ClusterManager mClusterManager; + private Person itemToUpdate = new Person(City.ENFIELD.latLng, "Teach", R.drawable.teacher); + private int currentLocationIndex = 0; + + protected int getLayoutId() { + return R.layout.map_with_floating_button; + } + + @Override + public void onMapReady(@NonNull GoogleMap map) { + super.onMapReady(map); + findViewById(R.id.fab_rotate_location).setOnClickListener(v -> rotateLocation()); + getMap().animateCamera(CameraUpdateFactory.newLatLngZoom(midpoint, 12)); + } + + private LatLng getMidpoint() { + double latitude = 0.0; + double longitude = 0.0; + + for (City city : City.values()) { + latitude += city.latLng.latitude; + longitude += city.latLng.longitude; } - @Override - public void onMapReady(@NonNull GoogleMap map) { - super.onMapReady(map); - findViewById(R.id.fab_rotate_location).setOnClickListener(v -> rotateLocation()); - getMap().animateCamera(CameraUpdateFactory.newLatLngZoom(midpoint, 12)); - } + int numCities = City.values().length; - private LatLng getMidpoint() { - double latitude = 0.0; - double longitude = 0.0; + return new LatLng(latitude / numCities, longitude / numCities); + } - for (City city: City.values()) { - latitude += city.latLng.latitude; - longitude += city.latLng.longitude; - } + @Override + public boolean onClusterClick(Cluster cluster) { + // Show a toast with some info when the cluster is clicked. + String firstName = cluster.getItems().iterator().next().name; + Toast.makeText( + this, + getString(R.string.cluster_info_fmt, cluster.getSize(), firstName), + Toast.LENGTH_SHORT) + .show(); - int numCities = City.values().length; + // Zoom in the cluster. Need to create LatLngBounds and including all the cluster items + // inside of bounds, then animate to center of the bounds. - return new LatLng(latitude / numCities, longitude / numCities); + // Create the builder to collect all essential cluster items for the bounds. + LatLngBounds.Builder builder = LatLngBounds.builder(); + for (ClusterItem item : cluster.getItems()) { + builder.include(item.getPosition()); } - - @Override - public boolean onClusterClick(Cluster cluster) { - // Show a toast with some info when the cluster is clicked. - String firstName = cluster.getItems().iterator().next().name; - Toast.makeText(this, getString(R.string.cluster_info_fmt, cluster.getSize(), firstName), Toast.LENGTH_SHORT) - .show(); - - // Zoom in the cluster. Need to create LatLngBounds and including all the cluster items - // inside of bounds, then animate to center of the bounds. - - // Create the builder to collect all essential cluster items for the bounds. - LatLngBounds.Builder builder = LatLngBounds.builder(); - for (ClusterItem item : cluster.getItems()) { - builder.include(item.getPosition()); - } - // Get the LatLngBounds - final LatLngBounds bounds = builder.build(); - - // Animate camera to the bounds - try { - getMap().animateCamera(CameraUpdateFactory.newLatLngBounds(bounds, 200)); - } catch (Exception e) { - e.printStackTrace(); - } - - return true; + // Get the LatLngBounds + final LatLngBounds bounds = builder.build(); + + // Animate camera to the bounds + try { + getMap().animateCamera(CameraUpdateFactory.newLatLngBounds(bounds, 200)); + } catch (Exception e) { + e.printStackTrace(); } - @Override - public void onClusterInfoWindowClick(Cluster cluster) { - // Does nothing, but you could go to a list of the users. + return true; + } + + @Override + public void onClusterInfoWindowClick(Cluster cluster) { + // Does nothing, but you could go to a list of the users. + } + + @Override + public boolean onClusterItemClick(Person item) { + // Does nothing, but you could go into the user's profile page, for example. + return false; + } + + @Override + public void onClusterItemInfoWindowClick(Person item) { + // Does nothing, but you could go into the user's profile page, for example. + } + + @Override + protected void startDemo(boolean isRestore) { + if (!isRestore) { + getMap().moveCamera(CameraUpdateFactory.newLatLngZoom(new LatLng(51.503186, -0.126446), 6)); } - @Override - public boolean onClusterItemClick(Person item) { - // Does nothing, but you could go into the user's profile page, for example. - return false; - } + mClusterManager = new ClusterManager<>(this, getMap()); + mClusterManager.setRenderer(new PersonRenderer()); + getMap().setOnCameraIdleListener(mClusterManager); + getMap().setOnMarkerClickListener(mClusterManager); + getMap().setOnInfoWindowClickListener(mClusterManager); + mClusterManager.setOnClusterClickListener(this); + mClusterManager.setOnClusterInfoWindowClickListener(this); + mClusterManager.setOnClusterItemClickListener(this); + mClusterManager.setOnClusterItemInfoWindowClickListener(this); - @Override - public void onClusterItemInfoWindowClick(Person item) { - // Does nothing, but you could go into the user's profile page, for example. - } + addItems(); + mClusterManager.cluster(); + } - @Override - protected void startDemo(boolean isRestore) { - if (!isRestore) { - getMap().moveCamera(CameraUpdateFactory.newLatLngZoom(new LatLng(51.503186, -0.126446), 6)); - } + private void addItems() { + // Marker in Enfield + mClusterManager.addItem(new Person(City.ENFIELD.latLng, "John", R.drawable.john)); - mClusterManager = new ClusterManager<>(this, getMap()); - mClusterManager.setRenderer(new PersonRenderer()); - getMap().setOnCameraIdleListener(mClusterManager); - getMap().setOnMarkerClickListener(mClusterManager); - getMap().setOnInfoWindowClickListener(mClusterManager); - mClusterManager.setOnClusterClickListener(this); - mClusterManager.setOnClusterInfoWindowClickListener(this); - mClusterManager.setOnClusterItemClickListener(this); - mClusterManager.setOnClusterItemInfoWindowClickListener(this); - - addItems(); - mClusterManager.cluster(); - } + // Marker in the center of London + itemToUpdate = new Person(City.LONDON.latLng, "Teach", R.drawable.teacher); + mClusterManager.addItem(itemToUpdate); + } - private void addItems() { - // Marker in Enfield - mClusterManager.addItem(new Person(City.ENFIELD.latLng, "John", R.drawable.john)); + private void rotateLocation() { + // Update the current index to cycle through locations (0 = Enfield, 1 = Olford, 2 = London) + currentLocationIndex = (currentLocationIndex + 1) % City.values().length; - // Marker in the center of London - itemToUpdate = new Person(City.LONDON.latLng, "Teach", R.drawable.teacher); - mClusterManager.addItem(itemToUpdate); - } + City nextCity = City.values()[currentLocationIndex]; - private void rotateLocation() { - // Update the current index to cycle through locations (0 = Enfield, 1 = Olford, 2 = London) - currentLocationIndex = (currentLocationIndex + 1) % City.values().length; + LatLng newLocation = nextCity.latLng; + String cityName = nextCity.label; - City nextCity = City.values()[currentLocationIndex]; + Log.d("ClusterTest", "Item rotated to: " + newLocation.toString() + ", City: " + cityName); - LatLng newLocation = nextCity.latLng; - String cityName = nextCity.label; + if (itemToUpdate != null) { + itemToUpdate = new Person(newLocation, "Teach", R.drawable.teacher); + mClusterManager.updateItem(itemToUpdate); // Update the marker + mClusterManager.cluster(); + } + } + + /** + * Draws profile photos inside markers (using IconGenerator). When there are multiple people in + * the cluster, draw multiple photos (using MultiDrawable). + */ + @SuppressLint("InflateParams") + private class PersonRenderer extends ClusterRendererMultipleItems { + private final IconGenerator mIconGenerator = new IconGenerator(getApplicationContext()); + private final IconGenerator mClusterIconGenerator = new IconGenerator(getApplicationContext()); + private final ImageView mImageView; + private final ImageView mClusterImageView; + private final int mDimension; + + public PersonRenderer() { + super(getApplicationContext(), getMap(), mClusterManager); + + View multiProfile = getLayoutInflater().inflate(R.layout.multi_profile, null); + mClusterIconGenerator.setContentView(multiProfile); + mClusterImageView = multiProfile.findViewById(R.id.image); + + mImageView = new ImageView(getApplicationContext()); + mDimension = (int) getResources().getDimension(R.dimen.custom_profile_image); + mImageView.setLayoutParams(new ViewGroup.LayoutParams(mDimension, mDimension)); + int padding = (int) getResources().getDimension(R.dimen.custom_profile_padding); + mImageView.setPadding(padding, padding, padding, padding); + mIconGenerator.setContentView(mImageView); + } - Log.d("ClusterTest", "Item rotated to: " + newLocation.toString() + ", City: " + cityName); + @Override + protected void onBeforeClusterItemRendered( + @NonNull Person person, @NonNull MarkerOptions markerOptions) { + // Draw a single person - show their profile photo and set the info window to show their name + markerOptions.icon(getItemIcon(person)).title(person.name); + } - if (itemToUpdate != null) { - itemToUpdate = new Person(newLocation, "Teach", R.drawable.teacher); - mClusterManager.updateItem(itemToUpdate); // Update the marker - mClusterManager.cluster(); - } + @Override + protected void onClusterItemUpdated(@NonNull Person person, @NonNull Marker marker) { + // Same implementation as onBeforeClusterItemRendered() (to update cached markers) + marker.setIcon(getItemIcon(person)); + marker.setTitle(person.name); } /** - * Draws profile photos inside markers (using IconGenerator). - * When there are multiple people in the cluster, draw multiple photos (using MultiDrawable). + * Get a descriptor for a single person (i.e., a marker outside a cluster) from their profile + * photo to be used for a marker icon + * + * @param person person to return an BitmapDescriptor for + * @return the person's profile photo as a BitmapDescriptor */ - @SuppressLint("InflateParams") - private class PersonRenderer extends ClusterRendererMultipleItems { - private final IconGenerator mIconGenerator = new IconGenerator(getApplicationContext()); - private final IconGenerator mClusterIconGenerator = new IconGenerator(getApplicationContext()); - private final ImageView mImageView; - private final ImageView mClusterImageView; - private final int mDimension; - - public PersonRenderer() { - super(getApplicationContext(), getMap(), mClusterManager); - - View multiProfile = getLayoutInflater().inflate(R.layout.multi_profile, null); - mClusterIconGenerator.setContentView(multiProfile); - mClusterImageView = multiProfile.findViewById(R.id.image); - - mImageView = new ImageView(getApplicationContext()); - mDimension = (int) getResources().getDimension(R.dimen.custom_profile_image); - mImageView.setLayoutParams(new ViewGroup.LayoutParams(mDimension, mDimension)); - int padding = (int) getResources().getDimension(R.dimen.custom_profile_padding); - mImageView.setPadding(padding, padding, padding, padding); - mIconGenerator.setContentView(mImageView); - } - - @Override - protected void onBeforeClusterItemRendered(@NonNull Person person, @NonNull MarkerOptions markerOptions) { - // Draw a single person - show their profile photo and set the info window to show their name - markerOptions.icon(getItemIcon(person)).title(person.name); - } - - @Override - protected void onClusterItemUpdated(@NonNull Person person, @NonNull Marker marker) { - // Same implementation as onBeforeClusterItemRendered() (to update cached markers) - marker.setIcon(getItemIcon(person)); - marker.setTitle(person.name); - } - - /** - * Get a descriptor for a single person (i.e., a marker outside a cluster) from their - * profile photo to be used for a marker icon - * - * @param person person to return an BitmapDescriptor for - * @return the person's profile photo as a BitmapDescriptor - */ - private BitmapDescriptor getItemIcon(Person person) { - mImageView.setImageResource(person.profilePhoto); - Bitmap icon = mIconGenerator.makeIcon(); - return BitmapDescriptorFactory.fromBitmap(icon); - } + private BitmapDescriptor getItemIcon(Person person) { + mImageView.setImageResource(person.profilePhoto); + Bitmap icon = mIconGenerator.makeIcon(); + return BitmapDescriptorFactory.fromBitmap(icon); + } - @Override - protected void onBeforeClusterRendered(@NonNull Cluster cluster, @NonNull MarkerOptions markerOptions) { - // Draw multiple people. - // Note: this method runs on the UI thread. Don't spend too much time in here (like in this example). - markerOptions.icon(getClusterIcon(cluster)); - } + @Override + protected void onBeforeClusterRendered( + @NonNull Cluster cluster, @NonNull MarkerOptions markerOptions) { + // Draw multiple people. + // Note: this method runs on the UI thread. Don't spend too much time in here (like in this + // example). + markerOptions.icon(getClusterIcon(cluster)); + } - @Override - protected void onClusterUpdated(@NonNull Cluster cluster, @NonNull Marker marker) { - // Same implementation as onBeforeClusterRendered() (to update cached markers) - marker.setIcon(getClusterIcon(cluster)); - } + @Override + protected void onClusterUpdated(@NonNull Cluster cluster, @NonNull Marker marker) { + // Same implementation as onBeforeClusterRendered() (to update cached markers) + marker.setIcon(getClusterIcon(cluster)); + } - /** - * Get a descriptor for multiple people (a cluster) to be used for a marker icon. Note: this - * method runs on the UI thread. Don't spend too much time in here (like in this example). - * - * @param cluster cluster to draw a BitmapDescriptor for - * @return a BitmapDescriptor representing a cluster - */ - private BitmapDescriptor getClusterIcon(Cluster cluster) { - List profilePhotos = new ArrayList<>(Math.min(4, cluster.getSize())); - int width = mDimension; - int height = mDimension; - - for (Person p : cluster.getItems()) { - // Draw 4 at most. - if (profilePhotos.size() == 4) break; - Drawable drawable = ResourcesCompat.getDrawable(getBaseContext().getResources(), p.profilePhoto, null); - if (drawable != null) { - drawable.setBounds(0, 0, width, height); - } - profilePhotos.add(drawable); - } - MultiDrawable multiDrawable = new MultiDrawable(profilePhotos); - multiDrawable.setBounds(0, 0, width, height); - - mClusterImageView.setImageDrawable(multiDrawable); - Bitmap icon = mClusterIconGenerator.makeIcon(String.valueOf(cluster.getSize())); - return BitmapDescriptorFactory.fromBitmap(icon); + /** + * Get a descriptor for multiple people (a cluster) to be used for a marker icon. Note: this + * method runs on the UI thread. Don't spend too much time in here (like in this example). + * + * @param cluster cluster to draw a BitmapDescriptor for + * @return a BitmapDescriptor representing a cluster + */ + private BitmapDescriptor getClusterIcon(Cluster cluster) { + List profilePhotos = new ArrayList<>(Math.min(4, cluster.getSize())); + int width = mDimension; + int height = mDimension; + + for (Person p : cluster.getItems()) { + // Draw 4 at most. + if (profilePhotos.size() == 4) break; + Drawable drawable = + ResourcesCompat.getDrawable(getBaseContext().getResources(), p.profilePhoto, null); + if (drawable != null) { + drawable.setBounds(0, 0, width, height); } + profilePhotos.add(drawable); + } + MultiDrawable multiDrawable = new MultiDrawable(profilePhotos); + multiDrawable.setBounds(0, 0, width, height); + + mClusterImageView.setImageDrawable(multiDrawable); + Bitmap icon = mClusterIconGenerator.makeIcon(String.valueOf(cluster.getSize())); + return BitmapDescriptorFactory.fromBitmap(icon); + } - @Override - protected boolean shouldRenderAsCluster(@NonNull Cluster cluster) { - return cluster.getSize() >= 2; - } + @Override + protected boolean shouldRenderAsCluster(@NonNull Cluster cluster) { + return cluster.getSize() >= 2; } -} \ No newline at end of file + } +} diff --git a/demo/src/main/java/com/google/maps/android/utils/demo/ClusteringViewModel.java b/demo/src/main/java/com/google/maps/android/utils/demo/ClusteringViewModel.java index c3baad333..d7c44881c 100644 --- a/demo/src/main/java/com/google/maps/android/utils/demo/ClusteringViewModel.java +++ b/demo/src/main/java/com/google/maps/android/utils/demo/ClusteringViewModel.java @@ -1,11 +1,11 @@ /* - * Copyright 2019 Google Inc. + * 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 + * 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, @@ -13,46 +13,43 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - package com.google.maps.android.utils.demo; -import androidx.lifecycle.ViewModel; import android.content.res.Resources; - +import androidx.lifecycle.ViewModel; import com.google.android.gms.maps.model.LatLng; import com.google.maps.android.clustering.algo.NonHierarchicalViewBasedAlgorithm; import com.google.maps.android.utils.demo.model.MyItem; - -import org.json.JSONException; - import java.io.InputStream; import java.util.List; +import org.json.JSONException; public class ClusteringViewModel extends ViewModel { - private final NonHierarchicalViewBasedAlgorithm mAlgorithm = new NonHierarchicalViewBasedAlgorithm<>(0, 0); - - NonHierarchicalViewBasedAlgorithm getAlgorithm() { - return mAlgorithm; - } - - void readItems(Resources resources) throws JSONException { - InputStream inputStream = resources.openRawResource(R.raw.radar_search); - List items = new MyItemReader().read(inputStream); - mAlgorithm.lock(); - try { - for (int i = 0; i < 100; i++) { - double offset = i / 60d; - for (MyItem item : items) { - LatLng position = item.getPosition(); - double lat = position.latitude + offset; - double lng = position.longitude + offset; - MyItem offsetItem = new MyItem(lat, lng); - mAlgorithm.addItem(offsetItem); - } - } - } finally { - mAlgorithm.unlock(); + private final NonHierarchicalViewBasedAlgorithm mAlgorithm = + new NonHierarchicalViewBasedAlgorithm<>(0, 0); + + NonHierarchicalViewBasedAlgorithm getAlgorithm() { + return mAlgorithm; + } + + void readItems(Resources resources) throws JSONException { + InputStream inputStream = resources.openRawResource(R.raw.radar_search); + List items = new MyItemReader().read(inputStream); + mAlgorithm.lock(); + try { + for (int i = 0; i < 100; i++) { + double offset = i / 60d; + for (MyItem item : items) { + LatLng position = item.getPosition(); + double lat = position.latitude + offset; + double lng = position.longitude + offset; + MyItem offsetItem = new MyItem(lat, lng); + mAlgorithm.addItem(offsetItem); } + } + } finally { + mAlgorithm.unlock(); } + } } diff --git a/demo/src/main/java/com/google/maps/android/utils/demo/ClusteringViewModelDemoActivity.java b/demo/src/main/java/com/google/maps/android/utils/demo/ClusteringViewModelDemoActivity.java index 61fd9e172..e4b40c2fb 100644 --- a/demo/src/main/java/com/google/maps/android/utils/demo/ClusteringViewModelDemoActivity.java +++ b/demo/src/main/java/com/google/maps/android/utils/demo/ClusteringViewModelDemoActivity.java @@ -1,11 +1,11 @@ /* - * Copyright 2019 Google Inc. + * 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 + * 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, @@ -13,54 +13,51 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - package com.google.maps.android.utils.demo; -import androidx.lifecycle.ViewModelProviders; import android.os.Bundle; import android.util.DisplayMetrics; import android.widget.Toast; - +import androidx.lifecycle.ViewModelProviders; import com.google.android.gms.maps.CameraUpdateFactory; import com.google.android.gms.maps.model.LatLng; import com.google.maps.android.clustering.ClusterManager; import com.google.maps.android.utils.demo.model.MyItem; - import org.json.JSONException; public class ClusteringViewModelDemoActivity extends BaseDemoActivity { - private ClusteringViewModel mViewModel; - - @Override - public void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - mViewModel = ViewModelProviders.of(this).get(ClusteringViewModel.class); - if (savedInstanceState == null) { - try { - mViewModel.readItems(getResources()); - } catch (JSONException e) { - Toast.makeText(this, "Problem reading list of markers.", Toast.LENGTH_LONG).show(); - } - } + private ClusteringViewModel mViewModel; + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + mViewModel = ViewModelProviders.of(this).get(ClusteringViewModel.class); + if (savedInstanceState == null) { + try { + mViewModel.readItems(getResources()); + } catch (JSONException e) { + Toast.makeText(this, "Problem reading list of markers.", Toast.LENGTH_LONG).show(); + } } + } - @Override - protected void startDemo(boolean isRestore) { - if (!isRestore) { - getMap().moveCamera(CameraUpdateFactory.newLatLngZoom(new LatLng(51.503186, -0.126446), 10)); - } + @Override + protected void startDemo(boolean isRestore) { + if (!isRestore) { + getMap().moveCamera(CameraUpdateFactory.newLatLngZoom(new LatLng(51.503186, -0.126446), 10)); + } - DisplayMetrics metrics = new DisplayMetrics(); - getWindowManager().getDefaultDisplay().getMetrics(metrics); + DisplayMetrics metrics = new DisplayMetrics(); + getWindowManager().getDefaultDisplay().getMetrics(metrics); - int widthDp = (int) (metrics.widthPixels / metrics.density); - int heightDp = (int) (metrics.heightPixels / metrics.density); + int widthDp = (int) (metrics.widthPixels / metrics.density); + int heightDp = (int) (metrics.heightPixels / metrics.density); - mViewModel.getAlgorithm().updateViewSize(widthDp, heightDp); + mViewModel.getAlgorithm().updateViewSize(widthDp, heightDp); - ClusterManager mClusterManager = new ClusterManager<>(this, getMap()); - mClusterManager.setAlgorithm(mViewModel.getAlgorithm()); + ClusterManager mClusterManager = new ClusterManager<>(this, getMap()); + mClusterManager.setAlgorithm(mViewModel.getAlgorithm()); - getMap().setOnCameraIdleListener(mClusterManager); - } + getMap().setOnCameraIdleListener(mClusterManager); + } } diff --git a/demo/src/main/java/com/google/maps/android/utils/demo/CustomAdvancedMarkerClusteringDemoActivity.java b/demo/src/main/java/com/google/maps/android/utils/demo/CustomAdvancedMarkerClusteringDemoActivity.java index c6f253be1..fc9e0f2eb 100644 --- a/demo/src/main/java/com/google/maps/android/utils/demo/CustomAdvancedMarkerClusteringDemoActivity.java +++ b/demo/src/main/java/com/google/maps/android/utils/demo/CustomAdvancedMarkerClusteringDemoActivity.java @@ -1,232 +1,232 @@ -/* - * Copyright 2023 Google Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.maps.android.utils.demo; - -import java.util.Objects; -import java.util.Random; - -import android.annotation.SuppressLint; -import android.graphics.Color; -import android.util.Log; -import android.view.View; -import android.widget.TextView; -import android.widget.Toast; - -import com.google.android.gms.maps.CameraUpdateFactory; -import com.google.android.gms.maps.MapsInitializer; -import com.google.android.gms.maps.OnMapsSdkInitializedCallback; -import com.google.android.gms.maps.model.AdvancedMarker; -import com.google.android.gms.maps.model.AdvancedMarkerOptions; -import com.google.android.gms.maps.model.BitmapDescriptorFactory; -import com.google.android.gms.maps.model.LatLng; -import com.google.android.gms.maps.model.LatLngBounds; -import com.google.android.gms.maps.model.Marker; -import com.google.android.gms.maps.model.PinConfig; -import com.google.maps.android.clustering.Cluster; -import com.google.maps.android.clustering.ClusterItem; -import com.google.maps.android.clustering.ClusterManager; -import com.google.maps.android.clustering.view.DefaultAdvancedMarkersClusterRenderer; -import com.google.maps.android.utils.demo.model.Person; - -import androidx.annotation.NonNull; - -/** - * This sample demonstrates how to make use of the new Advanced Markers. - */ -public class CustomAdvancedMarkerClusteringDemoActivity extends BaseDemoActivity implements - ClusterManager.OnClusterClickListener, ClusterManager.OnClusterInfoWindowClickListener, - ClusterManager.OnClusterItemClickListener, ClusterManager.OnClusterItemInfoWindowClickListener, - OnMapsSdkInitializedCallback { - private ClusterManager mClusterManager; - private final Random mRandom = new Random(1984); - - @Override - public void onMapsSdkInitialized(@NonNull MapsInitializer.Renderer renderer) { - switch (renderer) { - case LATEST: - Log.d("MapsDemo", getString(R.string.renderer_latest)); - break; - case LEGACY: - Log.d("MapsDemo", getString(R.string.renderer_legacy)); - break; - default: - break; - } - } - - private class AdvancedMarkerRenderer extends DefaultAdvancedMarkersClusterRenderer { - - public AdvancedMarkerRenderer() { - super(getApplicationContext(), getMap(), mClusterManager); - } - - @Override - protected void onBeforeClusterItemRendered(@NonNull Person person, - @NonNull AdvancedMarkerOptions markerOptions) { - markerOptions - .icon(BitmapDescriptorFactory.fromPinConfig(getPinConfig(person).build())) - .title(person.name); - } - - @Override - protected void onClusterItemUpdated(@NonNull Person person, @NonNull Marker marker) { - // Same implementation as onBeforeClusterItemRendered() (to update cached markers) - marker.setIcon(BitmapDescriptorFactory.fromPinConfig(getPinConfig(person).build())); - marker.setTitle(person.name); - } - - private PinConfig.Builder getPinConfig(Person person) { - PinConfig.Builder pinConfigBuilder = PinConfig.builder(); - pinConfigBuilder.setBackgroundColor(Color.MAGENTA); - pinConfigBuilder.setBorderColor(getResources().getColor(R.color.colorPrimaryDark)); - pinConfigBuilder.setGlyph(new PinConfig.Glyph(BitmapDescriptorFactory.fromResource(person.profilePhoto))); - return pinConfigBuilder; - } - - @SuppressLint("SetTextI18n") - private View addTextAsMarker(int size) { - TextView textView = new TextView(getApplicationContext()); - textView.setText(getString(R.string.cluster_text_fmt, size)); - textView.setBackgroundColor(Color.BLACK); - textView.setTextColor(Color.YELLOW); - return textView; - } - - @Override - protected void onBeforeClusterRendered(@NonNull Cluster cluster, - @NonNull AdvancedMarkerOptions markerOptions) { - markerOptions.iconView(addTextAsMarker(cluster.getSize())); - } - - @Override - protected void onClusterUpdated(@NonNull Cluster cluster, AdvancedMarker marker) { - marker.setIconView(addTextAsMarker(cluster.getSize())); - } - - - @Override - protected boolean shouldRenderAsCluster(@NonNull Cluster cluster) { - // Always render clusters. - return cluster.getSize() > 1; - } - } - - - @Override - public boolean onClusterClick(Cluster cluster) { - // Show a toast with some info when the cluster is clicked. - String firstName = cluster.getItems().iterator().next().name; - Toast.makeText(this, getString(R.string.cluster_info_fmt, cluster.getSize(), firstName), Toast.LENGTH_SHORT) - .show(); - - // Zoom in the cluster. Need to create LatLngBounds and including all the cluster items - // inside of bounds, then animate to center of the bounds. - - // Create the builder to collect all essential cluster items for the bounds. - LatLngBounds.Builder builder = LatLngBounds.builder(); - for (ClusterItem item : cluster.getItems()) { - builder.include(item.getPosition()); - } - // Get the LatLngBounds - final LatLngBounds bounds = builder.build(); - - // Animate camera to the bounds - try { - getMap().animateCamera(CameraUpdateFactory.newLatLngBounds(bounds, 100)); - } catch (Exception e) { - Log.e("MapsDemo", Objects.requireNonNull(e.getMessage())); - } - - return true; - } - - @Override - public void onClusterInfoWindowClick(Cluster cluster) { - // Does nothing, but you could go to a list of the users. - } - - @Override - public boolean onClusterItemClick(Person item) { - // Does nothing, but you could go into the user's profile page, for example. - return false; - } - - @Override - public void onClusterItemInfoWindowClick(Person item) { - // Does nothing, but you could go into the user's profile page, for example. - } - - @Override - protected void startDemo(boolean isRestore) { - if (!isRestore) { - getMap().moveCamera(CameraUpdateFactory.newLatLngZoom(new LatLng(51.503186, -0.126446), 9.5f)); - } - - // This line is extremely important to initialise the advanced markers. Without it, advanced markers will not work. - MapsInitializer.initialize(getApplicationContext(), MapsInitializer.Renderer.LATEST, this); - - mClusterManager = new ClusterManager<>(this, getMap()); - mClusterManager.setRenderer(new AdvancedMarkerRenderer()); - getMap().setOnCameraIdleListener(mClusterManager); - getMap().setOnMarkerClickListener(mClusterManager); - getMap().setOnInfoWindowClickListener(mClusterManager); - mClusterManager.setOnClusterClickListener(this); - mClusterManager.setOnClusterInfoWindowClickListener(this); - mClusterManager.setOnClusterItemClickListener(this); - mClusterManager.setOnClusterItemInfoWindowClickListener(this); - - addItems(); - mClusterManager.cluster(); - } - - private void addItems() { - // http://www.flickr.com/photos/sdasmarchives/5036248203/ - mClusterManager.addItem(new Person(position(), "Walter", R.drawable.walter)); - - // http://www.flickr.com/photos/usnationalarchives/4726917149/ - mClusterManager.addItem(new Person(position(), "Gran", R.drawable.gran)); - - // http://www.flickr.com/photos/nypl/3111525394/ - mClusterManager.addItem(new Person(position(), "Ruth", R.drawable.ruth)); - - // http://www.flickr.com/photos/smithsonian/2887433330/ - mClusterManager.addItem(new Person(position(), "Stefan", R.drawable.stefan)); - - // http://www.flickr.com/photos/library_of_congress/2179915182/ - mClusterManager.addItem(new Person(position(), "Mechanic", R.drawable.mechanic)); - - // http://www.flickr.com/photos/nationalmediamuseum/7893552556/ - mClusterManager.addItem(new Person(position(), "Yeats", R.drawable.yeats)); - - // http://www.flickr.com/photos/sdasmarchives/5036231225/ - mClusterManager.addItem(new Person(position(), "John", R.drawable.john)); - - // http://www.flickr.com/photos/anmm_thecommons/7694202096/ - mClusterManager.addItem(new Person(position(), "Trevor the Turtle", R.drawable.turtle)); - - // http://www.flickr.com/photos/usnationalarchives/4726892651/ - mClusterManager.addItem(new Person(position(), "Teach", R.drawable.teacher)); - } - - private LatLng position() { - return new LatLng(random(51.6723432, 51.38494009999999), random(0.148271, -0.3514683)); - } - - private double random(double min, double max) { - return mRandom.nextDouble() * (max - min) + min; - } -} \ No newline at end of file +/* + * 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.utils.demo; + +import android.annotation.SuppressLint; +import android.graphics.Color; +import android.util.Log; +import android.view.View; +import android.widget.TextView; +import android.widget.Toast; +import androidx.annotation.NonNull; +import com.google.android.gms.maps.CameraUpdateFactory; +import com.google.android.gms.maps.MapsInitializer; +import com.google.android.gms.maps.OnMapsSdkInitializedCallback; +import com.google.android.gms.maps.model.AdvancedMarker; +import com.google.android.gms.maps.model.AdvancedMarkerOptions; +import com.google.android.gms.maps.model.BitmapDescriptorFactory; +import com.google.android.gms.maps.model.LatLng; +import com.google.android.gms.maps.model.LatLngBounds; +import com.google.android.gms.maps.model.Marker; +import com.google.android.gms.maps.model.PinConfig; +import com.google.maps.android.clustering.Cluster; +import com.google.maps.android.clustering.ClusterItem; +import com.google.maps.android.clustering.ClusterManager; +import com.google.maps.android.clustering.view.DefaultAdvancedMarkersClusterRenderer; +import com.google.maps.android.utils.demo.model.Person; +import java.util.Objects; +import java.util.Random; + +/** This sample demonstrates how to make use of the new Advanced Markers. */ +public class CustomAdvancedMarkerClusteringDemoActivity extends BaseDemoActivity + implements ClusterManager.OnClusterClickListener, + ClusterManager.OnClusterInfoWindowClickListener, + ClusterManager.OnClusterItemClickListener, + ClusterManager.OnClusterItemInfoWindowClickListener, + OnMapsSdkInitializedCallback { + private ClusterManager mClusterManager; + private final Random mRandom = new Random(1984); + + @Override + public void onMapsSdkInitialized(@NonNull MapsInitializer.Renderer renderer) { + switch (renderer) { + case LATEST: + Log.d("MapsDemo", getString(R.string.renderer_latest)); + break; + case LEGACY: + Log.d("MapsDemo", getString(R.string.renderer_legacy)); + break; + default: + break; + } + } + + private class AdvancedMarkerRenderer extends DefaultAdvancedMarkersClusterRenderer { + + public AdvancedMarkerRenderer() { + super(getApplicationContext(), getMap(), mClusterManager); + } + + @Override + protected void onBeforeClusterItemRendered( + @NonNull Person person, @NonNull AdvancedMarkerOptions markerOptions) { + markerOptions + .icon(BitmapDescriptorFactory.fromPinConfig(getPinConfig(person).build())) + .title(person.name); + } + + @Override + protected void onClusterItemUpdated(@NonNull Person person, @NonNull Marker marker) { + // Same implementation as onBeforeClusterItemRendered() (to update cached markers) + marker.setIcon(BitmapDescriptorFactory.fromPinConfig(getPinConfig(person).build())); + marker.setTitle(person.name); + } + + private PinConfig.Builder getPinConfig(Person person) { + PinConfig.Builder pinConfigBuilder = PinConfig.builder(); + pinConfigBuilder.setBackgroundColor(Color.MAGENTA); + pinConfigBuilder.setBorderColor(getResources().getColor(R.color.colorPrimaryDark)); + pinConfigBuilder.setGlyph( + new PinConfig.Glyph(BitmapDescriptorFactory.fromResource(person.profilePhoto))); + return pinConfigBuilder; + } + + @SuppressLint("SetTextI18n") + private View addTextAsMarker(int size) { + TextView textView = new TextView(getApplicationContext()); + textView.setText(getString(R.string.cluster_text_fmt, size)); + textView.setBackgroundColor(Color.BLACK); + textView.setTextColor(Color.YELLOW); + return textView; + } + + @Override + protected void onBeforeClusterRendered( + @NonNull Cluster cluster, @NonNull AdvancedMarkerOptions markerOptions) { + markerOptions.iconView(addTextAsMarker(cluster.getSize())); + } + + @Override + protected void onClusterUpdated(@NonNull Cluster cluster, AdvancedMarker marker) { + marker.setIconView(addTextAsMarker(cluster.getSize())); + } + + @Override + protected boolean shouldRenderAsCluster(@NonNull Cluster cluster) { + // Always render clusters. + return cluster.getSize() > 1; + } + } + + @Override + public boolean onClusterClick(Cluster cluster) { + // Show a toast with some info when the cluster is clicked. + String firstName = cluster.getItems().iterator().next().name; + Toast.makeText( + this, + getString(R.string.cluster_info_fmt, cluster.getSize(), firstName), + Toast.LENGTH_SHORT) + .show(); + + // Zoom in the cluster. Need to create LatLngBounds and including all the cluster items + // inside of bounds, then animate to center of the bounds. + + // Create the builder to collect all essential cluster items for the bounds. + LatLngBounds.Builder builder = LatLngBounds.builder(); + for (ClusterItem item : cluster.getItems()) { + builder.include(item.getPosition()); + } + // Get the LatLngBounds + final LatLngBounds bounds = builder.build(); + + // Animate camera to the bounds + try { + getMap().animateCamera(CameraUpdateFactory.newLatLngBounds(bounds, 100)); + } catch (Exception e) { + Log.e("MapsDemo", Objects.requireNonNull(e.getMessage())); + } + + return true; + } + + @Override + public void onClusterInfoWindowClick(Cluster cluster) { + // Does nothing, but you could go to a list of the users. + } + + @Override + public boolean onClusterItemClick(Person item) { + // Does nothing, but you could go into the user's profile page, for example. + return false; + } + + @Override + public void onClusterItemInfoWindowClick(Person item) { + // Does nothing, but you could go into the user's profile page, for example. + } + + @Override + protected void startDemo(boolean isRestore) { + if (!isRestore) { + getMap() + .moveCamera(CameraUpdateFactory.newLatLngZoom(new LatLng(51.503186, -0.126446), 9.5f)); + } + + // This line is extremely important to initialise the advanced markers. Without it, advanced + // markers will not work. + MapsInitializer.initialize(getApplicationContext(), MapsInitializer.Renderer.LATEST, this); + + mClusterManager = new ClusterManager<>(this, getMap()); + mClusterManager.setRenderer(new AdvancedMarkerRenderer()); + getMap().setOnCameraIdleListener(mClusterManager); + getMap().setOnMarkerClickListener(mClusterManager); + getMap().setOnInfoWindowClickListener(mClusterManager); + mClusterManager.setOnClusterClickListener(this); + mClusterManager.setOnClusterInfoWindowClickListener(this); + mClusterManager.setOnClusterItemClickListener(this); + mClusterManager.setOnClusterItemInfoWindowClickListener(this); + + addItems(); + mClusterManager.cluster(); + } + + private void addItems() { + // http://www.flickr.com/photos/sdasmarchives/5036248203/ + mClusterManager.addItem(new Person(position(), "Walter", R.drawable.walter)); + + // http://www.flickr.com/photos/usnationalarchives/4726917149/ + mClusterManager.addItem(new Person(position(), "Gran", R.drawable.gran)); + + // http://www.flickr.com/photos/nypl/3111525394/ + mClusterManager.addItem(new Person(position(), "Ruth", R.drawable.ruth)); + + // http://www.flickr.com/photos/smithsonian/2887433330/ + mClusterManager.addItem(new Person(position(), "Stefan", R.drawable.stefan)); + + // http://www.flickr.com/photos/library_of_congress/2179915182/ + mClusterManager.addItem(new Person(position(), "Mechanic", R.drawable.mechanic)); + + // http://www.flickr.com/photos/nationalmediamuseum/7893552556/ + mClusterManager.addItem(new Person(position(), "Yeats", R.drawable.yeats)); + + // http://www.flickr.com/photos/sdasmarchives/5036231225/ + mClusterManager.addItem(new Person(position(), "John", R.drawable.john)); + + // http://www.flickr.com/photos/anmm_thecommons/7694202096/ + mClusterManager.addItem(new Person(position(), "Trevor the Turtle", R.drawable.turtle)); + + // http://www.flickr.com/photos/usnationalarchives/4726892651/ + mClusterManager.addItem(new Person(position(), "Teach", R.drawable.teacher)); + } + + private LatLng position() { + return new LatLng(random(51.6723432, 51.38494009999999), random(0.148271, -0.3514683)); + } + + private double random(double min, double max) { + return mRandom.nextDouble() * (max - min) + min; + } +} diff --git a/demo/src/main/java/com/google/maps/android/utils/demo/CustomMarkerClusteringDemoActivity.java b/demo/src/main/java/com/google/maps/android/utils/demo/CustomMarkerClusteringDemoActivity.java index d272696b6..5d7e2ea6c 100644 --- a/demo/src/main/java/com/google/maps/android/utils/demo/CustomMarkerClusteringDemoActivity.java +++ b/demo/src/main/java/com/google/maps/android/utils/demo/CustomMarkerClusteringDemoActivity.java @@ -1,11 +1,11 @@ /* - * Copyright 2013 Google Inc. + * 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 + * 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, @@ -13,7 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - package com.google.maps.android.utils.demo; import android.graphics.Bitmap; @@ -22,9 +21,7 @@ import android.view.ViewGroup; import android.widget.ImageView; import android.widget.Toast; - import androidx.annotation.NonNull; - import com.google.android.gms.maps.CameraUpdateFactory; import com.google.android.gms.maps.model.BitmapDescriptor; import com.google.android.gms.maps.model.BitmapDescriptorFactory; @@ -38,216 +35,220 @@ import com.google.maps.android.clustering.view.DefaultClusterRenderer; import com.google.maps.android.ui.IconGenerator; import com.google.maps.android.utils.demo.model.Person; - import java.util.ArrayList; import java.util.List; import java.util.Random; -/** - * Demonstrates heavy customisation of the look of rendered clusters. - */ -public class CustomMarkerClusteringDemoActivity extends BaseDemoActivity implements ClusterManager.OnClusterClickListener, ClusterManager.OnClusterInfoWindowClickListener, ClusterManager.OnClusterItemClickListener, ClusterManager.OnClusterItemInfoWindowClickListener { - private ClusterManager mClusterManager; - private final Random mRandom = new Random(1984); +/** Demonstrates heavy customisation of the look of rendered clusters. */ +public class CustomMarkerClusteringDemoActivity extends BaseDemoActivity + implements ClusterManager.OnClusterClickListener, + ClusterManager.OnClusterInfoWindowClickListener, + ClusterManager.OnClusterItemClickListener, + ClusterManager.OnClusterItemInfoWindowClickListener { + private ClusterManager mClusterManager; + private final Random mRandom = new Random(1984); + + /** + * Draws profile photos inside markers (using IconGenerator). When there are multiple people in + * the cluster, draw multiple photos (using MultiDrawable). + */ + private class PersonRenderer extends DefaultClusterRenderer { + private final IconGenerator mIconGenerator = new IconGenerator(getApplicationContext()); + private final IconGenerator mClusterIconGenerator = new IconGenerator(getApplicationContext()); + private final ImageView mImageView; + private final ImageView mClusterImageView; + private final int mDimension; + + public PersonRenderer() { + super(getApplicationContext(), getMap(), mClusterManager); + + View multiProfile = getLayoutInflater().inflate(R.layout.multi_profile, null); + mClusterIconGenerator.setContentView(multiProfile); + mClusterImageView = multiProfile.findViewById(R.id.image); + + mImageView = new ImageView(getApplicationContext()); + mDimension = (int) getResources().getDimension(R.dimen.custom_profile_image); + mImageView.setLayoutParams(new ViewGroup.LayoutParams(mDimension, mDimension)); + int padding = (int) getResources().getDimension(R.dimen.custom_profile_padding); + mImageView.setPadding(padding, padding, padding, padding); + mIconGenerator.setContentView(mImageView); + } + + @Override + protected void onBeforeClusterItemRendered( + @NonNull Person person, @NonNull MarkerOptions markerOptions) { + // Draw a single person - show their profile photo and set the info window to show their name + markerOptions.icon(getItemIcon(person)).title(person.name); + } + + @Override + protected void onClusterItemUpdated(@NonNull Person person, @NonNull Marker marker) { + // Same implementation as onBeforeClusterItemRendered() (to update cached markers) + marker.setIcon(getItemIcon(person)); + marker.setTitle(person.name); + } /** - * Draws profile photos inside markers (using IconGenerator). - * When there are multiple people in the cluster, draw multiple photos (using MultiDrawable). + * Get a descriptor for a single person (i.e., a marker outside a cluster) from their profile + * photo to be used for a marker icon + * + * @param person person to return an BitmapDescriptor for + * @return the person's profile photo as a BitmapDescriptor */ - private class PersonRenderer extends DefaultClusterRenderer { - private final IconGenerator mIconGenerator = new IconGenerator(getApplicationContext()); - private final IconGenerator mClusterIconGenerator = new IconGenerator(getApplicationContext()); - private final ImageView mImageView; - private final ImageView mClusterImageView; - private final int mDimension; - - public PersonRenderer() { - super(getApplicationContext(), getMap(), mClusterManager); - - View multiProfile = getLayoutInflater().inflate(R.layout.multi_profile, null); - mClusterIconGenerator.setContentView(multiProfile); - mClusterImageView = multiProfile.findViewById(R.id.image); - - mImageView = new ImageView(getApplicationContext()); - mDimension = (int) getResources().getDimension(R.dimen.custom_profile_image); - mImageView.setLayoutParams(new ViewGroup.LayoutParams(mDimension, mDimension)); - int padding = (int) getResources().getDimension(R.dimen.custom_profile_padding); - mImageView.setPadding(padding, padding, padding, padding); - mIconGenerator.setContentView(mImageView); - } - - @Override - protected void onBeforeClusterItemRendered(@NonNull Person person, @NonNull MarkerOptions markerOptions) { - // Draw a single person - show their profile photo and set the info window to show their name - markerOptions - .icon(getItemIcon(person)) - .title(person.name); - } - - @Override - protected void onClusterItemUpdated(@NonNull Person person, @NonNull Marker marker) { - // Same implementation as onBeforeClusterItemRendered() (to update cached markers) - marker.setIcon(getItemIcon(person)); - marker.setTitle(person.name); - } - - /** - * Get a descriptor for a single person (i.e., a marker outside a cluster) from their - * profile photo to be used for a marker icon - * - * @param person person to return an BitmapDescriptor for - * @return the person's profile photo as a BitmapDescriptor - */ - private BitmapDescriptor getItemIcon(Person person) { - mImageView.setImageResource(person.profilePhoto); - Bitmap icon = mIconGenerator.makeIcon(); - return BitmapDescriptorFactory.fromBitmap(icon); - } - - @Override - protected void onBeforeClusterRendered(@NonNull Cluster cluster, @NonNull MarkerOptions markerOptions) { - // Draw multiple people. - // Note: this method runs on the UI thread. Don't spend too much time in here (like in this example). - markerOptions.icon(getClusterIcon(cluster)); - } - - @Override - protected void onClusterUpdated(@NonNull Cluster cluster, Marker marker) { - // Same implementation as onBeforeClusterRendered() (to update cached markers) - marker.setIcon(getClusterIcon(cluster)); - } - - /** - * Get a descriptor for multiple people (a cluster) to be used for a marker icon. Note: this - * method runs on the UI thread. Don't spend too much time in here (like in this example). - * - * @param cluster cluster to draw a BitmapDescriptor for - * @return a BitmapDescriptor representing a cluster - */ - private BitmapDescriptor getClusterIcon(Cluster cluster) { - List profilePhotos = new ArrayList<>(Math.min(4, cluster.getSize())); - int width = mDimension; - int height = mDimension; - - for (Person p : cluster.getItems()) { - // Draw 4 at most. - if (profilePhotos.size() == 4) break; - Drawable drawable = getResources().getDrawable(p.profilePhoto); - drawable.setBounds(0, 0, width, height); - profilePhotos.add(drawable); - } - MultiDrawable multiDrawable = new MultiDrawable(profilePhotos); - multiDrawable.setBounds(0, 0, width, height); - - mClusterImageView.setImageDrawable(multiDrawable); - Bitmap icon = mClusterIconGenerator.makeIcon(String.valueOf(cluster.getSize())); - return BitmapDescriptorFactory.fromBitmap(icon); - } - - @Override - protected boolean shouldRenderAsCluster(@NonNull Cluster cluster) { - // Always render clusters. - return cluster.getSize() > 1; - } + private BitmapDescriptor getItemIcon(Person person) { + mImageView.setImageResource(person.profilePhoto); + Bitmap icon = mIconGenerator.makeIcon(); + return BitmapDescriptorFactory.fromBitmap(icon); } @Override - public boolean onClusterClick(Cluster cluster) { - // Show a toast with some info when the cluster is clicked. - String firstName = cluster.getItems().iterator().next().name; - Toast.makeText(this, cluster.getSize() + " (including " + firstName + ")", Toast.LENGTH_SHORT).show(); - - // Zoom in the cluster. Need to create LatLngBounds and including all the cluster items - // inside of bounds, then animate to center of the bounds. - - // Create the builder to collect all essential cluster items for the bounds. - LatLngBounds.Builder builder = LatLngBounds.builder(); - for (ClusterItem item : cluster.getItems()) { - builder.include(item.getPosition()); - } - // Get the LatLngBounds - final LatLngBounds bounds = builder.build(); - - // Animate camera to the bounds - try { - getMap().animateCamera(CameraUpdateFactory.newLatLngBounds(bounds, 100)); - } catch (Exception e) { - e.printStackTrace(); - } - - return true; + protected void onBeforeClusterRendered( + @NonNull Cluster cluster, @NonNull MarkerOptions markerOptions) { + // Draw multiple people. + // Note: this method runs on the UI thread. Don't spend too much time in here (like in this + // example). + markerOptions.icon(getClusterIcon(cluster)); } @Override - public void onClusterInfoWindowClick(Cluster cluster) { - // Does nothing, but you could go to a list of the users. + protected void onClusterUpdated(@NonNull Cluster cluster, Marker marker) { + // Same implementation as onBeforeClusterRendered() (to update cached markers) + marker.setIcon(getClusterIcon(cluster)); } - @Override - public boolean onClusterItemClick(Person item) { - // Does nothing, but you could go into the user's profile page, for example. - return false; + /** + * Get a descriptor for multiple people (a cluster) to be used for a marker icon. Note: this + * method runs on the UI thread. Don't spend too much time in here (like in this example). + * + * @param cluster cluster to draw a BitmapDescriptor for + * @return a BitmapDescriptor representing a cluster + */ + private BitmapDescriptor getClusterIcon(Cluster cluster) { + List profilePhotos = new ArrayList<>(Math.min(4, cluster.getSize())); + int width = mDimension; + int height = mDimension; + + for (Person p : cluster.getItems()) { + // Draw 4 at most. + if (profilePhotos.size() == 4) break; + Drawable drawable = getResources().getDrawable(p.profilePhoto); + drawable.setBounds(0, 0, width, height); + profilePhotos.add(drawable); + } + MultiDrawable multiDrawable = new MultiDrawable(profilePhotos); + multiDrawable.setBounds(0, 0, width, height); + + mClusterImageView.setImageDrawable(multiDrawable); + Bitmap icon = mClusterIconGenerator.makeIcon(String.valueOf(cluster.getSize())); + return BitmapDescriptorFactory.fromBitmap(icon); } @Override - public void onClusterItemInfoWindowClick(Person item) { - // Does nothing, but you could go into the user's profile page, for example. + protected boolean shouldRenderAsCluster(@NonNull Cluster cluster) { + // Always render clusters. + return cluster.getSize() > 1; + } + } + + @Override + public boolean onClusterClick(Cluster cluster) { + // Show a toast with some info when the cluster is clicked. + String firstName = cluster.getItems().iterator().next().name; + Toast.makeText(this, cluster.getSize() + " (including " + firstName + ")", Toast.LENGTH_SHORT) + .show(); + + // Zoom in the cluster. Need to create LatLngBounds and including all the cluster items + // inside of bounds, then animate to center of the bounds. + + // Create the builder to collect all essential cluster items for the bounds. + LatLngBounds.Builder builder = LatLngBounds.builder(); + for (ClusterItem item : cluster.getItems()) { + builder.include(item.getPosition()); + } + // Get the LatLngBounds + final LatLngBounds bounds = builder.build(); + + // Animate camera to the bounds + try { + getMap().animateCamera(CameraUpdateFactory.newLatLngBounds(bounds, 100)); + } catch (Exception e) { + e.printStackTrace(); } - @Override - protected void startDemo(boolean isRestore) { - if (!isRestore) { - getMap().moveCamera(CameraUpdateFactory.newLatLngZoom(new LatLng(51.503186, -0.126446), 9.5f)); - } - - mClusterManager = new ClusterManager<>(this, getMap()); - mClusterManager.setRenderer(new PersonRenderer()); - getMap().setOnCameraIdleListener(mClusterManager); - getMap().setOnMarkerClickListener(mClusterManager); - getMap().setOnInfoWindowClickListener(mClusterManager); - mClusterManager.setOnClusterClickListener(this); - mClusterManager.setOnClusterInfoWindowClickListener(this); - mClusterManager.setOnClusterItemClickListener(this); - mClusterManager.setOnClusterItemInfoWindowClickListener(this); - - addItems(); - mClusterManager.cluster(); + return true; + } + + @Override + public void onClusterInfoWindowClick(Cluster cluster) { + // Does nothing, but you could go to a list of the users. + } + + @Override + public boolean onClusterItemClick(Person item) { + // Does nothing, but you could go into the user's profile page, for example. + return false; + } + + @Override + public void onClusterItemInfoWindowClick(Person item) { + // Does nothing, but you could go into the user's profile page, for example. + } + + @Override + protected void startDemo(boolean isRestore) { + if (!isRestore) { + getMap() + .moveCamera(CameraUpdateFactory.newLatLngZoom(new LatLng(51.503186, -0.126446), 9.5f)); } - private void addItems() { - // http://www.flickr.com/photos/sdasmarchives/5036248203/ - mClusterManager.addItem(new Person(position(), "Walter", R.drawable.walter)); + mClusterManager = new ClusterManager<>(this, getMap()); + mClusterManager.setRenderer(new PersonRenderer()); + getMap().setOnCameraIdleListener(mClusterManager); + getMap().setOnMarkerClickListener(mClusterManager); + getMap().setOnInfoWindowClickListener(mClusterManager); + mClusterManager.setOnClusterClickListener(this); + mClusterManager.setOnClusterInfoWindowClickListener(this); + mClusterManager.setOnClusterItemClickListener(this); + mClusterManager.setOnClusterItemInfoWindowClickListener(this); - // http://www.flickr.com/photos/usnationalarchives/4726917149/ - mClusterManager.addItem(new Person(position(), "Gran", R.drawable.gran)); + addItems(); + mClusterManager.cluster(); + } - // http://www.flickr.com/photos/nypl/3111525394/ - mClusterManager.addItem(new Person(position(), "Ruth", R.drawable.ruth)); + private void addItems() { + // http://www.flickr.com/photos/sdasmarchives/5036248203/ + mClusterManager.addItem(new Person(position(), "Walter", R.drawable.walter)); - // http://www.flickr.com/photos/smithsonian/2887433330/ - mClusterManager.addItem(new Person(position(), "Stefan", R.drawable.stefan)); + // http://www.flickr.com/photos/usnationalarchives/4726917149/ + mClusterManager.addItem(new Person(position(), "Gran", R.drawable.gran)); - // http://www.flickr.com/photos/library_of_congress/2179915182/ - mClusterManager.addItem(new Person(position(), "Mechanic", R.drawable.mechanic)); + // http://www.flickr.com/photos/nypl/3111525394/ + mClusterManager.addItem(new Person(position(), "Ruth", R.drawable.ruth)); - // http://www.flickr.com/photos/nationalmediamuseum/7893552556/ - mClusterManager.addItem(new Person(position(), "Yeats", R.drawable.yeats)); + // http://www.flickr.com/photos/smithsonian/2887433330/ + mClusterManager.addItem(new Person(position(), "Stefan", R.drawable.stefan)); - // http://www.flickr.com/photos/sdasmarchives/5036231225/ - mClusterManager.addItem(new Person(position(), "John", R.drawable.john)); + // http://www.flickr.com/photos/library_of_congress/2179915182/ + mClusterManager.addItem(new Person(position(), "Mechanic", R.drawable.mechanic)); - // http://www.flickr.com/photos/anmm_thecommons/7694202096/ - mClusterManager.addItem(new Person(position(), "Trevor the Turtle", R.drawable.turtle)); + // http://www.flickr.com/photos/nationalmediamuseum/7893552556/ + mClusterManager.addItem(new Person(position(), "Yeats", R.drawable.yeats)); - // http://www.flickr.com/photos/usnationalarchives/4726892651/ - mClusterManager.addItem(new Person(position(), "Teach", R.drawable.teacher)); - } + // http://www.flickr.com/photos/sdasmarchives/5036231225/ + mClusterManager.addItem(new Person(position(), "John", R.drawable.john)); - private LatLng position() { - return new LatLng(random(51.6723432, 51.38494009999999), random(0.148271, -0.3514683)); - } + // http://www.flickr.com/photos/anmm_thecommons/7694202096/ + mClusterManager.addItem(new Person(position(), "Trevor the Turtle", R.drawable.turtle)); - private double random(double min, double max) { - return mRandom.nextDouble() * (max - min) + min; - } -} \ No newline at end of file + // http://www.flickr.com/photos/usnationalarchives/4726892651/ + mClusterManager.addItem(new Person(position(), "Teach", R.drawable.teacher)); + } + + private LatLng position() { + return new LatLng(random(51.6723432, 51.38494009999999), random(0.148271, -0.3514683)); + } + + private double random(double min, double max) { + return mRandom.nextDouble() * (max - min) + min; + } +} diff --git a/demo/src/main/java/com/google/maps/android/utils/demo/DemoApplication.java b/demo/src/main/java/com/google/maps/android/utils/demo/DemoApplication.java index 4796ddd75..cb4cddc81 100644 --- a/demo/src/main/java/com/google/maps/android/utils/demo/DemoApplication.java +++ b/demo/src/main/java/com/google/maps/android/utils/demo/DemoApplication.java @@ -1,5 +1,5 @@ /* - * Copyright 2025 Google LLC + * 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. @@ -13,14 +13,13 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - package com.google.maps.android.utils.demo; import android.app.Application; -import android.util.Log; import android.content.pm.ApplicationInfo; import android.content.pm.PackageManager; import android.os.Bundle; +import android.util.Log; import android.widget.Toast; import androidx.annotation.Nullable; import java.util.Objects; @@ -28,73 +27,81 @@ /** * {@code DemoApplication} is a custom Application class for the API demo. * - *

This class is responsible for application-wide initialization and setup, - * such as checking for the presence and validity of the API key during the - * application's startup.

+ *

This class is responsible for application-wide initialization and setup, such as checking for + * the presence and validity of the API key during the application's startup. * - *

It extends the {@link Application} class and overrides the {@link #onCreate()} - * method to perform these initialization tasks.

+ *

It extends the {@link Application} class and overrides the {@link #onCreate()} method to + * perform these initialization tasks. */ public class DemoApplication extends Application { - private static final String TAG = "DemoApplication"; - private boolean mapIdSet = false; - private String mapId = null; + private static final String TAG = "DemoApplication"; + private boolean mapIdSet = false; + private String mapId = null; - @Override - public void onCreate() { - super.onCreate(); - checkApiKey(); - } + @Override + public void onCreate() { + super.onCreate(); + checkApiKey(); + } - /** - * Checks if the API key for Google Maps is properly configured in the application's metadata. - *

- * This method retrieves the API key from the application's metadata, specifically looking for - * a string value associated with the key "com.google.android.geo.API_KEY". - * The key must be present, not blank, and not set to the placeholder value "DEFAULT_API_KEY". - *

- * If any of these checks fail, a Toast message is displayed indicating that the API key is missing or - * incorrectly configured, and a RuntimeException is thrown. - *

- */ - private void checkApiKey() { - try { - ApplicationInfo appInfo = getPackageManager().getApplicationInfo(getPackageName(), PackageManager.GET_META_DATA); - Bundle bundle = Objects.requireNonNull(appInfo.metaData); + /** + * Checks if the API key for Google Maps is properly configured in the application's metadata. + * + *

This method retrieves the API key from the application's metadata, specifically looking for + * a string value associated with the key "com.google.android.geo.API_KEY". The key must be + * present, not blank, and not set to the placeholder value "DEFAULT_API_KEY". + * + *

If any of these checks fail, a Toast message is displayed indicating that the API key is + * missing or incorrectly configured, and a RuntimeException is thrown. + * + *

+ */ + private void checkApiKey() { + try { + ApplicationInfo appInfo = + getPackageManager().getApplicationInfo(getPackageName(), PackageManager.GET_META_DATA); + Bundle bundle = Objects.requireNonNull(appInfo.metaData); - String apiKey = bundle.getString("com.google.android.geo.API_KEY"); // Key name is important! + String apiKey = bundle.getString("com.google.android.geo.API_KEY"); // Key name is important! - if (apiKey == null || apiKey.isBlank() || apiKey.equals("DEFAULT_API_KEY")) { - Toast.makeText(this, "API Key was not set in secrets.properties", Toast.LENGTH_LONG).show(); - throw new RuntimeException("API Key was not set in secrets.properties"); - } - } catch (PackageManager.NameNotFoundException e) { - Log.e(TAG, "Package name not found.", e); - throw new RuntimeException("Error getting package info.", e); - } catch (NullPointerException e) { - Log.e(TAG, "Error accessing meta-data.", e); // Handle the case where meta-data is completely missing. - throw new RuntimeException("Error accessing meta-data in manifest", e); - } + if (apiKey == null || apiKey.isBlank() || apiKey.equals("DEFAULT_API_KEY")) { + Toast.makeText(this, "API Key was not set in secrets.properties", Toast.LENGTH_LONG).show(); + throw new RuntimeException("API Key was not set in secrets.properties"); + } + } catch (PackageManager.NameNotFoundException e) { + Log.e(TAG, "Package name not found.", e); + throw new RuntimeException("Error getting package info.", e); + } catch (NullPointerException e) { + Log.e( + TAG, + "Error accessing meta-data.", + e); // Handle the case where meta-data is completely missing. + throw new RuntimeException("Error accessing meta-data in manifest", e); } + } - /** - * Retrieves the map ID from the BuildConfig or string resource. - * - * @return The valid map ID or null if no valid map ID is found. - */ - @Nullable - public String getMapId() { - if (!mapIdSet) { - if (!BuildConfig.MAP_ID.equals("MAP_ID")) { - mapId = BuildConfig.MAP_ID; - } else if (!getString(R.string.map_id).equals("DEMO_MAP_ID")) { - mapId = getString(R.string.map_id); - } else { - Log.w(TAG, "Map ID is not set. See README for instructions."); - Toast.makeText(this, "Map ID is not set. Some features may not work. See README for instructions.", Toast.LENGTH_LONG).show(); - } - mapIdSet = true; - } - return mapId; + /** + * Retrieves the map ID from the BuildConfig or string resource. + * + * @return The valid map ID or null if no valid map ID is found. + */ + @Nullable + public String getMapId() { + if (!mapIdSet) { + if (!BuildConfig.MAP_ID.equals("MAP_ID")) { + mapId = BuildConfig.MAP_ID; + } else if (!getString(R.string.map_id).equals("DEMO_MAP_ID")) { + mapId = getString(R.string.map_id); + } else { + Log.w(TAG, "Map ID is not set. See README for instructions."); + Toast.makeText( + this, + "Map ID is not set. Some features may not work. See README for instructions.", + Toast.LENGTH_LONG) + .show(); + } + mapIdSet = true; } + return mapId; + } } diff --git a/demo/src/main/java/com/google/maps/android/utils/demo/DistanceDemoActivity.java b/demo/src/main/java/com/google/maps/android/utils/demo/DistanceDemoActivity.java index 767539630..7b4f22cea 100644 --- a/demo/src/main/java/com/google/maps/android/utils/demo/DistanceDemoActivity.java +++ b/demo/src/main/java/com/google/maps/android/utils/demo/DistanceDemoActivity.java @@ -1,11 +1,11 @@ /* - * Copyright 2013 Google Inc. + * 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 + * 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, @@ -13,13 +13,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - package com.google.maps.android.utils.demo; import android.annotation.SuppressLint; import android.widget.TextView; import android.widget.Toast; - +import androidx.annotation.NonNull; import com.google.android.gms.maps.CameraUpdateFactory; import com.google.android.gms.maps.GoogleMap; import com.google.android.gms.maps.model.LatLng; @@ -28,76 +27,77 @@ import com.google.android.gms.maps.model.Polyline; import com.google.android.gms.maps.model.PolylineOptions; import com.google.maps.android.SphericalUtil; - import java.util.Arrays; -import androidx.annotation.NonNull; - -public class DistanceDemoActivity extends BaseDemoActivity implements GoogleMap.OnMarkerDragListener { - private TextView mTextView; - private Marker mMarkerA; - private Marker mMarkerB; - private Polyline mPolyline; - - @Override - protected int getLayoutId() { - return R.layout.distance_demo; - } - - @Override - protected void startDemo(boolean isRestore) { - mTextView = findViewById(R.id.textView); +public class DistanceDemoActivity extends BaseDemoActivity + implements GoogleMap.OnMarkerDragListener { + private TextView mTextView; + private Marker mMarkerA; + private Marker mMarkerB; + private Polyline mPolyline; - if (!isRestore) { - getMap().moveCamera(CameraUpdateFactory.newLatLngZoom(new LatLng(-33.8256, 151.2395), 10)); - } - getMap().setOnMarkerDragListener(this); + @Override + protected int getLayoutId() { + return R.layout.distance_demo; + } - mMarkerA = getMap().addMarker(new MarkerOptions().position(new LatLng(-33.9046, 151.155)).draggable(true)); - mMarkerB = getMap().addMarker(new MarkerOptions().position(new LatLng(-33.8291, 151.248)).draggable(true)); - mPolyline = getMap().addPolyline(new PolylineOptions().geodesic(true)); + @Override + protected void startDemo(boolean isRestore) { + mTextView = findViewById(R.id.textView); - Toast.makeText(this, getString(R.string.drag_the_markers), Toast.LENGTH_LONG).show(); - showDistance(); + if (!isRestore) { + getMap().moveCamera(CameraUpdateFactory.newLatLngZoom(new LatLng(-33.8256, 151.2395), 10)); } - - @SuppressLint("SetTextI18n") - private void showDistance() { - double distance = SphericalUtil.computeDistanceBetween(mMarkerA.getPosition(), mMarkerB.getPosition()); - mTextView.setText(getString(R.string.distance_format, formatNumber(distance))); + getMap().setOnMarkerDragListener(this); + + mMarkerA = + getMap() + .addMarker(new MarkerOptions().position(new LatLng(-33.9046, 151.155)).draggable(true)); + mMarkerB = + getMap() + .addMarker(new MarkerOptions().position(new LatLng(-33.8291, 151.248)).draggable(true)); + mPolyline = getMap().addPolyline(new PolylineOptions().geodesic(true)); + + Toast.makeText(this, getString(R.string.drag_the_markers), Toast.LENGTH_LONG).show(); + showDistance(); + } + + @SuppressLint("SetTextI18n") + private void showDistance() { + double distance = + SphericalUtil.computeDistanceBetween(mMarkerA.getPosition(), mMarkerB.getPosition()); + mTextView.setText(getString(R.string.distance_format, formatNumber(distance))); + } + + private void updatePolyline() { + mPolyline.setPoints(Arrays.asList(mMarkerA.getPosition(), mMarkerB.getPosition())); + } + + private String formatNumber(double distance) { + String unit = getString(R.string.unit_m); + if (distance < 1) { + distance *= 1000; + unit = getString(R.string.unit_mm); + } else if (distance > 1000) { + distance /= 1000; + unit = getString(R.string.unit_km); } - private void updatePolyline() { - mPolyline.setPoints(Arrays.asList(mMarkerA.getPosition(), mMarkerB.getPosition())); - } + return String.format("%4.3f%s", distance, unit); + } - private String formatNumber(double distance) { - String unit = getString(R.string.unit_m); - if (distance < 1) { - distance *= 1000; - unit = getString(R.string.unit_mm); - } else if (distance > 1000) { - distance /= 1000; - unit = getString(R.string.unit_km); - } + @Override + public void onMarkerDragEnd(@NonNull Marker marker) { + showDistance(); + updatePolyline(); + } - return String.format("%4.3f%s", distance, unit); - } - - @Override - public void onMarkerDragEnd(@NonNull Marker marker) { - showDistance(); - updatePolyline(); - } + @Override + public void onMarkerDragStart(@NonNull Marker marker) {} - @Override - public void onMarkerDragStart(@NonNull Marker marker) { - - } - - @Override - public void onMarkerDrag(@NonNull Marker marker) { - showDistance(); - updatePolyline(); - } + @Override + public void onMarkerDrag(@NonNull Marker marker) { + showDistance(); + updatePolyline(); + } } diff --git a/demo/src/main/java/com/google/maps/android/utils/demo/GeoJsonDemoActivity.java b/demo/src/main/java/com/google/maps/android/utils/demo/GeoJsonDemoActivity.java index 595f53a58..c4af1416b 100644 --- a/demo/src/main/java/com/google/maps/android/utils/demo/GeoJsonDemoActivity.java +++ b/demo/src/main/java/com/google/maps/android/utils/demo/GeoJsonDemoActivity.java @@ -1,11 +1,11 @@ /* - * Copyright 2020 Google Inc. + * 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 + * 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, @@ -13,13 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - package com.google.maps.android.utils.demo; import android.os.AsyncTask; import android.util.Log; import android.widget.Toast; - import com.google.android.gms.maps.CameraUpdateFactory; import com.google.android.gms.maps.model.BitmapDescriptor; import com.google.android.gms.maps.model.BitmapDescriptorFactory; @@ -28,145 +26,143 @@ import com.google.maps.android.data.geojson.GeoJsonFeature; import com.google.maps.android.data.geojson.GeoJsonLayer; import com.google.maps.android.data.geojson.GeoJsonPointStyle; - -import org.json.JSONException; -import org.json.JSONObject; - import java.io.BufferedReader; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.net.URL; +import org.json.JSONException; +import org.json.JSONObject; public class GeoJsonDemoActivity extends BaseDemoActivity { - private final static String mLogTag = "GeoJsonDemo"; - - /** - * Assigns a color based on the given magnitude - */ - private static float magnitudeToColor(double magnitude) { - if (magnitude < 1.0) { - return BitmapDescriptorFactory.HUE_CYAN; - } else if (magnitude < 2.5) { - return BitmapDescriptorFactory.HUE_GREEN; - } else if (magnitude < 4.5) { - return BitmapDescriptorFactory.HUE_YELLOW; - } else { - return BitmapDescriptorFactory.HUE_RED; - } + private static final String mLogTag = "GeoJsonDemo"; + + /** Assigns a color based on the given magnitude */ + private static float magnitudeToColor(double magnitude) { + if (magnitude < 1.0) { + return BitmapDescriptorFactory.HUE_CYAN; + } else if (magnitude < 2.5) { + return BitmapDescriptorFactory.HUE_GREEN; + } else if (magnitude < 4.5) { + return BitmapDescriptorFactory.HUE_YELLOW; + } else { + return BitmapDescriptorFactory.HUE_RED; } + } - protected int getLayoutId() { - return R.layout.geojson_demo; - } + protected int getLayoutId() { + return R.layout.geojson_demo; + } - @Override - protected void startDemo(boolean isRestore) { - if (!isRestore) { - getMap().moveCamera(CameraUpdateFactory.newLatLng(new LatLng(31.4118,-103.5355))); - } - // Download the GeoJSON file. - retrieveFileFromUrl(); - // Alternate approach of loading a local GeoJSON file. - //retrieveFileFromResource(); + @Override + protected void startDemo(boolean isRestore) { + if (!isRestore) { + getMap().moveCamera(CameraUpdateFactory.newLatLng(new LatLng(31.4118, -103.5355))); } - - private void retrieveFileFromUrl() { - new DownloadGeoJsonFile().execute(getString(R.string.geojson_url)); + // Download the GeoJSON file. + retrieveFileFromUrl(); + // Alternate approach of loading a local GeoJSON file. + // retrieveFileFromResource(); + } + + private void retrieveFileFromUrl() { + new DownloadGeoJsonFile().execute(getString(R.string.geojson_url)); + } + + private void retrieveFileFromResource() { + try { + GeoJsonLayer layer = new GeoJsonLayer(getMap(), R.raw.earthquakes_with_usa, this); + addGeoJsonLayerToMap(layer); + } catch (IOException e) { + Log.e(mLogTag, "GeoJSON file could not be read"); + } catch (JSONException e) { + Log.e(mLogTag, "GeoJSON file could not be converted to a JSONObject"); } - - private void retrieveFileFromResource() { - try { - GeoJsonLayer layer = new GeoJsonLayer(getMap(), R.raw.earthquakes_with_usa, this); - addGeoJsonLayerToMap(layer); - } catch (IOException e) { - Log.e(mLogTag, "GeoJSON file could not be read"); - } catch (JSONException e) { - Log.e(mLogTag, "GeoJSON file could not be converted to a JSONObject"); - } + } + + /** + * Adds a point style to all features to change the color of the marker based on its magnitude + * property + */ + private void addColorsToMarkers(GeoJsonLayer layer) { + // Iterate over all the features stored in the layer + for (GeoJsonFeature feature : layer.getFeatures()) { + // Check if the magnitude property exists + if (feature.getProperty("mag") != null && feature.hasProperty("place")) { + double magnitude = Double.parseDouble(feature.getProperty("mag")); + + // Get the icon for the feature + BitmapDescriptor pointIcon = + BitmapDescriptorFactory.defaultMarker(magnitudeToColor(magnitude)); + + // Create a new point style + GeoJsonPointStyle pointStyle = new GeoJsonPointStyle(); + + // Set options for the point style + pointStyle.setIcon(pointIcon); + pointStyle.setTitle("Magnitude of " + magnitude); + pointStyle.setSnippet("Earthquake occured " + feature.getProperty("place")); + + // Assign the point style to the feature + feature.setPointStyle(pointStyle); + } } + } - /** - * Adds a point style to all features to change the color of the marker based on its magnitude - * property - */ - private void addColorsToMarkers(GeoJsonLayer layer) { - // Iterate over all the features stored in the layer - for (GeoJsonFeature feature : layer.getFeatures()) { - // Check if the magnitude property exists - if (feature.getProperty("mag") != null && feature.hasProperty("place")) { - double magnitude = Double.parseDouble(feature.getProperty("mag")); - - // Get the icon for the feature - BitmapDescriptor pointIcon = BitmapDescriptorFactory - .defaultMarker(magnitudeToColor(magnitude)); - - // Create a new point style - GeoJsonPointStyle pointStyle = new GeoJsonPointStyle(); - - // Set options for the point style - pointStyle.setIcon(pointIcon); - pointStyle.setTitle("Magnitude of " + magnitude); - pointStyle.setSnippet("Earthquake occured " + feature.getProperty("place")); - - // Assign the point style to the feature - feature.setPointStyle(pointStyle); - } - } - } + private class DownloadGeoJsonFile extends AsyncTask { - private class DownloadGeoJsonFile extends AsyncTask { - - @Override - protected GeoJsonLayer doInBackground(String... params) { - try { - // Open a stream from the URL - InputStream stream = new URL(params[0]).openStream(); - - String line; - StringBuilder result = new StringBuilder(); - BufferedReader reader = new BufferedReader(new InputStreamReader(stream)); - - while ((line = reader.readLine()) != null) { - // Read and save each line of the stream - result.append(line); - } - - // Close the stream - reader.close(); - stream.close(); - - return new GeoJsonLayer(getMap(), new JSONObject(result.toString())); - } catch (IOException e) { - Log.e(mLogTag, "GeoJSON file could not be read"); - } catch (JSONException e) { - Log.e(mLogTag, "GeoJSON file could not be converted to a JSONObject"); - } - return null; + @Override + protected GeoJsonLayer doInBackground(String... params) { + try { + // Open a stream from the URL + InputStream stream = new URL(params[0]).openStream(); + + String line; + StringBuilder result = new StringBuilder(); + BufferedReader reader = new BufferedReader(new InputStreamReader(stream)); + + while ((line = reader.readLine()) != null) { + // Read and save each line of the stream + result.append(line); } - @Override - protected void onPostExecute(GeoJsonLayer layer) { - if (layer != null) { - addGeoJsonLayerToMap(layer); - } - } + // Close the stream + reader.close(); + stream.close(); + + return new GeoJsonLayer(getMap(), new JSONObject(result.toString())); + } catch (IOException e) { + Log.e(mLogTag, "GeoJSON file could not be read"); + } catch (JSONException e) { + Log.e(mLogTag, "GeoJSON file could not be converted to a JSONObject"); + } + return null; } - private void addGeoJsonLayerToMap(GeoJsonLayer layer) { - - addColorsToMarkers(layer); - layer.addLayerToMap(); - // Demonstrate receiving features via GeoJsonLayer clicks. - layer.setOnFeatureClickListener(new GeoJsonLayer.GeoJsonOnFeatureClickListener() { - @Override - public void onFeatureClick(Feature feature) { - Toast.makeText(GeoJsonDemoActivity.this, - "Feature clicked: " + feature.getProperty("title"), - Toast.LENGTH_SHORT).show(); - } - - }); + @Override + protected void onPostExecute(GeoJsonLayer layer) { + if (layer != null) { + addGeoJsonLayerToMap(layer); + } } + } + + private void addGeoJsonLayerToMap(GeoJsonLayer layer) { + + addColorsToMarkers(layer); + layer.addLayerToMap(); + // Demonstrate receiving features via GeoJsonLayer clicks. + layer.setOnFeatureClickListener( + new GeoJsonLayer.GeoJsonOnFeatureClickListener() { + @Override + public void onFeatureClick(Feature feature) { + Toast.makeText( + GeoJsonDemoActivity.this, + "Feature clicked: " + feature.getProperty("title"), + Toast.LENGTH_SHORT) + .show(); + } + }); + } } diff --git a/demo/src/main/java/com/google/maps/android/utils/demo/HeatmapsDemoActivity.java b/demo/src/main/java/com/google/maps/android/utils/demo/HeatmapsDemoActivity.java index 66b33aead..dafba4466 100644 --- a/demo/src/main/java/com/google/maps/android/utils/demo/HeatmapsDemoActivity.java +++ b/demo/src/main/java/com/google/maps/android/utils/demo/HeatmapsDemoActivity.java @@ -1,11 +1,11 @@ /* - * Copyright 2014 Google Inc. + * 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 + * 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, @@ -13,7 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - package com.google.maps.android.utils.demo; import android.graphics.Color; @@ -25,199 +24,189 @@ import android.widget.Spinner; import android.widget.TextView; import android.widget.Toast; - import com.google.android.gms.maps.CameraUpdateFactory; import com.google.android.gms.maps.model.LatLng; import com.google.android.gms.maps.model.TileOverlay; import com.google.android.gms.maps.model.TileOverlayOptions; import com.google.maps.android.heatmaps.Gradient; import com.google.maps.android.heatmaps.HeatmapTileProvider; - -import org.json.JSONArray; -import org.json.JSONException; -import org.json.JSONObject; - import java.io.InputStream; import java.util.ArrayList; import java.util.HashMap; import java.util.Scanner; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; /** - * A demo of the Heatmaps library. Demonstrates how the HeatmapTileProvider can be used to create - * a colored map overlay that visualises many points of weighted importance/intensity, with - * different colors representing areas of high and low concentration/combined intensity of points. + * A demo of the Heatmaps library. Demonstrates how the HeatmapTileProvider can be used to create a + * colored map overlay that visualises many points of weighted importance/intensity, with different + * colors representing areas of high and low concentration/combined intensity of points. */ public class HeatmapsDemoActivity extends BaseDemoActivity { - /** - * Alternative radius for convolution - */ - private static final int ALT_HEATMAP_RADIUS = 10; - - /** - * Alternative opacity of heatmap overlay - */ - private static final double ALT_HEATMAP_OPACITY = 0.4; - - /** - * Alternative heatmap gradient (blue -> red) - * Copied from Javascript version - */ - private static final int[] ALT_HEATMAP_GRADIENT_COLORS = { - Color.argb(0, 0, 255, 255),// transparent - Color.argb(255 / 3 * 2, 0, 255, 255), - Color.rgb(0, 191, 255), - Color.rgb(0, 0, 127), - Color.rgb(255, 0, 0) - }; - - public static final float[] ALT_HEATMAP_GRADIENT_START_POINTS = { - 0.0f, 0.10f, 0.20f, 0.60f, 1.0f - }; - - public static final Gradient ALT_HEATMAP_GRADIENT = new Gradient(ALT_HEATMAP_GRADIENT_COLORS, - ALT_HEATMAP_GRADIENT_START_POINTS); - - private HeatmapTileProvider mProvider; - private TileOverlay mOverlay; - - private boolean mDefaultGradient = true; - private boolean mDefaultRadius = true; - private boolean mDefaultOpacity = true; - - /** - * Maps name of data set to data (list of LatLngs) - * Also maps to the URL of the data set for attribution - */ - private HashMap mLists = new HashMap<>(); - - @Override - protected int getLayoutId() { - return R.layout.heatmaps_demo; - } + /** Alternative radius for convolution */ + private static final int ALT_HEATMAP_RADIUS = 10; + + /** Alternative opacity of heatmap overlay */ + private static final double ALT_HEATMAP_OPACITY = 0.4; + + /** Alternative heatmap gradient (blue -> red) Copied from Javascript version */ + private static final int[] ALT_HEATMAP_GRADIENT_COLORS = { + Color.argb(0, 0, 255, 255), // transparent + Color.argb(255 / 3 * 2, 0, 255, 255), + Color.rgb(0, 191, 255), + Color.rgb(0, 0, 127), + Color.rgb(255, 0, 0) + }; - @Override - protected void startDemo(boolean isRestore) { - if (!isRestore) { - getMap().moveCamera(CameraUpdateFactory.newLatLngZoom(new LatLng(-25, 143), 4)); - } - - // Set up the spinner/dropdown list - Spinner spinner = findViewById(R.id.spinner); - ArrayAdapter adapter = ArrayAdapter.createFromResource(this, - R.array.heatmaps_datasets_array, android.R.layout.simple_spinner_item); - adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); - spinner.setAdapter(adapter); - spinner.setOnItemSelectedListener(new SpinnerActivity()); - - try { - mLists.put(getString(R.string.police_stations), new DataSet(readItems(R.raw.police), - getString(R.string.police_stations_url))); - mLists.put(getString(R.string.medicare), new DataSet(readItems(R.raw.medicare), - getString(R.string.medicare_url))); - } catch (JSONException e) { - Toast.makeText(this, "Problem reading list of markers.", Toast.LENGTH_LONG).show(); - } - - // Make the handler deal with the map - // Input: list of WeightedLatLngs, minimum and maximum zoom levels to calculate custom - // intensity from, and the map to draw the heatmap on - // radius, gradient and opacity not specified, so default are used + public static final float[] ALT_HEATMAP_GRADIENT_START_POINTS = {0.0f, 0.10f, 0.20f, 0.60f, 1.0f}; + + public static final Gradient ALT_HEATMAP_GRADIENT = + new Gradient(ALT_HEATMAP_GRADIENT_COLORS, ALT_HEATMAP_GRADIENT_START_POINTS); + + private HeatmapTileProvider mProvider; + private TileOverlay mOverlay; + + private boolean mDefaultGradient = true; + private boolean mDefaultRadius = true; + private boolean mDefaultOpacity = true; + + /** + * Maps name of data set to data (list of LatLngs) Also maps to the URL of the data set for + * attribution + */ + private HashMap mLists = new HashMap<>(); + + @Override + protected int getLayoutId() { + return R.layout.heatmaps_demo; + } + + @Override + protected void startDemo(boolean isRestore) { + if (!isRestore) { + getMap().moveCamera(CameraUpdateFactory.newLatLngZoom(new LatLng(-25, 143), 4)); } - public void changeRadius(View view) { - if (mDefaultRadius) { - mProvider.setRadius(ALT_HEATMAP_RADIUS); - } else { - mProvider.setRadius(HeatmapTileProvider.DEFAULT_RADIUS); - } - mOverlay.clearTileCache(); - mDefaultRadius = !mDefaultRadius; + // Set up the spinner/dropdown list + Spinner spinner = findViewById(R.id.spinner); + ArrayAdapter adapter = + ArrayAdapter.createFromResource( + this, R.array.heatmaps_datasets_array, android.R.layout.simple_spinner_item); + adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); + spinner.setAdapter(adapter); + spinner.setOnItemSelectedListener(new SpinnerActivity()); + + try { + mLists.put( + getString(R.string.police_stations), + new DataSet(readItems(R.raw.police), getString(R.string.police_stations_url))); + mLists.put( + getString(R.string.medicare), + new DataSet(readItems(R.raw.medicare), getString(R.string.medicare_url))); + } catch (JSONException e) { + Toast.makeText(this, "Problem reading list of markers.", Toast.LENGTH_LONG).show(); } - public void changeGradient(View view) { - if (mDefaultGradient) { - mProvider.setGradient(ALT_HEATMAP_GRADIENT); - } else { - mProvider.setGradient(HeatmapTileProvider.DEFAULT_GRADIENT); - } + // Make the handler deal with the map + // Input: list of WeightedLatLngs, minimum and maximum zoom levels to calculate custom + // intensity from, and the map to draw the heatmap on + // radius, gradient and opacity not specified, so default are used + } + + public void changeRadius(View view) { + if (mDefaultRadius) { + mProvider.setRadius(ALT_HEATMAP_RADIUS); + } else { + mProvider.setRadius(HeatmapTileProvider.DEFAULT_RADIUS); + } + mOverlay.clearTileCache(); + mDefaultRadius = !mDefaultRadius; + } + + public void changeGradient(View view) { + if (mDefaultGradient) { + mProvider.setGradient(ALT_HEATMAP_GRADIENT); + } else { + mProvider.setGradient(HeatmapTileProvider.DEFAULT_GRADIENT); + } + mOverlay.clearTileCache(); + mDefaultGradient = !mDefaultGradient; + } + + public void changeOpacity(View view) { + if (mDefaultOpacity) { + mProvider.setOpacity(ALT_HEATMAP_OPACITY); + } else { + mProvider.setOpacity(HeatmapTileProvider.DEFAULT_OPACITY); + } + mOverlay.clearTileCache(); + mDefaultOpacity = !mDefaultOpacity; + } + + // Dealing with spinner choices + public class SpinnerActivity implements AdapterView.OnItemSelectedListener { + public void onItemSelected(AdapterView parent, View view, int pos, long id) { + String dataset = parent.getItemAtPosition(pos).toString(); + + TextView attribution = findViewById(R.id.attribution); + + // Check if need to instantiate (avoid setData etc twice) + if (mProvider == null) { + mProvider = + new HeatmapTileProvider.Builder() + .data(mLists.get(getString(R.string.police_stations)).getData()) + .build(); + mOverlay = getMap().addTileOverlay(new TileOverlayOptions().tileProvider(mProvider)); + // Render links + attribution.setMovementMethod(LinkMovementMethod.getInstance()); + } else { + mProvider.setData(mLists.get(dataset).getData()); mOverlay.clearTileCache(); - mDefaultGradient = !mDefaultGradient; + } + // Update attribution + attribution.setText( + Html.fromHtml( + String.format(getString(R.string.attrib_format), mLists.get(dataset).getUrl()))); } - public void changeOpacity(View view) { - if (mDefaultOpacity) { - mProvider.setOpacity(ALT_HEATMAP_OPACITY); - } else { - mProvider.setOpacity(HeatmapTileProvider.DEFAULT_OPACITY); - } - mOverlay.clearTileCache(); - mDefaultOpacity = !mDefaultOpacity; + public void onNothingSelected(AdapterView parent) { + // Another interface callback + } + } + + // Datasets from http://data.gov.au + private ArrayList readItems(int resource) throws JSONException { + ArrayList list = new ArrayList<>(); + InputStream inputStream = getResources().openRawResource(resource); + String json = new Scanner(inputStream).useDelimiter("\\A").next(); + JSONArray array = new JSONArray(json); + for (int i = 0; i < array.length(); i++) { + JSONObject object = array.getJSONObject(i); + double lat = object.getDouble("lat"); + double lng = object.getDouble("lng"); + list.add(new LatLng(lat, lng)); } + return list; + } + + /** Helper class - stores data sets and sources. */ + private class DataSet { + private ArrayList mDataset; + private String mUrl; - // Dealing with spinner choices - public class SpinnerActivity implements AdapterView.OnItemSelectedListener { - public void onItemSelected(AdapterView parent, View view, - int pos, long id) { - String dataset = parent.getItemAtPosition(pos).toString(); - - TextView attribution = findViewById(R.id.attribution); - - // Check if need to instantiate (avoid setData etc twice) - if (mProvider == null) { - mProvider = new HeatmapTileProvider.Builder().data( - mLists.get(getString(R.string.police_stations)).getData()).build(); - mOverlay = getMap().addTileOverlay(new TileOverlayOptions().tileProvider(mProvider)); - // Render links - attribution.setMovementMethod(LinkMovementMethod.getInstance()); - } else { - mProvider.setData(mLists.get(dataset).getData()); - mOverlay.clearTileCache(); - } - // Update attribution - attribution.setText(Html.fromHtml(String.format(getString(R.string.attrib_format), - mLists.get(dataset).getUrl()))); - - } - - public void onNothingSelected(AdapterView parent) { - // Another interface callback - } + public DataSet(ArrayList dataSet, String url) { + this.mDataset = dataSet; + this.mUrl = url; } - // Datasets from http://data.gov.au - private ArrayList readItems(int resource) throws JSONException { - ArrayList list = new ArrayList<>(); - InputStream inputStream = getResources().openRawResource(resource); - String json = new Scanner(inputStream).useDelimiter("\\A").next(); - JSONArray array = new JSONArray(json); - for (int i = 0; i < array.length(); i++) { - JSONObject object = array.getJSONObject(i); - double lat = object.getDouble("lat"); - double lng = object.getDouble("lng"); - list.add(new LatLng(lat, lng)); - } - return list; + public ArrayList getData() { + return mDataset; } - /** - * Helper class - stores data sets and sources. - */ - private class DataSet { - private ArrayList mDataset; - private String mUrl; - - public DataSet(ArrayList dataSet, String url) { - this.mDataset = dataSet; - this.mUrl = url; - } - - public ArrayList getData() { - return mDataset; - } - - public String getUrl() { - return mUrl; - } + public String getUrl() { + return mUrl; } + } } diff --git a/demo/src/main/java/com/google/maps/android/utils/demo/HeatmapsPlacesDemoActivity.java b/demo/src/main/java/com/google/maps/android/utils/demo/HeatmapsPlacesDemoActivity.java index 1f2aa3c35..8329f02da 100644 --- a/demo/src/main/java/com/google/maps/android/utils/demo/HeatmapsPlacesDemoActivity.java +++ b/demo/src/main/java/com/google/maps/android/utils/demo/HeatmapsPlacesDemoActivity.java @@ -1,11 +1,11 @@ /* - * Copyright 2014 Google Inc. + * 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 + * 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, @@ -13,7 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - package com.google.maps.android.utils.demo; import android.content.Context; @@ -29,7 +28,6 @@ import android.widget.LinearLayout; import android.widget.ProgressBar; import android.widget.Toast; - import com.google.android.gms.maps.CameraUpdateFactory; import com.google.android.gms.maps.GoogleMap; import com.google.android.gms.maps.model.CircleOptions; @@ -39,11 +37,6 @@ import com.google.maps.android.SphericalUtil; import com.google.maps.android.heatmaps.Gradient; import com.google.maps.android.heatmaps.HeatmapTileProvider; - -import org.json.JSONArray; -import org.json.JSONException; -import org.json.JSONObject; - import java.io.IOException; import java.io.InputStreamReader; import java.net.HttpURLConnection; @@ -53,339 +46,347 @@ import java.util.Collection; import java.util.HashMap; import java.util.Hashtable; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; /** - * A demo of the heatmaps library incorporating radar search from the Google Places API. - * This demonstrates the usefulness of heatmaps for displaying the distribution of points, - * as well as demonstrating the various color options and dealing with multiple heatmaps. + * A demo of the heatmaps library incorporating radar search from the Google Places API. This + * demonstrates the usefulness of heatmaps for displaying the distribution of points, as well as + * demonstrating the various color options and dealing with multiple heatmaps. */ public class HeatmapsPlacesDemoActivity extends BaseDemoActivity { - private final String TAG = "HeatmapPlacesDemo"; - - private final LatLng SYDNEY = new LatLng(-33.873651, 151.2058896); - private final LatLng BOULDER = new LatLng(40.0216437819216, -105.25471683073081); - - private final LatLng FOCUS = BOULDER; - - /** - * The base URL for the radar search request. - */ - private static final String PLACES_API_BASE = "https://maps.googleapis.com/maps/api/place"; - - /** - * The options required for the radar search. - */ - private static final String TYPE_NEARBY_SEARCH = "/nearbysearch"; - private static final String OUT_JSON = "/json"; - - /** - * Places API server key. - */ - private static final String API_KEY = BuildConfig.PLACES_API_KEY; - - /** - * The colors to be used for the different heatmap layers. - */ - private static final int[] HEATMAP_COLORS = { - HeatmapColors.RED.color, - HeatmapColors.BLUE.color, - HeatmapColors.GREEN.color, - HeatmapColors.PINK.color, - HeatmapColors.GREY.color - }; - - public enum HeatmapColors { - RED(Color.rgb(238, 44, 44)), - BLUE(Color.rgb(60, 80, 255)), - GREEN(Color.rgb(20, 170, 50)), - PINK(Color.rgb(255, 80, 255)), - GREY(Color.rgb(100, 100, 100)); - - private final int color; - - HeatmapColors(int color) { - this.color = color; - } - } + private final String TAG = "HeatmapPlacesDemo"; - private static final int MAX_CHECKBOXES = 5; - - /** - * The search radius which roughly corresponds to the radius of the results - * from the radar search in meters. - */ - public static final int SEARCH_RADIUS = 8000; - - /** - * Stores the TileOverlay corresponding to each of the keywords that have been searched for. - */ - private final Hashtable mOverlays = new Hashtable(); - - /** - * A layout containing checkboxes for each of the heatmaps rendered. - */ - private LinearLayout mCheckboxLayout; - - /** - * The number of overlays rendered so far. - */ - private int mOverlaysRendered = 0; - - /** - * The number of overlays that have been inputted so far. - */ - private int mOverlaysInput = 0; - - @Override - public void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - if ("YOUR_API_KEY".equals(API_KEY)) { - Toast.makeText( - this, - "Please sign up for a Places API key and add it to HeatmapsPlacesDemoActivity.API_KEY", - Toast.LENGTH_LONG - ).show(); - finish(); - } - } + private final LatLng SYDNEY = new LatLng(-33.873651, 151.2058896); + private final LatLng BOULDER = new LatLng(40.0216437819216, -105.25471683073081); - @Override - protected int getLayoutId() { - return R.layout.places_demo; - } + private final LatLng FOCUS = BOULDER; - @Override - protected void startDemo(boolean isRestore) { - EditText editText = findViewById(R.id.input_text); - editText.setOnEditorActionListener((textView, actionId, keyEvent) -> { - boolean handled = false; - if (actionId == EditorInfo.IME_ACTION_GO) { - submit(null); - handled = true; - } - return handled; - }); + /** The base URL for the radar search request. */ + private static final String PLACES_API_BASE = "https://maps.googleapis.com/maps/api/place"; - mCheckboxLayout = findViewById(R.id.checkboxes); - GoogleMap map = getMap(); - if (!isRestore) { - map.moveCamera(CameraUpdateFactory.newLatLngZoom(FOCUS, 11)); - } - // Add a circle around FOCUS to roughly encompass the results - map.addCircle(new CircleOptions() - .center(FOCUS) - .radius(SEARCH_RADIUS * 1.2) - .strokeColor(Color.RED) - .strokeWidth(4)); - } + /** The options required for the radar search. */ + private static final String TYPE_NEARBY_SEARCH = "/nearbysearch"; - /** - * Takes the input from the user and generates the required heatmap. - * Called when a search query is submitted - */ - public void submit(View view) { - EditText editText = findViewById(R.id.input_text); - String keyword = editText.getText().toString(); - if (mOverlays.contains(keyword)) { - Toast.makeText(this, "This keyword has already been inputted :(", Toast.LENGTH_SHORT).show(); - } else if (mOverlaysRendered == MAX_CHECKBOXES) { - Toast.makeText(this, "You can only input " + MAX_CHECKBOXES + " keywords. :(", Toast.LENGTH_SHORT).show(); - } else if (!keyword.isEmpty()) { - mOverlaysInput++; - ProgressBar progressBar = findViewById(R.id.progress_bar); - progressBar.setVisibility(View.VISIBLE); - new MakeOverlayTask().execute(keyword); - editText.setText(""); - - InputMethodManager imm = (InputMethodManager) getSystemService( - Context.INPUT_METHOD_SERVICE); - imm.hideSoftInputFromWindow(editText.getWindowToken(), 0); - } - } + private static final String OUT_JSON = "/json"; - /** - * Makes four radar search requests for the given keyword, then parses the - * json output and returns the search results as a collection of LatLng objects. - * - * @param keyword A string to use as a search term for the radar search - * @return Returns the search results from radar search as a collection - * of LatLng objects, or null if there was an error calling the API - */ - private Collection getPoints(String keyword) { - HashMap results = new HashMap<>(); - - // Calculate four equidistant points around FOCUS to use as search centers - // so that four searches can be done. - ArrayList searchCenters = new ArrayList<>(4); - for (int heading = 45; heading < 360; heading += 90) { - searchCenters.add(SphericalUtil.computeOffset(FOCUS, (double) SEARCH_RADIUS / 2, heading)); - } + /** Places API server key. */ + private static final String API_KEY = BuildConfig.PLACES_API_KEY; - for (int j = 0; j < 4; j++) { - String jsonResults = getJsonPlaces(keyword, searchCenters.get(j)); - if (jsonResults == null) { - // Error calling Places API - return null; - } - try { - // Create a JSON object hierarchy from the results - JSONObject jsonObj = new JSONObject(jsonResults); - JSONArray pointsJsonArray = jsonObj.getJSONArray("results"); - - // Extract the Place descriptions from the results - for (int i = 0; i < pointsJsonArray.length(); i++) { - if (!results.containsKey(pointsJsonArray.getJSONObject(i).getString("place_id"))) { - JSONObject location = pointsJsonArray.getJSONObject(i) - .getJSONObject("geometry").getJSONObject("location"); - results.put(pointsJsonArray.getJSONObject(i).getString("place_id"), - new LatLng(location.getDouble("lat"), - location.getDouble("lng"))); - } - } - } catch (JSONException e) { - Log.e(TAG, "Error parsing JSON:" + e); - runOnUiThread(() -> Toast.makeText(this, "Cannot process JSON results", Toast.LENGTH_SHORT).show()); - } - } - return results.values(); + /** The colors to be used for the different heatmap layers. */ + private static final int[] HEATMAP_COLORS = { + HeatmapColors.RED.color, + HeatmapColors.BLUE.color, + HeatmapColors.GREEN.color, + HeatmapColors.PINK.color, + HeatmapColors.GREY.color + }; + + public enum HeatmapColors { + RED(Color.rgb(238, 44, 44)), + BLUE(Color.rgb(60, 80, 255)), + GREEN(Color.rgb(20, 170, 50)), + PINK(Color.rgb(255, 80, 255)), + GREY(Color.rgb(100, 100, 100)); + + private final int color; + + HeatmapColors(int color) { + this.color = color; + } + } + + private static final int MAX_CHECKBOXES = 5; + + /** + * The search radius which roughly corresponds to the radius of the results from the radar search + * in meters. + */ + public static final int SEARCH_RADIUS = 8000; + + /** Stores the TileOverlay corresponding to each of the keywords that have been searched for. */ + private final Hashtable mOverlays = new Hashtable(); + + /** A layout containing checkboxes for each of the heatmaps rendered. */ + private LinearLayout mCheckboxLayout; + + /** The number of overlays rendered so far. */ + private int mOverlaysRendered = 0; + + /** The number of overlays that have been inputted so far. */ + private int mOverlaysInput = 0; + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + if ("YOUR_API_KEY".equals(API_KEY)) { + Toast.makeText( + this, + "Please sign up for a Places API key and add it to HeatmapsPlacesDemoActivity.API_KEY", + Toast.LENGTH_LONG) + .show(); + finish(); } + } + + @Override + protected int getLayoutId() { + return R.layout.places_demo; + } + + @Override + protected void startDemo(boolean isRestore) { + EditText editText = findViewById(R.id.input_text); + editText.setOnEditorActionListener( + (textView, actionId, keyEvent) -> { + boolean handled = false; + if (actionId == EditorInfo.IME_ACTION_GO) { + submit(null); + handled = true; + } + return handled; + }); - /** - * Makes a radar search request and returns the results in a json format. - * - * @param keyword The keyword to be searched for. - * @param location The location the radar search should be based around. - * @return The results from the radar search request as a json - */ - private String getJsonPlaces(String keyword, LatLng location) { - HttpURLConnection conn = null; - StringBuilder jsonResults = new StringBuilder(); - try { - URL url = new URL( - PLACES_API_BASE + TYPE_NEARBY_SEARCH + OUT_JSON - + "?location=" + location.latitude + "," + location.longitude - + "&radius=" + (SEARCH_RADIUS / 2) - + "&sensor=false" - + "&key=" + API_KEY - + "&keyword=" + keyword.replace(" ", "%20") - ); - Log.d(TAG, "URL: " + url); - conn = (HttpURLConnection) url.openConnection(); - InputStreamReader in = new InputStreamReader(conn.getInputStream()); - - // Load the results into a StringBuilder - int read; - char[] buff = new char[1024]; - while ((read = in.read(buff)) != -1) { - jsonResults.append(buff, 0, read); - } - } catch (MalformedURLException e) { - runOnUiThread(() -> Toast.makeText(this, "Error processing Places API URL", Toast.LENGTH_SHORT).show()); - return null; - } catch (IOException e) { - runOnUiThread(() -> Toast.makeText(this, "Error connecting to Places API", Toast.LENGTH_SHORT).show()); - return null; - } finally { - if (conn != null) { - conn.disconnect(); - } - } - return jsonResults.toString(); + mCheckboxLayout = findViewById(R.id.checkboxes); + GoogleMap map = getMap(); + if (!isRestore) { + map.moveCamera(CameraUpdateFactory.newLatLngZoom(FOCUS, 11)); + } + // Add a circle around FOCUS to roughly encompass the results + map.addCircle( + new CircleOptions() + .center(FOCUS) + .radius(SEARCH_RADIUS * 1.2) + .strokeColor(Color.RED) + .strokeWidth(4)); + } + + /** + * Takes the input from the user and generates the required heatmap. Called when a search query is + * submitted + */ + public void submit(View view) { + EditText editText = findViewById(R.id.input_text); + String keyword = editText.getText().toString(); + if (mOverlays.contains(keyword)) { + Toast.makeText(this, "This keyword has already been inputted :(", Toast.LENGTH_SHORT).show(); + } else if (mOverlaysRendered == MAX_CHECKBOXES) { + Toast.makeText( + this, "You can only input " + MAX_CHECKBOXES + " keywords. :(", Toast.LENGTH_SHORT) + .show(); + } else if (!keyword.isEmpty()) { + mOverlaysInput++; + ProgressBar progressBar = findViewById(R.id.progress_bar); + progressBar.setVisibility(View.VISIBLE); + new MakeOverlayTask().execute(keyword); + editText.setText(""); + + InputMethodManager imm = (InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE); + imm.hideSoftInputFromWindow(editText.getWindowToken(), 0); + } + } + + /** + * Makes four radar search requests for the given keyword, then parses the json output and returns + * the search results as a collection of LatLng objects. + * + * @param keyword A string to use as a search term for the radar search + * @return Returns the search results from radar search as a collection of LatLng objects, or null + * if there was an error calling the API + */ + private Collection getPoints(String keyword) { + HashMap results = new HashMap<>(); + + // Calculate four equidistant points around FOCUS to use as search centers + // so that four searches can be done. + ArrayList searchCenters = new ArrayList<>(4); + for (int heading = 45; heading < 360; heading += 90) { + searchCenters.add(SphericalUtil.computeOffset(FOCUS, (double) SEARCH_RADIUS / 2, heading)); } - /** - * Creates check box for a given search term - * - * @param keyword the search terms associated with the check box - */ - private void makeCheckBox(final String keyword) { - mCheckboxLayout.setVisibility(View.VISIBLE); - - // Make new checkbox - CheckBox checkBox = new CheckBox(this); - checkBox.setText(keyword); - checkBox.setTextColor(HEATMAP_COLORS[mOverlaysRendered]); - checkBox.setChecked(true); - checkBox.setOnClickListener(view -> { - CheckBox c = (CheckBox) view; - // Text is the keyword - TileOverlay overlay = mOverlays.get(keyword); - if (overlay != null) { - overlay.setVisible(c.isChecked()); - } + for (int j = 0; j < 4; j++) { + String jsonResults = getJsonPlaces(keyword, searchCenters.get(j)); + if (jsonResults == null) { + // Error calling Places API + return null; + } + try { + // Create a JSON object hierarchy from the results + JSONObject jsonObj = new JSONObject(jsonResults); + JSONArray pointsJsonArray = jsonObj.getJSONArray("results"); + + // Extract the Place descriptions from the results + for (int i = 0; i < pointsJsonArray.length(); i++) { + if (!results.containsKey(pointsJsonArray.getJSONObject(i).getString("place_id"))) { + JSONObject location = + pointsJsonArray + .getJSONObject(i) + .getJSONObject("geometry") + .getJSONObject("location"); + results.put( + pointsJsonArray.getJSONObject(i).getString("place_id"), + new LatLng(location.getDouble("lat"), location.getDouble("lng"))); + } + } + } catch (JSONException e) { + Log.e(TAG, "Error parsing JSON:" + e); + runOnUiThread( + () -> Toast.makeText(this, "Cannot process JSON results", Toast.LENGTH_SHORT).show()); + } + } + return results.values(); + } + + /** + * Makes a radar search request and returns the results in a json format. + * + * @param keyword The keyword to be searched for. + * @param location The location the radar search should be based around. + * @return The results from the radar search request as a json + */ + private String getJsonPlaces(String keyword, LatLng location) { + HttpURLConnection conn = null; + StringBuilder jsonResults = new StringBuilder(); + try { + URL url = + new URL( + PLACES_API_BASE + + TYPE_NEARBY_SEARCH + + OUT_JSON + + "?location=" + + location.latitude + + "," + + location.longitude + + "&radius=" + + (SEARCH_RADIUS / 2) + + "&sensor=false" + + "&key=" + + API_KEY + + "&keyword=" + + keyword.replace(" ", "%20")); + Log.d(TAG, "URL: " + url); + conn = (HttpURLConnection) url.openConnection(); + InputStreamReader in = new InputStreamReader(conn.getInputStream()); + + // Load the results into a StringBuilder + int read; + char[] buff = new char[1024]; + while ((read = in.read(buff)) != -1) { + jsonResults.append(buff, 0, read); + } + } catch (MalformedURLException e) { + runOnUiThread( + () -> Toast.makeText(this, "Error processing Places API URL", Toast.LENGTH_SHORT).show()); + return null; + } catch (IOException e) { + runOnUiThread( + () -> Toast.makeText(this, "Error connecting to Places API", Toast.LENGTH_SHORT).show()); + return null; + } finally { + if (conn != null) { + conn.disconnect(); + } + } + return jsonResults.toString(); + } + + /** + * Creates check box for a given search term + * + * @param keyword the search terms associated with the check box + */ + private void makeCheckBox(final String keyword) { + mCheckboxLayout.setVisibility(View.VISIBLE); + + // Make new checkbox + CheckBox checkBox = new CheckBox(this); + checkBox.setText(keyword); + checkBox.setTextColor(HEATMAP_COLORS[mOverlaysRendered]); + checkBox.setChecked(true); + checkBox.setOnClickListener( + view -> { + CheckBox c = (CheckBox) view; + // Text is the keyword + TileOverlay overlay = mOverlays.get(keyword); + if (overlay != null) { + overlay.setVisible(c.isChecked()); + } }); - mCheckboxLayout.addView(checkBox); + mCheckboxLayout.addView(checkBox); + } + + /** + * Async task, because finding the points cannot be done on the main thread, while adding the + * overlay must be done on the main thread. + */ + private class MakeOverlayTask extends AsyncTask { + protected PointsKeywords doInBackground(String... keyword) { + Collection points = getPoints(keyword[0]); + if (points != null) { + return new PointsKeywords(points, keyword[0]); + } else { + return null; + } } - /** - * Async task, because finding the points cannot be done on the main thread, while adding - * the overlay must be done on the main thread. - */ - private class MakeOverlayTask extends AsyncTask { - protected PointsKeywords doInBackground(String... keyword) { - Collection points = getPoints(keyword[0]); - if (points != null) { - return new PointsKeywords(points, keyword[0]); - } else { - return null; - } + protected void onPostExecute(PointsKeywords pointsKeywords) { + ProgressBar progressBar = findViewById(R.id.progress_bar); + if (pointsKeywords == null) { + // Error calling Places API + progressBar.setVisibility(View.GONE); + return; + } + Collection points = pointsKeywords.points; + String keyword = pointsKeywords.keyword; + + // Check that it wasn't an empty query. + if (!points.isEmpty()) { + if (mOverlays.size() < MAX_CHECKBOXES) { + makeCheckBox(keyword); + HeatmapTileProvider provider = + new HeatmapTileProvider.Builder() + .data(new ArrayList<>(points)) + .gradient(makeGradient(HEATMAP_COLORS[mOverlaysRendered])) + .build(); + TileOverlay overlay = + getMap().addTileOverlay(new TileOverlayOptions().tileProvider(provider)); + mOverlays.put(keyword, overlay); } - - protected void onPostExecute(PointsKeywords pointsKeywords) { - ProgressBar progressBar = findViewById(R.id.progress_bar); - if (pointsKeywords == null) { - // Error calling Places API - progressBar.setVisibility(View.GONE); - return; - } - Collection points = pointsKeywords.points; - String keyword = pointsKeywords.keyword; - - // Check that it wasn't an empty query. - if (!points.isEmpty()) { - if (mOverlays.size() < MAX_CHECKBOXES) { - makeCheckBox(keyword); - HeatmapTileProvider provider = new HeatmapTileProvider.Builder() - .data(new ArrayList<>(points)) - .gradient(makeGradient(HEATMAP_COLORS[mOverlaysRendered])) - .build(); - TileOverlay overlay = getMap().addTileOverlay(new TileOverlayOptions().tileProvider(provider)); - mOverlays.put(keyword, overlay); - } - mOverlaysRendered++; - if (mOverlaysRendered == mOverlaysInput) { - progressBar.setVisibility(View.GONE); - } - } else { - progressBar.setVisibility(View.GONE); - Toast.makeText(HeatmapsPlacesDemoActivity.this, "No results for this query :(", Toast.LENGTH_SHORT).show(); - } + mOverlaysRendered++; + if (mOverlaysRendered == mOverlaysInput) { + progressBar.setVisibility(View.GONE); } + } else { + progressBar.setVisibility(View.GONE); + Toast.makeText( + HeatmapsPlacesDemoActivity.this, "No results for this query :(", Toast.LENGTH_SHORT) + .show(); + } } + } - /** - * Class to store both the points and the keywords, for use in the MakeOverlayTask class. - */ - private class PointsKeywords { - public Collection points; - public String keyword; - - public PointsKeywords(Collection points, String keyword) { - this.points = points; - this.keyword = keyword; - } - } + /** Class to store both the points and the keywords, for use in the MakeOverlayTask class. */ + private class PointsKeywords { + public Collection points; + public String keyword; - /** - * Creates a one colored gradient which varies in opacity. - * - * @param color The opaque color the gradient should be. - * @return A gradient made purely of the given color with different alpha values. - */ - private Gradient makeGradient(int color) { - int[] colors = {color}; - float[] startPoints = {1.0f}; - return new Gradient(colors, startPoints); + public PointsKeywords(Collection points, String keyword) { + this.points = points; + this.keyword = keyword; } + } + + /** + * Creates a one colored gradient which varies in opacity. + * + * @param color The opaque color the gradient should be. + * @return A gradient made purely of the given color with different alpha values. + */ + private Gradient makeGradient(int color) { + int[] colors = {color}; + float[] startPoints = {1.0f}; + return new Gradient(colors, startPoints); + } } diff --git a/demo/src/main/java/com/google/maps/android/utils/demo/IconGeneratorDemoActivity.java b/demo/src/main/java/com/google/maps/android/utils/demo/IconGeneratorDemoActivity.java index e4d11ed7f..48c50273a 100644 --- a/demo/src/main/java/com/google/maps/android/utils/demo/IconGeneratorDemoActivity.java +++ b/demo/src/main/java/com/google/maps/android/utils/demo/IconGeneratorDemoActivity.java @@ -1,11 +1,11 @@ /* - * Copyright 2013 Google Inc. + * 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 + * 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, @@ -13,72 +13,72 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - package com.google.maps.android.utils.demo; +import static android.graphics.Typeface.BOLD; +import static android.graphics.Typeface.ITALIC; + import android.graphics.Color; import android.text.SpannableStringBuilder; import android.text.Spanned; import android.text.style.StyleSpan; - import com.google.android.gms.maps.CameraUpdateFactory; import com.google.android.gms.maps.model.BitmapDescriptorFactory; import com.google.android.gms.maps.model.LatLng; import com.google.android.gms.maps.model.MarkerOptions; import com.google.maps.android.ui.IconGenerator; -import static android.graphics.Typeface.BOLD; -import static android.graphics.Typeface.ITALIC; - public class IconGeneratorDemoActivity extends BaseDemoActivity { - @Override - protected void startDemo(boolean isRestore) { - if (!isRestore) { - getMap().moveCamera(CameraUpdateFactory.newLatLngZoom(new LatLng(-33.8696, 151.2094), 10)); - } + @Override + protected void startDemo(boolean isRestore) { + if (!isRestore) { + getMap().moveCamera(CameraUpdateFactory.newLatLngZoom(new LatLng(-33.8696, 151.2094), 10)); + } - IconGenerator iconFactory = new IconGenerator(this); - addIcon(iconFactory, "Default", new LatLng(-33.8696, 151.2094)); + IconGenerator iconFactory = new IconGenerator(this); + addIcon(iconFactory, "Default", new LatLng(-33.8696, 151.2094)); - iconFactory.setColor(Color.CYAN); - addIcon(iconFactory, "Custom color", new LatLng(-33.9360, 151.2070)); + iconFactory.setColor(Color.CYAN); + addIcon(iconFactory, "Custom color", new LatLng(-33.9360, 151.2070)); - iconFactory.setRotation(90); - iconFactory.setStyle(IconGenerator.STYLE_RED); - addIcon(iconFactory, "Rotated 90 degrees", new LatLng(-33.8858, 151.096)); + iconFactory.setRotation(90); + iconFactory.setStyle(IconGenerator.STYLE_RED); + addIcon(iconFactory, "Rotated 90 degrees", new LatLng(-33.8858, 151.096)); - iconFactory.setContentRotation(-90); - iconFactory.setStyle(IconGenerator.STYLE_PURPLE); - addIcon(iconFactory, "Rotate=90, ContentRotate=-90", new LatLng(-33.9992, 151.098)); + iconFactory.setContentRotation(-90); + iconFactory.setStyle(IconGenerator.STYLE_PURPLE); + addIcon(iconFactory, "Rotate=90, ContentRotate=-90", new LatLng(-33.9992, 151.098)); - iconFactory.setRotation(0); - iconFactory.setContentRotation(90); - iconFactory.setStyle(IconGenerator.STYLE_GREEN); - addIcon(iconFactory, "ContentRotate=90", new LatLng(-33.7677, 151.244)); + iconFactory.setRotation(0); + iconFactory.setContentRotation(90); + iconFactory.setStyle(IconGenerator.STYLE_GREEN); + addIcon(iconFactory, "ContentRotate=90", new LatLng(-33.7677, 151.244)); - iconFactory.setRotation(0); - iconFactory.setContentRotation(0); - iconFactory.setStyle(IconGenerator.STYLE_ORANGE); - addIcon(iconFactory, makeCharSequence(), new LatLng(-33.77720, 151.12412)); - } + iconFactory.setRotation(0); + iconFactory.setContentRotation(0); + iconFactory.setStyle(IconGenerator.STYLE_ORANGE); + addIcon(iconFactory, makeCharSequence(), new LatLng(-33.77720, 151.12412)); + } - private void addIcon(IconGenerator iconFactory, CharSequence text, LatLng position) { - MarkerOptions markerOptions = new MarkerOptions(). - icon(BitmapDescriptorFactory.fromBitmap(iconFactory.makeIcon(text))). - position(position). - anchor(iconFactory.getAnchorU(), iconFactory.getAnchorV()); + private void addIcon(IconGenerator iconFactory, CharSequence text, LatLng position) { + MarkerOptions markerOptions = + new MarkerOptions() + .icon(BitmapDescriptorFactory.fromBitmap(iconFactory.makeIcon(text))) + .position(position) + .anchor(iconFactory.getAnchorU(), iconFactory.getAnchorV()); - getMap().addMarker(markerOptions); - } + getMap().addMarker(markerOptions); + } - private CharSequence makeCharSequence() { - String prefix = "Mixing "; - String suffix = "different fonts"; - String sequence = prefix + suffix; - SpannableStringBuilder ssb = new SpannableStringBuilder(sequence); - ssb.setSpan(new StyleSpan(ITALIC), 0, prefix.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - ssb.setSpan(new StyleSpan(BOLD), prefix.length(), sequence.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - return ssb; - } + private CharSequence makeCharSequence() { + String prefix = "Mixing "; + String suffix = "different fonts"; + String sequence = prefix + suffix; + SpannableStringBuilder ssb = new SpannableStringBuilder(sequence); + ssb.setSpan(new StyleSpan(ITALIC), 0, prefix.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + ssb.setSpan( + new StyleSpan(BOLD), prefix.length(), sequence.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + return ssb; + } } diff --git a/demo/src/main/java/com/google/maps/android/utils/demo/KmlDemoActivity.java b/demo/src/main/java/com/google/maps/android/utils/demo/KmlDemoActivity.java index 985f1a700..83470f0cd 100644 --- a/demo/src/main/java/com/google/maps/android/utils/demo/KmlDemoActivity.java +++ b/demo/src/main/java/com/google/maps/android/utils/demo/KmlDemoActivity.java @@ -1,11 +1,11 @@ /* - * Copyright 2020 Google Inc. + * 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 + * 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, @@ -13,21 +13,19 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - package com.google.maps.android.utils.demo; import android.os.AsyncTask; import android.os.Bundle; import android.util.Log; import android.widget.Toast; - import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentManager; - import com.google.android.gms.maps.CameraUpdateFactory; import com.google.android.gms.maps.GoogleMap; import com.google.android.gms.maps.model.LatLng; import com.google.android.gms.maps.model.LatLngBounds; +import com.google.android.gms.maps.model.PolygonOptions; import com.google.maps.android.collections.GroundOverlayManager; import com.google.maps.android.collections.MarkerManager; import com.google.maps.android.collections.PolygonManager; @@ -37,170 +35,188 @@ import com.google.maps.android.data.kml.KmlLayer; import com.google.maps.android.data.kml.KmlPlacemark; import com.google.maps.android.data.kml.KmlPolygon; - -import org.xmlpull.v1.XmlPullParserException; - import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; import java.net.URL; +import org.xmlpull.v1.XmlPullParserException; public class KmlDemoActivity extends BaseDemoActivity { - private GoogleMap mMap; - private boolean mIsRestore; - - protected int getLayoutId() { - return R.layout.kml_demo; + private GoogleMap mMap; + private boolean mIsRestore; + + protected int getLayoutId() { + return R.layout.kml_demo; + } + + public void startDemo(boolean isRestore) { + mIsRestore = isRestore; + try { + mMap = getMap(); + // retrieveFileFromResource(); + retrieveFileFromUrl(); + } catch (Exception e) { + Log.e("Exception caught", e.toString()); } - - public void startDemo (boolean isRestore) { - mIsRestore = isRestore; - try { - mMap = getMap(); - //retrieveFileFromResource(); - retrieveFileFromUrl(); - } catch (Exception e) { - Log.e("Exception caught", e.toString()); + } + + private void retrieveFileFromResource() { + new LoadLocalKmlFile(R.raw.campus).execute(); + } + + private void retrieveFileFromUrl() { + new DownloadKmlFile(getString(R.string.kml_url)).execute(); + } + + private void moveCameraToKml(KmlLayer kmlLayer) { + if (mIsRestore) return; + try { + // Retrieve the first container in the KML layer + KmlContainer container = kmlLayer.getContainers().iterator().next(); + // Retrieve a nested container within the first container + container = container.getContainers().iterator().next(); + + Iterable pms = container.getPlacemarks(); + + LatLngBounds.Builder builder = new LatLngBounds.Builder(); + + for (KmlPlacemark placemark : pms) { + // Retrieve a polygon object in a placemark + KmlPolygon polygon = (KmlPolygon) placemark.getGeometry(); + // Create LatLngBounds of the outer coordinates of the polygon + for (LatLng latLng : polygon.getOuterBoundaryCoordinates()) { + builder.include(latLng); } + } + + int width = getResources().getDisplayMetrics().widthPixels; + int height = getResources().getDisplayMetrics().heightPixels; + getMap().moveCamera(CameraUpdateFactory.newLatLngBounds(builder.build(), width, height, 25)); + getMap() + .addPolygon( + new PolygonOptions() + .add( + new LatLng( + builder.build().southwest.latitude, builder.build().southwest.longitude), + new LatLng( + builder.build().northeast.latitude, builder.build().southwest.longitude), + new LatLng( + builder.build().northeast.latitude, builder.build().northeast.longitude), + new LatLng( + builder.build().southwest.latitude, + builder.build().northeast.longitude))); + } catch (Exception e) { + // may fail depending on the KML being shown + e.printStackTrace(); } - - private void retrieveFileFromResource() { - new LoadLocalKmlFile(R.raw.campus).execute(); + } + + private Renderer.ImagesCache getImagesCache() { + final RetainFragment retainFragment = + RetainFragment.findOrCreateRetainFragment(getSupportFragmentManager()); + return retainFragment.mImagesCache; + } + + /** Fragment for retaining the bitmap cache between configuration changes. */ + public static class RetainFragment extends Fragment { + private static final String TAG = RetainFragment.class.getName(); + Renderer.ImagesCache mImagesCache; + + static RetainFragment findOrCreateRetainFragment(FragmentManager fm) { + RetainFragment fragment = (RetainFragment) fm.findFragmentByTag(TAG); + if (fragment == null) { + fragment = new RetainFragment(); + fm.beginTransaction().add(fragment, TAG).commit(); + } + return fragment; } - private void retrieveFileFromUrl() { - new DownloadKmlFile(getString(R.string.kml_url)).execute(); + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setRetainInstance(true); } + } - private void moveCameraToKml(KmlLayer kmlLayer) { - if (mIsRestore) return; - try { - //Retrieve the first container in the KML layer - KmlContainer container = kmlLayer.getContainers().iterator().next(); - //Retrieve a nested container within the first container - container = container.getContainers().iterator().next(); - //Retrieve the first placemark in the nested container - KmlPlacemark placemark = container.getPlacemarks().iterator().next(); - //Retrieve a polygon object in a placemark - KmlPolygon polygon = (KmlPolygon) placemark.getGeometry(); - //Create LatLngBounds of the outer coordinates of the polygon - LatLngBounds.Builder builder = new LatLngBounds.Builder(); - for (LatLng latLng : polygon.getOuterBoundaryCoordinates()) { - builder.include(latLng); - } - - int width = getResources().getDisplayMetrics().widthPixels; - int height = getResources().getDisplayMetrics().heightPixels; - getMap().moveCamera(CameraUpdateFactory.newLatLngBounds(builder.build(), width, height, 1)); - } catch (Exception e) { - // may fail depending on the KML being shown - e.printStackTrace(); - } - } + private class LoadLocalKmlFile extends AsyncTask { + private final int mResourceId; - private Renderer.ImagesCache getImagesCache() { - final RetainFragment retainFragment = - RetainFragment.findOrCreateRetainFragment(getSupportFragmentManager()); - return retainFragment.mImagesCache; + LoadLocalKmlFile(int resourceId) { + mResourceId = resourceId; } - /** - * Fragment for retaining the bitmap cache between configuration changes. - */ - public static class RetainFragment extends Fragment { - private static final String TAG = RetainFragment.class.getName(); - Renderer.ImagesCache mImagesCache; - - static RetainFragment findOrCreateRetainFragment(FragmentManager fm) { - RetainFragment fragment = (RetainFragment) fm.findFragmentByTag(TAG); - if (fragment == null) { - fragment = new RetainFragment(); - fm.beginTransaction().add(fragment, TAG).commit(); - } - return fragment; - } - - @Override - public void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setRetainInstance(true); - } + @Override + protected KmlLayer doInBackground(String... strings) { + try { + return new KmlLayer(mMap, mResourceId, KmlDemoActivity.this); + } catch (XmlPullParserException e) { + e.printStackTrace(); + } catch (IOException e) { + e.printStackTrace(); + } + return null; } - private class LoadLocalKmlFile extends AsyncTask { - private final int mResourceId; - - LoadLocalKmlFile(int resourceId) { - mResourceId = resourceId; - } - - @Override - protected KmlLayer doInBackground(String... strings) { - try { - return new KmlLayer(mMap, mResourceId, KmlDemoActivity.this); - } catch (XmlPullParserException e) { - e.printStackTrace(); - } catch (IOException e) { - e.printStackTrace(); - } - return null; - } - - @Override - protected void onPostExecute(KmlLayer kmlLayer) { - addKmlToMap(kmlLayer); - } + @Override + protected void onPostExecute(KmlLayer kmlLayer) { + addKmlToMap(kmlLayer); } + } - private class DownloadKmlFile extends AsyncTask { - private final String mUrl; + private class DownloadKmlFile extends AsyncTask { + private final String mUrl; - DownloadKmlFile(String url) { - mUrl = url; - } + DownloadKmlFile(String url) { + mUrl = url; + } - protected KmlLayer doInBackground(String... params) { - try { - InputStream is = new URL(mUrl).openStream(); - ByteArrayOutputStream buffer = new ByteArrayOutputStream(); - int nRead; - byte[] data = new byte[16384]; - while ((nRead = is.read(data, 0, data.length)) != -1) { - buffer.write(data, 0, nRead); - } - buffer.flush(); - try { - return new KmlLayer(mMap, - new ByteArrayInputStream(buffer.toByteArray()), - KmlDemoActivity.this, - new MarkerManager(mMap), - new PolygonManager(mMap), - new PolylineManager(mMap), - new GroundOverlayManager(mMap), - getImagesCache()); - } catch (XmlPullParserException e) { - e.printStackTrace(); - } - } catch (IOException e) { - e.printStackTrace(); - } - return null; + protected KmlLayer doInBackground(String... params) { + try { + InputStream is = new URL(mUrl).openStream(); + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + int nRead; + byte[] data = new byte[16384]; + while ((nRead = is.read(data, 0, data.length)) != -1) { + buffer.write(data, 0, nRead); } - - protected void onPostExecute(KmlLayer kmlLayer) { - addKmlToMap(kmlLayer); + buffer.flush(); + try { + return new KmlLayer( + mMap, + new ByteArrayInputStream(buffer.toByteArray()), + KmlDemoActivity.this, + new MarkerManager(mMap), + new PolygonManager(mMap), + new PolylineManager(mMap), + new GroundOverlayManager(mMap), + getImagesCache()); + } catch (XmlPullParserException e) { + e.printStackTrace(); } + } catch (IOException e) { + e.printStackTrace(); + } + return null; } - private void addKmlToMap(KmlLayer kmlLayer) { - if (kmlLayer != null) { - kmlLayer.addLayerToMap(); - kmlLayer.setOnFeatureClickListener(feature -> Toast.makeText(KmlDemoActivity.this, - "Feature clicked: " + feature.getId(), - Toast.LENGTH_SHORT).show()); - moveCameraToKml(kmlLayer); - } + protected void onPostExecute(KmlLayer kmlLayer) { + addKmlToMap(kmlLayer); + } + } + + private void addKmlToMap(KmlLayer kmlLayer) { + if (kmlLayer != null) { + kmlLayer.addLayerToMap(); + kmlLayer.setOnFeatureClickListener( + feature -> + Toast.makeText( + KmlDemoActivity.this, + "Feature clicked: " + feature.getId(), + Toast.LENGTH_SHORT) + .show()); + moveCameraToKml(kmlLayer); } + } } diff --git a/demo/src/main/java/com/google/maps/android/utils/demo/MainActivity.kt b/demo/src/main/java/com/google/maps/android/utils/demo/MainActivity.kt index 9c48c894e..3276fced0 100644 --- a/demo/src/main/java/com/google/maps/android/utils/demo/MainActivity.kt +++ b/demo/src/main/java/com/google/maps/android/utils/demo/MainActivity.kt @@ -13,7 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - package com.google.maps.android.utils.demo import android.app.Activity @@ -76,7 +75,7 @@ class MainActivity : ComponentActivity() { contentPadding = innerPadding, onDemoClick = { activityClass -> startActivity(Intent(this, activityClass)) - } + }, ) } } @@ -89,10 +88,11 @@ class MainActivity : ComponentActivity() { * In a real production app, this might come from a ViewModel or a resource file, * but for a self-contained demo, hardcoding the hierarchy here is simple and clear. */ - private fun getDemoGroups(): List { - return listOf( + private fun getDemoGroups(): List = + listOf( DemoGroup( - R.string.category_clustering, listOf( + R.string.category_clustering, + listOf( Demo(R.string.demo_title_clustering_advanced, CustomAdvancedMarkerClusteringDemoActivity::class.java), Demo(R.string.demo_title_clustering_algorithms, ClusterAlgorithmsDemoActivity::class.java), Demo(R.string.demo_title_clustering_default, ClusteringDemoActivity::class.java), @@ -101,42 +101,46 @@ class MainActivity : ComponentActivity() { Demo(R.string.demo_title_clustering_2k, BigClusteringDemoActivity::class.java), Demo(R.string.demo_title_clustering_20k, VisibleClusteringDemoActivity::class.java), Demo(R.string.demo_title_clustering_viewmodel, ClusteringViewModelDemoActivity::class.java), - Demo(R.string.demo_title_clustering_force_zoom, ZoomClusteringDemoActivity::class.java) - ) + Demo(R.string.demo_title_clustering_force_zoom, ZoomClusteringDemoActivity::class.java), + ), ), DemoGroup( - R.string.category_data_layers, listOf( + R.string.category_data_layers, + listOf( Demo(R.string.demo_title_geojson, GeoJsonDemoActivity::class.java), Demo(R.string.demo_title_kml, KmlDemoActivity::class.java), Demo(R.string.demo_title_heatmaps, HeatmapsDemoActivity::class.java), Demo(R.string.demo_title_heatmaps_places, HeatmapsPlacesDemoActivity::class.java), Demo(R.string.demo_title_multi_layer, MultiLayerDemoActivity::class.java), - Demo(R.string.demo_title_transit_layer, TransitLayerDemoActivity::class.java) - ) + Demo(R.string.demo_title_transit_layer, TransitLayerDemoActivity::class.java), + Demo(R.string.demo_title_renderer, RendererDemoActivity::class.java), + ), ), DemoGroup( - R.string.category_geometry, listOf( + R.string.category_geometry, + listOf( Demo(R.string.demo_title_poly_decode, PolyDecodeDemoActivity::class.java), Demo(R.string.demo_title_poly_simplify, PolySimplifyDemoActivity::class.java), Demo(R.string.demo_title_polyline_progress, PolylineProgressDemoActivity::class.java), - Demo(R.string.demo_title_spherical_distance, DistanceDemoActivity::class.java) - ) + Demo(R.string.demo_title_spherical_distance, DistanceDemoActivity::class.java), + ), ), DemoGroup( - R.string.category_utilities, listOf( + R.string.category_utilities, + listOf( Demo(R.string.demo_title_icon_generator, IconGeneratorDemoActivity::class.java), Demo(R.string.demo_title_tile_provider, TileProviderAndProjectionDemo::class.java), - Demo(R.string.demo_title_animation_util, AnimationUtilDemoActivity::class.java) - ) + Demo(R.string.demo_title_animation_util, AnimationUtilDemoActivity::class.java), + ), ), DemoGroup( - R.string.category_street_view, listOf( + R.string.category_street_view, + listOf( Demo(R.string.demo_title_street_view, StreetViewDemoActivity::class.java), - Demo(R.string.demo_title_street_view_java, StreetViewDemoJavaActivity::class.java) - ) - ) + Demo(R.string.demo_title_street_view_java, StreetViewDemoJavaActivity::class.java), + ), + ), ) - } } /** @@ -150,7 +154,7 @@ class MainActivity : ComponentActivity() { fun DemoList( groups: List, contentPadding: PaddingValues, - onDemoClick: (Class) -> Unit + onDemoClick: (Class) -> Unit, ) { // We use rememberSaveable to preserve the expanded state across configuration changes (e.g. rotation). // Storing the ID of the expanded group ensures only one group is open at a time (accordion style). @@ -165,11 +169,11 @@ fun DemoList( // Toggle expansion: if clicking the already open group, collapse it. Otherwise expand the new one. expandedGroupResId = if (expandedGroupResId == group.titleResId) null else group.titleResId }, - onDemoClick = onDemoClick + onDemoClick = onDemoClick, ) HorizontalDivider( thickness = dimensionResource(id = R.dimen.divider_thickness), - color = MaterialTheme.colorScheme.outlineVariant + color = MaterialTheme.colorScheme.outlineVariant, ) } } @@ -188,24 +192,25 @@ fun DemoGroupItem( group: DemoGroup, isExpanded: Boolean, onHeaderClick: () -> Unit, - onDemoClick: (Class) -> Unit + onDemoClick: (Class) -> Unit, ) { Column { // Group Header Row( - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onHeaderClick) - .background(MaterialTheme.colorScheme.surfaceVariant) // Use semantic color - .padding(dimensionResource(id = R.dimen.padding_medium)), - verticalAlignment = Alignment.CenterVertically + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onHeaderClick) + .background(MaterialTheme.colorScheme.surfaceVariant) // Use semantic color + .padding(dimensionResource(id = R.dimen.padding_medium)), + verticalAlignment = Alignment.CenterVertically, ) { Text( text = stringResource(group.titleResId), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.weight(1f) + modifier = Modifier.weight(1f), ) // Animate the arrow rotation for a polished feel val rotation by animateFloatAsState(if (isExpanded) 180f else 0f, label = "arrowRotation") @@ -213,7 +218,7 @@ fun DemoGroupItem( imageVector = Icons.Default.KeyboardArrowDown, contentDescription = if (isExpanded) stringResource(R.string.collapse) else stringResource(R.string.expand), modifier = Modifier.rotate(rotation), - tint = MaterialTheme.colorScheme.onSurfaceVariant + tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } @@ -224,20 +229,22 @@ fun DemoGroupItem( group.demos.forEach { demo -> Text( text = stringResource(demo.titleResId), - modifier = Modifier - .fillMaxWidth() - .clickable { onDemoClick(demo.activityClass) } - .padding( - horizontal = dimensionResource(id = R.dimen.padding_large), - vertical = dimensionResource(id = R.dimen.padding_medium) - ), // Indented for hierarchy + modifier = + Modifier + .fillMaxWidth() + .clickable { onDemoClick(demo.activityClass) } + .padding( + horizontal = dimensionResource(id = R.dimen.padding_large), + vertical = dimensionResource(id = R.dimen.padding_medium), + ), + // Indented for hierarchy style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface + color = MaterialTheme.colorScheme.onSurface, ) HorizontalDivider( thickness = dimensionResource(id = R.dimen.divider_thickness), color = MaterialTheme.colorScheme.outlineVariant, - modifier = Modifier.padding(start = dimensionResource(id = R.dimen.padding_medium)) + modifier = Modifier.padding(start = dimensionResource(id = R.dimen.padding_medium)), ) } } diff --git a/demo/src/main/java/com/google/maps/android/utils/demo/MultiDrawable.java b/demo/src/main/java/com/google/maps/android/utils/demo/MultiDrawable.java index 7ab222faf..00eb2a654 100644 --- a/demo/src/main/java/com/google/maps/android/utils/demo/MultiDrawable.java +++ b/demo/src/main/java/com/google/maps/android/utils/demo/MultiDrawable.java @@ -1,11 +1,11 @@ /* - * Copyright 2013 Google Inc. + * 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 + * 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, @@ -13,94 +13,86 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - package com.google.maps.android.utils.demo; import android.graphics.Canvas; import android.graphics.ColorFilter; import android.graphics.PixelFormat; import android.graphics.drawable.Drawable; - import java.util.List; -/** - * Draws up to four other drawables. - */ +/** Draws up to four other drawables. */ public class MultiDrawable extends Drawable { - private final List mDrawables; - - public MultiDrawable(List drawables) { - mDrawables = drawables; - } - - @Override - public void draw(Canvas canvas) { - if (mDrawables.size() == 1) { - mDrawables.get(0).draw(canvas); - return; - } - int width = getBounds().width(); - int height = getBounds().height(); - - canvas.save(); - canvas.clipRect(0, 0, width, height); - - if (mDrawables.size() == 2 || mDrawables.size() == 3) { - // Paint left half - canvas.save(); - canvas.clipRect(0, 0, width / 2, height); - canvas.translate(-width / 4, 0); - mDrawables.get(0).draw(canvas); - canvas.restore(); - } - if (mDrawables.size() == 2) { - // Paint right half - canvas.save(); - canvas.clipRect(width / 2, 0, width, height); - canvas.translate(width / 4, 0); - mDrawables.get(1).draw(canvas); - canvas.restore(); - } else { - // Paint top right - canvas.save(); - canvas.scale(.5f, .5f); - canvas.translate(width, 0); - mDrawables.get(1).draw(canvas); + private final List mDrawables; - // Paint bottom right - canvas.translate(0, height); - mDrawables.get(2).draw(canvas); - canvas.restore(); - } + public MultiDrawable(List drawables) { + mDrawables = drawables; + } - if (mDrawables.size() >= 4) { - // Paint top left - canvas.save(); - canvas.scale(.5f, .5f); - mDrawables.get(0).draw(canvas); - - // Paint bottom left - canvas.translate(0, height); - mDrawables.get(3).draw(canvas); - canvas.restore(); - } - - canvas.restore(); + @Override + public void draw(Canvas canvas) { + if (mDrawables.size() == 1) { + mDrawables.get(0).draw(canvas); + return; + } + int width = getBounds().width(); + int height = getBounds().height(); + + canvas.save(); + canvas.clipRect(0, 0, width, height); + + if (mDrawables.size() == 2 || mDrawables.size() == 3) { + // Paint left half + canvas.save(); + canvas.clipRect(0, 0, width / 2, height); + canvas.translate(-width / 4, 0); + mDrawables.get(0).draw(canvas); + canvas.restore(); + } + if (mDrawables.size() == 2) { + // Paint right half + canvas.save(); + canvas.clipRect(width / 2, 0, width, height); + canvas.translate(width / 4, 0); + mDrawables.get(1).draw(canvas); + canvas.restore(); + } else { + // Paint top right + canvas.save(); + canvas.scale(.5f, .5f); + canvas.translate(width, 0); + mDrawables.get(1).draw(canvas); + + // Paint bottom right + canvas.translate(0, height); + mDrawables.get(2).draw(canvas); + canvas.restore(); } - @Override - public void setAlpha(int i) { + if (mDrawables.size() >= 4) { + // Paint top left + canvas.save(); + canvas.scale(.5f, .5f); + mDrawables.get(0).draw(canvas); + // Paint bottom left + canvas.translate(0, height); + mDrawables.get(3).draw(canvas); + canvas.restore(); } - @Override - public void setColorFilter(ColorFilter colorFilter) { + canvas.restore(); + } - } + @Override + public void setAlpha(int i) {} - @Override - public int getOpacity() { - return PixelFormat.UNKNOWN; - } + @Override + public void setColorFilter(ColorFilter colorFilter) {} + + @Override + public int getOpacity() { + return PixelFormat.UNKNOWN; + } } diff --git a/demo/src/main/java/com/google/maps/android/utils/demo/MultiLayerDemoActivity.java b/demo/src/main/java/com/google/maps/android/utils/demo/MultiLayerDemoActivity.java index caa66d674..3ab1e4bcd 100644 --- a/demo/src/main/java/com/google/maps/android/utils/demo/MultiLayerDemoActivity.java +++ b/demo/src/main/java/com/google/maps/android/utils/demo/MultiLayerDemoActivity.java @@ -1,11 +1,11 @@ /* - * Copyright 2020 Google Inc. + * 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 + * 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, @@ -13,13 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - package com.google.maps.android.utils.demo; import android.graphics.Color; import android.util.Log; import android.widget.Toast; - import com.google.android.gms.maps.CameraUpdateFactory; import com.google.android.gms.maps.model.BitmapDescriptorFactory; import com.google.android.gms.maps.model.LatLng; @@ -35,117 +33,169 @@ import com.google.maps.android.data.geojson.GeoJsonPolygonStyle; import com.google.maps.android.data.kml.KmlLayer; import com.google.maps.android.utils.demo.model.MyItem; - -import org.json.JSONException; -import org.xmlpull.v1.XmlPullParserException; - import java.io.IOException; import java.io.InputStream; import java.util.List; +import org.json.JSONException; +import org.xmlpull.v1.XmlPullParserException; /** - * Activity that adds multiple layers on the same map. This helps ensure that layers don't - * interfere with one another. + * Activity that adds multiple layers on the same map. This helps ensure that layers don't interfere + * with one another. */ public class MultiLayerDemoActivity extends BaseDemoActivity { - public final static String TAG = "MultiDemo"; + public static final String TAG = "MultiDemo"; - @Override - protected void startDemo(boolean isRestore) { - if (!isRestore) { - getMap().moveCamera(CameraUpdateFactory.newLatLngZoom(new LatLng(51.403186, -0.126446), 10)); - } + @Override + protected void startDemo(boolean isRestore) { + if (!isRestore) { + getMap().moveCamera(CameraUpdateFactory.newLatLngZoom(new LatLng(51.403186, -0.126446), 10)); + } - // Shared object managers - used to support multiple layer types on the map simultaneously - MarkerManager markerManager = new MarkerManager(getMap()); - GroundOverlayManager groundOverlayManager = new GroundOverlayManager(getMap()); - PolygonManager polygonManager = new PolygonManager(getMap()); - PolylineManager polylineManager = new PolylineManager(getMap()); + // Shared object managers - used to support multiple layer types on the map simultaneously + MarkerManager markerManager = new MarkerManager(getMap()); + GroundOverlayManager groundOverlayManager = new GroundOverlayManager(getMap()); + PolygonManager polygonManager = new PolygonManager(getMap()); + PolylineManager polylineManager = new PolylineManager(getMap()); - // Add clustering - ClusterManager clusterManager = new ClusterManager<>(this, getMap(), markerManager); - getMap().setOnCameraIdleListener(clusterManager); - addClusterItems(clusterManager); + // Add clustering + ClusterManager clusterManager = new ClusterManager<>(this, getMap(), markerManager); + getMap().setOnCameraIdleListener(clusterManager); + addClusterItems(clusterManager); - // Add GeoJSON from resource - try { - // GeoJSON polyline - GeoJsonLayer geoJsonLineLayer = new GeoJsonLayer(getMap(), R.raw.south_london_line_geojson, this, markerManager, polygonManager, polylineManager, groundOverlayManager); - // Make the line red - GeoJsonLineStringStyle geoJsonLineStringStyle = new GeoJsonLineStringStyle(); - geoJsonLineStringStyle.setColor(Color.RED); - for (GeoJsonFeature f : geoJsonLineLayer.getFeatures()) { - f.setLineStringStyle(geoJsonLineStringStyle); - } - geoJsonLineLayer.addLayerToMap(); - geoJsonLineLayer.setOnFeatureClickListener((GeoJsonLayer.GeoJsonOnFeatureClickListener) feature -> - Toast.makeText(MultiLayerDemoActivity.this, - "GeoJSON polyline clicked: " + feature.getProperty("title"), - Toast.LENGTH_SHORT).show()); + // Add GeoJSON from resource + try { + // GeoJSON polyline + GeoJsonLayer geoJsonLineLayer = + new GeoJsonLayer( + getMap(), + R.raw.south_london_line_geojson, + this, + markerManager, + polygonManager, + polylineManager, + groundOverlayManager); + // Make the line red + GeoJsonLineStringStyle geoJsonLineStringStyle = new GeoJsonLineStringStyle(); + geoJsonLineStringStyle.setColor(Color.RED); + for (GeoJsonFeature f : geoJsonLineLayer.getFeatures()) { + f.setLineStringStyle(geoJsonLineStringStyle); + } + geoJsonLineLayer.addLayerToMap(); + geoJsonLineLayer.setOnFeatureClickListener( + (GeoJsonLayer.GeoJsonOnFeatureClickListener) + feature -> + Toast.makeText( + MultiLayerDemoActivity.this, + "GeoJSON polyline clicked: " + feature.getProperty("title"), + Toast.LENGTH_SHORT) + .show()); - // GeoJSON polygon - GeoJsonLayer geoJsonPolygonLayer = new GeoJsonLayer(getMap(), R.raw.south_london_square_geojson, this, markerManager, polygonManager, polylineManager, groundOverlayManager); - // Fill it with red - GeoJsonPolygonStyle geoJsonPolygonStyle = new GeoJsonPolygonStyle(); - geoJsonPolygonStyle.setFillColor(Color.RED); - for (GeoJsonFeature f : geoJsonPolygonLayer.getFeatures()) { - f.setPolygonStyle(geoJsonPolygonStyle); - } - geoJsonPolygonLayer.addLayerToMap(); - geoJsonPolygonLayer.setOnFeatureClickListener((GeoJsonLayer.GeoJsonOnFeatureClickListener) feature -> - Toast.makeText(MultiLayerDemoActivity.this, - "GeoJSON polygon clicked: " + feature.getProperty("title"), - Toast.LENGTH_SHORT).show()); - } catch (IOException e) { - Log.e(TAG, "GeoJSON file could not be read"); - } catch (JSONException e) { - Log.e(TAG, "GeoJSON file could not be converted to a JSONObject"); - } + // GeoJSON polygon + GeoJsonLayer geoJsonPolygonLayer = + new GeoJsonLayer( + getMap(), + R.raw.south_london_square_geojson, + this, + markerManager, + polygonManager, + polylineManager, + groundOverlayManager); + // Fill it with red + GeoJsonPolygonStyle geoJsonPolygonStyle = new GeoJsonPolygonStyle(); + geoJsonPolygonStyle.setFillColor(Color.RED); + for (GeoJsonFeature f : geoJsonPolygonLayer.getFeatures()) { + f.setPolygonStyle(geoJsonPolygonStyle); + } + geoJsonPolygonLayer.addLayerToMap(); + geoJsonPolygonLayer.setOnFeatureClickListener( + (GeoJsonLayer.GeoJsonOnFeatureClickListener) + feature -> + Toast.makeText( + MultiLayerDemoActivity.this, + "GeoJSON polygon clicked: " + feature.getProperty("title"), + Toast.LENGTH_SHORT) + .show()); + } catch (IOException e) { + Log.e(TAG, "GeoJSON file could not be read"); + } catch (JSONException e) { + Log.e(TAG, "GeoJSON file could not be converted to a JSONObject"); + } - // Add KMLs from resources - try { - // KML Polyline - KmlLayer kmlPolylineLayer = new KmlLayer(getMap(), R.raw.south_london_line_kml, this, markerManager, polygonManager, polylineManager, groundOverlayManager, null); - kmlPolylineLayer.addLayerToMap(); - kmlPolylineLayer.setOnFeatureClickListener(feature -> Toast.makeText(MultiLayerDemoActivity.this, - "KML polyline clicked: " + feature.getProperty("name"), - Toast.LENGTH_SHORT).show()); + // Add KMLs from resources + try { + // KML Polyline + KmlLayer kmlPolylineLayer = + new KmlLayer( + getMap(), + R.raw.south_london_line_kml, + this, + markerManager, + polygonManager, + polylineManager, + groundOverlayManager, + null); + kmlPolylineLayer.addLayerToMap(); + kmlPolylineLayer.setOnFeatureClickListener( + feature -> + Toast.makeText( + MultiLayerDemoActivity.this, + "KML polyline clicked: " + feature.getProperty("name"), + Toast.LENGTH_SHORT) + .show()); - // KML Polygon - KmlLayer kmlPolygonLayer = new KmlLayer(getMap(), R.raw.south_london_square_kml, this, markerManager, polygonManager, polylineManager, groundOverlayManager, null); - kmlPolygonLayer.addLayerToMap(); - kmlPolygonLayer.setOnFeatureClickListener(feature -> Toast.makeText(MultiLayerDemoActivity.this, - "KML polygon clicked: " + feature.getProperty("name"), - Toast.LENGTH_SHORT).show()); - } catch (XmlPullParserException e) { - e.printStackTrace(); - } catch (IOException e) { - e.printStackTrace(); - } + // KML Polygon + KmlLayer kmlPolygonLayer = + new KmlLayer( + getMap(), + R.raw.south_london_square_kml, + this, + markerManager, + polygonManager, + polylineManager, + groundOverlayManager, + null); + kmlPolygonLayer.addLayerToMap(); + kmlPolygonLayer.setOnFeatureClickListener( + feature -> + Toast.makeText( + MultiLayerDemoActivity.this, + "KML polygon clicked: " + feature.getProperty("name"), + Toast.LENGTH_SHORT) + .show()); + } catch (XmlPullParserException e) { + e.printStackTrace(); + } catch (IOException e) { + e.printStackTrace(); + } - // Unclustered marker - instead of adding to the map directly, use the MarkerManager - MarkerManager.Collection markerCollection = markerManager.newCollection(); - markerCollection.addMarker(new MarkerOptions() - .position(new LatLng(51.150000, -0.150032)) - .icon(BitmapDescriptorFactory.defaultMarker(BitmapDescriptorFactory.HUE_AZURE)) - .title("Unclustered marker")); - markerCollection.setOnMarkerClickListener(marker -> { - Toast.makeText(MultiLayerDemoActivity.this, - "Marker clicked: " + marker.getTitle(), - Toast.LENGTH_SHORT).show(); - return false; + // Unclustered marker - instead of adding to the map directly, use the MarkerManager + MarkerManager.Collection markerCollection = markerManager.newCollection(); + markerCollection.addMarker( + new MarkerOptions() + .position(new LatLng(51.150000, -0.150032)) + .icon(BitmapDescriptorFactory.defaultMarker(BitmapDescriptorFactory.HUE_AZURE)) + .title("Unclustered marker")); + markerCollection.setOnMarkerClickListener( + marker -> { + Toast.makeText( + MultiLayerDemoActivity.this, + "Marker clicked: " + marker.getTitle(), + Toast.LENGTH_SHORT) + .show(); + return false; }); - } + } - private void addClusterItems(ClusterManager clusterManager) { - InputStream inputStream = getResources().openRawResource(R.raw.radar_search); - List items; - try { - items = new MyItemReader().read(inputStream); - clusterManager.addItems(items); - } catch (JSONException e) { - Toast.makeText(this, "Problem reading list of markers.", Toast.LENGTH_LONG).show(); - e.printStackTrace(); - } + private void addClusterItems(ClusterManager clusterManager) { + InputStream inputStream = getResources().openRawResource(R.raw.radar_search); + List items; + try { + items = new MyItemReader().read(inputStream); + clusterManager.addItems(items); + } catch (JSONException e) { + Toast.makeText(this, "Problem reading list of markers.", Toast.LENGTH_LONG).show(); + e.printStackTrace(); } + } } diff --git a/demo/src/main/java/com/google/maps/android/utils/demo/MyItemReader.java b/demo/src/main/java/com/google/maps/android/utils/demo/MyItemReader.java index 7e33bd66c..11f25487e 100644 --- a/demo/src/main/java/com/google/maps/android/utils/demo/MyItemReader.java +++ b/demo/src/main/java/com/google/maps/android/utils/demo/MyItemReader.java @@ -1,11 +1,11 @@ /* - * Copyright 2013 Google Inc. + * 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 + * 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, @@ -13,48 +13,44 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - package com.google.maps.android.utils.demo; import com.google.maps.android.utils.demo.model.MyItem; - -import org.json.JSONArray; -import org.json.JSONException; -import org.json.JSONObject; - import java.io.InputStream; import java.util.ArrayList; import java.util.List; import java.util.Scanner; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; public class MyItemReader { - /* - * This matches only once in whole input, - * so Scanner.next returns whole InputStream as a String. - * http://stackoverflow.com/a/5445161/2183804 - */ - private static final String REGEX_INPUT_BOUNDARY_BEGINNING = "\\A"; - - public List read(InputStream inputStream) throws JSONException { - List items = new ArrayList(); - String json = new Scanner(inputStream).useDelimiter(REGEX_INPUT_BOUNDARY_BEGINNING).next(); - JSONArray array = new JSONArray(json); - for (int i = 0; i < array.length(); i++) { - String title = null; - String snippet = null; - JSONObject object = array.getJSONObject(i); - double lat = object.getDouble("lat"); - double lng = object.getDouble("lng"); - if (!object.isNull("title")) { - title = object.getString("title"); - } - if (!object.isNull("snippet")) { - snippet = object.getString("snippet"); - } - items.add(new MyItem(lat, lng, title, snippet)); - } - return items; + /* + * This matches only once in whole input, + * so Scanner.next returns whole InputStream as a String. + * http://stackoverflow.com/a/5445161/2183804 + */ + private static final String REGEX_INPUT_BOUNDARY_BEGINNING = "\\A"; + + public List read(InputStream inputStream) throws JSONException { + List items = new ArrayList(); + String json = new Scanner(inputStream).useDelimiter(REGEX_INPUT_BOUNDARY_BEGINNING).next(); + JSONArray array = new JSONArray(json); + for (int i = 0; i < array.length(); i++) { + String title = null; + String snippet = null; + JSONObject object = array.getJSONObject(i); + double lat = object.getDouble("lat"); + double lng = object.getDouble("lng"); + if (!object.isNull("title")) { + title = object.getString("title"); + } + if (!object.isNull("snippet")) { + snippet = object.getString("snippet"); + } + items.add(new MyItem(lat, lng, title, snippet)); } - + return items; + } } diff --git a/demo/src/main/java/com/google/maps/android/utils/demo/PolyDecodeDemoActivity.java b/demo/src/main/java/com/google/maps/android/utils/demo/PolyDecodeDemoActivity.java index 370b445c2..36420df28 100644 --- a/demo/src/main/java/com/google/maps/android/utils/demo/PolyDecodeDemoActivity.java +++ b/demo/src/main/java/com/google/maps/android/utils/demo/PolyDecodeDemoActivity.java @@ -1,11 +1,11 @@ /* - * Copyright 2013 Google Inc. + * 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 + * 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, @@ -13,28 +13,27 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - package com.google.maps.android.utils.demo; import com.google.android.gms.maps.CameraUpdateFactory; import com.google.android.gms.maps.model.LatLng; import com.google.android.gms.maps.model.PolylineOptions; import com.google.maps.android.PolyUtil; - import java.util.List; public class PolyDecodeDemoActivity extends BaseDemoActivity { - private final static String LINE = "rvumEis{y[}DUaBGu@EqESyCMyAGGZGdEEhBAb@DZBXCPGP]Xg@LSBy@E{@SiBi@wAYa@AQGcAY]I]KeBm@_Bw@cBu@ICKB}KiGsEkCeEmBqJcFkFuCsFuCgB_AkAi@cA[qAWuAKeB?uALgB\\eDx@oBb@eAVeAd@cEdAaCp@s@PO@MBuEpA{@R{@NaAHwADuBAqAGE?qCS[@gAO{Fg@qIcAsCg@u@SeBk@aA_@uCsAkBcAsAy@AMGIw@e@_Bq@eA[eCi@QOAK@O@YF}CA_@Ga@c@cAg@eACW@YVgDD]Nq@j@}AR{@rBcHvBwHvAuFJk@B_@AgAGk@UkAkBcH{@qCuAiEa@gAa@w@c@o@mA{Ae@s@[m@_AaCy@uB_@kAq@_Be@}@c@m@{AwAkDuDyC_De@w@{@kB_A}BQo@UsBGy@AaA@cLBkCHsBNoD@c@E]q@eAiBcDwDoGYY_@QWEwE_@i@E}@@{BNaA@s@EyB_@c@?a@F}B\\iCv@uDjAa@Ds@Bs@EyAWo@Sm@a@YSu@c@g@Mi@GqBUi@MUMMMq@}@SWWM]C[DUJONg@hAW\\QHo@BYIOKcG{FqCsBgByAaAa@gA]c@I{@Gi@@cALcEv@_G|@gAJwAAUGUAk@C{Ga@gACu@A[Em@Sg@Y_AmA[u@Oo@qAmGeAeEs@sCgAqDg@{@[_@m@e@y@a@YIKCuAYuAQyAUuAWUaA_@wBiBgJaAoFyCwNy@cFIm@Bg@?a@t@yIVuDx@qKfA}N^aE@yE@qAIeDYaFBW\\eBFkANkANWd@gALc@PwAZiBb@qCFgCDcCGkCKoC`@gExBaVViDH}@kAOwAWe@Cg@BUDBU`@sERcCJ{BzFeB"; + private static final String LINE = + "rvumEis{y[}DUaBGu@EqESyCMyAGGZGdEEhBAb@DZBXCPGP]Xg@LSBy@E{@SiBi@wAYa@AQGcAY]I]KeBm@_Bw@cBu@ICKB}KiGsEkCeEmBqJcFkFuCsFuCgB_AkAi@cA[qAWuAKeB?uALgB\\eDx@oBb@eAVeAd@cEdAaCp@s@PO@MBuEpA{@R{@NaAHwADuBAqAGE?qCS[@gAO{Fg@qIcAsCg@u@SeBk@aA_@uCsAkBcAsAy@AMGIw@e@_Bq@eA[eCi@QOAK@O@YF}CA_@Ga@c@cAg@eACW@YVgDD]Nq@j@}AR{@rBcHvBwHvAuFJk@B_@AgAGk@UkAkBcH{@qCuAiEa@gAa@w@c@o@mA{Ae@s@[m@_AaCy@uB_@kAq@_Be@}@c@m@{AwAkDuDyC_De@w@{@kB_A}BQo@UsBGy@AaA@cLBkCHsBNoD@c@E]q@eAiBcDwDoGYY_@QWEwE_@i@E}@@{BNaA@s@EyB_@c@?a@F}B\\iCv@uDjAa@Ds@Bs@EyAWo@Sm@a@YSu@c@g@Mi@GqBUi@MUMMMq@}@SWWM]C[DUJONg@hAW\\QHo@BYIOKcG{FqCsBgByAaAa@gA]c@I{@Gi@@cALcEv@_G|@gAJwAAUGUAk@C{Ga@gACu@A[Em@Sg@Y_AmA[u@Oo@qAmGeAeEs@sCgAqDg@{@[_@m@e@y@a@YIKCuAYuAQyAUuAWUaA_@wBiBgJaAoFyCwNy@cFIm@Bg@?a@t@yIVuDx@qKfA}N^aE@yE@qAIeDYaFBW\\eBFkANkANWd@gALc@PwAZiBb@qCFgCDcCGkCKoC`@gExBaVViDH}@kAOwAWe@Cg@BUDBU`@sERcCJ{BzFeB"; - @Override - protected void startDemo(boolean isRestore) { - List decodedPath = PolyUtil.decode(LINE); + @Override + protected void startDemo(boolean isRestore) { + List decodedPath = PolyUtil.decode(LINE); - getMap().addPolyline(new PolylineOptions().addAll(decodedPath)); + getMap().addPolyline(new PolylineOptions().addAll(decodedPath)); - if (!isRestore) { - getMap().moveCamera(CameraUpdateFactory.newLatLngZoom(new LatLng(-33.8256, 151.2395), 12)); - } + if (!isRestore) { + getMap().moveCamera(CameraUpdateFactory.newLatLngZoom(new LatLng(-33.8256, 151.2395), 12)); } + } } diff --git a/demo/src/main/java/com/google/maps/android/utils/demo/PolySimplifyDemoActivity.java b/demo/src/main/java/com/google/maps/android/utils/demo/PolySimplifyDemoActivity.java index c021077bb..4a09650f1 100644 --- a/demo/src/main/java/com/google/maps/android/utils/demo/PolySimplifyDemoActivity.java +++ b/demo/src/main/java/com/google/maps/android/utils/demo/PolySimplifyDemoActivity.java @@ -1,24 +1,11 @@ -// Copyright 2025 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. /* - * Copyright 2015 Sean J. Barbeau + * 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 + * 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, @@ -26,118 +13,113 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - package com.google.maps.android.utils.demo; import android.graphics.Color; - import com.google.android.gms.maps.CameraUpdateFactory; import com.google.android.gms.maps.GoogleMap; import com.google.android.gms.maps.model.LatLng; import com.google.android.gms.maps.model.PolygonOptions; import com.google.android.gms.maps.model.PolylineOptions; import com.google.maps.android.PolyUtil; - import java.util.ArrayList; import java.util.List; public class PolySimplifyDemoActivity extends BaseDemoActivity { - private final static 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@@"; - private final static String OVAL_POLYGON = "}wgjDxw_vNuAd@}AN{A]w@_Au@kAUaA?{@Ke@@_@C]D[FULWFOLSNMTOVOXO\\I\\CX?VJXJTDTNXTVVLVJ`@FXA\\AVLZBTATBZ@ZAT?\\?VFT@XGZAP"; - private final static int ALPHA_ADJUSTMENT = 0x77000000; - - @Override - protected void startDemo(boolean isRestore) { - GoogleMap map = getMap(); - - // Original line - List line = PolyUtil.decode(LINE); - map.addPolyline(new PolylineOptions() - .addAll(line) - .color(Color.BLACK)); - - if (!isRestore) { - map.moveCamera(CameraUpdateFactory.newLatLngZoom(new LatLng(28.05870, -82.4090), 15)); - } - - List simplifiedLine; - - /* - * Simplified lines - increasing the tolerance will result in fewer points in the simplified - * line - */ - double tolerance = 5; // meters - simplifiedLine = PolyUtil.simplify(line, tolerance); - map.addPolyline(new PolylineOptions() - .addAll(simplifiedLine) - .color(Color.RED - ALPHA_ADJUSTMENT)); - - tolerance = 20; // meters - simplifiedLine = PolyUtil.simplify(line, tolerance); - map.addPolyline(new PolylineOptions() - .addAll(simplifiedLine) - .color(Color.GREEN - ALPHA_ADJUSTMENT)); - - tolerance = 50; // meters - simplifiedLine = PolyUtil.simplify(line, tolerance); - map.addPolyline(new PolylineOptions() - .addAll(simplifiedLine) - .color(Color.MAGENTA - ALPHA_ADJUSTMENT)); - - tolerance = 500; // meters - simplifiedLine = PolyUtil.simplify(line, tolerance); - map.addPolyline(new PolylineOptions() - .addAll(simplifiedLine) - .color(Color.YELLOW - ALPHA_ADJUSTMENT)); - - tolerance = 1000; // meters - simplifiedLine = PolyUtil.simplify(line, tolerance); - map.addPolyline(new PolylineOptions() - .addAll(simplifiedLine) - .color(Color.BLUE - ALPHA_ADJUSTMENT)); - - - // Triangle polygon - the polygon should be closed - ArrayList triangle = new ArrayList<>(); - triangle.add(new LatLng(28.06025,-82.41030)); // Should match last point - 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)); - triangle.add(new LatLng(28.06025,-82.41030)); // Should match first point - - map.addPolygon(new PolygonOptions() - .addAll(triangle) - .fillColor(Color.BLUE - ALPHA_ADJUSTMENT) - .strokeColor(Color.BLUE) - .strokeWidth(5)); - - // Simplified triangle polygon - tolerance = 88; // meters - List simplifiedTriangle = PolyUtil.simplify(triangle, tolerance); - map.addPolygon(new PolygonOptions() - .addAll(simplifiedTriangle) - .fillColor(Color.YELLOW - ALPHA_ADJUSTMENT) - .strokeColor(Color.YELLOW) - .strokeWidth(5)); - - // Oval polygon - the polygon should be closed - List oval = PolyUtil.decode(OVAL_POLYGON); - map.addPolygon(new PolygonOptions() - .addAll(oval) - .fillColor(Color.BLUE - ALPHA_ADJUSTMENT) - .strokeColor(Color.BLUE) - .strokeWidth(5)); - - // Simplified oval polygon - tolerance = 10; // meters - List simplifiedOval= PolyUtil.simplify(oval, tolerance); - map.addPolygon(new PolygonOptions() - .addAll(simplifiedOval) - .fillColor(Color.YELLOW - ALPHA_ADJUSTMENT) - .strokeColor(Color.YELLOW) - .strokeWidth(5)); + private static 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@@"; + private static final String OVAL_POLYGON = + "}wgjDxw_vNuAd@}AN{A]w@_Au@kAUaA?{@Ke@@_@C]D[FULWFOLSNMTOVOXO\\I\\CX?VJXJTDTNXTVVLVJ`@FXA\\AVLZBTATBZ@ZAT?\\?VFT@XGZAP"; + private static final int ALPHA_ADJUSTMENT = 0x77000000; + + @Override + protected void startDemo(boolean isRestore) { + GoogleMap map = getMap(); + + // Original line + List line = PolyUtil.decode(LINE); + map.addPolyline(new PolylineOptions().addAll(line).color(Color.BLACK)); + + if (!isRestore) { + map.moveCamera(CameraUpdateFactory.newLatLngZoom(new LatLng(28.05870, -82.4090), 15)); } + + List simplifiedLine; + + /* + * Simplified lines - increasing the tolerance will result in fewer points in the simplified + * line + */ + double tolerance = 5; // meters + simplifiedLine = PolyUtil.simplify(line, tolerance); + map.addPolyline( + new PolylineOptions().addAll(simplifiedLine).color(Color.RED - ALPHA_ADJUSTMENT)); + + tolerance = 20; // meters + simplifiedLine = PolyUtil.simplify(line, tolerance); + map.addPolyline( + new PolylineOptions().addAll(simplifiedLine).color(Color.GREEN - ALPHA_ADJUSTMENT)); + + tolerance = 50; // meters + simplifiedLine = PolyUtil.simplify(line, tolerance); + map.addPolyline( + new PolylineOptions().addAll(simplifiedLine).color(Color.MAGENTA - ALPHA_ADJUSTMENT)); + + tolerance = 500; // meters + simplifiedLine = PolyUtil.simplify(line, tolerance); + map.addPolyline( + new PolylineOptions().addAll(simplifiedLine).color(Color.YELLOW - ALPHA_ADJUSTMENT)); + + tolerance = 1000; // meters + simplifiedLine = PolyUtil.simplify(line, tolerance); + map.addPolyline( + new PolylineOptions().addAll(simplifiedLine).color(Color.BLUE - ALPHA_ADJUSTMENT)); + + // Triangle polygon - the polygon should be closed + ArrayList triangle = new ArrayList<>(); + triangle.add(new LatLng(28.06025, -82.41030)); // Should match last point + 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)); + triangle.add(new LatLng(28.06025, -82.41030)); // Should match first point + + map.addPolygon( + new PolygonOptions() + .addAll(triangle) + .fillColor(Color.BLUE - ALPHA_ADJUSTMENT) + .strokeColor(Color.BLUE) + .strokeWidth(5)); + + // Simplified triangle polygon + tolerance = 88; // meters + List simplifiedTriangle = PolyUtil.simplify(triangle, tolerance); + map.addPolygon( + new PolygonOptions() + .addAll(simplifiedTriangle) + .fillColor(Color.YELLOW - ALPHA_ADJUSTMENT) + .strokeColor(Color.YELLOW) + .strokeWidth(5)); + + // Oval polygon - the polygon should be closed + List oval = PolyUtil.decode(OVAL_POLYGON); + map.addPolygon( + new PolygonOptions() + .addAll(oval) + .fillColor(Color.BLUE - ALPHA_ADJUSTMENT) + .strokeColor(Color.BLUE) + .strokeWidth(5)); + + // Simplified oval polygon + tolerance = 10; // meters + List simplifiedOval = PolyUtil.simplify(oval, tolerance); + map.addPolygon( + new PolygonOptions() + .addAll(simplifiedOval) + .fillColor(Color.YELLOW - ALPHA_ADJUSTMENT) + .strokeColor(Color.YELLOW) + .strokeWidth(5)); + } } diff --git a/demo/src/main/java/com/google/maps/android/utils/demo/PolylineProgressDemoActivity.kt b/demo/src/main/java/com/google/maps/android/utils/demo/PolylineProgressDemoActivity.kt index a36694b77..bfa09801b 100644 --- a/demo/src/main/java/com/google/maps/android/utils/demo/PolylineProgressDemoActivity.kt +++ b/demo/src/main/java/com/google/maps/android/utils/demo/PolylineProgressDemoActivity.kt @@ -1,11 +1,11 @@ /* - * Copyright 2025 Google LLC + * 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 + * 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, @@ -13,7 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - package com.google.maps.android.utils.demo import android.graphics.Canvas @@ -44,8 +43,9 @@ import kotlinx.coroutines.launch * This demo showcases how to animate a marker along a geodesic polyline, illustrating * key features of the Android Maps Utils library and modern Android development practices. */ -class PolylineProgressDemoActivity : BaseDemoActivity(), SeekBar.OnSeekBarChangeListener { - +class PolylineProgressDemoActivity : + BaseDemoActivity(), + SeekBar.OnSeekBarChangeListener { companion object { private const val POLYLINE_WIDTH = 15f private const val PROGRESS_POLYLINE_WIDTH = 7f @@ -62,21 +62,25 @@ class PolylineProgressDemoActivity : BaseDemoActivity(), SeekBar.OnSeekBarChange bitmapDescriptorFromVector(R.drawable.baseline_airplanemode_active_24, "#FFD700".toColorInt()) } - private data class AnimationState(val progress: Int, val direction: Int) + private data class AnimationState( + val progress: Int, + val direction: Int, + ) private val animationState = MutableLiveData() private var animationJob: Job? = null - private val polylinePoints = listOf( - LatLng(40.7128, -74.0060), // New York - LatLng(47.6062, -122.3321), // Seattle - LatLng(39.7392, -104.9903), // Denver - LatLng(37.7749, -122.4194), // San Francisco - LatLng(34.0522, -118.2437), // Los Angeles - LatLng(41.8781, -87.6298), // Chicago - LatLng(29.7604, -95.3698), // Houston - LatLng(39.9526, -75.1652) // Philadelphia - ) + private val polylinePoints = + listOf( + LatLng(40.7128, -74.0060), // New York + LatLng(47.6062, -122.3321), // Seattle + LatLng(39.7392, -104.9903), // Denver + LatLng(37.7749, -122.4194), // San Francisco + LatLng(34.0522, -118.2437), // Los Angeles + LatLng(41.8781, -87.6298), // Chicago + LatLng(29.7604, -95.3698), // Houston + LatLng(39.9526, -75.1652), // Philadelphia + ) override fun getLayoutId(): Int = R.layout.activity_polyline_progress_demo @@ -96,17 +100,21 @@ class PolylineProgressDemoActivity : BaseDemoActivity(), SeekBar.OnSeekBarChange } private fun setupMap() { - originalPolyline = map.addPolyline( - PolylineOptions() - .addAll(polylinePoints) - .color(Color.GRAY) - .width(POLYLINE_WIDTH) - .geodesic(true) // A geodesic polyline follows the curvature of the Earth. - ) + originalPolyline = + map.addPolyline( + PolylineOptions() + .addAll(polylinePoints) + .color(Color.GRAY) + .width(POLYLINE_WIDTH) + .geodesic(true), // A geodesic polyline follows the curvature of the Earth. + ) - val bounds = LatLngBounds.builder().apply { - polylinePoints.forEach { include(it) } - }.build() + val bounds = + LatLngBounds + .builder() + .apply { + polylinePoints.forEach { include(it) } + }.build() map.moveCamera(CameraUpdateFactory.newLatLngBounds(bounds, 100)) } @@ -136,26 +144,32 @@ class PolylineProgressDemoActivity : BaseDemoActivity(), SeekBar.OnSeekBarChange stopAnimation() val currentState = animationState.value ?: return - animationJob = lifecycleScope.launch { - var progress = currentState.progress - var direction = currentState.direction - while (true) { - progress = when { - progress > 100 -> { - direction = -1 - 100 - } - progress < 0 -> { - direction = 1 - 0 - } - else -> progress + direction * ANIMATION_STEP_SIZE - } + animationJob = + lifecycleScope.launch { + var progress = currentState.progress + var direction = currentState.direction + while (true) { + progress = + when { + progress > 100 -> { + direction = -1 + 100 + } - animationState.postValue(AnimationState(progress, direction)) - delay(ANIMATION_DELAY_MS) + progress < 0 -> { + direction = 1 + 0 + } + + else -> { + progress + direction * ANIMATION_STEP_SIZE + } + } + + animationState.postValue(AnimationState(progress, direction)) + delay(ANIMATION_DELAY_MS) + } } - } } private fun stopAnimation() { @@ -163,7 +177,11 @@ class PolylineProgressDemoActivity : BaseDemoActivity(), SeekBar.OnSeekBarChange animationJob = null } - override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) { + override fun onProgressChanged( + seekBar: SeekBar?, + progress: Int, + fromUser: Boolean, + ) { if (fromUser) { stopAnimation() animationState.value = AnimationState(progress, animationState.value?.direction ?: 1) @@ -174,19 +192,23 @@ class PolylineProgressDemoActivity : BaseDemoActivity(), SeekBar.OnSeekBarChange override fun onStopTrackingTouch(seekBar: SeekBar?) { /* No-op */ } - private fun updateProgressOnMap(percentage: Double, direction: Int) { + private fun updateProgressOnMap( + percentage: Double, + direction: Int, + ) { progressPolyline?.remove() val prefix = SphericalUtil.getPolylinePrefix(polylinePoints, percentage) if (prefix.isNotEmpty()) { - progressPolyline = map.addPolyline( - PolylineOptions() - .addAll(prefix) - .color(Color.BLUE) - .width(PROGRESS_POLYLINE_WIDTH) - .zIndex(1f) - .geodesic(true) - ) + progressPolyline = + map.addPolyline( + PolylineOptions() + .addAll(prefix) + .color(Color.BLUE) + .width(PROGRESS_POLYLINE_WIDTH) + .zIndex(1f) + .geodesic(true), + ) } SphericalUtil.getPointOnPolyline(polylinePoints, percentage)?.let { point -> @@ -194,20 +216,27 @@ class PolylineProgressDemoActivity : BaseDemoActivity(), SeekBar.OnSeekBarChange } } - private fun updateMarker(point: LatLng, percentage: Double, direction: Int) { - val heading = SphericalUtil.getPointOnPolyline(polylinePoints, percentage + 0.0001) - ?.let { SphericalUtil.computeHeading(point, it) } - ?.let { if (direction == -1) it + 180 else it } // Adjust for reverse direction. + private fun updateMarker( + point: LatLng, + percentage: Double, + direction: Int, + ) { + val heading = + SphericalUtil + .getPointOnPolyline(polylinePoints, percentage + 0.0001) + ?.let { SphericalUtil.computeHeading(point, it) } + ?.let { if (direction == -1) it + 180 else it } // Adjust for reverse direction. if (progressMarker == null) { - progressMarker = map.addMarker( - MarkerOptions() - .position(point) - .flat(true) - .draggable(false) - .icon(planeIcon) - .apply { heading?.let { rotation(it.toFloat()) } } - ) + progressMarker = + map.addMarker( + MarkerOptions() + .position(point) + .flat(true) + .draggable(false) + .icon(planeIcon) + .apply { heading?.let { rotation(it.toFloat()) } }, + ) } else { progressMarker?.also { it.position = point @@ -216,13 +245,17 @@ class PolylineProgressDemoActivity : BaseDemoActivity(), SeekBar.OnSeekBarChange } } - private fun bitmapDescriptorFromVector(vectorResId: Int, color: Int): BitmapDescriptor { + private fun bitmapDescriptorFromVector( + vectorResId: Int, + color: Int, + ): BitmapDescriptor { val vectorDrawable = ContextCompat.getDrawable(this, vectorResId)!! vectorDrawable.setTint(color) - val bitmap = createBitmap( - vectorDrawable.intrinsicWidth, - vectorDrawable.intrinsicHeight - ) + val bitmap = + createBitmap( + vectorDrawable.intrinsicWidth, + vectorDrawable.intrinsicHeight, + ) val canvas = Canvas(bitmap) vectorDrawable.setBounds(0, 0, canvas.width, canvas.height) vectorDrawable.draw(canvas) diff --git a/demo/src/main/java/com/google/maps/android/utils/demo/RendererDemoActivity.kt b/demo/src/main/java/com/google/maps/android/utils/demo/RendererDemoActivity.kt new file mode 100644 index 000000000..c49bbe6e0 --- /dev/null +++ b/demo/src/main/java/com/google/maps/android/utils/demo/RendererDemoActivity.kt @@ -0,0 +1,326 @@ +/* + * 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.utils.demo + +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.provider.OpenableColumns +import android.util.Log +import android.widget.Toast +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.ViewCompat +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.lifecycle.lifecycleScope +import com.google.android.gms.maps.CameraUpdateFactory +import com.google.android.gms.maps.GoogleMap +import com.google.android.gms.maps.MapsInitializer +import com.google.android.gms.maps.OnMapReadyCallback +import com.google.android.gms.maps.SupportMapFragment +import com.google.android.gms.maps.model.LatLng +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.chip.Chip +import com.google.maps.android.data.DataLayerLoader +import com.google.maps.android.data.renderer.UrlIconProvider +import com.google.maps.android.data.renderer.mapview.MapViewRenderer +import com.google.maps.android.data.renderer.model.DataLayer +import com.google.maps.android.data.renderer.model.Feature +import com.google.maps.android.data.renderer.model.Point +import com.google.maps.android.data.renderer.model.PointGeometry +import com.google.maps.android.data.renderer.model.PointStyle +import com.google.maps.android.utils.demo.databinding.RendererDemoBinding +import kotlinx.coroutines.launch + +/** + * Demo activity for the new Renderer system. + * + * This activity demonstrates: + * 1. **Unified Rendering**: Using `MapViewRenderer` to render KML, GeoJSON, and GPX data. + * 2. **Modern UI**: A full-screen map experience with a persistent Bottom Sheet for controls. + * 3. **Layer Management**: Toggling layers (Peaks, Ranges) and handling multiple data sources. + * 4. **Feature Interaction**: Loading external files via the system file picker. + * 5. **Advanced Markers**: Toggling between legacy and Advanced Markers. + */ +class RendererDemoActivity : + AppCompatActivity(), + OnMapReadyCallback { + private lateinit var binding: RendererDemoBinding + private lateinit var map: GoogleMap + private lateinit var mapViewRenderer: MapViewRenderer + + private val addedLayers = java.util.Collections.newSetFromMap(java.util.IdentityHashMap()) + + // Map Chip ID to loaded DataLayer + private val chipLayers = mutableMapOf() + + private val layerCache = mutableMapOf() + + private val dataLayerLoader = DataLayerLoader(this) + + private val filePickerLauncher = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + if (result.resultCode == RESULT_OK) { + result.data?.data?.let { uri -> + loadGeoFile(uri) + } + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + MapsInitializer.initialize(applicationContext, MapsInitializer.Renderer.LATEST) { renderer -> + Log.d( + "RendererDemo", + "Maps SDK initialized with renderer: $renderer", + ) + } + + binding = RendererDemoBinding.inflate(layoutInflater) + setContentView(binding.root) + + // Enable edge-to-edge display + WindowCompat.setDecorFitsSystemWindows(window, false) + ViewCompat.setOnApplyWindowInsetsListener(binding.root) { _, windowInsets -> + val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) + + // Apply bottom inset to Bottom Sheet (padding) + binding.bottomSheet.setPadding( + binding.bottomSheet.paddingLeft, + binding.bottomSheet.paddingTop, + binding.bottomSheet.paddingRight, + insets.bottom + (16 * resources.displayMetrics.density).toInt(), // Original padding + inset + ) + + WindowInsetsCompat.CONSUMED + } + + val mapFragment: SupportMapFragment + val existingFragment = supportFragmentManager.findFragmentById(R.id.map_container) + if (existingFragment is SupportMapFragment) { + mapFragment = existingFragment + } else { + val app = application as DemoApplication + val mapId = app.mapId + + val mapOptions = + com.google.android.gms.maps + .GoogleMapOptions() + if (mapId != null) { + mapOptions.mapId(mapId) + } + + mapFragment = SupportMapFragment.newInstance(mapOptions) + supportFragmentManager + .beginTransaction() + .add(R.id.map_container, mapFragment) + .commit() + } + + mapFragment.getMapAsync(this) + } + + override fun onMapReady(googleMap: GoogleMap) { + map = googleMap + val iconProvider = UrlIconProvider() + mapViewRenderer = MapViewRenderer(map, iconProvider) + + // Add padding to the map to account for the bottom sheet's peek height + val behavior = BottomSheetBehavior.from(binding.bottomSheet) + map.setPadding(0, 0, 0, behavior.peekHeight) + + map.moveCamera(CameraUpdateFactory.newLatLngZoom(LatLng(37.422, -122.084), 10f)) + addDefaultLayer() + + binding.btnLoadFile.setOnClickListener { + val intent = + Intent(Intent.ACTION_OPEN_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = "*/*" + } + filePickerLauncher.launch(intent) + } + + binding.btnClear.setOnClickListener { + mapViewRenderer.clear() + addedLayers.clear() + chipLayers.clear() + binding.chipGroupLayers.clearCheck() + } + + binding.btnToggleSheet.setOnClickListener { + if (behavior.state == BottomSheetBehavior.STATE_EXPANDED) { + behavior.state = BottomSheetBehavior.STATE_COLLAPSED + } else { + behavior.state = BottomSheetBehavior.STATE_EXPANDED + } + } + + binding.chipPeaks.setOnClickListener { + onChipClicked(binding.chipPeaks, "top_peaks.kml") + } + + binding.chipRanges.setOnClickListener { + onChipClicked(binding.chipRanges, "mountain_ranges.kml") + } + + binding.chipComplexKml.setOnClickListener { + onChipClicked(binding.chipComplexKml, "kml-types.kml") + } + + binding.chipComplexGeojson.setOnClickListener { + onChipClicked(binding.chipComplexGeojson, "geojson-types.json") + } + + binding.chipGroundOverlay.setOnClickListener { + onChipClicked(binding.chipGroundOverlay, "ground_overlay.kml") + } + + binding.chipBrightAngel.setOnClickListener { + onChipClicked(binding.chipBrightAngel, "BrightAngel.gpx") + } + + binding.switchAdvancedMarkers.setOnCheckedChangeListener { _, isChecked -> + mapViewRenderer.useAdvancedMarkers = isChecked + } + } + + private fun onChipClicked( + chip: Chip, + filename: String, + ) { + if (chip.isChecked) { + // Load and add + val existingLayer = chipLayers[chip.id] + if (existingLayer != null) { + addLayerToMap(existingLayer) + } else { + lifecycleScope.launch { + loadLayerFromAsset(filename) { layer -> + chipLayers[chip.id] = layer + addLayerToMap(layer) + } + } + } + } else { + // Remove + val layer = chipLayers[chip.id] + if (layer != null) { + mapViewRenderer.removeLayer(layer) + addedLayers.remove(layer) + } + } + } + + private suspend fun loadLayerFromAsset( + filename: String, + onSuccess: (DataLayer) -> Unit, + ) { + dataLayerLoader.loadAsset(filename)?.let { layer -> onSuccess(layer) } + } + + private fun addDefaultLayer() { + // Add a Marker using the new DataRenderer system + val point = Point(37.422, -122.084) + val properties = + mapOf( + "name" to "Googleplex", + "description" to "Mountain View, CA", + ) + val style = + PointStyle( + iconUrl = null, // Use default marker + ) + val feature = + Feature( + geometry = PointGeometry(point), + properties = properties, + style = style, + ) + val layer = DataLayer(features = listOf(feature)) + mapViewRenderer.addLayer(layer) + addedLayers.add(layer) + } + + private fun addLayerToMap(layer: DataLayer) { + mapViewRenderer.addLayer(layer) + addedLayers.add(layer) + + layer.boundingBox?.let { bounds -> + try { + map.moveCamera(CameraUpdateFactory.newLatLngBounds(bounds, 100)) + } catch (e: Exception) { + e.printStackTrace() + } + } + } + + private fun loadGeoFile(uri: Uri) { + lifecycleScope.launch { + val filename = getFileName(uri) ?: uri.toString() + + val cacheKey = uri.toString() + + if (layerCache.containsKey(cacheKey)) { + val layer = layerCache[cacheKey]!! + addLayerToMap(layer) + return@launch + } + + try { + val inputStream = contentResolver.openInputStream(uri) ?: return@launch + val layer = dataLayerLoader.loadInputStream(inputStream, filename) + + if (layer != null) { + layerCache[cacheKey] = layer + addLayerToMap(layer) + } else { + Toast.makeText(this@RendererDemoActivity, "Unsupported or failed to parse file", Toast.LENGTH_SHORT).show() + } + } catch (e: Exception) { + e.printStackTrace() + Toast.makeText(this@RendererDemoActivity, "Error loading file: ${e.message}", Toast.LENGTH_SHORT).show() + } + } + } + + private fun getFileName(uri: Uri): String? { + var result: String? = null + if (uri.scheme == "content") { + val cursor = contentResolver.query(uri, null, null, null, null) + try { + if (cursor != null && cursor.moveToFirst()) { + val index = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + if (index >= 0) { + result = cursor.getString(index) + } + } + } finally { + cursor?.close() + } + } + if (result == null) { + result = uri.path + val cut = result?.lastIndexOf('/') + if (cut != null && cut != -1) { + result = result.substring(cut + 1) + } + } + return result + } +} diff --git a/demo/src/main/java/com/google/maps/android/utils/demo/StreetViewDemoActivity.kt b/demo/src/main/java/com/google/maps/android/utils/demo/StreetViewDemoActivity.kt index 20c7c4418..fee1957a2 100644 --- a/demo/src/main/java/com/google/maps/android/utils/demo/StreetViewDemoActivity.kt +++ b/demo/src/main/java/com/google/maps/android/utils/demo/StreetViewDemoActivity.kt @@ -1,5 +1,5 @@ /* - * Copyright 2025 Google LLC + * 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. @@ -13,7 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - package com.google.maps.android.utils.demo import android.annotation.SuppressLint @@ -41,7 +40,6 @@ import kotlinx.coroutines.launch * 4. Displays the results of the Street View data fetch on the screen. */ class StreetViewDemoActivity : AppCompatActivity() { - @SuppressLint("SetTextI18n") override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) diff --git a/demo/src/main/java/com/google/maps/android/utils/demo/StreetViewDemoJavaActivity.java b/demo/src/main/java/com/google/maps/android/utils/demo/StreetViewDemoJavaActivity.java index 2260dca4f..f2e5907a2 100644 --- a/demo/src/main/java/com/google/maps/android/utils/demo/StreetViewDemoJavaActivity.java +++ b/demo/src/main/java/com/google/maps/android/utils/demo/StreetViewDemoJavaActivity.java @@ -1,11 +1,11 @@ /* - * Copyright 2025 Google LLC + * 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 + * 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, @@ -13,7 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - package com.google.maps.android.utils.demo; import android.annotation.SuppressLint; @@ -21,14 +20,12 @@ import android.util.Log; import android.widget.TextView; import android.widget.Toast; - import androidx.annotation.NonNull; import androidx.appcompat.app.AppCompatActivity; import androidx.core.graphics.Insets; import androidx.core.view.ViewCompat; import androidx.core.view.WindowCompat; import androidx.core.view.WindowInsetsCompat; - import com.google.android.gms.maps.model.LatLng; import com.google.maps.android.Status; import com.google.maps.android.StreetViewJavaHelper; @@ -36,69 +33,88 @@ /** * An activity that demonstrates how to use the Street View utility in Java to check for Street View * availability at different locations. - *

- * This activity performs the following actions: - * 1. Sets up the layout to fit system windows. - * 2. Checks if a valid Google Maps API key is present. - * 3. Fetches Street View data for two predefined locations using asynchronous callbacks. - * 4. Displays the results of the Street View data fetch on the screen. + * + *

This activity performs the following actions: 1. Sets up the layout to fit system windows. 2. + * Checks if a valid Google Maps API key is present. 3. Fetches Street View data for two predefined + * locations using asynchronous callbacks. 4. Displays the results of the Street View data fetch on + * the screen. */ public class StreetViewDemoJavaActivity extends AppCompatActivity { - @SuppressLint("SetTextI18n") - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - // Make the activity content fit behind the system bars. - WindowCompat.setDecorFitsSystemWindows(getWindow(), false); - setContentView(R.layout.street_view_demo); + @SuppressLint("SetTextI18n") + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + // Make the activity content fit behind the system bars. + WindowCompat.setDecorFitsSystemWindows(getWindow(), false); + setContentView(R.layout.street_view_demo); - // Apply window insets to the main view to avoid content overlapping with system bars. - ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main), (v, insetsCompat) -> { - Insets insets = insetsCompat.getInsets(WindowInsetsCompat.Type.systemBars()); - v.setPadding(insets.left, insets.top, insets.right, insets.bottom); - return insetsCompat; + // Apply window insets to the main view to avoid content overlapping with system bars. + ViewCompat.setOnApplyWindowInsetsListener( + findViewById(R.id.main), + (v, insetsCompat) -> { + Insets insets = insetsCompat.getInsets(WindowInsetsCompat.Type.systemBars()); + v.setPadding(insets.left, insets.top, insets.right, insets.bottom); + return insetsCompat; }); - // Check for a valid Maps API key before proceeding. - if (!ApiKeyValidator.hasMapsApiKey(this)) { - Toast.makeText(this, R.string.bad_maps_api_key, Toast.LENGTH_LONG).show(); - finish(); - return; // Return early to prevent further execution - } + // Check for a valid Maps API key before proceeding. + if (!ApiKeyValidator.hasMapsApiKey(this)) { + Toast.makeText(this, R.string.bad_maps_api_key, Toast.LENGTH_LONG).show(); + finish(); + return; // Return early to prevent further execution + } - // Fetches Street View data for the first location (expected to be supported). - StreetViewJavaHelper.fetchStreetViewData( - new LatLng(48.1425918, 11.5386121), - BuildConfig.MAPS_API_KEY, new StreetViewJavaHelper.StreetViewCallback() { - @Override - public void onStreetViewResult(@NonNull Status status) { - // Updates the UI with the result on the UI thread. - runOnUiThread(() -> ((TextView) findViewById(R.id.textViewFirstLocation)).setText("Location 1 is supported in StreetView: " + status)); - } + // Fetches Street View data for the first location (expected to be supported). + StreetViewJavaHelper.fetchStreetViewData( + new LatLng(48.1425918, 11.5386121), + BuildConfig.MAPS_API_KEY, + new StreetViewJavaHelper.StreetViewCallback() { + @Override + public void onStreetViewResult(@NonNull Status status) { + // Updates the UI with the result on the UI thread. + runOnUiThread( + () -> + ((TextView) findViewById(R.id.textViewFirstLocation)) + .setText("Location 1 is supported in StreetView: " + status)); + } - @Override - public void onStreetViewError(@NonNull Exception e) { - // Handles the error by printing stack trace and showing a toast. - Log.w("SVJDemo", "Error fetching Street View data: " + e.getMessage()); - Toast.makeText(StreetViewDemoJavaActivity.this, "Error fetching Street View data: " + e.getMessage(), Toast.LENGTH_SHORT).show(); - } - }); + @Override + public void onStreetViewError(@NonNull Exception e) { + // Handles the error by printing stack trace and showing a toast. + Log.w("SVJDemo", "Error fetching Street View data: " + e.getMessage()); + Toast.makeText( + StreetViewDemoJavaActivity.this, + "Error fetching Street View data: " + e.getMessage(), + Toast.LENGTH_SHORT) + .show(); + } + }); - // Fetches Street View data for the second location (expected to be unsupported). - StreetViewJavaHelper.fetchStreetViewData(new LatLng(8.1425918, 11.5386121), BuildConfig.MAPS_API_KEY, new StreetViewJavaHelper.StreetViewCallback() { - @Override - public void onStreetViewResult(@NonNull Status status) { - // Updates the UI with the result on the UI thread. - runOnUiThread(() -> ((TextView) findViewById(R.id.textViewSecondLocation)).setText("Location 2 is supported in StreetView: " + status)); - } + // Fetches Street View data for the second location (expected to be unsupported). + StreetViewJavaHelper.fetchStreetViewData( + new LatLng(8.1425918, 11.5386121), + BuildConfig.MAPS_API_KEY, + new StreetViewJavaHelper.StreetViewCallback() { + @Override + public void onStreetViewResult(@NonNull Status status) { + // Updates the UI with the result on the UI thread. + runOnUiThread( + () -> + ((TextView) findViewById(R.id.textViewSecondLocation)) + .setText("Location 2 is supported in StreetView: " + status)); + } - @Override - public void onStreetViewError(@NonNull Exception e) { - // Handles the error by printing stack trace and showing a toast. - Log.w("SVJDemo", "Error fetching Street View data: " + e.getMessage()); - Toast.makeText(StreetViewDemoJavaActivity.this, "Error fetching Street View data: " + e.getMessage(), Toast.LENGTH_SHORT).show(); - } + @Override + public void onStreetViewError(@NonNull Exception e) { + // Handles the error by printing stack trace and showing a toast. + Log.w("SVJDemo", "Error fetching Street View data: " + e.getMessage()); + Toast.makeText( + StreetViewDemoJavaActivity.this, + "Error fetching Street View data: " + e.getMessage(), + Toast.LENGTH_SHORT) + .show(); + } }); - } -} \ No newline at end of file + } +} diff --git a/demo/src/main/java/com/google/maps/android/utils/demo/TileProviderAndProjectionDemo.java b/demo/src/main/java/com/google/maps/android/utils/demo/TileProviderAndProjectionDemo.java index a4e7232f3..ad652ee50 100644 --- a/demo/src/main/java/com/google/maps/android/utils/demo/TileProviderAndProjectionDemo.java +++ b/demo/src/main/java/com/google/maps/android/utils/demo/TileProviderAndProjectionDemo.java @@ -1,11 +1,11 @@ /* - * Copyright 2013 Google Inc. + * 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 + * 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, @@ -13,65 +13,61 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - package com.google.maps.android.utils.demo; - import android.graphics.Bitmap; import android.graphics.Canvas; import android.graphics.Matrix; import android.graphics.Paint; - import com.google.android.gms.maps.model.LatLng; import com.google.android.gms.maps.model.Tile; import com.google.android.gms.maps.model.TileOverlayOptions; import com.google.android.gms.maps.model.TileProvider; import com.google.maps.android.geometry.Point; import com.google.maps.android.projection.SphericalMercatorProjection; - import java.io.ByteArrayOutputStream; import java.util.ArrayList; import java.util.List; public class TileProviderAndProjectionDemo extends BaseDemoActivity { - @Override - protected void startDemo(boolean isRestore) { - PointTileOverlay pto = new PointTileOverlay(); - pto.addPoint(new LatLng(0, 0)); - pto.addPoint(new LatLng(21, -10)); - getMap().addTileOverlay(new TileOverlayOptions().tileProvider(pto)); - } + @Override + protected void startDemo(boolean isRestore) { + PointTileOverlay pto = new PointTileOverlay(); + pto.addPoint(new LatLng(0, 0)); + pto.addPoint(new LatLng(21, -10)); + getMap().addTileOverlay(new TileOverlayOptions().tileProvider(pto)); + } - private class PointTileOverlay implements TileProvider { - private List mPoints = new ArrayList<>(); - private int mTileSize = 256; - private SphericalMercatorProjection mProjection = new SphericalMercatorProjection(mTileSize); - private int mScale = 2; - private int mDimension = mScale * mTileSize; + private class PointTileOverlay implements TileProvider { + private List mPoints = new ArrayList<>(); + private int mTileSize = 256; + private SphericalMercatorProjection mProjection = new SphericalMercatorProjection(mTileSize); + private int mScale = 2; + private int mDimension = mScale * mTileSize; - @Override - public Tile getTile(int x, int y, int zoom) { - Matrix matrix = new Matrix(); - float scale = (float) Math.pow(2, zoom) * mScale; - matrix.postScale(scale, scale); - matrix.postTranslate(-x * mDimension, -y * mDimension); + @Override + public Tile getTile(int x, int y, int zoom) { + Matrix matrix = new Matrix(); + float scale = (float) Math.pow(2, zoom) * mScale; + matrix.postScale(scale, scale); + matrix.postTranslate(-x * mDimension, -y * mDimension); - Bitmap bitmap = Bitmap.createBitmap(mDimension, mDimension, Bitmap.Config.ARGB_8888); - Canvas c = new Canvas(bitmap); - c.setMatrix(matrix); + Bitmap bitmap = Bitmap.createBitmap(mDimension, mDimension, Bitmap.Config.ARGB_8888); + Canvas c = new Canvas(bitmap); + c.setMatrix(matrix); - for (Point p : mPoints) { - c.drawCircle((float) p.x, (float) p.y, 1, new Paint()); - } + for (Point p : mPoints) { + c.drawCircle((float) p.x, (float) p.y, 1, new Paint()); + } - ByteArrayOutputStream baos = new ByteArrayOutputStream(); - bitmap.compress(Bitmap.CompressFormat.PNG, 100, baos); - return new Tile(mDimension, mDimension, baos.toByteArray()); - } + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + bitmap.compress(Bitmap.CompressFormat.PNG, 100, baos); + return new Tile(mDimension, mDimension, baos.toByteArray()); + } - public void addPoint(LatLng latLng) { - mPoints.add(mProjection.toPoint(latLng)); - } + public void addPoint(LatLng latLng) { + mPoints.add(mProjection.toPoint(latLng)); } + } } diff --git a/demo/src/main/java/com/google/maps/android/utils/demo/TransitLayerDemoActivity.kt b/demo/src/main/java/com/google/maps/android/utils/demo/TransitLayerDemoActivity.kt index b267f8bde..faa9f510f 100644 --- a/demo/src/main/java/com/google/maps/android/utils/demo/TransitLayerDemoActivity.kt +++ b/demo/src/main/java/com/google/maps/android/utils/demo/TransitLayerDemoActivity.kt @@ -13,7 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - package com.google.maps.android.utils.demo import android.widget.CheckBox @@ -28,18 +27,15 @@ import com.google.android.gms.maps.model.LatLng * The transit layer displays public transport lines and stations on the map. */ class TransitLayerDemoActivity : BaseDemoActivity() { - - override fun getLayoutId(): Int { - return R.layout.activity_transit_layer_demo - } + override fun getLayoutId(): Int = R.layout.activity_transit_layer_demo override fun startDemo(isRestore: Boolean) { if (!isRestore) { map.moveCamera( CameraUpdateFactory.newLatLngZoom( LONDON, - DEFAULT_ZOOM - ) + DEFAULT_ZOOM, + ), ) } @@ -54,11 +50,12 @@ class TransitLayerDemoActivity : BaseDemoActivity() { } private fun updateMessage() { - val status = if (map.isTransitEnabled) { - getString(R.string.status_enabled) - } else { - getString(R.string.status_disabled) - } + val status = + if (map.isTransitEnabled) { + getString(R.string.status_enabled) + } else { + getString(R.string.status_disabled) + } Toast.makeText(this, getString(R.string.transit_layer_status_fmt, status), Toast.LENGTH_SHORT).show() } diff --git a/demo/src/main/java/com/google/maps/android/utils/demo/VisibleClusteringDemoActivity.java b/demo/src/main/java/com/google/maps/android/utils/demo/VisibleClusteringDemoActivity.java index b4e713a53..0fe458a61 100644 --- a/demo/src/main/java/com/google/maps/android/utils/demo/VisibleClusteringDemoActivity.java +++ b/demo/src/main/java/com/google/maps/android/utils/demo/VisibleClusteringDemoActivity.java @@ -1,11 +1,11 @@ /* - * Copyright 2016 Google Inc. + * 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 + * 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, @@ -13,62 +13,58 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - package com.google.maps.android.utils.demo; import android.util.DisplayMetrics; import android.widget.Toast; - import com.google.android.gms.maps.CameraUpdateFactory; import com.google.android.gms.maps.model.LatLng; import com.google.maps.android.clustering.ClusterManager; import com.google.maps.android.clustering.algo.NonHierarchicalViewBasedAlgorithm; import com.google.maps.android.utils.demo.model.MyItem; - -import org.json.JSONException; - import java.io.InputStream; import java.util.List; +import org.json.JSONException; public class VisibleClusteringDemoActivity extends BaseDemoActivity { - private ClusterManager mClusterManager; + private ClusterManager mClusterManager; - @Override - protected void startDemo(boolean isRestore) { - DisplayMetrics metrics = new DisplayMetrics(); - getWindowManager().getDefaultDisplay().getMetrics(metrics); + @Override + protected void startDemo(boolean isRestore) { + DisplayMetrics metrics = new DisplayMetrics(); + getWindowManager().getDefaultDisplay().getMetrics(metrics); - int widthDp = (int) (metrics.widthPixels / metrics.density); - int heightDp = (int) (metrics.heightPixels / metrics.density); + int widthDp = (int) (metrics.widthPixels / metrics.density); + int heightDp = (int) (metrics.heightPixels / metrics.density); - if (!isRestore) { - getMap().moveCamera(CameraUpdateFactory.newLatLngZoom(new LatLng(51.503186, -0.126446), 10)); - } + if (!isRestore) { + getMap().moveCamera(CameraUpdateFactory.newLatLngZoom(new LatLng(51.503186, -0.126446), 10)); + } - mClusterManager = new ClusterManager<>(this, getMap()); - mClusterManager.setAlgorithm(new NonHierarchicalViewBasedAlgorithm<>(widthDp, heightDp)); + mClusterManager = new ClusterManager<>(this, getMap()); + mClusterManager.setAlgorithm(new NonHierarchicalViewBasedAlgorithm<>(widthDp, heightDp)); - getMap().setOnCameraIdleListener(mClusterManager); + getMap().setOnCameraIdleListener(mClusterManager); - try { - readItems(); - } catch (JSONException e) { - Toast.makeText(this, "Problem reading list of markers.", Toast.LENGTH_LONG).show(); - } + try { + readItems(); + } catch (JSONException e) { + Toast.makeText(this, "Problem reading list of markers.", Toast.LENGTH_LONG).show(); } + } - private void readItems() throws JSONException { - InputStream inputStream = getResources().openRawResource(R.raw.radar_search); - List items = new MyItemReader().read(inputStream); - for (int i = 0; i < 100; i++) { - double offset = i / 60d; - for (MyItem item : items) { - LatLng position = item.getPosition(); - double lat = position.latitude + offset; - double lng = position.longitude + offset; - MyItem offsetItem = new MyItem(lat, lng); - mClusterManager.addItem(offsetItem); - } - } + private void readItems() throws JSONException { + InputStream inputStream = getResources().openRawResource(R.raw.radar_search); + List items = new MyItemReader().read(inputStream); + for (int i = 0; i < 100; i++) { + double offset = i / 60d; + for (MyItem item : items) { + LatLng position = item.getPosition(); + double lat = position.latitude + offset; + double lng = position.longitude + offset; + MyItem offsetItem = new MyItem(lat, lng); + mClusterManager.addItem(offsetItem); + } } + } } diff --git a/demo/src/main/java/com/google/maps/android/utils/demo/ZoomClusteringDemoActivity.java b/demo/src/main/java/com/google/maps/android/utils/demo/ZoomClusteringDemoActivity.java index 83c5a3b29..5c5bb81f0 100644 --- a/demo/src/main/java/com/google/maps/android/utils/demo/ZoomClusteringDemoActivity.java +++ b/demo/src/main/java/com/google/maps/android/utils/demo/ZoomClusteringDemoActivity.java @@ -1,11 +1,11 @@ /* - * Copyright 2021 Google Inc. + * 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 + * 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, @@ -13,14 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - package com.google.maps.android.utils.demo; import android.content.Context; import android.widget.Toast; - import androidx.annotation.NonNull; - import com.google.android.gms.maps.CameraUpdateFactory; import com.google.android.gms.maps.GoogleMap; import com.google.android.gms.maps.model.LatLng; @@ -30,168 +27,178 @@ import com.google.maps.android.clustering.ClusterManager; import com.google.maps.android.clustering.view.DefaultClusterRenderer; import com.google.maps.android.utils.demo.model.MyItem; - import java.util.Set; /** * Demonstrates how to force re-rendering of clusters even when the contents don't change. For * example, when changing zoom levels. */ -public class ZoomClusteringDemoActivity extends BaseDemoActivity implements ClusterManager.OnClusterClickListener, ClusterManager.OnClusterInfoWindowClickListener, ClusterManager.OnClusterItemClickListener, ClusterManager.OnClusterItemInfoWindowClickListener { +public class ZoomClusteringDemoActivity extends BaseDemoActivity + implements ClusterManager.OnClusterClickListener, + ClusterManager.OnClusterInfoWindowClickListener, + ClusterManager.OnClusterItemClickListener, + ClusterManager.OnClusterItemInfoWindowClickListener { + + @Override + public boolean onClusterClick(Cluster cluster) { + // Show a toast with some info when the cluster is clicked. + String title = cluster.getItems().iterator().next().getTitle(); + Toast.makeText(this, cluster.getSize() + " (including " + title + ")", Toast.LENGTH_SHORT) + .show(); + + // Zoom in the cluster. Need to create LatLngBounds and including all the cluster items + // inside of bounds, then animate to center of the bounds. + + // Create the builder to collect all essential cluster items for the bounds. + LatLngBounds.Builder builder = LatLngBounds.builder(); + for (ClusterItem item : cluster.getItems()) { + builder.include(item.getPosition()); + } - @Override - public boolean onClusterClick(Cluster cluster) { - // Show a toast with some info when the cluster is clicked. - String title = cluster.getItems().iterator().next().getTitle(); - Toast.makeText(this, cluster.getSize() + " (including " + title + ")", Toast.LENGTH_SHORT).show(); - - // Zoom in the cluster. Need to create LatLngBounds and including all the cluster items - // inside of bounds, then animate to center of the bounds. - - // Create the builder to collect all essential cluster items for the bounds. - LatLngBounds.Builder builder = LatLngBounds.builder(); - for (ClusterItem item : cluster.getItems()) { - builder.include(item.getPosition()); - } - - // Animate camera to the bounds - try { - getMap().animateCamera(CameraUpdateFactory.newLatLngBounds(builder.build(), 100)); - } catch (Exception e) { - e.printStackTrace(); - } + // Animate camera to the bounds + try { + getMap().animateCamera(CameraUpdateFactory.newLatLngBounds(builder.build(), 100)); + } catch (Exception e) { + e.printStackTrace(); + } - return true; + return true; + } + + @Override + public void onClusterInfoWindowClick(Cluster cluster) { + // Does nothing, but you could go to a list of the users. + } + + @Override + public boolean onClusterItemClick(MyItem item) { + // Does nothing, but you could go into a user's profile page, for example. + return false; + } + + @Override + public void onClusterItemInfoWindowClick(MyItem item) { + // Does nothing, but you could go into a user's profile page, for example. + } + + @Override + protected void startDemo(boolean isRestore) { + if (!isRestore) { + getMap() + .moveCamera(CameraUpdateFactory.newLatLngZoom(new LatLng(18.528146, 73.797726), 9.5f)); } - @Override - public void onClusterInfoWindowClick(Cluster cluster) { - // Does nothing, but you could go to a list of the users. + ClusterManager clusterManager = new ClusterManager<>(this, getMap()); + getMap().setOnCameraIdleListener(clusterManager); + + // Initialize renderer + ZoomBasedRenderer renderer = new ZoomBasedRenderer(this, getMap(), clusterManager); + clusterManager.setRenderer(renderer); + + // Set click listeners + clusterManager.setOnClusterClickListener(this); + clusterManager.setOnClusterInfoWindowClickListener(this); + clusterManager.setOnClusterItemClickListener(this); + clusterManager.setOnClusterItemInfoWindowClickListener(this); + + String snippet = + "This item wouldn't have changed to a marker if we didn't override shouldRenderAsCluster() AND shouldRender()"; + + // Add items + clusterManager.addItem(new MyItem(18.528146, 73.797726, "Loc1", snippet)); + clusterManager.addItem(new MyItem(18.545723, 73.917202, "Loc2", snippet)); + } + + private class ZoomBasedRenderer extends DefaultClusterRenderer + implements GoogleMap.OnCameraIdleListener { + private Float zoom = 15f; + private Float oldZoom; + private static final float ZOOM_THRESHOLD = 12f; + + public ZoomBasedRenderer( + Context context, GoogleMap map, ClusterManager clusterManager) { + super(context, map, clusterManager); } + /** + * The {@link ClusterManager} will call the {@link this.onCameraIdle()} implementation of any + * Renderer that implements {@link GoogleMap.OnCameraIdleListener} before clustering and + * rendering takes place. This allows us to capture metrics that may be useful for clustering, + * such as the zoom level. + */ @Override - public boolean onClusterItemClick(MyItem item) { - // Does nothing, but you could go into a user's profile page, for example. - return false; + public void onCameraIdle() { + // Remember the previous zoom level, capture the new zoom level. + oldZoom = zoom; + zoom = getMap().getCameraPosition().zoom; } + /** + * You can override this method to control when the cluster manager renders a group of items as + * a cluster (vs. as a set of individual markers). + * + *

In this case, we want single markers to show up as a cluster when zoomed out, but + * individual markers when zoomed in. + * + * @param cluster cluster to examine for rendering + * @return true when zoom level is less than the threshold (show as cluster when zoomed out), + * and false when the the zoom level is more than or equal to the threshold (show as marker + * when zoomed in) + */ @Override - public void onClusterItemInfoWindowClick(MyItem item) { - // Does nothing, but you could go into a user's profile page, for example. + protected boolean shouldRenderAsCluster(@NonNull Cluster cluster) { + // Show as cluster when zoom is less than the threshold, otherwise show as marker + return zoom < ZOOM_THRESHOLD; } + /** + * You can override this method to control optimizations surrounding rendering. The default + * implementation in the library simply checks if the new clusters are equal to the old + * clusters, and if so, it returns false to avoid re-rendering the same content. + * + *

However, in our case we need to change this behavior. As defined in {@link + * this.shouldRenderAsCluster()}, we want an item to render as a cluster above a certain zoom + * level and as a marker below a certain zoom level even if the contents of the clusters + * themselves did not change. In this case, we need to override this method to implement + * this new optimization behavior. + * + *

Note that always returning true from this method could potentially have negative + * performance implications as clusters will be re-rendered on each pass even if they don't + * change. + * + * @param oldClusters The clusters from the previous iteration of the clustering algorithm + * @param newClusters The clusters from the current iteration of the clustering algorithm + * @return true if the new clusters should be rendered on the map, and false if they should not. + */ @Override - protected void startDemo(boolean isRestore) { - if (!isRestore) { - getMap().moveCamera(CameraUpdateFactory.newLatLngZoom(new LatLng(18.528146, 73.797726), 9.5f)); - } - - ClusterManager clusterManager = new ClusterManager<>(this, getMap()); - getMap().setOnCameraIdleListener(clusterManager); - - // Initialize renderer - ZoomBasedRenderer renderer = new ZoomBasedRenderer(this, getMap(), clusterManager); - clusterManager.setRenderer(renderer); - - // Set click listeners - clusterManager.setOnClusterClickListener(this); - clusterManager.setOnClusterInfoWindowClickListener(this); - clusterManager.setOnClusterItemClickListener(this); - clusterManager.setOnClusterItemInfoWindowClickListener(this); - - String snippet = "This item wouldn't have changed to a marker if we didn't override shouldRenderAsCluster() AND shouldRender()"; - - // Add items - clusterManager.addItem(new MyItem(18.528146, 73.797726, "Loc1", snippet)); - clusterManager.addItem(new MyItem(18.545723, 73.917202, "Loc2", snippet)); + protected boolean shouldRender( + @NonNull Set> oldClusters, + @NonNull Set> newClusters) { + if (crossedZoomThreshold(oldZoom, zoom)) { + // Render when the zoom level crosses the threshold, even if the clusters don't change + return true; + } else { + // If clusters didn't change, skip render for optimization using default super + // implementation + return super.shouldRender(oldClusters, newClusters); + } } - private class ZoomBasedRenderer extends DefaultClusterRenderer implements GoogleMap.OnCameraIdleListener { - private Float zoom = 15f; - private Float oldZoom; - private static final float ZOOM_THRESHOLD = 12f; - - public ZoomBasedRenderer(Context context, GoogleMap map, ClusterManager clusterManager) { - super(context, map, clusterManager); - } - - /** - * The {@link ClusterManager} will call the {@link this.onCameraIdle()} implementation of - * any Renderer that implements {@link GoogleMap.OnCameraIdleListener} before - * clustering and rendering takes place. This allows us to capture metrics that may be - * useful for clustering, such as the zoom level. - */ - @Override - public void onCameraIdle() { - // Remember the previous zoom level, capture the new zoom level. - oldZoom = zoom; - zoom = getMap().getCameraPosition().zoom; - } - - /** - * You can override this method to control when the cluster manager renders a group of - * items as a cluster (vs. as a set of individual markers). - *

- * In this case, we want single markers to show up as a cluster when zoomed out, but - * individual markers when zoomed in. - * - * @param cluster cluster to examine for rendering - * @return true when zoom level is less than the threshold (show as cluster when zoomed out), - * and false when the the zoom level is more than or equal to the threshold (show as marker - * when zoomed in) - */ - @Override - protected boolean shouldRenderAsCluster(@NonNull Cluster cluster) { - // Show as cluster when zoom is less than the threshold, otherwise show as marker - return zoom < ZOOM_THRESHOLD; - } - - /** - * You can override this method to control optimizations surrounding rendering. The default - * implementation in the library simply checks if the new clusters are equal to the old - * clusters, and if so, it returns false to avoid re-rendering the same content. - *

- * However, in our case we need to change this behavior. As defined in - * {@link this.shouldRenderAsCluster()}, we want an item to render as a cluster above a - * certain zoom level and as a marker below a certain zoom level even if the contents of - * the clusters themselves did not change. In this case, we need to override this method - * to implement this new optimization behavior. - * - * Note that always returning true from this method could potentially have negative - * performance implications as clusters will be re-rendered on each pass even if they don't - * change. - * - * @param oldClusters The clusters from the previous iteration of the clustering algorithm - * @param newClusters The clusters from the current iteration of the clustering algorithm - * @return true if the new clusters should be rendered on the map, and false if they should - * not. - */ - @Override - protected boolean shouldRender(@NonNull Set> oldClusters, @NonNull Set> newClusters) { - if (crossedZoomThreshold(oldZoom, zoom)) { - // Render when the zoom level crosses the threshold, even if the clusters don't change - return true; - } else { - // If clusters didn't change, skip render for optimization using default super implementation - return super.shouldRender(oldClusters, newClusters); - } - } - - /** - * Returns true if the transition between the two zoom levels crossed a defined threshold, - * false if it did not. - * - * @param oldZoom zoom level from the previous time the camera stopped moving - * @param newZoom zoom level from the most recent time the camera stopped moving - * @return true if the transition between the two zoom levels crossed a defined threshold, - * false if it did not. - */ - private boolean crossedZoomThreshold(Float oldZoom, Float newZoom) { - if (oldZoom == null || newZoom == null) { - return true; - } - return (oldZoom < ZOOM_THRESHOLD && newZoom > ZOOM_THRESHOLD) || - (oldZoom > ZOOM_THRESHOLD && newZoom < ZOOM_THRESHOLD); - } + /** + * Returns true if the transition between the two zoom levels crossed a defined threshold, false + * if it did not. + * + * @param oldZoom zoom level from the previous time the camera stopped moving + * @param newZoom zoom level from the most recent time the camera stopped moving + * @return true if the transition between the two zoom levels crossed a defined threshold, false + * if it did not. + */ + private boolean crossedZoomThreshold(Float oldZoom, Float newZoom) { + if (oldZoom == null || newZoom == null) { + return true; + } + return (oldZoom < ZOOM_THRESHOLD && newZoom > ZOOM_THRESHOLD) + || (oldZoom > ZOOM_THRESHOLD && newZoom < ZOOM_THRESHOLD); } + } } diff --git a/demo/src/main/java/com/google/maps/android/utils/demo/model/DemoModels.kt b/demo/src/main/java/com/google/maps/android/utils/demo/model/DemoModels.kt index d851fa092..95dc104dc 100644 --- a/demo/src/main/java/com/google/maps/android/utils/demo/model/DemoModels.kt +++ b/demo/src/main/java/com/google/maps/android/utils/demo/model/DemoModels.kt @@ -13,30 +13,29 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - package com.google.maps.android.utils.demo.model - - import android.app.Activity - import androidx.annotation.StringRes - - /** - * Represents a single demo activity in the list. - * - * @property titleResId The string resource ID for the demo title. - * @property activityClass The Activity class to launch when this demo is selected. - */ - data class Demo( - @StringRes val titleResId: Int, - val activityClass: Class - ) - - /** - * Represents a group of related demos (e.g., "Clustering", "Data Layers"). - * - * @property titleResId The string resource ID for the group header. - * @property demos The list of demos contained within this group. - */ - data class DemoGroup( - @StringRes val titleResId: Int, - val demos: List - ) + +import android.app.Activity +import androidx.annotation.StringRes + +/** + * Represents a single demo activity in the list. + * + * @property titleResId The string resource ID for the demo title. + * @property activityClass The Activity class to launch when this demo is selected. + */ +data class Demo( + @StringRes val titleResId: Int, + val activityClass: Class, +) + +/** + * Represents a group of related demos (e.g., "Clustering", "Data Layers"). + * + * @property titleResId The string resource ID for the group header. + * @property demos The list of demos contained within this group. + */ +data class DemoGroup( + @StringRes val titleResId: Int, + val demos: List, +) diff --git a/demo/src/main/java/com/google/maps/android/utils/demo/model/MyItem.java b/demo/src/main/java/com/google/maps/android/utils/demo/model/MyItem.java index 6ff5a6a1f..fad7b8ae0 100644 --- a/demo/src/main/java/com/google/maps/android/utils/demo/model/MyItem.java +++ b/demo/src/main/java/com/google/maps/android/utils/demo/model/MyItem.java @@ -1,11 +1,11 @@ /* - * Copyright 2023 Google Inc. + * 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 + * 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, @@ -13,70 +13,75 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - package com.google.maps.android.utils.demo.model; +import androidx.annotation.NonNull; import com.google.android.gms.maps.model.LatLng; import com.google.maps.android.clustering.ClusterItem; -import androidx.annotation.NonNull; - public class MyItem implements ClusterItem { - private final LatLng mPosition; - private String mTitle; - private String mSnippet; - private Float zIndex; + private final LatLng mPosition; + private String mTitle; + private String mSnippet; + private Float zIndex; - public MyItem(double lat, double lng) { - mPosition = new LatLng(lat, lng); - mTitle = null; - mSnippet = null; - } + public MyItem(double lat, double lng) { + mPosition = new LatLng(lat, lng); + mTitle = null; + mSnippet = null; + } - public MyItem(double lat, double lng, String title, String snippet) { - mPosition = new LatLng(lat, lng); - mTitle = title; - mSnippet = snippet; - } + public MyItem(double lat, double lng, String title, String snippet) { + mPosition = new LatLng(lat, lng); + mTitle = title; + mSnippet = snippet; + } - @NonNull - @Override - public LatLng getPosition() { - return mPosition; - } + @NonNull + @Override + public LatLng getPosition() { + return mPosition; + } - @Override - public String getTitle() { return mTitle; } + @Override + public String getTitle() { + return mTitle; + } - @Override - public String getSnippet() { return mSnippet; } + @Override + public String getSnippet() { + return mSnippet; + } - @Override - public Float getZIndex() { - return zIndex; - } + @Override + public Float getZIndex() { + return zIndex; + } - /** - * Set the title of the marker - * @param title string to be set as title - */ - public void setTitle(String title) { - mTitle = title; - } + /** + * Set the title of the marker + * + * @param title string to be set as title + */ + public void setTitle(String title) { + mTitle = title; + } - /** - * Set the description of the marker - * @param snippet string to be set as snippet - */ - public void setSnippet(String snippet) { - mSnippet = snippet; - } + /** + * Set the description of the marker + * + * @param snippet string to be set as snippet + */ + public void setSnippet(String snippet) { + mSnippet = snippet; + } - /** - * Set the z-index of the marker - * @param zIndex float to be set as z-index - */ - public void setZIndex(Float zIndex) { - this.zIndex = zIndex; - } + /** + * Set the z-index of the marker + * + * @param zIndex float to be set as z-index + */ + public void setZIndex(Float zIndex) { + this.zIndex = zIndex; + } } diff --git a/demo/src/main/java/com/google/maps/android/utils/demo/model/Person.java b/demo/src/main/java/com/google/maps/android/utils/demo/model/Person.java index 6d1a7fe70..95a63f4cc 100644 --- a/demo/src/main/java/com/google/maps/android/utils/demo/model/Person.java +++ b/demo/src/main/java/com/google/maps/android/utils/demo/model/Person.java @@ -1,11 +1,11 @@ /* - * Copyright 2023 Google Inc. + * 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 + * 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, @@ -13,62 +13,58 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - package com.google.maps.android.utils.demo.model; -import com.google.android.gms.maps.model.LatLng; -import com.google.maps.android.clustering.ClusterItem; - import androidx.annotation.NonNull; import androidx.annotation.Nullable; - +import com.google.android.gms.maps.model.LatLng; +import com.google.maps.android.clustering.ClusterItem; import java.util.Objects; - public class Person implements ClusterItem { - public final String name; - public final int profilePhoto; - private final LatLng mPosition; + public final String name; + public final int profilePhoto; + private final LatLng mPosition; - public Person(LatLng position, String name, int pictureResource) { - this.name = name; - profilePhoto = pictureResource; - mPosition = position; - } + public Person(LatLng position, String name, int pictureResource) { + this.name = name; + profilePhoto = pictureResource; + mPosition = position; + } - @NonNull - @Override - public LatLng getPosition() { - return mPosition; - } + @NonNull + @Override + public LatLng getPosition() { + return mPosition; + } - @Override - public String getTitle() { - return null; - } + @Override + public String getTitle() { + return null; + } - @Override - public String getSnippet() { - return null; - } + @Override + public String getSnippet() { + return null; + } - @Nullable - @Override - public Float getZIndex() { - return null; - } + @Nullable + @Override + public Float getZIndex() { + return null; + } - @Override - public int hashCode() { - return Objects.hashCode(name); - } + @Override + public int hashCode() { + return Objects.hashCode(name); + } - // If we use the diff() operation, we need to implement an equals operation, to determine what - // makes each ClusterItem unique (which is probably not the position) - @Override - public boolean equals(@Nullable Object obj) { - if (obj != null && getClass() != obj.getClass()) return false; - Person myObj = (Person) obj; - return this.name.equals(myObj.name); - } + // If we use the diff() operation, we need to implement an equals operation, to determine what + // makes each ClusterItem unique (which is probably not the position) + @Override + public boolean equals(@Nullable Object obj) { + if (obj != null && getClass() != obj.getClass()) return false; + Person myObj = (Person) obj; + return this.name.equals(myObj.name); + } } diff --git a/demo/src/main/res/drawable/baseline_airplanemode_active_24.xml b/demo/src/main/res/drawable/baseline_airplanemode_active_24.xml index fefccaeaa..f059a7e59 100644 --- a/demo/src/main/res/drawable/baseline_airplanemode_active_24.xml +++ b/demo/src/main/res/drawable/baseline_airplanemode_active_24.xml @@ -1,6 +1,6 @@ + + + + + diff --git a/demo/src/main/res/drawable/bg_circle_white.xml b/demo/src/main/res/drawable/bg_circle_white.xml new file mode 100644 index 000000000..0d471d054 --- /dev/null +++ b/demo/src/main/res/drawable/bg_circle_white.xml @@ -0,0 +1,21 @@ + + + + + + diff --git a/demo/src/main/res/layout/activity_cluster_algorithms_demo.xml b/demo/src/main/res/layout/activity_cluster_algorithms_demo.xml index 1c75a833c..39b8bd336 100644 --- a/demo/src/main/res/layout/activity_cluster_algorithms_demo.xml +++ b/demo/src/main/res/layout/activity_cluster_algorithms_demo.xml @@ -1,6 +1,6 @@ + + + + + + + + + + + + +