Java tutorial
/* * 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.amap.api.maps2d.heatmaps; import java.io.ByteArrayOutputStream; import java.util.ArrayList; import java.util.Collection; import java.util.Iterator; import android.graphics.Bitmap; import android.graphics.Color; import android.support.v4.util.LongSparseArray; import com.amap.api.maps2d.geometry.Bounds; import com.amap.api.maps2d.geometry.Point; import com.amap.api.maps2d.model.LatLng; import com.amap.api.maps2d.model.Tile; import com.amap.api.maps2d.model.TileProvider; import com.amap.api.maps2d.quadtree.PointQuadTree; /** * 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; private HeatmapTileProvider(final 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); } /** * Creates tile. * * @param x X coordinate of tile. * @param y Y coordinate of tile. * @param zoom Zoom level. * @return image in Tile format */ @Override public Tile getTile(final int x, final int y, final 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 final 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 final double padding = tileWidth * mRadius / TILE_DIM; // padded tile width // In terms of world width units final double tileWidthPadded = tileWidth + 2 * padding; // padded bucket width - divided by number of buckets // In terms of world width units final double bucketWidth = tileWidthPadded / (TILE_DIM + mRadius * 2); // Make bounds: minX, maxX, minY, maxY final double minX = x * tileWidth - padding; final double maxX = (x + 1) * tileWidth + padding; final double minY = y * tileWidth - padding; final 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 final 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 final Bounds overlapBounds = new Bounds(0, maxX - WORLD_WIDTH, minY, maxY); xOffset = WORLD_WIDTH; wrappedPoints = mTree.search(overlapBounds); } // Main tile bounds to search final 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 final 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 final Collection<WeightedLatLng> points = mTree.search(tileBounds); // If no points, return blank tile if (points.isEmpty()) return TileProvider.NO_TILE; // Quantize points final double[][] intensity = new double[TILE_DIM + mRadius * 2][TILE_DIM + mRadius * 2]; for (final WeightedLatLng w : points) { final Point p = w.getPoint(); final int bucketX = (int) ((p.x - minX) / bucketWidth); final int bucketY = (int) ((p.y - minY) / bucketWidth); intensity[bucketX][bucketY] += w.getIntensity(); } // Quantize wraparound points (taking xOffset into account) for (final WeightedLatLng w : wrappedPoints) { final Point p = w.getPoint(); final int bucketX = (int) ((p.x + xOffset - minX) / bucketWidth); final int bucketY = (int) ((p.y - minY) / bucketWidth); intensity[bucketX][bucketY] += w.getIntensity(); } // Convolve it ("smoothen" it out) final double[][] convolved = convolve(intensity, mKernel); // Color it into a bitmap final Bitmap bitmap = colorize(convolved, mColorMap, mMaxIntensity[zoom]); // Convert bitmap to tile and return return convertBitmap(bitmap); } @Override public int getTileHeight() { // TODO implement this throw new UnsupportedOperationException(); } @Override public int getTileWidth() { // TODO implement this throw new UnsupportedOperationException(); } /** * 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(final Collection<LatLng> data) { // Turn them into WeightedLatLngs and delegate. setWeightedData(wrapData(data)); } /** * 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(final Gradient gradient) { mGradient = gradient; mColorMap = gradient.generateColorMap(mOpacity); } /** * Setter for opacity User should clear overlay's tile cache (using * clearTileCache()) after calling this. * * @param opacity opacity to set */ public void setOpacity(final double opacity) { mOpacity = opacity; // need to recompute kernel color map setGradient(mGradient); } /** * Setter for radius. User should clear overlay's tile cache (using * clearTileCache()) after calling this. * * @param radius Radius to set */ public void setRadius(final int radius) { mRadius = radius; // need to recompute kernel mKernel = generateKernel(mRadius, mRadius / 3.0); // need to recalculate max intensity mMaxIntensity = getMaxIntensities(mRadius); } /** * 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(final 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 (final WeightedLatLng l : mData) { mTree.add(l); } // Calculate reasonable maximum intensity for color scale (user can also // specify) // Get max intensities mMaxIntensity = getMaxIntensities(mRadius); } /** * 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(final int radius) { // Can go from zoom level 3 to zoom level 22 final 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(final Bitmap bitmap) { // Convert it into byte array (required for tile creation) final ByteArrayOutputStream stream = new ByteArrayOutputStream(); bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream); final byte[] bitmapdata = stream.toByteArray(); return new Tile(TILE_DIM, TILE_DIM, bitmapdata); } /* Utility functions below */ /** * Helper function - wraps LatLngs into WeightedLatLngs. * * @param data Data to wrap (LatLng) * @return Data, in WeightedLatLng form */ private static Collection<WeightedLatLng> wrapData(final Collection<LatLng> data) { // Use an ArrayList as it is a nice collection final ArrayList<WeightedLatLng> weightedData = new ArrayList<WeightedLatLng>(); for (final LatLng l : data) { weightedData.add(new WeightedLatLng(l)); } return weightedData; } /** * 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(final double[][] grid, final int[] colorMap, final double max) { // Maximum color value final int maxColor = colorMap[colorMap.length - 1]; // Multiplier to "scale" intensity values with, to map to appropriate // color final double colorMapScaling = (colorMap.length - 1) / max; // Dimension of the input grid (and dimension of output bitmap) final int dim = grid.length; int i, j, index, col; double val; // Array of colors final 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 final 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; } /** * 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(final double[][] grid, final double[] kernel) { // Calculate radius size final int radius = (int) Math.floor(kernel.length / 2.0); // Padded dimension final int dimOld = grid.length; // Calculate final (non padded) dimension final int dim = dimOld - 2 * radius; // Upper and lower limits of non padded (inclusive) final int lowerLimit = radius; final int upperLimit = radius + dim - 1; // Convolve horizontally final 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 final 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; } /** * 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(final int radius, final double sd) { final 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; } /** * 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(final Collection<WeightedLatLng> points) { // Use an iterator, need to access any one point of the collection for // starting bounds final Iterator<WeightedLatLng> iter = points.iterator(); final 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()) { final WeightedLatLng l = iter.next(); final double x = l.getPoint().x; final 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); } /** * 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(final Collection<WeightedLatLng> points, final Bounds bounds, final int radius, final int screenDim) { // Approximate scale as if entire heatmap is on the screen // ie scale dimensions to larger of width or height (screenDim) final double minX = bounds.minX; final double maxX = bounds.maxX; final double minY = bounds.minY; final double maxY = bounds.maxY; final double boundsDim = maxX - minX > maxY - minY ? maxX - minX : maxY - minY; // Number of buckets: have diameter sized buckets final int nBuckets = (int) (screenDim / (2 * radius) + 0.5); // Scaling factor to convert width in terms of point distance, to which // bucket final double scale = nBuckets / boundsDim; // Make buckets // Use a sparse array - use LongSparseArray just in case final 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 (final WeightedLatLng l : points) { x = l.getPoint().x; y = l.getPoint().y; final int xBucket = (int) ((x - minX) * scale); final 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; } /** * 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() { } /** * 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); } /** * 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(final Collection<LatLng> val) { return weightedData(wrapData(val)); } /** * Setter for gradient in builder * * @param val Gradient to color heatmap with. * @return updated builder object */ public Builder gradient(final 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(final 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 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(final 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 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(final Collection<WeightedLatLng> val) { data = val; // Check that points is non empty if (data.isEmpty()) throw new IllegalArgumentException("No input points."); return this; } } }