/*
 * HeatMap.java
 *
 * Copyright 2017 Heartland Software Solutions 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 ca.hss.heatmaplib;

import android.annotation.SuppressLint;
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.LinearGradient;
import android.graphics.Paint;
import android.graphics.PorterDuff;
import android.graphics.RadialGradient;
import android.graphics.Rect;
import android.graphics.Shader;
import android.support.annotation.AnyThread;
import android.support.annotation.WorkerThread;
import android.support.annotation.ColorInt;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.View;

import java.util.ArrayList;
import java.util.List;
import java.util.Map;

/**
 * A class for rendering a heat map in an Android view.
 * <br/>
 * Created by Travis Redpath on 10/2/2016.
 */
public class HeatMap extends View implements View.OnTouchListener {

    /**
     * The data that will be displayed in the heat map.
     */
    private List<DataPoint> data;

    /**
     * A buffer for new data that hasn't been displayed yet.
     */
    private List<DataPoint> dataBuffer;

    /**
     * Whether the information stored in dataBuffer has changed.
     */
    private boolean dataModified = false;

    /**
     * The value that corresponds to the minimum of the gradient scale.
     */
    private double min = Double.NEGATIVE_INFINITY;
    /**
     * The value that corresponds to the maximum of the gradient scale.
     */
    private double max = Double.POSITIVE_INFINITY;

    /**
     * The amount of blur to use.
     */
    private double mBlur = 0.85;
    /**
     * The radius (px) of the circle each data point takes up.
     */
    private double mRadius = 200;

    /**
     * If greater than 0 this will be used as the transparency for the entire map.
     */
    private int opacity = 0;
    /**
     * The minimum opacity to use in the map. Only used when {@link HeatMap#opacity} is 0.
     */
    private int minOpacity = 0;
    /**
     * The maximum opacity to use in the map. Only used when {@link HeatMap#opacity} is 0.
     */
    private int maxOpacity = 255;

    /**
     * The bounds of actual data. For the sake of efficiency this stops us updating outside
     * of where data is present.
     */
    private double mRenderBoundaries[] = new double[4];

    /**
     * Colors to be used in building the gradient.
     */
    private @ColorInt int colors[] = new int[] { 0xffff0000, 0xff00ff00 };

    /**
     * The stops to position the colors at.
     */
    private float positions[] = new float[] { 0.0f, 1.0f };

    /**
     * A paint for solid black.
     */
    private Paint mBlack;

    private boolean mTransparentBackground = true;

    /**
     * A paint for the background fill.
     */
    private Paint mBackground;

    /**
     * A paint to be used to fill objects.
     */
    private Paint mFill;

    /**
     * The color palette being used to create the radial gradients.
     */
    private int palette[] = null;

    /**
     * Whether the palette needs refreshed.
     */
    private boolean needsRefresh = true;

    /**
     * Update the shadow layer when the size changes.
     */
    private boolean sizeChange = false;

    /**
     * The top padding on the heatmap.
     */
    private float mTop = 0;

    /**
     * The left padding on the heatmap.
     */
    private float mLeft = 0;

    /**
     * The right padding on the heatmap.
     */
    private float mRight = 0;

    /**
     * The bottom padding on the heatmap.
     */
    private float mBottom = 0;

    /**
     * The maximum width of the rendering surface.
     */
    private Integer mMaxWidth = 0;

    /**
     * The maximum height of the rendering surface.
     */
    private Integer mMaxHeight = 0;

    /**
     * A listener for click events.
     */
    private OnMapClickListener mListener;

    /**
     * The bitmap that the shadow layer is rendered into.
     */
    private Bitmap mShadow = null;

    /**
     * A lock to make sure that the bitmap is not rendered more than once at a time.
     */
    private final Object tryRefreshLock = new Object();

    /**
     * Should the drawing cache be used or should a new bitmap be created.
     */
    private boolean mUseDrawingCache = false;

    /**
     * A listener that is used to draw
     */
    private HeatMapMarkerCallback mMarkerCallback = null;

    /**
     * Set a right padding for the data positions. The gradient will still extend into the
     * padding area.
     * @param padding The amount of padding to add to the right of the data points (in pixels).
     */
    public void setRightPadding(int padding) { mRight = padding; }

