From 14f0f0202101580be7bf571da99775e9c9db8616 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Adrian=20Or=C5=82=C3=B3w?= <adrian.orlow@fishbrain.com>
Date: Thu, 11 Jan 2024 23:11:19 +0100
Subject: [PATCH] add: map location btn and fit bounds logic

---
 .../AppWrapper/AppWrapper.component.tsx       |   7 +-
 .../MapAdditionalActions.component.test.tsx   |  98 +++++++-
 .../utils/useAdditionalActions.test.ts        |  93 +++++++-
 .../utils/useAdditionalActions.ts             |  14 +-
 ...sibleBioEntitiesPolygonCoordinates.test.ts | 221 ++++++++++++++++++
 ...useVisibleBioEntitiesPolygonCoordinates.ts |  49 ++++
 .../Map/MapViewer/MapViewer.types.ts          |   3 -
 .../MapViewer/utils/config/useOlMapLayers.ts  |   3 +-
 .../MapViewer/utils/config/useOlMapView.ts    |   4 +-
 .../utils/listeners/useOlMapListeners.ts      |   8 +-
 .../Map/MapViewer/utils/useOlMap.ts           |  17 +-
 src/models/mapPoint.ts                        |   9 +
 src/redux/bioEntity/bioEntity.selectors.ts    |  13 +-
 src/types/map.ts                              |   4 +
 src/types/mapLayers.ts                        |  15 ++
 src/utils/context/mapInstanceContext.tsx      |  40 ++++
 src/utils/map/useSetBounds.test.ts            | 109 +++++++++
 src/utils/map/useSetBounds.ts                 |  48 ++++
 src/utils/point/isPointValid.test.ts          |  21 ++
 src/utils/point/isPointValid.ts               |   7 +
 .../testing/getReduxWrapperWithStore.tsx      |  17 +-
 21 files changed, 768 insertions(+), 32 deletions(-)
 create mode 100644 src/components/Map/MapAdditionalActions/utils/useVisibleBioEntitiesPolygonCoordinates.test.ts
 create mode 100644 src/components/Map/MapAdditionalActions/utils/useVisibleBioEntitiesPolygonCoordinates.ts
 create mode 100644 src/models/mapPoint.ts
 create mode 100644 src/types/mapLayers.ts
 create mode 100644 src/utils/context/mapInstanceContext.tsx
 create mode 100644 src/utils/map/useSetBounds.test.ts
 create mode 100644 src/utils/map/useSetBounds.ts
 create mode 100644 src/utils/point/isPointValid.test.ts
 create mode 100644 src/utils/point/isPointValid.ts

diff --git a/src/components/AppWrapper/AppWrapper.component.tsx b/src/components/AppWrapper/AppWrapper.component.tsx
index 2bb7e192..3b59e82b 100644
--- a/src/components/AppWrapper/AppWrapper.component.tsx
+++ b/src/components/AppWrapper/AppWrapper.component.tsx
@@ -1,11 +1,14 @@
+import { store } from '@/redux/store';
+import { MapInstanceProvider } from '@/utils/context/mapInstanceContext';
 import { ReactNode } from 'react';
 import { Provider } from 'react-redux';
-import { store } from '@/redux/store';
 
 interface AppWrapperProps {
   children: ReactNode;
 }
 
 export const AppWrapper = ({ children }: AppWrapperProps): JSX.Element => (
-  <Provider store={store}>{children}</Provider>
+  <MapInstanceProvider>
+    <Provider store={store}>{children}</Provider>
+  </MapInstanceProvider>
 );
diff --git a/src/components/Map/MapAdditionalActions/MapAdditionalActions.component.test.tsx b/src/components/Map/MapAdditionalActions/MapAdditionalActions.component.test.tsx
index 226bd904..09f91627 100644
--- a/src/components/Map/MapAdditionalActions/MapAdditionalActions.component.test.tsx
+++ b/src/components/Map/MapAdditionalActions/MapAdditionalActions.component.test.tsx
@@ -1,10 +1,35 @@
+/* eslint-disable no-magic-numbers */
 import { FIRST_ARRAY_ELEMENT } from '@/constants/common';
-import { AppDispatch, RootState } from '@/redux/store';
+import { MAP_DATA_INITIAL_STATE } from '@/redux/map/map.constants';
+import { INITIAL_STORE_STATE_MOCK } from '@/redux/root/root.fixtures';
+import { AppDispatch, RootState, StoreType } from '@/redux/store';
 import { getReduxStoreWithActionsListener } from '@/utils/testing/getReduxStoreActionsListener';
-import { InitialStoreState } from '@/utils/testing/getReduxWrapperWithStore';
-import { render, screen } from '@testing-library/react';
+import {
+  InitialStoreState,
+  getReduxWrapperWithStore,
+} from '@/utils/testing/getReduxWrapperWithStore';
+import { act, render, screen } from '@testing-library/react';
+import Map from 'ol/Map';
 import { MockStoreEnhanced } from 'redux-mock-store';
 import { MapAdditionalActions } from './MapAdditionalActions.component';
+import { useVisibleBioEntitiesPolygonCoordinates } from './utils/useVisibleBioEntitiesPolygonCoordinates';
+
+const setBounds = jest.fn();
+
+jest.mock('../../../utils/map/useSetBounds', () => ({
+  _esModule: true,
+  useSetBounds: (): jest.Mock => setBounds,
+}));
+
+jest.mock('./utils/useVisibleBioEntitiesPolygonCoordinates', () => ({
+  _esModule: true,
+  useVisibleBioEntitiesPolygonCoordinates: jest.fn(),
+}));
+
+const useVisibleBioEntitiesPolygonCoordinatesMock =
+  useVisibleBioEntitiesPolygonCoordinates as jest.Mock;
+
+setBounds.mockImplementation(() => {});
 
 const renderComponent = (
   initialStore?: InitialStoreState,
@@ -22,10 +47,33 @@ const renderComponent = (
   );
 };
 
