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

Summary

Maintainability
A
1 hr
Test Coverage
import { Box, CircularProgress } from '@mui/material';
import React, {
  createContext,
  PropsWithChildren,
  useContext,
  useEffect,
  useMemo,
  useRef,
  useState,
} from 'react';
import { MapContainer, Polygon, useMapEvents } from 'react-leaflet';
import L, { LatLngBounds, LatLngBoundsExpression, LatLngExpression, Map, Point } from 'leaflet';
import MapLayers from '@/components/map/MapLayers';
import { ParcelPopup } from '@/components/map/parcelPopup/ParcelPopup';
import { InventoryLayer } from '@/components/map/InventoryLayer';
import useDataLoader from '@/hooks/useDataLoader';
import { MapFilter, PropertyGeo } from '@/hooks/api/usePropertiesApi';
import usePimsApi from '@/hooks/usePimsApi';
import { SnackBarContext } from '@/contexts/snackbarContext';
import MapSidebar from '@/components/map/sidebar/MapSidebar';
import ClusterPopup, { PopupState } from '@/components/map/clusterPopup/ClusterPopup';
import { ParcelLayerFeature } from '@/hooks/api/useParcelLayerApi';
import PolygonQuery, { LeafletMultiPolygon } from '@/components/map/polygonQuery/PolygonQuery';

type ParcelMapProps = {
  height: string;
  mapRef?: React.MutableRefObject<Map>;
  movable?: boolean;
  zoomable?: boolean;
  loadProperties?: boolean;
  popupSize?: 'small' | 'large';
  scrollOnClick?: boolean;
  zoomOnScroll?: boolean;
  hideControls?: boolean;
  defaultZoom?: number;
  defaultLocation?: LatLngExpression;
} & PropsWithChildren;

export const SelectedMarkerContext = createContext(null);

/**
 * ParcelMap component renders a map with various layers and functionalities.
 *
 * @param {ParcelMapProps} props - The props object used for ParcelMap component.
 * @returns {JSX.Element} The ParcelMap component.
 *
 * @example
 * ```tsx
 * <ParcelMap
 *   height="500px"
 *   mapRef={mapRef}
 *   movable={true}
 *   zoomable={true}
 *   loadProperties={false}
 * >
 *   {children}
 * </ParcelMap>
 * ```
 */
