[v7] utility hooks (#1663)

This commit is contained in:
Xiaoji Chen 2022-01-02 20:41:52 -08:00 committed by Xiaoji Chen
parent 55ce87d3f7
commit 3f200a0bfe
42 changed files with 784 additions and 294 deletions

View File

@ -0,0 +1,21 @@
# MapProvider
A [Context.Provider](https://reactjs.org/docs/context.html#contextprovider) that facilitates map operations outside of the component that directly renders a [Map](/docs/api-reference/map.md).
The component should wrap all nodes in which you may want to access the maps:
```js
import {MapProvider} from 'react-map-gl';
function Root() {
return (
<MapProvider>
{
// Application tree, somewhere one or more <Map /> component(s) are rendered
}
</MapProvider>
);
}
```
See [useMap](/docs/api-reference/use-map.md) for more information.

View File

@ -24,11 +24,8 @@ function App() {
## Methods
#### `getMap()`
The following methods are accessible via a [React ref](https://reactjs.org/docs/refs-and-the-dom.html#creating-refs) or the [useMap](/docs/api-reference/use-map.md) hook.
Returns the underlying [Map](https://docs.mapbox.com/mapbox-gl-js/api/map/) instance.
Example:
```js
import * as React from 'react';
@ -46,6 +43,13 @@ function App() {
}
```
#### `getMap()`
Returns the underlying [Map](https://docs.mapbox.com/mapbox-gl-js/api/map/) instance.
#### `getViewState()`
Returns the current view state of the map.
## Properties

View File

@ -0,0 +1,64 @@
# useControl
The `useControl` hook is used to create React wrappers for custom map controls.
```js
import MapboxDraw from '@mapbox/mapbox-gl-draw';
import {Map, useControl} from 'react-map-gl';
function DrawControl(props: DrawControlProps) {
useControl(() => new MapboxDraw(props), {
position: props.position
});
return null;
}
function App() {
return (
<Map
initialViewState={{
longitude: -122.4,
latitude: 37.8,
zoom: 14
}}
mapStyle="mapbox://styles/mapbox/satellite-v9"
>
<DrawControl
position="top-left"
displayControlsDefault={false}
controls={{
polygon: true,
trash: true
}}
/>
</Map>
);
}
```
See a full example [here](/examples/draw-polygon).
## Signature
```js
useControl(onCreate: () => IControl, options?: {
position?: ControlPosition;
onAdd?: (map: MapboxMap) => void;
onRemove?: (map: MapboxMap) => void;
}): IControl
```
The hook creates an [IControl](https://docs.mapbox.com/mapbox-gl-js/api/markers/#icontrol) instance, adds it to the map when it's available, and removes it upon unmount.
Parameters:
- `onCreate` (Function) - called to create an instance of the control.
- `options` (object)
+ `position` ('top-left' | 'top-right' | 'bottom-left' | 'bottom-right') - control position relative to the map
+ `onAdd` (Function) - called after the control is added to a map
+ `onRemove` (Function) - called before the control is removed from a map
Returns:
The control instance.

View File

@ -0,0 +1,37 @@
# useMap
The `useMap` hook, used with the [MapProvider](/docs/api-reference/map-provider.md), helps an app to perform map operations outside of the component that directly renders a [Map](/docs/api-reference/map.md).
```js
import {MapProvider, Map, useMap} from 'react-map-gl';
function Root() {
return (
<MapProvider>
<Map id="myMapA" ... />
<Map id="myMapB" ... />
<NavigateButton />
</MapProvider>
);
}
function NavigateButton() {
const {myMapA, myMapB} = useMap();
const onClick = () => {
myMapA.flyTo({center: [-122.4, 37.8]});
myMapB.flyTo({center: [-74, 40.7]});
};
return <button onClick={onClick}>Go</button>;
}
```
See a full example [here](/examples/get-started/hook).
## Signature
`useMap(): {[id: string]: MapRef}`
The hook returns an object that contains all mounted maps under the closest `MapProvider`. The keys are each map's [id](/docs/api-reference/map.md#prperties) and the values are the [ref object](/docs/api-reference/map.md#methods).

View File

@ -18,15 +18,14 @@ export default function App() {
const feature = event.features[0];
const clusterId = feature.properties.cluster_id;
const map = mapRef.current.getMap();
const mapboxSource = map.getSource('earthquakes') as GeoJSONSource;
const mapboxSource = mapRef.current.getSource('earthquakes') as GeoJSONSource;
mapboxSource.getClusterExpansionZoom(clusterId, (err, zoom) => {
if (err) {
return;
}
map.easeTo({
mapRef.current.easeTo({
center: feature.geometry.coordinates,
zoom,
duration: 500

View File

@ -1,6 +1,6 @@
# Example: Draw Polygon
Demonstrates how to use [react-map-gl-draw](https://github.com/uber/nebula.gl/tree/master/modules/react-map-gl-draw) to draw polygons with react-map-gl.
This app reproduces Mapbox's [Draw a polygon and calculate its area](https://docs.mapbox.com/mapbox-gl-js/example/mapbox-gl-draw/) example.
## Usage

View File

@ -1,24 +0,0 @@
body {
margin: 0;
background: #000;
}
#map {
width: 100vw;
height: 100vh;
}
.control-panel {
position: absolute;
top: 0;
right: 0;
max-width: 320px;
background: #fff;
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
padding: 12px 24px;
margin: 20px;
font-size: 13px;
line-height: 2;
color: #6b6b76;
text-transform: uppercase;
outline: none;
}

View File

@ -1,17 +1,41 @@
<!doctype html>
<html>
<head>
<meta charset='UTF-8' />
<title>react-map-gl Example</title>
<link rel="stylesheet" type="text/css" href="app.css" />
</head>
<body>
<div id="map"></div>
<!--use mapbox button icon css-->
<link rel="stylesheet" href="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-draw/v1.0.9/mapbox-gl-draw.css" type="text/css">
<script src='app.js'></script>
</body>
<script type="text/javascript">
App.renderToDom(document.getElementById('map'));
</script>
<head>
<meta charset='UTF-8' />
<title>react-map-gl Example</title>
<link href="https://api.mapbox.com/mapbox-gl-js/v2.6.1/mapbox-gl.css" rel="stylesheet">
<link rel="stylesheet" href="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-draw/v1.3.0/mapbox-gl-draw.css" type="text/css">
<style>
body {
margin: 0;
font-family: Helvetica, Arial, sans-serif;
}
#map {
width: 100vw;
height: 100vh;
}
.control-panel {
position: absolute;
top: 0;
right: 0;
max-width: 320px;
background: #fff;
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
padding: 12px 24px;
margin: 20px;
font-size: 13px;
line-height: 2;
color: #6b6b76;
text-transform: uppercase;
outline: none;
}
</style>
</head>
<body>
<div id="map"></div>
<script src='app.js'></script>
</body>
<script type="text/javascript">
App.renderToDom(document.getElementById('map'));
</script>
</html>

View File

@ -1,27 +1,25 @@
{
"scripts": {
"start": "webpack-dev-server --progress --hot --open",
"start-local": "webpack-dev-server --env.local --progress --hot --open"
"start-local": "webpack-dev-server --env local --progress --hot --open"
},
"dependencies": {
"@turf/area": "^6.0.1",
"react": "^16.3.0",
"react-dom": "^16.3.0",
"react-map-gl": "^6.0.0",
"react-map-gl-draw": "^0.21.0",
"styled-components": "^4.3.2"
"@mapbox/mapbox-gl-draw": "^1.3.0",
"@turf/area": "^6.5.0",
"react": "^17.0.0",
"react-dom": "^17.0.0",
"react-map-gl": "^7.0.0",
"mapbox-gl": "^2.0.0"
},
"devDependencies": {
"@babel/core": "^7.0.0",
"@babel/plugin-proposal-class-properties": "^7.0.0",
"@babel/preset-env": "^7.0.0",
"@babel/preset-react": "^7.0.0",
"babel-loader": "^8.0.0",
"webpack": "^4.20.0",
"webpack-cli": "^3.1.2",
"webpack-dev-server": "^3.1.0"
},
"resolutions": {
"@turf/difference": "6.0.1"
"ts-loader": "^9.0.0",
"typescript": "^4.0.0",
"webpack": "^5.65.0",
"webpack-cli": "^4.9.0",
"webpack-dev-server": "^4.7.0"
}
}

View File

@ -1,89 +0,0 @@
import * as React from 'react';
import {useState, useRef, useCallback} from 'react';
import {render} from 'react-dom';
import MapGL from 'react-map-gl';
import {Editor, DrawPolygonMode, EditingMode} from 'react-map-gl-draw';
import ControlPanel from './control-panel';
import {getFeatureStyle, getEditHandleStyle} from './style';
const TOKEN = ''; // Set your mapbox token here
export default function App() {
const [viewport, setViewport] = useState({
longitude: -91.874,
latitude: 42.76,
zoom: 12
});
const [mode, setMode] = useState(null);
const [selectedFeatureIndex, setSelectedFeatureIndex] = useState(null);
const editorRef = useRef(null);
const onSelect = useCallback(options => {
setSelectedFeatureIndex(options && options.selectedFeatureIndex);
}, []);
const onDelete = useCallback(() => {
if (selectedFeatureIndex !== null && selectedFeatureIndex >= 0) {
editorRef.current.deleteFeatures(selectedFeatureIndex);
}
}, [selectedFeatureIndex]);
const onUpdate = useCallback(({editType}) => {
if (editType === 'addFeature') {
setMode(new EditingMode());
}
}, []);
const drawTools = (
<div className="mapboxgl-ctrl-top-left">
<div className="mapboxgl-ctrl-group mapboxgl-ctrl">
<button
className="mapbox-gl-draw_ctrl-draw-btn mapbox-gl-draw_polygon"
title="Polygon tool (p)"
onClick={() => setMode(new DrawPolygonMode())}
/>
<button
className="mapbox-gl-draw_ctrl-draw-btn mapbox-gl-draw_trash"
title="Delete"
onClick={onDelete}
/>
</div>
</div>
);
const features = editorRef.current && editorRef.current.getFeatures();
const selectedFeature =
features && (features[selectedFeatureIndex] || features[features.length - 1]);
return (
<>
<MapGL
{...viewport}
width="100%"
height="100%"
mapStyle="mapbox://styles/mapbox/satellite-v9"
mapboxApiAccessToken={TOKEN}
onViewportChange={setViewport}
>
<Editor
ref={editorRef}
style={{width: '100%', height: '100%'}}
clickRadius={12}
mode={mode}
onSelect={onSelect}
onUpdate={onUpdate}
editHandleShape={'circle'}
featureStyle={getFeatureStyle}
editHandleStyle={getEditHandleStyle}
/>
{drawTools}
</MapGL>
<ControlPanel polygon={selectedFeature} />
</>
);
}
export function renderToDom(container) {
render(<App />, container);
}

View File

@ -0,0 +1,64 @@
import * as React from 'react';
import {useState, useCallback} from 'react';
import {render} from 'react-dom';
import Map from 'react-map-gl';
import DrawControl from './draw-control';
import ControlPanel from './control-panel';
const TOKEN = ''; // Set your mapbox token here
export default function App() {
const [features, setFeatures] = useState({});
const onUpdate = useCallback(
e => {
const newFeatures = {...features};
for (const f of e.features) {
newFeatures[f.id] = f;
}
setFeatures(newFeatures);
},
[features]
);
const onDelete = useCallback(e => {
const newFeatures = {...features};
for (const f of e.features) {
delete newFeatures[f.id];
}
setFeatures(newFeatures);
}, []);
return (
<>
<Map
initialViewState={{
longitude: -91.874,
latitude: 42.76,
zoom: 12
}}
mapStyle="mapbox://styles/mapbox/satellite-v9"
mapboxAccessToken={TOKEN}
>
<DrawControl
position="top-left"
displayControlsDefault={false}
controls={{
polygon: true,
trash: true
}}
defaultMode="draw_polygon"
onCreate={onUpdate}
onUpdate={onUpdate}
onDelete={onDelete}
/>
</Map>
<ControlPanel polygons={Object.values(features)} />
</>
);
}
export function renderToDom(container) {
render(<App />, container);
}

View File

@ -2,14 +2,17 @@ import * as React from 'react';
import area from '@turf/area';
function ControlPanel(props) {
const polygon = props.polygon;
const polygonArea = polygon && area(polygon);
let polygonArea = 0;
for (const polygon of props.polygons) {
polygonArea += area(polygon);
}
return (
<div className="control-panel">
<h3>Draw Polygon</h3>
{polygon && (
{polygonArea > 0 && (
<p>
{polygonArea} <br />
{Math.round(polygonArea * 100) / 100} <br />
square meters
</p>
)}

View File

@ -0,0 +1,56 @@
import MapboxDraw from '@mapbox/mapbox-gl-draw';
import {useControl} from 'react-map-gl';
import type {MapboxMap, ControlPosition} from 'react-map-gl';
type ControlTypes =
| 'point'
| 'line_string'
| 'polygon'
| 'trash'
| 'combine_features'
| 'uncombine_features';
type DrawControlProps = {
keybindings?: boolean;
touchEnable?: boolean;
boxSelect?: boolean;
clickBuffer?: number;
touchBuffer?: number;
controls?: Partial<{[name in ControlTypes]: boolean}>;
displayControlsDefault?: boolean;
styles?: any;
modes?: any;
defaultMode?: string;
userProperties?: boolean;
position?: ControlPosition;
onCreate?: (evt: {features: object[]}) => void;
onUpdate?: (evt: {features: object[]; action: string}) => void;
onDelete?: (evt: {features: object[]}) => void;
};
export default function DrawControl(props: DrawControlProps) {
useControl(() => new MapboxDraw(props), {
position: props.position,
onAdd: (map: MapboxMap) => {
map.on('draw.create', props.onCreate);
map.on('draw.update', props.onUpdate);
map.on('draw.delete', props.onDelete);
},
onRemove: (map: MapboxMap) => {
map.off('draw.create', props.onCreate);
map.off('draw.update', props.onUpdate);
map.off('draw.delete', props.onDelete);
}
});
return null;
}
DrawControl.defaultProps = {
onCreate: () => {},
onUpdate: () => {},
onDelete: () => {}
};

View File

@ -1,49 +0,0 @@
import {RENDER_STATE} from 'react-map-gl-draw';
export function getEditHandleStyle({feature, state}) {
switch (state) {
case RENDER_STATE.SELECTED:
case RENDER_STATE.HOVERED:
case RENDER_STATE.UNCOMMITTED:
return {
fill: 'rgb(251, 176, 59)',
fillOpacity: 1,
stroke: 'rgb(255, 255, 255)',
strokeWidth: 2,
r: 7
};
default:
return {
fill: 'rgb(251, 176, 59)',
fillOpacity: 1,
stroke: 'rgb(255, 255, 255)',
strokeWidth: 2,
r: 5
};
}
}
export function getFeatureStyle({feature, index, state}) {
switch (state) {
case RENDER_STATE.SELECTED:
case RENDER_STATE.HOVERED:
case RENDER_STATE.UNCOMMITTED:
case RENDER_STATE.CLOSING:
return {
stroke: 'rgb(251, 176, 59)',
strokeWidth: 2,
fill: 'rgb(251, 176, 59)',
fillOpacity: 0.3,
strokeDasharray: '4,2'
};
default:
return {
stroke: 'rgb(60, 178, 208)',
strokeWidth: 2,
fill: 'rgb(60, 178, 208)',
fillOpacity: 0.1
};
}
}

View File

@ -0,0 +1,10 @@
{
"compilerOptions": {
"target": "es2020",
"jsx": "react",
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"moduleResolution": "node",
"sourceMap": true
}
}

View File

@ -5,33 +5,40 @@
const resolve = require('path').resolve;
const webpack = require('webpack');
const BABEL_CONFIG = {
presets: ['@babel/env', '@babel/react'],
plugins: ['@babel/proposal-class-properties']
};
const config = {
mode: 'development',
devServer: {
static: '.'
},
entry: {
app: resolve('./src/app.js')
app: resolve('./src/app')
},
output: {
library: 'App'
},
resolve: {
extensions: ['.ts', '.tsx', '.js', '.json']
},
module: {
rules: [
{
// Compile ES2015 using babel
test: /\.js$/,
test: /\.(ts|js)x?$/,
include: [resolve('.')],
exclude: [/node_modules/],
use: [
{
loader: 'babel-loader',
options: BABEL_CONFIG
options: {
presets: ['@babel/env', '@babel/react']
}
},
{
loader: 'ts-loader'
}
]
}
@ -39,7 +46,7 @@ const config = {
},
// Optional: Enables reading mapbox token from environment variable
plugins: [new webpack.EnvironmentPlugin(['MapboxAccessToken'])]
plugins: [new webpack.EnvironmentPlugin({MapboxAccessToken: ''})]
};
// Enables bundling against src in this repo rather than the installed version

View File

@ -30,15 +30,8 @@ export default function App() {
} = event;
const hoveredFeature = features && features[0];
setHoverInfo(
hoveredFeature
? {
feature: hoveredFeature,
x,
y
}
: null
);
// prettier-ignore
setHoverInfo(hoveredFeature && {feature: hoveredFeature, x, y});
}, []);
const data = useMemo(() => {

View File

@ -0,0 +1,18 @@
# react-map-gl Example: Using Map with a State Management System
This example shows how to use react-map-gl's Map component with the `useMap` hook.
## Usage
To run this example, you need a [Mapbox token](http://visgl.github.io/react-map-gl/docs/get-started/mapbox-tokens). You can either set it as `MAPBOX_TOKEN` in `map.js`, or set a `MapboxAccessToken` environment variable in the command line.
```bash
npm i
npm run start
```
To build a production version:
```bash
npm run build
```

View File

@ -0,0 +1,18 @@
/* global document */
import * as React from 'react';
import {render} from 'react-dom';
import {MapProvider} from 'react-map-gl';
import Map from './map';
import Controls from './controls';
function Root() {
return (
<MapProvider>
<Controls />
<Map />
</MapProvider>
);
}
render(<Root />, document.body.appendChild(document.createElement('div')));

57
examples/get-started/hook/controls.js vendored Normal file
View File

@ -0,0 +1,57 @@
import * as React from 'react';
import {useCallback, useState, useEffect} from 'react';
import {useMap} from 'react-map-gl';
export default function Controls() {
const {mymap} = useMap();
const [inputValue, setInputValue] = useState('');
const [hasError, setError] = useState(false);
useEffect(() => {
if (!mymap) {
return undefined;
}
const onMove = () => {
const {longitude, latitude} = mymap.getViewState();
setInputValue(`${longitude.toFixed(3)}, ${latitude.toFixed(3)}`);
setError(false);
};
mymap.on('move', onMove);
onMove();
return () => {
mymap.off('move', onMove);
};
}, [mymap]);
const onChange = useCallback(evt => {
setInputValue(evt.target.value);
}, []);
const onSubmit = useCallback(() => {
const [lng, lat] = inputValue.split(',').map(Number);
if (Math.abs(lng) <= 180 && Math.abs(lat) <= 85) {
mymap.easeTo({
center: [lng, lat],
duration: 1000
});
} else {
setError(true);
}
}, [mymap, inputValue]);
return (
<div style={{padding: 12, fontFamily: 'sans-serif'}}>
<span>MAP CENTER: </span>
<input
type="text"
value={inputValue}
onChange={onChange}
style={{color: hasError ? 'red' : 'black'}}
/>
<button onClick={onSubmit}>GO</button>
</div>
);
}

View File

@ -0,0 +1,22 @@
import * as React from 'react';
import Map from 'react-map-gl';
import 'mapbox-gl/dist/mapbox-gl.css';
const MAPBOX_TOKEN = ''; // Set your mapbox token here
export default function MapView() {
return (
<Map
id="mymap"
initialViewState={{
longitude: -122.4,
latitude: 37.8,
zoom: 14
}}
style={{width: 800, height: 600}}
mapStyle="mapbox://styles/mapbox/streets-v9"
mapboxAccessToken={MAPBOX_TOKEN}
/>
);
}

View File

@ -0,0 +1,24 @@
{
"scripts": {
"start": "webpack-dev-server --progress --hot --open",
"build": "webpack --mode=production -o ./dist"
},
"dependencies": {
"react": "^17.0.0",
"react-dom": "^17.0.0",
"react-map-gl": "^7.0.0",
"mapbox-gl": "^2.0.0"
},
"devDependencies": {
"@babel/core": "^7.0.0",
"@babel/preset-env": "^7.0.0",
"@babel/preset-react": "^7.0.0",
"babel-loader": "^8.0.0",
"css-loader": "^6.5.0",
"html-webpack-plugin": "^5.5.0",
"style-loader": "^3.3.0",
"webpack": "^5.65.0",
"webpack-cli": "^4.9.0",
"webpack-dev-server": "^4.7.0"
}
}

View File

@ -0,0 +1,39 @@
const webpack = require('webpack');
const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = {
mode: 'development',
entry: {
app: './app.js'
},
devtool: 'source-map',
module: {
rules: [
{
test: /\.css$/,
use: ['style-loader', 'css-loader']
},
{
test: /\.js$/,
exclude: [/node_modules/],
use: [
{
loader: 'babel-loader',
options: {
presets: ['@babel/env', '@babel/react']
}
}
]
}
]
},
// Optional: Enables reading mapbox token from environment variable
plugins: [
new HtmlWebpackPlugin({title: 'react-map-gl Example'}),
new webpack.EnvironmentPlugin({MapboxAccessToken: ''})
]
};

View File

@ -28,7 +28,7 @@ function NumericInput({name, value, onChange}) {
}
function ControlPanel(props) {
const {settings, interactionState, onChange} = props;
const {settings, onChange} = props;
const renderSetting = (name, value) => {
switch (typeof value) {

View File

@ -19,10 +19,7 @@ export default function App() {
const mapRef = useRef<MapRef>();
const onSelectCity = useCallback(({longitude, latitude}) => {
if (mapRef.current) {
const map = mapRef.current.getMap();
map.flyTo({center: [longitude, latitude]});
}
mapRef.current?.flyTo({center: [longitude, latitude], duration: 2000});
}, []);
return (

View File

@ -17,12 +17,10 @@ export default function App() {
const onClick = (event: MapLayerMouseEvent) => {
const feature = event.features[0];
if (feature) {
const map = mapRef.current.getMap();
// calculate the bounding box of the feature
const [minLng, minLat, maxLng, maxLat] = bbox(feature);
map.fitBounds(
mapRef.current.fitBounds(
[
[minLng, minLat],
[maxLng, maxLat]

View File

@ -17,7 +17,7 @@ export type AttributionControlProps = {
};
function AttributionControl(props: AttributionControlProps): null {
const ctrl = useControl(() => new mapboxgl.AttributionControl(props), props.position);
useControl(() => new mapboxgl.AttributionControl(props), {position: props.position});
return null;
}

View File

@ -14,12 +14,12 @@ export type FullscreenControlProps = {
};
function FullscreenControl(props: FullscreenControlProps): null {
const ctrl = useControl(
useControl(
() =>
new mapboxgl.FullscreenControl({
container: props.containerId && document.getElementById(props.containerId)
}),
props.position
{position: props.position}
);
return null;

View File

@ -58,27 +58,30 @@ export type GeolocateControlProps = {
const GeolocateControl = forwardRef<GeolocateControlRef, GeolocateControlProps>((props, ref) => {
const thisRef = useRef({props});
const ctrl = useControl(() => {
const gc = new mapboxgl.GeolocateControl(props);
const ctrl = useControl(
() => {
const gc = new mapboxgl.GeolocateControl(props);
gc.on('geolocate', e => {
thisRef.current.props.onGeolocate?.(e as GeolocateEvent);
});
gc.on('error', e => {
thisRef.current.props.onError?.(e as GeolocateErrorEvent);
});
gc.on('outofmaxbounds', e => {
thisRef.current.props.onOutOfMaxBounds?.(e as GeolocateEvent);
});
gc.on('trackuserlocationstart', e => {
thisRef.current.props.onTrackUserLocationStart?.(e as MapboxEvent);
});
gc.on('trackuserlocationend', e => {
thisRef.current.props.onTrackUserLocationEnd?.(e as MapboxEvent);
});
gc.on('geolocate', e => {
thisRef.current.props.onGeolocate?.(e as GeolocateEvent);
});
gc.on('error', e => {
thisRef.current.props.onError?.(e as GeolocateErrorEvent);
});
gc.on('outofmaxbounds', e => {
thisRef.current.props.onOutOfMaxBounds?.(e as GeolocateEvent);
});
gc.on('trackuserlocationstart', e => {
thisRef.current.props.onTrackUserLocationStart?.(e as MapboxEvent);
});
gc.on('trackuserlocationend', e => {
thisRef.current.props.onTrackUserLocationEnd?.(e as MapboxEvent);
});
return gc;
}, props.position) as mapboxgl.GeolocateControl;
return gc;
},
{position: props.position}
) as mapboxgl.GeolocateControl;
thisRef.current.props = props;

View File

@ -1,5 +1,5 @@
import {useContext, useEffect, useMemo, useState, useRef} from 'react';
import MapContext from './map-context';
import {MapContext} from './map';
import assert from '../utils/assert';
import {deepEqual} from '../utils/deep-equal';

View File

@ -1,4 +0,0 @@
import * as React from 'react';
import type {MapboxMap} from '../utils/types';
export default React.createContext<MapboxMap>(null);

View File

@ -1,17 +1,15 @@
import * as React from 'react';
import {useState, useRef, useEffect, forwardRef, useImperativeHandle} from 'react';
import {useState, useRef, useEffect, useContext, forwardRef, useImperativeHandle} from 'react';
import Mapbox from '../mapbox/mapbox';
import type {MapboxProps} from '../mapbox/mapbox';
import MapContext from './map-context';
import {MountedMapsContext} from './use-map';
import Mapbox, {MapboxProps} from '../mapbox/mapbox';
import createRef, {MapRef} from '../mapbox/create-ref';
import type {CSSProperties} from 'react';
import type {MapboxMap} from '../utils/types';
import useIsomorphicLayoutEffect from '../utils/use-isomorphic-layout-effect';
export interface MapRef {
getMap(): MapboxMap;
}
export const MapContext = React.createContext<MapboxMap>(null);
export type MapProps = MapboxProps & {
/** Map container id */
@ -47,7 +45,8 @@ const defaultProps: MapProps = {
renderWorldCopies: true
};
const Map = forwardRef((props: MapProps, ref) => {
const Map = forwardRef<MapRef, MapProps>((props, ref) => {
const mountedMapsContext = useContext(MountedMapsContext);
const [mapInstance, setMapInstance] = useState<Mapbox>(null);
const containerRef = useRef();
@ -55,7 +54,12 @@ const Map = forwardRef((props: MapProps, ref) => {
const map = new Mapbox(props);
map.initialize(containerRef.current);
setMapInstance(map);
return () => map.destroy();
mountedMapsContext?.onMapMount(createRef(map), props.id);
return () => {
mountedMapsContext?.onMapUnmount(props.id);
map.destroy();
};
}, []);
useIsomorphicLayoutEffect(() => {
@ -64,13 +68,7 @@ const Map = forwardRef((props: MapProps, ref) => {
}
});
useImperativeHandle(
ref,
() => ({
getMap: () => mapInstance.getMap()
}),
[mapInstance]
);
useImperativeHandle(ref, () => createRef(mapInstance), [mapInstance]);
const style: CSSProperties = {
position: 'relative',
@ -82,7 +80,7 @@ const Map = forwardRef((props: MapProps, ref) => {
return (
<div id={props.id} ref={containerRef} style={style}>
{mapInstance && (
<MapContext.Provider value={mapInstance.getMap()}>{props.children}</MapContext.Provider>
<MapContext.Provider value={mapInstance.map}>{props.children}</MapContext.Provider>
)}
</div>
);

View File

@ -6,7 +6,7 @@ import {useEffect, useState, useRef, useContext} from 'react';
import mapboxgl from '../utils/mapboxgl';
import type {MarkerDragEvent, MarkerOptions, MapboxPopup} from '../utils/types';
import MapContext from './map-context';
import {MapContext} from './map';
import {arePointsEqual} from '../utils/deep-equal';
export type MarkerProps = Omit<MarkerOptions, 'element'> & {

View File

@ -16,7 +16,7 @@ export type NavigationControlProps = {
};
function NavigationControl(props: NavigationControlProps): null {
const ctrl = useControl(() => new mapboxgl.NavigationControl(props), props.position);
useControl(() => new mapboxgl.NavigationControl(props), {position: props.position});
return null;
}

View File

@ -6,7 +6,7 @@ import {useEffect, useState, useRef, useContext} from 'react';
import mapboxgl from '../utils/mapboxgl';
import type {PopupOptions, MapboxEvent} from '../utils/types';
import MapContext from './map-context';
import {MapContext} from './map';
import {deepEqual} from '../utils/deep-equal';
export type PopupProps = PopupOptions & {

View File

@ -19,10 +19,9 @@ const defaultProps: ScaleControlProps = {
};
function ScaleControl(props: ScaleControlProps): null {
const ctrl = useControl(
() => new mapboxgl.ScaleControl(props),
props.position
) as mapboxgl.ScaleControl;
const ctrl = useControl(() => new mapboxgl.ScaleControl(props), {
position: props.position
}) as mapboxgl.ScaleControl;
// @ts-ignore
if (ctrl.options.unit !== props.unit || ctrl.options.maxWidth !== props.maxWidth) {

View File

@ -1,7 +1,7 @@
import * as React from 'react';
import {useContext, useEffect, useMemo, useState, useRef} from 'react';
import {cloneElement} from 'react';
import MapContext from './map-context';
import {MapContext} from './map';
import assert from '../utils/assert';
import {deepEqual} from '../utils/deep-equal';

View File

@ -1,18 +1,30 @@
import {useContext, useState, useEffect} from 'react';
import type {IControl, ControlPosition} from '../utils/types';
import MapContext from './map-context';
import type {IControl, ControlPosition, MapboxMap} from '../utils/types';
import {MapContext} from './map';
export default function useControl(onCreate: () => IControl, position?: ControlPosition) {
export default function useControl(
onCreate: () => IControl,
opts?: {
position?: ControlPosition;
onAdd?: (map: MapboxMap) => void;
onRemove?: (map: MapboxMap) => void;
}
) {
const map = useContext(MapContext);
const [ctrl] = useState(onCreate);
const [ctrl] = useState(onCreate());
useEffect(() => {
map.addControl(ctrl, position);
if (map) {
map.addControl(ctrl, opts?.position);
opts?.onAdd?.(map);
return () => {
map.removeControl(ctrl);
};
}, []);
return () => {
opts?.onRemove?.(map);
map.removeControl(ctrl);
};
}
return undefined;
}, [map]);
return ctrl;
}

View File

@ -0,0 +1,52 @@
import * as React from 'react';
import {useState, useCallback, useContext} from 'react';
import {MapRef} from '../mapbox/create-ref';
type MountedMapsContextValue = {
maps: {[id: string]: MapRef};
onMapMount: (map: MapRef, id: string) => void;
onMapUnmount: (id: string) => void;
};
export const MountedMapsContext = React.createContext<MountedMapsContextValue>(null);
export const MapProvider: React.FC<{}> = props => {
const [maps, setMaps] = useState<{[id: string]: MapRef}>({});
const onMapMount = useCallback(
(map: MapRef, id: string = 'default') => {
if (maps[id]) {
throw new Error(`Multiple maps with the same id: ${id}`);
}
setMaps({...maps, [id]: map});
},
[maps]
);
const onMapUnmount = useCallback(
(id: string = 'default') => {
const nextMaps = {...maps};
delete nextMaps[id];
setMaps(nextMaps);
},
[maps]
);
return (
<MountedMapsContext.Provider
value={{
maps,
onMapMount,
onMapUnmount
}}
>
{props.children}
</MountedMapsContext.Provider>
);
};
export function useMap() {
const {maps} = useContext(MountedMapsContext);
return maps;
}

View File

@ -1,6 +1,7 @@
export {default} from './components/map';
export {default as Map, MapProps, MapRef} from './components/map';
export {default as Map, MapProps} from './components/map';
export {MapRef} from './mapbox/create-ref';
export {default as Marker, MarkerProps} from './components/marker';
export {default as Popup, PopupProps} from './components/popup';
@ -22,5 +23,8 @@ export {default as ScaleControl, ScaleControlProps} from './components/scale-con
export {default as Source, SourceProps} from './components/source';
export {default as Layer, LayerProps} from './components/layer';
export {default as useControl} from './components/use-control';
export {MapProvider, useMap} from './components/use-map';
// Types
export * from './utils/types';

130
src/mapbox/create-ref.ts Normal file
View File

@ -0,0 +1,130 @@
import type {MapboxMap, ViewState} from '../utils/types';
import type Mapbox from './mapbox';
/** mapboxgl.Map methods to forward to the ref object
Object.getOwnPropertyNames(Object.getPrototypeOf(map))
.filter(key => typeof temp1[key] === 'function' && key[0] != '_')
*/
const forwardMethods = [
// 'getCenter',
// 'setCenter',
'panBy',
'panTo',
// 'getZoom',
// 'setZoom',
'zoomTo',
'zoomIn',
'zoomOut',
// 'getBearing',
// 'setBearing',
// 'getPadding',
// 'setPadding',
'rotateTo',
'resetNorth',
'resetNorthPitch',
'snapToNorth',
// 'getPitch',
// 'setPitch',
'cameraForBounds',
'fitBounds',
'fitScreenCoordinates',
'jumpTo',
'getFreeCameraOptions',
'setFreeCameraOptions',
'easeTo',
'flyTo',
'isEasing',
'stop',
// "addControl",
// "removeControl",
// "hasControl",
'getContainer',
'getCanvasContainer',
'getCanvas',
'resize',
'getBounds',
// "getMaxBounds",
// "setMaxBounds",
// "setMinZoom",
// "getMinZoom",
// "setMaxZoom",
// "getMaxZoom",
// "setMinPitch",
// "getMinPitch",
// "setMaxPitch",
// "getMaxPitch",
// "getRenderWorldCopies",
// "setRenderWorldCopies",
// "getProjection",
// "setProjection",
'project',
'unproject',
'isMoving',
'isZooming',
'isRotating',
'on',
'once',
'off',
'queryRenderedFeatures',
'querySourceFeatures',
'queryTerrainElevation',
// "setStyle",
// "getStyle",
'isStyleLoaded',
// "addSource",
'isSourceLoaded',
'areTilesLoaded',
// "addSourceType",
// "removeSource",
'getSource',
'addImage',
'updateImage',
'hasImage',
'removeImage',
'loadImage',
'listImages',
// "addLayer",
'moveLayer',
// "removeLayer",
'getLayer',
// "setLayerZoomRange",
// "setFilter",
// "getFilter",
// "setPaintProperty",
// "getPaintProperty",
// "setLayoutProperty",
// "getLayoutProperty",
'setLight',
'getLight',
'setTerrain',
'getTerrain',
'setFog',
'getFog',
'setFeatureState',
'removeFeatureState',
'getFeatureState',
'loaded'
// "remove",
// "triggerRepaint"
] as const;
export type MapRef = {
getMap(): MapboxMap;
getViewState(): ViewState;
} & Pick<MapboxMap, typeof forwardMethods[number]>;
export default function createRef(mapInstance: Mapbox): MapRef {
if (!mapInstance) {
return null;
}
const result: any = {
getMap: () => mapInstance.map,
getViewState: () => mapInstance.viewState
};
for (const key of forwardMethods) {
result[key] = mapInstance.map[key].bind(mapInstance.map);
}
return result;
}

View File

@ -19,7 +19,8 @@ import type {
MapDataEvent,
MapboxEvent,
ErrorEvent,
MapboxGeoJSONFeature
MapboxGeoJSONFeature,
MapboxMap
} from '../utils/types';
export type MapboxProps = Omit<
@ -198,8 +199,12 @@ export default class Mapbox {
this.props = props;
}
getMap() {
return this._map;
get map(): MapboxMap {
return this._map as MapboxMap;
}
get viewState(): ViewState {
return transformToViewState(this._renderTransform);
}
setProps(props: MapboxProps) {