package org.osmdroid.samplefragments.data; import android.graphics.Color; import android.util.DisplayMetrics; import android.util.Log; import org.osmdroid.api.IGeoPoint; import org.osmdroid.events.MapListener; import org.osmdroid.events.ScrollEvent; import org.osmdroid.events.ZoomEvent; import org.osmdroid.samplefragments.BaseSampleFragment; import org.osmdroid.util.BoundingBox; import org.osmdroid.util.GeoPoint; import org.osmdroid.views.overlay.FolderOverlay; import org.osmdroid.views.overlay.Overlay; import org.osmdroid.views.overlay.Polygon; import java.util.ArrayList; import java.util.HashMap; import java.util.Iterator; import java.util.List; import java.util.Map; /** * * EXPERIMENTAL!! * * <a href="https://github.com/osmdroid/osmdroid/issues/499">https://github.com/osmdroid/osmdroid/issues/499</a> * <p> * Demonstrates a way to generate heatmaps using osmdroid and a collection of data points. * There's a lot of room for improvement but this class demonstrates two things * <ul> * <li>How to load data asynchronously when the map moves/zooms</li> * <li>How to generate a basic heat map</li> * </ul> * <p> * There's probably a many options to implement this. This example basically chops up the screen * into cells, generates some random data, then iterates all of the data and increments up a * counter based on the cell that it was rendered into. Finally the cells are converted into square * polygons with a fill color based on the counter. * <p> * It's assumed that all required data is available on device for this example. * <p> * For future readers: other approaches * <ul> * <li>if a server/network connection is available, it would be better to have the server * generate a kml/kmz for the heat map, then use osmbonuspack to do the parsing. this will be much * better at handling higher volumes of data</li> * <li>use a server that generates slippy map tiles representing the overlay, then add a secondary {@link org.osmdroid.views.overlay.TilesOverlay} with that source.</li> * <li>locally (on device) generate an image for the slippy map tiles representing the data, then add a secondary {@link org.osmdroid.views.overlay.TilesOverlay} with that source.</li> * <li>make a custom {@link Overlay} class that has some custom onDraw logical to paint the image.</li> * </ul> * All of these other (and better) approaches really need some kind of geospatial index mechanism, such * as <a href="https://github.com/davidmoten/rtree">this</a>, only modified with some kind of running * estimate algorithm. * <p> * created on 1/1/2017. * * @author Alex O'Ree * @since 5.6.3 */ public class HeatMap extends BaseSampleFragment implements MapListener, Runnable { @Override public String getSampleTitle() { return "Heatmap with Async loading"; } String TAG = "heatmap"; DisplayMetrics dm = null; // async loading stuff boolean renderJobActive = false; boolean running = true; long lastMovement = 0; boolean needsDataRefresh = true; // end async loading stuff /** * the size of the cell in density independent pixels * a higher value = smoother image but higher processing and rendering times */ int cellSizeInDp = 20; //colors and alpha settings String alpha = "#55"; String red = "FF0000"; String orange = "FFA500"; String yellow = "FFFF00"; //a pointer to the last render overlay, so that we can remove/replace it with the new one FolderOverlay heatmapOverlay = null; @Override public void addOverlays() { super.addOverlays(); dm = getResources().getDisplayMetrics(); mMapView.getController().setCenter(new GeoPoint(38.8977, -77.0365)); mMapView.getController().setZoom(14); mMapView.setMapListener(this); } @Override public void onPause() { super.onPause(); running = false; } @Override public void onResume() { super.onResume(); running = true; Thread t = new Thread(this); t.start(); } /** * this generates the heatmap off of the main thread, loads the data, makes the overlay, then * adds it to the map */ private void generateMap() { if (getActivity()==null) //java.lang.IllegalStateException: Fragment HeatMap{44f341d0} not attached to Activity return; if (renderJobActive) return; renderJobActive = true; int densityDpi = (int) (dm.density * cellSizeInDp); //10 dpi sized cells IGeoPoint iGeoPoint = mMapView.getProjection().fromPixels(0, 0); IGeoPoint iGeoPoint2 = mMapView.getProjection().fromPixels(densityDpi, densityDpi); //delta is the size of our cell in lat,lon //since this is zoom dependent, rerun the calculations on zoom changes double xCellSizeLongitude = Math.abs(iGeoPoint.getLongitude() - iGeoPoint2.getLongitude()); double yCellSizeLatitude = Math.abs(iGeoPoint.getLatitude() - iGeoPoint2.getLatitude()); BoundingBox view = mMapView.getBoundingBox(); //a set of a GeoPoints representing what we want a heat map of. List<IGeoPoint> pts = loadPoints(view); //the highest value in our collection of stuff int maxHeat = 0; //a temp container of all grid cells and their hit count (which turns into a color on render) //the lower the cell size the more cells and items in the map. Map<BoundingBox, Integer> heatmap = new HashMap<BoundingBox, Integer>(); //create the grid Log.i(TAG, "heatmap builder " + yCellSizeLatitude + " " + xCellSizeLongitude); Log.i(TAG, "heatmap builder " + view); //populate the cells for (double lat = view.getLatNorth(); lat >= view.getLatSouth(); lat = lat - yCellSizeLatitude) { for (double lon = view.getLonEast(); lon >= view.getLonWest(); lon = lon - xCellSizeLongitude) { //Log.i(TAG,"heatmap builder " + lat + "," + lon); heatmap.put(new BoundingBox(lat, lon, lat - yCellSizeLatitude, lon - xCellSizeLongitude), 0); } } Log.i(TAG, "generating the heatmap"); long now = System.currentTimeMillis(); //generate the map, put the items in each cell for (int i = 0; i < pts.size(); i++) { //get the box for this pt's coordinates int x = increment(pts.get(i), heatmap); if (x > maxHeat) maxHeat = x; } Log.i(TAG, "generating the heatmap, done " + (System.currentTimeMillis() - now)); //figure out the color scheme //if you need a more logirthmic scale, this is the place to do it. //cells with a 0 value are blank //cells 1 to 1/3 of the max value are yellow //cells from 1/3 to 2/3 are organge //cells 2/3 or higher are red int redthreshold = maxHeat * 2 / 3; //upper 1/3 int orangethreshold = maxHeat * 1 / 3; //middle 1/3 //render the map Log.i(TAG, "rendering"); now = System.currentTimeMillis(); //each bounding box if the hit count > 0 create a polygon with the bounding box coordinates with the right fill color final FolderOverlay group = new FolderOverlay(); Iterator<Map.Entry<BoundingBox, Integer>> iterator = heatmap.entrySet().iterator(); while (iterator.hasNext()) { Map.Entry<BoundingBox, Integer> next = iterator.next(); if (next.getValue() > 0) { group.add(createPolygon(next.getKey(), next.getValue(), redthreshold, orangethreshold)); } } Log.i(TAG, "render done , done " + (System.currentTimeMillis() - now)); if (getActivity()==null) //java.lang.IllegalStateException: Fragment HeatMap{44f341d0} not attached to Activity return; if (mMapView==null) //java.lang.IllegalStateException: Fragment HeatMap{44f341d0} not attached to Activity return; mMapView.post(new Runnable() { @Override public void run() { if (heatmapOverlay != null) mMapView.getOverlayManager().remove(heatmapOverlay); mMapView.getOverlayManager().add(group); heatmapOverlay = group; mMapView.invalidate(); renderJobActive = false; } }); } /** * generates a bunch of random data * * @param view * @return */ private List<IGeoPoint> loadPoints(BoundingBox view) { List<IGeoPoint> pts = new ArrayList<IGeoPoint>(); for (int i = 0; i < 10000; i++) { pts.add(new GeoPoint((Math.random() * view.getLatitudeSpan()) + view.getLatSouth(), (Math.random() * view.getLongitudeSpan()) + view.getLonWest())); } pts.add(new GeoPoint(0d, 0d)); pts.add(new GeoPoint(0d, 0d)); pts.add(new GeoPoint(0d, 0d)); pts.add(new GeoPoint(0d, 0d)); pts.add(new GeoPoint(0d, 0d)); pts.add(new GeoPoint(0d, 0d)); pts.add(new GeoPoint(0d, 0d)); pts.add(new GeoPoint(0d, 0d)); pts.add(new GeoPoint(0d, 0d)); pts.add(new GeoPoint(0d, 0d)); pts.add(new GeoPoint(0d, 0d)); pts.add(new GeoPoint(0d, 0d)); pts.add(new GeoPoint(0d, 0d)); pts.add(new GeoPoint(0d, 0d)); pts.add(new GeoPoint(1.1d * cellSizeInDp, 1.1d * cellSizeInDp)); pts.add(new GeoPoint(1.1d * cellSizeInDp, 1.1d * cellSizeInDp)); pts.add(new GeoPoint(1.1d * cellSizeInDp, 1.1d * cellSizeInDp)); pts.add(new GeoPoint(1.1d * cellSizeInDp, 1.1d * cellSizeInDp)); pts.add(new GeoPoint(1.1d * cellSizeInDp, 1.1d * cellSizeInDp)); pts.add(new GeoPoint(1.1d * cellSizeInDp, 1.1d * cellSizeInDp)); pts.add(new GeoPoint(1.1d * cellSizeInDp, 1.1d * cellSizeInDp)); pts.add(new GeoPoint(-1.1d * cellSizeInDp, -1.1d * cellSizeInDp)); pts.add(new GeoPoint(-1.1d * cellSizeInDp, -1.1d * cellSizeInDp)); pts.add(new GeoPoint(-1.1d * cellSizeInDp, -1.1d * cellSizeInDp)); pts.add(new GeoPoint(-1.1d * cellSizeInDp, -1.1d * cellSizeInDp)); pts.add(new GeoPoint(-1.1d * cellSizeInDp, -1.1d * cellSizeInDp)); pts.add(new GeoPoint(-1.1d * cellSizeInDp, -1.1d * cellSizeInDp)); pts.add(new GeoPoint(-1.1d * cellSizeInDp, -1.1d * cellSizeInDp)); pts.add(new GeoPoint(-1.1d * cellSizeInDp, 1.1d * cellSizeInDp)); pts.add(new GeoPoint(-1.1d * cellSizeInDp, 1.1d * cellSizeInDp)); pts.add(new GeoPoint(-1.1d * cellSizeInDp, 1.1d * cellSizeInDp)); pts.add(new GeoPoint(-1.1d * cellSizeInDp, 1.1d * cellSizeInDp)); pts.add(new GeoPoint(1.1d * cellSizeInDp, -1.1d * cellSizeInDp)); pts.add(new GeoPoint(1.1d * cellSizeInDp, -1.1d * cellSizeInDp)); pts.add(new GeoPoint(1.1d * cellSizeInDp, -1.1d * cellSizeInDp)); return pts; } /** * converts the bounding box into a color filled polygon * * @param key * @param value * @param redthreshold * @param orangethreshold * @return */ private Overlay createPolygon(BoundingBox key, Integer value, int redthreshold, int orangethreshold) { Polygon polygon = new Polygon(); if (value < orangethreshold) polygon.setFillColor(Color.parseColor(alpha + yellow)); else if (value < redthreshold) polygon.setFillColor(Color.parseColor(alpha + orange)); else if (value >= redthreshold) polygon.setFillColor(Color.parseColor(alpha + red)); else { //no polygon } polygon.setStrokeColor(polygon.getFillColor()); //if you set this to something like 20f and have a low alpha setting, // you'll end with a gaussian blur like effect polygon.setStrokeWidth(0f); List<GeoPoint> pts = new ArrayList<GeoPoint>(); pts.add(new GeoPoint(key.getLatNorth(), key.getLonWest())); pts.add(new GeoPoint(key.getLatNorth(), key.getLonEast())); pts.add(new GeoPoint(key.getLatSouth(), key.getLonEast())); pts.add(new GeoPoint(key.getLatSouth(), key.getLonWest())); polygon.setPoints(pts); return polygon; } /** * For each data point, find the corresponding cell, then increment the count. This is the * most inefficient portion of this example. * <p> * room for improvement: replace with some kind of geospatial indexing mechanism * * @param iGeoPoint * @param heatmap * @return */ private int increment(IGeoPoint iGeoPoint, Map<BoundingBox, Integer> heatmap) { Iterator<Map.Entry<BoundingBox, Integer>> iterator = heatmap.entrySet().iterator(); while (iterator.hasNext()) { Map.Entry<BoundingBox, Integer> next = iterator.next(); if (next.getKey().contains(iGeoPoint)) { int newval = next.getValue() + 1; heatmap.put(next.getKey(), newval); return newval; } } return 0; } /** * handles the map movement rendering portions, prevents more than one render at a time, * waits for the user to stop moving the map before triggering the render */ @Override public boolean onScroll(ScrollEvent event) { lastMovement = System.currentTimeMillis(); needsDataRefresh = true; return false; } /** * handles the map movement rendering portions, prevents more than one render at a time, * waits for the user to stop moving the map before triggering the render */ @Override public boolean onZoom(ZoomEvent event) { lastMovement = System.currentTimeMillis(); needsDataRefresh = true; return false; } /** * handles the map movement rendering portions, prevents more than one render at a time, * waits for the user to stop moving the map before triggering the render */ @Override public void run() { try { Thread.sleep(1000); } catch (InterruptedException e) { } //TODO replace me with a timer task while (running) { try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } if (needsDataRefresh) { if (System.currentTimeMillis() - lastMovement > 500) { generateMap(); needsDataRefresh = false; } } } } /** * optional place to put automated test procedures, used during the connectCheck tests * this is called OFF of the UI thread. block this method call util the test is done */ @Override public void runTestProcedures() throws Exception{ Thread.sleep(5000); } }