    /**
     * Set a left padding for the data positions. The gradient will still extend into the
     * padding area.
     * @param padding The amount of padding to add to the left of the data points (in pixels).
     */
    public void setLeftPadding(int padding) { mLeft = padding; }

    /**
     * Set a top padding for the data positions. The gradient will still extend into the
     * padding area.
     * @param padding The amount of padding to add to the top of the data points (in pixels).
     */
    public void setTopPadding(int padding) { mTop = padding; }

    /**
     * Set a bottom padding for the data positions. The gradient will still extend into the
     * padding area.
     * @param padding The amount of padding to add to the bottom of the data points (in pixels).
     */
    public void setBottomPadding(int padding) { mBottom = padding; }

    /**
     * Show markers at the data positions.
     * @param callback Callback that will draw the data point markers.
     */
    public void setMarkerCallback(HeatMapMarkerCallback callback) { mMarkerCallback = callback; }

    /**
     * Set the blur factor for the heat map. Must be between 0 and 1.
     * @param blur The blur factor
     */
    @AnyThread
    public void setBlur(double blur) {
        if (blur > 1.0 || blur < 0.0)
            throw new IllegalArgumentException("Blur must be between 0 and 1.");
        mBlur = blur;
    }
    /**
     * Get the heat map's blur factor.
     */
    @AnyThread
    public double getBlur() { return mBlur; }

    /**
     * Sets the value associated with the maximum on the gradient scale.
     *
     * This should be greater than the minimum value.
     * @param max The maximum value.
     */
    @AnyThread
    public void setMaximum(double max) { this.max = max; }

    /**
     * Sets the value associated with the minimum on the gradient scale.
     *
     * This should be less than the maximum value.
     * @param min The minimum value.
     */
    @AnyThread
    public void setMinimum(double min) { this.min = min; }

    /**
     * Set the opacity to be used in the heat map. This opacity will be used for the entire map.
     * @param opacity The opacity in the range [0,255].
     */
    @AnyThread
    public void setOpacity(int opacity) { this.opacity = opacity; }

    /**
     * Set the minimum opacity to be used in the map. Only used when {@link HeatMap#opacity} is 0.
     * @param min The minimum opacity in the range [0,255].
     */
    @AnyThread
    public void setMinimumOpactity(int min) { this.minOpacity = min; }

    /**
     * Set the maximum opacity to be used in the map. Only used when {@link HeatMap#opacity} is 0.
     * @param max The maximum opacity in the range [0,255].
     */
    @AnyThread
    public void setMaximumOpactity(int max) { this.maxOpacity = max; }

    /**
     * Set the circles radius when drawing data points.
     * @param radius The radius in pixels.
     */
    @AnyThread
    public void setRadius(double radius) { this.mRadius = radius; }

    /**
     * Use the drawing cache instead of creating a new {@link Bitmap}. Causes {@link NullPointerException} on some
     * devices so is disabled by default.
     * @param use Use the drawing cache instead of a new {@link Bitmap}.
     */
    public void setUseDrawingCache(boolean use) { this.mUseDrawingCache = use; invalidate(); }

    /**
     * The maximum width of the bitmap that is used to render the heatmap.
     * @param width The maximum width in pixels.
     */
    public void setMaxDrawingWidth(int width) { mMaxWidth = width; }

    /**
     * The maximum height of the bitmap that is used to render the heatmap.
     * @param height The maximum height in pixels.
     */
    public void setMaxDrawingHeight(int height) { mMaxHeight = height; }

    /**
     * Set the color stops used for the heat map's gradient. There needs to be at least 2 stops
     * and there should be one at a position of 0 and one at a position of 1.
     * @param stops A map from stop positions (as fractions of the width in [0,1]) to ARGB colors.
     */
    @AnyThread
    public void setColorStops(Map<Float, Integer> stops) {
        if (stops.size() < 2)
            throw new IllegalArgumentException("There must be at least 2 color stops");
        colors = new int[stops.size()];
        positions = new float[stops.size()];
        int i = 0;
        for (Float key : stops.keySet()) {
            colors[i] = stops.get(key);
            positions[i] = key;
            i++;
        }
        if (!mTransparentBackground)
            mBackground.setColor(colors[0]);
    }

    /**
     * Add a new data point to the heat map.
     *
     * Does not refresh the display. See {@link HeatMap#forceRefresh()} in order to redraw the heat map.
     * @param point A new data point.
     */
    @AnyThread
    public void addData(DataPoint point) {
        dataBuffer.add(point);
        dataModified = true;
    }

