Hi folks 👋!
In this post I'm gonna show you how to fit the map viewport to the markers we have using react-map-gl and viewport-mercator-project, React, Typescript. Seems that there are people with doubts about how to do it, so seems that is time to help the community👌!
First, let's describe the task:
We have an array of markers that came from outside our component (for example, our API) and we want to adjust our map viewport to be able to see all the markers we received.
Step 1. Install dependencies
You should install types only if you're using Typescript:
yarn add react-map-gl viewport-mercator-project @types/react-map-gl @types/viewport-mercator-project
Step 2. Create a Map component.
Remember to:
- Import the mapbox styles css.
- Use a mapbox layer. I've used the dark-v10.
- Use your mapbox token. For security reasons, it should go in your environment variables.
We will use two refs here.
mapContainerRef: a reference to the container. This is needed to get the viewport width and height as a numeric value (mercator viewport doesn't support string values like "100%").
mapRef: a reference to the map.
import React from "react";
import ReactMapGL, { Marker, NavigationControl } from "react-map-gl";
import "mapbox-gl/dist/mapbox-gl.css";
import "./map.css";
const MAP_STYLE = { width: "100%", height: "100%" };
const MAP_CONFIG = {
maxZoom: 20,
mapStyle: "mapbox://styles/mapbox/dark-v10",
mapboxApiAccessToken: process.env.REACT_APP_MAPBOX_KEY
};
export const Map: React.FC<unknown> = () => {
const mapRef = React.useRef();
const mapContainerRef = React.useRef(null);
const viewport = {
width: 400,
height: 400
};
return (
<div ref={mapContainerRef} className="map">
<ReactMapGL ref={mapRef} {...MAP_CONFIG} {...viewport}>
<NavigationControl className="navigation-control"
showCompass={false} />
</ReactMapGL>
</div>
);
};
Styles:
.map {
width: 100%;
height: 100%;
}
.navigation-control {
position: absolute;
right: 0;
margin-right: 10px;
margin-top: 10px;
}
We will use Marker later.
Step 3. Adjust the map.
You may noticed that our viewport is hardcoded to width: 400, height: 400. Let's use the useSize hook to get the container size. Then we will use its width and height for the map viewport (so our map will be of the size of its container).
yarn add react-hook-size
export const Map: React.FC<unknown> = () => {
const mapRef = React.useRef();
const mapContainerRef = React.useRef(null);
const { width, height } = useSize(mapContainerRef)
const viewport = {
width: width || 400,
height: height || 400
};
return (
<div ref={mapContainerRef} style={MAP_STYLE}>
<ReactMapGL ref={mapRef} {...MAP_CONFIG} {...viewport}>
<NavigationControl className="navigation-control"
showCompass={false} />
</ReactMapGL>
</div>
);
};
Step 4. Update viewport when it change.
Now need to move our viewport inside a state.
We also need a useEffect to update our viewport when the container size changes.
And we need to create a function that will update the viewport when it changes (onViewportChange). We have to assign that function to the map:
const [viewport, setViewport] = React.useState({
width: width || 400,
height: height || 400
});
React.useEffect(() => {
if(width && height){
setViewport((viewport) => ({
...viewport,
width,
height
}));
}
}, [width, height]);
const onViewportChange = (nextViewport: ViewportProps) => setViewport(nextViewport)
return (
...
<ReactMapGL
...
onViewportChange={onViewportChange}>
...
</ReactMapGL>
...
);
Step 5. Create a map marker component.
Let's go to https://heroicons.com/ and copy a map marker (JSX option), and create a MarkerIcon component using that JSX. I've added a width, height and a stroke color:
export const MarkerIcon: React.FC<unknown> = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="35"
height="56"
fill=""
viewBox="0 0 24 24"
stroke="#3eb8db"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827
0l-4.244-4.243a8 8 0 1111.314 0z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
);
Step 6. Rendering markers.
Define some hardcoded markers and render a marker per each in the map per each one.
type MarkerType = {
id: string;
latitude: number;
longitude: number;
};
const MARKERS: MarkerType[] = [
{
id: "tenerife-1",
latitude: 28.481174533178944,
longitude: -16.318345483015758
},
{
id: "tenerife-2",
latitude: 28.352557291487383,
longitude: -16.745886630143065
}
];
...
return (
...
<ReactMapGL
...
>
...
{MARKERS.map(({ id, ...marker }) => (
<Marker key={id} {...marker} offsetLeft={-17.5}
offsetTop={-38}>
<MarkerIcon />
</Marker>
))}
</ReactMapGL>
...
);
TIP: offset property helps to adjust marker position in the map depending on the icon we are using.
So far, we have been creating our map with some markers and you should see something like:
If you do zoom in, you will see that we have two markers.
Step 7. Getting our markers bounds.
Now we are going to find the bounds of our markers, which is needed by WebMercatorViewport fitBounds method.
Install lodash:
yarn add lodash @types/lodash
Import maxBy and minBy from lodash. We will use it to find the maxium lat and long, and the minimum lat and long. All together will be our bounds.
import { maxBy, minBy } from "lodash";
const getMinOrMax = (markers: MarkerType[], minOrMax: "max" | "min", latOrLng: "latitude" | "longitude") => {
if (minOrMax === "max") {
return (maxBy(markers, value => value[latOrLng]) as any)[latOrLng];
} else {
return (minBy(markers, value => value[latOrLng]) as any)[latOrLng];
}
};
const getBounds = (markers: MarkerType[]) => {
const maxLat = getMinOrMax(markers, "max", "latitude");
const minLat = getMinOrMax(markers, "min", "latitude");
const maxLng = getMinOrMax(markers, "max", "longitude");
const minLng = getMinOrMax(markers, "min", "longitude");
const southWest = [minLng, minLat];
const northEast = [maxLng, maxLat];
return [southWest, northEast];
};
Now using our markers, we can get the bounds:
const MARKERS_BOUNDS = getBounds(MARKERS);
Step 8. Fit map to bounds.
Let's update our viewport useEffect to listen to marker changes and update the viewport using the bounds:
import WebMercatorViewport, { Bounds } from "viewport-mercator-project"
...
React.useEffect(() => {
if (width && height) {
const MARKERS_BOUNDS = getBounds(MARKERS);
setViewport((viewport) => {
const NEXT_VIEWPORT = new WebMercatorViewport({
...(viewport as WebMercatorViewport),
width,
height
}).fitBounds(MARKERS_BOUNDS as Bounds, {
padding: 100
});
return NEXT_VIEWPORT;
});
}
}, [width, height]);
...
Now we create a WebMercatorViewport using our current viewport and the current width and height. Then we fit the viewport to the MARKERS_BOUNDS adding some padding.
That will be our next viewport, so we store it as NEXT_VIEWPORT and return it (we can do it inline, but I think this way is more clear).
Now you will see that the map is fit to our markers:
Try now to add one marker in France and another one in London. You will see that our map works as expected.
const MARKERS: MarkerType[] = [
{
id: "tenerife-1",
latitude: 28.481174533178944,
longitude: -16.318345483015758
},
{
id: "tenerife-2",
latitude: 28.352557291487383,
longitude: -16.745886630143065
},
{
id: "london",
latitude: 51.52167056034225,
longitude: -0.12894469488176763
},
{ id: "france", latitude: 46.58635156377568, longitude: 2.1796793230151184 }
];
Final notes
Now if you want to listen to map marker changes, you've to add your markers as dependency to the viewport effect and receive it via props:
export const Map: React.FC<{ markers: MarkerType[] }> = ({ markers = []) => {
...
React.useEffect(() => {
if (width && height && markers.length) {
const MARKERS_BOUNDS = getBounds(markers);
...
}
}, [width, height, markers]);
...
<ReactMapGL
...
>
...
{markers.map(({ id, ...marker }) => (
<Marker key={id} {...marker} offsetLeft={-17.5} offsetTop={-38}>
<MarkerIcon />
</Marker>
))}
</ReactMapGL>
}
As recommendation, you can move all the map logic inside a custom hook (useMap), and getBounds and getMinOrMax to an utilities file (for example mapUtils). Here the full example:
https://codesandbox.io/s/react-map-gl-fit-to-markers-lq1mp
You need to add your own mapbox token to see it working.
If you've any doubts, feel free to ask here.
Follow me if you want: @ivanbtrujillo
🙂 Hope you've enjoyed it!
Top comments (2)
I can't believe it doesn't have a prop fitToMarkers={true}
An excellent article, thank you.