Планировщик походов изнутри: своя база OSM, свой тайлсет и три замера, которые развернули меня на 180°

В прошлой статье я рассказывал, как обычная задача «собрать маршрут на выходные» превратилась в сервис — там была история без единой строчки кода. Но много интересного осталось за кадром: как именно это устроено и почему решения принимались так, а не иначе.
Исправляюсь. Здесь — техническая часть: где я брал данные, что из этого работало, и главное — три случая, когда я честно провёл замер, получил убедительное число и сделал из него неправильный вывод. Последнее, подозреваю, полезнее всего остального.
Коротко о хозяйстве: фронтенд — чистый JavaScript без фреймворка (сначала Leaflet, сейчас MapLibre GL — про этот переезд будет отдельно), бэкенд — Node.js с Express и SQLite, карта своя, векторная. Ни сборки, ни webpack: один index.html и набор скриптов. За это можно ругать, но у подхода есть свойство — через месяц перерыва открываешь файл и сразу понимаешь, что там происходит. Так же мне нравится, что SPA позволяет ускорить работу сервиса в разы в отличии от стандартного способа, как минимум не надо грузить каждый раз карту заново, когда бегаешь по вкладкам. В общем для картографического сервиса я посчитал, что подходит идеально. Хотя время покажет ошибаюсь я или нет, в любом случае интересно - играем.
Высоты: источник данных важнее алгоритма
Первое, за чем я вообще пришёл в этот проект, — нормальный профиль высот. Не «всего +1200 м», а картинка, по которой видно, что тебя ждёт: один затяжной подъём или пять коротких, но злых.
Считал я его по тайлам Mapbox Terrain-RGB. Работает, цифры красивые, всё замечательно — ровно до момента, когда я прошёл ногами маршрут, который знал, и цифры не сошлись с ощущениями.
Проверил на том же треке альтернативу: Terrain Tiles из AWS Open Data (формат Terrarium, бесплатно и без ключа). В горах они дали заметно более правдоподобный рельеф. Переключился и с тех пор отношусь к любому «набору высоты» с профессиональной подозрительностью: у разных DEM разный шаг сетки, и на одном треке разница легко набегает на сотню метров.
Вторая половина проблемы — не источник, а арифметика. Сырой профиль высот шумит. Если складывать все положительные разности подряд, вы получите очень красивую и полностью выдуманную цифру: ваш маршрут «наберёт» лишние метры просто потому, что рельеф дрожит в пределах точности данных. Я прореживаю профиль по Дугласу–Пекеру с допуском 5 метров и считаю набор уже по нему. На одном и том же маршруте: сырой способ — 453 метра, прореженный — 344.
Какая из цифр правильная? Строго говоря, никакая. Но вторая честнее.
Если ваш сервис показывает набор высоты — он показывает не факт, а оценку. Хорошо бы хотя бы самому знать, какую именно.
Overpass: 81 мегабайт в браузере и 26 компонент связности
Дальше я захотел прокладку по тропам, а не только по дорогам. Данные о тропах живут в OpenStreetMap, живые запросы к ним делает Overpass API. Сделал, заработало, обрадовался.
Потом померил.
запрос | ответ | время |
|---|---|---|
Архыз, участок 12 км | 346 КБ | 6,6 с |
Москва, участок 23 км | 81 МБ | 27 с |
И это уже после того, как я заменил прямоугольник запроса на коридор вдоль линии — до этого было 153 МБ и 40 секунд. 81 мегабайт JSON — это порядка полутора гигабайт объектов в памяти браузера. На телефоне вкладка просто умирает, без предсмертной записки.
Плюс сам Overpass — бесплатная инфраструктура сообщества, и она честно устаёт: из трёх запросов подряд один отвечал 504. Строить на этом пользовательский сценарий «нажал кнопку — получил маршрут» невозможно.
Но самое интересное было не в объёме. Примерно в трети случаев путь не находился вовсе, и я, как приличный человек, пошёл чинить алгоритм поиска. Алгоритм был ни при чём.
Виновата оказалась привязка концов маршрута. Я привязывал старт и финиш к ближайшей линии — а в OSM рядом с вашей точкой сплошь и рядом лежит обрывок тропы, который ни с чем не соединён. Кто-то обвёл кусок по снимку и не дорисовал.
Замер на реальных данных Архыза: 26 компонент связности, главная — 7808 узлов из 8877. Из 37 пар точек не строилось 12, и все 12 — ровно по этой причине: старт оказывался в компоненте на 7808 узлов, финиш — в компоненте на 13. Между ними пути нет и быть не может, хоть A*, хоть Дейкстра, хоть экстрасенс.
Лечение — двадцать строк обхода графа: находим главную компоненту, привязываемся только к ней. Стало 28 успехов из 28, а девять заведомо безнадёжных точек отсеиваются заранее с внятным сообщением вместо молчаливого «не получилось».
Сам поиск пути, повторюсь, был здоров: даже на 257 тысячах линий граф строится 1,5 секунды, A* отрабатывает за полсекунды. Узкими местами были объём загрузки и привязка — то есть ровно то, на что я сначала не смотрел.
Своя база OpenStreetMap: две грабли и одна мораль
Чтобы слезть с Overpass, я поднял на VPS собственную базу: беру слепки Geofabrik, прогоняю через osmium export, складываю в SQLite с r-tree индексом. Сейчас там 2 миллиона узлов и 13,2 миллиона линий — Россия плюс соседи.
Грабля первая: osmium merge не годится для склейки стран. Он дедуплицирует объекты по паре (id, версия), а слепки скачаны в разные дни. Приграничный узел приезжает в двух версиях, и экспорт падает с бодрым Node ID twice in input. Пришлось учить свой импортёр принимать несколько pbf-файлов и гасить дубликаты через INSERT OR REPLACE.
Грабля вторая, она же мораль: проверять надо не «команда отработала», а данные. После первой сборки я отрапортовал себе об успехе — процесс завершился с нулевым кодом, лог чистый, красота. Потом из любопытства сравнил квадраты одинакового размера:
Крым — 7953 объекта;
Минск — 0;
Тбилиси — 0.
Карта там была, а троп и точек не было вовсе: фильтр тегов молча выбрасывал всё за пределами России. Пользователь в Сванетии увидел бы пустоту и решил, что сервис сломан — и был бы прав.
Нулевой код возврата означает ровно одно: программа не упала. Он ничего не говорит о том, сделала ли она то, что вы думаете.
Тропы, которых не было (спойлер: были)
Эту историю я коротко упоминал в прошлой статье, но там она шла без цифр — а цифры тут и есть самое поучительное.
Живые данные из своей базы я подмешивал поверх открытых тайлов Protomaps, и это было заметно глазом: при быстром движении карты объекты мелькали. Напрашивалось решение — отказаться от живого слоя и брать тропы прямо из тайлов подложки.
Прежде чем делать, я декодировал архив и посчитал тропы в тайле. Тайл Архыза на 13-м зуме: одна тропа. У меня на том же месте — 37. Вывод очевиден: в подложке троп практически нет, живой слой отключать нельзя.
С этим выводом я прожил очень долго.
Вскрылось случайно — когда я рисовал иллюстрацию к статье. Нарисовал обе панели рядом и увидел, что «одна тропа» слева выглядит как густая сеть: её геометрия состоит из 19 отдельных кусков. Protomaps склеивает тропы в многокусковые объекты, у меня каждый кусок — отдельный объект со своим названием. Я сравнивал не данные, а способ их упаковки.
Пересчитал по-честному, по суммарной длине линий:
участок | тайлы Protomaps | мой тайлсет |
|---|---|---|
Архыз, 1 тайл z13 | 19,8 км | 19,9 км |
Ай-Петри | 42,0 км | 42,6 км |
Хибины | 4,7 км | 4,8 км |
Домбай | 14,8 км | 15,1 км |
Архыз, 9 тайлов (примерно экран) | 119,0 км | 120,3 км |
Разница — доли процента. Тропы были в подложке всё это время.
Чего в ней действительно нет — и это я померил на тех же девяти тайлах: из точек там только вершины (50), вода (3) и ледник (1). У меня на той же площади — 63 брода, 35 перевалов, 29 стоянок, 10 водопадов, 7 родников, 5 приютов. Названий у линий 2 против 11. Осыпей (natural=scree) нет вовсе. Нет и sac_scale — категории сложности тропы, по которой можно сказать «тут вам понадобятся руки». Водных линий, кстати, в подложке даже больше моего: 230 км против 201.
Свой тайлсет я оставил — ради перевалов, бродов, родников, названий и собственных порогов зумов. Но обосновывать его фразой «в подложке нет троп» было нечестно. Хорошо, что это вскрылось на картинке к статье, а не в комментариях под ней — там бы мне объяснили быстро и с огоньком.

