3

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.

kboul
  • 13,836
  • 5
  • 42
  • 53
Seth Lutske
  • 9,154
  • 5
  • 29
  • 78

2 Answers2

4

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">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

kboul
  • 13,836
  • 5
  • 42
  • 53
  • 1
    Thank you so much for taking the time to write this up. This is more intuitive to me (at least while I am still learning hooks). From what I gather, you are using `this.props.setMap(map)` in the `` component to set the state of the Provider to hold the reference to the map instance. Then the Provider is passing that reference to any consumer...is that right? – Seth Lutske Jan 17 '20 at 18:49
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="/"
        title="Zoom in"
        role="button"
        aria-label="Zoom in"
        onClick={zoomIn}
      >
        +
      </a>
      <a
        className="leaflet-control-zoom-out"
        href="/"
        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

kboul
  • 13,836
  • 5
  • 42
  • 53
  • 1
    This is 100% what I was looking for, thank you for your thoughtful answer. – Seth Lutske Dec 23 '19 at 17:21
  • The more I study your solution the more I realize that I'm not fully understanding it. Part of that is because I am just now learning hooks. Would you mind explaining the line `const { setMap } = useContext(Context)` (line 10 in Leaflet.jsx)? How did the `setMap` function become part of the context object? I'm also not quite understanding how your `useEffect` hook is interacting with your `const mapRef = useRef(null)` line. Can your solution be written without hooks? (I know that's a tall order, I appreciate the effort you put into the answer) – Seth Lutske Jan 16 '20 at 16:44
  • `setMap(map);` is the function that updates the map context with the map instance when the component loads using context api. `mapRef` is used to hold the reference to the map, therefore is used and is updated when Leaflet component loads. – kboul Jan 16 '20 at 21:18
  • I provided another answer without using hooks. Please upvote it if it helps you – kboul Jan 16 '20 at 21:26