From f303aaf4bf889090807518c1d1fd5cef5dbb9c49 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Adrian=20Or=C5=82=C3=B3w?= <adrian.orlow@fishbrain.com>
Date: Thu, 4 Jan 2024 16:24:23 +0100
Subject: [PATCH] feat: add display legend

---
 .../NavBar/NavBar.component.tsx               |  3 +-
 .../Map/Legend/Legend.component.test.tsx      | 75 +++++++++++++++++++
 .../Map/Legend/Legend.component.tsx           | 24 ++++++
 src/components/Map/Legend/Legend.constants.ts |  1 +
 .../LegendHeader.component.test.tsx           | 49 ++++++++++++
 .../LegendHeader/LegendHeader.component.tsx   | 31 ++++++++
 .../Map/Legend/LegendHeader/index.ts          |  1 +
 .../LegendImages.component.test.tsx           | 66 ++++++++++++++++
 .../LegendImages/LegendImages.component.tsx   | 24 ++++++
 .../Map/Legend/LegendImages/index.ts          |  1 +
 src/components/Map/Legend/index.ts            |  1 +
 src/components/Map/Map.component.tsx          |  7 +-
 .../overlaysLayer/useOlMapOverlaysLayer.ts    | 13 ++--
 .../reactionsLayer/useOlMapReactionsLayer.ts  |  5 +-
 .../configuration/configuration.constants.ts  |  7 ++
 .../configuration/configuration.selectors.ts  |  9 ++-
 src/redux/legend/legend.constants.ts          |  7 ++
 src/redux/legend/legend.mock.ts               |  7 ++
 src/redux/legend/legend.reducers.ts           | 21 ++++++
 src/redux/legend/legend.selectors.ts          | 20 +++++
 src/redux/legend/legend.slice.ts              | 16 ++++
 src/redux/legend/legend.types.ts              |  8 ++
 src/redux/root/root.fixtures.ts               |  4 +-
 src/redux/store.ts                            | 10 ++-
 24 files changed, 392 insertions(+), 18 deletions(-)
 create mode 100644 src/components/Map/Legend/Legend.component.test.tsx
 create mode 100644 src/components/Map/Legend/Legend.component.tsx
 create mode 100644 src/components/Map/Legend/Legend.constants.ts
 create mode 100644 src/components/Map/Legend/LegendHeader/LegendHeader.component.test.tsx
 create mode 100644 src/components/Map/Legend/LegendHeader/LegendHeader.component.tsx
 create mode 100644 src/components/Map/Legend/LegendHeader/index.ts
 create mode 100644 src/components/Map/Legend/LegendImages/LegendImages.component.test.tsx
 create mode 100644 src/components/Map/Legend/LegendImages/LegendImages.component.tsx
 create mode 100644 src/components/Map/Legend/LegendImages/index.ts
 create mode 100644 src/components/Map/Legend/index.ts
 create mode 100644 src/redux/legend/legend.constants.ts
 create mode 100644 src/redux/legend/legend.mock.ts
 create mode 100644 src/redux/legend/legend.reducers.ts
 create mode 100644 src/redux/legend/legend.selectors.ts
 create mode 100644 src/redux/legend/legend.slice.ts
 create mode 100644 src/redux/legend/legend.types.ts

diff --git a/src/components/FunctionalArea/NavBar/NavBar.component.tsx b/src/components/FunctionalArea/NavBar/NavBar.component.tsx
index 6783d04b..29ce0fb0 100644
--- a/src/components/FunctionalArea/NavBar/NavBar.component.tsx
+++ b/src/components/FunctionalArea/NavBar/NavBar.component.tsx
@@ -2,6 +2,7 @@ import logoImg from '@/assets/images/logo.png';
 import luxembourgLogoImg from '@/assets/images/luxembourg-logo.png';
 import { openDrawer } from '@/redux/drawer/drawer.slice';
 import { useAppDispatch } from '@/redux/hooks/useAppDispatch';
+import { openLegend } from '@/redux/legend/legend.slice';
 import { IconButton } from '@/shared/IconButton';
 import Image from 'next/image';
 
