/*
* 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 android.support.v4.util.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<WeightedLatLng> mTree;
/**
* Collection of all the data.
*/
private Collection<WeightedLatLng> 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;
/**
* Builder class for the HeatmapTileProvider.
*/
public static class Builder {
// Required parameters - not final, as there are 2 ways to set it
private Collection<WeightedLatLng> data;
// Optional, initialised to default values
private int radius = DEFAULT_RADIUS;
private Gradient gradient = DEFAULT_GRADIENT;
private double opacity = DEFAULT_OPACITY;
/**
* 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<LatLng> 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<WeightedLatLng> 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;
}
/**
* 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;
// 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<WeightedLatLng> 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<WeightedLatLng>(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<LatLng> 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<WeightedLatLng> wrapData(Collection<LatLng> data) {
// Use an ArrayList as it is a nice collection
ArrayList<WeightedLatLng> weightedData = new ArrayList<WeightedLatLng>();
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<WeightedLatLng> wrappedPoints = new ArrayList<WeightedLatLng>();
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<WeightedLatLng> 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);
}
/**
* 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];
// 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<WeightedLatLng> points) {
// Use an iterator, need to access any one point of the collection for starting bounds
Iterator<WeightedLatLng> 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<WeightedLatLng> 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<LongSparseArray<Double>> buckets = new LongSparseArray<LongSparseArray<Double>>();
//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<Double> column = buckets.get(xBucket);
if (column == null) {
column = new LongSparseArray<Double>();
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;
}
}