Реализация self-hosted карт в закрытом контуре

от автора

На связи разработчики продукта Аврора Центр компании Открытая мобильная платформа. Сегодня мы расскажем как реализовать сервис self-hosted карт в закрытом контуре.

Наша компания активно развивается и добавляет новый функционал в продукт по удалённому управлению устройствами — Аврора Центр (UEM-решение, которое позволяет управлять устройствами и жизненным циклом приложений на ОС Аврора, Android и Linux). Так по запросам заказчиков было решено добавить отображение геопозиции мобильного устройства на карте территории России. И вот перед нами встаёт задача по работе с картами в АЦ.

Требования к карте:

  • должна работать офлайн, без наличия доступа к сети;

  • не должна вызывать лишних вопросов в свете геополитических событий;

  • должна быть актуальной.

Введение в карты

Существует много картографических сервисов с различными подходами. Так, например, есть сервисы, предоставляющие механизм (инструмент) по получению картографических данных через запросы (API). Например, это сервисы 2GIS и Yandex.

Также существуют сервисы, поставляющие географические данные, предназначенные для обработки и предоставления в желаемом формате. Такие сервисы представляют собой комплексные и разнородные системы, в которых необходимо выбрать подход к тому, как хранить, обновлять и поставлять географические данные. Например, они могут предоставлять их в «портативном» виде mbtiles или хранить в формате PostGIS.

Самым известным поставщиком географических данных является OpenStreetMap — открытая компания, которая предоставляет набор инструментов, необходимых для взаимодействия с картографической информацией, а также публикует их документацию на собственном Wiki. Как несложно догадаться, основной рабочей силой в OpenStreetMap являются волонтёры, которые пишут упомянутый софт, а также наполняют карту географическими данными.

Закатываем рукава

Начинаем работать над задачей и исследуем уже готовые решения. Заботливый аналитик подкидывает варианты и первым из них является использование карт Яндекса, 2GIS или OpenStreetMap.

Вариант с использованием внешних API, выглядит вполне закономерным решением — меньше трудозатрат. Использование сервисов 2GIS и Yandex требует предоставления конечному заказчику ключей и прав доступа к платному API. Возможно, некоторым заказчикам такое решение подходит, но мы стали изучать следующий вариант. Смотрим инструменты OpenStreetMap и понимаем, что их использование не соответствует одному из главных требований — карты должны работать без доступа в интернет. И аналитик предлагает ещё один вариант, чтобы соответствовать этому требованию, можно попробовать выгрузить необходимую нам территорию в виде изображений! Казалось бы — просто, что может пойти не так?

Обсудив решение с аналитиком и архитектором, приступили к реализации. Написали простенький скрипт, который выкачивает png-файлы тайлов по определённым координатам (x/y/z).

Вся карта разбита на небольшие кусочки, количество которых в сетке зависит от зума. Тайлы — это эти кусочки карты. x и y — координаты относительно левого верхнего угла сетки в направлении справа вниз. z — уровень зума.

К сожалению, попытка оказалась неудачной. Изображения одной только территории Москвы занимали очень много места. Но проблема объемов данных не является критичной, ведь HDD ёмкостью в десятки терабайт — обычное дело, и они вполне доступны. Суть проблемы кроется в обновлении этих данных — выкачивать каждый раз огромные объемы нецелесообразно, если, к примеру, изменилось только название улицы/парка/аптеки.

Не опускаем руки

Не опускаем руки и продолжаем изучать готовые решения, но уже не API, а сервера, которые могут хостить «наши данные» в пределах Аврора Центра. Выбор пал на два популярных инструмента: tileserver-gl и mbtileserver. Первый реализован на JS, второй на Go. Обрадовались, взяли самый удобный — tileserver-gl. Инструмент позволяет запрашивать и векторные тайлы, и растровые картинки из исходного файла mbtiles. Также есть возможность подгружать файлы стилей к карте, что тоже добавляет гибкости. Но тут пришёл безопасник и надавал по неопущенным рукам, ведь интерпретируемые языки (коим и является JS) сертифицировать весьма проблематично. Проблема заключается в том, что для сертификации продукта, написанного на интерпретируемом языке, как правило, необходимо сертифицировать его интерпретатор. Поэтому нельзя просто так взять и использовать какой-то интерпретируемый язык.

