Виртуализация маркеров

Работа с большим количеством объектов на карте в Google Maps JavaScript API быстро приводит к проблемам производительности, если каждый объект представлен отдельным DOM-маркером. При росте числа точек до нескольких тысяч и более основная нагрузка возникает не только при первичном рендеринге, но и при каждом изменении масштаба, перемещении карты и пересчёте проекций.

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


Проблема масштабируемости стандартных маркеров

Классический google.maps.Marker (и его современный аналог AdvancedMarkerElement) создаёт отдельный визуальный элемент для каждой точки. Даже при умеренных объёмах данных проявляются следующие ограничения:

  • рост количества DOM-элементов;
  • увеличение времени layout/reflow;
  • высокая стоимость пересчёта позиций при pan/zoom;
  • деградация FPS при анимации карты;
  • увеличение потребления памяти.

При 5–10 тысячах маркеров браузер начинает упираться в ограничения рендеринга DOM, особенно на мобильных устройствах.


Базовая идея виртуализации

Виртуализация маркеров опирается на принцип:

рендерится только то, что видно в текущих границах карты + небольшой буфер вокруг viewport

Это означает:

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

Работа с границами карты (viewport filtering)

Основой виртуализации является получение текущих границ:

const bounds = map.getBounds();

Каждая точка проверяется на принадлежность:

function isVisible(marker, bounds) {
  return bounds.contains(new google.maps.LatLng(marker.lat, marker.lng));
}

Оптимизация заключается в том, чтобы:

  • не создавать LatLng для каждой проверки;
  • кэшировать географические координаты;
  • использовать предварительные индексы (например, spatial hash).

События карты и триггеры пересчёта

Виртуализация всегда привязана к событиям:

  • idle — окончание перемещения/зумирования;
  • bounds_changed — частые изменения (используется осторожно);
  • zoom_changed — смена уровня детализации;
  • dragend — завершение перемещения.

Наиболее стабильный вариант:

map.addListener('idle', updateMarkers);

Событие idle снижает количество пересчётов и предотвращает перегрузку при анимации.


Простая реализация виртуализации

Базовый подход заключается в полном пересоздании маркеров при каждом обновлении viewport:

let markers = [];

function updateMarkers(data) {
  const bounds = map.getBounds();

  // удалить старые
  markers.forEach(m => m.setMap(null));
  markers = [];

  // создать новые только для видимой области
  data.forEach(point => {
    if (bounds.contains(point.position)) {
      const marker = new google.maps.Marker({
        position: point.position,
        map: map
      });
      markers.push(marker);
    }
  });
}

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


Переиспользование маркеров (object pooling)

Более эффективная стратегия — пул объектов:

  • маркеры создаются один раз;
  • затем переиспользуются;
  • изменяется только position и map.
function updateMarkers(data) {
  const bounds = map.getBounds();

  let i = 0;

  for (const point of data) {
    if (bounds.contains(point.position)) {
      if (!markers[i]) {
        markers[i] = new google.maps.Marker();
      }

      markers[i].setPosition(point.position);
      markers[i].setMap(map);
      i++;
    }
  }

  // скрыть лишние
  for (let j = i; j < markers.length; j++) {
    markers[j].setMap(null);
  }
}

Такой подход снижает нагрузку на GC и DOM.


Пространственная индексация

При больших наборах данных (100k+) линейная проверка становится неэффективной. Используются структуры:

  • quadtree;
  • grid-based spatial hash;
  • k-d tree;
  • R-tree.

На практике часто применяются библиотеки типа supercluster, которые обеспечивают:

  • агрегацию точек;
  • быстрый поиск по bbox;
  • адаптацию к zoom level.

Кластеризация как форма виртуализации

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

При увеличении масштаба кластеры «разворачиваются» в точки.

Используемая логика:

  • zoom < threshold → кластеры;
  • zoom ≥ threshold → отдельные маркеры.

Классическая схема:

const index = new Supercluster({
  radius: 60,
  maxZoom: 16
});

index.load(features);

function update() {
  const bounds = map.getBounds();
  const zoom = map.getZoom();

  const clusters = index.getClusters([
    bounds.getSouthWest().lng(),
    bounds.getSouthWest().lat(),
    bounds.getNorthEast().lng(),
    bounds.getNorthEast().lat()
  ], zoom);
}

Разделение на уровни детализации (LOD)

LOD (Level of Detail) позволяет менять способ отображения в зависимости от масштаба:

  • крупный масштаб — отдельные маркеры;
  • средний — упрощённые иконки;
  • мелкий — кластеры или heatmap.

Пример логики:

function getRenderMode(zoom) {
  if (zoom < 6) return 'cluster';
  if (zoom < 10) return 'simplified';
  return 'full';
}

Canvas и WebGL как альтернатива DOM-маркерам

DOM-маркеры становятся узким местом при десятках тысяч объектов. Альтернативой является отрисовка через:

  • Canvas Overlay;
  • WebGL (например, через WebGLOverlayView).

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

  • один canvas вместо тысяч DOM-элементов;
  • GPU-ускорение;
  • минимальные reflow/layout операции.

Пример структуры overlay:

class CustomOverlay extends google.maps.OverlayView {
  onAdd() {}
  draw() {}
  onRemove() {}
}

В draw() выполняется:

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

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

Ключевой элемент виртуализации — работа с проекцией:

const projection = overlay.getProjection();

const pixel = projection.fromLatLngToDivPixel(
  new google.maps.LatLng(lat, lng)
);

Это позволяет:

  • группировать точки;
  • рисовать их в canvas;
  • избегать создания DOM-элементов.

Буферизация вокруг viewport

Жёсткая привязка к границам карты вызывает «мигание» маркеров при небольших движениях. Поэтому добавляется буфер:

  • расширение bounds;
  • предзагрузка соседних зон.
function extendBounds(bounds, factor = 0.2) {
  const ne = bounds.getNorthEast();
  const sw = bounds.getSouthWest();

  const latDelta = (ne.lat() - sw.lat()) * factor;
  const lngDelta = (ne.lng() - sw.lng()) * factor;

  return new google.maps.LatLngBounds(
    new google.maps.LatLng(sw.lat() - latDelta, sw.lng() - lngDelta),
    new google.maps.LatLng(ne.lat() + latDelta, ne.lng() + lngDelta)
  );
}

Оптимизация частоты обновлений

Проблема частых пересчётов решается через:

  • debounce событий;
  • requestAnimationFrame;
  • батчинг обновлений.
let pending = false;

map.addListener('bounds_changed', () => {
  if (!pending) {
    pending = true;

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

Гибридная модель виртуализации

Наиболее устойчивые системы комбинируют подходы:

  • кластеризация на низком зуме;
  • виртуализация viewport на среднем;
  • canvas/WebGL на высоком объёме;
  • пул объектов для повторного использования;
  • spatial index для фильтрации.

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


Сравнение стратегий

Подход Производительность Масштаб Сложность
DOM-маркеры низкая до ~1k низкая
виртуализация DOM средняя ~10k средняя
кластеризация высокая ~100k средняя
canvas overlay высокая ~500k+ высокая
WebGL очень высокая 1M+ очень высокая

Типичные ошибки реализации

  • обновление маркеров на bounds_changed вместо idle;
  • отсутствие spatial index при больших данных;
  • пересоздание маркеров вместо переиспользования;
  • игнорирование буфера вокруг viewport;
  • смешивание DOM и canvas логики;
  • отсутствие контроля частоты обновлений.

Архитектурные принципы

Эффективная виртуализация строится вокруг:

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