Оптимизация перерисовки

Визуализация карт в Mapbox GL JS основана на использовании WebGL. Несмотря на высокую производительность графического ускорения, каждая операция обновления состояния карты требует затрат вычислительных ресурсов. При работе с большими объёмами данных, множеством слоёв и частыми изменениями источников данных возникает риск появления задержек интерфейса, падения FPS и повышенного потребления памяти.

Основные факторы, влияющие на производительность:

  • большое количество объектов на карте;
  • частое обновление GeoJSON-источников;
  • избыточное количество слоёв;
  • сложные выражения стилей;
  • частые вызовы методов изменения состояния карты;
  • использование тяжёлых пользовательских изображений и иконок;
  • чрезмерное количество обработчиков событий.

Оптимизация перерисовки направлена на уменьшение количества операций, которые движок должен выполнять между кадрами.


Как работает цикл рендеринга

После создания объекта карты запускается цикл визуализации.

const map = new mapboxgl.Map({
    container: 'map',
    style: 'mapbox://styles/mapbox/light-v11',
    center: [37.6176, 55.7558],
    zoom: 10
});

Перерисовка выполняется при следующих событиях:

  • перемещение карты;
  • изменение масштаба;
  • вращение;
  • обновление источника данных;
  • изменение параметров слоя;
  • добавление или удаление объектов;
  • изменение размеров контейнера.

Каждый кадр проходит несколько этапов:

  1. Обработка пользовательских событий.
  2. Обновление состояния карты.
  3. Подготовка данных для GPU.
  4. Выполнение команд WebGL.
  5. Отрисовка финального изображения.

Чем меньше изменений происходит между кадрами, тем выше производительность.


Минимизация обновлений GeoJSON

Наиболее распространённая причина просадки производительности — постоянное обновление GeoJSON-источников.

Неэффективный вариант:

setInterval(() => {
    source.setData(generateLargeGeoJSON());
}, 100);

В этом примере каждые 100 миллисекунд создаётся новый набор данных. Mapbox GL JS вынужден заново анализировать структуру GeoJSON, строить внутренние индексы и пересчитывать отображение объектов.

Особенно критично это становится при работе с десятками тысяч геометрий.

Снижение частоты обновлений

Лучше обновлять данные только при реальном изменении.

function updateData(data) {
    if (hasChanges(data)) {
        source.setData(data);
    }
}

Подобный подход уменьшает количество полных перестроений источника.


Использование debounce

При поступлении большого количества событий обновления полезно применять отложенный вызов.

function debounce(fn, delay) {
    let timer;

    return (...args) => {
        clearTimeout(timer);

        timer = setTimeout(() => {
            fn(...args);
        }, delay);
    };
}

Пример использования:

const updateMapData = debounce((data) => {
    source.setData(data);
}, 300);

Если данные приходят десятки раз в секунду, фактическое обновление карты будет происходить значительно реже.


Использование throttle

Для анимаций и потоковых данных чаще подходит ограничение частоты вызова.

function throttle(fn, interval) {
    let lastCall = 0;

    return (...args) => {
        const now = Date.now();

        if (now - lastCall >= interval) {
            lastCall = now;
            fn(...args);
        }
    };
}

Пример:

const updatePosition = throttle((data) => {
    source.setData(data);
}, 100);

Карта будет обновляться не чаще десяти раз в секунду.


Уменьшение количества слоёв

Каждый слой требует отдельного прохода рендеринга.

Неэффективно:

map.addLayer({
    id: 'restaurants',
    type: 'circle',
    source: 'places'
});

map.addLayer({
    id: 'cafes',
    type: 'circle',
    source: 'places'
});

map.addLayer({
    id: 'bars',
    type: 'circle',
    source: 'places'
});

Более эффективный вариант — объединение категорий в один слой.

