- PVSM.RU - https://www.pvsm.ru -

Весь мир в кармане или как сделать мобильную карту за пару дней

Весь мир в кармане или как сделать мобильную карту за пару дней - 1

В прошлой статье [1] я рассказывал о том, как можно быстро сделать Web-звонилку. А что если поставить более амбициозную задачу — собрать своё собственное приложение с картой, без рекламы и с блэк-джеком? А если всего за пару дней?

Давайте сделаем это! Прошу под кат.

Для начала разберёмся, что нам предстоит сделать. На выходе мы хотим получить приложение со справочными данными и картой. И чтоб работало офлайн. Как разработчика меня в первую очередь интересует как раз карта, ведь показывать справочные данные мы и так умеем. А офлайн — довольно сильное ограничение в этом случае, ведь хороших библиотек с поддержкой офлайна не так много. Поэтому в статье сконцентрируемся на карте, а про справочник поговорим вскользь.

Выбираем движок карты

Первое, что нужно сделать — добыть данные для приложения. На рынке много источников, бесплатных и не очень. Для старта нам вполне подойдёт OpenStreetMap [2] как открытый источник картографических данных. Там же можно взять и какое-то количество POI [3] для нашего справочника.

Следующий шаг — выбираем картодвижок. На просторах интернета их довольно мало, бесплатных ещё меньше, а с поддержкой офлайна вообще единицы. Предлагаю воспользоваться довольно крутым вариантом — mapsforge/vtm [4]. Это векторный OpenGL движок, очень шустрый, поддерживает офлайн, Android, iOS, различные источники данных, кастомную стилизацию, оверлеи, маркеры, 3D и даже 3D-модели объектов! Очень, очень круто.

В репозитории достаточно много примеров для быстрого старта, есть готовые карты, есть плагин, позволяющий собрать собственную карту из данных в OSM формате. Итак, приступаем!

MapView mapView = findViewById(R.id.map_view);
this.map = mapView.map();

File baseMapFile = getMapFile("cyprus.map");
MapFileTileSource tileSource = new MapFileTileSource();
tileSource.setMapFile(baseMapFile.getAbsolutePath());

VectorTileLayer layer = this.map.setBaseMap(tileSource);

MapInfo info = tileSource.getMapInfo();
if (info != null) {
    MapPosition pos = new MapPosition();
    pos.setByBoundingBox(info.boundingBox, Tile.SIZE * 4, Tile.SIZE * 4);
    this.map.setMapPosition(pos);
}

this.map.setTheme(VtmThemes.DEFAULT);

this.map.layers().add(new BuildingLayer(this.map, layer));
this.map.layers().add(new LabelLayer(this.map, layer));

Создаём источник данных MapFileTileSource, указываем местонахождение файла карты. Дополнительно позиционируемся в центр интересующего нас баундинг-бокса, чтоб не оказаться где-то за пределами выбранной локации при старте приложения. Устанавливаем дефолтную тему. Добавляем слой домов и слой подписей. На этом всё. Запускаем — чудеса!

Весь мир в кармане или как сделать мобильную карту за пару дней - 2

Кажется, быстрее и проще и быть не может.

Делаем геокодинг

Следующий важный шаг — реализация геокодинга. Сама по себе карта — это уже неплохо, но нужна интерактивность. Мы хотим тапать в карту и видеть информацию по объекту, в который попали. И здесь есть некоторая сложность. По большому счёту, полноценный геокодинг в нашей библиотеке отсутствует. Это, пожалуй, самый большой её минус. Если ничего не изобретать, то мы можем воспользоваться имеющейся функциональностью.