+const renderComponentWithMapInstance = (initialStore?: InitialStoreState): { store: StoreType } => {
+  const dummyElement = document.createElement('div');
+  const mapInstance = new Map({ target: dummyElement });
+
+  const { Wrapper, store } = getReduxWrapperWithStore(initialStore, {
+    mapInstanceContextValue: {
+      mapInstance,
+      setMapInstance: () => {},
+    },
+  });
+
+  return (
+    render(
+      <Wrapper>
+        <MapAdditionalActions />
+      </Wrapper>,
+    ),
+    {
+      store,
+    }
+  );
+};
+
 describe('MapAdditionalActions - component', () => {
   describe('when always', () => {
     beforeEach(() => {
-      renderComponent();
+      renderComponent(INITIAL_STORE_STATE_MOCK);
     });
 
     it('should render zoom in button', () => {
@@ -49,7 +97,7 @@ describe('MapAdditionalActions - component', () => {
 
   describe('when clicked on zoom in button', () => {
     it('should dispatch varyPositionZoom action with valid delta', () => {
-      const { store } = renderComponent();
+      const { store } = renderComponent(INITIAL_STORE_STATE_MOCK);
       const image = screen.getByAltText('zoom in button icon');
       const button = image.closest('button');
       button!.click();
@@ -64,7 +112,7 @@ describe('MapAdditionalActions - component', () => {
 
   describe('when clicked on zoom in button', () => {
     it('should dispatch varyPositionZoom action with valid delta', () => {
-      const { store } = renderComponent();
+      const { store } = renderComponent(INITIAL_STORE_STATE_MOCK);
       const image = screen.getByAltText('zoom out button icon');
       const button = image.closest('button');
       button!.click();
@@ -77,7 +125,41 @@ describe('MapAdditionalActions - component', () => {
     });
   });
 
-  describe.skip('when clicked on location button', () => {
-    // TODO: implelemnt test
+  describe('when clicked on location button', () => {
+    it('setBounds should be called', () => {
+      useVisibleBioEntitiesPolygonCoordinatesMock.mockImplementation(() => [
+        [128, 128],
+        [192, 192],
+      ]);
+
+      renderComponentWithMapInstance({
+        map: {
+          data: {
+            ...MAP_DATA_INITIAL_STATE,
+            size: {
+              width: 256,
+              height: 256,
+              tileSize: 256,
+              minZoom: 1,
+              maxZoom: 1,
+            },
+          },
+          loading: 'idle',
+          error: {
+            name: '',
+            message: '',
+          },
+          openedMaps: [],
+        },
+      });
+
+      const image = screen.getByAltText('location button icon');
+      const button = image.closest('button');
+      act(() => {
+        button!.click();
+      });
+
+      expect(setBounds).toHaveBeenCalled();
+    });
   });
 });
diff --git a/src/components/Map/MapAdditionalActions/utils/useAdditionalActions.test.ts b/src/components/Map/MapAdditionalActions/utils/useAdditionalActions.test.ts
index 34986a2e..7d296729 100644
--- a/src/components/Map/MapAdditionalActions/utils/useAdditionalActions.test.ts
+++ b/src/components/Map/MapAdditionalActions/utils/useAdditionalActions.test.ts
@@ -1,12 +1,26 @@
+/* eslint-disable no-magic-numbers */
 import { FIRST_ARRAY_ELEMENT } from '@/constants/common';
+import { MAP_DATA_INITIAL_STATE } from '@/redux/map/map.constants';
+import { INITIAL_STORE_STATE_MOCK } from '@/redux/root/root.fixtures';
 import { getReduxStoreWithActionsListener } from '@/utils/testing/getReduxStoreActionsListener';
+import { getReduxWrapperWithStore } from '@/utils/testing/getReduxWrapperWithStore';
 import { renderHook } from '@testing-library/react';
+import Map from 'ol/Map';
 import { useAddtionalActions } from './useAdditionalActions';
+import { useVisibleBioEntitiesPolygonCoordinates } from './useVisibleBioEntitiesPolygonCoordinates';
+
+jest.mock('./useVisibleBioEntitiesPolygonCoordinates', () => ({
+  _esModule: true,
+  useVisibleBioEntitiesPolygonCoordinates: jest.fn(),
+}));
+
+const useVisibleBioEntitiesPolygonCoordinatesMock =
+  useVisibleBioEntitiesPolygonCoordinates as jest.Mock;
 
 describe('useAddtionalActions - hook', () => {
   describe('on zoomIn', () => {
     it('should dispatch varyPositionZoom action with valid delta', () => {
-      const { Wrapper, store } = getReduxStoreWithActionsListener();
+      const { Wrapper, store } = getReduxStoreWithActionsListener(INITIAL_STORE_STATE_MOCK);
       const {
         result: {
           current: { zoomIn },
@@ -27,7 +41,7 @@ describe('useAddtionalActions - hook', () => {
 
   describe('on zoomOut', () => {
     it('should dispatch varyPositionZoom action with valid delta', () => {
-      const { Wrapper, store } = getReduxStoreWithActionsListener();
+      const { Wrapper, store } = getReduxStoreWithActionsListener(INITIAL_STORE_STATE_MOCK);
       const {
         result: {
           current: { zoomOut },
@@ -47,6 +61,79 @@ describe('useAddtionalActions - hook', () => {
   });
 
   describe('on zoomInToBioEntities', () => {
-    // TODO: implelemnt test
+    describe('when there are valid polygon coordinates', () => {
+      beforeEach(() => {
+        useVisibleBioEntitiesPolygonCoordinatesMock.mockImplementation(() => [
+          [128, 128],
+          [192, 192],
+        ]);
+      });
+
+      it('should return valid results', () => {
+        const dummyElement = document.createElement('div');
+        const mapInstance = new Map({ target: dummyElement });
+
+        const { Wrapper } = getReduxWrapperWithStore(
+          {
+            map: {
+              data: {
+                ...MAP_DATA_INITIAL_STATE,
+                size: {
+                  width: 256,
+                  height: 256,
+                  tileSize: 256,
+                  minZoom: 1,
+                  maxZoom: 1,
+                },
+              },
+              loading: 'idle',
+              error: {
+                name: '',
+                message: '',
+              },
+              openedMaps: [],
+            },
+          },
+          {
+            mapInstanceContextValue: {
+              mapInstance,
+              setMapInstance: () => {},
+            },
+          },
+        );
+        const {
+          result: {
+            current: { zoomInToBioEntities },
+          },
+        } = renderHook(() => useAddtionalActions(), {
+          wrapper: Wrapper,
+        });
+
+        expect(zoomInToBioEntities()).toStrictEqual({
+          extent: [128, 128, 192, 192],
+          options: { maxZoom: 1, padding: [128, 128, 128, 128], size: undefined },
+          // size is real size on the screen, so it'll be undefined in the jest
+        });
+      });
+    });
+
+    describe('when there are no polygon coordinates', () => {
+      beforeEach(() => {
+        useVisibleBioEntitiesPolygonCoordinatesMock.mockImplementation(() => undefined);
+      });
+
+      it('should return undefined', () => {
+        const { Wrapper } = getReduxStoreWithActionsListener(INITIAL_STORE_STATE_MOCK);
+        const {
+          result: {
+            current: { zoomInToBioEntities },
+          },
+        } = renderHook(() => useAddtionalActions(), {
+          wrapper: Wrapper,
+        });
+
+        expect(zoomInToBioEntities()).toBeUndefined();
+      });
+    });
   });
 });
diff --git a/src/components/Map/MapAdditionalActions/utils/useAdditionalActions.ts b/src/components/Map/MapAdditionalActions/utils/useAdditionalActions.ts
index ef7519f9..b93fc761 100644
--- a/src/components/Map/MapAdditionalActions/utils/useAdditionalActions.ts
+++ b/src/components/Map/MapAdditionalActions/utils/useAdditionalActions.ts
@@ -1,7 +1,9 @@
 import { varyPositionZoom } from '@/redux/map/map.slice';
+import { SetBoundsResult, useSetBounds } from '@/utils/map/useSetBounds';
 import { useCallback } from 'react';
 import { useDispatch } from 'react-redux';
 import { MAP_ZOOM_IN_DELTA, MAP_ZOOM_OUT_DELTA } from '../MappAdditionalActions.constants';
+import { useVisibleBioEntitiesPolygonCoordinates } from './useVisibleBioEntitiesPolygonCoordinates';
 
 interface UseAddtionalActionsResult {
   zoomIn(): void;
@@ -11,6 +13,16 @@ interface UseAddtionalActionsResult {
 
 export const useAddtionalActions = (): UseAddtionalActionsResult => {
   const dispatch = useDispatch();
+  const setBounds = useSetBounds();
+  const polygonCoordinates = useVisibleBioEntitiesPolygonCoordinates();
+
+  const zoomInToBioEntities = (): SetBoundsResult | undefined => {
+    if (!polygonCoordinates) {
+      return undefined;
+    }
+
+    return setBounds(polygonCoordinates);
+  };
 
   const varyZoomByDelta = useCallback(
     (delta: number) => {
@@ -22,6 +34,6 @@ export const useAddtionalActions = (): UseAddtionalActionsResult => {
   return {
     zoomIn: () => varyZoomByDelta(MAP_ZOOM_IN_DELTA),
     zoomOut: () => varyZoomByDelta(MAP_ZOOM_OUT_DELTA),
-    zoomInToBioEntities: (): void => {},
+    zoomInToBioEntities,
   };
 };
diff --git a/src/components/Map/MapAdditionalActions/utils/useVisibleBioEntitiesPolygonCoordinates.test.ts b/src/components/Map/MapAdditionalActions/utils/useVisibleBioEntitiesPolygonCoordinates.test.ts
new file mode 100644
index 00000000..8f84e5d6
--- /dev/null
+++ b/src/components/Map/MapAdditionalActions/utils/useVisibleBioEntitiesPolygonCoordinates.test.ts
@@ -0,0 +1,221 @@
+import { drugsFixture } from '@/models/fixtures/drugFixtures';
+/* eslint-disable no-magic-numbers */
+import { bioEntityContentFixture } from '@/models/fixtures/bioEntityContentsFixture';
+import { chemicalsFixture } from '@/models/fixtures/chemicalsFixture';
+import { modelsFixture } from '@/models/fixtures/modelsFixture';
+import { BIOENTITY_INITIAL_STATE_MOCK } from '@/redux/bioEntity/bioEntity.mock';
+import { DRAWER_INITIAL_STATE } from '@/redux/drawer/drawer.constants';
+import { INITIAL_STORE_STATE_MOCK } from '@/redux/root/root.fixtures';
+import { RootState } from '@/redux/store';
+import { getReduxWrapperWithStore } from '@/utils/testing/getReduxWrapperWithStore';
+import { renderHook } from '@testing-library/react';
+import { CHEMICALS_INITIAL_STATE_MOCK } from '../../../../redux/chemicals/chemicals.mock';
+import { DRUGS_INITIAL_STATE_MOCK } from '../../../../redux/drugs/drugs.mock';
+import { DEFAULT_POSITION, MAIN_MAP, MAP_INITIAL_STATE } from '../../../../redux/map/map.constants';
+import { MODELS_INITIAL_STATE_MOCK } from '../../../../redux/models/models.mock';
+import { useVisibleBioEntitiesPolygonCoordinates } from './useVisibleBioEntitiesPolygonCoordinates';
+
+/* key elements of the state:
+  - this state simulates situation where there is:
+  -- one searched element
+  -- of currently selected map
+  -- for each content/chemicals/drugs data set
+
+  - the key differences in this states are x/y/z coordinates of element's bioEntities
+*/
+
+const getInitalState = (
+  { hideElements }: { hideElements: boolean } = { hideElements: false },
+): RootState => {
+  const elementsLimit = hideElements ? 0 : 1;
+
+  return {
+    ...INITIAL_STORE_STATE_MOCK,
+    drawer: {
+      ...DRAWER_INITIAL_STATE,
+      searchDrawerState: {
+        ...DRAWER_INITIAL_STATE.searchDrawerState,
+        selectedSearchElement: 'search',
+      },
+    },
+    models: {
+      ...MODELS_INITIAL_STATE_MOCK,
+      data: [
+        {
+          ...modelsFixture[0],
+          idObject: 5052,
+        },
+      ],
+    },
+    map: {
+      ...MAP_INITIAL_STATE,
+      data: {
+        ...MAP_INITIAL_STATE.data,
+        modelId: 5052,
+        size: {
+          width: 256,
+          height: 256,
+          tileSize: 256,
+          minZoom: 1,
+          maxZoom: 1,
+        },
+      },
+      openedMaps: [{ modelId: 5052, modelName: MAIN_MAP, lastPosition: DEFAULT_POSITION }],
+    },
+    bioEntity: {
+      ...BIOENTITY_INITIAL_STATE_MOCK,
+      data: [
+        {
+          searchQueryElement: 'search',
+          data: [
+            {
+              ...bioEntityContentFixture,
+              bioEntity: {
+                ...bioEntityContentFixture.bioEntity,
+                model: 5052,
+                x: 16,
+                y: 16,
+                z: 1,
+              },
+            },
+          ].slice(0, elementsLimit),
+          loading: 'succeeded',
+          error: { message: '', name: '' },
+        },
+      ],
+    },
+    chemicals: {
+      ...CHEMICALS_INITIAL_STATE_MOCK,
+      data: [
+        {
+          searchQueryElement: 'search',
+          data: [
+            {
+              ...chemicalsFixture[0],
+              targets: [
+                {
+                  ...chemicalsFixture[0].targets[0],
+                  targetElements: [
+                    {
+                      ...chemicalsFixture[0].targets[0].targetElements[0],
+                      model: 5052,
+                      x: 32,
+                      y: 32,
+                      z: 1,
+                    },
+                  ],
+                },
+              ],
+            },
+          ].slice(0, elementsLimit),
+          loading: 'succeeded',
+          error: { message: '', name: '' },
+        },
+        {
+          searchQueryElement: 'not-search',
+          data: [
+            {
+              ...chemicalsFixture[0],
+              targets: [
+                {
+                  ...chemicalsFixture[0].targets[0],
+                  targetElements: [
+                    {
+                      ...chemicalsFixture[0].targets[0].targetElements[0],
+                      model: 5052,
+                      x: 8,
+                      y: 2,
+                      z: 9,
+                    },
+                  ],
+                },
+              ],
+            },
+          ].slice(0, elementsLimit),
+          loading: 'succeeded',
+          error: { message: '', name: '' },
+        },
+      ],
+    },
+    drugs: {
+      ...DRUGS_INITIAL_STATE_MOCK,
+      data: [
+        {
+          searchQueryElement: 'search',
+          data: [
+            {
+              ...drugsFixture[0],
+              targets: [
+                {
+                  ...drugsFixture[0].targets[0],
+                  targetElements: [
+                    {
+                      ...drugsFixture[0].targets[0].targetElements[0],
+                      model: 5052,
+                      x: 128,
+                      y: 128,
+                      z: 1,
+                    },
+                  ],
+                },
+              ],
+            },
+          ].slice(0, elementsLimit),
+          loading: 'succeeded',
+          error: { message: '', name: '' },
+        },
+        {
+          searchQueryElement: 'not-search',
+          data: [
+            {
+              ...drugsFixture[0],
+              targets: [
+                {
+                  ...drugsFixture[0].targets[0],
+                  targetElements: [
+                    {
+                      ...drugsFixture[0].targets[0].targetElements[0],
+                      model: 5052,
+                      x: 100,
+                      y: 50,
+                      z: 4,
+                    },
+                  ],
+                },
+              ],
+            },
+          ].slice(0, elementsLimit),
+          loading: 'succeeded',
+          error: { message: '', name: '' },
+        },
+      ],
+    },
+  };
+};
+
+describe('useVisibleBioEntitiesPolygonCoordinates - hook', () => {
+  describe('when allVisibleBioEntities is empty', () => {
+    const { Wrapper } = getReduxWrapperWithStore(getInitalState({ hideElements: true }));
+    const { result } = renderHook(() => useVisibleBioEntitiesPolygonCoordinates(), {
+      wrapper: Wrapper,
+    });
+
+    it('should return undefined', () => {
+      expect(result.current).toBe(undefined);
+    });
+  });
+
+  describe('when allVisibleBioEntities has data', () => {
+    const { Wrapper } = getReduxWrapperWithStore(getInitalState());
+    const { result } = renderHook(() => useVisibleBioEntitiesPolygonCoordinates(), {
+      wrapper: Wrapper,
+    });
+
+    it('should return undefined', () => {
+      expect(result.current).toStrictEqual([
+        [-17532820, -0],
+        [0, 17532820],
+      ]);
+    });
+  });
+});
diff --git a/src/components/Map/MapAdditionalActions/utils/useVisibleBioEntitiesPolygonCoordinates.ts b/src/components/Map/MapAdditionalActions/utils/useVisibleBioEntitiesPolygonCoordinates.ts
new file mode 100644
index 00000000..4fbcd551
--- /dev/null
+++ b/src/components/Map/MapAdditionalActions/utils/useVisibleBioEntitiesPolygonCoordinates.ts
@@ -0,0 +1,49 @@
+import { allVisibleBioEntitiesSelector } from '@/redux/bioEntity/bioEntity.selectors';
+import { Point } from '@/types/map';
+import { usePointToProjection } from '@/utils/map/usePointToProjection';
+import { isPointValid } from '@/utils/point/isPointValid';
+import { Coordinate } from 'ol/coordinate';
+import { useMemo } from 'react';
+import { useSelector } from 'react-redux';
+
+const VALID_POLYGON_COORDINATES_LENGTH = 2;
+
+export const useVisibleBioEntitiesPolygonCoordinates = (): Coordinate[] | undefined => {
+  const allVisibleBioEntities = useSelector(allVisibleBioEntitiesSelector);
+  const pointToProjection = usePointToProjection();
+
+  const polygonPoints = useMemo((): Point[] => {
+    const allX = allVisibleBioEntities.map(({ x }) => x);
+    const allY = allVisibleBioEntities.map(({ y }) => y);
+
+    const minX = Math.min(...allX);
+    const maxX = Math.max(...allX);
+
+    const minY = Math.min(...allY);
+    const maxY = Math.max(...allY);
+
+    const points = [
+      {
+        x: minX,
+        y: maxY,
+      },
+      {
+        x: maxX,
+        y: minY,
+      },
+    ];
+
+    return points.filter(isPointValid);
+  }, [allVisibleBioEntities]);
+
+  const polygonCoordinates = useMemo(
+    () => polygonPoints.map(point => pointToProjection(point)),
+    [polygonPoints, pointToProjection],
+  );
+
+  if (polygonCoordinates.length !== VALID_POLYGON_COORDINATES_LENGTH) {
+    return undefined;
+  }
+
+  return polygonCoordinates;
+};
diff --git a/src/components/Map/MapViewer/MapViewer.types.ts b/src/components/Map/MapViewer/MapViewer.types.ts
index 2cc15d5d..f6750e5c 100644
--- a/src/components/Map/MapViewer/MapViewer.types.ts
+++ b/src/components/Map/MapViewer/MapViewer.types.ts
@@ -1,9 +1,6 @@
-import Map from 'ol/Map';
 import View from 'ol/View';
 import BaseLayer from 'ol/layer/Base';
 
-export type MapInstance = Map | undefined;
-
 export type MapConfig = {
   view: View;
   layers: BaseLayer[];
diff --git a/src/components/Map/MapViewer/utils/config/useOlMapLayers.ts b/src/components/Map/MapViewer/utils/config/useOlMapLayers.ts
index 070d37d8..ff40ba91 100644
--- a/src/components/Map/MapViewer/utils/config/useOlMapLayers.ts
+++ b/src/components/Map/MapViewer/utils/config/useOlMapLayers.ts
@@ -1,6 +1,7 @@
 /* eslint-disable no-magic-numbers */
+import { MapInstance } from '@/types/map';
 import { useEffect } from 'react';
-import { MapConfig, MapInstance } from '../../MapViewer.types';
+import { MapConfig } from '../../MapViewer.types';
 import { useOlMapOverlaysLayer } from './overlaysLayer/useOlMapOverlaysLayer';
 import { useOlMapPinsLayer } from './pinsLayer/useOlMapPinsLayer';
 import { useOlMapReactionsLayer } from './reactionsLayer/useOlMapReactionsLayer';
diff --git a/src/components/Map/MapViewer/utils/config/useOlMapView.ts b/src/components/Map/MapViewer/utils/config/useOlMapView.ts
index 9dc00a26..4a4d9dc1 100644
--- a/src/components/Map/MapViewer/utils/config/useOlMapView.ts
+++ b/src/components/Map/MapViewer/utils/config/useOlMapView.ts
@@ -1,12 +1,12 @@
 /* eslint-disable no-magic-numbers */
 import { OPTIONS } from '@/constants/map';
 import { mapDataInitialPositionSelector } from '@/redux/map/map.selectors';
-import { Point } from '@/types/map';
+import { MapInstance, Point } from '@/types/map';
 import { usePointToProjection } from '@/utils/map/usePointToProjection';
 import { View } from 'ol';
 import { useEffect, useMemo } from 'react';
 import { useSelector } from 'react-redux';
-import { MapConfig, MapInstance } from '../../MapViewer.types';
+import { MapConfig } from '../../MapViewer.types';
 
 interface UseOlMapViewInput {
   mapInstance: MapInstance;
diff --git a/src/components/Map/MapViewer/utils/listeners/useOlMapListeners.ts b/src/components/Map/MapViewer/utils/listeners/useOlMapListeners.ts
index 5be3fd4c..5d7631ff 100644
--- a/src/components/Map/MapViewer/utils/listeners/useOlMapListeners.ts
+++ b/src/components/Map/MapViewer/utils/listeners/useOlMapListeners.ts
@@ -2,16 +2,16 @@ 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 { MapInstance } from '@/types/map';
 import { View } from 'ol';
 import { unByKey } from 'ol/Observable';
+import { Coordinate } from 'ol/coordinate';
+import { Pixel } from 'ol/pixel';
 import { useEffect, useRef } from 'react';
 import { useSelector } from 'react-redux';
 import { useDebouncedCallback } from 'use-debounce';
-import { Pixel } from 'ol/pixel';
-import { Coordinate } from 'ol/coordinate';
-import { MapInstance } from '../../MapViewer.types';
-import { onMapSingleClick } from './mapSingleClick/onMapSingleClick';
 import { onMapRightClick } from './mapRightClick/onMapRightClick';
+import { onMapSingleClick } from './mapSingleClick/onMapSingleClick';
 import { onMapPositionChange } from './onMapPositionChange';
 
 interface UseOlMapListenersInput {
diff --git a/src/components/Map/MapViewer/utils/useOlMap.ts b/src/components/Map/MapViewer/utils/useOlMap.ts
index a7ffb398..326e8ec8 100644
--- a/src/components/Map/MapViewer/utils/useOlMap.ts
+++ b/src/components/Map/MapViewer/utils/useOlMap.ts
@@ -1,6 +1,8 @@
+import { MapInstance } from '@/types/map';
+import { useMapInstance } from '@/utils/context/mapInstanceContext';
 import Map from 'ol/Map';
-import React, { MutableRefObject, useEffect, useState } from 'react';
-import { MapInstance } from '../MapViewer.types';
+import { Zoom } from 'ol/control';
+import React, { MutableRefObject, useEffect } from 'react';
 import { useOlMapLayers } from './config/useOlMapLayers';
 import { useOlMapView } from './config/useOlMapView';
 import { useOlMapListeners } from './listeners/useOlMapListeners';
@@ -17,7 +19,7 @@ type UseOlMap = (input?: UseOlMapInput) => UseOlMapOutput;
 
 export const useOlMap: UseOlMap = ({ target } = {}) => {
   const mapRef = React.useRef<null | HTMLDivElement>(null);
-  const [mapInstance, setMapInstance] = useState<MapInstance>(undefined);
+  const { mapInstance, setMapInstance } = useMapInstance();
   const view = useOlMapView({ mapInstance });
   useOlMapLayers({ mapInstance });
   useOlMapListeners({ view, mapInstance });
@@ -32,8 +34,15 @@ export const useOlMap: UseOlMap = ({ target } = {}) => {
       target: target || mapRef.current,
     });
 
+    // remove zoom controls as we are using our own
+    map.getControls().forEach(mapControl => {
+      if (mapControl instanceof Zoom) {
+        map.removeControl(mapControl);
+      }
+    });
+
     setMapInstance(currentMap => currentMap || map);
-  }, [target]);
+  }, [target, setMapInstance]);
 
   return {
     mapRef,
diff --git a/src/models/mapPoint.ts b/src/models/mapPoint.ts
new file mode 100644
index 00000000..813926d1
--- /dev/null
+++ b/src/models/mapPoint.ts
@@ -0,0 +1,9 @@
+import { z } from 'zod';
+
+/* This schema is used only for local Point objects, it's NOT returned from backend */
+
+export const mapPointSchema = z.object({
+  x: z.number().finite().nonnegative(),
+  y: z.number().finite().nonnegative(),
+  z: z.number().finite().nonnegative().optional(),
+});
diff --git a/src/redux/bioEntity/bioEntity.selectors.ts b/src/redux/bioEntity/bioEntity.selectors.ts
index f3b2149d..8b39954f 100644
--- a/src/redux/bioEntity/bioEntity.selectors.ts
+++ b/src/redux/bioEntity/bioEntity.selectors.ts
@@ -3,11 +3,13 @@ import { rootSelector } from '@/redux/root/root.selectors';
 import { MultiSearchData } from '@/types/fetchDataState';
 import { BioEntity, BioEntityContent, MapModel } from '@/types/models';
 import { createSelector } from '@reduxjs/toolkit';
+import { searchedChemicalsBioEntitesOfCurrentMapSelector } from '../chemicals/chemicals.selectors';
+import { currentSelectedBioEntityIdSelector } from '../contextMenu/contextMenu.selector';
 import {
   currentSearchedBioEntityId,
   currentSelectedSearchElement,
 } from '../drawer/drawer.selectors';
-import { currentSelectedBioEntityIdSelector } from '../contextMenu/contextMenu.selector';
+import { searchedDrugsBioEntitesOfCurrentMapSelector } from '../drugs/drugs.selectors';
 import { currentModelIdSelector, modelsDataSelector } from '../models/models.selectors';
 
 export const bioEntitySelector = createSelector(rootSelector, state => state.bioEntity);
@@ -105,3 +107,12 @@ export const bioEntitiesPerModelSelector = createSelector(
     );
   },
 );
+
+export const allVisibleBioEntitiesSelector = createSelector(
+  searchedBioEntitesSelectorOfCurrentMap,
+  searchedChemicalsBioEntitesOfCurrentMapSelector,
+  searchedDrugsBioEntitesOfCurrentMapSelector,
+  (content, chemicals, drugs): BioEntity[] => {
+    return [content, chemicals, drugs].flat();
+  },
+);
diff --git a/src/types/map.ts b/src/types/map.ts
index 8dedc23f..81013f96 100644
--- a/src/types/map.ts
+++ b/src/types/map.ts
@@ -1,3 +1,5 @@
+import Map from 'ol/Map';
+
 export interface Point {
   x: number;
   y: number;
@@ -5,3 +7,5 @@ export interface Point {
 }
 
 export type LatLng = [number, number];
+
+export type MapInstance = Map | undefined;
diff --git a/src/types/mapLayers.ts b/src/types/mapLayers.ts
new file mode 100644
index 00000000..d5b7bb6a
--- /dev/null
+++ b/src/types/mapLayers.ts
@@ -0,0 +1,15 @@
+/* excluded from map.ts due to depenceny cycle */
+
+import { useOlMapOverlaysLayer } from '@/components/Map/MapViewer/utils/config/overlaysLayer/useOlMapOverlaysLayer';
+import { useOlMapPinsLayer } from '@/components/Map/MapViewer/utils/config/pinsLayer/useOlMapPinsLayer';
+import { useOlMapReactionsLayer } from '@/components/Map/MapViewer/utils/config/reactionsLayer/useOlMapReactionsLayer';
+import { useOlMapTileLayer } from '@/components/Map/MapViewer/utils/config/useOlMapTileLayer';
+
+export type MapLayers =
+  | {
+      tileLayer: ReturnType<typeof useOlMapTileLayer>;
+      reactionsLayer: ReturnType<typeof useOlMapReactionsLayer>;
+      pinsLayer: ReturnType<typeof useOlMapPinsLayer>;
+      overlaysLayer: ReturnType<typeof useOlMapOverlaysLayer>;
+    }
+  | undefined;
diff --git a/src/utils/context/mapInstanceContext.tsx b/src/utils/context/mapInstanceContext.tsx
new file mode 100644
index 00000000..1c0982d8
--- /dev/null
+++ b/src/utils/context/mapInstanceContext.tsx
@@ -0,0 +1,40 @@
+import { MapInstance } from '@/types/map';
+import { Dispatch, SetStateAction, createContext, useContext, useMemo, useState } from 'react';
+
+export interface MapInstanceContext {
+  mapInstance: MapInstance;
+  setMapInstance: Dispatch<SetStateAction<MapInstance>>;
+}
+
+export const MapInstanceContext = createContext<MapInstanceContext>({
+  mapInstance: undefined,
+  setMapInstance: () => {},
+});
+
+export const useMapInstance = (): MapInstanceContext => useContext(MapInstanceContext);
+
+export interface MapInstanceProviderProps {
+  children: React.ReactNode;
+  initialValue?: MapInstanceContext;
+}
+
+export const MapInstanceProvider = ({
+  children,
+  initialValue,
+}: MapInstanceProviderProps): JSX.Element => {
+  const [mapInstance, setMapInstance] = useState<MapInstance>(initialValue?.mapInstance);
+
+  const mapInstanceContextValue = useMemo(
+    () => ({
+      mapInstance,
+      setMapInstance,
+    }),
+    [mapInstance],
+  );
+
+  return (
+    <MapInstanceContext.Provider value={mapInstanceContextValue}>
+      {children}
+    </MapInstanceContext.Provider>
+  );
+};
diff --git a/src/utils/map/useSetBounds.test.ts b/src/utils/map/useSetBounds.test.ts
new file mode 100644
index 00000000..4e00469d
--- /dev/null
+++ b/src/utils/map/useSetBounds.test.ts
@@ -0,0 +1,109 @@
+/* eslint-disable no-magic-numbers */
+import { ONE } from '@/constants/common';
+import { MAP_DATA_INITIAL_STATE } from '@/redux/map/map.constants';
+import { renderHook } from '@testing-library/react';
+import { Map } from 'ol';
+import { Coordinate } from 'ol/coordinate';
+import { getReduxWrapperWithStore } from '../testing/getReduxWrapperWithStore';
+import { useSetBounds } from './useSetBounds';
+
+describe('useSetBounds - hook', () => {
+  const coordinates: Coordinate[] = [
+    [128, 128],
+    [192, 192],
+  ];
+
+  describe('when mapInstance is not set', () => {
+    it('setBounds should return void', () => {
+      const { Wrapper } = getReduxWrapperWithStore(
+        {
+          map: {
+            data: {
+              ...MAP_DATA_INITIAL_STATE,
+              size: {
+                width: 256,
+                height: 256,
+                tileSize: 256,
+                minZoom: 1,
+                maxZoom: 1,
+              },
+            },
+            loading: 'idle',
+            error: {
+              name: '',
+              message: '',
+            },
+            openedMaps: [],
+          },
+        },
+        {
+          mapInstanceContextValue: {
+            mapInstance: undefined,
+            setMapInstance: () => {},
+          },
+        },
+      );
+
+      const {
+        result: { current: setBounds },
+      } = renderHook(() => useSetBounds(), { wrapper: Wrapper });
+
+      expect(setBounds(coordinates)).toBe(undefined);
+    });
+  });
+
+  describe('when mapInstance is set', () => {
+    const dummyElement = document.createElement('div');
+    const mapInstance = new Map({ target: dummyElement });
+    const view = mapInstance.getView();
+    const getViewSpy = jest.spyOn(mapInstance, 'getView');
+    const fitSpy = jest.spyOn(view, 'fit');
+
+    it('setBounds should set  return void', () => {
+      const { Wrapper } = getReduxWrapperWithStore(
+        {
+          map: {
+            data: {
+              ...MAP_DATA_INITIAL_STATE,
+              size: {
+                width: 256,
+                height: 256,
+                tileSize: 256,
+                minZoom: 1,
+                maxZoom: 1,
+              },
+            },
+            loading: 'idle',
+            error: {
+              name: '',
+              message: '',
+            },
+            openedMaps: [],
+          },
+        },
+        {
+          mapInstanceContextValue: {
+            mapInstance,
+            setMapInstance: () => {},
+          },
+        },
+      );
+
+      const {
+        result: { current: setBounds },
+      } = renderHook(() => useSetBounds(), { wrapper: Wrapper });
+
+      expect(setBounds(coordinates)).toStrictEqual({
+        extent: [128, 128, 192, 192],
+        options: { maxZoom: 1, padding: [128, 128, 128, 128], size: undefined },
+        // size is real size on the screen, so it'll be undefined in the jest
+      });
+      expect(getViewSpy).toHaveBeenCalledTimes(ONE);
+      expect(fitSpy).toHaveBeenCalledWith([128, 128, 192, 192], {
+        maxZoom: 1,
+        padding: [128, 128, 128, 128],
+        size: undefined,
+      });
+    });
+  });
+});
diff --git a/src/utils/map/useSetBounds.ts b/src/utils/map/useSetBounds.ts
new file mode 100644
index 00000000..29ee4727
--- /dev/null
+++ b/src/utils/map/useSetBounds.ts
@@ -0,0 +1,48 @@
+import { HALF } from '@/constants/dividers';
+import { DEFAULT_TILE_SIZE } from '@/constants/map';
+import { mapDataSizeSelector } from '@/redux/map/map.selectors';
+import { MapInstance } from '@/types/map';
+import { FitOptions } from 'ol/View';
+import { Coordinate } from 'ol/coordinate';
+import { Extent, boundingExtent } from 'ol/extent';
+import { useSelector } from 'react-redux';
+import { useMapInstance } from '../context/mapInstanceContext';
+
+export interface SetBoundsResult {
+  extent: Extent;
+  options: FitOptions;
+}
+
+type SetBounds = (coordinates: Coordinate[]) => SetBoundsResult | undefined;
+
+const BOUNDS_PADDING = DEFAULT_TILE_SIZE / HALF;
+const DEFAULT_PADDING = [BOUNDS_PADDING, BOUNDS_PADDING, BOUNDS_PADDING, BOUNDS_PADDING];
+
+/* prettier-ignore */
+export const handleSetBounds =
+    (mapInstance: MapInstance, maxZoom: number, coordinates: Coordinate[]): SetBoundsResult | undefined => {
+      if (!mapInstance) {
+        return undefined;
+      }
+
+      const extent = boundingExtent(coordinates);
+
+      const options: FitOptions = {
+        size: mapInstance.getSize(),
+        padding: DEFAULT_PADDING,
+        maxZoom,
+      };
+
+      mapInstance.getView().fit(extent, options);
+      return { extent, options };
+    };
+
+export const useSetBounds = (): SetBounds => {
+  const { mapInstance } = useMapInstance();
+  const { maxZoom } = useSelector(mapDataSizeSelector);
+
+  const setBounds = (coordinates: Coordinate[]): SetBoundsResult | undefined =>
+    handleSetBounds(mapInstance, maxZoom, coordinates);
+
+  return setBounds;
+};
diff --git a/src/utils/point/isPointValid.test.ts b/src/utils/point/isPointValid.test.ts
new file mode 100644
index 00000000..fa5a1809
--- /dev/null
+++ b/src/utils/point/isPointValid.test.ts
@@ -0,0 +1,21 @@
+/* eslint-disable no-magic-numbers */
+import { Point } from '@/types/map';
+import { isPointValid } from './isPointValid';
+
+describe('isPointValid - util', () => {
+  const cases = [
+    [true, 1, 1, undefined], // x, y valid, z undefined
+    [true, 1, 1, 1], // x, y, z valid
+    [false, 1, undefined, 1], // y undefined
+    [false, undefined, 1, 1], // x undefined
+    [false, undefined, undefined, 1], // x, y undefined
+    [false, 1, -1, 1], // y negative
+    [false, -1, 1, 1], // x negative
+    [false, -1, -1, 1], // x, y negative
+    [false, -1, -1, -1], // x, y, z negative
+  ];
+
+  it.each(cases)('should return %s for point x=%s, y=%s, z=%s', (result, x, y, z) => {
+    expect(isPointValid({ x, y, z } as Point)).toBe(result);
+  });
+});
diff --git a/src/utils/point/isPointValid.ts b/src/utils/point/isPointValid.ts
new file mode 100644
index 00000000..f3db3d22
--- /dev/null
+++ b/src/utils/point/isPointValid.ts
@@ -0,0 +1,7 @@
+import { mapPointSchema } from '@/models/mapPoint';
+import { Point } from '@/types/map';
+
+export const isPointValid = (point: Point): boolean => {
+  const { success } = mapPointSchema.safeParse(point);
+  return success;
+};
diff --git a/src/utils/testing/getReduxWrapperWithStore.tsx b/src/utils/testing/getReduxWrapperWithStore.tsx
index d1f0c3df..18c3beb8 100644
--- a/src/utils/testing/getReduxWrapperWithStore.tsx
+++ b/src/utils/testing/getReduxWrapperWithStore.tsx
@@ -1,20 +1,29 @@
 import { RootState, StoreType, middlewares, reducers } from '@/redux/store';
 import { configureStore } from '@reduxjs/toolkit';
 import { Provider } from 'react-redux';
+import { MapInstanceContext, MapInstanceProvider } from '../context/mapInstanceContext';
 
 interface WrapperProps {
   children: React.ReactNode;
 }
 
 export type InitialStoreState = Partial<RootState>;
+export type ReduxComponentWrapper = ({ children }: WrapperProps) => JSX.Element;
+export interface Options {
+  mapInstanceContextValue?: MapInstanceContext;
+}
 
-export type GetReduxWrapperUsingSliceReducer = (initialState?: InitialStoreState) => {
-  Wrapper: ({ children }: WrapperProps) => JSX.Element;
+export type GetReduxWrapperUsingSliceReducer = (
+  initialState?: InitialStoreState,
+  options?: Options,
+) => {
+  Wrapper: ReduxComponentWrapper;
   store: StoreType;
 };
 
 export const getReduxWrapperWithStore: GetReduxWrapperUsingSliceReducer = (
   preloadedState: InitialStoreState = {},
+  options: Options = {},
 ) => {
   const testStore = configureStore({
     reducer: reducers,
@@ -23,7 +32,9 @@ export const getReduxWrapperWithStore: GetReduxWrapperUsingSliceReducer = (
   });
 
   const Wrapper = ({ children }: WrapperProps): JSX.Element => (
-    <Provider store={testStore}>{children}</Provider>
+    <MapInstanceProvider initialValue={options.mapInstanceContextValue}>
+      <Provider store={testStore}>{children}</Provider>
+    </MapInstanceProvider>
   );
 
   return { Wrapper, store: testStore };
-- 
GitLab