DEV Community

loading...

Creating an Ellipse in React-Leaflet

jharris711 profile image Josh Harris ・9 min read

Leaflet map with ellipses and markers
If you use React-Leaflet in your mapping applications, there may come a time when you need to display data in the form of an ellipse on your map. Leaflet does not provide an ellipse marker by default, which means that React-Leaflet does not supply one either. In the Leaflet plugins section of the Leaflet documentation, there is a plugin called Leaflet.Ellipse that allows users to create an ellipse in a vanilla Leaflet app. But, how does one get this plugin to work with their React-Leaflet app?

The answer is to use the React Leaflet Core API. The purpose of the Core API is to make React-Leaflet's internal logic available so devs like ourselves can implement our own custom behaviors and third-party plugins. To better understand how this API works, let's extend Leaflet.Ellipse to work with React-Leaflet by creating our own Ellipse component.

To make things simple, I have created a React-Leaflet template hosted on Stack Blitz so you don't have to worry about set up. If you want to code along on your local machine, you can always follow the React-Leaflet Getting Started guide to get going.


Simple Implementation

For right now, let's just focus on getting our ellipse on the map. Let's create a new file named Ellipse.jsx in our components directory:

Ellipse.jsx
import React, { useEffect } from 'react'
import L from 'leaflet'
import 'leaflet-ellipse'
import { useLeafletContext } from '@react-leaflet/core'

const Ellipse = (props) => {
  const context = useLeafletContext()

  const { center, radii, tilt, options } = props

  useEffect(() => {
    const ellipse = new L.Ellipse(center, radii, tilt, options)

    const container = context.layerContainer || context.map
    container.addLayer(ellipse)

    return () => {
      container.removeLayer(ellipse)
    }
  })

  return null
}

export default Ellipse
Enter fullscreen mode Exit fullscreen mode

First, we use the useLeafletContext hook from the Core API to access the context created by the MapContainer component in Map.jsx. We will also destructure the necessary bits of data from props:

const context = useLeafletContext()

const { center, radii, tilt, options } = props
Enter fullscreen mode Exit fullscreen mode

Next, we use React's useEffect hook to create the L.Ellipse instance by passing the following to the Ellipse constructor:

  • center - The position of the center of the ellipse [lat, lng].
  • radii - The semi-major and semi-minor axis in meters
  • tilt - The rotation of the ellipse in degrees from west
  • options - Options dictionary to pass to L.Path
const ellipse = new L.Ellipse(center, radii, tilt, options)
Enter fullscreen mode Exit fullscreen mode

Now that we have our ellipse instance set up, the layer needs to be added to the container provided to us via the context. This will be either a parent container like a LayerGroup, or the Map instance:

const container = context.layerContainer || context.map
container.addLayer(ellipse)
Enter fullscreen mode Exit fullscreen mode

At the end of the useEffect hook, we are going to return a cleanup function that will remove our ellipse from its parent container:

return () => {
  container.removeLayer(ellipse)
}
Enter fullscreen mode Exit fullscreen mode

Notice how we are returning a null value at the end of the component. Returning null from a component just means that React will be evaluating our component, but not rendering anything. Usually, we would need to return a valid React node from our components, but since Leaflet will be performing the rendering, we only return a null value.

We can now import our Ellipse component to our Map component and .map through our city data to place our ellipses:

Map.jsx
// ...imports
import Ellipse from './Ellipse'

