С этой недели светлая и тёмная карты в Cuando — наши. Данные из OpenStreetMap, картинки рисует наш собственный сервер по нашим же стилям, а хранятся они в Cloudflare. Спутник по-прежнему берём у Mapbox. Если ты пользуешься приложением и ничего не заметил, так и было задумано.
Это история о том, как мы к этому пришли. Если оглянуться на последние полгода, каждое наше решение про карту вытекало из одного: как за карту брали деньги.
Пять дней как у людей
Самой первой картой, ещё в августе, был фирменный Flutter SDK от Mapbox. Нативный, векторный, плавный: именно так карту в приложение и полагается встраивать.
Продержался он пять дней. Причины скучные. Плагин не работал в веб-сборке, где десктопные браузеры получали вежливое «пока не поддерживается плагином карт», а мы хотим одну кодовую базу для Android, iOS и веба. Вдобавок сборке под Android нужен был специальный токен Mapbox для скачивания, а наш был не того типа.
Так что мы переехали на flutter_map. Он работает везде, потому что делает очень простую вещь: раскладывает сеткой квадратные картинки карты — их называют тайлами — и подгружает новые, пока ты двигаешь карту и зумишь. Картинки брали у Mapbox: стиль Navigation Day для светлой темы, Navigation Night для тёмной, плюс Streets и Satellite.
Арифметика
Через две недели мы сели считать и расписали всё в issue с предложением вернуться на SDK. Mapbox берёт деньги за SDK и за тайлы совершенно по-разному:
- Мобильный SDK оплачивается по числу активных пользователей за месяц, и первые 25 000 в месяц бесплатны.
- Static Tiles — те самые картинки, которые показывал flutter_map, — оплачиваются по запросам, и первые 200 000 запросов в месяц бесплатны.
Кажется, что запросов куча, пока не начнёшь их считать: каждый сдвиг и каждый зум требует целый экран свежих тайлов. Как написано в самом issue: «лимит Static Tiles в 200k запросов в месяц закончится раньше, чем у нас вообще появятся пользователи».
И это ещё был оптимистичный прогноз. Мы втроём одними тестами без труда выбирали всю бесплатную месячную квоту и с тех пор до этой самой недели платили Mapbox $10–30 в месяц за приложение, у которого почти не было пользователей. На SDK трое человек не стоили бы ничего. Но с SDK мы только что ушли, и по веским причинам. Тайлы вдвое крупнее (512 пикселей вместо 256, так что на экран их нужно вчетверо меньше) немного смягчили удар. А по-настоящему помочь могло только одно: перестать платить за каждый тайл.
Неделя, когда мы попробовали вектор
Очевидное решение — векторные тайлы. Вместо картинок сервер отдаёт карту как данные — дороги, здания, подписи, — а рисует её уже сам телефон. Тайлы крошечные, смена стиля — это правка JSON, а подписи не заваливаются набок, когда крутишь карту.
В ноябре мы попробовали модуль векторных тайлов для flutter_map, только на мобильных (веб остался на картинках). Технически всё заработало на обеих платформах. На айфоне — без проблем. На Android карта лагала и дёргалась, и можно было смотреть, как она дорисовывается, тайл за тайлом.
Автобусное приложение открывают на остановке, с того телефона, какой уж есть, и многие из этих телефонов — Android-середнячки. Через неделю вектор выпилили.
Не рисуй то, на что никто не смотрит
Значит, картинки. У картинок есть одно мерзкое свойство: на каждом уровне зума тайлов в четыре раза больше, чем на предыдущем. Уровень улиц, который нужен большинству, — это примерно 18-й или 19-й зум, и именно там почти все тайлы.
Но большая часть нашего региона — это заросли кустарника, поля, два солёных озера (одно из них розовое) и море. Никому не нужно разглядывать солёное озеро на 19-м зуме. И запросить его тоже должно быть нельзя: каждый тайл, который можно запросить, — это тайл, который кому-то придётся скачать, оплатить или нарисовать.
Поэтому 12 февраля сервер начал считать сетку зума. Это грубая сетка поверх региона, и в каждой ячейке лежит одно число: насколько глубоко там разрешено зумить. Правила простые:
- Везде для начала стоит зум 13: этого хватает, чтобы увидеть, где какие города.
- Ячейка, у которой в пределах пары километров от центра есть остановка, открывается до самого 22, а её соседи получают 16.
- Ячейка, через которую проходит трасса маршрута, получает 16, а её соседи — около 15.
- Всегда побеждает большее значение. Ячейку никогда не понижают.
В приложение сетка приезжает маленьким protobuf-файлом. Приложение смотрит, где сейчас центр экрана, и ограничивает зум по этой точке, интерполируя между соседними ячейками, чтобы лимит плавно скользил, а не прыгал. А ещё карту больше нельзя утащить за пределы региона.