map.addLayer({
    id: 'places',
    type: 'circle',
    source: 'places',
    paint: {
        'circle-color': [
            'match',
            ['get', 'type'],
            'restaurant', '#ff0000',
            'cafe', '#00ff00',
            'bar', '#0000ff',
            '#999999'
        ]
    }
});

Количество проходов рендеринга уменьшается, а производительность возрастает.


Оптимизация выражений стилей

Каждое выражение вычисляется для большого числа объектов.

Сложное выражение:

[
    'case',
    ['>', ['get', 'population'], 1000000],
    '#ff0000',
    ['>', ['get', 'population'], 500000],
    '#ffaa00',
    '#00ff00'
]

При тысячах объектов подобные вычисления выполняются огромное количество раз.

Если значения известны заранее, полезно подготовить данные на стороне сервера или при загрузке.

Вместо:

['get', 'population']

можно использовать заранее вычисленное поле:

['get', 'color']

что снижает нагрузку на движок.


Использование векторных тайлов

Большие GeoJSON-файлы являются серьёзным источником проблем производительности.

Например:

map.addSource('cities', {
    type: 'geojson',
    data: '/data/cities.geojson'
});

Если файл содержит сотни тысяч объектов, браузеру приходится загружать и хранить весь набор данных.

Гораздо эффективнее использовать векторные тайлы.

map.addSource('cities', {
    type: 'vector',
    url: 'mapbox://user.cities'
});

Преимущества:

  • загрузка только видимой области;
  • автоматическая генерализация объектов;
  • меньшее потребление памяти;
  • более быстрая перерисовка.

Кластеризация точек

Отображение большого количества маркеров существенно влияет на производительность.

Неоптимальный вариант:

{
    "type": "FeatureCollection",
    "features": [
        // 50000 точек
    ]
}

Каждая точка требует отдельной отрисовки.

Оптимизированный источник:

map.addSource('points', {
    type: 'geojson',
    data: data,
    cluster: true,
    clusterRadius: 50,
    clusterMaxZoom: 14
});

Преимущества кластеризации:

  • меньше объектов на экране;
  • сокращение количества вычислений;
  • более плавное масштабирование;
  • уменьшение нагрузки на GPU.

Отказ от HTML-маркеров при больших объёмах данных

HTML-маркеры создаются через DOM.

new mapboxgl.Marker()
    .setLngLat([37.61, 55.75])
    .addTo(map);

Несколько десятков таких элементов не создают проблем.

Однако тысячи DOM-узлов резко снижают производительность браузера.

Для больших наборов данных рекомендуется использовать слои.

map.addLayer({
    id: 'points',
    type: 'circle',
    source: 'points'
});

Отрисовка через WebGL значительно быстрее.


Использование feature-state вместо обновления источника

Часто требуется изменить состояние отдельного объекта.

Неэффективный способ:

source.setData(updatedGeoJSON);

Даже если изменился один объект, обновляется весь источник.

Лучшее решение:

map.setFeatureState(
    {
        source: 'buildings',
        id: 101
    },
    {
        selected: true
    }
);

Использование состояния:

'fill-color': [
    'case',
    ['boolean', ['feature-state', 'selected'], false],
    '#ff0000',
    '#0088ff'
]

В этом случае происходит обновление только нужного объекта.


Сокращение количества вызовов setPaintProperty

Частая ошибка — изменение параметров слоя внутри циклов.

Неэффективно:

features.forEach(item => {
    map.setPaintProperty(
        'points',
        'circle-color',
        item.color
    );
});

Каждый вызов инициирует перестройку слоя.

Лучше выполнять обновление один раз:

map.setPaintProperty(
    'points',
    'circle-color',
    '#ff0000'
);

Либо использовать выражения данных.


Использование requestAnimationFrame

Для анимаций следует синхронизировать обновления с циклом рендеринга браузера.

Плохой вариант:

setInterval(update, 16);

Оптимальный вариант:

function animate() {
    update();

    requestAnimationFrame(animate);
}

animate();

