Введение
В данной статье я хочу описать известные мне способы встраивания 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 () => {
mapboxMap.remove();
};
}, []);
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">;
onCreated?(map: mapboxgl.Map): void;
onLoaded?(map: mapboxgl.Map): void;
onRemoved?(): void;
}
function MapboxMap({
initialOptions = {},
onCreated,
onLoaded,
onRemoved,
}: MapboxMapProps) {
// ...
Используемые props
initialOptions - параметры инициализации карты, свойство
container
интерфейсаMapboxOptions
в данному случае не потребуется, чтобы исключить его используем утилитарный типOmit
onCreated - коллбек вызываемый по событию создания инстанса карты
onLoaded - коллбек вызываемый по событию полной загрузки карты
onRemoved - коллбек вызываемый при удалении инстанса карты
Свойство container
интерфейса MapboxOptions
в данному случае не потребуется, чтобы исключить его используем утилитарный тип Omit
Передадим initialOptions
в аргументы инициализации веб-карты, используя spread syntax, так же установим обработчик события загрузки карты, если коллбек onMapLoaded
, устанавливаем только в том случае если он был передан в props
компонента, аналогично для onMapRemoved
// ...
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 (onCreated) onCreated(mapboxMap)
// если onLoaded указан, он будет вызван единожды
// по событию загрузка карты
if (onLoaded) mapboxMap.once("load", () => onLoaded(mapboxMap));
return () => {
mapboxMap.remove();
setMap(undefined);
if (onRemoved) onRemoved();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// ...
Тут вы можете заметить специальный комментарий для линтера
// eslint-disable-next-line react-hooks/exhaustive-deps
Согласно общепринятому правилу react-hooks/exhaustive-deps
мы должны были указать в списке зависимостей для React.useEffect
добавленные в хук переменные [initialOptions, onMapLoaded, onMapRemoved]
В данном случае важно оставить список зависимостей пустым, это позволит не пересоздавать инстанс карты повторно если 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">;
onCreated?(map: mapboxgl.Map): void;
onLoaded?(map: mapboxgl.Map): void;
onRemoved?(): void;
}
function MapboxMap({
initialOptions = {},
onCreated,
onLoaded,
onRemoved,
}: 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 (onCreated) onCreated(mapboxMap);
if (onLoaded) mapboxMap.once("load", () => onLoaded(mapboxMap));
return () => {
mapboxMap.remove();
setMap(undefined);
if (onRemoved) onRemoved();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return <div ref={mapNode} style={{ width: "100%", height: "100%" }} />;
}
export default MapboxMap;
Теперь мы можем переопределять стандартные свойства при создании карты и использовать коллбек onMapLoaded
по событию ее загрузки. Так же мы можем использовать onMapLoaded
чтобы сохранить ссылку на инстанс карты например в родительском компоненте. Так же мы можем использовать onMapRemoved
если нам необходимо узнать что инстанс карты был удален.
Воспользуемся этим, укажем координаты центра карты, а так же добавим начальный экран загрузки карты.
Для начала подготовим компонент 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
я расскажу в своей следующей статье