diff --git a/src/components/Map/Drawer/SearchDrawerWrapper/BioEntitiesResultsList/BioEntitiesPinsList/BioEntitiesPinsListItem/BioEntitiesPinsListItem.component.test.tsx b/src/components/Map/Drawer/SearchDrawerWrapper/BioEntitiesResultsList/BioEntitiesPinsList/BioEntitiesPinsListItem/BioEntitiesPinsListItem.component.test.tsx index 9c245a247ee17a1d0ae73d10b5d509ae3db9656a..d10ba567c9fff0d174a8d04d32da4a212e978ce6 100644 --- a/src/components/Map/Drawer/SearchDrawerWrapper/BioEntitiesResultsList/BioEntitiesPinsList/BioEntitiesPinsListItem/BioEntitiesPinsListItem.component.test.tsx +++ b/src/components/Map/Drawer/SearchDrawerWrapper/BioEntitiesResultsList/BioEntitiesPinsList/BioEntitiesPinsListItem/BioEntitiesPinsListItem.component.test.tsx @@ -7,6 +7,9 @@ import { import { bioEntitiesContentFixture } from '@/models/fixtures/bioEntityContentsFixture'; import { StoreType } from '@/redux/store'; import { BioEntity } from '@/types/models'; +import { act } from 'react-dom/test-utils'; +import { MAP_INITIAL_STATE } from '@/redux/map/map.constants'; +import { DEFAULT_MAX_ZOOM } from '@/constants/map'; import { BioEntitiesPinsListItem } from './BioEntitiesPinsListItem.component'; const BIO_ENTITY = bioEntitiesContentFixture[0].bioEntity; @@ -84,4 +87,46 @@ describe('BioEntitiesPinsListItem - component ', () => { expect(screen.getByText(secondPinReferenceType, { exact: false })).toBeInTheDocument(); expect(screen.getByText(secondPinReferenceResource, { exact: false })).toBeInTheDocument(); }); + it('should center map to pin coordinates after click on pin icon', async () => { + const { store } = renderComponent(BIO_ENTITY.name, BIO_ENTITY, { + map: { + ...MAP_INITIAL_STATE, + data: { + ...MAP_INITIAL_STATE.data, + modelId: 5052, + size: { + width: 256, + height: 256, + tileSize: 256, + minZoom: 1, + maxZoom: 1, + }, + position: { + initial: { + x: 0, + y: 0, + z: 2, + }, + last: { + x: 1, + y: 1, + z: 3, + }, + }, + }, + }, + }); + const button = screen.getByTestId('center-to-pin-button'); + expect(button).toBeInTheDocument(); + + act(() => { + button.click(); + }); + + expect(store.getState().map.data.position.last).toEqual({ + x: BIO_ENTITY.x, + y: BIO_ENTITY.y, + z: DEFAULT_MAX_ZOOM, + }); + }); }); diff --git a/src/components/Map/Drawer/SearchDrawerWrapper/BioEntitiesResultsList/BioEntitiesPinsList/BioEntitiesPinsListItem/BioEntitiesPinsListItem.component.tsx b/src/components/Map/Drawer/SearchDrawerWrapper/BioEntitiesResultsList/BioEntitiesPinsList/BioEntitiesPinsListItem/BioEntitiesPinsListItem.component.tsx index c4322112939b40d33f621d1987ac07ce25420d34..e0e46854747a9fa20aaaca0f769325cd534ddc39 100644 --- a/src/components/Map/Drawer/SearchDrawerWrapper/BioEntitiesResultsList/BioEntitiesPinsList/BioEntitiesPinsListItem/BioEntitiesPinsListItem.component.tsx +++ b/src/components/Map/Drawer/SearchDrawerWrapper/BioEntitiesResultsList/BioEntitiesPinsList/BioEntitiesPinsListItem/BioEntitiesPinsListItem.component.tsx @@ -1,6 +1,8 @@ -import { twMerge } from 'tailwind-merge'; import { Icon } from '@/shared/Icon'; import { BioEntity } from '@/types/models'; +import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; +import { setMapPosition } from '@/redux/map/map.slice'; +import { DEFAULT_MAX_ZOOM } from '@/constants/map'; import { getPinColor } from '../../../ResultsList/PinsList/PinsListItem/PinsListItem.component.utils'; interface BioEntitiesPinsListItemProps { @@ -12,10 +14,29 @@ export const BioEntitiesPinsListItem = ({ name, pin, }: BioEntitiesPinsListItemProps): JSX.Element => { + const dispatch = useAppDispatch(); + + const handleCenterMapToPin = (): void => { + dispatch( + setMapPosition({ + x: pin.x, + y: pin.y, + z: DEFAULT_MAX_ZOOM, + }), + ); + }; + return ( <div className="mb-4 flex w-full flex-col gap-3 rounded-lg border-[1px] border-solid border-greyscale-500 p-4"> <div className="flex w-full flex-row items-center gap-2"> - <Icon name="pin" className={twMerge('mr-2 shrink-0', getPinColor('bioEntity'))} /> + <button + type="button" + onClick={handleCenterMapToPin} + className="mr-2 shrink-0" + data-testid="center-to-pin-button" + > + <Icon name="pin" className={getPinColor('bioEntity')} /> + </button> <p> {pin.stringType}: <span className="w-full font-bold">{name}</span> </p> diff --git a/src/components/Map/Drawer/SearchDrawerWrapper/ResultsList/PinsList/PinsListItem/PinsListItem.component.test.tsx b/src/components/Map/Drawer/SearchDrawerWrapper/ResultsList/PinsList/PinsListItem/PinsListItem.component.test.tsx index fdc457a0a2a6b776dcf6fb1381e79468c8624c4f..cff97d8b721a5e7023a8e26504afcc747174e606 100644 --- a/src/components/Map/Drawer/SearchDrawerWrapper/ResultsList/PinsList/PinsListItem/PinsListItem.component.test.tsx +++ b/src/components/Map/Drawer/SearchDrawerWrapper/ResultsList/PinsList/PinsListItem/PinsListItem.component.test.tsx @@ -10,8 +10,27 @@ import { } from '@/utils/testing/getReduxWrapperWithStore'; import { render, screen } from '@testing-library/react'; // import { MODELS_MOCK_SHORT } from '@/models/mocks/modelsMock'; +import { act } from 'react-dom/test-utils'; import { PinTypeWithNone } from '../PinsList.types'; import { PinsListItem } from './PinsListItem.component'; +import { useVisiblePinsPolygonCoordinates } from './hooks/useVisiblePinsPolygonCoordinates'; + +const setBounds = jest.fn(); + +setBounds.mockImplementation(() => {}); +jest.mock('../../../../../../../utils/map/useSetBounds', () => ({ + _esModule: true, + useSetBounds: (): jest.Mock => setBounds, +})); + +const useVisiblePinsPolygonCoordinatesMock = useVisiblePinsPolygonCoordinates as jest.Mock; + +jest.mock('./hooks/useVisiblePinsPolygonCoordinates', () => ({ + _esModule: true, + useVisiblePinsPolygonCoordinates: jest.fn(), +})); + +setBounds.mockImplementation(() => {}); const DRUGS_PIN = { name: drugsFixture[0].targets[0].name, @@ -111,4 +130,37 @@ describe('PinsListItem - component ', () => { expect(screen.queryByText('Available in submaps:')).toBeNull(); }); + it('should not call setBounds if coordinates do not exist', () => { + useVisiblePinsPolygonCoordinatesMock.mockImplementation(() => undefined); + + renderComponent(DRUGS_PIN.name, DRUGS_PIN.pin, 'drugs'); + + const buttonCenterMapToPin = screen.getByTestId('center-to-pin'); + + expect(buttonCenterMapToPin).toBeInTheDocument(); + + act(() => { + buttonCenterMapToPin.click(); + }); + + expect(setBounds).not.toHaveBeenCalled(); + }); + it('should call setBounds if coordinates exist', () => { + useVisiblePinsPolygonCoordinatesMock.mockImplementation(() => [ + [292, 333], + [341, 842], + ]); + + renderComponent(DRUGS_PIN.name, DRUGS_PIN.pin, 'drugs'); + + const buttonCenterMapToPin = screen.getByTestId('center-to-pin'); + + expect(buttonCenterMapToPin).toBeInTheDocument(); + + act(() => { + buttonCenterMapToPin.click(); + }); + + expect(setBounds).toHaveBeenCalled(); + }); }); diff --git a/src/components/Map/Drawer/SearchDrawerWrapper/ResultsList/PinsList/PinsListItem/PinsListItem.component.tsx b/src/components/Map/Drawer/SearchDrawerWrapper/ResultsList/PinsList/PinsListItem/PinsListItem.component.tsx index d87156efe2be89368063d16df26f41b49ced4d48..1f5ec270c52f3f3b55a3ab99055801c0e3deed67 100644 --- a/src/components/Map/Drawer/SearchDrawerWrapper/ResultsList/PinsList/PinsListItem/PinsListItem.component.tsx +++ b/src/components/Map/Drawer/SearchDrawerWrapper/ResultsList/PinsList/PinsListItem/PinsListItem.component.tsx @@ -1,14 +1,15 @@ import { Icon } from '@/shared/Icon'; import { PinDetailsItem } from '@/types/models'; -import { twMerge } from 'tailwind-merge'; import { useAppSelector } from '@/redux/hooks/useAppSelector'; import { modelsDataSelector } from '@/redux/models/models.selectors'; import { SIZE_OF_EMPTY_ARRAY } from '@/constants/common'; import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; import { openMapAndSetActive, setActiveMap } from '@/redux/map/map.slice'; import { mapOpenedMapsSelector } from '@/redux/map/map.selectors'; +import { useSetBounds } from '@/utils/map/useSetBounds'; import { getListOfAvailableSubmaps, getPinColor } from './PinsListItem.component.utils'; import { AvailableSubmaps, PinTypeWithNone } from '../PinsList.types'; +import { useVisiblePinsPolygonCoordinates } from './hooks/useVisiblePinsPolygonCoordinates'; interface PinsListItemProps { name: string; @@ -21,6 +22,8 @@ export const PinsListItem = ({ name, type, pin }: PinsListItemProps): JSX.Elemen const openedMaps = useAppSelector(mapOpenedMapsSelector); const models = useAppSelector(modelsDataSelector); const availableSubmaps = getListOfAvailableSubmaps(pin, models); + const coordinates = useVisiblePinsPolygonCoordinates(pin.targetElements); + const setBounds = useSetBounds(); const isMapAlreadyOpened = (modelId: number): boolean => openedMaps.some(map => map.modelId === modelId); @@ -33,10 +36,22 @@ export const PinsListItem = ({ name, type, pin }: PinsListItemProps): JSX.Elemen } }; + const handleCenterMapToPin = (): void => { + if (!coordinates) return; + setBounds(coordinates); + }; + return ( <div className="mb-4 flex w-full flex-col gap-3 rounded-lg border-[1px] border-solid border-greyscale-500 p-4"> <div className="flex w-full flex-row items-center gap-2"> - <Icon name="pin" className={twMerge('mr-2 shrink-0', getPinColor(type))} /> + <button + type="button" + className="mr-2 shrink-0" + onClick={handleCenterMapToPin} + data-testid="center-to-pin" + > + <Icon name="pin" className={getPinColor(type)} /> + </button> <p> Full name: <span className="w-full font-bold">{name}</span> </p> diff --git a/src/components/Map/Drawer/SearchDrawerWrapper/ResultsList/PinsList/PinsListItem/hooks/useVisiblePinsPolygonCoordinates.test.ts b/src/components/Map/Drawer/SearchDrawerWrapper/ResultsList/PinsList/PinsListItem/hooks/useVisiblePinsPolygonCoordinates.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..f45826095806be323410a5ce0176199c35d1786d --- /dev/null +++ b/src/components/Map/Drawer/SearchDrawerWrapper/ResultsList/PinsList/PinsListItem/hooks/useVisiblePinsPolygonCoordinates.test.ts @@ -0,0 +1,123 @@ +/* eslint-disable no-magic-numbers */ +import { getReduxWrapperWithStore } from '@/utils/testing/getReduxWrapperWithStore'; +import { renderHook } from '@testing-library/react'; +import { MAP_INITIAL_STATE } from '@/redux/map/map.constants'; +import { bioEntityContentFixture } from '@/models/fixtures/bioEntityContentsFixture'; +import { useVisiblePinsPolygonCoordinates } from './useVisiblePinsPolygonCoordinates'; + +describe('useVisiblePinsPolygonCoordinates - hook', () => { + it('should return undefined if receives empty array', () => { + const { Wrapper } = getReduxWrapperWithStore({ + map: { + ...MAP_INITIAL_STATE, + data: { + ...MAP_INITIAL_STATE.data, + modelId: 5052, + size: { + width: 256, + height: 256, + tileSize: 256, + minZoom: 1, + maxZoom: 1, + }, + }, + }, + }); + + const { result } = renderHook(() => useVisiblePinsPolygonCoordinates([]), { + wrapper: Wrapper, + }); + + expect(result.current).toBe(undefined); + }); + it('should return undefined if received array does not contain bioEntities with current map id', () => { + const { Wrapper } = getReduxWrapperWithStore({ + map: { + ...MAP_INITIAL_STATE, + data: { + ...MAP_INITIAL_STATE.data, + modelId: 5052, + size: { + width: 256, + height: 256, + tileSize: 256, + minZoom: 1, + maxZoom: 1, + }, + }, + }, + }); + + const { result } = renderHook( + () => + useVisiblePinsPolygonCoordinates([ + { + ...bioEntityContentFixture.bioEntity, + model: 52, + }, + { + ...bioEntityContentFixture.bioEntity, + model: 51, + }, + ]), + { + wrapper: Wrapper, + }, + ); + + expect(result.current).toBe(undefined); + }); + it('should return coordinates if received array contain bioEntities with current map id', () => { + const { Wrapper } = getReduxWrapperWithStore({ + map: { + ...MAP_INITIAL_STATE, + data: { + ...MAP_INITIAL_STATE.data, + modelId: 5052, + size: { + width: 256, + height: 256, + tileSize: 256, + minZoom: 1, + maxZoom: 1, + }, + }, + }, + }); + + const { result } = renderHook( + () => + useVisiblePinsPolygonCoordinates([ + { + ...bioEntityContentFixture.bioEntity, + model: 5051, + x: 97, + y: 53, + z: 1, + }, + { + ...bioEntityContentFixture.bioEntity, + model: 5052, + x: 12, + y: 25, + z: 1, + }, + { + ...bioEntityContentFixture.bioEntity, + model: 5052, + x: 16, + y: 16, + z: 1, + }, + ]), + { + wrapper: Wrapper, + }, + ); + + expect(result.current).toEqual([ + [-18158992, 16123932], + [-17532820, 17532820], + ]); + }); +}); diff --git a/src/components/Map/Drawer/SearchDrawerWrapper/ResultsList/PinsList/PinsListItem/hooks/useVisiblePinsPolygonCoordinates.ts b/src/components/Map/Drawer/SearchDrawerWrapper/ResultsList/PinsList/PinsListItem/hooks/useVisiblePinsPolygonCoordinates.ts new file mode 100644 index 0000000000000000000000000000000000000000..dfd253522558c46f65ca8da64cbfa9da9f254a36 --- /dev/null +++ b/src/components/Map/Drawer/SearchDrawerWrapper/ResultsList/PinsList/PinsListItem/hooks/useVisiblePinsPolygonCoordinates.ts @@ -0,0 +1,54 @@ +import { useAppSelector } from '@/redux/hooks/useAppSelector'; +import { mapModelIdSelector } from '@/redux/map/map.selectors'; +import { Point } from '@/types/map'; +import { PinDetailsItem } from '@/types/models'; +import { usePointToProjection } from '@/utils/map/usePointToProjection'; +import { isPointValid } from '@/utils/point/isPointValid'; +import { Coordinate } from 'ol/coordinate'; +import { useMemo } from 'react'; + +const VALID_POLYGON_COORDINATES_LENGTH = 2; + +export const useVisiblePinsPolygonCoordinates = ( + pinTargetElements: PinDetailsItem['targetElements'], +): Coordinate[] | undefined => { + const pointToProjection = usePointToProjection(); + const currentModelId = useAppSelector(mapModelIdSelector); + const currentMapPinElements = useMemo( + () => pinTargetElements.filter(el => el.model === currentModelId), + [currentModelId, pinTargetElements], + ); + + const polygonPoints = useMemo((): Point[] => { + const allX = currentMapPinElements.map(({ x }) => x); + const allY = currentMapPinElements.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); + }, [currentMapPinElements]); + + const polygonCoordinates = useMemo( + () => polygonPoints.map(point => pointToProjection(point)), + [polygonPoints, pointToProjection], + ); + + if (polygonCoordinates.length !== VALID_POLYGON_COORDINATES_LENGTH) { + return undefined; + } + + return polygonCoordinates; +};