diff --git a/demo/src/main/java/com/google/maps/android/utils/demo/BaseDemoActivity.java b/demo/src/main/java/com/google/maps/android/utils/demo/BaseDemoActivity.java index 67cc61043..2281c8dfc 100644 --- a/demo/src/main/java/com/google/maps/android/utils/demo/BaseDemoActivity.java +++ b/demo/src/main/java/com/google/maps/android/utils/demo/BaseDemoActivity.java @@ -28,6 +28,8 @@ import com.google.android.gms.maps.OnMapReadyCallback; import com.google.android.gms.maps.SupportMapFragment; +import java.util.Objects; + public abstract class BaseDemoActivity extends FragmentActivity implements OnMapReadyCallback { private GoogleMap mMap; private boolean mIsRestore; @@ -60,7 +62,7 @@ public void onMapReady(@NonNull GoogleMap map) { } private void setUpMap() { - ((SupportMapFragment) getSupportFragmentManager().findFragmentById(R.id.map)).getMapAsync(this); + ((SupportMapFragment) Objects.requireNonNull(getSupportFragmentManager().findFragmentById(R.id.map))).getMapAsync(this); } /** diff --git a/demo/src/main/java/com/google/maps/android/utils/demo/HeatmapsPlacesDemoActivity.java b/demo/src/main/java/com/google/maps/android/utils/demo/HeatmapsPlacesDemoActivity.java index 7a9c9f7d4..1f2aa3c35 100644 --- a/demo/src/main/java/com/google/maps/android/utils/demo/HeatmapsPlacesDemoActivity.java +++ b/demo/src/main/java/com/google/maps/android/utils/demo/HeatmapsPlacesDemoActivity.java @@ -19,6 +19,7 @@ import android.content.Context; import android.graphics.Color; import android.os.AsyncTask; +import android.os.Bundle; import android.util.Log; import android.view.View; import android.view.inputmethod.EditorInfo; @@ -63,6 +64,9 @@ public class HeatmapsPlacesDemoActivity extends BaseDemoActivity { private final String TAG = "HeatmapPlacesDemo"; private final LatLng SYDNEY = new LatLng(-33.873651, 151.2058896); + private final LatLng BOULDER = new LatLng(40.0216437819216, -105.25471683073081); + + private final LatLng FOCUS = BOULDER; /** * The base URL for the radar search request. @@ -78,27 +82,28 @@ public class HeatmapsPlacesDemoActivity extends BaseDemoActivity { /** * Places API server key. */ - private static final String API_KEY = "YOUR_KEY_HERE"; // TODO place your own here! + private static final String API_KEY = BuildConfig.PLACES_API_KEY; /** * The colors to be used for the different heatmap layers. */ private static final int[] HEATMAP_COLORS = { - HeatmapColors.RED.color, - HeatmapColors.BLUE.color, - HeatmapColors.GREEN.color, - HeatmapColors.PINK.color, - HeatmapColors.GREY.color + HeatmapColors.RED.color, + HeatmapColors.BLUE.color, + HeatmapColors.GREEN.color, + HeatmapColors.PINK.color, + HeatmapColors.GREY.color }; public enum HeatmapColors { - RED (Color.rgb(238, 44, 44)), - BLUE (Color.rgb(60, 80, 255)), - GREEN (Color.rgb(20, 170, 50)), - PINK (Color.rgb(255, 80, 255)), - GREY (Color.rgb(100, 100, 100)); + RED(Color.rgb(238, 44, 44)), + BLUE(Color.rgb(60, 80, 255)), + GREEN(Color.rgb(20, 170, 50)), + PINK(Color.rgb(255, 80, 255)), + GREY(Color.rgb(100, 100, 100)); private final int color; + HeatmapColors(int color) { this.color = color; } @@ -115,7 +120,7 @@ public enum HeatmapColors { /** * Stores the TileOverlay corresponding to each of the keywords that have been searched for. */ - private Hashtable mOverlays = new Hashtable(); + private final Hashtable mOverlays = new Hashtable(); /** * A layout containing checkboxes for each of the heatmaps rendered. @@ -132,6 +137,19 @@ public enum HeatmapColors { */ private int mOverlaysInput = 0; + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + if ("YOUR_API_KEY".equals(API_KEY)) { + Toast.makeText( + this, + "Please sign up for a Places API key and add it to HeatmapsPlacesDemoActivity.API_KEY", + Toast.LENGTH_LONG + ).show(); + finish(); + } + } + @Override protected int getLayoutId() { return R.layout.places_demo; @@ -152,11 +170,11 @@ protected void startDemo(boolean isRestore) { mCheckboxLayout = findViewById(R.id.checkboxes); GoogleMap map = getMap(); if (!isRestore) { - map.moveCamera(CameraUpdateFactory.newLatLngZoom(SYDNEY, 11)); + map.moveCamera(CameraUpdateFactory.newLatLngZoom(FOCUS, 11)); } - // Add a circle around Sydney to roughly encompass the results + // Add a circle around FOCUS to roughly encompass the results map.addCircle(new CircleOptions() - .center(SYDNEY) + .center(FOCUS) .radius(SEARCH_RADIUS * 1.2) .strokeColor(Color.RED) .strokeWidth(4)); @@ -167,18 +185,13 @@ protected void startDemo(boolean isRestore) { * Called when a search query is submitted */ public void submit(View view) { - if ("YOUR_KEY_HERE".equals(API_KEY)) { - Toast.makeText(this, "Please sign up for a Places API key and add it to HeatmapsPlacesDemoActivity.API_KEY", - Toast.LENGTH_LONG).show(); - return; - } EditText editText = findViewById(R.id.input_text); String keyword = editText.getText().toString(); if (mOverlays.contains(keyword)) { Toast.makeText(this, "This keyword has already been inputted :(", Toast.LENGTH_SHORT).show(); } else if (mOverlaysRendered == MAX_CHECKBOXES) { Toast.makeText(this, "You can only input " + MAX_CHECKBOXES + " keywords. :(", Toast.LENGTH_SHORT).show(); - } else if (keyword.length() != 0) { + } else if (!keyword.isEmpty()) { mOverlaysInput++; ProgressBar progressBar = findViewById(R.id.progress_bar); progressBar.setVisibility(View.VISIBLE); @@ -202,11 +215,11 @@ public void submit(View view) { private Collection getPoints(String keyword) { HashMap results = new HashMap<>(); - // Calculate four equidistant points around Sydney to use as search centers + // Calculate four equidistant points around FOCUS to use as search centers // so that four searches can be done. ArrayList searchCenters = new ArrayList<>(4); for (int heading = 45; heading < 360; heading += 90) { - searchCenters.add(SphericalUtil.computeOffset(SYDNEY, SEARCH_RADIUS / 2, heading)); + searchCenters.add(SphericalUtil.computeOffset(FOCUS, (double) SEARCH_RADIUS / 2, heading)); } for (int j = 0; j < 4; j++) { diff --git a/library/src/main/java/com/google/maps/android/heatmaps/Gradient.java b/library/src/main/java/com/google/maps/android/heatmaps/Gradient.java deleted file mode 100644 index 559b5f0bc..000000000 --- a/library/src/main/java/com/google/maps/android/heatmaps/Gradient.java +++ /dev/null @@ -1,193 +0,0 @@ -/* - * Copyright 2014 Google Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.maps.android.heatmaps; - -import android.graphics.Color; - -import java.util.HashMap; - -/** - * A class to generate a color map from a given array of colors and the fractions - * that the colors represent by interpolating between their HSV values. - * This color map is to be used in the HeatmapTileProvider. - */ -public class Gradient { - - private class ColorInterval { - private final int color1; - private final int color2; - - /** - * The period over which the color changes from color1 to color2. - * This is given as the number of elements it represents in the colorMap. - */ - private final float duration; - - private ColorInterval(int color1, int color2, float duration) { - this.color1 = color1; - this.color2 = color2; - this.duration = duration; - } - } - - private static final int DEFAULT_COLOR_MAP_SIZE = 1000; - - /** - * Size of a color map for the heatmap - */ - public final int mColorMapSize; - - /** - * The colors to be used in the gradient - */ - public int[] mColors; - - /** - * The starting point for each color, given as a percentage of the maximum intensity - */ - public float[] mStartPoints; - - /** - * Creates a Gradient with the given colors and starting points. - * These are given as parallel arrays. - * - * @param colors The colors to be used in the gradient - * @param startPoints The starting point for each color, given as a percentage of the maximum intensity - * This is given as an array of floats with values in the interval [0,1] - */ - public Gradient(int[] colors, float[] startPoints) { - this(colors, startPoints, DEFAULT_COLOR_MAP_SIZE); - } - - /** - * Creates a Gradient with the given colors and starting points which creates a colorMap of given size. - * The colors and starting points are given as parallel arrays. - * - * @param colors The colors to be used in the gradient - * @param startPoints The starting point for each color, given as a percentage of the maximum intensity - * This is given as an array of floats with values in the interval [0,1] - * @param colorMapSize The size of the colorMap to be generated by the Gradient - */ - public Gradient(int[] colors, float[] startPoints, int colorMapSize) { - if (colors.length != startPoints.length) { - throw new IllegalArgumentException("colors and startPoints should be same length"); - } else if (colors.length == 0) { - throw new IllegalArgumentException("No colors have been defined"); - } - for (int i = 1; i < startPoints.length; i++) { - if (startPoints[i] <= startPoints[i - 1]) { - throw new IllegalArgumentException("startPoints should be in increasing order"); - } - } - mColorMapSize = colorMapSize; - mColors = new int[colors.length]; - mStartPoints = new float[startPoints.length]; - System.arraycopy(colors, 0, mColors, 0, colors.length); - System.arraycopy(startPoints, 0, mStartPoints, 0, startPoints.length); - } - - private HashMap generateColorIntervals() { - HashMap colorIntervals = new HashMap(); - // Create first color if not already created - // The initial color is transparent by default - if (mStartPoints[0] != 0) { - int initialColor = Color.argb( - 0, Color.red(mColors[0]), Color.green(mColors[0]), Color.blue(mColors[0])); - colorIntervals.put(0, new ColorInterval(initialColor, mColors[0], mColorMapSize * mStartPoints[0])); - } - // Generate color intervals - for (int i = 1; i < mColors.length; i++) { - colorIntervals.put(((int) (mColorMapSize * mStartPoints[i - 1])), - new ColorInterval(mColors[i - 1], mColors[i], - (mColorMapSize * (mStartPoints[i] - mStartPoints[i - 1])))); - } - // Extend to a final color - // If color for 100% intensity is not given, the color of highest intensity is used. - if (mStartPoints[mStartPoints.length - 1] != 1) { - int i = mStartPoints.length - 1; - colorIntervals.put(((int) (mColorMapSize * mStartPoints[i])), - new ColorInterval(mColors[i], mColors[i], mColorMapSize * (1 - mStartPoints[i]))); - } - return colorIntervals; - } - - /** - * Generates the color map to use with a provided gradient. - * - * @param opacity Overall opacity of entire image: every individual alpha value will be - * multiplied by this opacity. - * @return the generated color map based on the gradient - */ - int[] generateColorMap(double opacity) { - HashMap colorIntervals = generateColorIntervals(); - int[] colorMap = new int[mColorMapSize]; - ColorInterval interval = colorIntervals.get(0); - int start = 0; - for (int i = 0; i < mColorMapSize; i++) { - if (colorIntervals.containsKey(i)) { - interval = colorIntervals.get(i); - start = i; - } - float ratio = (i - start) / interval.duration; - colorMap[i] = interpolateColor(interval.color1, interval.color2, ratio); - } - if (opacity != 1) { - for (int i = 0; i < mColorMapSize; i++) { - int c = colorMap[i]; - colorMap[i] = Color.argb((int) (Color.alpha(c) * opacity), - Color.red(c), Color.green(c), Color.blue(c)); - } - } - - return colorMap; - } - - /** - * Helper function for creation of color map - * Interpolates between two given colors using their HSV values. - * - * @param color1 First color - * @param color2 Second color - * @param ratio Between 0 to 1. Fraction of the distance between color1 and color2 - * @return Color associated with x2 - */ - static int interpolateColor(int color1, int color2, float ratio) { - - int alpha = (int) ((Color.alpha(color2) - Color.alpha(color1)) * ratio + Color.alpha(color1)); - - float[] hsv1 = new float[3]; - Color.RGBToHSV(Color.red(color1), Color.green(color1), Color.blue(color1), hsv1); - float[] hsv2 = new float[3]; - Color.RGBToHSV(Color.red(color2), Color.green(color2), Color.blue(color2), hsv2); - - // adjust so that the shortest path on the color wheel will be taken - if (hsv1[0] - hsv2[0] > 180) { - hsv2[0] += 360; - } else if (hsv2[0] - hsv1[0] > 180) { - hsv1[0] += 360; - } - - // Interpolate using calculated ratio - float[] result = new float[3]; - for (int i = 0; i < 3; i++) { - result[i] = (hsv2[i] - hsv1[i]) * (ratio) + hsv1[i]; - } - - return Color.HSVToColor(alpha, result); - } - -} diff --git a/library/src/main/java/com/google/maps/android/heatmaps/Gradient.kt b/library/src/main/java/com/google/maps/android/heatmaps/Gradient.kt new file mode 100644 index 000000000..0a5b0dd0a --- /dev/null +++ b/library/src/main/java/com/google/maps/android/heatmaps/Gradient.kt @@ -0,0 +1,154 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.maps.android.heatmaps + +import android.graphics.Color +import java.util.HashMap + +/** + * A class to generate a color map from a given array of colors and the fractions + * that the colors represent by interpolating between their HSV values. + * This color map is to be used in the HeatmapTileProvider. + * + * @param colors The colors to be used in the gradient. + * @param startPoints The starting point for each color, given as a percentage of the maximum + * intensity. This is given as an array of floats with values in the interval [0, 1]. + * @param colorMapSize The size of the colorMap to be generated by the Gradient. + * Default value is 1000. + */ +class Gradient @JvmOverloads constructor( + val colors: IntArray, + val startPoints: FloatArray, + val colorMapSize: Int = DEFAULT_COLOR_MAP_SIZE +) { + private data class ColorInterval( + val color1: Int, + val color2: Int, + /** + * The period over which the color changes from color1 to color2. + * This is given as the number of elements it represents in the colorMap. + */ + val duration: Float + ) + + init { + require(colors.size == startPoints.size) { "colors and startPoints should be same length" } + require(colors.isNotEmpty()) { "No colors have been defined" } + for (i in 1 until startPoints.size) { + require(startPoints[i] > startPoints[i - 1]) { "startPoints should be in increasing order" } + } + } + + private fun generateColorIntervals(): HashMap { + val colorIntervals = HashMap() + // Create first color if not already created + // The initial color is transparent by default + if (startPoints[0] != 0f) { + val initialColor = Color.argb( + 0, Color.red(colors[0]), Color.green(colors[0]), Color.blue(colors[0]) + ) + colorIntervals[0] = ColorInterval(initialColor, colors[0], colorMapSize * startPoints[0]) + } + // Generate color intervals + for (i in 1 until colors.size) { + val start = (colorMapSize * startPoints[i - 1]).toInt() + val duration = colorMapSize * (startPoints[i] - startPoints[i - 1]) + colorIntervals[start] = ColorInterval(colors[i - 1], colors[i], duration) + } + // Extend to a final color + // If color for 100% intensity is not given, the color of highest intensity is used. + if (startPoints.last() != 1f) { + val i = startPoints.size - 1 + val start = (colorMapSize * startPoints[i]).toInt() + colorIntervals[start] = ColorInterval(colors[i], colors[i], colorMapSize * (1 - startPoints[i])) + } + return colorIntervals + } + + /** + * Generates a color map array from the gradient's colors and start points. This map is a key + * component for rendering the heatmap, where each color corresponds to a different intensity + * level. + * + * The process involves interpolating between the specified colors in the HSV color space to create + * a smooth transition. + * + * @param opacity The overall opacity of the entire color map. Each color's alpha value will be + * multiplied by this factor. The default value is [HeatmapTileProvider.DEFAULT_OPACITY]. + * @return An integer array representing the color map, where each element is a color integer. + */ + @JvmOverloads + fun generateColorMap(opacity: Double = HeatmapTileProvider.DEFAULT_OPACITY): IntArray { + val colorIntervals = generateColorIntervals() + val colorMap = IntArray(colorMapSize) + var interval = colorIntervals[0] + var start = 0 + for (i in 0 until colorMapSize) { + if (colorIntervals.containsKey(i)) { + interval = colorIntervals[i] + start = i + } + val ratio = (i - start) / interval!!.duration + colorMap[i] = interpolateColor(interval.color1, interval.color2, ratio) + } + if (opacity != 1.0) { + for (i in 0 until colorMapSize) { + val c = colorMap[i] + colorMap[i] = Color.argb( + (Color.alpha(c) * opacity).toInt(), + Color.red(c), Color.green(c), Color.blue(c) + ) + } + } + return colorMap + } + + companion object { + private const val DEFAULT_COLOR_MAP_SIZE = 1000 + + /** + * Helper function for creation of color map. + * Interpolates between two given colors using their HSV values. + * + * @param color1 First color + * @param color2 Second color + * @param ratio Between 0 to 1. Fraction of the distance between color1 and color2 + * @return Color associated with x2 + */ + @JvmStatic + fun interpolateColor(color1: Int, color2: Int, ratio: Float): Int { + val alpha = ((Color.alpha(color2) - Color.alpha(color1)) * ratio + Color.alpha(color1)).toInt() + val hsv1 = FloatArray(3) + Color.RGBToHSV(Color.red(color1), Color.green(color1), Color.blue(color1), hsv1) + val hsv2 = FloatArray(3) + Color.RGBToHSV(Color.red(color2), Color.green(color2), Color.blue(color2), hsv2) + + // adjust so that the shortest path on the color wheel will be taken + if (hsv1[0] - hsv2[0] > 180) { + hsv2[0] += 360f + } else if (hsv2[0] - hsv1[0] > 180) { + hsv1[0] += 360f + } + + // Interpolate using calculated ratio + val result = FloatArray(3) + for (i in 0..2) { + result[i] = (hsv2[i] - hsv1[i]) * ratio + hsv1[i] + } + return Color.HSVToColor(alpha, result) + } + } +} \ No newline at end of file diff --git a/library/src/main/java/com/google/maps/android/heatmaps/HeatmapTileProvider.java b/library/src/main/java/com/google/maps/android/heatmaps/HeatmapTileProvider.java deleted file mode 100644 index 396a1900c..000000000 --- a/library/src/main/java/com/google/maps/android/heatmaps/HeatmapTileProvider.java +++ /dev/null @@ -1,814 +0,0 @@ -/* - * Copyright 2014 Google Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.maps.android.heatmaps; - -import android.graphics.Bitmap; -import android.graphics.Color; - -import androidx.collection.LongSparseArray; - -import com.google.android.gms.maps.model.LatLng; -import com.google.android.gms.maps.model.Tile; -import com.google.android.gms.maps.model.TileProvider; -import com.google.maps.android.geometry.Bounds; -import com.google.maps.android.geometry.Point; -import com.google.maps.android.quadtree.PointQuadTree; - -import java.io.ByteArrayOutputStream; -import java.util.ArrayList; -import java.util.Collection; -import java.util.Iterator; - -/** - * Tile provider that creates heatmap tiles. - */ -public class HeatmapTileProvider implements TileProvider { - - /** - * Default radius for convolution - */ - public static final int DEFAULT_RADIUS = 20; - - /** - * Default opacity of heatmap overlay - */ - public static final double DEFAULT_OPACITY = 0.7; - - /** - * Colors for default gradient. - * Array of colors, represented by ints. - */ - private static final int[] DEFAULT_GRADIENT_COLORS = { - Color.rgb(102, 225, 0), - Color.rgb(255, 0, 0) - }; - - /** - * Starting fractions for default gradient. - * This defines which percentages the above colors represent. - * These should be a sorted array of floats in the interval [0, 1]. - */ - private static final float[] DEFAULT_GRADIENT_START_POINTS = { - 0.2f, 1f - }; - - /** - * Default gradient for heatmap. - */ - public static final Gradient DEFAULT_GRADIENT = new Gradient(DEFAULT_GRADIENT_COLORS, DEFAULT_GRADIENT_START_POINTS); - - /** - * Size of the world (arbitrary). - * Used to measure distances relative to the total world size. - * Package access for WeightedLatLng. - */ - static final double WORLD_WIDTH = 1; - - /** - * Tile dimension, in pixels. - */ - private static final int TILE_DIM = 512; - - /** - * Assumed screen size (pixels) - */ - private static final int SCREEN_SIZE = 1280; - - /** - * Default (and minimum possible) minimum zoom level at which to calculate maximum intensities - */ - private static final int DEFAULT_MIN_ZOOM = 5; - - /** - * Default (and maximum possible) maximum zoom level at which to calculate maximum intensities - */ - private static final int DEFAULT_MAX_ZOOM = 11; - - /** - * Maximum zoom level possible on a map. - */ - private static final int MAX_ZOOM_LEVEL = 22; - - /** - * Minimum radius value. - */ - private static final int MIN_RADIUS = 10; - - /** - * Maximum radius value. - */ - private static final int MAX_RADIUS = 50; - - /** - * Quad tree of all the points to display in the heatmap - */ - private PointQuadTree mTree; - - /** - * Collection of all the data. - */ - private Collection mData; - - /** - * Bounds of the quad tree - */ - private Bounds mBounds; - - /** - * Heatmap point radius. - */ - private int mRadius; - - /** - * Gradient of the color map - */ - private Gradient mGradient; - - /** - * Color map to use to color tiles - */ - private int[] mColorMap; - - /** - * Kernel to use for convolution - */ - private double[] mKernel; - - /** - * Opacity of the overall heatmap overlay [0...1] - */ - private double mOpacity; - - /** - * Maximum intensity estimates for heatmap - */ - private double[] mMaxIntensity; - - /** - * Optional user defined maximum intensity for heatmap - */ - private double mCustomMaxIntensity; - - /** - * Builder class for the HeatmapTileProvider. - */ - public static class Builder { - // Required parameters - not final, as there are 2 ways to set it - private Collection data; - - // Optional, initialised to default values - private int radius = DEFAULT_RADIUS; - private Gradient gradient = DEFAULT_GRADIENT; - private double opacity = DEFAULT_OPACITY; - private double intensity = 0; - - /** - * Constructor for builder. - * No required parameters here, but user must call either data() or weightedData(). - */ - public Builder() { - } - - /** - * Setter for data in builder. Must call this or weightedData - * - * @param val Collection of LatLngs to put into quadtree. - * Should be non-empty. - * @return updated builder object - */ - public Builder data(Collection val) { - return weightedData(wrapData(val)); - } - - /** - * Setter for data in builder. Must call this or data - * - * @param val Collection of WeightedLatLngs to put into quadtree. - * Should be non-empty. - * @return updated builder object - */ - public Builder weightedData(Collection val) { - this.data = val; - - // Check that points is non empty - if (this.data.isEmpty()) { - throw new IllegalArgumentException("No input points."); - } - return this; - } - - /** - * Setter for radius in builder - * - * @param val Radius of convolution to use, in terms of pixels. - * Must be within minimum and maximum values of 10 to 50 inclusive. - * @return updated builder object - */ - public Builder radius(int val) { - radius = val; - // Check that radius is within bounds. - if (radius < MIN_RADIUS || radius > MAX_RADIUS) { - throw new IllegalArgumentException("Radius not within bounds."); - } - return this; - } - - /** - * Setter for gradient in builder - * - * @param val Gradient to color heatmap with. - * @return updated builder object - */ - public Builder gradient(Gradient val) { - gradient = val; - return this; - } - - /** - * Setter for opacity in builder - * - * @param val Opacity of the entire heatmap in range [0, 1] - * @return updated builder object - */ - public Builder opacity(double val) { - opacity = val; - // Check that opacity is in range - if (opacity < 0 || opacity > 1) { - throw new IllegalArgumentException("Opacity must be in range [0, 1]"); - } - return this; - } - - /** - * Setter for Max Intensity in builder - * - * @param val maximum intensity of pixel density - * @return updated builder object - */ - public Builder maxIntensity(double val) { - intensity = val; - return this; - } - - /** - * Call when all desired options have been set. - * Note: you must set data using data or weightedData before this! - * - * @return HeatmapTileProvider created with desired options. - */ - public HeatmapTileProvider build() { - // Check if data or weightedData has been called - if (data == null) { - throw new IllegalStateException("No input data: you must use either .data or " + - ".weightedData before building"); - } - - return new HeatmapTileProvider(this); - } - } - - private HeatmapTileProvider(Builder builder) { - // Get parameters from builder - mData = builder.data; - - mRadius = builder.radius; - mGradient = builder.gradient; - mOpacity = builder.opacity; - mCustomMaxIntensity = builder.intensity; - - // Compute kernel density function (sd = 1/3rd of radius) - mKernel = generateKernel(mRadius, mRadius / 3.0); - - // Generate color map - setGradient(mGradient); - - // Set the data - setWeightedData(mData); - } - - /** - * Changes the dataset the heatmap is portraying. Weighted. - * User should clear overlay's tile cache (using clearTileCache()) after calling this. - * - * @param data Data set of points to use in the heatmap, as LatLngs. - * Note: Editing data without calling setWeightedData again will not update the data - * displayed on the map, but will impact calculation of max intensity values, - * as the collection you pass in is stored. - * Outside of changing the data, max intensity values are calculated only upon - * changing the radius. - */ - public void setWeightedData(Collection data) { - // Change point set - mData = data; - - // Check point set is OK - if (mData.isEmpty()) { - throw new IllegalArgumentException("No input points."); - } - - // Because quadtree bounds are final once the quadtree is created, we cannot add - // points outside of those bounds to the quadtree after creation. - // As quadtree creation is actually quite lightweight/fast as compared to other functions - // called in heatmap creation, re-creating the quadtree is an acceptable solution here. - - // Make the quad tree - mBounds = getBounds(mData); - - mTree = new PointQuadTree(mBounds); - - // Add points to quad tree - for (WeightedLatLng l : mData) { - mTree.add(l); - } - - // Calculate reasonable maximum intensity for color scale (user can also specify) - // Get max intensities - mMaxIntensity = getMaxIntensities(mRadius); - } - - /** - * Changes the dataset the heatmap is portraying. Unweighted. - * User should clear overlay's tile cache (using clearTileCache()) after calling this. - * - * @param data Data set of points to use in the heatmap, as LatLngs. - */ - public void setData(Collection data) { - // Turn them into WeightedLatLngs and delegate. - setWeightedData(wrapData(data)); - } - - /** - * Helper function - wraps LatLngs into WeightedLatLngs. - * - * @param data Data to wrap (LatLng) - * @return Data, in WeightedLatLng form - */ - private static Collection wrapData(Collection data) { - // Use an ArrayList as it is a nice collection - ArrayList weightedData = new ArrayList(); - - for (LatLng l : data) { - weightedData.add(new WeightedLatLng(l)); - } - - return weightedData; - } - - /** - * Creates tile. - * - * @param x X coordinate of tile. - * @param y Y coordinate of tile. - * @param zoom Zoom level. - * @return image in Tile format - */ - public Tile getTile(int x, int y, int zoom) { - // Convert tile coordinates and zoom into Point/Bounds format - // Know that at zoom level 0, there is one tile: (0, 0) (arbitrary width 512) - // Each zoom level multiplies number of tiles by 2 - // Width of the world = WORLD_WIDTH = 1 - // x = [0, 1) corresponds to [-180, 180) - - // calculate width of one tile, given there are 2 ^ zoom tiles in that zoom level - // In terms of world width units - double tileWidth = WORLD_WIDTH / Math.pow(2, zoom); - - // how much padding to include in search - // is to tileWidth as mRadius (padding in terms of pixels) is to TILE_DIM - // In terms of world width units - double padding = tileWidth * mRadius / TILE_DIM; - - // padded tile width - // In terms of world width units - double tileWidthPadded = tileWidth + 2 * padding; - - // padded bucket width - divided by number of buckets - // In terms of world width units - double bucketWidth = tileWidthPadded / (TILE_DIM + mRadius * 2); - - // Make bounds: minX, maxX, minY, maxY - double minX = x * tileWidth - padding; - double maxX = (x + 1) * tileWidth + padding; - double minY = y * tileWidth - padding; - double maxY = (y + 1) * tileWidth + padding; - - // Deal with overlap across lat = 180 - // Need to make it wrap around both ways - // However, maximum tile size is such that you wont ever have to deal with both, so - // hence, the else - // Note: Tile must remain square, so cant optimise by editing bounds - double xOffset = 0; - Collection wrappedPoints = new ArrayList(); - if (minX < 0) { - // Need to consider "negative" points - // (minX to 0) -> (512+minX to 512) ie +512 - // add 512 to search bounds and subtract 512 from actual points - Bounds overlapBounds = new Bounds(minX + WORLD_WIDTH, WORLD_WIDTH, minY, maxY); - xOffset = -WORLD_WIDTH; - wrappedPoints = mTree.search(overlapBounds); - } else if (maxX > WORLD_WIDTH) { - // Cant both be true as then tile covers whole world - // Need to consider "overflow" points - // (512 to maxX) -> (0 to maxX-512) ie -512 - // subtract 512 from search bounds and add 512 to actual points - Bounds overlapBounds = new Bounds(0, maxX - WORLD_WIDTH, minY, maxY); - xOffset = WORLD_WIDTH; - wrappedPoints = mTree.search(overlapBounds); - } - - // Main tile bounds to search - Bounds tileBounds = new Bounds(minX, maxX, minY, maxY); - - // If outside of *padded* quadtree bounds, return blank tile - // This is comparing our bounds to the padded bounds of all points in the quadtree - // ie tiles that don't touch the heatmap at all - Bounds paddedBounds = new Bounds(mBounds.minX - padding, mBounds.maxX + padding, - mBounds.minY - padding, mBounds.maxY + padding); - if (!tileBounds.intersects(paddedBounds)) { - return TileProvider.NO_TILE; - } - - // Search for all points within tile bounds - Collection points = mTree.search(tileBounds); - - // If no points, return blank tile - if (points.isEmpty()) { - return TileProvider.NO_TILE; - } - - // Quantize points - double[][] intensity = new double[TILE_DIM + mRadius * 2][TILE_DIM + mRadius * 2]; - for (WeightedLatLng w : points) { - Point p = w.getPoint(); - int bucketX = (int) ((p.x - minX) / bucketWidth); - int bucketY = (int) ((p.y - minY) / bucketWidth); - intensity[bucketX][bucketY] += w.getIntensity(); - } - // Quantize wraparound points (taking xOffset into account) - for (WeightedLatLng w : wrappedPoints) { - Point p = w.getPoint(); - int bucketX = (int) ((p.x + xOffset - minX) / bucketWidth); - int bucketY = (int) ((p.y - minY) / bucketWidth); - intensity[bucketX][bucketY] += w.getIntensity(); - } - - // Convolve it ("smoothen" it out) - double[][] convolved = convolve(intensity, mKernel); - - // Color it into a bitmap - Bitmap bitmap = colorize(convolved, mColorMap, mMaxIntensity[zoom]); - - // Convert bitmap to tile and return - return convertBitmap(bitmap); - } - - /** - * Setter for gradient/color map. - * User should clear overlay's tile cache (using clearTileCache()) after calling this. - * - * @param gradient Gradient to set - */ - public void setGradient(Gradient gradient) { - mGradient = gradient; - mColorMap = gradient.generateColorMap(mOpacity); - } - - /** - * Setter for radius. - * User should clear overlay's tile cache (using clearTileCache()) after calling this. - * - * @param radius Radius to set - */ - public void setRadius(int radius) { - mRadius = radius; - // need to recompute kernel - mKernel = generateKernel(mRadius, mRadius / 3.0); - // need to recalculate max intensity - mMaxIntensity = getMaxIntensities(mRadius); - } - - /** - * Setter for opacity - * User should clear overlay's tile cache (using clearTileCache()) after calling this. - * - * @param opacity opacity to set - */ - public void setOpacity(double opacity) { - mOpacity = opacity; - // need to recompute kernel color map - setGradient(mGradient); - } - - /** - * Setter for max intensity - * User should clear overlay's tile cache (using clearTileCache()) after calling this. - * - * @param intensity intensity to set - */ - public void setMaxIntensity(double intensity) { - mCustomMaxIntensity = intensity; - // need to recompute data convolution - setWeightedData(mData); - } - - /** - * Gets array of maximum intensity values to use with the heatmap for each zoom level - * This is the value that the highest color on the color map corresponds to - * - * @param radius radius of the heatmap - * @return array of maximum intensities - */ - private double[] getMaxIntensities(int radius) { - // Can go from zoom level 3 to zoom level 22 - double[] maxIntensityArray = new double[MAX_ZOOM_LEVEL]; - - // A custom max intensity has been specified by user - // Set all zoom levels with intensity value - if(mCustomMaxIntensity != 0.0) { - for(int i = 0; i < MAX_ZOOM_LEVEL; i++) { - maxIntensityArray[i] = mCustomMaxIntensity; - } - - return maxIntensityArray; - } - - // Calculate max intensity for each zoom level - for (int i = DEFAULT_MIN_ZOOM; i < DEFAULT_MAX_ZOOM; i++) { - // Each zoom level multiplies viewable size by 2 - maxIntensityArray[i] = getMaxValue(mData, mBounds, radius, - (int) (SCREEN_SIZE * Math.pow(2, i - 3))); - if (i == DEFAULT_MIN_ZOOM) { - for (int j = 0; j < i; j++) maxIntensityArray[j] = maxIntensityArray[i]; - } - } - for (int i = DEFAULT_MAX_ZOOM; i < MAX_ZOOM_LEVEL; i++) { - maxIntensityArray[i] = maxIntensityArray[DEFAULT_MAX_ZOOM - 1]; - } - - return maxIntensityArray; - } - - /** - * helper function - convert a bitmap into a tile - * - * @param bitmap bitmap to convert into a tile - * @return the tile - */ - private static Tile convertBitmap(Bitmap bitmap) { - // Convert it into byte array (required for tile creation) - ByteArrayOutputStream stream = new ByteArrayOutputStream(); - bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream); - byte[] bitmapdata = stream.toByteArray(); - return new Tile(TILE_DIM, TILE_DIM, bitmapdata); - } - - /* Utility functions below */ - - /** - * Helper function for quadtree creation - * - * @param points Collection of WeightedLatLng to calculate bounds for - * @return Bounds that enclose the listed WeightedLatLng points - */ - static Bounds getBounds(Collection points) { - - // Use an iterator, need to access any one point of the collection for starting bounds - Iterator iter = points.iterator(); - - WeightedLatLng first = iter.next(); - - double minX = first.getPoint().x; - double maxX = first.getPoint().x; - double minY = first.getPoint().y; - double maxY = first.getPoint().y; - - while (iter.hasNext()) { - WeightedLatLng l = iter.next(); - double x = l.getPoint().x; - double y = l.getPoint().y; - // Extend bounds if necessary - if (x < minX) minX = x; - if (x > maxX) maxX = x; - if (y < minY) minY = y; - if (y > maxY) maxY = y; - } - - return new Bounds(minX, maxX, minY, maxY); - } - - /** - * Generates 1D Gaussian kernel density function, as a double array of size radius * 2 + 1 - * Normalised with central value of 1. - * - * @param radius radius of the kernel - * @param sd standard deviation of the Gaussian function - * @return generated Gaussian kernel - */ - static double[] generateKernel(int radius, double sd) { - double[] kernel = new double[radius * 2 + 1]; - for (int i = -radius; i <= radius; i++) { - kernel[i + radius] = (Math.exp(-i * i / (2 * sd * sd))); - } - return kernel; - } - - /** - * Applies a 2D Gaussian convolution to the input grid, returning a 2D grid cropped of padding. - * - * @param grid Raw input grid to convolve: dimension (dim + 2 * radius) x (dim + 2 * radius) - * ie dim * dim with padding of size radius - * @param kernel Pre-computed Gaussian kernel of size radius * 2 + 1 - * @return the smoothened grid - */ - static double[][] convolve(double[][] grid, double[] kernel) { - // Calculate radius size - int radius = (int) Math.floor((double) kernel.length / 2.0); - // Padded dimension - int dimOld = grid.length; - // Calculate final (non padded) dimension - int dim = dimOld - 2 * radius; - - // Upper and lower limits of non padded (inclusive) - int lowerLimit = radius; - int upperLimit = radius + dim - 1; - - // Convolve horizontally - double[][] intermediate = new double[dimOld][dimOld]; - - // Need to convolve every point (including those outside of non-padded area) - // but only need to add to points within non-padded area - int x, y, x2, xUpperLimit, initial; - double val; - for (x = 0; x < dimOld; x++) { - for (y = 0; y < dimOld; y++) { - // for each point (x, y) - val = grid[x][y]; - // only bother if something there - if (val != 0) { - // need to "apply" convolution from that point to every point in - // (max(lowerLimit, x - radius), y) to (min(upperLimit, x + radius), y) - xUpperLimit = ((upperLimit < x + radius) ? upperLimit : x + radius) + 1; - // Replace Math.max - initial = (lowerLimit > x - radius) ? lowerLimit : x - radius; - for (x2 = initial; x2 < xUpperLimit; x2++) { - // multiplier for x2 = x - radius is kernel[0] - // x2 = x + radius is kernel[radius * 2] - // so multiplier for x2 in general is kernel[x2 - (x - radius)] - intermediate[x2][y] += val * kernel[x2 - (x - radius)]; - } - } - } - } - - // Convolve vertically - double[][] outputGrid = new double[dim][dim]; - - // Similarly, need to convolve every point, but only add to points within non-padded area - // However, we are adding to a smaller grid here (previously, was to a grid of same size) - int y2, yUpperLimit; - - // Don't care about convolving parts in horizontal padding - wont impact inner - for (x = lowerLimit; x < upperLimit + 1; x++) { - for (y = 0; y < dimOld; y++) { - // for each point (x, y) - val = intermediate[x][y]; - // only bother if something there - if (val != 0) { - // need to "apply" convolution from that point to every point in - // (x, max(lowerLimit, y - radius) to (x, min(upperLimit, y + radius)) - // Don't care about - yUpperLimit = ((upperLimit < y + radius) ? upperLimit : y + radius) + 1; - // replace math.max - initial = (lowerLimit > y - radius) ? lowerLimit : y - radius; - for (y2 = initial; y2 < yUpperLimit; y2++) { - // Similar logic to above - // subtract, as adding to a smaller grid - outputGrid[x - radius][y2 - radius] += val * kernel[y2 - (y - radius)]; - } - } - } - } - - return outputGrid; - } - - /** - * Converts a grid of intensity values to a colored Bitmap, using a given color map - * - * @param grid the input grid (assumed to be square) - * @param colorMap color map (created by generateColorMap) - * @param max Maximum intensity value: maps to 100% on gradient - * @return the colorized grid in Bitmap form, with same dimensions as grid - */ - static Bitmap colorize(double[][] grid, int[] colorMap, double max) { - // Maximum color value - int maxColor = colorMap[colorMap.length - 1]; - // Multiplier to "scale" intensity values with, to map to appropriate color - double colorMapScaling = (colorMap.length - 1) / max; - // Dimension of the input grid (and dimension of output bitmap) - int dim = grid.length; - - int i, j, index, col; - double val; - // Array of colors - int colors[] = new int[dim * dim]; - for (i = 0; i < dim; i++) { - for (j = 0; j < dim; j++) { - // [x][y] - // need to enter each row of x coordinates sequentially (x first) - // -> [j][i] - val = grid[j][i]; - index = i * dim + j; - col = (int) (val * colorMapScaling); - - if (val != 0) { - // Make it more resilient: cant go outside colorMap - if (col < colorMap.length) colors[index] = colorMap[col]; - else colors[index] = maxColor; - } else { - colors[index] = Color.TRANSPARENT; - } - } - } - - // Now turn these colors into a bitmap - Bitmap tile = Bitmap.createBitmap(dim, dim, Bitmap.Config.ARGB_8888); - // (int[] pixels, int offset, int stride, int x, int y, int width, int height) - tile.setPixels(colors, 0, dim, 0, 0, dim, dim); - return tile; - } - - /** - * Calculate a reasonable maximum intensity value to map to maximum color intensity - * - * @param points Collection of LatLngs to put into buckets - * @param bounds Bucket boundaries - * @param radius radius of convolution - * @param screenDim larger dimension of screen in pixels (for scale) - * @return Approximate max value - */ - static double getMaxValue(Collection points, Bounds bounds, int radius, - int screenDim) { - // Approximate scale as if entire heatmap is on the screen - // ie scale dimensions to larger of width or height (screenDim) - double minX = bounds.minX; - double maxX = bounds.maxX; - double minY = bounds.minY; - double maxY = bounds.maxY; - double boundsDim = (maxX - minX > maxY - minY) ? maxX - minX : maxY - minY; - - // Number of buckets: have diameter sized buckets - int nBuckets = (int) (screenDim / (2 * radius) + 0.5); - // Scaling factor to convert width in terms of point distance, to which bucket - double scale = nBuckets / boundsDim; - - // Make buckets - // Use a sparse array - use LongSparseArray just in case - LongSparseArray> buckets = new LongSparseArray>(); - //double[][] buckets = new double[nBuckets][nBuckets]; - - // Assign into buckets + find max value as we go along - double x, y; - double max = 0; - for (WeightedLatLng l : points) { - x = l.getPoint().x; - y = l.getPoint().y; - - int xBucket = (int) ((x - minX) * scale); - int yBucket = (int) ((y - minY) * scale); - - // Check if x bucket exists, if not make it - LongSparseArray column = buckets.get(xBucket); - if (column == null) { - column = new LongSparseArray(); - buckets.put(xBucket, column); - } - // Check if there is already a y value there - Double value = column.get(yBucket); - if (value == null) { - value = 0.0; - } - value += l.getIntensity(); - // Yes, do need to update it, despite it being a Double. - column.put(yBucket, value); - - if (value > max) max = value; - } - - return max; - } -} diff --git a/library/src/main/java/com/google/maps/android/heatmaps/HeatmapTileProvider.kt b/library/src/main/java/com/google/maps/android/heatmaps/HeatmapTileProvider.kt new file mode 100644 index 000000000..ba172423e --- /dev/null +++ b/library/src/main/java/com/google/maps/android/heatmaps/HeatmapTileProvider.kt @@ -0,0 +1,426 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.maps.android.heatmaps + +import android.graphics.Bitmap +import android.graphics.Color +import com.google.android.gms.maps.model.LatLng +import com.google.android.gms.maps.model.Tile +import com.google.android.gms.maps.model.TileProvider +import com.google.maps.android.geometry.Bounds +import com.google.maps.android.quadtree.PointQuadTree +import java.io.ByteArrayOutputStream +import androidx.core.graphics.createBitmap +import kotlin.math.exp +import kotlin.math.floor +import kotlin.math.pow + +/** + * Tile provider that creates heatmap tiles. + */ +class HeatmapTileProvider private constructor(builder: Builder) : TileProvider { + + private var data: Collection + private var radius: Int + private var gradient: Gradient + private var opacity: Double + private var customMaxIntensity: Double + + private lateinit var tree: PointQuadTree + private lateinit var bounds: Bounds + private lateinit var colorMap: IntArray + private var kernel: DoubleArray + private lateinit var maxIntensity: DoubleArray + + init { + data = builder.weightedData!! + radius = builder.radius + gradient = builder.gradient + opacity = builder.opacity + customMaxIntensity = builder.intensity + + // Don't compute anything till data is set + kernel = generateKernel(radius, radius / 3.0) + setGradient(gradient) + updateData(data) + } + + /** + * Builder class for the HeatmapTileProvider. + */ + class Builder { + internal var weightedData: Collection? = null + internal var radius = DEFAULT_RADIUS + internal var gradient = DEFAULT_GRADIENT + internal var opacity = DEFAULT_OPACITY + internal var intensity = 0.0 + + /** + * Specifies the dataset to use for the heatmap, accepting unweighted LatLngs. + * + * @param latLngs A collection of LatLngs. + * @return This builder. + */ + fun data(latLngs: Collection): Builder = apply { + this.weightedData(wrapData(latLngs)) + require(this.weightedData?.isNotEmpty() == true) { "No input points." } + } + + /** + * Specifies the dataset to use for the heatmap, accepting WeightedLatLngs. + * + * @param weightedData A collection of WeightedLatLngs. + * @return This builder. + */ + fun weightedData(weightedData: Collection): Builder = apply { + this.weightedData = weightedData + require(this.weightedData?.isNotEmpty() == true) { "No input points." } + } + + /** + * Specifies the radius of the heatmap blur, in pixels. + * + * @param radius The radius. Must be between 10 and 50, inclusive. + * @return This builder. + */ + fun radius(radius: Int): Builder = apply { + this.radius = radius + require(this.radius in MIN_RADIUS..MAX_RADIUS) { "Radius not within bounds." } + } + + /** + * Specifies the color gradient of the heatmap. + * + * @param gradient The gradient to use. + * @return This builder. + */ + fun gradient(gradient: Gradient): Builder = apply { + this.gradient = gradient + } + + /** + * Specifies the opacity of the heatmap layer. + * + * @param opacity The opacity. Must be between 0 and 1, inclusive. + * @return This builder. + */ + fun opacity(opacity: Double): Builder = apply { + this.opacity = opacity + require(this.opacity in 0.0..1.0) { "Opacity must be in range [0, 1]" } + } + + /** + * Specifies a custom maximum intensity value for the heatmap. + * + * @param intensity The maximum intensity. + * @return This builder. + */ + fun maxIntensity(intensity: Double): Builder = apply { + this.intensity = intensity + } + + /** + * Creates a new HeatmapTileProvider instance from the builder's properties. + * + * @return A new HeatmapTileProvider. + */ + fun build(): HeatmapTileProvider { + check(this.weightedData?.isNotEmpty() == true) { "No input data: you must use either .data or .weightedData before building." } + return HeatmapTileProvider(this) + } + } + + @Deprecated("Use updateData(Collection) instead.", ReplaceWith("updateData(data)")) + fun setWeightedData(data: Collection) { + updateData(data) + } + + /** + * Refreshes the heatmap with a new collection of weighted data points. + * + * This is an expensive operation. It involves rebuilding the quadtree index and recalculating + * the bounds and maximum intensity values for the new dataset. This method should be used when + * the underlying data for the heatmap has changed. + * + * @param data The new collection of [WeightedLatLng] points. + */ + fun updateData(data: Collection) { + this.data = data + require(this.data.isNotEmpty()) { "No input points." } + this.bounds = getBounds(this.data) + this.tree = PointQuadTree(this.bounds) + for (l in this.data) { + this.tree.add(l) + } + this.maxIntensity = getMaxIntensities(this.radius) + } + + @Deprecated("Use updateLatLngs(Collection) instead.", ReplaceWith("updateLatLngs(latLngs)")) + fun setData(latLngs: Collection) { + updateLatLngs(latLngs) + } + + /** + * Refreshes the heatmap with a new collection of unweighted data points. + * Each point is assigned a default weight of 1.0. + * + * This is a convenience method that wraps the data in [WeightedLatLng] objects before + * calling [updateData]. + * + * @param latLngs The new collection of [LatLng] points. + */ + fun updateLatLngs(latLngs: Collection) { + updateData(wrapData(latLngs)) + } + + fun setGradient(gradient: Gradient) { + this.gradient = gradient + this.colorMap = gradient.generateColorMap(this.opacity) + } + + fun setRadius(radius: Int) { + this.radius = radius + this.kernel = generateKernel(this.radius, this.radius / 3.0) + this.maxIntensity = getMaxIntensities(this.radius) + } + + fun setOpacity(opacity: Double) { + this.opacity = opacity + setGradient(this.gradient) + } + + fun setMaxIntensity(intensity: Double) { + this.customMaxIntensity = intensity + updateData(this.data) + } + + override fun getTile(x: Int, y: Int, zoom: Int): Tile { + val tileWidth = WORLD_WIDTH / 2.0.pow(zoom.toDouble()) + val padding = tileWidth * radius / TILE_DIM + val tileWidthPadded = tileWidth + 2 * padding + val bucketWidth = tileWidthPadded / (TILE_DIM + radius * 2) + val minX = x * tileWidth - padding + val maxX = (x + 1) * tileWidth + padding + val minY = y * tileWidth - padding + val maxY = (y + 1) * tileWidth + padding + + var xOffset = 0.0 + var wrappedPoints: Collection = emptyList() + + if (minX < 0) { + val overlapBounds = Bounds(minX + WORLD_WIDTH, WORLD_WIDTH, minY, maxY) + xOffset = -WORLD_WIDTH + wrappedPoints = tree.search(overlapBounds) + } else if (maxX > WORLD_WIDTH) { + val overlapBounds = Bounds(0.0, maxX - WORLD_WIDTH, minY, maxY) + xOffset = WORLD_WIDTH + wrappedPoints = tree.search(overlapBounds) + } + + val tileBounds = Bounds(minX, maxX, minY, maxY) + val paddedBounds = Bounds( + bounds.minX - padding, bounds.maxX + padding, + bounds.minY - padding, bounds.maxY + padding + ) + if (!tileBounds.intersects(paddedBounds)) { + return TileProvider.NO_TILE + } + + val points = tree.search(tileBounds) + if (points.isEmpty()) { + return TileProvider.NO_TILE + } + + val intensity = Array(TILE_DIM + radius * 2) { DoubleArray(TILE_DIM + radius * 2) } + points.forEach { w -> + val p = w.point + val bucketX = ((p.x - minX) / bucketWidth).toInt() + val bucketY = ((p.y - minY) / bucketWidth).toInt() + intensity[bucketX][bucketY] += w.intensity + } + wrappedPoints.forEach { w -> + val p = w.point + val bucketX = ((p.x + xOffset - minX) / bucketWidth).toInt() + val bucketY = ((p.y - minY) / bucketWidth).toInt() + intensity[bucketX][bucketY] += w.intensity + } + + val convolved = convolve(intensity, kernel) + val bitmap = colorize(convolved, colorMap, maxIntensity[zoom]) + return convertBitmap(bitmap) + } + + private fun getMaxIntensities(radius: Int): DoubleArray { + val maxIntensityArray = DoubleArray(MAX_ZOOM_LEVEL) + if (customMaxIntensity != 0.0) { + for (i in 0 until MAX_ZOOM_LEVEL) { + maxIntensityArray[i] = customMaxIntensity + } + return maxIntensityArray + } + for (i in DEFAULT_MIN_ZOOM until DEFAULT_MAX_ZOOM) { + maxIntensityArray[i] = getMaxValue(data, bounds, radius, (SCREEN_SIZE * Math.pow(2.0, (i - 3).toDouble())).toInt()) + if (i == DEFAULT_MIN_ZOOM) { + for (j in 0 until i) maxIntensityArray[j] = maxIntensityArray[i] + } + } + for (i in DEFAULT_MAX_ZOOM until MAX_ZOOM_LEVEL) { + maxIntensityArray[i] = maxIntensityArray[DEFAULT_MAX_ZOOM - 1] + } + return maxIntensityArray + } + + companion object { + const val DEFAULT_RADIUS = 20 + const val DEFAULT_OPACITY = 0.7 + private val DEFAULT_GRADIENT_COLORS = intArrayOf(Color.rgb(102, 225, 0), Color.rgb(255, 0, 0)) + private val DEFAULT_GRADIENT_START_POINTS = floatArrayOf(0.2f, 1f) + @JvmField + val DEFAULT_GRADIENT = Gradient(DEFAULT_GRADIENT_COLORS, DEFAULT_GRADIENT_START_POINTS) + internal const val WORLD_WIDTH = 1.0 + private const val TILE_DIM = 512 + private const val SCREEN_SIZE = 1280 + private const val DEFAULT_MIN_ZOOM = 5 + private const val DEFAULT_MAX_ZOOM = 11 + private const val MAX_ZOOM_LEVEL = 22 + const val MIN_RADIUS = 10 + const val MAX_RADIUS = 50 + + private data class Vector(val x: Int, val y: Int) + + private fun wrapData(data: Collection): Collection = data.map { WeightedLatLng(it) } + + private fun convertBitmap(bitmap: Bitmap): Tile { + val stream = ByteArrayOutputStream() + bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream) + val bitmapData = stream.toByteArray() + return Tile(TILE_DIM, TILE_DIM, bitmapData) + } + + @JvmStatic + fun getBounds(points: Collection): Bounds { + val firstPoint = points.first().point + var minX = firstPoint.x + var maxX = firstPoint.x + var minY = firstPoint.y + var maxY = firstPoint.y + + points.drop(1).forEach { + val x = it.point.x + val y = it.point.y + if (x < minX) minX = x + if (x > maxX) maxX = x + if (y < minY) minY = y + if (y > maxY) maxY = y + } + return Bounds(minX, maxX, minY, maxY) + } + + @JvmStatic + fun generateKernel(radius: Int, sd: Double): DoubleArray { + val kernel = DoubleArray(radius * 2 + 1) + for (i in -radius..radius) { + kernel[i + radius] = exp(-i * i / (2 * sd * sd)) + } + return kernel + } + + @JvmStatic + fun convolve(grid: Array, kernel: DoubleArray): Array { + val radius = floor(kernel.size / 2.0).toInt() + val dimOld = grid.size + val dim = dimOld - 2 * radius + val lowerLimit = radius + val upperLimit = radius + dim - 1 + val intermediate = Array(dimOld) { DoubleArray(dimOld) } + + for (x in 0 until dimOld) { + for (y in 0 until dimOld) { + val value = grid[x][y] + if (value != 0.0) { + val xUpperLimit = (x + radius).coerceAtMost(upperLimit) + for (x2 in (x - radius).coerceAtLeast(lowerLimit)..xUpperLimit) { + intermediate[x2][y] += value * kernel[x2 - (x - radius)] + } + } + } + } + + val outputGrid = Array(dim) { DoubleArray(dim) } + for (x in lowerLimit..upperLimit) { + for (y in 0 until dimOld) { + val value = intermediate[x][y] + if (value != 0.0) { + val yUpperLimit = (y + radius).coerceAtMost(upperLimit) + for (y2 in (y - radius).coerceAtLeast(lowerLimit)..yUpperLimit) { + outputGrid[x - radius][y2 - radius] += value * kernel[y2 - (y - radius)] + } + } + } + } + return outputGrid + } + + internal fun colorize(grid: Array, colorMap: IntArray, max: Double): Bitmap { + val maxColor = colorMap.last() + val colorMapScaling = (colorMap.size - 1) / max + val dim = grid.size + val colors = IntArray(dim * dim) + for (i in 0 until dim) { + for (j in 0 until dim) { + val value = grid[j][i] + val index = i * dim + j + val col = (value * colorMapScaling).toInt() + colors[index] = if (value != 0.0) { + if (col < colorMap.size) colorMap[col] else maxColor + } else { + Color.TRANSPARENT + } + } + } + val tile = createBitmap(dim, dim) + tile.setPixels(colors, 0, dim, 0, 0, dim, dim) + return tile + } + + internal fun getMaxValue( + points: Collection, + bounds: Bounds, + radius: Int, + screenDim: Int + ): Double { + val minX = bounds.minX + val maxX = bounds.maxX + val minY = bounds.minY + val maxY = bounds.maxY + val boundsDim = (maxX - minX).coerceAtLeast(maxY - minY) + val nBuckets = (screenDim / (2 * radius) + 0.5).toInt() + val scale = nBuckets / boundsDim + val buckets = mutableMapOf() + + points.forEach { l -> + val x = l.point.x + val y = l.point.y + val xBucket = ((x - minX) * scale).toInt() + val yBucket = ((y - minY) * scale).toInt() + val bucket = Vector(xBucket, yBucket) + val currentValue = buckets.getOrPut(bucket) { 0.0 } + buckets[bucket] = currentValue + l.intensity + } + return buckets.values.maxOrNull() ?: 0.0 + } + } +} diff --git a/library/src/main/java/com/google/maps/android/heatmaps/WeightedLatLng.java b/library/src/main/java/com/google/maps/android/heatmaps/WeightedLatLng.java deleted file mode 100644 index 18c29550a..000000000 --- a/library/src/main/java/com/google/maps/android/heatmaps/WeightedLatLng.java +++ /dev/null @@ -1,81 +0,0 @@ -/* - * Copyright 2014 Google Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.maps.android.heatmaps; - -import com.google.android.gms.maps.model.LatLng; -import com.google.maps.android.geometry.Point; -import com.google.maps.android.projection.SphericalMercatorProjection; -import com.google.maps.android.quadtree.PointQuadTree; - -/** - * A wrapper class that can be used in a PointQuadTree - * Created from a LatLng and optional intensity: point coordinates of the LatLng and the intensity - * value can be accessed from it later. - */ -public class WeightedLatLng implements PointQuadTree.Item { - - /** - * Default intensity to use when intensity not specified - */ - public static final double DEFAULT_INTENSITY = 1; - - /** - * Projection to use for points - * Converts LatLng to (x, y) coordinates using a SphericalMercatorProjection - */ - private static final SphericalMercatorProjection sProjection = - new SphericalMercatorProjection(HeatmapTileProvider.WORLD_WIDTH); - - private Point mPoint; - - private double mIntensity; - - /** - * Constructor - * - * @param latLng LatLng to add to wrapper - * @param intensity Intensity to use: should be greater than 0 - * Default value is 1. - * This represents the "importance" or "value" of this particular point - * Higher intensity values map to higher colours. - * Intensity is additive: having two points of intensity 1 at the same - * location is identical to having one of intensity 2. - */ - public WeightedLatLng(LatLng latLng, double intensity) { - mPoint = sProjection.toPoint(latLng); - if (intensity >= 0) mIntensity = intensity; - else mIntensity = DEFAULT_INTENSITY; - } - - /** - * Constructor that uses default value for intensity - * - * @param latLng LatLng to add to wrapper - */ - public WeightedLatLng(LatLng latLng) { - this(latLng, DEFAULT_INTENSITY); - } - - public Point getPoint() { - return mPoint; - } - - public double getIntensity() { - return mIntensity; - } - -} diff --git a/library/src/main/java/com/google/maps/android/heatmaps/WeightedLatLng.kt b/library/src/main/java/com/google/maps/android/heatmaps/WeightedLatLng.kt new file mode 100644 index 000000000..c7b55712b --- /dev/null +++ b/library/src/main/java/com/google/maps/android/heatmaps/WeightedLatLng.kt @@ -0,0 +1,46 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.maps.android.heatmaps + +import com.google.android.gms.maps.model.LatLng +import com.google.maps.android.geometry.Point +import com.google.maps.android.projection.SphericalMercatorProjection +import com.google.maps.android.quadtree.PointQuadTree + +data class WeightedLatLng( + val latLng: LatLng, + override val point: Point, + val intensity: Double +) : PointQuadTree.Item { + + /** + * Constructor that uses default value for intensity + * + * @param latLng LatLng to add to wrapper + */ + @JvmOverloads + constructor(latLng: LatLng, intensity: Double = DEFAULT_INTENSITY) : this( + latLng, + sProjection.toPoint(latLng), + if (intensity >= 0) intensity else DEFAULT_INTENSITY + ) + + companion object { + const val DEFAULT_INTENSITY = 1.0 + private val sProjection = SphericalMercatorProjection(HeatmapTileProvider.WORLD_WIDTH) + } +} diff --git a/library/src/main/java/com/google/maps/android/projection/Point.java b/library/src/main/java/com/google/maps/android/projection/Point.kt similarity index 63% rename from library/src/main/java/com/google/maps/android/projection/Point.java rename to library/src/main/java/com/google/maps/android/projection/Point.kt index 75ca7fe20..9cecbb098 100644 --- a/library/src/main/java/com/google/maps/android/projection/Point.java +++ b/library/src/main/java/com/google/maps/android/projection/Point.kt @@ -14,14 +14,10 @@ * limitations under the License. */ -package com.google.maps.android.projection; +package com.google.maps.android.projection /** - * @deprecated since 0.2. Use {@link com.google.maps.android.geometry.Point} instead. + * @deprecated since 0.2. Use [com.google.maps.android.geometry.Point] instead. */ -@Deprecated -public class Point extends com.google.maps.android.geometry.Point { - public Point(double x, double y) { - super(x, y); - } -} +@Deprecated("since 0.2. Use com.google.maps.android.geometry.Point instead.", ReplaceWith("com.google.maps.android.geometry.Point(x, y)")) +public class Point(x: Double, y: Double) : com.google.maps.android.geometry.Point(x, y) \ No newline at end of file diff --git a/library/src/test/java/com/google/maps/android/heatmaps/GradientTest.java b/library/src/test/java/com/google/maps/android/heatmaps/GradientTest.java deleted file mode 100644 index 118910678..000000000 --- a/library/src/test/java/com/google/maps/android/heatmaps/GradientTest.java +++ /dev/null @@ -1,125 +0,0 @@ -/* - * Copyright 2014 Google Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.maps.android.heatmaps; - -import org.junit.Test; -import org.junit.runner.RunWith; -import org.robolectric.RobolectricTestRunner; - -import android.graphics.Color; -import android.os.Build; - -import static android.graphics.Color.BLUE; -import static android.graphics.Color.GREEN; -import static android.graphics.Color.RED; -import static org.junit.Assert.assertEquals; - -@RunWith(RobolectricTestRunner.class) -public class GradientTest { - @Test - public void testInterpolateColor() { - // Expect itself - assertEquals(RED, Gradient.interpolateColor(RED, RED, 0.5f)); - assertEquals(BLUE, Gradient.interpolateColor(BLUE, BLUE, 0.5f)); - assertEquals(GREEN, Gradient.interpolateColor(GREEN, GREEN, 0.5f)); - - // Expect first to be returned - int result = Gradient.interpolateColor(RED, BLUE, 0); - assertEquals(RED, result); - - // Expect second to be returned - result = Gradient.interpolateColor(RED, BLUE, 1); - assertEquals(BLUE, result); - - // Expect same value (should wraparound correctly, shortest path both times) - assertEquals( - Gradient.interpolateColor(BLUE, RED, 0.5f), - Gradient.interpolateColor(RED, BLUE, 0.5f)); - assertEquals( - Gradient.interpolateColor(BLUE, RED, 0.8f), - Gradient.interpolateColor(RED, BLUE, 0.2f)); - assertEquals( - Gradient.interpolateColor(BLUE, RED, 0.2f), - Gradient.interpolateColor(RED, BLUE, 0.8f)); - - // Due to issue with Color.RGBToHSV() below only works on Android O and greater (#573) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - assertEquals(-65434, Gradient.interpolateColor(RED, BLUE, 0.2f)); - assertEquals(Color.MAGENTA, Gradient.interpolateColor(RED, BLUE, 0.5f)); - assertEquals(-10092289, Gradient.interpolateColor(RED, BLUE, 0.8f)); - assertEquals(Color.YELLOW, Gradient.interpolateColor(RED, GREEN, 0.5f)); - assertEquals(Color.CYAN, Gradient.interpolateColor(BLUE, GREEN, 0.5f)); - } - } - - @Test - public void testSimpleColorMap() { - int[] colors = {RED, BLUE}; - float[] startPoints = {0f, 1.0f}; - - Gradient g = new Gradient(colors, startPoints, 2); - int[] colorMap = g.generateColorMap(1.0); - assertEquals(RED, colorMap[0]); - assertEquals(Gradient.interpolateColor(RED, BLUE, 0.5f), colorMap[1]); - } - - @Test - public void testLargerColorMap() { - int[] colors = {RED, GREEN}; - float[] startPoints = {0f, 1.0f}; - - Gradient g = new Gradient(colors, startPoints, 10); - int[] colorMap = g.generateColorMap(1.0); - assertEquals(RED, colorMap[0]); - for (int i = 1; i < 10; i++) { - assertEquals(Gradient.interpolateColor(RED, GREEN, (i * 0.1f)), colorMap[i]); - } - } - - @Test - public void testOpacityInterpolation() { - int[] colors = {Color.argb(0, 0, 255, 0), GREEN, RED}; - float[] startPoints = {0f, 0.2f, 1f}; - Gradient g = new Gradient(colors, startPoints, 10); - int[] colorMap = g.generateColorMap(1.0); - assertEquals(Color.argb(0, 0, 255, 0), colorMap[0]); - assertEquals(Color.argb(127, 0, 255, 0), colorMap[1]); - assertEquals(GREEN, colorMap[2]); - assertEquals(Gradient.interpolateColor(GREEN, RED, 0.125f), colorMap[3]); - assertEquals(Gradient.interpolateColor(GREEN, RED, 0.25f), colorMap[4]); - assertEquals(Gradient.interpolateColor(GREEN, RED, 0.375f), colorMap[5]); - assertEquals(Gradient.interpolateColor(GREEN, RED, 0.5f), colorMap[6]); - assertEquals(Gradient.interpolateColor(GREEN, RED, 0.625f), colorMap[7]); - assertEquals(Gradient.interpolateColor(GREEN, RED, 0.75f), colorMap[8]); - assertEquals(Gradient.interpolateColor(GREEN, RED, 0.875f), colorMap[9]); - - colorMap = g.generateColorMap(0.5); - assertEquals(Color.argb(0, 0, 255, 0), colorMap[0]); - assertEquals(Color.argb(63, 0, 255, 0), colorMap[1]); - assertEquals(Color.argb(127, 0, 255, 0), colorMap[2]); - } - - @Test - public void testMoreColorsThanColorMap() { - int[] colors = {Color.argb(0, 0, 255, 0), GREEN, RED, BLUE}; - float[] startPoints = {0f, 0.2f, 0.5f, 1f}; - Gradient g = new Gradient(colors, startPoints, 2); - int[] colorMap = g.generateColorMap(1.0); - assertEquals(GREEN, colorMap[0]); - assertEquals(RED, colorMap[1]); - } -} diff --git a/library/src/test/java/com/google/maps/android/heatmaps/GradientTest.kt b/library/src/test/java/com/google/maps/android/heatmaps/GradientTest.kt new file mode 100644 index 000000000..143d0a2d7 --- /dev/null +++ b/library/src/test/java/com/google/maps/android/heatmaps/GradientTest.kt @@ -0,0 +1,114 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.maps.android.heatmaps + +import android.graphics.Color +import android.os.Build +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class GradientTest { + @Test + fun testInterpolateColor() { + // Expect itself + assertThat(Gradient.interpolateColor(Color.RED, Color.RED, 0.5f)).isEqualTo(Color.RED) + assertThat(Gradient.interpolateColor(Color.BLUE, Color.BLUE, 0.5f)).isEqualTo(Color.BLUE) + assertThat(Gradient.interpolateColor(Color.GREEN, Color.GREEN, 0.5f)).isEqualTo(Color.GREEN) + + // Expect first to be returned + val result = Gradient.interpolateColor(Color.RED, Color.BLUE, 0f) + assertThat(result).isEqualTo(Color.RED) + + // Expect second to be returned + val result2 = Gradient.interpolateColor(Color.RED, Color.BLUE, 1f) + assertThat(result2).isEqualTo(Color.BLUE) + + // Expect same value (should wraparound correctly, shortest path both times) + assertThat(Gradient.interpolateColor(Color.BLUE, Color.RED, 0.5f)) + .isEqualTo(Gradient.interpolateColor(Color.RED, Color.BLUE, 0.5f)) + assertThat(Gradient.interpolateColor(Color.BLUE, Color.RED, 0.8f)) + .isEqualTo(Gradient.interpolateColor(Color.RED, Color.BLUE, 0.2f)) + assertThat(Gradient.interpolateColor(Color.BLUE, Color.RED, 0.2f)) + .isEqualTo(Gradient.interpolateColor(Color.RED, Color.BLUE, 0.8f)) + + // Due to issue with Color.RGBToHSV() below only works on Android O and greater (#573) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + assertThat(Gradient.interpolateColor(Color.RED, Color.BLUE, 0.2f)).isEqualTo(-65434) + assertThat(Gradient.interpolateColor(Color.RED, Color.BLUE, 0.5f)).isEqualTo(Color.MAGENTA) + assertThat(Gradient.interpolateColor(Color.RED, Color.BLUE, 0.8f)).isEqualTo(-10092289) + assertThat(Gradient.interpolateColor(Color.RED, Color.GREEN, 0.5f)).isEqualTo(Color.YELLOW) + assertThat(Gradient.interpolateColor(Color.BLUE, Color.GREEN, 0.5f)).isEqualTo(Color.CYAN) + } + } + + @Test + fun testSimpleColorMap() { + val colors = intArrayOf(Color.RED, Color.BLUE) + val startPoints = floatArrayOf(0f, 1.0f) + val g = Gradient(colors, startPoints, 2) + val colorMap = g.generateColorMap(1.0) + assertThat(colorMap[0]).isEqualTo(Color.RED) + assertThat(colorMap[1]).isEqualTo(Gradient.interpolateColor(Color.RED, Color.BLUE, 0.5f)) + } + + @Test + fun testLargerColorMap() { + val colors = intArrayOf(Color.RED, Color.GREEN) + val startPoints = floatArrayOf(0f, 1.0f) + val g = Gradient(colors, startPoints, 10) + val colorMap = g.generateColorMap(1.0) + assertThat(colorMap[0]).isEqualTo(Color.RED) + for (i in 1..9) { + assertThat(colorMap[i]).isEqualTo(Gradient.interpolateColor(Color.RED, Color.GREEN, i * 0.1f)) + } + } + + @Test + fun testOpacityInterpolation() { + val colors = intArrayOf(Color.argb(0, 0, 255, 0), Color.GREEN, Color.RED) + val startPoints = floatArrayOf(0f, 0.2f, 1f) + val g = Gradient(colors, startPoints, 10) + var colorMap = g.generateColorMap(1.0) + assertThat(colorMap[0]).isEqualTo(Color.argb(0, 0, 255, 0)) + assertThat(colorMap[1]).isEqualTo(Color.argb(127, 0, 255, 0)) + assertThat(colorMap[2]).isEqualTo(Color.GREEN) + assertThat(colorMap[3]).isEqualTo(Gradient.interpolateColor(Color.GREEN, Color.RED, 0.125f)) + assertThat(colorMap[4]).isEqualTo(Gradient.interpolateColor(Color.GREEN, Color.RED, 0.25f)) + assertThat(colorMap[5]).isEqualTo(Gradient.interpolateColor(Color.GREEN, Color.RED, 0.375f)) + assertThat(colorMap[6]).isEqualTo(Gradient.interpolateColor(Color.GREEN, Color.RED, 0.5f)) + assertThat(colorMap[7]).isEqualTo(Gradient.interpolateColor(Color.GREEN, Color.RED, 0.625f)) + assertThat(colorMap[8]).isEqualTo(Gradient.interpolateColor(Color.GREEN, Color.RED, 0.75f)) + assertThat(colorMap[9]).isEqualTo(Gradient.interpolateColor(Color.GREEN, Color.RED, 0.875f)) + colorMap = g.generateColorMap(0.5) + assertThat(colorMap[0]).isEqualTo(Color.argb(0, 0, 255, 0)) + assertThat(colorMap[1]).isEqualTo(Color.argb(63, 0, 255, 0)) + assertThat(colorMap[2]).isEqualTo(Color.argb(127, 0, 255, 0)) + } + + @Test + fun testMoreColorsThanColorMap() { + val colors = intArrayOf(Color.argb(0, 0, 255, 0), Color.GREEN, Color.RED, Color.BLUE) + val startPoints = floatArrayOf(0f, 0.2f, 0.5f, 1f) + val g = Gradient(colors, startPoints, 2) + val colorMap = g.generateColorMap(1.0) + assertThat(colorMap[0]).isEqualTo(Color.GREEN) + assertThat(colorMap[1]).isEqualTo(Color.RED) + } +} \ No newline at end of file diff --git a/library/src/test/java/com/google/maps/android/heatmaps/HeatmapTileProviderTest.kt b/library/src/test/java/com/google/maps/android/heatmaps/HeatmapTileProviderTest.kt new file mode 100644 index 000000000..ccde0afbc --- /dev/null +++ b/library/src/test/java/com/google/maps/android/heatmaps/HeatmapTileProviderTest.kt @@ -0,0 +1,132 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.maps.android.heatmaps + +import com.google.android.gms.maps.model.LatLng +import com.google.android.gms.maps.model.TileProvider +import com.google.common.truth.Truth.assertThat +import org.junit.Assert.fail +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class HeatmapTileProviderTest { + + @Test + fun testBuilder_weightedData() { + val data = listOf(WeightedLatLng(LatLng(0.0, 0.0))) + val provider = HeatmapTileProvider.Builder().weightedData(data).build() + assertThat(provider).isNotNull() + } + + @Test + fun testBuilder_data() { + val data = listOf(LatLng(0.0, 0.0)) + val provider = HeatmapTileProvider.Builder().data(data).build() + assertThat(provider).isNotNull() + } + + @Test + fun testBuilder_noData() { + try { + HeatmapTileProvider.Builder().build() + fail("Should have thrown IllegalStateException") + } catch (e: IllegalStateException) { + // success + } + } + + @Test + fun testBuilder_emptyData() { + try { + HeatmapTileProvider.Builder().data(emptyList()) + fail("Should have thrown IllegalArgumentException") + } catch (e: IllegalArgumentException) { + // success + } + } + + @Test + fun testBuilder_radius() { + val data = listOf(LatLng(0.0, 0.0)) + val provider = HeatmapTileProvider.Builder().data(data).radius(20).build() + assertThat(provider).isNotNull() + } + + @Test + fun testBuilder_invalidRadius() { + val data = listOf(LatLng(0.0, 0.0)) + try { + HeatmapTileProvider.Builder().data(data).radius(0) + fail("Should have thrown IllegalArgumentException") + } catch (e: IllegalArgumentException) { + // success + } + try { + HeatmapTileProvider.Builder().data(data).radius(100) + fail("Should have thrown IllegalArgumentException") + } catch (e: IllegalArgumentException) { + // success + } + } + + @Test + fun testBuilder_opacity() { + val data = listOf(LatLng(0.0, 0.0)) + val provider = HeatmapTileProvider.Builder().data(data).opacity(0.5).build() + assertThat(provider).isNotNull() + } + + @Test + fun testBuilder_invalidOpacity() { + val data = listOf(LatLng(0.0, 0.0)) + try { + HeatmapTileProvider.Builder().data(data).opacity(-1.0) + fail("Should have thrown IllegalArgumentException") + } catch (e: IllegalArgumentException) { + // success + } + try { + HeatmapTileProvider.Builder().data(data).opacity(2.0) + fail("Should have thrown IllegalArgumentException") + } catch (e: IllegalArgumentException) { + // success + } + } + + @Test + fun testGetTile() { + val data = listOf(LatLng(0.0, 0.0)) + val provider = HeatmapTileProvider.Builder().data(data).build() + val tile = provider.getTile(512, 512, 10) + assertThat(tile).isNotNull() + assertThat(tile).isNotEqualTo(TileProvider.NO_TILE) + assertThat(tile.width).isEqualTo(512) + assertThat(tile.height).isEqualTo(512) + } + + @Test + fun testGetTile_noPointsInTile() { + // Point is at (0,0), so tile at (1,1) should be empty + val data = listOf(LatLng(0.0, 0.0)) + val provider = HeatmapTileProvider.Builder().data(data).build() + // A zoom level high enough that (0,0) and (1,1) are far apart + val tile = provider.getTile(1, 1, 20) + assertThat(tile).isEqualTo(TileProvider.NO_TILE) + } +} diff --git a/library/src/test/java/com/google/maps/android/heatmaps/WeightedLatLngTest.kt b/library/src/test/java/com/google/maps/android/heatmaps/WeightedLatLngTest.kt new file mode 100644 index 000000000..254a525e1 --- /dev/null +++ b/library/src/test/java/com/google/maps/android/heatmaps/WeightedLatLngTest.kt @@ -0,0 +1,72 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.maps.android.heatmaps + +import com.google.android.gms.maps.model.LatLng +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class WeightedLatLngTest { + + @Test + fun testConstructorWithIntensity() { + val latLng = LatLng(10.0, 20.0) + val weighted = WeightedLatLng(latLng, 5.0) + assertThat(weighted.latLng).isEqualTo(latLng) + assertThat(weighted.intensity).isEqualTo(5.0) + assertThat(weighted.point).isNotNull() + } + + @Test + fun testConstructorWithDefaultIntensity() { + val latLng = LatLng(10.0, 20.0) + val weighted = WeightedLatLng(latLng) + assertThat(weighted.latLng).isEqualTo(latLng) + assertThat(weighted.intensity).isEqualTo(WeightedLatLng.DEFAULT_INTENSITY) + } + + @Test + fun testConstructorWithNegativeIntensity() { + val latLng = LatLng(10.0, 20.0) + val weighted = WeightedLatLng(latLng, -1.0) + assertThat(weighted.intensity).isEqualTo(WeightedLatLng.DEFAULT_INTENSITY) + } + + @Test + fun testDataClassMethods() { + val latLng1 = LatLng(1.0, 2.0) + val weighted1 = WeightedLatLng(latLng1, 3.0) + val weighted2 = WeightedLatLng(latLng1, 3.0) + val weighted3 = WeightedLatLng(latLng1, 4.0) + + // Test equals() + assertThat(weighted1).isEqualTo(weighted2) + assertThat(weighted1).isNotEqualTo(weighted3) + + // Test hashCode() + assertThat(weighted1.hashCode()).isEqualTo(weighted2.hashCode()) + assertThat(weighted1.hashCode()).isNotEqualTo(weighted3.hashCode()) + + // Test copy() + val weightedCopy = weighted1.copy(intensity = 5.0) + assertThat(weightedCopy.latLng).isEqualTo(latLng1) + assertThat(weightedCopy.intensity).isEqualTo(5.0) + } +} diff --git a/library/src/test/java/com/google/maps/android/projection/PointTest.kt b/library/src/test/java/com/google/maps/android/projection/PointTest.kt new file mode 100644 index 000000000..85ae49c0e --- /dev/null +++ b/library/src/test/java/com/google/maps/android/projection/PointTest.kt @@ -0,0 +1,40 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.maps.android.projection + +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class PointTest { + + @Test + fun testPointConstruction() { + val point = Point(1.0, 2.0) + assertThat(point).isNotNull() + assertThat(point.x).isEqualTo(1.0) + assertThat(point.y).isEqualTo(2.0) + } + + @Test + fun testPointIsInstanceOfGeometryPoint() { + val point = Point(1.0, 2.0) + assertThat(point).isInstanceOf(com.google.maps.android.geometry.Point::class.java) + } +} diff --git a/local.defaults.properties b/local.defaults.properties index 818d21b20..251530fa0 100644 --- a/local.defaults.properties +++ b/local.defaults.properties @@ -1 +1,2 @@ -MAPS_API_KEY="YOUR_API_KEY" \ No newline at end of file +MAPS_API_KEY=YOUR_API_KEY +PLACES_API_KEY=YOUR_API_KEY