История начинается с самой неблагодарной формулировки бага, какая бывает:
«Иногда при зуме карту дёргает».
Не «всегда», не «на таком‑то действии», не с шагами воспроизведения. Иногда. Причём — цитата — «иногда зум работает хорошо». Любой, кто ловил плавающие баги, сейчас сочувственно поморщился.
Закончилось это тем, что я выбросил картографический движок и переписал под другой всю карту сервиса. Рассказываю, как я туда доехал, что мерил по дороге и чем потом доказывал себе, что не сломал людям сохранённые маршруты.
Это четвёртая статья про мой планировщик походов; предыдущие читать необязательно, здесь всё самостоятельное.
Откуда взялся мост
Карта на сайте была на Leaflet — проверенная, простая, сотни мест в коде на неё завязаны. Но мне понадобился собственный векторный стиль: тропы заметнее дорог, горизонтали, текстуры скал, свои иконки перевалов и родников. Это MapLibre GL, то есть WebGL.
Переписывать всё ради одного слоя было немыслимо, поэтому я подключил официальный мост: MapLibre живёт внутри Leaflet как слой. Leaflet управляет камерой, мост транслирует её в GL‑карту. Решение абсолютно стандартное и много где работает.
И оно работало. Кроме зума, который иногда дёргало.
Четыре гипотезы, которые не подтвердились
Дальше — чистый детектив, и мне кажется, он полезнее вывода. Потому что все четыре неверные гипотезы были совершенно разумными.
Гипотеза 1: гонка при жесте щипком. В мосте есть обработка пинч‑зума, и там логично искать гонку между жестом и анимацией. Написал патч, который учитывает признак жеста. Эффекта — ноль. Причём дёргало и на колесе мыши, где никакого пинча нет; это стоило бы заметить раньше.
Гипотеза 2: браузер рисует без GPU. Классика: если WebGL свалился на программный рендер, всё именно так и выглядит. Проверил — и обнаружил, что Chrome действительно рисует софтом. Момент триумфа. Почистил кэш шейдеров, убедился, что GPU включился по‑настоящему... дёргание осталось. Гипотеза подтвердилась как факт, но оказалась не причиной — отдельный жанр ловушки: вы нашли настоящую проблему, она просто не та.
Гипотеза 3: целочисленный зум. Leaflet по умолчанию прыгает по целым уровням, а GL‑карта умеет дробные. Похоже на источник рассинхрона? Похоже. Поставил шаг 0.25 — не помогло даже на мелком шаге.
Гипотеза 4: просто тормозит. Её убивала сама формулировка бага: «иногда работает хорошо». Равномерный тормоз не бывает иногда. Это гонка.
Гипотеза 5, решающая. Выключил анимацию зума у Leaflet целиком — дёргание исчезло полностью. Вот и причина.
Механизм такой. Во время анимации зума Leaflet берёт снимок содержимого и растягивает его средствами CSS — дёшево и плавно. А настоящий GL‑кадр подменяет снимок только по завершении анимации. Пока содержимое — обычные тайлы‑картинки, разницы не видно. Но растянутый снимок WebGL‑канваса и честно отрисованный кадр — это заметно разные картинки, и момент подмены виден как рывок. «Иногда» — потому что зависит от того, успел ли GL отрисоваться к моменту подмены.
То есть дело не в производительности и не в настройках. Дело в шве между двумя моделями отрисовки.
Развилка
Вариантов было два, и оба неприятные.
Первый: оставить анимацию зума выключенной. Одна строчка. Но это опция карты, а не слоя — плавность зума пропадает на всём сайте, для всех. Меняем рывок на вечную рваность. Так себе сделка.
Второй: переписать мост так, чтобы GL‑камера синхронизировалась непрерывно. Правка вендорного кода, который потом придётся сопровождать самому и при каждом обновлении библиотек проверять заново.
Есть и третий, о котором я до этого старался не думать: убрать мост. Оставить один движок. Не «Leaflet, внутри которого MapLibre», а просто MapLibre.
Именно шов был причиной бага. Шва не будет — не будет и класса багов, который из него растёт. Плюс длинные треки на GL рисуются быстрее, а вся моя карта и так уже была стилем MapLibre — Leaflet, по сути, остался в роли рамки с кнопками зума.
Я выбрал третий.
Как переезжать так, чтобы потом спать
Вот тут начинается часть, которая мне самому нравится больше всего, потому что она про страх.
На сервисе живые люди: На тот момент 427 пользователей, 251 сохранённый маршрут. У каждого — сегменты, метки, цвета, разбивка по дням. Это данные, которые человек собирал руками и не сможет восстановить, если я их испорчу. Переписывание всего слоя работы с картой — ровно тот случай, когда что‑то ломается тихо и обнаруживается через месяц.
Схему базы я не трогал принципиально: переезд был только в отрисовке. Но «я вроде ничего не менял» — это не доказательство, это самоуспокоение.
Поэтому я сделал сверку на свежей копии боевой базы. Старая версия сайта и новая открывают каждый из 251 маршрута, правят, сохраняют и экспортируют — и результаты сравниваются байт в байт. Не «глазами похоже», а файл против файла; слепки вышли по 175 МБ и совпали целиком.
Отдельно — точка отката, подготовленная до выкладки, а не после начала пожара:
метка в git на последнем коммите старой версии;
копия базы на сервере, проверенная на целостность;
копия конфига веб‑сервера.
Откат — одна команда. Стоило это полчаса, и полчаса за спокойный сон в ночь после выкладки большой правки — это, по‑моему, лучший курс обмена в индустрии.

