Введение

Всем привет! Меня зовут Никита Русанов, я лид команды фронтенда в компании, где мы создаем продукт, упрощающий переезд. В данной предметной области много задач по взаимодействию с гео данными. Сегодня я расскажу, каким образом можно работать с картами в браузере.

Данная статья начинает цикл “Прикладная архитектура карт в вебе на React и Mapbox”. В этой части мы с вами поговорим про используемые технологии. Посмотрим какая библиотека для чего используется. Если есть понимание, зачем нужен React и Mapbox, и как подготовить данные для отображения данных на карте, то можно сразу переходить к следующей части.

Во второй части пройдемся по архитектуре карт на вебе, как достигается масштабируемость, как переиспользовать функционал разных карт. Посмотрим примеры, как происходит работа с маркерами, полигонами и событиями - click, hover, drag-n-drop.

И в завершающей третьей части затронем детали реализации карт, с какими трудностями приходится сталкиваться, например, отрисовка 1000 интерактивных маркеров, и как данные испытания преодолеть.

Стек

Начнем говорить про технологии. Если откинуть все детали, то мы используем 3 слоя, когда работаем с картами:

  1. Отрисовка пользовательского интерфейса, например, кнопки, формы. React выполняет эту роль в нашем стеке;

  2. Отрисовка карты и взаимодействие с ней. Mapbox нам поможет в этом;

  3. Получение данных с сервера, например, информации о маркерах или полигонах. Возьмем нативный для браузера fetch.

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

React

Маркеры, элементы управления картой - это React компоненты

React - это библиотека, позволяющая удобно и самое главное эффективно работать с элементами на странице. Ее разработал Facebook для своих нужд. В их приложении были тысячи компонентов для кнопок, форм и других элементов, так же разнообразный интерактив на страницах, поэтому они придумали алгоритм как быстро сравнивать разные состояния. Он называется reconciliation algorithm.

Так как для браузера самая дорогая операция - это изменение элементов на странице, то необходимо максимально эффективно это делать. Инженеры из Facebook придумали библиотеку React, которая позволяет легко и не задумываясь изменять элементы на странице. Помимо того, что React быстро меняет состояние элементов на странице, он позволяет делать это декларативно, мы не работаем непосредственно с DOM элементами. Вместо этого мы используем абстракцию, чаще всего это JSX, синтаксис внешне похожий на HTML. Рассмотрим пример:

// It's our state. Is the user our friend or not?
// false by default
const [isFriend, setIsFriend] = useState(false)

// Depending on the state, we show the text on the button
const buttonText = isFriend ? 'Your my Friend' : 'Add as Friend'

// There is JSX, syntax for UI
// In this case, we display a button, when clicked, we change the state
return (
  <button onClick={() => setIsFriend(true)}>{buttonText}</button>
)

Компоненты могут быть вложенными. В самом низу иерархии лежат обычные DOM элементы, например, form, button, input. Из простых элементов мы можем собирать более сложные. Например, форму:

const Form = () => (
  <form>
      <input name="Email"/>
      <input name="Password"/>
  </form>
)

const App = () => (
  <main>
      <h1>My form!</h1>
      <Form />
  </main>
)

Что для нас делает React в контексте карт? Так как карта на странице это такой же интерактивный элемент как кнопка или форма, мы хотим работать эффективно с ее отрисовкой, взаимодействию с ней через события, например, клики по карте. Выглядит это примерно так:

// Use React to render the map with different event handlers
// and render markets
return (
  <BaseMap
    onInitMap={() => console.log('I am alive!')}
    onClickMap={() => console.log('Click!')}
    onDestroyMap={() => console.log('Oh no!')}
  >
    <ClustersMarkers />
    <PostsMarkers />
    <ListingsMarkers />
  </BaseMap>
)

Что стоит запомнить? React позволяет работать эффективно и легко с элементами на странице, обрабатывать события и менять после этого состояние. Карта это такой же элемент, поэтому для взаимодействия с ней мы используем данную библиотеку.