// Определяем координаты клика и находим тайлы в его зоне
float touchRadius = TOUCH_RADIUS * CanvasAdapter.getScale();
long mapSize = MercatorProjection.getMapSize((byte) mMap.getMapPosition().getZoomLevel());
double pixelX = MercatorProjection.longitudeToPixelX(p.getLongitude(), mapSize);
double pixelY = MercatorProjection.latitudeToPixelY(p.getLatitude(), mapSize);
int tileXMin = MercatorProjection.pixelXToTileX(pixelX - touchRadius, (byte) mMap.getMapPosition().getZoomLevel());
int tileXMax = MercatorProjection.pixelXToTileX(pixelX + touchRadius, (byte) mMap.getMapPosition().getZoomLevel());
int tileYMin = MercatorProjection.pixelYToTileY(pixelY - touchRadius, (byte) mMap.getMapPosition().getZoomLevel());
int tileYMax = MercatorProjection.pixelYToTileY(pixelY + touchRadius, (byte) mMap.getMapPosition().getZoomLevel());
Tile upperLeft = new Tile(tileXMin, tileYMin, (byte) mMap.getMapPosition().getZoomLevel());
Tile lowerRight = new Tile(tileXMax, tileYMax, (byte) mMap.getMapPosition().getZoomLevel());

//Получаем данные из базы, указав левый верхний и правый нижний тайлы
MapDatabase mapDatabase = ((MapDatabase) ((OverzoomTileDataSource) tileSource.getDataSource()).getDataSource());
MapReadResult mapReadResult = mapDatabase.readLabels(upperLeft, lowerRight);

StringBuilder sb = new StringBuilder();

// Фильтруем полученные POI с учётом области клика
sb.append("*** POI ***");
for (PointOfInterest pointOfInterest : mapReadResult.pointOfInterests) {
    Point layerXY = new Point();
    mMap.viewport().toScreenPoint(pointOfInterest.position, false, layerXY);
    Point tapXY = new Point(e.getX(), e.getY());
    if (layerXY.distance(tapXY) > touchRadius) {
        continue;
    }
    sb.append("n");
    List<Tag> tags = pointOfInterest.tags;
    for (Tag tag : tags) {
        sb.append("n").append(tag.key).append("=").append(tag.value);
    }
}

// Фильтруем геометрии, попавшие в область клика
sb.append("nn").append("*** WAYS ***");
for (Way way : mapReadResult.ways) {
    if (way.geometryType != GeometryBuffer.GeometryType.POLY
            || !GeoPointUtils.contains(way.geoPoints[0], p)) {
        continue;
    }
    sb.append("n");
    List<Tag> tags = way.tags;
    for (Tag tag : tags) {
        sb.append("n").append(tag.key).append("=").append(tag.value);
    }
}

Получилось относительно многословно. Нужно найти тайл, получить ways (в терминологии OSM way — это линейный объект), и можно из них извлечь какую-то атрибутику. Помимо ways есть возможность получить ещё и POI, но на этом всё. Остальную логику придется накручивать самостоятельно: выбирать «правильный» из всего множества объектов, в которые попал клик, фильтровать по зум-левелам. И ещё один момент. Фактически, мы теряем информацию об исходной геометрии и получаем в ответ на поиск просто набор линий. Если захочется сделать ещё и гео-редактор, то этого явно будет недостаточно.

Но для демонстрации подхода нас всё устраивает.

Весь мир в кармане или как сделать мобильную карту за пару дней - 3

«Продвинутый» геокодинг

Вообще говоря, есть более продвинутый вариант. Для этого нам понадобится своя база. В частности, можно воспользоваться SQLite. Правда, нам недостаточно будет стандартного SQLite, и придётся собирать свой, подключив к нему плагин RTree для геопоиска. Как это сделать, я уже рассказывал в статье [5], раздел «Делаем хороший поиск».
В этом случае мы получаем полный контроль над данными, можем сохранять всё, что требуется, и в нужном формате. Еще и Full Text Search сможем прикрутить и искать наши геообъекты и фирмы по названию, адресу и другим атрибутам.