    /**
     * Clears the data that is being displayed in the heat map.
     *
     * Does not refresh the display. See {@link HeatMap#forceRefresh()} in order to redraw the heat map.
     */
    @AnyThread
    public void clearData() {
        dataBuffer.clear();
        dataModified = true;
    }

    /**
     * Register a callback to be invoked when this view is clicked. It will return the closest
     * data point as well as the clicked location.
     * @param listener The callback that will run
     */
    public void setOnMapClickListener(OnMapClickListener listener) { this.mListener = listener; }

    /**
     * Register a callback to be invoked when this view is touched.
     * @param listener The callback that will run
     * @deprecated Use {@link #setOnMapClickListener(OnMapClickListener)} instead.
     */
    @Override
    @Deprecated
    public void setOnTouchListener(OnTouchListener listener) {
        mListener = null;
        super.setOnTouchListener(listener);
    }

    /**
     * Simple constructor to use when creating a view from code.
     * @param context The Context the view is running in, through which it can access the current theme, resources, etc.
     */
    public HeatMap(Context context) {
        super(context);
        initialize();
    }

    /**
     * Constructor that is called when inflating a view from XML. This is called when a view is
     * being constructed from an XML file, supplying attributes that were specified in the XML file.
     * This version uses a default style of 0, so the only attribute values applied are those in the
     * Context's Theme and the given AttributeSet.
     * @param context The Context the view is running in, through which it can access the current theme, resources, etc.
     * @param attrs The attributes of the XML tag that is inflating the view.
     */
    public HeatMap(Context context, AttributeSet attrs) {
        super(context, attrs);
        initialize();
        TypedArray a = context.getTheme().obtainStyledAttributes(attrs, R.styleable.HeatMap, 0, 0);
        try {
            opacity = a.getInt(R.styleable.HeatMap_opacity, -1);
            if (opacity < 0)
                opacity = 0;
            minOpacity = a.getInt(R.styleable.HeatMap_minOpacity, -1);
            if (minOpacity < 0)
                minOpacity = 0;
            maxOpacity = a.getInt(R.styleable.HeatMap_maxOpacity, -1);
            if (maxOpacity < 0)
                maxOpacity = 255;
            mBlur = a.getFloat(R.styleable.HeatMap_blur, -1);
            if (mBlur < 0)
                mBlur = 0.85;
            mRadius = a.getDimension(R.styleable.HeatMap_radius, -1);
            if (mRadius < 0)
                mRadius = 200;
            float padding = a.getDimension(R.styleable.HeatMap_dataPadding, -1);
            if (padding < 0)
                padding = 0;
            mTop = a.getDimension(R.styleable.HeatMap_dataPaddingTop, -1);
            if (mTop < 0)
                mTop = padding;
            mBottom = a.getDimension(R.styleable.HeatMap_dataPaddingBottom, -1);
            if (mBottom < 0)
                mBottom = padding;
            mRight = a.getDimension(R.styleable.HeatMap_dataPaddingRight, -1);
            if (mRight < 0)
                mRight = padding;
            mLeft = a.getDimension(R.styleable.HeatMap_dataPaddingLeft, -1);
            if (mLeft < 0)
                mLeft = padding;
            mMaxWidth = (int)a.getDimension(R.styleable.HeatMap_maxDrawingWidth, -1);
            if (mMaxWidth < 0)
                mMaxWidth = null;
            mMaxHeight = (int)a.getDimension(R.styleable.HeatMap_maxDrawingHeight, -1);
            if (mMaxHeight < 0)
                mMaxHeight = null;
            mTransparentBackground = a.getBoolean(R.styleable.HeatMap_transparentBackground, true);
        } finally {
            a.recycle();
        }
    }

    /**
     * Force a refresh of the heat map.
     *
     * Use this instead of {@link View#invalidate()}.
     */
    public void forceRefresh() {
        needsRefresh = true;
        invalidate();
    }

