Remove Zoom control from map in react-leaflet

All we need is an easy explanation of the problem, so here it is.

I am building a react-leaflet application, and I am trying to separate the zoom control from the map itself. The same question in a vanilla Leaflet context was asked here: Placing controls outside map container with Leaflet?. This is what I’m trying to accomplish within the react-leaflet framework. Here is the general outline of my project:

import UIWindow from './UIWindow'
import Map from './MapRL'

class App extends React.Component {
   render () {
      return (
         <div className="App">
            <Map />
            <UIWindow />
         </div>
      )
   }
}

export default App;

My map component looks like this:

import React from 'react'
import { Map as LeafletMap, TileLayer } from 'react-leaflet'

class Map extends React.Component {

   render () {
      return(
         <LeafletMap
            className="sidebar-map"
            center={...}
            zoom={...}
            zoomControl={false}
            id="mapId" >

            <TileLayer
                url="..."
                attribution="...
            />

         </LeafletMap>
      )
   }
}

export default Map;

Then my UIWindow looks like this:

class UIWindow extends React.Component {
   render () {
      return(
         <div className="UIWindow">
            <Header />
            <ControlLayer />
         </div>
      )
   }
}

And finally, my ControlLayer (where I want my ZoomControl to live) should look something like this:

class ControlLayer extends React.Component {
   render () {
      return (
         <div className="ControlLayer">
            <LeftSidebar />
            <ZoomControl />
            {this.props.infoPage && <InfoPage />}
         </div>
      )
   }
}

Of course with this current code, putting ZoomControl in the ControlLayer throws an error: TypeError: Cannot read property ‘_zoom’ of undefined, with some more detailed writeup of what’s wrong, with all the references the Leaflet’s internal code regarding the zoom functionality.
(DomUtil.removeClass(this._zoomInButton, className);, etc.)

I expected an error, because the ZoomControl is no longer a child of the <Map /> component, but rather a grandchild of the <Map />‘s sibling. I know react-leaflet functions on its context provider and consumer, LeafletProvider and LeafletConsumer. When I try to call on my LeafletConsumer from within my <ControlLayer />, I get nothing back. For example:

            <LeafletConsumer>
               {context => console.log(context)}
            </LeafletConsumer>

This logs an empty object. Clearly my LeafletConsumer from my ControlLayer is not properly hooked into the LeaflerProvider from my <Map />. Do I need to export the context from the Map somehow using LeafletProvider? I am a little new to React Context API, so this is not yet intuitive for me. (Most of the rest of the app will be using React Redux to manage state changes and communication between components. This is how I plan to hook up the contents of the sidebar to the map itself. My sidebar doesn’t seem to have any problem with being totally disconnected from the <Map />).

How can I properly hook this ZoomControl up to my Map component?

UPDATE:

I tried capturing the context in my redux store, and then serving it to my externalized ZoomControl. Like so:

            <LeafletConsumer>
               { context => {
                  this.props.captureContext(context)
               }}
            </LeafletConsumer>

This captures the context as part of my redux store. Then I use this as a value in a new context:

// ControlLayer.js

const MapContext = React.createContext()

            <MapContext.Provider value={this.props.mapContext}>
               <LeftSidebar />
               <MapContext.Consumer>
                  {context => {
                     if (context){
                        console.log(ZoomControl);

                     }
                  }}
               </MapContext.Consumer>
            </MapContext.Provider>

Where this.props.mapContext is brought in from my redux matchStateToProps, and its exactly the context captured by the captureContext function.

Still, this is not working. My react dev tools show that the MapContent.Consumer is giving the exact same values as react-leaflet’s inherent ” gives when the ZoomControl is within the Map component. But I still get the same error message. Very frustrated over here.

How to solve :

I know you bored from this bug, So we are here to help you! Take a deep breath and look at the explanation of your problem. We have many solutions to this problem, But we recommend you to use the first method because it is tested & true method that will 100% work for you.

Method 1

Here is the same approach without hooks:

the Provider should look like this:

class Provider extends Component {
  state = { map: null };

  setMap = map => {
    this.setState({ map });
  };

  render() {
    return (
      <Context.Provider value={{ map: this.state.map, setMap: this.setMap }}>
        {this.props.children}
      </Context.Provider>
    );
  }
}

Leaflet component will be:

class Leaflet extends Component {
  mapRef = createRef(null);

  componentDidMount() {
    const map = this.mapRef.current.leafletElement;
    this.props.setMap(map);
  }

  render() {
    return (
      <Map
        style={{ width: "80vw", height: "60vh" }}
        ref={this.mapRef}
        center={[50.63, 13.047]}
        zoom={13}
        zoomControl={false}
        minZoom={3}
        maxZoom={18}
      >
        <TileLayer
          attribution='&amp;copy <a href="http://osm.org/copyright" rel="nofollow noreferrer noopener">OpenStreetMap</a> contributors'
          url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png?"
        />
      </Map>
    );
  }
}

and now to access the setMap function to the compoenntDidMount you need to do the following:

export default props => (
  <Context.Consumer>
    {({ setMap }) => <Leaflet {...props} setMap={setMap} />}
  </Context.Consumer>
);

For the rest take a look here: Demo

Method 2

I am not sure how to achieve that using your approach where react-leaflet’s wrapper ZoomControl is not a child of Map wrapper when you try to place it outside the Map wrapper.

However, for a small control like the ZoomControl, an easy solution would be to create a custom Zoom component, identical to the original, construct it easily using the native css style and after accessing the map element, invoke the zoom in and out methods respectively.

In the below example I use react-context to save the map element after the map loads:

useEffect(() => {
    const map = mapRef.current.leafletElement;
    setMap(map);
  }, [mapRef, setMap]);

and then here use the map reference to make a custom Zoom component identical to the native (for css see the demo):

const Zoom = () => {
  const { map } = useContext(Context);

  const zoomIn = e => {
    e.preventDefault();
    map.setZoom(map.getZoom() + 1);
  };
  const zoomOut = e => {
    e.preventDefault();
    map.setZoom(map.getZoom() - 1);
  };

  return (
    <div className="leaflet-bar">
      <a
        className="leaflet-control-zoom-in"
        href="/" rel="nofollow noreferrer noopener" rel="nofollow noreferrer noopener"
        title="Zoom in"
        role="button"
        aria-label="Zoom in"
        onClick={zoomIn}
      >
        +
      </a>
      <a
        className="leaflet-control-zoom-out"
        href="/" rel="nofollow noreferrer noopener" rel="nofollow noreferrer noopener"
        title="Zoom out"
        role="button"
        aria-label="Zoom out"
        onClick={zoomOut}
      >
        −
      </a>
    </div>
  );
};

and then place it wherever you like:

const App = () => {
  return (
    <Provider>
      <div style={{ display: "flex", flexDirection: "row" }}>
        <Leaflet />
        <Zoom />
      </div>
    </Provider>
  );
};

Demo

Note: Use and implement method 1 because this method fully tested our system.
Thank you 🙂

All methods was sourced from stackoverflow.com or stackexchange.com, is licensed under cc by-sa 2.5, cc by-sa 3.0 and cc by-sa 4.0

Leave a Reply