Пришли к решению на Go — mbtileserver. С одной стороны это решило проблему с сертификацией, с другой — реализация этого сервера не подходит под нашу архитектуру и его необходимо переписывать.

Первый этап пройден: создан инструмент, который умеет отдавать тайлы тестовой карты по запросу фронтенда и всё работает! Переходим к следующему этапу — где взять полную карту? И тут начинается тернистый путь и самое интересное.

Карты генерировали-генерировали, да не выгенерировали

Выяснилось, что есть готовые файлы mbtiles. Поискав в открытом доступе, нашли пару ресурсов, которые предлагают «на пробу» mbtiles небольших размеров, например Лихтенштейна, а если нужны конкретные регионы больших размеров — добро пожаловать в мир платных подписок. Такой вариант нам не подходит. Но ведь mbtiles как-то генерируются? Как? Начинаем изучать этот вопрос и находим ответ — некоторые сервисы предоставляют «сырые» данные, содержащие географическую информацию — osm.pbf. Из этих «ПБФок» мы можем сформировать mbtiles. Что является исходными данными мы выяснили, но как перевести один формат в другой? К сожалению, Wiki OpenStreetMap не даёт прямых ответов на этот вопрос (мы знаем, мы искали). Потратив кучу времени, мы нашли инструмент, который выглядел наиболее удовлетворяющим нашим потребностям — tilemaker.

«Исходники» карты (osm.pbf) есть, их можно скачать на сайте. Утилита для формирования mbtiles — есть! Приступаем к генерации. Для начала пробуем что-нибудь небольшое, а именно территорию Северного Кавказа (её размер около 100 Мб). Запускаем tilemaker.

$ tilemaker --input north-caucasus-fed-district-latest.osm.pbf --output north-caucasus-fed-district.mbtiles

Получаем следующий вывод:

Reading .pbf north-caucasus-fed-district-latest.osm.pbf (Scanning for ways used in relations: 83%)           (56 ms) Block 2370/2371 (393 ms) SortedNodeStore: 59102 groups, 407009 chunks, 18956414 nodes, 108239858 bytes (24% wasted) Block 266/267 (3125 ms) SortedWayStore: 14150 groups, 132594 chunks, 1565189 ways, 17370675 nodes, 56313586 bytes only 6 relation blocks; subdividing for better parallelism Block 95/96 (3384 ms) Generated points: 936512, lines: 26, polygons: 536435 Attributes: 79817 sets from 3109888 objects (830464 uncached), 2691072 pairs (683008 uncached) Creating mbtiles at north-caucasus-fed-district.mbtiles indexed 207564 contended objects osm: finalizing z6 tile 4096/4096 (165 ms) osm: finalizing z6 tile 4096/4096 (0 ms) indexed 0 contended objects shp: finalizing z6 tile 4096/4096 (0 ms) shp: finalizing z6 tile 4096/4096 (0 ms) collecting tiles: 23ms, filtering tiles: z0 (1, 0ms) z1 (1, 0ms) z2 (1, 0ms) z3 (3, 0ms) z4 (3, 0ms) z5 (3, 0ms) z6 (8, 0ms) z7 (18, 0ms) z8 (51, 0ms) z9 (163, 0ms) z10 (554, 2ms) z11 (2121, 8ms) z12 (8030, 31ms) z13 (30947, 129ms) z14 (118776, 516ms) z6/40/23, writing tile 160680 of 160680                Filled the tileset with good things at north-caucasus-fed-district.mbtiles

…и сгенерированный файл mbtiles!

Что ж, репетиция прошла успешно, пришло время сгенерировать карту всей России. Скачиваем исходники карты России и запускаем генерацию, счастливые наблюдаем за процессом… проходит полчаса и ловим зависание компьютера, т.к. заканчивается вся оперативная память. Локально протестировать не получилось, запрашиваем ресурсы, а именно виртуалку с 256 Гб ОЗУ и какое-то время экспериментируем на ней. Но в итоге находим другой выход — tilemaker умеет «скидывать» промежуточные этапы на диск, не храня всё в ОЗУ. Для включения этой опции используйте флаг --store. Победа!

Стоит отметить, что размер необходимой оперативной памяти напрямую зависит от размера исходного файла (.pbf), без опции сохранения промежуточных этапов на диск. Так, например, для генерации карты из файла russia-latest.osm.pbf, размером 3,7 Гб, требуется до 32 Гб ОЗУ.

