From b356879edb8eb5a902f230be35af4f44c4765c8d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Adrian=20Or=C5=82=C3=B3w?= <adrian.orlow@fishbrain.com>
Date: Tue, 14 Nov 2023 02:26:54 +0100
Subject: [PATCH] feat(map): add interactive base layer

---
 package-lock.json                             |  21 ++
 package.json                                  |   3 +
 .../utils/config/useOlMapView.test.ts         |   8 +-
 .../listeners/onMapPositionChange.test.ts     |   8 +-
 .../utils/listeners/onMapSingleClick.test.ts  | 235 ++++++++++++++++++
 .../utils/listeners/onMapSingleClick.ts       |  76 ++++++
 .../utils/listeners/useOlMapListeners.test.ts |  22 +-
 .../utils/listeners/useOlMapListeners.ts      |  29 ++-
 .../Map/MapViewer/utils/useOlMap.ts           |   2 +-
 src/constants/map.ts                          |   3 +-
 src/constants/time.ts                         |   1 +
 src/models/elementSearchResult.ts             |   9 +
 .../fixtures/elementSearchResultFixture.ts    |  10 +
 src/models/fixtures/reactionFixture.ts        |  10 +
 src/models/mocks/elementSearchResultMock.ts   |  13 +
 src/models/reaction.ts                        |  27 ++
 src/redux/apiPath.test.ts                     |   2 +-
 src/redux/apiPath.ts                          |  20 +-
 src/redux/bioEntity/bioEntity.constants.ts    |   3 +
 src/redux/bioEntity/bioEntity.reducers.ts     |   9 +-
 src/redux/bioEntity/bioEntity.slice.ts        |  10 +-
 src/redux/bioEntity/bioEntity.thunks.test.ts  |  87 +++++--
 src/redux/bioEntity/bioEntity.thunks.ts       |  47 +++-
 src/redux/bioEntity/bioEntity.types.ts        |  12 +
 src/redux/models/models.selectors.ts          |   6 +
 src/redux/reactions/reactions.reducers.ts     |  17 ++
 src/redux/reactions/reactions.slice.ts        |  20 ++
 src/redux/reactions/reactions.thunks.test.ts  |  50 ++++
 src/redux/reactions/reactions.thunks.ts       |  23 ++
 src/redux/reactions/reactions.types.ts        |   4 +
 src/redux/store.ts                            |   2 +
 src/types/models.ts                           |   5 +
 src/utils/map/getPointOffset.test.ts          |   4 +-
 src/utils/map/getPointOffset.ts               |   2 +-
 src/utils/map/latLngToPoint.test.ts           |  16 +-
 src/utils/map/pointToLatLng.test.ts           |   4 +-
 src/utils/map/usePointToProjection.test.tsx   |   2 +-
 .../search/getElementsByCoordinates.test.ts   |  42 ++++
 src/utils/search/getElementsByCoordinates.ts  |  25 ++
 .../testing/getReduxStoreActionsListener.tsx  |  30 +++
 yarn.lock                                     |  56 ++++-
 41 files changed, 915 insertions(+), 60 deletions(-)
 create mode 100644 src/components/Map/MapViewer/utils/listeners/onMapSingleClick.test.ts
 create mode 100644 src/components/Map/MapViewer/utils/listeners/onMapSingleClick.ts
 create mode 100644 src/models/elementSearchResult.ts
 create mode 100644 src/models/fixtures/elementSearchResultFixture.ts
 create mode 100644 src/models/fixtures/reactionFixture.ts
 create mode 100644 src/models/mocks/elementSearchResultMock.ts
 create mode 100644 src/models/reaction.ts
 create mode 100644 src/redux/bioEntity/bioEntity.constants.ts
 create mode 100644 src/redux/reactions/reactions.reducers.ts
 create mode 100644 src/redux/reactions/reactions.slice.ts
 create mode 100644 src/redux/reactions/reactions.thunks.test.ts
 create mode 100644 src/redux/reactions/reactions.thunks.ts
 create mode 100644 src/redux/reactions/reactions.types.ts
 create mode 100644 src/utils/search/getElementsByCoordinates.test.ts
 create mode 100644 src/utils/search/getElementsByCoordinates.ts
 create mode 100644 src/utils/testing/getReduxStoreActionsListener.tsx

diff --git a/package-lock.json b/package-lock.json
index 79ab1fa8..6b0f180a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -27,6 +27,7 @@
         "react-accessible-accordion": "^5.0.0",
         "react-dom": "18.2.0",
         "react-redux": "^8.1.2",
+        "redux-thunk": "^2.4.2",
         "tailwind-merge": "^1.14.0",
         "tailwindcss": "3.3.3",
         "ts-deepmerge": "^6.2.0",
@@ -40,6 +41,7 @@
         "@testing-library/react": "^14.0.0",
         "@types/jest": "^29.5.5",
         "@types/react-redux": "^7.1.26",
+        "@types/redux-mock-store": "^1.0.6",
         "@typescript-eslint/eslint-plugin": "^6.7.0",
         "@typescript-eslint/parser": "^6.7.0",
         "axios-mock-adapter": "^1.22.0",
@@ -68,6 +70,7 @@
         "prettier": "^3.0.3",
         "prettier-2": "npm:prettier@^2",
         "prettier-plugin-tailwindcss": "^0.5.6",
+        "redux-mock-store": "^1.5.4",
         "typescript": "^5.2.2",
         "zod-fixture": "^2.5.0"
       }
@@ -2311,6 +2314,15 @@
         "redux": "^4.0.0"
       }
     },
+    "node_modules/@types/redux-mock-store": {
+      "version": "1.0.6",
+      "resolved": "https://registry.npmjs.org/@types/redux-mock-store/-/redux-mock-store-1.0.6.tgz",
+      "integrity": "sha512-eg5RDfhJTXuoJjOMyXiJbaDb1B8tfTaJixscmu+jOusj6adGC0Krntz09Tf4gJgXeCqCrM5bBMd+B7ez0izcAQ==",
+      "dev": true,
+      "dependencies": {
+        "redux": "^4.0.5"
+      }
+    },
     "node_modules/@types/scheduler": {
       "version": "0.16.4",
       "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.4.tgz",
@@ -11175,6 +11187,15 @@
         "@babel/runtime": "^7.9.2"
       }
     },
+    "node_modules/redux-mock-store": {
+      "version": "1.5.4",
+      "resolved": "https://registry.npmjs.org/redux-mock-store/-/redux-mock-store-1.5.4.tgz",
+      "integrity": "sha512-xmcA0O/tjCLXhh9Fuiq6pMrJCwFRaouA8436zcikdIpYWWCjU76CRk+i2bHx8EeiSiMGnB85/lZdU3wIJVXHTA==",
+      "dev": true,
+      "dependencies": {
+        "lodash.isplainobject": "^4.0.6"
+      }
+    },
     "node_modules/redux-thunk": {
       "version": "2.4.2",
       "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-2.4.2.tgz",
diff --git a/package.json b/package.json
index cc259882..c5b84e0b 100644
--- a/package.json
+++ b/package.json
@@ -41,6 +41,7 @@
     "react-accessible-accordion": "^5.0.0",
     "react-dom": "18.2.0",
     "react-redux": "^8.1.2",
+    "redux-thunk": "^2.4.2",
     "tailwind-merge": "^1.14.0",
     "tailwindcss": "3.3.3",
     "ts-deepmerge": "^6.2.0",
@@ -54,6 +55,7 @@
     "@testing-library/react": "^14.0.0",
     "@types/jest": "^29.5.5",
     "@types/react-redux": "^7.1.26",
+    "@types/redux-mock-store": "^1.0.6",
     "@typescript-eslint/eslint-plugin": "^6.7.0",
     "@typescript-eslint/parser": "^6.7.0",
     "axios-mock-adapter": "^1.22.0",
@@ -82,6 +84,7 @@
     "prettier": "^3.0.3",
     "prettier-2": "npm:prettier@^2",
     "prettier-plugin-tailwindcss": "^0.5.6",
+    "redux-mock-store": "^1.5.4",
     "typescript": "^5.2.2",
     "zod-fixture": "^2.5.0"
   },
diff --git a/src/components/Map/MapViewer/utils/config/useOlMapView.test.ts b/src/components/Map/MapViewer/utils/config/useOlMapView.test.ts
index 6ff16dd9..3f87400f 100644
--- a/src/components/Map/MapViewer/utils/config/useOlMapView.test.ts
+++ b/src/components/Map/MapViewer/utils/config/useOlMapView.test.ts
@@ -73,12 +73,12 @@ describe('useOlMapView - util', () => {
           },
           position: {
             initial: {
-              x: 256,
-              y: 256,
+              x: 128,
+              y: 128,
             },
             last: {
-              x: 256,
-              y: 256,
+              x: 128,
+              y: 128,
             },
           },
         },
