Throttling обновлений

Природа частых обновлений в WebGL-картах

В MapLibre GL JS любая интерактивность — движение карты, обновление источников данных, изменение стиля — приводит к перерасчёту сцены и потенциальному перерисовыванию WebGL-контекста. В отличие от классического DOM, здесь даже небольшие изменения могут запускать полный цикл рендеринга.

Throttling (ограничение частоты обновлений) в этом контексте означает контроль над тем, как часто инициируются операции, вызывающие:

  • перерасчёт тайлов
  • пересборку стиля
  • повторный рендер кадра WebGL
  • пересчёт геометрии источников

Без контроля эти операции легко перегружают основной поток и WebGL-пайплайн.


Внутренний цикл рендеринга MapLibre GL JS

MapLibre GL JS использует событийно-ориентированный рендеринг с привязкой к requestAnimationFrame.

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

Срабатывание рендера происходит при:

  • изменении камеры (zoom, center, bearing, pitch)
  • изменении стиля (setPaintProperty, setLayoutProperty)
  • обновлении источников (source.setData)
  • изменении видимости слоёв
  • вызове map.triggerRepaint()

После завершения всех изменений карта стремится к состоянию idle, когда нет pending-операций.


Почему частые обновления становятся проблемой

Проблема не в одном вызове, а в их плотности.

При высокочастотных событиях:

  • mousemove
  • GPS-трекинг
  • потоковые данные (WebSocket)
  • сенсоры устройства

карта может начать:

  • рендерить десятки кадров в секунду без смысла
  • пересобирать GeoJSON-источники слишком часто
  • перегружать worker-потоки
  • вызывать layout/style recalculation

Особенно дорого стоят:

  • source.setData() для больших GeoJSON
  • обновление фильтров слоёв
  • пересчёт выражений (expressions)
  • изменение layout properties

События, которые требуют throttling

move / moveend / zoom

Событие move может вызываться десятки раз в секунду во время жеста.

Типичная ошибка — обновление UI или источников напрямую:

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

Это приводит к избыточной нагрузке.


mousemove

Самый частый источник перегрузки при работе с интерактивными слоями:

map.on('mousemove', (e) => {
  highlightFeature(e.point);
});

Без throttling это превращается в сотни вызовов в секунду.


render

Событие render вызывается каждый кадр во время активного рендеринга. Любая логика внутри него должна быть минимальной.


Базовые стратегии throttling

1. requestAnimationFrame batching

Самый естественный для MapLibre способ синхронизации с рендером.

Идея: объединять все обновления в один кадр.

let pending = false;

function updateState() {
  if (pending) return;

  pending = true;

  requestAnimationFrame(() => {
    pending = false;
    syncUIWithMap();
  });
}

Преимущество: идеально синхронизируется с WebGL-циклом.


2. lodash.throttle (time-based throttling)

Подходит для событий вне render loop.

import throttle from "lodash.throttle";

const updateOnMove = throttle(() => {
  refreshData();
}, 100);

map.on('move', updateOnMove);

Используется для:

  • API-запросов
  • обновления внешнего UI
  • аналитики

3. debounce для финального состояния

Для событий, где важно только завершение действия:

import debounce from "lodash.debounce";

const onMove End = debounce(() => {
  loadVisibleFeatures();
}, 200);

map.on('move', onMoveEnd);

Подходит для:

  • загрузки данных по bbox
  • кластеризации
  • серверных запросов

Throttling обновлений источников данных

GeoJSONSource.setData

Одна из самых дорогих операций:

map.getSource('points').setData(geojson);

При потоковых данных важно агрегировать изменения:

let buffer = [];

function pushPoint(point) {
  buffer.push(point);
}

setInterval(() => {
  if (buffer.length === 0) return;

  const source = map.getSource('points');

  source.setData({
    type: "FeatureCollection",
    features: buffer
  });

  buffer = [];
}, 500);

Оптимизация структуры данных

Часто проблема не в частоте, а в объёме:

  • уменьшение числа свойств
  • упрощение геометрии
  • использование Vector Tiles вместо GeoJSON
  • кластеризация на стороне клиента или сервера

Throttling взаимодействия с камерой

Изменения камеры — setCenter, setZoom, flyTo — сами по себе оптимизированы, но их вызовы могут быть избыточными.

Пример плохой практики:

map.on('mousemove', (e) => {
  map.setCenter([e.lngLat.lng, e.lngLat.lat]);
});

Это приводит к рекурсивному рендер-шторму.

Корректный вариант:

const moveCamera = throttle((lngLat) => {
  map.setCenter(lngLat);
}, 50);

Разделение UI и рендеринга карты

Одна из ключевых оптимизаций — отделение частого UI-обновления от тяжёлого MapLibre-рендера.

Проблема

map.on('move', () => {
  updateSidebar();      // лёгкая операция
  setSourceData();      // тяжёлая операция
});

Решение

map.on('move', updateSidebar);

const updateDataThrottled = throttle(() => {
  setSourceData();
}, 200);

map.on('move', updateDataThrottled);

Потоковая визуализация данных (real-time)

При работе с WebSocket или MQTT потоками важна агрегация:

const queue = [];

socket.onmess age = (msg) => {
  queue.push(JSON.parse(msg.data));
};

setInterval(() => {
  if (!queue.length) return;

  const source = map.getSource('stream');

  source.setData({
    type: "FeatureCollection",
    features: queue.splice(0)
  });

}, 1000);

Дополнительная оптимизация:

  • ограничение размера очереди
  • фильтрация на входе
  • downsampling координат

Throttling кастомных слоёв

Custom layers в MapLibre используют render callback:

render(gl, matrix) {
  draw(gl);
  map.triggerRepaint();
}

Без throttling triggerRepaint() создаёт бесконечный цикл рендера.

Контроль:

let needsUpdate = true;

render(gl, matrix) {
  if (!needsUpdate) return;

  draw(gl);
  needsUpdate = false;
}

Частые ошибки при throttling

1. Throttle внутри обработчика

map.on('move', throttle(() => {
  update();
}, 100));

Это создаёт новый throttled-объект при каждом событии.

Правильно:

const throttledUpdate = throttle(update, 100);
map.on('move', throttledUpdate);

2. Игнорирование idle состояния

MapLibre предоставляет состояние покоя карты:

map.on('idle', () => {
  finalizeHeavyOperations();
});

Использование idle позволяет полностью убрать ручной throttling в некоторых сценариях.


3. Перегрузка setData

Даже throttled вызовы setData могут быть слишком частыми при больших GeoJSON.

Решение — переход на:

  • vector tiles
  • tiled geojson sources
  • clustering

Комбинированные стратегии оптимизации

На практике используется сочетание подходов:

  • requestAnimationFrame для UI
  • throttle для сетевых запросов
  • debounce для завершённых операций
  • буферизация данных
  • разделение источников данных по частоте обновления

Пример архитектуры:

// UI (часто)
map.on('move', updateUI);

// сеть (средне)
const fetchData = throttle(loadData, 300);
map.on('moveend', fetchData);

// данные (редко)
const updateSource = debounce(() => {
  map.getSource('data').setData(bigDataset);
}, 500);

Управление нагрузкой рендера

MapLibre может быть приведён к избыточному render loop даже без явных ошибок:

  • постоянные изменения источников
  • анимации с setPaintProperty
  • custom layers с triggerRepaint
  • непрерывные события камеры

Контроль осуществляется через:

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