Откуда берётся карта паломника
Dham Navigator работает с выключенной связью — каждая улица, холм, здание, храм и тропа уже лежат в телефоне. Вот откуда берутся все эти данные, в каком виде хранятся и как мы их делаем.
У Dham Navigator одно жёсткое правило: он работает без связи. Где бы паломник ни оказался в дхаме — нет сети, нет проводника, симка не ловит — он всё равно увидит, где стоит, что здесь произошло и какой тропой идти дальше. Чтобы так вышло, нужно одно: всё уже лежит в телефоне до того, как паломник придёт. Улицы, холмы, дома, храмы, рассказы о местах, пешеходные маршруты — всё внутри приложения.
Отсюда и главный вопрос статьи: откуда берётся каждый кусок этих данных, в каком виде он лежит в телефоне и как мы его делаем? Данные у нас двух совсем разных видов.
Одни собираются автоматически из открытых карт мира — OpenStreetMap, модель рельефа Европейского космического агентства, слои зданий из Overture и Google. Улицы Вриндавана мы не рисуем вручную — мы их берём готовыми. Эти данные можно восстановить в любой момент: удалил, запустил скрипт — и они снова на месте.
Остальное пишем мы сами, и в этом душа путеводителя: какие места важны, что произошло в каждом из них, фотография, по которой узнаёшь храм ещё с дороги, традиционная парикрама. Такого не соберёт ни один скрипт.
Всё, о чём пойдёт речь ниже, мы собираем для каждого региона отдельно — пока это Вриндаван, Маяпур и Варшана — в одну папку файлов. Она входит в само приложение: качать по дороге ничего не нужно.
Общая картина
Один конфиг regions.json хранит для каждого региона только рамку (bbox) и
диапазон зума. На нём держится вся автоматическая часть:
{
"vrindavan": { "bbox": [77.66, 27.548, 77.73, 27.605], "maxzoom": 16,
"geofabrikZone": "asia/india/northern-zone", "terrain": true },
"barsana": { "bbox": [77.355, 27.63, 77.405, 27.67], "maxzoom": 16,
"geofabrikZone": "asia/india/northern-zone", "terrain": true }
}
Из него для каждого региона получается несколько файлов. Вот они все — что это и откуда берётся:
| Файл | Формат | Что это | Откуда |
|---|---|---|---|
tiles.pmtiles | PMTiles, векторные | базовая карта | автоматически — OSM |
terrain.pmtiles | PMTiles, raster-DEM | холмы | автоматически — Copernicus DEM |
buildings.geojson | GeoJSON, полигоны | контуры зданий и высота | автоматически — Overture + Google |
routing.json | JSON-граф | пешеходная сеть | автоматически — OSM через Overpass |
locations.geojson | GeoJSON, точки | отобранные места | вручную |
locations/<id>.json | JSON | фотографии места | вручную |
locations/<id>.<lang>.md | Markdown | история места | вручную |
routes.json | JSON | готовые парикрамы | вручную |
models.geojson + .glb | GeoJSON + glTF | 3D-модели достопримечательностей | вручную |
Дальше разберём каждый: сначала четыре файла, которые собираются сами, потом те, что мы пишем руками.
Базовая карта
Улицы, реки, гхаты, парки и подписи берутся из OpenStreetMap — открытой карты, на которой держится немалая часть интернета. Мы берём у Protomaps ежедневную глобальную сборку, вырезаем несколько квадратных километров вокруг каждого дхама и сохраняем как один файл PMTiles — архив векторных тайлов, около мегабайта на регион:
pmtiles extract https://build.protomaps.com/<date>.pmtiles \
tiles.pmtiles --bbox=77.355,27.63,77.405,27.67 --maxzoom=15
Разверните эти слои плоско — это основа, поверх которой ложится всё остальное: зелень холма Маангарх, синие кунды, переулки, дома:
То, что это один файл, важнее, чем кажется. Архив читается по кускам, поэтому MapLibre — движок отрисовки — берёт ровно тот фрагмент, что нужен для текущего экрана, прямо из памяти телефона, без сети. Благодаря этому карта целого городка умещается в мегабайте и открывается мгновенно даже в режиме полёта. Лицензия — ODbL проекта OpenStreetMap, приложение указывает её на экране «О программе».
Пешеходная сеть
Когда вы отмечаете две точки и приложение прокладывает между ними путь, оно не гадает — оно идёт по графу, который мы собрали из пешеходных троп OpenStreetMap. Скрипт запрашивает у Overpass API только проходимые пути: тротуары, дорожки, ступени, жилые переулки, грунтовки. Автомагистрали не берём — паломник идёт малыми дорогами. Из этих путей собирается компактный граф: каждый перекрёсток — узел, каждый отрезок дороги — ребро, вес которого равен его длине в метрах.
Запрос к Overpass — это просто фильтр по тегу highway в пределах рамки региона: оставить проходимые классы, отбросить остальное:
way["highway"~"^(footway|path|pedestrian|steps|living_street|residential|
service|track|unclassified|tertiary|secondary|primary|cycleway|bridleway)$"]
(s, w, n, e);
Этот граф, нарисованный прямо из файла, что лежит в приложении, и есть настоящая дорожная сеть Варшаны: ядро деревни и переулки, уходящие в поля:
На диске он совсем маленький — только координаты и связи, без лишней геометрии:
{ "bbox": [77.355, 27.63, 77.405, 27.67],
"nodes": [[77.2755, 27.6554], [77.2761, 27.6551], …],
"edges": [[0, 1, 64.0], [1, 2, 85.6], [2, 3, 118.4], …] }
Узел — это просто индекс в массиве; ребро задаётся как [from, to, metres], где
вес — расстояние по большому кругу между его концами, гаверсинус их широт и
долгот:
где — радиус Земли, 6371 км. Сеть Варшаны — это 1231 узел и 1252 ребра, весит она около 46 КБ; Вриндаван, город куда более плотный, тяжелее раз в десять. При загрузке приложение разворачивает это в список смежности и пускает по нему A* — а поскольку граф размечен по связным компонентам, концы маршрута притягиваются к одному и тому же достижимому «острову» дорог (так решается проблема Маяпура, где покрытие OSM пока с прорехами).
Холмы — карта высот, спрятанная в картинке
Варшана стоит на холмах. Деревня Шримати Радхарани поднимается над равниной, а на плоской карте этого не видно. Поэтому для Варшаны мы добавляем рельеф — его видно, и карту можно наклонять. А высоты, что стоят за ним, — самое хитрое во всех данных приложения.
Они берутся из Copernicus DEM, бесплатной глобальной модели высот Европейского космического агентства: на каждые 30 метров земли — измеренная высота над уровнем моря. Всё это лежит в файлах Cloud-Optimized GeoTIFF, которые мы читаем прямо по HTTPS, без полной загрузки. Загвоздка в том, что «высота» сама по себе не картинка, а наш движок понимает только картинки. Выход — кодировка Terrarium: каждую высоту упаковывают в красный, зелёный и синий каналы одного пикселя.
В файле это выглядит как бессмысленная зелёная каша. Расшифруйте формулой выше — и холмы на месте:

Слева — ровно те данные, что лежат в terrain.pmtiles: просто цветные байты.
Справа — те же пиксели после того, как телефон прогнал формулу: проступают
гребни Варшаны, от 160 метров на равнине до 285 на вершинах. Приложение
раскодирует это на лету, поднимает поверхность в трёхмерную сетку и накладывает
hillshade. Вриндаван и Маяпур на плоских равнинах Ямуны и Ганги рельеф не грузят
вовсе — там нет перепада высот, ради которого стоит тратить батарею.
Hillshade — освещение склонов
Карта высот — это просто числа. Чтобы глаз увидел в них рельеф, из той же DEM мы считаем hillshade. Для каждого пикселя смотрим на соседей и определяем, куда и насколько круто уходит склон — его уклон и экспозицию, — а потом освещаем всё под одним фиксированным углом солнца. Склон, повёрнутый к солнцу, становится светлым; отвёрнутый — тёмным. И плоская таблица чисел вдруг выглядит как холмы.
Конкретно: из квадрата высот 3×3 вокруг каждой ячейки (метод Хорна) берём градиент, а он даёт уклон и сторону, куда обращён склон, — экспозицию . Если солнце стоит под зенитным углом и азимутом , яркость ячейки — это одно скалярное произведение:
В коде это едва ли сложнее — именно так и получена картинка ниже:
// gradient from the 3×3 neighbourhood (a,b,c / d,·,f / g,h,i), z-exaggerated
const dzdx = ((c + 2*f + i) - (a + 2*d + g)) / (8 * cellMetres) * zFactor
const dzdy = ((g + 2*h + i) - (a + 2*b + c)) / (8 * cellMetres) * zFactor
const slope = Math.atan(Math.hypot(dzdx, dzdy))
const aspect = Math.atan2(dzdy, -dzdx)
let shade = Math.cos(zenith) * Math.cos(slope)
+ Math.sin(zenith) * Math.sin(slope) * Math.cos(azimuth - aspect)
shade = Math.max(0, shade) // 0 = shadow, 1 = full sun
Движок карты делает то же самое вживую, на GPU, прямо из тайлов Terrarium. Прогоните это по Варшане с холмом, что дал деревне имя, — и гребни парикрамы встают над равниной:

Построено для этой статьи прямо из Copernicus GLO-30 DEM: попиксельный уклон и экспозиция, освещённые с северо-запада. Слабая рябь — это модель поверхности ловит крыши и деревья.
А это уже не картинка: то же затенение считается прямо в вашем браузере из
настоящего terrain.pmtiles, и карту можно двигать:
Здания
Фигуры, что встают на карте, — это настоящие контуры зданий из Overture Maps, открытого набора данных, за которым стоят Meta, Microsoft, Amazon и другие. Одна команда скачивает все контуры внутри рамки:
overturemaps download --bbox=77.355,27.63,77.405,27.67 \
-t building -f geojson -o buildings.geojson
От каждого оставляем только геометрию и складываем всё в обычный GeoJSON — список полигонов. В Варшане их 7738. Раскрасьте их по высоте — и проступает вся деревня:
Каждый объект так же прост, как и выглядит: полигон и одно число:
{ "type": "Feature",
"properties": { "height": 8.1 },
"geometry": { "type": "Polygon",
"coordinates": [[[77.356037, 27.630412], [77.356112, 27.630492], …]] } }
Но откуда берётся эта height? Overture даёт нам форму каждого здания, но не
его высоту. За неё отвечают второй набор данных и небольшой алгоритм.
Как здание получает свою высоту
Высоты берутся из Google Open Buildings, где выложен растр — сетка пикселей, примерно по одному на каждые четыре метра. Каждый пиксель хранит примерную высоту здания в этой точке. Чтобы получить одну высоту для одного контура, мы накладываем полигон на растр, смотрим на все накрытые пиксели и берём самый высокий — тогда побеждает шикхара храма, а не двор рядом с ней:

Это зональный максимум: пройти все ячейки под контуром по сетке с шагом 2 метра и взять самую высокую. Иначе центр контура мог бы попасть во двор, а не в башню:
let best = 0 // metres
for (const [x, y] of cellsUnder(footprint, 2)) // 2 m sampling grid
best = Math.max(best, sampleHeight(x, y) ?? 0)
feature.properties.height = Math.round(best * 10) / 10
Это число становится properties.height, и карта выдавливает плоский контур в
трёхмерный блок такой высоты. Там, где данные не уверены — обычно это крупные
комплексы вроде большого храма, — высота приходит нулём, и мы ставим её вручную.
Высоты идут под лицензией
CC-BY-4.0, поэтому приложение
указывает Google Open Buildings рядом с Overture и OSM.
Часть, которую мы пишем вручную
Всё, что выше, даёт красивую и точную карту. Но не путеводитель. Эту часть мы пишем сами, место за местом.
Места, которые важны
locations.geojson — наш список отобранных мест: храмы, гхаты, кунды и самадхи.
Каждое — одна точка со стабильным id, полем kind и названием на английском и
русском. По этим точкам вы и нажимаете. Поверх дорожной сети 13 мест Варшаны
выглядят так:
{ "type": "Feature",
"properties": { "id": "maan-mandir", "kind": "temple",
"name:en": "Shri Maan Mandir", "name:ru": "Шри Ман Мандир" },
"geometry": { "type": "Point", "coordinates": [77.3655119, 27.6434345] } }
Эти координаты — наши, не собранные с чужих карт: часть сверена с OpenStreetMap, но большинство мы сняли GPS в поле и выписали из старых книг, по одной. Файл нарочно лёгкий — ровно чтобы нарисовать точку и чтобы работал поиск. Тяжёлое содержимое подгружается, только когда вы открываете место.
История и фотографии каждого места
Всё основное содержимое места лежит в двух файлах с одним и тем же id и
подгружается только тогда, когда открываешь карточку. Возьмём Шри Ман Мандир,
храм на холме Маангарх — мы подписали его на карте выше. Рассказ о нём — это просто
Markdown, locations/maan-mandir.en.md:
A temple on Maangarh hill marking the place where Srimati Radharani showed her
loving sulk (maan) toward Krishna. It overlooks Barsana and is a centre of daily
seva and kirtan.
По одному файлу на язык — чтобы поездка стала даршаном, а не просто осмотром
достопримечательностей. А обычный Markdown значит, что переводчик правит текст
напрямую, и правки понятно видны в диффе. Фотографии лежат рядом, в
locations/maan-mandir.json; у каждой перечислены варианты — полный оригинал и
миниатюра — с размерами:
{ "id": "maan-mandir",
"photos": [{
"description": { "en": "Maan Mandir on Maangarh hill", "ru": "Ман Мандир на холме Мангарх" },
"variants": [
{ "type": "original", "path": "photos/maan-mandir/01.original.jpg", "width": 1600, "height": 1067 },
{ "type": "thumbnail", "path": "photos/maan-mandir/01.thumbnail.jpg", "width": 500, "height": 333 }
] }] }
Сами фотографии — единственное, что не лежит внутри приложения: они раздули бы его до огромных размеров. Манифест перечисляет их по имени, а сами JPEG-и подгружаются с нашего сервера картинок, когда вы впервые открываете место, и потом навсегда остаются в кэше телефона.
Маршруты
routes.json хранит традиционные обходы — Вриндаван-парикраму и другие — как
список остановок по порядку. Остановка — это либо отобранное место, либо просто
опорная точка, которая задаёт форму пути. И весь маршрут идёт через тот же
пешеходный маршрутизатор, что и путь, который вы рисуете сами.
Модели достопримечательностей
Для самых важных храмов мы делаем больше, чем простой выдавленный блок: лепим
трёхмерную модель вручную, файл glTF .glb, и
ставим её на карту через крошечный models.geojson — там записано, где она стоит
и какого размера. Это только начало: пока одна модель, храм Мадана-мохана во
Вриндаване. Но идея та же, что и везде здесь: маленький файл данных, сделанный
один раз и работающий офлайн.
Как всё это сходится воедино
Две половины, одна карта. Первую собирают скрипты из открытых данных, вторую пишем мы. Вместе они дают карту одного региона — все слои в одном пакете:

А автоматическую половину разберём подробнее: каждый скрипт читает одну рамку из
regions.json и пишет один файл:

Вот и весь конвейер: открытые карты мира — для земли под ногами, наши руки — для всего, что делает эту землю святой. Всё это умещается в кармане и не просит связи.
Результат
Всё из этой статьи складывается в один кадр, нарисованный тем же движком MapLibre, что и в приложении. Снизу вверх слои идут в таком порядке:
flowchart TB M["Сетка рельефа — из DEM"] --> A["Заливки земли, воды и зелени"] A --> H["Hillshade"] H --> R["Дороги — белые линии"] R --> B["Здания — 3D-выдавливание"]
Холмы Copernicus, поднятые в сетку и затенённые hillshade; контуры зданий, вставшие на измеренные Google высоты; переулки OpenStreetMap, проведённые белым; кунды, выделенные водой — каждый слой из этой статьи в одном месте.
А вот и она вживую — Варшана, собранная в 3D прямо здесь, на странице. Карту можно вращать, приближать и наклонять — под наклоном проступает рельеф:
Ни тайл-сервера, ни сигнала — только горстка собранных нами файлов, отрисованная в вашем браузере ровно так же, как на телефоне в руке, глубоко в дхаме.
Часть проекта
Dham Navigator