    /**
     * Initialize all of the paints that we're cable of before drawing.
     */
    private void initialize() {
        mBlack = new Paint();
        mBlack.setColor(0xff000000);
        mFill = new Paint();
        mFill.setStyle(Paint.Style.FILL);
        mBackground = new Paint();
        if (!mTransparentBackground)
            mBackground.setColor(0xfffefefe);
        data = new ArrayList<>();
        dataBuffer = new ArrayList<>();
        super.setOnTouchListener(this);
        if (mUseDrawingCache) {
            this.setDrawingCacheEnabled(true);
            this.setDrawingCacheBackgroundColor(Color.TRANSPARENT);
        }
    }

    @AnyThread
    @SuppressLint("WrongThread")
    private int getDrawingWidth() {
        if (mMaxWidth == null)
            return getWidth();
        return Math.min(calcMaxWidth(), getWidth());
    }

    @AnyThread
    @SuppressLint("WrongThread")
    private int getDrawingHeight() {
        if (mMaxHeight == null)
            return getHeight();
        return Math.min(calcMaxHeight(), getHeight());
    }

    @AnyThread
    @SuppressWarnings("WrongThread")
    private float getScale() {
        if (mMaxWidth == null || mMaxHeight == null)
            return 1.0f;
        float sourceRatio = getWidth() / getHeight();
        float targetRatio = mMaxWidth / mMaxHeight;
        float scale;
        if (sourceRatio < targetRatio) {
            scale = getWidth() / ((float)mMaxWidth);
        }
        else {
            scale = getHeight() / ((float)mMaxHeight);
        }
        return scale;
    }

    @AnyThread
    @SuppressLint("WrongThread")
    private int calcMaxHeight() {
        return (int)(getHeight() / getScale());
    }

    @AnyThread
    @SuppressLint("WrongThread")
    private int calcMaxWidth() {
        return (int)(getWidth() / getScale());
    }

    @AnyThread
    @SuppressLint("WrongThread")
    private void redrawShadow(int width, int height) {
        mRenderBoundaries[0] = 10000;
        mRenderBoundaries[1] = 10000;
        mRenderBoundaries[2] = 0;
        mRenderBoundaries[3] = 0;

        if (mUseDrawingCache)
            mShadow = getDrawingCache();
        else
            mShadow = Bitmap.createBitmap(getDrawingWidth(), getDrawingHeight(), Bitmap.Config.ARGB_8888);
        Canvas shadowCanvas = new Canvas(mShadow);

        drawTransparent(shadowCanvas, width, height);
    }

    /**
     * Draws the heatmap from a background thread.
     *
     * This allows offloading some of the work that would usualy be done in
     * {@link #onDraw(Canvas)} into a background thread. If the view is redrawn
     * for some reason while this operation is still ongoing, the UI thread
     * will block until this call is finished.
     *
     * The caller should take care to invalidate the view on the UI thread
     * afterwards, but not before this call has finished.
     *
     * <pre>{@code
     * final HeatMap heatmap = (HeatMap) findViewById(R.id.heatmap);
     * new AsyncTask<Void,Void,Void>() {
     *     protected Void doInBackground(Void... params) {
     *         Random rand = new Random();
     *         //add 20 random points of random intensity
     *         for (int i = 0; i < 20; i++) {
     *             heatmap.addData(getRandomDataPoint());
     *         }
     *
     *         heatmap.forceRefreshOnWorkerThread();
     *
     *         return null;
     *     }
     *
     *     protected void onPostExecute(Void aVoid) {
     *         heatmap.invalidate();
     *         heatmap.setAlpha(0.0f);
     *         heatmap.animate().alpha(1.0f).setDuration(700L).start();
     *     }
     * }.execute();
     * }</pre>
     */
    @WorkerThread
    @SuppressLint("WrongThread")
    public void forceRefreshOnWorkerThread() {
        synchronized (tryRefreshLock) {
            // These getters are in fact available on this thread. The caller will have to
            // take care that the view is in an acceptable state here.
            tryRefresh(true, getDrawingWidth(), getDrawingHeight());
        }
    }

