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() {