Интеграция с Vue.js

Deck.gl представляет собой высокопроизводительную библиотеку визуализации геопространственных данных на базе WebGL. При использовании совместно с Vue.js возникает задача корректного связывания реактивной модели данных Vue с системой слоёв и механизмом рендеринга Deck.gl.

В типичной архитектуре Vue отвечает за:

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

Deck.gl берёт на себя:

  • визуализацию миллионов объектов;
  • работу с WebGL;
  • обработку взаимодействий с картой;
  • создание специализированных геопространственных слоёв.

Схема взаимодействия выглядит следующим образом:

Vue State
     ↓
Computed Properties
     ↓
Deck.gl Layers
     ↓
Deck Instance
     ↓
WebGL Rendering

Главная задача интеграции заключается в том, чтобы изменения реактивных данных Vue автоматически приводили к обновлению слоёв Deck.gl без лишних пересозданий экземпляров визуализации.


Установка зависимостей

Минимальный набор пакетов:

npm install deck.gl
npm install @deck.gl/core
npm install @deck.gl/layers

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

npm install maplibre-gl
npm install @deck.gl/mapbox

или

npm install mapbox-gl

Для проектов на Vue 3 рекомендуется использовать Vite:

npm create vite@latest

После создания проекта устанавливаются необходимые зависимости.


Создание базового компонента Deck.gl

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

DeckView.vue

<template>
  <div ref="container" class="deck-container"></div>
</template>

<script setup>
import { ref, onMounted, onUnmounted } from 'vue';
import { Deck } from '@deck.gl/core';

const container = ref(null);

let deck = null;

onMounted(() => {
  deck = new Deck({
    parent: container.value,
    initialViewState: {
      longitude: 37.6173,
      latitude: 55.7558,
      zoom: 10
    },
    controller: true,
    layers: []
  });
});

onUnmounted(() => {
  if (deck) {
    deck.finalize();
  }
});
</script>

<style>
.deck-container {
  width: 100%;
  height: 100vh;
}
</style>

После монтирования компонента создаётся экземпляр Deck, который получает DOM-контейнер для вывода WebGL-сцены.


Использование реактивных данных Vue

Основное преимущество Vue заключается в реактивности.

Предположим, имеется массив координат:

const points = ref([
  {
    position: [37.6173, 55.7558]
  },
  {
    position: [37.6200, 55.7600]
  }
]);

На основе этих данных можно формировать слой.

import { ScatterplotLayer } from '@deck.gl/layers';

const createLayers = () => [
  new ScatterplotLayer({
    id: 'points',
    data: points.value,
    getPosition: d => d.position,
    getRadius: 100,
    getFillColor: [255, 0, 0]
  })
];

При изменении массива необходимо обновлять список слоёв:

watch(points, () => {
  deck.setProps({
    layers: createLayers()
  });
}, { deep: true });

Таким образом данные Vue становятся источником состояния для Deck.gl.


Использование computed для генерации слоёв

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

const layers = computed(() => [
  new ScatterplotLayer({
    id: 'points',
    data: points.value,
    getPosition: d => d.position,
    getRadius: 100
  })
]);

Обновление Deck.gl:

watch(layers, value => {
  deck.setProps({
    layers: value
  });
});

Такой подход хорошо масштабируется при большом количестве слоёв.


Композиционная функция useDeck

При крупных проектах логика работы с Deck.gl выносится в composable.

useDeck.js

import { Deck } from '@deck.gl/core';
import { ref } from 'vue';

export function useDeck() {
  const deck = ref(null);

  const createDeck = (container, options) => {
    deck.value = new Deck({
      parent: container,
      ...options
    });
  };

  const updateLayers = layers => {
    deck.value?.setProps({
      layers
    });
  };

  const destroyDeck = () => {
    deck.value?.finalize();
  };

  return {
    deck,
    createDeck,
    updateLayers,
    destroyDeck
  };
}

Использование:

const {
  createDeck,
  updateLayers,
  destroyDeck
} = useDeck();

Подобная организация делает компоненты значительно чище.


Интеграция с MapLibre

Одним из самых распространённых сценариев является отображение слоёв поверх карты.

Создание карты

<template>
  <div ref="mapContainer"></div>
</template>

<script setup>
import maplibregl from 'maplibre-gl';
import { onMounted, ref } from 'vue';

const mapContainer = ref();

onMounted(() => {
  new maplibregl.Map({
    container: mapContainer.value,
    style: 'https://demotiles.maplibre.org/style.json',
    center: [37.6173, 55.7558],
    zoom: 10
  });
});
</script>

Использование MapboxOverlay

Deck.gl может быть встроен непосредственно в карту.

import { MapboxOverlay } from '@deck.gl/mapbox';

Создание оверлея:

const overlay = new MapboxOverlay({
  layers: []
});

map.addControl(overlay);

Обновление слоёв:

overlay.setProps({
  layers
});

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


Реактивное управление слоями

Часто требуется включать и отключать слои через интерфейс.

const showPoints = ref(true);
const showHeatmap = ref(false);

Формирование списка слоёв:

const layers = computed(() => {
  const result = [];

  if (showPoints.value) {
    result.push(createPointLayer());
  }

  if (showHeatmap.value) {
    result.push(createHeatmapLayer());
  }

  return result;
});

Связь между состоянием интерфейса и картой становится полностью декларативной.


Обработка событий

Deck.gl предоставляет систему событий для взаимодействия с объектами.

Пример обработки клика:

new ScatterplotLayer({
  id: 'points',
  data,
  pickable: true,

  onClick: info => {
    console.log(info.object);
  }
});

Для передачи данных в Vue:

const selectedObject = ref(null);

const layer = new ScatterplotLayer({
  id: 'points',
  data,
  pickable: true,

  onClick: info => {
    selectedObject.value = info.object;
  }
});

