package org.osmdroid.views.overlay.simplefastpoint; import android.graphics.Canvas; import android.graphics.Point; import android.view.MotionEvent; import org.osmdroid.api.IGeoPoint; import org.osmdroid.util.BoundingBox; import org.osmdroid.views.MapView; import org.osmdroid.views.Projection; import org.osmdroid.views.overlay.Overlay; import java.util.Arrays; /** * Overlay to draw a layer of clickable simple points, optimized for rendering speed. Nice * performance up to 100k points. Does not support styling each point individually, the style * applies to all points. Does not support rotated maps. * There are three rendering algorithms: * NO_OPTIMIZATION: all points all drawn in every draw event * MEDIUM_OPTIMIZATION: not recommended for >10k points. Recalculates the grid index on each draw * event and only draws one point per grid cell. * MAXIMUM_OPTIMIZATION: for >10k points, only recalculates the grid on touch up, hence much faster. * * TODO: support for rotated maps! * TODO: a quadtree index would improve rendering speed! * Created by Miguel Porto on 25-10-2016. */ public class SimpleFastPointOverlay extends Overlay { private final SimpleFastPointOverlayOptions mStyle; private final PointAdapter mPointList; private final BoundingBox mBoundingBox; private Integer mSelectedPoint; private OnClickListener clickListener; // grid index for optimizing drawing k's of points private LabelledPoint grid[][]; private boolean gridBool[][]; private int gridWid, gridHei, viewWid, viewHei; private float startX, startY, curX, curY, offsetX, offsetY; private int prevNumPointers, numLabels; private BoundingBox prevBoundingBox = new BoundingBox(0, 0, 0, 0); public class LabelledPoint extends Point { private String mlabel; public LabelledPoint(Point point, String label) { super(point); this.mlabel = label; } } public interface PointAdapter extends Iterable<IGeoPoint> { int size(); IGeoPoint get(int i); /** * Whether this adapter has labels * @return */ boolean isLabelled(); } public interface OnClickListener { void onClick(PointAdapter points, Integer point); } public SimpleFastPointOverlay(PointAdapter pointList, SimpleFastPointOverlayOptions style) { mStyle = style; mPointList = pointList; Double east = null, west = null, north = null, south = null; for(IGeoPoint p : mPointList) { if(p == null) continue; if(east == null || p.getLongitude() > east) east = p.getLongitude(); if(west == null || p.getLongitude() < west) west = p.getLongitude(); if(north == null || p.getLatitude() > north) north = p.getLatitude(); if(south == null || p.getLatitude() < south) south = p.getLatitude(); } if(east != null) mBoundingBox = new BoundingBox(north, east, south, west); else mBoundingBox = null; } public SimpleFastPointOverlay(PointAdapter pointList) { this(pointList, SimpleFastPointOverlayOptions.getDefaultStyle()); } private void updateGrid(MapView mapView) { viewWid = mapView.getWidth(); viewHei = mapView.getHeight(); gridWid = (int) Math.floor((float) viewWid / mStyle.mCellSize) + 1; gridHei = (int) Math.floor((float) viewHei / mStyle.mCellSize) + 1; if(mStyle.mAlgorithm == SimpleFastPointOverlayOptions.RenderingAlgorithm.MAXIMUM_OPTIMIZATION) grid = new LabelledPoint[gridWid][gridHei]; else gridBool = new boolean[gridWid][gridHei]; // TODO the measures on first draw are not the final values. // MapView should propagate onLayout to overlays } /** * Re-calculates which points to be shown and their coordinates * TODO: this could be further optimized for speed, for example, the grid could be calculated * in geographic coordinates, instead of projected. N.B. the speed bottleneck is pj.toPixels() * @param pMapView */ private void computeGrid(final MapView pMapView) { // TODO: 15-11-2016 should take map orientation into account in the BBox! BoundingBox viewBBox = pMapView.getBoundingBox(); // do not compute grid if BBox is the same if(viewBBox.getLatNorth() != prevBoundingBox.getLatNorth() || viewBBox.getLatSouth() != prevBoundingBox.getLatSouth() || viewBBox.getLonWest() != prevBoundingBox.getLonWest() || viewBBox.getLonEast() != prevBoundingBox.getLonEast()) { prevBoundingBox = new BoundingBox(viewBBox.getLatNorth(), viewBBox.getLonEast() , viewBBox.getLatSouth(), viewBBox.getLonWest()); if (grid == null || viewHei != pMapView.getHeight() || viewWid != pMapView.getWidth()) { updateGrid(pMapView); } else { // empty grid. // TODO: we might leave the grid as it was before to avoid the "flickering"? for (Point[] row : grid) Arrays.fill(row, null); } int gridX, gridY; final Point mPositionPixels = new Point(); final Projection pj = pMapView.getProjection(); numLabels = 0; for (IGeoPoint pt1 : mPointList) { if (pt1 == null) continue; if (pt1.getLatitude() > viewBBox.getLatSouth() && pt1.getLatitude() < viewBBox.getLatNorth() && pt1.getLongitude() > viewBBox.getLonWest() && pt1.getLongitude() < viewBBox.getLonEast()) { pj.toPixels(pt1, mPositionPixels); // test whether in this grid cell there is already a point, skip if yes gridX = (int) Math.floor((float) mPositionPixels.x / mStyle.mCellSize); gridY = (int) Math.floor((float) mPositionPixels.y / mStyle.mCellSize); if (gridX >= gridWid || gridY >= gridHei || gridX < 0 || gridY < 0 || grid[gridX][gridY] != null) continue; grid[gridX][gridY] = new LabelledPoint(mPositionPixels , mPointList.isLabelled() ? ((LabelledGeoPoint) pt1).getLabel() : null); numLabels++; } } } } @Override public boolean onTouchEvent(MotionEvent event, MapView mapView) { if(mStyle.mAlgorithm != SimpleFastPointOverlayOptions.RenderingAlgorithm.MAXIMUM_OPTIMIZATION) return false; switch (event.getAction()) { case MotionEvent.ACTION_DOWN: prevNumPointers = event.getPointerCount(); startX = event.getX(0); startY = event.getY(0); for (int i = 1; i < prevNumPointers; i++) { startX += event.getX(i); startY += event.getY(i); } startX /= prevNumPointers; startY /= prevNumPointers; break; // TODO: this isn't quite well synchronized with MultitouchController in zoom... case MotionEvent.ACTION_MOVE: curX = event.getX(0); curY = event.getY(0); for (int i = 1; i < event.getPointerCount(); i++) { curX += event.getX(i); curY += event.getY(i); } curX /= event.getPointerCount(); curY /= event.getPointerCount(); if(event.getPointerCount() != prevNumPointers) { computeGrid(mapView); prevNumPointers = event.getPointerCount(); offsetX = curX - startX; offsetY = curY - startY; } break; case MotionEvent.ACTION_UP: startX = 0; startY = 0; curX = 0; curY = 0; offsetX = 0; offsetY = 0; mapView.invalidate(); break; } return false; } /** * Default action on tap is to select the nearest point. */ @Override public boolean onSingleTapConfirmed(final MotionEvent event, final MapView mapView) { if(!mStyle.mClickable) return false; float hyp; Float minHyp = null; int closest = -1; Point tmp = new Point(); Projection pj = mapView.getProjection(); for(int i = 0; i < mPointList.size(); i++) { if(mPointList.get(i) == null) continue; // TODO avoid projecting coordinates, do a test before calling next line pj.toPixels(mPointList.get(i), tmp); if(Math.abs(event.getX() - tmp.x) > 50 || Math.abs(event.getY() - tmp.y) > 50) continue; hyp = (event.getX() - tmp.x) * (event.getX() - tmp.x) + (event.getY() - tmp.y) * (event.getY() - tmp.y); if(minHyp == null || hyp < minHyp) { minHyp = hyp; closest = i; } } if(minHyp == null) return false; setSelectedPoint(closest); mapView.invalidate(); if(clickListener != null) clickListener.onClick(mPointList, closest); return true; } /** * Sets the highlighted point. App must invalidate the MapView. * @param toSelect The index of the point (zero-based) in the original list. */ public void setSelectedPoint(Integer toSelect) { if(toSelect == null || toSelect < 0 || toSelect >= mPointList.size()) mSelectedPoint = null; else mSelectedPoint = toSelect; } public Integer getSelectedPoint() { return mSelectedPoint; } public BoundingBox getBoundingBox() { return mBoundingBox; } public void setOnClickListener(OnClickListener listener) { clickListener = listener; } @Override public void draw(Canvas canvas, MapView mapView, boolean b) { final BoundingBox viewBBox; if (b) return; final Point mPositionPixels = new Point(); final Projection pj = mapView.getProjection(); String tmpLabel; boolean showLabels; if(mStyle.mPointStyle != null) { switch (mStyle.mAlgorithm) { case MAXIMUM_OPTIMIZATION: // optimized for speed, recommended for > 10k points // recompute grid only on specific events - only onDraw but when not animating // and not in the middle of a touch scroll gesture if (grid == null || (curX == 0 && curY == 0 && !mapView.isAnimating())) computeGrid(mapView); showLabels = ((mStyle.mLabelPolicy == SimpleFastPointOverlayOptions.LabelPolicy.DENSITY_THRESHOLD && numLabels <= mStyle.mMaxNShownLabels) || (mStyle.mLabelPolicy == SimpleFastPointOverlayOptions.LabelPolicy.ZOOM_THRESHOLD && mapView.getZoomLevel() >= mStyle.mMinZoomShowLabels)); // draw points float offX = curX - startX; float offY = curY - startY; for (int x = 0; x < gridWid; x++) { for (int y = 0; y < gridHei; y++) { if (grid[x][y] != null) { if(mStyle.mSymbol == SimpleFastPointOverlayOptions.Shape.CIRCLE) canvas.drawCircle(grid[x][y].x + offX - offsetX , grid[x][y].y + offY - offsetY , mStyle.mCircleRadius, mStyle.mPointStyle); else canvas.drawRect( grid[x][y].x + offX - offsetX - mStyle.mCircleRadius , grid[x][y].y + offY - offsetY - mStyle.mCircleRadius , grid[x][y].x + offX - offsetX + mStyle.mCircleRadius , grid[x][y].y + offY - offsetY + mStyle.mCircleRadius , mStyle.mPointStyle); if(mPointList.isLabelled() && showLabels && (tmpLabel = grid[x][y].mlabel) != null) canvas.drawText(tmpLabel , grid[x][y].x + offX - offsetX , grid[x][y].y + offY - offsetY - mStyle.mCircleRadius - 5 , mStyle.mTextStyle); } } } break; case MEDIUM_OPTIMIZATION: // recompute grid index on every draw if (grid == null || viewHei != mapView.getHeight() || viewWid != mapView.getWidth()) updateGrid(mapView); else for (boolean[] row : gridBool) Arrays.fill(row, false); showLabels = (mStyle.mLabelPolicy == SimpleFastPointOverlayOptions.LabelPolicy.ZOOM_THRESHOLD && mapView.getZoomLevel() >= mStyle.mMinZoomShowLabels); int gridX, gridY; viewBBox = mapView.getBoundingBox(); for (IGeoPoint pt1 : mPointList) { if (pt1 == null) continue; if (pt1.getLatitude() > viewBBox.getLatSouth() && pt1.getLatitude() < viewBBox.getLatNorth() && pt1.getLongitude() > viewBBox.getLonWest() && pt1.getLongitude() < viewBBox.getLonEast()) { pj.toPixels(pt1, mPositionPixels); // test whether in this grid cell there is already a point, skip if yes // this makes a lot of difference in rendering speed gridX = (int) Math.floor((float) mPositionPixels.x / mStyle.mCellSize); gridY = (int) Math.floor((float) mPositionPixels.y / mStyle.mCellSize); if (gridX >= gridWid || gridY >= gridHei || gridX < 0 || gridY < 0 || gridBool[gridX][gridY]) continue; gridBool[gridX][gridY] = true; if(mStyle.mSymbol == SimpleFastPointOverlayOptions.Shape.CIRCLE) canvas.drawCircle((float) mPositionPixels.x , (float) mPositionPixels.y , mStyle.mCircleRadius, mStyle.mPointStyle); else canvas.drawRect((float) mPositionPixels.x - mStyle.mCircleRadius , (float) mPositionPixels.y - mStyle.mCircleRadius , (float) mPositionPixels.x + mStyle.mCircleRadius , (float) mPositionPixels.y + mStyle.mCircleRadius , mStyle.mPointStyle); if(mPointList.isLabelled() && showLabels && (tmpLabel = ((LabelledGeoPoint) pt1).getLabel()) != null) canvas.drawText(tmpLabel , (float) mPositionPixels.x , (float) mPositionPixels.y - mStyle.mCircleRadius - 5 , mStyle.mTextStyle); } } break; case NO_OPTIMIZATION: // draw all points showLabels = (mStyle.mLabelPolicy == SimpleFastPointOverlayOptions.LabelPolicy.ZOOM_THRESHOLD && mapView.getZoomLevel() >= mStyle.mMinZoomShowLabels); viewBBox = mapView.getBoundingBox(); for (IGeoPoint pt1 : mPointList) { if (pt1 == null) continue; if (pt1.getLatitude() > viewBBox.getLatSouth() && pt1.getLatitude() < viewBBox.getLatNorth() && pt1.getLongitude() > viewBBox.getLonWest() && pt1.getLongitude() < viewBBox.getLonEast()) { pj.toPixels(pt1, mPositionPixels); if(mStyle.mSymbol == SimpleFastPointOverlayOptions.Shape.CIRCLE) canvas.drawCircle((float) mPositionPixels.x , (float) mPositionPixels.y , mStyle.mCircleRadius, mStyle.mPointStyle); else canvas.drawRect((float) mPositionPixels.x - mStyle.mCircleRadius , (float) mPositionPixels.y - mStyle.mCircleRadius , (float) mPositionPixels.x + mStyle.mCircleRadius , (float) mPositionPixels.y + mStyle.mCircleRadius , mStyle.mPointStyle); if(mPointList.isLabelled() && showLabels && (tmpLabel = ((LabelledGeoPoint) pt1).getLabel()) != null) canvas.drawText(tmpLabel , (float) mPositionPixels.x , (float) mPositionPixels.y - mStyle.mCircleRadius - 5 , mStyle.mTextStyle); } } break; } } if(mSelectedPoint != null && mSelectedPoint < mPointList.size() && mPointList.get(mSelectedPoint) != null && mStyle.mSelectedPointStyle != null) { pj.toPixels(mPointList.get(mSelectedPoint), mPositionPixels); if(mStyle.mSymbol == SimpleFastPointOverlayOptions.Shape.CIRCLE) canvas.drawCircle(mPositionPixels.x, mPositionPixels.y , mStyle.mSelectedCircleRadius, mStyle.mSelectedPointStyle); else canvas.drawRect((float) mPositionPixels.x - mStyle.mSelectedCircleRadius , (float) mPositionPixels.y - mStyle.mSelectedCircleRadius , (float) mPositionPixels.x + mStyle.mSelectedCircleRadius , (float) mPositionPixels.y + mStyle.mSelectedCircleRadius , mStyle.mSelectedPointStyle); } } }