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/Drawer/OverlaysDrawer/GeneralOverlays/GeneralOverlays.component.tsx b/src/components/Map/Drawer/OverlaysDrawer/GeneralOverlays/GeneralOverlays.component.tsx index c398dc343041ec6a6df218691482d81ed310e3a9..faa6192e3cef1e0f0cd03e7cec93a51fb5497dfa 100644 --- a/src/components/Map/Drawer/OverlaysDrawer/GeneralOverlays/GeneralOverlays.component.tsx +++ b/src/components/Map/Drawer/OverlaysDrawer/GeneralOverlays/GeneralOverlays.component.tsx @@ -10,7 +10,11 @@ export const GeneralOverlays = (): JSX.Element => { <p className="mb-5 text-sm font-semibold">General Overlays:</p> <ul> {generalPublicOverlays.map(overlay => ( - <OverlayListItem key={overlay.idObject} name={overlay.name} /> + <OverlayListItem + key={overlay.idObject} + name={overlay.name} + overlayId={overlay.idObject} + /> ))} </ul> </div> diff --git a/src/components/Map/Drawer/OverlaysDrawer/GeneralOverlays/OverlayListItem/OverlayListItem.component.test.tsx b/src/components/Map/Drawer/OverlaysDrawer/GeneralOverlays/OverlayListItem/OverlayListItem.component.test.tsx index 2ae9126e2c73e07ea4345317757dff1eb53426ac..dd3cc6eaab9b49e7ce522f2b28f833d29d3f1bca 100644 --- a/src/components/Map/Drawer/OverlaysDrawer/GeneralOverlays/OverlayListItem/OverlayListItem.component.test.tsx +++ b/src/components/Map/Drawer/OverlaysDrawer/GeneralOverlays/OverlayListItem/OverlayListItem.component.test.tsx @@ -1,18 +1,35 @@ import { StoreType } from '@/redux/store'; -import { render, screen } from '@testing-library/react'; +import { act, render, screen } from '@testing-library/react'; import { InitialStoreState, getReduxWrapperWithStore, } from '@/utils/testing/getReduxWrapperWithStore'; +import { + BACKGROUNDS_MOCK, + BACKGROUND_INITIAL_STATE_MOCK, +} from '@/redux/backgrounds/background.mock'; +import { initialMapStateFixture } from '@/redux/map/map.fixtures'; +import { mockNetworkNewAPIResponse } from '@/utils/mockNetworkResponse'; +import { OVERLAY_BIO_ENTITY_INITIAL_STATE_MOCK } from '@/redux/overlayBioEntity/overlayBioEntity.mock'; +import { HttpStatusCode } from 'axios'; +import { overlayBioEntityFixture } from '@/models/fixtures/overlayBioEntityFixture'; +import { apiPath } from '@/redux/apiPath'; +import { CORE_PD_MODEL_MOCK } from '@/models/mocks/modelsMock'; +import { MODELS_INITIAL_STATE_MOCK } from '@/redux/models/models.mock'; +import { parseOverlayBioEntityToOlRenderingFormat } from '@/redux/overlayBioEntity/overlayBioEntity.utils'; import { OverlayListItem } from './OverlayListItem.component'; +const mockedAxiosNewClient = mockNetworkNewAPIResponse(); +const DEFAULT_BACKGROUND_ID = 0; +const EMPTY_BACKGROUND_ID = 15; + const renderComponent = (initialStoreState: InitialStoreState = {}): { store: StoreType } => { const { Wrapper, store } = getReduxWrapperWithStore(initialStoreState); return ( render( <Wrapper> - <OverlayListItem name="Ageing brain" /> + <OverlayListItem name="Ageing brain" overlayId={21} /> </Wrapper>, ), { @@ -29,8 +46,31 @@ describe('OverlayListItem - component', () => { expect(screen.getByRole('button', { name: 'View' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Download' })).toBeInTheDocument(); }); - // TODO implement when connecting logic to component - it.skip('should trigger view overlays on view button click', () => {}); + + it('should trigger view overlays on view button click and switch background to Empty if available', async () => { + const OVERLAY_ID = 21; + const { store } = renderComponent({ + map: initialMapStateFixture, + backgrounds: { ...BACKGROUND_INITIAL_STATE_MOCK, data: BACKGROUNDS_MOCK }, + overlayBioEntity: OVERLAY_BIO_ENTITY_INITIAL_STATE_MOCK, + models: { ...MODELS_INITIAL_STATE_MOCK, data: [CORE_PD_MODEL_MOCK] }, + }); + mockedAxiosNewClient + .onGet(apiPath.getOverlayBioEntity({ overlayId: OVERLAY_ID, modelId: 5053 })) + .reply(HttpStatusCode.Ok, overlayBioEntityFixture); + + expect(store.getState().map.data.backgroundId).toBe(DEFAULT_BACKGROUND_ID); + + const ViewButton = screen.getByRole('button', { name: 'View' }); + await act(() => { + ViewButton.click(); + }); + + expect(store.getState().map.data.backgroundId).toBe(EMPTY_BACKGROUND_ID); + expect(store.getState().overlayBioEntity.data).toEqual( + parseOverlayBioEntityToOlRenderingFormat(overlayBioEntityFixture, OVERLAY_ID), + ); + }); // TODO implement when connecting logic to component it.skip('should trigger download overlay to PC on download button click', () => {}); }); diff --git a/src/components/Map/Drawer/OverlaysDrawer/GeneralOverlays/OverlayListItem/OverlayListItem.component.tsx b/src/components/Map/Drawer/OverlaysDrawer/GeneralOverlays/OverlayListItem/OverlayListItem.component.tsx index a29195c79b88436e94ca026360d3e8d6da976d4e..20f173fe7eb91fc7cf480e7cab43a30bc0a0187e 100644 --- a/src/components/Map/Drawer/OverlaysDrawer/GeneralOverlays/OverlayListItem/OverlayListItem.component.tsx +++ b/src/components/Map/Drawer/OverlaysDrawer/GeneralOverlays/OverlayListItem/OverlayListItem.component.tsx @@ -1,12 +1,22 @@ +import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; +import { getOverlayBioEntityForAllModels } from '@/redux/overlayBioEntity/overlayBioEntity.thunk'; import { Button } from '@/shared/Button'; +import { useEmptyBackground } from './hooks/useEmptyBackground'; interface OverlayListItemProps { name: string; + overlayId: number; } -export const OverlayListItem = ({ name }: OverlayListItemProps): JSX.Element => { - const onViewOverlay = (): void => {}; +export const OverlayListItem = ({ name, overlayId }: OverlayListItemProps): JSX.Element => { const onDownloadOverlay = (): void => {}; + const dispatch = useAppDispatch(); + const { setBackgroundtoEmptyIfAvailable } = useEmptyBackground(); + + const onViewOverlay = (): void => { + setBackgroundtoEmptyIfAvailable(); + dispatch(getOverlayBioEntityForAllModels({ overlayId })); + }; return ( <li className="flex flex-row flex-nowrap justify-between pl-5 [&:not(:last-of-type)]:mb-4"> diff --git a/src/components/Map/Drawer/OverlaysDrawer/GeneralOverlays/OverlayListItem/hooks/useEmptyBackground.test.ts b/src/components/Map/Drawer/OverlaysDrawer/GeneralOverlays/OverlayListItem/hooks/useEmptyBackground.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..9c58dfc3baa4207cb6af954a6662ddeb0a16ea92 --- /dev/null +++ b/src/components/Map/Drawer/OverlaysDrawer/GeneralOverlays/OverlayListItem/hooks/useEmptyBackground.test.ts @@ -0,0 +1,43 @@ +import { getReduxWrapperWithStore } from '@/utils/testing/getReduxWrapperWithStore'; +import { initialMapStateFixture } from '@/redux/map/map.fixtures'; +import { + BACKGROUNDS_MOCK, + BACKGROUND_INITIAL_STATE_MOCK, +} from '@/redux/backgrounds/background.mock'; +import { renderHook } from '@testing-library/react'; +import { useEmptyBackground } from './useEmptyBackground'; + +const DEFAULT_BACKGROUND_ID = 0; +const EMPTY_BACKGROUND_ID = 15; + +describe('useEmptyBackground - hook', () => { + describe('returns setEmptyBackground function', () => { + it('should not set background to "Empty" if its not available', () => { + const { Wrapper, store } = getReduxWrapperWithStore({ + map: initialMapStateFixture, + backgrounds: BACKGROUND_INITIAL_STATE_MOCK, + }); + const { result } = renderHook(() => useEmptyBackground(), { wrapper: Wrapper }); + + expect(store.getState().map.data.backgroundId).toBe(DEFAULT_BACKGROUND_ID); + + result.current.setBackgroundtoEmptyIfAvailable(); + + expect(store.getState().map.data.backgroundId).toBe(DEFAULT_BACKGROUND_ID); + }); + + it('should set background to "Empty" if its available', () => { + const { Wrapper, store } = getReduxWrapperWithStore({ + map: initialMapStateFixture, + backgrounds: { ...BACKGROUND_INITIAL_STATE_MOCK, data: BACKGROUNDS_MOCK }, + }); + const { result } = renderHook(() => useEmptyBackground(), { wrapper: Wrapper }); + + expect(store.getState().map.data.backgroundId).toBe(DEFAULT_BACKGROUND_ID); + + result.current.setBackgroundtoEmptyIfAvailable(); + + expect(store.getState().map.data.backgroundId).toBe(EMPTY_BACKGROUND_ID); + }); + }); +}); diff --git a/src/components/Map/Drawer/OverlaysDrawer/GeneralOverlays/OverlayListItem/hooks/useEmptyBackground.ts b/src/components/Map/Drawer/OverlaysDrawer/GeneralOverlays/OverlayListItem/hooks/useEmptyBackground.ts new file mode 100644 index 0000000000000000000000000000000000000000..2536fa84e049dd7dc659040a96933d3287639436 --- /dev/null +++ b/src/components/Map/Drawer/OverlaysDrawer/GeneralOverlays/OverlayListItem/hooks/useEmptyBackground.ts @@ -0,0 +1,22 @@ +import { useCallback } from 'react'; +import { emptyBackgroundIdSelector } from '@/redux/backgrounds/background.selectors'; +import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; +import { useAppSelector } from '@/redux/hooks/useAppSelector'; +import { setMapBackground } from '@/redux/map/map.slice'; + +type UseEmptyBackgroundReturn = { + setBackgroundtoEmptyIfAvailable: () => void; +}; + +export const useEmptyBackground = (): UseEmptyBackgroundReturn => { + const dispatch = useAppDispatch(); + const emptyBackgroundId = useAppSelector(emptyBackgroundIdSelector); + + const setBackgroundtoEmptyIfAvailable = useCallback(() => { + if (emptyBackgroundId) { + dispatch(setMapBackground(emptyBackgroundId)); + } + }, [dispatch, emptyBackgroundId]); + + return { setBackgroundtoEmptyIfAvailable }; +}; diff --git a/src/components/Map/Drawer/SearchDrawerWrapper/ResultsList/PinsList/PinsList.types.tsx b/src/components/Map/Drawer/SearchDrawerWrapper/ResultsList/PinsList/PinsList.types.tsx index f9707cb23f573f4e7f5ed6466b60509a844c1a78..74d6fe343e329624a3cd16fe827787a090acd756 100644 --- a/src/components/Map/Drawer/SearchDrawerWrapper/ResultsList/PinsList/PinsList.types.tsx +++ b/src/components/Map/Drawer/SearchDrawerWrapper/ResultsList/PinsList/PinsList.types.tsx @@ -8,3 +8,9 @@ export type PinItem = { }; export type PinTypeWithNone = PinType | 'none'; + +export type AvailableSubmaps = { + id: number; + modelId: number; + name: string; +}; 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 edf080098c2c65d28d96338ba5370ed11ffa7e1d..fdc457a0a2a6b776dcf6fb1381e79468c8624c4f 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 @@ -9,6 +9,7 @@ import { getReduxWrapperWithStore, } from '@/utils/testing/getReduxWrapperWithStore'; import { render, screen } from '@testing-library/react'; +// import { MODELS_MOCK_SHORT } from '@/models/mocks/modelsMock'; import { PinTypeWithNone } from '../PinsList.types'; import { PinsListItem } from './PinsListItem.component'; @@ -77,7 +78,7 @@ describe('PinsListItem - component ', () => { expect(screen.getByText(secondPinReferenceResource, { exact: false })).toBeInTheDocument(); }); it('should display list of elements for pin for chemicals', () => { - renderComponent(CHEMICALS_PIN.name, CHEMICALS_PIN.pin, 'drugs'); + renderComponent(CHEMICALS_PIN.name, CHEMICALS_PIN.pin, 'chemicals'); const firstPinElementType = chemicalsFixture[0].targets[0].targetParticipants[0].type; const firstPinElementResource = chemicalsFixture[0].targets[0].targetParticipants[0].resource; @@ -100,4 +101,14 @@ describe('PinsListItem - component ', () => { expect(screen.queryByText(bioEntityName, { exact: false })).not.toBeInTheDocument(); }); + it("should not display list of available submaps for pin when there aren't any submaps", () => { + const chemicalWithoutSubmaps = { + ...CHEMICALS_PIN.pin, + targetElements: [], + }; + + renderComponent(CHEMICALS_PIN.name, chemicalWithoutSubmaps, 'chemicals'); + + expect(screen.queryByText('Available in submaps:')).toBeNull(); + }); }); 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 88bdb4bda775221cb2dc0ad3fbe4a5cb0d0da651..d87156efe2be89368063d16df26f41b49ced4d48 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,8 +1,14 @@ import { Icon } from '@/shared/Icon'; import { PinDetailsItem } from '@/types/models'; import { twMerge } from 'tailwind-merge'; -import { PinTypeWithNone } from '../PinsList.types'; -import { getPinColor } from './PinsListItem.component.utils'; +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 { getListOfAvailableSubmaps, getPinColor } from './PinsListItem.component.utils'; +import { AvailableSubmaps, PinTypeWithNone } from '../PinsList.types'; interface PinsListItemProps { name: string; @@ -11,6 +17,22 @@ interface PinsListItemProps { } export const PinsListItem = ({ name, type, pin }: PinsListItemProps): JSX.Element => { + const dispatch = useAppDispatch(); + const openedMaps = useAppSelector(mapOpenedMapsSelector); + const models = useAppSelector(modelsDataSelector); + const availableSubmaps = getListOfAvailableSubmaps(pin, models); + + const isMapAlreadyOpened = (modelId: number): boolean => + openedMaps.some(map => map.modelId === modelId); + + const onSubmapClick = (map: AvailableSubmaps): void => { + if (isMapAlreadyOpened(map.modelId)) { + dispatch(setActiveMap({ modelId: map.modelId })); + } else { + dispatch(openMapAndSetActive({ modelId: map.modelId, modelName: map.name })); + } + }; + 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"> @@ -52,6 +74,23 @@ export const PinsListItem = ({ name, type, pin }: PinsListItemProps): JSX.Elemen ); })} </ul> + {availableSubmaps.length > SIZE_OF_EMPTY_ARRAY && ( + <ul className="leading-6"> + <div className="mb-2 font-bold">Available in submaps:</div> + {availableSubmaps.map(submap => { + return ( + <button + onClick={(): void => onSubmapClick(submap)} + className="mb-2 mr-2 rounded border border-solid border-greyscale-500 p-2 font-normal text-[#6A6977] hover:border-[#6A6977]" + type="button" + key={submap.id} + > + {submap.name} + </button> + ); + })} + </ul> + )} </div> ); }; diff --git a/src/components/Map/Drawer/SearchDrawerWrapper/ResultsList/PinsList/PinsListItem/PinsListItem.component.utils.ts b/src/components/Map/Drawer/SearchDrawerWrapper/ResultsList/PinsList/PinsListItem/PinsListItem.component.utils.ts index 49c0547a9302b1b64f28e8822a5ade3e8a6a6654..f0775c25e6de370a0e0ace771cf6729bff5f4658 100644 --- a/src/components/Map/Drawer/SearchDrawerWrapper/ResultsList/PinsList/PinsListItem/PinsListItem.component.utils.ts +++ b/src/components/Map/Drawer/SearchDrawerWrapper/ResultsList/PinsList/PinsListItem/PinsListItem.component.utils.ts @@ -1,4 +1,7 @@ -import { PinTypeWithNone } from '../PinsList.types'; +import { MapModel, PinDetailsItem } from '@/types/models'; +import { AvailableSubmaps, PinTypeWithNone } from '../PinsList.types'; + +const MAIN_MAP_ID = 52; export const getPinColor = (type: PinTypeWithNone): string => { const pinColors: Record<PinTypeWithNone, string> = { @@ -10,3 +13,27 @@ export const getPinColor = (type: PinTypeWithNone): string => { return pinColors[type]; }; + +export const getListOfAvailableSubmaps = ( + pin: PinDetailsItem, + models: MapModel[], +): AvailableSubmaps[] => { + const submaps = pin.targetElements.filter((element, index) => { + return ( + index === + pin.targetElements.findIndex(o => element.model === o.model && element.model !== MAIN_MAP_ID) + ); + }); + + const availableSubmaps = submaps.map(submap => { + const data: AvailableSubmaps = { + id: submap.id, + modelId: submap.model, + name: models.find(model => model.idObject === submap.model)?.name || '', + }; + + return data; + }); + + return availableSubmaps; +}; diff --git a/src/components/Map/MapAdditionalOptions/MapAdditionalOptions.component.test.tsx b/src/components/Map/MapAdditionalOptions/MapAdditionalOptions.component.test.tsx index 4fddb0e309317922694a548ab88f3ecb5422d23a..19571c28cdedc3cc2b49845b5f60e0f9a53f0350 100644 --- a/src/components/Map/MapAdditionalOptions/MapAdditionalOptions.component.test.tsx +++ b/src/components/Map/MapAdditionalOptions/MapAdditionalOptions.component.test.tsx @@ -68,8 +68,8 @@ describe('MapAdditionalOptions - component', () => { const actions = store.getActions(); expect(actions[FIRST_ARRAY_ELEMENT]).toStrictEqual({ - payload: undefined, - type: 'modal/openOverviewImagesModal', + payload: 0, + type: 'modal/openOverviewImagesModalById', }); }); }); diff --git a/src/components/Map/MapViewer/utils/config/overlaysLayer/createOverlayGeometryFeature.test.ts b/src/components/Map/MapViewer/utils/config/overlaysLayer/createOverlayGeometryFeature.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..8ee122219c608c7e6bedd00e1fd1e269e54255b1 --- /dev/null +++ b/src/components/Map/MapViewer/utils/config/overlaysLayer/createOverlayGeometryFeature.test.ts @@ -0,0 +1,49 @@ +import { createOverlayGeometryFeature } from './createOverlayGeometryFeature'; + +describe('createOverlayGeometryFeature', () => { + it('should create a feature with the correct geometry and style', () => { + const xMin = 0; + const yMin = 0; + const xMax = 10; + const yMax = 10; + const colorHexString = '#FF0000'; + + const feature = createOverlayGeometryFeature([xMin, yMin, xMax, yMax], colorHexString); + + expect(feature.getGeometry()!.getCoordinates()).toEqual([ + [ + [xMin, yMin], + [xMin, yMax], + [xMax, yMax], + [xMax, yMin], + [xMin, yMin], + ], + ]); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - getStyle() is not typed + expect(feature.getStyle().getFill().getColor()).toEqual(colorHexString); + }); + + it('should create a feature with the correct geometry and style when using a different color', () => { + const xMin = -5; + const yMin = -5; + const xMax = 5; + const yMax = 5; + const colorHexString = '#00FF00'; + + const feature = createOverlayGeometryFeature([xMin, yMin, xMax, yMax], colorHexString); + + expect(feature.getGeometry()!.getCoordinates()).toEqual([ + [ + [xMin, yMin], + [xMin, yMax], + [xMax, yMax], + [xMax, yMin], + [xMin, yMin], + ], + ]); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - getStyle() is not typed + expect(feature.getStyle().getFill().getColor()).toEqual(colorHexString); + }); +}); diff --git a/src/components/Map/MapViewer/utils/config/overlaysLayer/createOverlayGeometryFeature.ts b/src/components/Map/MapViewer/utils/config/overlaysLayer/createOverlayGeometryFeature.ts new file mode 100644 index 0000000000000000000000000000000000000000..90887721e354c6af5a68d1368cc74ee74d86181f --- /dev/null +++ b/src/components/Map/MapViewer/utils/config/overlaysLayer/createOverlayGeometryFeature.ts @@ -0,0 +1,13 @@ +import { Fill, Style } from 'ol/style'; +import { fromExtent } from 'ol/geom/Polygon'; +import Feature from 'ol/Feature'; +import type Polygon from 'ol/geom/Polygon'; + +export const createOverlayGeometryFeature = ( + [xMin, yMin, xMax, yMax]: number[], + color: string, +): Feature<Polygon> => { + const feature = new Feature({ geometry: fromExtent([xMin, yMin, xMax, yMax]) }); + feature.setStyle(new Style({ fill: new Fill({ color }) })); + return feature; +}; diff --git a/src/components/Map/MapViewer/utils/config/overlaysLayer/getColorByAvailableProperties.test.ts b/src/components/Map/MapViewer/utils/config/overlaysLayer/getColorByAvailableProperties.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..6b71e37ee7f09fe9fb74e4aab6a6e9bc7bf420a0 --- /dev/null +++ b/src/components/Map/MapViewer/utils/config/overlaysLayer/getColorByAvailableProperties.test.ts @@ -0,0 +1,73 @@ +import { OverlayBioEntityRender } from '@/types/OLrendering'; +import { getColorByAvailableProperties } from './getColorByAvailableProperties'; + +describe('getColorByAvailableProperties', () => { + const ENTITY: OverlayBioEntityRender = { + id: 0, + modelId: 0, + x1: 0, + y1: 0, + x2: 0, + y2: 0, + width: 0, + height: 0, + value: null, + overlayId: 0, + color: null, + }; + + const getHexTricolorGradientColorWithAlpha = jest.fn().mockReturnValue('#FFFFFF'); + const defaultColor = '#000000'; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return the result of getHexTricolorGradientColorWithAlpha if entity has a value equal to 0', () => { + const entity = { ...ENTITY, value: 0 }; + const result = getColorByAvailableProperties( + entity, + getHexTricolorGradientColorWithAlpha, + defaultColor, + ); + + expect(result).toEqual('#FFFFFF'); + expect(getHexTricolorGradientColorWithAlpha).toHaveBeenCalledWith(entity.value); + }); + + it('should return the result of getHexTricolorGradientColorWithAlpha if entity has a value', () => { + const entity = { ...ENTITY, value: -0.2137 }; + const result = getColorByAvailableProperties( + entity, + getHexTricolorGradientColorWithAlpha, + defaultColor, + ); + + expect(result).toEqual('#FFFFFF'); + expect(getHexTricolorGradientColorWithAlpha).toHaveBeenCalledWith(entity.value); + }); + + it('should return the result of convertDecimalToHex if entity has a color', () => { + const entity = { ...ENTITY, color: { rgb: -65536, alpha: 0 } }; // red color + + const result = getColorByAvailableProperties( + entity, + getHexTricolorGradientColorWithAlpha, + defaultColor, + ); + + expect(result).toEqual('#ff0000'); + expect(getHexTricolorGradientColorWithAlpha).not.toHaveBeenCalled(); + }); + + it('should return the default color if entity has neither a value nor a color', () => { + const result = getColorByAvailableProperties( + ENTITY, + getHexTricolorGradientColorWithAlpha, + defaultColor, + ); + + expect(result).toEqual('#000000'); + expect(getHexTricolorGradientColorWithAlpha).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/Map/MapViewer/utils/config/overlaysLayer/getColorByAvailableProperties.ts b/src/components/Map/MapViewer/utils/config/overlaysLayer/getColorByAvailableProperties.ts new file mode 100644 index 0000000000000000000000000000000000000000..6cbc988306822a2bcd1bd2a4cc1aabbc7d24b4f4 --- /dev/null +++ b/src/components/Map/MapViewer/utils/config/overlaysLayer/getColorByAvailableProperties.ts @@ -0,0 +1,18 @@ +import { ZERO } from '@/constants/common'; +import type { GetHex3ColorGradientColorWithAlpha } from '@/hooks/useTriColorLerp'; +import { OverlayBioEntityRender } from '@/types/OLrendering'; +import { convertDecimalToHexColor } from '@/utils/convert/convertDecimalToHex'; + +export const getColorByAvailableProperties = ( + entity: OverlayBioEntityRender, + getHexTricolorGradientColorWithAlpha: GetHex3ColorGradientColorWithAlpha, + defaultColor: string, +): string => { + if (typeof entity.value === 'number') { + return getHexTricolorGradientColorWithAlpha(entity.value || ZERO); + } + if (entity.color) { + return convertDecimalToHexColor(entity.color.rgb); + } + return defaultColor; +}; diff --git a/src/components/Map/MapViewer/utils/config/overlaysLayer/getOverlayFeatures.ts b/src/components/Map/MapViewer/utils/config/overlaysLayer/getOverlayFeatures.ts new file mode 100644 index 0000000000000000000000000000000000000000..a2a7fb430edee5f165119643802bda01c8477cce --- /dev/null +++ b/src/components/Map/MapViewer/utils/config/overlaysLayer/getOverlayFeatures.ts @@ -0,0 +1,30 @@ +import type { GetHex3ColorGradientColorWithAlpha } from '@/hooks/useTriColorLerp'; +import { OverlayBioEntityRender } from '@/types/OLrendering'; +import { UsePointToProjectionResult } from '@/utils/map/usePointToProjection'; +import type Feature from 'ol/Feature'; +import type Polygon from 'ol/geom/Polygon'; +import { createOverlayGeometryFeature } from './createOverlayGeometryFeature'; +import { getColorByAvailableProperties } from './getColorByAvailableProperties'; + +type GetOverlayFeaturesProps = { + bioEntities: OverlayBioEntityRender[]; + pointToProjection: UsePointToProjectionResult; + getHex3ColorGradientColorWithAlpha: GetHex3ColorGradientColorWithAlpha; + defaultColor: string; +}; + +export const getOverlayFeatures = ({ + bioEntities, + pointToProjection, + getHex3ColorGradientColorWithAlpha, + defaultColor, +}: GetOverlayFeaturesProps): Feature<Polygon>[] => + bioEntities.map(entity => + createOverlayGeometryFeature( + [ + ...pointToProjection({ x: entity.x1, y: entity.y1 }), + ...pointToProjection({ x: entity.x2, y: entity.y2 }), + ], + getColorByAvailableProperties(entity, getHex3ColorGradientColorWithAlpha, defaultColor), + ), + ); diff --git a/src/components/Map/MapViewer/utils/config/overlaysLayer/useOlMapOverlaysLayer.ts b/src/components/Map/MapViewer/utils/config/overlaysLayer/useOlMapOverlaysLayer.ts new file mode 100644 index 0000000000000000000000000000000000000000..830dc4984d814080ea1cf5e9e55104a43dad3406 --- /dev/null +++ b/src/components/Map/MapViewer/utils/config/overlaysLayer/useOlMapOverlaysLayer.ts @@ -0,0 +1,43 @@ +import Geometry from 'ol/geom/Geometry'; +import VectorLayer from 'ol/layer/Vector'; +import VectorSource from 'ol/source/Vector'; +import { useMemo } from 'react'; +import { usePointToProjection } from '@/utils/map/usePointToProjection'; +import { useTriColorLerp } from '@/hooks/useTriColorLerp'; +import { useAppSelector } from '@/redux/hooks/useAppSelector'; +import { overlayBioEntitiesForCurrentModelSelector } from '@/redux/overlayBioEntity/overlayBioEntity.selector'; +import { Feature } from 'ol'; +import { getOverlayFeatures } from './getOverlayFeatures'; + +export const useOlMapOverlaysLayer = (): VectorLayer<VectorSource<Feature<Geometry>>> => { + const pointToProjection = usePointToProjection(); + const { getHex3ColorGradientColorWithAlpha, defaultColorHex } = useTriColorLerp(); + const bioEntities = useAppSelector(overlayBioEntitiesForCurrentModelSelector); + + const features = useMemo( + () => + getOverlayFeatures({ + bioEntities, + pointToProjection, + getHex3ColorGradientColorWithAlpha, + defaultColor: defaultColorHex, + }), + [bioEntities, getHex3ColorGradientColorWithAlpha, pointToProjection, defaultColorHex], + ); + + const vectorSource = useMemo(() => { + return new VectorSource({ + features, + }); + }, [features]); + + const overlaysLayer = useMemo( + () => + new VectorLayer({ + source: vectorSource, + }), + [vectorSource], + ); + + return overlaysLayer; +}; diff --git a/src/components/Map/MapViewer/utils/config/reactionsLayer/useOlMapReactionsLayer.ts b/src/components/Map/MapViewer/utils/config/reactionsLayer/useOlMapReactionsLayer.ts index 3c4fbb6483e8b8bce865b2a5077846b28b637575..8b784426df6c6838d87d60b32cc7921543e8764f 100644 --- a/src/components/Map/MapViewer/utils/config/reactionsLayer/useOlMapReactionsLayer.ts +++ b/src/components/Map/MapViewer/utils/config/reactionsLayer/useOlMapReactionsLayer.ts @@ -12,12 +12,13 @@ import Stroke from 'ol/style/Stroke'; import Style from 'ol/style/Style'; import { useMemo } from 'react'; import { useSelector } from 'react-redux'; +import { Feature } from 'ol'; import { getLineFeature } from './getLineFeature'; const getReactionsLines = (reactions: Reaction[]): LinePoint[] => reactions.map(({ lines }) => lines.map(({ start, end }): LinePoint => [start, end])).flat(); -export const useOlMapReactionsLayer = (): VectorLayer<VectorSource<Geometry>> => { +export const useOlMapReactionsLayer = (): VectorLayer<VectorSource<Feature<Geometry>>> => { const pointToProjection = usePointToProjection(); const reactions = useSelector(allReactionsSelectorOfCurrentMap); const reactionsLines = getReactionsLines(reactions); diff --git a/src/components/Map/MapViewer/utils/config/useOlMapLayers.ts b/src/components/Map/MapViewer/utils/config/useOlMapLayers.ts index 950ae0ca14e9c581dd0016b2645bb71e0433c089..b4db459df1c08630eae039f9f5539855cf4964ac 100644 --- a/src/components/Map/MapViewer/utils/config/useOlMapLayers.ts +++ b/src/components/Map/MapViewer/utils/config/useOlMapLayers.ts @@ -4,6 +4,7 @@ import { MapConfig, MapInstance } from '../../MapViewer.types'; import { useOlMapPinsLayer } from './pinsLayer/useOlMapPinsLayer'; import { useOlMapReactionsLayer } from './reactionsLayer/useOlMapReactionsLayer'; import { useOlMapTileLayer } from './useOlMapTileLayer'; +import { useOlMapOverlaysLayer } from './overlaysLayer/useOlMapOverlaysLayer'; interface UseOlMapLayersInput { mapInstance: MapInstance; @@ -13,14 +14,15 @@ export const useOlMapLayers = ({ mapInstance }: UseOlMapLayersInput): MapConfig[ const tileLayer = useOlMapTileLayer(); const pinsLayer = useOlMapPinsLayer(); const reactionsLayer = useOlMapReactionsLayer(); + const overlaysLayer = useOlMapOverlaysLayer(); useEffect(() => { if (!mapInstance) { return; } - mapInstance.setLayers([tileLayer, reactionsLayer, pinsLayer]); - }, [reactionsLayer, tileLayer, pinsLayer, mapInstance]); + mapInstance.setLayers([tileLayer, reactionsLayer, pinsLayer, overlaysLayer]); + }, [reactionsLayer, tileLayer, pinsLayer, mapInstance, overlaysLayer]); - return [tileLayer, pinsLayer, reactionsLayer]; + return [tileLayer, pinsLayer, reactionsLayer, overlaysLayer]; }; 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/backgrounds.ts b/src/constants/backgrounds.ts new file mode 100644 index 0000000000000000000000000000000000000000..1bfb46194f374830b2b9ba89d807cbec4dc658fa --- /dev/null +++ b/src/constants/backgrounds.ts @@ -0,0 +1 @@ +export const EMPTY_BACKGROUND_NAME = 'Empty'; 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/constants/hexColors.ts b/src/constants/hexColors.ts new file mode 100644 index 0000000000000000000000000000000000000000..1a81cfbc01e74b9ae6e76a778e65a73dd7ee76e5 --- /dev/null +++ b/src/constants/hexColors.ts @@ -0,0 +1 @@ +export const WHITE_HEX_OPACITY_0 = '#00000000'; diff --git a/src/hooks/useTriColorLerp.ts b/src/hooks/useTriColorLerp.ts new file mode 100644 index 0000000000000000000000000000000000000000..f872b09c506edbf4b8c50c3e70d02e1a860d12f2 --- /dev/null +++ b/src/hooks/useTriColorLerp.ts @@ -0,0 +1,44 @@ +import { useCallback } from 'react'; +import { WHITE_HEX_OPACITY_0 } from '@/constants/hexColors'; +import { + maxColorValSelector, + minColorValSelector, + neutralColorValSelector, + overlayOpacitySelector, + simpleColorValSelector, +} from '@/redux/configuration/configuration.selectors'; +import { useAppSelector } from '@/redux/hooks/useAppSelector'; +import { getHexTricolorGradientColorWithAlpha } from '@/utils/convert/getHexTricolorGradientColorWithAlpha'; +import { ONE } from '@/constants/common'; +import { addAlphaToHexString } from '../utils/convert/addAlphaToHexString'; + +export type GetHex3ColorGradientColorWithAlpha = (position: number) => string; + +type UseTriColorLerpReturn = { + getHex3ColorGradientColorWithAlpha: GetHex3ColorGradientColorWithAlpha; + defaultColorHex: string; +}; + +export const useTriColorLerp = (): UseTriColorLerpReturn => { + const minColorValHexString = useAppSelector(minColorValSelector) || ''; + const maxColorValHexString = useAppSelector(maxColorValSelector) || ''; + const neutralColorValHexString = useAppSelector(neutralColorValSelector) || ''; + const overlayOpacityValue = useAppSelector(overlayOpacitySelector) || ONE; + const simpleColorValue = useAppSelector(simpleColorValSelector) || WHITE_HEX_OPACITY_0; + + const getHex3ColorGradientColorWithAlpha = useCallback( + (position: number) => + getHexTricolorGradientColorWithAlpha({ + leftColor: minColorValHexString, + middleColor: neutralColorValHexString, + rightColor: maxColorValHexString, + position, + alpha: Number(overlayOpacityValue), + }), + [minColorValHexString, neutralColorValHexString, maxColorValHexString, overlayOpacityValue], + ); + + const defaultColorHex = addAlphaToHexString(simpleColorValue, Number(overlayOpacityValue)); + + return { getHex3ColorGradientColorWithAlpha, defaultColorHex }; +}; diff --git a/src/models/configurationOptionSchema.ts b/src/models/configurationOptionSchema.ts new file mode 100644 index 0000000000000000000000000000000000000000..9268478c1f5bc48753e2b184c60d00394406cf59 --- /dev/null +++ b/src/models/configurationOptionSchema.ts @@ -0,0 +1,11 @@ +import { z } from 'zod'; + +export const configurationOptionSchema = z.object({ + idObject: z.number(), + type: z.string(), + valueType: z.string(), + commonName: z.string(), + isServerSide: z.boolean(), + value: z.string().optional(), + group: z.string(), +}); diff --git a/src/models/fixtures/overlayBioEntityFixture.ts b/src/models/fixtures/overlayBioEntityFixture.ts new file mode 100644 index 0000000000000000000000000000000000000000..da0c6da654ba996874863860034ebf6856762054 --- /dev/null +++ b/src/models/fixtures/overlayBioEntityFixture.ts @@ -0,0 +1,10 @@ +import { ZOD_SEED } from '@/constants'; +import { z } from 'zod'; +// eslint-disable-next-line import/no-extraneous-dependencies +import { createFixture } from 'zod-fixture'; +import { overlayBioEntitySchema } from '../overlayBioEntitySchema'; + +export const overlayBioEntityFixture = createFixture(z.array(overlayBioEntitySchema), { + seed: ZOD_SEED, + array: { min: 3, max: 3 }, +}); diff --git a/src/models/mocks/configurationOptionMock.ts b/src/models/mocks/configurationOptionMock.ts new file mode 100644 index 0000000000000000000000000000000000000000..f658402ecdddcb0df80cf9a1e92e940b5c422679 --- /dev/null +++ b/src/models/mocks/configurationOptionMock.ts @@ -0,0 +1,47 @@ +import { ConfigurationOption } from '@/types/models'; + +export const CONFIGURATION_OPTIONS_TYPES_MOCK: string[] = [ + 'MIN_COLOR_VAL', + 'MAX_COLOR_VAL', + 'SIMPLE_COLOR_VAL', + 'NEUTRAL_COLOR_VAL', +]; + +export const CONFIGURATION_OPTIONS_COLOURS_MOCK: ConfigurationOption[] = [ + { + idObject: 29, + type: 'MIN_COLOR_VAL', + valueType: 'COLOR', + commonName: 'Overlay color for negative values', + isServerSide: false, + value: 'FF0000', + group: 'Overlays', + }, + { + idObject: 30, + type: 'MAX_COLOR_VAL', + valueType: 'COLOR', + commonName: 'Overlay color for postive values', + isServerSide: false, + value: '0000FF', + group: 'Overlays', + }, + { + idObject: 31, + type: 'SIMPLE_COLOR_VAL', + valueType: 'COLOR', + commonName: 'Overlay color when no values are defined', + isServerSide: false, + value: '00FF00', + group: 'Overlays', + }, + { + idObject: 32, + type: 'NEUTRAL_COLOR_VAL', + valueType: 'COLOR', + commonName: 'Overlay color for value=0', + isServerSide: false, + value: 'FFFFFF', + group: 'Overlays', + }, +]; diff --git a/src/models/mocks/modelsMock.ts b/src/models/mocks/modelsMock.ts index 96cd8bf94a4a098980ee270c78be6a17f95349ea..5254ae65fb2158e7e999e4cebce4229742aa139c 100644 --- a/src/models/mocks/modelsMock.ts +++ b/src/models/mocks/modelsMock.ts @@ -457,3 +457,21 @@ export const MODELS_MOCK_SHORT: MapModel[] = [ maxZoom: 5, }, ]; + +export const CORE_PD_MODEL_MOCK: MapModel = { + idObject: 5053, + width: 26779.25, + height: 13503.0, + defaultCenterX: null, + defaultCenterY: null, + description: '', + name: 'Core PD map', + defaultZoomLevel: null, + tileSize: 256, + references: [], + authors: [], + creationDate: null, + modificationDates: [], + minZoom: 2, + maxZoom: 9, +}; 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/overlayBioEntitySchema.ts b/src/models/overlayBioEntitySchema.ts new file mode 100644 index 0000000000000000000000000000000000000000..d9dd58950b85a6d21bdde12e38e668af4d9b30e4 --- /dev/null +++ b/src/models/overlayBioEntitySchema.ts @@ -0,0 +1,8 @@ +import { z } from 'zod'; +import { overlayLeftBioEntitySchema } from './overlayLeftBioEntitySchema'; +import { overlayRightBioEntitySchema } from './overlayRightBioEntitySchema'; + +export const overlayBioEntitySchema = z.object({ + left: overlayLeftBioEntitySchema, + right: overlayRightBioEntitySchema, +}); diff --git a/src/models/overlayLeftBioEntitySchema.ts b/src/models/overlayLeftBioEntitySchema.ts new file mode 100644 index 0000000000000000000000000000000000000000..5e751d72037cb7131dd7b6502a58a733d0d38ec6 --- /dev/null +++ b/src/models/overlayLeftBioEntitySchema.ts @@ -0,0 +1,52 @@ +import { z } from 'zod'; +import { colorSchema } from './colorSchema'; +import { referenceSchema } from './referenceSchema'; + +export const overlayLeftBioEntitySchema = z.object({ + id: z.number(), + model: z.number(), + glyph: z.unknown(), + submodel: z.unknown(), + compartment: z.number().nullable(), + elementId: z.union([z.string(), z.number()]), + x: z.number(), + y: z.number(), + z: z.number(), + width: z.number(), + height: z.number(), + fontSize: z.number().optional(), + fontColor: colorSchema.optional(), + fillColor: colorSchema.optional(), + borderColor: colorSchema, + visibilityLevel: z.string(), + transparencyLevel: z.string(), + notes: z.string(), + symbol: z.string().nullable(), + fullName: z.string().nullable().optional(), + abbreviation: z.unknown(), + formula: z.unknown(), + name: z.string(), + nameX: z.number().optional(), + nameY: z.number().optional(), + nameWidth: z.number().optional(), + nameHeight: z.number().optional(), + nameVerticalAlign: z.string().optional(), + nameHorizontalAlign: z.string().optional(), + synonyms: z.array(z.string()), + formerSymbols: z.array(z.string()).optional(), + activity: z.boolean().optional(), + lineWidth: z.number().optional(), + complex: z.number().nullable().optional(), + initialAmount: z.unknown().nullable(), + charge: z.unknown(), + initialConcentration: z.number().nullable().optional(), + onlySubstanceUnits: z.unknown(), + homodimer: z.number().optional(), + hypothetical: z.unknown(), + boundaryCondition: z.boolean().optional(), + constant: z.boolean().nullable().optional(), + modificationResidues: z.unknown(), + stringType: z.string(), + substanceUnits: z.boolean().nullable().optional(), + references: z.array(referenceSchema), +}); diff --git a/src/models/overlayRightBioEntitySchema.ts b/src/models/overlayRightBioEntitySchema.ts new file mode 100644 index 0000000000000000000000000000000000000000..970271c56a9942521cb00987932a4d2bdd9857dd --- /dev/null +++ b/src/models/overlayRightBioEntitySchema.ts @@ -0,0 +1,14 @@ +import { z } from 'zod'; +import { colorSchema } from './colorSchema'; + +export const overlayRightBioEntitySchema = z.object({ + id: z.number(), + name: z.string(), + modelName: z.boolean().nullable(), + elementId: z.string().nullable(), + reverseReaction: z.boolean().nullable(), + lineWidth: z.number().nullable().optional(), + value: z.number().nullable(), + color: colorSchema.nullable(), + description: z.string().nullable(), +}); 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/apiPath.ts b/src/redux/apiPath.ts index 427114048b9bf5331691283debe1b0170dfa1a89..d7693545008351a6ac605b75165fb5d87e0da706 100644 --- a/src/redux/apiPath.ts +++ b/src/redux/apiPath.ts @@ -29,4 +29,7 @@ export const apiPath = { getAllBackgroundsByProjectIdQuery: (projectId: string): string => `projects/${projectId}/backgrounds/`, getProjectById: (projectId: string): string => `projects/${projectId}`, + getConfigurationOptions: (): string => 'configuration/options/', + getOverlayBioEntity: ({ overlayId, modelId }: { overlayId: number; modelId: number }): string => + `projects/${PROJECT_ID}/overlays/${overlayId}/models/${modelId}/bioEntities/`, }; diff --git a/src/redux/backgrounds/background.selectors.ts b/src/redux/backgrounds/background.selectors.ts index 596301e1262dc5ed73303636d8fe7acd87da1c94..b8443ab5545da2f894ca43fe284669b56fcdf038 100644 --- a/src/redux/backgrounds/background.selectors.ts +++ b/src/redux/backgrounds/background.selectors.ts @@ -1,4 +1,5 @@ import { createSelector } from '@reduxjs/toolkit'; +import { EMPTY_BACKGROUND_NAME } from '@/constants/backgrounds'; import { mapDataSelector } from '../map/map.selectors'; import { rootSelector } from '../root/root.selectors'; @@ -36,3 +37,10 @@ export const currentBackgroundImagePathSelector = createSelector( currentBackgroundImageSelector, image => (image ? image.path : ''), ); + +export const emptyBackgroundIdSelector = createSelector(backgroundsDataSelector, backgrounds => { + const emptyBackground = backgrounds?.find( + background => background.name === EMPTY_BACKGROUND_NAME, + ); + return emptyBackground?.id; +}); diff --git a/src/redux/configuration/configuration.adapter.ts b/src/redux/configuration/configuration.adapter.ts new file mode 100644 index 0000000000000000000000000000000000000000..cb3c59beabe94d7b0fd1ef707ccc90c03342cecd --- /dev/null +++ b/src/redux/configuration/configuration.adapter.ts @@ -0,0 +1,18 @@ +import { DEFAULT_ERROR } from '@/constants/errors'; +import { Loading } from '@/types/loadingState'; +import { ConfigurationOption } from '@/types/models'; +import { createEntityAdapter } from '@reduxjs/toolkit'; + +export const configurationAdapter = createEntityAdapter<ConfigurationOption>({ + selectId: option => option.type, +}); + +const REQUEST_INITIAL_STATUS: { loading: Loading; error: Error } = { + loading: 'idle', + error: DEFAULT_ERROR, +}; + +export const CONFIGURATION_INITIAL_STATE = + configurationAdapter.getInitialState(REQUEST_INITIAL_STATUS); + +export type ConfigurationState = typeof CONFIGURATION_INITIAL_STATE; diff --git a/src/redux/configuration/configuration.constants.ts b/src/redux/configuration/configuration.constants.ts new file mode 100644 index 0000000000000000000000000000000000000000..765ad32a6b1fb7a9a64649cb41bd2188a951d81e --- /dev/null +++ b/src/redux/configuration/configuration.constants.ts @@ -0,0 +1,5 @@ +export const MIN_COLOR_VAL_NAME_ID = 'MIN_COLOR_VAL'; +export const MAX_COLOR_VAL_NAME_ID = 'MAX_COLOR_VAL'; +export const SIMPLE_COLOR_VAL_NAME_ID = 'SIMPLE_COLOR_VAL'; +export const NEUTRAL_COLOR_VAL_NAME_ID = 'NEUTRAL_COLOR_VAL'; +export const OVERLAY_OPACITY_NAME_ID = 'OVERLAY_OPACITY'; diff --git a/src/redux/configuration/configuration.mock.ts b/src/redux/configuration/configuration.mock.ts new file mode 100644 index 0000000000000000000000000000000000000000..ce8f052d426a153b14230093988426ea8d3c25f0 --- /dev/null +++ b/src/redux/configuration/configuration.mock.ts @@ -0,0 +1,27 @@ +/* eslint-disable no-magic-numbers */ +import { DEFAULT_ERROR } from '@/constants/errors'; +import { + CONFIGURATION_OPTIONS_TYPES_MOCK, + CONFIGURATION_OPTIONS_COLOURS_MOCK, +} from '@/models/mocks/configurationOptionMock'; +import { ConfigurationState } from './configuration.adapter'; + +export const CONFIGURATION_INITIAL_STORE_MOCK: ConfigurationState = { + ids: [], + entities: {}, + loading: 'idle', + error: DEFAULT_ERROR, +}; + +/** IMPORTANT MOCK IDS MUST MATCH KEYS IN ENTITIES */ +export const CONFIGURATION_INITIAL_STORE_MOCKS: ConfigurationState = { + ids: CONFIGURATION_OPTIONS_TYPES_MOCK, + entities: { + [CONFIGURATION_OPTIONS_TYPES_MOCK[0]]: CONFIGURATION_OPTIONS_COLOURS_MOCK[0], + [CONFIGURATION_OPTIONS_TYPES_MOCK[1]]: CONFIGURATION_OPTIONS_COLOURS_MOCK[1], + [CONFIGURATION_OPTIONS_TYPES_MOCK[2]]: CONFIGURATION_OPTIONS_COLOURS_MOCK[2], + [CONFIGURATION_OPTIONS_TYPES_MOCK[3]]: CONFIGURATION_OPTIONS_COLOURS_MOCK[3], + }, + loading: 'idle', + error: DEFAULT_ERROR, +}; diff --git a/src/redux/configuration/configuration.reducers.ts b/src/redux/configuration/configuration.reducers.ts new file mode 100644 index 0000000000000000000000000000000000000000..01cd1fe5a6a869b6707bb12c6d2f4917a2bc25a1 --- /dev/null +++ b/src/redux/configuration/configuration.reducers.ts @@ -0,0 +1,21 @@ +import { ActionReducerMapBuilder } from '@reduxjs/toolkit'; +import { getConfigurationOptions } from './configuration.thunks'; +import { ConfigurationState, configurationAdapter } from './configuration.adapter'; + +export const getConfigurationOptionsReducer = ( + builder: ActionReducerMapBuilder<ConfigurationState>, +): void => { + builder.addCase(getConfigurationOptions.pending, state => { + state.loading = 'pending'; + }); + builder.addCase(getConfigurationOptions.fulfilled, (state, action) => { + if (action.payload) { + state.loading = 'succeeded'; + configurationAdapter.addMany(state, action.payload); + } + }); + builder.addCase(getConfigurationOptions.rejected, state => { + state.loading = 'failed'; + // TODO to discuss manage state of failure + }); +}; diff --git a/src/redux/configuration/configuration.selectors.ts b/src/redux/configuration/configuration.selectors.ts new file mode 100644 index 0000000000000000000000000000000000000000..7a694a44779048afc22d05ab9b406f8f970fd153 --- /dev/null +++ b/src/redux/configuration/configuration.selectors.ts @@ -0,0 +1,39 @@ +import { createSelector } from '@reduxjs/toolkit'; +import { configurationAdapter } from './configuration.adapter'; +import { rootSelector } from '../root/root.selectors'; +import { + MAX_COLOR_VAL_NAME_ID, + MIN_COLOR_VAL_NAME_ID, + NEUTRAL_COLOR_VAL_NAME_ID, + OVERLAY_OPACITY_NAME_ID, + SIMPLE_COLOR_VAL_NAME_ID, +} from './configuration.constants'; + +const configurationSelector = createSelector(rootSelector, state => state.configuration); + +const configurationAdapterSelectors = configurationAdapter.getSelectors(); + +export const minColorValSelector = createSelector( + configurationSelector, + state => configurationAdapterSelectors.selectById(state, MIN_COLOR_VAL_NAME_ID)?.value, +); + +export const maxColorValSelector = createSelector( + configurationSelector, + state => configurationAdapterSelectors.selectById(state, MAX_COLOR_VAL_NAME_ID)?.value, +); + +export const neutralColorValSelector = createSelector( + configurationSelector, + state => configurationAdapterSelectors.selectById(state, NEUTRAL_COLOR_VAL_NAME_ID)?.value, +); + +export const overlayOpacitySelector = createSelector( + configurationSelector, + state => configurationAdapterSelectors.selectById(state, OVERLAY_OPACITY_NAME_ID)?.value, +); + +export const simpleColorValSelector = createSelector( + configurationSelector, + state => configurationAdapterSelectors.selectById(state, SIMPLE_COLOR_VAL_NAME_ID)?.value, +); diff --git a/src/redux/configuration/configuration.slice.ts b/src/redux/configuration/configuration.slice.ts new file mode 100644 index 0000000000000000000000000000000000000000..4bf43488b0dbf265504a24370e7607a90c1f82b5 --- /dev/null +++ b/src/redux/configuration/configuration.slice.ts @@ -0,0 +1,14 @@ +import { createSlice } from '@reduxjs/toolkit'; +import { getConfigurationOptionsReducer } from './configuration.reducers'; +import { CONFIGURATION_INITIAL_STATE } from './configuration.adapter'; + +export const configurationSlice = createSlice({ + name: 'configuration', + initialState: CONFIGURATION_INITIAL_STATE, + reducers: {}, + extraReducers: builder => { + getConfigurationOptionsReducer(builder); + }, +}); + +export default configurationSlice.reducer; diff --git a/src/redux/configuration/configuration.thunks.ts b/src/redux/configuration/configuration.thunks.ts new file mode 100644 index 0000000000000000000000000000000000000000..ad3812bbf4d28e9093e64ca80bc92dc43b1be770 --- /dev/null +++ b/src/redux/configuration/configuration.thunks.ts @@ -0,0 +1,23 @@ +import { ConfigurationOption } from '@/types/models'; +import { createAsyncThunk } from '@reduxjs/toolkit'; +import { z } from 'zod'; +import { axiosInstance } from '@/services/api/utils/axiosInstance'; +import { validateDataUsingZodSchema } from '@/utils/validateDataUsingZodSchema'; +import { configurationOptionSchema } from '@/models/configurationOptionSchema'; +import { apiPath } from '../apiPath'; + +export const getConfigurationOptions = createAsyncThunk( + 'configuration/getConfigurationOptions', + async (): Promise<ConfigurationOption[] | undefined> => { + const response = await axiosInstance.get<ConfigurationOption[]>( + apiPath.getConfigurationOptions(), + ); + + const isDataValid = validateDataUsingZodSchema( + response.data, + z.array(configurationOptionSchema), + ); + + return isDataValid ? response.data : undefined; + }, +); 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/models/models.selectors.ts b/src/redux/models/models.selectors.ts index 92cd6b719dcf44ab0171b31b87b1b1c378d2dc52..ab2cdc1631bc94bc2d67754860bbc2cab3a044ac 100644 --- a/src/redux/models/models.selectors.ts +++ b/src/redux/models/models.selectors.ts @@ -13,6 +13,10 @@ export const currentModelSelector = createSelector( (models, mapData) => models.find(model => model.idObject === mapData.modelId), ); +export const modelsIdsSelector = createSelector(modelsDataSelector, models => + models.map(model => model.idObject), +); + export const currentModelIdSelector = createSelector( currentModelSelector, model => model?.idObject || MODEL_ID_DEFAULT, diff --git a/src/redux/overlayBioEntity/overlayBioEntity.mock.ts b/src/redux/overlayBioEntity/overlayBioEntity.mock.ts new file mode 100644 index 0000000000000000000000000000000000000000..4fa115d0c747d65e9d25369984108bc98c4ca6ce --- /dev/null +++ b/src/redux/overlayBioEntity/overlayBioEntity.mock.ts @@ -0,0 +1,6 @@ +import { OverlaysBioEntityState } from './overlayBioEntity.types'; + +export const OVERLAY_BIO_ENTITY_INITIAL_STATE_MOCK: OverlaysBioEntityState = { + overlaysId: [], + data: [], +}; diff --git a/src/redux/overlayBioEntity/overlayBioEntity.reducers.ts b/src/redux/overlayBioEntity/overlayBioEntity.reducers.ts new file mode 100644 index 0000000000000000000000000000000000000000..da76054be13a2b6f4de7576bdf948f59ad3c38b3 --- /dev/null +++ b/src/redux/overlayBioEntity/overlayBioEntity.reducers.ts @@ -0,0 +1,22 @@ +import { ActionReducerMapBuilder } from '@reduxjs/toolkit'; +import { getOverlayBioEntity, getOverlayBioEntityForAllModels } from './overlayBioEntity.thunk'; +import { OverlaysBioEntityState } from './overlayBioEntity.types'; + +export const getOverlayBioEntityReducer = ( + builder: ActionReducerMapBuilder<OverlaysBioEntityState>, +): void => { + builder.addCase(getOverlayBioEntity.fulfilled, (state, action) => { + if (action.payload) { + state.overlaysId = [action.meta.arg.overlayId]; + state.data.push(...action.payload); + } + }); +}; + +export const getOverlayBioEntityForAllModelsReducer = ( + builder: ActionReducerMapBuilder<OverlaysBioEntityState>, +): void => { + builder.addCase(getOverlayBioEntityForAllModels.pending, state => { + state.data = []; + }); +}; diff --git a/src/redux/overlayBioEntity/overlayBioEntity.selector.ts b/src/redux/overlayBioEntity/overlayBioEntity.selector.ts new file mode 100644 index 0000000000000000000000000000000000000000..72c3b359fdbb43ecff2a59617090397b59e90852 --- /dev/null +++ b/src/redux/overlayBioEntity/overlayBioEntity.selector.ts @@ -0,0 +1,19 @@ +import { createSelector } from '@reduxjs/toolkit'; +import { rootSelector } from '../root/root.selectors'; +import { currentModelIdSelector } from '../models/models.selectors'; + +export const overlayBioEntitySelector = createSelector( + rootSelector, + state => state.overlayBioEntity, +); + +export const overlayBioEntityDataSelector = createSelector( + overlayBioEntitySelector, + overlayBioEntity => overlayBioEntity.data, +); + +export const overlayBioEntitiesForCurrentModelSelector = createSelector( + overlayBioEntityDataSelector, + currentModelIdSelector, + (data, currentModelId) => data.filter(entity => entity.modelId === currentModelId), +); diff --git a/src/redux/overlayBioEntity/overlayBioEntity.slice.ts b/src/redux/overlayBioEntity/overlayBioEntity.slice.ts new file mode 100644 index 0000000000000000000000000000000000000000..f25d3ed6e81ac34f12cad62d428f694865ff5e46 --- /dev/null +++ b/src/redux/overlayBioEntity/overlayBioEntity.slice.ts @@ -0,0 +1,23 @@ +import { createSlice } from '@reduxjs/toolkit'; +import { + getOverlayBioEntityForAllModelsReducer, + getOverlayBioEntityReducer, +} from './overlayBioEntity.reducers'; +import { OverlaysBioEntityState } from './overlayBioEntity.types'; + +const initialState: OverlaysBioEntityState = { + overlaysId: [], + data: [], +}; + +export const overlayBioEntitySlice = createSlice({ + name: 'overlayBioEntity', + initialState, + reducers: {}, + extraReducers: builder => { + getOverlayBioEntityReducer(builder); + getOverlayBioEntityForAllModelsReducer(builder); + }, +}); + +export default overlayBioEntitySlice.reducer; diff --git a/src/redux/overlayBioEntity/overlayBioEntity.thunk.ts b/src/redux/overlayBioEntity/overlayBioEntity.thunk.ts new file mode 100644 index 0000000000000000000000000000000000000000..21aecf106db07d5bc44a4701f544dee1e5d490bf --- /dev/null +++ b/src/redux/overlayBioEntity/overlayBioEntity.thunk.ts @@ -0,0 +1,56 @@ +import { createAsyncThunk } from '@reduxjs/toolkit'; +import { z } from 'zod'; +import { axiosInstanceNewAPI } from '@/services/api/utils/axiosInstance'; +import { OverlayBioEntity } from '@/types/models'; +import { overlayBioEntitySchema } from '@/models/overlayBioEntitySchema'; +import { validateDataUsingZodSchema } from '@/utils/validateDataUsingZodSchema'; +import { OverlayBioEntityRender } from '@/types/OLrendering'; +import { parseOverlayBioEntityToOlRenderingFormat } from './overlayBioEntity.utils'; +import { apiPath } from '../apiPath'; +import { modelsIdsSelector } from '../models/models.selectors'; +import type { RootState } from '../store'; + +type GetOverlayBioEntityThunkProps = { + overlayId: number; + modelId: number; +}; + +export const getOverlayBioEntity = createAsyncThunk( + 'overlayBioEntity/getOverlayBioEntity', + async ({ + overlayId, + modelId, + }: GetOverlayBioEntityThunkProps): Promise<OverlayBioEntityRender[] | undefined> => { + const response = await axiosInstanceNewAPI.get<OverlayBioEntity[]>( + apiPath.getOverlayBioEntity({ overlayId, modelId }), + ); + + const isDataValid = validateDataUsingZodSchema(response.data, z.array(overlayBioEntitySchema)); + + if (isDataValid) { + return parseOverlayBioEntityToOlRenderingFormat(response.data, overlayId); + } + + return undefined; + }, +); + +type GetOverlayBioEntityForAllModelsThunkProps = { overlayId: number }; + +export const getOverlayBioEntityForAllModels = createAsyncThunk< + void, + GetOverlayBioEntityForAllModelsThunkProps, + { state: RootState } +>( + 'overlayBioEntity/getOverlayBioEntityForAllModels', + async ({ overlayId }, { dispatch, getState }): Promise<void> => { + const state = getState(); + const modelsIds = modelsIdsSelector(state); + + const asyncGetOverlayBioEntityFunctions = modelsIds.map(id => + dispatch(getOverlayBioEntity({ overlayId, modelId: id })), + ); + + await Promise.all(asyncGetOverlayBioEntityFunctions); + }, +); diff --git a/src/redux/overlayBioEntity/overlayBioEntity.types.ts b/src/redux/overlayBioEntity/overlayBioEntity.types.ts new file mode 100644 index 0000000000000000000000000000000000000000..43eeb895696b8af7397be5013249843aa10d649a --- /dev/null +++ b/src/redux/overlayBioEntity/overlayBioEntity.types.ts @@ -0,0 +1,6 @@ +import { OverlayBioEntityRender } from '@/types/OLrendering'; + +export type OverlaysBioEntityState = { + overlaysId: number[]; + data: OverlayBioEntityRender[]; +}; diff --git a/src/redux/overlayBioEntity/overlayBioEntity.utils.ts b/src/redux/overlayBioEntity/overlayBioEntity.utils.ts new file mode 100644 index 0000000000000000000000000000000000000000..b875e1bba425bc9f7f9a08617d120cdd8ed4a4c8 --- /dev/null +++ b/src/redux/overlayBioEntity/overlayBioEntity.utils.ts @@ -0,0 +1,25 @@ +import { OverlayBioEntityRender } from '@/types/OLrendering'; +import { OverlayBioEntity } from '@/types/models'; + +export const parseOverlayBioEntityToOlRenderingFormat = ( + data: OverlayBioEntity[], + overlayId: number, +): OverlayBioEntityRender[] => + data.reduce((acc: OverlayBioEntityRender[], entity: OverlayBioEntity) => { + if (entity.left.x && entity.left.y) { + acc.push({ + 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; + }, []); 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/redux/root/init.thunks.ts b/src/redux/root/init.thunks.ts index b2c3ef2a92ac89f7d46a76eff3b70a2e44103772..6ac3a819aa3b96656bbb5c0137d983837419bf59 100644 --- a/src/redux/root/init.thunks.ts +++ b/src/redux/root/init.thunks.ts @@ -16,6 +16,7 @@ import { } from '../map/map.thunks'; import { getSearchData } from '../search/search.thunks'; import { setPerfectMatch } from '../search/search.slice'; +import { getConfigurationOptions } from '../configuration/configuration.thunks'; interface InitializeAppParams { queryData: QueryData; @@ -28,6 +29,7 @@ export const fetchInitialAppData = createAsyncThunk< >('appInit/fetchInitialAppData', async ({ queryData }, { dispatch }): Promise<void> => { /** Fetch all data required for renderin map */ await Promise.all([ + dispatch(getConfigurationOptions()), dispatch(getProjectById(PROJECT_ID)), dispatch(getAllBackgroundsByProjectId(PROJECT_ID)), dispatch(getAllPublicOverlaysByProjectId(PROJECT_ID)), diff --git a/src/redux/root/root.fixtures.ts b/src/redux/root/root.fixtures.ts index 38fdcb0e474a0c60e01739a09bfe1c836a9eb751..cd7a8f8d6e84f9a9bdf2cdf9571b16433be59bf8 100644 --- a/src/redux/root/root.fixtures.ts +++ b/src/redux/root/root.fixtures.ts @@ -2,11 +2,13 @@ import { BACKGROUND_INITIAL_STATE_MOCK } from '../backgrounds/background.mock'; import { BIOENTITY_INITIAL_STATE_MOCK } from '../bioEntity/bioEntity.mock'; import { CHEMICALS_INITIAL_STATE_MOCK } from '../chemicals/chemicals.mock'; import { CONTEXT_MENU_INITIAL_STATE } from '../contextMenu/contextMenu.constants'; +import { CONFIGURATION_INITIAL_STATE } from '../configuration/configuration.adapter'; import { initialStateFixture as drawerInitialStateMock } from '../drawer/drawerFixture'; import { DRUGS_INITIAL_STATE_MOCK } from '../drugs/drugs.mock'; import { initialMapStateFixture } from '../map/map.fixtures'; import { MODAL_INITIAL_STATE_MOCK } from '../modal/modal.mock'; import { MODELS_INITIAL_STATE_MOCK } from '../models/models.mock'; +import { OVERLAY_BIO_ENTITY_INITIAL_STATE_MOCK } from '../overlayBioEntity/overlayBioEntity.mock'; import { OVERLAYS_INITIAL_STATE_MOCK } from '../overlays/overlays.mock'; import { PROJECT_STATE_INITIAL_MOCK } from '../project/project.mock'; import { REACTIONS_STATE_INITIAL_MOCK } from '../reactions/reactions.mock'; @@ -25,6 +27,8 @@ export const INITIAL_STORE_STATE_MOCK: RootState = { map: initialMapStateFixture, overlays: OVERLAYS_INITIAL_STATE_MOCK, reactions: REACTIONS_STATE_INITIAL_MOCK, + configuration: CONFIGURATION_INITIAL_STATE, + overlayBioEntity: OVERLAY_BIO_ENTITY_INITIAL_STATE_MOCK, modal: MODAL_INITIAL_STATE_MOCK, contextMenu: CONTEXT_MENU_INITIAL_STATE, }; diff --git a/src/redux/store.ts b/src/redux/store.ts index 51ec608f3bf2c3af64e1a920cc8b46fac48f91d8..22e8d7f8551f0576237f2face200a6f46be49b53 100644 --- a/src/redux/store.ts +++ b/src/redux/store.ts @@ -11,6 +11,8 @@ import projectReducer from '@/redux/project/project.slice'; import reactionsReducer from '@/redux/reactions/reactions.slice'; import contextMenuReducer from '@/redux/contextMenu/contextMenu.slice'; import searchReducer from '@/redux/search/search.slice'; +import configurationReducer from '@/redux/configuration/configuration.slice'; +import overlayBioEntityReducer from '@/redux/overlayBioEntity/overlayBioEntity.slice'; import { AnyAction, ListenerEffectAPI, @@ -34,6 +36,8 @@ export const reducers = { models: modelsReducer, reactions: reactionsReducer, contextMenu: contextMenuReducer, + configuration: configurationReducer, + overlayBioEntity: overlayBioEntityReducer, }; export const middlewares = [mapListenerMiddleware.middleware]; diff --git a/src/types/OLrendering.ts b/src/types/OLrendering.ts new file mode 100644 index 0000000000000000000000000000000000000000..3a651659743e46e286dda16845ea676fcfa4e314 --- /dev/null +++ b/src/types/OLrendering.ts @@ -0,0 +1,15 @@ +import { Color } from './models'; + +export type OverlayBioEntityRender = { + id: number; + modelId: number; + x1: number; + y1: number; + x2: number; + y2: number; + width: number; + height: number; + value: number | null; + overlayId: number; + color: Color | null; +}; diff --git a/src/types/colors.ts b/src/types/colors.ts new file mode 100644 index 0000000000000000000000000000000000000000..15af80a7e5353e1a4014083c177d9bc80ceeb8d0 --- /dev/null +++ b/src/types/colors.ts @@ -0,0 +1,5 @@ +export type RGBColor = { + r: number; + g: number; + b: number; +}; diff --git a/src/types/loadingState.ts b/src/types/loadingState.ts index 12859c959353a01acaf2758d22b167e51f1b6f9d..d21ff588656e52ee44079aa866ddf3afcefc8cb7 100644 --- a/src/types/loadingState.ts +++ b/src/types/loadingState.ts @@ -1 +1,4 @@ export type Loading = 'idle' | 'pending' | 'succeeded' | 'failed'; +export interface LoadingInterface { + loading: 'idle' | 'pending' | 'succeeded' | 'failed'; +} diff --git a/src/types/models.ts b/src/types/models.ts index f83079f195cdeced1fdc30201e5fc87ea404fe77..f01e8f5f79369a2049c5c3899e2f9fdae934596c 100644 --- a/src/types/models.ts +++ b/src/types/models.ts @@ -2,6 +2,8 @@ import { bioEntityContentSchema } from '@/models/bioEntityContentSchema'; import { bioEntityResponseSchema } from '@/models/bioEntityResponseSchema'; import { bioEntitySchema } from '@/models/bioEntitySchema'; import { chemicalSchema } from '@/models/chemicalSchema'; +import { colorSchema } from '@/models/colorSchema'; +import { configurationOptionSchema } from '@/models/configurationOptionSchema'; import { disease } from '@/models/disease'; import { drugSchema } from '@/models/drugSchema'; import { elementSearchResult, elementSearchResultType } from '@/models/elementSearchResult'; @@ -9,6 +11,12 @@ import { mapBackground } from '@/models/mapBackground'; 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'; @@ -19,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>; @@ -35,3 +46,6 @@ export type Reference = z.infer<typeof referenceSchema>; export type ReactionLine = z.infer<typeof reactionLineSchema>; export type ElementSearchResult = z.infer<typeof elementSearchResult>; export type ElementSearchResultType = z.infer<typeof elementSearchResultType>; +export type ConfigurationOption = z.infer<typeof configurationOptionSchema>; +export type OverlayBioEntity = z.infer<typeof overlayBioEntitySchema>; +export type Color = z.infer<typeof colorSchema>; diff --git a/src/utils/convert/addAlphaToHexString.test.ts b/src/utils/convert/addAlphaToHexString.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..e3c72080da658c4871fdd828996388ae66694c1c --- /dev/null +++ b/src/utils/convert/addAlphaToHexString.test.ts @@ -0,0 +1,20 @@ +import { addAlphaToHexString } from './addAlphaToHexString'; + +const Opactiy100 = 1; +const Opacity80 = 0.8; +const Opacity50 = 0.5; +const Opacity0 = 0; + +describe('addAlphaToHexString', () => { + const cases: [[string, number | undefined], string][] = [ + [['#ff0000', undefined], '#ff0000ff'], + [['#ff0000', Opactiy100], '#ff0000ff'], + [['#ff0000', Opacity80], '#ff0000cc'], + [['#ff0000', Opacity50], '#ff000080'], + [['#ff0000', Opacity0], '#ff000000'], + ]; + + it.each(cases)('for %s should return %s', (input, output) => { + expect(addAlphaToHexString(...input)).toEqual(output); + }); +}); diff --git a/src/utils/convert/addAlphaToHexString.ts b/src/utils/convert/addAlphaToHexString.ts new file mode 100644 index 0000000000000000000000000000000000000000..ca474de5d2ef44f8dd293ddc56cb9880c3eceaeb --- /dev/null +++ b/src/utils/convert/addAlphaToHexString.ts @@ -0,0 +1,16 @@ +import { expandHexToFullFormatIfItsShorthanded } from './hexToRgb'; + +const HEX_RADIX = 16; +const EXPECTED_HEX_LENGTH = 2; +const MAX_RGB_VALUE = 255; +const DEFAULT_ALPHA = 1; + +export const addAlphaToHexString = (hexString: string, alpha: number = DEFAULT_ALPHA): string => { + const fullHexString = expandHexToFullFormatIfItsShorthanded(hexString); + + const alphaHex = Math.round(alpha * MAX_RGB_VALUE) + .toString(HEX_RADIX) + .padStart(EXPECTED_HEX_LENGTH, '0'); + + return `${fullHexString}${alphaHex}`; +}; diff --git a/src/utils/convert/convertDecimalToHex.test.ts b/src/utils/convert/convertDecimalToHex.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..fa822477648b75c542da0f812a0aefa6da56f0e4 --- /dev/null +++ b/src/utils/convert/convertDecimalToHex.test.ts @@ -0,0 +1,15 @@ +/* eslint-disable no-magic-numbers */ + +import { convertDecimalToHexColor } from './convertDecimalToHex'; + +describe('convertDecimalToHexColor - util', () => { + it('should convert small decimal', () => { + expect(convertDecimalToHexColor(57)).toEqual('#000039'); + expect(convertDecimalToHexColor(0)).toEqual('#000000'); + }); + + it('should convert negative decimal', () => { + expect(convertDecimalToHexColor(-3342388)).toEqual('#ccffcc'); + expect(convertDecimalToHexColor(-750)).toBe('#fffd12'); + }); +}); diff --git a/src/utils/convert/convertDecimalToHex.ts b/src/utils/convert/convertDecimalToHex.ts new file mode 100644 index 0000000000000000000000000000000000000000..aa3def2a55c00760207b5ec61efd730bb9ee82d0 --- /dev/null +++ b/src/utils/convert/convertDecimalToHex.ts @@ -0,0 +1,13 @@ +const HEX_BASE_NUMBER = 16; +const WHITE_HEX = 0xffffff; +const EXPECTED_HEX_CHARS_NUMBER = 6; + +export const convertDecimalToHexColor = (value: number): string => { + // eslint-disable-next-line no-bitwise + const trimmedValue = value & WHITE_HEX; + let colorStr = trimmedValue.toString(HEX_BASE_NUMBER); + while (colorStr.length < EXPECTED_HEX_CHARS_NUMBER) { + colorStr = `0${colorStr}`; + } + return `#${colorStr}`; +}; diff --git a/src/utils/convert/getHexTricolorGradientColorWithAlpha.test.ts b/src/utils/convert/getHexTricolorGradientColorWithAlpha.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..2206afbcef5ba9d813e255e1bc586884bbc7b471 --- /dev/null +++ b/src/utils/convert/getHexTricolorGradientColorWithAlpha.test.ts @@ -0,0 +1,31 @@ +import { getHexTricolorGradientColorWithAlpha } from './getHexTricolorGradientColorWithAlpha'; + +const RED_HEX = '#ff0000'; +const GREEN_HEX = '#00ff00'; +const BLUE_HEX = '#0000ff'; + +describe('getHexTricolorGradientColorWithAlpha', () => { + const cases: [{ alpha: number | undefined; position: number }, string][] = [ + [{ alpha: 1, position: -1 }, '#FF0000ff'], + [{ alpha: 0.8, position: -0.75 }, '#BF4000cc'], + [{ alpha: 0.5, position: -0.5 }, '#80800080'], + [{ alpha: 0, position: -0.25 }, '#40BF0000'], + [{ alpha: 1, position: 0 }, '#00FF00ff'], + [{ alpha: 1, position: 0.25 }, '#00BF40ff'], + [{ alpha: 1, position: 0.5 }, '#008080ff'], + [{ alpha: 1, position: 0.75 }, '#0040BFff'], + [{ alpha: 1, position: 1 }, '#0000FFff'], + ]; + + it.each(cases)(`and position %s should return %s`, (input, output) => { + expect( + getHexTricolorGradientColorWithAlpha({ + leftColor: RED_HEX, + middleColor: GREEN_HEX, + rightColor: BLUE_HEX, + alpha: input.alpha, + position: input.position, + }), + ).toEqual(output); + }); +}); diff --git a/src/utils/convert/getHexTricolorGradientColorWithAlpha.ts b/src/utils/convert/getHexTricolorGradientColorWithAlpha.ts new file mode 100644 index 0000000000000000000000000000000000000000..eacefc8d7390de1a3cc1eed31f01a59d734e2044 --- /dev/null +++ b/src/utils/convert/getHexTricolorGradientColorWithAlpha.ts @@ -0,0 +1,41 @@ +import { WHITE_HEX_OPACITY_0 } from '@/constants/hexColors'; +import { interpolateThreeColors } from '../lerp/interpolateThreeColors'; +import { addAlphaToHexString } from './addAlphaToHexString'; +import { hexToRgb } from './hexToRgb'; +import { rgbToHex } from './rgbToHex'; + +export type GetHexTricolorGradientColorWithAlphaProps = { + leftColor: string; + middleColor: string; + rightColor: string; + alpha: number | undefined; + position: number; +}; + +export const getHexTricolorGradientColorWithAlpha = ({ + leftColor, + middleColor, + rightColor, + alpha, + position, +}: GetHexTricolorGradientColorWithAlphaProps): string => { + const leftRgb = hexToRgb(leftColor); + const middleRgb = hexToRgb(middleColor); + const rightRgb = hexToRgb(rightColor); + + if (!leftRgb || !middleRgb || !rightRgb) { + return WHITE_HEX_OPACITY_0; // white, opacity 0 + } + + const interpolatedColor = interpolateThreeColors({ + leftColor: leftRgb, + middleColor: middleRgb, + rightColor: rightRgb, + position, + }); + + const interpolatedHexColor = rgbToHex(interpolatedColor); + const interpolatedColorWithAlpha = addAlphaToHexString(interpolatedHexColor, alpha); + + return interpolatedColorWithAlpha; +}; diff --git a/src/utils/convert/hexToRgb.test.ts b/src/utils/convert/hexToRgb.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..4801c8cc7670ea597606c37925f62699c0ec217c --- /dev/null +++ b/src/utils/convert/hexToRgb.test.ts @@ -0,0 +1,60 @@ +import { expandHexToFullFormatIfItsShorthanded, hexToRgb } from './hexToRgb'; + +describe('expandHexToFullFormatIfItsShorthanded', () => { + it('should expand short-handed hex string to full format', () => { + const result = expandHexToFullFormatIfItsShorthanded('#abc'); + expect(result).toBe('#aabbcc'); + }); + + it('should not modify full-format hex string', () => { + const result = expandHexToFullFormatIfItsShorthanded('#aabbcc'); + expect(result).toBe('#aabbcc'); + }); + + it('should handle hex string without leading #', () => { + const result = expandHexToFullFormatIfItsShorthanded('abc'); + expect(result).toBe('#aabbcc'); + }); + + it('should return original string if it does not match short-hand regex', () => { + const result = expandHexToFullFormatIfItsShorthanded('invalid'); + expect(result).toBe('#invalid'); + }); +}); + +describe('hexToRgb', () => { + it('should convert valid hex string to RGB object', () => { + const result = hexToRgb('#aabbcc'); + expect(result).toEqual({ r: 170, g: 187, b: 204 }); + }); + + it('should return null for invalid hex string', () => { + const result = hexToRgb('invalid'); + expect(result).toBeNull(); + }); + + it('should handle hex string without leading #', () => { + const result = hexToRgb('aabbcc'); + expect(result).toEqual({ r: 170, g: 187, b: 204 }); + }); + + it('should return null for hex string with invalid characters', () => { + const result = hexToRgb('#xyz123'); + expect(result).toBeNull(); + }); + + it('should convert short-handed RGB hex string without leading # to RGB object', () => { + const result = hexToRgb('abc'); // Short-handed RGB hex string without leading # + expect(result).toEqual({ r: 170, g: 187, b: 204 }); + }); + + it('should handle short-handed RGB hex string with invalid characters', () => { + const result = hexToRgb('#xyz'); // Short-handed RGB hex string with invalid characters + expect(result).toBeNull(); + }); + + it('should handle short-handed RGB hex string with invalid characters and without leading #', () => { + const result = hexToRgb('xyz'); // Short-handed RGB hex string with invalid characters and without leading # + expect(result).toBeNull(); + }); +}); diff --git a/src/utils/convert/hexToRgb.ts b/src/utils/convert/hexToRgb.ts new file mode 100644 index 0000000000000000000000000000000000000000..e9b4544be1b962d55fd97ff7ee2e70b816eccd5f --- /dev/null +++ b/src/utils/convert/hexToRgb.ts @@ -0,0 +1,29 @@ +/* eslint-disable no-magic-numbers */ +export const expandHexToFullFormatIfItsShorthanded = (hexString: string): string => { + const SHORT_HAND_REGEX = /^#?([a-f\d])([a-f\d])([a-f\d])$/i; + const fullHexString = hexString.replace(SHORT_HAND_REGEX, (m, r, g, b) => { + return r + r + g + g + b + b; + }); + const fullHexStringWithPrefix = fullHexString.startsWith('#') + ? fullHexString + : `#${fullHexString}`; + return fullHexStringWithPrefix; +}; + +const FULL_HEX_REGEX = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i; + +export const hexToRgb = (hexString: string): { r: number; g: number; b: number } | null => { + const fullHexString = expandHexToFullFormatIfItsShorthanded(hexString); + + const result = FULL_HEX_REGEX.exec(fullHexString); + + if (!result) { + return null; + } + + return { + r: parseInt(result[1], 16), + g: parseInt(result[2], 16), + b: parseInt(result[3], 16), + }; +}; diff --git a/src/utils/convert/rgbToHex.test.ts b/src/utils/convert/rgbToHex.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..bcb1f2182badb2701e68a89cb436972d9d669da4 --- /dev/null +++ b/src/utils/convert/rgbToHex.test.ts @@ -0,0 +1,34 @@ +import { rgbToHex } from './rgbToHex'; // Replace 'yourFileName' with the actual file name where your function is defined + +describe('rgbToHex - util', () => { + it('should convert RGB values to hex format', () => { + // Test case 1: Black color + expect(rgbToHex({ r: 0, g: 0, b: 0 })).toBe('#000000'); + + // Test case 2: White color + expect(rgbToHex({ r: 255, g: 255, b: 255 })).toBe('#FFFFFF'); + + // Test case 3: Red color + expect(rgbToHex({ r: 255, g: 0, b: 0 })).toBe('#FF0000'); + + // Test case 4: Green color + expect(rgbToHex({ r: 0, g: 255, b: 0 })).toBe('#00FF00'); + + // Test case 5: Blue color + expect(rgbToHex({ r: 0, g: 0, b: 255 })).toBe('#0000FF'); + + // Test case 6: Custom color + expect(rgbToHex({ r: 128, g: 64, b: 32 })).toBe('#804020'); + }); + + it('should handle invalid input values', () => { + // Test case 1: Negative RGB values + expect(() => rgbToHex({ r: -1, g: 0, b: 255 })).toThrow(); + + // Test case 2: RGB values exceeding 255 + expect(() => rgbToHex({ r: 256, g: 128, b: 64 })).toThrow(); + + // Test case 3: Non-integer RGB values + expect(() => rgbToHex({ r: 50.5, g: 100.75, b: 150.25 })).toThrow(); + }); +}); diff --git a/src/utils/convert/rgbToHex.ts b/src/utils/convert/rgbToHex.ts new file mode 100644 index 0000000000000000000000000000000000000000..8ae95fed2ddfd12f5439a0c4aa947d7bd07de63b --- /dev/null +++ b/src/utils/convert/rgbToHex.ts @@ -0,0 +1,23 @@ +import { RGBColor } from '@/types/colors'; + +const MIN_RGB_VALUE = 0; +const MAX_RGB_VALUE = 255; + +const isCorrectRgbValue = ({ r, g, b }: RGBColor): boolean => + !Number.isInteger(r) || + !Number.isInteger(g) || + !Number.isInteger(b) || + r < MIN_RGB_VALUE || + g < MIN_RGB_VALUE || + b < MIN_RGB_VALUE || + r > MAX_RGB_VALUE || + g > MAX_RGB_VALUE || + b > MAX_RGB_VALUE; + +export const rgbToHex = ({ r, g, b }: RGBColor): string => { + if (isCorrectRgbValue({ r, g, b })) { + throw new Error('Invalid RGB values. Values must be integers between 0 and 255.'); + } + // eslint-disable-next-line no-bitwise, no-magic-numbers + return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1).toUpperCase()}`; +}; diff --git a/src/utils/lerp/interpolateThreeColors.test.ts b/src/utils/lerp/interpolateThreeColors.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..421b9af47473eaa83be736d0000f4d6b8aa4a76a --- /dev/null +++ b/src/utils/lerp/interpolateThreeColors.test.ts @@ -0,0 +1,39 @@ +/* eslint-disable no-magic-numbers */ +import { RGBColor } from '@/types/colors'; +import { interpolateThreeColors } from './interpolateThreeColors'; + +const LEFT_COLOR = { r: 255, g: 0, b: 0 }; // Red +const MIDDLE_COLOR = { r: 0, g: 255, b: 0 }; // Green +const RIGHT_COLOR = { r: 0, g: 0, b: 255 }; // Blue + +describe('interpolateThreeColors - util', () => { + const cases: [number, RGBColor][] = [ + [-1, LEFT_COLOR], + [-0.75, { r: 191, g: 64, b: 0 }], + [-0.5, { r: 128, g: 128, b: 0 }], + [-0.25, { r: 64, g: 191, b: 0 }], + [0, MIDDLE_COLOR], + [0.25, { r: 0, g: 191, b: 64 }], + [0.5, { r: 0, g: 128, b: 128 }], + [0.75, { r: 0, g: 64, b: 191 }], + [1, RIGHT_COLOR], + ]; + + it.each(cases)( + `for linear gradient with range [-1,0,1]: left color (-1) ${JSON.stringify( + LEFT_COLOR, + )}, middle color (0) ${JSON.stringify(MIDDLE_COLOR)} and right Color (1) ${JSON.stringify( + RIGHT_COLOR, + )} and position %s should return %s`, + (input, output) => { + expect( + interpolateThreeColors({ + leftColor: LEFT_COLOR, + middleColor: MIDDLE_COLOR, + rightColor: RIGHT_COLOR, + position: input, + }), + ).toEqual(output); + }, + ); +}); diff --git a/src/utils/lerp/interpolateThreeColors.ts b/src/utils/lerp/interpolateThreeColors.ts new file mode 100644 index 0000000000000000000000000000000000000000..d7a347965b8d86a8401c689258160497eb6eac9d --- /dev/null +++ b/src/utils/lerp/interpolateThreeColors.ts @@ -0,0 +1,45 @@ +import { RGBColor } from '@/types/colors'; +import { lerpRGBColor } from './lerpRGBColor'; + +const MIN_VAL = -1; +const MAX_VAL = 1; +const MIDDLE_VAL = 0; + +type InterpolateColorProps = { + leftColor: RGBColor; + middleColor: RGBColor; + rightColor: RGBColor; + position: number; +}; + +/** + * + * @param {position} range [-1,1] + * function interpolates between linear gradient of 3 colors for given position + * -1 is value for leftColor + * 0 is value for middleColor + * 1 is value for rightColor + */ + +export const interpolateThreeColors = ({ + leftColor, + middleColor, + rightColor, + position, +}: InterpolateColorProps): RGBColor => { + const clampedPosition = Math.max(MIN_VAL, Math.min(MAX_VAL, position)); // make sure value is in [-1,1] range + + if (clampedPosition < MIDDLE_VAL) { + /** + * -1 .......|... 0 + * -0.25 - this is position + * BUT function interpolates on positive values so position must not be negative value + * 0 .............1 + * 0.75 + */ + // eslint-disable-next-line no-magic-numbers + return lerpRGBColor({ leftColor, rightColor: middleColor, position: 1 + clampedPosition }); + } + + return lerpRGBColor({ leftColor: middleColor, rightColor, position: clampedPosition }); +}; diff --git a/src/utils/lerp/lerpRGBColor.test.ts b/src/utils/lerp/lerpRGBColor.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..506570e363fc53d77ea07284f65cea535271c741 --- /dev/null +++ b/src/utils/lerp/lerpRGBColor.test.ts @@ -0,0 +1,32 @@ +/* eslint-disable no-magic-numbers */ +import { lerpRGBColor } from './lerpRGBColor'; + +describe('interpolateColor', () => { + const leftColor = { r: 255, g: 0, b: 0 }; // Red + const rightColor = { r: 0, g: 255, b: 0 }; // Green + + it('should return color1 for position 0', () => { + const result = lerpRGBColor({ leftColor, rightColor, position: 0 }); + expect(result).toEqual(leftColor); + }); + + it('should return color2 for position 1', () => { + const result = lerpRGBColor({ leftColor, rightColor, position: 1 }); + expect(result).toEqual(rightColor); + }); + + it('should interpolate colors for position 0.25', () => { + const result = lerpRGBColor({ leftColor, rightColor, position: 0.25 }); + expect(result).toEqual({ r: 191, g: 64, b: 0 }); + }); + + it('should interpolate colors for position 0.5', () => { + const result = lerpRGBColor({ leftColor, rightColor, position: 0.5 }); + expect(result).toEqual({ r: 128, g: 128, b: 0 }); + }); + + it('should interpolate colors for position 0.75', () => { + const result = lerpRGBColor({ leftColor, rightColor, position: 0.75 }); + expect(result).toEqual({ r: 64, g: 191, b: 0 }); + }); +}); diff --git a/src/utils/lerp/lerpRGBColor.ts b/src/utils/lerp/lerpRGBColor.ts new file mode 100644 index 0000000000000000000000000000000000000000..15252a419feb7bf2fb179e71152e44c116b92619 --- /dev/null +++ b/src/utils/lerp/lerpRGBColor.ts @@ -0,0 +1,28 @@ +type RGBColor = { + r: number; + g: number; + b: number; +}; + +type InterpolateColorProps = { + leftColor: RGBColor; + rightColor: RGBColor; + position: number; +}; + +export const lerpRGBColor = ({ + leftColor, + rightColor, + position, +}: InterpolateColorProps): RGBColor => { + const result = {} as RGBColor; + + Object.keys(leftColor).forEach(key => { + const numericKey = key as keyof RGBColor; + result[numericKey] = Math.round( + leftColor[numericKey] + (rightColor[numericKey] - leftColor[numericKey]) * position, + ); + }); + + return result; +};