Ehsan Zand
Ehsan Zand

Reputation: 350

changing cluster's styles not working with lazy loading leaflet in SSR react application

I'm encountering some challenges with lazy loading a Leaflet map in a React application deployed on a server-side rendering (SSR) environment. The application involves loading a list of products in a modal using Leaflet, and when a user hovers over a product, I need to change the markers' icon and clusters' style on the map accordingly. I've tried different approaches, including lazy loading the Leaflet component and using react-leaflet, but I'm facing some issues:

/node_modules/leaflet/dist/leaflet-src.js:230
  var requestFn = window.requestAnimationFrame || getPrefixed('RequestAnimationFrame') || timeoutDefer;
                  ^

ReferenceError: window is not defined

At first, I thought maybe the leaflet doesn't work with lazy loading and I re-wrote all the code using react-leaflet, but the problem persisted. Any insights or ideas on how to resolve these issues would be greatly appreciated.

Here is my code:

import { useEffect, useRef } from 'react';
import L, { DivIcon, MarkerClusterGroup } from 'leaflet';
import 'leaflet.markercluster/dist/leaflet.markercluster';
import 'leaflet.markercluster/dist/MarkerCluster.css';
import 'leaflet.markercluster/dist/MarkerCluster.Default.css';
import 'leaflet/dist/leaflet.css';
import { createRoot } from 'react-dom/client';

import mapMarkerHover from './assets/mapMarkerHover.png';
import { CustomMapMarker } from '../../types/customMapMarker';
import { ProductListItem } from '../../types/productListItem';
import mapMarker from './assets/mapMarker.png';
import { MapViewPopUpProduct } from './MapViewPopUpProduct';

const MapView = (
    isMapViewModalOpen: boolean,
    productListItems: ProductListItem[],
    hoveredProductId?: string,
): {
    mapRef: React.MutableRefObject<L.Map | null>;
    markerClusterRef: React.MutableRefObject<L.MarkerClusterGroup | null>;
} => {
    const mapRef = useRef<L.Map | null>(null);
    const markerClusterRef = useRef<L.MarkerClusterGroup | null>(null);

    const defaultIcon = L.icon({
        iconUrl: mapMarker,
        iconSize: [24, 32],
    });

    const highlightedIcon = L.icon({
        iconUrl: mapMarkerHover,
        iconSize: [24, 32],
    });

    const getClusterSize = (childCount: number): string => {
        if (childCount < 10) {
            return '-small';
        }

        if (childCount < 100) {
            return '-medium';
        }

        return '-large';
    };

    const defaultClusterIcon = (childCount: number): DivIcon => {
        return L.divIcon({
            html: `<div><span>${childCount}</span></div>`,
            className: `marker-cluster marker-cluster${getClusterSize(childCount)}`,
            iconSize: [40, 40],
        });
    };

    const highlightedClusterIcon = (childCount: number): DivIcon => {
        return L.divIcon({
            html: `<div><span>${childCount}</span></div>`,
            className: `marker-cluster marker-cluster${getClusterSize(childCount)} cluster-hovered`,
            iconSize: [40, 40],
        });
    };

    const closePopup = (): void => {
        if (mapRef.current) mapRef.current.closePopup();
    };

    const initializeMap = (): L.Map | null => {
        const container = document.getElementById('map');
        if (!container) return null;
        const map = L.map(container, {
            center: [51.1657, 10.4515],
            zoom: 6,
            zoomControl: false,
        });
        mapRef.current = map;

        map.addControl(new L.Control.Zoom({ position: 'bottomright' }));
        L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png').addTo(map);
        return map;
    };

    const createMarkerCluster = (): MarkerClusterGroup => {
        const clusters = L.markerClusterGroup({
            iconCreateFunction(cluster) {
                const childCount = cluster.getChildCount();
                const hasRelatedProduct = cluster
                    .getAllChildMarkers()
                    .some((marker: CustomMapMarker) => {
                        return marker.isHovered;
                    });

                return hasRelatedProduct
                    ? highlightedClusterIcon(childCount)
                    : defaultClusterIcon(childCount);
            },
        });
        markerClusterRef.current = clusters;
        return clusters;
    };

    const addMarkersToCluster = (map: L.Map, markers: MarkerClusterGroup): void => {
        productListItems.forEach((item, index) => {
            const currentItem = { ...item };
            if (currentItem.product !== undefined) {
                if (currentItem.product?.locations) {
                    const popupNode = document.createElement('div');
                    const root = createRoot(popupNode);
                    root.render(
                        <MapViewPopUpProduct {...currentItem.product} closePopup={closePopup} />,
                    );

                    currentItem.product.locations.forEach(location => {
                        const marker = L.marker([location.lat, location.long], {
                            icon: defaultIcon,
                        }) as CustomMapMarker;
                        marker.productID = currentItem.product?.productId;

                        if (currentItem.product) {
                            const popup = L.popup({
                                closeButton: false,
                                autoClose: false,
                                closeOnClick: true,
                                closeOnEscapeKey: true,
                                minWidth: 350,
                                className: 'custom-popup',
                            })
                                .setLatLng(map.getBounds().getCenter())
                                .setContent(popupNode);
                            marker.bindPopup(popup);
                        }

                        markers.addLayer(marker);
                    });
                }
            }
        });
        map.addLayer(markers);
    };

    useEffect(() => {
        if (isMapViewModalOpen) {
            setTimeout(() => {
                mapRef.current = initializeMap();

                const clusters = createMarkerCluster();

                if (mapRef.current) addMarkersToCluster(mapRef.current, clusters);
            }, 0);
        }
        // eslint-disable-next-line react-hooks/exhaustive-deps
    }, [isMapViewModalOpen]);

    useEffect(() => {
        if (!markerClusterRef.current) return;

        markerClusterRef.current.eachLayer(layer => {
            if ('productID' in layer) {
                const marker = layer as CustomMapMarker;
                const shouldBeHighlighted = marker.productID === hoveredProductId;
                marker.setIcon(shouldBeHighlighted ? highlightedIcon : defaultIcon);
                marker.isHovered = shouldBeHighlighted;
            }
        });

        markerClusterRef.current.refreshClusters();
        // eslint-disable-next-line react-hooks/exhaustive-deps
    }, [hoveredProductId]);

    return { mapRef, markerClusterRef };
};