    /**
     * If needed, refresh the palette.
     */
    @AnyThread
    private void tryRefresh(boolean forceRefresh, int width, int height) {
        if (forceRefresh || needsRefresh) {
            Bitmap bit = Bitmap.createBitmap(256, 1, Bitmap.Config.ARGB_8888);
            Canvas canvas = new Canvas(bit);
            LinearGradient grad;
            grad = new LinearGradient(0, 0, 256, 1, colors, positions, Shader.TileMode.CLAMP);
            Paint paint = new Paint();
            paint.setStyle(Paint.Style.FILL);
            paint.setShader(grad);
            canvas.drawLine(0, 0, 256, 1, paint);
            palette = new int[256];
            bit.getPixels(palette, 0, 256, 0, 0, 256, 1);

            if (dataModified) {
                data.clear();
                data.addAll(dataBuffer);
                dataBuffer.clear();
                dataModified = false;
            }

            redrawShadow(width, height);
        }
        else if (sizeChange) {
            redrawShadow(width, height);
        }
        needsRefresh = false;
        sizeChange = false;
    }

    @Override
    public void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
        if (mMaxWidth == null || mMaxHeight == null)
            sizeChange = true;
    }

    /**
     * Draw the heat map.
     *
     * @param canvas Canvas to draw into.
     */
    @Override
    protected void onDraw(Canvas canvas) {
        synchronized (tryRefreshLock) {
            tryRefresh(false, getDrawingWidth(), getDrawingHeight());
        }
        drawColour(canvas);
    }

    /**
     * Draw a radial gradient at a given location. Only draws in black with the gradient being only
     * in transparency.
     *
     * @param canvas Canvas to draw into.
     * @param x The x location to draw the point.
     * @param y The y location to draw the point.
     * @param radius The radius (in pixels) of the point.
     * @param blurFactor A factor to scale the circles width by.
     * @param alpha The transparency of the gradient.
     */
    @AnyThread
    private void drawDataPoint(Canvas canvas, float x, float y, double radius, double blurFactor, double alpha) {
        if (blurFactor == 1) {
            canvas.drawCircle(x, y, (float)radius, mBlack);
        }
        else {
            //create a radial gradient at the requested position with the requested size
            RadialGradient gradient = new RadialGradient(x, y, (float)(radius * blurFactor),
                    new int[] { Color.argb((int)(alpha * 255), 0, 0, 0), Color.argb(0, 0, 0, 0) },
                    null, Shader.TileMode.CLAMP);
            mFill.setShader(gradient);
            canvas.drawCircle(x, y, (float)(2 * radius), mFill);
        }
    }

    /**
     * Draw a heat map in only black and transparency to be used as the blended base of the coloured
     * version.
     *
     * @param canvas Canvas to draw into.
     * @param width The width of the view.
     * @param height The height of the view.
     */
    @AnyThread
    private void drawTransparent(Canvas canvas, int width, int height) {
        //invert the blur factor
        double blur = 1 - mBlur;

        //clear the canvas
        canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR);

        float scale = getScale();
        float top = mTop / scale;
        float bottom = mBottom / scale;
        float left = mLeft / scale;
        float right = mRight / scale;

        float w = width - left - right;
        float h = height - top - bottom;

        //loop through the data points
        for (DataPoint point : data) {
            float x = (point.x * w) + left;
            float y = (point.y * h) + top;
            double value = Math.max(min, Math.min(point.value, max));
            //the edge of the bounding rectangle for the circle
            double rectX = x - mRadius;
            double rectY = y - mRadius;

            //calculate the transparency of the circle from its percentage between the max and
            //min values
            double alpha = (value - min)/(max - min);

            //draw the point into the canvas
            drawDataPoint(canvas, x, y, mRadius, blur, alpha);

            //update the modified bounds of the image if necessary
            if (rectX < mRenderBoundaries[0])
                mRenderBoundaries[0] = rectX;
            if (rectY < mRenderBoundaries[1])
                mRenderBoundaries[1] = rectY;
            if ((rectX + (2*mRadius)) > mRenderBoundaries[2])
                mRenderBoundaries[2] = rectX + (2*mRadius);
            if ((rectY + (2*mRadius)) > mRenderBoundaries[3])
                mRenderBoundaries[3] = rectY + (2*mRadius);
        }
    }

    /**
     * Convert the black/transparent heat map into a full colour one.
     *
     * @param canvas The canvas to draw into.
     */
    private void drawColour(Canvas canvas) {
        if (data.size() == 0)
            return;

        //calculate the bounds of shadow layer that have modified pixels
        int x = (int)mRenderBoundaries[0];
        int y = (int)mRenderBoundaries[1];
        int width = (int)mRenderBoundaries[2];
        int height = (int)mRenderBoundaries[3];
        int maxWidth = getDrawingWidth();
        int maxHeight = getDrawingHeight();

        if (x < 0)
            x = 0;
        if (y < 0)
            y = 0;
        if (x + width > maxWidth)
            width = maxWidth - x;
        if (y + height > maxHeight)
            height = maxHeight - y;

        //retrieve the modified pixels from the shadow layer
        int pixels[] = new int[width];

        //loop over each retrieved pixel
        for (int j = 0; j < height; j++) {
            mShadow.getPixels(pixels, 0, width, x, y + j, width, 1);

            for (int i = 0; i < width; i++) {
                int pixel = pixels[i];
                //the pixels alpha value (0-255)
                int alpha = 0xff & (pixel >> 24);

                //clamp the alpha value to user specified bounds
                int clampAlpha;
                if (opacity > 0)
                    clampAlpha = opacity;
                else {
                    if (alpha < maxOpacity) {
                        if (alpha < minOpacity) {
                            clampAlpha = minOpacity;
                        }
                        else {
                            clampAlpha = alpha;
                        }
                    }
                    else {
                        clampAlpha = maxOpacity;
                    }
                }

                //set the pixels colour to its corresponding colour in the palette
                pixels[i] = ((0xff & clampAlpha) << 24) | (0xffffff & palette[alpha]);
            }

            //set the modified pixels back into the bitmap
            mShadow.setPixels(pixels, 0, width, x, y + j, width, 1);
        }

        //clear to the min colour
        if (!mTransparentBackground)
            canvas.drawRect(0, 0, getWidth(), getHeight(), mBackground);
        //render the bitmap onto the heat map
        canvas.drawBitmap(mShadow, new Rect(0, 0, getDrawingWidth(), getDrawingHeight()), new Rect(0, 0, getWidth(), getHeight()), null);

        //draw markers at each data point if requested
        if (mMarkerCallback != null) {
            float rwidth = getWidth() - mLeft - mRight;
            float rheight = getHeight() - mTop - mBottom;

            for (DataPoint point : data) {
                float rx = (point.x * rwidth) + mLeft;
                float ry = (point.y * rheight) + mTop;
                mMarkerCallback.drawMarker(canvas, rx, ry);
            }
        }
    }

    private float touchX;
    private float touchY;

    @Override
    public boolean onTouch(View view, MotionEvent motionEvent) {
        if (mListener != null) {
            if (motionEvent.getAction() == MotionEvent.ACTION_UP) {
                float x = motionEvent.getX();
                float y = motionEvent.getY();
                double d = Math.sqrt(Math.pow(touchX - x, 2.0f) + Math.pow(touchY - y, 2.0f));
                if (d < 10) {
                    x = x / (float) getWidth();
                    y = y / (float) getHeight();
                    double minDist = Double.MAX_VALUE;
                    DataPoint minPoint = null;
                    for (DataPoint point : data) {
                        double dist = point.distanceTo(x, y);
                        if (dist < minDist) {
                            minDist = dist;
                            minPoint = point;
                        }
                    }
                    mListener.onMapClicked((int) x, (int) y, minPoint);
                    return true;
                }
            }
            else if (motionEvent.getAction() == MotionEvent.ACTION_DOWN) {
                touchX = motionEvent.getX();
                touchY = motionEvent.getY();
                return true;
            }
        }
        return false;
    }

    /**
     * Stores data points to display in the heat map.
     */
    public static class DataPoint {
        /**
         * The data points x value as a decimal percent of the views width.
         */
        public float x;
        /**
         * The data points y value as a decimal percent of the views height.
         */
        public float y;
        /**
         * The intensity value of the data point.
         */
        public double value;
        /**
         * Any user specific data that may need to be associated with a point.
         */
        public Object userData = null;

        /**
         * Construct a new data point to be displayed in the heat map.
         *
         * @param x The data points x location as a decimal percent of the views width
         * @param y The data points y location as a decimal percent of the views height
         * @param value The intensity value of the data point
         */
        public DataPoint(float x, float y, double value) {
            this.x = x;
            this.y = y;
            this.value = value;
        }

        double distanceTo(float x, float y) {
            return Math.sqrt(Math.pow(x - this.x, 2.0) + Math.pow(y - this.y, 2.0));
        }
    }

    public interface OnMapClickListener {
        void onMapClicked(int x, int y, DataPoint closest);
    }
}