InquirerMinimum wage in Central Visayas to rise by P42 per dayThe Jerusalem PostCzech Republic's 6,500-year-old sanctuary found to predate Stonehenge by 1,000 yearsESPN DeportesMourinho y las reglas 'no negociables' en el Real Madrid: "No me traiciones"ESPN'Debate settled': Texas' UT jab at Tennessee leads top trolls of CFB Week 4RTP DesportoEspanha opera reviravolta e bate InglaterraColliderThe 8 Darkest 'Black Mirror' Episodes, RankedSouth China Morning PostHow China is seeking to transform the meaning of great powerBBC NewsAppeal underway after judge blocks Drumcree paradeANSA SportNations League: Repubblica Ceca-Croazia 1-2ZDF heuteAktuelles zum Krieg in der UkraineRadio-Canada« Nous faisons face à une rupture, pas à une transition », reconnaît Anita Anand à l’ONUVanguard10m cash transfer beneficiaries have no phones, social media, says APC chairman
The Daily Newsstand · Free, Always
Saturday, September 26, 2026

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

Translate

В прошлой статье я рассказывал, как обычная задача «собрать маршрут на выходные» превратилась в сервис — там была история без единой строчки кода. Но много интересного осталось за кадром: как именно это устроено и почему решения принимались так, а не иначе.

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

Коротко о хозяйстве: фронтенд — чистый 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 тысячи точек, чтобы нарисовать кашу.

Пять правок, каждая после замера:

  1. Cache-Control: public, max-age=86400 на ответ с данными. ETag там был, но без max-age браузер такой ответ эвристикой не кэширует — и возврат в уже виденную область каждый раз шёл по сети. Повторный запрос: 945 мс → 2 мс. Это, пожалуй, лучший коэффициент «выигрыш на строчку кода» за весь проект.

  2. Прореживание линий под зум — с допуском в полпикселя экрана. Вершин на 13-м зуме минус 57%. Приятная деталь: допуск считается как 0.7 / 2**zoom градусов, и широта в формулу не входит — косинус широты есть и в метрах-на-пиксель, и в длине градуса, он сокращается.

  3. Тропы отдаются с 13-го зума, а не с 12-го.

  4. Точки интереса приходят только с того зума, где они рисуются. До этого на 12-м зуме честно приезжали броды и родники, чтобы там же быть отфильтрованными на клиенте.

  5. Горизонтали на 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, пользуюсь им сам в каждом походе. Буду рад замечаниям — особенно про данные. Может когда то и встретимся на тропе )

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.