Implementing a dynamic JSX element within a marker, using react-leaflet
Asked Answered
N

3

10

I have a React app in which I am using Leaflet through react-leaflet, both super useful libraries.

In this app, I have a group of coordinates that need to be rendered as follows:

  1. When zoomed out, cluster the coordinates into Marker Clusters like so enter image description here

  2. When zoomed in, each Marker needs to have

    1. A dynamic countdown timer under it
    2. A dynamic SVG countdown clock around it like so enter image description here

For the clustering, I am using the react-leaflet-markercluster plugin, which works great for showing static content.

But when I need to show any dynamic content within each marker, I have no option of sending in JSX, there's only provision for static HTML as can been seen from the example available here.

// Template for getting popup html MarkerClusterGroup
// IMPORTANT: that function returns string, not JSX
function getStringPopup(name) {
  return (`
    <div>
      <b>Hello world!</b>
      <p>I am a ${name} popup.</p>
    </div>
  `);
}

// that function returns Leaflet.Popup
function getLeafletPopup(name) {
  return L.popup({ minWidth: 200, closeButton: false })
    .setContent(`
      <div>
        <b>Hello world!</b>
        <p>I am a ${name} popup.</p>
      </div>
    `);
}

Is there a way to handle this situation? How can I make a JSX marker instead of a static HTML marker?

PS: I have tried using ReactDOM.renderToStringalready, but it's an ugly hack and involves re-rendering the markers every time.

TIA!!

Here's a sample WebpackBin for you to play around with if you have a solution in mind

Numeral answered 30/10, 2017 at 14:58 Comment(7)
seems like a dublicate #37080347Inandin
@Inandin Actually, I'm not looking at using renderToString as a solution. I'm looking to use JSX here, not static HTMLNumeral
So why not to render jsx to hidden (with css) html and than pass it in as .innerHTML ?Inandin
@Inandin Could you update the provided bin to show what you mean? I didn't quite get itNumeral
Oh, i meant answer on a question i've posted link to aboveInandin
I'm also looking for a solution to this issueHaines
This may be a solution: jahed.io/2018/03/20/react-portals-and-leaflet But I have no idea how to "use" it. Maybe someone can figure it outAfield
A
10

I now figured out some working code for rendering custom JSX as Marker.

It's a 95% copy of https://jahed.dev/2018/03/20/react-portals-and-leaflet/ and 5% inspiration from https://github.com/PaulLeCam/react-leaflet/blob/master/packages/react-leaflet/src/Marker.tsx

I'm sure some things can be optimized further.

import * as React from 'react';
import { createPortal } from "react-dom";
import { DivIcon, marker } from "leaflet";
import * as RL from "react-leaflet";
import { MapLayer } from "react-leaflet";
import { difference } from "lodash";

const CustomMarker = (RL as any).withLeaflet(class extends MapLayer<any> {
    leafletElement: any;
    contextValue: any;

    createLeafletElement(props: any) {
        const { map, layerContainer, position, ...rest } = props;

// when not providing className, the element's background is a white square
// when not providing iconSize, the element will be 12x12 pixels
        const icon = new DivIcon({ ...rest, className: '', iconSize: undefined });

        const el = marker(position, { icon: icon, ...rest });
        this.contextValue = { ...props.leaflet, popupContainer: el };
        return el;
    }

    updateLeafletElement(fromProps: any, toProps: any) {
        const { position: fromPosition, zIndexOffset: fromZIndexOffset, opacity: fromOpacity, draggable: fromDraggable, className: fromClassName } = fromProps;
        const { position: toPosition, zIndexOffset: toZIndexOffset, toOpacity, draggable: toDraggable, className: toClassName } = toProps;

        if(toPosition !== fromPosition) {
            this.leafletElement.setLatLng(toPosition);
        }
        if(toZIndexOffset !== fromZIndexOffset) {
            this.leafletElement.setZIndexOffset(toZIndexOffset);
        }
        if(toOpacity !== fromOpacity) {
            this.leafletElement.setOpacity(toOpacity);
        }
        if(toDraggable !== fromDraggable) {
            if(toDraggable) {
                this.leafletElement.dragging.enable();
            } else {
                this.leafletElement.dragging.disable();
            }
        }
        if(toClassName !== fromClassName) {
            const fromClasses = fromClassName.split(" ");
            const toClasses = toClassName.split(" ");
            this.leafletElement._icon.classList.remove(
                ...difference(fromClasses, toClasses)
            );
            this.leafletElement._icon.classList.add(
                ...difference(toClasses, fromClasses)
            );
        }
    }

    componentWillMount() {
        if(super.componentWillMount) {
            super.componentWillMount();
        }
        this.leafletElement = this.createLeafletElement(this.props);
        this.leafletElement.on("add", () => this.forceUpdate());
    }

    componentDidUpdate(fromProps: any) {
        this.updateLeafletElement(fromProps, this.props);
    }

    render() {
        const { children } = this.props;
        const container = this.leafletElement._icon;

        if(!container) {
            return null;
        }

        const portal = createPortal(children, container);

        const LeafletProvider = (RL as any).LeafletProvider;

        return children == null || portal == null || this.contextValue == null ? null : (
            <LeafletProvider value={this.contextValue}>{portal}</LeafletProvider>
        )
    }
});

