diff --git a/.env b/.env index 470be3d78ae1c38d2951c67acae288685d447b9c..b3e48b427a49bb631ab1a95914bf2c1beec5fe37 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 f41b112587042b8d19ebbab8add5288dcd762146..79ab1fa87d504d5a0522547ceebce4f5a23fe7ee 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 c6c19cd69978bfb0f35c523f619b33883299f8d2..cc259882ab521253bac4b434e85f01e7d3f26885 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 babe85b11c2b8f13358119061cf17ae39cf2a54a..2cc15d5da01ea0a5ab0cec43e9f0abacc762f790 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 e2873af0aa32bfea673a0184ab46ae7bca804cd5..0000000000000000000000000000000000000000 --- 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 2a584d8764593a4cfaa69b8d98e33cfe3b172159..0000000000000000000000000000000000000000 --- 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 0000000000000000000000000000000000000000..f2c512a5cd10a2b0bf78f73bd6b9e62c44906f6b --- /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 0000000000000000000000000000000000000000..67d71d50060f40018ec5d515b0608a8b98bb4f92 --- /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 0000000000000000000000000000000000000000..8a78f734250e8f020e17ae8093f0af4f33d3ee0f --- /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 0000000000000000000000000000000000000000..9dc00a261aaed3dfc9c4f3dda0187755d0ae9c7e --- /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 0000000000000000000000000000000000000000..a0164f94774dd3b8921e1e6ef0aac88aaec0fd23 --- /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 0000000000000000000000000000000000000000..20d0401abf66cd12e2ab5578c637963cf56b7c54 --- /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 0000000000000000000000000000000000000000..ec88619565b10bb91760b36c50ef199fbe8cd61e --- /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 c606b77abe4d46c7db675e0a6a68eda9bf0d78b2..29cf1fc029a9296492288ab0304e851d49e8119c 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 ca82591af5077a935f1a5833451885696e70a81c..a80407e303374c2da8c3829dfe0b8d66824918c4 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 856e3487b746b504123995ebdf46329736f81787..4de3ca1fb95c0e3bbf4b82dbc85e7afffb32d033 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 83fbec09ffae3560d187f82cd564aed44a976c02..765b30b0ea67439b1de5933022a122fd2a7811da 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 0000000000000000000000000000000000000000..0cb37b138acc5057e5b395567743831169c10fca --- /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 ba88a03811be7e0e0d08fe58bb13ceaff6f51d65..34be216db6c6b3a7eabe6ca7fe6a11fb6d06ad08 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 962704b36070701d2e763147d57f2f99c8751ae8..25099909bee3b89f30a9f4106ac03004c0c6e330 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 e5bbcfe7dc366b0154edc21775477bb38be82973..bc71ec9840e6bd2c6700461b528082e707bfd217 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 b14e744dab577afaa1b8f25b56d76f2162053908..d717ad71b5c557ab908eed821bdd5f1616325c01 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 e6d8fef456ef2f0bda91c46d99cd7c45347c192e..062328d6d9f3c8e511fd738c8c2c9c2b6546d803 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 7051691ed57d56fe19e934c18e8fa020a9f9246e..2f723872a225ef9bf88d73ab60373e00a85cc9a0 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 0221d9f6a2705f68cad2a51b2c1c24c7b2cb7565..a09dc6bcc00004bcced835a767e3f6c4ba63b69d 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 82176735f129c72fbb1161a73b6ee241f3e6909c..5095956a5633679600d67a86d458ecffb7227ac2 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 0000000000000000000000000000000000000000..e0c3d7850dc2b53a4140a9bf02c335c2c06637f3 --- /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 0000000000000000000000000000000000000000..a715a34a3397f9f4bb7b2a3eaa7e82657d7d1463 --- /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 0000000000000000000000000000000000000000..969944cb5527ae540324b159f192a8e971271546 --- /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 7e2fb0af61681ddf2f73cefa1571580fd8b36022..68a6100e2075d8fa3e3a8ce1cb6a1465036e1b1c 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 9c4e01fe65cbd78846f6396af30c71a2971de7d2..08e60559574b402e40ef3ddbcb37129f5306d856 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 5afdbc3c00a37776b425577324036ae739553b07..4e4b28522c80a83c0fd23ac76091125f91af56b1 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 552e29a64b20d955929f5e99a2233c915aebe9f0..c3ebf2a09e1988249062c119681baa8b4e869944 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 0000000000000000000000000000000000000000..aa799201ba057e7cfaf5bca95655e6216dff9abf --- /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 2625526a1ff11a741bffa34ed336df6b0863d67d..8c34d284fe32a5f1725b2572fdd0d78a870412d2 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 d6d82a4e2551b1f9e57eb5da3924ced0258bc0cf..cf8a6e58e8bad0bee61593660b332c9b8a95760c 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 a647f1fc71ce15f531001e3ccf0cce753b150638..b3659e7aea7c613a186c23e36fe728cd4c152586 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 281a33bb55556dfcf80ef4433b8b3eda5cf92855..d0b0066b98d5f73abc92c4cca76dadf86e439ead 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 0000000000000000000000000000000000000000..abad3552fc7463f45ea5dd998d30556127dd7ed9 --- /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 0000000000000000000000000000000000000000..9b413ed22435a48f8956da1db1e0798ad9fbb857 --- /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 0000000000000000000000000000000000000000..347655b49a4c6db03a1830b14bd6737bad3d4504 --- /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 0000000000000000000000000000000000000000..e8fe8153bda70f679dfec460bc9c3b9b006c912e --- /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 0000000000000000000000000000000000000000..762118e6b445e628b92624cb9a53f5269ad5e352 --- /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 0000000000000000000000000000000000000000..4ad04c417f2044f420782544930cffada60388b9 --- /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 c865fa7436c7be819c80651b30e867e2502d5574..9f44645148c1bb09e68044a8d3a46aa3c4b2d56c 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"