Vizzuality/landgriffon

View on GitHub
client/src/containers/analysis-visualization/analysis-map/component.tsx

Summary

Maintainability
B
5 hrs
Test Coverage
import { useCallback, useMemo, useState } from 'react';
import { XCircleIcon } from '@heroicons/react/solid';

import { scaleByLegendType } from 'hooks/h3-data/utils';
import LayerManager from 'components/map/layer-manager';
import { useAppSelector } from 'store/hooks';
import { analysisMap } from 'store/features/analysis';
import { analysisUI } from 'store/features/analysis/ui';
import { useImpactLayer } from 'hooks/layers/impact';
import Legend from 'containers/analysis-visualization/analysis-legend';
import PageLoading from 'containers/page-loading';
import ZoomControl from 'components/map/controls/zoom';
import PopUp from 'components/map/popup';
import BasemapControl from 'components/map/controls/basemap';
import { formatNumber } from 'utils/number-format';
import Map, { INITIAL_VIEW_STATE } from 'components/map';
import { getLayerConfig } from 'components/map/layers/utils';

import type { LayerConstructor } from 'components/map/layers/utils';
import type { H3HexagonLayerProps } from '@deck.gl/geo-layers/typed';
import type { ViewState } from 'react-map-gl/maplibre';
import type { MapStyle } from 'components/map/types';
import type { BasemapValue } from 'components/map/controls/basemap/types';
import type { Layer, Legend as LegendType } from 'types';

const getLegendScale = (legendInfo: LegendType) => {
  if (legendInfo?.type === 'range' || legendInfo?.type === 'category') {
    const threshold = legendInfo.items.map((item) => item.value);
    const rangeValues = legendInfo.items.map((item) => item.label);
    const scale = scaleByLegendType(legendInfo?.type, threshold as number[], rangeValues);
    return scale;
  }
  return (value: number) => {
    if (!value) return null;
    if (!Number.isNaN(value)) return formatNumber(Number(value));
    return value.toString();
  };
};

const AnalysisMap = () => {
  const { layers } = useAppSelector(analysisMap);
  const { isSidebarCollapsed } = useAppSelector(analysisUI);

  const [mapStyle, setMapStyle] = useState<MapStyle>('terrain');
  const [viewState, setViewState] = useState<Partial<ViewState>>(INITIAL_VIEW_STATE);
  const handleViewState = useCallback((viewState: ViewState) => setViewState(viewState), []);
  const [tooltipData, setTooltipData] = useState(null);

  // Pre-Calculating legend scales
  const legendScales = useMemo(() => {
    const scales = {};
    Object.values(layers).forEach((layer) => {
      scales[layer.id] = getLegendScale(layer.metadata?.legend);
    });
    return scales;
  }, [layers]);

  // Loading layers
  const { isError, isFetching } = useImpactLayer();

  const onHoverLayer = useCallback(
    (
      { object, coordinate, viewport, x, y }: Parameters<H3HexagonLayerProps['onHover']>[0],
      metadata: Layer['metadata'] & { layerId?: string },
    ): void => {
      setTooltipData({
        x,
        y,
        viewport,
        data: {
          ...object,
          v: legendScales[metadata?.layerId]
            ? legendScales[metadata?.layerId](object?.v)
            : object?.v,
          coordinate,
          name: metadata?.name || metadata?.legend.name,
          unit: metadata?.legend?.unit,
          legend: metadata?.legend,
        },
      });
    },
    [legendScales],
  );

  const handleMapStyleChange = useCallback((newStyle: BasemapValue) => {
    setMapStyle(newStyle);
  }, []);

  const sortedLayers: { id: string; layer: LayerConstructor; props: Layer }[] = useMemo(() => {
    return Object.values(layers)
      .filter((layer) => layer.active && !!layers[layer.id])
      .sort((a, b) => {
        if (a.order > b.order) return 1;
        if (a.order < b.order) return -1;
        return 0;
      })
      .map((layer) => ({ id: layer.id, layer: getLayerConfig(layers[layer.id]), props: layer }));
  }, [layers]);

  return (
    <div className="absolute left-0 top-0 h-full w-full overflow-hidden" data-testid="analysis-map">
      {isFetching && <PageLoading />}
      <Map
        className="h-full w-screen"
        mapStyle={mapStyle}
        viewState={viewState}
        onMapViewStateChange={handleViewState}
        sidebarCollapsed={isSidebarCollapsed}
      >
        {() => (
          <>
            <LayerManager layers={sortedLayers} onHoverLayer={onHoverLayer} />
            {tooltipData && tooltipData.data?.v && (
              <PopUp
                position={{
                  ...tooltipData.viewport,
                  x: tooltipData.x,
                  y: tooltipData.y,
                }}
              >
                <div className="space-y-2 rounded-md bg-white p-4 shadow-md">
                  <div className="text-sm font-semibold text-gray-900">
                    {tooltipData.data.v}
                    {tooltipData.data.unit && ` ${tooltipData.data.unit}`}
                  </div>
                  <div className="text-xs text-gray-500">{tooltipData.data.name}</div>
                </div>
              </PopUp>
            )}
          </>
        )}
      </Map>
      {isError && (
        <div className="left-12 top-20 rounded-md bg-red-50 p-4">
          <div className="flex">
            <XCircleIcon className="h-5 w-5 text-red-400" aria-hidden="true" />
            <p className="mb-0 ml-3 text-sm text-red-400">
              No available data for the current filter selection. Please try another one.
            </p>
          </div>
        </div>
      )}
      <div className="absolute bottom-10 right-6 z-10 w-10 space-y-2">
        <BasemapControl value={mapStyle} onChange={handleMapStyleChange} />
        <ZoomControl />
        <Legend />
      </div>
    </div>
  );
};

export default AnalysisMap;