From 3f200a0bfe6f65de6024eb537899abeb98c80bbc Mon Sep 17 00:00:00 2001 From: Xiaoji Chen Date: Sun, 2 Jan 2022 20:41:52 -0800 Subject: [PATCH] [v7] utility hooks (#1663) --- docs/api-reference/map-provider.md | 21 +++ docs/api-reference/map.md | 12 +- docs/api-reference/use-control.md | 64 +++++++++ docs/api-reference/use-map.md | 37 +++++ examples/clusters/src/app.tsx | 5 +- examples/draw-polygon/README.md | 2 +- examples/draw-polygon/app.css | 24 ---- examples/draw-polygon/index.html | 52 +++++-- examples/draw-polygon/package.json | 26 ++-- examples/draw-polygon/src/app.js | 89 ------------ examples/draw-polygon/src/app.tsx | 64 +++++++++ .../{control-panel.js => control-panel.tsx} | 11 +- examples/draw-polygon/src/draw-control.ts | 56 ++++++++ examples/draw-polygon/src/style.js | 49 ------- examples/draw-polygon/tsconfig.json | 10 ++ examples/draw-polygon/webpack.config.js | 27 ++-- examples/geojson/src/app.tsx | 11 +- examples/get-started/hook/README.md | 18 +++ examples/get-started/hook/app.js | 18 +++ examples/get-started/hook/controls.js | 57 ++++++++ examples/get-started/hook/map.js | 22 +++ examples/get-started/hook/package.json | 24 ++++ examples/get-started/hook/webpack.config.js | 39 ++++++ examples/interaction/src/control-panel.tsx | 2 +- examples/viewport-animation/src/app.tsx | 5 +- examples/zoom-to-bounds/src/app.tsx | 4 +- src/components/attribution-control.ts | 2 +- src/components/fullscreen-control.ts | 4 +- src/components/geolocate-control.ts | 41 +++--- src/components/layer.ts | 2 +- src/components/map-context.ts | 4 - src/components/map.tsx | 32 ++--- src/components/marker.ts | 2 +- src/components/navigation-control.ts | 2 +- src/components/popup.ts | 2 +- src/components/scale-control.ts | 7 +- src/components/source.ts | 2 +- src/components/use-control.ts | 30 ++-- src/components/use-map.tsx | 52 +++++++ src/index.ts | 6 +- src/mapbox/create-ref.ts | 130 ++++++++++++++++++ src/mapbox/mapbox.ts | 11 +- 42 files changed, 784 insertions(+), 294 deletions(-) create mode 100644 docs/api-reference/map-provider.md create mode 100644 docs/api-reference/use-control.md create mode 100644 docs/api-reference/use-map.md delete mode 100644 examples/draw-polygon/app.css delete mode 100644 examples/draw-polygon/src/app.js create mode 100644 examples/draw-polygon/src/app.tsx rename examples/draw-polygon/src/{control-panel.js => control-panel.tsx} (73%) create mode 100644 examples/draw-polygon/src/draw-control.ts delete mode 100644 examples/draw-polygon/src/style.js create mode 100644 examples/draw-polygon/tsconfig.json create mode 100644 examples/get-started/hook/README.md create mode 100644 examples/get-started/hook/app.js create mode 100644 examples/get-started/hook/controls.js create mode 100644 examples/get-started/hook/map.js create mode 100644 examples/get-started/hook/package.json create mode 100644 examples/get-started/hook/webpack.config.js delete mode 100644 src/components/map-context.ts create mode 100644 src/components/use-map.tsx create mode 100644 src/mapbox/create-ref.ts diff --git a/docs/api-reference/map-provider.md b/docs/api-reference/map-provider.md new file mode 100644 index 00000000..cf10f012 --- /dev/null +++ b/docs/api-reference/map-provider.md @@ -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 ( + + { + // Application tree, somewhere one or more component(s) are rendered + } + + ); +} +``` + +See [useMap](/docs/api-reference/use-map.md) for more information. diff --git a/docs/api-reference/map.md b/docs/api-reference/map.md index fec6f4fb..4b7b6456 100644 --- a/docs/api-reference/map.md +++ b/docs/api-reference/map.md @@ -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 diff --git a/docs/api-reference/use-control.md b/docs/api-reference/use-control.md new file mode 100644 index 00000000..af053046 --- /dev/null +++ b/docs/api-reference/use-control.md @@ -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 ( + + + + ); +} +``` + +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. diff --git a/docs/api-reference/use-map.md b/docs/api-reference/use-map.md new file mode 100644 index 00000000..0ca1f3ae --- /dev/null +++ b/docs/api-reference/use-map.md @@ -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 ( + + + + + + ); +} + +function NavigateButton() { + const {myMapA, myMapB} = useMap(); + + const onClick = () => { + myMapA.flyTo({center: [-122.4, 37.8]}); + myMapB.flyTo({center: [-74, 40.7]}); + }; + + return ; +} +``` + +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). diff --git a/examples/clusters/src/app.tsx b/examples/clusters/src/app.tsx index aa1368c3..61a3d1cd 100644 --- a/examples/clusters/src/app.tsx +++ b/examples/clusters/src/app.tsx @@ -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 diff --git a/examples/draw-polygon/README.md b/examples/draw-polygon/README.md index 057fe1ab..afa42cab 100644 --- a/examples/draw-polygon/README.md +++ b/examples/draw-polygon/README.md @@ -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 diff --git a/examples/draw-polygon/app.css b/examples/draw-polygon/app.css deleted file mode 100644 index 72db167a..00000000 --- a/examples/draw-polygon/app.css +++ /dev/null @@ -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; -} diff --git a/examples/draw-polygon/index.html b/examples/draw-polygon/index.html index 3506f5a3..a9e93176 100644 --- a/examples/draw-polygon/index.html +++ b/examples/draw-polygon/index.html @@ -1,17 +1,41 @@ - - - react-map-gl Example - - - -
- - - - - + + + react-map-gl Example + + + + + +
+ + + diff --git a/examples/draw-polygon/package.json b/examples/draw-polygon/package.json index 062f866a..a6b9fe40 100644 --- a/examples/draw-polygon/package.json +++ b/examples/draw-polygon/package.json @@ -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" } } diff --git a/examples/draw-polygon/src/app.js b/examples/draw-polygon/src/app.js deleted file mode 100644 index 9d4868b0..00000000 --- a/examples/draw-polygon/src/app.js +++ /dev/null @@ -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 = ( -
-
-
-
- ); - - const features = editorRef.current && editorRef.current.getFeatures(); - const selectedFeature = - features && (features[selectedFeatureIndex] || features[features.length - 1]); - - return ( - <> - - - {drawTools} - - - - ); -} - -export function renderToDom(container) { - render(, container); -} diff --git a/examples/draw-polygon/src/app.tsx b/examples/draw-polygon/src/app.tsx new file mode 100644 index 00000000..47cfadac --- /dev/null +++ b/examples/draw-polygon/src/app.tsx @@ -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 ( + <> + + + + + + ); +} + +export function renderToDom(container) { + render(, container); +} diff --git a/examples/draw-polygon/src/control-panel.js b/examples/draw-polygon/src/control-panel.tsx similarity index 73% rename from examples/draw-polygon/src/control-panel.js rename to examples/draw-polygon/src/control-panel.tsx index c87a10ad..da11cb9c 100644 --- a/examples/draw-polygon/src/control-panel.js +++ b/examples/draw-polygon/src/control-panel.tsx @@ -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 (

Draw Polygon

- {polygon && ( + {polygonArea > 0 && (

- {polygonArea}
+ {Math.round(polygonArea * 100) / 100}
square meters

)} diff --git a/examples/draw-polygon/src/draw-control.ts b/examples/draw-polygon/src/draw-control.ts new file mode 100644 index 00000000..1775061c --- /dev/null +++ b/examples/draw-polygon/src/draw-control.ts @@ -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: () => {} +}; diff --git a/examples/draw-polygon/src/style.js b/examples/draw-polygon/src/style.js deleted file mode 100644 index bfcfd859..00000000 --- a/examples/draw-polygon/src/style.js +++ /dev/null @@ -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 - }; - } -} diff --git a/examples/draw-polygon/tsconfig.json b/examples/draw-polygon/tsconfig.json new file mode 100644 index 00000000..ff49de04 --- /dev/null +++ b/examples/draw-polygon/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "target": "es2020", + "jsx": "react", + "allowSyntheticDefaultImports": true, + "resolveJsonModule": true, + "moduleResolution": "node", + "sourceMap": true + } +} \ No newline at end of file diff --git a/examples/draw-polygon/webpack.config.js b/examples/draw-polygon/webpack.config.js index 78fe2a6a..ed4df0d5 100644 --- a/examples/draw-polygon/webpack.config.js +++ b/examples/draw-polygon/webpack.config.js @@ -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 diff --git a/examples/geojson/src/app.tsx b/examples/geojson/src/app.tsx index dfceeb29..4a08882f 100644 --- a/examples/geojson/src/app.tsx +++ b/examples/geojson/src/app.tsx @@ -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(() => { diff --git a/examples/get-started/hook/README.md b/examples/get-started/hook/README.md new file mode 100644 index 00000000..6a2e74e9 --- /dev/null +++ b/examples/get-started/hook/README.md @@ -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 +``` diff --git a/examples/get-started/hook/app.js b/examples/get-started/hook/app.js new file mode 100644 index 00000000..f93eed5a --- /dev/null +++ b/examples/get-started/hook/app.js @@ -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 ( + + + + + ); +} + +render(, document.body.appendChild(document.createElement('div'))); diff --git a/examples/get-started/hook/controls.js b/examples/get-started/hook/controls.js new file mode 100644 index 00000000..5d4c7151 --- /dev/null +++ b/examples/get-started/hook/controls.js @@ -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 ( +
+ MAP CENTER: + + +
+ ); +} diff --git a/examples/get-started/hook/map.js b/examples/get-started/hook/map.js new file mode 100644 index 00000000..f7c0e222 --- /dev/null +++ b/examples/get-started/hook/map.js @@ -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 ( + + ); +} diff --git a/examples/get-started/hook/package.json b/examples/get-started/hook/package.json new file mode 100644 index 00000000..c0dc4cc7 --- /dev/null +++ b/examples/get-started/hook/package.json @@ -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" + } +} diff --git a/examples/get-started/hook/webpack.config.js b/examples/get-started/hook/webpack.config.js new file mode 100644 index 00000000..f2fc1518 --- /dev/null +++ b/examples/get-started/hook/webpack.config.js @@ -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: ''}) + ] +}; diff --git a/examples/interaction/src/control-panel.tsx b/examples/interaction/src/control-panel.tsx index 36edd7b7..08dd6f3b 100644 --- a/examples/interaction/src/control-panel.tsx +++ b/examples/interaction/src/control-panel.tsx @@ -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) { diff --git a/examples/viewport-animation/src/app.tsx b/examples/viewport-animation/src/app.tsx index a05b53aa..3f4dbbed 100644 --- a/examples/viewport-animation/src/app.tsx +++ b/examples/viewport-animation/src/app.tsx @@ -19,10 +19,7 @@ export default function App() { const mapRef = useRef(); 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 ( diff --git a/examples/zoom-to-bounds/src/app.tsx b/examples/zoom-to-bounds/src/app.tsx index c9887389..ef8de0a8 100644 --- a/examples/zoom-to-bounds/src/app.tsx +++ b/examples/zoom-to-bounds/src/app.tsx @@ -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] diff --git a/src/components/attribution-control.ts b/src/components/attribution-control.ts index 64421b2f..be095742 100644 --- a/src/components/attribution-control.ts +++ b/src/components/attribution-control.ts @@ -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; } diff --git a/src/components/fullscreen-control.ts b/src/components/fullscreen-control.ts index 0ebc1e99..43bb521d 100644 --- a/src/components/fullscreen-control.ts +++ b/src/components/fullscreen-control.ts @@ -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; diff --git a/src/components/geolocate-control.ts b/src/components/geolocate-control.ts index 069d01dd..fba37930 100644 --- a/src/components/geolocate-control.ts +++ b/src/components/geolocate-control.ts @@ -58,27 +58,30 @@ export type GeolocateControlProps = { const GeolocateControl = forwardRef((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; diff --git a/src/components/layer.ts b/src/components/layer.ts index f2c07623..80c498d9 100644 --- a/src/components/layer.ts +++ b/src/components/layer.ts @@ -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'; diff --git a/src/components/map-context.ts b/src/components/map-context.ts deleted file mode 100644 index 1c8a44e9..00000000 --- a/src/components/map-context.ts +++ /dev/null @@ -1,4 +0,0 @@ -import * as React from 'react'; -import type {MapboxMap} from '../utils/types'; - -export default React.createContext(null); diff --git a/src/components/map.tsx b/src/components/map.tsx index 99c9c638..30955c1f 100644 --- a/src/components/map.tsx +++ b/src/components/map.tsx @@ -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(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((props, ref) => { + const mountedMapsContext = useContext(MountedMapsContext); const [mapInstance, setMapInstance] = useState(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 (
{mapInstance && ( - {props.children} + {props.children} )}
); diff --git a/src/components/marker.ts b/src/components/marker.ts index c2764a7c..bdb8baf7 100644 --- a/src/components/marker.ts +++ b/src/components/marker.ts @@ -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 & { diff --git a/src/components/navigation-control.ts b/src/components/navigation-control.ts index 69b2639a..a5fd54e7 100644 --- a/src/components/navigation-control.ts +++ b/src/components/navigation-control.ts @@ -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; } diff --git a/src/components/popup.ts b/src/components/popup.ts index 824a7709..cdf5ce3c 100644 --- a/src/components/popup.ts +++ b/src/components/popup.ts @@ -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 & { diff --git a/src/components/scale-control.ts b/src/components/scale-control.ts index 3e8421e3..1b54d28a 100644 --- a/src/components/scale-control.ts +++ b/src/components/scale-control.ts @@ -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) { diff --git a/src/components/source.ts b/src/components/source.ts index 22390470..81e9345e 100644 --- a/src/components/source.ts +++ b/src/components/source.ts @@ -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'; diff --git a/src/components/use-control.ts b/src/components/use-control.ts index 62260043..90293e92 100644 --- a/src/components/use-control.ts +++ b/src/components/use-control.ts @@ -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; } diff --git a/src/components/use-map.tsx b/src/components/use-map.tsx new file mode 100644 index 00000000..6ada4fb8 --- /dev/null +++ b/src/components/use-map.tsx @@ -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(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 ( + + {props.children} + + ); +}; + +export function useMap() { + const {maps} = useContext(MountedMapsContext); + return maps; +} diff --git a/src/index.ts b/src/index.ts index 8841bfaf..3cb8bd6b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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'; diff --git a/src/mapbox/create-ref.ts b/src/mapbox/create-ref.ts new file mode 100644 index 00000000..ffd5d54e --- /dev/null +++ b/src/mapbox/create-ref.ts @@ -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; + +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; +} diff --git a/src/mapbox/mapbox.ts b/src/mapbox/mapbox.ts index dc3166d7..8fc1426b 100644 --- a/src/mapbox/mapbox.ts +++ b/src/mapbox/mapbox.ts @@ -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) {