const Map = () => {
  // ... state hooks...

  return (
    <>
      <MapContainer
        center={[38, -82]}
        zoom={4}
        zoomControl={false}
        style={{ height: '100vh', width: '100%', padding: 0 }}
        whenCreated={(map) => setMap(map)}
      >
        {/* ... other layers... */}
        <LayersControl.Overlay checked name='Ellipses'>
          <LayerGroup>
            {cities.map((city) => (
              <>
                <Ellipse
                  center={[city.lat, city.lng]}
                  radii={[city.semimajor, city.semiminor]}
                  tilt={city.tilt}
                  options={city.options}
                />
              </>
            ))}
          </LayerGroup>
        </LayersControl.Overlay>
        {/* ... other layers... */}
      </MapContainer>
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

Improving the Update Logic

The Ellipse component we have built thus far will work just fine for most simple cases, but there is a catch. With every render of the component, the useEffect callback will run and add/remove the ellipse to/from the map even if the props haven't changed.

When working with React, this is not the expected behavior since the virtual DOM checks which updates are necessary to apply to the DOM. Since we are using React-Leaflet, the DOM rendering is being performed by Leaflet so we will need to improve our update logic to avoid unnecessary changes to the DOM:

Ellipse.jsx
import React, { useEffect, useRef } from 'react'
// ...imports

const Ellipse = (props) => {
  const context = useLeafletContext()
  const ellipseRef = useRef()
  const propsRef = useRef(props)

  const { center, radii, tilt, options } = props

  useEffect(() => {
    ellipseRef.current = new L.Ellipse(center, radii, tilt, options)

    const container = context.layerContainer || context.map
    container.addLayer(ellipseRef.current)

    return () => {
      container.removeLayer(ellipseRef.current)
    }
  }, [])

  useEffect(() => {
    if (
      center !== propsRef.center ||
      radii !== propsRef.radii ||
      tilt !== propsRef.tilt ||
      options !== propsRef.options
    ) {
      ellipseRef.current.setLatLng(center)
      ellipseRef.current.setRadius(radii)
      ellipseRef.current.setTilt(tilt)
      ellipseRef.current.setStyle(options)
    }
    propsRef.current = props
  }, [center, radii, tilt, options])

  return null
}

export default Ellipse
Enter fullscreen mode Exit fullscreen mode

Here, we create references to our Leaflet Ellipse instance and its props by using React's useRef hook :

const ellipseRef = useRef()
const propsRef = useRef(props)
Enter fullscreen mode Exit fullscreen mode

We are going to separate the ellipse creation logic from the update logic by placing them in separate useEffect callbacks. We want the first useEffect callback to only run when the ellipse component is mounted and unmounted, so we set the dependency array to an empty array:

useEffect(() => {
  ellipseRef.current = new L.Ellipse(center, radii, tilt, options)

  const container = context.layerContainer || context.map
  container.addLayer(ellipseRef.current)

  return () => {
    container.removeLayer(ellipseRef.current)
  }
}, [])
Enter fullscreen mode Exit fullscreen mode

We are going to want to call the second useEffect whenever the data from props changes so that Leaflet can conditionally apply any updates to our ellipse layer. This means we simply pass the data we destructured from props to the second useEffect's dependency array. We can find out what methods we need to call to update our ellipse by taking a look at the Leaflet.Ellipse source file starting at line 63:

useEffect(() => {
  if (
    center !== propsRef.current.center ||
    radii !== propsRef.current.radii ||
    tilt !== propsRef.current.tilt ||
    options !== propsRef.current.options
  ) {
    ellipseRef.current.setLatLng(center)
    ellipseRef.current.setRadius(radii)
    ellipseRef.current.setTilt(tilt)
    ellipseRef.current.setStyle(options)
  }
  propsRef.current = props
}, [center, radii, tilt, options])
Enter fullscreen mode Exit fullscreen mode

Element Hook Factory

What we have so far is a fully functional Ellipse component for our React-Leaflet application, but the Core API provides functions like the createElementHook factory to make the process a bit easier and remove some of the repetitive code:

import { useLeafletContext, createElementHook } from '@react-leaflet/core'

function createEllipse(props, context) {
  return {
    instance: new L.Ellipse(
      props.center,
      props.radii,
      props.tilt,
      props.options
    ),
    context,
  }
}

function updateEllipse(instance, props, prevProps) {
  if (
    props.center !== prevProps.center ||
    props.radii !== prevProps.radii ||
    props.tilt !== prevProps.tilt ||
    props.options !== prevProps.options
  ) {
    instance.setLatLng(props.center)
    instance.setRadius(props.radii)
    instance.setTilt(props.tilt)
    instance.setStyle(props.options)
  }
}

const useEllipseElement = createElementHook(createEllipse, updateEllipse)

const Ellipse = (props) => {
  const context = useLeafletContext()
  const elementRef = useEllipseElement(props, context)

  useEffect(() => {
    const container = context.layerContainer || context.map
    container.addLayer(elementRef.current.instance)

    return () => {
      container.removeLayer(elementRef.current.instance)
    }
  }, [])

  return null
}

export default Ellipse
Enter fullscreen mode Exit fullscreen mode

Rather than keeping our creation and update logic in useEffect callbacks, we can extract them to stand-alone functions that implement the interface expected by the createElementHook function:

function createEllipse(props, context) {
  return {
    instance: new L.Ellipse(
      props.center,
      props.radii,
      props.tilt,
      props.options
    ),
    context,
  }
}

function updateEllipse(instance, props, prevProps) {
  if (
    props.center !== prevProps.center ||
    props.radii !== prevProps.radii ||
    props.tilt !== prevProps.tilt ||
    props.options !== prevProps.options
  ) {
    instance.setLatLng(props.center)
    instance.setRadius(props.radii)
    instance.setTilt(props.tilt)
    instance.setStyle(props.options)
  }
}
Enter fullscreen mode Exit fullscreen mode

Then, we take these create and update functions and pass them to the createElementHook factory function:

const useEllipseElement = createElementHook(createEllipse, updateEllipse)
Enter fullscreen mode Exit fullscreen mode

This hook tracks the Ellipse element's instance and props, which means we only need one useEffect hook to add/remove our Ellipse layer to/from the map:

const elementRef = useEllipseElement(props, context)

useEffect(() => {
  const container = context.layerContainer || context.map
  container.addLayer(elementRef.current.instance)

  return () => {
    container.removeLayer(elementRef.current.instance)
  }
}, [])
Enter fullscreen mode Exit fullscreen mode

Layer lifecycle hook

The Core API provides hooks designed to handle specific pieces of logic, such as the useLayerLifecycle hook. This hook's whole purpose is to take care of adding and removing the assigned layer to and from the parent container or map. Let's use this and get rid of the useEffect hook that is currently handling the add/remove logic:

import {
  useLeafletContext,
  createElementHook,
  useLayerLifecycle,
} from '@react-leaflet/core'

// ... create and update functions

// ... createElementHook

const Ellipse = (props) => {
  const context = useLeafletContext()
  const elementRef = useEllipseElement(props, context)
  useLayerLifecycle(elementRef.current, context)

  return null
}
Enter fullscreen mode Exit fullscreen mode

Higher-level createPathHook

We also have access to higher-level factory functions via the Core API. These higher-level factory functions, like the createPathHook function, implement logic that is shared by different hooks. If we want to simplify our Ellipse component even more, we can get rid of the useLeafletContext and useLayerLifecycle functions, and just call the hook we create with createPathHook inside the Ellipse component:

import { createElementHook, createPathHook } from '@react-leaflet/core'

// ... create and update functions

const useEllipseElement = createElementHook(createEllipse, updateEllipse)
const useEllipse = createPathHook(useEllipseElement)

const Ellipse = (props) => {
  useEllipse(props)

  return null
}
Enter fullscreen mode Exit fullscreen mode

Component factory

Now that all of the logic for our Ellipse component is implemented in the useEllipse hook we have created, the component has become incredibly simple. We can actually take our functional component and replace it with the createLeafComponent function, which also allows us to now access our Ellipse instance with React's ref:

import {
  createElementHook,
  createPathHook,
  createLeafComponent,
} from '@react-leaflet/core'

// ... create and update functions

const useEllipseElement = createElementHook(createEllipse, updateEllipse)
const useEllipse = createPathHook(useEllipseElement)

const Ellipse = createLeafComponent(useEllipse)

export default Ellipse
Enter fullscreen mode Exit fullscreen mode

Supporting child elements

Our Ellipse component works great, with all of our logic implented in just a few lines of code. The problem with our ellipse now is that it doesn't yet support children, which is a fairly common requirement for React-Leaflet components. Since our Ellipse is a Leaflet layer, we can attach overlays like Popups and Tooltips to our Ellipse:

Ellipse.jsx
import {
  createElementHook,
  createPathHook,
  createContainerComponent,
} from '@react-leaflet/core'

function createEllipse(props, context) {
  const instance = new L.Ellipse(
    props.center,
    props.radii,
    props.tilt,
    props.options
  )
  return {
    instance,
    context: { ...context, overlayContainer: instance },
  }
}

// ... update function

const useEllipseElement = createElementHook(createEllipse, updateEllipse)
const useEllipse = createPathHook(useEllipseElement)
const Ellipse = createContainerComponent(useEllipse)
Enter fullscreen mode Exit fullscreen mode
Map.jsx
// ...Map component
<LayersControl.Overlay checked name='Ellipses'>
  <LayerGroup>
    {cities.map((city) => (
      <>
        <Ellipse
          center={[city.lat, city.lng]}
          radii={[city.semimajor, city.semiminor]}
          tilt={city.tilt}
          options={city.options}
        >
          <Popup>This is quality popup content.</Popup>
        </Ellipse>
      </>
    ))}
  </LayerGroup>
</LayersControl.Overlay>
// ...Map component
Enter fullscreen mode Exit fullscreen mode

To support these overlays, we need to set the created layer as the context's overlayContainer in our createEllipse function. Remember, the context object returned from the createEllipse function must be a copy of the one provided in the function arguments and the function must not mutate the provided context:

function createEllipse(props, context) {
  const instance = new L.Ellipse(
    props.center,
    props.radii,
    props.tilt,
    props.options
  )
  return {
    instance,
    context: { ...context, overlayContainer: instance },
  }
}
Enter fullscreen mode Exit fullscreen mode

Then, we replace the component factory with thecreateContainerComponent factory function. ThecreateLeafComponent function and createOverlayComponent function can also be used to create overlays, like popups and tooltips:

const Ellipse = createContainerComponent(useEllipse)
Enter fullscreen mode Exit fullscreen mode

Higher-level component factory

Most of what React Leaflet's Core API provides are React components that handle the logic for creating and interacting with our Leaflet elements. These different hooks and factories that are exposed by the API implement various pieces of logic that need to be combined to create components, and in some cases the same series of functions are used to create different components. Take a look at the functions we used to create our Ellipse:

const useEllipseElement = createElementHook(createEllipse, updateEllipse)
const useEllipse = createPathHook(useEllipseElement)
const Ellipse = createContainerComponent(useEllipse)
Enter fullscreen mode Exit fullscreen mode

We can use these functions to create many different types of layers so React-Leaflet provides a higher-level component factories, like the createPathComponent function, that combines the logic of all three. We can remove the functions above and pass our create and update functions directly to the createPathComponent factory, like so:

Ellipse.jsx
const Ellipse = createPathComponent(createEllipse, updateEllipse)
Enter fullscreen mode Exit fullscreen mode

Conclusion

Our Ellipse component is now finished! We were able to get it done in just a few lines of code and, thanks to the React-Leaflet Core API, we have a fully interactive Leaflet layer. We've gone over a lot regarding the Core API, but there is still plenty more. I encourage you to try and create your own custom React-Leaflet elements or extend a third-party Leaflet plugin to work in your React-Leaflet application. If you do, blog about it and share your link below to help out fellow devs! If you catch any errors here or have any suggestions, feel free to let me know. Thanks for reading!

Discussion (0)

pic
Editor guide