And then just use it in you component like this:

<Map ...>
  <CustomMarker position={[50, 10]}>
    <Tooltip>
      tooltip
    </Tooltip>
    <Popup>
      popup
    </Popup>
    
    <div style={{ backgroundColor: 'red' }} onClick={() => console.log("CLICK")}>
      CUSTOM MARKER CONTENT
    </div>
    MORE CONTENT
  </CustomMarker>
</Map>

If you don't use TypeScript. Just remove the as any and : any stuff.


EDIT: Something automatically sets width: 12px; and height: 12px;. I'm not yet sure, how to prevent this. Everything else seems to work fine!

EDIT2: Fixed it! Using iconSize: undefined


EDIT3: There's also this: https://github.com/OpenGov/react-leaflet-marker-layer Haven't tested it, but the example code looks good.

Afield answered 3/2, 2019 at 15:53 Comment(3)
great, do you have the code written using the functional component?Koniology
MapLayer doesn't exist in the new react leaflet. Any ideas for an alternative?Globigerina
See #77882566Elianaelianora
L
2

Here is a 2023 answer based on react 18, with functional components, in typescript:

import React, { useState } from "react";
import { Marker, MarkerProps } from "react-leaflet";
import ReactDOM from "react-dom/client";
import L from "leaflet";

interface Props extends MarkerProps {
  /**
   * Options to pass to the react-lefalet L.divIcon that is used as the marker's custom icon
   */
  iconOptions?: L.DivIconOptions;
}

/**
 * React-leaflet marker that allows for fully interactive JSX in icon
 */
export const JSXMarker = React.forwardRef<L.Marker, Props>(
  ({ children, iconOptions, ...rest }, refInParent) => {
    const [ref, setRef] = useState<L.Marker>();

    const node = React.useMemo(
      () => (ref ? ReactDOM.createRoot(ref.getElement()) : null),
      [ref]
    );

    return (
      <>
        {React.useMemo(
          () => (
            <Marker
              {...rest}
              ref={(r) => {
                setRef(r as L.Marker);
                if (refInParent) {
                  // @ts-expect-error fowardref ts defs are tricky
                  refInParent.current = r;
                }
              }}
              icon={L.divIcon(iconOptions)}
            />
          ),
          []
        )}
        {ref && node.render(children)}
      </>
    );
  }
);

And how you would use it:

import React from "react";
import { MapContainer } from "react-leaflet";
import { Marker } from "./JSXMarker";

export const Map: React.FC = () => {
  return (
    <MapContainer>
      <Marker
        position={[20.27, -157]}
        iconOptions={{
          className: "jsx-marker",
          iconSize: [100, 100],
          iconAnchor: [50, 50]
        }}
      >
        <div>
         {/* Fully functional, interactive JSX goes here */}
        </div>
      </Marker>
    </MapContainer>
  );
};

Working codesandbox

Leschen answered 23/1, 2023 at 23:28 Comment(0)
B
-1

2023 Using createportal, with forwardref

import React, { useMemo, useRef, useState } from "react";
import { Marker, MarkerProps } from "react-leaflet";
import L from "leaflet";
import { createPortal } from "react-dom";


interface Props extends MarkerProps {}

const ReactMarkerForward = React.forwardRef<L.Marker, MarkerProps>(
  ({ ...props }: MarkerProps, ref) => {
    return <Marker ref={ref} {...props}></Marker>;
  }
);

export const JSXMarker: React.FC<MarkerProps> = (
  { children, ...rest },
  refInParent
) => {
  const ref = useRef<L.Marker>(null);
  return (
    <>
      {useMemo(
        () => (
          <ReactMarkerForward {...rest} ref={ref} />
        ),
        []
      )}
      {ref && ref.current && ref.current.getElement()
        ? createPortal(children, ref.current.getElement()!)
        : ""}
    </>
  );
};
Bump answered 26/9, 2023 at 13:57 Comment(1)
this is not working. and it has many variables not used.Humiliate

© 2022 - 2024 — McMap. All rights reserved.