diff --git a/src/components/Map/MapViewer/utils/listeners/onMapPositionChange.test.ts b/src/components/Map/MapViewer/utils/listeners/onMapPositionChange.test.ts
index c7a7ed78..3ab05530 100644
--- a/src/components/Map/MapViewer/utils/listeners/onMapPositionChange.test.ts
+++ b/src/components/Map/MapViewer/utils/listeners/onMapPositionChange.test.ts
@@ -30,8 +30,8 @@ describe('onMapPositionChange - util', () => {
         zoom: 6,
       },
       {
-        x: 9177,
-        y: 8641,
+        x: 4589,
+        y: 4320,
         z: 6,
       },
     ],
@@ -48,8 +48,8 @@ describe('onMapPositionChange - util', () => {
         zoom: 6.68620779943448,
       },
       {
-        x: 2957,
-        y: 1163,
+        x: 1479,
+        y: 581,
         z: 7,
       },
     ],
diff --git a/src/components/Map/MapViewer/utils/listeners/onMapSingleClick.test.ts b/src/components/Map/MapViewer/utils/listeners/onMapSingleClick.test.ts
new file mode 100644
index 00000000..184c604e
--- /dev/null
+++ b/src/components/Map/MapViewer/utils/listeners/onMapSingleClick.test.ts
@@ -0,0 +1,235 @@
+/* eslint-disable no-magic-numbers */
+import { SIZE_OF_EMPTY_ARRAY } from '@/constants/common';
+import { bioEntityResponseFixture } from '@/models/fixtures/bioEntityContentsFixture';
+import { reactionsFixture } from '@/models/fixtures/reactionFixture';
+import {
+  ELEMENT_SEARCH_RESULT_MOCK_ALIAS,
+  ELEMENT_SEARCH_RESULT_MOCK_REACTION,
+} from '@/models/mocks/elementSearchResultMock';
+import { apiPath } from '@/redux/apiPath';
+import { mockNetworkNewAPIResponse, mockNetworkResponse } from '@/utils/mockNetworkResponse';
+import { getReduxStoreActionsListener } from '@/utils/testing/getReduxStoreActionsListener';
+import { waitFor } from '@testing-library/react';
+import { HttpStatusCode } from 'axios';
+import { MapBrowserEvent } from 'ol';
+import * as onMapSingleClickUtils from './onMapSingleClick';
+import { handleAliasResults, handleReactionResults } from './onMapSingleClick';
+
+const { onMapSingleClick } = onMapSingleClickUtils;
+const mockedAxiosOldClient = mockNetworkResponse();
+const mockedAxiosNewClient = mockNetworkNewAPIResponse();
+
+const getEvent = (coordinate: MapBrowserEvent<UIEvent>['coordinate']): MapBrowserEvent<UIEvent> =>
+  ({
+    coordinate,
+  }) as unknown as MapBrowserEvent<UIEvent>;
+
+describe('onMapSingleClick - util', () => {
+  beforeEach(() => {
+    jest.clearAllMocks();
+    jest.resetAllMocks();
+  });
+
+  describe('when searchResults are undefined', () => {
+    const { store } = getReduxStoreActionsListener();
+    const { dispatch } = store;
+    const modelId = 1000;
+    const mapSize = {
+      width: 90,
+      height: 90,
+      tileSize: 256,
+      minZoom: 2,
+      maxZoom: 9,
+    };
+    const handler = onMapSingleClick(mapSize, modelId, dispatch);
+    const coordinate = [90, 90];
+    const point = { x: 180.0008084837557, y: 179.99919151624428 };
+    const event = getEvent(coordinate);
+
+    mockedAxiosOldClient
+      .onGet(apiPath.getSingleBioEntityContentsStringWithCoordinates(point, modelId))
+      .reply(HttpStatusCode.Ok, undefined);
+
+    it('does not fire search result action', async () => {
+      await handler(event);
+      const actions = store.getActions();
+      expect(actions.length).toBe(SIZE_OF_EMPTY_ARRAY);
+    });
+  });
+
+  describe('when searchResults are empty', () => {
+    const { store } = getReduxStoreActionsListener();
+    const { dispatch } = store;
+
+    const modelId = 1000;
+    const mapSize = {
+      width: 180,
+      height: 180,
+      tileSize: 256,
+      minZoom: 2,
+      maxZoom: 9,
+    };
+
+    const handler = onMapSingleClick(mapSize, modelId, dispatch);
+    const coordinate = [180, 180];
+    const point = { x: 360.0032339350228, y: 359.9967660649771 };
+    const event = getEvent(coordinate);
+
+    mockedAxiosOldClient
+      .onGet(apiPath.getSingleBioEntityContentsStringWithCoordinates(point, modelId))
+      .reply(HttpStatusCode.Ok, []);
+
+    it('does not fire search result action', async () => {
+      await handler(event);
+      const actions = store.getActions();
+      expect(actions.length).toBe(SIZE_OF_EMPTY_ARRAY);
+    });
+  });
+
+  describe('when searchResults are valid', () => {
+    describe('when results type is ALIAS', () => {
+      const { store } = getReduxStoreActionsListener();
+      const { dispatch } = store;
+      const { modelId } = ELEMENT_SEARCH_RESULT_MOCK_ALIAS;
+      const mapSize = {
+        width: 270,
+        height: 270,
+        tileSize: 256,
+        minZoom: 2,
+        maxZoom: 9,
+      };
+      const coordinate = [270, 270];
+      const point = { x: 540.0072763538013, y: 539.9927236461986 };
+      const event = getEvent(coordinate);
+
+      mockedAxiosOldClient
+        .onGet(apiPath.getSingleBioEntityContentsStringWithCoordinates(point, modelId))
+        .reply(HttpStatusCode.Ok, [ELEMENT_SEARCH_RESULT_MOCK_ALIAS]);
+
+      beforeAll(async () => {
+        const handler = onMapSingleClick(mapSize, modelId, dispatch);
+        await handler(event);
+      });
+
+      it('does fire search result action - getBioEntity', async () => {
+        const actions = store.getActions();
+        expect(actions.length).toBeGreaterThan(SIZE_OF_EMPTY_ARRAY);
+        expect(actions[0].type).toEqual('project/getBioEntityContents/pending');
+      });
+
+      it('does NOT fire search result action - getReactionsByIds', async () => {
+        const actions = store.getActions();
+        expect(actions.length).toBeGreaterThan(SIZE_OF_EMPTY_ARRAY);
+        expect(actions[0].type).not.toEqual('reactions/getByIds/pending');
+      });
+    });
+
+    describe('when results type is REACTION', () => {
+      const { store } = getReduxStoreActionsListener();
+      const { dispatch } = store;
+      const { modelId } = ELEMENT_SEARCH_RESULT_MOCK_REACTION;
+      const mapSize = {
+        width: 0,
+        height: 0,
+        tileSize: 256,
+        minZoom: 2,
+        maxZoom: 9,
+      };
+      const coordinate = [0, 0];
+      const point = {
+        x: 0,
+        y: 0,
+      };
+      const event = getEvent(coordinate);
+
+      mockedAxiosOldClient
+        .onGet(apiPath.getSingleBioEntityContentsStringWithCoordinates(point, modelId))
+        .reply(HttpStatusCode.Ok, [ELEMENT_SEARCH_RESULT_MOCK_REACTION]);
+
+      beforeAll(async () => {
+        const handler = onMapSingleClick(mapSize, modelId, dispatch);
+        await handler(event);
+      });
+
+      it('does NOT fire search result action - getBioEntity', async () => {
+        const actions = store.getActions();
+        expect(actions.length).toBeGreaterThan(SIZE_OF_EMPTY_ARRAY);
+        expect(actions[0].type).not.toEqual('project/getBioEntityContents/pending');
+      });
+
+      it('does fire search result action - getReactionsByIds', async () => {
+        const actions = store.getActions();
+        expect(actions.length).toBeGreaterThan(SIZE_OF_EMPTY_ARRAY);
+        expect(actions[0].type).toEqual('reactions/getByIds/pending');
+      });
+    });
+  });
+});
+
+describe('handleAliasResults - util', () => {
+  const { store } = getReduxStoreActionsListener();
+  const { dispatch } = store;
+
+  mockedAxiosOldClient
+    .onGet(
+      apiPath.getBioEntityContentsStringWithQuery(ELEMENT_SEARCH_RESULT_MOCK_ALIAS.id.toString(), {
+        perfectMatch: true,
+      }),
+    )
+    .reply(HttpStatusCode.Ok, bioEntityResponseFixture);
+
+  beforeAll(async () => {
+    handleAliasResults(dispatch)(ELEMENT_SEARCH_RESULT_MOCK_ALIAS);
+  });
+
+  it('should run getBioEntityAction', async () => {
+    await waitFor(() => {
+      const actions = store.getActions();
+      expect(actions.length).toBeGreaterThan(SIZE_OF_EMPTY_ARRAY);
+      expect(actions[0].type).toEqual('project/getBioEntityContents/pending');
+    });
+  });
+});
+
+describe('handleReactionResults - util', () => {
+  const { store } = getReduxStoreActionsListener();
+  const { dispatch } = store;
+
+  mockedAxiosNewClient
+    .onGet(
+      apiPath.getBioEntityContentsStringWithQuery(
+        ELEMENT_SEARCH_RESULT_MOCK_REACTION.id.toString(),
+        {
+          perfectMatch: true,
+        },
+      ),
+    )
+    .reply(HttpStatusCode.Ok, bioEntityResponseFixture);
+
+  mockedAxiosOldClient
+    .onGet(apiPath.getReactionsWithIds([ELEMENT_SEARCH_RESULT_MOCK_REACTION.id]))
+    .reply(HttpStatusCode.Ok, reactionsFixture);
+
+  beforeAll(async () => {
+    handleReactionResults(dispatch)(ELEMENT_SEARCH_RESULT_MOCK_REACTION);
+  });
+
+  it('should run getReactionsByIds as first action', () => {
+    const actions = store.getActions();
+    expect(actions.length).toBeGreaterThan(SIZE_OF_EMPTY_ARRAY);
+    expect(actions[0].type).toEqual('reactions/getByIds/pending');
+    expect(actions[1].type).toEqual('reactions/getByIds/fulfilled');
+  });
+
+  it('should run setBioEntityContent to empty array as second action', () => {
+    const actions = store.getActions();
+    expect(actions.length).toBeGreaterThan(SIZE_OF_EMPTY_ARRAY);
+    expect(actions[2].type).toEqual('bioEntityContents/setBioEntityContent');
+  });
+
+  it('should run getBioEntity as third action', () => {
+    const actions = store.getActions();
+    expect(actions.length).toBeGreaterThan(SIZE_OF_EMPTY_ARRAY);
+    expect(actions[3].type).toEqual('project/getBioEntityContents/pending');
+  });
+});
diff --git a/src/components/Map/MapViewer/utils/listeners/onMapSingleClick.ts b/src/components/Map/MapViewer/utils/listeners/onMapSingleClick.ts
new file mode 100644
index 00000000..acf6e516
--- /dev/null
+++ b/src/components/Map/MapViewer/utils/listeners/onMapSingleClick.ts
@@ -0,0 +1,76 @@
+import { SIZE_OF_EMPTY_ARRAY } from '@/constants/common';
+import { setBioEntityContent } from '@/redux/bioEntity/bioEntity.slice';
+import { getBioEntity } from '@/redux/bioEntity/bioEntity.thunks';
+import { MapSize } from '@/redux/map/map.types';
+import { AppDispatch } from '@/redux/store';
+import { ElementSearchResult, Reaction } from '@/types/models';
+import { latLngToPoint } from '@/utils/map/latLngToPoint';
+import { getElementsByPoint } from '@/utils/search/getElementsByCoordinates';
+import { PayloadAction } from '@reduxjs/toolkit';
+import { MapBrowserEvent } from 'ol';
+import { toLonLat } from 'ol/proj';
+import { getReactionsByIds } from '../../../../../redux/reactions/reactions.thunks';
+
+const FIRST = 0;
+
+/* prettier-ignore */
+export const handleAliasResults =
+  (dispatch: AppDispatch) =>
+    async ({ id }: ElementSearchResult): Promise<void> => {
+      dispatch(
+        getBioEntity({
+          query: id.toString(),
+          params: {
+            perfectMatch: true,
+          },
+        }),
+      );
+    };
+
+/* prettier-ignore */
+export const handleReactionResults =
+  (dispatch: AppDispatch) =>
+    async ({ id }: ElementSearchResult): Promise<void> => {
+      const data = await dispatch(getReactionsByIds([id])) as PayloadAction<Reaction[] | undefined>;
+      const payload = data?.payload;
+      if (!data || !payload || payload.length === SIZE_OF_EMPTY_ARRAY) {
+        return;
+      }
+
+      const { products, reactants } = payload[FIRST];
+      const productsIds = products.map(p => p.aliasId);
+      const reactantsIds = reactants.map(r => r.aliasId);
+      const bioEntitiesIds = [...productsIds, ...reactantsIds].map(identifier => String(identifier));
+
+      dispatch(setBioEntityContent([]));
+      await dispatch(
+        getBioEntity({
+          query: bioEntitiesIds,
+          params: {
+            perfectMatch: true,
+          },
+        }),
+      );
+    };
+
+/* prettier-ignore */
+export const onMapSingleClick =
+  (mapSize: MapSize, modelId: number, dispatch: AppDispatch) =>
+    async (e: MapBrowserEvent<UIEvent>): Promise<void> => {
+      const [lng, lat] = toLonLat(e.coordinate);
+      const point = latLngToPoint([lat, lng], mapSize);
+      const searchResults = await getElementsByPoint({ point, currentModelId: modelId });
+
+      if (!searchResults || searchResults.length === SIZE_OF_EMPTY_ARRAY) {
+        return;
+      }
+
+      const closestSearchResult = searchResults[FIRST];
+      const { type } = closestSearchResult;
+      const action = {
+        'ALIAS': handleAliasResults,
+        'REACTION': handleReactionResults,
+      }[type];
+
+      await action(dispatch)(closestSearchResult);
+    };
diff --git a/src/components/Map/MapViewer/utils/listeners/useOlMapListeners.test.ts b/src/components/Map/MapViewer/utils/listeners/useOlMapListeners.test.ts
index 20d0401a..0f409f88 100644
--- a/src/components/Map/MapViewer/utils/listeners/useOlMapListeners.test.ts
+++ b/src/components/Map/MapViewer/utils/listeners/useOlMapListeners.test.ts
@@ -4,6 +4,7 @@ import { getReduxWrapperUsingSliceReducer } from '@/utils/testing/getReduxWrappe
 import { renderHook } from '@testing-library/react';
 import { View } from 'ol';
 import * as positionListener from './onMapPositionChange';
