Android — фильтруем пины на карте, в зависимости от расстояния друг от друга

в 10:57, , рубрики: Без рубрики

Довелось как то недавно делать проект с использованием google maps. В один из моментов, увидел я вот такую картину:

Android — фильтруем пины на карте, в зависимости от расстояния друг от друга

Android — фильтруем пины на карте, в зависимости от расстояния друг от друга


Это происходило, как вы догадались, когда рядом находилось слишком много мест, которые требовалось отображать. Ну ничего – проблема то вроде стандартная – много у кого должно было такое встречаться, наверняка разработчики предусмотрели группировку и фильтрацию… Ага, ни тут то было! Просмотрев документацию(сразу признаюсь что только бегло – возможно и упустил то что мне нужно) и спросив у гугла – к своему удивлению быстро ничего нужного найти не удалось. Ну ничего – не боги горшки обжигают – значит напишем сами.

Для начала создадим проект и подключим к нему google maps api. Тут я думаю объяснять ничего не надо – про это много уже было написано, вот кстати ссылки и на хабре – раз, два, три.

Cначала определимся что да как должно работать. Поехали…

Очевидно что нам надо придумать некий алгоритм, по которому будут выводиться на экран не все пины, а только находящиеся друг от друга на определенном расстоянии. А так же надо научиться объединять их в группы. Пересчет всего этого хозяйства надо чтобы происходил:

1) При первоначальной загрузке пинов на карту.
2) При изменении zoom

С первым пунктом в принципе все ясно – давайте определимся со вторым. Объявим интерфейс:

public interface IOnZoomListener {
	void onZoomChanged();
}

И модифицируем наш MapView вот таким образом:

public class MyMapView extends MapView {
	int oldZoomLevel = -1;
	IOnZoomListener onZoomListener;

	public MyMapView(Context context, AttributeSet attrs, int defStyle) {
		super(context, attrs, defStyle);
	}

	public MyMapView(Context context, String apiKey) {
		super(context, apiKey);
	}

	public MyMapView(Context context, AttributeSet attrs) {
		super(context, attrs);
	}

	public void setOnZoomListener(IOnZoomListener onZoomListener) {

		this.onZoomListener = onZoomListener;

	}

	@Override
	public void dispatchDraw(Canvas canvas) {
		super.dispatchDraw(canvas);
		int newZoom = this.getZoomLevel();

		if (newZoom != oldZoomLevel) {
			if (oldZoomLevel != -1 && onZoomListener != null) {
				onZoomListener.onZoomChanged();
			}

			oldZoomLevel = getZoomLevel();
		}
	}
}

Здесь мы просто добавили возможность регистрации нашего интерфейса и проверку при отрисовке на изменение ZoomLevel(если ZoomLevel поменялся – дергаем метод нашего интерфейса).

Теперь определимся с тем, как мы будем отображать пины на карте и как объединять их в группы. Для этого создадим class MyOverlayItem унаследованный от OverlayItem вот с такими дополнениями:

public class MyOverlayItem extends OverlayItem {

	private String name;

	private ArrayList<MyOverlayItem> list = new ArrayList<MyOverlayItem>();

	public MyOverlayItem(GeoPoint point, String name) {
		super(point, "", "");
		this.name = name;
	}

	public String getName() {
		if (list.size() > 0) {
			return "There are " + (list.size() + 1) + " places.";
		} else {
			return name;
		}

	}

	public void addList(MyOverlayItem item) {
		list.add(item);
	}

	public ArrayList<MyOverlayItem> getList() {
		return list;
	}

}

В ArrayList list будет храниться список сгруппированных пинов, а метод getName будет возвращать нам либо имя объекта, либо их количество в группе.

Теперь опишем то, ради чего, собственно, это все и затевалось – наш модифицированный ItemizedOverlay.
Суть алгоритма фильтрации довольно таки проста: мы просто бежим в цикле по всем существующим элементам и проверяем каждый элемент на предмет близкого расстояния с уже существующей группой элементов. Если мы нашли такую группу – элемент добавляется в нее, если нет – создается новая группа с данным элементом:

		boolean isImposition;

		for (MyOverlayItem itemFromAll : myOverlaysAll) {
			isImposition = false;
			for (MyOverlayItem item : myOverlays) {
				if (itemFromAll == item) {
					isImposition = true;
					break;

				}
				if (isImposition(itemFromAll, item)) {
					item.addList(itemFromAll);
					isImposition = true;
					break;
				}
			}

			if (!isImposition) {
				myOverlays.add(itemFromAll);
			}
		}

