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

Комментарии (4)


  1. NikiN
    23.05.2025 06:29

    Есть староватое, но работающее решение от OSM

    https://github.com/Overv/openstreetmap-tile-server

    Почему его не стали использовать?


    1. freeExec
      23.05.2025 06:29

      Передать mbtiles проще, чем разворачивать postgis и мучиться с импортом на стороне заказчика.
      Собственно и поделки на node/go не к чему, когда есть небольшой модуль для apache.
      Да и скрипты обновления самописные зачем, есть же osmupdate


      1. NikiN
        23.05.2025 06:29

        Передается pbf , тоже самое что mbtiles, postgres в докере, как и osmupdate , импорт одной командой.


        1. freeExec
          23.05.2025 06:29

          Если так рассуждать, то зачем тогда вообще эта прослойка, клиент и так всё сам может сделать.