А это чьё?

На текущий момент в предоставляемых OpenStreetMap данных некоторых территорий могут находиться в разных исходных файлах. Для формирования карт, интересующих заказчика, может потребоваться объединение нескольких исходных файлов, что может привести к неправильному отображению границ территорий. Перед нами встаёт новая задача, или даже две: 1) решить, что с этим делать и 2) сделать. Пообщавшись с аналитиком, пришли к выводу, что можно позаимствовать идею у Яндекс карт и убрать границы. Таким образом, отображаем только границы административных регионов.

Для того чтобы сделать невидимыми границы стран, необходимо в файле стилей установить фильтр для boundary-land-level-2:

{       "id": "boundary-land-level-2",       "type": "line",       "source": "openmaptiles",       "source-layer": "boundary",       "filter": ["all", ["!=", "maritime", 1], ["!=", "disputed", 1]],       "layout": {         "line-cap": "round",         "line-join": "round",         "visibility": "visible"       } }

За границы регионов отвечает boundary-land-level-4, он остаётся без изменений. В итоге получилось как-то так:

Для того чтобы карта выглядела полноценной, были добавлены исходные данные Европы и Азии, а также на карте убрана отрисовка границ между странами. Конечно, после этого карта начала занимать десятки гигабайтов на диске и встал новый вопрос — как эти данные передавать конечному заказчику с каждым релизом? Ведь карта должна быть актуальной, а значит, регулярно обновляться. Для решения этой проблемы было придумано следующее — карты, интересующие заказчиков, были детализированы до 14 зума. Данные по остальным территориям были отфильтрованы, и оставлен набор элементов ways, relations, nodes, которые нужны для нормального отображения до 8 зума. На бОльших зумах pbf будут просто растягиваться. Для Азии такой фильтр может включать, например, основные магистрали, парки, ЖД пути, названия регионов и областей. Нужное подчеркнуть и добавить с помощью утилиты osmfilter. Например:

osmfilter asia-latest.o5m --keep= --keep-ways="highway=trunk or railway=rail or natural= or boundary=national_park or boundary=administrative and type=boundary" -o=asia-latest-filter.o5m

Также карты остальных территорий можно приправить добавлением названий столиц, областей или дополнительной информации на ваш вкус. Например, столицы можно скачать с ресурса overpass-turbo. Запрос будет выглядеть так:

[out:xml][timeout:10000]; (   node["capital"="yes"](4,4,-20,80.5,180); ); out body; >; out skel qt;

Запрос для континентов, стран, областей, городов:

[out:xml][timeout:10000]; (   node["place"="continent"](4,4,-20,80.5,180);   node["place"="country"](4,4,-20,80.5,180);   node["place"="state"](4,4,-20,80.5,180);   node["place"="city"](4,4,-20,80.5,180); ); out body; >; out skel qt;

Экспортируем результат в необработанные данные OSM. Добавить столицы и страны:

osmconvert eurasia-latest-filter.o5m capitals.osm country.osm -o=eurasia-filter.pbf
  • eurasia-latest-filter.o5m — файл для Евразии, отфильтрованный до нужного зума.

  • capitals.osm — скачанные столицы из шага выше.

  • country.osm — скачанные названия континентов, стран, областей и городов из шага выше.

  • eurasia-filter.pbf — результирующий файл.

Просто добавь воды

Карта, включающая территории РФ, Европы и Азии, может выглядеть не совсем законченной, т.к. не обрамляется океанами или морями. Чтобы «налить воды», можно внести изменения конфигурационный файл tilemaker’a /resources/process-openmaptiles.lua, а именно в секции:

if natural=="water" or natural=="bay" or leisure=="swimming_pool" or landuse=="reservoir" or landuse=="basin" or waterClasses[waterway] then         if way:Find("covered")=="yes" or not isClosed then return end         local class="lake"; if natural=="bay" then class="ocean" elseif waterway~="" then class="river" end         if class=="lake" and way:Find("wikidata")=="Q192770" then return end         if class=="ocean" and isClosed and (way:AreaIntersecting("ocean")/way:Area() > 0.98) then return end         way:Layer("water",true)         way:MinZoom(0)         -- SetMinZoomByArea(way)         way:Attribute("class",class) ...

