2
votes

This is a more generalized version of my other question: Remove Zoom control from map in react-leaflet. React-leaflet comes with a handful of components which control the map - i.e. the <ZoomControl />, <LayersControl />, etc. But in order for these components to properly communicate with a map instance, they must be written as a child of the <Map /> component, like so:

<Map center={...} zoom={...}>

  <ZoomControl />
  <LayersControl />
  <MyCustomComponent />

</Map>

What I am trying to create is a situation where map components that are not direct children of the map can properly communicate with the map. For example:

<App>
  <Map />
  <Sibling>
    <Niece>
      <ZoomControl />
      <OtherControls />
    </Niece>
  </Sibling>
</App>

Obviously the trouble here is that when these controls are no longer children or descendants of the <Map />, they don't receive the map instance through a provider. As you can see in my other question, I tried creating a new Context object and using it to provide the map to the displaced controls. That didn't work, and I'm not sure why. So far, my solution has been to use vanilla javascript to rehome these controls in the componentDidMount of their intended parent, like so:

// Niece.js
componentDidMount(){
  const newZoomHome = document.querySelector('#Niece')
  const leafletZoomControl= document.querySelector('.leaflet-control-zoom')
  newZoomHome.appendChild(leafletZoomControl)
}

I really hate this because now my general component structure does not reflect the application structure. My Zoom needs to be written as part of my map, but ends up in my Neice component.

Kboul's solution in my other question was simply to rebuild the zoom component from scratch and feed it the map context. This works fine for a simple zoom component, but for more complex components, I can't be rebuilding entire frameworks. For example, I made a quick react-leaflet component version of esri-leaflet's geosearch:

import { withLeaflet, MapControl } from "react-leaflet";
import * as ELG from "esri-leaflet-geocoder";

class GeoSearch extends MapControl {
  createLeafletElement(props) {
    const searchOptions = {
       ...props,
      providers: props.providers ? props.providers.map( provider => ELG[provider]()) : null
    };

    const GeoSearch = new ELG.Geosearch(searchOptions);
    // Author can specify a callback function for the results event
    if (props.onResult){
      GeoSearch.addEventListener('results', props.onResult)
    }
    return GeoSearch;
  }

  componentDidMount() {
    const { map } = this.props.leaflet;
    this.leafletElement.addTo(map);
  }
}

export default withLeaflet(GeoSearch);

Its relatively simple and works great when declared inside the <Map /> component. But I want to move it to a separate place in the app, and I don't want to have to recode the entire esri Geosearch. How can I use functioning react-leaflet control components outside of the <Map /> component while properly linking it to the map instance?

Here's a quick codsandbox template to start messing with if you're so inclined. Thanks for reading.

3

3 Answers

2
votes

You can use onAdd method to create a container for your plugin outside the map and then with the use of refs to add the element to the DOM like this:

class Map extends React.Component {
  mapRef = createRef();
  plugin = createRef();

  componentDidMount() {
    // map instance
    const map = this.mapRef.current.leafletElement;

    const searchcontrol = new ELG.Geosearch();
    const results = new L.LayerGroup().addTo(map);
    searchcontrol.on("results", function(data) {
      results.clearLayers();
      for (let i = data.results.length - 1; i >= 0; i--) {
        results.addLayer(L.marker(data.results[i].latlng));
      }
    });
    const searchContainer = searchcontrol.onAdd(map);
    this.plugin.current.appendChild(searchContainer);
  }

  render() {
    return (
      <>
        <LeafletMap
          zoom={4}
          style={{ height: "50vh" }}
          ref={this.mapRef}
          center={[33.852169, -100.5322]}
        >
          <TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />
        </LeafletMap>
        <div ref={this.plugin} />
      </>
    );
  }
}

Demo

2
votes

Thanks to kboul's answer in this and my other question, I am going to write out an answer here. This is kboul's answer really, but I want to cement it in my brain by writing it out, and have it available for anyone that stumbles by.

First, we need to create a context object, and a provider for that context. We'll create two new files in the directory for easy access from other files:

/src
  -App.js
  -Map.js
  -MapContext.js
  -MapProvider.js
  -Sibling.js
  -Niece.js
  /Components
    -ZoomControl.js
    -OtherControls.js

Create an empty context object:

// MapContext.jsx
import { createContext } from "react";

const MapContext = createContext();

export default MapContext

Next, we use the MapContext object to create a MapProvider. The MapProvider has its own state, which holds a blank reference which will become the map reference. It also has a method setMap to set the map reference within its state. It provides as its value the blank reference, as well as the method to set the map reference. Finally, it renders its children:

// MapProvider.jsx

import React from "react";
import MapContext from "./MapContext";

