Skip to content
Snippets Groups Projects
Commit 5b34ca5c authored by mateusz-winiarczyk's avatar mateusz-winiarczyk
Browse files

feat(bounds): plugins bounds (MIN-228)

parent 1bfc48af
No related branches found
No related tags found
2 merge requests!223reset the pin numbers before search results are fetch (so the results will be...,!142feat(bounds): plugins bounds (MIN-228)
Showing
with 488 additions and 14 deletions
### 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,
});
```
......@@ -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
......
### 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.
......
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;
};
......
......@@ -54,7 +54,7 @@ const renderComponentWithMapInstance = (initialStore?: InitialStoreState): { sto
const { Wrapper, store } = getReduxWrapperWithStore(initialStore, {
mapInstanceContextValue: {
mapInstance,
setMapInstance: () => {},
handleSetMapInstance: () => {},
},
});
......
......@@ -99,7 +99,7 @@ describe('useAddtionalActions - hook', () => {
{
mapInstanceContextValue: {
mapInstance,
setMapInstance: () => {},
handleSetMapInstance: () => {},
},
},
);
......
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
});
};
......@@ -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
});
};
......@@ -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);
......
......@@ -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,
......
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';
/* 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,
});
});
});
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,
};
};
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];
/* 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,
});
});
});
});
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);
};
/* 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]);
});
});
});
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;
};
export { fitBounds } from './fitBounds';
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);
});
});
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment