diff --git a/src/components/FunctionalArea/Modal/PublicationsModal/PublicationsModal.test.tsx b/src/components/FunctionalArea/Modal/PublicationsModal/PublicationsModal.test.tsx index 4df51d1e1fd12dbfe315b8e639981c44d7ea554a..15f359d2ec7d78fe77b1cb949295f00e48ddac58 100644 --- a/src/components/FunctionalArea/Modal/PublicationsModal/PublicationsModal.test.tsx +++ b/src/components/FunctionalArea/Modal/PublicationsModal/PublicationsModal.test.tsx @@ -1,3 +1,13 @@ +import { mockNetworkResponse } from '@/utils/mockNetworkResponse'; + +mockNetworkResponse(); + +/** + * Since data to table is passed down from Publications Modal component + * this test will be covered in the integration test for the Publications Table component + * and interactions beetween filtering, sorting, searching and pagination + */ + describe('Publications Modal - component', () => { it('should render number of publications', () => {}); it('should render download csv button', () => {}); diff --git a/src/components/FunctionalArea/Modal/PublicationsModal/PublicationsModalLayout/PublicationsModalLayout.test.tsx b/src/components/FunctionalArea/Modal/PublicationsModal/PublicationsModalLayout/PublicationsModalLayout.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..87c74c952d1b55098f4a69cf602717d00301573f --- /dev/null +++ b/src/components/FunctionalArea/Modal/PublicationsModal/PublicationsModalLayout/PublicationsModalLayout.test.tsx @@ -0,0 +1,45 @@ +import { + InitialStoreState, + getReduxWrapperWithStore, +} from '@/utils/testing/getReduxWrapperWithStore'; +import { StoreType } from '@/redux/store'; +import { render, screen } from '@testing-library/react'; +import { PUBLICATIONS_DEFAULT_SEARCH_FIRST_10_STATE_MOCK } from '@/redux/publications/publications.mock'; +import { PublicationsModalLayout } from './PublicationsModalLayout.component'; + +const renderComponent = (initialStoreState: InitialStoreState = {}): { store: StoreType } => { + const { Wrapper, store } = getReduxWrapperWithStore(initialStoreState); + + return ( + render( + <Wrapper> + <PublicationsModalLayout> + <div>children</div> + </PublicationsModalLayout> + </Wrapper>, + ), + { + store, + } + ); +}; + +describe('Publications Modal Layout - component', () => { + it('should render number of publications', () => { + renderComponent({ publications: PUBLICATIONS_DEFAULT_SEARCH_FIRST_10_STATE_MOCK }); + + expect(screen.getByText('Publications (1586 results)')).toBeInTheDocument(); + }); + it.skip('should render download csv button', () => {}); + it.skip('should trigger download on csv button click', () => {}); + it('should render search input', () => { + renderComponent({ publications: PUBLICATIONS_DEFAULT_SEARCH_FIRST_10_STATE_MOCK }); + + expect(screen.getByTestId('search-input')).toBeInTheDocument(); + }); + it('should render children', () => { + renderComponent({ publications: PUBLICATIONS_DEFAULT_SEARCH_FIRST_10_STATE_MOCK }); + + expect(screen.getByText('children')).toBeInTheDocument(); + }); +}); diff --git a/src/components/FunctionalArea/Modal/PublicationsModal/PublicationsSearch/PublicationsSearch.test.tsx b/src/components/FunctionalArea/Modal/PublicationsModal/PublicationsSearch/PublicationsSearch.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..00fc8e08ea0c632674984998cbac9b55801cf535 --- /dev/null +++ b/src/components/FunctionalArea/Modal/PublicationsModal/PublicationsSearch/PublicationsSearch.test.tsx @@ -0,0 +1,11 @@ +// remember to mock api call + +describe('PublicationsSearch', () => { + it('should render the input field', () => {}); + + it('should update the value when the input field changes', () => {}); + + it('should dispatch getPublications action when the input field changes', () => {}); + + it('should disable the input and button when isLoading is true', () => {}); +}); 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 6049a6e29f28c13c4d6f67e3995a86cacee3994e..c5e4e4029fa87e2bed4477c4666c8d41729959e6 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 @@ -1,19 +1,24 @@ /* eslint-disable no-magic-numbers */ +import { DEFAULT_MAX_ZOOM } from '@/constants/map'; import { bioEntitiesContentFixture } from '@/models/fixtures/bioEntityContentsFixture'; -import { StoreType } from '@/redux/store'; -import { BioEntity } from '@/types/models'; +import { MAP_INITIAL_STATE } from '@/redux/map/map.constants'; +import { AppDispatch, RootState, StoreType } from '@/redux/store'; +import { getReduxStoreWithActionsListener } from '@/utils/testing/getReduxStoreActionsListener'; import { InitialStoreState, getReduxWrapperWithStore, } from '@/utils/testing/getReduxWrapperWithStore'; import { render, screen } from '@testing-library/react'; +import { act } from 'react-dom/test-utils'; +import { MockStoreEnhanced } from 'redux-mock-store'; import { BioEntitiesPinsListItem } from './BioEntitiesPinsListItem.component'; +import { PinListBioEntity } from './BioEntitiesPinsListItem.types'; const BIO_ENTITY = bioEntitiesContentFixture[0].bioEntity; const renderComponent = ( name: string, - pin: BioEntity, + pin: PinListBioEntity, initialStoreState: InitialStoreState = {}, ): { store: StoreType } => { const { Wrapper, store } = getReduxWrapperWithStore(initialStoreState); @@ -30,6 +35,25 @@ const renderComponent = ( ); }; +const renderComponentWithActionListener = ( + name: string, + pin: PinListBioEntity, + initialStoreState: InitialStoreState = {}, +): { store: MockStoreEnhanced<Partial<RootState>, AppDispatch> } => { + const { Wrapper, store } = getReduxStoreWithActionsListener(initialStoreState); + + return ( + render( + <Wrapper> + <BioEntitiesPinsListItem name={name} pin={pin} /> + </Wrapper>, + ), + { + store, + } + ); +}; + describe('BioEntitiesPinsListItem - component ', () => { it('should display name of bio entity element', () => { renderComponent(BIO_ENTITY.name, BIO_ENTITY); @@ -84,4 +108,162 @@ 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, + }); + }); + + it('should not center map to pin coordinates after click on pin icon if pin has no coords', async () => { + const { store } = renderComponent( + BIO_ENTITY.name, + { + ...BIO_ENTITY, + x: undefined, + y: undefined, + }, + { + 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: 1, + y: 1, + z: 3, + }); + }); + + it('should dispatch get search data and open drawer on fullName click', async () => { + const { store } = renderComponentWithActionListener( + BIO_ENTITY.name, + { + ...BIO_ENTITY, + x: undefined, + y: undefined, + }, + { + 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.getByText(BIO_ENTITY.name); + expect(button).toBeInTheDocument(); + + act(() => { + button.click(); + }); + + const actions = store.getActions(); + + expect(actions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + payload: undefined, + type: 'project/getSearchData/pending', + }), + ]), + ); + + expect(actions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + payload: BIO_ENTITY.name, + type: 'drawer/openSearchDrawerWithSelectedTab', + }), + ]), + ); + }); }); 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 8dc3049031ee14f1962e99de8879daf1492950bc..ceff8eb02308d091e466e63055f2a09211bbd4a5 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,7 +1,19 @@ +/* eslint-disable jsx-a11y/no-static-element-interactions */ +/* eslint-disable jsx-a11y/click-events-have-key-events */ +import { + getDefaultSearchTab, + getSearchValuesArrayAndTrimToSeven, +} from '@/components/FunctionalArea/TopBar/SearchBar/SearchBar.utils'; +import { DEFAULT_MAX_ZOOM } from '@/constants/map'; +import { openSearchDrawerWithSelectedTab } from '@/redux/drawer/drawer.slice'; +import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; +import { setMapPosition } from '@/redux/map/map.slice'; +import { getSearchData } from '@/redux/search/search.thunks'; import { Icon } from '@/shared/Icon'; import { twMerge } from 'tailwind-merge'; import { getPinColor } from '../../../ResultsList/PinsList/PinsListItem/PinsListItem.component.utils'; import { PinListBioEntity } from './BioEntitiesPinsListItem.types'; +import { isPinWithCoordinates } from './BioEntitiesPinsListItem.utils'; interface BioEntitiesPinsListItemProps { name: string; @@ -12,13 +24,48 @@ export const BioEntitiesPinsListItem = ({ name, pin, }: BioEntitiesPinsListItemProps): JSX.Element => { + const dispatch = useAppDispatch(); + const pinHasCoords = isPinWithCoordinates(pin); + + const handleCenterMapToPin = (): void => { + if (!pinHasCoords) { + return; + } + + dispatch( + setMapPosition({ + x: pin.x, + y: pin.y, + z: DEFAULT_MAX_ZOOM, + }), + ); + }; + + const handleSearchMapForPin = (): void => { + const searchValues = getSearchValuesArrayAndTrimToSeven(name); + dispatch(getSearchData({ searchQueries: searchValues, isPerfectMatch: true })); + dispatch(openSearchDrawerWithSelectedTab(getDefaultSearchTab(searchValues))); + }; + 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={twMerge('mr-2 shrink-0', !pinHasCoords && 'cursor-default')} + data-testid="center-to-pin-button" + > + <Icon name="pin" className={getPinColor('bioEntity')} /> + </button> <p> {pin.stringType ? `${pin.stringType}: ` : ''} - <span className="w-full font-bold">{name}</span> + <span + className="w-full cursor-pointer font-bold underline" + onClick={handleSearchMapForPin} + > + {name} + </span> </p> </div> {pin.fullName && ( diff --git a/src/components/Map/Drawer/SearchDrawerWrapper/BioEntitiesResultsList/BioEntitiesPinsList/BioEntitiesPinsListItem/BioEntitiesPinsListItem.types.ts b/src/components/Map/Drawer/SearchDrawerWrapper/BioEntitiesResultsList/BioEntitiesPinsList/BioEntitiesPinsListItem/BioEntitiesPinsListItem.types.ts index 03cfcae8114c6447b5a64bab66521d72f10c7a71..e1e6cce5b1aa6d7af4cf29fde5c4626ebe230335 100644 --- a/src/components/Map/Drawer/SearchDrawerWrapper/BioEntitiesResultsList/BioEntitiesPinsList/BioEntitiesPinsListItem/BioEntitiesPinsListItem.types.ts +++ b/src/components/Map/Drawer/SearchDrawerWrapper/BioEntitiesResultsList/BioEntitiesPinsList/BioEntitiesPinsListItem/BioEntitiesPinsListItem.types.ts @@ -4,4 +4,11 @@ export type PinListBioEntity = Pick<BioEntity, 'synonyms' | 'references'> & { symbol?: BioEntity['symbol']; stringType?: BioEntity['stringType']; fullName?: BioEntity['fullName']; + x?: BioEntity['x']; + y?: BioEntity['y']; +}; + +export type PinListBioEntityWithCoords = PinListBioEntity & { + x: BioEntity['x']; + y: BioEntity['y']; }; diff --git a/src/components/Map/Drawer/SearchDrawerWrapper/BioEntitiesResultsList/BioEntitiesPinsList/BioEntitiesPinsListItem/BioEntitiesPinsListItem.utils.ts b/src/components/Map/Drawer/SearchDrawerWrapper/BioEntitiesResultsList/BioEntitiesPinsList/BioEntitiesPinsListItem/BioEntitiesPinsListItem.utils.ts new file mode 100644 index 0000000000000000000000000000000000000000..ac04a9083867762cc3aedeabc3804c3c3bccdb03 --- /dev/null +++ b/src/components/Map/Drawer/SearchDrawerWrapper/BioEntitiesResultsList/BioEntitiesPinsList/BioEntitiesPinsListItem/BioEntitiesPinsListItem.utils.ts @@ -0,0 +1,5 @@ +import { PinListBioEntity, PinListBioEntityWithCoords } from './BioEntitiesPinsListItem.types'; + +export const isPinWithCoordinates = (pin: PinListBioEntity): pin is PinListBioEntityWithCoords => { + return Boolean(pin?.x && pin?.y); +}; 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; +}; diff --git a/src/components/Map/MapViewer/utils/config/overlaysLayer/createFeatureFromExtent.ts b/src/components/Map/MapViewer/utils/config/overlaysLayer/createFeatureFromExtent.ts new file mode 100644 index 0000000000000000000000000000000000000000..121bfe2bb2b3c25f79ecb2f0dd7809a4378ace40 --- /dev/null +++ b/src/components/Map/MapViewer/utils/config/overlaysLayer/createFeatureFromExtent.ts @@ -0,0 +1,5 @@ +import Polygon, { fromExtent } from 'ol/geom/Polygon'; +import Feature from 'ol/Feature'; + +export const createFeatureFromExtent = ([xMin, yMin, xMax, yMax]: number[]): Feature<Polygon> => + new Feature({ geometry: fromExtent([xMin, yMin, xMax, yMax]) }); diff --git a/src/components/Map/MapViewer/utils/config/overlaysLayer/createOverlayGeometryFeature.ts b/src/components/Map/MapViewer/utils/config/overlaysLayer/createOverlayGeometryFeature.ts index 9f27f8567185a59c2a6bffb5a94a56ca857d8ec4..b294d492153d7f74aea80a7836f30c0557032ba5 100644 --- a/src/components/Map/MapViewer/utils/config/overlaysLayer/createOverlayGeometryFeature.ts +++ b/src/components/Map/MapViewer/utils/config/overlaysLayer/createOverlayGeometryFeature.ts @@ -1,10 +1,7 @@ import { Fill, Stroke, Style } from 'ol/style'; -import { fromExtent } from 'ol/geom/Polygon'; import Feature from 'ol/Feature'; import type Polygon from 'ol/geom/Polygon'; - -const createFeatureFromExtent = ([xMin, yMin, xMax, yMax]: number[]): Feature<Polygon> => - new Feature({ geometry: fromExtent([xMin, yMin, xMax, yMax]) }); +import { createFeatureFromExtent } from './createFeatureFromExtent'; const getBioEntityOverlayFeatureStyle = (color: string): Style => new Style({ fill: new Fill({ color }), stroke: new Stroke({ color: 'black', width: 1 }) }); diff --git a/src/components/Map/MapViewer/utils/config/overlaysLayer/createOverlaySubmapLinkRectangleFeature.test.ts b/src/components/Map/MapViewer/utils/config/overlaysLayer/createOverlaySubmapLinkRectangleFeature.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..06d6074af86160a0318c919e1cda4a95105be466 --- /dev/null +++ b/src/components/Map/MapViewer/utils/config/overlaysLayer/createOverlaySubmapLinkRectangleFeature.test.ts @@ -0,0 +1,67 @@ +/* eslint-disable no-magic-numbers */ +import Feature from 'ol/Feature'; +import { createOverlaySubmapLinkRectangleFeature } from './createOverlaySubmapLinkRectangleFeature'; + +const COLOR = '#FFFFFFcc'; + +const CASES = [ + [ + [0, 0, 0, 0], + [0, 0, 0, 0], + ], + [ + [0, 0, 100, 100], + [0, 0, 100, 100], + ], + [ + [100, 0, 230, 100], + [100, 0, 230, 100], + ], + [ + [-50, 0, 0, 50], + [-50, 0, 0, 50], + ], +]; + +describe('createOverlaySubmapLinkRectangleFeature - util', () => { + it.each(CASES)('should return Feature instance', points => { + const feature = createOverlaySubmapLinkRectangleFeature(points, COLOR); + + expect(feature).toBeInstanceOf(Feature); + }); + + it.each(CASES)('should return Feature instance with valid style and stroke', points => { + const feature = createOverlaySubmapLinkRectangleFeature(points, COLOR); + const style = feature.getStyle(); + + expect(style).toMatchObject({ + fill_: { color_: COLOR }, + stroke_: { + color_: COLOR, + width_: 1, + }, + }); + }); + it('should return object with transparent fill and black stroke color when color is null', () => { + const feature = createOverlaySubmapLinkRectangleFeature([0, 0, 0, 0], null); + const style = feature.getStyle(); + + expect(style).toMatchObject({ + fill_: { color_: 'transparent' }, + stroke_: { + color_: 'black', + width_: 1, + }, + }); + }); + it.each(CASES)('should return Feature instance with valid geometry', (points, extent) => { + const feature = createOverlaySubmapLinkRectangleFeature(points, COLOR); + const geometry = feature.getGeometry(); + + expect(geometry?.getExtent()).toEqual(extent); + }); + + it('should throw error if extent is not valid', () => { + expect(() => createOverlaySubmapLinkRectangleFeature([100, 100, 0, 0], COLOR)).toThrow(); + }); +}); diff --git a/src/components/Map/MapViewer/utils/config/overlaysLayer/createOverlaySubmapLinkRectangleFeature.ts b/src/components/Map/MapViewer/utils/config/overlaysLayer/createOverlaySubmapLinkRectangleFeature.ts new file mode 100644 index 0000000000000000000000000000000000000000..cef983542a7777c57a8c551ed6e9d96aa1dcb35c --- /dev/null +++ b/src/components/Map/MapViewer/utils/config/overlaysLayer/createOverlaySubmapLinkRectangleFeature.ts @@ -0,0 +1,14 @@ +/* eslint-disable no-magic-numbers */ +import Feature from 'ol/Feature'; +import type Polygon from 'ol/geom/Polygon'; +import { createFeatureFromExtent } from './createFeatureFromExtent'; +import { getOverlaySubmapLinkRectangleFeatureStyle } from './getOverlaySubmapLinkRectangleFeatureStyle'; + +export const createOverlaySubmapLinkRectangleFeature = ( + [xMin, yMin, xMax, yMax]: number[], + color: string | null, +): Feature<Polygon> => { + const feature = createFeatureFromExtent([xMin, yMin, xMax, yMax]); + feature.setStyle(getOverlaySubmapLinkRectangleFeatureStyle(color)); + return feature; +}; diff --git a/src/components/Map/MapViewer/utils/config/overlaysLayer/getOverlaySubmapLinkRectangleFeatureStyle.test.ts b/src/components/Map/MapViewer/utils/config/overlaysLayer/getOverlaySubmapLinkRectangleFeatureStyle.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..90bb4af947e8dc4e7978b62ae4135bec61841c3f --- /dev/null +++ b/src/components/Map/MapViewer/utils/config/overlaysLayer/getOverlaySubmapLinkRectangleFeatureStyle.test.ts @@ -0,0 +1,37 @@ +/* eslint-disable no-magic-numbers */ +import { Fill, Stroke, Style } from 'ol/style'; +import { getOverlaySubmapLinkRectangleFeatureStyle } from './getOverlaySubmapLinkRectangleFeatureStyle'; + +const COLORS = ['#000000', '#FFFFFF', '#F5F5F5', '#C0C0C0', '#C0C0C0aa', '#C0C0C0bb']; + +describe('getOverlaySubmapLinkRectangleFeatureStyle - util', () => { + it.each(COLORS)('should return Style object', color => { + const result = getOverlaySubmapLinkRectangleFeatureStyle(color); + expect(result).toBeInstanceOf(Style); + }); + + it.each(COLORS)('should set valid color values for fill', color => { + const result = getOverlaySubmapLinkRectangleFeatureStyle(color); + const fill = result.getFill(); + expect(fill).toBeInstanceOf(Fill); + expect(fill?.getColor()).toBe(color); + }); + + it.each(COLORS)('should set valid color values for fill', color => { + const result = getOverlaySubmapLinkRectangleFeatureStyle(color); + const stroke = result.getStroke(); + expect(stroke).toBeInstanceOf(Stroke); + expect(stroke?.getColor()).toBe(color); + expect(stroke?.getWidth()).toBe(1); + }); + it('should set transparent fill and black stroke if color is null', () => { + const result = getOverlaySubmapLinkRectangleFeatureStyle(null); + const stroke = result.getStroke(); + expect(stroke).toBeInstanceOf(Stroke); + expect(stroke?.getColor()).toBe('black'); + expect(stroke?.getWidth()).toBe(1); + const fill = result.getFill(); + expect(fill).toBeInstanceOf(Fill); + expect(fill?.getColor()).toBe('transparent'); + }); +}); diff --git a/src/components/Map/MapViewer/utils/config/overlaysLayer/getOverlaySubmapLinkRectangleFeatureStyle.ts b/src/components/Map/MapViewer/utils/config/overlaysLayer/getOverlaySubmapLinkRectangleFeatureStyle.ts new file mode 100644 index 0000000000000000000000000000000000000000..b597c42d4849e21c85b1e0a84e0ede947961df1b --- /dev/null +++ b/src/components/Map/MapViewer/utils/config/overlaysLayer/getOverlaySubmapLinkRectangleFeatureStyle.ts @@ -0,0 +1,9 @@ +/* eslint-disable no-magic-numbers */ +import { Fill, Stroke, Style } from 'ol/style'; + +export const getOverlaySubmapLinkRectangleFeatureStyle = (color: string | null): Style => + new Style({ + fill: new Fill({ color: color || 'transparent' }), + stroke: new Stroke({ color: color || 'black', width: 1 }), + zIndex: color ? 0 : 1, + }); diff --git a/src/components/Map/MapViewer/utils/config/overlaysLayer/getSubmapLinkRectangle.ts b/src/components/Map/MapViewer/utils/config/overlaysLayer/getSubmapLinkRectangle.ts new file mode 100644 index 0000000000000000000000000000000000000000..eab42ae4016d8fbfbf6883d79b963624bf764df3 --- /dev/null +++ b/src/components/Map/MapViewer/utils/config/overlaysLayer/getSubmapLinkRectangle.ts @@ -0,0 +1,26 @@ +/* eslint-disable no-magic-numbers, no-param-reassign */ +import type { SubmapLinkRectangle } from './useBioEntitiesWithSubmapLinks'; + +export const getSubmapLinkRectangle = ( + submapsLinksRectangles: SubmapLinkRectangle[], + submapLinkRectangle: SubmapLinkRectangle, + index: number, + submapLinksRectanglesGroup: SubmapLinkRectangle[], + rectangleHeight: number, +): void => { + if (index === 0) { + submapsLinksRectangles.push({ + ...submapLinkRectangle, + amount: 0, + value: Infinity, + }); + } + + if (index !== 0) { + submapLinkRectangle.y2 = submapLinksRectanglesGroup[index - 1].y1; + } + submapLinkRectangle.y1 = submapLinkRectangle.y2 + rectangleHeight; + submapLinkRectangle.height = rectangleHeight; + + submapsLinksRectangles.push(submapLinkRectangle); +}; diff --git a/src/components/Map/MapViewer/utils/config/overlaysLayer/groupSubmapLinksRectanglesById.ts b/src/components/Map/MapViewer/utils/config/overlaysLayer/groupSubmapLinksRectanglesById.ts new file mode 100644 index 0000000000000000000000000000000000000000..e1691c06812c6db4e3f46300e9d58c06e623cad9 --- /dev/null +++ b/src/components/Map/MapViewer/utils/config/overlaysLayer/groupSubmapLinksRectanglesById.ts @@ -0,0 +1,45 @@ +import type { OverlayBioEntityRender } from '@/types/OLrendering'; +import type { GroupedSubmapsLinksRectangles } from './useBioEntitiesWithSubmapLinks'; + +export const groupSubmapLinksRectanglesById = ( + data: OverlayBioEntityRender[], +): GroupedSubmapsLinksRectangles => { + const submapsLinksRectangles = [...data]; + const groupedSubmapsLinksRectanglesById: GroupedSubmapsLinksRectangles = {}; + + submapsLinksRectangles.forEach(submapLinkRectangle => { + const { id, overlayId } = submapLinkRectangle; + const groupId = `${id}-${overlayId}`; + + if (!groupedSubmapsLinksRectanglesById[groupId]) { + groupedSubmapsLinksRectanglesById[groupId] = []; + } + + const matchedSubmapLinkRectangle = groupedSubmapsLinksRectanglesById[groupId].find(element => { + const hasAllRequiredValueProperties = element.value && submapLinkRectangle.value; + const isValueEqual = + hasAllRequiredValueProperties && element.value === submapLinkRectangle.value; + + const hasAllRequiredColorProperties = element.color && submapLinkRectangle.color; + const isColorEqual = + hasAllRequiredColorProperties && + element.color?.alpha === submapLinkRectangle?.color?.alpha && + element.color?.rgb === submapLinkRectangle?.color?.rgb; + + if (isValueEqual || isColorEqual) return true; + + return false; + }); + + if (!matchedSubmapLinkRectangle) { + groupedSubmapsLinksRectanglesById[groupId].push({ + ...submapLinkRectangle, + amount: 1, + }); + } else { + matchedSubmapLinkRectangle.amount += 1; + } + }); + + return groupedSubmapsLinksRectanglesById; +}; diff --git a/src/components/Map/MapViewer/utils/config/overlaysLayer/useBioEntitiesWithSubmapLinks.test.ts b/src/components/Map/MapViewer/utils/config/overlaysLayer/useBioEntitiesWithSubmapLinks.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..788b89f248c1afb9a64d4d39b420c18b8a39c40a --- /dev/null +++ b/src/components/Map/MapViewer/utils/config/overlaysLayer/useBioEntitiesWithSubmapLinks.test.ts @@ -0,0 +1,331 @@ +/* eslint-disable no-magic-numbers */ +import { getReduxStoreWithActionsListener } from '@/utils/testing/getReduxStoreActionsListener'; +import { renderHook } from '@testing-library/react'; +import { CONFIGURATION_INITIAL_STORE_MOCKS } from '@/redux/configuration/configuration.mock'; +import { PUBLIC_OVERLAYS_MOCK } from '@/redux/overlays/overlays.mock'; +import { mapStateWithCurrentlySelectedMainMapFixture } from '@/redux/map/map.fixtures'; +import { + MOCKED_OVERLAY_SUBMAPS_LINKS_WITH_DIFFERENT_COLORS, + MOCKED_OVERLAY_SUBMAPS_LINKS_WITH_SAME_COLORS, + OVERLAY_BIO_ENTITY_INITIAL_STATE_MOCK, +} from '@/redux/overlayBioEntity/overlayBioEntity.mock'; +import { MODELS_DATA_MOCK_WITH_MAIN_MAP } from '@/redux/models/models.mock'; +import { useBioEntitiesWithSubmapsLinks } from './useBioEntitiesWithSubmapLinks'; + +const RESULT_SUBMAP_LINKS_DIFFERENT_VALUES = [ + { + type: 'submap-link', + id: 97, + modelId: 52, + amount: 0, + width: 30, + x1: 18412, + x2: 18492, + y1: 2100.5, + y2: 2000.5, + overlayId: 12, + height: 100, + value: Infinity, + color: null, + }, + { + type: 'submap-link', + amount: 1, + id: 97, + modelId: 52, + width: 30, + x1: 18412, + x2: 18492, + y1: 2025.5, + y2: 2000.5, + overlayId: 12, + height: 25, + value: 0.8, + color: null, + }, + { + type: 'submap-link', + amount: 1, + id: 97, + modelId: 52, + width: 30, + x1: 18412, + x2: 18492, + y1: 2050.5, + y2: 2025.5, + overlayId: 12, + height: 25, + value: 0.5, + color: null, + }, + { + type: 'submap-link', + amount: 1, + id: 97, + modelId: 52, + width: 30, + x1: 18412, + x2: 18492, + y1: 2075.5, + y2: 2050.5, + overlayId: 12, + height: 25, + value: 0.4, + color: null, + }, + { + type: 'submap-link', + amount: 1, + id: 97, + modelId: 52, + width: 30, + x1: 18412, + x2: 18492, + y1: 2100.5, + y2: 2075.5, + overlayId: 12, + height: 25, + value: null, + color: { + alpha: 255, + rgb: -2348283, + }, + }, + { + type: 'rectangle', + id: 1, + modelId: 52, + width: 30, + x1: 18412, + x2: 18492, + y1: 3128.653195488725, + y2: 3088.653195488725, + overlayId: 12, + height: 10, + value: 0, + color: null, + }, +]; + +export const RESULT_SUBMAP_LINKS_SAME_COLORS = [ + { + type: 'submap-link', + amount: 0, + id: 97, + modelId: 52, + width: 30, + x1: 18412, + x2: 18492, + y1: 2100.5, + y2: 2000.5, + overlayId: 12, + height: 100, + value: Infinity, + color: null, + }, + { + type: 'submap-link', + amount: 2, + id: 97, + modelId: 52, + width: 30, + x1: 18412, + x2: 18492, + y1: 2050.5, + y2: 2000.5, + overlayId: 12, + height: 50, + value: 23, + color: null, + }, + { + type: 'submap-link', + amount: 2, + id: 97, + modelId: 52, + width: 30, + x1: 18412, + x2: 18492, + y1: 2100.5, + y2: 2050.5, + overlayId: 12, + height: 50, + value: null, + color: { + alpha: 255, + rgb: -2348283, + }, + }, + { + type: 'rectangle', + id: 1, + modelId: 52, + width: 30, + x1: 18412, + x2: 18492, + y1: 3128.653195488725, + y2: 3088.653195488725, + overlayId: 12, + height: 10, + value: 0, + color: null, + }, +]; + +describe('useBioEntitiesWithSubmapsLinks', () => { + it('should return bioEntities without submaps links if no submaps links are present', () => { + const { Wrapper } = getReduxStoreWithActionsListener({ + overlayBioEntity: { + ...OVERLAY_BIO_ENTITY_INITIAL_STATE_MOCK, + overlaysId: PUBLIC_OVERLAYS_MOCK.map(o => o.idObject), + }, + configuration: CONFIGURATION_INITIAL_STORE_MOCKS, + map: mapStateWithCurrentlySelectedMainMapFixture, + }); + + const { + result: { current }, + } = renderHook(() => useBioEntitiesWithSubmapsLinks(), { + wrapper: Wrapper, + }); + + expect(current).toEqual([]); + }); + + describe('submap links with the same ID and overlayID but different values or colors', () => { + it('should create submap link with Infinity value, for displaying black border of submap link', () => { + const { Wrapper } = getReduxStoreWithActionsListener({ + overlayBioEntity: { + overlaysId: [12], + data: { + 12: { + 52: MOCKED_OVERLAY_SUBMAPS_LINKS_WITH_DIFFERENT_COLORS, + }, + }, + }, + configuration: CONFIGURATION_INITIAL_STORE_MOCKS, + map: mapStateWithCurrentlySelectedMainMapFixture, + + models: { + ...MODELS_DATA_MOCK_WITH_MAIN_MAP, + }, + }); + + const { + result: { current }, + } = renderHook(() => useBioEntitiesWithSubmapsLinks(), { + wrapper: Wrapper, + }); + + expect(current[0]).toEqual({ + type: 'submap-link', + amount: 0, + id: 97, + modelId: 52, + width: 30, + x1: 18412, + x2: 18492, + y1: 2100.5, + y2: 2000.5, + overlayId: 12, + height: 100, + value: Infinity, + color: null, + }); + }); + it('should modify height, coordinates and return in sorted order to create submap link from several submap link rectangles', () => { + const { Wrapper } = getReduxStoreWithActionsListener({ + overlayBioEntity: { + overlaysId: [12], + data: { + 12: { + 52: MOCKED_OVERLAY_SUBMAPS_LINKS_WITH_DIFFERENT_COLORS, + }, + }, + }, + configuration: CONFIGURATION_INITIAL_STORE_MOCKS, + map: mapStateWithCurrentlySelectedMainMapFixture, + + models: { + ...MODELS_DATA_MOCK_WITH_MAIN_MAP, + }, + }); + + const { + result: { current }, + } = renderHook(() => useBioEntitiesWithSubmapsLinks(), { + wrapper: Wrapper, + }); + + expect(current).toStrictEqual(RESULT_SUBMAP_LINKS_DIFFERENT_VALUES); + }); + }); + describe('submap links with the same ID and overlayID and the same values or colors', () => { + it('should create submap link with Infinity value, for displaying black border of submap link', () => { + const { Wrapper } = getReduxStoreWithActionsListener({ + overlayBioEntity: { + overlaysId: [12], + data: { + 12: { + 52: MOCKED_OVERLAY_SUBMAPS_LINKS_WITH_SAME_COLORS, + }, + }, + }, + configuration: CONFIGURATION_INITIAL_STORE_MOCKS, + map: mapStateWithCurrentlySelectedMainMapFixture, + + models: { + ...MODELS_DATA_MOCK_WITH_MAIN_MAP, + }, + }); + + const { + result: { current }, + } = renderHook(() => useBioEntitiesWithSubmapsLinks(), { + wrapper: Wrapper, + }); + + expect(current[0]).toEqual({ + type: 'submap-link', + amount: 0, + id: 97, + modelId: 52, + width: 30, + x1: 18412, + x2: 18492, + y1: 2100.5, + y2: 2000.5, + overlayId: 12, + height: 100, + value: Infinity, + color: null, + }); + }); + it('should modify height, coordinates and return in sorted order to create submap link from several submap link rectangles', () => { + const { Wrapper } = getReduxStoreWithActionsListener({ + overlayBioEntity: { + overlaysId: [12], + data: { + 12: { + 52: MOCKED_OVERLAY_SUBMAPS_LINKS_WITH_SAME_COLORS, + }, + }, + }, + configuration: CONFIGURATION_INITIAL_STORE_MOCKS, + map: mapStateWithCurrentlySelectedMainMapFixture, + + models: { + ...MODELS_DATA_MOCK_WITH_MAIN_MAP, + }, + }); + + const { + result: { current }, + } = renderHook(() => useBioEntitiesWithSubmapsLinks(), { + wrapper: Wrapper, + }); + + expect(current).toStrictEqual(RESULT_SUBMAP_LINKS_SAME_COLORS); + }); + }); +}); diff --git a/src/components/Map/MapViewer/utils/config/overlaysLayer/useBioEntitiesWithSubmapLinks.ts b/src/components/Map/MapViewer/utils/config/overlaysLayer/useBioEntitiesWithSubmapLinks.ts new file mode 100644 index 0000000000000000000000000000000000000000..fd9ef50e007133e8339f9952c3b3bfd54b491a39 --- /dev/null +++ b/src/components/Map/MapViewer/utils/config/overlaysLayer/useBioEntitiesWithSubmapLinks.ts @@ -0,0 +1,92 @@ +/* eslint-disable no-magic-numbers */ +import { useAppSelector } from '@/redux/hooks/useAppSelector'; +import { overlayBioEntitiesForCurrentModelSelector } from '@/redux/overlayBioEntity/overlayBioEntity.selector'; +import { useCallback, useMemo } from 'react'; +import type { OverlayBioEntityRender } from '@/types/OLrendering'; +import { useGetOverlayColor } from './useGetOverlayColor'; +import { getSubmapLinkRectangle } from './getSubmapLinkRectangle'; +import { groupSubmapLinksRectanglesById } from './groupSubmapLinksRectanglesById'; + +export type SubmapLinkRectangle = OverlayBioEntityRender & { + amount: number; +}; + +export type GroupedSubmapsLinksRectangles = { + [id: string]: SubmapLinkRectangle[]; +}; + +export const useBioEntitiesWithSubmapsLinks = (): OverlayBioEntityRender[] => { + const { getOverlayBioEntityColorByAvailableProperties } = useGetOverlayColor(); + const bioEntities = useAppSelector(overlayBioEntitiesForCurrentModelSelector); + const submapsLinks = useMemo( + () => bioEntities.filter(bioEntity => bioEntity.type === 'submap-link'), + [bioEntities], + ); + const bioEntitiesWithoutSubmapsLinks = useMemo( + () => bioEntities.filter(bioEntity => bioEntity.type !== 'submap-link'), + [bioEntities], + ); + + const sortSubmapLinksRectanglesByColor = useCallback( + (submapLinksRectangles: SubmapLinkRectangle[]): void => { + submapLinksRectangles.sort((a, b) => { + const firstSubmapLinkRectangleColor = getOverlayBioEntityColorByAvailableProperties(a); + const secondSubmapLinkRectangleColor = getOverlayBioEntityColorByAvailableProperties(b); + + if (firstSubmapLinkRectangleColor === secondSubmapLinkRectangleColor) { + return 0; + } + + return firstSubmapLinkRectangleColor < secondSubmapLinkRectangleColor ? -1 : 1; + }); + }, + [getOverlayBioEntityColorByAvailableProperties], + ); + + const calculateSubmapsLinksRectanglesPosition = useCallback( + ( + groupedSubmapsLinksRectanglesById: GroupedSubmapsLinksRectangles, + ): OverlayBioEntityRender[] => { + const submapsLinksRectangles: SubmapLinkRectangle[] = []; + // eslint-disable-next-line no-restricted-syntax, guard-for-in + for (const id in groupedSubmapsLinksRectanglesById) { + const submapLinksRectanglesGroup = groupedSubmapsLinksRectanglesById[id]; + + sortSubmapLinksRectanglesByColor(submapLinksRectanglesGroup); + + const submapLinkRectanglesTotalHeight = submapLinksRectanglesGroup[0].height; + const submapLinkRectanglesAmount = submapLinksRectanglesGroup.reduce( + (accumulator: number, currentValue) => accumulator + currentValue.amount, + 0, + ); + + submapLinksRectanglesGroup.forEach((submapLinkRectangle, index) => { + const ratio = submapLinkRectangle.amount / submapLinkRectanglesAmount; + const rectangleHeight = ratio * submapLinkRectanglesTotalHeight; + + getSubmapLinkRectangle( + submapsLinksRectangles, + submapLinkRectangle, + index, + submapLinksRectanglesGroup, + rectangleHeight, + ); + }); + } + + return submapsLinksRectangles; + }, + [sortSubmapLinksRectanglesByColor], + ); + + const groupedSubmapLinksRectanglesById = useMemo( + () => groupSubmapLinksRectanglesById(submapsLinks), + [submapsLinks], + ); + const submapsLinksRectangles = useMemo( + () => calculateSubmapsLinksRectanglesPosition(groupedSubmapLinksRectanglesById), + [groupedSubmapLinksRectanglesById, calculateSubmapsLinksRectanglesPosition], + ); + + return [...submapsLinksRectangles, ...bioEntitiesWithoutSubmapsLinks]; +}; diff --git a/src/components/Map/MapViewer/utils/config/overlaysLayer/useOverlayFeatures.ts b/src/components/Map/MapViewer/utils/config/overlaysLayer/useOverlayFeatures.ts index 477a95dd324f06f8099d88db81055e7324b77d5f..98e528f393c900d3716cbfb5814fca0db056ddb0 100644 --- a/src/components/Map/MapViewer/utils/config/overlaysLayer/useOverlayFeatures.ts +++ b/src/components/Map/MapViewer/utils/config/overlaysLayer/useOverlayFeatures.ts @@ -1,9 +1,6 @@ import { ZERO } from '@/constants/common'; import { useAppSelector } from '@/redux/hooks/useAppSelector'; -import { - getOverlayOrderSelector, - overlayBioEntitiesForCurrentModelSelector, -} from '@/redux/overlayBioEntity/overlayBioEntity.selector'; +import { getOverlayOrderSelector } from '@/redux/overlayBioEntity/overlayBioEntity.selector'; import { LinePoint } from '@/types/reactions'; import { usePointToProjection } from '@/utils/map/usePointToProjection'; import type Feature from 'ol/Feature'; @@ -14,12 +11,14 @@ import { createOverlayGeometryFeature } from './createOverlayGeometryFeature'; import { createOverlayLineFeature } from './createOverlayLineFeature'; import { getPolygonLatitudeCoordinates } from './getPolygonLatitudeCoordinates'; import { useGetOverlayColor } from './useGetOverlayColor'; +import { useBioEntitiesWithSubmapsLinks } from './useBioEntitiesWithSubmapLinks'; +import { createOverlaySubmapLinkRectangleFeature } from './createOverlaySubmapLinkRectangleFeature'; export const useOverlayFeatures = (): Feature<Polygon>[] | Feature<SimpleGeometry>[] => { const pointToProjection = usePointToProjection(); const { getOverlayBioEntityColorByAvailableProperties } = useGetOverlayColor(); const overlaysOrder = useAppSelector(getOverlayOrderSelector); - const bioEntities = useAppSelector(overlayBioEntitiesForCurrentModelSelector); + const bioEntities = useBioEntitiesWithSubmapsLinks(); const features = useMemo( () => @@ -39,6 +38,16 @@ export const useOverlayFeatures = (): Feature<Polygon>[] | Feature<SimpleGeometr const color = getOverlayBioEntityColorByAvailableProperties(entity); + if (entity.type === 'submap-link') { + return createOverlaySubmapLinkRectangleFeature( + [ + ...pointToProjection({ x: xMin, y: entity.y1 }), + ...pointToProjection({ x: xMax, y: entity.y2 }), + ], + entity.value === Infinity ? null : color, + ); + } + if (entity.type === 'rectangle') { return createOverlayGeometryFeature( [ @@ -60,7 +69,7 @@ export const useOverlayFeatures = (): Feature<Polygon>[] | Feature<SimpleGeometr }, ); }), - [overlaysOrder, bioEntities, pointToProjection, getOverlayBioEntityColorByAvailableProperties], + [overlaysOrder, pointToProjection, getOverlayBioEntityColorByAvailableProperties, bioEntities], ); return features; diff --git a/src/models/mocks/publicationsResponseMock.ts b/src/models/mocks/publicationsResponseMock.ts new file mode 100644 index 0000000000000000000000000000000000000000..09021f4ed644552f37e1fa98ccbd0240f424ee00 --- /dev/null +++ b/src/models/mocks/publicationsResponseMock.ts @@ -0,0 +1,281 @@ +export const PUBLICATIONS_DEFAULT_SEARCH_FIRST_10_MOCK = { + data: [ + { + elements: [ + { + id: 19519, + modelId: 52, + type: 'REACTION', + }, + ], + publication: { + article: { + title: 'The glutamate receptor ion channels.', + authors: ['Dingledine R', ' Borges K', ' Bowie D', ' Traynelis SF.'], + journal: 'Pharmacological reviews', + year: 1999, + link: 'https://www.ncbi.nlm.nih.gov/pubmed/10049997', + pubmedId: '10049997', + citationCount: 2458, + }, + }, + }, + { + elements: [ + { + id: 16167, + modelId: 61, + type: 'REACTION', + }, + ], + publication: { + article: { + title: 'Regulation of JNK signaling by GSTp.', + authors: [ + 'Adler V', + ' Yin Z', + ' Fuchs SY', + ' Benezra M', + ' Rosario L', + ' Tew KD', + ' Pincus MR', + ' Sardana M', + ' Henderson CJ', + ' Wolf CR', + ' Davis RJ', + ' Ronai Z.', + ], + journal: 'The EMBO journal', + year: 1999, + link: 'https://www.ncbi.nlm.nih.gov/pubmed/10064598', + pubmedId: '10064598', + citationCount: 656, + }, + }, + }, + { + elements: [ + { + id: 17823, + modelId: 52, + type: 'REACTION', + }, + { + id: 19461, + modelId: 52, + type: 'REACTION', + }, + ], + publication: { + article: { + title: + 'Generic signals and specific outcomes: signaling through Ca2+, calcineurin, and NF-AT.', + authors: ['Crabtree GR.'], + journal: 'Cell', + year: 1999, + link: 'https://www.ncbi.nlm.nih.gov/pubmed/10089876', + pubmedId: '10089876', + citationCount: 454, + }, + }, + }, + { + elements: [ + { + id: 18189, + modelId: 52, + type: 'REACTION', + }, + { + id: 18729, + modelId: 52, + type: 'REACTION', + }, + ], + publication: { + article: { + title: 'G protein regulation of adenylate cyclase.', + authors: ['Simonds WF.'], + journal: 'Trends in pharmacological sciences', + year: 1999, + link: 'https://www.ncbi.nlm.nih.gov/pubmed/10101967', + pubmedId: '10101967', + citationCount: 139, + }, + }, + }, + { + elements: [ + { + id: 16077, + modelId: 58, + type: 'REACTION', + }, + { + id: 16135, + modelId: 58, + type: 'REACTION', + }, + ], + publication: { + article: { + title: + 'Akt promotes cell survival by phosphorylating and inhibiting a Forkhead transcription factor.', + authors: [ + 'Brunet A', + ' Bonni A', + ' Zigmond MJ', + ' Lin MZ', + ' Juo P', + ' Hu LS', + ' Anderson MJ', + ' Arden KC', + ' Blenis J', + ' Greenberg ME.', + ], + journal: 'Cell', + year: 1999, + link: 'https://www.ncbi.nlm.nih.gov/pubmed/10102273', + pubmedId: '10102273', + citationCount: 4019, + }, + }, + }, + { + elements: [ + { + id: 15955, + modelId: 55, + type: 'REACTION', + }, + ], + publication: { + article: { + title: 'Ca2+-induced apoptosis through calcineurin dephosphorylation of BAD.', + authors: [ + 'Wang HG', + ' Pathan N', + ' Ethell IM', + ' Krajewski S', + ' Yamaguchi Y', + ' Shibasaki F', + ' McKeon F', + ' Bobo T', + ' Franke TF', + ' Reed JC.', + ], + journal: 'Science (New York, N.Y.)', + year: 1999, + link: 'https://www.ncbi.nlm.nih.gov/pubmed/10195903', + pubmedId: '10195903', + citationCount: 708, + }, + }, + }, + { + elements: [ + { + id: 15937, + modelId: 55, + type: 'REACTION', + }, + { + id: 15955, + modelId: 55, + type: 'REACTION', + }, + ], + publication: { + article: { + title: + 'The proapoptotic activity of the Bcl-2 family member Bim is regulated by interaction with the dynein motor complex.', + authors: ['Puthalakath H', ' Huang DC', " O'Reilly LA", ' King SM', ' Strasser A.'], + journal: 'Molecular cell', + year: 1999, + link: 'https://www.ncbi.nlm.nih.gov/pubmed/10198631', + pubmedId: '10198631', + citationCount: 662, + }, + }, + }, + { + elements: [ + { + id: 15948, + modelId: 55, + type: 'REACTION', + }, + ], + publication: { + article: { + title: + 'An APAF-1.cytochrome c multimeric complex is a functional apoptosome that activates procaspase-9.', + authors: ['Zou H', ' Li Y', ' Liu X', ' Wang X.'], + journal: 'The Journal of biological chemistry', + year: 1999, + link: 'https://www.ncbi.nlm.nih.gov/pubmed/10206961', + pubmedId: '10206961', + citationCount: 1162, + }, + }, + }, + { + elements: [ + { + id: 16286, + modelId: 62, + type: 'REACTION', + }, + ], + publication: { + article: { + title: + 'Biochemical characterization and crystal structure determination of human heart short chain L-3-hydroxyacyl-CoA dehydrogenase provide insights into catalytic mechanism.', + authors: [ + 'Barycki JJ', + " O'Brien LK", + ' Bratt JM', + ' Zhang R', + ' Sanishvili R', + ' Strauss AW', + ' Banaszak LJ.', + ], + journal: 'Biochemistry', + year: 1999, + link: 'https://www.ncbi.nlm.nih.gov/pubmed/10231530', + pubmedId: '10231530', + citationCount: 56, + }, + }, + }, + { + elements: [ + { + id: 17780, + modelId: 52, + type: 'REACTION', + }, + { + id: 17937, + modelId: 52, + type: 'REACTION', + }, + ], + publication: { + article: { + title: 'The Ca-calmodulin-dependent protein kinase cascade.', + authors: ['Soderling TR.'], + journal: 'Trends in biochemical sciences', + year: 1999, + link: 'https://www.ncbi.nlm.nih.gov/pubmed/10366852', + pubmedId: '10366852', + citationCount: 322, + }, + }, + }, + ], + totalSize: 159, + filteredSize: 1586, + length: 10, + page: 0, +}; diff --git a/src/redux/apiPath.ts b/src/redux/apiPath.ts index 5051aa2ab98167bbfef888206ea98cd431d6ccb2..19aac51cbd7e5a42dad6e5cb907a10053e14b038 100644 --- a/src/redux/apiPath.ts +++ b/src/redux/apiPath.ts @@ -55,7 +55,7 @@ export const apiPath = { getConfigurationOptions: (): string => 'configuration/options/', getConfiguration: (): string => 'configuration/', getOverlayBioEntity: ({ overlayId, modelId }: { overlayId: number; modelId: number }): string => - `projects/${PROJECT_ID}/overlays/${overlayId}/models/${modelId}/bioEntities/`, + `projects/${PROJECT_ID}/overlays/${overlayId}/models/${modelId}/bioEntities/?includeIndirect=true`, createOverlay: (projectId: string): string => `projects/${projectId}/overlays/`, createOverlayFile: (): string => `files/`, uploadOverlayFileContent: (fileId: number): string => `files/${fileId}:uploadContent`, diff --git a/src/redux/overlayBioEntity/overlayBioEntity.mock.ts b/src/redux/overlayBioEntity/overlayBioEntity.mock.ts index 9b093c3eeaecee77b8fc2923f37f8cd3da8c5eec..bd2d8522533e66f539c0cd656fee9ab239ea2202 100644 --- a/src/redux/overlayBioEntity/overlayBioEntity.mock.ts +++ b/src/redux/overlayBioEntity/overlayBioEntity.mock.ts @@ -78,3 +78,159 @@ export const MOCKED_OVERLAY_BIO_ENTITY_RENDER: OverlayBioEntityRender[] = [ color: null, }, ]; + +export const MOCKED_OVERLAY_SUBMAPS_LINKS_WITH_SAME_COLORS: OverlayBioEntityRender[] = [ + { + type: 'rectangle', + id: 1, + modelId: 52, + width: 30, + x1: 18412, + x2: 18492, + y1: 3128.653195488725, + y2: 3088.653195488725, + overlayId: 12, + height: 10, + value: 0, + color: null, + }, + { + type: 'submap-link', + id: 97, + modelId: 52, + width: 30, + x1: 18412, + x2: 18492, + y1: 2100.5, + y2: 2000.5, + overlayId: 12, + height: 100, + value: 23, + color: null, + }, + { + type: 'submap-link', + id: 97, + modelId: 52, + width: 30, + x1: 18412, + x2: 18492, + y1: 2100.5, + y2: 2000.5, + overlayId: 12, + height: 100, + value: 23, + color: null, + }, + { + type: 'submap-link', + id: 97, + modelId: 52, + width: 30, + x1: 18412, + x2: 18492, + y1: 2100.5, + y2: 2000.5, + overlayId: 12, + height: 100, + value: null, + color: { + alpha: 255, + rgb: -2348283, + }, + }, + { + type: 'submap-link', + id: 97, + modelId: 52, + width: 30, + x1: 18412, + x2: 18492, + y1: 2100.5, + y2: 2000.5, + overlayId: 12, + height: 100, + value: null, + color: { + alpha: 255, + rgb: -2348283, + }, + }, +]; + +export const MOCKED_OVERLAY_SUBMAPS_LINKS_WITH_DIFFERENT_COLORS: OverlayBioEntityRender[] = [ + { + type: 'rectangle', + id: 1, + modelId: 52, + width: 30, + x1: 18412, + x2: 18492, + y1: 3128.653195488725, + y2: 3088.653195488725, + overlayId: 12, + height: 10, + value: 0, + color: null, + }, + { + type: 'submap-link', + id: 97, + modelId: 52, + width: 30, + x1: 18412, + x2: 18492, + y1: 2100.5, + y2: 2000.5, + overlayId: 12, + height: 100, + value: 0.4, + color: null, + }, + { + type: 'submap-link', + id: 97, + modelId: 52, + width: 30, + x1: 18412, + x2: 18492, + y1: 2100.5, + y2: 2000.5, + overlayId: 12, + height: 100, + value: 0.5, + color: null, + }, + { + type: 'submap-link', + id: 97, + modelId: 52, + width: 30, + x1: 18412, + x2: 18492, + y1: 2100.5, + y2: 2000.5, + overlayId: 12, + height: 100, + value: 0.8, + color: null, + }, + + { + type: 'submap-link', + id: 97, + modelId: 52, + width: 30, + x1: 18412, + x2: 18492, + y1: 2100.5, + y2: 2000.5, + overlayId: 12, + height: 100, + value: null, + color: { + alpha: 255, + rgb: -2348283, + }, + }, +]; diff --git a/src/redux/overlayBioEntity/overlayBioEntity.utils.ts b/src/redux/overlayBioEntity/overlayBioEntity.utils.ts index b765ff8de4471783e7eca9ffa73ec405b9829118..becf2d2ab94699294879668994a6b5c88cf73f62 100644 --- a/src/redux/overlayBioEntity/overlayBioEntity.utils.ts +++ b/src/redux/overlayBioEntity/overlayBioEntity.utils.ts @@ -3,7 +3,7 @@ import { overlayBioEntitySchema } from '@/models/overlayBioEntitySchema'; import { OverlayBioEntityRender } from '@/types/OLrendering'; import { OverlayBioEntity } from '@/types/models'; import { getOverlayReactionCoordsFromLine } from '@/utils/overlays/getOverlayReactionCoords'; -import { isBioEntity, isReaction } from '@/utils/overlays/overlaysElementsTypeGuards'; +import { isBioEntity, isReaction, isSubmapLink } from '@/utils/overlays/overlaysElementsTypeGuards'; import { z } from 'zod'; export const parseOverlayBioEntityToOlRenderingFormat = ( @@ -18,6 +18,24 @@ export const parseOverlayBioEntityToOlRenderingFormat = ( * Every reaction line is a different entity after reduce */ + if (isSubmapLink(entity)) { + acc.push({ + type: 'submap-link', + id: entity.left.id, + modelId: entity.left.model, + x1: entity.left.x, + y1: entity.left.y + entity.left.height, + x2: entity.left.x + entity.left.width, + y2: entity.left.y, + width: entity.left.width, + height: entity.left.height, + value: entity.right.value, + overlayId, + color: entity.right.color, + }); + return acc; + } + if (isBioEntity(entity)) { acc.push({ type: 'rectangle', diff --git a/src/redux/publications/publications.mock.ts b/src/redux/publications/publications.mock.ts index 3ae459cd1a5e4a1bad2f273d6c20bb6336d46cd7..97f88fd95e2cbb33e23e78331541b88f44f5ad3b 100644 --- a/src/redux/publications/publications.mock.ts +++ b/src/redux/publications/publications.mock.ts @@ -1,3 +1,4 @@ +import { PUBLICATIONS_DEFAULT_SEARCH_FIRST_10_MOCK } from '@/models/mocks/publicationsResponseMock'; import { PublicationsState } from './publications.types'; export const PUBLICATIONS_INITIAL_STATE_MOCK: PublicationsState = { @@ -9,3 +10,13 @@ export const PUBLICATIONS_INITIAL_STATE_MOCK: PublicationsState = { searchValue: '', selectedModelId: undefined, }; + +export const PUBLICATIONS_DEFAULT_SEARCH_FIRST_10_STATE_MOCK: PublicationsState = { + data: PUBLICATIONS_DEFAULT_SEARCH_FIRST_10_MOCK, + loading: 'idle', + error: { name: '', message: '' }, + sortColumn: '', + sortOrder: 'asc', + searchValue: '', + selectedModelId: undefined, +}; diff --git a/src/redux/store.ts b/src/redux/store.ts index 925fb4094256ffe3ccd792ba655e0d24e717e919..6aa4c44501acd537ce6d49ff63f4a9aba1c3e894 100644 --- a/src/redux/store.ts +++ b/src/redux/store.ts @@ -22,12 +22,12 @@ import { TypedStartListening, configureStore, } from '@reduxjs/toolkit'; -import compartmentPathwaysReducer from './compartmentPathways/compartmentPathways.slice'; import exportReducer from './export/export.slice'; import legendReducer from './legend/legend.slice'; import { mapListenerMiddleware } from './map/middleware/map.middleware'; import pluginsReducer from './plugins/plugins.slice'; import statisticsReducer from './statistics/statistics.slice'; +import compartmentPathwaysReducer from './compartmentPathways/compartmentPathways.slice'; import publicationsReducer from './publications/publications.slice'; export const reducers = { diff --git a/src/types/OLrendering.ts b/src/types/OLrendering.ts index 18075b437e4f2f7e91f7de2cbe1955f8851feaf4..9769064d9bada810382fe653e7df432030f9f94b 100644 --- a/src/types/OLrendering.ts +++ b/src/types/OLrendering.ts @@ -1,6 +1,6 @@ import { Color, GeneVariant } from './models'; -export type OverlayBioEntityRenderType = 'line' | 'rectangle'; +export type OverlayBioEntityRenderType = 'line' | 'rectangle' | 'submap-link'; export type OverlayBioEntityRender = { id: number; diff --git a/src/utils/overlays/overlaysElementsTypeGuards.ts b/src/utils/overlays/overlaysElementsTypeGuards.ts index 6997b141f5d627b5d4641ee23c2f7b902f17a1af..fdd3f1938b4021b29c610671c6e5225b6b97d082 100644 --- a/src/utils/overlays/overlaysElementsTypeGuards.ts +++ b/src/utils/overlays/overlaysElementsTypeGuards.ts @@ -12,3 +12,8 @@ export const isReaction = (e: OverlayBioEntity): e is OverlayElementWithReaction export const isBioEntity = (e: OverlayBioEntity): e is OverlayElementWithBioEntity => (e.left as OverlayLeftBioEntity).x !== undefined && (e.left as OverlayLeftBioEntity).y !== undefined; + +export const isSubmapLink = (e: OverlayBioEntity): e is OverlayElementWithBioEntity => + (e.left as OverlayLeftBioEntity).x !== undefined && + (e.left as OverlayLeftBioEntity).y !== undefined && + (e.left as OverlayLeftBioEntity).submodel !== undefined;