diff --git a/src/components/FunctionalArea/Modal/OverviewImagesModal/OverviewImageModal.types.ts b/src/components/FunctionalArea/Modal/OverviewImagesModal/OverviewImageModal.types.ts index ec227bb337061f3d5bafca30f35a8f46253829c5..a09968c65631dad23f8b8df5fca373d2b35e42a2 100644 --- a/src/components/FunctionalArea/Modal/OverviewImagesModal/OverviewImageModal.types.ts +++ b/src/components/FunctionalArea/Modal/OverviewImagesModal/OverviewImageModal.types.ts @@ -1,3 +1,5 @@ +import { OverviewImageLinkImage, OverviewImageLinkModel } from '@/types/models'; + export interface OverviewImageSize { width: number; height: number; @@ -7,3 +9,20 @@ export interface ImageContainerSize { width: number; height: number; } + +export interface OverviewImageLinkConfigSize { + top: number; + left: number; + width: number; + height: number; +} + +export interface OverviewImageLinkConfig { + idObject: number; + size: OverviewImageLinkConfigSize; + onClick(): void; +} + +export type OverviewImageLinkImageHandler = (link: OverviewImageLinkImage) => void; + +export type OverviewImageLinkModelHandler = (link: OverviewImageLinkModel) => void; diff --git a/src/components/FunctionalArea/Modal/OverviewImagesModal/OverviewImagesModal.component.tsx b/src/components/FunctionalArea/Modal/OverviewImagesModal/OverviewImagesModal.component.tsx index ef0e608a1086bd4e72defc88889523994d704d0c..07f58c8b1fa57c09233e0b5122316db0793ee51d 100644 --- a/src/components/FunctionalArea/Modal/OverviewImagesModal/OverviewImagesModal.component.tsx +++ b/src/components/FunctionalArea/Modal/OverviewImagesModal/OverviewImagesModal.component.tsx @@ -1,21 +1,32 @@ /* eslint-disable @next/next/no-img-element */ import * as React from 'react'; -import { useCallback, useState } from 'react'; +import { useCallback, useLayoutEffect, useState } from 'react'; import { useOverviewImage } from './utils/useOverviewImage'; export const OverviewImagesModal: React.FC = () => { const [containerRect, setContainerRect] = useState<DOMRect>(); - const { imageUrl, size } = useOverviewImage({ containerRect }); + const { imageUrl, size, linkConfigs } = useOverviewImage({ containerRect }); const { width, height } = size; + const [containerNode, setContainerNode] = useState<HTMLDivElement | null>(null); - const handleRect = useCallback((node: HTMLDivElement | null) => { - if (!node) { - return; + const handleContainerRef = useCallback((node: HTMLDivElement | null) => { + if (node !== null) { + setContainerRect(node.getBoundingClientRect()); + setContainerNode(node); } - - setContainerRect(node.getBoundingClientRect()); }, []); + useLayoutEffect(() => { + const updateContainerSize = (): void => { + if (containerNode !== null) { + setContainerRect(containerNode.getBoundingClientRect()); + } + }; + + window.addEventListener('resize', updateContainerSize); + return () => window.removeEventListener('resize', updateContainerSize); + }, [containerNode]); + if (!imageUrl) { return null; } @@ -24,11 +35,27 @@ export const OverviewImagesModal: React.FC = () => { <div data-testid="overview-images-modal" className="flex h-full w-full items-center justify-center bg-white" - ref={handleRect} + ref={handleContainerRef} > <div className="relative" style={{ width, height }}> <img alt="overview" className="block h-full w-full" src={imageUrl} /> - {/* TODO: interactions - clickable elements (in next task) */} + {linkConfigs.map(({ size: linkSize, onClick, idObject }) => ( + // eslint-disable-next-line jsx-a11y/click-events-have-key-events + <div + role="button" + tabIndex={0} + key={idObject} + className="cursor-pointer" + style={{ + height: linkSize.height, + width: linkSize.width, + top: linkSize.top, + left: linkSize.left, + position: 'absolute', + }} + onClick={onClick} + /> + ))} </div> </div> ); diff --git a/src/components/FunctionalArea/Modal/OverviewImagesModal/OverviewImagesModal.constants.ts b/src/components/FunctionalArea/Modal/OverviewImagesModal/OverviewImagesModal.constants.ts new file mode 100644 index 0000000000000000000000000000000000000000..d108d444f510429b33cde666db18f35fbdf017c3 --- /dev/null +++ b/src/components/FunctionalArea/Modal/OverviewImagesModal/OverviewImagesModal.constants.ts @@ -0,0 +1,8 @@ +import { OverviewImageLinkConfigSize } from './OverviewImageModal.types'; + +export const DEFAULT_OVERVIEW_IMAGE_LINK_CONFIG: OverviewImageLinkConfigSize = { + top: 0, + left: 0, + width: 0, + height: 0, +}; diff --git a/src/components/FunctionalArea/Modal/OverviewImagesModal/utils/getOverviewImageLinkSize.test.ts b/src/components/FunctionalArea/Modal/OverviewImagesModal/utils/getOverviewImageLinkSize.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..2aad19fbfab91a3f9c476c62aa6d2cc8a8abf903 --- /dev/null +++ b/src/components/FunctionalArea/Modal/OverviewImagesModal/utils/getOverviewImageLinkSize.test.ts @@ -0,0 +1,108 @@ +import { OverviewImageLink } from '@/types/models'; +import { OverviewImageLinkConfigSize } from '../OverviewImageModal.types'; +import { DEFAULT_OVERVIEW_IMAGE_LINK_CONFIG } from '../OverviewImagesModal.constants'; +import { getOverviewImageLinkSize } from './getOverviewImageLinkSize'; + +describe('getOverviewImageLinkSize - util', () => { + const cases: [ + Pick<OverviewImageLink, 'polygon'>, + { + sizeFactor: number; + }, + OverviewImageLinkConfigSize, + ][] = [ + // invalid polygon + [ + { + polygon: [], + }, + { + sizeFactor: 1, + }, + DEFAULT_OVERVIEW_IMAGE_LINK_CONFIG, + ], + // invalid polygon + [ + { + polygon: [ + { x: 0, y: 0 }, + { x: 0, y: 0 }, + { x: 0, y: 0 }, + ], + }, + { + sizeFactor: 1, + }, + DEFAULT_OVERVIEW_IMAGE_LINK_CONFIG, + ], + // valid polygon with size of 0x0 + [ + { + polygon: [ + { x: 0, y: 0 }, + { x: 0, y: 0 }, + { x: 0, y: 0 }, + { x: 0, y: 0 }, + ], + }, + { + sizeFactor: 1, + }, + { + top: 0, + left: 0, + width: 0, + height: 0, + }, + ], + // valid polygon with size of 20x50 + [ + { + polygon: [ + { x: 10, y: 0 }, + { x: 30, y: 0 }, + { x: 30, y: 50 }, + { x: 10, y: 50 }, + ], + }, + { + sizeFactor: 1, + }, + { + top: 0, + left: 10, + width: 20, + height: 50, + }, + ], + // valid polygon with size of 27x67.5 in scale of 1.35 + [ + { + polygon: [ + { x: 10, y: 0 }, + { x: 30, y: 0 }, + { x: 30, y: 50 }, + { x: 10, y: 50 }, + ], + }, + { + sizeFactor: 1.35, + }, + { + height: 67.5, + left: 13.5, + top: 0, + width: 27, + }, + ], + ]; + + it.each(cases)( + 'should return valid link config size', + (overviewImageWithPolygon, options, finalConfigSize) => { + expect(getOverviewImageLinkSize(overviewImageWithPolygon, options)).toStrictEqual( + finalConfigSize, + ); + }, + ); +}); diff --git a/src/components/FunctionalArea/Modal/OverviewImagesModal/utils/getOverviewImageLinkSize.ts b/src/components/FunctionalArea/Modal/OverviewImagesModal/utils/getOverviewImageLinkSize.ts new file mode 100644 index 0000000000000000000000000000000000000000..f03b7509f3abe59ed4b23d8eb3cdd32e34703dea --- /dev/null +++ b/src/components/FunctionalArea/Modal/OverviewImagesModal/utils/getOverviewImageLinkSize.ts @@ -0,0 +1,31 @@ +import { SIZE_OF_ARRAY_WITH_FOUR_ELEMENTS } from '@/constants/common'; +import { OverviewImageLink } from '@/types/models'; +import { OverviewImageLinkConfigSize } from '../OverviewImageModal.types'; +import { DEFAULT_OVERVIEW_IMAGE_LINK_CONFIG } from '../OverviewImagesModal.constants'; + +export const getOverviewImageLinkSize = ( + { polygon }: Pick<OverviewImageLink, 'polygon'>, + { + sizeFactor, + }: { + sizeFactor: number; + }, +): OverviewImageLinkConfigSize => { + // valid polygon needs to have four points + if (polygon.length < SIZE_OF_ARRAY_WITH_FOUR_ELEMENTS) { + return DEFAULT_OVERVIEW_IMAGE_LINK_CONFIG; + } + + const polygonScaled = polygon.map(({ x, y }) => ({ x: x * sizeFactor, y: y * sizeFactor })); + const [pointTopLeft, , pointBottomRight] = polygonScaled; + const width = pointBottomRight.x - pointTopLeft.x; + const height = pointBottomRight.y - pointTopLeft.y; + const { x, y } = pointTopLeft; + + return { + top: y, + left: x, + width, + height, + }; +}; diff --git a/src/components/FunctionalArea/Modal/OverviewImagesModal/utils/useOverviewImage.test.ts b/src/components/FunctionalArea/Modal/OverviewImagesModal/utils/useOverviewImage.test.ts index f4d964d3095eb52532ff2ed7097a146cac1f6d3e..cd7468fd69680ce98645251e955ece392dc61994 100644 --- a/src/components/FunctionalArea/Modal/OverviewImagesModal/utils/useOverviewImage.test.ts +++ b/src/components/FunctionalArea/Modal/OverviewImagesModal/utils/useOverviewImage.test.ts @@ -35,6 +35,7 @@ describe('useOverviewImage - hook', () => { expect(result.current).toStrictEqual({ imageUrl: '', size: DEFAULT_OVERVIEW_IMAGE_SIZE, + linkConfigs: [], }); }); }); @@ -65,7 +66,7 @@ describe('useOverviewImage - hook', () => { it('should return default size of image and valid imageUrl', () => { const imageUrl = `${BASE_MAP_IMAGES_URL}/map_images/${PROJECT_OVERVIEW_IMAGE_MOCK.filename}`; - expect(result.current).toStrictEqual({ + expect(result.current).toMatchObject({ imageUrl, size: DEFAULT_OVERVIEW_IMAGE_SIZE, }); @@ -107,7 +108,7 @@ describe('useOverviewImage - hook', () => { it('should return size of image and valid imageUrl', () => { const imageUrl = `${BASE_MAP_IMAGES_URL}/map_images/${PROJECT_OVERVIEW_IMAGE_MOCK.filename}`; - expect(result.current).toStrictEqual({ + expect(result.current).toMatchObject({ imageUrl, size: { height: 100, width: 100, sizeFactor: 0.2 }, }); diff --git a/src/components/FunctionalArea/Modal/OverviewImagesModal/utils/useOverviewImage.ts b/src/components/FunctionalArea/Modal/OverviewImagesModal/utils/useOverviewImage.ts index db956acda5b183bcd854960c17242eb303fc3282..e09467b051efbdca9720a87f661027c85b7986b8 100644 --- a/src/components/FunctionalArea/Modal/OverviewImagesModal/utils/useOverviewImage.ts +++ b/src/components/FunctionalArea/Modal/OverviewImagesModal/utils/useOverviewImage.ts @@ -1,4 +1,5 @@ -import { OverviewImageSize } from '../OverviewImageModal.types'; +import { OverviewImageLinkConfig, OverviewImageSize } from '../OverviewImageModal.types'; +import { useOverviewImageLinkConfigs } from './useOverviewImageLinkElements'; import { useOverviewImageSize } from './useOverviewImageSize'; import { useOverviewImageUrl } from './useOverviewImageUrl'; @@ -9,6 +10,7 @@ interface UseOverviewImageArgs { interface UseOverviewImageResults { imageUrl: string; size: OverviewImageSize; + linkConfigs: OverviewImageLinkConfig[]; } export const useOverviewImage = ({ @@ -16,9 +18,11 @@ export const useOverviewImage = ({ }: UseOverviewImageArgs): UseOverviewImageResults => { const imageUrl = useOverviewImageUrl(); const size = useOverviewImageSize({ containerRect }); + const linkConfigs = useOverviewImageLinkConfigs({ sizeFactor: size.sizeFactor }); return { size, imageUrl, + linkConfigs, }; }; diff --git a/src/components/FunctionalArea/Modal/OverviewImagesModal/utils/useOverviewImageLinkActions.test.ts b/src/components/FunctionalArea/Modal/OverviewImagesModal/utils/useOverviewImageLinkActions.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..c0df892c5e7a03016d752a3d1ae78f208bb33ad9 --- /dev/null +++ b/src/components/FunctionalArea/Modal/OverviewImagesModal/utils/useOverviewImageLinkActions.test.ts @@ -0,0 +1,306 @@ +import { projectFixture } from '@/models/fixtures/projectFixture'; +import { MODELS_MOCK_SHORT } from '@/models/mocks/modelsMock'; +import { + OVERVIEW_LINK_IMAGE_MOCK, + OVERVIEW_LINK_MODEL_MOCK, +} from '@/models/mocks/overviewImageMocks'; +import { + initialMapDataFixture, + openedMapsInitialValueFixture, + openedMapsThreeSubmapsFixture, +} from '@/redux/map/map.fixtures'; +import { MODAL_INITIAL_STATE_MOCK } from '@/redux/modal/modal.mock'; +import { PROJECT_OVERVIEW_IMAGE_MOCK } from '@/redux/project/project.mock'; +import { OverviewImageLink } from '@/types/models'; +import { getReduxStoreWithActionsListener } from '@/utils/testing/getReduxStoreActionsListener'; +import { getReduxWrapperWithStore } from '@/utils/testing/getReduxWrapperWithStore'; +import { renderHook } from '@testing-library/react'; +import { + FIRST_ARRAY_ELEMENT, + NOOP, + SECOND_ARRAY_ELEMENT, + SIZE_OF_EMPTY_ARRAY, + THIRD_ARRAY_ELEMENT, +} from '../../../../../constants/common'; +import { useOverviewImageLinkActions } from './useOverviewImageLinkActions'; + +jest.mock('../../../../../constants/common', () => ({ + ...jest.requireActual('../../../../../constants/common'), + NOOP: jest.fn(), +})); + +describe('useOverviewImageLinkActions - hook', () => { + describe('when clicked on image link', () => { + describe('when image id is NOT valid', () => { + const { Wrapper, store } = getReduxStoreWithActionsListener({ + project: { + data: { + ...projectFixture, + overviewImageViews: [], + topOverviewImage: PROJECT_OVERVIEW_IMAGE_MOCK, + }, + loading: 'succeeded', + error: { message: '', name: '' }, + }, + modal: { + ...MODAL_INITIAL_STATE_MOCK, + overviewImagesState: { + imageId: 0, + }, + }, + map: { + data: { + ...initialMapDataFixture, + modelId: 5053, + }, + loading: 'succeeded', + error: { name: '', message: '' }, + openedMaps: openedMapsThreeSubmapsFixture, + }, + }); + + const { + result: { + current: { handleLinkClick }, + }, + } = renderHook(() => useOverviewImageLinkActions(), { + wrapper: Wrapper, + }); + + it('should NOT fire action set overview image id', () => { + handleLinkClick(OVERVIEW_LINK_IMAGE_MOCK); + const actions = store.getActions(); + expect(actions.length).toEqual(SIZE_OF_EMPTY_ARRAY); + }); + }); + + describe('when image id is valid', () => { + const { Wrapper, store } = getReduxStoreWithActionsListener({ + project: { + data: { + ...projectFixture, + overviewImageViews: [ + { + ...PROJECT_OVERVIEW_IMAGE_MOCK, + height: 500, + width: 500, + }, + ], + topOverviewImage: PROJECT_OVERVIEW_IMAGE_MOCK, + }, + loading: 'succeeded', + error: { message: '', name: '' }, + }, + modal: { + ...MODAL_INITIAL_STATE_MOCK, + overviewImagesState: { + imageId: PROJECT_OVERVIEW_IMAGE_MOCK.idObject, + }, + }, + map: { + data: { + ...initialMapDataFixture, + modelId: 5053, + }, + loading: 'succeeded', + error: { name: '', message: '' }, + openedMaps: openedMapsThreeSubmapsFixture, + }, + }); + + const { + result: { + current: { handleLinkClick }, + }, + } = renderHook(() => useOverviewImageLinkActions(), { + wrapper: Wrapper, + }); + + it('should fire action set overview image id', () => { + handleLinkClick(OVERVIEW_LINK_IMAGE_MOCK); + const actions = store.getActions(); + expect(actions[FIRST_ARRAY_ELEMENT]).toStrictEqual({ + payload: 440, + type: 'modal/setOverviewImageId', + }); + }); + }); + }); + describe('when clicked on model link', () => { + describe('when model is not available', () => {}); + + describe('when model is available', () => { + describe('when map is already opened', () => { + const { Wrapper, store } = getReduxStoreWithActionsListener({ + project: { + data: { + ...projectFixture, + overviewImageViews: [ + { + ...PROJECT_OVERVIEW_IMAGE_MOCK, + height: 500, + width: 500, + }, + ], + topOverviewImage: PROJECT_OVERVIEW_IMAGE_MOCK, + }, + loading: 'succeeded', + error: { message: '', name: '' }, + }, + modal: { + ...MODAL_INITIAL_STATE_MOCK, + overviewImagesState: { + imageId: PROJECT_OVERVIEW_IMAGE_MOCK.idObject, + }, + }, + map: { + data: { + ...initialMapDataFixture, + modelId: 5053, + }, + loading: 'succeeded', + error: { name: '', message: '' }, + openedMaps: openedMapsThreeSubmapsFixture, + }, + models: { + data: MODELS_MOCK_SHORT, + loading: 'succeeded', + error: { name: '', message: '' }, + }, + }); + + const { + result: { + current: { handleLinkClick }, + }, + } = renderHook(() => useOverviewImageLinkActions(), { + wrapper: Wrapper, + }); + + it('should set active map', () => { + handleLinkClick(OVERVIEW_LINK_MODEL_MOCK); + const actions = store.getActions(); + expect(actions[FIRST_ARRAY_ELEMENT]).toStrictEqual({ + payload: { + modelId: 5053, + }, + type: 'map/setActiveMap', + }); + }); + + it('should set map position', () => { + handleLinkClick(OVERVIEW_LINK_MODEL_MOCK); + const actions = store.getActions(); + expect(actions[SECOND_ARRAY_ELEMENT]).toStrictEqual({ + payload: { x: 15570, y: 3016, z: 7 }, + type: 'map/setMapPosition', + }); + }); + + it('should close modal', () => { + handleLinkClick(OVERVIEW_LINK_MODEL_MOCK); + const actions = store.getActions(); + expect(actions[THIRD_ARRAY_ELEMENT]).toStrictEqual({ + payload: undefined, + type: 'modal/closeModal', + }); + }); + }); + + describe('when map is not opened', () => { + const { Wrapper, store } = getReduxStoreWithActionsListener({ + project: { + data: { + ...projectFixture, + overviewImageViews: [ + { + ...PROJECT_OVERVIEW_IMAGE_MOCK, + height: 500, + width: 500, + }, + ], + topOverviewImage: PROJECT_OVERVIEW_IMAGE_MOCK, + }, + loading: 'succeeded', + error: { message: '', name: '' }, + }, + modal: { + ...MODAL_INITIAL_STATE_MOCK, + overviewImagesState: { + imageId: PROJECT_OVERVIEW_IMAGE_MOCK.idObject, + }, + }, + map: { + data: { + ...initialMapDataFixture, + modelId: 5053, + }, + loading: 'succeeded', + error: { name: '', message: '' }, + openedMaps: openedMapsInitialValueFixture, + }, + models: { + data: MODELS_MOCK_SHORT, + loading: 'succeeded', + error: { name: '', message: '' }, + }, + }); + + const { + result: { + current: { handleLinkClick }, + }, + } = renderHook(() => useOverviewImageLinkActions(), { + wrapper: Wrapper, + }); + + it('should open map and set as active', () => { + handleLinkClick(OVERVIEW_LINK_MODEL_MOCK); + const actions = store.getActions(); + expect(actions[FIRST_ARRAY_ELEMENT]).toStrictEqual({ + payload: { + modelId: 5053, + modelName: 'Core PD map', + }, + type: 'map/openMapAndSetActive', + }); + }); + + it('should set map position', () => { + handleLinkClick(OVERVIEW_LINK_MODEL_MOCK); + const actions = store.getActions(); + expect(actions[SECOND_ARRAY_ELEMENT]).toStrictEqual({ + payload: { x: 15570, y: 3016, z: 7 }, + type: 'map/setMapPosition', + }); + }); + + it('should close modal', () => { + handleLinkClick(OVERVIEW_LINK_MODEL_MOCK); + const actions = store.getActions(); + expect(actions[THIRD_ARRAY_ELEMENT]).toStrictEqual({ + payload: undefined, + type: 'modal/closeModal', + }); + }); + }); + }); + }); + describe('when clicked on unsupported link', () => { + const { Wrapper } = getReduxWrapperWithStore(); + const { + result: { + current: { handleLinkClick }, + }, + } = renderHook(() => useOverviewImageLinkActions(), { + wrapper: Wrapper, + }); + + it('should noop', () => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore to simulate invalid link object + handleLinkClick({ link: {} as unknown as OverviewImageLink }); + expect(NOOP).toBeCalled(); + }); + }); +}); diff --git a/src/components/FunctionalArea/Modal/OverviewImagesModal/utils/useOverviewImageLinkActions.ts b/src/components/FunctionalArea/Modal/OverviewImagesModal/utils/useOverviewImageLinkActions.ts new file mode 100644 index 0000000000000000000000000000000000000000..1388a45a82c05425f769e3d6fff4ce5d05b7c98b --- /dev/null +++ b/src/components/FunctionalArea/Modal/OverviewImagesModal/utils/useOverviewImageLinkActions.ts @@ -0,0 +1,95 @@ +import { NOOP } from '@/constants/common'; +import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; +import { useAppSelector } from '@/redux/hooks/useAppSelector'; +import { mapOpenedMapsSelector } from '@/redux/map/map.selectors'; +import { openMapAndSetActive, setActiveMap, setMapPosition } from '@/redux/map/map.slice'; +import { closeModal, setOverviewImageId } from '@/redux/modal/modal.slice'; +import { modelsDataSelector } from '@/redux/models/models.selectors'; +import { projectOverviewImagesSelector } from '@/redux/project/project.selectors'; +import { MapModel, OverviewImageLink, OverviewImageLinkModel } from '@/types/models'; +import { + OverviewImageLinkImageHandler, + OverviewImageLinkModelHandler, +} from '../OverviewImageModal.types'; + +interface UseOverviewImageLinkActionsResult { + handleLinkClick(link: OverviewImageLink): void; +} + +export const useOverviewImageLinkActions = (): UseOverviewImageLinkActionsResult => { + const dispatch = useAppDispatch(); + const openedMaps = useAppSelector(mapOpenedMapsSelector); + const models = useAppSelector(modelsDataSelector); + const overviewImages = useAppSelector(projectOverviewImagesSelector); + + const checkIfImageIsAvailable = (imageId: number): boolean => + overviewImages.some(image => image.idObject === imageId); + + const checkIfMapAlreadyOpened = (modelId: number): boolean => + openedMaps.some(map => map.modelId === modelId); + + const getModelById = (modelId: number): MapModel | undefined => + models.find(map => map.idObject === modelId); + + const handleOpenMap = (model: MapModel): void => { + const modelId = model.idObject; + const isMapOpened = checkIfMapAlreadyOpened(modelId); + + if (isMapOpened) { + dispatch(setActiveMap({ modelId })); + return; + } + + dispatch(openMapAndSetActive({ modelId, modelName: model.name })); + }; + + const handleSetMapPosition = (link: OverviewImageLinkModel, model: MapModel): void => { + dispatch( + setMapPosition({ + x: link.modelPoint.x, + y: link.modelPoint.y, + z: link.zoomLevel + model.minZoom, + }), + ); + }; + + const onSubmapClick: OverviewImageLinkModelHandler = link => { + const modelId = link.modelLinkId; + const model = getModelById(modelId); + if (!model) { + return; + } + + handleOpenMap(model); + handleSetMapPosition(link, model); + dispatch(closeModal()); + }; + + const onImageClick: OverviewImageLinkImageHandler = link => { + const isImageAvailable = checkIfImageIsAvailable(link.imageLinkId); + if (!isImageAvailable) { + return; + } + + dispatch(setOverviewImageId(link.imageLinkId)); + }; + + const handleLinkClick: UseOverviewImageLinkActionsResult['handleLinkClick'] = link => { + const isImageLink = 'imageLinkId' in link; + const isModelLink = 'modelLinkId' in link; + + if (isImageLink) { + return onImageClick(link); + } + + if (isModelLink) { + return onSubmapClick(link); + } + + return NOOP(); + }; + + return { + handleLinkClick, + }; +}; diff --git a/src/components/FunctionalArea/Modal/OverviewImagesModal/utils/useOverviewImageLinkElements.test.ts b/src/components/FunctionalArea/Modal/OverviewImagesModal/utils/useOverviewImageLinkElements.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..308e52405d55345fc2345155ff60abe1abc25da5 --- /dev/null +++ b/src/components/FunctionalArea/Modal/OverviewImagesModal/utils/useOverviewImageLinkElements.test.ts @@ -0,0 +1,148 @@ +import { ZERO } from '@/constants/common'; +import { projectFixture } from '@/models/fixtures/projectFixture'; +import { MODAL_INITIAL_STATE_MOCK } from '@/redux/modal/modal.mock'; +import { PROJECT_OVERVIEW_IMAGE_MOCK } from '@/redux/project/project.mock'; +import { getReduxWrapperWithStore } from '@/utils/testing/getReduxWrapperWithStore'; +import { renderHook } from '@testing-library/react'; +import { useOverviewImageLinkConfigs } from './useOverviewImageLinkElements'; + +describe('useOverviewImageLinkConfigs - hook', () => { + describe('when currentImage is undefined', () => { + const { Wrapper } = getReduxWrapperWithStore({ + project: { + data: { + ...projectFixture, + overviewImageViews: [], + topOverviewImage: PROJECT_OVERVIEW_IMAGE_MOCK, + }, + loading: 'succeeded', + error: { message: '', name: '' }, + }, + modal: { + ...MODAL_INITIAL_STATE_MOCK, + overviewImagesState: { + imageId: 0, + }, + }, + }); + + const { + result: { current: returnValue }, + } = renderHook( + () => + useOverviewImageLinkConfigs({ + sizeFactor: 1, + }), + { + wrapper: Wrapper, + }, + ); + + it('should return empty array', () => { + expect(returnValue).toStrictEqual([]); + }); + }); + + describe('when sizeFactor is zero', () => { + const { Wrapper } = getReduxWrapperWithStore({ + project: { + data: { + ...projectFixture, + overviewImageViews: [PROJECT_OVERVIEW_IMAGE_MOCK], + topOverviewImage: PROJECT_OVERVIEW_IMAGE_MOCK, + }, + loading: 'succeeded', + error: { message: '', name: '' }, + }, + modal: { + ...MODAL_INITIAL_STATE_MOCK, + overviewImagesState: { + imageId: PROJECT_OVERVIEW_IMAGE_MOCK.idObject, + }, + }, + }); + + const { + result: { current: returnValue }, + } = renderHook( + () => + useOverviewImageLinkConfigs({ + sizeFactor: ZERO, + }), + { + wrapper: Wrapper, + }, + ); + + it('should return empty array', () => { + expect(returnValue).toStrictEqual([]); + }); + }); + + describe('when all args are valid', () => { + const { Wrapper } = getReduxWrapperWithStore({ + project: { + data: { + ...projectFixture, + overviewImageViews: [PROJECT_OVERVIEW_IMAGE_MOCK], + topOverviewImage: PROJECT_OVERVIEW_IMAGE_MOCK, + }, + loading: 'succeeded', + error: { message: '', name: '' }, + }, + modal: { + ...MODAL_INITIAL_STATE_MOCK, + overviewImagesState: { + imageId: PROJECT_OVERVIEW_IMAGE_MOCK.idObject, + }, + }, + }); + + const { + result: { current: returnValue }, + } = renderHook( + () => + useOverviewImageLinkConfigs({ + sizeFactor: 1, + }), + { + wrapper: Wrapper, + }, + ); + + it('should return correct value', () => { + expect(returnValue).toStrictEqual([ + { + idObject: 2062, + size: { top: 2187, left: 515, width: 558, height: 333 }, + onClick: expect.any(Function), + }, + { + idObject: 2063, + size: { top: 1360, left: 2410, width: 282, height: 210 }, + onClick: expect.any(Function), + }, + { + idObject: 2064, + size: { top: 497, left: 2830, width: 426, height: 335 }, + onClick: expect.any(Function), + }, + { + idObject: 2065, + size: { top: 2259, left: 3232, width: 288, height: 197 }, + onClick: expect.any(Function), + }, + { + idObject: 2066, + size: { top: 761, left: 4205, width: 420, height: 341 }, + onClick: expect.any(Function), + }, + { + idObject: 2067, + size: { top: 1971, left: 4960, width: 281, height: 192 }, + onClick: expect.any(Function), + }, + ]); + }); + }); +}); diff --git a/src/components/FunctionalArea/Modal/OverviewImagesModal/utils/useOverviewImageLinkElements.tsx b/src/components/FunctionalArea/Modal/OverviewImagesModal/utils/useOverviewImageLinkElements.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6e0150dc37c986fb81881503c9ec81bb7321f691 --- /dev/null +++ b/src/components/FunctionalArea/Modal/OverviewImagesModal/utils/useOverviewImageLinkElements.tsx @@ -0,0 +1,25 @@ +import { ZERO } from '@/constants/common'; +import { currentOverviewImageSelector } from '@/redux/project/project.selectors'; +import { useSelector } from 'react-redux'; +import { OverviewImageLinkConfig } from '../OverviewImageModal.types'; +import { getOverviewImageLinkSize } from './getOverviewImageLinkSize'; +import { useOverviewImageLinkActions } from './useOverviewImageLinkActions'; + +interface UseOverviewImageLinksArgs { + sizeFactor: number; +} + +export const useOverviewImageLinkConfigs = ({ + sizeFactor, +}: UseOverviewImageLinksArgs): OverviewImageLinkConfig[] => { + const { handleLinkClick } = useOverviewImageLinkActions(); + const currentImage = useSelector(currentOverviewImageSelector); + + if (!currentImage || sizeFactor === ZERO) return []; + + return currentImage.links.map(link => ({ + idObject: link.idObject, + size: getOverviewImageLinkSize(link, { sizeFactor }), + onClick: () => handleLinkClick(link), + })); +}; diff --git a/src/components/Map/MapViewer/utils/config/overlaysLayer/getColorByAvailableProperties.ts b/src/components/Map/MapViewer/utils/config/overlaysLayer/getColorByAvailableProperties.ts index b7ac985fcb8f3f6328b5d72e7f9f7e66faffe07d..6cbc988306822a2bcd1bd2a4cc1aabbc7d24b4f4 100644 --- a/src/components/Map/MapViewer/utils/config/overlaysLayer/getColorByAvailableProperties.ts +++ b/src/components/Map/MapViewer/utils/config/overlaysLayer/getColorByAvailableProperties.ts @@ -1,7 +1,7 @@ import { ZERO } from '@/constants/common'; import type { GetHex3ColorGradientColorWithAlpha } from '@/hooks/useTriColorLerp'; import { OverlayBioEntityRender } from '@/types/OLrendering'; -import { convertDecimalToHex } from '@/utils/convert/convertDecimalToHex'; +import { convertDecimalToHexColor } from '@/utils/convert/convertDecimalToHex'; export const getColorByAvailableProperties = ( entity: OverlayBioEntityRender, @@ -12,7 +12,7 @@ export const getColorByAvailableProperties = ( return getHexTricolorGradientColorWithAlpha(entity.value || ZERO); } if (entity.color) { - return convertDecimalToHex(entity.color.rgb); + return convertDecimalToHexColor(entity.color.rgb); } return defaultColor; }; diff --git a/src/components/Map/MapViewer/utils/config/useOlMapView.test.ts b/src/components/Map/MapViewer/utils/config/useOlMapView.test.ts index 2832e68da8c053073a8d9bf713a99251c4e42a6a..48b643ceab96db55df795a156aa6880fa3ac3e53 100644 --- a/src/components/Map/MapViewer/utils/config/useOlMapView.test.ts +++ b/src/components/Map/MapViewer/utils/config/useOlMapView.test.ts @@ -53,12 +53,8 @@ describe('useOlMapView - util', () => { await act(() => { store.dispatch( setMapPosition({ - position: { - initial: { - x: 0, - y: 0, - }, - }, + x: 0, + y: 0, }), ); }); diff --git a/src/components/Map/MapViewer/utils/listeners/onMapPositionChange.ts b/src/components/Map/MapViewer/utils/listeners/onMapPositionChange.ts index 482f0509d7bbdd3b3da128a3ce3638c818302d12..8bdf55b6e8614a029db153428d9f7defd248b114 100644 --- a/src/components/Map/MapViewer/utils/listeners/onMapPositionChange.ts +++ b/src/components/Map/MapViewer/utils/listeners/onMapPositionChange.ts @@ -16,13 +16,9 @@ export const onMapPositionChange = dispatch( setMapPosition({ - position: { - last: { - x, - y, - z: Math.round(zoom), - } - } + x, + y, + z: Math.round(zoom), }), ); }; diff --git a/src/constants/common.ts b/src/constants/common.ts index 31d51626019883c2e2120a297e125b5580ddf21c..00220963e428965dbee6767b1715359ac15bbd02 100644 --- a/src/constants/common.ts +++ b/src/constants/common.ts @@ -1,4 +1,5 @@ export const SIZE_OF_EMPTY_ARRAY = 0; +export const SIZE_OF_ARRAY_WITH_FOUR_ELEMENTS = 4; export const SIZE_OF_ARRAY_WITH_ONE_ELEMENT = 1; export const ZERO = 0; @@ -6,3 +7,7 @@ export const FIRST_ARRAY_ELEMENT = 0; export const ONE = 1; export const SECOND_ARRAY_ELEMENT = 1; + +export const THIRD_ARRAY_ELEMENT = 2; + +export const NOOP = (): void => {}; diff --git a/src/models/mocks/overviewImageMocks.ts b/src/models/mocks/overviewImageMocks.ts new file mode 100644 index 0000000000000000000000000000000000000000..999063908b5595dc0575d00eaaec26bc089f1713 --- /dev/null +++ b/src/models/mocks/overviewImageMocks.ts @@ -0,0 +1,21 @@ +import { PROJECT_OVERVIEW_IMAGE_MOCK } from '@/redux/project/project.mock'; +import { OverviewImageLinkImage, OverviewImageLinkModel } from '@/types/models'; + +export const OVERVIEW_LINK_IMAGE_MOCK: OverviewImageLinkImage = { + idObject: 1, + polygon: [], + imageLinkId: PROJECT_OVERVIEW_IMAGE_MOCK.idObject, + type: 'OverviewImageLink', +}; + +export const OVERVIEW_LINK_MODEL_MOCK: OverviewImageLinkModel = { + idObject: 1, + polygon: [], + zoomLevel: 5, + modelPoint: { + x: 15570.0, + y: 3016.0, + }, + modelLinkId: 5053, + type: 'OverviewImageLink', +}; diff --git a/src/models/overviewImageLink.ts b/src/models/overviewImageLink.ts index 7c31c710645441466fa82e65a036c9065c84d511..6a36667a3f7fd50cd76c5bc209ef06add6a6312c 100644 --- a/src/models/overviewImageLink.ts +++ b/src/models/overviewImageLink.ts @@ -1,19 +1,20 @@ import { z } from 'zod'; import { positionSchema } from './positionSchema'; -export const overviewImageLink = z.union([ - z.object({ - idObject: z.number(), - polygon: z.array(positionSchema), - imageLinkId: z.number(), - type: z.string(), - }), - z.object({ - idObject: z.number(), - polygon: z.array(positionSchema), - zoomLevel: z.number(), - modelPoint: positionSchema, - modelLinkId: z.number(), - type: z.string(), - }), -]); +export const overviewImageLinkImage = z.object({ + idObject: z.number(), + polygon: z.array(positionSchema), + imageLinkId: z.number(), + type: z.string(), +}); + +export const overviewImageLinkModel = z.object({ + idObject: z.number(), + polygon: z.array(positionSchema), + zoomLevel: z.number(), + modelPoint: positionSchema, + modelLinkId: z.number(), + type: z.string(), +}); + +export const overviewImageLink = z.union([overviewImageLinkImage, overviewImageLinkModel]); diff --git a/src/redux/map/map.reducers.ts b/src/redux/map/map.reducers.ts index ae325fb9127f901719094ef2883ce029581aede1..b17f1432b0cad6c95e8877e6970d2c6de5d501fa 100644 --- a/src/redux/map/map.reducers.ts +++ b/src/redux/map/map.reducers.ts @@ -1,5 +1,13 @@ -import { ActionReducerMapBuilder } from '@reduxjs/toolkit'; import { ZERO } from '@/constants/common'; +import { ActionReducerMapBuilder } from '@reduxjs/toolkit'; +import { getPointMerged } from '../../utils/object/getPointMerged'; +import { MAIN_MAP } from './map.constants'; +import { + initMapBackground, + initMapPosition, + initMapSizeAndModelId, + initOpenedMaps, +} from './map.thunks'; import { CloseMapAction, MapState, @@ -9,14 +17,6 @@ import { SetMapDataAction, SetMapPositionDataAction, } from './map.types'; -import { MAIN_MAP } from './map.constants'; -import { getPointMerged } from '../../utils/object/getPointMerged'; -import { - initMapBackground, - initMapPosition, - initMapSizeAndModelId, - initOpenedMaps, -} from './map.thunks'; export const setMapDataReducer = (state: MapState, action: SetMapDataAction): void => { const payload = action.payload || {}; @@ -28,15 +28,14 @@ export const setMapDataReducer = (state: MapState, action: SetMapDataAction): vo }; export const setMapPositionReducer = (state: MapState, action: SetMapPositionDataAction): void => { - const payload = action.payload || {}; - const payloadPosition = 'position' in payload ? payload.position : undefined; + const position = action.payload || {}; const statePosition = state.data.position; state.data = { ...state.data, position: { - initial: getPointMerged(payloadPosition?.initial || {}, statePosition.initial), - last: getPointMerged(payloadPosition?.last || {}, statePosition.last), + initial: getPointMerged(position || {}, statePosition.initial), + last: getPointMerged(position || {}, statePosition.last), }, }; }; diff --git a/src/redux/map/map.types.ts b/src/redux/map/map.types.ts index 9ed2e9129f3906fbce9393b8525a1a289827377c..16f5e54210814eca8c851769a049622b2b4057c1 100644 --- a/src/redux/map/map.types.ts +++ b/src/redux/map/map.types.ts @@ -80,9 +80,7 @@ export type GetUpdatedMapDataResult = Pick< 'modelId' | 'backgroundId' | 'size' | 'position' >; -export type SetMapPositionDataActionPayload = GetUpdatedMapDataResult | object; - -export type SetMapPositionDataAction = PayloadAction<SetMapPositionDataActionPayload>; +export type SetMapPositionDataAction = PayloadAction<Point>; export type InitMapDataActionPayload = { data: GetUpdatedMapDataResult | object; diff --git a/src/redux/map/middleware/map.middleware.ts b/src/redux/map/middleware/map.middleware.ts index 8083f40e940043bc116ec8cdcaca7f1bbeb233cc..8d0f4b3e54a016ff95899e88f9802a0d70ac9e4b 100644 --- a/src/redux/map/middleware/map.middleware.ts +++ b/src/redux/map/middleware/map.middleware.ts @@ -2,17 +2,17 @@ import { currentBackgroundSelector } from '@/redux/backgrounds/background.select import type { AppListenerEffectAPI, AppStartListening } from '@/redux/store'; import { getUpdatedMapData } from '@/utils/map/getUpdatedMapData'; import { Action, createListenerMiddleware, isAnyOf } from '@reduxjs/toolkit'; +import { mapOpenedMapPositionByIdSelector } from '../map.selectors'; import { + closeMapAndSetMainMapActive, openMapAndSetActive, setActiveMap, + setMapBackground, setMapData, setMapPosition, - closeMapAndSetMainMapActive, - setMapBackground, } from '../map.slice'; import { checkIfIsMapUpdateActionValid } from './checkIfIsMapUpdateActionValid'; import { getUpdatedModel } from './getUpdatedModel'; -import { mapOpenedMapPositionByIdSelector } from '../map.selectors'; export const mapListenerMiddleware = createListenerMiddleware(); @@ -39,7 +39,7 @@ export const mapDataMiddlewareListener = async ( background, }); dispatch(setMapData(updatedMapData)); - dispatch(setMapPosition(updatedMapData)); + dispatch(setMapPosition(updatedMapData.position.initial)); }; startListening({ diff --git a/src/redux/modal/modal.reducers.ts b/src/redux/modal/modal.reducers.ts index b5534465a8a26d95da67664a8425eeb9cc27840f..90759575d6c3ec73246a2d402d7ed5489875f3b7 100644 --- a/src/redux/modal/modal.reducers.ts +++ b/src/redux/modal/modal.reducers.ts @@ -23,3 +23,12 @@ export const openOverviewImagesModalByIdReducer = ( imageId: action.payload, }; }; + +export const setOverviewImageIdReducer = ( + state: ModalState, + action: PayloadAction<number>, +): void => { + state.overviewImagesState = { + imageId: action.payload, + }; +}; diff --git a/src/redux/modal/modal.selector.ts b/src/redux/modal/modal.selector.ts index c233d253a934a2ef5be26cf88a3c6df7a9e8e61f..2036f8576d34d866e987f6d1bdee5c88d3b7b869 100644 --- a/src/redux/modal/modal.selector.ts +++ b/src/redux/modal/modal.selector.ts @@ -6,7 +6,7 @@ export const modalSelector = createSelector(rootSelector, state => state.modal); export const isModalOpenSelector = createSelector(modalSelector, state => state.isOpen); -export const currentOverviewImageId = createSelector( +export const currentOverviewImageIdSelector = createSelector( modalSelector, modal => modal?.overviewImagesState.imageId || OVERVIEW_IMAGE_ID_DEFAULT, ); diff --git a/src/redux/modal/modal.slice.ts b/src/redux/modal/modal.slice.ts index a5ae179621048583f838a2427fe399b88c236913..3dbe1970fb135799870640bf00c38f3ab5c9a080 100644 --- a/src/redux/modal/modal.slice.ts +++ b/src/redux/modal/modal.slice.ts @@ -4,6 +4,7 @@ import { closeModalReducer, openModalReducer, openOverviewImagesModalByIdReducer, + setOverviewImageIdReducer, } from './modal.reducers'; const modalSlice = createSlice({ @@ -13,9 +14,11 @@ const modalSlice = createSlice({ openModal: openModalReducer, closeModal: closeModalReducer, openOverviewImagesModalById: openOverviewImagesModalByIdReducer, + setOverviewImageId: setOverviewImageIdReducer, }, }); -export const { openModal, closeModal, openOverviewImagesModalById } = modalSlice.actions; +export const { openModal, closeModal, openOverviewImagesModalById, setOverviewImageId } = + modalSlice.actions; export default modalSlice.reducer; diff --git a/src/redux/project/project.selectors.ts b/src/redux/project/project.selectors.ts index 970ef322a089277a96da126713a463695a483a04..610a6cce94495eb86214761dc84c5bc185a56609 100644 --- a/src/redux/project/project.selectors.ts +++ b/src/redux/project/project.selectors.ts @@ -1,7 +1,7 @@ import { OverviewImageView } from '@/types/models'; import { createSelector } from '@reduxjs/toolkit'; import { OVERVIEW_IMAGE_ID_DEFAULT } from '../map/map.constants'; -import { currentOverviewImageId } from '../modal/modal.selector'; +import { currentOverviewImageIdSelector } from '../modal/modal.selector'; import { rootSelector } from '../root/root.selectors'; export const projectSelector = createSelector(rootSelector, state => state.project); @@ -15,13 +15,18 @@ export const projectDefaultOverviewImageIdSelector = createSelector( export const currentOverviewImageSelector = createSelector( projectDataSelector, - currentOverviewImageId, + currentOverviewImageIdSelector, (projectData, imageId): OverviewImageView | undefined => (projectData?.overviewImageViews || []).find( overviewImage => overviewImage.idObject === imageId, ), ); +export const projectOverviewImagesSelector = createSelector( + projectDataSelector, + (projectData): OverviewImageView[] => projectData?.overviewImageViews || [], +); + export const projectDirectorySelector = createSelector( projectDataSelector, projectData => projectData?.directory, diff --git a/src/types/models.ts b/src/types/models.ts index 017169d84d671d555470f779311992388fb5ccda..f01e8f5f79369a2049c5c3899e2f9fdae934596c 100644 --- a/src/types/models.ts +++ b/src/types/models.ts @@ -12,6 +12,11 @@ import { mapOverlay } from '@/models/mapOverlay'; import { mapModelSchema } from '@/models/modelSchema'; import { organism } from '@/models/organism'; import { overlayBioEntitySchema } from '@/models/overlayBioEntitySchema'; +import { + overviewImageLink, + overviewImageLinkImage, + overviewImageLinkModel, +} from '@/models/overviewImageLink'; import { overviewImageView } from '@/models/overviewImageView'; import { projectSchema } from '@/models/project'; import { reactionSchema } from '@/models/reaction'; @@ -22,6 +27,9 @@ import { z } from 'zod'; export type Project = z.infer<typeof projectSchema>; export type OverviewImageView = z.infer<typeof overviewImageView>; +export type OverviewImageLink = z.infer<typeof overviewImageLink>; +export type OverviewImageLinkImage = z.infer<typeof overviewImageLinkImage>; +export type OverviewImageLinkModel = z.infer<typeof overviewImageLinkModel>; export type MapModel = z.infer<typeof mapModelSchema>; export type MapOverlay = z.infer<typeof mapOverlay>; export type MapBackground = z.infer<typeof mapBackground>;