From 037ef8d3561dc00d06b16da6995dedce7a6fe9b2 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Adrian=20Or=C5=82=C3=B3w?= <adrian.orlow@fishbrain.com>
Date: Thu, 2 Nov 2023 04:03:11 +0100
Subject: [PATCH] feat: add global query manager [DRAFT]

---
 .env                                          |   4 +-
 package-lock.json                             |  21 ++++
 package.json                                  |   2 +
 .../Map/MapViewer/MapViewer.types.ts          |   7 ++
 .../utils/config/useOlMapConfig.test.ts       |   9 --
 .../MapViewer/utils/config/useOlMapConfig.ts  |  66 -----------
 .../utils/config/useOlMapLayers.test.ts       |  94 +++++++++++++++
 .../MapViewer/utils/config/useOlMapLayers.ts  |  57 ++++++++++
 .../utils/config/useOlMapView.test.ts         | 107 ++++++++++++++++++
 .../MapViewer/utils/config/useOlMapView.ts    |  53 +++++++++
 .../utils/listeners/onMapPositionChange.ts    |  28 +++++
 .../utils/listeners/useOlMapListeners.test.ts |  39 +++++++
 .../utils/listeners/useOlMapListeners.ts      |  28 +++++
 .../Map/MapViewer/utils/useOlMap.test.ts      |  45 +-------
 .../Map/MapViewer/utils/useOlMap.ts           |  17 +--
 src/components/SPA/MinervaSPA.component.tsx   |   4 +-
 src/constants/map.ts                          |   2 +
 src/constants/time.ts                         |   1 +
 src/redux/map/map.constants.ts                |   5 +-
 src/redux/map/map.reducers.ts                 |  17 ++-
 src/redux/map/map.selectors.ts                |  14 ++-
 src/redux/map/map.thunks.test.ts              |  13 ++-
 src/redux/map/map.thunks.ts                   |  53 +++++----
 src/redux/map/map.types.ts                    |  26 ++++-
 src/redux/map/middleware/map.middleware.ts    |  10 +-
 src/redux/root/init.selectors.ts              |  10 ++
 src/redux/root/query.selectors.ts             |  12 ++
 src/types/query.ts                            |  15 +++
 src/types/utils.ts                            |   3 +
 .../initialize}/useInitializeStore.test.ts    |   0
 .../initialize}/useInitializeStore.ts         |  23 +++-
 src/utils/map/getPointOffset.ts               |  16 ++-
 src/utils/map/getUpdatedMapData.test.ts       |  39 +++++--
 src/utils/map/getUpdatedMapData.ts            |  49 +++++---
 src/utils/map/latLngToPoint.ts                |  40 +++++++
 src/utils/map/pointToLatLng.test.ts           |   8 +-
 src/utils/map/pointToLatLng.ts                |   2 +-
 src/utils/map/usePointToProjection.test.tsx   |   8 +-
 src/utils/map/usePointToProjection.ts         |   9 +-
 src/utils/number/boundNumber.ts               |   4 +
 src/utils/number/degreesToRadians.ts          |   5 +
 src/utils/object/getPointMerged.ts            |   7 ++
 .../object/getTruthyObjectOrUndefined.ts      |  11 ++
 src/utils/query-manager/getQueryData.ts       |  24 ++++
 .../query-manager/useReduxBusQueryManager.ts  |  38 +++++++
 yarn.lock                                     |  12 +-
 46 files changed, 843 insertions(+), 214 deletions(-)
 delete mode 100644 src/components/Map/MapViewer/utils/config/useOlMapConfig.test.ts
 delete mode 100644 src/components/Map/MapViewer/utils/config/useOlMapConfig.ts
 create mode 100644 src/components/Map/MapViewer/utils/config/useOlMapLayers.test.ts
 create mode 100644 src/components/Map/MapViewer/utils/config/useOlMapLayers.ts
 create mode 100644 src/components/Map/MapViewer/utils/config/useOlMapView.test.ts
 create mode 100644 src/components/Map/MapViewer/utils/config/useOlMapView.ts
 create mode 100644 src/components/Map/MapViewer/utils/listeners/onMapPositionChange.ts
 create mode 100644 src/components/Map/MapViewer/utils/listeners/useOlMapListeners.test.ts
 create mode 100644 src/components/Map/MapViewer/utils/listeners/useOlMapListeners.ts
 create mode 100644 src/constants/time.ts
 create mode 100644 src/redux/root/query.selectors.ts
 create mode 100644 src/types/query.ts
 create mode 100644 src/types/utils.ts
 rename src/{components/SPA/utils => utils/initialize}/useInitializeStore.test.ts (100%)
 rename src/{components/SPA/utils => utils/initialize}/useInitializeStore.ts (56%)
 create mode 100644 src/utils/map/latLngToPoint.ts
 create mode 100644 src/utils/number/boundNumber.ts
 create mode 100644 src/utils/number/degreesToRadians.ts
 create mode 100644 src/utils/object/getPointMerged.ts
 create mode 100644 src/utils/object/getTruthyObjectOrUndefined.ts
 create mode 100644 src/utils/query-manager/getQueryData.ts
 create mode 100644 src/utils/query-manager/useReduxBusQueryManager.ts

diff --git a/.env b/.env
index 470be3d7..b3e48b42 100644
--- a/.env
+++ b/.env
@@ -1,6 +1,6 @@
 
-NEXT_PUBLIC_BASE_API_URL = 'https://corsproxy.io/?https://lux1.atcomp.pl/minerva/api'
-NEXT_PUBLIC_BASE_NEW_API_URL = 'https://corsproxy.io/?https://lux1.atcomp.pl/minerva/new_api/'
+NEXT_PUBLIC_BASE_API_URL = 'https://lux1.atcomp.pl/minerva/api'
+NEXT_PUBLIC_BASE_NEW_API_URL = 'https://lux1.atcomp.pl/minerva/new_api/'
 BASE_MAP_IMAGES_URL = 'https://lux1.atcomp.pl/'
 NEXT_PUBLIC_PROJECT_ID = 'pdmap_appu_test'
 ZOD_SEED = 997
diff --git a/package-lock.json b/package-lock.json
index f41b1125..79ab1fa8 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -29,6 +29,8 @@
         "react-redux": "^8.1.2",
         "tailwind-merge": "^1.14.0",
         "tailwindcss": "3.3.3",
+        "ts-deepmerge": "^6.2.0",
+        "use-debounce": "^9.0.4",
         "zod": "^3.22.2"
       },
       "devDependencies": {
@@ -12316,6 +12318,14 @@
         "typescript": ">=4.2.0"
       }
     },
+    "node_modules/ts-deepmerge": {
+      "version": "6.2.0",
+      "resolved": "https://registry.npmjs.org/ts-deepmerge/-/ts-deepmerge-6.2.0.tgz",
+      "integrity": "sha512-2qxI/FZVDPbzh63GwWIZYE7daWKtwXZYuyc8YNq0iTmMUwn4mL0jRLsp6hfFlgbdRSR4x2ppe+E86FnvEpN7Nw==",
+      "engines": {
+        "node": ">=14.13.1"
+      }
+    },
     "node_modules/ts-interface-checker": {
       "version": "0.1.13",
       "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
@@ -12627,6 +12637,17 @@
         "requires-port": "^1.0.0"
       }
     },