@@ -21,7 +22,7 @@ export const NavBar = (): JSX.Element => {
   };
 
   const openDrawerLegend = (): void => {
-    dispatch(openDrawer('legend'));
+    dispatch(openLegend());
   };
 
   return (
diff --git a/src/components/Map/Legend/Legend.component.test.tsx b/src/components/Map/Legend/Legend.component.test.tsx
new file mode 100644
index 00000000..4ad65a5a
--- /dev/null
+++ b/src/components/Map/Legend/Legend.component.test.tsx
@@ -0,0 +1,75 @@
+import { currentLegendImagesSelector, legendSelector } from '@/redux/legend/legend.selectors';
+import { StoreType } from '@/redux/store';
+import {
+  InitialStoreState,
+  getReduxWrapperWithStore,
+} from '@/utils/testing/getReduxWrapperWithStore';
+import { render, screen, within } from '@testing-library/react';
+import { Legend } from './Legend.component';
+import { LEGEND_ROLE } from './Legend.constants';
+
+jest.mock('../../../redux/legend/legend.selectors', () => ({
+  legendSelector: jest.fn(),
+  currentLegendImagesSelector: jest.fn(),
+}));
+
+const legendSelectorMock = legendSelector as unknown as jest.Mock;
+const currentLegendImagesSelectorMock = currentLegendImagesSelector as unknown as jest.Mock;
+
+const renderComponent = (initialStore?: InitialStoreState): { store: StoreType } => {
+  const { Wrapper, store } = getReduxWrapperWithStore(initialStore);
+  return (
+    render(
+      <Wrapper>
+        <Legend />
+      </Wrapper>,
+    ),
+    {
+      store,
+    }
+  );
+};
+
+describe('Legend - component', () => {
+  beforeAll(() => {
+    currentLegendImagesSelectorMock.mockImplementation(() => []);
+  });
+
+  describe('when is closed', () => {
+    beforeEach(() => {
+      legendSelectorMock.mockImplementation(() => ({
+        isOpen: false,
+      }));
+      renderComponent();
+    });
+
+    it('should render the component without translation', () => {
+      expect(screen.getByRole(LEGEND_ROLE)).not.toHaveClass('translate-y-0');
+    });
+  });
+
+  describe('when is open', () => {
+    beforeEach(() => {
+      legendSelectorMock.mockImplementation(() => ({
+        isOpen: true,
+      }));
+      renderComponent();
+    });
+
+    it('should render the component with translation', () => {
+      expect(screen.getByRole(LEGEND_ROLE)).toHaveClass('translate-y-0');
+    });
+
+    it('should render legend header', async () => {
+      const legendContainer = screen.getByRole(LEGEND_ROLE);
+      const legendHeader = await within(legendContainer).getByTestId('legend-header');
+      expect(legendHeader).toBeInTheDocument();
+    });
+
+    it('should render legend images', async () => {
+      const legendContainer = screen.getByRole(LEGEND_ROLE);
+      const legendImages = await within(legendContainer).getByTestId('legend-images');
+      expect(legendImages).toBeInTheDocument();
+    });
+  });
+});
diff --git a/src/components/Map/Legend/Legend.component.tsx b/src/components/Map/Legend/Legend.component.tsx
new file mode 100644
index 00000000..7414765e
--- /dev/null
+++ b/src/components/Map/Legend/Legend.component.tsx
@@ -0,0 +1,24 @@
+import { useAppSelector } from '@/redux/hooks/useAppSelector';
+import { legendSelector } from '@/redux/legend/legend.selectors';
+import * as React from 'react';
+import { twMerge } from 'tailwind-merge';
+import { LEGEND_ROLE } from './Legend.constants';
+import { LegendHeader } from './LegendHeader';
+import { LegendImages } from './LegendImages';
+
+export const Legend: React.FC = () => {
+  const { isOpen } = useAppSelector(legendSelector);
+
+  return (
+    <div
+      className={twMerge(
+        'absolute bottom-0 left-[88px] z-10 w-[calc(100%-88px)] -translate-y-[-100%] transform border border-divide bg-white-pearl text-font-500 transition-all duration-500',
+        isOpen && 'translate-y-0',
+      )}
+      role={LEGEND_ROLE}
+    >
+      <LegendHeader />
+      <LegendImages />
+    </div>
+  );
+};
diff --git a/src/components/Map/Legend/Legend.constants.ts b/src/components/Map/Legend/Legend.constants.ts
new file mode 100644
index 00000000..e0b04e86
--- /dev/null
+++ b/src/components/Map/Legend/Legend.constants.ts
@@ -0,0 +1 @@
+export const LEGEND_ROLE = 'legend';
diff --git a/src/components/Map/Legend/LegendHeader/LegendHeader.component.test.tsx b/src/components/Map/Legend/LegendHeader/LegendHeader.component.test.tsx
new file mode 100644
index 00000000..1cf02917
--- /dev/null
+++ b/src/components/Map/Legend/LegendHeader/LegendHeader.component.test.tsx
@@ -0,0 +1,49 @@
+import { FIRST_ARRAY_ELEMENT } from '@/constants/common';
+import { AppDispatch, RootState } from '@/redux/store';
+import { getReduxStoreWithActionsListener } from '@/utils/testing/getReduxStoreActionsListener';
+import { InitialStoreState } from '@/utils/testing/getReduxWrapperWithStore';
+import { render, screen } from '@testing-library/react';
+import { MockStoreEnhanced } from 'redux-mock-store';
+import { CLOSE_BUTTON_ROLE, LegendHeader } from './LegendHeader.component';
+
+const renderComponent = (
+  initialStore?: InitialStoreState,
+): { store: MockStoreEnhanced<Partial<RootState>, AppDispatch> } => {
+  const { Wrapper, store } = getReduxStoreWithActionsListener(initialStore);
+  return (
+    render(
+      <Wrapper>
+        <LegendHeader />
+      </Wrapper>,
+    ),
+    {
+      store,
+    }
+  );
+};
+
+describe('LegendHeader - component', () => {
+  it('should render legend title', () => {
+    renderComponent();
+
+    const legendTitle = screen.getByText('Legend');
+    expect(legendTitle).toBeInTheDocument();
+  });
+
+  it('should render legend close button', () => {
+    renderComponent();
+
+    const closeButton = screen.getByRole(CLOSE_BUTTON_ROLE);
+    expect(closeButton).toBeInTheDocument();
+  });
+
+  it('should close legend on close button click', async () => {
+    const { store } = renderComponent();
+
+    const closeButton = screen.getByRole(CLOSE_BUTTON_ROLE);
+    closeButton.click();
+
+    const actions = store.getActions();
+    expect(actions[FIRST_ARRAY_ELEMENT].type).toBe('legend/closeLegend');
+  });
+});
diff --git a/src/components/Map/Legend/LegendHeader/LegendHeader.component.tsx b/src/components/Map/Legend/LegendHeader/LegendHeader.component.tsx
new file mode 100644
index 00000000..19e52d7a
--- /dev/null
+++ b/src/components/Map/Legend/LegendHeader/LegendHeader.component.tsx
@@ -0,0 +1,31 @@
+import { useAppDispatch } from '@/redux/hooks/useAppDispatch';
+import { closeLegend } from '@/redux/legend/legend.slice';
+import { IconButton } from '@/shared/IconButton';
+
+export const CLOSE_BUTTON_ROLE = 'close-legend-button';
+
+export const LegendHeader: React.FC = () => {
+  const dispatch = useAppDispatch();
+
+  const handleCloseLegend = (): void => {
+    dispatch(closeLegend());
+  };
+
+  return (
+    <div
+      data-testid="legend-header"
+      className="flex items-center justify-between border-b border-b-divide px-6"
+    >
+      <div className="py-8 text-xl">
+        <span className="font-semibold">Legend</span>
+      </div>
+      <IconButton
+        className="bg-white-pearl"
+        classNameIcon="fill-font-500"
+        icon="close"
+        role={CLOSE_BUTTON_ROLE}
+        onClick={handleCloseLegend}
+      />
+    </div>
+  );
+};
diff --git a/src/components/Map/Legend/LegendHeader/index.ts b/src/components/Map/Legend/LegendHeader/index.ts
new file mode 100644
index 00000000..8cd37107
--- /dev/null
+++ b/src/components/Map/Legend/LegendHeader/index.ts
@@ -0,0 +1 @@
+export { LegendHeader } from './LegendHeader.component';
diff --git a/src/components/Map/Legend/LegendImages/LegendImages.component.test.tsx b/src/components/Map/Legend/LegendImages/LegendImages.component.test.tsx
new file mode 100644
index 00000000..e6d2681f
--- /dev/null
+++ b/src/components/Map/Legend/LegendImages/LegendImages.component.test.tsx
@@ -0,0 +1,66 @@
+import { BASE_MAP_IMAGES_URL } from '@/constants';
+import { currentLegendImagesSelector, legendSelector } from '@/redux/legend/legend.selectors';
+import { StoreType } from '@/redux/store';
+import {
+  InitialStoreState,
+  getReduxWrapperWithStore,
+} from '@/utils/testing/getReduxWrapperWithStore';
+import { render, screen } from '@testing-library/react';
+import { LegendImages } from './LegendImages.component';
+
+jest.mock('../../../../redux/legend/legend.selectors', () => ({
+  legendSelector: jest.fn(),
+  currentLegendImagesSelector: jest.fn(),
+}));
+
+const legendSelectorMock = legendSelector as unknown as jest.Mock;
+const currentLegendImagesSelectorMock = currentLegendImagesSelector as unknown as jest.Mock;
+
+const renderComponent = (initialStore?: InitialStoreState): { store: StoreType } => {
+  const { Wrapper, store } = getReduxWrapperWithStore(initialStore);
+  return (
+    render(
+      <Wrapper>
+        <LegendImages />
+      </Wrapper>,
+    ),
+    {
+      store,
+    }
+  );
+};
+
+describe('LegendImages - component', () => {
+  beforeAll(() => {
+    legendSelectorMock.mockImplementation(() => ({
+      isOpen: true,
+    }));
+  });
+
+  describe('when current images are empty', () => {
+    beforeEach(() => {
+      currentLegendImagesSelectorMock.mockImplementation(() => []);
+      renderComponent();
+    });
+
+    it('should render empty container', () => {
+      expect(screen.getByTestId('legend-images')).toBeEmptyDOMElement();
+    });
+  });
+
+  describe('when current images are present', () => {
+    const imagesPartialUrls = ['url1/image.png', 'url2/image.png', 'url3/image.png'];
+
+    beforeEach(() => {
+      currentLegendImagesSelectorMock.mockImplementation(() => imagesPartialUrls);
+      renderComponent();
+    });
+
+    it.each(imagesPartialUrls)('should render img element, partialUrl=%s', partialUrl => {
+      const imgElement = screen.getByAltText(partialUrl);
+
+      expect(imgElement).toBeInTheDocument();
+      expect(imgElement.getAttribute('src')).toBe(`${BASE_MAP_IMAGES_URL}/minerva/${partialUrl}`);
+    });
+  });
+});
diff --git a/src/components/Map/Legend/LegendImages/LegendImages.component.tsx b/src/components/Map/Legend/LegendImages/LegendImages.component.tsx
new file mode 100644
index 00000000..04516e44
--- /dev/null
+++ b/src/components/Map/Legend/LegendImages/LegendImages.component.tsx
@@ -0,0 +1,24 @@
+/* eslint-disable @next/next/no-img-element */
+import { BASE_MAP_IMAGES_URL } from '@/constants';
+import { useAppSelector } from '@/redux/hooks/useAppSelector';
+import { currentLegendImagesSelector } from '@/redux/legend/legend.selectors';
+
+export const LegendImages: React.FC = () => {
+  const imageUrls = useAppSelector(currentLegendImagesSelector);
+
+  return (
+    <div
+      data-testid="legend-images"
+      className="flex items-center justify-between overflow-x-auto border-b border-b-divide px-6 py-8"
+    >
+      {imageUrls.map(imageUrl => (
+        <img
+          key={imageUrl}
+          src={`${BASE_MAP_IMAGES_URL}/minerva/${imageUrl}`}
+          alt={imageUrl}
+          className="h-[400px]"
+        />
+      ))}
+    </div>
+  );
+};
diff --git a/src/components/Map/Legend/LegendImages/index.ts b/src/components/Map/Legend/LegendImages/index.ts
new file mode 100644
index 00000000..e038a280
--- /dev/null
+++ b/src/components/Map/Legend/LegendImages/index.ts
@@ -0,0 +1 @@
+export { LegendImages } from './LegendImages.component';
diff --git a/src/components/Map/Legend/index.ts b/src/components/Map/Legend/index.ts
new file mode 100644
index 00000000..2237b30d
--- /dev/null
+++ b/src/components/Map/Legend/index.ts
@@ -0,0 +1 @@
+export { Legend } from './Legend.component';
diff --git a/src/components/Map/Map.component.tsx b/src/components/Map/Map.component.tsx
index 90492655..ea5a18e2 100644
--- a/src/components/Map/Map.component.tsx
+++ b/src/components/Map/Map.component.tsx
@@ -1,11 +1,16 @@
 import { Drawer } from '@/components/Map/Drawer';
+import { Legend } from '@/components/Map/Legend';
 import { MapAdditionalOptions } from './MapAdditionalOptions';
 import { MapViewer } from './MapViewer/MapViewer.component';
 
 export const Map = (): JSX.Element => (
-  <div className="relative z-0 h-screen w-full bg-black" data-testid="map-container">
+  <div
+    className="relative z-0 h-screen w-full overflow-hidden bg-black"
+    data-testid="map-container"
+  >
     <MapAdditionalOptions />
     <Drawer />
     <MapViewer />
+    <Legend />
   </div>
 );
diff --git a/src/components/Map/MapViewer/utils/config/overlaysLayer/useOlMapOverlaysLayer.ts b/src/components/Map/MapViewer/utils/config/overlaysLayer/useOlMapOverlaysLayer.ts
index 830dc498..048fecd7 100644
--- a/src/components/Map/MapViewer/utils/config/overlaysLayer/useOlMapOverlaysLayer.ts
+++ b/src/components/Map/MapViewer/utils/config/overlaysLayer/useOlMapOverlaysLayer.ts
@@ -1,15 +1,14 @@
-import Geometry from 'ol/geom/Geometry';
-import VectorLayer from 'ol/layer/Vector';
-import VectorSource from 'ol/source/Vector';
-import { useMemo } from 'react';
-import { usePointToProjection } from '@/utils/map/usePointToProjection';
 import { useTriColorLerp } from '@/hooks/useTriColorLerp';
 import { useAppSelector } from '@/redux/hooks/useAppSelector';
 import { overlayBioEntitiesForCurrentModelSelector } from '@/redux/overlayBioEntity/overlayBioEntity.selector';
-import { Feature } from 'ol';
+import { usePointToProjection } from '@/utils/map/usePointToProjection';
+import { Polygon } from 'ol/geom';
+import VectorLayer from 'ol/layer/Vector';
+import VectorSource from 'ol/source/Vector';
+import { useMemo } from 'react';
 import { getOverlayFeatures } from './getOverlayFeatures';
 
-export const useOlMapOverlaysLayer = (): VectorLayer<VectorSource<Feature<Geometry>>> => {
+export const useOlMapOverlaysLayer = (): VectorLayer<VectorSource<Polygon>> => {
   const pointToProjection = usePointToProjection();
   const { getHex3ColorGradientColorWithAlpha, defaultColorHex } = useTriColorLerp();
   const bioEntities = useAppSelector(overlayBioEntitiesForCurrentModelSelector);
diff --git a/src/components/Map/MapViewer/utils/config/reactionsLayer/useOlMapReactionsLayer.ts b/src/components/Map/MapViewer/utils/config/reactionsLayer/useOlMapReactionsLayer.ts
index 8b784426..3722f302 100644
--- a/src/components/Map/MapViewer/utils/config/reactionsLayer/useOlMapReactionsLayer.ts
+++ b/src/components/Map/MapViewer/utils/config/reactionsLayer/useOlMapReactionsLayer.ts
@@ -4,7 +4,7 @@ import { allReactionsSelectorOfCurrentMap } from '@/redux/reactions/reactions.se
 import { Reaction } from '@/types/models';
 import { LinePoint } from '@/types/reactions';
 import { usePointToProjection } from '@/utils/map/usePointToProjection';
-import Geometry from 'ol/geom/Geometry';
+import { Geometry } from 'ol/geom';
 import VectorLayer from 'ol/layer/Vector';
 import VectorSource from 'ol/source/Vector';
 import Fill from 'ol/style/Fill';
@@ -12,13 +12,12 @@ import Stroke from 'ol/style/Stroke';
 import Style from 'ol/style/Style';
 import { useMemo } from 'react';
 import { useSelector } from 'react-redux';
-import { Feature } from 'ol';
 import { getLineFeature } from './getLineFeature';
 
 const getReactionsLines = (reactions: Reaction[]): LinePoint[] =>
   reactions.map(({ lines }) => lines.map(({ start, end }): LinePoint => [start, end])).flat();
 
-export const useOlMapReactionsLayer = (): VectorLayer<VectorSource<Feature<Geometry>>> => {
+export const useOlMapReactionsLayer = (): VectorLayer<VectorSource<Geometry>> => {
   const pointToProjection = usePointToProjection();
   const reactions = useSelector(allReactionsSelectorOfCurrentMap);
   const reactionsLines = getReactionsLines(reactions);
diff --git a/src/redux/configuration/configuration.constants.ts b/src/redux/configuration/configuration.constants.ts
index 765ad32a..ebf80efa 100644
--- a/src/redux/configuration/configuration.constants.ts
+++ b/src/redux/configuration/configuration.constants.ts
@@ -3,3 +3,10 @@ export const MAX_COLOR_VAL_NAME_ID = 'MAX_COLOR_VAL';
 export const SIMPLE_COLOR_VAL_NAME_ID = 'SIMPLE_COLOR_VAL';
 export const NEUTRAL_COLOR_VAL_NAME_ID = 'NEUTRAL_COLOR_VAL';
 export const OVERLAY_OPACITY_NAME_ID = 'OVERLAY_OPACITY';
+
+export const LEGEND_FILE_NAMES_IDS = [
+  'LEGEND_FILE_1',
+  'LEGEND_FILE_2',
+  'LEGEND_FILE_3',
+  'LEGEND_FILE_4',
+];
diff --git a/src/redux/configuration/configuration.selectors.ts b/src/redux/configuration/configuration.selectors.ts
index 7a694a44..2831a55f 100644
--- a/src/redux/configuration/configuration.selectors.ts
+++ b/src/redux/configuration/configuration.selectors.ts
@@ -1,7 +1,8 @@
 import { createSelector } from '@reduxjs/toolkit';
-import { configurationAdapter } from './configuration.adapter';
 import { rootSelector } from '../root/root.selectors';
+import { configurationAdapter } from './configuration.adapter';
 import {
+  LEGEND_FILE_NAMES_IDS,
   MAX_COLOR_VAL_NAME_ID,
   MIN_COLOR_VAL_NAME_ID,
   NEUTRAL_COLOR_VAL_NAME_ID,
@@ -37,3 +38,9 @@ export const simpleColorValSelector = createSelector(
   configurationSelector,
   state => configurationAdapterSelectors.selectById(state, SIMPLE_COLOR_VAL_NAME_ID)?.value,
 );
+
+export const defaultLegendImagesSelector = createSelector(configurationSelector, state =>
+  LEGEND_FILE_NAMES_IDS.map(
+    legendNameId => configurationAdapterSelectors.selectById(state, legendNameId)?.value,
+  ).filter(legendImage => Boolean(legendImage)),
+);
diff --git a/src/redux/legend/legend.constants.ts b/src/redux/legend/legend.constants.ts
new file mode 100644
index 00000000..5719c74e
--- /dev/null
+++ b/src/redux/legend/legend.constants.ts
@@ -0,0 +1,7 @@
+import { LegendState } from './legend.types';
+
+export const LEGEND_INITIAL_STATE: LegendState = {
+  isOpen: false,
+  pluginLegend: {},
+  selectedPluginId: undefined,
+};
diff --git a/src/redux/legend/legend.mock.ts b/src/redux/legend/legend.mock.ts
new file mode 100644
index 00000000..6874d3a6
--- /dev/null
+++ b/src/redux/legend/legend.mock.ts
@@ -0,0 +1,7 @@
+import { LegendState } from './legend.types';
+
+export const LEGEND_INITIAL_STATE_MOCK: LegendState = {
+  isOpen: false,
+  pluginLegend: {},
+  selectedPluginId: undefined,
+};
diff --git a/src/redux/legend/legend.reducers.ts b/src/redux/legend/legend.reducers.ts
new file mode 100644
index 00000000..be1f0572
--- /dev/null
+++ b/src/redux/legend/legend.reducers.ts
@@ -0,0 +1,21 @@
+import { PayloadAction } from '@reduxjs/toolkit';
+import { LegendState, PluginId } from './legend.types';
+
+export const openLegendReducer = (state: LegendState): void => {
+  state.isOpen = true;
+};
+
+export const closeLegendReducer = (state: LegendState): void => {
+  state.isOpen = false;
+};
+
+export const selectLegendPluginIdReducer = (
+  state: LegendState,
+  action: PayloadAction<PluginId>,
+): void => {
+  state.selectedPluginId = action.payload;
+};
+
+export const selectDefaultLegendReducer = (state: LegendState): void => {
+  state.selectedPluginId = undefined;
+};
diff --git a/src/redux/legend/legend.selectors.ts b/src/redux/legend/legend.selectors.ts
new file mode 100644
index 00000000..fdf927f1
--- /dev/null
+++ b/src/redux/legend/legend.selectors.ts
@@ -0,0 +1,20 @@
+import { createSelector } from '@reduxjs/toolkit';
+import { defaultLegendImagesSelector } from '../configuration/configuration.selectors';
+import { rootSelector } from '../root/root.selectors';
+
+export const legendSelector = createSelector(rootSelector, state => state.legend);
+
+export const isLegendOpenSelector = createSelector(legendSelector, state => state.isOpen);
+
+// TODO: add filter for active plugins
+export const currentLegendImagesSelector = createSelector(
+  legendSelector,
+  defaultLegendImagesSelector,
+  ({ selectedPluginId, pluginLegend }, defaultImages) => {
+    if (selectedPluginId) {
+      return pluginLegend?.[selectedPluginId] || [];
+    }
+
+    return defaultImages;
+  },
+);
diff --git a/src/redux/legend/legend.slice.ts b/src/redux/legend/legend.slice.ts
new file mode 100644
index 00000000..fbb62f00
--- /dev/null
+++ b/src/redux/legend/legend.slice.ts
@@ -0,0 +1,16 @@
+import { createSlice } from '@reduxjs/toolkit';
+import { LEGEND_INITIAL_STATE } from './legend.constants';
+import { closeLegendReducer, openLegendReducer } from './legend.reducers';
+
+const legendSlice = createSlice({
+  name: 'legend',
+  initialState: LEGEND_INITIAL_STATE,
+  reducers: {
+    openLegend: openLegendReducer,
+    closeLegend: closeLegendReducer,
+  },
+});
+
+export const { openLegend, closeLegend } = legendSlice.actions;
+
+export default legendSlice.reducer;
diff --git a/src/redux/legend/legend.types.ts b/src/redux/legend/legend.types.ts
new file mode 100644
index 00000000..80314aec
--- /dev/null
+++ b/src/redux/legend/legend.types.ts
@@ -0,0 +1,8 @@
+export type PluginId = number;
+export type ImageUrl = string;
+
+export type LegendState = {
+  isOpen: boolean;
+  pluginLegend: Record<PluginId, ImageUrl[]>;
+  selectedPluginId: PluginId | undefined;
+};
diff --git a/src/redux/root/root.fixtures.ts b/src/redux/root/root.fixtures.ts
index e2c17d64..966bb1d5 100644
--- a/src/redux/root/root.fixtures.ts
+++ b/src/redux/root/root.fixtures.ts
@@ -1,11 +1,12 @@
 import { BACKGROUND_INITIAL_STATE_MOCK } from '../backgrounds/background.mock';
 import { BIOENTITY_INITIAL_STATE_MOCK } from '../bioEntity/bioEntity.mock';
 import { CHEMICALS_INITIAL_STATE_MOCK } from '../chemicals/chemicals.mock';
+import { CONFIGURATION_INITIAL_STATE } from '../configuration/configuration.adapter';
 import { CONTEXT_MENU_INITIAL_STATE } from '../contextMenu/contextMenu.constants';
 import { COOKIE_BANNER_INITIAL_STATE_MOCK } from '../cookieBanner/cookieBanner.mock';
-import { CONFIGURATION_INITIAL_STATE } from '../configuration/configuration.adapter';
 import { initialStateFixture as drawerInitialStateMock } from '../drawer/drawerFixture';
 import { DRUGS_INITIAL_STATE_MOCK } from '../drugs/drugs.mock';
+import { LEGEND_INITIAL_STATE_MOCK } from '../legend/legend.mock';
 import { initialMapStateFixture } from '../map/map.fixtures';
 import { MODAL_INITIAL_STATE_MOCK } from '../modal/modal.mock';
 import { MODELS_INITIAL_STATE_MOCK } from '../models/models.mock';
@@ -35,4 +36,5 @@ export const INITIAL_STORE_STATE_MOCK: RootState = {
   contextMenu: CONTEXT_MENU_INITIAL_STATE,
   cookieBanner: COOKIE_BANNER_INITIAL_STATE_MOCK,
   user: USER_INITIAL_STATE_MOCK,
+  legend: LEGEND_INITIAL_STATE_MOCK,
 };
diff --git a/src/redux/store.ts b/src/redux/store.ts
index c0016526..663bc180 100644
--- a/src/redux/store.ts
+++ b/src/redux/store.ts
@@ -1,20 +1,20 @@
 import backgroundsReducer from '@/redux/backgrounds/backgrounds.slice';
 import bioEntityReducer from '@/redux/bioEntity/bioEntity.slice';
 import chemicalsReducer from '@/redux/chemicals/chemicals.slice';
+import configurationReducer from '@/redux/configuration/configuration.slice';
+import contextMenuReducer from '@/redux/contextMenu/contextMenu.slice';
+import cookieBannerReducer from '@/redux/cookieBanner/cookieBanner.slice';
 import drawerReducer from '@/redux/drawer/drawer.slice';
 import drugsReducer from '@/redux/drugs/drugs.slice';
 import mapReducer from '@/redux/map/map.slice';
 import modalReducer from '@/redux/modal/modal.slice';
 import modelsReducer from '@/redux/models/models.slice';
+import overlayBioEntityReducer from '@/redux/overlayBioEntity/overlayBioEntity.slice';
 import overlaysReducer from '@/redux/overlays/overlays.slice';
 import projectReducer from '@/redux/project/project.slice';
 import reactionsReducer from '@/redux/reactions/reactions.slice';
-import contextMenuReducer from '@/redux/contextMenu/contextMenu.slice';
 import searchReducer from '@/redux/search/search.slice';
-import cookieBannerReducer from '@/redux/cookieBanner/cookieBanner.slice';
 import userReducer from '@/redux/user/user.slice';
-import configurationReducer from '@/redux/configuration/configuration.slice';
-import overlayBioEntityReducer from '@/redux/overlayBioEntity/overlayBioEntity.slice';
 import {
   AnyAction,
   ListenerEffectAPI,
@@ -22,6 +22,7 @@ import {
   TypedStartListening,
   configureStore,
 } from '@reduxjs/toolkit';
+import legendReducer from './legend/legend.slice';
 import { mapListenerMiddleware } from './map/middleware/map.middleware';
 
 export const reducers = {
@@ -42,6 +43,7 @@ export const reducers = {
   user: userReducer,
   configuration: configurationReducer,
   overlayBioEntity: overlayBioEntityReducer,
+  legend: legendReducer,
 };
 
 export const middlewares = [mapListenerMiddleware.middleware];
-- 
GitLab