diff --git a/app/src/main/java/com/geeksville/mesh/model/map/MarkerWithLabel.kt b/app/src/main/java/com/geeksville/mesh/model/map/MarkerWithLabel.kt index 01afc805a..bf779a489 100644 --- a/app/src/main/java/com/geeksville/mesh/model/map/MarkerWithLabel.kt +++ b/app/src/main/java/com/geeksville/mesh/model/map/MarkerWithLabel.kt @@ -93,8 +93,10 @@ class MarkerWithLabel(mapView: MapView?, label: String, emoji: String? = null) : val bgRect = getTextBackgroundSize(mLabel, (p.x - 0F), (p.y - LABEL_Y_OFFSET)) bgRect.inset(-8F, -2F) - c.drawRoundRect(bgRect, LABEL_CORNER_RADIUS, LABEL_CORNER_RADIUS, bgPaint) - c.drawText(mLabel, (p.x - 0F), (p.y - LABEL_Y_OFFSET), textPaint) + if(mLabel.isNotEmpty()) { + c.drawRoundRect(bgRect, LABEL_CORNER_RADIUS, LABEL_CORNER_RADIUS, bgPaint) + c.drawText(mLabel, (p.x - 0F), (p.y - LABEL_Y_OFFSET), textPaint) + } mEmoji?.let { c.drawText(it, (p.x - 0f), (p.y - 30f), emojiPaint) } getPrecisionMeters()?.let { radius -> diff --git a/app/src/main/java/com/geeksville/mesh/model/map/clustering/MarkerClusterer.java b/app/src/main/java/com/geeksville/mesh/model/map/clustering/MarkerClusterer.java new file mode 100644 index 000000000..05d07d5cf --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/model/map/clustering/MarkerClusterer.java @@ -0,0 +1,201 @@ +package com.geeksville.mesh.model.map.clustering; + +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Point; +import android.view.MotionEvent; + +import org.osmdroid.api.IGeoPoint; +import org.osmdroid.bonuspack.kml.KmlFeature; +import org.osmdroid.util.BoundingBox; +import org.osmdroid.util.GeoPoint; +import org.osmdroid.views.MapView; +import org.osmdroid.views.overlay.Overlay; +import com.geeksville.mesh.model.map.MarkerWithLabel; + +import java.util.ArrayList; +import java.util.Iterator; +import java.util.ListIterator; + +/** + * An overlay allowing to perform markers clustering. + * Usage: put your markers inside with add(Marker), and add the MarkerClusterer to the map overlays. + * Depending on the zoom level, markers will be displayed separately, or grouped as a single Marker.
+ * + * This abstract class provides the framework. Sub-classes have to implement the clustering algorithm, + * and the rendering of a cluster. + * + * @author M.Kergall + * + */ +public abstract class MarkerClusterer extends Overlay { + + /** impossible value for zoom level, to force clustering */ + protected static final int FORCE_CLUSTERING = -1; + + protected ArrayList mItems = new ArrayList(); + protected Point mPoint = new Point(); + protected ArrayList mClusters = new ArrayList(); + protected int mLastZoomLevel; + protected Bitmap mClusterIcon; + protected String mName, mDescription; + + // abstract methods: + + /** clustering algorithm */ + public abstract ArrayList clusterer(MapView mapView); + /** Build the marker for a cluster. */ + public abstract MarkerWithLabel buildClusterMarker(StaticCluster cluster, MapView mapView); + /** build clusters markers to be used at next draw */ + public abstract void renderer(ArrayList clusters, Canvas canvas, MapView mapView); + + public MarkerClusterer() { + super(); + mLastZoomLevel = FORCE_CLUSTERING; + } + + public void setName(String name){ + mName = name; + } + + public String getName(){ + return mName; + } + + public void setDescription(String description){ + mDescription = description; + } + + public String getDescription(){ + return mDescription; + } + + /** Set the cluster icon to be drawn when a cluster contains more than 1 marker. + * If not set, default will be the default osmdroid marker icon (which is really inappropriate as a cluster icon). */ + public void setIcon(Bitmap icon){ + mClusterIcon = icon; + } + + /** Add the Marker. + * Important: Markers added in a MarkerClusterer should not be added in the map overlays. */ + public void add(MarkerWithLabel marker){ + mItems.add(marker); + } + + /** Force a rebuild of clusters at next draw, even without a zooming action. + * Should be done when you changed the content of a MarkerClusterer. */ + public void invalidate(){ + mLastZoomLevel = FORCE_CLUSTERING; + } + + /** @return the Marker at id (starting at 0) */ + public MarkerWithLabel getItem(int id){ + return mItems.get(id); + } + + /** @return the list of Markers. */ + public ArrayList getItems(){ + return mItems; + } + + protected void hideInfoWindows(){ + for (MarkerWithLabel m : mItems){ + if (m.isInfoWindowShown()) + m.closeInfoWindow(); + } + } + + @Override public void draw(Canvas canvas, MapView mapView, boolean shadow) { + if (shadow) + return; + //if zoom has changed and mapView is now stable, rebuild clusters: + int zoomLevel = mapView.getZoomLevel(); + if (zoomLevel != mLastZoomLevel && !mapView.isAnimating()){ + hideInfoWindows(); + mClusters = clusterer(mapView); + renderer(mClusters, canvas, mapView); + mLastZoomLevel = zoomLevel; + } + + for (StaticCluster cluster:mClusters){ + MarkerWithLabel marker = cluster.getMarker(); + marker.draw(canvas, mapView, false); + } + } + + public Iterable reversedClusters() { + return new Iterable() { + @Override + public Iterator iterator() { + final ListIterator i = mClusters.listIterator(mClusters.size()); + return new Iterator() { + @Override + public boolean hasNext() { + return i.hasPrevious(); + } + + @Override + public StaticCluster next() { + return i.previous(); + } + + @Override + public void remove() { + i.remove(); + } + }; + } + }; + } + + @Override public boolean onSingleTapConfirmed(final MotionEvent event, final MapView mapView){ + for (final StaticCluster cluster : reversedClusters()) { + if (cluster.getMarker().onSingleTapConfirmed(event, mapView)) + return true; + } + return false; + } + + @Override public boolean onLongPress(final MotionEvent event, final MapView mapView) { + for (final StaticCluster cluster : reversedClusters()) { + if (cluster.getMarker().onLongPress(event, mapView)) + return true; + } + return false; + } + + @Override public boolean onTouchEvent(final MotionEvent event, final MapView mapView) { + for (StaticCluster cluster : reversedClusters()) { + if (cluster.getMarker().onTouchEvent(event, mapView)) + return true; + } + return false; + } + + @Override public boolean onDoubleTap(final MotionEvent event, final MapView mapView) { + for (final StaticCluster cluster : reversedClusters()) { + if (cluster.getMarker().onDoubleTap(event, mapView)) + return true; + } + return false; + } + + @Override public BoundingBox getBounds(){ + if (mItems.size() == 0) + return null; + double minLat = Double.MAX_VALUE; + double minLon = Double.MAX_VALUE; + double maxLat = -Double.MAX_VALUE; + double maxLon = -Double.MAX_VALUE; + for (final MarkerWithLabel item : mItems) { + final double latitude = item.getPosition().getLatitude(); + final double longitude = item.getPosition().getLongitude(); + minLat = Math.min(minLat, latitude); + minLon = Math.min(minLon, longitude); + maxLat = Math.max(maxLat, latitude); + maxLon = Math.max(maxLon, longitude); + } + return new BoundingBox(maxLat, maxLon, minLat, minLon); + } + +} diff --git a/app/src/main/java/com/geeksville/mesh/model/map/clustering/RadiusMarkerClusterer.java b/app/src/main/java/com/geeksville/mesh/model/map/clustering/RadiusMarkerClusterer.java new file mode 100644 index 000000000..c9ae2a194 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/model/map/clustering/RadiusMarkerClusterer.java @@ -0,0 +1,195 @@ +package com.geeksville.mesh.model.map.clustering; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.Rect; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.Drawable; +import android.view.MotionEvent; + +import org.osmdroid.bonuspack.R; +import org.osmdroid.util.BoundingBox; +import org.osmdroid.util.GeoPoint; +import org.osmdroid.views.MapView; +import com.geeksville.mesh.model.map.MarkerWithLabel; + +import java.util.ArrayList; +import java.util.Iterator; + +/** + * Radius-based Clustering algorithm: + * create a cluster using the first point from the cloned list. + * All points that are found within the neighborhood are added to this cluster. + * Then all the neighbors and the main point are removed from the list of points. + * It continues until the list is empty. + * + * Largely inspired from GridMarkerClusterer by M.Kergall + * + * @author sidorovroman92@gmail.com + */ + +public class RadiusMarkerClusterer extends MarkerClusterer { + + protected int mMaxClusteringZoomLevel = 17; + protected int mRadiusInPixels = 100; + protected double mRadiusInMeters; + protected Paint mTextPaint; + private ArrayList mClonedMarkers; + protected boolean mAnimated; + int mDensityDpi; + + /** cluster icon anchor */ + public float mAnchorU = MarkerWithLabel.ANCHOR_CENTER, mAnchorV = MarkerWithLabel.ANCHOR_CENTER; + /** anchor point to draw the number of markers inside the cluster icon */ + public float mTextAnchorU = MarkerWithLabel.ANCHOR_CENTER, mTextAnchorV = MarkerWithLabel.ANCHOR_CENTER; + + public RadiusMarkerClusterer(Context ctx) { + super(); + mTextPaint = new Paint(); + mTextPaint.setColor(Color.WHITE); + mTextPaint.setTextSize(15 * ctx.getResources().getDisplayMetrics().density); + mTextPaint.setFakeBoldText(true); + mTextPaint.setTextAlign(Paint.Align.CENTER); + mTextPaint.setAntiAlias(true); + Drawable clusterIconD = ctx.getResources().getDrawable(R.drawable.marker_cluster); + Bitmap clusterIcon = ((BitmapDrawable) clusterIconD).getBitmap(); + setIcon(clusterIcon); + mAnimated = true; + mDensityDpi = ctx.getResources().getDisplayMetrics().densityDpi; + } + + /** If you want to change the default text paint (color, size, font) */ + public Paint getTextPaint(){ + return mTextPaint; + } + + /** Set the radius of clustering in pixels. Default is 100px. */ + public void setRadius(int radius){ + mRadiusInPixels = radius; + } + + /** Set max zoom level with clustering. When zoom is higher or equal to this level, clustering is disabled. + * You can put a high value to disable this feature. */ + public void setMaxClusteringZoomLevel(int zoom){ + mMaxClusteringZoomLevel = zoom; + } + + /** Radius-Based clustering algorithm */ + @Override public ArrayList clusterer(MapView mapView) { + + ArrayList clusters = new ArrayList(); + convertRadiusToMeters(mapView); + + mClonedMarkers = new ArrayList(mItems); //shallow copy + while (!mClonedMarkers.isEmpty()) { + MarkerWithLabel m = mClonedMarkers.get(0); + StaticCluster cluster = createCluster(m, mapView); + clusters.add(cluster); + } + return clusters; + } + + private StaticCluster createCluster(MarkerWithLabel m, MapView mapView) { + GeoPoint clusterPosition = m.getPosition(); + + StaticCluster cluster = new StaticCluster(clusterPosition); + cluster.add(m); + + mClonedMarkers.remove(m); + + if (mapView.getZoomLevel() > mMaxClusteringZoomLevel) { + //above max level => block clustering: + return cluster; + } + + Iterator it = mClonedMarkers.iterator(); + while (it.hasNext()) { + MarkerWithLabel neighbour = it.next(); + double distance = clusterPosition.distanceToAsDouble(neighbour.getPosition()); + if (distance <= mRadiusInMeters) { + cluster.add(neighbour); + it.remove(); + } + } + + return cluster; + } + + @Override public MarkerWithLabel buildClusterMarker(StaticCluster cluster, MapView mapView) { + MarkerWithLabel m = new MarkerWithLabel(mapView, "", null); + m.setPosition(cluster.getPosition()); + m.setInfoWindow(null); + m.setAnchor(mAnchorU, mAnchorV); + + Bitmap finalIcon = Bitmap.createBitmap(mClusterIcon.getScaledWidth(mDensityDpi), + mClusterIcon.getScaledHeight(mDensityDpi), mClusterIcon.getConfig()); + Canvas iconCanvas = new Canvas(finalIcon); + iconCanvas.drawBitmap(mClusterIcon, 0, 0, null); + String text = "" + cluster.getSize(); + int textHeight = (int) (mTextPaint.descent() + mTextPaint.ascent()); + iconCanvas.drawText(text, + mTextAnchorU * finalIcon.getWidth(), + mTextAnchorV * finalIcon.getHeight() - textHeight / 2, + mTextPaint); + m.setIcon(new BitmapDrawable(mapView.getContext().getResources(), finalIcon)); + + return m; + } + + @Override public void renderer(ArrayList clusters, Canvas canvas, MapView mapView) { + for (StaticCluster cluster : clusters) { + if (cluster.getSize() == 1) { + //cluster has only 1 marker => use it as it is: + cluster.setMarker(cluster.getItem(0)); + } else { + //only draw 1 Marker at Cluster center, displaying number of Markers contained + MarkerWithLabel m = buildClusterMarker(cluster, mapView); + cluster.setMarker(m); + } + } + } + + private void convertRadiusToMeters(MapView mapView) { + + Rect mScreenRect = mapView.getIntrinsicScreenRect(null); + + int screenWidth = mScreenRect.right - mScreenRect.left; + int screenHeight = mScreenRect.bottom - mScreenRect.top; + + BoundingBox bb = mapView.getBoundingBox(); + + double diagonalInMeters = bb.getDiagonalLengthInMeters(); + double diagonalInPixels = Math.sqrt(screenWidth * screenWidth + screenHeight * screenHeight); + double metersInPixel = diagonalInMeters / diagonalInPixels; + + mRadiusInMeters = mRadiusInPixels * metersInPixel; + } + + public void setAnimation(boolean animate){ + mAnimated = animate; + } + + public void zoomOnCluster(MapView mapView, StaticCluster cluster){ + BoundingBox bb = cluster.getBoundingBox(); + if (bb.getLatNorth()!=bb.getLatSouth() || bb.getLonEast()!=bb.getLonWest()) { + bb = bb.increaseByScale(1.15f); + mapView.zoomToBoundingBox(bb, true); + } else //all points exactly at the same place: + mapView.setExpectedCenter(bb.getCenterWithDateLine()); + } + + @Override public boolean onSingleTapConfirmed(final MotionEvent event, final MapView mapView){ + for (final StaticCluster cluster : reversedClusters()) { + if (cluster.getMarker().onSingleTapConfirmed(event, mapView)) { + if (mAnimated && cluster.getSize() > 1) + zoomOnCluster(mapView, cluster); + return true; + } + } + return false; + } + +} diff --git a/app/src/main/java/com/geeksville/mesh/model/map/clustering/StaticCluster.java b/app/src/main/java/com/geeksville/mesh/model/map/clustering/StaticCluster.java new file mode 100644 index 000000000..254020613 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/model/map/clustering/StaticCluster.java @@ -0,0 +1,67 @@ +package com.geeksville.mesh.model.map.clustering; + +import org.osmdroid.util.BoundingBox; +import org.osmdroid.util.GeoPoint; +import com.geeksville.mesh.model.map.MarkerWithLabel; + +import java.util.ArrayList; + +/** + * Cluster of Markers. + * @author M.Kergall + */ +public class StaticCluster { + protected final ArrayList mItems = new ArrayList(); + protected GeoPoint mCenter; + protected MarkerWithLabel mMarker; + + public StaticCluster(GeoPoint center) { + mCenter = center; + } + + public void setPosition(GeoPoint center){ + mCenter = center; + } + + public GeoPoint getPosition() { + return mCenter; + } + + public int getSize() { + return mItems.size(); + } + + public MarkerWithLabel getItem(int index) { + return mItems.get(index); + } + + public boolean add(MarkerWithLabel t) { + return mItems.add(t); + } + + /** set the Marker to be displayed for this cluster */ + public void setMarker(MarkerWithLabel marker){ + mMarker = marker; + } + + /** @return the Marker to be displayed for this cluster */ + public MarkerWithLabel getMarker(){ + return mMarker; + } + + public BoundingBox getBoundingBox(){ + if (getSize()==0) + return null; + GeoPoint p = getItem(0).getPosition(); + BoundingBox bb = new BoundingBox(p.getLatitude(), p.getLongitude(), p.getLatitude(), p.getLongitude()); + for (int i=1; i, - waypointMarkers: List + waypointMarkers: List, + nodeClusterer: RadiusMarkerClusterer ) { debug("Showing on map: ${nodeMarkers.size} nodes ${waypointMarkers.size} waypoints") overlays.removeAll { it is MarkerWithLabel } - overlays.addAll(nodeMarkers + waypointMarkers) + // overlays.addAll(nodeMarkers + waypointMarkers) + overlays.addAll(waypointMarkers) + nodeClusterer.getItems().clear() + nodeMarkers.forEach { + nodeClusterer.add(it) + } } /** @@ -283,6 +290,8 @@ fun MapView( val map = rememberMapViewWithLifecycle(context) val state by model.mapState.collectAsStateWithLifecycle() + val nodeClusterer = RadiusMarkerClusterer(context) + fun MapView.toggleMyLocation() { if (context.gpsDisabled()) { debug("Telling user we need location turned on for MyLocationNewOverlay") @@ -479,6 +488,8 @@ fun MapView( if (myLocationOverlay != null && overlays.none { it is MyLocationNewOverlay }) { overlays.add(myLocationOverlay) } + map.overlays.add(nodeClusterer) + addCopyright() // Copyright is required for certain map sources createLatLongGrid(false) @@ -486,7 +497,7 @@ fun MapView( } with(map) { - UpdateMarkers(onNodesChanged(nodes), onWaypointChanged(waypoints.values)) + UpdateMarkers(onNodesChanged(nodes), onWaypointChanged(waypoints.values), nodeClusterer) } fun MapView.zoomToNodes() {