Направление такое:

  1. Делаем таблицы:
    • геообъектов (id, type, geometry, attributes)
    • фирм (id, attributes, geo_id) со ссылкой на геометрию здания, в котором она находится
    • геоиндекса на rtree [6] вот так:
      CREATE VIRTUAL TABLE geo_index USING rtree(
      id,              -- Integer primary key
      minX, maxX,      -- Minimum and maximum X coordinate
      minY, maxY       -- Minimum and maximum Y coordinate
      );
  2. Наполняем всё данными.
  3. При тапе в карту получаем GeoPoint и выполняем запрос:
    SELECT id FROM geo_index
    WHERE minX>=-81.08 AND maxX<=-80.58 
    AND minY>=35.00  AND  maxY<=35.44
  4. Последний шаг: фильтруем и выбираем подходящий объект.

Один из вариантов реализации можно посмотреть в репозитории [7].

В итоге мы уже умеем показывать карту и обрабатывать нажатия. Неплохо.

Добавляем важные мелочи

Давайте добавим пару важных функций.

Начнём с текущей геопозиции. В mapsforge/vtm для этого как раз имеется спец. слой LocationLayer. Использование крайне простое.

LocationLayer locationLayer = new LocationLayer(this.map);
locationLayer.setEnabled(true);

// Позицию выставляем в центр карты для простоты, вообще, её надо получить с GPS
GeoPoint initialGeoPoint = this.map.getMapPosition().getGeoPoint();
locationLayer.setPosition(initialGeoPoint.getLatitude(), initialGeoPoint.getLongitude(), 1);
this.map.layers().add(locationLayer);

Есть только один недостаток — это постоянная пульсация «синей точки» на границе экрана, когда текущая локация находится за пределами карты. Скорее всего, в процессе использования вы редко будете оказываться в такой ситуации, но это вызывает постоянный перерендеринг, соответственно, немного нагружает процессор. Избавиться от этого немного сложнее, нужно залезть в шейдер и поправить его. Но это уже совсем для перфекционистов. Как сделать — можно посмотреть тут [8].

Так, позиция есть. Пора добавить кнопку перемещения к текущей позиции, как во всех уважающих себя картографических приложениях.

View vLocation = findViewById(R.id.am_location);
vLocation.setOnClickListener(v ->
                this.map.animator().animateTo(initialGeoPoint));

Ещё нам понадобятся кнопки зума.

View vZoomIn = findViewById(R.id.am_zoom_in);
vZoomIn.setOnClickListener(v ->
        this.map.animator().animateZoom(500, 2, 0, 0));

View vZoomOut = findViewById(R.id.am_zoom_out);
vZoomOut.setOnClickListener(v ->
        this.map.animator().animateZoom(500, 0.5, 0, 0));

И вишенка на торте — компас.

View vCompass = findViewById(R.id.am_compass);
vCompass.setVisibility(View.GONE);
vCompass.setOnClickListener(v -> {

    MapPosition mapPosition = this.map.getMapPosition();
    mapPosition.setBearing(0);
    this.map.animator().animateTo(500, mapPosition);

    vCompass.animate().setListener(new Animator.AnimatorListener() {
        @Override
        public void onAnimationStart(Animator animation) {
        }

        @Override
        public void onAnimationEnd(Animator animation) {
            vCompass.setVisibility(View.GONE);
        }

        @Override
        public void onAnimationCancel(Animator animation) {
        }

        @Override
        public void onAnimationRepeat(Animator animation) {
        }
    }).setDuration(500).rotation(0).start();
});

this.map.events.bind((e, mapPosition) -> {
    if (e == Map.ROTATE_EVENT) {
        vCompass.setRotation(mapPosition.getBearing());
        vCompass.setVisibility(View.VISIBLE);
    }
});

Весь мир в кармане или как сделать мобильную карту за пару дней - 4

Захватываем мир

Друзья, мы на финишной прямой. Осталось добавить последний штрих. Мы ведь планируем захват мира, а это значит, что его нужно как-то запихать в наше приложение.