и

-- Set 'landcover' (from landuse, natural, leisure)     local l = landuse     if l=="" then l=natural end     if l=="" then l=leisure end     if landcoverKeys[l] then         way:Layer("landcover", true)         way:MinZoom(0)         -- SetMinZoomByArea(way)         way:Attribute("class", landcoverKeys[l])         if l=="wetland" then way:Attribute("subclass", way:Find("wetland"))         else way:Attribute("subclass", l) end         write_name = true ...

SetMinZoomByArea(way) заменить на way:MinZoom(0), где 0 — нужный зум.

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

Обновления

И так, промежуточные итоги:

  • генерация карт — есть;

  • сервис для хостинга данных этих карт — есть;

  • фронт их отрисовывает — есть!

Можно праздновать победу? Не совсем. Как уже говорилось ранее, данные карт должны обновляться. Снова погружаемся в пучину Wiki OpenStreetMap.

Находим несколько интересных особенностей:

  1. Обновления предоставляются geofabric.de в формате ocs.gz.

  2. Обновления пропускать нельзя, иначе это поломает .osm.pbf.

  3. Чтобы обновить, необходимо выяснить порядковый номер (sequence number) текущей и обновлённой pbf.

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

  5. Объединить файлы обновлений в один.

  6. Применить.

Для этого в дело вступает еще один инструмент — Osmium. С его помощью из pbf достаём replication info, в которой хранится текущий sequence number. Также из replication info достаём базовый URL, с которого можно подтянуть обновления. А теперь наиболее интересное в данном процессе — высчитываем путь до файлов обновления. Для этого сходим за файлом state.txt (например, сюда), в котором хранится последний sequence number для нашей pbf (например, текущая pbf имеет sn=3700, а файл state.txt содержит в себе sn=3710). После того как мы узнали текущий sn и sn в файле, начинаем считать. Да, стоит отметить особенность URL для файлов обновления, они выглядят примерно так. Проходимся в цикле от текущего sn+1 до sn из state.txt, делим на 1000, чтобы получить часть URL (3701/1000 = 3) и делим с остатком, чтобы получить номер обновления (3701%1000 = 701). На основе упомянутых ранее особенностей формируем URL и получаем результат. Ниже приведён bash-скрипт, который используется для автоматизации процесса:

# get pbf header # $1 argument -- name of the file in format name.osm.gz function get_pbf_header() {     if [[ -z $REPLICATION_INFO ]]     then         REPLICATION_INFO=`$OSMIUM fileinfo osm-data/pbf/$1`     fi }  # parse pbf header and get sequence number # $1 argument -- name of the file in format name.osm.gz function get_sequence_number() {     get_pbf_header $1     echo $(echo -e $REPLICATION_INFO | grep -E -o "replication_sequence_number=([0-9]+)" | cut -d '=' -f2) }  # parse pbf header and get base url # $1 argument -- name of the file in format name.osm.gz function get_base_url() {     get_pbf_header $1     echo $(echo $REPLICATION_INFO | grep -E -o "replication_base_url=.*[^\s]" | cut -d ' ' -f1 | cut -d '=' -f2) }  # get information about pbf, parse and prepare download url # $1 argument -- pbf name # $2 argument -- wanted sequence number function calc_osc_update_path() {     get_pbf_header $1      local __seq_num=$2     local __base_url=$(get_base_url)      local __lead=$(( $__seq_num / 1000 ))     local __remain=$(( $__seq_num % 1000 ))      local i=${#__lead}     local __lead_prefix=""     while [ $i -lt $SUB_DIR_LEN ]     do         local __lead_prefix="0""$__lead_prefix"         local i=$(( $i + 1))     done      i=${#__remain}     local __remain_prefix=""     while [ $i -lt $SUB_DIR_LEN ]     do         local __remain_name="0""$__remain_prefix"         local i=$(( $i + 1))     done      echo "$__base_url/$UPDATES_PREFIX/$__lead_prefix$__lead/$__remain_prefix$__remain"  }  # get last change number from origin resourse # $1 argument -- name of the file in format name.osm.gz function get_latest_change_number() {     get_pbf_header $1     local __base_url=$(get_base_url)     echo $(curl -sL $__base_url/state.txt | grep -oE "sequenceNumber=(.*)" | cut -d"=" -f2) }  # download needed change files # from current sequence number to latest # $1 argument -- file name in the "name.osm.pbf" format function download_changes() {     local __fullname=$1     local __filename="${__fullname%.*.*}"      local cur_seq_num=$(get_sequence_number $__fullname)     local last_seq_num=$(get_latest_change_number $__fullname)      if [[ $cur_seq_num -gt $last_seq_num ]]; then         echo "[critical] bad sequence numbers for $__fullname. current $cur_seq_num, latest $last_seq_num"         return 1     elif [[ $cur_seq_num -eq $last_seq_num ]]; then         echo "[warn] no updates for $__fullname with latest $last_seq_num"         return 2     fi      echo "[info] downloading updates for $__fullname"     for i in $(seq $(( $cur_seq_num + 1 )) $last_seq_num)     do         local __url=$(calc_osc_update_path $__fullname $i)         local __file_date=$(curl -s $__url.state.txt | grep -oE "timestamp=([0-9]+-[0-9]+-[0-9]+)" | cut -d"=" -f2)          wget \             --quiet \             --progress=bar \             -e robots=off \             -np \             -nH \             -R "index.html*" \             -O "osm-data/changes/$__filename"_"$i"_"$__file_date.osc.gz" \             $__url.osc.gz     done }

