
На связи разработчики продукта Аврора Центр компании Открытая мобильная платформа. Сегодня мы расскажем как реализовать сервис 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.
Находим несколько интересных особенностей:
Обновления предоставляются geofabric.de в формате ocs.gz.
Обновления пропускать нельзя, иначе это поломает .osm.pbf.
Чтобы обновить, необходимо выяснить порядковый номер (sequence number) текущей и обновлённой pbf.
Высчитать URL для файлов обновлений и загрузить их, основываясь на порядковых номерах.
Объединить файлы обновлений в один.
Применить.
Для этого в дело вступает еще один инструмент — 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 карты — это весьма трудозатратное занятие. Построение такой инфраструктуры с нуля требует больших временных затрат и высокой квалификации сотрудников. Так как информация, доступная из открытых источников, неоднородная и неисчерпывающая, приходится собирать всё по крупицам методом проб и ошибок. Плюс не стоит забывать о возможных подводных камнях, которые могут встретиться как во время исследования и реализации, так и в процессе эксплуатации, поддержки и обновления данных для карт. Но то, насколько это увлекательно и какой прирост функциональности даёт — несравнимо ни с какими сложностями, которые мы повстречали на пути реализации. С момента создания статьи могли появиться новые возможности по работе с картами.
NikiN
Есть староватое, но работающее решение от OSM
https://github.com/Overv/openstreetmap-tile-server
Почему его не стали использовать?
freeExec
Передать mbtiles проще, чем разворачивать postgis и мучиться с импортом на стороне заказчика.
Собственно и поделки на node/go не к чему, когда есть небольшой модуль для apache.
Да и скрипты обновления самописные зачем, есть же osmupdate
NikiN
Передается pbf , тоже самое что mbtiles, postgres в докере, как и osmupdate , импорт одной командой.
freeExec
Если так рассуждать, то зачем тогда вообще эта прослойка, клиент и так всё сам может сделать.