Mapbox

Mapbox позволяет отображать собственные данные

Перейдем к самой карте. Так как отрисовка карт и взаимодействие с ними это сложная область, мало кто в продуктовых компаниях пишет карты с нуля. Обычно все берут готовую библиотеку с понятным API, проверенным временем.

Есть несколько провайдеров динамических карт: Google Maps, Yandex Maps, Bing Maps, Mapbox и так далее. Их достаточно много, мы рассматриваем здесь Mapbox из-за его богатого функционала и за ценовую политику, например, Google Maps за 100к отображений карт в месяц берет 700 долларов, а Mapbox всего 250 долларов. Так же до 50к отображений в месяц Mapbox бесплатный.

У Mapbox есть Mapbox Studio, по их словам это Photoshop для карт. В них дизайнер может набросать стили, скрыть элементы карты, например, убрать отображения домов. Например, можно использовать этот инструмент для улучшения восприятия Points of Interest. Так же возможны эксперименты со стилизацией карты под корпоративные цвета компании, но не всегда это лучшая идея, так как важно сохранять привычную для пользователя палитру цветов - трава зеленая, вода голубая. Самое главное, что Mapbox предоставляет удобный инструмент для дизайнера, вам не нужно тратить время на разработку, передачу требований, что приводит к удешевлению процесса работы с картами.

Так же у Mapbox есть собственный geocoding tool, это когда мы конвертируем адрес в координаты или наоборот, например, при поиске точки на карте. Но он обладает очень низким покрытием по миру, и даже не покрывает все улицы в Нью-Йорке, что для некоторых проектов критично. Mapbox содержит данные из разных источников(governments, open data projects, например, OpenStreetMap и private companies).

Примеры использования mapbox

Mapbox имеет достаточно богатую библиотеку примеров. Документация местами оставляет желать лучшего, но примеры спасают. Например, при добавлении функционала маркеров, приходится разбираться в принципах их работы больше по примерам, а не документации. Могут возникнуть трудности в соединении воедино несколько тем - кластеры для маркеров, загрузка собственных данных и их нормализация, и изменение состояния маркера.

Вернемся к картам от Mapbox. Что из себя представляет API Mapbox?

  1. Инициализация карты в элемент на странице;

  2. Загрузка и отрисовка изображений с кусочками карты;

  3. Отрисовка дополнительных элементов, например, маркеров, используя GeoJson в качестве входных данных;

  4. Генерация событий, которые можно обработать. Например, клики или событие изменения zoom.

Пройдемся немного по каждому из пунктов.

Карта разбивается на tiles(512x512)

В первую очередь Mapbox занимается отображением карты по tiles, это небольшие квадраты с изображениями карты, размером по умолчанию один tile равен 512x512 пикселей. Tile может быть векторным или растровым. Вектор в основном используется для отображения дорог, домов, PoI и так далее. Его можно динамически стилизовать, он мало весит, позволяет "плавно" взаимодействовать с картой. Растр используется, например, для спутниковых изображений.

Кстати, как раз через Mapbox Studio, которую я упоминал выше, мы указываем, что нам будет приходить в этих tiles. Данные кусочки карты вставляются на canvas, специальный DOM элемент в браузере. Обычно он используется для отрисовки изображений или каких-либо графических примитивов в вебе. Все же работали с Google Doc? Там как раз документы отображаются на canvas - кстати, с английского canvas это холст.

<canvas width="100" height="100" />

За загрузку tiles, их вставку и обновление отвечает непосредственно Mapbox. Мы лишь говорим ему куда отрисовать карту и при каких начальных условиях, например, какой должен быть zoom или в пределах каких координат надо отрисовать положение карты. Для работы с Mapbox вам нужен accessToken, специальный ключ, его вы можете найти в личном аккаунте Mapbox. Здесь я приведу простой пример инициализации карты, подробнее можно посмотреть по ссылке:

mapboxgl.accessToken = 'YOUR_MAPBOX_ACCESS_TOKEN';

