Manage Map Effects & Dynamically Calculate a Map's Center with Turf

Colby Fayock
InstructorColby Fayock

Share this video with your friends

Send Tweet
Published a year ago
Updated 10 months ago

When trying to show store locations on a map, showing the entire world by default when you only have a few locations to show means more work for your customer, trying to narrow down and finding where they actually are. Instead, a better UX could be zooming into a specific portion of that map, centering it in the middle of all of your locations.

To do this, we can use geospatial analysis tools like Turf.js which allows us to provide a set of coordinates and calculate where that center is.

We'll walk through setting up Turf.js in a Next.js app and using it to calculate the center of all of our online stores. In order to allow someone to select the store and automatically zoom in, we'll also use the useEffect hook along with our React Leaflet map to change the view whenever someone clicks on a store.

Instructor: [0:00] We have our store locator page, where we have our locations listed out. We also have a map that shows our locations. We could probably do something to fix the UX here a little bit, where we don't want people to have to scroll over on this page.

[0:12] We want it to be nice and centered and zoomed in whenever somebody loads the page. On top of that, we have these links in the sidebar where we want to make it so that if somebody clicks view on map, it's going to automatically zoom in on a particular location.

[0:26] Starting with the map, in order to figure out where we can actually center the map, we need to do some calculations to figure out where our existing markers are and what area we can actually zoom in on.

[0:36] To do that, we're going to use Turf.js, which is a geospatial analysis tool that we can use right inside of our project. Particularly, we're going to use the center function, which is going to allow us to calculate well the center of the map where our markers are.

[0:49] We're also going to use a helper function called point, which is going to allow us to turn our coordinates into what's called a feature or what's going to be understandable by the library and geospatial analysis tools.

[1:01] Getting started, we're not going to try to import the entire library, as that's way too much. We only want to import what we need so that we can keep things lean.

[1:09] The first thing we're going to do is add our center as a dependency. We can see here that it's going to be using @turf/center. We'll also see that our point is going to be available inside @turf/helpers. We'll need to import all the helpers in order to do that.

[1:23] Inside my terminal, I'm going to run yarn add @turf/center. I'm going to add a space and then @turf/helpers. Then, inside of my stores.js file, this is where we're going to perform the calculation, so we can tell the map exactly where to go.

[1:38] Under our Apollo Client import, I'm going to add import center from @turf/center. Keep in mind, we're not destructuring this. We're importing the default. Then, I'm going to also import points, which this time it is destructured from @turf/helpers.

[1:55] Now, the first thing we're going to run is our points function. Where we can see here, we're looking at a point, but we're going to use points with an S plural so that we can pass in an array of all of our stores and get back an array.

[2:09] We can see here that it's going to be similar. The only difference is going to be we're going to pass in an array. For our coordinates, we're going to pass in longitude, latitude positions for each one. If we look down at an example, we can see exactly that where for the singular version, we're going to pass in the longitude, latitude for that particular point.

[2:28] At the top of my component, I'm going to create a new constant called features, which is the terminology used for those points. I'm going to say that I want to set my store locations and create a map where for each location, I'm going to eventually return a new value.

[2:42] If we remember, our location is stored in location.location. I can destructure this and call it location, because we're not going to have to use any of the top-level data. That said, we can then pass in an array which is going to be our coordinates of location.latitude and location.longitude.

[3:00] Now that we're going to have an array of these coordinates, we want to now use our points function. I'm going to go ahead and wrap that entire thing with our points function. I'm going to take this array of features and I'm going to pass that into the center function, so that the center can determine the middle of all those points.

[3:22] If we look at center, we can see that our argument is going to be GeoJSON, which is exactly what our features are made up of. That's going to be the format that's used by tools like Turf and geospatial analysis. We don't need to worry about that particular terminology.

[3:36] All we need to know is that our array of features is an array of GeoJSON objects. Where if we look down at the example, we can see that we're going to pass in our features right into turf.center, similar to what we see inside the example.

[3:51] Typically, I would name my new constant center, but since we already are importing Turf/center as center, we're going use a temporary value as we're going to grab the value by destructuring in a second. For now, let's say, constant my center.

[4:05] Let's set that equal to center, and let's simply pass in our features. With that value, let's console.log that out, so we can see what it looks like. If we look inside of our console, where it looks like React is yelling at us for a key prop from earlier.

