Vizzuality/landgriffon

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

Summary

Maintainability
A
0 mins
Test Coverage
import React, { useCallback, useMemo, useState } from 'react';
import { EyeIcon } from '@heroicons/react/solid';

import PreviewMap from './preview-map';
import MaterialSettings from './materials';
import CategoryLayer from './categories/category-layer';
import CategoryHeader from './categories/category-header';

import Accordion from 'components/accordion';
import { Button } from 'components/button';
import Search from 'components/search';
import useFuse from 'hooks/fuse';
import { analysisMap } from 'store/features/analysis';
import { useAppSelector } from 'store/hooks';
import { analysisFilters } from 'store/features/analysis/filters';

import type { UseFuseOptions } from 'hooks/fuse';
import type { CategoryWithLayers } from 'hooks/layers/getContextualLayers';
import type { Dispatch } from 'react';
import type { Layer, Material } from 'types';
import type { AnalysisMapState } from 'store/features/analysis/map';
import type {
  PreviewStatus,
  CategoryLayerProps as LayerSettingsProps,
} from './categories/category-layer/types';

interface LegendSettingsProps {
  categories: CategoryWithLayers[];
  onApply?: Dispatch<{ layers: Layer[]; material: Material['id'] }>;
  onDismiss?: () => void;
}

interface CategorySettingsProps
  extends Pick<LayerSettingsProps, 'onPreviewChange' | 'previewStatus'> {
  category: CategoryWithLayers['category'];
  layers: Layer[];
  onLayerStateChange: (id: Layer['id'], state: Partial<Layer>) => void;
  visibleLayers: number;
  activePreviewLayerId?: Layer['id'];
}

const NoMatches = () => (
  <div className="p-2 text-sm text-gray-500">
    There are no layers matching your query for this category
  </div>
);

const CategorySettings = ({
  category,
  layers,
  onLayerStateChange,
  visibleLayers,
  activePreviewLayerId,
  ...rest
}: CategorySettingsProps) => {
  return (
    <Accordion.Entry header={<CategoryHeader visibleLayers={visibleLayers} category={category} />}>
      {layers.length ? (
        layers.map((layer) => (
          <CategoryLayer
            isPreviewActive={layer.id === activePreviewLayerId}
            onChange={onLayerStateChange}
            layer={layer}
            key={layer.id}
            {...rest}
          />
        ))
      ) : (
        <NoMatches />
      )}
    </Accordion.Entry>
  );
};

const FUSE_OPTIONS: UseFuseOptions<CategoryWithLayers['layers'][number]> = {
  keys: ['name', 'metadata.name', 'metadata.description'],
  shouldSort: false,
  threshold: 0.3,
};

const LegendSettings: React.FC<LegendSettingsProps> = ({ categories = [], onApply, onDismiss }) => {
  const { materialId } = useAppSelector(analysisFilters);
  const {
    layers: { impact, ..._initialLayerState },
  }: AnalysisMapState = useAppSelector(analysisMap);

  const [localLayerState, setLocalLayerState] = useState(_initialLayerState);
  const [localMaterial, setLocalMaterial] = useState(materialId);
  const [selectedLayerForPreview, setSelectedLayerForPreview] = useState<Layer['id'] | null>(null);

  const handleTogglePreview = useCallback<CategorySettingsProps['onPreviewChange']>(
    (id, active) => {
      setSelectedLayerForPreview(active ? id : null);
    },
    [],
  );

  const handleLayerStateChange = useCallback<CategorySettingsProps['onLayerStateChange']>(
    (id, state) => {
      setLocalLayerState((currentState) => ({
        ...currentState,
        [id]: { ...currentState[id], ...state },
      }));
    },
    [],
  );

  const handleApply = useCallback(() => {
    onApply?.({ layers: Object.values(localLayerState), material: localMaterial });
  }, [localLayerState, localMaterial, onApply]);

  const flatLayers = useMemo(() => categories.flatMap(({ layers }) => layers), [categories]);

  const [searchText, setSearchText] = useState('');
  const result = useFuse(flatLayers, searchText, FUSE_OPTIONS);

  const reset = useCallback(() => {
    setSearchText('');
  }, []);

  const filteredLayersIds = useMemo(() => result.map((item) => item.id), [result]);

  const [previewStatus, setPreviewStatus] = useState<PreviewStatus>('loading');

  const layerStateByCategory = useMemo(() => {
    return Object.fromEntries(
      categories.map(({ category, layers }) => {
        return [
          category,
          layers
            .filter((layer) => filteredLayersIds.includes(layer.id))
            .map(({ id }) => localLayerState[id]),
        ];
      }),
    );
  }, [categories, filteredLayersIds, localLayerState]);

  const localSelectedLayerNumber = useMemo<number>(
    () => Object.values(localLayerState).filter((l) => l.visible).length,
    [localLayerState],
  );

  return (
    <div className="flex h-[600px] flex-row items-stretch">
      <div className="flex w-[25rem] flex-col gap-5 p-6">
        <div className="w-full">
          <Search
            onChange={setSearchText}
            value={searchText}
            placeholder="Search layers"
            onReset={reset}
            disabled
          />
        </div>
        <div className="text-right text-sm text-navy-400 underline-offset-[3px]">
          Selected layers ({localSelectedLayerNumber})
        </div>
        <div className="max-h-full flex-grow overflow-y-auto p-0.5">
          <Accordion>
            <MaterialSettings
              previewStatus={previewStatus}
              layer={localLayerState.material}
              materialId={localMaterial}
              onChange={handleLayerStateChange}
              onChangeMaterial={setLocalMaterial}
              onPreviewChange={handleTogglePreview}
              isPreviewActive={localLayerState.material.id === selectedLayerForPreview}
            />
            {categories.map((cat) => (
              <CategorySettings
                previewStatus={previewStatus}
                activePreviewLayerId={selectedLayerForPreview}
                visibleLayers={
                  cat.layers.filter((layer) => localLayerState[layer.id].visible).length
                }
                layers={layerStateByCategory[cat.category]}
                onLayerStateChange={handleLayerStateChange}
                onPreviewChange={handleTogglePreview}
                key={cat.category}
                category={cat.category}
              />
            ))}
          </Accordion>
        </div>
        <div className="flex flex-row justify-between gap-2">
          <Button variant="white" onClick={onDismiss}>
            Cancel
          </Button>
          <Button
            className="flex-1"
            onClick={handleApply}
            variant="primary"
            data-testid="contextual-layer-apply-button"
            disabled={localSelectedLayerNumber === 0}
          >
            Apply
          </Button>
        </div>
      </div>
      <div className="relative aspect-square">
        <div className="absolute left-0 top-0 h-full w-full">
          <PreviewMap
            selectedMaterialId={localMaterial}
            selectedLayerId={selectedLayerForPreview}
            onStatusChange={setPreviewStatus}
          />
          <div className="absolute left-3 top-3 flex h-fit flex-row rounded-md bg-black text-sm text-white">
            <div className="p-3 font-bold">Preview layers</div>
            {!selectedLayerForPreview && (
              <>
                <div className="w-0.5 self-stretch bg-white" />
                <div className="p-3">
                  Click the eye icon <EyeIcon className="inline h-4 w-4" /> next to the layer name
                  preview
                </div>
              </>
            )}
          </div>
        </div>
      </div>
    </div>
  );
};

export default LegendSettings;