DEV Community

Cover image for Fit viewport to markers using react-map-gl
ɪᴠᴀɴ
ɪᴠᴀɴ

Posted on

Fit viewport to markers using react-map-gl

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
Enter fullscreen mode Exit fullscreen mode

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>
  );
};

Enter fullscreen mode Exit fullscreen mode

Styles:

.map {
  width: 100%;
  height: 100%;
}

.navigation-control {
  position: absolute;
  right: 0;
  margin-right: 10px;
  margin-top: 10px;
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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>
    ...
  );
Enter fullscreen mode Exit fullscreen mode

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>
);
Enter fullscreen mode Exit fullscreen mode

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>
    ...
  );

Enter fullscreen mode Exit fullscreen mode

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:

Alt Text

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
Enter fullscreen mode Exit fullscreen mode

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];
};

Enter fullscreen mode Exit fullscreen mode

Now using our markers, we can get the bounds:

const MARKERS_BOUNDS = getBounds(MARKERS);
Enter fullscreen mode Exit fullscreen mode

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]);

...

Enter fullscreen mode Exit fullscreen mode

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:

Alt Text

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 }
];
Enter fullscreen mode Exit fullscreen mode

Alt Text

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>

}
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
agustind profile image
Agustin

I can't believe it doesn't have a prop fitToMarkers={true}

Collapse
 
mattheslington profile image
Matt Heslington

An excellent article, thank you.