Синхронизация изменений

В Mapbox GL JS вся визуализация строится вокруг реактивного состояния карты: центра, масштаба, наклона, азимута, стиля, источников данных и слоёв. Синхронизация изменений — это процесс согласования этого состояния с внешними системами (UI, хранилища, маршрутизаторы, серверные потоки) и обратного обновления карты при изменениях извне.

Ключевая особенность архитектуры Mapbox GL JS заключается в том, что карта не является «простым DOM-виджетом». Это WebGL-сцена с внутренним состоянием, которое обновляется через событийную модель и императивные методы API.


Модель событий и точка истины

Карты Mapbox GL JS поддерживают богатую систему событий, отражающих изменения состояния:

  • move — любое изменение позиции камеры
  • zoom — изменение масштаба
  • rotate — поворот
  • pitch — наклон
  • render — каждый кадр рендеринга
  • idle — завершение всех анимаций и загрузок

С точки зрения синхронизации важно различать:

Источник истины: карта

  • пользователь двигает карту мышью или тачем
  • приложение только наблюдает и реагирует

Источник истины: внешнее состояние

  • URL, Redux/Vuex/Pinia, сервер, WebSocket
  • карта обновляется программно

Конфликты возникают, если обе стороны одновременно управляют камерой без координации.


Синхронизация камеры (center, zoom, bearing, pitch)

Основной объект синхронизации — камера.

map.on('move', () => {
  const center = map.getCenter();
  const zoom = map.getZoom();
  const bearing = map.getBearing();
  const pitch = map.getPitch();

  externalState.update({
    center,
    zoom,
    bearing,
    pitch
  });
});

Программное обновление:

externalState.subscribe(state => {
  map.jumpTo({
    center: state.center,
    zoom: state.zoom,
    bearing: state.bearing,
    pitch: state.pitch
  });
});

Защита от циклических обновлений

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

Решение — флаг источника изменения:

let isInternalUpdate = false;

map.on('move', () => {
  if (isInternalUpdate) return;

  externalState.set({
    center: map.getCenter(),
    zoom: map.getZoom()
  });
});

externalState.subscribe(state => {
  isInternalUpdate = true;

  map.easeTo({
    center: state.center,
    zoom: state.zoom
  });

  isInternalUpdate = false;
});

Синхронизация через URL (deep linking)

Состояние карты часто кодируется в адресной строке:

  • координаты
  • zoom
  • слой
  • выбранный объект
function updateHash() {
  const center = map.getCenter();
  const zoom = map.getZoom();

  location.hash = `${zoom}/${center.lng.toFixed(4)}/${center.lat.toFixed(4)}`;
}

map.on('moveend', updateHash);

Инициализация из URL:

function parseHash() {
  const [zoom, lng, lat] = location.hash
    .replace('#', '')
    .split('/')
    .map(Number);

  if (!isNaN(zoom)) {
    map.setView({
      center: [lng, lat],
      zoom
    });
  }
}

parseHash();

Синхронизация источников данных (Sources)

Источники (sources) — динамическая часть карты. Они часто обновляются без перезагрузки стиля.

GeoJSON источник

map.addSource('points', {
  type: 'geojson',
  data: {
    type: 'FeatureCollection',
    features: []
  }
});

Обновление данных:

function updatePoints(newFeatures) {
  const source = map.getSource('points');

  source.setData({
    type: 'FeatureCollection',
    features: newFeatures
  });
}

Потоковая синхронизация

При работе с WebSocket данные приходят инкрементально:

socket.onmess age = (event) => {
  const data = JSON.parse(event.data);

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

  current.features.push(data);

  source.setData(current);
};

Проблема здесь — мутация текущего объекта. Более стабильный подход:

socket.onmess age = (event) => {
  const data = JSON.parse(event.data);

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

  source.setData({
    type: 'FeatureCollection',
    features: [
      ...cachedFeatures,
      data
    ]
  });
};

Синхронизация слоёв (Layers)

Слои изменяются через style diffing — Mapbox GL JS пересобирает WebGL-пайплайн при изменении описания стиля.

Добавление слоя:

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

Динамическое включение/выключение:

function toggleLayer(id, visible) {
  map.setLayoutProperty(
    id,
    'visibility',
    visible ? 'visible' : 'none'
  );
}

Синхронизация состояния UI:

ui.onCheckboxChange(layerId, value => {
  map.setLayoutProperty(layerId, 'visibility', value);
});

