В Mapbox GL JS вся визуализация строится вокруг реактивного состояния карты: центра, масштаба, наклона, азимута, стиля, источников данных и слоёв. Синхронизация изменений — это процесс согласования этого состояния с внешними системами (UI, хранилища, маршрутизаторы, серверные потоки) и обратного обновления карты при изменениях извне.
Ключевая особенность архитектуры Mapbox GL JS заключается в том, что карта не является «простым DOM-виджетом». Это WebGL-сцена с внутренним состоянием, которое обновляется через событийную модель и императивные методы API.
Карты Mapbox GL JS поддерживают богатую систему событий, отражающих изменения состояния:
move — любое изменение позиции камерыzoom — изменение масштабаrotate — поворотpitch — наклонrender — каждый кадр рендерингаidle — завершение всех анимаций и загрузокС точки зрения синхронизации важно различать:
Источник истины: карта
Источник истины: внешнее состояние
Конфликты возникают, если обе стороны одновременно управляют камерой без координации.
Основной объект синхронизации — камера.
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;
});
Состояние карты часто кодируется в адресной строке:
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) — динамическая часть карты. Они
часто обновляются без перезагрузки стиля.
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
]
});
};
Слои изменяются через 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);
});
Полная замена стиля — дорогостоящая операция.
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:
Решение — слой абстракции:
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;
});
useEffect(() => {
map.jumpTo({
center: state.center,
zoom: state.zoom
});
}, [state]);
Слушатель карты:
map.on('move', () => {
setState({
center: map.getCenter(),
zoom: map.getZoom()
});
});
Событие 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);
}
Синхронизация с сервером:
Плавные переходы влияют на модель синхронизации: состояние меняется не мгновенно, а во времени.
map.easeTo({
center: [0, 0],
zoom: 5,
duration: 2000
});
Во время анимации события move продолжают поступать, но
конечное состояние фиксируется на moveend.
map.on('moveend', () => {
externalState.set({
center: map.getCenter()
});
});
Типичный конфликт:
Стратегии разрешения:
Пример приоритета пользователя:
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
});
}
Фильтры слоёв часто синхронизируются с формами:
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 обычно раскладывается на три слоя:
Связь между ними строится через адаптеры, минимизирующие прямые зависимости и предотвращающие циклические обновления состояния.