const ParcelMap = (props: ParcelMapProps) => {
  const MapEvents = () => {
    useMapEvents({
      resize: () => {
        setPopupState({ ...popupState, open: false });
      },
      baselayerchange: (e) => {
        setTileLayerName(e.name);
      },
    });
    return null;
  };
  const api = usePimsApi();
  const snackbar = useContext(SnackBarContext);
  const [filter, setFilter] = useState<MapFilter>({}); // Applies when request for properties is made
  const [properties, setProperties] = useState<PropertyGeo[]>([]);
  const [tileLayerName, setTileLayerName] = useState<string>('Street Map');
  const [polygonQueryShape, setPolygonQueryShape] = useState<LeafletMultiPolygon>({
    type: 'MultiPolygon',
    coordinates: [],
    leafletIds: [],
  });
  const [mapEventsDisabled, setMapEventsDisabled] = useState<boolean>(false);

  // When drawn multipolygon changes, query the new area
  useEffect(() => {
    const polygonCoordinates = polygonQueryShape.coordinates.map((polygon) =>
      polygon.map((point) => [point.lat, point.lng]),
    );
    setFilter({
      ...filter,
      Polygon: polygonCoordinates.length ? JSON.stringify(polygonCoordinates) : undefined,
    });
  }, [polygonQueryShape]);

  // Get properties for map.
  const { data, refreshData, isLoading } = useDataLoader(() =>
    api.properties.propertiesGeoSearch(filter),
  );

  // Controls ClusterPopup contents
  const [popupState, setPopupState] = useState<PopupState>({
    open: false,
    properties: [],
    position: new Point(500, 500),
    pageSize: 10,
    pageIndex: 0,
    total: 0,
  });

  const controlledSetPopupState = (stateUpdates: Partial<PopupState>) => {
    // Only block if trying to open. Allow users to close popup/change page at all times.
    if (stateUpdates.open && mapEventsDisabled) return;
    setPopupState({
      ...popupState,
      ...stateUpdates,
    });
  };

  // Store polygon overlay data for parcel layer
  const [parcelPolygon, setParcelPolygon] = useState([]);

  // Elevated state for the sidebar
  const [sidebarOpen, setSidebarOpen] = useState<boolean>(true);

  const {
    height,
    mapRef,
    movable = true,
    zoomable = true,
    loadProperties = false,
    popupSize,
    scrollOnClick,
    zoomOnScroll = true,
    hideControls = false,
    defaultLocation,
    defaultZoom,
  } = props;

  // To access map outside of MapContainer
  const localMapRef = mapRef ?? useRef<Map>();

  const deletionBroadcastChannel = useMemo(() => new BroadcastChannel('property'), []);
  useEffect(
    () =>
      deletionBroadcastChannel.addEventListener('message', (event) => {
        if (typeof event.data === 'string' && event.data === 'refresh') {
          refreshData();
        }
      }),
    [],
  );

  // Default for BC view
  const defaultBounds = [
    [54.2516, -129.371],
    [49.129, -117.203],
  ];

  // Get the property data for mapping
  useEffect(() => {
    if (data) {
      handleDataChange();
    } else {
      if (loadProperties) {
        refreshData();
      }
    }
  }, [data, isLoading]);

  // Loops through any array and pairs it down to a flat list of its base elements
  // Used here for breaking shape geography down to bounds coordinates
  const extractLowestElements: (arr: any[]) => [number, number][] = (arr) => {
    return arr.reduce((acc, item) => {
      if (Array.isArray(item[0])) {
        return acc.concat(extractLowestElements(item));
      } else {
        acc.push(item);
        return acc;
      }
    }, []);
  };

  const handleDataChange = async () => {
    setParcelPolygon([]);
    if (data.length) {
      setProperties(data as PropertyGeo[]);
      snackbar.setMessageState({
        open: true,
        text: `${data.length} properties found.`,
        style: snackbar.styles.success,
      });
    } else {
      setProperties([]);
      // No properties in inventory. Check the parcel layer.
      const parcelLayerFeatures: ParcelLayerFeature[] = [];
      if (filter.PID) {
        await api.parcelLayer.getParcelByPid(String(filter.PID)).then((response) => {
          parcelLayerFeatures.push(...response.features);
        });
      }
      if (filter.PIN) {
        await api.parcelLayer.getParcelByPin(String(filter.PIN)).then((response) => {
          parcelLayerFeatures.push(...response.features);
        });
      }
      // Were any parcels found that match?
      if (parcelLayerFeatures.length) {
        snackbar.setMessageState({
          open: true,
          text: `No inventory found, but ${parcelLayerFeatures.length} match${parcelLayerFeatures.length > 1 ? 'es' : ''} found on Parcel Layer.`,
          style: snackbar.styles.success,
        });
        // Place feature shapes on map
        if (localMapRef.current) {
          const polygonShapes = [];
          /** Will be one of two types:
           * Polygon for a single shape
           * MultiPolygon for many shapes
           * Coordinates have to be switched to work with Leaflet
           */
          parcelLayerFeatures.forEach((feature) => {
            if (feature.geometry.type === 'Polygon') {
              polygonShapes.push(
                feature.geometry.coordinates
                  .at(0)
                  .map((coordinate) => [coordinate[1], coordinate[0]]),
              );
            } else if (feature.geometry.type === 'MultiPolygon') {
              feature.geometry.coordinates.forEach((coordinateList) => {
                coordinateList.forEach((list) => {
                  polygonShapes.push(list.map((pair) => [pair[1], pair[0]]));
                });
              });
            }
          });
          setParcelPolygon(polygonShapes);
          // Centres map to encompass all found features. Accepts flat list of coordinate pairs
          localMapRef.current.fitBounds(extractLowestElements(polygonShapes));
          // Hide the sidebar
          setSidebarOpen(false);
        }
      } else {
        // No properties in inventory or in parcel layer
        snackbar.setMessageState({
          open: true,
          text: `No properties or parcels found matching filter criteria.`,
          style: snackbar.styles.warning,
        });
      }
    }
  };

  // Refresh the data if the filter changes
  useEffect(() => {
    if (loadProperties) {
      refreshData();
    }
  }, [filter]);

  // When properties change, update the zoom
  useEffect(() => {
    // Prioritize fitting in the polygon
    if (polygonQueryShape.coordinates.length) {
      // Flattening all coordinates from the MultiPolygon
      const allCoordinates = polygonQueryShape.coordinates.flat(2);

      // Find min and max latitudes and longitudes
      const latitudes = allCoordinates.map((coord) => coord.lat);
      const longitudes = allCoordinates.map((coord) => coord.lng);

      const southWest = [Math.min(...latitudes), Math.min(...longitudes)];
      const northEast = [Math.max(...latitudes), Math.max(...longitudes)];

      // Use fitBounds with the calculated bounding box
      localMapRef.current.fitBounds([southWest, northEast] as unknown as LatLngBounds, {
        paddingBottomRight: [500, 0],
      });
    } else 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[];
      localMapRef.current.fitBounds(
        L.latLngBounds(
          coordsArray.length
            ? coordsArray
            : [
                [54.2516, -129.371],
                [49.129, -117.203],
              ],
        ),
        {
          paddingBottomRight: [500, 0], // Padding for map sidebar
        },
      );
    }
  }, [properties]);

  return (
    <Box height={height} display={'flex'}>
      {loadProperties ? <LoadingCover show={isLoading} /> : <></>}
      <MapContainer
        style={{ height: '100%', width: '100%' }}
        ref={localMapRef}
        bounds={defaultBounds as LatLngBoundsExpression}
        zoom={defaultZoom}
        center={defaultLocation}
        dragging={movable}
        zoomControl={zoomable}
        scrollWheelZoom={zoomOnScroll}
        touchZoom={zoomable}
        boxZoom={zoomable}
        doubleClickZoom={zoomable}
        preferCanvas
      >
        <MapLayers hideControls={hideControls} />
        {!hideControls && loadProperties ? (
          <PolygonQuery
            setPolygons={setPolygonQueryShape}
            setMapEventsDisabled={setMapEventsDisabled}
          />
        ) : (
          <></>
        )}
        <ParcelPopup
          size={popupSize}
          scrollOnClick={scrollOnClick}
          mapEventsDisabled={mapEventsDisabled}
        />
        <MapEvents />
        {loadProperties ? (
          <InventoryLayer
            isLoading={isLoading}
            properties={properties}
            popupState={popupState}
            setPopupState={controlledSetPopupState}
            tileLayerName={tileLayerName}
          />
        ) : (
          <></>
        )}
        {parcelPolygon.map((coordinates, index) => (
          <Polygon key={index} pathOptions={{ color: 'blue' }} positions={coordinates} />
        ))}
        {props.children}
      </MapContainer>
      {loadProperties ? (
        <>
          <MapSidebar
            properties={properties}
            map={localMapRef}
            setFilter={setFilter}
            sidebarOpen={sidebarOpen}
            setSidebarOpen={setSidebarOpen}
            filter={filter}
          />
          <ClusterPopup popupState={popupState} setPopupState={controlledSetPopupState} />
        </>
      ) : (
        <></>
      )}
    </Box>
  );
};
export interface LoadingCoverProps {
  show?: boolean;
}

const LoadingCover: React.FC<LoadingCoverProps> = ({ show }) => {
  return show ? (
    <div
      style={{
        width: '100%',
        height: '100%',
        position: 'absolute',
        zIndex: '999',
        left: '0',
        backgroundColor: 'rgba(0, 0, 0, 0.4)',
        display: 'flex',
        alignItems: 'center',
        alignContent: 'center',
        justifyItems: 'center',
        justifyContent: 'center',
      }}
    >
      <CircularProgress />
    </div>
  ) : null;
};

export default ParcelMap;