Файлы обновлений скачаны. Самое время их объединить в один. Для этого используем уже упомянутый Osmium:

# merge multiple osc.gz change files into one # $1 argument -- file name in the "name.osm.pbf" format function merge_changes() {     local __filename=$1     local __filename="${__filename%.*.*}"     echo "[info] merging changes for $1"     $OSMIUM merge-changes osm-data/changes/"$__filename"_*.gz \         --progress \         -O \         -o osm-data/changes/"$__filename"-merged.osc.gz }

Ну и наконец, применяем наш файл к pbf’ке:

# apply change files to pbfs function apply_changes() {     local __filename_ext=$1     local __filename="${__filename_ext%.*.*}"      local __seq_num=$(get_latest_change_number $__filename_ext)     local __base_url=$(get_base_url $__filename_ext)      echo "[info] applying changes for $1"     $OSMIUM apply-changes \         -O \         --progress \         --output-header=osmosis_replication_base_url=$__base_url \         --output-header=osmosis_replication_sequence_number=$(( $__seq_num )) \         -o osm-data/pbf/$__filename-updated.osm.pbf \         osm-data/pbf/$__filename.osm.pbf \         osm-data/changes/$__filename-merged.osc.gz       if [[ $? -eq 0 ]]; then         echo "[info] removing intermediate files"         mv osm-data/pbf/$__filename-updated.osm.pbf osm-data/pbf/$__filename.osm.pbf          echo $($OSMIUM fileinfo osm-data/pbf/$__filename.osm.pbf)     else         echo "[critical] something went wrong"         return $?     fi }

Обновлять ли все файлы территорий конечной карты, решать вам. В соответствии с этим нужно будет скорректировать скрипты. Вот и всё. И с обновлениями мы успешно справились!

Мы подошли из-за угла

Использование данных, которые поддерживаются сообществом, несет в себе некоторые риски и опасности. Данные OpenStreetMap — не исключение. В один прекрасный день мы столкнулись с одной опасностью — неверные или даже намерено испорченные данные. В случае с картами — это нецензурная лексика и различные выражения оскорбительного характера в названиях улиц и «поломанные» дороги. Процесс валидации кажется простым: посмотри и поищи. НО! Нужно это как-то автоматизировать. Вдруг поменяют имя только одной улицы?

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

Пусть и не идеально, но:

import atexit import argparse import pathlib import threading import time from typing import TypeAlias  import osmium as osm import osmium.osm.types as osm_types   # OSMElementType is a type alias for OSM types used in handler. OSMElementType: TypeAlias = osm_types.Node | osm_types.Way | osm_types.Relation # https://wiki.openstreetmap.org/wiki/Key:name TagKeyName: str = 'name'   class OSMNameValidator:     def __init__(self, forbidden_words: list[str]):         self._forbidden_words = forbidden_words      def valid(self, line: str) -> bool:         for word in self._forbidden_words:             if word in line.lower():                 return False          return True   class OSMHandler(osm.SimpleHandler):     def __init__(self, validator: OSMNameValidator):         osm.SimpleHandler.__init__(self)         self.__validator = validator         self.processed_elements = 0         self.processed_tags = 0         self.non_valid_count = 0         self.processed_files = 0      def tag_inventory(self, elem: OSMElementType):         for tag in elem.tags:             if (key := tag.k) == TagKeyName:                 if not self.__validator.valid(tag.v):                     self.non_valid_count += 1                     print(f"{tag.v} contains forbidden words")                          self.processed_tags += 1          self.processed_elements += 1      def node(self, node: osm_types.Node):         self.tag_inventory(node)      def way(self, w: osm_types.Way):         self.tag_inventory(w)      def relation(self, r: osm_types.Relation):         self.tag_inventory(r)          def apply_file(self, filename: str, locations: bool = False, idx: str = '') -> None:         self.processed_files += 1         return super().apply_file(filename, locations, idx)   class VerbosityHandlerThread:     '''Prints statistics if verbosity flag is set'''     def __init__(self, verbose: bool, handler: OSMHandler, forbidden_words_count: int):         self.__handler = handler         self.__verbose = verbose         self.__fwc = forbidden_words_count         self.__f_count = 0         self.__time_now = lambda : time.ctime(time.time())          self.__run()          atexit.register(self.__exit)          def add_files_count(self, count: int):         self.__f_count += count          def __fetch_stats(self) -> str:         elems = self.__handler.processed_elements         tags = self.__handler.processed_tags         non_valid = self.__handler.non_valid_count         processed_files = self.__handler.processed_files         now = self.__time_now()         return (             f'Processed elements: {elems} tags: {tags}'             f' processed files: {processed_files}/{self.__f_count}'             f' non valid: {non_valid} {now}'         )          def __print_stats(self):         stats = self.__fetch_stats()         print(stats, end='\r')          def __print_stats_forever(self):         while True:             self.__print_stats()             time.sleep(5)          def __run(self):         if self.__verbose:             print("started", self.__time_now())             print("forbidden words count", self.__fwc)             t = threading.Thread(                 target=self.__print_stats_forever,                 daemon=True,             )             t.start()          def __exit(self):         self.__print_stats()         print("\nfinished", self.__time_now())   def cli_args() -> argparse.Namespace:     ap = argparse.ArgumentParser()     ap.add_argument(         '-s',         '--source',         help='data source file to process',         type=str,     )     ap.add_argument(         '-d',         '--directory',         type=str,     )     ap.add_argument(         '-f',         '--forbidden',         default='./forbidden_words.data',     )     ap.add_argument(         '-v',         '--verbose',         default=False,         action='store_true',     )     return ap.parse_args()   def main():     args = cli_args()      if args.source is None and args.directory is None:         print("source or directory arguments must be provided")         exit(1)      try:         f = open(args.forbidden, 'r')         forbidden = f.read().split(',')         f.close()     except Exception as e:         print(e)         exit(1)      osm_name_validator = OSMNameValidator(forbidden)      osmhandler = OSMHandler(osm_name_validator)      verbosity_handler = VerbosityHandlerThread(         args.verbose,         osmhandler,         len(forbidden),     )      if args.source:         osmhandler.apply_file(args.source)         verbosity_handler.add_files_count(1)       if args.directory:         file_dir = pathlib.Path(args.directory)         globed_files = [f for f in file_dir.glob('**/*')]         verbosity_handler.add_files_count(len(globed_files))         for file in globed_files:             osmhandler.apply_file(file)   if __name__ == '__main__':     main()

Финал

Self-hosted карты — это весьма трудозатратное занятие. Построение такой инфраструктуры с нуля требует больших временных затрат и высокой квалификации сотрудников. Так как информация, доступная из открытых источников, неоднородная и неисчерпывающая, приходится собирать всё по крупицам методом проб и ошибок. Плюс не стоит забывать о возможных подводных камнях, которые могут встретиться как во время исследования и реализации, так и в процессе эксплуатации, поддержки и обновления данных для карт. Но то, насколько это увлекательно и какой прирост функциональности даёт — несравнимо ни с какими сложностями, которые мы повстречали на пути реализации. С момента создания статьи могли появиться новые возможности по работе с картами.


ссылка на оригинал статьи https://habr.com/ru/articles/912000/