Синхронизация стиля (Style)

Полная замена стиля — дорогостоящая операция.

map.setStyle('mapbox://styles/mapbox/dark-v11');

После смены стиля необходимо восстанавливать источники и слои:

map.on('style.load', () => {
  map.addSource('points', {
    type: 'geojson',
    data: cachedData
  });

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

Проблема потери состояния

При setStyle:

  • удаляются все sources
  • удаляются layers
  • сбрасываются фильтры

Решение — слой абстракции:

const persistentState = {
  sources: [],
  layers: []
};

И восстановление:

function restoreState() {
  persistentState.sources.forEach(addSourceSafe);
  persistentState.layers.forEach(addLayerSafe);
}

Синхронизация нескольких карт

Частый сценарий — несколько синхронизированных экземпляров карты.

function syncMaps(master, slave) {
  master.on('move', () => {
    slave.jumpTo(master.getCamera());
  });
}

Предотвращение рекурсии

let syncing = false;

master.on('move', () => {
  if (syncing) return;

  syncing = true;
  slave.jumpTo(master.getCenter());
  syncing = false;
});

Синхронизация с внешним состоянием приложения

React-подобная модель

useEffect(() => {
  map.jumpTo({
    center: state.center,
    zoom: state.zoom
  });
}, [state]);

Слушатель карты:

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

Разделение ответственности

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

Throttling и производительность

Событие move генерируется десятки раз в секунду. Прямая синхронизация приводит к перегрузке состояния.

let lastUpdate = 0;

map.on('move', () => {
  const now = Date.now();

  if (now - lastUpdate < 50) return;

  lastUpdate = now;

  externalState.set({
    center: map.getCenter()
  });
});

Альтернатива — requestAnimationFrame:

let scheduled = false;

map.on('move', () => {
  if (scheduled) return;

  scheduled = true;

  requestAnimationFrame(() => {
    externalState.set({
      center: map.getCenter()
    });

    scheduled = false;
  });
});

Синхронизация кластеров и динамических данных

При использовании кластеризации GeoJSON источников обновления требуют полной пересборки данных:

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

Обновление:

function updateClusters(newData) {
  map.getSource('clusters').setData(newData);
}

Синхронизация с сервером:

  • сервер отправляет обновления объектов
  • клиент пересобирает FeatureCollection
  • карта перерисовывает кластеры

Синхронизация с анимацией и переходами камеры

Плавные переходы влияют на модель синхронизации: состояние меняется не мгновенно, а во времени.

map.easeTo({
  center: [0, 0],
  zoom: 5,
  duration: 2000
});

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

map.on('moveend', () => {
  externalState.set({
    center: map.getCenter()
  });
});

Конфликты параллельных источников управления

Типичный конфликт:

  • пользователь двигает карту
  • сервер присылает обновление
  • UI применяет локальное состояние

Стратегии разрешения:

  • приоритет пользовательского ввода
  • приоритет сервера
  • временная блокировка обновлений
  • merge стратегий состояния

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

let userInteracting = false;

map.on('mousedown', () => userInteracting = true);
map.on('mouseup', () => userInteracting = false);

server.on('update', state => {
  if (userInteracting) return;

  map.jumpTo(state);
});

Инкрементальная синхронизация данных

При больших наборах данных полная пересборка источника неэффективна.

Подход:

  • хранение локального кеша
  • диффы изменений
  • частичные обновления через фильтры и свойства
function applyPatch(patch) {
  cachedFeatures = applyDiff(cachedFeatures, patch);

  map.getSource('points').setData({
    type: 'FeatureCollection',
    features: cachedFeatures
  });
}

Синхронизация фильтров и UI состояния

Фильтры слоёв часто синхронизируются с формами:

function updateFilter(value) {
  map.setFilter('points-layer', [
    '==',
    ['get', 'type'],
    value
  ]);
}

Обратная синхронизация:

map.on('data', () => {
  const filter = map.getFilter('points-layer');
  ui.syncFilter(filter);
});

Общая архитектурная модель синхронизации

Система синхронизации в Mapbox GL JS обычно раскладывается на три слоя:

  • Presentation layer: карта и UI
  • State layer: внешнее состояние приложения
  • Transport layer: WebSocket, HTTP, URL, события

Связь между ними строится через адаптеры, минимизирующие прямые зависимости и предотвращающие циклические обновления состояния.