Преимущества:

  • стабильная частота кадров;
  • отсутствие лишних перерисовок;
  • более плавная анимация.

Контроль загрузки изображений

Большое количество изображений увеличивает объём видеопамяти.

Неэффективно:

map.loadImage('/icons/icon1.png');
map.loadImage('/icons/icon2.png');
map.loadImage('/icons/icon3.png');

При сотнях иконок возникают проблемы:

  • рост потребления памяти;
  • увеличение времени загрузки;
  • замедление рендеринга.

Эффективнее использовать:

  • спрайты;
  • объединённые атласы текстур;
  • повторное использование изображений.

Оптимизация событий перемещения карты

Событие move генерируется непрерывно.

map.on('move', () => {
    updateSidebar();
});

Во время перетаскивания карты обработчик может вызываться сотни раз.

Лучше использовать:

map.on('moveend', () => {
    updateSidebar();
});

Либо применять throttle.

map.on(
    'move',
    throttle(updateSidebar, 200)
);

Это значительно уменьшает количество операций между кадрами.


Контроль количества объектов в видимой области

Даже если источник содержит миллионы объектов, отображать следует только необходимые.

Пример фильтрации:

map.setFilter('cities', [
    '>',
    ['get', 'population'],
    50000
]);

Или отображение по масштабу:

map.addLayer({
    id: 'cities',
    type: 'circle',
    source: 'cities',
    minzoom: 8
});

При малых масштабах слой не будет участвовать в рендеринге.


Профилирование производительности

Mapbox GL JS предоставляет события для контроля процесса отрисовки.

Отслеживание завершения загрузки:

map.on('idle', () => {
    console.log('Карта полностью отрисована');
});

Измерение времени обновления:

const start = performance.now();

source.setData(data);

map.once('idle', () => {
    console.log(
        performance.now() - start
    );
});

Подобные измерения позволяют выявлять узкие места в приложении.


Практические рекомендации для высоконагруженных карт

До 1 000 объектов

Допустимо использование:

  • GeoJSON;
  • Circle Layer;
  • Symbol Layer;
  • отдельных маркеров.

От 1 000 до 10 000 объектов

Рекомендуется:

  • отказаться от HTML-маркеров;
  • использовать кластеризацию;
  • минимизировать обновления источников.

От 10 000 до 100 000 объектов

Рекомендуется:

  • использовать векторные тайлы;
  • объединять слои;
  • применять feature-state;
  • оптимизировать выражения стилей.

Более 100 000 объектов

Желательно:

  • использовать серверную генерализацию данных;
  • загружать данные по тайлам;
  • отображать только видимые объекты;
  • выполнять агрегацию на сервере;
  • минимизировать количество динамических обновлений.

Типичные ошибки, приводящие к лишним перерисовкам

Полное обновление источника при изменении одного объекта

source.setData(bigGeoJSON);

Вместо этого:

map.setFeatureState(...);

Использование тысяч HTML-маркеров

new mapboxgl.Marker(...)

Вместо этого:

map.addLayer({
    type: 'circle'
});

Частые обновления без ограничения

socket.on('message', data => {
    source.setData(data);
});

Вместо этого:

socket.on(
    'message',
    throttle(updateMap, 100)
);

Избыточное количество слоёв

100 отдельных слоёв

Вместо:

1 слой + data-driven styling

Выполнение тяжёлой логики в обработчиках движения карты

map.on('move', heavyCalculation);

Вместо:

map.on('moveend', heavyCalculation);

Грамотная оптимизация перерисовки в Mapbox GL JS строится на уменьшении количества обновляемых данных, сокращении числа слоёв, рациональном использовании WebGL-слоёв вместо DOM-элементов, применении кластеризации, feature-state и векторных тайлов, а также на строгом контроле частоты операций, инициирующих новый цикл рендеринга. Это позволяет поддерживать высокую частоту кадров даже при работе с большими объёмами геоданных и сложными интерактивными картами.