/* * 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) ); // TODO - This doesn't look good since when bouncing, the focus marker is drawn behind // the bus stop marker. Maybe fix with new z-order property? // 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; // } }