javascript

Карта чёрная, в консоли ни одной ошибки. Три бага одной подложки

  • среда, 23 сентября 2026 г. в 00:00:05
https://habr.com/ru/articles/1085300/

Мы делаем карту загруженности московских парковок — parkout.ru. Раз в полчаса собираем данные по 210 муниципальным парковкам, показываем теплокарту и прогноз.

Подложка у нас была растровая, от Esri. И она была мыльная. На максимальном зуме вместо домов — кашица из пикселей, подписи не читаются. Стали разбираться: оказалось, что тайлы z17 и z18 приходят одинаковыми — байт в байт, 2521 байт каждый. Это заглушка. Выше z16 у этого слоя данных просто нет, а сервер вместо 404 отдаёт растянутую картинку.

Решение очевидное: переходим на векторные тайлы. Взяли OpenFreeMap (стиль positron), MapLibre GL и адаптер @maplibre/maplibre-gl-leaflet, чтобы вписать векторный слой в уже работающий Leaflet.

Собрали. Открыли.

Чёрный экран.

Не «карта не загрузилась», не серый фон с сеткой — именно чёрный прямоугольник. Поверх него нормально рисуется наша теплокарта и маркеры парковок. То есть Leaflet живой, слои живые, а подложки нет.

В консоли — ни одной ошибки. Ни одного предупреждения. Идеально чистый лог.

Отладка по отсутствию

Когда что-то падает с исключением, у вас есть стек. Когда что-то отдаёт 500, у вас есть статус. Здесь не было ничего — и это оказалось самой полезной уликой.

Первое, что я сделал — полез в Network и стал искать запросы за тайлами. Векторные тайлы приходят как .pbf. Отфильтровал по pbf.

Пусто.

Не «запросы с ошибкой», не «запросы, которые висят». Их не было вообще. Карта даже не пыталась ничего загрузить.

Дальше проверил, что думает о себе сам MapLibre:

setTimeout(() => console.log('loaded:', map.loaded()), 4000)
// loaded: false

Через четыре секунды после рендера карта считает, что она не загружена. При этом молчит.

Тут я начал смотреть, что вообще уехало в бандл — и обнаружил, что воркера MapLibre в сборке нет. Ни одного чанка с ним.

Почему воркер не попал в сборку

MapLibre GL — многопоточная библиотека. Парсинг векторных тайлов, построение геометрии, раскладка подписей — всё это уезжает в Web Worker, чтобы не блокировать основной поток. Без воркера рисовать нечего: тайлы никто не разберёт. Более того, библиотека даже не начнёт их запрашивать.

Воркер внутри подключается примерно так:

new URL(`./${workerFile}`, import.meta.url)

И вот здесь ломается всё. Связка new URL(..., import.meta.url) — это тот паттерн, который Rollup и Vite специально распознают, чтобы затащить файл в граф сборки и выдать ему правильный путь. Но распознают они его только тогда, когда первый аргумент — статический литерал. А здесь шаблонная строка с подстановкой. Проанализировать её на этапе сборки невозможно: значение переменной известно только в рантайме.

Сборщик пожимает плечами и оставляет выражение как есть. Файл в бандл не попадает. В рантайме конструируется путь к файлу, которого на сервере нет.

Дальше — самое неприятное. Загрузка воркера падает, но это происходит внутри конструктора Worker, в асинхронном контексте, и MapLibre эту ошибку не пробрасывает наружу. Карта просто остаётся в состоянии «инициализируюсь» навсегда. Отсюда и чёрный экран при чистой консоли.

Лечится явным указанием пути. Vite умеет отдавать URL воркера, если попросить прямо:

import workerUrl from 'maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url'

const { setWorkerUrl } = await import('maplibre-gl')
setWorkerUrl(workerUrl)

Плюс одна строчка в vite.config.ts, иначе воркер соберётся в формате IIFE и развалится на первом import:

export default defineConfig({
  worker: { format: 'es' },
})

После этого в Network посыпались .pbf, и карта наконец нарисовалась.