export { MapView };

Upvotes: 0

Views: 149

Answers (1)

Disco
Disco

Reputation: 1634

window is not defined is probably due to leaflet not liking SSR that much. Make sure you dynamically load your components that use leaflet components:

const Map: React.FC<MapProps> = (props) => {
  const Map = React.useMemo(
    () =>
      dynamic(() => import("src/components/Map/Leaflet/LeafletMap"), {
        loading: () => <Skeleton height={"400px"} />,
        ssr: false,
      }),
    []
  );
  return <Map {...props} />;
};

When it come to cluster markers I noticed that that the react-leaflet package for it was quite light, which made it easy to copy an adapt in my own code, below can you see my adaption to change the icons and add other functionality to the markers:

import { createPathComponent } from "@react-leaflet/core";
import L, { LeafletMouseEventHandlerFn } from "leaflet";
import "leaflet.markercluster";
import { ReactElement, useMemo } from "react";
import { Building, BuildingStore, Circle } from "tabler-icons-react";
import { createLeafletIcon } from "./utils";
import styles from "./LeafletMarkerCluster.module.css";
import "leaflet.markercluster/dist/MarkerCluster.css";
type ClusterType = { [key in string]: any };

type ClusterEvents = {
  onClick?: LeafletMouseEventHandlerFn;
  onDblClick?: LeafletMouseEventHandlerFn;
  onMouseDown?: LeafletMouseEventHandlerFn;
  onMouseUp?: LeafletMouseEventHandlerFn;
  onMouseOver?: LeafletMouseEventHandlerFn;
  onMouseOut?: LeafletMouseEventHandlerFn;
  onContextMenu?: LeafletMouseEventHandlerFn;
};