class MapProvider extends React.Component {
  state = { map: null };

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

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

export default MapProvider;

Now, within the Map component, we will export a map wrapped in the MapProvider.

// Map.jsx

import React from "react";
import { Map as MapComponent, TileLayer, Marker, etc } from 'react-leaflet'
import MapContext from './MapContext'

class Map extends React.Component{

  mapRef = React.createRef(null);

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

  render(){

    return (

      <MapComponent 
         center={[centerLat, centerLng]} 
         zoom={11.5} 
         ...all the props
         ref={this.mapRef} >

      </MapComponent>

    );
  }
}

const LeafletMap = props =>  (
  <MapContext.Consumer>
    {({ setMap }) => <Map {...props} setMap={setMap} />}
  </MapContext.Consumer>
)

export default LeafletMap

In this last step, we don't export the Map, but rather we export the Map wrapped in the provider, with the {value} of the MapProvider as the Map's props. In this way, when the LeafletMap is called in the App component, on componentDidMount, the setMap function will be called as a prop, calling back to the MapProvider setMap function. This sets the state of the MapProvider to have the reference to the map. But this does not happen until the map is rendered in App:

// App.js

class App extends React.Component{

  state = { mapLoaded: false }

  componentDidMount(){
    this.setState({ mapLoaded:true })

  }

  render(){
    return (
      <MapProvider>
        <LeafletMap  />
        {this.state.mapLoaded && <Sibling/>}
      </MapProvider>
    )
  }

}

Note that the setMap method of the MapProvider is not called until the LeafletMap componentDidMount fires. So upon render in App, there's not yet a context value, and any component within Sibling that tries to access the context will not have it yet. But once App's render runs, and LeafletMaps componentDidMount runs, setMap runs, and the map value is the Provider is available. So in App, we wait until the componentDidMount runs, at which point setMap has already run. We set the state within the App that the map is loaded, our conditional render statement for Sibling will render the Sibling, with all of its children, with the MapContext object properly referencing the map. Now we can use it in a component. For example, I rewrote the GeoSearch component to work like so (thanks to kboul's suggestion):

// GeoSearch

import React from 'react'
import MapContext from '../Context'
import * as ELG from "esri-leaflet-geocoder";

class EsriGeoSearch extends React.Component {

   componentDidMount() {

      const map = this.mapReference

      const searchOptions = {
         ...this.props,
        providers: this.props.providers ? this.props.providers.map( provider => ELG[provider]()) : null
      };
      const GeoSearch = new ELG.Geosearch(searchOptions);

      const searchContainer = GeoSearch.onAdd(map);
      document.querySelector('geocoder-control-wrapper').appendChild(searchContainer);

   }


  render() {
     return (
        <MapContext.Consumer>
           { ({map}) => {
              this.mapReference = map
              return <div className='geocoder-control-wrapper' EsriGeoSearch`} />
           }}
        </MapContext.Consumer>
     )
  }
}

export default EsriGeoSearch;

So all we did here was create an Esri GeoSearch object, and store its HTML and associated handlers in a variable searchContainer, but did not add it to the map. Rather we create a container div where we want it in our DOM tree, and then on componentDidMount, we render that HTML inside of that container div. So we have a component that is written and rendered in its intended place in the application, which properly communicates with the map.

Sorry for the long read, but I wanted to write out the answer to cement my own knowledge, and have a fairly canonical answer out there for anyone who may find themselves in the same situation in the future. The credit goes 100% to kboul, I'm just synthesizing his answers into one place. He's got a working example here. If you like this answer please upvote his anwers.

1
votes

Might not be helpful in this instance but I used redux to define the state of the map and then used normal actions and reducers to update the map from anywhere in the application.

So your action would look something like this

export const setCenterMap = (payload) => ({
  type: CENTER_MAP,
  payload,
})

and a basic reducer:

const initialState = {
centerMap: false,
}

export const reducer = (state = initialState, action) => {
    switch (action.type) {
        case (CENTER_MAP) : {
            return ({
                ...state,
                centerMap: action.payload
            })
        }
        default: return state
    }
}

Then you connect it to your map component

const mapStateToProps = state => ({
  centerMap: state.app.centerMap,
})

const mapDispatchToProps = dispatch => ({
  setCenterMap: (centerMap) => dispatch(setCenterMap(centerMap)),
})

You can now manipulate the map outside of the Leaflet component.

        <LeafletMap
            center={centerMap}
            sites={event.sites && [...event.sites, ...event.workingGroups]}
        />
        <button onClick={() => setCenterMap([5.233, 3.342])} >SET MAP CENTER</button>

Most of this is pseudo code so you would have to adopt it for your own use but I found it a fairly painless way to add some basic map controls from outside of the LeafletMap component especially if you are already using redux.