И дела обстоят так, что с нашим движком это намного проще, чем кажется.
Нам нужно немного модифицировать метод загрузки карты, добавив в него MultyMapTileSource. Это по сути враппер для любых других источников тайлов, который позволяет отображать на карте сразу всё, что в него добавлено. Просто киллер-фича. В итоге нам остаётся подготовить карту мира с минимальной детализацией, добавить её самой первой в наш враппер, а поверх рисовать всё остальное. Более того, мы можем сразу добавить все карты, какие у нас есть в каталоге с картами приложения! Шикарно, просто шикарно. И не забываем, что это офлайн :)

// Создаём мульти-источник
MultiMapFileTileSource mmtilesource = new MultiMapFileTileSource();

File baseMapFile = getMapFile("cyprus.map");
MapFileTileSource tileSource = new MapFileTileSource();
tileSource.setMapFile(baseMapFile.getAbsolutePath());
mmtilesource.add(tileSource); // Добавляем все источники в MultiMapFileTileSource 

MapFileTileSource worldTileSource = new MapFileTileSource();

File worldMapFile = getMapFile("world.map");
worldTileSource.setMapFile(worldMapFile.getAbsolutePath());
mmtilesource.add(worldTileSource);

// В качестве базовой карты используем мульти-источник
VectorTileLayer layer = this.map.setBaseMap(mmtilesource);

Пожалуй, мы готовы к релизу. Собираем билд, выкладываем в маркет и получаем заслуженные звёзды :)

Пара ложек дёгтя в огромной бочке мёда

Движок open source, развивается активно, но команда у него, прямо скажем, довольно скромная. По большому счёту это один человек под ником devemux86 [9]. И ещё пара ребят контрибьютят время от времени.

Порой встречаются артефакты в отрисовке, какие-то моргания и подёргивания. Но я ни разу не столкнулся с какими-то критическими проблемами и тем более падениями, что не может не радовать.

Есть еще один нюанс, который может не понравиться. Это отрисовка скруглений и окружностей. Пример того, как это выглядит, на скриншоте:

Весь мир в кармане или как сделать мобильную карту за пару дней - 5

Если в исходной геометрии достаточно много точек (скругление гладенькая), то на карте вы можете увидеть довольно-таки «угловатую» окружность с множеством небольших выпуклостей и вогнутостей. Очевидно, это делается в угоду производительности и размеру map-файла, но выглядит не очень.

Пожалуй, это все минусы на сегодня. Вам решать, сможете вы с ними жить или нет. А мы тем временем используем эту библиотеку уже более 1,5 лет, полёт отличный, по крайней мере, на Андроиде.

Итоги

В этой статье я показал, что даже такую довольно нетривиальную задачу можно решить относительно быстро. Вы получили готовый скелет, с которым можно запрототипировать любой проект, подразумевающий использование офлайн-карты, за минимальное время.

Если возникнет интерес, в следующей статье покажу, как сделать этажи а-ля 2ГИС. И это на самом деле гораздо проще, чем кажется :)

Автор: vloboda

Источник [10]


Сайт-источник PVSM.RU: https://www.pvsm.ru

Путь до страницы источника: https://www.pvsm.ru/java/319291

Ссылки в тексте:

[1] прошлой статье: https://habr.com/ru/company/2gis/blog/448266/

[2] OpenStreetMap: https://www.openstreetmap.org

[3] POI: https://ru.wikipedia.org/wiki/POI

[4] mapsforge/vtm: https://github.com/mapsforge/vtm

[5] статье: https://habr.com/ru/company/2gis/blog/366089/

[6] rtree: https://www.sqlite.org/draft/rtree.html

[7] репозитории: https://github.com/viloboda/MapMobileApp/blob/master/dal/src/main/java/com/example/dal/impl/GeoDataRepositoryImpl.kt

[8] тут: https://github.com/viloboda/MapMobileApp/blob/master/app/src/main/java/com/example/mapapp/CustomLocationLayer.java

[9] devemux86: https://github.com/devemux86

[10] Источник: https://habr.com/ru/post/453182/?utm_campaign=453182