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

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.


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


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


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


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


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


Следующий шаг — выбираем картодвижок. На просторах интернета их довольно мало, бесплатных ещё меньше, а с поддержкой офлайна вообще единицы. Предлагаю воспользоваться довольно крутым вариантом — mapsforge/vtm. Это векторный 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, указываем местонахождение файла карты. Дополнительно позиционируемся в центр интересующего нас баундинг-бокса, чтоб не оказаться где-то за пределами выбранной локации при старте приложения. Устанавливаем дефолтную тему. Добавляем слой домов и слой подписей. На этом всё. Запускаем — чудеса!



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


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


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


// Определяем координаты клика и находим тайлы в его зоне
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("\n\n").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, но на этом всё. Остальную логику придется накручивать самостоятельно: выбирать «правильный» из всего множества объектов, в которые попал клик, фильтровать по зум-левелам. И ещё один момент. Фактически, мы теряем информацию об исходной геометрии и получаем в ответ на поиск просто набор линий. Если захочется сделать ещё и гео-редактор, то этого явно будет недостаточно.


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





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


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


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


  1. Делаем таблицы:
    • геообъектов (id, type, geometry, attributes)
    • фирм (id, attributes, geo_id) со ссылкой на геометрию здания, в котором она находится
    • геоиндекса на rtree вот так:
      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. Последний шаг: фильтруем и выбираем подходящий объект.

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


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


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


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


Начнём с текущей геопозиции. В 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);

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


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


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);
    }
});




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


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


И дела обстоят так, что с нашим движком это намного проще, чем кажется.
Нам нужно немного модифицировать метод загрузки карты, добавив в него 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. И ещё пара ребят контрибьютят время от времени.


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


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





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


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


Итоги


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


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

Источник: https://habr.com/ru/company/2gis/blog/453182/


Интересные статьи

Интересные статьи

Рады продолжить цикл статей с подборками из недавних вызовов, случившихся в нашей повседневной практике эксплуатации. Для этого мы описываем свои мысли и действия, которые привели к их ус...
Рекламные истории и тексты на картинках. Краткая история изображений Первые картинки, которые попали в категорию «изображения», нарисованы на стенах пещеры Ла-Пасьега, в Кантабри...
Последние три года жизни я работал на круизных лайнерах. Как я там оказался, что делал и что вообще там происходит — все это заслуживает отдельного поста. Если коротко: работаешь без выходных, см...
Ваш сайт работает на 1С-Битрикс? Каждому клиенту вы даёте собственную скидку или назначаете персональную цену на товар? Со временем в вашей 1С сложилась непростая логика ценообразования и формирования...
Ощущения смерти, одиночества, в то же время безумная жажда к жизни… Вы могли бы подумать, что мы решили устроить лекцию по экспрессионизму и погрузить вас в творчество Мунка. Но нет. Все эти этап...