← Журнал
Jiva Studio

Откуда берётся карта паломника

Dham Navigator работает с выключенной связью — каждая улица, холм, здание, храм и тропа уже лежат в телефоне. Вот откуда берутся все эти данные, в каком виде хранятся и как мы их делаем.

Dham NavigatorProduct

У 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.pmtilesPMTiles, векторныебазовая картаавтоматически — OSM
terrain.pmtilesPMTiles, raster-DEMхолмыавтоматически — Copernicus DEM
buildings.geojsonGeoJSON, полигоныконтуры зданий и высотаавтоматически — Overture + Google
routing.jsonJSON-графпешеходная сетьавтоматически — OSM через Overpass
locations.geojsonGeoJSON, точкиотобранные меставручную
locations/<id>.jsonJSONфотографии меставручную
locations/<id>.<lang>.mdMarkdownистория меставручную
routes.jsonJSONготовые парикрамывручную
models.geojson + .glbGeoJSON + glTF3D-модели достопримечательностейвручную

Дальше разберём каждый: сначала четыре файла, которые собираются сами, потом те, что мы пишем руками.

Базовая карта

Улицы, реки, гхаты, парки и подписи берутся из 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], где вес — расстояние по большому кругу между его концами, гаверсинус их широт и долгот:

d=2Rarcsinsin2Δφ2+cosφ1cosφ2sin2Δλ2d = 2R\arcsin\sqrt{\sin^2\tfrac{\Delta\varphi}{2} + \cos\varphi_1\cos\varphi_2\sin^2\tfrac{\Delta\lambda}{2}}

где RR — радиус Земли, 6371 км. Сеть Варшаны — это 1231 узел и 1252 ребра, весит она около 46 КБ; Вриндаван, город куда более плотный, тяжелее раз в десять. При загрузке приложение разворачивает это в список смежности и пускает по нему A* — а поскольку граф размечен по связным компонентам, концы маршрута притягиваются к одному и тому же достижимому «острову» дорог (так решается проблема Маяпура, где покрытие OSM пока с прорехами).

Холмы — карта высот, спрятанная в картинке

Варшана стоит на холмах. Деревня Шримати Радхарани поднимается над равниной, а на плоской карте этого не видно. Поэтому для Варшаны мы добавляем рельеф — его видно, и карту можно наклонять. А высоты, что стоят за ним, — самое хитрое во всех данных приложения.

Они берутся из Copernicus DEM, бесплатной глобальной модели высот Европейского космического агентства: на каждые 30 метров земли — измеренная высота над уровнем моря. Всё это лежит в файлах Cloud-Optimized GeoTIFF, которые мы читаем прямо по HTTPS, без полной загрузки. Загвоздка в том, что «высота» сама по себе не картинка, а наш движок понимает только картинки. Выход — кодировка Terrarium: каждую высоту упаковывают в красный, зелёный и синий каналы одного пикселя.

elevation=R×256+G+B25632768\text{elevation} = R \times 256 + G + \frac{B}{256} - 32768

В файле это выглядит как бессмысленная зелёная каша. Расшифруйте формулой выше — и холмы на месте:

Карта высот: те же данные в виде сырых закодированных байтов слева и расшифрованные в рельеф Варшаны справа

Слева — ровно те данные, что лежат в terrain.pmtiles: просто цветные байты. Справа — те же пиксели после того, как телефон прогнал формулу: проступают гребни Варшаны, от 160 метров на равнине до 285 на вершинах. Приложение раскодирует это на лету, поднимает поверхность в трёхмерную сетку и накладывает hillshade. Вриндаван и Маяпур на плоских равнинах Ямуны и Ганги рельеф не грузят вовсе — там нет перепада высот, ради которого стоит тратить батарею.

Hillshade — освещение склонов

Карта высот — это просто числа. Чтобы глаз увидел в них рельеф, из той же DEM мы считаем hillshade. Для каждого пикселя смотрим на соседей и определяем, куда и насколько круто уходит склон — его уклон и экспозицию, — а потом освещаем всё под одним фиксированным углом солнца. Склон, повёрнутый к солнцу, становится светлым; отвёрнутый — тёмным. И плоская таблица чисел вдруг выглядит как холмы.

Конкретно: из квадрата высот 3×3 вокруг каждой ячейки (метод Хорна) берём градиент, а он даёт уклон SS и сторону, куда обращён склон, — экспозицию AA. Если солнце стоит под зенитным углом ZZ и азимутом AA_\odot, яркость ячейки — это одно скалярное произведение:

shade=cosZcosS+sinZsinScos(AA)\text{shade} = \cos Z \cos S + \sin Z \sin S \cos(A_\odot - A)

В коде это едва ли сложнее — именно так и получена картинка ниже:

// 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. Прогоните это по Варшане с холмом, что дал деревне имя, — и гребни парикрамы встают над равниной:

Hillshade Варшаны, порождённый из Copernicus DEM — холм Варшаны и гребни парикрамы, затенённые северо-западным солнцем

Построено для этой статьи прямо из 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 и пишет один файл:

Производный конвейер: рамка из regions.json запускает четыре скрипта, каждый из которых порождает один файл

Вот и весь конвейер: открытые карты мира — для земли под ногами, наши руки — для всего, что делает эту землю святой. Всё это умещается в кармане и не просит связи.

Результат

Всё из этой статьи складывается в один кадр, нарисованный тем же движком MapLibre, что и в приложении. Снизу вверх слои идут в таком порядке:

flowchart TB
  M["Сетка рельефа — из DEM"] --> A["Заливки земли, воды и зелени"]
  A --> H["Hillshade"]
  H --> R["Дороги — белые линии"]
  R --> B["Здания — 3D-выдавливание"]

Холмы Copernicus, поднятые в сетку и затенённые hillshade; контуры зданий, вставшие на измеренные Google высоты; переулки OpenStreetMap, проведённые белым; кунды, выделенные водой — каждый слой из этой статьи в одном месте.

А вот и она вживую — Варшана, собранная в 3D прямо здесь, на странице. Карту можно вращать, приближать и наклонять — под наклоном проступает рельеф:

Ни тайл-сервера, ни сигнала — только горстка собранных нами файлов, отрисованная в вашем браузере ровно так же, как на телефоне в руке, глубоко в дхаме.


Часть проекта

Dham Navigator

Открыть проект