Что дал переезд, в цифрах
Дёргание ушло — как и ожидалось, вместе со швом. Но были и приятные побочные эффекты.
Километровые метки рисует карта, а не HTML. Раньше каждая отметка «5 км» была обычным элементом страницы, который браузер позиционировал поверх карты. Пока меток десяток — нормально. На треке 47 км в трёхмерном режиме кадр занимал 65 мс. После переноса меток в слой карты — 12 мс.
Свои обработчики мыши молчат, пока двигают камеру. Были провалы кадра до 572 мс — это когда карта уже поехала, а мой код всё ещё пересчитывает, что там под курсором. Теперь не пересчитывает.
Сетка рельефа берётся с 12-го зума вместо 14-го: 27 → 12 мс.
Заодно появилось то, что на связке двух движков было бы мучением: трёхмерный рельеф с наклоном и вращением, сравнение двух карт шторкой, миникарта, экспорт карты в PNG, слой крутизны склонов, тепловая карта по всем публичным маршрутам, проигрывание маршрута с записью видео.
И объёмные здания — причём они оказались бесплатными. Высоты уже лежали в архиве карты: замер на Москве показал 4145 зданий на экране, у 80% высота есть, медиана 14 м. Никакой пересборки тайлов, никакого трафика, кадры не изменились (78 против 82 при вращении камеры).
Но с ними есть тонкость, которая стоит отдельного абзаца.
Порядок слоёв решает больше, чем кажется
Слой трёхмерных зданий надо вставлять сразу за плоским слоем зданий — а не в конец списка, куда его кладёт рука.
Потому что все двадцать слоёв подписей идут после. Вставите дома в конец — и они накроют номера домов, названия организаций и вообще всё, что человек читает на карте. Дома будут красивые, карта — бесполезная.
Из той же серии — история с подписями. MapLibre расставляет их с конца списка слоёв: чем позже слой, тем выше приоритет при проверке перекрытия. Интуиция ровно обратная, и на этом я потерял немало времени, когда у меня месяц не показывались остановки транспорта.
А ещё была прекрасная в своей бессмысленности ошибка со второй картой в режиме сравнения. Я скопировал на неё источник и слой километровых меток — они не появились. Слой есть, источник есть, данные есть, меток нет.
Разгадка: плашки меток не лежат в спрайте, их рисует canvas по событию «нужной картинки нет». А обработчик этого события подписан на конкретный экземпляр карты. Вторая карта его не унаследовала и молча рисовала символы без картинок.
Вторая карта MapLibre не наследует ничего, что вы повесили на первую. Скопировать источники и слои — мало.
Там же рядом — мой любимый вид ошибок: вспомогательная функция добавления источника принимает карту третьим аргументом, а соседняя функция добавления слоя — четвёртым. Вызвал по образцу соседней, получил TypeError ... reading 'sources' в фоновом таймере, то есть в консоли и без внятного стека. Слой «не появился». Мораль: когда слой не появился — сначала проверьте, создался ли источник.
Тепловая карта и лучи через полрайона
Приятный бонус переезда — тепловая карта по публичным маршрутам, по мотивам того, как это сделано у Strava. И на ней сразу обнаружились странные прямые лучи, прошивающие район насквозь.
Причина — в форме данных. У меня есть фильтр, который выбрасывает подозрительно длинные прямые перегоны (человек не идёт 1,8 км по идеальной прямой, это значит, что маршрут нарисован, а не проложен). Фильтр требовал, чтобы между точками была промежуточная геометрия. А нарисованная вручную прямая лежит как одно ребро между двумя кликами — промежуточной геометрии у неё нет по определению. Такие отрезки фильтр не выбрасывал никогда.
Замер на боевой базе: 46 рёбер длиннее 800 м в 18 маршрутах, самое длинное — 1795 м. После починки — 0. Линий на карте стало 621 → 645, то есть ни один нормальный маршрут не потерялся.
Добавил заодно защиту от «рисования»: если больше 70% длины маршрута — прямые перегоны, в тепловую карту он не идёт вовсе. Рано или поздно кто‑нибудь напишет треком слово, и пусть он напишет его себе, а не всем. Есть же такие приколисты которые любят где не попадя писать слово йух, только наоборот. Ладно признаюсь честно, я сам когда то таким был:D
И одна оперативная деталь, которая на таких вещах кусается: ответ кэшируется на сутки, поэтому вместе с правкой логики обязательно поднимать ключ запроса. Иначе вы почините и ещё сутки будете смотреть на старые данные из собственного браузера, постепенно сомневаясь в реальности.
Что удалили
Отдельно скажу про то, чего в итоге нет, потому что удалять работающий код полезно и мало кто про это пишет.
Были слои погоды: облачность со спутника и осадки. Сделаны, работали, отображались — и «отображается непонятно как». Осадки поверх походной карты без легенды и без времени съёмки — это не информация, это цветные пятна. Удалил вместе с серверным прокси.
Отладочные инструменты карты убрал из общего интерфейса — теперь они появляются только по специальному параметру в адресе. Хорошая привычка: вещи «для себя» не должны жить в интерфейсе для людей, даже если они маленькие и никому не мешают.
Маленькая грабля на выкладке
Уже после переезда я заметил, что веб‑сервер отдаёт наружу файлы самопроверок рядом с обычными скриптами. Не секрет, но и не то, что стоит публиковать.
Добавил правило‑запрет — и оно не сработало. Потому что побеждает первое подошедшее регулярное правило, а у меня выше уже стояло общее правило для скриптов. Переставил запрет выше — заработало.
Универсальное: запрет, который стоит после разрешения, — это не запрет, а украшение конфига.

Итог
Три вещи, которые я забираю с собой:
1. Ищите шов. Когда баг «иногда», подозревайте не код, а границу между двумя системами, у которых разные представления о времени и об отрисовке. Мой рывок жил ровно на стыке CSS‑анимации и WebGL‑кадра, и никакая настройка его убрать не могла, потому что настройки были не при чём.
2. Подтвердившаяся гипотеза — ещё не причина. Я нашёл, что Chrome рисует софтом. Это была правда. Это была не та правда.
3. Перед большой правкой сделайте себе способ доказать, что ничего не сломалось. Не «проверил глазами десяток случаев», а прогон всех живых данных через старую и новую версию со сравнением байт в байт. Это заняло у меня меньше времени, чем детектив с зумом, и дало то, чего детектив не даёт вовсе, — спокойствие.
Сервис живёт по адресу putemer.ru. Карта там теперь на одном движке, зум не дёргается, а дома в трёхмерном режиме стоят под подписями, а не поверх.

