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