Вывод, который я забрал: отсутствие запроса — это тоже сигнал, и иногда более информативный, чем ошибка. Если бы запрос за тайлами уходил и падал, я бы увидел его за десять секунд. А вот «запросов нет вообще» сразу отсекает половину гипотез: проблема не в сети, не в CORS, не в ключе API и не в стиле. Проблема до того, как что-либо вообще пытается загрузиться.

Баг второй: смена города убивает карту

Радость была недолгой. Мы как раз добавляли второй город (Мельбурн — там открытые данные по датчикам парковки), и обнаружилось: переключаешь город — карта умирает. Насовсем, до перезагрузки страницы.

Механика такая. Переключение города меняет ключ запроса. TanStack Query честно переходит в состояние pending. В шаблоне на это состояние повешен спиннер — значит, блок с картой размонтируется, и Leaflet уничтожает свой инстанс. А эффекты, которые следят за выбранным городом, в этот же момент вызывают flyTo на уже мёртвом объекте.

Дальше начинается интересное. Исключение внутри эффекта Svelte прерывает весь flush эффектов, а не только тот, в котором оно случилось. Поэтому ломается не одна анимация, а вообще всё, что должно было отработать в этом цикле.

Лечение из двух частей. Первая — не размонтировать карту вообще:

createQuery({
  queryKey: ['occupancy', city],
  placeholderData: keepPreviousData,
})

С keepPreviousData запрос не проваливается в pending: пока грузятся новые данные, показываются старые. Спиннер не появляется, карта не размонтируется.

Вторая — перестать доверять ссылке на карту. Инстанс может быть жив как объект, но уже оторван от DOM:

const isMapAlive = (map) => Boolean(map?.getContainer()?.isConnected)

Проверка на isConnected ловит ровно тот случай, когда Leaflet уже уничтожен, а ссылка в замыкании осталась.

Баг третий: локализация, которая не наступает

Третья мелочь на ту же тему. Подписи на OpenFreeMap по умолчанию на локальном языке — по Москве это нормально, а вот по Мельбурну нам хотелось единообразия. У MapLibre для этого есть setLayoutProperty('...', 'text-field', ...), но применять его нужно после того, как стиль загрузился.

Штатный способ:

map.on('load', localizeLabels)
// или
if (map.isStyleLoaded()) localizeLabels()

Внутри Leaflet-слоя не работает ни то, ни другое. Событие load не наступает никогда, isStyleLoaded() стабильно возвращает false — адаптер управляет жизненным циклом карты сам, и до этих хуков дело не доходит.

Попробовал повесить на styledata — подписи локализовались, но сломался рендер тайлов: перезапись слоёв во время отрисовки приводила к тому, что часть тайлов оставалась пустой.

В итоге сделал проще всего: забрал стиль сам, поправил JSON и отдал MapLibre уже готовым.

let styleOption = styleUrl
try {
  const response = await fetch(styleUrl)
  if (response.ok) styleOption = localizeStyle(await response.json(), lang)
} catch {
  // не получилось — отдаём URL как есть, подписи будут локальные
}

layer = maplibreGL({ style: styleOption, attributionControl: false })

Карта получает стиль, который уже правильный. Никаких гонок, никаких хуков, которые не наступают. И деградация честная: не скачался стиль — работаем с URL, подписи будут на местном языке, но карта будет.

Что забрать с собой

Три бага, и все три — про одно и то же: состояние, в котором ничего не происходит, отлаживать труднее, чем состояние, в котором что-то падает.

  • Если сборщик не видит статический литерал в new URL(..., import.meta.url), файл в бандл не попадёт. Молча.

  • Если библиотека прячет ошибку инициализации, единственный признак — отсутствие ожидаемой активности. Смотрите не только на то, что в логе есть, но и на то, чего в нём нет.

  • Исключение в эффекте Svelte рвёт весь flush. Один мёртвый объект в замыкании ломает функциональность, которая с ним никак не связана.

  • Библиотека, обёрнутая в адаптер, теряет часть своего жизненного цикла. События, которые в документации описаны как обязательные, могут не наступить ни разу.

Карта сейчас живёт на parkout.ru — векторная, чёткая, с двумя городами. Данные по Москве мы заодно выложили открытым датасетом: 4,6 млн получасовых замеров с марта 2025-го, на GitHub, на Kaggle и на Hugging Face.