Сначала для проверки расстояния, я хотел использовать просто координаты пинов(погрешностью, возникающей в зависимости от широты, можно пренебречь, так как расстояния не велики), но тогда пришлось бы еще и контролировать ZoomLevel.
Для моих задач вполне подошел метод mapView.getLatitudeSpan который возвращает расстояние видимой ширины экрана в нужной нам системе координат. Осталось только поделить это расстояние на некий коэффициент(сколько максимально пинов должно «влезать» в экран по ширине) – это и будет минимальное расстояние между пинами:

private boolean isImposition(MyOverlayItem item1, MyOverlayItem item2) {

		int latspan = mapView.getLatitudeSpan();
		int delta = latspan / KOEFF;

		int dx = item1.getPoint().getLatitudeE6() - item2.getPoint().getLatitudeE6();
		int dy = item1.getPoint().getLongitudeE6() - item2.getPoint().getLongitudeE6();

		double dist = Math.sqrt(dx * dx + dy * dy);

		if (dist < delta) {
			return true;
		} else {
			return false;
		}

	}

Вот на всякий случай полный исходник класса:

public class PlaceOverlay extends ItemizedOverlay<MyOverlayItem> {

	private static final int KOEFF = 20;
	private ArrayList<MyOverlayItem> myOverlaysAll = new ArrayList<MyOverlayItem>();
	private ArrayList<MyOverlayItem> myOverlays = new ArrayList<MyOverlayItem>();
	private MapView mapView;

	public PlaceOverlay(Drawable defaultMarker, MapView mapView) {
		super(boundCenterBottom(defaultMarker));
		this.mapView = mapView;
		populate();
	}

	public void addOverlay(MyOverlayItem overlay) {
		myOverlaysAll.add(overlay);
		myOverlays.add(overlay);
		

	}

	public void doPopulate() {
		populate();
		setLastFocusedIndex(-1);

	}

	@Override
	protected MyOverlayItem createItem(int i) {
		return myOverlays.get(i);
	}

	@Override
	public int size() {
		return myOverlays.size();
	}

	private boolean isImposition(MyOverlayItem item1, MyOverlayItem item2) {

		int latspan = mapView.getLatitudeSpan();
		int delta = latspan / KOEFF;

		int dx = item1.getPoint().getLatitudeE6() - item2.getPoint().getLatitudeE6();
		int dy = item1.getPoint().getLongitudeE6() - item2.getPoint().getLongitudeE6();

		double dist = Math.sqrt(dx * dx + dy * dy);

		if (dist < delta) {
			return true;
		} else {
			return false;
		}

	}

	public void clear() {
		myOverlaysAll.clear();
		myOverlays.clear();
	}


	public void calculateItems() {
		
		myOverlaysClear();

		boolean isImposition;

		for (MyOverlayItem itemFromAll : myOverlaysAll) {
			isImposition = false;
			for (MyOverlayItem item : myOverlays) {
				if (itemFromAll == item) {
					isImposition = true;
					break;

				}
				if (isImposition(itemFromAll, item)) {
					item.addList(itemFromAll);
					isImposition = true;
					break;
				}
			}

			if (!isImposition) {
				myOverlays.add(itemFromAll);
			}
		}

		doPopulate();

	}

	private void myOverlaysClear() {
		for (MyOverlayItem item : myOverlaysAll) {
			item.getList().clear();

		}
		myOverlays.clear();

	}
	
	@Override
	protected boolean onTap(int index) {
		Toast.makeText(mapView.getContext(), myOverlays.get(index).getName(), Toast.LENGTH_SHORT).show();
		return true;
	}

}

Ах да – в методе onTap мы выводим Toast с именем группы – для наглядной демонстрации работы алгоритма.

Хочу добавить, что данный алгоритм не является истиной в последней инстанции – его можно и нужно улучшать – например рисовать пин не на месте первого элемента группы, а рассчитывать его местоположение в зависимости от ее наполнения. Но это вы уже реализуете сами в собственных проектах.

Теперь давайте разберемся как все это собрать воедино.
Создадим ManyPinsProjectActivity которое унаследуем от MapActivity и реализуем следующие интерфейсы: LocationListener, IOnZoomListener. Впрочем не буду расписывать все подробно – исходники все расскажут за меня:

public class ManyPinsProjectActivity extends MapActivity implements LocationListener, IOnZoomListener {

    private static final int DEFAULT_ZOOM    = 15;

    private MyMapView mapView         = null;
    private Drawable myCurrentMarker = null;
    private Drawable placeMarker     = null;

    private List<Overlay> mapOverlays;

    private PlaceOverlay placeOverlay;
    private MyCurrentLocationOverlay myCurrentLocationOverlay;

    double currentLatitude, currentLongitude;