[4:20] We can see this object that we're console logging out, where we start to see things like the geometry and the properties, where this is going to be what we're calling a feature. This is going to be a GeoJSON object. Where we can see inside of this geometry, we have the coordinates which is exactly what we want for our center.

[4:38] On my responsive center, I want to say, if I get something back, I want to get my geometry and I want to get my coordinates. Now, I can destructure those values right from the coordinates. Looking at the coordinates, we have our latitude and we have our longitude, so we can destructure them as an array.

[4:54] I'm going to destructure as an array and I'm going to say default latitude and default longitude. Of course, we want to get rid of that my center. With my defaults, I'm going to scroll down until I find that instance of a map.

[5:09] I'm going to replace this center, where I'm going to now replace the first with default latitude and the second with default longitude. As soon as we reload the page, we can see that it's now centered on all of our markers. While we can probably programmatically also zoom in on this, we could set this as a closer zoom level for now.

[5:29] I'm going to say that I want a zoom of four instead of two, and I think that's looking pretty good for showing my locations on my map. Next, anytime I click on one of these view on map links, I want that to automatically zoom in on one of these locations.

[5:44] In order for the map and the sidebar to understand each other, we need to set a top-level state where anytime somebody clicks on view on map, we're going to set an active location. Once we have that active location, our map will be able to see that and then zoom in on that location any time it changes.

[6:01] To start, we need to be able to set our state. I'm going to import useState from React and that's inside of my stores.js file. Then inside of my stores component, I'm going to create a new instance and call that active store, as well as use the setter for set active store. Then set that equal to useState without a default.

[6:23] Now, anytime somebody clicks on this view on map button, I want to trigger a function that's going to update that store. Inside of the top of my map where I'm looping through and creating all those locations, I'm going to create a new function called handleOnClick.

[6:37] Inside, I'm going to simply run setActiveStore and I'm going to pass in my location.id. Then on my button, I'm going to pass in an onClick handler, where I'm going to say, handleOnClick. Any time somebody clicks on one of these buttons, it's going to fire off this function that sets the active store to that particular location's ID.

[7:00] Before we make the map do anything, let's console.log that out. I'm going to console.log active store. We can see by default when it loads, it's going to be undefined which is expected. As soon as we click view on map, we're going to see that ID.

[7:14] If we click on another one, we're going to see a different ID. Next, anytime that active store changes, we want to update this map and tell it to zoom in on that location. To do that, we need to have access to the Leaflet map instance.

[7:27] If you remember from a previous lesson inside of the map component, we now are passing in that map instance through to that children prop, so we now as a second argument will have access to that map. From earlier, we were already grabbing that as our argument, but now the tricky thing is we need to be inside of a child component in order to access that map information.

[7:50] Otherwise, we'll be trying to access it too early, and we won't be able to have that readily available to us. What that means, is if we try to simply run something like useEffect right here and access that map, it's not going to work. If we had a component inside of here, and we try to access that map inside of useEffect, we would then have access to it.

[8:12] This might look a little bit ugly, but what we're going to do is, we're going to create a new component. I'm going to say constant mapEffect. I'm going to set that to a new component. Inside, we're going to simply return no, as we're not going to return anything inside of the view.

[8:27] Instead, inside of this mapEffect, we're going to use the useEffect Hook where we're going to listen for that active store. I'm going to set a new array where inside instead of an empty array which would only run once, we want to pass in active store as the dependency.

[8:41] That way, anytime this active store changes, it's going to re-fire this function. Before we have access to useEffect, we need to make sure we scroll to the top and we import it along with our useState. To test out this is working, let's console.log out a few things.

[8:57] I'm going to console.log out my map so we can see what that looks like. I'm going to console.log out my active store. Then let's also console.log out our store locations, as we'll need to look through that as well. Finally, before we can see this work, we need to use our mapEffect.

[9:13] At the top of our return statement, I'm going to add an additional mapEffect as that component. When the page reloads, we can see our map which is going to be that Leaflet map instance. We can also see our active store which is undefined as well as our store locations.

[9:29] Again, when we click view on map, that store ID is going to change, and we can see that that fired every time that active store changed. First of all, inside of my useEffect, I'm going to say if I don't even have an active store, I want to return because I don't want to do anything specific.

[9:46] Next, I want to look through all of my store locations and find a store that I need. I'm going to create a new constant called location and I'm going to set that equal to store locations.