const map = new mapboxgl.Map({
  container: 'map', // we can use an Id or an element
  style: 'mapbox://styles/mapbox/streets-v11', // URL for styles
  center: [-74.5, 40], // initial coordinates [lng, lat]
  zoom: 9, // initial zoom
});

После этого мы получим карту на странице в элементе с id 'map':

Отображение карты без дополнительного контента

Как правило, мы хотим показать пользователю дополнительную информацию поверх карты. Например, где находится то или иное заведение, или показать границы какой-либо области. Для этого мы должны использовать, во-первых, определенный формат данных, во-вторых, сказать mapbox как эти данные отобразить.

Формат для хранения географических структур на карте называется GeoJson. Он может хранить примитивные типы для описания географических объектов, такие как: точки (адреса и местоположения), линии (улицы, шоссе, границы), полигоны (страны, штаты). Также могут храниться так называемые Multipart, которые представляют собой объединение нескольких примитивных типов. Появился он в 2008 году. Выглядит GeoJson следующим образом:

{
  "type": "Feature", // also can be FeatureCollection, it's collection of Feature
  "geometry": {
    "type": "Point", // also can be LineString, Polygon, MultiPolygon
    "coordinates": [125.6, 10.1] // for other types you can use Array with coordinates
  },
  "properties": { // it's metadata, we can you that to show something on the map
    "name": "Dinagat Islands"
  }
}

Так же стоит упомянуть про систему координат. По умолчанию Mapbox использует для работы с tiles EPSG 3857 - проекцию Земли на плоскую поверхность. Web Mercator(реализация спецификации EPSG 3857) является стандартом для карт в вебе. Но данные о полигонах и маркерах содержут информацию в другой системе координат - EPSG 4326 - где используются широта/долгота для описания координат на елипсоиде(Земле). Mapbox преобразует автоматически координаты из EPSG 4326 в EPSG 3857, как это делают и другие провайдеры карт. Мы можем работать с разными проекциями, для этого нужно использовать метод map.setProjection.

Перейдем к отображению GeoJson на карте. У mapbox есть две сущности, которые нам пригодятся:

  1. Source - это источник данных. Мы можем создать новый source с GeoJson данными и настроить его, например, сказать, что нам нужно генерировать id для каждой Feature в FeatureCollection;

  2. Layer - это отображение данных. Мы можем показать данные из Source с разных сторон. Например, отобразить границы полигона или полностью закрасить область по координатам. Смотря как мы настроим наш layer.

Получается, если мы хотим отобразить наши собственные полигоны или маркеры на карте, то мы должны взять данные в GeoJson формате, например, получить их с сервера, создать source и положить в него данные, и затем связать source с нужным нам layer:

const geoJsonFeature = {
  'type': 'Feature',
  'geometry': {
      'type': 'Polygon',
      'coordinates': [
          [-67.13734, 45.13745],
          [-66.96466, 44.8097],
          [-68.03252, 44.3252],
          [-67.13734, 45.13745]
      ]
  }
}

// Create source with our data
map.addSource('ourSource', {
  'type': 'geojson',
  'data': geoJsonFeature
});
 
// Add layer for background
map.addLayer({
  'id': 'background',
  'type': 'fill',
  'source': 'ourSource', // название нашего source
  'layout': {},
  'paint': {
      'fill-color': '#0080ff',
      'fill-opacity': 0.5
  }
});

// Add layer for border
map.addLayer({
  'id': 'border',
  'type': 'line',
  'source': 'ourSource',
  'layout': {},
  'paint': {
      'line-color': '#000',
      'line-width': 3
  }
});

Выполнив этот код, мы получаем результат:

Мы можем отображать на mapbox собственные данные

Подробнее данный пример можно посмотреть в документации mapbox.

Мы с вами посмотрели как инициализировать карту и отобразить на ней наши собственные данные. Осталось разобраться как мы работаем с событиями, например, click, drag, zoom.