Это 54 × 72 ячейки примерно по 800 метров на площади около 2 560 км², и только 452 из 3 888 ячеек разрешают хоть что-то глубже зума 13. Если посчитать тайлы с 13-го по 19-й зум, у которых центр попадает туда, где сетка этот зум разрешает, выходит около 40 000 из 949 000: примерно 4%, то есть в 24 раза меньше, чем вся пирамида.
Больше всего времени ушло на подкрутку: в каком радиусе от остановки можно зумить до упора, насколько широким делать коридор вдоль трассы и где проходит граница «достаточно» для городка, через который ты просто проезжаешь.
Без этой сетки следующий шаг был бы просто немыслим.
Рисуем сами
14 февраля мы взялись за собственный сервер карт. В первый же вечер в ход пошли сразу два подхода:
- стандартный набор: tilemaker, чтобы нарезать данные OpenStreetMap на векторные тайлы, и tileserver-gl, чтобы их раздавать и рендерить из них картинки;
- скрипт на сорок строк, который рендерил один тайл напрямую через MapLibre Native. tileserver-gl и сам под капотом рендерит на MapLibre Native.
tilemaker остался отвечать за данные. А вторую половину забрал скрипт. Через три дня он стал маленьким сервером на Express, а к концу недели его ядро в слегка урезанном виде выглядело вот так:
app.get('/tiles/:style/:size/:z/:x/:y', async (req, res) => {
// …parse z, x, y and the size…
const center = mercator.ll([(x + 0.5) * 256, (y + 0.5) * 256], z)
const map = new maplibre.Map({ ratio: 1.0 })
map.load(styles[req.params.style])
map.render({ zoom: z, center, width: size, height: size }, (err, rgba) => {
map.release()
const png = new PNG({ width: size, height: size })
png.data.set(rgba)
res.set('Content-Type', 'image/png')
png.pack().pipe(res)
})
})
Каждый запрос собирает новую карту, наводит окно в 512 пикселей на центр запрошенного тайла, рендерит и отдаёт PNG. MapLibre Native — это тот же C++-рендерер, что сидит внутри мобильных SDK MapLibre, и ему нужен OpenGL-контекст. У сервера экрана нет, поэтому всё это крутится в контейнере под Xvfb — виртуальным дисплеем.
Стили переделаны из Positron от OpenMapTiles для светлой темы и Dark Matter для тёмной. По дороге мы попробовали ещё несколько (OSM Bright, Liberty, один туристический, один ночной) и оставили Positron и Dark Matter: они специально сделаны так, чтобы тихо лежать под тем, что рисуешь сверху.
А теперь самое смешное. На вход рендерер получает векторную карту региона, которую и так уже отдаёт наш собственный API: OpenStreetMap, который tilemaker нарезал на векторные тайлы. То есть у нас есть совершенно нормальная векторная карта и сервер, который превращает её в картинки, потому что телефоны не успевают рисовать её сами. Да, я в курсе.
Перед рендерером стоит Cloudflare Worker, и достучаться до рендерера может только он. Для каждого тайла он сначала заглядывает в R2, объектное хранилище Cloudflare. Если там пусто, он просит тайл у рендерера, отдаёт PNG телефону и по дороге кладёт копию в R2. Так что каждый тайл рисуется ровно один раз — для того, кто первым на него посмотрит.
Это важно, потому что рисовать долго. Железке, на которой крутится рендерер, нужно около трёх секунд на тайл, и одновременно она тянет штуки четыре. Первый, кто доберётся зумом до новой улицы, ждёт, а все после него — уже нет. С миллионом возможных тайлов ждать пришлось бы очень долго. С сорока тысячами — нормально.

