From 5b34ca5cd49c40598fd86be2c383b6bdd6a75fbe Mon Sep 17 00:00:00 2001 From: mateusz-winiarczyk <mateusz.winiarczyk@appunite.com> Date: Thu, 7 Mar 2024 13:15:51 +0100 Subject: [PATCH] feat(bounds): plugins bounds (MIN-228) --- docs/plugins/bounds.md | 43 +++++ docs/plugins/errors.md | 4 +- docs/plugins/submaps.md | 10 ++ index.d.ts | 9 + .../MapAdditionalActions.component.test.tsx | 2 +- .../utils/useAdditionalActions.test.ts | 2 +- .../mapSingleClick/handleAliasResults.ts | 12 +- .../mapSingleClick/handleReactionResults.ts | 13 +- .../handleSearchResultAction.ts | 6 +- .../Map/MapViewer/utils/useOlMap.ts | 6 +- src/services/pluginsManager/errorMessages.ts | 5 +- .../pluginsManager/map/data/getBounds.test.ts | 60 +++++++ .../pluginsManager/map/data/getBounds.ts | 37 ++++ .../map/fitBounds/fitBounds.constants.ts | 5 + .../map/fitBounds/fitBounds.test.ts | 122 ++++++++++++++ .../pluginsManager/map/fitBounds/fitBounds.ts | 45 +++++ .../map/fitBounds/fitBounds.utils.test.ts | 66 ++++++++ .../map/fitBounds/fitBounds.utils.ts | 14 ++ .../pluginsManager/map/fitBounds/index.ts | 1 + .../pluginsManager/map/getOpenMapId.test.ts | 40 +++++ .../pluginsManager/map/getOpenMapId.ts | 13 ++ .../pluginsManager/map/mapManager.test.ts | 34 ++++ src/services/pluginsManager/map/mapManager.ts | 18 ++ .../map/triggerSearch/getPolygonPoints.ts | 33 ++++ ...sibleBioEntitiesPolygonCoordinates.test.ts | 158 ++++++++++++++++++ ...getVisibleBioEntitiesPolygonCoordinates.ts | 33 ++++ .../map/triggerSearch/searchByCoordinates.ts | 4 +- .../map/triggerSearch/searchByQuery.test.ts | 119 +++++++++++++ .../map/triggerSearch/searchByQuery.ts | 18 +- .../map/triggerSearch/searchFitBounds.test.ts | 105 ++++++++++++ .../map/triggerSearch/searchFitBounds.ts | 15 ++ .../map/triggerSearch/triggerSearch.test.ts | 5 +- .../map/triggerSearch/triggerSearch.ts | 4 +- src/services/pluginsManager/pluginsManager.ts | 7 + src/utils/context/mapInstanceContext.tsx | 21 ++- src/utils/map/useSetBounds.test.ts | 4 +- 36 files changed, 1064 insertions(+), 29 deletions(-) create mode 100644 docs/plugins/bounds.md create mode 100644 src/services/pluginsManager/map/data/getBounds.test.ts create mode 100644 src/services/pluginsManager/map/data/getBounds.ts create mode 100644 src/services/pluginsManager/map/fitBounds/fitBounds.constants.ts create mode 100644 src/services/pluginsManager/map/fitBounds/fitBounds.test.ts create mode 100644 src/services/pluginsManager/map/fitBounds/fitBounds.ts create mode 100644 src/services/pluginsManager/map/fitBounds/fitBounds.utils.test.ts create mode 100644 src/services/pluginsManager/map/fitBounds/fitBounds.utils.ts create mode 100644 src/services/pluginsManager/map/fitBounds/index.ts create mode 100644 src/services/pluginsManager/map/getOpenMapId.test.ts create mode 100644 src/services/pluginsManager/map/getOpenMapId.ts create mode 100644 src/services/pluginsManager/map/mapManager.test.ts create mode 100644 src/services/pluginsManager/map/mapManager.ts create mode 100644 src/services/pluginsManager/map/triggerSearch/getPolygonPoints.ts create mode 100644 src/services/pluginsManager/map/triggerSearch/getVisibleBioEntitiesPolygonCoordinates.test.ts create mode 100644 src/services/pluginsManager/map/triggerSearch/getVisibleBioEntitiesPolygonCoordinates.ts create mode 100644 src/services/pluginsManager/map/triggerSearch/searchByQuery.test.ts create mode 100644 src/services/pluginsManager/map/triggerSearch/searchFitBounds.test.ts create mode 100644 src/services/pluginsManager/map/triggerSearch/searchFitBounds.ts diff --git a/docs/plugins/bounds.md b/docs/plugins/bounds.md new file mode 100644 index 00000000..a9863e90 --- /dev/null +++ b/docs/plugins/bounds.md @@ -0,0 +1,43 @@ +### Bounds + +#### Get Bounds + +To get bounds of the current active map, plugins can use the `getBounds` method defined in `window.minerva.map.data` object available globally. It returns object with properties x1, y1, x2, y2 + +- x1, y1 - top left corner coordinates +- x2, y2 - right bottom corner coordinates + +Example of returned object: + +```javascript +{ + x1: 12853, + y1: 4201, + x2: 23327, + y2: 9575 +} +``` + +##### Example of getBounds method usage: + +```javascript +window.minerva.map.data.getBounds(); +``` + +#### Fit bounds + +To zoom in the map in a way that rectangle defined by coordinates is visible, plugins can use the `fitBounds` method defined in `window.minerva.map` object available globally. This method takes one argument: object with properties x1, y1, x2, y2. + +- x1, y1 - top left corner coordinates +- x2, y2 - right bottom corner coordinates + +##### Example of fitBounds method usage: + +```javascript +window.minerva.map.fitBounds({ + x1: 14057.166666666668, + y1: 6805.337365980873, + x2: 14057.166666666668, + y2: 6805.337365980873, +}); +``` diff --git a/docs/plugins/errors.md b/docs/plugins/errors.md index 2795ec51..fca95f8b 100644 --- a/docs/plugins/errors.md +++ b/docs/plugins/errors.md @@ -4,13 +4,15 @@ - **Map with provided id does not exist**: This error occurs when the provided map id does not correspond to any existing map. +- **Unable to retrieve the id of the active map: the modelId is not a number**: This error occurs when the modelId parameter provided from store to retrieve the id of the active map is not a number. + ## Search Errors - **Invalid query type. The query should be of string type**: This error occurs when the query parameter is not of string type. - **Invalid coordinates type or values**: This error occurs when the coordinates parameter is missing keys, or its values are not of number type. -- **Invalid model id type. The model should be of number type**: This error occurs when the modelId parameter is not of number type. +- **Invalid model id type. The model id should be a number**: This error occurs when the modelId parameter is not of number type. ## Project Errors diff --git a/docs/plugins/submaps.md b/docs/plugins/submaps.md index 54c6a340..aab6dc23 100644 --- a/docs/plugins/submaps.md +++ b/docs/plugins/submaps.md @@ -1,5 +1,15 @@ ### Submaps +#### Get current open map id + +To get current open map id, plugins can use the `getOpenMapId` method defined in `window.minerva.map.data` object available globally. It returns id of current open map. + +##### Example of getOpenMapId method usage: + +```javascript +window.minerva.map.data.getOpenMapId(); +``` + #### Get Models To get data about all available submaps, plugins can use the `getModels` method defined in `window.minerva.map.data`. This method returns array with data about all submaps. diff --git a/index.d.ts b/index.d.ts index db769bd2..3cd0efd1 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1,3 +1,9 @@ +import { getBounds } from '@/services/pluginsManager/map/data/getBounds'; +import { fitBounds } from '@/services/pluginsManager/map/fitBounds'; +import { getOpenMapId } from '@/services/pluginsManager/map/getOpenMapId'; +import { triggerSearch } from '@/services/pluginsManager/map/triggerSearch'; +import { MinervaConfiguration } from '@/services/pluginsManager/pluginsManager'; +import { MapInstance } from '@/types/map'; import { getModels } from '@/services/pluginsManager/map/models/getModels'; import { OpenMapArgs, openMap } from '@/services/pluginsManager/map/openMap'; import { triggerSearch } from '@/services/pluginsManager/map/triggerSearch'; @@ -36,8 +42,11 @@ declare global { }; map: { data: { + getBounds: typeof getBounds; + getOpenMapId: typeof getOpenMapId; getModels: typeof getModels; }; + fitBounds: typeof fitBounds; openMap: typeof openMap; triggerSearch: typeof triggerSearch; }; diff --git a/src/components/Map/MapAdditionalActions/MapAdditionalActions.component.test.tsx b/src/components/Map/MapAdditionalActions/MapAdditionalActions.component.test.tsx index f65f5b60..06bd09fe 100644 --- a/src/components/Map/MapAdditionalActions/MapAdditionalActions.component.test.tsx +++ b/src/components/Map/MapAdditionalActions/MapAdditionalActions.component.test.tsx @@ -54,7 +54,7 @@ const renderComponentWithMapInstance = (initialStore?: InitialStoreState): { sto const { Wrapper, store } = getReduxWrapperWithStore(initialStore, { mapInstanceContextValue: { mapInstance, - setMapInstance: () => {}, + handleSetMapInstance: () => {}, }, }); diff --git a/src/components/Map/MapAdditionalActions/utils/useAdditionalActions.test.ts b/src/components/Map/MapAdditionalActions/utils/useAdditionalActions.test.ts index f711b392..b862beae 100644 --- a/src/components/Map/MapAdditionalActions/utils/useAdditionalActions.test.ts +++ b/src/components/Map/MapAdditionalActions/utils/useAdditionalActions.test.ts @@ -99,7 +99,7 @@ describe('useAddtionalActions - hook', () => { { mapInstanceContextValue: { mapInstance, - setMapInstance: () => {}, + handleSetMapInstance: () => {}, }, }, ); diff --git a/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleAliasResults.ts b/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleAliasResults.ts index b1487583..cd7aefe9 100644 --- a/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleAliasResults.ts +++ b/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleAliasResults.ts @@ -1,11 +1,12 @@ import { getMultiBioEntity } from '@/redux/bioEntity/bioEntity.thunks'; import { openBioEntityDrawerById } from '@/redux/drawer/drawer.slice'; import { AppDispatch } from '@/redux/store'; +import { searchFitBounds } from '@/services/pluginsManager/map/triggerSearch/searchFitBounds'; import { ElementSearchResult } from '@/types/models'; /* prettier-ignore */ export const handleAliasResults = - (dispatch: AppDispatch) => + (dispatch: AppDispatch, hasFitBounds?: boolean, fitBoundsZoom?: number) => async ({ id }: ElementSearchResult): Promise<void> => { dispatch(openBioEntityDrawerById(id)); @@ -14,5 +15,12 @@ export const handleAliasResults = searchQueries: [id.toString()], isPerfectMatch: true }), - ); + ) + .unwrap().then(() => { + if (hasFitBounds) { + searchFitBounds(fitBoundsZoom); + } + }).catch(() => { + // TODO to discuss manage state of failure + }); }; diff --git a/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleReactionResults.ts b/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleReactionResults.ts index 55c244ad..d8282817 100644 --- a/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleReactionResults.ts +++ b/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleReactionResults.ts @@ -3,12 +3,13 @@ import { getMultiBioEntity } from '@/redux/bioEntity/bioEntity.thunks'; import { openReactionDrawerById } from '@/redux/drawer/drawer.slice'; import { getReactionsByIds } from '@/redux/reactions/reactions.thunks'; import { AppDispatch } from '@/redux/store'; +import { searchFitBounds } from '@/services/pluginsManager/map/triggerSearch/searchFitBounds'; import { ElementSearchResult, Reaction } from '@/types/models'; import { PayloadAction } from '@reduxjs/toolkit'; /* prettier-ignore */ export const handleReactionResults = - (dispatch: AppDispatch) => + (dispatch: AppDispatch, hasFitBounds?: boolean, fitBoundsZoom?: number) => async ({ id }: ElementSearchResult): Promise<void> => { const data = await dispatch(getReactionsByIds([id])) as PayloadAction<Reaction[] | undefined>; const payload = data?.payload; @@ -28,6 +29,12 @@ export const handleReactionResults = getMultiBioEntity({ searchQueries: bioEntitiesIds, isPerfectMatch: true }, - ), - ); + ) + ).unwrap().then(() => { + if (hasFitBounds) { + searchFitBounds(fitBoundsZoom); + } + }).catch(() => { + // TODO to discuss manage state of failure + }); }; diff --git a/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleSearchResultAction.ts b/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleSearchResultAction.ts index 23ae912e..c3663e41 100644 --- a/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleSearchResultAction.ts +++ b/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleSearchResultAction.ts @@ -8,11 +8,15 @@ import { handleReactionResults } from './handleReactionResults'; interface HandleSearchResultActionInput { searchResults: ElementSearchResult[]; dispatch: AppDispatch; + hasFitBounds?: boolean; + fitBoundsZoom?: number; } export const handleSearchResultAction = async ({ searchResults, dispatch, + hasFitBounds, + fitBoundsZoom, }: HandleSearchResultActionInput): Promise<void> => { const closestSearchResult = searchResults[FIRST_ARRAY_ELEMENT]; const { type } = closestSearchResult; @@ -21,7 +25,7 @@ export const handleSearchResultAction = async ({ REACTION: handleReactionResults, }[type]; - await action(dispatch)(closestSearchResult); + await action(dispatch, hasFitBounds, fitBoundsZoom)(closestSearchResult); if (type === 'ALIAS') { PluginsEventBus.dispatchEvent('onBioEntityClick', closestSearchResult); diff --git a/src/components/Map/MapViewer/utils/useOlMap.ts b/src/components/Map/MapViewer/utils/useOlMap.ts index 326e8ec8..49ec3002 100644 --- a/src/components/Map/MapViewer/utils/useOlMap.ts +++ b/src/components/Map/MapViewer/utils/useOlMap.ts @@ -19,7 +19,7 @@ type UseOlMap = (input?: UseOlMapInput) => UseOlMapOutput; export const useOlMap: UseOlMap = ({ target } = {}) => { const mapRef = React.useRef<null | HTMLDivElement>(null); - const { mapInstance, setMapInstance } = useMapInstance(); + const { mapInstance, handleSetMapInstance } = useMapInstance(); const view = useOlMapView({ mapInstance }); useOlMapLayers({ mapInstance }); useOlMapListeners({ view, mapInstance }); @@ -41,8 +41,8 @@ export const useOlMap: UseOlMap = ({ target } = {}) => { } }); - setMapInstance(currentMap => currentMap || map); - }, [target, setMapInstance]); + handleSetMapInstance(map); + }, [target, handleSetMapInstance]); return { mapRef, diff --git a/src/services/pluginsManager/errorMessages.ts b/src/services/pluginsManager/errorMessages.ts index 7d356494..753b06f0 100644 --- a/src/services/pluginsManager/errorMessages.ts +++ b/src/services/pluginsManager/errorMessages.ts @@ -1,6 +1,7 @@ export const ERROR_MAP_NOT_FOUND = 'Map with provided id does not exist'; export const ERROR_INVALID_QUERY_TYPE = 'Invalid query type. The query should be of string type'; export const ERROR_INVALID_COORDINATES = 'Invalid coordinates type or values'; -export const ERROR_INVALID_MODEL_ID_TYPE = - 'Invalid model id type. The model should be of number type'; +export const ERROR_INVALID_MODEL_ID_TYPE = 'Invalid model id type. The model id should be a number'; export const ERROR_PROJECT_NOT_FOUND = 'Project does not exist'; +export const ERROR_INVALID_MODEL_ID_TYPE_FOR_RETRIEVAL = + 'Unable to retrieve the id of the active map: the modelId is not a number'; diff --git a/src/services/pluginsManager/map/data/getBounds.test.ts b/src/services/pluginsManager/map/data/getBounds.test.ts new file mode 100644 index 00000000..99f587da --- /dev/null +++ b/src/services/pluginsManager/map/data/getBounds.test.ts @@ -0,0 +1,60 @@ +/* eslint-disable no-magic-numbers */ +import { MAP_DATA_INITIAL_STATE } from '@/redux/map/map.constants'; +import { store } from '@/redux/store'; +import { Map } from 'ol'; +import { MapManager } from '../mapManager'; +import { getBounds } from './getBounds'; + +describe('getBounds', () => { + it('should return undefined if map instance does not exist', () => { + expect(getBounds()).toEqual(undefined); + }); + it('should return current bounds if map instance exist', () => { + const dummyElement = document.createElement('div'); + const mapInstance = new Map({ target: dummyElement }); + MapManager.setMapInstance(mapInstance); + + jest.spyOn(mapInstance, 'getView').mockImplementation( + () => + ({ + calculateExtent: () => [ + -14409068.309137221, 17994265.029590994, -13664805.690862779, 18376178.970409006, + ], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }) as any, + ); + + const getStateSpy = jest.spyOn(store, 'getState'); + getStateSpy.mockImplementation( + () => + ({ + map: { + data: { + ...MAP_DATA_INITIAL_STATE, + size: { + width: 26779.25, + height: 13503, + tileSize: 256, + minZoom: 2, + maxZoom: 9, + }, + }, + loading: 'idle', + error: { + name: '', + message: '', + }, + openedMaps: [], + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }) as any, + ); + + expect(getBounds()).toEqual({ + x1: 15044, + y1: 4441, + x2: 17034, + y2: 5461, + }); + }); +}); diff --git a/src/services/pluginsManager/map/data/getBounds.ts b/src/services/pluginsManager/map/data/getBounds.ts new file mode 100644 index 00000000..626e13f7 --- /dev/null +++ b/src/services/pluginsManager/map/data/getBounds.ts @@ -0,0 +1,37 @@ +import { mapDataSizeSelector } from '@/redux/map/map.selectors'; +import { store } from '@/redux/store'; +import { latLngToPoint } from '@/utils/map/latLngToPoint'; +import { toLonLat } from 'ol/proj'; +import { MapManager } from '../mapManager'; + +type GetBoundsReturnType = + | { + x1: number; + x2: number; + y1: number; + y2: number; + } + | undefined; + +export const getBounds = (): GetBoundsReturnType => { + const mapInstance = MapManager.getMapInstance(); + + if (!mapInstance) return undefined; + + const [minx, miny, maxx, maxy] = mapInstance.getView().calculateExtent(mapInstance.getSize()); + + const mapSize = mapDataSizeSelector(store.getState()); + + const [lngX1, latY1] = toLonLat([minx, maxy]); + const [lngX2, latY2] = toLonLat([maxx, miny]); + + const { x: x1, y: y1 } = latLngToPoint([latY1, lngX1], mapSize, { rounded: true }); + const { x: x2, y: y2 } = latLngToPoint([latY2, lngX2], mapSize, { rounded: true }); + + return { + x1, + y1, + x2, + y2, + }; +}; diff --git a/src/services/pluginsManager/map/fitBounds/fitBounds.constants.ts b/src/services/pluginsManager/map/fitBounds/fitBounds.constants.ts new file mode 100644 index 00000000..af85941c --- /dev/null +++ b/src/services/pluginsManager/map/fitBounds/fitBounds.constants.ts @@ -0,0 +1,5 @@ +import { HALF } from '@/constants/dividers'; +import { DEFAULT_TILE_SIZE } from '@/constants/map'; + +const BOUNDS_PADDING = DEFAULT_TILE_SIZE / HALF; +export const DEFAULT_PADDING = [BOUNDS_PADDING, BOUNDS_PADDING, BOUNDS_PADDING, BOUNDS_PADDING]; diff --git a/src/services/pluginsManager/map/fitBounds/fitBounds.test.ts b/src/services/pluginsManager/map/fitBounds/fitBounds.test.ts new file mode 100644 index 00000000..1f45aecf --- /dev/null +++ b/src/services/pluginsManager/map/fitBounds/fitBounds.test.ts @@ -0,0 +1,122 @@ +/* eslint-disable no-magic-numbers */ +import { MAP_DATA_INITIAL_STATE } from '@/redux/map/map.constants'; +import { Map } from 'ol'; +import { store } from '@/redux/store'; +import { fitBounds } from './fitBounds'; +import { MapManager } from '../mapManager'; + +jest.mock('../../../../redux/store'); + +describe('fitBounds', () => { + beforeEach(() => { + jest.clearAllMocks(); + MapManager.mapInstance = null; + }); + it('fitBounds should return undefined', () => { + expect( + fitBounds({ + x1: 5, + y1: 10, + x2: 15, + y2: 20, + }), + ).toBe(undefined); + }); + + describe('when mapInstance is set', () => { + it('should call and set map instance view properly', () => { + const dummyElement = document.createElement('div'); + const mapInstance = new Map({ target: dummyElement }); + MapManager.setMapInstance(mapInstance); + const view = mapInstance.getView(); + const getViewSpy = jest.spyOn(mapInstance, 'getView'); + const fitSpy = jest.spyOn(view, 'fit'); + const getStateSpy = jest.spyOn(store, 'getState'); + getStateSpy.mockImplementation( + () => + ({ + map: { + data: { + ...MAP_DATA_INITIAL_STATE, + size: { + width: 256, + height: 256, + tileSize: 256, + minZoom: 1, + maxZoom: 1, + }, + }, + loading: 'idle', + error: { + name: '', + message: '', + }, + openedMaps: [], + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }) as any, + ); + + fitBounds({ + x1: 10, + y1: 10, + x2: 15, + y2: 20, + }); + + expect(getViewSpy).toHaveBeenCalledTimes(1); + expect(fitSpy).toHaveBeenCalledWith([-18472078, 16906648, -17689363, 18472078], { + maxZoom: 1, + padding: [128, 128, 128, 128], + size: undefined, + }); + }); + it('should use max zoom value', () => { + const dummyElement = document.createElement('div'); + const mapInstance = new Map({ target: dummyElement }); + MapManager.setMapInstance(mapInstance); + const view = mapInstance.getView(); + const getViewSpy = jest.spyOn(mapInstance, 'getView'); + const fitSpy = jest.spyOn(view, 'fit'); + const getStateSpy = jest.spyOn(store, 'getState'); + getStateSpy.mockImplementation( + () => + ({ + map: { + data: { + ...MAP_DATA_INITIAL_STATE, + size: { + width: 256, + height: 256, + tileSize: 256, + minZoom: 1, + maxZoom: 99, + }, + }, + loading: 'idle', + error: { + name: '', + message: '', + }, + openedMaps: [], + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }) as any, + ); + + fitBounds({ + x1: 10, + y1: 10, + x2: 15, + y2: 20, + }); + + expect(getViewSpy).toHaveBeenCalledTimes(1); + expect(fitSpy).toHaveBeenCalledWith([-18472078, 16906648, -17689363, 18472078], { + maxZoom: 99, + padding: [128, 128, 128, 128], + size: undefined, + }); + }); + }); +}); diff --git a/src/services/pluginsManager/map/fitBounds/fitBounds.ts b/src/services/pluginsManager/map/fitBounds/fitBounds.ts new file mode 100644 index 00000000..079c6d9c --- /dev/null +++ b/src/services/pluginsManager/map/fitBounds/fitBounds.ts @@ -0,0 +1,45 @@ +import { FitOptions } from 'ol/View'; +import { boundingExtent } from 'ol/extent'; +import { mapDataSizeSelector } from '@/redux/map/map.selectors'; +import { store } from '@/redux/store'; +import { MapManager } from '../mapManager'; +import { pointToProjection } from './fitBounds.utils'; +import { DEFAULT_PADDING } from './fitBounds.constants'; + +type FitBoundsArgs = { + x1: number; + x2: number; + y1: number; + y2: number; +}; + +export const fitBounds = ({ x1, y1, x2, y2 }: FitBoundsArgs): void => { + const mapInstance = MapManager.getMapInstance(); + + if (!mapInstance) return; + + const mapSize = mapDataSizeSelector(store.getState()); + + const points = [ + { + x: x1, + y: y2, + }, + { + x: x2, + y: y1, + }, + ]; + + const coordinates = points.map(point => pointToProjection(point, mapSize)); + + const extent = boundingExtent(coordinates); + + const options: FitOptions = { + size: mapInstance.getSize(), + padding: DEFAULT_PADDING, + maxZoom: mapSize.maxZoom, + }; + + mapInstance.getView().fit(extent, options); +}; diff --git a/src/services/pluginsManager/map/fitBounds/fitBounds.utils.test.ts b/src/services/pluginsManager/map/fitBounds/fitBounds.utils.test.ts new file mode 100644 index 00000000..6d2f5cac --- /dev/null +++ b/src/services/pluginsManager/map/fitBounds/fitBounds.utils.test.ts @@ -0,0 +1,66 @@ +/* eslint-disable no-magic-numbers */ +import { pointToProjection } from './fitBounds.utils'; + +describe('pointToProjection - util', () => { + describe('when mapSize arg is invalid', () => { + const validPoint = { + x: 0, + y: 0, + }; + + const invalidMapSize = { + width: -256 * 10, + height: -256 * 10, + tileSize: -256, + minZoom: -1, + maxZoom: -10, + }; + + it('should return fallback value on function call', () => { + expect(pointToProjection(validPoint, invalidMapSize)).toStrictEqual([0, -0]); + }); + }); + + describe('when point and map size is valid', () => { + const validPoint = { + x: 256 * 100, + y: 256 * 100, + }; + + const validMapSize = { + width: 256 * 10, + height: 256 * 10, + tileSize: 256, + minZoom: 1, + maxZoom: 10, + }; + + const results = [380712659, -238107693]; + + it('should return valid lat lng value on function call', () => { + const [x, y] = pointToProjection(validPoint, validMapSize); + + expect(x).toBe(results[0]); + expect(y).toBe(results[1]); + }); + }); + describe('when point arg is invalid', () => { + const invalidPoint = { + x: 'x', + y: 'y', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + + const validMapSize = { + width: 256 * 10, + height: 256 * 10, + tileSize: 256, + minZoom: 1, + maxZoom: 10, + }; + + it('should return fallback value on function call', () => { + expect(pointToProjection(invalidPoint, validMapSize)).toStrictEqual([0, 0]); + }); + }); +}); diff --git a/src/services/pluginsManager/map/fitBounds/fitBounds.utils.ts b/src/services/pluginsManager/map/fitBounds/fitBounds.utils.ts new file mode 100644 index 00000000..de206bd4 --- /dev/null +++ b/src/services/pluginsManager/map/fitBounds/fitBounds.utils.ts @@ -0,0 +1,14 @@ +import { LATLNG_FALLBACK } from '@/constants/map'; +import { MapSize } from '@/redux/map/map.types'; +import { Point } from '@/types/map'; +import { pointToLngLat } from '@/utils/map/pointToLatLng'; +import { fromLonLat } from 'ol/proj'; + +export const pointToProjection = (point: Point, mapSize: MapSize): number[] => { + const [lng, lat] = pointToLngLat(point, mapSize); + const projection = fromLonLat([lng, lat]); + const projectionRounded = projection.map(v => Math.round(v)); + const isValid = !projectionRounded.some(v => Number.isNaN(v)); + + return isValid ? projectionRounded : LATLNG_FALLBACK; +}; diff --git a/src/services/pluginsManager/map/fitBounds/index.ts b/src/services/pluginsManager/map/fitBounds/index.ts new file mode 100644 index 00000000..28bd7717 --- /dev/null +++ b/src/services/pluginsManager/map/fitBounds/index.ts @@ -0,0 +1 @@ +export { fitBounds } from './fitBounds'; diff --git a/src/services/pluginsManager/map/getOpenMapId.test.ts b/src/services/pluginsManager/map/getOpenMapId.test.ts new file mode 100644 index 00000000..cdbe2147 --- /dev/null +++ b/src/services/pluginsManager/map/getOpenMapId.test.ts @@ -0,0 +1,40 @@ +import { RootState, store } from '@/redux/store'; +import { initialMapStateFixture } from '@/redux/map/map.fixtures'; +import { getOpenMapId } from './getOpenMapId'; +import { ERROR_INVALID_MODEL_ID_TYPE_FOR_RETRIEVAL } from '../errorMessages'; + +describe('getOpenMapId', () => { + const getStateMock = jest.spyOn(store, 'getState'); + + beforeEach(() => { + jest.clearAllMocks(); + }); + it('should return the modelId of the current map', () => { + getStateMock.mockImplementation( + () => + ({ + map: initialMapStateFixture, + }) as RootState, + ); + + expect(getOpenMapId()).toEqual(initialMapStateFixture.data.modelId); + }); + + it('should throw an error if modelId is not a number', () => { + getStateMock.mockImplementation( + () => + ({ + map: { + ...initialMapStateFixture, + data: { + ...initialMapStateFixture.data, + modelId: null, + }, + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }) as any, + ); + + expect(() => getOpenMapId()).toThrowError(ERROR_INVALID_MODEL_ID_TYPE_FOR_RETRIEVAL); + }); +}); diff --git a/src/services/pluginsManager/map/getOpenMapId.ts b/src/services/pluginsManager/map/getOpenMapId.ts new file mode 100644 index 00000000..dd3cc808 --- /dev/null +++ b/src/services/pluginsManager/map/getOpenMapId.ts @@ -0,0 +1,13 @@ +import { store } from '@/redux/store'; +import { ERROR_INVALID_MODEL_ID_TYPE_FOR_RETRIEVAL } from '../errorMessages'; + +export const getOpenMapId = (): number => { + const currentMap = store.getState().map.data; + const openMapId = currentMap.modelId; + + if (typeof openMapId !== 'number') { + throw new Error(ERROR_INVALID_MODEL_ID_TYPE_FOR_RETRIEVAL); + } + + return openMapId; +}; diff --git a/src/services/pluginsManager/map/mapManager.test.ts b/src/services/pluginsManager/map/mapManager.test.ts new file mode 100644 index 00000000..9f3349c9 --- /dev/null +++ b/src/services/pluginsManager/map/mapManager.test.ts @@ -0,0 +1,34 @@ +import { Map } from 'ol'; + +import { MapInstance } from '@/types/map'; +import { MapManager } from './mapManager'; + +describe('MapManager', () => { + describe('getMapInstance', () => { + it('should return null if no map instance is set', () => { + expect(MapManager.getMapInstance()).toBeNull(); + }); + + it('should return the set map instance', () => { + const dummyElement = document.createElement('div'); + const mapInstance = new Map({ target: dummyElement }); + MapManager.setMapInstance(mapInstance); + expect(MapManager.getMapInstance()).toEqual(mapInstance); + }); + }); + + describe('setMapInstance', () => { + beforeEach(() => { + MapManager.mapInstance = null; + }); + it('should set the map instance', () => { + const dummyElement = document.createElement('div'); + const mapInstance = new Map({ target: dummyElement }); + MapManager.setMapInstance(mapInstance); + expect(MapManager.mapInstance).toEqual(mapInstance); + }); + it('should throw error if map instance is not valid', () => { + expect(() => MapManager.setMapInstance({} as MapInstance)).toThrow('Not valid map instance'); + }); + }); +}); diff --git a/src/services/pluginsManager/map/mapManager.ts b/src/services/pluginsManager/map/mapManager.ts new file mode 100644 index 00000000..a710f208 --- /dev/null +++ b/src/services/pluginsManager/map/mapManager.ts @@ -0,0 +1,18 @@ +import { MapInstance } from '@/types/map'; +import { Map } from 'ol'; + +type MapManagerType = { + mapInstance: null | MapInstance; + setMapInstance: (mapInstance: MapInstance) => void; + getMapInstance: () => MapInstance | null; +}; + +export const MapManager: MapManagerType = { + mapInstance: null, + + setMapInstance: (mapInstance: MapInstance) => { + if (!(mapInstance instanceof Map)) throw new Error('Not valid map instance'); + MapManager.mapInstance = mapInstance; + }, + getMapInstance: () => MapManager.mapInstance, +}; diff --git a/src/services/pluginsManager/map/triggerSearch/getPolygonPoints.ts b/src/services/pluginsManager/map/triggerSearch/getPolygonPoints.ts new file mode 100644 index 00000000..ce27f4ba --- /dev/null +++ b/src/services/pluginsManager/map/triggerSearch/getPolygonPoints.ts @@ -0,0 +1,33 @@ +import { allVisibleBioEntitiesSelector } from '@/redux/bioEntity/bioEntity.selectors'; +import { store } from '@/redux/store'; +import { isPointValid } from '@/utils/point/isPointValid'; + +type Points = { + x: number; + y: number; +}[]; + +export const getPolygonPoints = (): Points => { + const allVisibleBioEntities = allVisibleBioEntitiesSelector(store.getState()); + 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); +}; diff --git a/src/services/pluginsManager/map/triggerSearch/getVisibleBioEntitiesPolygonCoordinates.test.ts b/src/services/pluginsManager/map/triggerSearch/getVisibleBioEntitiesPolygonCoordinates.test.ts new file mode 100644 index 00000000..47fc1165 --- /dev/null +++ b/src/services/pluginsManager/map/triggerSearch/getVisibleBioEntitiesPolygonCoordinates.test.ts @@ -0,0 +1,158 @@ +/* eslint-disable no-magic-numbers */ +import { MAP_INITIAL_STATE } from '@/redux/map/map.constants'; +import { RootState, store } from '@/redux/store'; +import { bioEntityContentFixture } from '@/models/fixtures/bioEntityContentsFixture'; +import { DRAWER_INITIAL_STATE } from '@/redux/drawer/drawer.constants'; + +import { MODELS_DATA_MOCK_WITH_MAIN_MAP } from '@/redux/models/models.mock'; +import { getVisibleBioEntitiesPolygonCoordinates } from './getVisibleBioEntitiesPolygonCoordinates'; + +jest.mock('../../../../redux/store'); + +const getStateSpy = jest.spyOn(store, 'getState'); + +describe('getVisibleBioEntitiesPolygonCoordinates', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + it('should return undefined if received array does not contain bioEntities with current map id', () => { + getStateSpy.mockImplementation( + () => + ({ + models: MODELS_DATA_MOCK_WITH_MAIN_MAP, + map: { + ...MAP_INITIAL_STATE, + data: { + ...MAP_INITIAL_STATE.data, + modelId: 5052, + size: { + width: 256, + height: 256, + tileSize: 256, + minZoom: 1, + maxZoom: 1, + }, + }, + }, + bioEntity: { + loading: 'succeeded', + error: { message: '', name: '' }, + }, + drugs: { + loading: 'succeeded', + error: { message: '', name: '' }, + }, + drawer: { + ...DRAWER_INITIAL_STATE, + bioEntityDrawerState: { + bioentityId: undefined, + drugs: {}, + chemicals: {}, + }, + }, + }) as RootState, + ); + + expect(getVisibleBioEntitiesPolygonCoordinates()).toBe(undefined); + }); + it('should return coordinates, max zoom, and map instance if received array contain bioEntities with current map id and max zoom', () => { + getStateSpy.mockImplementation( + () => + ({ + models: MODELS_DATA_MOCK_WITH_MAIN_MAP, + map: { + ...MAP_INITIAL_STATE, + data: { + ...MAP_INITIAL_STATE.data, + modelId: 52, + size: { + width: 256, + height: 256, + tileSize: 256, + minZoom: 1, + maxZoom: 5, + }, + }, + }, + bioEntity: { + data: [ + { + searchQueryElement: bioEntityContentFixture.bioEntity.name, + loading: 'succeeded', + error: { name: '', message: '' }, + data: [ + { + ...bioEntityContentFixture, + bioEntity: { + ...bioEntityContentFixture.bioEntity, + model: 52, + x: 97, + y: 53, + z: 1, + }, + }, + { + ...bioEntityContentFixture, + bioEntity: { + ...bioEntityContentFixture.bioEntity, + model: 52, + x: 12, + y: 25, + z: 1, + }, + }, + ], + }, + ], + loading: 'succeeded', + error: { message: '', name: '' }, + }, + drugs: { + data: [ + { + searchQueryElement: '', + loading: 'succeeded', + error: { name: '', message: '' }, + data: undefined, + }, + ], + loading: 'succeeded', + error: { message: '', name: '' }, + }, + chemicals: { + data: [ + { + searchQueryElement: '', + loading: 'succeeded', + error: { name: '', message: '' }, + data: undefined, + }, + ], + loading: 'succeeded', + error: { message: '', name: '' }, + }, + drawer: { + ...DRAWER_INITIAL_STATE, + bioEntityDrawerState: { + bioentityId: undefined, + drugs: {}, + chemicals: {}, + }, + searchDrawerState: { + ...DRAWER_INITIAL_STATE.searchDrawerState, + selectedSearchElement: bioEntityContentFixture.bioEntity.name, + }, + }, + }) as RootState, + ); + + expect(getVisibleBioEntitiesPolygonCoordinates()).toEqual({ + mapInstance: null, + maxZoom: 5, + polygonCoordinates: [ + [-18158992, 11740728], + [-4852834, 16123932], + ], + }); + }); +}); diff --git a/src/services/pluginsManager/map/triggerSearch/getVisibleBioEntitiesPolygonCoordinates.ts b/src/services/pluginsManager/map/triggerSearch/getVisibleBioEntitiesPolygonCoordinates.ts new file mode 100644 index 00000000..c1fba338 --- /dev/null +++ b/src/services/pluginsManager/map/triggerSearch/getVisibleBioEntitiesPolygonCoordinates.ts @@ -0,0 +1,33 @@ +import { store } from '@/redux/store'; +import { mapDataSizeSelector } from '@/redux/map/map.selectors'; +import { MapInstance } from '@/types/map'; +import { MapManager } from '../mapManager'; +import { pointToProjection } from '../fitBounds/fitBounds.utils'; +import { getPolygonPoints } from './getPolygonPoints'; + +const VALID_POLYGON_COORDINATES_LENGTH = 2; + +export const getVisibleBioEntitiesPolygonCoordinates = (): + | { + polygonCoordinates: number[][]; + maxZoom: number; + mapInstance: MapInstance | null; + } + | undefined => { + const mapSize = mapDataSizeSelector(store.getState()); + const { maxZoom } = mapDataSizeSelector(store.getState()); + + const polygonPoints = getPolygonPoints(); + + const polygonCoordinates = polygonPoints.map(point => pointToProjection(point, mapSize)); + + if (polygonCoordinates.length !== VALID_POLYGON_COORDINATES_LENGTH) { + return undefined; + } + + return { + polygonCoordinates, + maxZoom, + mapInstance: MapManager.getMapInstance(), + }; +}; diff --git a/src/services/pluginsManager/map/triggerSearch/searchByCoordinates.ts b/src/services/pluginsManager/map/triggerSearch/searchByCoordinates.ts index bdd6a539..c04dfaac 100644 --- a/src/services/pluginsManager/map/triggerSearch/searchByCoordinates.ts +++ b/src/services/pluginsManager/map/triggerSearch/searchByCoordinates.ts @@ -8,6 +8,8 @@ import { Coordinates } from './triggerSearch.types'; export const searchByCoordinates = async ( coordinates: Coordinates, modelId: number, + hasFitBounds?: boolean, + fitBoundsZoom?: number, ): Promise<void> => { const { dispatch } = store; // side-effect below is to prevent complications with data update - old data may conflict with new data @@ -23,5 +25,5 @@ export const searchByCoordinates = async ( return; } - handleSearchResultAction({ searchResults, dispatch }); + handleSearchResultAction({ searchResults, dispatch, hasFitBounds, fitBoundsZoom }); }; diff --git a/src/services/pluginsManager/map/triggerSearch/searchByQuery.test.ts b/src/services/pluginsManager/map/triggerSearch/searchByQuery.test.ts new file mode 100644 index 00000000..e804a294 --- /dev/null +++ b/src/services/pluginsManager/map/triggerSearch/searchByQuery.test.ts @@ -0,0 +1,119 @@ +import { RootState, store } from '@/redux/store'; +import { mockNetworkNewAPIResponse } from '@/utils/mockNetworkResponse'; +import { apiPath } from '@/redux/apiPath'; +import { HttpStatusCode } from 'axios'; +import { bioEntityResponseFixture } from '@/models/fixtures/bioEntityContentsFixture'; +import { drugsFixture } from '@/models/fixtures/drugFixtures'; +import { chemicalsFixture } from '@/models/fixtures/chemicalsFixture'; +import { DRAWER_INITIAL_STATE } from '@/redux/drawer/drawer.constants'; +import { MODELS_DATA_MOCK_WITH_MAIN_MAP } from '@/redux/models/models.mock'; +import { MAP_INITIAL_STATE } from '@/redux/map/map.constants'; +import { waitFor } from '@testing-library/react'; +import { searchByQuery } from './searchByQuery'; +import { searchFitBounds } from './searchFitBounds'; + +const MOCK_SEARCH_BY_QUERY_STORE = { + models: MODELS_DATA_MOCK_WITH_MAIN_MAP, + map: { + ...MAP_INITIAL_STATE, + data: { + ...MAP_INITIAL_STATE.data, + modelId: 5052, + size: { + width: 256, + height: 256, + tileSize: 256, + minZoom: 1, + maxZoom: 1, + }, + }, + }, + bioEntity: { + loading: 'succeeded', + error: { message: '', name: '' }, + }, + drugs: { + loading: 'succeeded', + error: { message: '', name: '' }, + }, + drawer: { + ...DRAWER_INITIAL_STATE, + bioEntityDrawerState: { + bioentityId: undefined, + drugs: {}, + chemicals: {}, + }, + }, +}; + +const mockedAxiosClient = mockNetworkNewAPIResponse(); +const SEARCH_QUERY = 'park7'; + +jest.mock('./searchFitBounds'); +jest.mock('../../../../redux/store'); +const dispatchSpy = jest.spyOn(store, 'dispatch'); +const getStateSpy = jest.spyOn(store, 'getState'); + +describe('searchByQuery', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + it('should fit bounds after search if hasFitBounds param is true', async () => { + dispatchSpy.mockImplementation(() => ({ + unwrap: (): Promise<void> => Promise.resolve(), + })); + + getStateSpy.mockImplementation(() => MOCK_SEARCH_BY_QUERY_STORE as RootState); + mockedAxiosClient + .onGet( + apiPath.getBioEntityContentsStringWithQuery({ + searchQuery: SEARCH_QUERY, + isPerfectMatch: false, + }), + ) + .reply(HttpStatusCode.Ok, bioEntityResponseFixture); + + mockedAxiosClient + .onGet(apiPath.getDrugsStringWithQuery(SEARCH_QUERY)) + .reply(HttpStatusCode.Ok, drugsFixture); + + mockedAxiosClient + .onGet(apiPath.getChemicalsStringWithQuery(SEARCH_QUERY)) + .reply(HttpStatusCode.Ok, chemicalsFixture); + + searchByQuery(SEARCH_QUERY, false, true); + + await waitFor(() => { + expect(searchFitBounds).toHaveBeenCalled(); + }); + }); + it('should not fit bounds after search if hasFitBounds param is false', async () => { + dispatchSpy.mockImplementation(() => ({ + unwrap: (): Promise<void> => Promise.resolve(), + })); + + getStateSpy.mockImplementation(() => MOCK_SEARCH_BY_QUERY_STORE as RootState); + mockedAxiosClient + .onGet( + apiPath.getBioEntityContentsStringWithQuery({ + searchQuery: SEARCH_QUERY, + isPerfectMatch: false, + }), + ) + .reply(HttpStatusCode.Ok, bioEntityResponseFixture); + + mockedAxiosClient + .onGet(apiPath.getDrugsStringWithQuery(SEARCH_QUERY)) + .reply(HttpStatusCode.Ok, drugsFixture); + + mockedAxiosClient + .onGet(apiPath.getChemicalsStringWithQuery(SEARCH_QUERY)) + .reply(HttpStatusCode.Ok, chemicalsFixture); + + searchByQuery(SEARCH_QUERY, false, false); + + await waitFor(() => { + expect(searchFitBounds).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/services/pluginsManager/map/triggerSearch/searchByQuery.ts b/src/services/pluginsManager/map/triggerSearch/searchByQuery.ts index 01d06037..4f48c0a7 100644 --- a/src/services/pluginsManager/map/triggerSearch/searchByQuery.ts +++ b/src/services/pluginsManager/map/triggerSearch/searchByQuery.ts @@ -2,13 +2,27 @@ import { getSearchValuesArrayAndTrimToSeven } from '@/components/FunctionalArea/ import { getSearchData } from '@/redux/search/search.thunks'; import { store } from '@/redux/store'; import { displaySearchDrawerWithSelectedDefaultTab } from './displaySearchDrawerWithSelectedDefaultTab'; +import { searchFitBounds } from './searchFitBounds'; -export const searchByQuery = (query: string, perfectSearch: boolean | undefined): void => { +export const searchByQuery = ( + query: string, + perfectSearch: boolean | undefined, + hasFitBounds?: boolean, +): void => { const { dispatch } = store; const searchValues = getSearchValuesArrayAndTrimToSeven(query); const isPerfectMatch = !!perfectSearch; - dispatch(getSearchData({ searchQueries: searchValues, isPerfectMatch })); + dispatch(getSearchData({ searchQueries: searchValues, isPerfectMatch })) + ?.unwrap() + .then(() => { + if (hasFitBounds) { + searchFitBounds(); + } + }) + .catch(() => { + // TODO to discuss manage state of failure + }); displaySearchDrawerWithSelectedDefaultTab(searchValues); }; diff --git a/src/services/pluginsManager/map/triggerSearch/searchFitBounds.test.ts b/src/services/pluginsManager/map/triggerSearch/searchFitBounds.test.ts new file mode 100644 index 00000000..25847a33 --- /dev/null +++ b/src/services/pluginsManager/map/triggerSearch/searchFitBounds.test.ts @@ -0,0 +1,105 @@ +/* eslint-disable no-magic-numbers */ +import { handleSetBounds } from '@/utils/map/useSetBounds'; +import { Map } from 'ol'; +import * as getVisibleBioEntitiesPolygonCoordinates from './getVisibleBioEntitiesPolygonCoordinates'; +import { searchFitBounds } from './searchFitBounds'; + +jest.mock('../../../../utils/map/useSetBounds'); + +jest.mock('./getVisibleBioEntitiesPolygonCoordinates', () => ({ + __esModule: true, + ...jest.requireActual('./getVisibleBioEntitiesPolygonCoordinates'), +})); + +const getVisibleBioEntitiesPolygonCoordinatesSpy = jest.spyOn( + getVisibleBioEntitiesPolygonCoordinates, + 'getVisibleBioEntitiesPolygonCoordinates', +); + +describe('searchFitBounds', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + it('should not handle set bounds if data is not valid', () => { + getVisibleBioEntitiesPolygonCoordinatesSpy.mockImplementation(() => undefined); + + searchFitBounds(); + + expect(handleSetBounds).not.toHaveBeenCalled(); + }); + it('should not handle set bounds if map instance is not valid', () => { + getVisibleBioEntitiesPolygonCoordinatesSpy.mockImplementation(() => ({ + mapInstance: null, + maxZoom: 5, + polygonCoordinates: [ + [231, 231], + [842, 271], + ], + })); + + searchFitBounds(); + + expect(handleSetBounds).not.toHaveBeenCalled(); + }); + it('should handle set bounds if provided data is valid', () => { + const dummyElement = document.createElement('div'); + const mapInstance = new Map({ target: dummyElement }); + const maxZoom = 5; + const polygonCoordinates = [ + [231, 231], + [842, 271], + ]; + + getVisibleBioEntitiesPolygonCoordinatesSpy.mockImplementation(() => ({ + mapInstance, + maxZoom, + polygonCoordinates, + })); + + searchFitBounds(); + + expect(handleSetBounds).toHaveBeenCalled(); + expect(handleSetBounds).toHaveBeenCalledWith(mapInstance, maxZoom, polygonCoordinates); + }); + it('should handle set bounds with max zoom if zoom is not provided in argument', () => { + const dummyElement = document.createElement('div'); + const mapInstance = new Map({ target: dummyElement }); + const maxZoom = 23; + const polygonCoordinates = [ + [231, 231], + [842, 271], + ]; + + getVisibleBioEntitiesPolygonCoordinatesSpy.mockImplementation(() => ({ + mapInstance, + maxZoom, + polygonCoordinates, + })); + + searchFitBounds(); + + expect(handleSetBounds).toHaveBeenCalled(); + expect(handleSetBounds).toHaveBeenCalledWith(mapInstance, maxZoom, polygonCoordinates); + }); + it('should handle set bounds with zoom provided in argument instead of max zoom', () => { + const zoom = 12; + const dummyElement = document.createElement('div'); + const mapInstance = new Map({ target: dummyElement }); + const maxZoom = 23; + const polygonCoordinates = [ + [231, 231], + [842, 271], + ]; + + getVisibleBioEntitiesPolygonCoordinatesSpy.mockImplementation(() => ({ + mapInstance, + maxZoom, + polygonCoordinates, + })); + + searchFitBounds(zoom); + + expect(handleSetBounds).toHaveBeenCalled(); + expect(handleSetBounds).toHaveBeenCalledWith(mapInstance, zoom, polygonCoordinates); + }); +}); diff --git a/src/services/pluginsManager/map/triggerSearch/searchFitBounds.ts b/src/services/pluginsManager/map/triggerSearch/searchFitBounds.ts new file mode 100644 index 00000000..9c2954eb --- /dev/null +++ b/src/services/pluginsManager/map/triggerSearch/searchFitBounds.ts @@ -0,0 +1,15 @@ +import { handleSetBounds } from '@/utils/map/useSetBounds'; +import { getVisibleBioEntitiesPolygonCoordinates } from './getVisibleBioEntitiesPolygonCoordinates'; + +export const searchFitBounds = (zoom?: number): void => { + const data = getVisibleBioEntitiesPolygonCoordinates(); + + if (data) { + const { polygonCoordinates, maxZoom, mapInstance } = data; + + if (!mapInstance) return; + + const setBoundsZoom = zoom || maxZoom; + handleSetBounds(mapInstance, setBoundsZoom, polygonCoordinates); + } +}; diff --git a/src/services/pluginsManager/map/triggerSearch/triggerSearch.test.ts b/src/services/pluginsManager/map/triggerSearch/triggerSearch.test.ts index 3c7408eb..1b522bb0 100644 --- a/src/services/pluginsManager/map/triggerSearch/triggerSearch.test.ts +++ b/src/services/pluginsManager/map/triggerSearch/triggerSearch.test.ts @@ -10,6 +10,7 @@ import { bioEntityResponseFixture } from '@/models/fixtures/bioEntityContentsFix import { waitFor } from '@testing-library/react'; import { handleSearchResultAction } from '@/components/Map/MapViewer/utils/listeners/mapSingleClick/handleSearchResultAction'; import { triggerSearch } from './triggerSearch'; +import { ERROR_INVALID_MODEL_ID_TYPE } from '../../errorMessages'; const mockedAxiosClient = mockNetworkNewAPIResponse(); const mockedAxiosOldClient = mockNetworkResponse(); @@ -148,9 +149,7 @@ describe('triggerSearch', () => { modelId: '53' as any, }; - await expect(triggerSearch(invalidParams)).rejects.toThrowError( - 'Invalid model id type. The model should be of number type', - ); + await expect(triggerSearch(invalidParams)).rejects.toThrowError(ERROR_INVALID_MODEL_ID_TYPE); }); it('should search result with proper data', async () => { mockedAxiosOldClient diff --git a/src/services/pluginsManager/map/triggerSearch/triggerSearch.ts b/src/services/pluginsManager/map/triggerSearch/triggerSearch.ts index 8f1b42fe..8a69f07b 100644 --- a/src/services/pluginsManager/map/triggerSearch/triggerSearch.ts +++ b/src/services/pluginsManager/map/triggerSearch/triggerSearch.ts @@ -12,7 +12,7 @@ export async function triggerSearch(params: SearchParams): Promise<void> { if (typeof params.query !== 'string') { throw new Error(ERROR_INVALID_QUERY_TYPE); } - searchByQuery(params.query, params.perfectSearch); + searchByQuery(params.query, params.perfectSearch, params.fitBounds); } else { const areCoordinatesInvalidType = typeof params.coordinates !== 'object' || params.coordinates === null; @@ -28,6 +28,6 @@ export async function triggerSearch(params: SearchParams): Promise<void> { throw new Error(ERROR_INVALID_MODEL_ID_TYPE); } - searchByCoordinates(params.coordinates, params.modelId); + searchByCoordinates(params.coordinates, params.modelId, params.fitBounds, params.zoom); } } diff --git a/src/services/pluginsManager/pluginsManager.ts b/src/services/pluginsManager/pluginsManager.ts index 23a78ff8..c0b7bd2b 100644 --- a/src/services/pluginsManager/pluginsManager.ts +++ b/src/services/pluginsManager/pluginsManager.ts @@ -15,6 +15,10 @@ import { getOrganism } from './project/data/getOrganism'; import type { PluginsManagerType } from './pluginsManager.types'; import { configurationMapper } from './pluginsManager.utils'; +import { getBounds } from './map/data/getBounds'; +import { fitBounds } from './map/fitBounds'; +import { getOpenMapId } from './map/getOpenMapId'; + export const PluginsManager: PluginsManagerType = { hashedPlugins: {}, setHashedPlugin({ pluginUrl, pluginScript }) { @@ -34,8 +38,11 @@ export const PluginsManager: PluginsManagerType = { }, map: { data: { + getBounds, + getOpenMapId, getModels, }, + fitBounds, openMap, triggerSearch, }, diff --git a/src/utils/context/mapInstanceContext.tsx b/src/utils/context/mapInstanceContext.tsx index 1c0982d8..85fe8fc8 100644 --- a/src/utils/context/mapInstanceContext.tsx +++ b/src/utils/context/mapInstanceContext.tsx @@ -1,14 +1,15 @@ +import { MapManager } from '@/services/pluginsManager/map/mapManager'; import { MapInstance } from '@/types/map'; -import { Dispatch, SetStateAction, createContext, useContext, useMemo, useState } from 'react'; +import { createContext, useCallback, useContext, useMemo, useState } from 'react'; export interface MapInstanceContext { mapInstance: MapInstance; - setMapInstance: Dispatch<SetStateAction<MapInstance>>; + handleSetMapInstance: (mapInstance: MapInstance) => void; } export const MapInstanceContext = createContext<MapInstanceContext>({ mapInstance: undefined, - setMapInstance: () => {}, + handleSetMapInstance: () => {}, }); export const useMapInstance = (): MapInstanceContext => useContext(MapInstanceContext); @@ -24,12 +25,22 @@ export const MapInstanceProvider = ({ }: MapInstanceProviderProps): JSX.Element => { const [mapInstance, setMapInstance] = useState<MapInstance>(initialValue?.mapInstance); + const handleSetMapInstance = useCallback( + (map: MapInstance) => { + if (!mapInstance) { + setMapInstance(map); + MapManager.setMapInstance(map); + } + }, + [mapInstance], + ); + const mapInstanceContextValue = useMemo( () => ({ mapInstance, - setMapInstance, + handleSetMapInstance, }), - [mapInstance], + [mapInstance, handleSetMapInstance], ); return ( diff --git a/src/utils/map/useSetBounds.test.ts b/src/utils/map/useSetBounds.test.ts index 4e00469d..71ee1260 100644 --- a/src/utils/map/useSetBounds.test.ts +++ b/src/utils/map/useSetBounds.test.ts @@ -39,7 +39,7 @@ describe('useSetBounds - hook', () => { { mapInstanceContextValue: { mapInstance: undefined, - setMapInstance: () => {}, + handleSetMapInstance: () => {}, }, }, ); @@ -84,7 +84,7 @@ describe('useSetBounds - hook', () => { { mapInstanceContextValue: { mapInstance, - setMapInstance: () => {}, + handleSetMapInstance: () => {}, }, }, ); -- GitLab