// Leaflet is badly typed, if more props needed add them to the interface.
// Look in this file to see what is available.
// node_modules/@types/leaflet.markercluster/index.d.ts
// MarkerClusterGroupOptions
export interface LeafletMarkerClusterProps {
  spiderfyOnMaxZoom?: boolean;
  children: React.ReactNode;
  size?: number;
  icon?: ReactElement;
  maxClusterRadius?: number;
}
const createMarkerCluster = (
  {
    children: _c,
    size = 30,
    icon = <Circle size={size} />,
    maxClusterRadius = 40,
    ...props
  }: LeafletMarkerClusterProps,
  context: any
) => {
  const markerIcons = {
    default: <Circle size={size} />,
    property: <Building size={size} />,
    business: <BuildingStore size={size} />,
  } as { [key in string]: ReactElement };

  const clusterProps: ClusterType = {
    maxClusterRadius,
    iconCreateFunction: (cluster: any) => {
      const markers = cluster.getAllChildMarkers();

      const types = markers.reduce(
        (
          acc: { [x: string]: number },
          marker: {
            key: string;
            options: { icon: { options: { className: string } } };
          }
        ) => {
          const key = marker?.key || "";
          const type =
            marker.options.icon.options.className || key.split("-")[0];
          const increment = (key.split("-")[1] as unknown as number) || 1;

          if (type in markerIcons) {
            return { ...acc, [type]: (acc[type] || 0) + increment };
          }
          return { ...acc, default: (acc.default || 0) + increment };
        },
        {}
      ) as { [key in string]: number };
      const typeIcons = Object.entries(types).map(([type, count], index) => {
        if (count > 0) {
          const typeIcon = markerIcons[type];
          return (
            <div key={`${type}-${count}`} style={{ display: "flex" }}>
              <span>{typeIcon}</span>
              <span style={{ width: "max-content" }}>{count}</span>
            </div>
          );
        }
      });
      const iconWidth = typeIcons.length * size;

      return createLeafletIcon(
        <div style={{ display: "flex" }} className={"cluster-marker"}>
          {typeIcons}
        </div>,
        iconWidth,
        undefined,
        iconWidth,
        30
      );
    },
    showCoverageOnHover: false,
    animate: true,
    animateAddingMarkers: false,
    removeOutsideVisibleBounds: false,
  };
  const clusterEvents: ClusterType = {};
  // Splitting props and events to different objects
  Object.entries(props).forEach(([propName, prop]) =>
    propName.startsWith("on")
      ? (clusterEvents[propName] = prop)
      : (clusterProps[propName] = prop)
  );

  const instance = new (L as any).MarkerClusterGroup(clusterProps);

  instance.on("spiderfied", (e: any) => {
    e.cluster._icon?.classList.add(styles.spiderfied);
  });
  instance.on("unspiderfied", (e: any) => {
    e.cluster._icon?.classList.remove(styles.spiderfied);
  });

  // This is not used at the moment, but could be used to add events to the cluster.
  // Initializing event listeners
  Object.entries(clusterEvents).forEach(([eventAsProp, callback]) => {
    const clusterEvent = `cluster${eventAsProp.substring(2).toLowerCase()}`;
    instance.on(clusterEvent, callback);
  });
  return {
    instance,
    context: {
      ...context,
      layerContainer: instance,
    },
  };
};

// No update needed since leaflet cluster does not really support updates
const updateMarkerCluster = (instance: any, props: any, prevProps: any) => {};

const LeafletMarkerCluster = createPathComponent(
  createMarkerCluster,
  updateMarkerCluster
);

const LeafletMarkerClusterWrapper: React.FC<LeafletMarkerClusterProps> = ({
  children,
  maxClusterRadius = 40,
  spiderfyOnMaxZoom,
  ...props
}) => {
  const markerCluster = useMemo(() => {
    return (
      <LeafletMarkerCluster
        // Adding a key to force re-rendering when maxClusterRadius changes
        key={`marker-cluster-${maxClusterRadius}`}
        maxClusterRadius={maxClusterRadius}
        spiderfyOnMaxZoom={spiderfyOnMaxZoom}
      >
        {children}
      </LeafletMarkerCluster>
    );
  }, [children, maxClusterRadius, spiderfyOnMaxZoom]);
  return <>{markerCluster}</>;
};

export default LeafletMarkerClusterWrapper;

Notice that LeafletMarkerClusterWrapper is what will trigger a recreation of a marker since the cluster marker itself does not really play well with updates.

This is also some needed css:

.leaflet-cluster-anim .leaflet-marker-icon, .leaflet-cluster-anim .leaflet-marker-shadow {
  -webkit-transition: -webkit-transform 0.3s ease-out, opacity 0.3s ease-in;
  -moz-transition: -moz-transform 0.3s ease-out, opacity 0.3s ease-in;
  -o-transition: -o-transform 0.3s ease-out, opacity 0.3s ease-in;
  transition: transform 0.3s ease-out, opacity 0.3s ease-in;
}

.leaflet-cluster-spider-leg {
  /* stroke-dashoffset (duration and function) should match with leaflet-marker-icon transform in order to track it exactly */
  -webkit-transition: -webkit-stroke-dashoffset 0.3s ease-out, -webkit-stroke-opacity 0.3s ease-in;
  -moz-transition: -moz-stroke-dashoffset 0.3s ease-out, -moz-stroke-opacity 0.3s ease-in;
  -o-transition: -o-stroke-dashoffset 0.3s ease-out, -o-stroke-opacity 0.3s ease-in;
  transition: stroke-dashoffset 0.3s ease-out, stroke-opacity 0.3s ease-in;
}

/* to hide cluster marker on spiderfy */
.spiderfied{
    opacity: 0 !important 
  }

Remember that you will probably need to dynamic import the LeafletMarkerCluster component as well.

Upvotes: 1

Related Questions