- PVSM.RU - https://www.pvsm.ru -
Мы делаем карту загруженности московских парковок — 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 [1], на Kaggle [2] и на Hugging Face [3].
Автор: cmtn
Источник [4]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/karty/458540
Ссылки в тексте:
[1] на GitHub: https://github.com/matrosovcmtn/moscow-parking-occupancy
[2] на Kaggle: https://www.kaggle.com/datasets/danilmatrosov/moscow-parking-occupancy
[3] на Hugging Face: https://huggingface.co/datasets/matrosovdani/moscow-parking-occupancy
[4] Источник: https://habr.com/ru/articles/1085300/?utm_source=habrahabr&utm_medium=rss&utm_campaign=1085300
Нажмите здесь для печати.