После этого выбранный объект может использоваться любыми компонентами приложения.


Работа с всплывающими окнами

Типичный сценарий:

const tooltip = ref(null);

Создание слоя:

new ScatterplotLayer({
  id: 'points',
  data,
  pickable: true,

  onHover: info => {
    tooltip.value = {
      x: info.x,
      y: info.y,
      object: info.object
    };
  }
});

Шаблон Vue:

<div
  v-if="tooltip"
  class="tooltip"
  :style="{
    left: tooltip.x + 'px',
    top: tooltip.y + 'px'
  }"
>
  {{ tooltip.object.name }}
</div>

Позиционирование и внешний вид полностью контролируются средствами Vue.


Асинхронная загрузка данных

Получение данных с сервера обычно осуществляется через fetch.

const points = ref([]);
onMounted(async () => {
  const response = await fetch('/api/points');

  points.value = await response.json();
});

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


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

Создание новых экземпляров слоёв при каждом изменении может быть затратным.

Deck.gl использует механизм сравнения свойств.

new ScatterplotLayer({
  id: 'points',
  data,

  updateTriggers: {
    getFillColor: selectedCategory.value
  },

  getFillColor: d => {
    return d.category === selectedCategory.value
      ? [255, 0, 0]
      : [0, 0, 255];
  }
});

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


Использование Pinia

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

Store

export const useMapStore = defineStore('map', {
  state: () => ({
    points: [],
    selectedPoint: null
  })
});

Компонент:

const store = useMapStore();

Слой:

new ScatterplotLayer({
  id: 'points',
  data: store.points,

  onClick: info => {
    store.selectedPoint = info.object;
  }
});

Такой подход обеспечивает единый источник данных для всей системы.


Динамическое изменение вида камеры

Положение камеры может храниться в реактивном состоянии.

const viewState = ref({
  longitude: 37.6173,
  latitude: 55.7558,
  zoom: 10,
  pitch: 0,
  bearing: 0
});

Обновление:

watch(viewState, value => {
  deck.setProps({
    viewState: value
  });
}, { deep: true });

Программное перемещение камеры:

viewState.value = {
  ...viewState.value,
  zoom: 14
};

Использование нескольких слоёв

Deck.gl позволяет комбинировать различные типы визуализации.

const layers = [
  scatterLayer,
  heatmapLayer,
  arcLayer,
  geoJsonLayer
];

В Vue обычно используется единое вычисляемое свойство:

const layers = computed(() => [
  pointLayer.value,
  polygonLayer.value,
  routeLayer.value
].filter(Boolean));

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


Интеграция с маршрутизацией Vue Router

Состояние карты может синхронизироваться с URL.

router.push({
  query: {
    zoom: viewState.value.zoom,
    lat: viewState.value.latitude,
    lng: viewState.value.longitude
  }
});

Восстановление состояния:

const zoom = Number(route.query.zoom);
const lat = Number(route.query.lat);
const lng = Number(route.query.lng);

Это позволяет создавать ссылки на конкретные области карты.


Очистка ресурсов

Любой экземпляр Deck.gl должен корректно уничтожаться.

onUnmounted(() => {
  deck.finalize();
});

Особенно важно это для:

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

Без вызова finalize() WebGL-ресурсы могут оставаться занятыми.


Структура крупного проекта

Пример организации каталогов:

src
├── components
│   ├── DeckMap.vue
│   ├── Tooltip.vue
│   └── LayerControl.vue
│
├── composables
│   └── useDeck.js
│
├── stores
│   └── mapStore.js
│
├── layers
│   ├── pointLayer.js
│   ├── heatmapLayer.js
│   ├── routeLayer.js
│   └── polygonLayer.js
│
├── services
│   └── api.js
│
└── views
    └── MapView.vue

Выделение слоёв в отдельные модули особенно полезно при работе с большим количеством визуализаций.


Паттерн фабрик слоёв

Для повышения переиспользуемости логика создания слоёв выносится в отдельные функции.

import { ScatterplotLayer } from '@deck.gl/layers';

export function createPointLayer(data) {
  return new ScatterplotLayer({
    id: 'points',
    data,
    getPosition: d => d.coordinates,
    getRadius: 100
  });
}

Использование:

const layers = computed(() => [
  createPointLayer(points.value)
]);

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


Серверный рендеринг и Vue

При использовании SSR необходимо учитывать, что Deck.gl зависит от браузерного окружения и WebGL.

Создание экземпляра должно происходить только после монтирования:

onMounted(() => {
  initializeDeck();
});

Нельзя создавать объекты Deck.gl на этапе выполнения серверного кода:

// Ошибка для SSR

const deck = new Deck({...});

Корректный вариант:

let deck;

onMounted(() => {
  deck = new Deck({...});
});

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


Типичные ошибки интеграции

Повторное создание экземпляра Deck

Неправильно:

watch(data, () => {
  deck = new Deck({...});
});

Правильно:

watch(data, () => {
  deck.setProps({
    layers: createLayers()
  });
});

Отсутствие очистки ресурсов

Неправильно:

onUnmounted(() => {});

Правильно:

onUnmounted(() => {
  deck.finalize();
});

Избыточные глубокие наблюдатели

Неправильно:

watch(
  hugeDataset,
  callback,
  { deep: true }
);

Предпочтительнее:

watch(
  () => hugeDataset.value,
  callback
);

Логика визуализации внутри шаблонов

Неправильно:

<div>
  {{ createLayer() }}
</div>

Правильно:

const layers = computed(() => {
  return createLayers();
});

Разделение ответственности между Vue и Deck.gl позволяет сохранять высокую производительность даже при работе с сотнями тысяч и миллионами геопространственных объектов.