[9:55] I'm going to use the find method which is going to allow me to search through that list or that array of locations, where I'm going to pass in a function, where I'm going to destructure the ID of each and every one of those store locations. As a return statement, which is the qualifier, I'm going to say that I want the ID to equal my active store.

[10:14] Let's try to console.log out that location value, just so we can see that it's working. If you remember, we're only logging that value if we have an active store. We're not going to even see an undefined value. If I click view on map, we see our Philly store.

[10:30] If I click view on map for San Francisco, we see our San Francisco store. Looking inside, I want to grab that location so I can grab the Latin long. Instead of setting that constant location, I'm going to destructure the location similar to what we did earlier.

[10:45] With our location, we can now look to Leaflet where we see on our map instance, we can run this setView method where we see this Latin long that we can pass in as our first argument. We can pass in options if we want. We can also pass in a zoom level, so that we can zoom in right to that location.

[11:01] I'm going to run map.setView. Where I'm going to pass in an array, so I can pass in my coordinates where I'm going to say location.latitude and location.longitude. As a second argument, I'm also going to add a zoom of 14. You can tweak that to however you like it.

[11:19] If we open up and reload the map and we click on view on map, we can see that it goes over to that location and it zooms in. If I change that and go to San Francisco, we can see it does the same. If I zoom out, we can see that it's exactly on San Francisco.

[11:33] In review, not only did we want to make these view on map links work, we wanted to zoom in by default on the center of all of our markers. We started off by using center and points from Turf.js where we were able to get the default latitude and longitude or our center.

[11:51] In order to make those view on map links work, we use click handler so that we can use state and we can set an active store. Once that active store was set, any time that active store changes, we run a useEffect hook where we nested that inside of our map so that we can find that location and set the view of our map.

[12:11] Now, whether it's Philadelphia, San Francisco, or New York, wherever we want to go, we can set our location and see exactly where is on our map.

Luis Ruiz
Luis Ruiz
~ 10 months ago

Again me here commenting the solution when developing this with leaflet v4.

// Map/Map.tsx
import L from 'leaflet';
import iconMarker2x from 'leaflet/dist/images/marker-icon-2x.png';
import iconMarker from 'leaflet/dist/images/marker-icon.png';
import iconMarkerShadow from 'leaflet/dist/images/marker-shadow.png';
import 'leaflet/dist/leaflet.css';
import { useEffect } from 'react';
import { MapContainer, Marker, Popup, TileLayer } from 'react-leaflet';
import styles from './Map.module.scss';

const Map = ({
  className,
  children,
  ...rest
}: L.MapOptions & {
  className?: string;
  children?: (ReactLeaflet: {
    Marker: typeof Marker;
    Popup: typeof Popup;
    TileLayer: typeof TileLayer;
  }) => React.ReactNode;
}) => {
  let mapClassName = styles.map;

  if (className) {
    mapClassName = `${mapClassName} ${className}`;
  }

  useEffect(() => {
    delete L.Icon.Default.prototype['_getIconUrl'];

    L.Icon.Default.mergeOptions({
      iconRetinaUrl: iconMarker2x.src,
      iconUrl: iconMarker.src,
      shadowUrl: iconMarkerShadow.src,
    });
  }, []);

  return (
    <MapContainer className={mapClassName} {...rest}>
      {children ? children({ Marker, Popup, TileLayer }) : null}
    </MapContainer>
  );
};

export default Map;
// Map/MapEffect.tsx
import { useEffect } from 'react';
import { useMap } from 'react-leaflet';

type MapEffectProps = {
  activeStore: string;
  storeLocations: {
    id: string;
    name: string;
    phoneNumber: string;
    address: string;
    location: {
      latitude: number;
      longitude: number;
    };
  }[];
};

const MapEffect = ({ activeStore, storeLocations }: MapEffectProps) => {
  const map = useMap();

  useEffect(() => {
    if (!activeStore || !map) {
      return;
    }

    const { location } = storeLocations.find(({ id }) => id === activeStore);

    map.setView([location.latitude, location.longitude], 14);
  }, [activeStore, storeLocations, map]);

  return null;
};
export default MapEffect;
// Map/index.ts
import dynamic from 'next/dynamic';

const Map = dynamic(() => import('./Map'), { ssr: false });
const MapEffect = dynamic(() => import('./MapEffect'), { ssr: false });

export default Map;
export { MapEffect };
~ 2 months ago

Hey Luis, Awesome, thanks for share. Do you have it without typescript?