Рассмотрим пример с выводом координат в консоль, при перемещении курсора по карте. Mapbox позволяет подписаться на различные события, для этого нам необходимо вызвать метод on с нужным нам типом, похожий принцип используется для DOM элементов:

map.on('mousemove', (e) => {
  console.log(JSON.stringify(e.point));
});

// Result: {"x":330,"y":49}

Итого, что нужно запомнить. Mapbox позволяет нам отобразить карту, нарисовать поверх нее наши собственные данные, и дает возможность обработать события карты. При этом mapbox берет на себя заботу о загрузке изображений(tiles) и их отображении.

Fetch

Fetch позволяет загрузить данные для карты

В двух словах об fetch. Мы уже посмотрели как отрисовать данные на карте, но сперва их нужно получить с сервера. Когда мы запрашиваем данные с сервера динамически в фоновом режиме, без перезагрузки страницы, мы называем такой подход AJAXAsynchronous JavaScript and XML»). Существует множество инструментов для загрузки данных асинхронно с сервера, например, axios, или XMLHttpRequest(native).

Что запомнить? Мы получаем данные с сервера, для этого существует множество библиотек, мы будем использовать fetch. Далее мы рассмотрим как именно мы это делаем при работе с картами, там есть нюансы.

React + Mapbox

Теперь мы посмотрим как технологии, описанные выше, работают вместе. Для начала мы получим данные для отображения полигона через fetch. Затем объявим инициализацию карты и после ее загрузки добавим на карту полигон.

Так же рабочий пример вы можете найти по ссылке:

const useFetch = () => {
  /*
  Our data
  {
      'type': 'Feature',
      'geometry': {
          'type': 'Polygon',
          'coordinates': [
              [
                  [-67.13734, 45.13745],
                  [-68.03252, 44.3252],
                  [-68.90478, 47.18479],
                  [-67.13734, 45.13745],
              ]
          ]
      }
  }
  */
  const [data, setData] = useState(null)
  
  useEffect(() => {
    fetch('https://our-api.com/polygon')
      .then(response => response.json())
      .then(setData)
      .catch(e => {
        console.error(e)
      })
  }, [setData])
  
  return { data }
}

const BaseMap = () => {
  // Use the hook to fetch data
  const { data } = useFetch(GET_REGION);
  
  // Map instance
  const map = useRef(null);
  // DOM element
  const mapContainer = useRef(null);
  
  // Main logic - init the map and add the event
  useEffect(() => {
    if (map.current) {
      return; // initialize map only once
    }

    mapboxgl.accessToken = 'YOUR_MAPBOX_ACCESS_TOKEN';
    map.current = new mapboxgl.Map({
      container: mapContainer.current,
      style: 'mapbox://styles/mapbox/light-v10', // style URL (it's Mapbox's core style)
      center: [-68.137343, 45.137451], // starting position
      zoom: 5 // starting zoom
    });

    // Handle event
    map.on('load', () => {
      const sourceId = 'source-region'

      // Add a data source containing GeoJSON data
      map.addSource(sourceId, {
        'type': 'geojson',
        'data': data.region // our data from Apollo
      });
  
      // Add a new layer to visualize the polygon
      map.addLayer({
        'id': 'background',
        'type': 'fill',
        'source': sourceId, // reference the data source
        'paint': {
            'fill-color': '#0080ff', // blue color fill
            'fill-opacity': 0.5
        }
      });
      // Add a black outline around the polygon
      map.addLayer({
        'id': 'outline',
        'type': 'line',
        'source': sourceId,
        'paint': {
            'line-color': '#000',
            'line-width': 3
        }
      });
    });
  });
  
  return <div ref={mapContainer} />;
}
Mapbox и react позволяют легко работать с картами в вебе

Заключение

В первой части мы рассмотрели технологический стек, который лежит в основе нашей будущей архитектуры. В следующей статье мы разберем какие принципы помогают проектировать архитектуру работы с картами, как можно добиться максимальной low coupling and high cohesion модулей и как поддерживать и развивать масштабируемую систему карт.

Спасибо большое за внимание! Хорошего дня.