    private MapController mapController;

    private LocationManager locationManager;

    
    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.main);

        locationManager = (LocationManager) this.getSystemService(Context.LOCATION_SERVICE);

        mapView = (MyMapView) findViewById(R.id.mapview);

        myCurrentMarker = this.getResources().getDrawable(R.drawable.my_pin_red);
        placeMarker = this.getResources().getDrawable(R.drawable.my_pin);
        
        myCurrentLocationOverlay = new MyCurrentLocationOverlay(myCurrentMarker, mapView);
        placeOverlay = new PlaceOverlay(placeMarker, mapView);
        mapOverlays = mapView.getOverlays();
        mapController = mapView.getController();
        
        mapView.setBuiltInZoomControls(true);
        mapView.setOnZoomListener(this);

    }


    private void animateToPlaceOnMap(final GeoPoint geopoint) {
        mapView.post(new Runnable() {

            @Override
            public void run() {
                mapView.invalidate();
                mapController.animateTo(geopoint);
                mapController.setZoom(DEFAULT_ZOOM);
            }
        });
    }

    private void setCurrentGeopoint(double myLatitude, double myLongitude) {
        
        currentLatitude = myLatitude;
        currentLongitude = myLongitude;

        final GeoPoint myCurrentGeoPoint = new GeoPoint((int) (myLatitude * 1E6), (int) (myLongitude * 1E6));

        MyOverlayItem myCurrentItem = new MyOverlayItem(myCurrentGeoPoint, "Current Location");
        myCurrentLocationOverlay.addOverlay(myCurrentItem);
        mapOverlays.add(myCurrentLocationOverlay);

        animateToPlaceOnMap(myCurrentGeoPoint);
    }


    @Override
    protected void onPause() {
        super.onPause();
        locationManager.removeUpdates(this);

    }


    @Override
    protected void onResume() {
        super.onResume();
        locationManager.requestLocationUpdates(LocationManager.NETWORK_PROVIDER, 5000, 100, this);

    }
    
    private ArrayList<PlaceInfo> generatePlaces(){
    	
    	Random random = new Random();
    	
    	int x, y;
    	
    	ArrayList<PlaceInfo> places = new ArrayList<PlaceInfo>();
        PlaceInfo p;
        
        for(int i = 0; i < 100; i++){
        	x = random.nextInt(2000);
        	y = random.nextInt(2000);
        	p = new PlaceInfo();
            p.setLatitude(currentLatitude + x/100000f);
            p.setLongitude(currentLongitude - y/100000f);
            p.setName("Place № " + i);
            places.add(p);
        }
        
        return places;
    }

    private void displayPlacesOnMap() {

        ArrayList<PlaceInfo> places = generatePlaces();
        
        mapOverlays.remove(placeOverlay);

        GeoPoint point = null;
        MyOverlayItem overlayitem = null;
        placeOverlay.clear();

        for (PlaceInfo place : places) {
            point = new GeoPoint((int) (place.getLatitude() * 1E6), (int) (place.getLongitude() * 1E6));
            overlayitem = new MyOverlayItem(point, place.getName());
            placeOverlay.addOverlay(overlayitem);
        }

        placeOverlay.calculateItems();

        placeOverlay.doPopulate();

        if (placeOverlay.size() > 0) {
            mapOverlays.add(placeOverlay);
            mapView.postInvalidate();
        }

    }


    @Override
    public void onLocationChanged(Location location) {

        locationManager.removeUpdates(this);

        double myLatitude = location.getLatitude();
        double myLongitude = location.getLongitude();

        setCurrentGeopoint(myLatitude, myLongitude);

        displayPlacesOnMap();
    }

    @Override
    public void onStatusChanged(String provider, int status, Bundle extras) {
    }

    @Override
    public void onProviderEnabled(String provider) {
    }

    @Override
    public void onProviderDisabled(String provider) {
    }

	@Override
	protected boolean isRouteDisplayed() {
		return false;
	}
	
	@Override
	public void onZoomChanged() {
		if (placeOverlay != null) {
			placeOverlay.calculateItems();
		}

	}

}

Тут стоит добавить что MyCurrentLocationOverlay – это обычный ItemizedOverlay с одним элементом, а PlaceInfo – обычный класс обертка содержащий в себе:

	private String name;
	private double latitude;
	private double longitude;

После всех этих манипуляций – вот как стала выглядеть наша карта с пинами:

Android — фильтруем пины на карте, в зависимости от расстояния друг от друга

Android — фильтруем пины на карте, в зависимости от расстояния друг от друга

Надеюсь статья окажется вам полезной.

Весь проект можно найти по ссылке.

Автор: igor_ab

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js