+    "node_modules/use-debounce": {
+      "version": "9.0.4",
+      "resolved": "https://registry.npmjs.org/use-debounce/-/use-debounce-9.0.4.tgz",
+      "integrity": "sha512-6X8H/mikbrt0XE8e+JXRtZ8yYVvKkdYRfmIhWZYsP8rcNs9hk3APV8Ua2mFkKRLcJKVdnX2/Vwrmg2GWKUQEaQ==",
+      "engines": {
+        "node": ">= 10.0.0"
+      },
+      "peerDependencies": {
+        "react": ">=16.8.0"
+      }
+    },
     "node_modules/use-sync-external-store": {
       "version": "1.2.0",
       "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz",
diff --git a/package.json b/package.json
index c6c19cd6..cc259882 100644
--- a/package.json
+++ b/package.json
@@ -43,6 +43,8 @@
     "react-redux": "^8.1.2",
     "tailwind-merge": "^1.14.0",
     "tailwindcss": "3.3.3",
+    "ts-deepmerge": "^6.2.0",
+    "use-debounce": "^9.0.4",
     "zod": "^3.22.2"
   },
   "devDependencies": {
diff --git a/src/components/Map/MapViewer/MapViewer.types.ts b/src/components/Map/MapViewer/MapViewer.types.ts
index babe85b1..2cc15d5d 100644
--- a/src/components/Map/MapViewer/MapViewer.types.ts
+++ b/src/components/Map/MapViewer/MapViewer.types.ts
@@ -1,3 +1,10 @@
 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/useOlMapConfig.test.ts b/src/components/Map/MapViewer/utils/config/useOlMapConfig.test.ts
deleted file mode 100644
index e2873af0..00000000
--- a/src/components/Map/MapViewer/utils/config/useOlMapConfig.test.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-describe('useOlMapConfig - util', () => {
-  // TODO: tests
-  // TileLayer is mocked in the file, so we need to firstly wait for module API connection
-
-  it('noop', () => {
-    // eslint-disable-next-line no-magic-numbers
-    expect(1).toEqual(1);
-  });
-});
diff --git a/src/components/Map/MapViewer/utils/config/useOlMapConfig.ts b/src/components/Map/MapViewer/utils/config/useOlMapConfig.ts
deleted file mode 100644
index 2a584d87..00000000
--- a/src/components/Map/MapViewer/utils/config/useOlMapConfig.ts
+++ /dev/null
@@ -1,66 +0,0 @@
-/* eslint-disable no-magic-numbers */
-import { OPTIONS } from '@/constants/map';
-import { currentBackgroundImagePathSelector } from '@/redux/backgrounds/background.selectors';
-import { mapDataPositionSelector, mapDataSizeSelector } from '@/redux/map/map.selectors';
-import { projectDataSelector } from '@/redux/project/project.selectors';
-import { Point } from '@/types/map';
-import { usePointToProjection } from '@/utils/map/usePointToProjection';
-import { View } from 'ol';
-import BaseLayer from 'ol/layer/Base';
-import TileLayer from 'ol/layer/Tile';
-import { XYZ } from 'ol/source';
-import { useMemo } from 'react';
-import { useSelector } from 'react-redux';
-import { getMapTileUrl } from './getMapTileUrl';
-
-interface UseOlMapConfigResult {
-  view: View;
-  layers: BaseLayer[];
-}
-
-export const useOlMapConfig = (): UseOlMapConfigResult => {
-  const mapPosition = useSelector(mapDataPositionSelector);
-  const mapSize = useSelector(mapDataSizeSelector);
-  const currentBackgroundImagePath = useSelector(currentBackgroundImagePathSelector);
-  const project = useSelector(projectDataSelector);
-  const pointToProjection = usePointToProjection();
-
-  const center = useMemo(() => {
-    const centerPoint: Point = {
-      x: mapPosition.x,
-      y: mapPosition.y,
-    };
-
-    return pointToProjection(centerPoint);
-  }, [mapPosition, pointToProjection]);
-
-  const view = useMemo(
-    () =>
-      new View({
-        center,
-        zoom: mapPosition.z,
-        showFullExtent: OPTIONS.showFullExtent,
-      }),
-    [center, mapPosition],
-  );
-
-  const tileLayer = useMemo(
-    (): TileLayer<XYZ> =>
-      new TileLayer({
-        visible: true,
-        source: new XYZ({
-          url: getMapTileUrl({ projectDirectory: project?.directory, currentBackgroundImagePath }),
-          maxZoom: mapSize.maxZoom,
-          minZoom: mapSize.minZoom,
-          tileSize: mapSize.tileSize,
-          wrapX: OPTIONS.wrapXInTileLayer,
-        }),
-      }),
-    [mapSize, currentBackgroundImagePath, project?.directory],
-  );
-
-  return {
-    view,
-    layers: [tileLayer],
-  };
-};
diff --git a/src/components/Map/MapViewer/utils/config/useOlMapLayers.test.ts b/src/components/Map/MapViewer/utils/config/useOlMapLayers.test.ts
new file mode 100644
index 00000000..f2c512a5
--- /dev/null
+++ b/src/components/Map/MapViewer/utils/config/useOlMapLayers.test.ts
@@ -0,0 +1,94 @@
+/* eslint-disable no-magic-numbers */
+import { MAP_DATA_INITIAL_STATE } from '@/redux/map/map.constants';
+import mapSlice from '@/redux/map/map.slice';
+import { getReduxWrapperUsingSliceReducer } from '@/utils/testing/getReduxWrapperUsingSliceReducer';
+import { getReduxWrapperWithStore } from '@/utils/testing/getReduxWrapperWithStore';
+import { renderHook, waitFor } from '@testing-library/react';
+import { Map } from 'ol';
+import TileLayer from 'ol/layer/Tile';
+import React from 'react';
+import { useOlMap } from '../useOlMap';
+import { useOlMapLayers } from './useOlMapLayers';
+
+const useRefValue = {
+  current: null,
+};
+
+Object.defineProperty(useRefValue, 'current', {
+  get: jest.fn(() => ({
+    innerHTML: '',
+    appendChild: jest.fn(),
+    addEventListener: jest.fn(),
+    getRootNode: jest.fn(),
+  })),
+  set: jest.fn(() => ({
+    innerHTML: '',
+    appendChild: jest.fn(),
+    addEventListener: jest.fn(),
+    getRootNode: jest.fn(),
+  })),
+});
+
+jest.spyOn(React, 'useRef').mockReturnValue(useRefValue);
+
+describe('useOlMapLayers - util', () => {
+  it('should modify layers of the map instance on init', async () => {
+    const { Wrapper } = getReduxWrapperUsingSliceReducer('map', mapSlice);
+    const dummyElement = document.createElement('div');
+    const mapInstance = new Map({ target: dummyElement });
+    const setLayersSpy = jest.spyOn(mapInstance, 'setLayers');
+    const CALLED_ONCE = 1;
+
+    renderHook(() => useOlMapLayers({ mapInstance }), {
+      wrapper: Wrapper,
+    });
+
+    await waitFor(() => expect(setLayersSpy).toBeCalledTimes(CALLED_ONCE));
+  });
+
+  it('should return valid View instance', async () => {
+    const { Wrapper } = getReduxWrapperWithStore({
+      map: {
+        data: {
+          ...MAP_DATA_INITIAL_STATE,
+          size: {
+            width: 256,
+            height: 256,
+            tileSize: 256,
+            minZoom: 1,
+            maxZoom: 1,
+          },
+          position: {
+            initial: {
+              x: 256,
+              y: 256,
+            },
+            last: {
+              x: 256,
+              y: 256,
+            },
+          },
+        },
+        loading: 'idle',
+        error: {
+          name: '',
+          message: '',
+        },
+      },
+    });
+    const dummyElement = document.createElement('div');
+    const { result: hohResult } = renderHook(() => useOlMap({ target: dummyElement }), {
+      wrapper: Wrapper,
+    });
+
+    const { result } = renderHook(
+      () => useOlMapLayers({ mapInstance: hohResult.current.mapInstance }),
+      {
+        wrapper: Wrapper,
+      },
+    );
+
+    expect(result.current[0]).toBeInstanceOf(TileLayer);
+    expect(result.current[0].getSourceState()).toBe('ready');
+  });
+});
diff --git a/src/components/Map/MapViewer/utils/config/useOlMapLayers.ts b/src/components/Map/MapViewer/utils/config/useOlMapLayers.ts
new file mode 100644
index 00000000..67d71d50
--- /dev/null
+++ b/src/components/Map/MapViewer/utils/config/useOlMapLayers.ts
@@ -0,0 +1,57 @@
+/* eslint-disable no-magic-numbers */
+import { OPTIONS } from '@/constants/map';
+import { currentBackgroundImagePathSelector } from '@/redux/backgrounds/background.selectors';
+import { mapDataSizeSelector } from '@/redux/map/map.selectors';
+import { projectDataSelector } from '@/redux/project/project.selectors';
+import TileLayer from 'ol/layer/Tile';
+import { XYZ } from 'ol/source';
+import { useEffect, useMemo } from 'react';
+import { useSelector } from 'react-redux';
+import { MapConfig, MapInstance } from '../../MapViewer.types';
+import { getMapTileUrl } from './getMapTileUrl';
+
+interface UseOlMapLayersInput {
+  mapInstance: MapInstance;
+}
+
+export const useOlMapLayers = ({ mapInstance }: UseOlMapLayersInput): MapConfig['layers'] => {
+  const mapSize = useSelector(mapDataSizeSelector);
+  const currentBackgroundImagePath = useSelector(currentBackgroundImagePathSelector);
+  const project = useSelector(projectDataSelector);
+
+  const sourceUrl = useMemo(
+    () => getMapTileUrl({ projectDirectory: project?.directory, currentBackgroundImagePath }),
+    [project?.directory, currentBackgroundImagePath],
+  );
+
+  const source = useMemo(
+    () =>
+      new XYZ({
+        url: sourceUrl,
+        maxZoom: mapSize.maxZoom,
+        minZoom: mapSize.minZoom,
+        tileSize: mapSize.tileSize,
+        wrapX: OPTIONS.wrapXInTileLayer,
+      }),
+    [sourceUrl, mapSize.maxZoom, mapSize.minZoom, mapSize.tileSize],
+  );
+
+  const tileLayer = useMemo(
+    (): TileLayer<XYZ> =>
+      new TileLayer({
+        visible: true,
+        source,
+      }),
+    [source],
+  );
+
+  useEffect(() => {
+    if (!mapInstance) {
+      return;
+    }
+
+    mapInstance.setLayers([tileLayer]);
+  }, [tileLayer, mapInstance]);
+
+  return [tileLayer];
+};
diff --git a/src/components/Map/MapViewer/utils/config/useOlMapView.test.ts b/src/components/Map/MapViewer/utils/config/useOlMapView.test.ts
new file mode 100644
index 00000000..8a78f734
--- /dev/null
+++ b/src/components/Map/MapViewer/utils/config/useOlMapView.test.ts
@@ -0,0 +1,107 @@
+/* eslint-disable no-magic-numbers */
+import mapSlice, { setMapData } from '@/redux/map/map.slice';
+import { getReduxWrapperUsingSliceReducer } from '@/utils/testing/getReduxWrapperUsingSliceReducer';
+import { getReduxWrapperWithStore } from '@/utils/testing/getReduxWrapperWithStore';
+import { renderHook, waitFor } from '@testing-library/react';
+import { View } from 'ol';
+import Map from 'ol/Map';
+import React from 'react';
+import { MAP_DATA_INITIAL_STATE } from '../../../../../redux/map/map.constants';
+import { useOlMap } from '../useOlMap';
+import { useOlMapView } from './useOlMapView';
+
+const useRefValue = {
+  current: null,
+};
+
+Object.defineProperty(useRefValue, 'current', {
+  get: jest.fn(() => ({
+    innerHTML: '',
+    appendChild: jest.fn(),
+    addEventListener: jest.fn(),
+    getRootNode: jest.fn(),
+  })),
+  set: jest.fn(() => ({
+    innerHTML: '',
+    appendChild: jest.fn(),
+    addEventListener: jest.fn(),
+    getRootNode: jest.fn(),
+  })),
+});
+
+jest.spyOn(React, 'useRef').mockReturnValue(useRefValue);
+
+describe('useOlMapView - util', () => {
+  it('should modify view of the map instance on INITIAL position config change', async () => {
+    const { Wrapper, store } = getReduxWrapperUsingSliceReducer('map', mapSlice);
+    const dummyElement = document.createElement('div');
+    const { result: hohResult } = renderHook(() => useOlMap({ target: dummyElement }), {
+      wrapper: Wrapper,
+    });
+    const setViewSpy = jest.spyOn(hohResult.current.mapInstance as Map, 'setView');
+    const CALLED_ONCE = 1;
+
+    store.dispatch(
+      setMapData({
+        position: {
+          initial: {
+            x: 0,
+            y: 0,
+          },
+        },
+      }),
+    );
+
+    renderHook(() => useOlMapView({ mapInstance: hohResult.current.mapInstance }), {
+      wrapper: Wrapper,
+    });
+
+    await waitFor(() => expect(setViewSpy).toBeCalledTimes(CALLED_ONCE));
+  });
+
+  it('should return valid View instance', async () => {
+    const { Wrapper } = getReduxWrapperWithStore({
+      map: {
+        data: {
+          ...MAP_DATA_INITIAL_STATE,
+          size: {
+            width: 256,
+            height: 256,
+            tileSize: 256,
+            minZoom: 1,
+            maxZoom: 1,
+          },
+          position: {
+            initial: {
+              x: 256,
+              y: 256,
+            },
+            last: {
+              x: 256,
+              y: 256,
+            },
+          },
+        },
+        loading: 'idle',
+        error: {
+          name: '',
+          message: '',
+        },
+      },
+    });
+    const dummyElement = document.createElement('div');
+    const { result: hohResult } = renderHook(() => useOlMap({ target: dummyElement }), {
+      wrapper: Wrapper,
+    });
+
+    const { result } = renderHook(
+      () => useOlMapView({ mapInstance: hohResult.current.mapInstance }),
+      {
+        wrapper: Wrapper,
+      },
+    );
+
+    expect(result.current).toBeInstanceOf(View);
+    expect(result.current.getCenter()).toStrictEqual([0, -0]);
+  });
+});
diff --git a/src/components/Map/MapViewer/utils/config/useOlMapView.ts b/src/components/Map/MapViewer/utils/config/useOlMapView.ts
new file mode 100644
index 00000000..9dc00a26
--- /dev/null
+++ b/src/components/Map/MapViewer/utils/config/useOlMapView.ts
@@ -0,0 +1,53 @@
+/* eslint-disable no-magic-numbers */
+import { OPTIONS } from '@/constants/map';
+import { mapDataInitialPositionSelector } from '@/redux/map/map.selectors';
+import { 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';
+
+interface UseOlMapViewInput {
+  mapInstance: MapInstance;
+}
+
+export const useOlMapView = ({ mapInstance }: UseOlMapViewInput): MapConfig['view'] => {
+  const mapInitialPosition = useSelector(mapDataInitialPositionSelector);
+  const pointToProjection = usePointToProjection();
+
+  const center = useMemo((): Point => {
+    const centerPoint: Point = {
+      x: mapInitialPosition.x,
+      y: mapInitialPosition.y,
+    };
+
+    const [x, y] = pointToProjection(centerPoint);
+
+    return {
+      x,
+      y,
+    };
+  }, [mapInitialPosition, pointToProjection]);
+
+  const viewConfig = useMemo(
+    () => ({
+      center: [center.x, center.y],
+      zoom: mapInitialPosition.z,
+      showFullExtent: OPTIONS.showFullExtent,
+    }),
+    [center.x, center.y, mapInitialPosition.z],
+  );
+
+  const view = useMemo(() => new View(viewConfig), [viewConfig]);
+
+  useEffect(() => {
+    if (!mapInstance) {
+      return;
+    }
+
+    mapInstance.setView(view);
+  }, [view, mapInstance]);
+
+  return view;
+};
diff --git a/src/components/Map/MapViewer/utils/listeners/onMapPositionChange.ts b/src/components/Map/MapViewer/utils/listeners/onMapPositionChange.ts
new file mode 100644
index 00000000..a0164f94
--- /dev/null
+++ b/src/components/Map/MapViewer/utils/listeners/onMapPositionChange.ts
@@ -0,0 +1,28 @@
+import { setMapData } from '@/redux/map/map.slice';
+import { MapSize } from '@/redux/map/map.types';
+import { AppDispatch } from '@/redux/store';
+import { Point } from '@/types/map';
+import { latLngToPoint } from '@/utils/map/latLngToPoint';
+import { toLonLat } from 'ol/proj';
+import { ObjectEvent } from 'openlayers';
+
+/* prettier-ignore */
+export const onMapPositionChange =
+  (mapSize: MapSize, mapPosition: Point, dispatch: AppDispatch) =>
+    (e: ObjectEvent): void => {
+      // eslint-disable-next-line no-underscore-dangle
+      const { center, zoom } = e.target.values_;
+      const [lng, lat] = toLonLat(center);
+      const value = latLngToPoint([lat, lng], mapSize, { rounded: true });
+
+      dispatch(
+        setMapData({
+          position: {
+            last: {
+              ...value,
+              z: Math.round(zoom),
+            }
+          }
+        }),
+      );
+    };
diff --git a/src/components/Map/MapViewer/utils/listeners/useOlMapListeners.test.ts b/src/components/Map/MapViewer/utils/listeners/useOlMapListeners.test.ts
new file mode 100644
index 00000000..20d0401a
--- /dev/null
+++ b/src/components/Map/MapViewer/utils/listeners/useOlMapListeners.test.ts
@@ -0,0 +1,39 @@
+/* eslint-disable @typescript-eslint/explicit-function-return-type */
+import mapSlice from '@/redux/map/map.slice';
+import { getReduxWrapperUsingSliceReducer } from '@/utils/testing/getReduxWrapperUsingSliceReducer';
+import { renderHook } from '@testing-library/react';
+import { View } from 'ol';
+import * as positionListener from './onMapPositionChange';
+import { useOlMapListeners } from './useOlMapListeners';
+
+jest.mock('./onMapPositionChange', () => ({
+  __esModule: true,
+  onMapPositionChange: jest.fn(),
+}));
+
+jest.mock('use-debounce', () => {
+  return {
+    useDebounce: () => {},
+    useDebouncedCallback: () => {},
+  };
+});
+
+describe('useOlMapListeners - util', () => {
+  const { Wrapper } = getReduxWrapperUsingSliceReducer('map', mapSlice);
+
+  beforeEach(() => {
+    jest.clearAllMocks();
+  });
+
+  describe('on change:center view event', () => {
+    it('should run onMapPositionChange event', () => {
+      const view = new View();
+      const CALLED_ONCE = 1;
+
+      renderHook(() => useOlMapListeners({ view }), { wrapper: Wrapper });
+      view.dispatchEvent('change:center');
+
+      expect(positionListener.onMapPositionChange).toBeCalledTimes(CALLED_ONCE);
+    });
+  });
+});
diff --git a/src/components/Map/MapViewer/utils/listeners/useOlMapListeners.ts b/src/components/Map/MapViewer/utils/listeners/useOlMapListeners.ts
new file mode 100644
index 00000000..ec886195
--- /dev/null
+++ b/src/components/Map/MapViewer/utils/listeners/useOlMapListeners.ts
@@ -0,0 +1,28 @@
+import { OPTIONS } from '@/constants/map';
+import { useAppDispatch } from '@/redux/hooks/useAppDispatch';
+import { mapDataLastPositionSelector, mapDataSizeSelector } from '@/redux/map/map.selectors';
+import { View } from 'ol';
+import { useEffect } from 'react';
+import { useSelector } from 'react-redux';
+import { useDebouncedCallback } from 'use-debounce';
+import { onMapPositionChange } from './onMapPositionChange';
+
+interface UseOlMapListenersInput {
+  view: View;
+}
+
+export const useOlMapListeners = ({ view }: UseOlMapListenersInput): void => {
+  const mapSize = useSelector(mapDataSizeSelector);
+  const mapLastPosition = useSelector(mapDataLastPositionSelector);
+  const dispatch = useAppDispatch();
+
+  const handleChangeCenter = useDebouncedCallback(
+    onMapPositionChange(mapSize, mapLastPosition, dispatch),
+    OPTIONS.queryPersistTime,
+    { leading: false },
+  );
+
+  useEffect(() => {
+    view.on('change:center', handleChangeCenter);
+  }, [view, handleChangeCenter]);
+};
diff --git a/src/components/Map/MapViewer/utils/useOlMap.test.ts b/src/components/Map/MapViewer/utils/useOlMap.test.ts
index c606b77a..29cf1fc0 100644
--- a/src/components/Map/MapViewer/utils/useOlMap.test.ts
+++ b/src/components/Map/MapViewer/utils/useOlMap.test.ts
@@ -1,4 +1,4 @@
-import mapSlice, { setMapData } from '@/redux/map/map.slice';
+import mapSlice from '@/redux/map/map.slice';
 import { getReduxWrapperUsingSliceReducer } from '@/utils/testing/getReduxWrapperUsingSliceReducer';
 import { renderHook, waitFor } from '@testing-library/react';
 import { Map } from 'ol';
@@ -27,7 +27,7 @@ Object.defineProperty(useRefValue, 'current', {
 jest.spyOn(React, 'useRef').mockReturnValue(useRefValue);
 
 describe('useOlMap - util', () => {
-  const { Wrapper, store } = getReduxWrapperUsingSliceReducer('map', mapSlice);
+  const { Wrapper } = getReduxWrapperUsingSliceReducer('map', mapSlice);
 
   describe('when initializing', () => {
     it('should set map instance', async () => {
@@ -44,45 +44,4 @@ describe('useOlMap - util', () => {
       expect(dummyElement.childNodes[FIRST_NODE]).toHaveClass('ol-viewport');
     });
   });
-
-  describe('when initialized', () => {
-    it('should modify view of the map instance on position config change', async () => {
-      const dummyElement = document.createElement('div');
-      const { result } = renderHook(() => useOlMap({ target: dummyElement }), { wrapper: Wrapper });
-      const setViewSpy = jest.spyOn(result.current.mapInstance as Map, 'setView');
-      const CALLED_ONCE = 1;
-
-      store.dispatch(
-        setMapData({
-          position: {
-            x: 0,
-            y: 0,
-          },
-        }),
-      );
-
-      await waitFor(() => expect(setViewSpy).toBeCalledTimes(CALLED_ONCE));
-    });
-
-    it('should modify layers of the map instance on size config change', async () => {
-      const dummyElement = document.createElement('div');
-      const { result } = renderHook(() => useOlMap({ target: dummyElement }), { wrapper: Wrapper });
-      const setLayersSpy = jest.spyOn(result.current.mapInstance as Map, 'setLayers');
-      const CALLED_ONCE = 1;
-
-      store.dispatch(
-        setMapData({
-          size: {
-            maxZoom: 10,
-            minZoom: 2,
-            tileSize: 256,
-            width: 1000,
-            height: 1000,
-          },
-        }),
-      );
-
-      await waitFor(() => expect(setLayersSpy).toBeCalledTimes(CALLED_ONCE));
-    });
-  });
 });
diff --git a/src/components/Map/MapViewer/utils/useOlMap.ts b/src/components/Map/MapViewer/utils/useOlMap.ts
index ca82591a..a80407e3 100644
--- a/src/components/Map/MapViewer/utils/useOlMap.ts
+++ b/src/components/Map/MapViewer/utils/useOlMap.ts
@@ -1,7 +1,9 @@
 import Map from 'ol/Map';
 import React, { MutableRefObject, useEffect, useState } from 'react';
 import { MapInstance } from '../MapViewer.types';
-import { useOlMapConfig } from './config/useOlMapConfig';
+import { useOlMapLayers } from './config/useOlMapLayers';
+import { useOlMapView } from './config/useOlMapView';
+import { useOlMapListeners } from './listeners/useOlMapListeners';
 
 interface UseOlMapInput {
   target?: HTMLElement;
@@ -16,7 +18,9 @@ type UseOlMap = (input?: UseOlMapInput) => UseOlMapOutput;
 export const useOlMap: UseOlMap = ({ target } = {}) => {
   const mapRef = React.useRef<null | HTMLDivElement>(null);
   const [mapInstance, setMapInstance] = useState<MapInstance>(undefined);
-  const mapConfig = useOlMapConfig();
+  const view = useOlMapView({ mapInstance });
+  useOlMapLayers({ mapInstance });
+  useOlMapListeners({ view });
 
   useEffect(() => {
     // checking if innerHTML is empty due to possibility of target element cloning by openlayers map instance
@@ -31,15 +35,6 @@ export const useOlMap: UseOlMap = ({ target } = {}) => {
     setMapInstance(currentMap => currentMap || map);
   }, [target]);
 
-  useEffect(() => {
-    if (!mapInstance) {
-      return;
-    }
-
-    mapInstance.setView(mapConfig.view);
-    mapInstance.setLayers(mapConfig.layers);
-  }, [mapConfig, mapInstance]);
-
   return {
     mapRef,
     mapInstance,
diff --git a/src/components/SPA/MinervaSPA.component.tsx b/src/components/SPA/MinervaSPA.component.tsx
index 856e3487..4de3ca1f 100644
--- a/src/components/SPA/MinervaSPA.component.tsx
+++ b/src/components/SPA/MinervaSPA.component.tsx
@@ -1,8 +1,9 @@
 import { FunctionalArea } from '@/components/FunctionalArea';
 import { Map } from '@/components/Map';
+import { useReduxBusQueryManager } from '@/utils/query-manager/useReduxBusQueryManager';
 import { Manrope } from '@next/font/google';
 import { twMerge } from 'tailwind-merge';
-import { useInitializeStore } from './utils/useInitializeStore';
+import { useInitializeStore } from '../../utils/initialize/useInitializeStore';
 
 const manrope = Manrope({
   variable: '--font-manrope',
@@ -13,6 +14,7 @@ const manrope = Manrope({
 
 export const MinervaSPA = (): JSX.Element => {
   useInitializeStore();
+  useReduxBusQueryManager();
 
   return (
     <div className={twMerge('relative', manrope.variable)}>
diff --git a/src/constants/map.ts b/src/constants/map.ts
index 83fbec09..765b30b0 100644
--- a/src/constants/map.ts
+++ b/src/constants/map.ts
@@ -1,5 +1,6 @@
 import { LatLng, Point } from '@/types/map';
 import { z } from 'zod';
+import { HALF_SECOND_MS } from './time';
 
 export const DEFAULT_TILE_SIZE = 256;
 export const DEFAULT_MIN_ZOOM = 2;
@@ -19,6 +20,7 @@ export const DEFAULT_CENTER_POINT: Point = {
 export const OPTIONS = {
   showFullExtent: false,
   wrapXInTileLayer: false,
+  queryPersistTime: HALF_SECOND_MS,
 };
 
 export const VALID_MAP_SIZE_SCHEMA = z.object({
diff --git a/src/constants/time.ts b/src/constants/time.ts
new file mode 100644
index 00000000..0cb37b13
--- /dev/null
+++ b/src/constants/time.ts
@@ -0,0 +1 @@
+export const HALF_SECOND_MS = 500;
diff --git a/src/redux/map/map.constants.ts b/src/redux/map/map.constants.ts
index ba88a038..34be216d 100644
--- a/src/redux/map/map.constants.ts
+++ b/src/redux/map/map.constants.ts
@@ -13,7 +13,10 @@ export const MAP_DATA_INITIAL_STATE: MapData = {
   modelId: 0,
   backgroundId: 0,
   overlaysIds: [],
-  position: DEFAULT_CENTER_POINT,
+  position: {
+    last: DEFAULT_CENTER_POINT,
+    initial: DEFAULT_CENTER_POINT,
+  },
   show: {
     legend: false,
     comments: false,
diff --git a/src/redux/map/map.reducers.ts b/src/redux/map/map.reducers.ts
index 962704b3..25099909 100644
--- a/src/redux/map/map.reducers.ts
+++ b/src/redux/map/map.reducers.ts
@@ -1,9 +1,22 @@
 import { ActionReducerMapBuilder } from '@reduxjs/toolkit';
+import merge from 'ts-deepmerge';
+import { getPointMerged } from '../../utils/object/getPointMerged';
 import { initMapData } from './map.thunks';
 import { MapState, SetMapDataAction } from './map.types';
 
 export const setMapDataReducer = (state: MapState, action: SetMapDataAction): void => {
-  state.data = { ...state.data, ...action.payload };
+  const payload = action.payload || {};
+  const payloadPosition = payload?.position || {};
+  const statePosition = state.data.position;
+
+  state.data = {
+    ...state.data,
+    ...payload,
+    position: {
+      initial: getPointMerged(payloadPosition?.initial || {}, statePosition.initial),
+      last: getPointMerged(payloadPosition?.last || {}, statePosition.last),
+    },
+  };
 };
 
 export const getMapReducers = (builder: ActionReducerMapBuilder<MapState>): void => {
@@ -12,7 +25,7 @@ export const getMapReducers = (builder: ActionReducerMapBuilder<MapState>): void
   });
   builder.addCase(initMapData.fulfilled, (state, action) => {
     const payload = action.payload || {};
-    state.data = { ...state.data, ...payload };
+    state.data = merge(state.data, payload);
     state.loading = 'succeeded';
   });
   builder.addCase(initMapData.rejected, state => {
diff --git a/src/redux/map/map.selectors.ts b/src/redux/map/map.selectors.ts
index e5bbcfe7..bc71ec98 100644
--- a/src/redux/map/map.selectors.ts
+++ b/src/redux/map/map.selectors.ts
@@ -1,8 +1,20 @@
 import { rootSelector } from '@/redux/root/root.selectors';
 import { createSelector } from '@reduxjs/toolkit';
 
-export const mapDataSelector = createSelector(rootSelector, state => state.map.data);
+export const mapSelector = createSelector(rootSelector, state => state.map);
+
+export const mapDataSelector = createSelector(mapSelector, map => map.data);
 
 export const mapDataSizeSelector = createSelector(mapDataSelector, map => map.size);
 
 export const mapDataPositionSelector = createSelector(mapDataSelector, map => map.position);
+
+export const mapDataInitialPositionSelector = createSelector(
+  mapDataPositionSelector,
+  position => position.initial,
+);
+
+export const mapDataLastPositionSelector = createSelector(
+  mapDataPositionSelector,
+  position => position.last,
+);
diff --git a/src/redux/map/map.thunks.test.ts b/src/redux/map/map.thunks.test.ts
index b14e744d..d717ad71 100644
--- a/src/redux/map/map.thunks.test.ts
+++ b/src/redux/map/map.thunks.test.ts
@@ -2,6 +2,7 @@ import { PROJECT_ID } from '@/constants';
 import { backgroundsFixture } from '@/models/fixtures/backgroundsFixture';
 import { modelsFixture } from '@/models/fixtures/modelsFixture';
 import { overlaysFixture } from '@/models/fixtures/overlaysFixture';
+import { QueryData } from '@/types/query';
 import { mockNetworkResponse } from '@/utils/mockNetworkResponse';
 import { getReduxWrapperWithStore } from '@/utils/testing/getReduxWrapperWithStore';
 import { HttpStatusCode } from 'axios';
@@ -15,6 +16,12 @@ import { InitMapDataActionPayload } from './map.types';
 
 const mockedAxiosClient = mockNetworkResponse();
 
+const EMPTY_QUERY_DATA: QueryData = {
+  modelId: undefined,
+  backgroundId: undefined,
+  initialPosition: undefined,
+};
+
 describe('map thunks', () => {
   describe('initMapData - thunk', () => {
     describe('when API is returning valid data', () => {
@@ -33,7 +40,8 @@ describe('map thunks', () => {
 
         store = getReduxWrapperWithStore().store;
         const dispatch = store.dispatch as AppDispatch;
-        payload = (await dispatch(initMapData())).payload as InitMapDataActionPayload;
+        payload = (await dispatch(initMapData({ queryData: EMPTY_QUERY_DATA })))
+          .payload as InitMapDataActionPayload;
       });
 
       it('should fetch backgrounds data in store', async () => {
@@ -76,7 +84,8 @@ describe('map thunks', () => {
 
         store = getReduxWrapperWithStore().store;
         const dispatch = store.dispatch as AppDispatch;
-        payload = (await dispatch(initMapData())).payload as InitMapDataActionPayload;
+        payload = (await dispatch(initMapData({ queryData: EMPTY_QUERY_DATA })))
+          .payload as InitMapDataActionPayload;
       });
 
       it('should return empty payload', () => {
diff --git a/src/redux/map/map.thunks.ts b/src/redux/map/map.thunks.ts
index e6d8fef4..062328d6 100644
--- a/src/redux/map/map.thunks.ts
+++ b/src/redux/map/map.thunks.ts
@@ -1,4 +1,6 @@
 import { PROJECT_ID } from '@/constants';
+import { QueryData } from '@/types/query';
+import { getUpdatedMapData } from '@/utils/map/getUpdatedMapData';
 import { createAsyncThunk } from '@reduxjs/toolkit';
 import { backgroundsDataSelector } from '../backgrounds/background.selectors';
 import { getAllBackgroundsByProjectId } from '../backgrounds/backgrounds.thunks';
@@ -6,36 +8,49 @@ import { modelsDataSelector } from '../models/models.selectors';
 import { getModels } from '../models/models.thunks';
 import { getAllPublicOverlaysByProjectId } from '../overlays/overlays.thunks';
 import type { AppDispatch, RootState } from '../store';
-import { InitMapDataActionPayload } from './map.types';
+import { InitMapDataActionParams, InitMapDataActionPayload } from './map.types';
 
-const getPayloadFromState = (state: RootState): InitMapDataActionPayload => {
+const getInitMapDataPayload = (
+  state: RootState,
+  queryData: QueryData,
+): InitMapDataActionPayload => {
   const FIRST = 0;
   const models = modelsDataSelector(state);
   const backgrounds = backgroundsDataSelector(state);
-  const modelId = models?.[FIRST]?.idObject;
-  const backgroundId = backgrounds?.[FIRST]?.id;
+  const modelId = queryData?.modelId || models?.[FIRST]?.idObject;
+  const backgroundId = queryData?.backgroundId || backgrounds?.[FIRST]?.id;
+  const model = models.find(({ idObject }) => idObject === modelId);
+  const background = backgrounds.find(({ id }) => id === backgroundId);
+  const position = queryData?.initialPosition;
 
-  if (!modelId || !backgroundId) {
+  if (!model || !background) {
     return {};
   }
 
-  return {
-    modelId,
-    backgroundId,
-  };
+  return getUpdatedMapData({
+    model,
+    background,
+    position: {
+      last: position,
+      initial: position,
+    },
+  });
 };
 
 export const initMapData = createAsyncThunk<
   InitMapDataActionPayload,
-  void,
+  InitMapDataActionParams,
   { dispatch: AppDispatch; state: RootState }
->('map/initMapData', async (_, { dispatch, getState }): Promise<InitMapDataActionPayload> => {
-  await Promise.all([
-    dispatch(getAllBackgroundsByProjectId(PROJECT_ID)),
-    dispatch(getAllPublicOverlaysByProjectId(PROJECT_ID)),
-    dispatch(getModels()),
-  ]);
+>(
+  'map/initMapData',
+  async ({ queryData }, { dispatch, getState }): Promise<InitMapDataActionPayload> => {
+    await Promise.all([
+      dispatch(getAllBackgroundsByProjectId(PROJECT_ID)),
+      dispatch(getAllPublicOverlaysByProjectId(PROJECT_ID)),
+      dispatch(getModels()),
+    ]);
 
-  const state = getState();
-  return getPayloadFromState(state);
-});
+    const state = getState();
+    return getInitMapDataPayload(state, queryData);
+  },
+);
diff --git a/src/redux/map/map.types.ts b/src/redux/map/map.types.ts
index 7051691e..2f723872 100644
--- a/src/redux/map/map.types.ts
+++ b/src/redux/map/map.types.ts
@@ -1,6 +1,7 @@
 import { FetchDataState } from '@/types/fetchDataState';
 import { Point } from '@/types/map';
-import { PayloadAction } from '@reduxjs/toolkit';
+import { QueryData } from '@/types/query';
+import { DeepPartial, PayloadAction } from '@reduxjs/toolkit';
 
 export interface MapSize {
   width: number;
@@ -17,7 +18,10 @@ export type MapData = {
   backgroundId: number;
   overlaysIds: number[];
   size: MapSize;
-  position: Point;
+  position: {
+    initial: Point;
+    last: Point;
+  };
   show: {
     legend: boolean;
     comments: boolean;
@@ -26,14 +30,28 @@ export type MapData = {
 
 export type MapState = FetchDataState<MapData, MapData>;
 
-export type SetMapDataActionPayload = Partial<MapData> | undefined;
+export type SetMapDataActionPayload =
+  | (Omit<Partial<MapData>, 'position' | 'projectId'> & {
+      position?: DeepPartial<MapData['position']>;
+      projectId?: string;
+    })
+  | undefined;
 
 export type SetMapDataAction = PayloadAction<SetMapDataActionPayload>;
 
-export type InitMapDataActionPayload = { modelId: number; backgroundId: number } | object;
+export type InitMapDataActionParams = { queryData: QueryData };
+
+export type InitMapDataActionPayload = SetMapDataActionPayload | object;
 
 export type InitMapDataAction = PayloadAction<SetMapDataAction>;
 
 export type MiddlewareAllowedAction = PayloadAction<
   SetMapDataActionPayload | InitMapDataActionPayload
 >;
+
+export type SetMapDataByQueryDataActionParams = { queryData: QueryData };
+
+export type SetMapDataByQueryDataActionPayload = Pick<
+  MapData,
+  'modelId' | 'backgroundId' | 'position'
+>;
diff --git a/src/redux/map/middleware/map.middleware.ts b/src/redux/map/middleware/map.middleware.ts
index 0221d9f6..a09dc6bc 100644
--- a/src/redux/map/middleware/map.middleware.ts
+++ b/src/redux/map/middleware/map.middleware.ts
@@ -1,8 +1,8 @@
+import { currentBackgroundSelector } from '@/redux/backgrounds/background.selectors';
 import type { AppListenerEffectAPI, AppStartListening } from '@/redux/store';
 import { getUpdatedMapData } from '@/utils/map/getUpdatedMapData';
 import { Action, createListenerMiddleware } from '@reduxjs/toolkit';
 import { setMapData } from '../map.slice';
-import { initMapData } from '../map.thunks';
 import { checkIfIsMapUpdateActionValid } from './checkIfIsMapUpdateActionValid';
 import { getUpdatedModel } from './getUpdatedModel';
 
@@ -22,15 +22,11 @@ export const mapDataMiddlewareListener = async (
     return;
   }
 
-  const updatedMapData = getUpdatedMapData({ model: updatedModel });
+  const background = currentBackgroundSelector(state);
+  const updatedMapData = getUpdatedMapData({ model: updatedModel, background });
   dispatch(setMapData(updatedMapData));
 };
 
-startListening({
-  actionCreator: initMapData.fulfilled,
-  effect: mapDataMiddlewareListener,
-});
-
 startListening({
   type: 'map/setMapData',
   effect: mapDataMiddlewareListener,
diff --git a/src/redux/root/init.selectors.ts b/src/redux/root/init.selectors.ts
index 82176735..5095956a 100644
--- a/src/redux/root/init.selectors.ts
+++ b/src/redux/root/init.selectors.ts
@@ -1,5 +1,6 @@
 import { createSelector } from '@reduxjs/toolkit';
 import { backgroundsSelector } from '../backgrounds/background.selectors';
+import { mapSelector } from '../map/map.selectors';
 import { modelsSelector } from '../models/models.selectors';
 import { overlaysSelector } from '../overlays/overlays.selectors';
 import { projectSelector } from '../project/project.selectors';
@@ -11,3 +12,12 @@ export const initDataLoadingInitialized = createSelector(
   overlaysSelector,
   (...selectors) => selectors.every(selector => selector.loading !== 'idle'),
 );
+
+export const initDataLoadingFinished = createSelector(
+  projectSelector,
+  backgroundsSelector,
+  modelsSelector,
+  overlaysSelector,
+  mapSelector,
+  (...selectors) => selectors.every(selector => selector.loading === 'succeeded'),
+);
diff --git a/src/redux/root/query.selectors.ts b/src/redux/root/query.selectors.ts
new file mode 100644
index 00000000..e0c3d785
--- /dev/null
+++ b/src/redux/root/query.selectors.ts
@@ -0,0 +1,12 @@
+import { QueryDataParams } from '@/types/query';
+import { createSelector } from '@reduxjs/toolkit';
+import { mapDataSelector } from '../map/map.selectors';
+
+export const queryDataParamsSelector = createSelector(
+  mapDataSelector,
+  ({ modelId, backgroundId, position }): QueryDataParams => ({
+    modelId,
+    backgroundId,
+    ...position.last,
+  }),
+);
diff --git a/src/types/query.ts b/src/types/query.ts
new file mode 100644
index 00000000..a715a34a
--- /dev/null
+++ b/src/types/query.ts
@@ -0,0 +1,15 @@
+import { Point } from './map';
+
+export interface QueryData {
+  modelId?: number;
+  backgroundId?: number;
+  initialPosition?: Partial<Point>;
+}
+
+export interface QueryDataParams {
+  modelId?: number;
+  backgroundId?: number;
+  x?: number;
+  y?: number;
+  z?: number;
+}
diff --git a/src/types/utils.ts b/src/types/utils.ts
new file mode 100644
index 00000000..969944cb
--- /dev/null
+++ b/src/types/utils.ts
@@ -0,0 +1,3 @@
+export type WithoutNullableKeys<Type> = {
+  [Key in keyof Type]-?: WithoutNullableKeys<NonNullable<Type[Key]>>;
+};
diff --git a/src/components/SPA/utils/useInitializeStore.test.ts b/src/utils/initialize/useInitializeStore.test.ts
similarity index 100%
rename from src/components/SPA/utils/useInitializeStore.test.ts
rename to src/utils/initialize/useInitializeStore.test.ts
diff --git a/src/components/SPA/utils/useInitializeStore.ts b/src/utils/initialize/useInitializeStore.ts
similarity index 56%
rename from src/components/SPA/utils/useInitializeStore.ts
rename to src/utils/initialize/useInitializeStore.ts
index 7e2fb0af..68a6100e 100644
--- a/src/components/SPA/utils/useInitializeStore.ts
+++ b/src/utils/initialize/useInitializeStore.ts
@@ -4,26 +4,39 @@ import { initMapData } from '@/redux/map/map.thunks';
 import { getProjectById } from '@/redux/project/project.thunks';
 import { initDataLoadingInitialized } from '@/redux/root/init.selectors';
 import { AppDispatch } from '@/redux/store';
+import { QueryData } from '@/types/query';
+import { useRouter } from 'next/router';
 import { useEffect } from 'react';
 import { useSelector } from 'react-redux';
+import { getQueryData } from '../query-manager/getQueryData';
+
+interface GetInitStoreDataArgs {
+  queryData: QueryData;
+}
 
 /* prettier-ignore */
 export const getInitStoreData =
-  () =>
+  ({ queryData }: GetInitStoreDataArgs) =>
     (dispatch: AppDispatch): void => {
       dispatch(getProjectById(PROJECT_ID));
-      dispatch(initMapData());
+      dispatch(initMapData({ queryData }));
     };
 
 export const useInitializeStore = (): void => {
   const dispatch = useAppDispatch();
   const isInitialized = useSelector(initDataLoadingInitialized);
+  const { query, isReady: isRouterReady } = useRouter();
 
   useEffect(() => {
-    if (isInitialized) {
+    const isQueryReady = query && isRouterReady;
+    if (isInitialized || !isQueryReady) {
       return;
     }
 
-    dispatch(getInitStoreData());
-  }, [dispatch, isInitialized]);
+    dispatch(
+      getInitStoreData({
+        queryData: getQueryData(query),
+      }),
+    );
+  }, [dispatch, query, isInitialized, isRouterReady]);
 };
diff --git a/src/utils/map/getPointOffset.ts b/src/utils/map/getPointOffset.ts
index 9c4e01fe..08e60559 100644
--- a/src/utils/map/getPointOffset.ts
+++ b/src/utils/map/getPointOffset.ts
@@ -3,7 +3,13 @@ import { VALID_MAP_SIZE_SCHEMA } from '@/constants/map';
 import { MapSize } from '@/redux/map/map.types';
 import { Point } from '@/types/map';
 
-export const getPointOffset = (point: Point, mapSize: MapSize): Point => {
+interface GetPointOffsetResults extends Point {
+  pointOrigin: Point;
+  pointShifted: Point;
+  zoomFactor: number;
+}
+
+export const getPointOffset = (point: Point, mapSize: MapSize): GetPointOffsetResults => {
   // parse throws error if map size may lead to invalid results
   VALID_MAP_SIZE_SCHEMA.parse(mapSize);
 
@@ -21,5 +27,11 @@ export const getPointOffset = (point: Point, mapSize: MapSize): Point => {
     y: point.y / zoomFactor,
   };
 
-  return { x: pointShifted.x - pointOrigin.x, y: pointShifted.y - pointOrigin.y };
+  return {
+    x: pointShifted.x - pointOrigin.x,
+    y: pointShifted.y - pointOrigin.y,
+    pointOrigin,
+    pointShifted,
+    zoomFactor,
+  };
 };
diff --git a/src/utils/map/getUpdatedMapData.test.ts b/src/utils/map/getUpdatedMapData.test.ts
index 5afdbc3c..4e4b2852 100644
--- a/src/utils/map/getUpdatedMapData.test.ts
+++ b/src/utils/map/getUpdatedMapData.test.ts
@@ -24,9 +24,16 @@ describe('getUpdatedMapData - util', () => {
           maxZoom: model.maxZoom,
         },
         position: {
-          x: model.width / HALF,
-          y: model.height / HALF,
-          z: DEFAULT_ZOOM,
+          initial: {
+            x: model.width / HALF,
+            y: model.height / HALF,
+            z: DEFAULT_ZOOM,
+          },
+          last: {
+            x: model.width / HALF,
+            y: model.height / HALF,
+            z: DEFAULT_ZOOM,
+          },
         },
       };
 
@@ -53,9 +60,16 @@ describe('getUpdatedMapData - util', () => {
           maxZoom: model.maxZoom,
         },
         position: {
-          x: 0,
-          y: 0,
-          z: DEFAULT_ZOOM,
+          initial: {
+            x: 0,
+            y: 0,
+            z: DEFAULT_ZOOM,
+          },
+          last: {
+            x: 0,
+            y: 0,
+            z: DEFAULT_ZOOM,
+          },
         },
       };
 
@@ -82,9 +96,16 @@ describe('getUpdatedMapData - util', () => {
           maxZoom: model.maxZoom,
         },
         position: {
-          x: 10,
-          y: 10,
-          z: 1,
+          initial: {
+            x: 10,
+            y: 10,
+            z: 1,
+          },
+          last: {
+            x: 10,
+            y: 10,
+            z: 1,
+          },
         },
       };
 
diff --git a/src/utils/map/getUpdatedMapData.ts b/src/utils/map/getUpdatedMapData.ts
index 552e29a6..c3ebf2a0 100644
--- a/src/utils/map/getUpdatedMapData.ts
+++ b/src/utils/map/getUpdatedMapData.ts
@@ -1,27 +1,46 @@
 import { DEFAULT_ZOOM } from '@/constants/map';
-import { MapData } from '@/redux/map/map.types';
-import { MapModel } from '@/types/models';
+import { MAP_DATA_INITIAL_STATE } from '@/redux/map/map.constants';
+import { MapData, SetMapDataActionPayload } from '@/redux/map/map.types';
+import { MapBackground, MapModel } from '@/types/models';
+import { DeepPartial } from '@reduxjs/toolkit';
+import { getPointMerged } from '../object/getPointMerged';
 
 interface GetUpdatedMapDataArgs {
   model: MapModel;
+  position?: DeepPartial<MapData['position']>;
+  background?: MapBackground;
 }
 
-type GetUpdatedMapDataResult = Pick<MapData, 'modelId' | 'size' | 'position'>;
+type GetUpdatedMapDataResult = SetMapDataActionPayload;
 
 const HALF = 2;
 
-export const getUpdatedMapData = ({ model }: GetUpdatedMapDataArgs): GetUpdatedMapDataResult => ({
-  modelId: model.idObject,
-  size: {
-    width: model.width,
-    height: model.height,
-    tileSize: model.tileSize,
-    minZoom: model.minZoom,
-    maxZoom: model.maxZoom,
-  },
-  position: {
+export const getUpdatedMapData = ({
+  model,
+  position,
+  background,
+}: GetUpdatedMapDataArgs): GetUpdatedMapDataResult => {
+  const defaultPosition = {
     x: model.defaultCenterX ?? model.width / HALF,
     y: model.defaultCenterY ?? model.height / HALF,
     z: model.defaultZoomLevel ?? DEFAULT_ZOOM,
-  },
-});
+  };
+
+  const mergedPosition = getPointMerged(position?.initial || {}, defaultPosition);
+
+  return {
+    modelId: model.idObject,
+    backgroundId: background?.id || MAP_DATA_INITIAL_STATE.backgroundId,
+    size: {
+      width: model.width,
+      height: model.height,
+      tileSize: model.tileSize,
+      minZoom: model.minZoom,
+      maxZoom: model.maxZoom,
+    },
+    position: {
+      initial: mergedPosition,
+      last: mergedPosition,
+    },
+  };
+};
diff --git a/src/utils/map/latLngToPoint.ts b/src/utils/map/latLngToPoint.ts
new file mode 100644
index 00000000..aa799201
--- /dev/null
+++ b/src/utils/map/latLngToPoint.ts
@@ -0,0 +1,40 @@
+/* eslint-disable no-magic-numbers */
+import { DEFAULT_CENTER_POINT } from '@/constants/map';
+import { MapSize } from '@/redux/map/map.types';
+import { LatLng, Point } from '@/types/map';
+import { boundNumber } from '../number/boundNumber';
+import { degreesToRadians } from '../number/degreesToRadians';
+import { getPointOffset } from './getPointOffset';
+
+const FULL_CIRCLE_DEGREES = 360;
+const SIN_Y_LIMIT = 0.9999;
+
+interface Options {
+  rounded?: boolean;
+}
+
+export const latLngToPoint = (
+  [lat, lng]: LatLng,
+  mapSize: MapSize,
+  options: Options = {},
+): Point => {
+  const { pointOrigin, zoomFactor } = getPointOffset(DEFAULT_CENTER_POINT, mapSize);
+  const pixelsPerLonDegree = mapSize.tileSize / FULL_CIRCLE_DEGREES;
+  const pixelsPerLonRadian = mapSize.tileSize / (2 * Math.PI);
+  const sinY = boundNumber(Math.sin(degreesToRadians(lat)), -SIN_Y_LIMIT, SIN_Y_LIMIT);
+
+  const point = {
+    x: pointOrigin.x + lng * pixelsPerLonDegree,
+    y: pointOrigin.y + 0.5 * Math.log((1 + sinY) / (1 - sinY)) * -pixelsPerLonRadian,
+  };
+
+  const getFinalPointValue = (pointValue: number): number => {
+    const pointValueFactored = pointValue * zoomFactor;
+    return options?.rounded ? Math.round(pointValueFactored) : pointValueFactored;
+  };
+
+  return {
+    x: getFinalPointValue(point.x),
+    y: getFinalPointValue(point.y),
+  };
+};
diff --git a/src/utils/map/pointToLatLng.test.ts b/src/utils/map/pointToLatLng.test.ts
index 2625526a..8c34d284 100644
--- a/src/utils/map/pointToLatLng.test.ts
+++ b/src/utils/map/pointToLatLng.test.ts
@@ -1,6 +1,6 @@
 /* eslint-disable no-magic-numbers */
 import { LATLNG_FALLBACK } from '@/constants/map';
-import { pointToLatLng } from './pointToLatLng';
+import { pointToLngLat } from './pointToLatLng';
 
 describe('pointToLatLng - util', () => {
   describe('when mapSize arg is undefined', () => {
@@ -12,7 +12,7 @@ describe('pointToLatLng - util', () => {
     const mapSizeUndefined = undefined;
 
     it('should return fallback value', () => {
-      expect(pointToLatLng(validPoint, mapSizeUndefined)).toBe(LATLNG_FALLBACK);
+      expect(pointToLngLat(validPoint, mapSizeUndefined)).toBe(LATLNG_FALLBACK);
     });
   });
 
@@ -34,7 +34,7 @@ describe('pointToLatLng - util', () => {
     it('should return fallback value', () => {
       const logger = jest.spyOn(console, 'error').mockImplementation(() => {});
 
-      expect(pointToLatLng(validPoint, invalidMapSize)).toBe(LATLNG_FALLBACK);
+      expect(pointToLngLat(validPoint, invalidMapSize)).toBe(LATLNG_FALLBACK);
       // TODO: need to rething way of handling parsing errors, for now let's leave it to console.log
       // eslint-disable-next-line no-console
       expect(logger).toBeCalledTimes(1);
@@ -58,7 +58,7 @@ describe('pointToLatLng - util', () => {
     const results = [-270, 0];
 
     it('should return valid lat lng value', () => {
-      expect(pointToLatLng(validPoint, validMapSize)).toStrictEqual(results);
+      expect(pointToLngLat(validPoint, validMapSize)).toStrictEqual(results);
     });
   });
 });
diff --git a/src/utils/map/pointToLatLng.ts b/src/utils/map/pointToLatLng.ts
index d6d82a4e..cf8a6e58 100644
--- a/src/utils/map/pointToLatLng.ts
+++ b/src/utils/map/pointToLatLng.ts
@@ -19,7 +19,7 @@ const getIsMapSizeValid = (mapSize?: MapSize): boolean => {
   return parseResult.success;
 };
 
-export const pointToLatLng = (point: Point, mapSize?: MapSize): LatLng => {
+export const pointToLngLat = (point: Point, mapSize?: MapSize): LatLng => {
   const isMapSizeValid = getIsMapSizeValid(mapSize);
   if (!isMapSizeValid || !mapSize) {
     return LATLNG_FALLBACK;
diff --git a/src/utils/map/usePointToProjection.test.tsx b/src/utils/map/usePointToProjection.test.tsx
index a647f1fc..b3659e7a 100644
--- a/src/utils/map/usePointToProjection.test.tsx
+++ b/src/utils/map/usePointToProjection.test.tsx
@@ -1,8 +1,6 @@
 import mapReducer, { setMapData } from '@/redux/map/map.slice';
 /* eslint-disable no-magic-numbers */
-import { LATLNG_FALLBACK } from '@/constants/map';
 import { act, renderHook } from '@testing-library/react';
-import { fromLonLat } from 'ol/proj';
 import { getReduxWrapperUsingSliceReducer } from '../testing/getReduxWrapperUsingSliceReducer';
 import { usePointToProjection } from './usePointToProjection';
 
@@ -29,7 +27,7 @@ describe('usePointToProjection - util', () => {
       const {
         result: { current: pointToProjection },
       } = renderHook(usePointToProjection, { wrapper: Wrapper });
-      expect(pointToProjection(validPoint)).toStrictEqual(fromLonLat(LATLNG_FALLBACK));
+      expect(pointToProjection(validPoint)).toStrictEqual([0, -0]);
     });
   });
 
@@ -60,7 +58,7 @@ describe('usePointToProjection - util', () => {
       const {
         result: { current: pointToProjection },
       } = renderHook(usePointToProjection, { wrapper: Wrapper });
-      expect(pointToProjection(validPoint)).toStrictEqual(fromLonLat(LATLNG_FALLBACK));
+      expect(pointToProjection(validPoint)).toStrictEqual([0, -0]);
     });
   });
 
@@ -78,7 +76,7 @@ describe('usePointToProjection - util', () => {
       maxZoom: 10,
     };
 
-    const results = [180337575.0851032, -180337344.38930294];
+    const results = [180337575, -180337344];
 
     it('should return valid lat lng value on function call', () => {
       act(() => {
diff --git a/src/utils/map/usePointToProjection.ts b/src/utils/map/usePointToProjection.ts
index 281a33bb..d0b0066b 100644
--- a/src/utils/map/usePointToProjection.ts
+++ b/src/utils/map/usePointToProjection.ts
@@ -5,7 +5,7 @@ import { Coordinate } from 'ol/coordinate';
 import { fromLonLat } from 'ol/proj';
 import { useCallback } from 'react';
 import { useSelector } from 'react-redux';
-import { pointToLatLng } from './pointToLatLng';
+import { pointToLngLat } from './pointToLatLng';
 
 type UsePointToProjectionResult = (point: Point) => Coordinate;
 
@@ -16,11 +16,12 @@ export const usePointToProjection: UsePointToProjection = () => {
 
   const pointToProjection = useCallback(
     (point: Point): Coordinate => {
-      const [lng, lat] = pointToLatLng(point, mapSize);
+      const [lng, lat] = pointToLngLat(point, mapSize);
       const projection = fromLonLat([lng, lat]);
-      const isValid = !projection.some(v => Number.isNaN(v));
+      const projectionRounded = projection.map(v => Math.round(v));
+      const isValid = !projectionRounded.some(v => Number.isNaN(v));
 
-      return isValid ? projection : LATLNG_FALLBACK;
+      return isValid ? projectionRounded : LATLNG_FALLBACK;
     },
     [mapSize],
   );
diff --git a/src/utils/number/boundNumber.ts b/src/utils/number/boundNumber.ts
new file mode 100644
index 00000000..abad3552
--- /dev/null
+++ b/src/utils/number/boundNumber.ts
@@ -0,0 +1,4 @@
+export const boundNumber = (value: number, minVal?: number, maxVal?: number): number => {
+  const valueBoundedMax = Math.max(value, minVal || value);
+  return Math.min(valueBoundedMax, maxVal || value);
+};
diff --git a/src/utils/number/degreesToRadians.ts b/src/utils/number/degreesToRadians.ts
new file mode 100644
index 00000000..9b413ed2
--- /dev/null
+++ b/src/utils/number/degreesToRadians.ts
@@ -0,0 +1,5 @@
+const HALF_CIRCLE_DEGREES = 180;
+
+export const degreesToRadians = (deg: number): number => {
+  return deg * (Math.PI / HALF_CIRCLE_DEGREES);
+};
diff --git a/src/utils/object/getPointMerged.ts b/src/utils/object/getPointMerged.ts
new file mode 100644
index 00000000..347655b4
--- /dev/null
+++ b/src/utils/object/getPointMerged.ts
@@ -0,0 +1,7 @@
+import { Point } from '@/types/map';
+
+export const getPointMerged = (primaryPoint: Partial<Point>, secondaryPoint: Point): Point => ({
+  x: primaryPoint.x ?? secondaryPoint.x,
+  y: primaryPoint.y ?? secondaryPoint.y,
+  z: primaryPoint.z ?? secondaryPoint.z,
+});
diff --git a/src/utils/object/getTruthyObjectOrUndefined.ts b/src/utils/object/getTruthyObjectOrUndefined.ts
new file mode 100644
index 00000000..e8fe8153
--- /dev/null
+++ b/src/utils/object/getTruthyObjectOrUndefined.ts
@@ -0,0 +1,11 @@
+import { WithoutNullableKeys } from '@/types/utils';
+
+export const getTruthyObjectOrUndefined = <I extends object>(
+  obj: I,
+): WithoutNullableKeys<I> | undefined => {
+  const isAllValuesTruthy = Object.entries(obj).every(
+    ([, value]) => value !== null && value !== undefined,
+  );
+
+  return isAllValuesTruthy ? (obj as WithoutNullableKeys<I>) : undefined;
+};
diff --git a/src/utils/query-manager/getQueryData.ts b/src/utils/query-manager/getQueryData.ts
new file mode 100644
index 00000000..762118e6
--- /dev/null
+++ b/src/utils/query-manager/getQueryData.ts
@@ -0,0 +1,24 @@
+import { QueryData } from '@/types/query';
+import { ParsedUrlQuery } from 'querystring';
+
+/* prettier-ignore */
+const getQueryFieldNumberCurry =
+  (query: ParsedUrlQuery) =>
+    (key: string): number | undefined =>
+      parseInt(`${query?.[key]}`, 10) || undefined;
+
+export const getQueryData = (query: ParsedUrlQuery): QueryData => {
+  const getQueryFieldNumber = getQueryFieldNumberCurry(query);
+
+  const initialPosition = {
+    x: getQueryFieldNumber('x'),
+    y: getQueryFieldNumber('y'),
+    z: getQueryFieldNumber('z'),
+  };
+
+  return {
+    modelId: getQueryFieldNumber('modelId'),
+    backgroundId: getQueryFieldNumber('backgroundId'),
+    initialPosition,
+  };
+};
diff --git a/src/utils/query-manager/useReduxBusQueryManager.ts b/src/utils/query-manager/useReduxBusQueryManager.ts
new file mode 100644
index 00000000..4ad04c41
--- /dev/null
+++ b/src/utils/query-manager/useReduxBusQueryManager.ts
@@ -0,0 +1,38 @@
+import { queryDataParamsSelector } from '@/redux/root/query.selectors';
+import { useRouter } from 'next/router';
+import { useCallback, useEffect } from 'react';
+import { useSelector } from 'react-redux';
+import { initDataLoadingFinished } from '../../redux/root/init.selectors';
+
+export const useReduxBusQueryManager = (): void => {
+  const router = useRouter();
+  const queryData = useSelector(queryDataParamsSelector);
+  const isDataLoaded = useSelector(initDataLoadingFinished);
+
+  const handleChangeQuery = useCallback(
+    () =>
+      router.replace(
+        {
+          query: {
+            ...router.query,
+            ...queryData,
+          },
+        },
+        undefined,
+        {
+          shallow: true,
+        },
+      ),
+    // router is not an stable reference
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+    [queryData],
+  );
+
+  useEffect(() => {
+    if (!isDataLoaded) {
+      return;
+    }
+
+    handleChangeQuery();
+  }, [handleChangeQuery, isDataLoaded]);
+};
diff --git a/yarn.lock b/yarn.lock
index c865fa74..9f446451 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -6045,7 +6045,7 @@
     "react-is" "^18.0.0"
     "use-sync-external-store" "^1.0.0"
 
-"react@^16.3.2 || ^17.0.0 || ^18.0.0", "react@^16.8 || ^17.0 || ^18.0", "react@^16.8.0 || ^17.0.0 || ^18.0.0", "react@^16.8.0-0 || ^17.0.0 || ^18.0.0", "react@^16.9.0 || ^17.0.0 || ^18", "react@^18.0.0", "react@^18.2.0", "react@>= 16.8.0 || 17.x.x || ^18.0.0-0", "react@>=17.0.0", "react@18.2.0":
+"react@^16.3.2 || ^17.0.0 || ^18.0.0", "react@^16.8 || ^17.0 || ^18.0", "react@^16.8.0 || ^17.0.0 || ^18.0.0", "react@^16.8.0-0 || ^17.0.0 || ^18.0.0", "react@^16.9.0 || ^17.0.0 || ^18", "react@^18.0.0", "react@^18.2.0", "react@>= 16.8.0 || 17.x.x || ^18.0.0-0", "react@>=16.8.0", "react@>=17.0.0", "react@18.2.0":
   "integrity" "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ=="
   "resolved" "https://registry.npmjs.org/react/-/react-18.2.0.tgz"
   "version" "18.2.0"
@@ -6890,6 +6890,11 @@
   "resolved" "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.0.3.tgz"
   "version" "1.0.3"
 
+"ts-deepmerge@^6.2.0":
+  "integrity" "sha512-2qxI/FZVDPbzh63GwWIZYE7daWKtwXZYuyc8YNq0iTmMUwn4mL0jRLsp6hfFlgbdRSR4x2ppe+E86FnvEpN7Nw=="
+  "resolved" "https://registry.npmjs.org/ts-deepmerge/-/ts-deepmerge-6.2.0.tgz"
+  "version" "6.2.0"
+
 "ts-interface-checker@^0.1.9":
   "integrity" "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="
   "resolved" "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz"
@@ -7092,6 +7097,11 @@
     "querystringify" "^2.1.1"
     "requires-port" "^1.0.0"
 
+"use-debounce@^9.0.4":
+  "integrity" "sha512-6X8H/mikbrt0XE8e+JXRtZ8yYVvKkdYRfmIhWZYsP8rcNs9hk3APV8Ua2mFkKRLcJKVdnX2/Vwrmg2GWKUQEaQ=="
+  "resolved" "https://registry.npmjs.org/use-debounce/-/use-debounce-9.0.4.tgz"
+  "version" "9.0.4"
+
 "use-sync-external-store@^1.0.0":
   "integrity" "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA=="
   "resolved" "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz"
-- 
GitLab