История начинается с самой неблагодарной формулировки бага, какая бывает:

«Иногда при зуме карту дёргает».

Не «всегда», не «на таком‑то действии», не с шагами воспроизведения. Иногда. Причём — цитата — «иногда зум работает хорошо». Любой, кто ловил плавающие баги, сейчас сочувственно поморщился.

Закончилось это тем, что я выбросил картографический движок и переписал под другой всю карту сервиса. Рассказываю, как я туда доехал, что мерил по дороге и чем потом доказывал себе, что не сломал людям сохранённые маршруты.

Это четвёртая статья про мой планировщик походов; предыдущие читать необязательно, здесь всё самостоятельное.

Откуда взялся мост

Карта на сайте была на 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. Карта там теперь на одном движке, зум не дёргается, а дома в трёхмерном режиме стоят под подписями, а не поверх.

Комментарии (0)