Использование mapbox-gl в React и Next.js

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!

Введение

В данной статье я хочу описать известные мне способы встраивания mapbox-gl в React приложение, на примере создания простого веб приложения содержащего карту на Next.js с использованием Typescript, код компонента карты можно также использовать в любом любом приложении на React

Эта статья входит в цикл статей

Я рассмотрю несколько вариантов реализации на примере создания функционального компонента карты:

  • Имплементация с хранением инстанса карты внутри React компонента

  • Хранение инстанса карты вне React

Справка по сниппетам с кодом

​​ Для комфортного чтения данной статьи Вам необходимо иметь базовые знания React, Typescript и CSS

​​ Все сниппеты с кодом будут с использованием Typescript, использование типизации в javascript является лучшей практикой, поэтому я принципиально ее придерживаюсь там где это возможно, прошу прощения если вы ее не знакомы с ним, вот замечательный курс от egghead.io где вы сможете с ним ознакомиться

​​ Я предпочитаю импортировать React как import * as React from "react" подробнее об этом можно почитать в замечательном артикле от Kent C. Dodds

​​ Если в коде встречается // ... это необходимо читать как места с пропущенным повторяющимся кодом

Подготовка окружения

Прежде всего создадим новый проект на Next.js по шаблону Typescript

npx create-next-app --typescript my-awesome-app

Откроем папку проекта и установим так же mapbox-gl с типами для Typescript

cd my-awesome-app

npm install --save mapbox-gl && npm install -D @types/mapbox-gl

Так же нам потребуется accessToken для mapbox-gl поместим его в переменной окружения чтобы не хранить его непосредственно в коде приложения

touch .env.local

echo NEXT_PUBLIC_MAPBOX_TOKEN=<ваш_токен> >> .env.local

Так должен выглядеть ваш файл с переменной окружения для Next.js

.env.local

NEXT_PUBLIC_MAPBOX_TOKEN=<ваш_токен>

Имплементация в виде функционального компонента React

Подготовка стилей

Удалим лишние стили и обновим глобальный файл стилей

rm styles/Home.module.css

styles/global.css

html,
body,
#__next {
  padding: 0;
  margin: 0;
  width: 100%;
  height: 100%;
}

* {
  box-sizing: border-box;
}

Чтобы высота содержимого приложения равнялась 100% высоты окна, зададим свойства width и height равные 100% для html и body

Высоту так же необходимо указать для элемента с css селектором #__next так как в Next.js приложении корневым элементом является <div id="__next">...</div>

Подготовка компонента карты

components/mapbox-map.tsx

import * as React from "react";
import mapboxgl from "mapbox-gl";
import "mapbox-gl/dist/mapbox-gl.css"; 
// импортируем стили mapbox-gl чтобы карта отображалась коррекно

function MapboxMap() {
    // здесь будет хранится инстанс карты после инициализации
  const [map, setMap] = React.useState<mapboxgl.Map>();

  // React ref для хранения ссылки на DOM ноду который будет 
  // использоваться как обязательный параметр `container` 
  // при инициализации карты `mapbox-gl`
  // по-умолчанию будет содержать `null`
    const mapNode = React.useRef(null);

  React.useEffect(() => {
    const node = mapNode.current;
        // если объект window не найден,
        // то есть компонент рендерится на сервере
        // или dom node не инициализирована, то ничего не делаем
    if (typeof window === "undefined" || node === null) return;

    // иначе создаем инстанс карты передавая ему ссылку на DOM ноду
    // а также accessToken для mapbox
    const mapboxMap = new mapboxgl.Map({
      container: node,
            accessToken: process.env.NEXT_PUBLIC_MAPBOX_TOKEN,
            style: "mapbox://styles/mapbox/streets-v11",
      center: [-74.5, 40],
      zoom: 9,
    });

        // и сохраняетм созданный объект карты в React.useState
    setMap(mapboxMap);
  }, []);

    return <div ref={mapNode} style={{ width: "100%", height: "100%" }} />;
}

export default MapboxMap

Описание параметров инициализации mapbox-gl можно посмотреть в документации

Далее импортируем его в главную страницу приложения и запустим проект

