Ленивая загрузка данных

Основные принципы ленивой загрузки в веб-картах

Ленивая загрузка данных в картографических приложениях опирается на идею получения только той информации, которая необходима для текущего состояния карты. Это критически важно для производительности при работе с большими наборами пространственных данных, особенно когда отображаются десятки или сотни тысяч объектов.

Ключевые цели ленивой загрузки:

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

MapLibre GL JS реализует ленивую загрузку через систему источников (sources), тайлов и событий изменения состояния карты.


Архитектура источников данных и тайлов

В основе лежит tile-based подход. Данные разбиваются на векторные или растровые тайлы, которые загружаются по мере необходимости.

Типичный источник данных:

map.addSource('points', {
  type: 'vector',
  tiles: [
    'https://example.com/tiles/{z}/{x}/{y}.pbf'
  ],
  minzoom: 0,
  maxzoom: 14
});

Здесь ключевую роль играет шаблон URL. MapLibre автоматически подставляет координаты тайла в зависимости от текущего viewport.

Параметры управления загрузкой:

  • minzoom — минимальный уровень масштабирования, при котором источник активен;
  • maxzoom — максимальный уровень детализации;
  • {z}/{x}/{y} — координаты тайловой сетки.

Ленивое добавление источников по событию загрузки карты

Часто источники не добавляются сразу, а подгружаются после первичного рендера карты.

map.on('load', () => {
  map.addSource('buildings', {
    type: 'geojson',
    data: 'https://example.com/data/buildings.json'
  });

  map.addLayer({
    id: 'buildings-layer',
    type: 'fill',
    source: 'buildings',
    paint: {
      'fill-color': '#3b82f6',
      'fill-opacity': 0.6
    }
  });
});

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


Загрузка данных по bounding box

Один из наиболее эффективных способов ленивой загрузки — подгрузка данных по текущим границам карты.

function loadDataByBounds() {
  const bounds = map.getBounds();

  const url = new URL('https://example.com/api/objects');
  url.searchParams.set('bbox', [
    bounds.getWest(),
    bounds.getSouth(),
    bounds.getEast(),
    bounds.getNorth()
  ].join(','));

  fetch(url)
    .then(res => res.json())
    .then(data => {
      map.getSource('dynamic-data').setData(data);
    });
}

map.on('moveend', loadDataByBounds);

Здесь ключевую роль играет событие moveend, которое срабатывает после завершения перемещения карты, предотвращая избыточные запросы во время панорамирования.


Дебаунсинг запросов при перемещении карты

Без ограничения частоты запросов ленивой загрузки легко перегрузить сервер. Поэтому используется debounce.

function debounce(fn, delay) {
  let timer;
  return function (...args) {
    clearTimeout(timer);
    timer = setTimeout(() => fn.apply(this, args), delay);
  };
}

const loadData = debounce(() => {
  const b = map.getBounds();

  fetch(`/api/data?bbox=${b.toArray().flat().join(',')}`)
    .then(r => r.json())
    .then(data => {
      map.getSource('points').setData(data);
    });
}, 300);

map.on('move', loadData);

Такой подход снижает количество запросов при плавном перемещении карты.


Использование тайлового API вместо GeoJSON

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

map.addSource('shops', {
  type: 'vector',
  tiles: [
    'https://tiles.example.com/shops/{z}/{x}/{y}.pbf'
  ],
  promoteId: 'id'
});

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

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

Управление загрузкой через zoom-уровни

Ленивая загрузка часто комбинируется с фильтрацией по масштабу.

map.addLayer({
  id: 'cities',
  type: 'circle',
  source: 'cities',
  minzoom: 5,
  maxzoom: 10,
  paint: {
    'circle-radius': 6,
    'circle-color': '#ef4444'
  }
});

Это позволяет не загружать или не отображать данные вне нужного диапазона масштабов.


Динамическая подгрузка слоёв

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

function enableTrafficLayer() {
  if (map.getSource('traffic')) return;

  map.addSource('traffic', {
    type: 'vector',
    tiles: [
      'https://example.com/traffic/{z}/{x}/{y}.pbf'
    ]
  });

  map.addLayer({
    id: 'traffic-lines',
    type: 'line',
    source: 'traffic',
    'source-layer': 'roads',
    paint: {
      'line-color': '#f97316',
      'line-width': 2
    }
  });
}

Такой подход снижает начальную нагрузку на клиент и ускоряет первичную отрисовку карты.


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

MapLibre предоставляет набор событий, позволяющих точно контролировать момент загрузки и обновления данных:

  • load — карта и стили загружены;
  • idle — нет активных операций рендеринга;
  • render — происходит перерисовка;
  • data — обновление источников данных.

Пример загрузки данных после полной стабилизации карты:

map.on('idle', () => {
  if (!map.getSource('pois')) {
    map.addSource('pois', {
      type: 'geojson',
      data: '/api/pois'
    });
  }
});

Кэширование и повторное использование данных

Ленивая загрузка эффективна только при наличии кэширования. Обычно применяются несколько уровней:

  • HTTP-кэширование тайлов;
  • локальный кэш GeoJSON;
  • in-memory кэш уже загруженных bbox.

Пример простого кэша:

const cache = new Map();

async function fetchData(bbox) {
  const key = bbox.join(',');

  if (cache.has(key)) {
    return cache.get(key);
  }

  const res = await fetch(`/api/data?bbox=${key}`);
  const json = await res.json();

  cache.set(key, json);
  return json;
}

Частичная загрузка и агрегация данных

При больших объёмах точек часто используется серверная агрегация:

  • кластеризация;
  • геохеширование;
  • предварительная генерация тайлов.

MapLibre поддерживает клиентскую кластеризацию:

map.addSource('earthquakes', {
  type: 'geojson',
  data: '/data/earthquakes.json',
  cluster: true,
  clusterMaxZoom: 12,
  clusterRadius: 50
});

Это снижает количество отображаемых объектов и ускоряет рендеринг при высоких масштабах.


Отмена устаревших запросов

При быстром перемещении карты важно предотвращать гонки запросов.

let controller;

async function load() {
  if (controller) controller.abort();

  controller = new AbortController();

  const bounds = map.getBounds();

  const res = await fetch(
    `/api/data?bbox=${bounds.toArray().flat().join(',')}`,
    { signal: controller.signal }
  );

  const data = await res.json();
  map.getSource('data').setData(data);
}

map.on('moveend', load);

Это гарантирует, что отображаются только актуальные данные.


Оптимизация ленивой загрузки через фильтрацию слоёв

MapLibre позволяет динамически фильтровать уже загруженные данные без повторных запросов:

map.setFilter('points-layer', [
  'all',
  ['>=', ['get', 'population'], 10000]
]);

Это снижает необходимость дополнительной загрузки данных при изменении пользовательских условий.


Итоговые принципы проектирования ленивой загрузки

Ленивая загрузка в MapLibre GL JS строится на сочетании нескольких механизмов:

  • тайловая структура данных;
  • загрузка по viewport (bbox);
  • управление через события карты;
  • кэширование и агрегация;
  • отмена устаревших запросов;
  • динамическое добавление источников и слоёв;
  • масштаб-зависимая фильтрация.