react-app/src/components/map/InventoryLayer.tsx

Summary

Maintainability
A
0 mins
Test Coverage
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { PropertyGeo } from '@/hooks/api/usePropertiesApi';
import { Marker, useMap, useMapEvents } from 'react-leaflet';
import useSupercluster from 'use-supercluster';
import './clusterHelpers/clusters.css';
import L, { LatLngExpression, Point } from 'leaflet';
import { BBox } from 'geojson';
import { PopupState } from '@/components/map/clusterPopup/ClusterPopup';

export interface InventoryLayerProps {
  isLoading: boolean;
  properties: PropertyGeo[];
  popupState: PopupState;
  setPopupState: React.Dispatch<React.SetStateAction<PopupState>>;
  tileLayerName: string;
}

// Properties added to PropertyGeo types after clustering
export interface ClusterGeo {
  properties: {
    // Optionals only on clustered points.
    cluster: boolean;
    cluster_id?: number;
    point_count?: number;
  };
}

/**
 * Renders an inventory layer on the map.
 *
 * @param {InventoryLayerProps} props - The props for the InventoryLayer component.
 * @returns {JSX.Element} The rendered InventoryLayer component.
 */
export const InventoryLayer = (props: InventoryLayerProps) => {
  const { isLoading, properties, popupState, setPopupState, tileLayerName } = props;
  const map = useMap();
  const [clusterBounds, setClusterBounds] = useState<BBox>(); // Affects clustering
  const [clusterZoom, setClusterZoom] = useState<number>(14); // Affects clustering

  const maxZoom = 18;

  // When properties change, set map bounds and remake clusters
  useEffect(() => {
    if (properties) {
      if (properties.length) {
        // Set map bounds based on received data. Eliminate outliers (outside BC)
        const coordsArray = properties
          .map((d) => [d.geometry.coordinates[1], d.geometry.coordinates[0]])
          .filter(
            (coords) => coords[0] > 40 && coords[0] < 60 && coords[1] > -140 && coords[1] < -110,
          ) as LatLngExpression[];
        map.fitBounds(
          L.latLngBounds(
            coordsArray.length
              ? coordsArray
              : [
                  [54.2516, -129.371],
                  [49.129, -117.203],
                ],
          ),
        );
        updateClusters();
      }
    }
  }, [properties]);

  // Updating the map for the clusterer
  const updateClusters = () => {
    const b = map.getBounds();
    setClusterBounds([
      b.getSouthWest().lng,
      b.getSouthWest().lat,
      b.getNorthEast().lng,
      b.getNorthEast().lat,
    ]);
    const zoom = map.getZoom();
    setClusterZoom(zoom);
  };

  // Update clusters once upon load
  useEffect(() => {
    updateClusters();
  }, []);

  // Create clustered markers
  const { clusters, supercluster } = useSupercluster({
    points: properties,
    bounds: clusterBounds,
    zoom: clusterZoom,
    options: { radius: 65, maxZoom, minZoom: 0, extent: 400 }, // Controls how markers cluster
    disableRefresh: isLoading, // So we don't refresh while loading
  });

  // Determine appropriate class for map clusters
  const getClusterClass = useMemo(() => {
    switch (tileLayerName) {
      case 'Street Map':
        return 'street';
      case 'Satellite':
        return 'satellite';
      default:
        return '';
    }
  }, [tileLayerName]);

  // Create icons for clusters
  const icons = {};
  const makeClusterIcon = (count: number) => {
    // Only make one icon per identical count
    const size = 10 + (count / properties.length) * 20;
    if (!icons[count]) {
      const displayCount = count < 1000 ? count : `${(count / 1000).toFixed(1)}K`;
      return (icons[count] = L.divIcon({
        // Some cluster styling found in clusterHelpers/clusters.css
        html: `<div class="cluster-marker ${getClusterClass}" style="width: ${size}px; height: ${size}px;">
        ${displayCount}
      </div>`,
        iconAnchor: [size, size], // This helps to centre the icon on the location. Otherwise anchored top-left.
      }));
    }
    return icons[count];
  };

  // Zoom towards cluster enough to break into small clusters
  const zoomOnCluster = useCallback(
    (cluster: PropertyGeo & ClusterGeo) => {
      if (!supercluster || !cluster) {
        return;
      }
      const expansionZoom = Math.min(
        supercluster.getClusterExpansionZoom(cluster.properties.cluster_id),
        maxZoom,
      );

      // Zoom towards cluster
      map.setView(
        [cluster.geometry.coordinates[1], cluster.geometry.coordinates[0]],
        expansionZoom,
        {
          animate: true,
        },
      );
    },
    [map, supercluster],
  );
  let timeoutID = undefined;
  // For expanding the cluster popup
  const openClusterPopup = (cluster: PropertyGeo & ClusterGeo, point: Point) => {
    // Prevent reseting state if entering the same cluster marker
    if (popupState.open && cluster.properties.cluster_id === popupState.clusterId) {
      return;
    }
    timeoutID = setTimeout(() => {
      // If it's a cluster of more than 1
      if (cluster.properties.cluster) {
        const newClusterProperties: (PropertyGeo & ClusterGeo)[] = supercluster.getLeaves(
          cluster.properties.cluster_id, // id of cluster containing properties
          popupState.pageSize, // size of page
          popupState.pageSize * popupState.pageIndex, // offset
        );
        const totalProperties: (PropertyGeo & ClusterGeo)[] = supercluster.getLeaves(
          cluster.properties.cluster_id,
          Infinity,
        );
        setPopupState({
          ...popupState,
          properties: newClusterProperties,
          open: true,
          position: point,
          pageIndex: 0,
          total: totalProperties.length,
          supercluster: supercluster,
          clusterId: cluster.properties.cluster_id,
        });
      } else {
        // Cluster marker of 1
        setPopupState({
          ...popupState,
          properties: [cluster],
          open: true,
          position: point,
          pageIndex: 0,
          total: 1,
        });
      }
    }, 350);
  };

  const cancelOpenPopup = () => {
    clearTimeout(timeoutID);
  };

  // Update map after these actions
  useMapEvents({
    zoomend: updateClusters,
    moveend: updateClusters,
    zoomstart: () =>
      setPopupState({
        ...popupState,
        properties: [],
        open: false,
        position: new Point(0, 0),
        pageIndex: 0,
      }),
    movestart: () =>
      setPopupState({
        ...popupState,
        properties: [],
        open: false,
        position: new Point(0, 0),
        pageIndex: 0,
      }),
  });

  return (
    <>
      {/* For all cluster objects */}
      {clusters.map((property: PropertyGeo & ClusterGeo) => {
        // Return a cluster circle if it's a cluster
        if (property.properties.cluster) {
          return (
            <Marker
              key={`cluster-${property.properties.cluster_id}`}
              position={[property.geometry.coordinates[1], property.geometry.coordinates[0]]} // Flip this back to leaflet-expected positions
              icon={makeClusterIcon(property.properties.point_count)}
              eventHandlers={{
                click: () => zoomOnCluster(property),
                mouseover: (e) => {
                  openClusterPopup(property, e.containerPoint);
                },
                mouseout: cancelOpenPopup,
              }}
            />
          );
        } else {
          // Cluster icons of 1
          return (
            <Marker
              key={`cluster-${property.properties.Id}`}
              position={[property.geometry.coordinates[1], property.geometry.coordinates[0]]} // Flip this back to leaflet-expected positions
              icon={makeClusterIcon(1)}
              eventHandlers={{
                click: () => {
                  // Zoom fully towards cluster of 1
                  map.setView(
                    [property.geometry.coordinates[1], property.geometry.coordinates[0]],
                    maxZoom,
                    {
                      animate: true,
                    },
                  );
                },
                mouseover: (e) => openClusterPopup(property, e.containerPoint),
                mouseout: cancelOpenPopup,
              }}
            />
          );
        }
      })}
    </>
  );
};