Свой тайлсет: три вещи, которых я не знал
Сам тайлсет собирается tippecanoe на VPS, на выходе PMTiles на 1,45 ГБ с зумами 11–14. Тайл Архыза на 13-м зуме весит 5 КБ и содержит 37 троп, 43 ручья и 48 точек. Живой запрос на тот же экран отдавал 325 КБ — разница в 65 раз, и никакого мелькания.
Три вещи, которые я бы рассказал себе на старте:
1. Порог показа слоя и порог нарезки тайлов — разные вещи. У меня тропы показываются с зума 11,5, а карта на нём запрашивает тайл 11-го. Подняв нарезку до 12-го «ради экономии», я оставил бы полосу зумов пустой. Самое неприятное: объекты с порогом ниже минимального зума нарезки пропадают молча, без единой строчки в логах. Пустой слой выглядит ровно как слой, в котором нечего показывать.
2. Метаданные архива PMTiles перезаписывают настройки источника. Я обрезал линии по 14-му зуму, а в заголовке архива остался maxzoom: 15. MapLibre честно верит заголовку: на 15-м зуме он запрашивает настоящий тайл — а там уже только точки, линий нет. Указать maxzoom в стиле недостаточно, читается именно заголовок архива (pmtiles edit --header-json). Я нашёл это в исходниках библиотеки, в строчке, где tileJSON из архива накатывается поверх настроек источника.
3. MapLibre расставляет подписи с конца списка слоёв. Чем позже слой в массиве — тем выше его приоритет при проверке перекрытия. Интуиция ровно обратная: кажется, что первый в списке главнее. Из-за этого у меня долго не появлялись на карте остановки транспорта: их молча вытесняли стоянки и броды, а я искал ошибку в порогах зума.
Бонусом ещё одна вещь про MapLibre, за которую я заплатил вечером: размер картинки fill-pattern — это прямая цена каждого тайла, а не «качество текстуры». Атлас собирается для каждого тайла отдельно, и пиксели паттерна копируются туда целиком. Плитка 512 px вместо 256 px удорожает тайл вчетверо. Для текстуры скал, которая на карте занимает пол-экрана, это очень заметно.
Кэш, прореживание и пороги: минус 84% веса
Когда живых слоёв стало меньше, я всё равно упёрся в то, что данные едут медленно. Замер экрана 1600×900 в Архызе на 12-м зуме: 1083 объекта, 38 тысяч вершин, 876 КБ. Из них тропы — 582 объекта и 23 тысячи вершин, то есть 60% веса. При этом на 12-м зуме тропа рисуется линией толщиной в полпикселя. Мы гнали по сети 23 тысячи точек, чтобы нарисовать кашу.
Пять правок, каждая после замера:
Cache-Control: public, max-age=86400на ответ с данными. ETag там был, но безmax-ageбраузер такой ответ эвристикой не кэширует — и возврат в уже виденную область каждый раз шёл по сети. Повторный запрос: 945 мс → 2 мс. Это, пожалуй, лучший коэффициент «выигрыш на строчку кода» за весь проект.Прореживание линий под зум — с допуском в полпикселя экрана. Вершин на 13-м зуме минус 57%. Приятная деталь: допуск считается как
0.7 / 2**zoomградусов, и широта в формулу не входит — косинус широты есть и в метрах-на-пиксель, и в длине градуса, он сокращается.Тропы отдаются с 13-го зума, а не с 12-го.
Точки интереса приходят только с того зума, где они рисуются. До этого на 12-м зуме честно приезжали броды и родники, чтобы там же быть отфильтрованными на клиенте.
Горизонтали на 12-м зуме — только жирные, с шагом 250 м вместо 50.
Итог по весу экрана: 876 КБ → 139 КБ на 12-м зуме, 591 → 325 КБ на 13-м.
И отдельная грабля, на которой я чуть не потерял день: ключ кэша на сервере обязан включать зум. Иначе близкий зум получает из кэша ответ, собранный для дальнего: с грубой геометрией и половиной отсутствующих категорий точек. Симптом — «иногда карта пустая, перезагрузишь — нормально», то есть самый любимый жанр багов.
SPA, мессенджеры и невидимый текст
Про то, что краулеры Telegram и ВКонтакте не исполняют JavaScript, я уже писал — но у этой истории было техническое продолжение, которое оказалось важнее.
Сайт одностраничный: заголовок и описание проставляет скрипт после загрузки. Для превью в мессенджерах пришлось сделать серверный слой, который отдаёт тот же HTML, но с подставленными мета-тегами и картинкой трека. Ожидаемо.
Неожиданным было вот что. Через некоторое время я открыл Яндекс.Вебмастер и увидел: «одинаковые заголовки у 43% страниц». Выяснилось, что мой же клиентский код прилежно ставил общий заголовок сайта поверх серверного — на любой странице, после загрузки. Поисковик исполняет скрипты, в отличие от мессенджеров, и видел заголовок главной страницы на страницах маршрутов. То есть серверные мета-теги, ради которых всё затевалось, для поиска не работали вовсе — а для мессенджеров работали, потому что те до скриптов не доходят.
Лечится меткой: сервер помечает отрисованную им страницу, клиентский код такие страницы не трогает. И закрепляется тестом, который падает, если затирание вернётся, — потому что такую вещь очень легко вернуть случайно.
Оттуда же второе открытие: текст, который показывается только после запуска скриптов, для поисковика почти не существует. У меня страница «Открыть GPX онлайн» лежала в разметке скрытым блоком — и заодно висела невидимым текстом на каждой странице сайта. Идеальный способ объяснить поисковику, что все ваши страницы одинаковые.

Что я вынес из всего этого
Три правила, которые сэкономили мне больше всего времени — и каждое куплено за конкретный потерянный вечера может и больше, кто ж его знает, так как дни за делом летят как минуты ;):
1. Мерить, а не рассуждать. Из пяти моих гипотез о том, почему тормозит карта, подтвердилась одна. Три правки я не сделал именно потому, что замер их отменил, — и это лучший результат замера из возможных: сэкономленная работа.
2. Мало померить — надо померить правильную величину. Я долго был уверен, что в подложке нет троп, потому что считал объекты. Стоило посчитать километры — и вывод развернулся на противоположный. Число выглядит убедительно независимо от того, верное оно или нет; убедительность — свойство шрифта, а не истины.
3. Проверять, что тест дёргает именно ваш код. Я однажды чинил уже починенное: правка лежала в локальном репозитории, а клиент ходил на прод. Отдельный подвид этого жанра на Windows: pkill -f "node index.js" не убивает процесс, порт остаётся за старым сервером, и вы бодро тестируете предыдущую версию своей же программы.
В общем жду по адресу putemer.ru, пользуюсь им сам в каждом походе. Буду рад замечаниям — особенно про данные. Может когда то и встретимся на тропе )
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.