+import * as singleClickListener from './onMapSingleClick';
 import { useOlMapListeners } from './useOlMapListeners';
 
 jest.mock('./onMapPositionChange', () => ({
@@ -11,6 +12,11 @@ jest.mock('./onMapPositionChange', () => ({
   onMapPositionChange: jest.fn(),
 }));
 
+jest.mock('./onMapSingleClick', () => ({
+  __esModule: true,
+  onMapSingleClick: jest.fn(),
+}));
+
 jest.mock('use-debounce', () => {
   return {
     useDebounce: () => {},
@@ -27,13 +33,25 @@ describe('useOlMapListeners - util', () => {
 
   describe('on change:center view event', () => {
     it('should run onMapPositionChange event', () => {
-      const view = new View();
       const CALLED_ONCE = 1;
+      const view = new View();
 
-      renderHook(() => useOlMapListeners({ view }), { wrapper: Wrapper });
+      renderHook(() => useOlMapListeners({ view, mapInstance: undefined }), { wrapper: Wrapper });
       view.dispatchEvent('change:center');
 
       expect(positionListener.onMapPositionChange).toBeCalledTimes(CALLED_ONCE);
     });
   });
+
+  describe('on singleclick view event', () => {
+    it('should run onMapPositionChange event', () => {
+      const CALLED_ONCE = 1;
+      const view = new View();
+
+      renderHook(() => useOlMapListeners({ view, mapInstance: undefined }), { wrapper: Wrapper });
+      view.dispatchEvent('singleclick');
+
+      expect(singleClickListener.onMapSingleClick).toBeCalledTimes(CALLED_ONCE);
+    });
+  });
 });
diff --git a/src/components/Map/MapViewer/utils/listeners/useOlMapListeners.ts b/src/components/Map/MapViewer/utils/listeners/useOlMapListeners.ts
index 8bea0c90..5cad414f 100644
--- a/src/components/Map/MapViewer/utils/listeners/useOlMapListeners.ts
+++ b/src/components/Map/MapViewer/utils/listeners/useOlMapListeners.ts
@@ -1,18 +1,24 @@
 import { OPTIONS } from '@/constants/map';
 import { useAppDispatch } from '@/redux/hooks/useAppDispatch';
 import { mapDataSizeSelector } from '@/redux/map/map.selectors';
+import { currentModelIdSelector } from '@/redux/models/models.selectors';
 import { View } from 'ol';
+import { unByKey } from 'ol/Observable';
 import { useEffect } from 'react';
 import { useSelector } from 'react-redux';
 import { useDebouncedCallback } from 'use-debounce';
+import { MapInstance } from '../../MapViewer.types';
 import { onMapPositionChange } from './onMapPositionChange';
+import { onMapSingleClick } from './onMapSingleClick';
 
 interface UseOlMapListenersInput {
   view: View;
+  mapInstance: MapInstance;
 }
 
-export const useOlMapListeners = ({ view }: UseOlMapListenersInput): void => {
+export const useOlMapListeners = ({ view, mapInstance }: UseOlMapListenersInput): void => {
   const mapSize = useSelector(mapDataSizeSelector);
+  const modelId = useSelector(currentModelIdSelector);
   const dispatch = useAppDispatch();
 
   const handleChangeCenter = useDebouncedCallback(
@@ -21,7 +27,26 @@ export const useOlMapListeners = ({ view }: UseOlMapListenersInput): void => {
     { leading: false },
   );
 
+  const handleMapSingleClick = useDebouncedCallback(
+    onMapSingleClick(mapSize, modelId, dispatch),
+    OPTIONS.clickPersistTime,
+    { leading: false },
+  );
+
   useEffect(() => {
-    view.on('change:center', handleChangeCenter);
+    const key = view.on('change:center', handleChangeCenter);
+
+    return () => unByKey(key);
   }, [view, handleChangeCenter]);
+
+  useEffect(() => {
+    if (!mapInstance) {
+      return;
+    }
+
+    const key = mapInstance.on('singleclick', handleMapSingleClick);
+
+    // eslint-disable-next-line consistent-return
+    return () => unByKey(key);
+  }, [mapInstance, handleMapSingleClick]);
 };
diff --git a/src/components/Map/MapViewer/utils/useOlMap.ts b/src/components/Map/MapViewer/utils/useOlMap.ts
index a80407e3..a7ffb398 100644
--- a/src/components/Map/MapViewer/utils/useOlMap.ts
+++ b/src/components/Map/MapViewer/utils/useOlMap.ts
@@ -20,7 +20,7 @@ export const useOlMap: UseOlMap = ({ target } = {}) => {
   const [mapInstance, setMapInstance] = useState<MapInstance>(undefined);
   const view = useOlMapView({ mapInstance });
   useOlMapLayers({ mapInstance });
-  useOlMapListeners({ view });
+  useOlMapListeners({ view, mapInstance });
 
   useEffect(() => {
     // checking if innerHTML is empty due to possibility of target element cloning by openlayers map instance
diff --git a/src/constants/map.ts b/src/constants/map.ts
index 765b30b0..47d6c6ce 100644
--- a/src/constants/map.ts
+++ b/src/constants/map.ts
@@ -1,6 +1,6 @@
 import { LatLng, Point } from '@/types/map';
 import { z } from 'zod';
-import { HALF_SECOND_MS } from './time';
+import { HALF_SECOND_MS, ONE_HUNDRED_MS } from './time';
 
 export const DEFAULT_TILE_SIZE = 256;
 export const DEFAULT_MIN_ZOOM = 2;
@@ -21,6 +21,7 @@ export const OPTIONS = {
   showFullExtent: false,
   wrapXInTileLayer: false,
   queryPersistTime: HALF_SECOND_MS,
+  clickPersistTime: ONE_HUNDRED_MS,
 };
 
 export const VALID_MAP_SIZE_SCHEMA = z.object({
diff --git a/src/constants/time.ts b/src/constants/time.ts
index 0cb37b13..396210de 100644
--- a/src/constants/time.ts
+++ b/src/constants/time.ts
@@ -1 +1,2 @@
+export const ONE_HUNDRED_MS = 100;
 export const HALF_SECOND_MS = 500;
diff --git a/src/models/elementSearchResult.ts b/src/models/elementSearchResult.ts
new file mode 100644
index 00000000..87d9acc4
--- /dev/null
+++ b/src/models/elementSearchResult.ts
@@ -0,0 +1,9 @@
+import { z } from 'zod';
+
+export const elementSearchResultType = z.union([z.literal('ALIAS'), z.literal('REACTION')]);
+
+export const elementSearchResult = z.object({
+  id: z.number(),
+  modelId: z.number(),
+  type: elementSearchResultType,
+});
diff --git a/src/models/fixtures/elementSearchResultFixture.ts b/src/models/fixtures/elementSearchResultFixture.ts
new file mode 100644
index 00000000..33ae2c13
--- /dev/null
+++ b/src/models/fixtures/elementSearchResultFixture.ts
@@ -0,0 +1,10 @@
+import { ZOD_SEED } from '@/constants';
+import { z } from 'zod';
+// eslint-disable-next-line import/no-extraneous-dependencies
+import { createFixture } from 'zod-fixture';
+import { elementSearchResult } from '../elementSearchResult';
+
+export const elementSearchResultFixture = createFixture(z.array(elementSearchResult), {
+  seed: ZOD_SEED,
+  array: { min: 2, max: 2 },
+});
diff --git a/src/models/fixtures/reactionFixture.ts b/src/models/fixtures/reactionFixture.ts
new file mode 100644
index 00000000..703e8be5
--- /dev/null
+++ b/src/models/fixtures/reactionFixture.ts
@@ -0,0 +1,10 @@
+import { ZOD_SEED } from '@/constants';
+import { z } from 'zod';
+// eslint-disable-next-line import/no-extraneous-dependencies
+import { createFixture } from 'zod-fixture';
+import { reactionSchema } from '../reaction';
+
+export const reactionsFixture = createFixture(z.array(reactionSchema), {
+  seed: ZOD_SEED,
+  array: { min: 2, max: 2 },
+});
diff --git a/src/models/mocks/elementSearchResultMock.ts b/src/models/mocks/elementSearchResultMock.ts
new file mode 100644
index 00000000..9bf978eb
--- /dev/null
+++ b/src/models/mocks/elementSearchResultMock.ts
@@ -0,0 +1,13 @@
+import { ElementSearchResult } from '@/types/models';
+
+export const ELEMENT_SEARCH_RESULT_MOCK_ALIAS: ElementSearchResult = {
+  id: 4,
+  modelId: 1000,
+  type: 'ALIAS',
+};
+
+export const ELEMENT_SEARCH_RESULT_MOCK_REACTION: ElementSearchResult = {
+  id: 5,
+  modelId: 1000,
+  type: 'REACTION',
+};
diff --git a/src/models/reaction.ts b/src/models/reaction.ts
new file mode 100644
index 00000000..7b2dd7b1
--- /dev/null
+++ b/src/models/reaction.ts
@@ -0,0 +1,27 @@
+import { z } from 'zod';
+import { positionSchema } from './positionSchema';
+import { productsSchema } from './products';
+import { referenceSchema } from './referenceSchema';
+
+export const reactionSchema = z.object({
+  centerPoint: positionSchema,
+  hierarchyVisibilityLevel: z.string(),
+  id: z.number(),
+  kineticLaw: z.null(),
+  lines: z.array(
+    z.object({
+      start: positionSchema,
+      end: positionSchema,
+      type: z.string(),
+    }),
+  ),
+  modelId: z.number(),
+  modifiers: z.array(z.unknown()),
+  name: z.string(),
+  notes: z.string(),
+  products: z.array(productsSchema),
+  reactants: z.array(productsSchema),
+  reactionId: z.string(),
+  references: z.array(referenceSchema),
+  type: z.string(),
+});
diff --git a/src/redux/apiPath.test.ts b/src/redux/apiPath.test.ts
index d23bffc0..56612a14 100644
--- a/src/redux/apiPath.test.ts
+++ b/src/redux/apiPath.test.ts
@@ -16,7 +16,7 @@ describe('api path', () => {
 
   it('should return url string for bio entity content', () => {
     expect(apiPath.getBioEntityContentsStringWithQuery('park7')).toBe(
-      `projects/${PROJECT_ID}/models/*/bioEntities/:search?query=park7&size=1000`,
+      `projects/${PROJECT_ID}/models/*/bioEntities/:search?query=park7&size=1000&perfectMatch=false`,
     );
   });
 
diff --git a/src/redux/apiPath.ts b/src/redux/apiPath.ts
index 776993ed..78fe8788 100644
--- a/src/redux/apiPath.ts
+++ b/src/redux/apiPath.ts
@@ -1,8 +1,24 @@
 import { PROJECT_ID } from '@/constants';
+import { Point } from '@/types/map';
+import { DEFAULT_BIOENTITY_PARAMS } from './bioEntity/bioEntity.constants';
 
 export const apiPath = {
-  getBioEntityContentsStringWithQuery: (searchQuery: string): string =>
-    `projects/${PROJECT_ID}/models/*/bioEntities/:search?query=${searchQuery}&size=1000`,
+  getBioEntityContentsStringWithQuery: (
+    searchQuery: string,
+    { perfectMatch }: { perfectMatch: boolean } = DEFAULT_BIOENTITY_PARAMS,
+  ): string =>
+    `projects/${PROJECT_ID}/models/*/bioEntities/:search?query=${searchQuery}&size=1000&perfectMatch=${String(
+      perfectMatch,
+    )}`,
+  getSingleBioEntityContentsStringWithCoordinates: (
+    { x, y }: Point,
+    currentModelId: number,
+  ): string => {
+    const coordinates = [x, y].join();
+    return `projects/${PROJECT_ID}/models/${currentModelId}/bioEntities:search/?coordinates=${coordinates}&count=1`;
+  },
+  getReactionsWithIds: (ids: number[]): string =>
+    `projects/${PROJECT_ID}/models/*/bioEntities/reactions/?id=${ids.join(',')}&size=1000`,
   getDrugsStringWithQuery: (searchQuery: string): string =>
     `projects/${PROJECT_ID}/drugs:search?query=${searchQuery}`,
   getMirnasStringWithQuery: (searchQuery: string): string =>
diff --git a/src/redux/bioEntity/bioEntity.constants.ts b/src/redux/bioEntity/bioEntity.constants.ts
new file mode 100644
index 00000000..3c109ae8
--- /dev/null
+++ b/src/redux/bioEntity/bioEntity.constants.ts
@@ -0,0 +1,3 @@
+export const DEFAULT_BIOENTITY_PARAMS = {
+  perfectMatch: false,
+};
diff --git a/src/redux/bioEntity/bioEntity.reducers.ts b/src/redux/bioEntity/bioEntity.reducers.ts
index 48ce02f4..1c4f0312 100644
--- a/src/redux/bioEntity/bioEntity.reducers.ts
+++ b/src/redux/bioEntity/bioEntity.reducers.ts
@@ -1,6 +1,13 @@
 import { ActionReducerMapBuilder } from '@reduxjs/toolkit';
-import { BioEntityContentsState } from './bioEntity.types';
 import { getBioEntity } from './bioEntity.thunks';
+import { BioEntityContentsState, SetBioEntityContentAction } from './bioEntity.types';
+
+export const setBioEntityContentReducer = (
+  state: BioEntityContentsState,
+  action: SetBioEntityContentAction,
+): void => {
+  state.data = action.payload;
+};
 
 export const getBioEntityContentsReducer = (
   builder: ActionReducerMapBuilder<BioEntityContentsState>,
diff --git a/src/redux/bioEntity/bioEntity.slice.ts b/src/redux/bioEntity/bioEntity.slice.ts
index 1400797a..b82e8013 100644
--- a/src/redux/bioEntity/bioEntity.slice.ts
+++ b/src/redux/bioEntity/bioEntity.slice.ts
@@ -1,6 +1,6 @@
-import { createSlice } from '@reduxjs/toolkit';
 import { BioEntityContentsState } from '@/redux/bioEntity/bioEntity.types';
-import { getBioEntityContentsReducer } from './bioEntity.reducers';
+import { createSlice } from '@reduxjs/toolkit';
+import { getBioEntityContentsReducer, setBioEntityContentReducer } from './bioEntity.reducers';
 
 const initialState: BioEntityContentsState = {
   data: [],
@@ -11,10 +11,14 @@ const initialState: BioEntityContentsState = {
 export const bioEntityContentsSlice = createSlice({
   name: 'bioEntityContents',
   initialState,
-  reducers: {},
+  reducers: {
+    setBioEntityContent: setBioEntityContentReducer,
+  },
   extraReducers: builder => {
     getBioEntityContentsReducer(builder);
   },
 });
 
+export const { setBioEntityContent } = bioEntityContentsSlice.actions;
+
 export default bioEntityContentsSlice.reducer;
diff --git a/src/redux/bioEntity/bioEntity.thunks.test.ts b/src/redux/bioEntity/bioEntity.thunks.test.ts
index b5b51d28..c8d5debb 100644
--- a/src/redux/bioEntity/bioEntity.thunks.test.ts
+++ b/src/redux/bioEntity/bioEntity.thunks.test.ts
@@ -1,17 +1,18 @@
 import { bioEntityResponseFixture } from '@/models/fixtures/bioEntityContentsFixture';
-import { mockNetworkNewAPIResponse } from '@/utils/mockNetworkResponse';
+import { apiPath } from '@/redux/apiPath';
 import {
   ToolkitStoreWithSingleSlice,
   createStoreInstanceUsingSliceReducer,
 } from '@/utils/createStoreInstanceUsingSliceReducer';
+import { mockNetworkNewAPIResponse } from '@/utils/mockNetworkResponse';
 import { HttpStatusCode } from 'axios';
-import { apiPath } from '@/redux/apiPath';
-import { getBioEntity } from './bioEntity.thunks';
 import contentsReducer from './bioEntity.slice';
+import { getBioEntity } from './bioEntity.thunks';
 import { BioEntityContentsState } from './bioEntity.types';
 
 const mockedAxiosClient = mockNetworkNewAPIResponse();
 const SEARCH_QUERY = 'park7';
+const SEARCH_QUERY_IDS = ['1000', '2000'];
 
 describe('bioEntityContents thunks', () => {
   let store = {} as ToolkitStoreWithSingleSlice<BioEntityContentsState>;
@@ -19,21 +20,77 @@ describe('bioEntityContents thunks', () => {
     store = createStoreInstanceUsingSliceReducer('bioEntityContents', contentsReducer);
   });
   describe('getBioEntityContents', () => {
-    it('should return data when data response from API is valid', async () => {
-      mockedAxiosClient
-        .onGet(apiPath.getBioEntityContentsStringWithQuery(SEARCH_QUERY))
-        .reply(HttpStatusCode.Ok, bioEntityResponseFixture);
+    describe('on default query search', () => {
+      it('should return data when data response from API is valid', async () => {
+        mockedAxiosClient
+          .onGet(apiPath.getBioEntityContentsStringWithQuery(SEARCH_QUERY))
+          .reply(HttpStatusCode.Ok, bioEntityResponseFixture);
 
-      const { payload } = await store.dispatch(getBioEntity(SEARCH_QUERY));
-      expect(payload).toEqual(bioEntityResponseFixture.content);
+        const { payload } = await store.dispatch(getBioEntity(SEARCH_QUERY));
+        expect(payload).toEqual(bioEntityResponseFixture.content);
+      });
+      it('should return undefined when data response from API is not valid ', async () => {
+        mockedAxiosClient
+          .onGet(apiPath.getBioEntityContentsStringWithQuery(SEARCH_QUERY))
+          .reply(HttpStatusCode.Ok, { randomProperty: 'randomValue' });
+
+        const { payload } = await store.dispatch(getBioEntity(SEARCH_QUERY));
+        expect(payload).toEqual(undefined);
+      });
     });
-    it('should return undefined when data response from API is not valid ', async () => {
-      mockedAxiosClient
-        .onGet(apiPath.getBioEntityContentsStringWithQuery(SEARCH_QUERY))
-        .reply(HttpStatusCode.Ok, { randomProperty: 'randomValue' });
 
-      const { payload } = await store.dispatch(getBioEntity(SEARCH_QUERY));
-      expect(payload).toEqual(undefined);
+    describe('on multi query search', () => {
+      it('should return data when data response from API is valid', async () => {
+        const FIRST = 0;
+        const SECOND = 1;
+        const params = {
+          perfectMatch: true,
+        };
+
+        mockedAxiosClient
+          .onGet(apiPath.getBioEntityContentsStringWithQuery(SEARCH_QUERY_IDS[FIRST], params))
+          .reply(HttpStatusCode.Ok, bioEntityResponseFixture);
+
+        mockedAxiosClient
+          .onGet(apiPath.getBioEntityContentsStringWithQuery(SEARCH_QUERY_IDS[SECOND], params))
+          .reply(HttpStatusCode.Ok, bioEntityResponseFixture);
+
+        const { payload } = await store.dispatch(
+          getBioEntity({
+            query: SEARCH_QUERY_IDS,
+            params,
+          }),
+        );
+
+        expect(payload).toEqual([
+          ...bioEntityResponseFixture.content,
+          ...bioEntityResponseFixture.content,
+        ]);
+      });
+      it('should return undefined when data response from API is not valid ', async () => {
+        const FIRST = 0;
+        const SECOND = 1;
+        const params = {
+          perfectMatch: true,
+        };
+
+        mockedAxiosClient
+          .onGet(apiPath.getBioEntityContentsStringWithQuery(SEARCH_QUERY_IDS[FIRST], params))
+          .reply(HttpStatusCode.Ok, bioEntityResponseFixture);
+
+        mockedAxiosClient
+          .onGet(apiPath.getBioEntityContentsStringWithQuery(SEARCH_QUERY_IDS[SECOND], params))
+          .reply(HttpStatusCode.Ok, { randomProperty: 'randomValue' });
+
+        const { payload } = await store.dispatch(
+          getBioEntity({
+            query: SEARCH_QUERY_IDS,
+            params,
+          }),
+        );
+
+        expect(payload).toEqual(undefined);
+      });
     });
   });
 });
diff --git a/src/redux/bioEntity/bioEntity.thunks.ts b/src/redux/bioEntity/bioEntity.thunks.ts
index c1759481..6d4571b1 100644
--- a/src/redux/bioEntity/bioEntity.thunks.ts
+++ b/src/redux/bioEntity/bioEntity.thunks.ts
@@ -1,19 +1,48 @@
-import { createAsyncThunk } from '@reduxjs/toolkit';
+import { bioEntityResponseSchema } from '@/models/bioEntityResponseSchema';
+import { apiPath } from '@/redux/apiPath';
 import { axiosInstanceNewAPI } from '@/services/api/utils/axiosInstance';
 import { BioEntityContent, BioEntityResponse } from '@/types/models';
 import { validateDataUsingZodSchema } from '@/utils/validateDataUsingZodSchema';
-import { apiPath } from '@/redux/apiPath';
-import { bioEntityResponseSchema } from '@/models/bioEntityResponseSchema';
+import { createAsyncThunk } from '@reduxjs/toolkit';
+import { DEFAULT_BIOENTITY_PARAMS } from './bioEntity.constants';
+import { BioEntityContentSearchQuery } from './bioEntity.types';
+
+const getQueryResponse = async ({
+  query,
+  params,
+}: BioEntityContentSearchQuery): Promise<BioEntityResponse> => {
+  const queries = typeof query === 'string' ? [query] : query;
+  const responses = await Promise.all(
+    queries.map(searchQuery =>
+      axiosInstanceNewAPI.get<BioEntityResponse>(
+        apiPath.getBioEntityContentsStringWithQuery(searchQuery, params),
+      ),
+    ),
+  );
+
+  const responsesData = responses.map(response => response.data);
+
+  return responsesData.reduce((acc, next) => ({
+    ...acc,
+    size: acc.size + next.size,
+    totalPages: acc.totalPages + next.totalPages,
+    totalElements: acc.totalElements + next.totalElements,
+    numberOfElements: acc.numberOfElements + next.numberOfElements,
+    content: [...acc.content, ...next.content],
+  }));
+};
 
 export const getBioEntity = createAsyncThunk(
   'project/getBioEntityContents',
-  async (searchQuery: string): Promise<BioEntityContent[] | undefined> => {
-    const response = await axiosInstanceNewAPI.get<BioEntityResponse>(
-      apiPath.getBioEntityContentsStringWithQuery(searchQuery),
-    );
+  async (
+    searchQuery: string | BioEntityContentSearchQuery,
+  ): Promise<BioEntityContent[] | undefined> => {
+    const query = typeof searchQuery === 'string' ? searchQuery : searchQuery.query;
+    const params = typeof searchQuery === 'string' ? DEFAULT_BIOENTITY_PARAMS : searchQuery.params;
 
-    const isDataValid = validateDataUsingZodSchema(response.data, bioEntityResponseSchema);
+    const response = await getQueryResponse({ query, params });
+    const isDataValid = validateDataUsingZodSchema(response, bioEntityResponseSchema);
 
-    return isDataValid ? response.data.content : undefined;
+    return isDataValid ? response.content : undefined;
   },
 );
diff --git a/src/redux/bioEntity/bioEntity.types.ts b/src/redux/bioEntity/bioEntity.types.ts
index 3efecc0f..dac619af 100644
--- a/src/redux/bioEntity/bioEntity.types.ts
+++ b/src/redux/bioEntity/bioEntity.types.ts
@@ -1,4 +1,16 @@
 import { FetchDataState } from '@/types/fetchDataState';
 import { BioEntityContent } from '@/types/models';
+import { PayloadAction } from '@reduxjs/toolkit';
 
 export type BioEntityContentsState = FetchDataState<BioEntityContent[]>;
+
+export type BioEntityContentSearchQuery = {
+  query: string | string[];
+  params: {
+    perfectMatch: boolean;
+  };
+};
+
+export type SetBioEntityContentActionPayload = BioEntityContent[];
+
+export type SetBioEntityContentAction = PayloadAction<SetBioEntityContentActionPayload>;
diff --git a/src/redux/models/models.selectors.ts b/src/redux/models/models.selectors.ts
index 8a9478b1..071b0682 100644
--- a/src/redux/models/models.selectors.ts
+++ b/src/redux/models/models.selectors.ts
@@ -11,3 +11,9 @@ export const currentModelSelector = createSelector(
   mapDataSelector,
   (models, mapData) => models.find(model => model.idObject === mapData.modelId),
 );
+
+export const currentModelIdSelector = createSelector(
+  currentModelSelector,
+  // eslint-disable-next-line no-magic-numbers
+  model => model?.idObject || 0,
+);
diff --git a/src/redux/reactions/reactions.reducers.ts b/src/redux/reactions/reactions.reducers.ts
new file mode 100644
index 00000000..8673c7a6
--- /dev/null
+++ b/src/redux/reactions/reactions.reducers.ts
@@ -0,0 +1,17 @@
+import { ActionReducerMapBuilder } from '@reduxjs/toolkit';
+import { getReactionsByIds } from './reactions.thunks';
+import { ReactionsState } from './reactions.types';
+
+export const getReactionsReducer = (builder: ActionReducerMapBuilder<ReactionsState>): void => {
+  builder.addCase(getReactionsByIds.pending, state => {
+    state.loading = 'pending';
+  });
+  builder.addCase(getReactionsByIds.fulfilled, (state, action) => {
+    state.data = action.payload;
+    state.loading = 'succeeded';
+  });
+  builder.addCase(getReactionsByIds.rejected, state => {
+    state.loading = 'failed';
+    // TODO: error management to be discussed in the team
+  });
+};
diff --git a/src/redux/reactions/reactions.slice.ts b/src/redux/reactions/reactions.slice.ts
new file mode 100644
index 00000000..47eeea91
--- /dev/null
+++ b/src/redux/reactions/reactions.slice.ts
@@ -0,0 +1,20 @@
+import { createSlice } from '@reduxjs/toolkit';
+import { getReactionsReducer } from './reactions.reducers';
+import { ReactionsState } from './reactions.types';
+
+const initialState: ReactionsState = {
+  data: [],
+  loading: 'idle',
+  error: { name: '', message: '' },
+};
+
+export const reactionsSlice = createSlice({
+  name: 'reactions',
+  initialState,
+  reducers: {},
+  extraReducers: builder => {
+    getReactionsReducer(builder);
+  },
+});
+
+export default reactionsSlice.reducer;
diff --git a/src/redux/reactions/reactions.thunks.test.ts b/src/redux/reactions/reactions.thunks.test.ts
new file mode 100644
index 00000000..88cfb4c4
--- /dev/null
+++ b/src/redux/reactions/reactions.thunks.test.ts
@@ -0,0 +1,50 @@
+/* eslint-disable no-magic-numbers */
+import { reactionsFixture } from '@/models/fixtures/reactionFixture';
+import {
+  ToolkitStoreWithSingleSlice,
+  createStoreInstanceUsingSliceReducer,
+} from '@/utils/createStoreInstanceUsingSliceReducer';
+import { mockNetworkResponse } from '@/utils/mockNetworkResponse';
+import { HttpStatusCode } from 'axios';
+import { apiPath } from '../apiPath';
+import reactionsReducer from './reactions.slice';
+import { getReactionsByIds } from './reactions.thunks';
+import { ReactionsState } from './reactions.types';
+
+const mockedAxiosClient = mockNetworkResponse();
+
+describe('reactions thunks', () => {
+  let store = {} as ToolkitStoreWithSingleSlice<ReactionsState>;
+  beforeEach(() => {
+    store = createStoreInstanceUsingSliceReducer('reactions', reactionsReducer);
+  });
+
+  describe('getReactionsByIds', () => {
+    it('should return data when data response from API is valid', async () => {
+      const ids = [1];
+
+      mockedAxiosClient
+        .onGet(apiPath.getReactionsWithIds(ids))
+        .reply(HttpStatusCode.Ok, reactionsFixture);
+
+      const { payload } = await store.dispatch(getReactionsByIds(ids));
+      expect(payload).toEqual(reactionsFixture);
+    });
+
+    it('should return undefined when data response from API is not valid ', async () => {
+      mockedAxiosClient
+        .onGet(apiPath.getReactionsWithIds([]))
+        .reply(HttpStatusCode.Ok, { randomProperty: 'randomValue' });
+
+      const { payload } = await store.dispatch(getReactionsByIds([]));
+      expect(payload).toEqual(undefined);
+    });
+
+    it('should return empty array when data response from API is empty', async () => {
+      mockedAxiosClient.onGet(apiPath.getReactionsWithIds([100])).reply(HttpStatusCode.Ok, []);
+
+      const { payload } = await store.dispatch(getReactionsByIds([100]));
+      expect(payload).toEqual([]);
+    });
+  });
+});
diff --git a/src/redux/reactions/reactions.thunks.ts b/src/redux/reactions/reactions.thunks.ts
new file mode 100644
index 00000000..51ef6bfa
--- /dev/null
+++ b/src/redux/reactions/reactions.thunks.ts
@@ -0,0 +1,23 @@
+import { reactionSchema } from '@/models/reaction';
+import { apiPath } from '@/redux/apiPath';
+import { axiosInstance } from '@/services/api/utils/axiosInstance';
+import { Reaction } from '@/types/models';
+import { validateDataUsingZodSchema } from '@/utils/validateDataUsingZodSchema';
+import { createAsyncThunk } from '@reduxjs/toolkit';
+import { z } from 'zod';
+
+const ZERO = 0;
+
+export const getReactionsByIds = createAsyncThunk<Reaction[] | undefined, number[]>(
+  'reactions/getByIds',
+  async (ids: number[]): Promise<Reaction[] | undefined> => {
+    const response = await axiosInstance.get<Reaction[]>(apiPath.getReactionsWithIds(ids));
+    const isDataValid = validateDataUsingZodSchema(response.data, z.array(reactionSchema));
+
+    if (!isDataValid || response?.data?.length === ZERO) {
+      return isDataValid ? response.data : undefined;
+    }
+
+    return response.data;
+  },
+);
diff --git a/src/redux/reactions/reactions.types.ts b/src/redux/reactions/reactions.types.ts
new file mode 100644
index 00000000..02bd81cd
--- /dev/null
+++ b/src/redux/reactions/reactions.types.ts
@@ -0,0 +1,4 @@
+import { FetchDataState } from '@/types/fetchDataState';
+import { Reaction } from '@/types/models';
+
+export type ReactionsState = FetchDataState<Reaction[]>;
diff --git a/src/redux/store.ts b/src/redux/store.ts
index d0d3e673..147673c2 100644
--- a/src/redux/store.ts
+++ b/src/redux/store.ts
@@ -8,6 +8,7 @@ import mirnasReducer from '@/redux/mirnas/mirnas.slice';
 import modelsReducer from '@/redux/models/models.slice';
 import overlaysReducer from '@/redux/overlays/overlays.slice';
 import projectReducer from '@/redux/project/project.slice';
+import reactionsReducer from '@/redux/reactions/reactions.slice';
 import searchReducer from '@/redux/search/search.slice';
 import {
   AnyAction,
@@ -30,6 +31,7 @@ export const reducers = {
   backgrounds: backgroundsReducer,
   overlays: overlaysReducer,
   models: modelsReducer,
+  reactions: reactionsReducer,
 };
 
 export const middlewares = [mapListenerMiddleware.middleware];
diff --git a/src/types/models.ts b/src/types/models.ts
index 5f7f81bf..38b9e6b3 100644
--- a/src/types/models.ts
+++ b/src/types/models.ts
@@ -4,6 +4,7 @@ import { bioEntitySchema } from '@/models/bioEntitySchema';
 import { chemicalSchema } from '@/models/chemicalSchema';
 import { disease } from '@/models/disease';
 import { drugSchema } from '@/models/drugSchema';
+import { elementSearchResult, elementSearchResultType } from '@/models/elementSearchResult';
 import { mapBackground } from '@/models/mapBackground';
 import { mapOverlay } from '@/models/mapOverlay';
 import { mirnaSchema } from '@/models/mirnaSchema';
@@ -11,6 +12,7 @@ import { mapModelSchema } from '@/models/modelSchema';
 import { organism } from '@/models/organism';
 import { overviewImageView } from '@/models/overviewImageView';
 import { projectSchema } from '@/models/project';
+import { reactionSchema } from '@/models/reaction';
 import { targetSchema } from '@/models/targetSchema';
 import { z } from 'zod';
 
@@ -28,3 +30,6 @@ export type BioEntity = z.infer<typeof bioEntitySchema>;
 export type BioEntityContent = z.infer<typeof bioEntityContentSchema>;
 export type BioEntityResponse = z.infer<typeof bioEntityResponseSchema>;
 export type Chemical = z.infer<typeof chemicalSchema>;
+export type Reaction = z.infer<typeof reactionSchema>;
+export type ElementSearchResult = z.infer<typeof elementSearchResult>;
+export type ElementSearchResultType = z.infer<typeof elementSearchResultType>;
diff --git a/src/utils/map/getPointOffset.test.ts b/src/utils/map/getPointOffset.test.ts
index 2528c7dd..e6d62a59 100644
--- a/src/utils/map/getPointOffset.test.ts
+++ b/src/utils/map/getPointOffset.test.ts
@@ -18,8 +18,8 @@ describe('getPointOffset - util', () => {
     };
 
     const results = {
-      x: -115.2,
-      y: -102.4,
+      x: -102.4,
+      y: -76.8,
     };
 
     it('should return valid point origin and shifted values', () => {
diff --git a/src/utils/map/getPointOffset.ts b/src/utils/map/getPointOffset.ts
index 08e60559..6a1c6629 100644
--- a/src/utils/map/getPointOffset.ts
+++ b/src/utils/map/getPointOffset.ts
@@ -15,7 +15,7 @@ export const getPointOffset = (point: Point, mapSize: MapSize): GetPointOffsetRe
 
   const longestSide = Math.max(mapSize.width, mapSize.height);
   const minZoomShifted = mapSize.minZoom * 2 ** mapSize.minZoom;
-  const zoomFactor = longestSide / (mapSize.tileSize / minZoomShifted);
+  const zoomFactor = longestSide / (mapSize.tileSize / minZoomShifted) / 2;
 
   const pointOrigin: Point = {
     x: mapSize.tileSize / 2,
diff --git a/src/utils/map/latLngToPoint.test.ts b/src/utils/map/latLngToPoint.test.ts
index 35e89dd6..8bb07ab4 100644
--- a/src/utils/map/latLngToPoint.test.ts
+++ b/src/utils/map/latLngToPoint.test.ts
@@ -16,8 +16,8 @@ describe('latLngToPoint - util', () => {
       [
         [84.480312233386, -159.90463877126223],
         {
-          x: 2308.7337233905396,
-          y: 719.5731221907884,
+          x: 1154.3668616952698,
+          y: 359.7865610953942,
         },
         {
           rounded: false,
@@ -26,8 +26,8 @@ describe('latLngToPoint - util', () => {
       [
         [84.20644283660516, -153.43406886300772],
         {
-          x: 3052,
-          y: 1039,
+          x: 1526,
+          y: 519,
         },
         {
           rounded: true,
@@ -53,8 +53,8 @@ describe('latLngToPoint - util', () => {
       [
         [843.480312233386, -84.90463877126223],
         {
-          x: 56590.721159659464,
-          y: 66154.2246606772,
+          x: 28295.360579829732,
+          y: 33077.1123303386,
         },
         {
           rounded: false,
@@ -63,8 +63,8 @@ describe('latLngToPoint - util', () => {
       [
         [32443.4536345435, -5546654.543645645],
         {
-          x: -3300676187,
-          y: 78350,
+          x: -1650338094,
+          y: 39175,
         },
         {
           rounded: true,
diff --git a/src/utils/map/pointToLatLng.test.ts b/src/utils/map/pointToLatLng.test.ts
index 8c34d284..318cfec4 100644
--- a/src/utils/map/pointToLatLng.test.ts
+++ b/src/utils/map/pointToLatLng.test.ts
@@ -44,7 +44,7 @@ describe('pointToLatLng - util', () => {
   describe('when all args are valid', () => {
     const validPoint = {
       x: -256 * 5,
-      y: 256 * 10,
+      y: 256 * 5,
     };
 
     const validMapSize = {
@@ -55,7 +55,7 @@ describe('pointToLatLng - util', () => {
       maxZoom: 10,
     };
 
-    const results = [-270, 0];
+    const results = [-360, 0];
 
     it('should return valid lat lng value', () => {
       expect(pointToLngLat(validPoint, validMapSize)).toStrictEqual(results);
diff --git a/src/utils/map/usePointToProjection.test.tsx b/src/utils/map/usePointToProjection.test.tsx
index b3659e7a..83a44e90 100644
--- a/src/utils/map/usePointToProjection.test.tsx
+++ b/src/utils/map/usePointToProjection.test.tsx
@@ -76,7 +76,7 @@ describe('usePointToProjection - util', () => {
       maxZoom: 10,
     };
 
-    const results = [180337575, -180337344];
+    const results = [380712659, -238107693];
 
     it('should return valid lat lng value on function call', () => {
       act(() => {
diff --git a/src/utils/search/getElementsByCoordinates.test.ts b/src/utils/search/getElementsByCoordinates.test.ts
new file mode 100644
index 00000000..cf2a56fa
--- /dev/null
+++ b/src/utils/search/getElementsByCoordinates.test.ts
@@ -0,0 +1,42 @@
+import { elementSearchResultFixture } from '@/models/fixtures/elementSearchResultFixture';
+import { apiPath } from '@/redux/apiPath';
+import { HttpStatusCode } from 'axios';
+import { mockNetworkResponse } from '../mockNetworkResponse';
+import { getElementsByPoint } from './getElementsByCoordinates';
+
+const mockedAxiosClient = mockNetworkResponse();
+
+describe('getElementsByPoint - utils', () => {
+  const point = {
+    x: 0,
+    y: 0,
+  };
+  const currentModelId = 1000;
+
+  it('should return data when data response from API is valid', async () => {
+    mockedAxiosClient
+      .onGet(apiPath.getSingleBioEntityContentsStringWithCoordinates(point, currentModelId))
+      .reply(HttpStatusCode.Ok, elementSearchResultFixture);
+
+    const response = await getElementsByPoint({ point, currentModelId });
+    expect(response).toEqual(elementSearchResultFixture);
+  });
+
+  it('should return undefined when data response from API is not valid ', async () => {
+    mockedAxiosClient
+      .onGet(apiPath.getSingleBioEntityContentsStringWithCoordinates(point, currentModelId))
+      .reply(HttpStatusCode.Ok, { randomProperty: 'randomValue' });
+
+    const response = await getElementsByPoint({ point, currentModelId });
+    expect(response).toEqual(undefined);
+  });
+
+  it('should return empty array when data response from API is empty', async () => {
+    mockedAxiosClient
+      .onGet(apiPath.getSingleBioEntityContentsStringWithCoordinates(point, currentModelId))
+      .reply(HttpStatusCode.Ok, []);
+
+    const response = await getElementsByPoint({ point, currentModelId });
+    expect(response).toEqual([]);
+  });
+});
diff --git a/src/utils/search/getElementsByCoordinates.ts b/src/utils/search/getElementsByCoordinates.ts
new file mode 100644
index 00000000..af110ac0
--- /dev/null
+++ b/src/utils/search/getElementsByCoordinates.ts
@@ -0,0 +1,25 @@
+import { elementSearchResult } from '@/models/elementSearchResult';
+import { apiPath } from '@/redux/apiPath';
+import { axiosInstance } from '@/services/api/utils/axiosInstance';
+import { Point } from '@/types/map';
+import { ElementSearchResult } from '@/types/models';
+import { z } from 'zod';
+import { validateDataUsingZodSchema } from '../validateDataUsingZodSchema';
+
+interface Args {
+  point: Point;
+  currentModelId: number;
+}
+
+export const getElementsByPoint = async ({
+  point,
+  currentModelId,
+}: Args): Promise<ElementSearchResult[] | undefined> => {
+  const response = await axiosInstance.get<ElementSearchResult[]>(
+    apiPath.getSingleBioEntityContentsStringWithCoordinates(point, currentModelId),
+  );
+
+  const isDataValid = validateDataUsingZodSchema(response.data, z.array(elementSearchResult));
+
+  return isDataValid ? response.data : undefined;
+};
diff --git a/src/utils/testing/getReduxStoreActionsListener.tsx b/src/utils/testing/getReduxStoreActionsListener.tsx
new file mode 100644
index 00000000..22c12cd0
--- /dev/null
+++ b/src/utils/testing/getReduxStoreActionsListener.tsx
@@ -0,0 +1,30 @@
+/* eslint-disable import/no-extraneous-dependencies */
+import { AppDispatch, RootState, middlewares } from '@/redux/store';
+import { Provider } from 'react-redux';
+import configureStore, { MockStoreEnhanced } from 'redux-mock-store';
+import thunk from 'redux-thunk';
+
+interface WrapperProps {
+  children: React.ReactNode;
+}
+
+export type InitialStoreState = Partial<RootState>;
+
+type GetReduxStoreActionsListener = (initialState?: InitialStoreState) => {
+  Wrapper: ({ children }: WrapperProps) => JSX.Element;
+  store: MockStoreEnhanced<Partial<RootState>, AppDispatch>;
+};
+
+export const getReduxStoreActionsListener: GetReduxStoreActionsListener = (
+  preloadedState: InitialStoreState = {},
+) => {
+  const testStore = configureStore<Partial<RootState>, AppDispatch>([thunk, ...middlewares])(
+    preloadedState,
+  );
+
+  const Wrapper = ({ children }: WrapperProps): JSX.Element => (
+    <Provider store={testStore}>{children}</Provider>
+  );
+
+  return { Wrapper, store: testStore };
+};
diff --git a/yarn.lock b/yarn.lock
index 9f446451..8c7c992b 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -852,6 +852,46 @@
   "resolved" "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-13.4.19.tgz"
   "version" "13.4.19"
 
+"@next/swc-darwin-x64@13.4.19":
+  "integrity" "sha512-jyzO6wwYhx6F+7gD8ddZfuqO4TtpJdw3wyOduR4fxTUCm3aLw7YmHGYNjS0xRSYGAkLpBkH1E0RcelyId6lNsw=="
+  "resolved" "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-13.4.19.tgz"
+  "version" "13.4.19"
+
+"@next/swc-linux-arm64-gnu@13.4.19":
+  "integrity" "sha512-vdlnIlaAEh6H+G6HrKZB9c2zJKnpPVKnA6LBwjwT2BTjxI7e0Hx30+FoWCgi50e+YO49p6oPOtesP9mXDRiiUg=="
+  "resolved" "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-13.4.19.tgz"
+  "version" "13.4.19"
+
+"@next/swc-linux-arm64-musl@13.4.19":
+  "integrity" "sha512-aU0HkH2XPgxqrbNRBFb3si9Ahu/CpaR5RPmN2s9GiM9qJCiBBlZtRTiEca+DC+xRPyCThTtWYgxjWHgU7ZkyvA=="
+  "resolved" "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-13.4.19.tgz"
+  "version" "13.4.19"
+
+"@next/swc-linux-x64-gnu@13.4.19":
+  "integrity" "sha512-htwOEagMa/CXNykFFeAHHvMJeqZfNQEoQvHfsA4wgg5QqGNqD5soeCer4oGlCol6NGUxknrQO6VEustcv+Md+g=="
+  "resolved" "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-13.4.19.tgz"
+  "version" "13.4.19"
+
+"@next/swc-linux-x64-musl@13.4.19":
+  "integrity" "sha512-4Gj4vvtbK1JH8ApWTT214b3GwUh9EKKQjY41hH/t+u55Knxi/0wesMzwQRhppK6Ddalhu0TEttbiJ+wRcoEj5Q=="
+  "resolved" "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-13.4.19.tgz"
+  "version" "13.4.19"
+
+"@next/swc-win32-arm64-msvc@13.4.19":
+  "integrity" "sha512-bUfDevQK4NsIAHXs3/JNgnvEY+LRyneDN788W2NYiRIIzmILjba7LaQTfihuFawZDhRtkYCv3JDC3B4TwnmRJw=="
+  "resolved" "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-13.4.19.tgz"
+  "version" "13.4.19"
+
+"@next/swc-win32-ia32-msvc@13.4.19":
+  "integrity" "sha512-Y5kikILFAr81LYIFaw6j/NrOtmiM4Sf3GtOc0pn50ez2GCkr+oejYuKGcwAwq3jiTKuzF6OF4iT2INPoxRycEA=="
+  "resolved" "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-13.4.19.tgz"
+  "version" "13.4.19"
+
+"@next/swc-win32-x64-msvc@13.4.19":
+  "integrity" "sha512-YzA78jBDXMYiINdPdJJwGgPNT3YqBNNGhsthsDoWHL9p24tEJn9ViQf/ZqTbwSpX/RrkPupLfuuTH2sf73JBAw=="
+  "resolved" "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-13.4.19.tgz"
+  "version" "13.4.19"
+
 "@nodelib/fs.scandir@2.1.5":
   "integrity" "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="
   "resolved" "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz"
@@ -1153,6 +1193,13 @@
     "@types/scheduler" "*"
     "csstype" "^3.0.2"
 
+"@types/redux-mock-store@^1.0.6":
+  "integrity" "sha512-eg5RDfhJTXuoJjOMyXiJbaDb1B8tfTaJixscmu+jOusj6adGC0Krntz09Tf4gJgXeCqCrM5bBMd+B7ez0izcAQ=="
+  "resolved" "https://registry.npmjs.org/@types/redux-mock-store/-/redux-mock-store-1.0.6.tgz"
+  "version" "1.0.6"
+  dependencies:
+    "redux" "^4.0.5"
+
 "@types/scheduler@*":
   "integrity" "sha512-2L9ifAGl7wmXwP4v3pN4p2FLhD0O1qsJpvKmNin5VA8+UvNVb447UDaAEV6UdrkA+m/Xs58U1RFps44x6TFsVQ=="
   "resolved" "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.4.tgz"
@@ -6102,12 +6149,19 @@
     "indent-string" "^4.0.0"
     "strip-indent" "^3.0.0"
 
+"redux-mock-store@^1.5.4":
+  "integrity" "sha512-xmcA0O/tjCLXhh9Fuiq6pMrJCwFRaouA8436zcikdIpYWWCjU76CRk+i2bHx8EeiSiMGnB85/lZdU3wIJVXHTA=="
+  "resolved" "https://registry.npmjs.org/redux-mock-store/-/redux-mock-store-1.5.4.tgz"
+  "version" "1.5.4"
+  dependencies:
+    "lodash.isplainobject" "^4.0.6"
+
 "redux-thunk@^2.4.2":
   "integrity" "sha512-+P3TjtnP0k/FEjcBL5FZpoovtvrTNT/UXd4/sluaSyrURlSlhLSzEdfsTBW7WsKB6yPvgd7q/iZPICFjW4o57Q=="
   "resolved" "https://registry.npmjs.org/redux-thunk/-/redux-thunk-2.4.2.tgz"
   "version" "2.4.2"
 
-"redux@^4", "redux@^4 || ^5.0.0-beta.0", "redux@^4.0.0", "redux@^4.2.1":
+"redux@^4", "redux@^4 || ^5.0.0-beta.0", "redux@^4.0.0", "redux@^4.0.5", "redux@^4.2.1":
   "integrity" "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w=="
   "resolved" "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz"
   "version" "4.2.1"
-- 
GitLab