React + Mapbox 初学者教程
Mapbox 和 React
Mapbox 和 React
我开发的首批全栈应用之一是一款交互式地图,可以向用户显示最近的垃圾食品购买点。前端使用 Google Maps API 和原生 JavaScript 构建。后端是一个 Node + Express 服务器,用于查询 Yelp API,查找评分低且搜索词听起来不健康的商家。
我最近决定用 React 和 Mapbox 前端重新创建那个应用程序(后端用 Go,不过那是另外一回事)。虽然我还不是 Mapbox 专家,但我还是想分享一些我学到的东西,希望能帮助其他人加快学习进度。本文假设您有 React 的使用经验,但对 Mapbox 还不熟悉。
为什么选择 Mapbox?
Mapbox 是一款功能强大、用途广泛的工具,可用于创建交互式地图和可视化地理数据。许多知名公司都在其各种用例中使用它(例如《纽约时报》、Strava 和 Weather Channel)。
为什么选择 React?
我承认,在这个应用中使用 React 有点儿过度了。Mapbox 已经提供了一系列非常简单的示例,可以作为一个很好的起点。然而,大多数现代复杂的 Web 应用都会使用某种库或框架。我选择 React 是因为它无处不在。
应用程序前提和设置
在这个应用中,我们将创建一个交互式地图,它会根据地图的中心点获取一些数据并显示结果。每次地图中心发生变化时,结果都会重新绘制在地图上。
API 超出了本文的范围,因此我们将使用随机模拟数据。
首先,创建一个新的 React 应用程序并安装mapbox-gl
为依赖项:
npx create-react-app react-mapbox-example
cd react-mapbox-example
yarn add mapbox-gl
接下来,创建一个免费的 Mapbox 帐户并在此处获取 API 访问令牌。在项目根目录中,创建一个.env.local
文件并将您的令牌添加到其中:
/* .env.local */
REACT_APP_MAPBOX_ACCESS_TOKEN=YOUR_TOKEN_HERE
<head>
在您的文件中添加 Mapbox CSS 文件public/index.html
(确保版本号与您的 中的版本号匹配,您的版本号可能不是 1.9.0。您可以在此处package.json
找到最新版本。):
/* public/index.html */
<link href="https://api.mapbox.com/mapbox-gl-js/v1.9.0/mapbox-gl.css" rel="stylesheet" />
创建地图
Mapbox 有一些使用类组件的 React 示例,但我想尝试一下函数式组件。使用函数式组件时需要注意一些关键区别:
- 您需要使用
useEffect
钩子和后跟空的依赖数组来初始化您的地图,这与的功能相同componentDidMount
。 - 这个
useRef
钩子可能也很有用,因为它允许你的地图在组件的整个生命周期内持续存在,即使重新渲染也是如此。在我的例子中,我将采用这种方式。
要添加地图,src/App.js
请用以下代码替换的内容:
/* src/App.js */
import React, { useRef, useEffect } from 'react';
import mapboxgl from 'mapbox-gl';
import './App.css';
mapboxgl.accessToken = process.env.REACT_APP_MAPBOX_ACCESS_TOKEN;
const App = () => {
const mapContainerRef = useRef(null);
// initialize map when component mounts
useEffect(() => {
const map = new mapboxgl.Map({
container: mapContainerRef.current,
// See style options here: https://docs.mapbox.com/api/maps/#styles
style: 'mapbox://styles/mapbox/streets-v11',
center: [-104.9876, 39.7405],
zoom: 12.5,
});
// add navigation control (the +/- zoom buttons)
map.addControl(new mapboxgl.NavigationControl(), 'bottom-right');
// clean up on unmount
return () => map.remove();
}, []); // eslint-disable-line react-hooks/exhaustive-deps
return <div className="map-container" ref={mapContainerRef} />;
};
export default App;
要设置地图样式,请将内容替换src/Apps.css
为:
/* src/App.css */
.map-container {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
}
现在,当您在本地运行该应用程序时,您应该会看到全屏地图。
向地图添加数据
Mapbox 可以使用多种不同格式的数据,但在本例中,我们将把虚构数据格式化为 GeoJSON FeatureCollection。如果您想深入了解 GeoJSON,可以点击此处进行操作,但现在您真正需要知道的是,GeoJSON FeatureCollection 看起来像这样,其中"features"
数组中的每个项目都是地图上的单个点:
{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"geometry": {
// there are different geometry types, but Point is best
// for this use case of simple latitude/longitude pairs
"type": "Point",
"coordinates": [0, 0] // longitude, latitude
},
"properties": {
// you can put almost anything here, it's kind of like
// the "metadata" for the feature
"name": "Some Cool Point"
}
}
]
}
我们将创建一个名为 的文件src/api/fetchFakeData.js
。在这个文件中,我们可能会进行真正的 API 调用来获取一组新的结果。相反,我们将返回一个基于地图中心点随机生成的 20 个坐标的列表。
/* src/api/fetchFakeData.js */
/**
* A complete Coordinate Pair consisting of a latitude and longitude
* @typedef {Object} CoordinatePair
* @property {number} longitude - longitude coordinate
* @property {number} latitude - latitude coordinate
*/
/**
* Generates a GeoJSON FeatureCollection of random points based on
* the center coordinates passed in.
* @param {CoordinatePair} centerCoordinates - the {@link CoordinatePair} for the map center
* @return {results} GeoJSON FeatureCollection
*/
const fetchFakeData = centerCoordinates => {
const newFeaturesList = [];
for (let i = 0; i < 20; i++) {
const id = i;
const { longitude, latitude } = getRandomCoordinate(centerCoordinates);
newFeaturesList.push({
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [longitude, latitude],
},
properties: {
id,
name: `Random Point #${id}`,
description: `description for Random Point #${id}`,
},
});
}
return Promise.resolve({
type: 'FeatureCollection',
features: newFeaturesList,
});
};
/**
* Generates a random point within 0.025 radius of map center coordinates.
* @param {CoordinatePair} centerCoordinates - the {@link CoordinatePair} for the map center
* @return {CoordinatePair} randomly generated coordinate pair
*/
const getRandomCoordinate = ({ longitude: centerLon, latitude: centerLat }) => {
const r = 0.025 * Math.sqrt(Math.random());
const theta = Math.random() * 2 * Math.PI;
const latitude = centerLat + r * Math.cos(theta);
const longitude = centerLon + r * Math.sin(theta);
return { longitude, latitude };
};
export default fetchFakeData;
标记
我第一次尝试在地图上显示数据时,遍历了 API 的结果,并将每个结果作为标记附加到地图上。剧透:这不是最好的主意。如果您不想了解标记以及为什么我选择不在此特定地图中使用它们,请直接跳到“图层”部分。
首先,我创建了一个 Marker 组件:
/* src/components/Marker.js */
import React from 'react';
const Marker = ({ id }) => <div id={`marker-${id}`} className="marker" />;
export default Marker;
...使用 svg 样式:
/* src/App.css */
.marker {
background-image: url('svg/marker.svg');
background-size: cover;
width: 24px;
height: 24px;
border-radius: 50%;
cursor: pointer;
}
接下来,我在地图上添加了标记。回到src/App.js
,我导入了Marker
组件,以及ReactDOM
来自“react-dom”的组件。在初始化地图(在 useEffect 钩子内部)之后,我立即添加了一个事件监听器,该监听器会在地图移动时获取新的虚假数据,并将每个要素作为标记附加到地图上:
map.on('moveend', async () => {
// get center coordinates
const { lng, lat } = map.getCenter();
// fetch new data
const results = await fetchFakeData({ longitude: lng, latitude: lat });
// iterate through the feature collection and append marker to the map for each feature
results.features.forEach(result => {
const { id, geometry } = result;
// create marker node
const markerNode = document.createElement('div');
ReactDOM.render(<Marker id={id} />, markerNode);
// add marker to map
new mapboxgl.Marker(markerNode)
.setLngLat(geometry.coordinates)
.addTo(map);
});
});
太棒了,现在我移动地图时,就能看到标记了。然而,随着我继续平移,效果是累积的——我在地图上添加了更多标记,覆盖了之前的标记。:(
要移除标记,你必须调用.remove()
标记实例的方法,这意味着你需要将每个标记保存到状态机上的某个数组中,以便稍后访问和循环。这对我来说已经感觉有点混乱了,所以我放弃了标记,转而开始探索图层。
图层
图层本质上是具有相同样式的数据集合。Mapbox 支持多种不同的数据类型,称为“源”,这些数据类型可以输入到图层中。
回到src/App.js
,在初始化地图(在 useEffect 钩子内)后,我们将添加一个事件监听器,等待地图加载,然后添加我们的数据源和图层。
/* src/App.js */
map.on('load', () => {
// add the data source for new a feature collection with no features
map.addSource('random-points-data', {
type: 'geojson',
data: {
type: 'FeatureCollection',
features: [],
},
});
// now add the layer, and reference the data source above by name
map.addLayer({
id: 'random-points-layer',
source: 'random-points-data',
type: 'symbol',
layout: {
// full list of icons here: https://labs.mapbox.com/maki-icons
'icon-image': 'bakery-15', // this will put little croissants on our map
'icon-padding': 0,
'icon-allow-overlap': true,
},
});
});
此时,您仍然不应该在地图上看到数据。与标记类似,我们需要添加一个事件监听器,用于在移动结束时获取带有更新中心坐标的虚假数据。不过,这次我们不用循环遍历所有要素并将其添加到地图,而是直接使用新的 FeatureCollection 更新数据源。为此,我们必须导入该fetchFakeData
函数,并在“加载时”监听器之后添加另一个监听器:
/* src/App.js */
map.on('moveend', async () => {
// get new center coordinates
const { lng, lat } = map.getCenter();
// fetch new data
const results = await fetchFakeData(lng, lat);
// update "random-points-data" source with new data
// all layers that consume the "random-points-data" data source will be updated automatically
map.getSource('random-points-data').setData(results);
});
现在,当您在地图上平移时,您会看到周围散布着小羊角面包图标。
标记与图层总结
标记更适合静态数据或易于手动管理的小数据点,例如用户的当前位置。虽然使用自定义 SVG 或 CSS 图片更容易设置标记的样式,但标记数量过多时管理起来会比较困难,而且交互也比较困难。
使用图层可以更轻松地管理更大的动态数据集。虽然设置样式略有困难(我个人认为),但交互起来却更容易。您可以向地图添加事件监听器,通过其唯一 ID 来定位特定图层,从而轻松访问和操作这些图层中的要素,而无需手动管理数据。
添加悬停弹出窗口
为了使地图更具交互性,我们可以添加一个弹出框,当用户点击某个要素时,它会显示更多详细信息。首先,我将创建一个新Popup
组件:
/* src/components/Popup.js */
import React from 'react';
const Popup = ({ feature }) => {
const { id, name, description } = feature.properties;
return (
<div id={`popup-${id}`}>
<h3>{name}</h3>
{description}
</div>
);
};
export default Popup;
回到src/App.js
,我们需要从 导入该组件Popup
。我希望这个弹出窗口在组件的整个生命周期内都持续存在,就像地图一样,所以我会在 之后立即添加一个,如下所示:ReactDOM
"react-dom"
App
popUpRef
mapContainerRef
/* src/App.js */
// offset puts the popup 15px above the feature
const popUpRef = useRef(new mapboxgl.Popup({ offset: 15 }));
为了设置弹出窗口的内容并使其实际显示,我们将向地图层添加“click”事件监听器:
/* src/App.js */
// add popup when user clicks a point
map.on('click', 'random-points-layer', e => {
if (e.features.length) {
const feature = e.features[0];
// create popup node
const popupNode = document.createElement('div');
ReactDOM.render(<Popup feature={feature} />, popupNode);
// set popup on map
popUpRef.current.setLngLat(feature.geometry.coordinates).setDOMContent(popupNode).addTo(map);
}
});
现在,当您点击某个功能时,应该会看到弹窗。我们还可以在用户鼠标悬停在可点击功能上时,将光标更改为指针,然后在鼠标离开时恢复默认状态。以下是我为实现此视觉提示而添加的监听器:
/* App.js */
// change cursor to pointer when user hovers over a clickable feature
map.on('mouseenter', 'random-points-layer', e => {
if (e.features.length) {
map.getCanvas().style.cursor = 'pointer';
}
});
// reset cursor to default when user is no longer hovering over a clickable feature
map.on('mouseleave', 'random-points-layer', () => {
map.getCanvas().style.cursor = '';
});
后续步骤
如你所见,Mapbox 的可定制性非常高,很容易让你陷入各种难以捉摸的困境,难以对项目进行精细调整,所以我们就此打住。但如果你愿意挑战自我,还有很多工作可以做,让这样的地图更加易用。
例如,你会注意到放大或缩小地图会触发“moveend”监听器并生成新的点。这很不合理。更好的解决方案可能是使用“moveend”监听器来更新组件状态的坐标,然后创建一个新的useEffect
钩子,该钩子仅在中心坐标发生变化时运行,获取新数据并将“random-points-data”源设置为新数据。毕竟,在挂载时初始化地图的钩子之外访问和操作地图的能力,useEffect
很大程度上影响了我决定将地图存储在引用中。
希望这对其他人也能有所帮助,就像我第一次开始深入研究 Mapbox 时一样!