Переезд
Со стороны приложения переезд вышел почти скучным. И это нарочно: наши URL тайлов устроены так же, как у Mapbox, /{style}/512/{z}/{x}/{y}@2x, поэтому для flutter_map новая карта — это другой шаблон URL и другой токен доступа.
Теперь каждый стиль знает, откуда он берётся. Светлый и тёмный — наши. Спутник остаётся у Mapbox, потому что нам не из чего его рисовать. Streets больше нет. А логотип Mapbox виден только тогда, когда ты смотришь на тайлы Mapbox. Последний кусок наш Flutter-разработчик выкатил незадолго до двух ночи — говорят, это традиционное время.
Чего это стоит
- Деньги: Mapbox, только за спутник. Хранилище R2 и запросы к Workers обходятся в копейки. Рендерер крутится на маленькой виртуалке, которая у нас и так была.
- Время: три секунды для первого, кто смотрит на тайл, который до него ещё никто не видел.
- Смена внешнего вида: вот она, настоящая цена. В картинку стиль запечён намертво. Поменять цвет — значит выкинуть все сохранённые тайлы и нарисовать их заново, и всё это дважды, потому что тем две.
Что дальше
- Рисовать заранее, чтобы никто не ждал эти три секунды. Сетка зума точно говорит, какие тайлы рисовать.
- Логировать, какие тайлы люди реально запрашивают, и пришли ли они из хранилища или их пришлось рисовать. Выкатываем завтра.
- Когда-нибудь — вектор. На сервере карта уже векторная. Осталось уговорить телефоны.
- По времени всё сошлось. К тому моменту, когда мы всерьёз начали рассказывать людям про Cuando, все карты в нём, кроме спутника, были наши.
- Картинки никуда не делись. Flutter-приложение до сих пор рисует наши растровые тайлы, и сетка зума по-прежнему едет вместе с ним. Теперь её генерирует API, на площади побольше, и в нашей админке у неё появилась своя вкладка специально для подкрутки.
- Рисовать заранее начали в апреле 2026 года. Зумы с 13-го по 19-й в прямоугольнике сетки — это 944 382 тайла на тему. Виртуалке понадобилось бы около восьми дней на тему, поэтому пререндер крутится на моём Mac mini: от 10 до 12 часов на тему, около 15 ГБ на каждую. Виртуалка дорисовывает только то, чего не хватает.
- Вектор всё равно победил, просто не во Flutter. Весь регион одним векторным архивом (файл PMTiles) весит 58,67 МБ. Это примерно в 255 раз меньше одной растровой темы, и этого одного архива хватает на все темы сразу: новая тема — это файл стиля на 22 КБ. Worker раздаёт его прямо из R2 и вычитывает для каждого тайла только нужные байты. Железка для рендера тут вообще не нужна. Глубже максимального зума архива телефон просто растягивает векторные данные, так что векторной карте и сетка зума не нужна.
- Где он используется: наша админка переехала на MapLibre в мае 2026 года. Новые приложения, переписанные на Swift со Skip, рисуют векторные карты: на Android — MapLibre на наших тайлах, на iOS — MapKit от Apple.
- Во Flutter вектор попробовали ещё раз в 2026-м, с включённым GPU-рендерингом. На Redmi-середнячке всё сразу же упало. Это уже история для второго поста про карты.
- Лимит зума так и не заскользил. Приложение переводит позицию в целые номера ячеек ещё до того, как «интерполировать» между ними, так что всегда читает ровно одну ячейку, и лимит всё-таки прыгает. Полтора года этого никто не замечал.
- Спутник — последнее, что мы брали у Mapbox, — убрали из выбора карт в августе 2026 года.