diff --git a/src/components/AppWrapper/AppWrapper.component.tsx b/src/components/AppWrapper/AppWrapper.component.tsx index 2bb7e192c61b37fef97dafaf58cd0bfc1d94395d..3b59e82bec77dc93528d14168014000abd095242 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 226bd904950ae5d936b46ff2e47634ecf97ceae6..09f916272f34bab3782ad34ca188c4b305032f24 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 34986a2ecc9a1014912024d286fabd40f7def5ee..7d296729a6fde1d04dec7fa42f3b4f8a313b54a7 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 ef7519f99173920a36a1ac4371c15d027c4931ee..b93fc761920b36c99d3a51426a9bbed0f25f6512 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 0000000000000000000000000000000000000000..8f84e5d6f681e7d13ea4f2e0ecd3f5326fd4b665 --- /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 0000000000000000000000000000000000000000..4fbcd551ac4a6e2856951352d999529494446903 --- /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 2cc15d5da01ea0a5ab0cec43e9f0abacc762f790..f6750e5cb1b7db0b38c29ba97fc7ca3c5cbd7b26 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 070d37d83be8bcb88a3ad8e509daad18127b10db..ff40ba9134e054c7dd1cb444337071f2a7aef2ed 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 9dc00a261aaed3dfc9c4f3dda0187755d0ae9c7e..4a4d9dc18032cb862f0bd6a88f96ab199f5bff47 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 5be3fd4cbf657d07bc3e958bda4e2c8b1205d812..5d7631ff007bc82ddcc675b81e67bd5eb384fdf4 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 a7ffb39839e07fea132e5b3fbb7093aadbc03ff4..326e8ec8822585de8fe9397ec380143a7f040016 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 0000000000000000000000000000000000000000..813926d1fa8cd6b008d0ebe51649938c0f6e533d --- /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 f3b2149d886a31f9605d9cf1b044837447059f40..8b39954fdb451fe218724c0315382a527b4f39b1 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 8dedc23f1526474dcc10d639b9e3e76312a86996..81013f9631bf84d468e0a89539747eae480f34c3 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 0000000000000000000000000000000000000000..d5b7bb6aaff107991b1aec21a9fd956ec00d3ed1 --- /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 0000000000000000000000000000000000000000..1c0982d8e55a4bcbafdf1416b473b43afcf9f9b9 --- /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 0000000000000000000000000000000000000000..4e00469d4c7df59c3b33135a5c6da8182b1e0db5 --- /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 0000000000000000000000000000000000000000..29ee4727a75618bfd9cff8ba17e0ec5d727ecf1e --- /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 0000000000000000000000000000000000000000..fa5a1809cbc1a79bbc26ca9d85d95f155fa1f9d4 --- /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 0000000000000000000000000000000000000000..f3db3d22c569c9f254a039629617b3e8889e837a --- /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 d1f0c3dfe1529bbe9cb64a9d09ec1f16ac219951..18c3beb8cf9e415abdd6e5820e9360a981e1b70e 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 };