org.onebusaway.android.map.googlemapsv2.StopOverlay.java Source code

Java tutorial

Introduction

Here is the source code for org.onebusaway.android.map.googlemapsv2.StopOverlay.java

Source

/*
 * Copyright (C) 2014 University of South Florida (sjbarbeau@gmail.com)
 *
 * 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 org.onebusaway.android.map.googlemapsv2;

import com.google.android.gms.maps.GoogleMap;
import com.google.android.gms.maps.Projection;
import com.google.android.gms.maps.model.BitmapDescriptor;
import com.google.android.gms.maps.model.BitmapDescriptorFactory;
import com.google.android.gms.maps.model.LatLng;
import com.google.android.gms.maps.model.Marker;
import com.google.android.gms.maps.model.MarkerOptions;

import org.onebusaway.android.BuildConfig;
import org.onebusaway.android.R;
import org.onebusaway.android.app.Application;
import org.onebusaway.android.io.ObaAnalytics;
import org.onebusaway.android.io.elements.ObaReferences;
import org.onebusaway.android.io.elements.ObaRoute;
import org.onebusaway.android.io.elements.ObaStop;

import android.app.Activity;
import android.content.Context;
import android.content.res.Resources;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.LinearGradient;
import android.graphics.Matrix;
import android.graphics.Paint;
import android.graphics.Path;
import android.graphics.Point;
import android.graphics.Shader;
import android.graphics.drawable.Drawable;
import android.location.Location;
import android.os.Build;
import android.os.Handler;
import android.os.SystemClock;
import android.support.v4.content.ContextCompat;
import android.util.Log;
import android.view.animation.BounceInterpolator;
import android.view.animation.Interpolator;
import android.widget.Toast;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;

public class StopOverlay implements GoogleMap.OnMarkerClickListener, GoogleMap.OnMapClickListener {

    private static final String TAG = "StopOverlay";

    private GoogleMap mMap;

    private MarkerData mMarkerData;

    private final Activity mActivity;

    private static final String NORTH = "N";

    private static final String NORTH_WEST = "NW";

    private static final String WEST = "W";

    private static final String SOUTH_WEST = "SW";

    private static final String SOUTH = "S";

    private static final String SOUTH_EAST = "SE";

    private static final String EAST = "E";

    private static final String NORTH_EAST = "NE";

    private static final String NO_DIRECTION = "null";

    private static final int NUM_DIRECTIONS = 9; // 8 directions + undirected mStops

    private static Bitmap[] bus_stop_icons = new Bitmap[NUM_DIRECTIONS];

    private static int mPx; // Bus stop icon size

    // Bus icon arrow attributes - by default assume we're not going to add a direction arrow
    private static float mArrowWidthPx = 0;

    private static float mArrowHeightPx = 0;

    private static float mBuffer = 0; // Add this to the icon size to get the Bitmap size

    private static float mPercentOffset = 0.5f;
    // % offset to position the stop icon, so the selection marker hits the middle of the circle

    private static Paint mArrowPaintStroke;
    // Stroke color used for outline of directional arrows on stops

    OnFocusChangedListener mOnFocusChangedListener;

    public interface OnFocusChangedListener {

        /**
         * Called when a stop on the map is clicked (i.e., tapped), which sets focus to a stop,
         * or when the user taps on an area away from the map for the first time after a stop
         * is already selected, which removes focus.  Clearly the focused stop can also be triggered
         * programmatically via a call to setFocus() with a stop of null - in that case, because
         * the user did not touch the map, location will be null.
         *
         * @param stop     the ObaStop that obtained focus, or null if no stop is in focus
         * @param routes   a HashMap of all route display names that serve this stop - key is
         *                 routeId
         * @param location the user touch location on the map, or null if the focus was changed
         *                 programmatically without the user tapping on the map
         */
        void onFocusChanged(ObaStop stop, HashMap<String, ObaRoute> routes, Location location);
    }

    public StopOverlay(Activity activity, GoogleMap map) {
        mActivity = activity;
        mMap = map;
        loadIcons();
        mMap.setOnMarkerClickListener(this);
        mMap.setOnMapClickListener(this);
    }

    public void setOnFocusChangeListener(OnFocusChangedListener onFocusChangedListener) {
        mOnFocusChangedListener = onFocusChangedListener;
    }

    public synchronized void populateStops(List<ObaStop> stops, ObaReferences refs) {
        populate(stops, refs.getRoutes());
    }

    public synchronized void populateStops(List<ObaStop> stops, List<ObaRoute> routes) {
        populate(stops, routes);
    }

    private void populate(List<ObaStop> stops, List<ObaRoute> routes) {
        // Make sure that the MarkerData has been initialized
        setupMarkerData();
        mMarkerData.populate(stops, routes);
    }

    public synchronized int size() {
        if (mMarkerData != null) {
            return mMarkerData.size();
        } else {
            return 0;
        }
    }

    /**
     * Clears any stop markers from the map
     * @param clearFocusedStop true to clear the currently focused stop, false to leave it on map
     */
    public synchronized void clear(boolean clearFocusedStop) {
        if (mMarkerData != null) {
            mMarkerData.clear(clearFocusedStop);
        }
    }

    /**
     * Cache the BitmapDescriptors that hold the images used for icons
     */
    private static final void loadIcons() {
        // Initialize variables used for all marker icons
        Resources r = Application.get().getResources();
        mPx = r.getDimensionPixelSize(R.dimen.map_stop_shadow_size_6);
        mArrowWidthPx = mPx / 2f; // half the stop icon size
        mArrowHeightPx = mPx / 3f; // 1/3 the stop icon size
        float arrowSpacingReductionPx = mPx / 10f;
        mBuffer = mArrowHeightPx - arrowSpacingReductionPx;

        // Set offset used to position the image for markers (see getX/YPercentOffsetForDirection())
        // This allows the current selection marker to land on the middle of the stop marker circle
        mPercentOffset = (mBuffer / (mPx + mBuffer)) * 0.5f;

        mArrowPaintStroke = new Paint();
        mArrowPaintStroke.setColor(Color.WHITE);
        mArrowPaintStroke.setStyle(Paint.Style.STROKE);
        mArrowPaintStroke.setStrokeWidth(1.0f);
        mArrowPaintStroke.setAntiAlias(true);

        bus_stop_icons[0] = createBusStopIcon(NORTH);
        bus_stop_icons[1] = createBusStopIcon(NORTH_WEST);
        bus_stop_icons[2] = createBusStopIcon(WEST);
        bus_stop_icons[3] = createBusStopIcon(SOUTH_WEST);
        bus_stop_icons[4] = createBusStopIcon(SOUTH);
        bus_stop_icons[5] = createBusStopIcon(SOUTH_EAST);
        bus_stop_icons[6] = createBusStopIcon(EAST);
        bus_stop_icons[7] = createBusStopIcon(NORTH_EAST);
        bus_stop_icons[8] = createBusStopIcon(NO_DIRECTION);
    }

    /**
     * Creates a bus stop icon with the given direction arrow, or without a direction arrow if
     * the direction is NO_DIRECTION
     *
     * @param direction Bus stop direction, obtained from ObaStop.getDirection() and defined in
     *                  constants in this class, or NO_DIRECTION if the stop icon shouldn't have a
     *                  direction arrow
     * @return a bus stop icon bitmap with the arrow pointing the given direction, or with no arrow
     * if direction is NO_DIRECTION
     */
    private static Bitmap createBusStopIcon(String direction) throws NullPointerException {
        if (direction == null) {
            throw new IllegalArgumentException(direction);
        }

        Resources r = Application.get().getResources();
        Context context = Application.get();

        Float directionAngle = null; // 0-360 degrees
        Bitmap bm;
        Canvas c;
        Drawable shape;
        Float rotationX = null, rotationY = null; // Point around which to rotate the arrow

        Paint arrowPaintFill = new Paint();
        arrowPaintFill.setStyle(Paint.Style.FILL);
        arrowPaintFill.setAntiAlias(true);

        if (direction.equals(NO_DIRECTION)) {
            // Don't draw the arrow
            bm = Bitmap.createBitmap(mPx, mPx, Bitmap.Config.ARGB_8888);
            c = new Canvas(bm);
            shape = ContextCompat.getDrawable(context, R.drawable.map_stop_icon);
            shape.setBounds(0, 0, bm.getWidth(), bm.getHeight());
        } else if (direction.equals(NORTH)) {
            directionAngle = 0f;
            bm = Bitmap.createBitmap(mPx, (int) (mPx + mBuffer), Bitmap.Config.ARGB_8888);
            c = new Canvas(bm);
            shape = ContextCompat.getDrawable(context, R.drawable.map_stop_icon);
            shape.setBounds(0, (int) mBuffer, mPx, bm.getHeight());
            // Shade with darkest color at tip of arrow
            arrowPaintFill.setShader(new LinearGradient(bm.getWidth() / 2, 0, bm.getWidth() / 2, mArrowHeightPx,
                    r.getColor(R.color.theme_primary), r.getColor(R.color.theme_accent), Shader.TileMode.MIRROR));
            // For NORTH, no rotation occurs - use center of image anyway so we have some value
            rotationX = bm.getWidth() / 2f;
            rotationY = bm.getHeight() / 2f;
        } else if (direction.equals(NORTH_WEST)) {
            directionAngle = 315f; // Arrow is drawn N, rotate 315 degrees
            bm = Bitmap.createBitmap((int) (mPx + mBuffer), (int) (mPx + mBuffer), Bitmap.Config.ARGB_8888);
            c = new Canvas(bm);
            shape = ContextCompat.getDrawable(context, R.drawable.map_stop_icon);
            shape.setBounds((int) mBuffer, (int) mBuffer, bm.getWidth(), bm.getHeight());
            // Shade with darkest color at tip of arrow
            arrowPaintFill.setShader(new LinearGradient(0, 0, mBuffer, mBuffer, r.getColor(R.color.theme_primary),
                    r.getColor(R.color.theme_accent), Shader.TileMode.MIRROR));
            // Rotate around below coordinates (trial and error)
            rotationX = mPx / 2f + mBuffer / 2f;
            rotationY = bm.getHeight() / 2f - mBuffer / 2f;
        } else if (direction.equals(WEST)) {
            directionAngle = 0f; // Arrow is drawn pointing West, so no rotation
            bm = Bitmap.createBitmap((int) (mPx + mBuffer), mPx, Bitmap.Config.ARGB_8888);
            c = new Canvas(bm);
            shape = ContextCompat.getDrawable(context, R.drawable.map_stop_icon);
            shape.setBounds((int) mBuffer, 0, bm.getWidth(), bm.getHeight());
            arrowPaintFill.setShader(new LinearGradient(0, bm.getHeight() / 2, mArrowHeightPx, bm.getHeight() / 2,
                    r.getColor(R.color.theme_primary), r.getColor(R.color.theme_accent), Shader.TileMode.MIRROR));
            // For WEST
            rotationX = bm.getHeight() / 2f;
            rotationY = bm.getHeight() / 2f;
        } else if (direction.equals(SOUTH_WEST)) {
            directionAngle = 225f; // Arrow is drawn N, rotate 225 degrees
            bm = Bitmap.createBitmap((int) (mPx + mBuffer), (int) (mPx + mBuffer), Bitmap.Config.ARGB_8888);
            c = new Canvas(bm);
            shape = ContextCompat.getDrawable(context, R.drawable.map_stop_icon);
            shape.setBounds((int) mBuffer, 0, bm.getWidth(), mPx);
            arrowPaintFill.setShader(new LinearGradient(0, bm.getHeight(), mBuffer, bm.getHeight() - mBuffer,
                    r.getColor(R.color.theme_primary), r.getColor(R.color.theme_accent), Shader.TileMode.MIRROR));
            // Rotate around below coordinates (trial and error)
            rotationX = bm.getWidth() / 2f - mBuffer / 4f;
            rotationY = mPx / 2f + mBuffer / 4f;
        } else if (direction.equals(SOUTH)) {
            directionAngle = 180f; // Arrow is drawn N, rotate 180 degrees
            bm = Bitmap.createBitmap(mPx, (int) (mPx + mBuffer), Bitmap.Config.ARGB_8888);
            c = new Canvas(bm);
            shape = ContextCompat.getDrawable(context, R.drawable.map_stop_icon);
            shape.setBounds(0, 0, bm.getWidth(), (int) (bm.getHeight() - mBuffer));
            arrowPaintFill.setShader(new LinearGradient(bm.getWidth() / 2, bm.getHeight(), bm.getWidth() / 2,
                    bm.getHeight() - mArrowHeightPx, r.getColor(R.color.theme_primary),
                    r.getColor(R.color.theme_accent), Shader.TileMode.MIRROR));
            rotationX = bm.getWidth() / 2f;
            rotationY = bm.getHeight() / 2f;
        } else if (direction.equals(SOUTH_EAST)) {
            directionAngle = 135f; // Arrow is drawn N, rotate 135 degrees
            bm = Bitmap.createBitmap((int) (mPx + mBuffer), (int) (mPx + mBuffer), Bitmap.Config.ARGB_8888);
            c = new Canvas(bm);
            shape = ContextCompat.getDrawable(context, R.drawable.map_stop_icon);
            shape.setBounds(0, 0, mPx, mPx);
            arrowPaintFill.setShader(new LinearGradient(bm.getWidth(), bm.getHeight(), bm.getWidth() - mBuffer,
                    bm.getHeight() - mBuffer, r.getColor(R.color.theme_primary), r.getColor(R.color.theme_accent),
                    Shader.TileMode.MIRROR));
            // Rotate around below coordinates (trial and error)
            rotationX = (mPx + mBuffer / 2) / 2f;
            rotationY = bm.getHeight() / 2f;
        } else if (direction.equals(EAST)) {
            directionAngle = 180f; // Arrow is drawn pointing West, so rotate 180
            bm = Bitmap.createBitmap((int) (mPx + mBuffer), mPx, Bitmap.Config.ARGB_8888);
            c = new Canvas(bm);
            shape = ContextCompat.getDrawable(context, R.drawable.map_stop_icon);
            shape.setBounds(0, 0, mPx, bm.getHeight());
            arrowPaintFill.setShader(new LinearGradient(bm.getWidth(), bm.getHeight() / 2,
                    bm.getWidth() - mArrowHeightPx, bm.getHeight() / 2, r.getColor(R.color.theme_primary),
                    r.getColor(R.color.theme_accent), Shader.TileMode.MIRROR));
            rotationX = bm.getWidth() / 2f;
            rotationY = bm.getHeight() / 2f;
        } else if (direction.equals(NORTH_EAST)) {
            directionAngle = 45f; // Arrow is drawn pointing N, so rotate 45 degrees
            bm = Bitmap.createBitmap((int) (mPx + mBuffer), (int) (mPx + mBuffer), Bitmap.Config.ARGB_8888);
            c = new Canvas(bm);
            shape = ContextCompat.getDrawable(context, R.drawable.map_stop_icon);
            shape.setBounds(0, (int) mBuffer, mPx, bm.getHeight());
            // Shade with darkest color at tip of arrow
            arrowPaintFill.setShader(new LinearGradient(bm.getWidth(), 0, bm.getWidth() - mBuffer, mBuffer,
                    r.getColor(R.color.theme_primary), r.getColor(R.color.theme_accent), Shader.TileMode.MIRROR));
            // Rotate around middle of circle
            rotationX = (float) mPx / 2;
            rotationY = bm.getHeight() - (float) mPx / 2;
        } else {
            throw new IllegalArgumentException(direction);
        }

        shape.draw(c);

        if (direction.equals(NO_DIRECTION)) {
            // Everything after this point is for drawing the arrow image, so return the bitmap as-is for no arrow
            return bm;
        }

        /**
         * Draw the arrow - all dimensions should be relative to px so the arrow is drawn the same
         * size for all orientations
         */
        // Height of the cutout in the bottom of the triangle that makes it an arrow (0=triangle)
        final float CUTOUT_HEIGHT = mPx / 12;
        Path path = new Path();
        float x1 = 0, y1 = 0; // Tip of arrow
        float x2 = 0, y2 = 0; // lower left
        float x3 = 0, y3 = 0; // cutout in arrow bottom
        float x4 = 0, y4 = 0; // lower right

        if (direction.equals(NORTH) || direction.equals(SOUTH) || direction.equals(NORTH_EAST)
                || direction.equals(SOUTH_EAST) || direction.equals(NORTH_WEST) || direction.equals(SOUTH_WEST)) {
            // Arrow is drawn pointing NORTH
            // Tip of arrow
            x1 = mPx / 2;
            y1 = 0;

            // lower left
            x2 = (mPx / 2) - (mArrowWidthPx / 2);
            y2 = mArrowHeightPx;

            // cutout in arrow bottom
            x3 = mPx / 2;
            y3 = mArrowHeightPx - CUTOUT_HEIGHT;

            // lower right
            x4 = (mPx / 2) + (mArrowWidthPx / 2);
            y4 = mArrowHeightPx;
        } else if (direction.equals(EAST) || direction.equals(WEST)) {
            // Arrow is drawn pointing WEST
            // Tip of arrow
            x1 = 0;
            y1 = mPx / 2;

            // lower left
            x2 = mArrowHeightPx;
            y2 = (mPx / 2) - (mArrowWidthPx / 2);

            // cutout in arrow bottom
            x3 = mArrowHeightPx - CUTOUT_HEIGHT;
            y3 = mPx / 2;

            // lower right
            x4 = mArrowHeightPx;
            y4 = (mPx / 2) + (mArrowWidthPx / 2);
        }

        path.setFillType(Path.FillType.EVEN_ODD);
        path.moveTo(x1, y1);
        path.lineTo(x2, y2);
        path.lineTo(x3, y3);
        path.lineTo(x4, y4);
        path.lineTo(x1, y1);
        path.close();

        // Rotate arrow around (rotationX, rotationY) point
        Matrix matrix = new Matrix();
        matrix.postRotate(directionAngle, rotationX, rotationY);
        path.transform(matrix);

        c.drawPath(path, arrowPaintFill);
        c.drawPath(path, mArrowPaintStroke);

        return bm;
    }

    /**
     * Gets the % X offset used for the bus stop icon, for the given direction
     *
     * @param direction Bus stop direction, obtained from ObaStop.getDirection() and defined in
     *                  constants in this class
     * @return percent offset X for the bus stop icon that should be used for that direction
     */
    private static float getXPercentOffsetForDirection(String direction) {
        if (direction.equals(NORTH)) {
            // Middle of icon
            return 0.5f;
        } else if (direction.equals(NORTH_WEST)) {
            return 0.5f + mPercentOffset;
        } else if (direction.equals(WEST)) {
            return 0.5f + mPercentOffset;
        } else if (direction.equals(SOUTH_WEST)) {
            return 0.5f + mPercentOffset;
        } else if (direction.equals(SOUTH)) {
            // Middle of icon
            return 0.5f;
        } else if (direction.equals(SOUTH_EAST)) {
            return 0.5f - mPercentOffset;
        } else if (direction.equals(EAST)) {
            return 0.5f - mPercentOffset;
        } else if (direction.equals(NORTH_EAST)) {
            return 0.5f - mPercentOffset;
        } else if (direction.equals(NO_DIRECTION)) {
            // Middle of icon
            return 0.5f;
        } else {
            // Assume middle of icon
            return 0.5f;
        }
    }

    /**
     * Gets the % Y offset used for the bus stop icon, for the given direction
     *
     * @param direction Bus stop direction, obtained from ObaStop.getDirection() and defined in
     *                  constants in this class
     * @return percent offset Y for the bus stop icon that should be used for that direction
     */
    private static float getYPercentOffsetForDirection(String direction) {
        if (direction.equals(NORTH)) {
            return 0.5f + mPercentOffset;
        } else if (direction.equals(NORTH_WEST)) {
            return 0.5f + mPercentOffset;
        } else if (direction.equals(WEST)) {
            // Middle of icon
            return 0.5f;
        } else if (direction.equals(SOUTH_WEST)) {
            return 0.5f - mPercentOffset;
        } else if (direction.equals(SOUTH)) {
            return 0.5f - mPercentOffset;
        } else if (direction.equals(SOUTH_EAST)) {
            return 0.5f - mPercentOffset;
        } else if (direction.equals(EAST)) {
            // Middle of icon
            return 0.5f;
        } else if (direction.equals(NORTH_EAST)) {
            return 0.5f + mPercentOffset;
        } else if (direction.equals(NO_DIRECTION)) {
            // Middle of icon
            return 0.5f;
        } else {
            // Assume middle of icon
            return 0.5f;
        }
    }

    /**
     * Returns the BitMapDescriptor for a particular bus stop icon, based on the stop direction
     *
     * @param direction Bus stop direction, obtained from ObaStop.getDirection() and defined in
     *                  constants in this class
     * @return BitmapDescriptor for the bus stop icon that should be used for that direction
     */
    private static BitmapDescriptor getBitmapDescriptorForBusStopDirection(String direction) {
        if (direction.equals(NORTH)) {
            return BitmapDescriptorFactory.fromBitmap(bus_stop_icons[0]);
        } else if (direction.equals(NORTH_WEST)) {
            return BitmapDescriptorFactory.fromBitmap(bus_stop_icons[1]);
        } else if (direction.equals(WEST)) {
            return BitmapDescriptorFactory.fromBitmap(bus_stop_icons[2]);
        } else if (direction.equals(SOUTH_WEST)) {
            return BitmapDescriptorFactory.fromBitmap(bus_stop_icons[3]);
        } else if (direction.equals(SOUTH)) {
            return BitmapDescriptorFactory.fromBitmap(bus_stop_icons[4]);
        } else if (direction.equals(SOUTH_EAST)) {
            return BitmapDescriptorFactory.fromBitmap(bus_stop_icons[5]);
        } else if (direction.equals(EAST)) {
            return BitmapDescriptorFactory.fromBitmap(bus_stop_icons[6]);
        } else if (direction.equals(NORTH_EAST)) {
            return BitmapDescriptorFactory.fromBitmap(bus_stop_icons[7]);
        } else if (direction.equals(NO_DIRECTION)) {
            return BitmapDescriptorFactory.fromBitmap(bus_stop_icons[8]);
        } else {
            return BitmapDescriptorFactory.fromBitmap(bus_stop_icons[8]);
        }
    }

    /**
     * Returns the currently focused stop, or null if no stop is in focus
     *
     * @return the currently focused stop, or null if no stop is in focus
     */
    public ObaStop getFocus() {
        if (mMarkerData != null) {
            return mMarkerData.getFocus();
        }

        return null;
    }

    /**
     * Sets focus to a particular stop, or pass in null for the stop to clear the focus
     *
     * @param stop   ObaStop to focus on, or null to clear the focus
     * @param routes a list of all route display names that serve this stop
     */
    public void setFocus(ObaStop stop, List<ObaRoute> routes) {
        // Make sure that the MarkerData has been initialized
        setupMarkerData();

        if (stop == null) {
            // Clear the focus
            removeFocus(null);
            return;
        }

        /**
         * If mMarkerData exists before this method is called, the stop reference passed into this
         * method might not match any existing stop reference in our HashMaps, since this stop came
         * from an external REST API call - is this a problem???
         *
         * If so, we'll need to keep another HashMap mapping stopIds to ObaStops so we can pull out
         * an internal reference to an ObaStop object that has the same stopId as the ObaStop object
         * passed into this method.  Then, we would use that internal reference in place of the
         * ObaStop passed into this method.  We don't want to maintain Yet Another HashMap for
         * memory/performance reasons if we don't have to.  For now, I think we can get away with
         * a separate reference that doesn't match the internal HashMaps, since we don't need to
         * match the references.
         */

        /**
         * Make sure that this stop is added to the overlay.  If an intent/orientation change started
         * the map fragment to focus on a stop, no markers may exist on the map
         */
        if (!mMarkerData.containsStop(stop)) {
            ArrayList<ObaStop> l = new ArrayList<ObaStop>();
            l.add(stop);
            populateStops(l, routes);
        }

        // Add the focus marker to the map by setting focus to this stop
        doFocusChange(stop);
    }

    @Override
    public boolean onMarkerClick(Marker marker) {
        long startTime = Long.MAX_VALUE, endTime = Long.MAX_VALUE;
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
            startTime = SystemClock.elapsedRealtimeNanos();
        }

        ObaStop stop = mMarkerData.getStopFromMarker(marker);

        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
            endTime = SystemClock.elapsedRealtimeNanos();
            Log.d(TAG, "Stop HashMap read time: "
                    + TimeUnit.MILLISECONDS.convert(endTime - startTime, TimeUnit.NANOSECONDS) + "ms");
        }

        if (stop == null) {
            // The marker isn't a stop that is contained in this StopOverlay - return unhandled
            return false;
        }

        if (BuildConfig.DEBUG) {
            // Show the stop_id in a toast for debug purposes
            Toast.makeText(mActivity, stop.getId(), Toast.LENGTH_SHORT).show();
        }

        doFocusChange(stop);

        // Report Stop distance metric
        Location stopLocation = stop.getLocation();
        Location myLocation = Application.getLastKnownLocation(mActivity, null);
        // Track the users distance to bus stop
        ObaAnalytics.trackBusStopDistance(stop.getId(), myLocation, stopLocation);
        return true;
    }

    private void doFocusChange(ObaStop stop) {
        mMarkerData.setFocus(stop);
        HashMap<String, ObaRoute> routes = mMarkerData.getCachedRoutes();

        // Notify listener
        mOnFocusChangedListener.onFocusChanged(stop, routes, stop.getLocation());
    }

    @Override
    public void onMapClick(LatLng latLng) {
        Log.d(TAG, "Map clicked");
        removeFocus(latLng);
    }

    /**
     * Removes the stop focus and notify listener
     *
     * @param latLng the location on the map where the user tapped if the focus change was
     *               triggered
     *               by the user tapping on the map, or null if the focus change was otherwise
     *               triggered programmatically.
     */
    private void removeFocus(LatLng latLng) {
        if (mMarkerData.getFocus() != null) {
            mMarkerData.removeFocus();
        }
        // Set map clicked location, if it exists
        Location location = null;
        if (latLng != null) {
            location = MapHelpV2.makeLocation(latLng);
        }
        // Notify focus changed every time the map is clicked away from a stop marker
        mOnFocusChangedListener.onFocusChanged(null, null, location);
    }

    private void setupMarkerData() {
        if (mMarkerData == null) {
            mMarkerData = new MarkerData();
        }
    }

    /**
     * Data structures to track what stops/markers are currently shown on the map
     */
    class MarkerData {

        /**
         * Stops-for-location REST API endpoint returns 100 markers per call by default
         * (see http://goo.gl/tzvrLb), so we'll support showing max results of around 2 calls
         * before
         * we completely clear the map and start over.  Note that this is a fuzzy max, since we
         * don't
         * want to clear the overlay in the middle of processing an API response and remove markers
         * in
         * the current view
         */
        private static final int FUZZY_MAX_MARKER_COUNT = 200;

        /**
         * A cached set of markers currently shown on the map, up to roughly
         * FUZZY_MAX_MARKER_COUNT in size.  This is needed to add/remove markers from the map.
         * StopId is the key.
         */
        private HashMap<String, Marker> mStopMarkers;

        /**
         * A cached set of ObaStops that are currently shown on the map, up to roughly
         * FUZZY_MAX_MARKER_COUNT in size.  Since onMarkerClick() provides a marker, we need a
         * mapping of that marker to the ObaStop.
         * Marker that represents an ObaStop is the key.
         */
        private HashMap<Marker, ObaStop> mStops;

        /**
         * A cached set of ObaRoutes that serve the currently cached ObaStops.  This is
         * needed to retrieve the route display names that serve a particular stop.
         * RouteId is the key.
         */
        private HashMap<String, ObaRoute> mStopRoutes;

        /**
         * Marker and stop used to indicate which bus stop has focus (i.e., was last
         * clicked/tapped)
         */
        private Marker mCurrentFocusMarker;

        private ObaStop mCurrentFocusStop;

        /**
         * Keep a copy of ObaRoute references for stops have have had focus, so we can reconstruct
         * the mStopRoutes HashMap after clearing the cache
         */
        private List<ObaRoute> mFocusedRoutes;

        MarkerData() {
            mStopMarkers = new HashMap<String, Marker>();
            mStops = new HashMap<Marker, ObaStop>();
            mStopRoutes = new HashMap<String, ObaRoute>();
            mFocusedRoutes = new LinkedList<ObaRoute>();
        }

        synchronized void populate(List<ObaStop> stops, List<ObaRoute> routes) {
            int count = 0;

            if (mStopMarkers.size() >= FUZZY_MAX_MARKER_COUNT) {
                // We've exceed our max, so clear the current marker cache and start over
                Log.d(TAG, "Exceed max marker cache of " + FUZZY_MAX_MARKER_COUNT + ", clearing cache");
                removeMarkersFromMap();
                mStopMarkers.clear();
                mStops.clear();

                // Make sure the currently focused stop still exists on the map
                if (mCurrentFocusStop != null && mFocusedRoutes != null) {
                    addMarkerToMap(mCurrentFocusStop, mFocusedRoutes);
                    count++;
                }
            }

            for (ObaStop stop : stops) {
                if (!mStopMarkers.containsKey(stop.getId())) {
                    addMarkerToMap(stop, routes);
                    count++;
                }
            }

            Log.d(TAG, "Added " + count + " markers, total markers = " + mStopMarkers.size());
        }

        /**
         * Places a marker on the map for this stop, and adds it to our marker HashMap
         *
         * @param stop   ObaStop that should be shown on the map
         * @param routes A list of ObaRoutes that serve this stop
         */
        private void addMarkerToMap(ObaStop stop, List<ObaRoute> routes) {
            Marker m = mMap.addMarker(new MarkerOptions().position(MapHelpV2.makeLatLng(stop.getLocation()))
                    .icon(getBitmapDescriptorForBusStopDirection(stop.getDirection())).flat(true)
                    .anchor(getXPercentOffsetForDirection(stop.getDirection()),
                            getYPercentOffsetForDirection(stop.getDirection())));
            mStopMarkers.put(stop.getId(), m);
            mStops.put(m, stop);
            for (ObaRoute route : routes) {
                // ObaRoutes may have already been added for other stops, so check before adding
                if (!mStopRoutes.containsKey(route.getId())) {
                    mStopRoutes.put(route.getId(), route);
                }
            }
        }

        synchronized ObaStop getStopFromMarker(Marker marker) {
            return mStops.get(marker);
        }

        /**
         * Returns true if this overlay contains the provided ObaStop
         *
         * @param stop ObaStop to check for
         * @return true if this overlay contains the provided ObaStop, false if it does not
         */
        synchronized boolean containsStop(ObaStop stop) {
            if (stop != null) {
                return containsStop(stop.getId());
            } else {
                return false;
            }
        }

        /**
         * Returns true if this overlay contains the provided stopId
         *
         * @param stopId stopId to check for
         * @return true if this overlay contains the provided stopId, false if it does not
         */
        synchronized boolean containsStop(String stopId) {
            if (mStopMarkers != null) {
                return mStopMarkers.containsKey(stopId);
            } else {
                return false;
            }
        }

        /**
         * Gets the ObaRoute objects that have been cached
         *
         * @return a copy of the HashMap containing the ObaRoutes that have been cached, with the
         * routeId as key
         */
        synchronized HashMap<String, ObaRoute> getCachedRoutes() {
            return new HashMap<String, ObaRoute>(mStopRoutes);
        }

        /**
         * Sets the current focus to a particular stop
         *
         * @param stop ObaStop that should have focus
         */
        void setFocus(ObaStop stop) {
            if (mCurrentFocusMarker != null) {
                // Remove the current focus marker from map
                mCurrentFocusMarker.remove();
            }
            mCurrentFocusStop = stop;

            // Save a copy of ObaRoute references for this stop, so we have them when clearing cache
            mFocusedRoutes.clear();
            String[] routeIds = stop.getRouteIds();
            for (int i = 0; i < routeIds.length; i++) {
                ObaRoute route = mStopRoutes.get(routeIds[i]);
                if (route != null) {
                    mFocusedRoutes.add(route);
                }
            }

            // Reduce focus marker latitude by small amount to ensure it is always on top of the
            // corresponding stop marker (i.e., so its not identical to stop marker latitude)
            LatLng latLng = new LatLng(stop.getLatitude() - 0.000001, stop.getLongitude());

            mCurrentFocusMarker = mMap.addMarker(new MarkerOptions().position(latLng));

            // This doesn't look good since when bouncing, the focus marker is drawn behind
            // the bus stop marker.  If only we could control z-order...
            // animateMarker(mCurrentFocusMarker);
        }

        /**
         * Give the marker a slight bounce effect
         *
         * @param marker marker to animate
         */
        private void animateMarker(final Marker marker) {
            final Handler handler = new Handler();

            final long startTime = SystemClock.uptimeMillis();
            final long duration = 300; // ms

            Projection proj = mMap.getProjection();
            final LatLng markerLatLng = marker.getPosition();
            Point startPoint = proj.toScreenLocation(markerLatLng);
            startPoint.offset(0, -10);
            final LatLng startLatLng = proj.fromScreenLocation(startPoint);

            final Interpolator interpolator = new BounceInterpolator();

            handler.post(new Runnable() {
                @Override
                public void run() {
                    long elapsed = SystemClock.uptimeMillis() - startTime;
                    float t = interpolator.getInterpolation((float) elapsed / duration);
                    double lng = t * markerLatLng.longitude + (1 - t) * startLatLng.longitude;
                    double lat = t * markerLatLng.latitude + (1 - t) * startLatLng.latitude;
                    marker.setPosition(new LatLng(lat, lng));

                    if (t < 1.0) {
                        // Post again 16ms later (60fps)
                        handler.postDelayed(this, 16);
                    }
                }
            });
        }

        /**
         * Returns the last focused stop, or null if no stop is in focus
         *
         * @return last focused stop, or null if no stop is in focus
         */
        ObaStop getFocus() {
            return mCurrentFocusStop;
        }

        /**
         * Remove focus of a stop on the map
         */
        void removeFocus() {
            if (mCurrentFocusMarker != null) {
                // Remove the current focus marker from map
                mCurrentFocusMarker.remove();
                mCurrentFocusMarker = null;
            }
            mFocusedRoutes.clear();
            mCurrentFocusStop = null;
        }

        private void removeMarkersFromMap() {
            for (Map.Entry<String, Marker> entry : mStopMarkers.entrySet()) {
                entry.getValue().remove();
            }
        }

        /**
         * Clears any stop markers from the map
         * @param clearFocusedStop true to clear the currently focused stop, false to leave it on map
         */
        synchronized void clear(boolean clearFocusedStop) {
            if (mStopMarkers != null) {
                // Clear all markers from the map
                removeMarkersFromMap();

                // Clear the data structures
                mStopMarkers.clear();
            }
            if (mStops != null) {
                mStops.clear();
            }
            if (mStopRoutes != null) {
                mStopRoutes.clear();
            }
            if (clearFocusedStop) {
                removeFocus();
            } else {
                // Make sure the currently focused stop still exists on the map
                if (mCurrentFocusStop != null && mFocusedRoutes != null) {
                    addMarkerToMap(mCurrentFocusStop, mFocusedRoutes);
                }
            }
        }

        synchronized int size() {
            return mStopMarkers.size();
        }
    }

    //    @Override
    //    public boolean onTrackballEvent(MotionEvent event, MapView view) {
    //        final int action = event.getAction();
    //        OverlayItem next = null;
    //        //Log.d(TAG, "MotionEvent: " + event);
    //
    //        if (action == MotionEvent.ACTION_MOVE) {
    //            final float xDiff = event.getX();
    //            final float yDiff = event.getY();
    //            // Up
    //            if (yDiff <= -1) {
    //                next = findNext(getFocus(), true, true);
    //            }
    //            // Down
    //            else if (yDiff >= 1) {
    //                next = findNext(getFocus(), true, false);
    //            }
    //            // Right
    //            else if (xDiff >= 1) {
    //                next = findNext(getFocus(), false, true);
    //            }
    //            // Left
    //            else if (xDiff <= -1) {
    //                next = findNext(getFocus(), false, false);
    //            }
    //            if (next != null) {
    //                setFocus(next);
    //                view.postInvalidate();
    //            }
    //        } else if (action == MotionEvent.ACTION_UP) {
    //            final OverlayItem focus = getFocus();
    //            if (focus != null) {
    //                ArrivalsListActivity.start(mActivity, ((StopOverlayItem) focus).getStop());
    //            }
    //        }
    //        return true;
    //    }

    //    @Override
    //    public boolean onKeyDown(int keyCode, KeyEvent event, MapView view) {
    //        //Log.d(TAG, "KeyEvent: " + event);
    //        OverlayItem next = null;
    //        switch (keyCode) {
    //            case KeyEvent.KEYCODE_DPAD_UP:
    //                next = findNext(getFocus(), true, true);
    //                break;
    //            case KeyEvent.KEYCODE_DPAD_DOWN:
    //                next = findNext(getFocus(), true, false);
    //                break;
    //            case KeyEvent.KEYCODE_DPAD_RIGHT:
    //                next = findNext(getFocus(), false, true);
    //                break;
    //            case KeyEvent.KEYCODE_DPAD_LEFT:
    //                next = findNext(getFocus(), false, false);
    //                break;
    //            case KeyEvent.KEYCODE_DPAD_CENTER:
    //                final OverlayItem focus = getFocus();
    //                if (focus != null) {
    //                    ArrivalsListActivity.start(mActivity, ((StopOverlayItem) focus).getStop());
    //                }
    //                break;
    //            default:
    //                return false;
    //        }
    //        if (next != null) {
    //            setFocus(next);
    //            view.postInvalidate();
    //        }
    //        return true;
    //    }

    //    boolean setFocusById(String id) {
    //        final int size = size();
    //        for (int i = 0; i < size; ++i) {
    //            StopOverlayItem item = (StopOverlayItem) getItem(i);
    //            if (id.equals(item.getStop().getId())) {
    //                setFocus(item);
    //                return true;
    //            }
    //        }
    //        return false;
    //    }
    //
    //    String getFocusedId() {
    //        final OverlayItem focus = getFocus();
    //        if (focus != null) {
    //            return ((StopOverlayItem) focus).getStop().getId();
    //        }
    //        return null;
    //    }

    //    @Override
    //    protected boolean onTap(int index) {
    //        final OverlayItem item = getItem(index);
    //        if (item.equals(getFocus())) {
    //            ObaStop stop = mStops.get(index);
    //            ArrivalsListActivity.start(mActivity, stop);
    //        } else {
    //            setFocus(item);
    //            // fix odd behavior where previously selected item is not re-highlighted
    //            setLastFocusedIndex(-1);
    //        }
    //        return true;
    //    }

    // The find next routines find the closest item along the specified axis.

    //    OverlayItem findNext(OverlayItem initial, boolean lat, boolean positive) {
    //        if (initial == null) {
    //            return null;
    //        }
    //        final int size = size();
    //        final GeoPoint initialPoint = initial.getPoint();
    //        OverlayItem min = initial;
    //        int minDist = Integer.MAX_VALUE;
    //
    //        for (int i = 0; i < size; ++i) {
    //            OverlayItem item = getItem(i);
    //            GeoPoint point = item.getPoint();
    //            final int distX = point.getLongitudeE6() - initialPoint.getLongitudeE6();
    //            final int distY = point.getLatitudeE6() - initialPoint.getLatitudeE6();
    //
    //            // We have to eliminate anything that's going in the wrong direction,
    //            // or doesn't change in the correct axis (including the initial point)
    //            if (lat) {
    //                if (positive) {
    //                    // Distance must be positive.
    //                    if (distY <= 0) {
    //                        continue;
    //                    }
    //                }
    //                // Distance must to be negative.
    //                else if (distY >= 0) {
    //                    continue;
    //                }
    //            } else {
    //                if (positive) {
    //                    // Distance must be positive
    //                    if (distX <= 0) {
    //                        continue;
    //                    }
    //                }
    //                // Distance must be negative
    //                else if (distX >= 0) {
    //                    continue;
    //                }
    //            }
    //
    //            final int distSq = distX * distX + distY * distY;
    //
    //            if (distSq < minDist) {
    //                min = item;
    //                minDist = distSq;
    //            }
    //        }
    //        return min;
    //    }
}