pages/index.tsx

import MapboxMap from "../components/mapbox-map";

function App() {
  return <MapboxMap />;
}

export default App;
npm run dev

Открыв http://localhost:3000 видим полноэкранную веб-карту

Что можно сделать лучше

В предложенной реализации не хватает нескольких полезных фичей

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

  • Доступ к инстансу карты из других компонентов - помимо самой веб-карты в приложении как правило содержатся другие компоненты для которых необходимо иметь доступ напрямую к инстансу карты

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

Пример с загрузкой карты в моем приложении https://app.mapflow.ai

Улучшенный компонент карты

Давайте имплементируем все эти возможности, сначала добавим props для компонента веб-карты

interface MapboxMapProps {
  initialOptions?: Omit<mapboxgl.MapboxOptions, "container">;
  onMapLoaded?(map: mapboxgl.Map): void;
}

function MapboxMap({ initialOptions = {}, onMapLoaded }: MapboxMapProps) {
    // ...

Свойство container интерфейса MapboxOptions в данному случае не потребуется, чтобы исключить его используем утилитарный тип Omit

Передадим initialOptions в аргументы инициализации веб-карты, используя spread syntax, так же установим обработчик события загрузки карты, если коллбек onMapLoaded, устанавливаем только в том случае если он был передан в props компонента

// ...
    const mapboxMap = new mapboxgl.Map({
      container: node,
      accessToken: process.env.NEXT_PUBLIC_MAPBOX_TOKEN,
      style: "mapbox://styles/mapbox/streets-v11",
      center: [-74.5, 40],
      zoom: 9,
      ...initialOptions,
    });

    setMap(mapboxMap);

        // если onMapLoaded указан, он будет вызван единожды
    // по событию загрузка карты
    if (onMapLoaded) mapboxMap.once("load", onMapLoaded);

    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

// ...

Тут вы можете заметить специальный комментарий для линтера

// eslint-disable-next-line react-hooks/exhaustive-deps

Согласно общепринятому правилу react-hooks/exhaustive-deps мы должны были указать в списке зависимостей для React.useEffect добавленные в хук переменные [initialOptions, onMapLoaded]

В данном случае важно оставить список зависимостей пустым, это позволит не пересоздавать инстанс карты повторно если initialOptions или onMapLoaded изменятся, подробнее о использовании React.useEffect можно почитать по ссылке ниже

В итоге компонент будет выглядеть так

components/mapbox-map.tsx

import * as React from "react";
import mapboxgl from "mapbox-gl";
import "mapbox-gl/dist/mapbox-gl.css";

interface MapboxMapProps {
  initialOptions?: Omit<mapboxgl.MapboxOptions, "container">;
  onMapLoaded?(map: mapboxgl.Map): void;
}

function MapboxMap({ initialOptions = {}, onMapLoaded }: MapboxMapProps) {
  const [map, setMap] = React.useState<mapboxgl.Map>();

  const mapNode = React.useRef(null);

  React.useEffect(() => {
    const node = mapNode.current;

    if (typeof window === "undefined" || node === null) return;

    const mapboxMap = new mapboxgl.Map({
      container: node,
      accessToken: process.env.NEXT_PUBLIC_MAPBOX_TOKEN,
      style: "mapbox://styles/mapbox/streets-v11",
      center: [-74.5, 40],
      zoom: 9,
      ...initialOptions,
    });

    setMap(mapboxMap);

    if (onMapLoaded) mapboxMap.once("load", onMapLoaded);

    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  return <div ref={mapNode} style={{ width: "100%", height: "100%" }} />;
}

export default MapboxMap;

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

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

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

Для экрана загрузки нам так же потребует svg иконка, я ее с Freepic, предварительно конвертировав ее в jsx формат с помощью https://svg2jsx.com/

components/world-icon.tsx

function WorldIcon({ className = "" }: { className?: string }) {
  return (
    <svg
      className={className}
      xmlns="http://www.w3.org/2000/svg"
      width="48.625"
      height="48.625"
      x="0"
      y="0"
      enableBackground="new 0 0 48.625 48.625"
      version="1.1"
      viewBox="0 0 48.625 48.625"
      xmlSpace="preserve"
    >
      <path d="M35.432 10.815L35.479 11.176 34.938 11.288 34.866 12.057 35.514 12.057 36.376 11.974 36.821 11.445 36.348 11.261 36.089 10.963 35.7 10.333 35.514 9.442 34.783 9.591 34.578 9.905 34.578 10.259 34.93 10.5z"></path>
      <path d="M34.809 11.111L34.848 10.629 34.419 10.444 33.819 10.583 33.374 11.297 33.374 11.76 33.893 11.76z"></path>
      <path d="M22.459 13.158l-.132.34h-.639v.33h.152l.022.162.392-.033.245-.152.064-.307.317-.027.125-.258-.291-.06-.255.005z"></path>
      <path d="M20.812 13.757L20.787 14.08 21.25 14.041 21.298 13.717 21.02 13.498z"></path>
      <path d="M48.619 24.061a24.552 24.552 0 00-.11-2.112 24.165 24.165 0 00-1.609-6.62c-.062-.155-.119-.312-.185-.465a24.341 24.341 0 00-4.939-7.441 24.19 24.19 0 00-1.11-1.086A24.22 24.22 0 0024.312 0c-6.345 0-12.126 2.445-16.46 6.44a24.6 24.6 0 00-2.78 3.035A24.18 24.18 0 000 24.312c0 13.407 10.907 24.313 24.313 24.313 9.43 0 17.617-5.4 21.647-13.268a24.081 24.081 0 002.285-6.795c.245-1.381.379-2.801.379-4.25.001-.084-.004-.167-.005-.251zm-4.576-9.717l.141-.158c.185.359.358.724.523 1.094l-.23-.009-.434.06v-.987zm-3.513-4.242l.004-1.086c.382.405.75.822 1.102 1.254l-.438.652-1.531-.014-.096-.319.959-.487zM11.202 7.403v-.041h.487l.042-.167h.797v.348l-.229.306h-1.098l.001-.446zm.778 1.085s.487-.083.529-.083 0 .486 0 .486l-1.098.069-.209-.25.778-.222zm33.612 9.651h-1.779l-1.084-.807-1.141.111v.696h-.361l-.39-.278-1.976-.501v-1.28l-2.504.195-.776.417h-.994l-.487-.049-1.207.67v1.261l-2.467 1.78.205.76h.5l-.131.724-.352.129-.019 1.892 2.132 2.428h.928l.056-.148h1.668l.481-.445h.946l.519.52 1.41.146-.187 1.875 1.565 2.763-.824 1.575.056.742.649.647v1.784l.852 1.146v1.482h.736c-4.096 5.029-10.33 8.25-17.305 8.25C12.009 46.625 2 36.615 2 24.312c0-3.097.636-6.049 1.781-8.732v-.696l.798-.969c.277-.523.574-1.033.891-1.53l.036.405-.926 1.125a22.14 22.14 0 00-.798 1.665v1.27l.927.446v1.765l.889 1.517.723.111.093-.52-.853-1.316-.167-1.279h.5l.211 1.316 1.233 1.799-.318.581.784 1.199 1.947.482v-.315l.779.111-.074.556.612.112.945.258 1.335 1.521 1.705.129.167 1.391-1.167.816-.055 1.242-.167.76 1.688 2.113.129.724s.612.166.687.166c.074 0 1.372.983 1.372.983v3.819l.463.13-.315 1.762.779 1.039-.144 1.746 1.029 1.809 1.321 1.154 1.328.024.13-.427-.976-.822.056-.408.175-.5.037-.51-.66-.02-.333-.418.548-.527.074-.398-.612-.175.036-.37.872-.132 1.326-.637.445-.816 1.391-1.78-.316-1.392.427-.741 1.279.039.861-.682.278-2.686.955-1.213.167-.779-.871-.279-.575-.943-1.965-.02-1.558-.594-.074-1.111-.52-.909-1.409-.021-.814-1.278-.723-.353-.037.39-1.316.078-.482-.671-1.373-.279-1.131 1.307-1.78-.302-.129-2.006-1.299-.222.521-.984-.149-.565-1.707 1.141-1.074-.131-.383-.839.234-.865.592-1.091 1.363-.69 2.632-.001-.007.803.946.44-.075-1.372.682-.686 1.376-.904.094-.636 1.372-1.428 1.459-.808-.129-.106.988-.93.362.096.166.208.375-.416.092-.041-.411-.058-.417-.139v-.4l.221-.181h.487l.223.098.193.39.236-.036v-.034l.068.023.684-.105.097-.334.39.098v.362l-.362.249h.001l.053.397 1.239.382.003.015.285-.024.019-.537-.982-.447-.056-.258.815-.278.036-.78-.852-.519-.056-1.315-1.168.574h-.426l.112-1.001-1.59-.375-.658.497v1.516l-1.183.375-.474.988-.514.083v-1.264l-1.112-.154-.556-.362-.224-.819 1.989-1.164.973-.296.098.654.542-.028.042-.329.567-.081.01-.115-.244-.101-.056-.348.697-.059.421-.438.023-.032.005.002.128-.132 1.465-.185.648.55-1.699.905 2.162.51.28-.723h.945l.334-.63-.668-.167v-.797l-2.095-.928-1.446.167-.816.427.056 1.038-.853-.13-.131-.574.817-.742-1.483-.074-.426.129-.185.5.556.094-.111.556-.945.056-.148.37-1.371.038s-.038-.778-.093-.778l1.075-.019.817-.798-.446-.223-.593.576-.984-.056-.593-.816h-1.261l-1.316.983h1.206l.11.353-.313.291 1.335.037.204.482-1.503-.056-.073-.371-.945-.204-.501-.278-1.125.009A22.188 22.188 0 0124.312 2c5.642 0 10.797 2.109 14.73 5.574l-.265.474-1.029.403-.434.471.1.549.531.074.32.8.916-.369.151 1.07h-.276l-.752-.111-.834.14-.807 1.14-1.154.181-.167.988.487.115-.141.635-1.146-.23-1.051.23-.223.585.182 1.228.617.289 1.035-.006.699-.063.213-.556 1.092-1.419.719.147.708-.64.132.5 1.742 1.175-.213.286-.785-.042.302.428.483.106.566-.236-.012-.682.251-.126-.202-.214-1.162-.648-.306-.861h.966l.309.306.832.717.035.867.862.918.321-1.258.597-.326.112 1.029.583.64 1.163-.02c.225.579.427 1.168.604 1.769l-.121.112zm-32.331-7.093l.584-.278.528.126-.182.709-.57.181-.36-.738zm3.099 1.669v.459h-1.334l-.5-.139.125-.32.641-.265h.876v.265h.192zm.614.64v.445l-.334.215-.416.077v-.737h.75zm-.376-.181v-.529l.459.418-.459.111zm.209 1.07v.433l-.319.32h-.709l.111-.486.335-.029.069-.167.513-.071zm-1.766-.889h.737l-.945 1.321-.39-.209.084-.556.514-.556zm3.018.737v.432h-.709l-.194-.28v-.402h.056l.847.25zm-.655-.594l.202-.212.341.212-.273.225-.27-.225zm28.55 5.767l.07-.082c.029.126.06.252.088.38l-.158-.298z"></path>
      <path d="M3.782 14.884v.696c.243-.568.511-1.122.798-1.665l-.798.969z"></path>
    </svg>
  );
}

export default WorldIcon;

components/map-loading-holder.tsx

import WorldIcon from "../components/world-icon";

function MapLoadingHolder() {
  return (
    <div className="loading-holder">
      <WorldIcon className="icon" />
      <h1>Initializing the map</h1>
      <div className="icon-attribute">
        Icons made by{" "}
        <a href="https://www.freepik.com" title="Freepik">
          Freepik
        </a>{" "}
        from{" "}
        <a href="https://www.flaticon.com/" title="Flaticon">
          www.flaticon.com
        </a>
      </div>
    </div>
  );
}

export default MapLoadingHolder;

Теперь соберем все вместе, поместим приложение в элемент .app-container, внутри которого будут абсолютно позиционированный элемент карты помещенный в map-wrapper и компонент MapLoadingHolder

Добавим так же компонент <Head>...</Head> в нем можно указать мета-теги и title для сайта

pages/index.tsx

import * as React from "react";
import Head from "next/head";
import MapboxMap from "../components/mapbox-map";
import MapLoadingHolder from "../components/map-loading-holder";

function App() {
  const [loading, setLoading] = React.useState(true);
  const handleMapLoading = () => setLoading(false);

  return (
    <>
      <Head>
        <title>Using mapbox-gl with React and Next.js</title>
      </Head>
      <div className="app-container">
        <div className="map-wrapper">
          <MapboxMap
            initialOptions={{ center: [38.0983, 55.7038] }}
            onMapLoaded={handleMapLoading}
          />
        </div>
        {loading && <MapLoadingHolder className="loading-holder" />}
      </div>
    </>
  );
}

export default App;

Внесем соответсвующие изменения в стили, добавим красивый фон для .loading-holder, также позиционируем его содержимое по центру, добавим пульсирующую анимацию для иконки, так как фон полупрозрачный, добавим цветную тень text-shadow: 0px 0px 10px rgba(152, 207, 195, 0.7); к элементу <h1>Initializing the map</h1>, подробнее об этом можно прочитать в моем посте про текст на цветном фоне

styles/global.css

html,
body,
#__next {
  padding: 0;
  margin: 0;
  width: 100%;
  height: 100%;
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
    Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
}

* {
  box-sizing: border-box;
}

.app-container {
  width: 100%;
  height: 100%;
  position: relative;
}

.map-wrapper,
.loading-holder {
  position: absolute;
  height: 100%;
  width: 100%;
  top: 0;
  left: 0;
  bottom: 0;
  right: 0;
}

.loading-holder {
  background: -webkit-linear-gradient(
    45deg,
    rgba(152, 207, 195, 0.7),
    rgb(86, 181, 184)
  );
  background: -moz-linear-gradient(
    45deg,
    rgba(152, 207, 195, 0.7),
    rgb(86, 181, 184)
  );
  background: linear-gradient(
    45deg,
    rgba(152, 207, 195, 0.7),
    rgb(86, 181, 184),
    0.9
  );

  display: flex;
  justify-content: center;
  align-items: center;
  flex-direction: column;
}

.loading-holder .icon {
  transform: scale(2);
  fill: rgba(1, 1, 1, 0.7);
  animation: pulse 1.5s ease-in-out infinite;
}

.loading-holder h1 {
  margin-top: 4rem;
  text-shadow: 0px 0px 10px rgba(152, 207, 195, 0.7);
}

@keyframes pulse {
  0% {
    transform: scale(2);
  }
  50% {
    transform: scale(2.3);
  }
  100% {
    transform: scale(2);
  }
}

Теперь при открытии карты мы увидим симпатичный экран загрузки

Ссылки на исходный код и запущенное приложение

Хранение инстанса карты вне React

Про то как хранить и использовать инстанс карты mapbox-gl вне React я расскажу в своей следующей статье

Источник: https://habr.com/ru/post/565636/


Интересные статьи

Интересные статьи

Главный разработчик и архитектор проектов ГК «ОТР» Дмитрий Копытов рассказал о практичном использовании CI/CD-подхода на примере доставки скриптов миграции базы данных Oracle 19. Для реше...
Нередко при работе с Bitrix24 REST API возникает необходимость быстро получить содержимое определенных полей всех элементов какого-то списка (например, лидов). Традиционн...
Предыстория Когда-то у меня возникла необходимость проверять наличие неотправленных сообщений в «1С-Битрикс: Управление сайтом» (далее Битрикс) и получать уведомления об этом. Пробле...
В обновлении «Сидней» Битрикс выпустил новый продукт в составе Битрикс24: магазины. Теперь в любом портале можно создать не только лендинг или многостраничный сайт, но даже интернет-магазин. С корзино...
Испанское агентство по защите данных (AEPD) оштрафовало авиакомпанию Vueling Airlines LS на 30 тыс. евро за незаконное использование cookies.