Синхронизация карт

Синхронизация карт в MapLibre GL JS используется для построения интерфейсов, где несколько карт должны отражать одно и то же состояние или его производные: сравнение слоёв, параллельный анализ данных, «до/после» визуализации, синхронные панели обзора и детализации.

Базовая идея заключается в том, что состояние одной карты (центр, масштаб, поворот, наклон) становится источником изменений для других экземпляров, при этом исключаются циклические обновления и лишние перерисовки.

Ключевые параметры синхронизации:

  • center — географический центр
  • zoom — уровень масштаба
  • bearing — поворот карты
  • pitch — наклон камеры
  • состояние viewport и bounding box

Событийная модель и точки перехвата

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

  • move — изменение центра
  • zoom — изменение масштаба
  • rotate — изменение поворота
  • pitch — изменение наклона
  • moveend — завершение взаимодействия пользователя
  • render — каждый кадр рендера

Наиболее важные для синхронизации — move и moveend, поскольку они отражают интерактивные изменения камеры.

map.on('move', syncHandler);
map.on('zoom', syncHandler);
map.on('rotate', syncHandler);
map.on('pitch', syncHandler);

Базовая модель «главная карта → ведомые»

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

const master = new maplibregl.Map({
  container: 'map1',
  style: 'https://demotiles.maplibre.org/style.json',
  center: [30, 50],
  zoom: 4
});

const slave = new maplibregl.Map({
  container: 'map2',
  style: 'https://demotiles.maplibre.org/style.json',
  center: [30, 50],
  zoom: 4
});

function syncFromMaster() {
  const center = master.getCenter();
  const zoom = master.getZoom();
  const bearing = master.getBearing();
  const pitch = master.getPitch();

  slave.jumpTo({ center, zoom, bearing, pitch });
}

master.on('move', syncFromMaster);
master.on('zoom', syncFromMaster);
master.on('rotate', syncFromMaster);
master.on('pitch', syncFromMaster);

Особенность jumpTo — мгновенное применение состояния без анимации, что снижает риск расхождений и дрожания интерфейса.


Проблема циклических обновлений

При двусторонней синхронизации возникает эффект бесконечного цикла:

  1. карта A изменяется
  2. обновляет карту B
  3. карта B вызывает событие
  4. обновляет карту A

Решение — флаг блокировки синхронизации.

let isSyncing = false;

function sync(mapFrom, mapTo) {
  if (isSyncing) return;

  isSyncing = true;

  const state = {
    center: mapFrom.getCenter(),
    zoom: mapFrom.getZoom(),
    bearing: mapFrom.getBearing(),
    pitch: mapFrom.getPitch()
  };

  mapTo.jumpTo(state);

  isSyncing = false;
}

Более устойчивый вариант — локальная блокировка на уровне каждой карты:

function createSyncPair(mapA, mapB) {
  let lockA = false;
  let lockB = false;

  mapA.on('move', () => {
    if (lockA) return;
    lockB = true;
    mapB.jumpTo(mapA.getCenter());
    lockB = false;
  });

  mapB.on('move', () => {
    if (lockB) return;
    lockA = true;
    mapA.jumpTo(mapB.getCenter());
    lockA = false;
  });
}

Использование requestAnimationFrame для оптимизации

При высокой частоте событий (move генерируется на каждый кадр) прямое обновление нескольких карт приводит к избыточным пересчётам.

Оптимизация — батчинг обновлений:

let pending = false;

function scheduleSync(source, targets) {
  if (pending) return;

  pending = true;

  requestAnimationFrame(() => {
    const state = {
      center: source.getCenter(),
      zoom: source.getZoom(),
      bearing: source.getBearing(),
      pitch: source.getPitch()
    };

    targets.forEach(map => map.jumpTo(state));
    pending = false;
  });
}

Этот подход снижает нагрузку на GPU и предотвращает «дёрганье» интерфейса при перетаскивании.


Синхронизация с сохранением относительного смещения

Иногда требуется не полная идентичность карт, а сохранение относительного сдвига между ними.

Пусть:

  • карта A — базовая
  • карта B — смещённая версия
const offsetCenter = (center, dx, dy) => {
  return [center[0] + dx, center[1] + dy];
};

mapA.on('move', () => {
  const c = mapA.getCenter();

  mapB.jumpTo({
    center: offsetCenter(c, 0.5, 0.2),
    zoom: mapA.getZoom(),
    bearing: mapA.getBearing()
  });
});

Такой подход используется в интерфейсах сравнения спутниковых снимков или слоёв разных лет.


Геометрическая точность и проекции

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

MapLibre GL JS использует Web Mercator, поэтому:

  • одинаковые center в разных масштабах могут визуально расходиться
  • bearing влияет на ориентацию экранных координат
  • pitch изменяет перспективное искажение

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

function syncCamera(from, to) {
  to.jumpTo({
    center: from.getCenter(),
    zoom: from.getZoom(),
    bearing: from.getBearing(),
    pitch: from.getPitch()
  });
}

Двусторонняя синхронизация с приоритетом события

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

let lastActive = null;

function attachSync(a, b) {
  a.on('move', () => {
    lastActive = a;
    if (lastActive === a) {
      b.jumpTo(a.getCenter());
    }
  });

  b.on('move', () => {
    lastActive = b;
    if (lastActive === b) {
      a.jumpTo(b.getCenter());
    }
  });
}

Такой подход предотвращает конфликт одновременного управления.


Синхронизация слоёв и стилей

Координация карт часто включает не только камеру, но и визуальные слои:

  • видимость слоёв (setLayoutProperty)
  • прозрачность (setPaintProperty)
  • фильтры данных
  • источники (setData)
function syncLayers(sourceMap, targetMap) {
  const layers = sourceMap.getStyle().layers;

  layers.forEach(layer => {
    const visibility = sourceMap.getLayoutProperty(layer.id, 'visibility');
    targetMap.setLayoutProperty(layer.id, 'visibility', visibility);
  });
}

Синхронизация через общий состояние (state store)

При масштабировании системы предпочтительнее использовать внешнее хранилище состояния:

  • Redux
  • Zustand
  • EventEmitter
  • собственный store
const state = {
  center: [0, 0],
  zoom: 3
};

function updateState(newState) {
  Object.assign(state, newState);
  emit('stateChanged', state);
}

map.on('move', () => {
  updateState({
    center: map.getCenter(),
    zoom: map.getZoom()
  });
});

on('stateChanged', (s) => {
  map.jumpTo(s);
});

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


Минимизация дрейфа состояния

Со временем две карты могут расходиться из-за:

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

Решение — периодическая «жёсткая» коррекция:

setInterval(() => {
  const a = mapA.getCenter();
  const b = mapB.getCenter();

  if (distance(a, b) > 0.0001) {
    mapB.jumpTo(mapA.getCenter());
  }
}, 2000);

Производственные ограничения

При синхронизации нескольких карт важно учитывать:

  • рост нагрузки GPU при нескольких WebGL контекстах
  • задержки между событиями разных экземпляров
  • необходимость throttle/debounce для событий
  • влияние анимаций easeTo на предсказуемость состояния

Часто используется комбинация:

  • jumpTo для синхронизации
  • easeTo только для пользовательской карты
  • throttling на уровне 16–33 мс

Координация взаимодействий пользователя

Сложные интерфейсы требуют различения источника действия:

map.on('movestart', () => {
  map.isUserInteracting = true;
});

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

И синхронизация выполняется только при активном пользовательском управлении:

if (map.isUserInteracting) {
  sync(map, otherMap);
}

Так предотвращается реакция на программные изменения как на пользовательские.