From 43bce44e6742d83d3a80e47541f45f4516adb573 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mi=C5=82osz=20Grocholewski?= <m.grocholewski@atcomp.pl> Date: Tue, 17 Dec 2024 11:08:21 +0100 Subject: [PATCH 01/22] feat(vector-map): implement adding a new layer --- .../LayerFactoryModal.component.tsx | 93 +++++++++++++++++++ .../Modal/LayerFactoryModal/index.ts | 1 + .../FunctionalArea/Modal/Modal.component.tsx | 6 ++ .../ModalLayout/ModalLayout.component.tsx | 1 + .../LayersDrawer/LayersDrawer.component.tsx | 13 ++- .../utils/shapes/layer/Layer.test.ts | 3 - src/models/layerTextSchema.ts | 4 - src/redux/apiPath.ts | 1 + src/redux/layers/layers.thunks.ts | 21 ++++- src/redux/layers/layers.types.ts | 7 ++ src/redux/modal/modal.reducers.ts | 6 ++ src/redux/modal/modal.slice.ts | 3 + src/shared/Switch/Switch.component.tsx | 3 + src/types/modal.ts | 3 +- 14 files changed, 155 insertions(+), 10 deletions(-) create mode 100644 src/components/FunctionalArea/Modal/LayerFactoryModal/LayerFactoryModal.component.tsx create mode 100644 src/components/FunctionalArea/Modal/LayerFactoryModal/index.ts diff --git a/src/components/FunctionalArea/Modal/LayerFactoryModal/LayerFactoryModal.component.tsx b/src/components/FunctionalArea/Modal/LayerFactoryModal/LayerFactoryModal.component.tsx new file mode 100644 index 00000000..410706e7 --- /dev/null +++ b/src/components/FunctionalArea/Modal/LayerFactoryModal/LayerFactoryModal.component.tsx @@ -0,0 +1,93 @@ +import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; +import { Button } from '@/shared/Button'; +import { Input } from '@/shared/Input'; +import React, { useState } from 'react'; + +import { useAppSelector } from '@/redux/hooks/useAppSelector'; +import { currentModelIdSelector } from '@/redux/models/models.selectors'; +import { closeModal } from '@/redux/modal/modal.slice'; +import { showToast } from '@/utils/showToast'; +import { Switch } from '@/shared/Switch'; +import { LayerStoreInterface } from '@/redux/layers/layers.types'; +import { addLayerForModel, getLayersForModel } from '@/redux/layers/layers.thunks'; +import { SerializedError } from '@reduxjs/toolkit'; + +export const LayerFactoryModal: React.FC = () => { + const dispatch = useAppDispatch(); + const currentModelId = useAppSelector(currentModelIdSelector); + + const [data, setData] = useState<LayerStoreInterface>({ + name: '', + visible: false, + locked: false, + modelId: currentModelId, + }); + + const handleChange = (value: string | boolean, key: string): void => { + setData(prevData => ({ ...prevData, [key]: value })); + }; + + const handleSubmit = async (event: React.FormEvent<HTMLFormElement>): Promise<void> => { + try { + event.preventDefault(); + await dispatch(addLayerForModel(data)).unwrap(); + dispatch(closeModal()); + dispatch(getLayersForModel(currentModelId)); + showToast({ + type: 'success', + message: 'A new layer has been successfully added', + }); + } catch (error) { + const typedError = error as SerializedError; + showToast({ + type: 'error', + message: typedError.message || 'An error occurred while adding a new layer', + }); + } + }; + + return ( + <div className="w-[400px] border border-t-[#E1E0E6] bg-white p-[24px]"> + <form onSubmit={handleSubmit}> + <label className="mb-6 block text-sm font-semibold" htmlFor="name"> + Name: + <Input + type="text" + id="name" + placeholder="Layer name here..." + value={data.name} + onChange={event => { + handleChange(event.target.value, 'name'); + }} + className="mt-2.5 text-sm font-medium text-font-400" + /> + </label> + <label + htmlFor="visible" + className="mb-6 flex items-center justify-between text-sm font-semibold" + > + Visible: + <Switch + id="visible" + isChecked={data.visible} + onToggle={value => handleChange(value, 'visible')} + /> + </label> + <label + htmlFor="locked" + className="mb-6 flex items-center justify-between text-sm font-semibold" + > + Locked: + <Switch + id="locked" + isChecked={data.locked} + onToggle={value => handleChange(value, 'locked')} + /> + </label> + <Button type="submit" className="w-full justify-center text-base font-medium"> + Submit + </Button> + </form> + </div> + ); +}; diff --git a/src/components/FunctionalArea/Modal/LayerFactoryModal/index.ts b/src/components/FunctionalArea/Modal/LayerFactoryModal/index.ts new file mode 100644 index 00000000..5e9a93d6 --- /dev/null +++ b/src/components/FunctionalArea/Modal/LayerFactoryModal/index.ts @@ -0,0 +1 @@ +export { LayerFactoryModal } from './LayerFactoryModal.component'; diff --git a/src/components/FunctionalArea/Modal/Modal.component.tsx b/src/components/FunctionalArea/Modal/Modal.component.tsx index 2768dfa3..8419791d 100644 --- a/src/components/FunctionalArea/Modal/Modal.component.tsx +++ b/src/components/FunctionalArea/Modal/Modal.component.tsx @@ -12,6 +12,7 @@ import { ModalLayout } from './ModalLayout'; import { OverviewImagesModal } from './OverviewImagesModal'; import { PublicationsModal } from './PublicationsModal'; import { LoggedInMenuModal } from './LoggedInMenuModal'; +import { LayerFactoryModal } from './LayerFactoryModal'; const MolArtModal = dynamic( () => import('./MolArtModal/MolArtModal.component').then(mod => mod.MolArtModal), @@ -79,6 +80,11 @@ export const Modal = (): React.ReactNode => { <AddCommentModal /> </ModalLayout> )} + {isOpen && modalName === 'layer-factory' && ( + <ModalLayout> + <LayerFactoryModal /> + </ModalLayout> + )} </> ); }; diff --git a/src/components/FunctionalArea/Modal/ModalLayout/ModalLayout.component.tsx b/src/components/FunctionalArea/Modal/ModalLayout/ModalLayout.component.tsx index b202afcd..493f2bc7 100644 --- a/src/components/FunctionalArea/Modal/ModalLayout/ModalLayout.component.tsx +++ b/src/components/FunctionalArea/Modal/ModalLayout/ModalLayout.component.tsx @@ -33,6 +33,7 @@ export const ModalLayout = ({ children }: ModalLayoutProps): JSX.Element => { modalName === 'terms-of-service' && 'h-auto w-[400px]', modalName === 'add-comment' && 'h-auto w-[400px]', modalName === 'error-report' && 'h-auto w-[800px]', + modalName === 'layer-factory' && 'h-auto w-[400px]', ['edit-overlay', 'logged-in-menu'].includes(modalName) && 'h-auto w-[432px]', )} > diff --git a/src/components/Map/Drawer/LayersDrawer/LayersDrawer.component.tsx b/src/components/Map/Drawer/LayersDrawer/LayersDrawer.component.tsx index e5f914e8..0387ccc8 100644 --- a/src/components/Map/Drawer/LayersDrawer/LayersDrawer.component.tsx +++ b/src/components/Map/Drawer/LayersDrawer/LayersDrawer.component.tsx @@ -8,6 +8,8 @@ import { import { Switch } from '@/shared/Switch'; import { setLayerVisibility } from '@/redux/layers/layers.slice'; import { currentModelIdSelector } from '@/redux/models/models.selectors'; +import { Button } from '@/shared/Button'; +import { openLayerFactoryModal } from '@/redux/modal/modal.slice'; export const LayersDrawer = (): JSX.Element => { const layersForCurrentModel = useAppSelector(layersForCurrentModelSelector); @@ -15,12 +17,21 @@ export const LayersDrawer = (): JSX.Element => { const currentModelId = useAppSelector(currentModelIdSelector); const dispatch = useAppDispatch(); + const addNewLayer = (): void => { + dispatch(openLayerFactoryModal()); + }; + return ( <div data-testid="layers-drawer" className="h-full max-h-full"> <DrawerHeading title="Layers" /> <div className="flex h-[calc(100%-93px)] max-h-[calc(100%-93px)] flex-col overflow-y-auto px-6"> + <div className="flex justify-start pt-2"> + <Button icon="plus" isIcon isFrontIcon onClick={addNewLayer}> + Add new layer + </Button> + </div> {layersForCurrentModel.map(layer => ( - <div key={layer.details.id} className="flex items-center justify-between border-b p-4"> + <div key={layer.details.id} className="flex items-center justify-between border-b py-4"> <h1>{layer.details.name}</h1> <Switch isChecked={layersVisibilityForCurrentModel[layer.details.id]} diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/layer/Layer.test.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/layer/Layer.test.ts index 46baee43..4fd1b57f 100644 --- a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/layer/Layer.test.ts +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/layer/Layer.test.ts @@ -44,10 +44,7 @@ describe('Layer', () => { width: 100, height: 100, fontSize: 12, - size: 12312, notes: 'XYZ', - glyph: null, - elementId: '34', verticalAlign: 'MIDDLE', horizontalAlign: 'CENTER', backgroundColor: WHITE_COLOR, diff --git a/src/models/layerTextSchema.ts b/src/models/layerTextSchema.ts index 3ad77ed0..6858da82 100644 --- a/src/models/layerTextSchema.ts +++ b/src/models/layerTextSchema.ts @@ -1,6 +1,5 @@ import { z } from 'zod'; import { colorSchema } from '@/models/colorSchema'; -import { glyphSchema } from '@/models/glyphSchema'; export const layerTextSchema = z.object({ id: z.number(), @@ -10,10 +9,7 @@ export const layerTextSchema = z.object({ width: z.number(), height: z.number(), fontSize: z.number(), - size: z.number(), notes: z.string(), - glyph: glyphSchema.nullable(), - elementId: z.string(), verticalAlign: z.string(), horizontalAlign: z.string(), backgroundColor: colorSchema, diff --git a/src/redux/apiPath.ts b/src/redux/apiPath.ts index 7daee91c..8f767881 100644 --- a/src/redux/apiPath.ts +++ b/src/redux/apiPath.ts @@ -58,6 +58,7 @@ export const apiPath = { `projects/${PROJECT_ID}/maps/${modelId}/layers/${layerId}/ovals/`, getLayerLines: (modelId: number, layerId: number): string => `projects/${PROJECT_ID}/maps/${modelId}/layers/${layerId}/lines/`, + storeLayer: (modelId: number): string => `projects/${PROJECT_ID}/maps/${modelId}/layers/`, getGlyphImage: (glyphId: number): string => `projects/${PROJECT_ID}/glyphs/${glyphId}/fileContent`, getNewReactionsForModel: (modelId: number): string => diff --git a/src/redux/layers/layers.thunks.ts b/src/redux/layers/layers.thunks.ts index f1594875..2d2699c3 100644 --- a/src/redux/layers/layers.thunks.ts +++ b/src/redux/layers/layers.thunks.ts @@ -8,7 +8,7 @@ import { getError } from '@/utils/error-report/getError'; import { axiosInstanceNewAPI } from '@/services/api/utils/axiosInstance'; import { layerSchema } from '@/models/layerSchema'; import { LAYERS_FETCHING_ERROR_PREFIX } from '@/redux/layers/layers.constants'; -import { LayersVisibilitiesState } from '@/redux/layers/layers.types'; +import { LayerStoreInterface, LayersVisibilitiesState } from '@/redux/layers/layers.types'; import { layerTextSchema } from '@/models/layerTextSchema'; import { layerRectSchema } from '@/models/layerRectSchema'; import { pageableSchema } from '@/models/pageableSchema'; @@ -64,3 +64,22 @@ export const getLayersForModel = createAsyncThunk< return Promise.reject(getError({ error, prefix: LAYERS_FETCHING_ERROR_PREFIX })); } }); + +export const addLayerForModel = createAsyncThunk<Layer | null, LayerStoreInterface, ThunkConfig>( + 'vectorMap/addLayer', + async ({ name, visible, locked, modelId }) => { + try { + const { data } = await axiosInstanceNewAPI.post<Layer>(apiPath.storeLayer(modelId), { + name, + visible, + locked, + }); + + const isDataValid = validateDataUsingZodSchema(data, layerSchema); + + return isDataValid ? data : null; + } catch (error) { + return Promise.reject(getError({ error })); + } + }, +); diff --git a/src/redux/layers/layers.types.ts b/src/redux/layers/layers.types.ts index 701a499a..4c1f283f 100644 --- a/src/redux/layers/layers.types.ts +++ b/src/redux/layers/layers.types.ts @@ -1,6 +1,13 @@ import { KeyedFetchDataState } from '@/types/fetchDataState'; import { Layer, LayerLine, LayerOval, LayerRect, LayerText } from '@/types/models'; +export interface LayerStoreInterface { + name: string; + visible: boolean; + locked: boolean; + modelId: number; +} + export type LayerState = { details: Layer; texts: LayerText[]; diff --git a/src/redux/modal/modal.reducers.ts b/src/redux/modal/modal.reducers.ts index a3704696..78a651f2 100644 --- a/src/redux/modal/modal.reducers.ts +++ b/src/redux/modal/modal.reducers.ts @@ -124,3 +124,9 @@ export const openToSModalReducer = (state: ModalState): void => { state.modalName = 'terms-of-service'; state.modalTitle = 'Terms of service!'; }; + +export const openLayerFactoryModalReducer = (state: ModalState): void => { + state.isOpen = true; + state.modalName = 'layer-factory'; + state.modalTitle = 'Add new layer'; +}; diff --git a/src/redux/modal/modal.slice.ts b/src/redux/modal/modal.slice.ts index bb145852..8ed04215 100644 --- a/src/redux/modal/modal.slice.ts +++ b/src/redux/modal/modal.slice.ts @@ -16,6 +16,7 @@ import { openSelectProjectModalReducer, openLicenseModalReducer, openToSModalReducer, + openLayerFactoryModalReducer, } from './modal.reducers'; const modalSlice = createSlice({ @@ -37,6 +38,7 @@ const modalSlice = createSlice({ openSelectProjectModal: openSelectProjectModalReducer, openLicenseModal: openLicenseModalReducer, openToSModal: openToSModalReducer, + openLayerFactoryModal: openLayerFactoryModalReducer, }, }); @@ -56,6 +58,7 @@ export const { openSelectProjectModal, openLicenseModal, openToSModal, + openLayerFactoryModal, } = modalSlice.actions; export default modalSlice.reducer; diff --git a/src/shared/Switch/Switch.component.tsx b/src/shared/Switch/Switch.component.tsx index 355e84b9..a3d9fafb 100644 --- a/src/shared/Switch/Switch.component.tsx +++ b/src/shared/Switch/Switch.component.tsx @@ -7,6 +7,7 @@ export interface SwitchProps { variantStyles?: VariantStyle; isChecked?: boolean; onToggle?: (checked: boolean) => void; + id?: string; } const variants = { @@ -32,6 +33,7 @@ export const Switch = ({ variantStyles = 'primary', isChecked = false, onToggle, + id, }: SwitchProps): JSX.Element => { const [checked, setChecked] = useState(isChecked); @@ -49,6 +51,7 @@ export const Switch = ({ return ( <button + id={id} type="button" className={twMerge( 'relative inline-flex h-5 w-10 cursor-pointer rounded-full transition-colors duration-300 ease-in-out', diff --git a/src/types/modal.ts b/src/types/modal.ts index eaf3a498..861bb295 100644 --- a/src/types/modal.ts +++ b/src/types/modal.ts @@ -11,4 +11,5 @@ export type ModalName = | 'access-denied' | 'select-project' | 'terms-of-service' - | 'logged-in-menu'; + | 'logged-in-menu' + | 'layer-factory'; -- GitLab From 1f929ceb4f76ab3730a7db4cbddf0338229b94a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mi=C5=82osz=20Grocholewski?= <m.grocholewski@atcomp.pl> Date: Tue, 17 Dec 2024 13:11:23 +0100 Subject: [PATCH 02/22] feat(vector-map): implement editing an existing layer --- .../EditOverlayModal.component.test.tsx | 7 ++ .../hooks/useEditOverlay.test.ts | 5 ++ .../LayerFactoryModal.component.tsx | 68 ++++++++++++++++--- .../LayerFactoryModal.styles.css | 12 ++++ .../ElementLink.component.test.tsx | 4 +- .../ChemicalsList.component.test.tsx | 2 +- .../DrugsList/DrugsList.component.test.tsx | 2 +- .../LayersDrawer/LayersDrawer.component.tsx | 9 ++- src/redux/apiPath.ts | 4 ++ src/redux/layers/layers.thunks.ts | 41 ++++++++++- src/redux/layers/layers.types.ts | 8 +++ src/redux/modal/modal.constants.ts | 1 + src/redux/modal/modal.mock.ts | 1 + src/redux/modal/modal.reducers.ts | 12 +++- src/redux/modal/modal.selector.ts | 5 ++ src/redux/modal/modal.types.ts | 5 ++ .../LoadingIndicator.component.tsx | 26 ++++--- 17 files changed, 184 insertions(+), 28 deletions(-) create mode 100644 src/components/FunctionalArea/Modal/LayerFactoryModal/LayerFactoryModal.styles.css diff --git a/src/components/FunctionalArea/Modal/EditOverlayModal/EditOverlayModal.component.test.tsx b/src/components/FunctionalArea/Modal/EditOverlayModal/EditOverlayModal.component.test.tsx index b8015a4b..f8072beb 100644 --- a/src/components/FunctionalArea/Modal/EditOverlayModal/EditOverlayModal.component.test.tsx +++ b/src/components/FunctionalArea/Modal/EditOverlayModal/EditOverlayModal.component.test.tsx @@ -47,6 +47,7 @@ describe('EditOverlayModal - component', () => { molArtState: {}, overviewImagesState: {}, errorReportState: {}, + layerFactoryState: { id: undefined }, }, }); @@ -65,6 +66,7 @@ describe('EditOverlayModal - component', () => { molArtState: {}, overviewImagesState: {}, errorReportState: {}, + layerFactoryState: { id: undefined }, }, }); @@ -95,6 +97,7 @@ describe('EditOverlayModal - component', () => { molArtState: {}, overviewImagesState: {}, errorReportState: {}, + layerFactoryState: { id: undefined }, }, overlays: OVERLAYS_INITIAL_STATE_MOCK, }); @@ -130,6 +133,7 @@ describe('EditOverlayModal - component', () => { molArtState: {}, overviewImagesState: {}, errorReportState: {}, + layerFactoryState: { id: undefined }, }, overlays: OVERLAYS_INITIAL_STATE_MOCK, }); @@ -166,6 +170,7 @@ describe('EditOverlayModal - component', () => { molArtState: {}, overviewImagesState: {}, errorReportState: {}, + layerFactoryState: { id: undefined }, }, overlays: OVERLAYS_INITIAL_STATE_MOCK, }); @@ -201,6 +206,7 @@ describe('EditOverlayModal - component', () => { molArtState: {}, overviewImagesState: {}, errorReportState: {}, + layerFactoryState: { id: undefined }, }, overlays: OVERLAYS_INITIAL_STATE_MOCK, }); @@ -230,6 +236,7 @@ describe('EditOverlayModal - component', () => { molArtState: {}, overviewImagesState: {}, errorReportState: {}, + layerFactoryState: { id: undefined }, }, }); diff --git a/src/components/FunctionalArea/Modal/EditOverlayModal/hooks/useEditOverlay.test.ts b/src/components/FunctionalArea/Modal/EditOverlayModal/hooks/useEditOverlay.test.ts index 172a1026..16f43071 100644 --- a/src/components/FunctionalArea/Modal/EditOverlayModal/hooks/useEditOverlay.test.ts +++ b/src/components/FunctionalArea/Modal/EditOverlayModal/hooks/useEditOverlay.test.ts @@ -24,6 +24,7 @@ describe('useEditOverlay', () => { molArtState: {}, overviewImagesState: {}, errorReportState: {}, + layerFactoryState: { id: undefined }, }, }); @@ -60,6 +61,7 @@ describe('useEditOverlay', () => { molArtState: {}, overviewImagesState: {}, errorReportState: {}, + layerFactoryState: { id: undefined }, }, }); @@ -99,6 +101,7 @@ describe('useEditOverlay', () => { molArtState: {}, overviewImagesState: {}, errorReportState: {}, + layerFactoryState: { id: undefined }, }, }); @@ -134,6 +137,7 @@ describe('useEditOverlay', () => { molArtState: {}, overviewImagesState: {}, errorReportState: {}, + layerFactoryState: { id: undefined }, }, }); @@ -170,6 +174,7 @@ describe('useEditOverlay', () => { molArtState: {}, overviewImagesState: {}, errorReportState: {}, + layerFactoryState: { id: undefined }, }, }); diff --git a/src/components/FunctionalArea/Modal/LayerFactoryModal/LayerFactoryModal.component.tsx b/src/components/FunctionalArea/Modal/LayerFactoryModal/LayerFactoryModal.component.tsx index 410706e7..a5165763 100644 --- a/src/components/FunctionalArea/Modal/LayerFactoryModal/LayerFactoryModal.component.tsx +++ b/src/components/FunctionalArea/Modal/LayerFactoryModal/LayerFactoryModal.component.tsx @@ -1,20 +1,30 @@ import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; import { Button } from '@/shared/Button'; import { Input } from '@/shared/Input'; -import React, { useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { useAppSelector } from '@/redux/hooks/useAppSelector'; import { currentModelIdSelector } from '@/redux/models/models.selectors'; import { closeModal } from '@/redux/modal/modal.slice'; import { showToast } from '@/utils/showToast'; import { Switch } from '@/shared/Switch'; -import { LayerStoreInterface } from '@/redux/layers/layers.types'; -import { addLayerForModel, getLayersForModel } from '@/redux/layers/layers.thunks'; +import { LayerStoreInterface, LayerUpdateInterface } from '@/redux/layers/layers.types'; +import { + addLayerForModel, + getLayer, + getLayersForModel, + updateLayer, +} from '@/redux/layers/layers.thunks'; import { SerializedError } from '@reduxjs/toolkit'; +import { layerFactoryStateSelector } from '@/redux/modal/modal.selector'; +import './LayerFactoryModal.styles.css'; +import { LoadingIndicator } from '@/shared/LoadingIndicator'; export const LayerFactoryModal: React.FC = () => { const dispatch = useAppDispatch(); const currentModelId = useAppSelector(currentModelIdSelector); + const layerFactoryState = useAppSelector(layerFactoryStateSelector); + const [loaded, setLoaded] = useState<boolean>(false); const [data, setData] = useState<LayerStoreInterface>({ name: '', @@ -23,6 +33,29 @@ export const LayerFactoryModal: React.FC = () => { modelId: currentModelId, }); + const fetchData = useMemo(() => { + return async (layerId: number): Promise<void> => { + const layer = await dispatch(getLayer({ modelId: currentModelId, layerId })).unwrap(); + if (layer) { + setData({ + name: layer.name, + visible: layer.visible, + locked: layer.locked, + modelId: currentModelId, + }); + } + setLoaded(true); + }; + }, [currentModelId, dispatch]); + + useEffect(() => { + if (layerFactoryState.id) { + fetchData(layerFactoryState.id); + } else { + setLoaded(true); + } + }, [fetchData, layerFactoryState.id]); + const handleChange = (value: string | boolean, key: string): void => { setData(prevData => ({ ...prevData, [key]: value })); }; @@ -30,13 +63,25 @@ export const LayerFactoryModal: React.FC = () => { const handleSubmit = async (event: React.FormEvent<HTMLFormElement>): Promise<void> => { try { event.preventDefault(); - await dispatch(addLayerForModel(data)).unwrap(); + if (layerFactoryState.id) { + const payload = { + ...data, + layerId: layerFactoryState.id, + } as LayerUpdateInterface; + await dispatch(updateLayer(payload)).unwrap(); + showToast({ + type: 'success', + message: 'The layer has been successfully updated', + }); + } else { + await dispatch(addLayerForModel(data)).unwrap(); + showToast({ + type: 'success', + message: 'A new layer has been successfully added', + }); + } dispatch(closeModal()); dispatch(getLayersForModel(currentModelId)); - showToast({ - type: 'success', - message: 'A new layer has been successfully added', - }); } catch (error) { const typedError = error as SerializedError; showToast({ @@ -47,7 +92,12 @@ export const LayerFactoryModal: React.FC = () => { }; return ( - <div className="w-[400px] border border-t-[#E1E0E6] bg-white p-[24px]"> + <div className="relative w-[400px] border border-t-[#E1E0E6] bg-white p-[24px]"> + {!loaded && ( + <div className="c-layer-factory-loader"> + <LoadingIndicator width={44} height={44} /> + </div> + )} <form onSubmit={handleSubmit}> <label className="mb-6 block text-sm font-semibold" htmlFor="name"> Name: diff --git a/src/components/FunctionalArea/Modal/LayerFactoryModal/LayerFactoryModal.styles.css b/src/components/FunctionalArea/Modal/LayerFactoryModal/LayerFactoryModal.styles.css new file mode 100644 index 00000000..9178a2af --- /dev/null +++ b/src/components/FunctionalArea/Modal/LayerFactoryModal/LayerFactoryModal.styles.css @@ -0,0 +1,12 @@ +.c-layer-factory-loader { + width: 100%; + height: 100%; + margin-left: -24px; + margin-top: -24px; + background: #f9f9f980; + z-index: 1; + position: absolute; + display: flex; + align-items: center; + justify-content: center; +} diff --git a/src/components/FunctionalArea/Modal/PublicationsModal/PublicationsTable/ElementsOnMapCell/ElementLink/ElementLink.component.test.tsx b/src/components/FunctionalArea/Modal/PublicationsModal/PublicationsTable/ElementsOnMapCell/ElementLink/ElementLink.component.test.tsx index 9ed58cb2..38e85800 100644 --- a/src/components/FunctionalArea/Modal/PublicationsModal/PublicationsTable/ElementsOnMapCell/ElementLink/ElementLink.component.test.tsx +++ b/src/components/FunctionalArea/Modal/PublicationsModal/PublicationsTable/ElementsOnMapCell/ElementLink/ElementLink.component.test.tsx @@ -54,7 +54,7 @@ describe('ElementLink - component', () => { }); it('should show loading indicator', () => { - const loadingIndicator = screen.getByAltText('spinner icon'); + const loadingIndicator = screen.getByTestId('loading-indicator'); expect(loadingIndicator).toBeInTheDocument(); }); @@ -75,7 +75,7 @@ describe('ElementLink - component', () => { }); it('should not show loading indicator', async () => { - const loadingIndicator = screen.getByAltText('spinner icon'); + const loadingIndicator = screen.getByTestId('loading-indicator'); await waitFor(() => { expect(loadingIndicator).not.toBeInTheDocument(); diff --git a/src/components/Map/Drawer/BioEntityDrawer/ChemicalsList/ChemicalsList.component.test.tsx b/src/components/Map/Drawer/BioEntityDrawer/ChemicalsList/ChemicalsList.component.test.tsx index 03adc8fd..c40c8c87 100644 --- a/src/components/Map/Drawer/BioEntityDrawer/ChemicalsList/ChemicalsList.component.test.tsx +++ b/src/components/Map/Drawer/BioEntityDrawer/ChemicalsList/ChemicalsList.component.test.tsx @@ -47,7 +47,7 @@ describe('ChemicalsList - component', () => { }); it('should show loading indicator', () => { - expect(screen.getByAltText('spinner icon')).toBeInTheDocument(); + expect(screen.getByTestId('loading-indicator')).toBeInTheDocument(); }); }); diff --git a/src/components/Map/Drawer/BioEntityDrawer/DrugsList/DrugsList.component.test.tsx b/src/components/Map/Drawer/BioEntityDrawer/DrugsList/DrugsList.component.test.tsx index 0ec503c4..b04ddbab 100644 --- a/src/components/Map/Drawer/BioEntityDrawer/DrugsList/DrugsList.component.test.tsx +++ b/src/components/Map/Drawer/BioEntityDrawer/DrugsList/DrugsList.component.test.tsx @@ -47,7 +47,7 @@ describe('DrugsList - component', () => { }); it('should show loading indicator', () => { - expect(screen.getByAltText('spinner icon')).toBeInTheDocument(); + expect(screen.getByTestId('loading-indicator')).toBeInTheDocument(); }); }); diff --git a/src/components/Map/Drawer/LayersDrawer/LayersDrawer.component.tsx b/src/components/Map/Drawer/LayersDrawer/LayersDrawer.component.tsx index 0387ccc8..4c38de5e 100644 --- a/src/components/Map/Drawer/LayersDrawer/LayersDrawer.component.tsx +++ b/src/components/Map/Drawer/LayersDrawer/LayersDrawer.component.tsx @@ -21,6 +21,10 @@ export const LayersDrawer = (): JSX.Element => { dispatch(openLayerFactoryModal()); }; + const editLayer = (layerId: number): void => { + dispatch(openLayerFactoryModal(layerId)); + }; + return ( <div data-testid="layers-drawer" className="h-full max-h-full"> <DrawerHeading title="Layers" /> @@ -32,7 +36,10 @@ export const LayersDrawer = (): JSX.Element => { </div> {layersForCurrentModel.map(layer => ( <div key={layer.details.id} className="flex items-center justify-between border-b py-4"> - <h1>{layer.details.name}</h1> + <div className="flex items-center gap-3"> + <Button onClick={() => editLayer(layer.details.id)}>Edit</Button> + <h1>{layer.details.name}</h1> + </div> <Switch isChecked={layersVisibilityForCurrentModel[layer.details.id]} onToggle={value => diff --git a/src/redux/apiPath.ts b/src/redux/apiPath.ts index 8f767881..96388b3f 100644 --- a/src/redux/apiPath.ts +++ b/src/redux/apiPath.ts @@ -59,6 +59,10 @@ export const apiPath = { getLayerLines: (modelId: number, layerId: number): string => `projects/${PROJECT_ID}/maps/${modelId}/layers/${layerId}/lines/`, storeLayer: (modelId: number): string => `projects/${PROJECT_ID}/maps/${modelId}/layers/`, + updateLayer: (modelId: number, layerId: number): string => + `projects/${PROJECT_ID}/maps/${modelId}/layers/${layerId}`, + getLayer: (modelId: number, layerId: number): string => + `projects/${PROJECT_ID}/maps/${modelId}/layers/${layerId}`, getGlyphImage: (glyphId: number): string => `projects/${PROJECT_ID}/glyphs/${glyphId}/fileContent`, getNewReactionsForModel: (modelId: number): string => diff --git a/src/redux/layers/layers.thunks.ts b/src/redux/layers/layers.thunks.ts index 2d2699c3..346ef51e 100644 --- a/src/redux/layers/layers.thunks.ts +++ b/src/redux/layers/layers.thunks.ts @@ -8,13 +8,33 @@ import { getError } from '@/utils/error-report/getError'; import { axiosInstanceNewAPI } from '@/services/api/utils/axiosInstance'; import { layerSchema } from '@/models/layerSchema'; import { LAYERS_FETCHING_ERROR_PREFIX } from '@/redux/layers/layers.constants'; -import { LayerStoreInterface, LayersVisibilitiesState } from '@/redux/layers/layers.types'; +import { + LayerStoreInterface, + LayersVisibilitiesState, + LayerUpdateInterface, +} from '@/redux/layers/layers.types'; import { layerTextSchema } from '@/models/layerTextSchema'; import { layerRectSchema } from '@/models/layerRectSchema'; import { pageableSchema } from '@/models/pageableSchema'; import { layerOvalSchema } from '@/models/layerOvalSchema'; import { layerLineSchema } from '@/models/layerLineSchema'; +export const getLayer = createAsyncThunk< + Layer | null, + { modelId: number; layerId: number }, + ThunkConfig +>('vectorMap/getLayer', async ({ modelId, layerId }) => { + try { + const { data } = await axiosInstanceNewAPI.get<Layer>(apiPath.getLayer(modelId, layerId)); + + const isDataValid = validateDataUsingZodSchema(data, layerSchema); + + return isDataValid ? data : null; + } catch (error) { + return Promise.reject(getError({ error })); + } +}); + export const getLayersForModel = createAsyncThunk< LayersVisibilitiesState | undefined, number, @@ -83,3 +103,22 @@ export const addLayerForModel = createAsyncThunk<Layer | null, LayerStoreInterfa } }, ); + +export const updateLayer = createAsyncThunk<Layer | null, LayerUpdateInterface, ThunkConfig>( + 'vectorMap/updateLayer', + async ({ name, visible, locked, modelId, layerId }) => { + try { + const { data } = await axiosInstanceNewAPI.put<Layer>(apiPath.updateLayer(modelId, layerId), { + name, + visible, + locked, + }); + + const isDataValid = validateDataUsingZodSchema(data, layerSchema); + + return isDataValid ? data : null; + } catch (error) { + return Promise.reject(getError({ error })); + } + }, +); diff --git a/src/redux/layers/layers.types.ts b/src/redux/layers/layers.types.ts index 4c1f283f..a5c1b30e 100644 --- a/src/redux/layers/layers.types.ts +++ b/src/redux/layers/layers.types.ts @@ -8,6 +8,14 @@ export interface LayerStoreInterface { modelId: number; } +export interface LayerUpdateInterface { + layerId: number; + name: string; + visible: boolean; + locked: boolean; + modelId: number; +} + export type LayerState = { details: Layer; texts: LayerText[]; diff --git a/src/redux/modal/modal.constants.ts b/src/redux/modal/modal.constants.ts index d7dea45c..1184d4ed 100644 --- a/src/redux/modal/modal.constants.ts +++ b/src/redux/modal/modal.constants.ts @@ -13,4 +13,5 @@ export const MODAL_INITIAL_STATE: ModalState = { }, editOverlayState: null, errorReportState: {}, + layerFactoryState: { id: undefined }, }; diff --git a/src/redux/modal/modal.mock.ts b/src/redux/modal/modal.mock.ts index cde5fab5..1a7a519f 100644 --- a/src/redux/modal/modal.mock.ts +++ b/src/redux/modal/modal.mock.ts @@ -13,4 +13,5 @@ export const MODAL_INITIAL_STATE_MOCK: ModalState = { }, editOverlayState: null, errorReportState: {}, + layerFactoryState: { id: undefined }, }; diff --git a/src/redux/modal/modal.reducers.ts b/src/redux/modal/modal.reducers.ts index 78a651f2..3371ed3c 100644 --- a/src/redux/modal/modal.reducers.ts +++ b/src/redux/modal/modal.reducers.ts @@ -125,8 +125,16 @@ export const openToSModalReducer = (state: ModalState): void => { state.modalTitle = 'Terms of service!'; }; -export const openLayerFactoryModalReducer = (state: ModalState): void => { +export const openLayerFactoryModalReducer = ( + state: ModalState, + action: PayloadAction<number | undefined>, +): void => { + state.layerFactoryState = { id: action.payload }; state.isOpen = true; state.modalName = 'layer-factory'; - state.modalTitle = 'Add new layer'; + if (action.payload) { + state.modalTitle = 'Edit layer'; + } else { + state.modalTitle = 'Add new layer'; + } }; diff --git a/src/redux/modal/modal.selector.ts b/src/redux/modal/modal.selector.ts index 654dfb7a..7f7c4441 100644 --- a/src/redux/modal/modal.selector.ts +++ b/src/redux/modal/modal.selector.ts @@ -21,6 +21,11 @@ export const currentEditedOverlaySelector = createSelector( modal => modal.editOverlayState, ); +export const layerFactoryStateSelector = createSelector( + modalSelector, + modal => modal.layerFactoryState, +); + export const currentErrorDataSelector = createSelector( modalSelector, modal => modal?.errorReportState.errorData || undefined, diff --git a/src/redux/modal/modal.types.ts b/src/redux/modal/modal.types.ts index ea772096..1b544f52 100644 --- a/src/redux/modal/modal.types.ts +++ b/src/redux/modal/modal.types.ts @@ -17,6 +17,10 @@ export type ErrorRepostState = { export type EditOverlayState = MapOverlay | null; +export type LayerFactoryState = { + id: number | undefined; +}; + export interface ModalState { isOpen: boolean; modalName: ModalName; @@ -25,6 +29,7 @@ export interface ModalState { molArtState: MolArtModalState; errorReportState: ErrorRepostState; editOverlayState: EditOverlayState; + layerFactoryState: LayerFactoryState; } export type OpenEditOverlayModalPayload = MapOverlay; diff --git a/src/shared/LoadingIndicator/LoadingIndicator.component.tsx b/src/shared/LoadingIndicator/LoadingIndicator.component.tsx index 39a2a413..e63f0267 100644 --- a/src/shared/LoadingIndicator/LoadingIndicator.component.tsx +++ b/src/shared/LoadingIndicator/LoadingIndicator.component.tsx @@ -1,6 +1,3 @@ -import Image from 'next/image'; -import spinnerIcon from '@/assets/vectors/icons/spinner.svg'; - type LoadingIndicatorProps = { height?: number; width?: number; @@ -13,12 +10,19 @@ export const LoadingIndicator = ({ height = DEFAULT_HEIGHT, width = DEFAULT_WIDTH, }: LoadingIndicatorProps): JSX.Element => ( - <Image - src={spinnerIcon} - alt="spinner icon" - height={height} - width={width} - className="animate-spin" - data-testid="loading-indicator" - /> + <div style={{ width, height }} className="animate-spin" data-testid="loading-indicator"> + <svg width={width} height={height} viewBox="0 0 50 50" xmlns="http://www.w3.org/2000/svg"> + <circle + cx="25" + cy="25" + r="20" + fill="none" + stroke="currentColor" + strokeWidth="4" + strokeDasharray="90, 150" + strokeDashoffset="0" + strokeLinecap="round" + /> + </svg> + </div> ); -- GitLab From 9989610faf52020b9f68efc0c6cd1cfb0804c5e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mi=C5=82osz=20Grocholewski?= <m.grocholewski@atcomp.pl> Date: Wed, 18 Dec 2024 09:58:50 +0100 Subject: [PATCH 03/22] feat(vector-map): implement removing an existing layer --- pages/_document.tsx | 1 + .../Modal/QuestionModal/Question.styles.css | 27 ++++++ .../QuestionModal/QustionModal.component.tsx | 56 ++++++++++++ .../LayersDrawer/LayersDrawer.component.tsx | 87 +++++++++++++++---- src/redux/apiPath.ts | 2 + src/redux/layers/layers.thunks.ts | 13 +++ src/shared/Button/Button.component.tsx | 7 +- src/shared/Icon/Icon.component.tsx | 2 + src/shared/Icon/Icons/QuestionIcon.tsx | 23 +++++ src/types/iconTypes.ts | 3 +- 10 files changed, 202 insertions(+), 19 deletions(-) create mode 100644 src/components/FunctionalArea/Modal/QuestionModal/Question.styles.css create mode 100644 src/components/FunctionalArea/Modal/QuestionModal/QustionModal.component.tsx create mode 100644 src/shared/Icon/Icons/QuestionIcon.tsx diff --git a/pages/_document.tsx b/pages/_document.tsx index 94c6212c..c0d2fd72 100644 --- a/pages/_document.tsx +++ b/pages/_document.tsx @@ -8,6 +8,7 @@ const Document = (): React.ReactNode => ( </Head> <body> <Main /> + <div id="modal-root" /> <NextScript /> <Script src="./config.js" strategy="beforeInteractive" /> </body> diff --git a/src/components/FunctionalArea/Modal/QuestionModal/Question.styles.css b/src/components/FunctionalArea/Modal/QuestionModal/Question.styles.css new file mode 100644 index 00000000..9d2ec888 --- /dev/null +++ b/src/components/FunctionalArea/Modal/QuestionModal/Question.styles.css @@ -0,0 +1,27 @@ +.c-question-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + justify-content: center; + align-items: center; + z-index: 11; +} + +.c-question-modal { + width: 400px; + height: auto; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 2rem; + background-color: #fff; + padding: 20px; + border-radius: 8px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); + text-align: center; +} diff --git a/src/components/FunctionalArea/Modal/QuestionModal/QustionModal.component.tsx b/src/components/FunctionalArea/Modal/QuestionModal/QustionModal.component.tsx new file mode 100644 index 00000000..3f7f3db7 --- /dev/null +++ b/src/components/FunctionalArea/Modal/QuestionModal/QustionModal.component.tsx @@ -0,0 +1,56 @@ +import React, { ReactPortal } from 'react'; +import ReactDOM from 'react-dom'; +import { Button } from '@/shared/Button'; +import './Question.styles.css'; +import { QuestionIcon } from '@/shared/Icon/Icons/QuestionIcon'; + +type QuestionModalProps = { + isOpen: boolean; + onClose: () => void; + onConfirm: () => void; + question: string; +}; + +const QuestionModal = ({ + isOpen, + onClose, + onConfirm, + question, +}: QuestionModalProps): null | ReactPortal => { + if (!isOpen) return null; + + const domElement = document.getElementById('modal-root'); + + if (!domElement) { + return null; + } + + return ReactDOM.createPortal( + <div className="c-question-overlay"> + <div className="c-question-modal"> + <QuestionIcon size={94} /> + <h1 className="text-center text-2xl font-semibold">{question}</h1> + <div className="flex w-full justify-center gap-10"> + <Button + type="submit" + className="w-[100px] justify-center text-base font-medium" + variantStyles="remove" + onClick={onClose} + > + No + </Button> + <Button + type="submit" + className="w-[100px] justify-center text-base font-medium" + onClick={onConfirm} + > + Yes + </Button> + </div> + </div> + </div>, + domElement, + ); +}; + +export default QuestionModal; diff --git a/src/components/Map/Drawer/LayersDrawer/LayersDrawer.component.tsx b/src/components/Map/Drawer/LayersDrawer/LayersDrawer.component.tsx index 4c38de5e..b431590c 100644 --- a/src/components/Map/Drawer/LayersDrawer/LayersDrawer.component.tsx +++ b/src/components/Map/Drawer/LayersDrawer/LayersDrawer.component.tsx @@ -10,23 +10,66 @@ import { setLayerVisibility } from '@/redux/layers/layers.slice'; import { currentModelIdSelector } from '@/redux/models/models.selectors'; import { Button } from '@/shared/Button'; import { openLayerFactoryModal } from '@/redux/modal/modal.slice'; +import QuestionModal from '@/components/FunctionalArea/Modal/QuestionModal/QustionModal.component'; +import { useState } from 'react'; +import { getLayersForModel, removeLayer } from '@/redux/layers/layers.thunks'; +import { showToast } from '@/utils/showToast'; +import { SerializedError } from '@reduxjs/toolkit'; export const LayersDrawer = (): JSX.Element => { const layersForCurrentModel = useAppSelector(layersForCurrentModelSelector); const layersVisibilityForCurrentModel = useAppSelector(layersVisibilityForCurrentModelSelector); const currentModelId = useAppSelector(currentModelIdSelector); const dispatch = useAppDispatch(); + const [isModalOpen, setIsModalOpen] = useState(false); + const [layerId, setLayerId] = useState<number | null>(null); const addNewLayer = (): void => { dispatch(openLayerFactoryModal()); }; - const editLayer = (layerId: number): void => { - dispatch(openLayerFactoryModal(layerId)); + const editLayer = (layerIdToEdit: number): void => { + dispatch(openLayerFactoryModal(layerIdToEdit)); + }; + + const rejectRemove = (): void => { + setIsModalOpen(false); + }; + + const confirmRemove = async (): Promise<void> => { + if (!layerId) { + return; + } + try { + await dispatch(removeLayer({ modelId: currentModelId, layerId })).unwrap(); + showToast({ + type: 'success', + message: 'The layer has been successfully removed', + }); + setIsModalOpen(false); + dispatch(getLayersForModel(currentModelId)); + } catch (error) { + const typedError = error as SerializedError; + showToast({ + type: 'error', + message: typedError.message || 'An error occurred while removing the layer', + }); + } + }; + + const onRemoveLayer = (layerIdToRemove: number): void => { + setLayerId(layerIdToRemove); + setIsModalOpen(true); }; return ( <div data-testid="layers-drawer" className="h-full max-h-full"> + <QuestionModal + isOpen={isModalOpen} + onClose={rejectRemove} + onConfirm={confirmRemove} + question="Are you sure you want to remove the layer?" + /> <DrawerHeading title="Layers" /> <div className="flex h-[calc(100%-93px)] max-h-[calc(100%-93px)] flex-col overflow-y-auto px-6"> <div className="flex justify-start pt-2"> @@ -35,23 +78,33 @@ export const LayersDrawer = (): JSX.Element => { </Button> </div> {layersForCurrentModel.map(layer => ( - <div key={layer.details.id} className="flex items-center justify-between border-b py-4"> - <div className="flex items-center gap-3"> + <div + key={layer.details.id} + className="flex items-center justify-between gap-3 border-b py-4" + > + <h1 className="truncate">{layer.details.name}</h1> + <div className="flex items-center gap-2"> + <Switch + isChecked={layersVisibilityForCurrentModel[layer.details.id]} + onToggle={value => + dispatch( + setLayerVisibility({ + modelId: currentModelId, + visible: value, + layerId: layer.details.id, + }), + ) + } + /> <Button onClick={() => editLayer(layer.details.id)}>Edit</Button> - <h1>{layer.details.name}</h1> + <Button + onClick={() => onRemoveLayer(layer.details.id)} + color="error" + variantStyles="remove" + > + Remove + </Button> </div> - <Switch - isChecked={layersVisibilityForCurrentModel[layer.details.id]} - onToggle={value => - dispatch( - setLayerVisibility({ - modelId: currentModelId, - visible: value, - layerId: layer.details.id, - }), - ) - } - /> </div> ))} </div> diff --git a/src/redux/apiPath.ts b/src/redux/apiPath.ts index 96388b3f..6a080e33 100644 --- a/src/redux/apiPath.ts +++ b/src/redux/apiPath.ts @@ -61,6 +61,8 @@ export const apiPath = { storeLayer: (modelId: number): string => `projects/${PROJECT_ID}/maps/${modelId}/layers/`, updateLayer: (modelId: number, layerId: number): string => `projects/${PROJECT_ID}/maps/${modelId}/layers/${layerId}`, + removeLayer: (modelId: number, layerId: number): string => + `projects/${PROJECT_ID}/maps/${modelId}/layers/${layerId}`, getLayer: (modelId: number, layerId: number): string => `projects/${PROJECT_ID}/maps/${modelId}/layers/${layerId}`, getGlyphImage: (glyphId: number): string => diff --git a/src/redux/layers/layers.thunks.ts b/src/redux/layers/layers.thunks.ts index 346ef51e..c8ee4fd5 100644 --- a/src/redux/layers/layers.thunks.ts +++ b/src/redux/layers/layers.thunks.ts @@ -122,3 +122,16 @@ export const updateLayer = createAsyncThunk<Layer | null, LayerUpdateInterface, } }, ); + +export const removeLayer = createAsyncThunk< + void, + { modelId: number; layerId: number }, + ThunkConfig + // eslint-disable-next-line consistent-return +>('vectorMap/removeLayer', async ({ modelId, layerId }) => { + try { + await axiosInstanceNewAPI.delete<void>(apiPath.removeLayer(modelId, layerId)); + } catch (error) { + return Promise.reject(getError({ error })); + } +}); diff --git a/src/shared/Button/Button.component.tsx b/src/shared/Button/Button.component.tsx index a7f831e7..72833322 100644 --- a/src/shared/Button/Button.component.tsx +++ b/src/shared/Button/Button.component.tsx @@ -4,7 +4,7 @@ import { twMerge } from 'tailwind-merge'; import type { ButtonHTMLAttributes } from 'react'; import type { IconTypes } from '@/types/iconTypes'; -type VariantStyle = 'primary' | 'secondary' | 'ghost' | 'quiet'; +type VariantStyle = 'primary' | 'secondary' | 'ghost' | 'quiet' | 'remove'; export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> { variantStyles?: VariantStyle; @@ -34,6 +34,11 @@ const variants = { 'text-font-500 bg-white-pearl hover:bg-greyscale-500 active:bg-greyscale-600 disabled:text-font-400 disabled:bg-white-pearl', icon: 'fill-font-500 group-disabled:fill-font-400', }, + remove: { + button: + 'text-white-pearl bg-red-500 hover:bg-red-600 active:bg-red-700 disabled:bg-greyscale-700', + icon: 'fill-white-pearl', + }, } as const; export const Button = ({ diff --git a/src/shared/Icon/Icon.component.tsx b/src/shared/Icon/Icon.component.tsx index 63456954..f22e882b 100644 --- a/src/shared/Icon/Icon.component.tsx +++ b/src/shared/Icon/Icon.component.tsx @@ -8,6 +8,7 @@ import { CloseIcon } from '@/shared/Icon/Icons/CloseIcon'; import { DotsIcon } from '@/shared/Icon/Icons/DotsIcon'; import { ExportIcon } from '@/shared/Icon/Icons/ExportIcon'; import { LayersIcon } from '@/shared/Icon/Icons/LayersIcon'; +import { QuestionIcon } from '@/shared/Icon/Icons/QuestionIcon'; import { InfoIcon } from '@/shared/Icon/Icons/InfoIcon'; import { LegendIcon } from '@/shared/Icon/Icons/LegendIcon'; import { PageIcon } from '@/shared/Icon/Icons/PageIcon'; @@ -43,6 +44,7 @@ const icons: Record<IconTypes, IconComponentType> = { admin: AdminIcon, export: ExportIcon, layers: LayersIcon, + question: QuestionIcon, info: InfoIcon, download: DownloadIcon, legend: LegendIcon, diff --git a/src/shared/Icon/Icons/QuestionIcon.tsx b/src/shared/Icon/Icons/QuestionIcon.tsx new file mode 100644 index 00000000..9deb2242 --- /dev/null +++ b/src/shared/Icon/Icons/QuestionIcon.tsx @@ -0,0 +1,23 @@ +/* eslint-disable no-magic-numbers */ +interface QuestionIconProps { + className?: string; + size?: number; +} + +export const QuestionIcon = ({ className, size = 20 }: QuestionIconProps): JSX.Element => ( + <svg + width={size} + height={size} + viewBox="0 0 100 100" + fill="none" + className={className} + xmlns="http://www.w3.org/2000/svg" + > + <circle cx="50" cy="50" r="44" stroke="black" strokeWidth="2" fill="none" /> + <path + d="M50 80a5 5 0 1 1 0-10 5 5 0 0 1 0 10zm1-20H47c0-6.6 3.6-8.8 6.6-10.9 3.4-2.3 5.4-4.5 5.4-8.1 0-5.5-4.5-10-10-10s-10 4.5-10 10H33c0-9.4 7.6-17 17-17s17 7.6 17 17c0 5.5-3.3 8.6-6.9 11.1-2.6 1.7-4.1 3.3-4.1 6.9z" + fill="black" + strokeWidth={1} + /> + </svg> +); diff --git a/src/types/iconTypes.ts b/src/types/iconTypes.ts index 5000d642..76818394 100644 --- a/src/types/iconTypes.ts +++ b/src/types/iconTypes.ts @@ -23,6 +23,7 @@ export type IconTypes = | 'clear' | 'user' | 'manage-user' - | 'download'; + | 'download' + | 'question'; export type IconComponentType = ({ className }: { className: string }) => JSX.Element; -- GitLab From 3aef53f4de020d0be3433be63cc27f3f1afe9694 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mi=C5=82osz=20Grocholewski?= <m.grocholewski@atcomp.pl> Date: Wed, 18 Dec 2024 10:38:31 +0100 Subject: [PATCH 04/22] feat(vector-map): add tests for layers crud --- .../LayerFactoryModal.component.test.tsx | 88 ++++++++++++++++++ .../LayerFactoryModal.component.tsx | 10 ++- .../QuestionModal.component.test.tsx | 58 ++++++++++++ src/models/fixtures/layerFixture.ts | 9 ++ src/redux/layers/layers.thunks.test.ts | 90 ++++++++++++++++++- src/shared/Switch/Switch.component.tsx | 6 +- 6 files changed, 257 insertions(+), 4 deletions(-) create mode 100644 src/components/FunctionalArea/Modal/LayerFactoryModal/LayerFactoryModal.component.test.tsx create mode 100644 src/components/FunctionalArea/Modal/QuestionModal/QuestionModal.component.test.tsx create mode 100644 src/models/fixtures/layerFixture.ts diff --git a/src/components/FunctionalArea/Modal/LayerFactoryModal/LayerFactoryModal.component.test.tsx b/src/components/FunctionalArea/Modal/LayerFactoryModal/LayerFactoryModal.component.test.tsx new file mode 100644 index 00000000..037943f2 --- /dev/null +++ b/src/components/FunctionalArea/Modal/LayerFactoryModal/LayerFactoryModal.component.test.tsx @@ -0,0 +1,88 @@ +/* eslint-disable no-magic-numbers */ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { StoreType } from '@/redux/store'; +import { + InitialStoreState, + getReduxWrapperWithStore, +} from '@/utils/testing/getReduxWrapperWithStore'; +import { mockNetworkNewAPIResponse } from '@/utils/mockNetworkResponse'; +import { overlaysFixture } from '@/models/fixtures/overlaysFixture'; +import { apiPath } from '@/redux/apiPath'; +import { HttpStatusCode } from 'axios'; +import { layerFixture } from '@/models/fixtures/layerFixture'; +import { layersFixture } from '@/models/fixtures/layersFixture'; +import { layerTextsFixture } from '@/models/fixtures/layerTextsFixture'; +import { layerRectsFixture } from '@/models/fixtures/layerRectsFixture'; +import { layerOvalsFixture } from '@/models/fixtures/layerOvalsFixture'; +import { layerLinesFixture } from '@/models/fixtures/layerLinesFixture'; +import { act } from 'react-dom/test-utils'; +import { LayerFactoryModal } from './LayerFactoryModal.component'; + +const mockedAxiosNewClient = mockNetworkNewAPIResponse(); + +const renderComponent = (initialStoreState: InitialStoreState = {}): { store: StoreType } => { + const { Wrapper, store } = getReduxWrapperWithStore(initialStoreState); + + return ( + render( + <Wrapper> + <LayerFactoryModal /> + </Wrapper>, + ), + { + store, + } + ); +}; + +describe('LayerFactoryModal - component', () => { + it('should render LayerFactoryModal component', () => { + renderComponent(); + + const name = screen.getByTestId('layer-factory-name'); + const visible = screen.getByTestId('layer-factory-visible'); + const locked = screen.getByTestId('layer-factory-locked'); + expect(name).toBeInTheDocument(); + expect(visible).toBeInTheDocument(); + expect(locked).toBeInTheDocument(); + }); + + it('should handles input change correctly', () => { + renderComponent(); + + const nameInput: HTMLInputElement = screen.getByTestId('layer-factory-name'); + + fireEvent.change(nameInput, { target: { value: 'test layer' } }); + + expect(nameInput.value).toBe('test layer'); + }); + + it('should fetch layers when the form is successfully submitted', async () => { + mockedAxiosNewClient.onPost(apiPath.storeLayer(0)).reply(HttpStatusCode.Ok, layerFixture); + mockedAxiosNewClient.onGet(apiPath.getLayers(0)).reply(HttpStatusCode.Ok, layersFixture); + mockedAxiosNewClient + .onGet(apiPath.getLayerTexts(0, layersFixture.content[0].id)) + .reply(HttpStatusCode.Ok, layerTextsFixture); + mockedAxiosNewClient + .onGet(apiPath.getLayerRects(0, layersFixture.content[0].id)) + .reply(HttpStatusCode.Ok, layerRectsFixture); + mockedAxiosNewClient + .onGet(apiPath.getLayerOvals(0, layersFixture.content[0].id)) + .reply(HttpStatusCode.Ok, layerOvalsFixture); + mockedAxiosNewClient + .onGet(apiPath.getLayerLines(0, layersFixture.content[0].id)) + .reply(HttpStatusCode.Ok, layerLinesFixture); + + const { store } = renderComponent(); + const nameInput: HTMLInputElement = screen.getByTestId('layer-factory-name'); + const submitButton = screen.getByTestId('submit'); + + fireEvent.change(nameInput, { target: { value: 'test layer' } }); + act(() => { + submitButton.click(); + }); + await waitFor(() => { + expect(store.getState().layers[0].loading).toBe('succeeded'); + }); + }); +}); diff --git a/src/components/FunctionalArea/Modal/LayerFactoryModal/LayerFactoryModal.component.tsx b/src/components/FunctionalArea/Modal/LayerFactoryModal/LayerFactoryModal.component.tsx index a5165763..21835162 100644 --- a/src/components/FunctionalArea/Modal/LayerFactoryModal/LayerFactoryModal.component.tsx +++ b/src/components/FunctionalArea/Modal/LayerFactoryModal/LayerFactoryModal.component.tsx @@ -83,6 +83,7 @@ export const LayerFactoryModal: React.FC = () => { dispatch(closeModal()); dispatch(getLayersForModel(currentModelId)); } catch (error) { + console.log('error', error); const typedError = error as SerializedError; showToast({ type: 'error', @@ -104,6 +105,7 @@ export const LayerFactoryModal: React.FC = () => { <Input type="text" id="name" + data-testid="layer-factory-name" placeholder="Layer name here..." value={data.name} onChange={event => { @@ -119,6 +121,7 @@ export const LayerFactoryModal: React.FC = () => { Visible: <Switch id="visible" + data-testid="layer-factory-visible" isChecked={data.visible} onToggle={value => handleChange(value, 'visible')} /> @@ -130,11 +133,16 @@ export const LayerFactoryModal: React.FC = () => { Locked: <Switch id="locked" + data-testid="layer-factory-locked" isChecked={data.locked} onToggle={value => handleChange(value, 'locked')} /> </label> - <Button type="submit" className="w-full justify-center text-base font-medium"> + <Button + type="submit" + className="w-full justify-center text-base font-medium" + data-testid="submit" + > Submit </Button> </form> diff --git a/src/components/FunctionalArea/Modal/QuestionModal/QuestionModal.component.test.tsx b/src/components/FunctionalArea/Modal/QuestionModal/QuestionModal.component.test.tsx new file mode 100644 index 00000000..556cf577 --- /dev/null +++ b/src/components/FunctionalArea/Modal/QuestionModal/QuestionModal.component.test.tsx @@ -0,0 +1,58 @@ +/* eslint-disable no-magic-numbers */ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import QuestionModal from './QustionModal.component'; + +beforeEach(() => { + const modalRoot = document.createElement('div'); + modalRoot.setAttribute('id', 'modal-root'); + document.body.appendChild(modalRoot); +}); + +afterEach(() => { + const modalRoot = document.getElementById('modal-root'); + if (modalRoot) { + document.body.removeChild(modalRoot); + } +}); + +describe('QuestionModal', () => { + const defaultProps = { + isOpen: true, + onClose: jest.fn(), + onConfirm: jest.fn(), + question: 'Are you sure?', + }; + + it('should not render when isOpen is false', () => { + render(<QuestionModal {...defaultProps} isOpen={false} />); + const modalContent = screen.queryByText(defaultProps.question); + expect(modalContent).not.toBeInTheDocument(); + }); + + it('should render the question when isOpen is true', () => { + render(<QuestionModal {...defaultProps} />); + expect(screen.getByText('Are you sure?')).toBeInTheDocument(); + }); + + it('should call onClose when "No" button is clicked', () => { + render(<QuestionModal {...defaultProps} />); + const noButton = screen.getByText('No'); + fireEvent.click(noButton); + expect(defaultProps.onClose).toHaveBeenCalledTimes(1); + }); + + it('should call onConfirm when "Yes" button is clicked', () => { + render(<QuestionModal {...defaultProps} />); + const yesButton = screen.getByText('Yes'); + fireEvent.click(yesButton); + expect(defaultProps.onConfirm).toHaveBeenCalledTimes(1); + }); + + it('should render inside the modal-root portal', () => { + render(<QuestionModal {...defaultProps} />); + const modalRoot = document.getElementById('modal-root'); + expect(modalRoot).toContainElement(screen.getByText('Are you sure?')); + }); +}); diff --git a/src/models/fixtures/layerFixture.ts b/src/models/fixtures/layerFixture.ts new file mode 100644 index 00000000..9f9d4843 --- /dev/null +++ b/src/models/fixtures/layerFixture.ts @@ -0,0 +1,9 @@ +import { ZOD_SEED } from '@/constants'; +// eslint-disable-next-line import/no-extraneous-dependencies +import { createFixture } from 'zod-fixture'; +import { layerSchema } from '@/models/layerSchema'; + +export const layerFixture = createFixture(layerSchema, { + seed: ZOD_SEED, + array: { min: 1, max: 1 }, +}); diff --git a/src/redux/layers/layers.thunks.test.ts b/src/redux/layers/layers.thunks.test.ts index 234d9950..c8b9b2bc 100644 --- a/src/redux/layers/layers.thunks.test.ts +++ b/src/redux/layers/layers.thunks.test.ts @@ -7,12 +7,19 @@ import { import { mockNetworkNewAPIResponse } from '@/utils/mockNetworkResponse'; import { HttpStatusCode } from 'axios'; import { LayersState } from '@/redux/layers/layers.types'; -import { getLayersForModel } from '@/redux/layers/layers.thunks'; +import { + addLayerForModel, + getLayer, + getLayersForModel, + removeLayer, + updateLayer, +} from '@/redux/layers/layers.thunks'; import { layersFixture } from '@/models/fixtures/layersFixture'; import { layerTextsFixture } from '@/models/fixtures/layerTextsFixture'; import { layerRectsFixture } from '@/models/fixtures/layerRectsFixture'; import { layerOvalsFixture } from '@/models/fixtures/layerOvalsFixture'; import { layerLinesFixture } from '@/models/fixtures/layerLinesFixture'; +import { layerFixture } from '@/models/fixtures/layerFixture'; import layersReducer from './layers.slice'; const mockedAxiosClient = mockNetworkNewAPIResponse(); @@ -65,4 +72,85 @@ describe('layers thunks', () => { expect(payload).toEqual(undefined); }); }); + + describe('getLayer', () => { + it('should return a layer when data is valid', async () => { + mockedAxiosClient.onGet(apiPath.getLayer(1, 2)).reply(HttpStatusCode.Ok, layerFixture); + + const { payload } = await store.dispatch(getLayer({ modelId: 1, layerId: 2 })); + expect(payload).toEqual(layerFixture); + }); + + it('should return null when data is invalid', async () => { + mockedAxiosClient.onGet(apiPath.getLayer(1, 2)).reply(HttpStatusCode.Ok, { invalid: 'data' }); + + const { payload } = await store.dispatch(getLayer({ modelId: 1, layerId: 2 })); + expect(payload).toBeNull(); + }); + }); + + describe('addLayerForModel', () => { + it('should add a layer when data is valid', async () => { + mockedAxiosClient.onPost(apiPath.storeLayer(1)).reply(HttpStatusCode.Created, layerFixture); + + const { payload } = await store.dispatch( + addLayerForModel({ name: 'New Layer', visible: true, locked: false, modelId: 1 }), + ); + expect(payload).toEqual(layerFixture); + }); + + it('should return null when response data is invalid', async () => { + mockedAxiosClient + .onPost(apiPath.storeLayer(1)) + .reply(HttpStatusCode.Created, { invalid: 'data' }); + + const { payload } = await store.dispatch( + addLayerForModel({ name: 'New Layer', visible: true, locked: false, modelId: 1 }), + ); + expect(payload).toBeNull(); + }); + }); + + describe('updateLayer', () => { + it('should update a layer successfully', async () => { + mockedAxiosClient.onPut(apiPath.updateLayer(1, 2)).reply(HttpStatusCode.Ok, layerFixture); + + const { payload } = await store.dispatch( + updateLayer({ + name: 'Updated Layer', + visible: false, + locked: true, + modelId: 1, + layerId: 2, + }), + ); + expect(payload).toEqual(layerFixture); + }); + + it('should return null for invalid data', async () => { + mockedAxiosClient + .onPut(apiPath.updateLayer(1, 2)) + .reply(HttpStatusCode.Ok, { invalid: 'data' }); + + const { payload } = await store.dispatch( + updateLayer({ + name: 'Updated Layer', + visible: false, + locked: true, + modelId: 1, + layerId: 2, + }), + ); + expect(payload).toBeNull(); + }); + }); + + describe('removeLayer', () => { + it('should successfully remove a layer', async () => { + mockedAxiosClient.onDelete(apiPath.removeLayer(1, 2)).reply(HttpStatusCode.NoContent); + + const result = await store.dispatch(removeLayer({ modelId: 1, layerId: 2 })); + expect(result.meta.requestStatus).toBe('fulfilled'); + }); + }); }); diff --git a/src/shared/Switch/Switch.component.tsx b/src/shared/Switch/Switch.component.tsx index a3d9fafb..b519870e 100644 --- a/src/shared/Switch/Switch.component.tsx +++ b/src/shared/Switch/Switch.component.tsx @@ -1,9 +1,9 @@ import { twMerge } from 'tailwind-merge'; -import { useEffect, useState } from 'react'; +import { type ButtonHTMLAttributes, useEffect, useState } from 'react'; type VariantStyle = 'primary' | 'secondary' | 'ghost' | 'quiet'; -export interface SwitchProps { +export interface SwitchProps extends ButtonHTMLAttributes<HTMLButtonElement> { variantStyles?: VariantStyle; isChecked?: boolean; onToggle?: (checked: boolean) => void; @@ -34,6 +34,7 @@ export const Switch = ({ isChecked = false, onToggle, id, + ...props }: SwitchProps): JSX.Element => { const [checked, setChecked] = useState(isChecked); @@ -59,6 +60,7 @@ export const Switch = ({ checked ? 'bg-primary-600' : '', )} onClick={handleToggle} + {...props} > <span className={twMerge( -- GitLab From 8d5c50e26f08a0a093398508fefd406bb60a5da2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mi=C5=82osz=20Grocholewski?= <m.grocholewski@atcomp.pl> Date: Wed, 18 Dec 2024 11:14:57 +0100 Subject: [PATCH 05/22] feat(vector-map): implement image objects for layers --- .../LayerFactoryModal.component.test.tsx | 5 +++- .../LayerFactoryModal.component.tsx | 1 - .../useOlMapAdditionalLayers.ts | 1 + .../utils/shapes/layer/Layer.test.ts | 11 +++++++ .../utils/shapes/layer/Layer.ts | 29 ++++++++++++++++++- src/models/fixtures/layerImagesFixture.ts | 10 +++++++ src/models/layerImageSchema.ts | 11 +++++++ src/redux/apiPath.ts | 2 ++ src/redux/layers/layers.reducers.test.ts | 9 ++++++ src/redux/layers/layers.thunks.test.ts | 5 ++++ src/redux/layers/layers.thunks.ts | 19 +++++++----- src/redux/layers/layers.types.ts | 3 +- src/types/models.ts | 2 ++ 13 files changed, 97 insertions(+), 11 deletions(-) create mode 100644 src/models/fixtures/layerImagesFixture.ts create mode 100644 src/models/layerImageSchema.ts diff --git a/src/components/FunctionalArea/Modal/LayerFactoryModal/LayerFactoryModal.component.test.tsx b/src/components/FunctionalArea/Modal/LayerFactoryModal/LayerFactoryModal.component.test.tsx index 037943f2..91d4fb0e 100644 --- a/src/components/FunctionalArea/Modal/LayerFactoryModal/LayerFactoryModal.component.test.tsx +++ b/src/components/FunctionalArea/Modal/LayerFactoryModal/LayerFactoryModal.component.test.tsx @@ -6,7 +6,6 @@ import { getReduxWrapperWithStore, } from '@/utils/testing/getReduxWrapperWithStore'; import { mockNetworkNewAPIResponse } from '@/utils/mockNetworkResponse'; -import { overlaysFixture } from '@/models/fixtures/overlaysFixture'; import { apiPath } from '@/redux/apiPath'; import { HttpStatusCode } from 'axios'; import { layerFixture } from '@/models/fixtures/layerFixture'; @@ -16,6 +15,7 @@ import { layerRectsFixture } from '@/models/fixtures/layerRectsFixture'; import { layerOvalsFixture } from '@/models/fixtures/layerOvalsFixture'; import { layerLinesFixture } from '@/models/fixtures/layerLinesFixture'; import { act } from 'react-dom/test-utils'; +import { layerImagesFixture } from '@/models/fixtures/layerImagesFixture'; import { LayerFactoryModal } from './LayerFactoryModal.component'; const mockedAxiosNewClient = mockNetworkNewAPIResponse(); @@ -72,6 +72,9 @@ describe('LayerFactoryModal - component', () => { mockedAxiosNewClient .onGet(apiPath.getLayerLines(0, layersFixture.content[0].id)) .reply(HttpStatusCode.Ok, layerLinesFixture); + mockedAxiosNewClient + .onGet(apiPath.getLayerImages(0, layersFixture.content[0].id)) + .reply(HttpStatusCode.Ok, layerImagesFixture); const { store } = renderComponent(); const nameInput: HTMLInputElement = screen.getByTestId('layer-factory-name'); diff --git a/src/components/FunctionalArea/Modal/LayerFactoryModal/LayerFactoryModal.component.tsx b/src/components/FunctionalArea/Modal/LayerFactoryModal/LayerFactoryModal.component.tsx index 21835162..8d925951 100644 --- a/src/components/FunctionalArea/Modal/LayerFactoryModal/LayerFactoryModal.component.tsx +++ b/src/components/FunctionalArea/Modal/LayerFactoryModal/LayerFactoryModal.component.tsx @@ -83,7 +83,6 @@ export const LayerFactoryModal: React.FC = () => { dispatch(closeModal()); dispatch(getLayersForModel(currentModelId)); } catch (error) { - console.log('error', error); const typedError = error as SerializedError; showToast({ type: 'error', diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/config/additionalLayers/useOlMapAdditionalLayers.ts b/src/components/Map/MapViewer/MapViewerVector/utils/config/additionalLayers/useOlMapAdditionalLayers.ts index f0dfaa9f..6b841f53 100644 --- a/src/components/Map/MapViewer/MapViewerVector/utils/config/additionalLayers/useOlMapAdditionalLayers.ts +++ b/src/components/Map/MapViewer/MapViewerVector/utils/config/additionalLayers/useOlMapAdditionalLayers.ts @@ -54,6 +54,7 @@ export const useOlMapAdditionalLayers = ( rects: layer.rects, ovals: layer.ovals, lines: layer.lines, + images: layer.images, visible: layer.details.visible, layerId: layer.details.id, lineTypes, diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/layer/Layer.test.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/layer/Layer.test.ts index 4fd1b57f..eeabb89c 100644 --- a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/layer/Layer.test.ts +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/layer/Layer.test.ts @@ -113,6 +113,17 @@ describe('Layer', () => { lineType: 'SOLID', }, ], + images: [ + { + id: 1, + glyph: 1, + x: 1, + y: 1, + width: 1, + height: 1, + z: 1, + }, + ], visible: true, layerId: 23, pointToProjection: jest.fn(point => [point.x, point.y]), diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/layer/Layer.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/layer/Layer.ts index 0b8d6403..ef12c427 100644 --- a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/layer/Layer.ts +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/layer/Layer.ts @@ -1,5 +1,5 @@ /* eslint-disable no-magic-numbers */ -import { LayerLine, LayerOval, LayerRect, LayerText } from '@/types/models'; +import { LayerImage, LayerLine, LayerOval, LayerRect, LayerText } from '@/types/models'; import { MapInstance } from '@/types/map'; import { UsePointToProjectionResult } from '@/utils/map/usePointToProjection'; import { Feature } from 'ol'; @@ -26,12 +26,14 @@ import { } from '@/components/Map/MapViewer/MapViewerVector/MapViewerVector.constants'; import getScaledElementStyle from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/style/getScaledElementStyle'; import { Stroke } from 'ol/style'; +import Glyph from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/Glyph'; export interface LayerProps { texts: Array<LayerText>; rects: Array<LayerRect>; ovals: Array<LayerOval>; lines: Array<LayerLine>; + images: Array<LayerImage>; visible: boolean; layerId: number; lineTypes: LineTypeDict; @@ -49,6 +51,8 @@ export default class Layer { lines: Array<LayerLine>; + images: Array<LayerImage>; + lineTypes: LineTypeDict; arrowTypes: ArrowTypeDict; @@ -59,6 +63,8 @@ export default class Layer { ovalFeatures: Array<Feature<Polygon>>; + imageFeatures: Array<Feature<Polygon>>; + lineFeatures: Array<Feature<LineString>>; arrowFeatures: Array<Feature<MultiPolygon>>; @@ -80,6 +86,7 @@ export default class Layer { rects, ovals, lines, + images, visible, layerId, lineTypes, @@ -91,6 +98,7 @@ export default class Layer { this.rects = rects; this.ovals = ovals; this.lines = lines; + this.images = images; this.lineTypes = lineTypes; this.arrowTypes = arrowTypes; this.pointToProjection = pointToProjection; @@ -98,6 +106,7 @@ export default class Layer { this.textFeatures = this.getTextsFeatures(); this.rectFeatures = this.getRectsFeatures(); this.ovalFeatures = this.getOvalsFeatures(); + this.imageFeatures = this.getImagesFeatures(); const { linesFeatures, arrowsFeatures } = this.getLinesFeatures(); this.lineFeatures = linesFeatures; this.arrowFeatures = arrowsFeatures; @@ -108,6 +117,7 @@ export default class Layer { ...this.ovalFeatures, ...this.lineFeatures, ...this.arrowFeatures, + ...this.imageFeatures, ], }); this.vectorLayer = new VectorLayer({ @@ -293,6 +303,23 @@ export default class Layer { return { linesFeatures, arrowsFeatures }; }; + private getImagesFeatures = (): Array<Feature<Polygon>> => { + return this.images.map(image => { + const glyph = new Glyph({ + elementId: image.id, + glyphId: image.glyph, + x: image.x, + y: image.y, + width: image.width, + height: image.height, + zIndex: image.z, + pointToProjection: this.pointToProjection, + mapInstance: this.mapInstance, + }); + return glyph.feature; + }); + }; + protected getStyle(feature: FeatureLike, resolution: number): Style | Array<Style> | void { const styles: Array<Style> = []; const maxZoom = this.mapInstance?.getView().get('originalMaxZoom'); diff --git a/src/models/fixtures/layerImagesFixture.ts b/src/models/fixtures/layerImagesFixture.ts new file mode 100644 index 00000000..382b5f92 --- /dev/null +++ b/src/models/fixtures/layerImagesFixture.ts @@ -0,0 +1,10 @@ +import { ZOD_SEED } from '@/constants'; +// eslint-disable-next-line import/no-extraneous-dependencies +import { createFixture } from 'zod-fixture'; +import { pageableSchema } from '@/models/pageableSchema'; +import { layerImageSchema } from '@/models/layerImageSchema'; + +export const layerImagesFixture = createFixture(pageableSchema(layerImageSchema), { + seed: ZOD_SEED, + array: { min: 3, max: 3 }, +}); diff --git a/src/models/layerImageSchema.ts b/src/models/layerImageSchema.ts new file mode 100644 index 00000000..61a6df2d --- /dev/null +++ b/src/models/layerImageSchema.ts @@ -0,0 +1,11 @@ +import { z } from 'zod'; + +export const layerImageSchema = z.object({ + id: z.number(), + x: z.number(), + y: z.number(), + z: z.number(), + width: z.number(), + height: z.number(), + glyph: z.number(), +}); diff --git a/src/redux/apiPath.ts b/src/redux/apiPath.ts index 6a080e33..cd1b447c 100644 --- a/src/redux/apiPath.ts +++ b/src/redux/apiPath.ts @@ -58,6 +58,8 @@ export const apiPath = { `projects/${PROJECT_ID}/maps/${modelId}/layers/${layerId}/ovals/`, getLayerLines: (modelId: number, layerId: number): string => `projects/${PROJECT_ID}/maps/${modelId}/layers/${layerId}/lines/`, + getLayerImages: (modelId: number, layerId: number): string => + `projects/${PROJECT_ID}/maps/${modelId}/layers/${layerId}/images/`, storeLayer: (modelId: number): string => `projects/${PROJECT_ID}/maps/${modelId}/layers/`, updateLayer: (modelId: number, layerId: number): string => `projects/${PROJECT_ID}/maps/${modelId}/layers/${layerId}`, diff --git a/src/redux/layers/layers.reducers.test.ts b/src/redux/layers/layers.reducers.test.ts index 158ed1b0..938b7f3e 100644 --- a/src/redux/layers/layers.reducers.test.ts +++ b/src/redux/layers/layers.reducers.test.ts @@ -14,6 +14,7 @@ import { layerTextsFixture } from '@/models/fixtures/layerTextsFixture'; import { layerRectsFixture } from '@/models/fixtures/layerRectsFixture'; import { layerOvalsFixture } from '@/models/fixtures/layerOvalsFixture'; import { layerLinesFixture } from '@/models/fixtures/layerLinesFixture'; +import { layerImagesFixture } from '@/models/fixtures/layerImagesFixture'; import { LayersState } from './layers.types'; import layersReducer from './layers.slice'; @@ -47,6 +48,9 @@ describe('layers reducer', () => { mockedAxiosClient .onGet(apiPath.getLayerLines(1, layersFixture.content[0].id)) .reply(HttpStatusCode.Ok, layerLinesFixture); + mockedAxiosClient + .onGet(apiPath.getLayerImages(1, layersFixture.content[0].id)) + .reply(HttpStatusCode.Ok, layerImagesFixture); const { type } = await store.dispatch(getLayersForModel(1)); const { data, loading, error } = store.getState().layers[1]; @@ -61,6 +65,7 @@ describe('layers reducer', () => { rects: layerRectsFixture.content, ovals: layerOvalsFixture.content, lines: layerLinesFixture.content, + images: layerImagesFixture.content, }, ], layersVisibility: { @@ -98,6 +103,9 @@ describe('layers reducer', () => { mockedAxiosClient .onGet(apiPath.getLayerLines(1, layersFixture.content[0].id)) .reply(HttpStatusCode.Ok, layerLinesFixture); + mockedAxiosClient + .onGet(apiPath.getLayerImages(1, layersFixture.content[0].id)) + .reply(HttpStatusCode.Ok, layerImagesFixture); const layersPromise = store.dispatch(getLayersForModel(1)); @@ -116,6 +124,7 @@ describe('layers reducer', () => { rects: layerRectsFixture.content, ovals: layerOvalsFixture.content, lines: layerLinesFixture.content, + images: layerImagesFixture.content, }, ], layersVisibility: { diff --git a/src/redux/layers/layers.thunks.test.ts b/src/redux/layers/layers.thunks.test.ts index c8b9b2bc..975e8ec9 100644 --- a/src/redux/layers/layers.thunks.test.ts +++ b/src/redux/layers/layers.thunks.test.ts @@ -20,6 +20,7 @@ import { layerRectsFixture } from '@/models/fixtures/layerRectsFixture'; import { layerOvalsFixture } from '@/models/fixtures/layerOvalsFixture'; import { layerLinesFixture } from '@/models/fixtures/layerLinesFixture'; import { layerFixture } from '@/models/fixtures/layerFixture'; +import { layerImagesFixture } from '@/models/fixtures/layerImagesFixture'; import layersReducer from './layers.slice'; const mockedAxiosClient = mockNetworkNewAPIResponse(); @@ -45,6 +46,9 @@ describe('layers thunks', () => { mockedAxiosClient .onGet(apiPath.getLayerLines(1, layersFixture.content[0].id)) .reply(HttpStatusCode.Ok, layerLinesFixture); + mockedAxiosClient + .onGet(apiPath.getLayerImages(1, layersFixture.content[0].id)) + .reply(HttpStatusCode.Ok, layerImagesFixture); const { payload } = await store.dispatch(getLayersForModel(1)); expect(payload).toEqual({ @@ -55,6 +59,7 @@ describe('layers thunks', () => { rects: layerRectsFixture.content, ovals: layerOvalsFixture.content, lines: layerLinesFixture.content, + images: layerImagesFixture.content, }, ], layersVisibility: { diff --git a/src/redux/layers/layers.thunks.ts b/src/redux/layers/layers.thunks.ts index c8ee4fd5..1fd5d6d4 100644 --- a/src/redux/layers/layers.thunks.ts +++ b/src/redux/layers/layers.thunks.ts @@ -18,6 +18,7 @@ import { layerRectSchema } from '@/models/layerRectSchema'; import { pageableSchema } from '@/models/pageableSchema'; import { layerOvalSchema } from '@/models/layerOvalSchema'; import { layerLineSchema } from '@/models/layerLineSchema'; +import { layerImageSchema } from '@/models/layerImageSchema'; export const getLayer = createAsyncThunk< Layer | null, @@ -48,12 +49,14 @@ export const getLayersForModel = createAsyncThunk< } let layers = await Promise.all( data.content.map(async (layer: Layer) => { - const [textsResponse, rectsResponse, ovalsResponse, linesResponse] = await Promise.all([ - axiosInstanceNewAPI.get(apiPath.getLayerTexts(modelId, layer.id)), - axiosInstanceNewAPI.get(apiPath.getLayerRects(modelId, layer.id)), - axiosInstanceNewAPI.get(apiPath.getLayerOvals(modelId, layer.id)), - axiosInstanceNewAPI.get(apiPath.getLayerLines(modelId, layer.id)), - ]); + const [textsResponse, rectsResponse, ovalsResponse, linesResponse, imagesResponse] = + await Promise.all([ + axiosInstanceNewAPI.get(apiPath.getLayerTexts(modelId, layer.id)), + axiosInstanceNewAPI.get(apiPath.getLayerRects(modelId, layer.id)), + axiosInstanceNewAPI.get(apiPath.getLayerOvals(modelId, layer.id)), + axiosInstanceNewAPI.get(apiPath.getLayerLines(modelId, layer.id)), + axiosInstanceNewAPI.get(apiPath.getLayerImages(modelId, layer.id)), + ]); return { details: layer, @@ -61,6 +64,7 @@ export const getLayersForModel = createAsyncThunk< rects: rectsResponse.data.content, ovals: ovalsResponse.data.content, lines: linesResponse.data.content, + images: imagesResponse.data.content, }; }), ); @@ -69,7 +73,8 @@ export const getLayersForModel = createAsyncThunk< z.array(layerTextSchema).safeParse(layer.texts).success && z.array(layerRectSchema).safeParse(layer.rects).success && z.array(layerOvalSchema).safeParse(layer.ovals).success && - z.array(layerLineSchema).safeParse(layer.lines).success + z.array(layerLineSchema).safeParse(layer.lines).success && + z.array(layerImageSchema).safeParse(layer.images).success ); }); const layersVisibility = layers.reduce((acc: { [key: string]: boolean }, layer) => { diff --git a/src/redux/layers/layers.types.ts b/src/redux/layers/layers.types.ts index a5c1b30e..f2283106 100644 --- a/src/redux/layers/layers.types.ts +++ b/src/redux/layers/layers.types.ts @@ -1,5 +1,5 @@ import { KeyedFetchDataState } from '@/types/fetchDataState'; -import { Layer, LayerLine, LayerOval, LayerRect, LayerText } from '@/types/models'; +import { Layer, LayerImage, LayerLine, LayerOval, LayerRect, LayerText } from '@/types/models'; export interface LayerStoreInterface { name: string; @@ -22,6 +22,7 @@ export type LayerState = { rects: LayerRect[]; ovals: LayerOval[]; lines: LayerLine[]; + images: LayerImage[]; }; export type LayerVisibilityState = { diff --git a/src/types/models.ts b/src/types/models.ts index a7f9735a..91a9a887 100644 --- a/src/types/models.ts +++ b/src/types/models.ts @@ -84,6 +84,7 @@ import { reactionProduct } from '@/models/reactionProduct'; import { operatorSchema } from '@/models/operatorSchema'; import { modificationResiduesSchema } from '@/models/modificationResiduesSchema'; import { segmentSchema } from '@/models/segmentSchema'; +import { layerImageSchema } from '@/models/layerImageSchema'; export type Project = z.infer<typeof projectSchema>; export type OverviewImageView = z.infer<typeof overviewImageView>; @@ -102,6 +103,7 @@ export type LayerText = z.infer<typeof layerTextSchema>; export type LayerRect = z.infer<typeof layerRectSchema>; export type LayerOval = z.infer<typeof layerOvalSchema>; export type LayerLine = z.infer<typeof layerLineSchema>; +export type LayerImage = z.infer<typeof layerImageSchema>; export type Arrow = z.infer<typeof arrowSchema>; const modelElementsSchema = pageableSchema(modelElementSchema); export type ModelElements = z.infer<typeof modelElementsSchema>; -- GitLab From 35249e07845833ac58cf1f6f131b5f736e65b1f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mi=C5=82osz=20Grocholewski?= <m.grocholewski@atcomp.pl> Date: Thu, 19 Dec 2024 16:16:43 +0100 Subject: [PATCH 06/22] feat(vector-map): implement adding layer image objects --- next.config.js | 2 +- .../ImagePreview/ImagePreview.component.tsx | 57 ++++++ .../EditOverlayModal.component.test.tsx | 7 + .../hooks/useEditOverlay.test.ts | 5 + .../LayerImageGlyphSelector.component.tsx | 82 +++++++++ .../LayerImageGlyphSelector.styles.css | 14 ++ ...LayerImageObjectFactoryModal.component.tsx | 162 ++++++++++++++++++ .../LayerImageObjectFactoryModal.styles.css | 12 ++ .../LayerImageObjectFactoryModal/index.ts | 1 + .../FunctionalArea/Modal/Modal.component.tsx | 6 + .../ModalLayout/ModalLayout.component.tsx | 1 + src/components/Map/Map.component.tsx | 8 +- .../MapActiveLayerSelector.component.tsx | 53 ++++++ .../useOlMapAdditionalLayers.ts | 28 ++- .../utils/shapes/elements/Glyph.test.ts | 5 - .../utils/shapes/elements/Glyph.ts | 57 +++--- .../utils/shapes/layer/Layer.ts | 3 + .../shapes/layer/getDrawImageInteraction.ts | 65 +++++++ src/models/fixtures/glyphsFixture.ts | 10 ++ src/models/layerImageSchema.ts | 2 +- src/redux/apiPath.ts | 4 + src/redux/glyphs/glyphs.constants.ts | 1 + src/redux/glyphs/glyphs.mock.ts | 8 + src/redux/glyphs/glyphs.reducers.test.ts | 72 ++++++++ src/redux/glyphs/glyphs.reducers.ts | 16 ++ src/redux/glyphs/glyphs.selectors.ts | 6 + src/redux/glyphs/glyphs.slice.ts | 15 ++ src/redux/glyphs/glyphs.thunks.test.ts | 38 ++++ src/redux/glyphs/glyphs.thunks.ts | 43 +++++ src/redux/glyphs/glyphs.types.ts | 4 + src/redux/layers/layers.mock.ts | 14 +- src/redux/layers/layers.reducers.test.ts | 8 +- src/redux/layers/layers.reducers.ts | 12 ++ src/redux/layers/layers.selectors.ts | 25 +++ src/redux/layers/layers.slice.ts | 4 +- src/redux/layers/layers.thunks.test.ts | 1 + src/redux/layers/layers.thunks.ts | 48 +++++- src/redux/layers/layers.types.ts | 1 + src/redux/modal/modal.constants.ts | 1 + src/redux/modal/modal.mock.ts | 1 + src/redux/modal/modal.reducers.ts | 10 ++ src/redux/modal/modal.selector.ts | 5 + src/redux/modal/modal.slice.ts | 3 + src/redux/modal/modal.types.ts | 10 ++ src/redux/root/init.thunks.ts | 2 + src/redux/root/root.fixtures.ts | 2 + src/redux/store.ts | 2 + src/shared/Input/Input.component.tsx | 26 +-- src/shared/Select/Select.component.tsx | 6 +- src/types/modal.ts | 3 +- src/types/models.ts | 2 + 51 files changed, 913 insertions(+), 60 deletions(-) create mode 100644 src/components/FunctionalArea/Image/ImagePreview/ImagePreview.component.tsx create mode 100644 src/components/FunctionalArea/Modal/LayerImageObjectFactoryModal/LayerImageGlyphSelector.component.tsx create mode 100644 src/components/FunctionalArea/Modal/LayerImageObjectFactoryModal/LayerImageGlyphSelector.styles.css create mode 100644 src/components/FunctionalArea/Modal/LayerImageObjectFactoryModal/LayerImageObjectFactoryModal.component.tsx create mode 100644 src/components/FunctionalArea/Modal/LayerImageObjectFactoryModal/LayerImageObjectFactoryModal.styles.css create mode 100644 src/components/FunctionalArea/Modal/LayerImageObjectFactoryModal/index.ts create mode 100644 src/components/Map/MapActiveLayerSelector/MapActiveLayerSelector.component.tsx create mode 100644 src/components/Map/MapViewer/MapViewerVector/utils/shapes/layer/getDrawImageInteraction.ts create mode 100644 src/models/fixtures/glyphsFixture.ts create mode 100644 src/redux/glyphs/glyphs.constants.ts create mode 100644 src/redux/glyphs/glyphs.mock.ts create mode 100644 src/redux/glyphs/glyphs.reducers.test.ts create mode 100644 src/redux/glyphs/glyphs.reducers.ts create mode 100644 src/redux/glyphs/glyphs.selectors.ts create mode 100644 src/redux/glyphs/glyphs.slice.ts create mode 100644 src/redux/glyphs/glyphs.thunks.test.ts create mode 100644 src/redux/glyphs/glyphs.thunks.ts create mode 100644 src/redux/glyphs/glyphs.types.ts diff --git a/next.config.js b/next.config.js index 46540574..c882ad1b 100644 --- a/next.config.js +++ b/next.config.js @@ -1,6 +1,6 @@ /** @type {import("next").NextConfig} */ const nextConfig = { - reactStrictMode: true, + reactStrictMode: false, basePath: process.env.APP_PREFIX ? process.env.APP_PREFIX + '/index.html' : '', assetPrefix: process.env.APP_PREFIX ? process.env.APP_PREFIX : '', productionBrowserSourceMaps: true, diff --git a/src/components/FunctionalArea/Image/ImagePreview/ImagePreview.component.tsx b/src/components/FunctionalArea/Image/ImagePreview/ImagePreview.component.tsx new file mode 100644 index 00000000..d5b13b0a --- /dev/null +++ b/src/components/FunctionalArea/Image/ImagePreview/ImagePreview.component.tsx @@ -0,0 +1,57 @@ +import React, { useEffect, useMemo, useState } from 'react'; + +interface FileInterface { + url: string; +} + +interface ImagePreviewProps { + imageFile?: File | FileInterface | null; +} + +const ImagePreview: React.FC<ImagePreviewProps> = ({ imageFile }) => { + const [imageSrc, setImageSrc] = useState<string | null>(null); + + const previewImage = (file: File): void => { + const reader = new FileReader(); + reader.onload = (event): void => { + if (event.target?.result && typeof event.target.result === 'string') { + setImageSrc(event.target.result); + } + }; + reader.readAsDataURL(file); + }; + + const setImageFile = useMemo(() => { + return (): void => { + if (imageFile) { + if (imageFile instanceof File) { + previewImage(imageFile); + } else if ('url' in imageFile) { + setImageSrc(imageFile.url); + } + } else { + setImageSrc(null); + } + }; + }, [imageFile]); + + useEffect(() => { + setImageFile(); + }, [imageFile, setImageFile]); + + return ( + <div style={{ padding: '16px', border: '1px solid #ccc', borderRadius: '8px' }}> + {imageSrc ? ( + <img + src={imageSrc} + alt="Preview" + style={{ maxHeight: '350px', borderRadius: '8px', objectFit: 'cover' }} + /> + ) : ( + <div>No Data Available</div> + )} + </div> + ); +}; + +export default ImagePreview; diff --git a/src/components/FunctionalArea/Modal/EditOverlayModal/EditOverlayModal.component.test.tsx b/src/components/FunctionalArea/Modal/EditOverlayModal/EditOverlayModal.component.test.tsx index f8072beb..f9c7b302 100644 --- a/src/components/FunctionalArea/Modal/EditOverlayModal/EditOverlayModal.component.test.tsx +++ b/src/components/FunctionalArea/Modal/EditOverlayModal/EditOverlayModal.component.test.tsx @@ -48,6 +48,7 @@ describe('EditOverlayModal - component', () => { overviewImagesState: {}, errorReportState: {}, layerFactoryState: { id: undefined }, + layerImageObjectFactoryState: undefined, }, }); @@ -67,6 +68,7 @@ describe('EditOverlayModal - component', () => { overviewImagesState: {}, errorReportState: {}, layerFactoryState: { id: undefined }, + layerImageObjectFactoryState: undefined, }, }); @@ -98,6 +100,7 @@ describe('EditOverlayModal - component', () => { overviewImagesState: {}, errorReportState: {}, layerFactoryState: { id: undefined }, + layerImageObjectFactoryState: undefined, }, overlays: OVERLAYS_INITIAL_STATE_MOCK, }); @@ -134,6 +137,7 @@ describe('EditOverlayModal - component', () => { overviewImagesState: {}, errorReportState: {}, layerFactoryState: { id: undefined }, + layerImageObjectFactoryState: undefined, }, overlays: OVERLAYS_INITIAL_STATE_MOCK, }); @@ -171,6 +175,7 @@ describe('EditOverlayModal - component', () => { overviewImagesState: {}, errorReportState: {}, layerFactoryState: { id: undefined }, + layerImageObjectFactoryState: undefined, }, overlays: OVERLAYS_INITIAL_STATE_MOCK, }); @@ -207,6 +212,7 @@ describe('EditOverlayModal - component', () => { overviewImagesState: {}, errorReportState: {}, layerFactoryState: { id: undefined }, + layerImageObjectFactoryState: undefined, }, overlays: OVERLAYS_INITIAL_STATE_MOCK, }); @@ -237,6 +243,7 @@ describe('EditOverlayModal - component', () => { overviewImagesState: {}, errorReportState: {}, layerFactoryState: { id: undefined }, + layerImageObjectFactoryState: undefined, }, }); diff --git a/src/components/FunctionalArea/Modal/EditOverlayModal/hooks/useEditOverlay.test.ts b/src/components/FunctionalArea/Modal/EditOverlayModal/hooks/useEditOverlay.test.ts index 16f43071..0079d31f 100644 --- a/src/components/FunctionalArea/Modal/EditOverlayModal/hooks/useEditOverlay.test.ts +++ b/src/components/FunctionalArea/Modal/EditOverlayModal/hooks/useEditOverlay.test.ts @@ -25,6 +25,7 @@ describe('useEditOverlay', () => { overviewImagesState: {}, errorReportState: {}, layerFactoryState: { id: undefined }, + layerImageObjectFactoryState: undefined, }, }); @@ -62,6 +63,7 @@ describe('useEditOverlay', () => { overviewImagesState: {}, errorReportState: {}, layerFactoryState: { id: undefined }, + layerImageObjectFactoryState: undefined, }, }); @@ -102,6 +104,7 @@ describe('useEditOverlay', () => { overviewImagesState: {}, errorReportState: {}, layerFactoryState: { id: undefined }, + layerImageObjectFactoryState: undefined, }, }); @@ -138,6 +141,7 @@ describe('useEditOverlay', () => { overviewImagesState: {}, errorReportState: {}, layerFactoryState: { id: undefined }, + layerImageObjectFactoryState: undefined, }, }); @@ -175,6 +179,7 @@ describe('useEditOverlay', () => { overviewImagesState: {}, errorReportState: {}, layerFactoryState: { id: undefined }, + layerImageObjectFactoryState: undefined, }, }); diff --git a/src/components/FunctionalArea/Modal/LayerImageObjectFactoryModal/LayerImageGlyphSelector.component.tsx b/src/components/FunctionalArea/Modal/LayerImageObjectFactoryModal/LayerImageGlyphSelector.component.tsx new file mode 100644 index 00000000..f5ae36b6 --- /dev/null +++ b/src/components/FunctionalArea/Modal/LayerImageObjectFactoryModal/LayerImageGlyphSelector.component.tsx @@ -0,0 +1,82 @@ +import React, { ReactElement, useEffect, useState } from 'react'; +import Autosuggest from 'react-autosuggest'; +import { Glyph } from '@/types/models'; +import './LayerImageGlyphSelector.styles.css'; + +interface LayerImageGlyphSelectorProps { + glyphs: Glyph[]; + selectedGlyph: number | null; + onGlyphSelect: (glyphId: number) => void; +} + +const LayerImageGlyphSelector: React.FC<LayerImageGlyphSelectorProps> = ({ + glyphs, + selectedGlyph, + onGlyphSelect, +}) => { + const [searchValue, setSearchValue] = useState(''); + const [suggestions, setSuggestions] = useState<Glyph[]>([]); + + useEffect(() => { + if (selectedGlyph) { + setSearchValue(String(selectedGlyph)); + } else { + setSearchValue(''); + } + }, [selectedGlyph]); + + const getSuggestions = (inputValue: string): Glyph[] => { + if (!inputValue) { + return glyphs; + } + const input = inputValue.trim().toLowerCase(); + return glyphs.filter(glyph => String(glyph.file).toLowerCase().includes(input)); + }; + + const getSuggestionValue = (suggestion: Glyph): string => String(suggestion.file); + + const renderSuggestion = (suggestion: Glyph): ReactElement => ( + <div className="cursor-pointer p-2">{suggestion.file}</div> + ); + + const onChange = (event: React.FormEvent, { newValue }: { newValue: string }): void => { + setSearchValue(newValue); + }; + + const onSuggestionsFetchRequested = ({ value }: { value: string }): void => { + setSuggestions(getSuggestions(value)); + }; + + const onSuggestionsClearRequested = (): void => { + setSuggestions([]); + }; + + const onSuggestionSelected = ( + event: React.FormEvent, + { suggestion }: { suggestion: Glyph }, + ): void => { + onGlyphSelect(suggestion.id); + setSearchValue(String(suggestion.file)); + }; + + const inputProps = { + placeholder: 'Select glyph...', + value: searchValue, + onChange, + }; + + return ( + <Autosuggest + suggestions={suggestions} + onSuggestionsFetchRequested={onSuggestionsFetchRequested} + onSuggestionsClearRequested={onSuggestionsClearRequested} + shouldRenderSuggestions={() => true} + getSuggestionValue={getSuggestionValue} + renderSuggestion={renderSuggestion} + onSuggestionSelected={onSuggestionSelected} + inputProps={inputProps} + /> + ); +}; + +export default LayerImageGlyphSelector; diff --git a/src/components/FunctionalArea/Modal/LayerImageObjectFactoryModal/LayerImageGlyphSelector.styles.css b/src/components/FunctionalArea/Modal/LayerImageObjectFactoryModal/LayerImageGlyphSelector.styles.css new file mode 100644 index 00000000..f1d46937 --- /dev/null +++ b/src/components/FunctionalArea/Modal/LayerImageObjectFactoryModal/LayerImageGlyphSelector.styles.css @@ -0,0 +1,14 @@ +.react-autosuggest__suggestions-container { + position: absolute; + z-index: 1000; + max-height: 400px; + overflow-y: auto; +} + +.react-autosuggest__input { + width: 100%; + height: 40px; + padding: 10px; + border: 1px solid #ccc; + border-radius: 4px 0 0 4px; +} diff --git a/src/components/FunctionalArea/Modal/LayerImageObjectFactoryModal/LayerImageObjectFactoryModal.component.tsx b/src/components/FunctionalArea/Modal/LayerImageObjectFactoryModal/LayerImageObjectFactoryModal.component.tsx new file mode 100644 index 00000000..10539f95 --- /dev/null +++ b/src/components/FunctionalArea/Modal/LayerImageObjectFactoryModal/LayerImageObjectFactoryModal.component.tsx @@ -0,0 +1,162 @@ +/* eslint-disable no-magic-numbers */ +import React, { useState, useRef } from 'react'; +import { glyphsDataSelector } from '@/redux/glyphs/glyphs.selectors'; +import { useAppSelector } from '@/redux/hooks/useAppSelector'; +import { layerImageObjectFactoryStateSelector } from '@/redux/modal/modal.selector'; +import { Button } from '@/shared/Button'; +import { BASE_NEW_API_URL } from '@/constants'; +import { apiPath } from '@/redux/apiPath'; +import { Input } from '@/shared/Input'; +import Image from 'next/image'; +import LayerImageGlyphSelector from '@/components/FunctionalArea/Modal/LayerImageObjectFactoryModal/LayerImageGlyphSelector.component'; +import { Glyph } from '@/types/models'; +import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; +import { currentModelIdSelector } from '@/redux/models/models.selectors'; +import { highestZIndexSelector, layersActiveLayerSelector } from '@/redux/layers/layers.selectors'; +import { addLayerImageObject, getLayersForModel } from '@/redux/layers/layers.thunks'; +import { addGlyph } from '@/redux/glyphs/glyphs.thunks'; +import { SerializedError } from '@reduxjs/toolkit'; +import { showToast } from '@/utils/showToast'; +import { closeModal } from '@/redux/modal/modal.slice'; +import { LoadingIndicator } from '@/shared/LoadingIndicator'; +import './LayerImageObjectFactoryModal.styles.css'; + +export const LayerImageObjectFactoryModal: React.FC = () => { + const glyphs: Glyph[] = useAppSelector(glyphsDataSelector); + const currentModelId = useAppSelector(currentModelIdSelector); + const activeLayer = useAppSelector(layersActiveLayerSelector); + const layerImageObjectFactoryState = useAppSelector(layerImageObjectFactoryStateSelector); + const dispatch = useAppDispatch(); + const fileInputRef = useRef<HTMLInputElement>(null); + const highestZIndex = useAppSelector(highestZIndexSelector); + + const [selectedGlyph, setSelectedGlyph] = useState<number | null>(null); + const [file, setFile] = useState<File | null>(null); + const [isSending, setIsSending] = useState<boolean>(false); + const [previewUrl, setPreviewUrl] = useState<string | null>(null); + + const handleGlyphChange = (glyphId: number | null): void => { + setSelectedGlyph(glyphId); + if (!glyphId) { + return; + } + setFile(null); + setPreviewUrl(`${BASE_NEW_API_URL}${apiPath.getGlyphImage(glyphId)}`); + + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + }; + + const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>): void => { + const uploadedFile = e.target.files?.[0] || null; + + setFile(uploadedFile); + if (!uploadedFile) { + return; + } + + setSelectedGlyph(null); + if (uploadedFile) { + const url = URL.createObjectURL(uploadedFile); + setPreviewUrl(url); + } else { + setPreviewUrl(null); + } + }; + + const handleSubmit = async (): Promise<void> => { + if (!layerImageObjectFactoryState || !activeLayer) { + return; + } + setIsSending(true); + try { + let glyphId = selectedGlyph; + if (file) { + const data = await dispatch(addGlyph(file)).unwrap(); + if (!data) { + return; + } + glyphId = data.id; + } + await dispatch( + addLayerImageObject({ + modelId: currentModelId, + layerId: activeLayer, + x: layerImageObjectFactoryState.x, + y: layerImageObjectFactoryState.y, + z: highestZIndex + 1, + width: layerImageObjectFactoryState.width, + height: layerImageObjectFactoryState.height, + glyph: glyphId, + }), + ).unwrap(); + showToast({ + type: 'success', + message: 'A new image object has been successfully added', + }); + dispatch(closeModal()); + dispatch(getLayersForModel(currentModelId)); + } catch (error) { + const typedError = error as SerializedError; + showToast({ + type: 'error', + message: typedError.message || 'An error occurred while adding a new image object', + }); + } finally { + setIsSending(false); + } + }; + + return ( + <div className="relative w-[800px] border border-t-[#E1E0E6] bg-white p-[24px]"> + {isSending && ( + <div className="c-layer-image-object-factory-loader"> + <LoadingIndicator width={44} height={44} /> + </div> + )} + <div className="grid grid-cols-2 gap-2"> + <div className="mb-4 flex flex-col gap-2"> + <span>Glyph:</span> + <LayerImageGlyphSelector + selectedGlyph={selectedGlyph} + glyphs={glyphs} + onGlyphSelect={handleGlyphChange} + /> + </div> + <div className="mb-4 flex flex-col gap-2"> + <span>File:</span> + <Input + ref={fileInputRef} + type="file" + accept="image/*" + onChange={handleFileChange} + className="w-full border border-[#ccc] bg-white p-2" + /> + </div> + </div> + + <div className="relative mb-4 flex h-[350px] w-full items-center justify-center overflow-hidden rounded border"> + {previewUrl ? ( + <Image + src={previewUrl} + alt="image preview" + layout="fill" + objectFit="contain" + className="rounded" + /> + ) : ( + <div className="text-gray-500">No Image</div> + )} + </div> + + <Button + type="button" + onClick={handleSubmit} + className="w-full justify-center text-base font-medium" + > + Submit + </Button> + </div> + ); +}; diff --git a/src/components/FunctionalArea/Modal/LayerImageObjectFactoryModal/LayerImageObjectFactoryModal.styles.css b/src/components/FunctionalArea/Modal/LayerImageObjectFactoryModal/LayerImageObjectFactoryModal.styles.css new file mode 100644 index 00000000..db49e443 --- /dev/null +++ b/src/components/FunctionalArea/Modal/LayerImageObjectFactoryModal/LayerImageObjectFactoryModal.styles.css @@ -0,0 +1,12 @@ +.c-layer-image-object-factory-loader { + width: 100%; + height: 100%; + margin-left: -24px; + margin-top: -24px; + background: #f9f9f980; + z-index: 1; + position: absolute; + display: flex; + align-items: center; + justify-content: center; +} diff --git a/src/components/FunctionalArea/Modal/LayerImageObjectFactoryModal/index.ts b/src/components/FunctionalArea/Modal/LayerImageObjectFactoryModal/index.ts new file mode 100644 index 00000000..11947806 --- /dev/null +++ b/src/components/FunctionalArea/Modal/LayerImageObjectFactoryModal/index.ts @@ -0,0 +1 @@ +export { LayerImageObjectFactoryModal } from './LayerImageObjectFactoryModal.component'; diff --git a/src/components/FunctionalArea/Modal/Modal.component.tsx b/src/components/FunctionalArea/Modal/Modal.component.tsx index 8419791d..feec78d9 100644 --- a/src/components/FunctionalArea/Modal/Modal.component.tsx +++ b/src/components/FunctionalArea/Modal/Modal.component.tsx @@ -5,6 +5,7 @@ import { AccessDeniedModal } from '@/components/FunctionalArea/Modal/AccessDenie import { AddCommentModal } from '@/components/FunctionalArea/Modal/AddCommentModal/AddCommentModal.component'; import { LicenseModal } from '@/components/FunctionalArea/Modal/LicenseModal'; import { ToSModal } from '@/components/FunctionalArea/Modal/ToSModal/ToSModal.component'; +import { LayerImageObjectFactoryModal } from '@/components/FunctionalArea/Modal/LayerImageObjectFactoryModal'; import { EditOverlayModal } from './EditOverlayModal'; import { LoginModal } from './LoginModal'; import { ErrorReportModal } from './ErrorReportModal'; @@ -85,6 +86,11 @@ export const Modal = (): React.ReactNode => { <LayerFactoryModal /> </ModalLayout> )} + {isOpen && modalName === 'layer-image-object-factory' && ( + <ModalLayout> + <LayerImageObjectFactoryModal /> + </ModalLayout> + )} </> ); }; diff --git a/src/components/FunctionalArea/Modal/ModalLayout/ModalLayout.component.tsx b/src/components/FunctionalArea/Modal/ModalLayout/ModalLayout.component.tsx index 493f2bc7..64816f0b 100644 --- a/src/components/FunctionalArea/Modal/ModalLayout/ModalLayout.component.tsx +++ b/src/components/FunctionalArea/Modal/ModalLayout/ModalLayout.component.tsx @@ -34,6 +34,7 @@ export const ModalLayout = ({ children }: ModalLayoutProps): JSX.Element => { modalName === 'add-comment' && 'h-auto w-[400px]', modalName === 'error-report' && 'h-auto w-[800px]', modalName === 'layer-factory' && 'h-auto w-[400px]', + modalName === 'layer-image-object-factory' && 'h-auto w-[800px]', ['edit-overlay', 'logged-in-menu'].includes(modalName) && 'h-auto w-[432px]', )} > diff --git a/src/components/Map/Map.component.tsx b/src/components/Map/Map.component.tsx index 02ee36d8..65392d69 100644 --- a/src/components/Map/Map.component.tsx +++ b/src/components/Map/Map.component.tsx @@ -4,6 +4,7 @@ import { Legend } from '@/components/Map/Legend'; import { MapViewer } from '@/components/Map/MapViewer'; import { MapLoader } from '@/components/Map/MapLoader/MapLoader.component'; import { MapVectorBackgroundSelector } from '@/components/Map/MapVectorBackgroundSelector/MapVectorBackgroundSelector.component'; +import { MapActiveLayerSelector } from '@/components/Map/MapActiveLayerSelector/MapActiveLayerSelector.component'; import { useAppSelector } from '@/redux/hooks/useAppSelector'; import { vectorRenderingSelector } from '@/redux/models/models.selectors'; import { MapAdditionalLogos } from '@/components/Map/MapAdditionalLogos'; @@ -20,7 +21,12 @@ export const Map = (): JSX.Element => { > <MapViewer /> {!vectorRendering && <MapAdditionalOptions />} - {vectorRendering && <MapVectorBackgroundSelector />} + {vectorRendering && ( + <> + <MapVectorBackgroundSelector /> + <MapActiveLayerSelector /> + </> + )} <Drawer /> <PluginsDrawer /> <Legend /> diff --git a/src/components/Map/MapActiveLayerSelector/MapActiveLayerSelector.component.tsx b/src/components/Map/MapActiveLayerSelector/MapActiveLayerSelector.component.tsx new file mode 100644 index 00000000..d4fd65a1 --- /dev/null +++ b/src/components/Map/MapActiveLayerSelector/MapActiveLayerSelector.component.tsx @@ -0,0 +1,53 @@ +/* eslint-disable no-magic-numbers */ +import { useAppSelector } from '@/redux/hooks/useAppSelector'; +import { twMerge } from 'tailwind-merge'; +import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; +import { Select } from '@/shared/Select'; +import { + layersActiveLayerSelector, + layersForCurrentModelSelector, + layersVisibilityForCurrentModelSelector, +} from '@/redux/layers/layers.selectors'; +import { useEffect, useMemo } from 'react'; +import { setActiveLayer } from '@/redux/layers/layers.slice'; +import { currentModelIdSelector } from '@/redux/models/models.selectors'; + +export const MapActiveLayerSelector = (): JSX.Element => { + const dispatch = useAppDispatch(); + const layers = useAppSelector(layersForCurrentModelSelector); + const layersVisibility = useAppSelector(layersVisibilityForCurrentModelSelector); + const currentModelId = useAppSelector(currentModelIdSelector); + const activeLayer = useAppSelector(layersActiveLayerSelector); + + const handleChange = (activeLayerId: number): void => { + dispatch(setActiveLayer({ modelId: currentModelId, layerId: activeLayerId })); + }; + const options: Array<{ id: number; name: string }> = useMemo(() => { + return layers + .filter(layer => layersVisibility[layer.details.id]) + .map(layer => { + return { + id: layer.details.id, + name: layer.details.name, + }; + }); + }, [layers, layersVisibility]); + + useEffect(() => { + const selectedOption = options.find(option => option.id === activeLayer) || null; + if (selectedOption) { + return; + } + if (options.length === 0 && currentModelId) { + dispatch(setActiveLayer({ modelId: currentModelId, layerId: null })); + } else { + dispatch(setActiveLayer({ modelId: currentModelId, layerId: options[0].id })); + } + }, [activeLayer, currentModelId, dispatch, options]); + + return ( + <div className={twMerge('absolute right-[140px] top-[calc(64px+40px+24px)] z-10 flex')}> + <Select options={options} selectedId={activeLayer} onChange={handleChange} width={140} /> + </div> + ); +}; diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/config/additionalLayers/useOlMapAdditionalLayers.ts b/src/components/Map/MapViewer/MapViewerVector/utils/config/additionalLayers/useOlMapAdditionalLayers.ts index 6b841f53..31c1a087 100644 --- a/src/components/Map/MapViewer/MapViewerVector/utils/config/additionalLayers/useOlMapAdditionalLayers.ts +++ b/src/components/Map/MapViewer/MapViewerVector/utils/config/additionalLayers/useOlMapAdditionalLayers.ts @@ -4,10 +4,11 @@ import VectorLayer from 'ol/layer/Vector'; import VectorSource from 'ol/source/Vector'; import { useEffect, useMemo } from 'react'; import { useSelector } from 'react-redux'; -import { currentModelIdSelector } from '@/redux/models/models.selectors'; +import { currentModelIdSelector, vectorRenderingSelector } from '@/redux/models/models.selectors'; import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; import { getLayersForModel } from '@/redux/layers/layers.thunks'; import { + layersActiveLayerSelector, layersForCurrentModelSelector, layersLoadingSelector, layersVisibilityForCurrentModelSelector, @@ -19,6 +20,8 @@ import Polygon from 'ol/geom/Polygon'; import Layer from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/layer/Layer'; import { arrowTypesSelector, lineTypesSelector } from '@/redux/shapes/shapes.selectors'; import { useAppSelector } from '@/redux/hooks/useAppSelector'; +import { mapDataSizeSelector } from '@/redux/map/map.selectors'; +import getDrawImageInteraction from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/layer/getDrawImageInteraction'; export const useOlMapAdditionalLayers = ( mapInstance: MapInstance, @@ -28,16 +31,26 @@ export const useOlMapAdditionalLayers = ( > > => { const dispatch = useAppDispatch(); + const mapSize = useSelector(mapDataSizeSelector); const currentModelId = useSelector(currentModelIdSelector); const layersForCurrentModel = useAppSelector(layersForCurrentModelSelector); const layersLoading = useAppSelector(layersLoadingSelector); const layersVisibilityForCurrentModel = useAppSelector(layersVisibilityForCurrentModelSelector); + const activeLayer = useAppSelector(layersActiveLayerSelector); + const vectorRendering = useAppSelector(vectorRenderingSelector); const lineTypes = useSelector(lineTypesSelector); const arrowTypes = useSelector(arrowTypesSelector); const pointToProjection = usePointToProjection(); + const drawImageInteraction = useMemo(() => { + if (!mapSize || !dispatch) { + return null; + } + return getDrawImageInteraction(mapSize, dispatch); + }, [mapSize, dispatch]); + useEffect(() => { if (!currentModelId) { return; @@ -64,7 +77,7 @@ export const useOlMapAdditionalLayers = ( }); return additionalLayer.vectorLayer; }); - }, [arrowTypes, lineTypes, mapInstance, layersForCurrentModel, pointToProjection]); + }, [layersForCurrentModel, lineTypes, arrowTypes, mapInstance, pointToProjection]); useEffect(() => { vectorLayers.forEach(layer => { @@ -75,5 +88,16 @@ export const useOlMapAdditionalLayers = ( }); }, [layersVisibilityForCurrentModel, vectorLayers]); + useEffect(() => { + if (!drawImageInteraction) { + return; + } + mapInstance?.removeInteraction(drawImageInteraction); + if (!activeLayer || !vectorRendering) { + return; + } + mapInstance?.addInteraction(drawImageInteraction); + }, [activeLayer, currentModelId, drawImageInteraction, mapInstance, vectorRendering]); + return vectorLayers; }; diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/Glyph.test.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/Glyph.test.ts index f2fc4d4d..6ac8c5d2 100644 --- a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/Glyph.test.ts +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/Glyph.test.ts @@ -59,13 +59,8 @@ describe('Glyph', () => { }); it('should scale image based on map resolution', () => { - const getImageScale = glyph.feature.get('getImageScale'); const getAnchorAndCoords = glyph.feature.get('getAnchorAndCoords'); if (mapInstance) { - const resolution = mapInstance - .getView() - .getResolutionForZoom(mapInstance.getView().getMaxZoom()); - expect(getImageScale(resolution)).toBe(1); expect(getAnchorAndCoords()).toEqual({ anchor: [0, 0], coords: [0, 0] }); } }); diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/Glyph.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/Glyph.ts index 41cc21a6..ff5a7887 100644 --- a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/Glyph.ts +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/Glyph.ts @@ -1,7 +1,7 @@ /* eslint-disable no-magic-numbers */ import { UsePointToProjectionResult } from '@/utils/map/usePointToProjection'; import { Feature } from 'ol'; -import Style from 'ol/style/Style'; +import { Style, Text } from 'ol/style'; import Icon from 'ol/style/Icon'; import { FeatureLike } from 'ol/Feature'; import { MapInstance } from '@/types/map'; @@ -13,10 +13,12 @@ import { Coordinate } from 'ol/coordinate'; import { FEATURE_TYPE } from '@/constants/features'; import getStyle from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/style/getStyle'; import { WHITE_COLOR } from '@/components/Map/MapViewer/MapViewerVector/MapViewerVector.constants'; +import getFill from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/style/getFill'; +import getScaledElementStyle from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/style/getScaledElementStyle'; export type GlyphProps = { elementId: number; - glyphId: number; + glyphId: number | null; x: number; y: number; width: number; @@ -31,6 +33,8 @@ export default class Glyph { style: Style = new Style({}); + noGlyphStyle: Style; + imageScale: number = 1; polygonStyle: Style; @@ -49,6 +53,8 @@ export default class Glyph { pixelRatio: number = 1; + minResolution: number; + pointToProjection: UsePointToProjectionResult; constructor({ @@ -71,10 +77,10 @@ export default class Glyph { const point2 = this.pointToProjection({ x: this.width, y: this.height }); this.widthOnMap = Math.abs(point2[0] - point1[0]); this.heightOnMap = Math.abs(point2[1] - point1[1]); - const minResolution = mapInstance?.getView().getMinResolution(); - if (minResolution) { - this.pixelRatio = this.widthOnMap / minResolution / this.width; - } + + const maxZoom = mapInstance?.getView().get('originalMaxZoom'); + this.minResolution = mapInstance?.getView().getResolutionForZoom(maxZoom) || 1; + this.pixelRatio = this.widthOnMap / this.minResolution / this.width; const polygon = new Polygon([ [ pointToProjection({ x, y }), @@ -84,23 +90,33 @@ export default class Glyph { pointToProjection({ x, y }), ], ]); + this.polygonStyle = getStyle({ geometry: polygon, zIndex, borderColor: { ...WHITE_COLOR, alpha: 0 }, fillColor: { ...WHITE_COLOR, alpha: 0 }, }); + + this.noGlyphStyle = getStyle({ + geometry: polygon, + zIndex, + fillColor: '#E7E7E7', + }); + this.noGlyphStyle.setText( + new Text({ + text: 'No image', + font: '12pt Arial', + fill: getFill({ color: '#000' }), + overflow: true, + }), + ); + this.feature = new Feature({ geometry: polygon, id: elementId, type: FEATURE_TYPE.GLYPH, zIndex, - getImageScale: (resolution: number): number => { - if (mapInstance) { - return mapInstance.getView().getMinResolution() / resolution; - } - return 1; - }, getAnchorAndCoords: (): { anchor: Array<number>; coords: Coordinate } => { const center = mapInstance?.getView().getCenter(); let anchorX = 0; @@ -115,6 +131,10 @@ export default class Glyph { }, }); + this.feature.setStyle(this.getStyle.bind(this)); + if (!glyphId) { + return; + } const img = new Image(); img.onload = (): void => { const imageWidth = img.naturalWidth; @@ -128,30 +148,27 @@ export default class Glyph { }), zIndex, }); - this.feature.setStyle(this.getStyle.bind(this)); }; img.src = `${BASE_NEW_API_URL}${apiPath.getGlyphImage(glyphId)}`; } protected getStyle(feature: FeatureLike, resolution: number): Style | Array<Style> | void { - const getImageScale = feature.get('getImageScale'); + const scale = this.minResolution / resolution; const getAnchorAndCoords = feature.get('getAnchorAndCoords'); - let imageScale = 1; let anchor = [0, 0]; let coords = this.pointToProjection({ x: this.x, y: this.y }); - if (getImageScale instanceof Function) { - imageScale = getImageScale(resolution); - } + if (getAnchorAndCoords instanceof Function) { const anchorAndCoords = getAnchorAndCoords(); anchor = anchorAndCoords.anchor; coords = anchorAndCoords.coords; } if (this.style.getImage()) { - this.style.getImage()?.setScale(imageScale * this.pixelRatio * this.imageScale); + this.style.getImage()?.setScale(scale * this.pixelRatio * this.imageScale); (this.style.getImage() as Icon).setAnchor(anchor); this.style.setGeometry(new Point(coords)); + return [this.style, this.polygonStyle]; } - return [this.style, this.polygonStyle]; + return getScaledElementStyle(this.noGlyphStyle, undefined, scale); } } diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/layer/Layer.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/layer/Layer.ts index ef12c427..50a2b285 100644 --- a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/layer/Layer.ts +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/layer/Layer.ts @@ -123,7 +123,10 @@ export default class Layer { this.vectorLayer = new VectorLayer({ source: this.vectorSource, visible, + updateWhileAnimating: true, + updateWhileInteracting: true, }); + this.vectorLayer.set('id', layerId); } diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/layer/getDrawImageInteraction.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/layer/getDrawImageInteraction.ts new file mode 100644 index 00000000..a4f044ee --- /dev/null +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/layer/getDrawImageInteraction.ts @@ -0,0 +1,65 @@ +/* eslint-disable no-magic-numbers */ +import Draw from 'ol/interaction/Draw'; +import SimpleGeometry from 'ol/geom/SimpleGeometry'; +import Polygon from 'ol/geom/Polygon'; +import { toLonLat } from 'ol/proj'; +import { latLngToPoint } from '@/utils/map/latLngToPoint'; +import { MapSize } from '@/redux/map/map.types'; +import { AppDispatch } from '@/redux/store'; +import { Coordinate } from 'ol/coordinate'; +import { openLayerImageObjectFactoryModal } from '@/redux/modal/modal.slice'; + +export default function getDrawImageInteraction(mapSize: MapSize, dispatch: AppDispatch): Draw { + const drawImageInteraction = new Draw({ + type: 'Circle', + geometryFunction: (coordinates, geometry): SimpleGeometry => { + const newGeometry = geometry || new Polygon([]); + if (!Array.isArray(coordinates) || coordinates.length < 2) { + return geometry; + } + const start = coordinates[0] as Coordinate; + const end = coordinates[1] as Coordinate; + + const minX = Math.min(start[0], end[0]); + const minY = Math.min(start[1], end[1]); + const maxX = Math.max(start[0], end[0]); + const maxY = Math.max(start[1], end[1]); + + const coords: Array<Coordinate> = [ + [minX, minY], + [maxX, minY], + [maxX, maxY], + [minX, maxY], + [minX, minY], + ]; + + newGeometry.setCoordinates([coords]); + + return newGeometry; + }, + }); + + drawImageInteraction.on('drawend', event => { + const geometry = event.feature.getGeometry() as Polygon; + const extent = geometry.getExtent(); + + const [startLng, startLat] = toLonLat([extent[0], extent[3]]); + const startPoint = latLngToPoint([startLat, startLng], mapSize); + const [endLng, endLat] = toLonLat([extent[2], extent[1]]); + const endPoint = latLngToPoint([endLat, endLng], mapSize); + + const width = Math.abs(endPoint.x - startPoint.x); + const height = Math.abs(endPoint.y - startPoint.y); + + dispatch( + openLayerImageObjectFactoryModal({ + x: startPoint.x, + y: startPoint.y, + width, + height, + }), + ); + }); + + return drawImageInteraction; +} diff --git a/src/models/fixtures/glyphsFixture.ts b/src/models/fixtures/glyphsFixture.ts new file mode 100644 index 00000000..489f5015 --- /dev/null +++ b/src/models/fixtures/glyphsFixture.ts @@ -0,0 +1,10 @@ +import { ZOD_SEED } from '@/constants'; +// eslint-disable-next-line import/no-extraneous-dependencies +import { createFixture } from 'zod-fixture'; +import { pageableSchema } from '@/models/pageableSchema'; +import { glyphSchema } from '@/models/glyphSchema'; + +export const glyphsFixture = createFixture(pageableSchema(glyphSchema), { + seed: ZOD_SEED, + array: { min: 3, max: 3 }, +}); diff --git a/src/models/layerImageSchema.ts b/src/models/layerImageSchema.ts index 61a6df2d..8547b313 100644 --- a/src/models/layerImageSchema.ts +++ b/src/models/layerImageSchema.ts @@ -7,5 +7,5 @@ export const layerImageSchema = z.object({ z: z.number(), width: z.number(), height: z.number(), - glyph: z.number(), + glyph: z.number().nullable(), }); diff --git a/src/redux/apiPath.ts b/src/redux/apiPath.ts index cd1b447c..1def76d9 100644 --- a/src/redux/apiPath.ts +++ b/src/redux/apiPath.ts @@ -65,10 +65,14 @@ export const apiPath = { `projects/${PROJECT_ID}/maps/${modelId}/layers/${layerId}`, removeLayer: (modelId: number, layerId: number): string => `projects/${PROJECT_ID}/maps/${modelId}/layers/${layerId}`, + addLayerImageObject: (modelId: number, layerId: number): string => + `projects/${PROJECT_ID}/maps/${modelId}/layers/${layerId}/images/`, getLayer: (modelId: number, layerId: number): string => `projects/${PROJECT_ID}/maps/${modelId}/layers/${layerId}`, getGlyphImage: (glyphId: number): string => `projects/${PROJECT_ID}/glyphs/${glyphId}/fileContent`, + getGlyphs: (): string => `projects/${PROJECT_ID}/glyphs/`, + addGlyph: (): string => `projects/${PROJECT_ID}/glyphs/`, getNewReactionsForModel: (modelId: number): string => `projects/${PROJECT_ID}/maps/${modelId}/bioEntities/reactions/?size=10000`, getNewReaction: (modelId: number, reactionId: number): string => diff --git a/src/redux/glyphs/glyphs.constants.ts b/src/redux/glyphs/glyphs.constants.ts new file mode 100644 index 00000000..e70a9a58 --- /dev/null +++ b/src/redux/glyphs/glyphs.constants.ts @@ -0,0 +1 @@ +export const GLYPHS_FETCHING_ERROR_PREFIX = 'Failed to fetch glyphs'; diff --git a/src/redux/glyphs/glyphs.mock.ts b/src/redux/glyphs/glyphs.mock.ts new file mode 100644 index 00000000..4e04e5d3 --- /dev/null +++ b/src/redux/glyphs/glyphs.mock.ts @@ -0,0 +1,8 @@ +import { DEFAULT_ERROR } from '@/constants/errors'; +import { GlyphsState } from '@/redux/glyphs/glyphs.types'; + +export const GLYPHS_STATE_INITIAL_MOCK: GlyphsState = { + data: [], + loading: 'idle', + error: DEFAULT_ERROR, +}; diff --git a/src/redux/glyphs/glyphs.reducers.test.ts b/src/redux/glyphs/glyphs.reducers.test.ts new file mode 100644 index 00000000..6a4264b5 --- /dev/null +++ b/src/redux/glyphs/glyphs.reducers.test.ts @@ -0,0 +1,72 @@ +import { apiPath } from '@/redux/apiPath'; +import { + ToolkitStoreWithSingleSlice, + createStoreInstanceUsingSliceReducer, +} from '@/utils/createStoreInstanceUsingSliceReducer'; +import { mockNetworkNewAPIResponse } from '@/utils/mockNetworkResponse'; +import { HttpStatusCode } from 'axios'; +import { unwrapResult } from '@reduxjs/toolkit'; +import { GLYPHS_STATE_INITIAL_MOCK } from '@/redux/glyphs/glyphs.mock'; +import { GlyphsState } from '@/redux/glyphs/glyphs.types'; +import { getGlyphs } from '@/redux/glyphs/glyphs.thunks'; +import { glyphsFixture } from '@/models/fixtures/glyphsFixture'; +import glyphsReducer from './glyphs.slice'; + +const mockedAxiosClient = mockNetworkNewAPIResponse(); + +const INITIAL_STATE: GlyphsState = GLYPHS_STATE_INITIAL_MOCK; + +describe('glyphs reducer', () => { + let store = {} as ToolkitStoreWithSingleSlice<GlyphsState>; + beforeEach(() => { + store = createStoreInstanceUsingSliceReducer('glyphs', glyphsReducer); + }); + + it('should match initial state', () => { + const action = { type: 'unknown' }; + + expect(glyphsReducer(undefined, action)).toEqual(INITIAL_STATE); + }); + + it('should update store after successful getGlyphs query', async () => { + mockedAxiosClient.onGet(apiPath.getGlyphs()).reply(HttpStatusCode.Ok, glyphsFixture); + + const { type } = await store.dispatch(getGlyphs()); + const { data, loading, error } = store.getState().glyphs; + expect(type).toBe('getGlyphs/fulfilled'); + expect(loading).toEqual('succeeded'); + expect(error).toEqual({ message: '', name: '' }); + expect(data).toEqual(glyphsFixture.content); + }); + + it('should update store after failed getGlyphs query', async () => { + mockedAxiosClient.onGet(apiPath.getGlyphs()).reply(HttpStatusCode.NotFound, []); + + const action = await store.dispatch(getGlyphs()); + const { data, loading, error } = store.getState().glyphs; + + expect(action.type).toBe('getGlyphs/rejected'); + expect(() => unwrapResult(action)).toThrow( + "Failed to fetch glyphs: The page you're looking for doesn't exist. Please verify the URL and try again.", + ); + expect(loading).toEqual('failed'); + expect(error).toEqual({ message: '', name: '' }); + expect(data).toEqual([]); + }); + + it('should update store on loading getGlyphs query', async () => { + mockedAxiosClient.onGet(apiPath.getGlyphs()).reply(HttpStatusCode.Ok, glyphsFixture); + + const glyphsPromise = store.dispatch(getGlyphs()); + + const { data, loading } = store.getState().glyphs; + expect(data).toEqual([]); + expect(loading).toEqual('pending'); + + glyphsPromise.then(() => { + const { data: dataPromiseFulfilled, loading: promiseFulfilled } = store.getState().glyphs; + expect(dataPromiseFulfilled).toEqual(glyphsFixture.content); + expect(promiseFulfilled).toEqual('succeeded'); + }); + }); +}); diff --git a/src/redux/glyphs/glyphs.reducers.ts b/src/redux/glyphs/glyphs.reducers.ts new file mode 100644 index 00000000..30db8147 --- /dev/null +++ b/src/redux/glyphs/glyphs.reducers.ts @@ -0,0 +1,16 @@ +import { ActionReducerMapBuilder } from '@reduxjs/toolkit'; +import { getGlyphs } from '@/redux/glyphs/glyphs.thunks'; +import { GlyphsState } from '@/redux/glyphs/glyphs.types'; + +export const getGlyphsReducer = (builder: ActionReducerMapBuilder<GlyphsState>): void => { + builder.addCase(getGlyphs.pending, state => { + state.loading = 'pending'; + }); + builder.addCase(getGlyphs.fulfilled, (state, action) => { + state.data = action.payload || {}; + state.loading = 'succeeded'; + }); + builder.addCase(getGlyphs.rejected, state => { + state.loading = 'failed'; + }); +}; diff --git a/src/redux/glyphs/glyphs.selectors.ts b/src/redux/glyphs/glyphs.selectors.ts new file mode 100644 index 00000000..6f99b412 --- /dev/null +++ b/src/redux/glyphs/glyphs.selectors.ts @@ -0,0 +1,6 @@ +import { createSelector } from '@reduxjs/toolkit'; +import { rootSelector } from '@/redux/root/root.selectors'; + +export const glyphsSelector = createSelector(rootSelector, state => state.glyphs); + +export const glyphsDataSelector = createSelector(glyphsSelector, state => state.data); diff --git a/src/redux/glyphs/glyphs.slice.ts b/src/redux/glyphs/glyphs.slice.ts new file mode 100644 index 00000000..ff81f3e4 --- /dev/null +++ b/src/redux/glyphs/glyphs.slice.ts @@ -0,0 +1,15 @@ +import { createSlice } from '@reduxjs/toolkit'; + +import { GLYPHS_STATE_INITIAL_MOCK } from '@/redux/glyphs/glyphs.mock'; +import { getGlyphsReducer } from '@/redux/glyphs/glyphs.reducers'; + +export const glyphsSlice = createSlice({ + name: 'glyphs', + initialState: GLYPHS_STATE_INITIAL_MOCK, + reducers: {}, + extraReducers: builder => { + getGlyphsReducer(builder); + }, +}); + +export default glyphsSlice.reducer; diff --git a/src/redux/glyphs/glyphs.thunks.test.ts b/src/redux/glyphs/glyphs.thunks.test.ts new file mode 100644 index 00000000..abd3a86d --- /dev/null +++ b/src/redux/glyphs/glyphs.thunks.test.ts @@ -0,0 +1,38 @@ +import { apiPath } from '@/redux/apiPath'; +import { + ToolkitStoreWithSingleSlice, + createStoreInstanceUsingSliceReducer, +} from '@/utils/createStoreInstanceUsingSliceReducer'; +import { mockNetworkNewAPIResponse } from '@/utils/mockNetworkResponse'; +import { HttpStatusCode } from 'axios'; +import { GlyphsState } from '@/redux/glyphs/glyphs.types'; +import { getGlyphs } from '@/redux/glyphs/glyphs.thunks'; +import { glyphsFixture } from '@/models/fixtures/glyphsFixture'; +import glyphsReducer from './glyphs.slice'; + +const mockedAxiosClient = mockNetworkNewAPIResponse(); + +describe('glyphs thunks', () => { + let store = {} as ToolkitStoreWithSingleSlice<GlyphsState>; + beforeEach(() => { + store = createStoreInstanceUsingSliceReducer('glyphs', glyphsReducer); + }); + + describe('getGlyphs', () => { + it('should return data when data response from API is valid', async () => { + mockedAxiosClient.onGet(apiPath.getGlyphs()).reply(HttpStatusCode.Ok, glyphsFixture); + + const { payload } = await store.dispatch(getGlyphs()); + expect(payload).toEqual(glyphsFixture.content); + }); + + it('should return empty object when data response from API is not valid ', async () => { + mockedAxiosClient + .onGet(apiPath.getGlyphs()) + .reply(HttpStatusCode.Ok, { randomProperty: 'randomValue' }); + + const { payload } = await store.dispatch(getGlyphs()); + expect(payload).toEqual([]); + }); + }); +}); diff --git a/src/redux/glyphs/glyphs.thunks.ts b/src/redux/glyphs/glyphs.thunks.ts new file mode 100644 index 00000000..2529ba48 --- /dev/null +++ b/src/redux/glyphs/glyphs.thunks.ts @@ -0,0 +1,43 @@ +import { apiPath } from '@/redux/apiPath'; +import { Glyph, PageOf } from '@/types/models'; +import { validateDataUsingZodSchema } from '@/utils/validateDataUsingZodSchema'; +import { createAsyncThunk } from '@reduxjs/toolkit'; +import { ThunkConfig } from '@/types/store'; +import { getError } from '@/utils/error-report/getError'; +import { axiosInstanceNewAPI } from '@/services/api/utils/axiosInstance'; +import { glyphSchema } from '@/models/glyphSchema'; +import { GLYPHS_FETCHING_ERROR_PREFIX } from '@/redux/glyphs/glyphs.constants'; +import { pageableSchema } from '@/models/pageableSchema'; + +export const getGlyphs = createAsyncThunk<Glyph[], void, ThunkConfig>('getGlyphs', async () => { + try { + const { data } = await axiosInstanceNewAPI.get<PageOf<Glyph>>(apiPath.getGlyphs()); + const isDataValid = validateDataUsingZodSchema(data, pageableSchema(glyphSchema)); + if (!isDataValid) { + return []; + } + return data.content; + } catch (error) { + return Promise.reject(getError({ error, prefix: GLYPHS_FETCHING_ERROR_PREFIX })); + } +}); + +export const addGlyph = createAsyncThunk< + Glyph | undefined, + File, + ThunkConfig + // eslint-disable-next-line consistent-return +>('addGlyph', async file => { + try { + const formData = new FormData(); + formData.append('file', file); + const { data } = await axiosInstanceNewAPI.post<Glyph>(apiPath.addGlyph(), formData); + const isDataValid = validateDataUsingZodSchema(data, glyphSchema); + if (!isDataValid) { + return undefined; + } + return data; + } catch (error) { + return Promise.reject(getError({ error })); + } +}); diff --git a/src/redux/glyphs/glyphs.types.ts b/src/redux/glyphs/glyphs.types.ts new file mode 100644 index 00000000..acf2e905 --- /dev/null +++ b/src/redux/glyphs/glyphs.types.ts @@ -0,0 +1,4 @@ +import { FetchDataState } from '@/types/fetchDataState'; +import { Glyph } from '@/types/models'; + +export type GlyphsState = FetchDataState<Glyph[], []>; diff --git a/src/redux/layers/layers.mock.ts b/src/redux/layers/layers.mock.ts index 38e72675..729624ff 100644 --- a/src/redux/layers/layers.mock.ts +++ b/src/redux/layers/layers.mock.ts @@ -4,16 +4,16 @@ import { FetchDataState } from '@/types/fetchDataState'; export const LAYERS_STATE_INITIAL_MOCK: LayersState = {}; +export const LAYER_STATE_DEFAULT_DATA = { + layers: [], + layersVisibility: {}, + activeLayer: null, +}; + export const LAYERS_STATE_INITIAL_LAYER_MOCK: FetchDataState<LayersVisibilitiesState> = { data: { - layers: [], - layersVisibility: {}, + ...LAYER_STATE_DEFAULT_DATA, }, loading: 'idle', error: DEFAULT_ERROR, }; - -export const LAYER_STATE_DEFAULT_DATA = { - layers: [], - layersVisibility: {}, -}; diff --git a/src/redux/layers/layers.reducers.test.ts b/src/redux/layers/layers.reducers.test.ts index 938b7f3e..f68bdc4a 100644 --- a/src/redux/layers/layers.reducers.test.ts +++ b/src/redux/layers/layers.reducers.test.ts @@ -58,6 +58,7 @@ describe('layers reducer', () => { expect(loading).toEqual('succeeded'); expect(error).toEqual({ message: '', name: '' }); expect(data).toEqual({ + activeLayer: null, layers: [ { details: layersFixture.content[0], @@ -86,7 +87,11 @@ describe('layers reducer', () => { ); expect(loading).toEqual('failed'); expect(error).toEqual({ message: '', name: '' }); - expect(data).toEqual({ layers: [], layersVisibility: {} }); + expect(data).toEqual({ + activeLayer: null, + layers: [], + layersVisibility: {}, + }); }); it('should update store on loading getLayers query', async () => { @@ -117,6 +122,7 @@ describe('layers reducer', () => { const { data: dataPromiseFulfilled, loading: promiseFulfilled } = store.getState().layers[1]; expect(dataPromiseFulfilled).toEqual({ + activeLayer: null, layers: [ { details: layersFixture.content[0], diff --git a/src/redux/layers/layers.reducers.ts b/src/redux/layers/layers.reducers.ts index b0e601d5..70bdf8d5 100644 --- a/src/redux/layers/layers.reducers.ts +++ b/src/redux/layers/layers.reducers.ts @@ -50,3 +50,15 @@ export const setLayerVisibilityReducer = ( data.layersVisibility[layerId] = visible; } }; + +export const setActiveLayerReducer = ( + state: LayersState, + action: PayloadAction<{ modelId: number; layerId: number | null }>, +): void => { + const { modelId, layerId } = action.payload; + const { data } = state[modelId]; + if (!data) { + return; + } + data.activeLayer = layerId; +}; diff --git a/src/redux/layers/layers.selectors.ts b/src/redux/layers/layers.selectors.ts index 4d9e3f6d..4392df6e 100644 --- a/src/redux/layers/layers.selectors.ts +++ b/src/redux/layers/layers.selectors.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-magic-numbers */ import { createSelector } from '@reduxjs/toolkit'; import { rootSelector } from '@/redux/root/root.selectors'; import { currentModelIdSelector } from '@/redux/models/models.selectors'; @@ -10,6 +11,11 @@ export const layersStateForCurrentModelSelector = createSelector( (state, currentModelId) => state[currentModelId], ); +export const layersActiveLayerSelector = createSelector( + layersStateForCurrentModelSelector, + state => state?.data?.activeLayer || null, +); + export const layersLoadingSelector = createSelector( layersStateForCurrentModelSelector, state => state?.loading, @@ -24,3 +30,22 @@ export const layersForCurrentModelSelector = createSelector( layersStateForCurrentModelSelector, state => state?.data?.layers || [], ); + +export const highestZIndexSelector = createSelector(layersForCurrentModelSelector, layers => { + if (!layers || layers.length === 0) return 0; + + const getMaxZFromItems = <T extends { z?: number }>(items: T[] = []): number => + items.length > 0 ? Math.max(...items.map(item => item.z || 0)) : 0; + + return layers.reduce((maxZ, layer) => { + const textsMaxZ = getMaxZFromItems(layer.texts); + const rectsMaxZ = getMaxZFromItems(layer.rects); + const ovalsMaxZ = getMaxZFromItems(layer.ovals); + const linesMaxZ = getMaxZFromItems(layer.lines); + const imagesMaxZ = getMaxZFromItems(layer.images); + + const layerMaxZ = Math.max(textsMaxZ, rectsMaxZ, ovalsMaxZ, linesMaxZ, imagesMaxZ); + + return Math.max(maxZ, layerMaxZ); + }, 0); +}); diff --git a/src/redux/layers/layers.slice.ts b/src/redux/layers/layers.slice.ts index 7c07cdc0..d35f1399 100644 --- a/src/redux/layers/layers.slice.ts +++ b/src/redux/layers/layers.slice.ts @@ -2,6 +2,7 @@ import { createSlice } from '@reduxjs/toolkit'; import { LAYERS_STATE_INITIAL_MOCK } from '@/redux/layers/layers.mock'; import { getLayersForModelReducer, + setActiveLayerReducer, setLayerVisibilityReducer, } from '@/redux/layers/layers.reducers'; @@ -10,12 +11,13 @@ export const layersSlice = createSlice({ initialState: LAYERS_STATE_INITIAL_MOCK, reducers: { setLayerVisibility: setLayerVisibilityReducer, + setActiveLayer: setActiveLayerReducer, }, extraReducers: builder => { getLayersForModelReducer(builder); }, }); -export const { setLayerVisibility } = layersSlice.actions; +export const { setLayerVisibility, setActiveLayer } = layersSlice.actions; export default layersSlice.reducer; diff --git a/src/redux/layers/layers.thunks.test.ts b/src/redux/layers/layers.thunks.test.ts index 975e8ec9..03ba8d80 100644 --- a/src/redux/layers/layers.thunks.test.ts +++ b/src/redux/layers/layers.thunks.test.ts @@ -52,6 +52,7 @@ describe('layers thunks', () => { const { payload } = await store.dispatch(getLayersForModel(1)); expect(payload).toEqual({ + activeLayer: null, layers: [ { details: layersFixture.content[0], diff --git a/src/redux/layers/layers.thunks.ts b/src/redux/layers/layers.thunks.ts index 1fd5d6d4..44f642a2 100644 --- a/src/redux/layers/layers.thunks.ts +++ b/src/redux/layers/layers.thunks.ts @@ -1,4 +1,5 @@ -import { z } from 'zod'; +/* eslint-disable no-magic-numbers */ +import { z as zod } from 'zod'; import { apiPath } from '@/redux/apiPath'; import { Layer, Layers } from '@/types/models'; import { validateDataUsingZodSchema } from '@/utils/validateDataUsingZodSchema'; @@ -70,20 +71,26 @@ export const getLayersForModel = createAsyncThunk< ); layers = layers.filter(layer => { return ( - z.array(layerTextSchema).safeParse(layer.texts).success && - z.array(layerRectSchema).safeParse(layer.rects).success && - z.array(layerOvalSchema).safeParse(layer.ovals).success && - z.array(layerLineSchema).safeParse(layer.lines).success && - z.array(layerImageSchema).safeParse(layer.images).success + zod.array(layerTextSchema).safeParse(layer.texts).success && + zod.array(layerRectSchema).safeParse(layer.rects).success && + zod.array(layerOvalSchema).safeParse(layer.ovals).success && + zod.array(layerLineSchema).safeParse(layer.lines).success && + zod.array(layerImageSchema).safeParse(layer.images).success ); }); const layersVisibility = layers.reduce((acc: { [key: string]: boolean }, layer) => { acc[layer.details.id] = layer.details.visible; return acc; }, {}); + let activeLayer = null; + const activeLayers = layers.filter(layer => layer.details.visible); + if (activeLayers.length) { + activeLayer = activeLayers[0].details.id; + } return { layers, layersVisibility, + activeLayer, }; } catch (error) { return Promise.reject(getError({ error, prefix: LAYERS_FETCHING_ERROR_PREFIX })); @@ -140,3 +147,32 @@ export const removeLayer = createAsyncThunk< return Promise.reject(getError({ error })); } }); + +export const addLayerImageObject = createAsyncThunk< + void, + { + modelId: number; + layerId: number; + x: number; + y: number; + z: number; + width: number; + height: number; + glyph: number | null; + }, + ThunkConfig + // eslint-disable-next-line consistent-return +>('vectorMap/addLayerImageObject', async ({ modelId, layerId, x, y, z, width, height, glyph }) => { + try { + await axiosInstanceNewAPI.post<void>(apiPath.addLayerImageObject(modelId, layerId), { + x, + y, + z, + width, + height, + glyph, + }); + } catch (error) { + return Promise.reject(getError({ error })); + } +}); diff --git a/src/redux/layers/layers.types.ts b/src/redux/layers/layers.types.ts index f2283106..049828d8 100644 --- a/src/redux/layers/layers.types.ts +++ b/src/redux/layers/layers.types.ts @@ -32,6 +32,7 @@ export type LayerVisibilityState = { export type LayersVisibilitiesState = { layersVisibility: LayerVisibilityState; layers: LayerState[]; + activeLayer: number | null; }; export type LayersState = KeyedFetchDataState<LayersVisibilitiesState>; diff --git a/src/redux/modal/modal.constants.ts b/src/redux/modal/modal.constants.ts index 1184d4ed..1b755f3b 100644 --- a/src/redux/modal/modal.constants.ts +++ b/src/redux/modal/modal.constants.ts @@ -14,4 +14,5 @@ export const MODAL_INITIAL_STATE: ModalState = { editOverlayState: null, errorReportState: {}, layerFactoryState: { id: undefined }, + layerImageObjectFactoryState: undefined, }; diff --git a/src/redux/modal/modal.mock.ts b/src/redux/modal/modal.mock.ts index 1a7a519f..40464dd3 100644 --- a/src/redux/modal/modal.mock.ts +++ b/src/redux/modal/modal.mock.ts @@ -14,4 +14,5 @@ export const MODAL_INITIAL_STATE_MOCK: ModalState = { editOverlayState: null, errorReportState: {}, layerFactoryState: { id: undefined }, + layerImageObjectFactoryState: undefined, }; diff --git a/src/redux/modal/modal.reducers.ts b/src/redux/modal/modal.reducers.ts index 3371ed3c..b59bf868 100644 --- a/src/redux/modal/modal.reducers.ts +++ b/src/redux/modal/modal.reducers.ts @@ -138,3 +138,13 @@ export const openLayerFactoryModalReducer = ( state.modalTitle = 'Add new layer'; } }; + +export const openLayerImageObjectFactoryModalReducer = ( + state: ModalState, + action: PayloadAction<{ x: number; y: number; width: number; height: number }>, +): void => { + state.layerImageObjectFactoryState = action.payload; + state.isOpen = true; + state.modalName = 'layer-image-object-factory'; + state.modalTitle = 'Select glyph or upload file'; +}; diff --git a/src/redux/modal/modal.selector.ts b/src/redux/modal/modal.selector.ts index 7f7c4441..132472b6 100644 --- a/src/redux/modal/modal.selector.ts +++ b/src/redux/modal/modal.selector.ts @@ -30,3 +30,8 @@ export const currentErrorDataSelector = createSelector( modalSelector, modal => modal?.errorReportState.errorData || undefined, ); + +export const layerImageObjectFactoryStateSelector = createSelector( + modalSelector, + modal => modal.layerImageObjectFactoryState, +); diff --git a/src/redux/modal/modal.slice.ts b/src/redux/modal/modal.slice.ts index 8ed04215..a9baf72a 100644 --- a/src/redux/modal/modal.slice.ts +++ b/src/redux/modal/modal.slice.ts @@ -17,6 +17,7 @@ import { openLicenseModalReducer, openToSModalReducer, openLayerFactoryModalReducer, + openLayerImageObjectFactoryModalReducer, } from './modal.reducers'; const modalSlice = createSlice({ @@ -39,6 +40,7 @@ const modalSlice = createSlice({ openLicenseModal: openLicenseModalReducer, openToSModal: openToSModalReducer, openLayerFactoryModal: openLayerFactoryModalReducer, + openLayerImageObjectFactoryModal: openLayerImageObjectFactoryModalReducer, }, }); @@ -59,6 +61,7 @@ export const { openLicenseModal, openToSModal, openLayerFactoryModal, + openLayerImageObjectFactoryModal, } = modalSlice.actions; export default modalSlice.reducer; diff --git a/src/redux/modal/modal.types.ts b/src/redux/modal/modal.types.ts index 1b544f52..3b22209e 100644 --- a/src/redux/modal/modal.types.ts +++ b/src/redux/modal/modal.types.ts @@ -21,6 +21,15 @@ export type LayerFactoryState = { id: number | undefined; }; +export type LayerImageObjectFactoryState = + | { + x: number; + y: number; + width: number; + height: number; + } + | undefined; + export interface ModalState { isOpen: boolean; modalName: ModalName; @@ -30,6 +39,7 @@ export interface ModalState { errorReportState: ErrorRepostState; editOverlayState: EditOverlayState; layerFactoryState: LayerFactoryState; + layerImageObjectFactoryState: LayerImageObjectFactoryState; } export type OpenEditOverlayModalPayload = MapOverlay; diff --git a/src/redux/root/init.thunks.ts b/src/redux/root/init.thunks.ts index b5a5b4e0..ba7f3059 100644 --- a/src/redux/root/init.thunks.ts +++ b/src/redux/root/init.thunks.ts @@ -23,6 +23,7 @@ import { USER_ACCEPTED_MATOMO_COOKIES_COOKIE_NAME, } from '@/components/FunctionalArea/CookieBanner/CookieBanner.constants'; import { injectMatomoTracking } from '@/utils/injectMatomoTracking'; +import { getGlyphs } from '@/redux/glyphs/glyphs.thunks'; import { getAllBackgroundsByProjectId } from '../backgrounds/backgrounds.thunks'; import { getConfiguration, getConfigurationOptions } from '../configuration/configuration.thunks'; import { @@ -67,6 +68,7 @@ export const fetchInitialAppData = createAsyncThunk< dispatch(getAllPublicOverlaysByProjectId(PROJECT_ID)), dispatch(getModels()), dispatch(getShapes()), + dispatch(getGlyphs()), dispatch(getLineTypes()), dispatch(getArrowTypes()), ]); diff --git a/src/redux/root/root.fixtures.ts b/src/redux/root/root.fixtures.ts index 007c2b35..e2724495 100644 --- a/src/redux/root/root.fixtures.ts +++ b/src/redux/root/root.fixtures.ts @@ -7,6 +7,7 @@ import { SHAPES_STATE_INITIAL_MOCK } from '@/redux/shapes/shapes.mock'; import { MODEL_ELEMENTS_INITIAL_STATE_MOCK } from '@/redux/modelElements/modelElements.mock'; import { LAYERS_STATE_INITIAL_MOCK } from '@/redux/layers/layers.mock'; import { NEW_REACTIONS_INITIAL_STATE_MOCK } from '@/redux/newReactions/newReactions.mock'; +import { GLYPHS_STATE_INITIAL_MOCK } from '@/redux/glyphs/glyphs.mock'; 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'; @@ -41,6 +42,7 @@ export const INITIAL_STORE_STATE_MOCK: RootState = { search: SEARCH_STATE_INITIAL_MOCK, project: PROJECT_STATE_INITIAL_MOCK, shapes: SHAPES_STATE_INITIAL_MOCK, + glyphs: GLYPHS_STATE_INITIAL_MOCK, projects: PROJECTS_STATE_INITIAL_MOCK, drugs: DRUGS_INITIAL_STATE_MOCK, chemicals: CHEMICALS_INITIAL_STATE_MOCK, diff --git a/src/redux/store.ts b/src/redux/store.ts index a5d31bb5..031b51be 100644 --- a/src/redux/store.ts +++ b/src/redux/store.ts @@ -11,6 +11,7 @@ import mapReducer from '@/redux/map/map.slice'; import modalReducer from '@/redux/modal/modal.slice'; import modelsReducer from '@/redux/models/models.slice'; import shapesReducer from '@/redux/shapes/shapes.slice'; +import glyphsReducer from '@/redux/glyphs/glyphs.slice'; import modelElementsReducer from '@/redux/modelElements/modelElements.slice'; import layersReducer from '@/redux/layers/layers.slice'; import oauthReducer from '@/redux/oauth/oauth.slice'; @@ -64,6 +65,7 @@ export const reducers = { overlays: overlaysReducer, models: modelsReducer, shapes: shapesReducer, + glyphs: glyphsReducer, modelElements: modelElementsReducer, layers: layersReducer, reactions: reactionsReducer, diff --git a/src/shared/Input/Input.component.tsx b/src/shared/Input/Input.component.tsx index 00f3e924..96675fdf 100644 --- a/src/shared/Input/Input.component.tsx +++ b/src/shared/Input/Input.component.tsx @@ -1,4 +1,4 @@ -import React, { InputHTMLAttributes } from 'react'; +import React, { InputHTMLAttributes, forwardRef } from 'react'; import { twMerge } from 'tailwind-merge'; type StyleVariant = 'primary' | 'primaryWithoutFull'; @@ -8,6 +8,7 @@ type InputProps = { className?: string; styleVariant?: StyleVariant; sizeVariant?: SizeVariant; + ref?: React.Ref<HTMLInputElement>; } & InputHTMLAttributes<HTMLInputElement>; const styleVariants = { @@ -22,14 +23,17 @@ const sizeVariants = { medium: 'rounded-lg h-12 text-sm', } as const; -export const Input = ({ - className = '', - sizeVariant = 'small', - styleVariant = 'primary', - ...props -}: InputProps): React.ReactNode => ( - <input - {...props} - className={twMerge(styleVariants[styleVariant], sizeVariants[sizeVariant], className)} - /> +export const Input = forwardRef<HTMLInputElement, InputProps>( + ( + { className = '', sizeVariant = 'small', styleVariant = 'primary', ...props }: InputProps, + ref, + ): React.ReactNode => ( + <input + ref={ref} + {...props} + className={twMerge(styleVariants[styleVariant], sizeVariants[sizeVariant], className)} + /> + ), ); + +Input.displayName = 'Input'; diff --git a/src/shared/Select/Select.component.tsx b/src/shared/Select/Select.component.tsx index aa3ab6d5..f0107571 100644 --- a/src/shared/Select/Select.component.tsx +++ b/src/shared/Select/Select.component.tsx @@ -5,7 +5,7 @@ import { Icon } from '@/shared/Icon'; type SelectProps = { options: Array<{ id: number; name: string }>; - selectedId: number; + selectedId: number | null; onChange: (selectedId: number) => void; width?: string | number; }; @@ -16,7 +16,7 @@ export const Select = ({ onChange, width = '100%', }: SelectProps): React.JSX.Element => { - const selectedOption = options.find(option => option.id === selectedId); + const selectedOption = options.find(option => option.id === selectedId) || null; const { isOpen, @@ -63,7 +63,7 @@ export const Select = ({ </div> <ul className={twMerge( - 'absolute z-10 overflow-auto rounded-b bg-white shadow-lg', + 'absolute z-20 overflow-auto rounded-b bg-white shadow-lg', !isOpen && 'hidden', )} style={widthStyle} diff --git a/src/types/modal.ts b/src/types/modal.ts index 861bb295..edf1c858 100644 --- a/src/types/modal.ts +++ b/src/types/modal.ts @@ -12,4 +12,5 @@ export type ModalName = | 'select-project' | 'terms-of-service' | 'logged-in-menu' - | 'layer-factory'; + | 'layer-factory' + | 'layer-image-object-factory'; diff --git a/src/types/models.ts b/src/types/models.ts index 91a9a887..0c00639e 100644 --- a/src/types/models.ts +++ b/src/types/models.ts @@ -85,6 +85,7 @@ import { operatorSchema } from '@/models/operatorSchema'; import { modificationResiduesSchema } from '@/models/modificationResiduesSchema'; import { segmentSchema } from '@/models/segmentSchema'; import { layerImageSchema } from '@/models/layerImageSchema'; +import { glyphSchema } from '@/models/glyphSchema'; export type Project = z.infer<typeof projectSchema>; export type OverviewImageView = z.infer<typeof overviewImageView>; @@ -105,6 +106,7 @@ export type LayerOval = z.infer<typeof layerOvalSchema>; export type LayerLine = z.infer<typeof layerLineSchema>; export type LayerImage = z.infer<typeof layerImageSchema>; export type Arrow = z.infer<typeof arrowSchema>; +export type Glyph = z.infer<typeof glyphSchema>; const modelElementsSchema = pageableSchema(modelElementSchema); export type ModelElements = z.infer<typeof modelElementsSchema>; export type ModelElement = z.infer<typeof modelElementSchema>; -- GitLab From c617996bfd3b93c7d7bc1c05500b4eb77a0bd217 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mi=C5=82osz=20Grocholewski?= <m.grocholewski@atcomp.pl> Date: Fri, 20 Dec 2024 09:56:10 +0100 Subject: [PATCH 07/22] fix(glyphs): correct scaling glyph image --- .../MapViewerVector/utils/shapes/elements/Glyph.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/Glyph.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/Glyph.ts index ff5a7887..c7844ae3 100644 --- a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/Glyph.ts +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/Glyph.ts @@ -139,7 +139,15 @@ export default class Glyph { img.onload = (): void => { const imageWidth = img.naturalWidth; const imageHeight = img.naturalHeight; - this.imageScale = width / imageWidth; + const heightScale = height / imageHeight; + const widthScale = width / imageWidth; + if (heightScale < widthScale) { + this.imageScale = heightScale; + this.widthOnMap = (this.heightOnMap * imageWidth) / imageHeight; + } else { + this.imageScale = widthScale; + this.heightOnMap = (this.widthOnMap * imageHeight) / imageWidth; + } this.style = new Style({ image: new Icon({ anchor: [0, 0], -- GitLab From 377d704bc358c5b4e7a5eb29d564182347bda831 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mi=C5=82osz=20Grocholewski?= <m.grocholewski@atcomp.pl> Date: Fri, 20 Dec 2024 11:54:57 +0100 Subject: [PATCH 08/22] feat(image-layer): add drawing image object --- next.config.js | 2 +- .../AppWrapper/AppWrapper.component.tsx | 2 + ...LayerImageObjectFactoryModal.component.tsx | 27 ++++++- .../useOlMapAdditionalLayers.ts | 19 ++++- .../utils/shapes/layer/Layer.test.ts | 9 +-- .../utils/shapes/layer/Layer.ts | 78 ++++++++----------- src/components/SPA/MinervaSPA.component.tsx | 4 +- src/models/fixtures/layerImagesFixture.ts | 2 +- src/redux/layers/layers.reducers.test.ts | 4 +- src/redux/layers/layers.reducers.ts | 17 ++++ src/redux/layers/layers.selectors.ts | 2 +- src/redux/layers/layers.slice.ts | 4 +- src/redux/layers/layers.thunks.test.ts | 2 +- src/redux/layers/layers.thunks.ts | 32 ++++---- src/redux/layers/layers.types.ts | 2 +- src/redux/modal/modal.reducers.ts | 7 +- src/utils/array/arrayToKeyValue.ts | 12 +++ 17 files changed, 144 insertions(+), 81 deletions(-) create mode 100644 src/utils/array/arrayToKeyValue.ts diff --git a/next.config.js b/next.config.js index c882ad1b..46540574 100644 --- a/next.config.js +++ b/next.config.js @@ -1,6 +1,6 @@ /** @type {import("next").NextConfig} */ const nextConfig = { - reactStrictMode: false, + reactStrictMode: true, basePath: process.env.APP_PREFIX ? process.env.APP_PREFIX + '/index.html' : '', assetPrefix: process.env.APP_PREFIX ? process.env.APP_PREFIX : '', productionBrowserSourceMaps: true, diff --git a/src/components/AppWrapper/AppWrapper.component.tsx b/src/components/AppWrapper/AppWrapper.component.tsx index 2bae3997..39a65352 100644 --- a/src/components/AppWrapper/AppWrapper.component.tsx +++ b/src/components/AppWrapper/AppWrapper.component.tsx @@ -3,6 +3,7 @@ import { MapInstanceProvider } from '@/utils/context/mapInstanceContext'; import { ReactNode } from 'react'; import { Provider } from 'react-redux'; import { Toaster } from 'sonner'; +import { Modal } from '@/components/FunctionalArea/Modal'; interface AppWrapperProps { children: ReactNode; @@ -13,6 +14,7 @@ export const AppWrapper = ({ children }: AppWrapperProps): JSX.Element => { <MapInstanceProvider> <Provider store={store}> <> + <Modal /> <Toaster position="top-center" visibleToasts={1} diff --git a/src/components/FunctionalArea/Modal/LayerImageObjectFactoryModal/LayerImageObjectFactoryModal.component.tsx b/src/components/FunctionalArea/Modal/LayerImageObjectFactoryModal/LayerImageObjectFactoryModal.component.tsx index 10539f95..5144ee8c 100644 --- a/src/components/FunctionalArea/Modal/LayerImageObjectFactoryModal/LayerImageObjectFactoryModal.component.tsx +++ b/src/components/FunctionalArea/Modal/LayerImageObjectFactoryModal/LayerImageObjectFactoryModal.component.tsx @@ -13,13 +13,15 @@ import { Glyph } from '@/types/models'; import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; import { currentModelIdSelector } from '@/redux/models/models.selectors'; import { highestZIndexSelector, layersActiveLayerSelector } from '@/redux/layers/layers.selectors'; -import { addLayerImageObject, getLayersForModel } from '@/redux/layers/layers.thunks'; +import { addLayerImageObject } from '@/redux/layers/layers.thunks'; import { addGlyph } from '@/redux/glyphs/glyphs.thunks'; import { SerializedError } from '@reduxjs/toolkit'; import { showToast } from '@/utils/showToast'; import { closeModal } from '@/redux/modal/modal.slice'; import { LoadingIndicator } from '@/shared/LoadingIndicator'; import './LayerImageObjectFactoryModal.styles.css'; +import { useMapInstance } from '@/utils/context/mapInstanceContext'; +import { layerAddImage } from '@/redux/layers/layers.slice'; export const LayerImageObjectFactoryModal: React.FC = () => { const glyphs: Glyph[] = useAppSelector(glyphsDataSelector); @@ -29,6 +31,7 @@ export const LayerImageObjectFactoryModal: React.FC = () => { const dispatch = useAppDispatch(); const fileInputRef = useRef<HTMLInputElement>(null); const highestZIndex = useAppSelector(highestZIndexSelector); + const { mapInstance } = useMapInstance(); const [selectedGlyph, setSelectedGlyph] = useState<number | null>(null); const [file, setFile] = useState<File | null>(null); @@ -79,7 +82,7 @@ export const LayerImageObjectFactoryModal: React.FC = () => { } glyphId = data.id; } - await dispatch( + const imageData = await dispatch( addLayerImageObject({ modelId: currentModelId, layerId: activeLayer, @@ -91,12 +94,30 @@ export const LayerImageObjectFactoryModal: React.FC = () => { glyph: glyphId, }), ).unwrap(); + if (!imageData) { + showToast({ + type: 'error', + message: 'Error during adding layer image object', + }); + return; + } + dispatch( + layerAddImage({ modelId: currentModelId, layerId: activeLayer, layerImage: imageData }), + ); + mapInstance?.getAllLayers().forEach(layer => { + if (layer.get('id') === activeLayer && layer.get('drawImage')) { + const drawImage = layer.get('drawImage'); + if (drawImage instanceof Function) { + drawImage(imageData); + } + } + }); + showToast({ type: 'success', message: 'A new image object has been successfully added', }); dispatch(closeModal()); - dispatch(getLayersForModel(currentModelId)); } catch (error) { const typedError = error as SerializedError; showToast({ diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/config/additionalLayers/useOlMapAdditionalLayers.ts b/src/components/Map/MapViewer/MapViewerVector/utils/config/additionalLayers/useOlMapAdditionalLayers.ts index 31c1a087..1563b536 100644 --- a/src/components/Map/MapViewer/MapViewerVector/utils/config/additionalLayers/useOlMapAdditionalLayers.ts +++ b/src/components/Map/MapViewer/MapViewerVector/utils/config/additionalLayers/useOlMapAdditionalLayers.ts @@ -2,7 +2,7 @@ import { Feature } from 'ol'; import VectorLayer from 'ol/layer/Vector'; import VectorSource from 'ol/source/Vector'; -import { useEffect, useMemo } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { useSelector } from 'react-redux'; import { currentModelIdSelector, vectorRenderingSelector } from '@/redux/models/models.selectors'; import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; @@ -22,6 +22,7 @@ import { arrowTypesSelector, lineTypesSelector } from '@/redux/shapes/shapes.sel import { useAppSelector } from '@/redux/hooks/useAppSelector'; import { mapDataSizeSelector } from '@/redux/map/map.selectors'; import getDrawImageInteraction from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/layer/getDrawImageInteraction'; +import { LayerState } from '@/redux/layers/layers.types'; export const useOlMapAdditionalLayers = ( mapInstance: MapInstance, @@ -40,6 +41,9 @@ export const useOlMapAdditionalLayers = ( const activeLayer = useAppSelector(layersActiveLayerSelector); const vectorRendering = useAppSelector(vectorRenderingSelector); + const [layersState, setLayersState] = useState<Array<LayerState>>([]); + const [layersLoadingState, setLayersLoadingState] = useState(false); + const lineTypes = useSelector(lineTypesSelector); const arrowTypes = useSelector(arrowTypesSelector); const pointToProjection = usePointToProjection(); @@ -61,7 +65,7 @@ export const useOlMapAdditionalLayers = ( }, [currentModelId, dispatch, layersLoading]); const vectorLayers = useMemo(() => { - return layersForCurrentModel.map(layer => { + return layersState.map(layer => { const additionalLayer = new Layer({ texts: layer.texts, rects: layer.rects, @@ -77,7 +81,16 @@ export const useOlMapAdditionalLayers = ( }); return additionalLayer.vectorLayer; }); - }, [layersForCurrentModel, lineTypes, arrowTypes, mapInstance, pointToProjection]); + }, [layersState, lineTypes, arrowTypes, mapInstance, pointToProjection]); + + useEffect(() => { + if (layersLoading === 'pending') { + setLayersLoadingState(true); + } else if (layersLoading === 'succeeded' && layersLoadingState) { + setLayersLoadingState(false); + setLayersState(layersForCurrentModel); + } + }, [layersForCurrentModel, layersLoading, layersLoadingState]); useEffect(() => { vectorLayers.forEach(layer => { diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/layer/Layer.test.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/layer/Layer.test.ts index eeabb89c..1cdd1aef 100644 --- a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/layer/Layer.test.ts +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/layer/Layer.test.ts @@ -113,8 +113,8 @@ describe('Layer', () => { lineType: 'SOLID', }, ], - images: [ - { + images: { + 1: { id: 1, glyph: 1, x: 1, @@ -123,7 +123,7 @@ describe('Layer', () => { height: 1, z: 1, }, - ], + }, visible: true, layerId: 23, pointToProjection: jest.fn(point => [point.x, point.y]), @@ -144,9 +144,6 @@ describe('Layer', () => { it('should initialize a Layer class', () => { const layer = new Layer(props); - expect(layer.textFeatures.length).toBe(1); - expect(layer.rectFeatures.length).toBe(1); - expect(layer.ovalFeatures.length).toBe(1); expect(layer.vectorSource).toBeInstanceOf(VectorSource); expect(layer.vectorLayer).toBeInstanceOf(VectorLayer); }); diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/layer/Layer.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/layer/Layer.ts index 50a2b285..e48faf93 100644 --- a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/layer/Layer.ts +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/layer/Layer.ts @@ -33,7 +33,7 @@ export interface LayerProps { rects: Array<LayerRect>; ovals: Array<LayerOval>; lines: Array<LayerLine>; - images: Array<LayerImage>; + images: { [key: string]: LayerImage }; visible: boolean; layerId: number; lineTypes: LineTypeDict; @@ -51,24 +51,12 @@ export default class Layer { lines: Array<LayerLine>; - images: Array<LayerImage>; + images: { [key: string]: LayerImage }; lineTypes: LineTypeDict; arrowTypes: ArrowTypeDict; - textFeatures: Array<Feature<Point>>; - - rectFeatures: Array<Feature<Polygon>>; - - ovalFeatures: Array<Feature<Polygon>>; - - imageFeatures: Array<Feature<Polygon>>; - - lineFeatures: Array<Feature<LineString>>; - - arrowFeatures: Array<Feature<MultiPolygon>>; - pointToProjection: UsePointToProjectionResult; mapInstance: MapInstance; @@ -94,6 +82,8 @@ export default class Layer { mapInstance, pointToProjection, }: LayerProps) { + this.vectorSource = new VectorSource({}); + this.texts = texts; this.rects = rects; this.ovals = ovals; @@ -103,23 +93,16 @@ export default class Layer { this.arrowTypes = arrowTypes; this.pointToProjection = pointToProjection; this.mapInstance = mapInstance; - this.textFeatures = this.getTextsFeatures(); - this.rectFeatures = this.getRectsFeatures(); - this.ovalFeatures = this.getOvalsFeatures(); - this.imageFeatures = this.getImagesFeatures(); + + this.vectorSource.addFeatures(this.getTextsFeatures()); + this.vectorSource.addFeatures(this.getRectsFeatures()); + this.vectorSource.addFeatures(this.getOvalsFeatures()); + this.drawImages(); + const { linesFeatures, arrowsFeatures } = this.getLinesFeatures(); - this.lineFeatures = linesFeatures; - this.arrowFeatures = arrowsFeatures; - this.vectorSource = new VectorSource({ - features: [ - ...this.textFeatures, - ...this.rectFeatures, - ...this.ovalFeatures, - ...this.lineFeatures, - ...this.arrowFeatures, - ...this.imageFeatures, - ], - }); + this.vectorSource.addFeatures(linesFeatures); + this.vectorSource.addFeatures(arrowsFeatures); + this.vectorLayer = new VectorLayer({ source: this.vectorSource, visible, @@ -128,6 +111,7 @@ export default class Layer { }); this.vectorLayer.set('id', layerId); + this.vectorLayer.set('drawImage', this.drawImage.bind(this)); } private getTextsFeatures = (): Array<Feature<Point>> => { @@ -306,22 +290,26 @@ export default class Layer { return { linesFeatures, arrowsFeatures }; }; - private getImagesFeatures = (): Array<Feature<Polygon>> => { - return this.images.map(image => { - const glyph = new Glyph({ - elementId: image.id, - glyphId: image.glyph, - x: image.x, - y: image.y, - width: image.width, - height: image.height, - zIndex: image.z, - pointToProjection: this.pointToProjection, - mapInstance: this.mapInstance, - }); - return glyph.feature; + private drawImages(): void { + Object.values(this.images).forEach(image => { + this.drawImage(image); }); - }; + } + + private drawImage(image: LayerImage): void { + const glyph = new Glyph({ + elementId: image.id, + glyphId: image.glyph, + x: image.x, + y: image.y, + width: image.width, + height: image.height, + zIndex: image.z, + pointToProjection: this.pointToProjection, + mapInstance: this.mapInstance, + }); + this.vectorSource.addFeature(glyph.feature); + } protected getStyle(feature: FeatureLike, resolution: number): Style | Array<Style> | void { const styles: Array<Style> = []; diff --git a/src/components/SPA/MinervaSPA.component.tsx b/src/components/SPA/MinervaSPA.component.tsx index 5762a59b..2e9751e3 100644 --- a/src/components/SPA/MinervaSPA.component.tsx +++ b/src/components/SPA/MinervaSPA.component.tsx @@ -6,7 +6,7 @@ import { twMerge } from 'tailwind-merge'; import { useEffect } from 'react'; import { PluginsManager } from '@/services/pluginsManager'; import { useInitializeStore } from '../../utils/initialize/useInitializeStore'; -import { Modal } from '../FunctionalArea/Modal'; +// import { Modal } from '../FunctionalArea/Modal'; import { ContextMenu } from '../FunctionalArea/ContextMenu'; import { CookieBanner } from '../FunctionalArea/CookieBanner'; @@ -24,7 +24,7 @@ export const MinervaSPA = (): JSX.Element => { <div className={twMerge('relative', manrope.variable)}> <FunctionalArea /> <Map /> - <Modal /> + {/* <Modal /> */} <ContextMenu /> <CookieBanner /> </div> diff --git a/src/models/fixtures/layerImagesFixture.ts b/src/models/fixtures/layerImagesFixture.ts index 382b5f92..77c2d027 100644 --- a/src/models/fixtures/layerImagesFixture.ts +++ b/src/models/fixtures/layerImagesFixture.ts @@ -6,5 +6,5 @@ import { layerImageSchema } from '@/models/layerImageSchema'; export const layerImagesFixture = createFixture(pageableSchema(layerImageSchema), { seed: ZOD_SEED, - array: { min: 3, max: 3 }, + array: { min: 1, max: 1 }, }); diff --git a/src/redux/layers/layers.reducers.test.ts b/src/redux/layers/layers.reducers.test.ts index f68bdc4a..20d1cdbd 100644 --- a/src/redux/layers/layers.reducers.test.ts +++ b/src/redux/layers/layers.reducers.test.ts @@ -66,7 +66,7 @@ describe('layers reducer', () => { rects: layerRectsFixture.content, ovals: layerOvalsFixture.content, lines: layerLinesFixture.content, - images: layerImagesFixture.content, + images: { [layerImagesFixture.content[0].id]: layerImagesFixture.content[0] }, }, ], layersVisibility: { @@ -130,7 +130,7 @@ describe('layers reducer', () => { rects: layerRectsFixture.content, ovals: layerOvalsFixture.content, lines: layerLinesFixture.content, - images: layerImagesFixture.content, + images: { [layerImagesFixture.content[0].id]: layerImagesFixture.content[0] }, }, ], layersVisibility: { diff --git a/src/redux/layers/layers.reducers.ts b/src/redux/layers/layers.reducers.ts index 70bdf8d5..9e41f8de 100644 --- a/src/redux/layers/layers.reducers.ts +++ b/src/redux/layers/layers.reducers.ts @@ -7,6 +7,7 @@ import { LAYERS_STATE_INITIAL_LAYER_MOCK, } from '@/redux/layers/layers.mock'; import { DEFAULT_ERROR } from '@/constants/errors'; +import { LayerImage } from '@/types/models'; export const getLayersForModelReducer = (builder: ActionReducerMapBuilder<LayersState>): void => { builder.addCase(getLayersForModel.pending, (state, action) => { @@ -62,3 +63,19 @@ export const setActiveLayerReducer = ( } data.activeLayer = layerId; }; + +export const layerAddImageReducer = ( + state: LayersState, + action: PayloadAction<{ modelId: number; layerId: number; layerImage: LayerImage }>, +): void => { + const { modelId, layerId, layerImage } = action.payload; + const { data } = state[modelId]; + if (!data) { + return; + } + const layer = data.layers.find(layerState => layerState.details.id === layerId); + if (!layer) { + return; + } + layer.images[layerImage.id] = layerImage; +}; diff --git a/src/redux/layers/layers.selectors.ts b/src/redux/layers/layers.selectors.ts index 4392df6e..aa71d5e4 100644 --- a/src/redux/layers/layers.selectors.ts +++ b/src/redux/layers/layers.selectors.ts @@ -42,7 +42,7 @@ export const highestZIndexSelector = createSelector(layersForCurrentModelSelecto const rectsMaxZ = getMaxZFromItems(layer.rects); const ovalsMaxZ = getMaxZFromItems(layer.ovals); const linesMaxZ = getMaxZFromItems(layer.lines); - const imagesMaxZ = getMaxZFromItems(layer.images); + const imagesMaxZ = getMaxZFromItems(Object.values(layer.images)); const layerMaxZ = Math.max(textsMaxZ, rectsMaxZ, ovalsMaxZ, linesMaxZ, imagesMaxZ); diff --git a/src/redux/layers/layers.slice.ts b/src/redux/layers/layers.slice.ts index d35f1399..9f78f0dd 100644 --- a/src/redux/layers/layers.slice.ts +++ b/src/redux/layers/layers.slice.ts @@ -2,6 +2,7 @@ import { createSlice } from '@reduxjs/toolkit'; import { LAYERS_STATE_INITIAL_MOCK } from '@/redux/layers/layers.mock'; import { getLayersForModelReducer, + layerAddImageReducer, setActiveLayerReducer, setLayerVisibilityReducer, } from '@/redux/layers/layers.reducers'; @@ -12,12 +13,13 @@ export const layersSlice = createSlice({ reducers: { setLayerVisibility: setLayerVisibilityReducer, setActiveLayer: setActiveLayerReducer, + layerAddImage: layerAddImageReducer, }, extraReducers: builder => { getLayersForModelReducer(builder); }, }); -export const { setLayerVisibility, setActiveLayer } = layersSlice.actions; +export const { setLayerVisibility, setActiveLayer, layerAddImage } = layersSlice.actions; export default layersSlice.reducer; diff --git a/src/redux/layers/layers.thunks.test.ts b/src/redux/layers/layers.thunks.test.ts index 03ba8d80..218d9ce5 100644 --- a/src/redux/layers/layers.thunks.test.ts +++ b/src/redux/layers/layers.thunks.test.ts @@ -60,7 +60,7 @@ describe('layers thunks', () => { rects: layerRectsFixture.content, ovals: layerOvalsFixture.content, lines: layerLinesFixture.content, - images: layerImagesFixture.content, + images: { [layerImagesFixture.content[0].id]: layerImagesFixture.content[0] }, }, ], layersVisibility: { diff --git a/src/redux/layers/layers.thunks.ts b/src/redux/layers/layers.thunks.ts index 44f642a2..5a2ec2d4 100644 --- a/src/redux/layers/layers.thunks.ts +++ b/src/redux/layers/layers.thunks.ts @@ -1,7 +1,7 @@ /* eslint-disable no-magic-numbers */ import { z as zod } from 'zod'; import { apiPath } from '@/redux/apiPath'; -import { Layer, Layers } from '@/types/models'; +import { Layer, LayerImage, Layers } from '@/types/models'; import { validateDataUsingZodSchema } from '@/utils/validateDataUsingZodSchema'; import { createAsyncThunk } from '@reduxjs/toolkit'; import { ThunkConfig } from '@/types/store'; @@ -20,6 +20,7 @@ import { pageableSchema } from '@/models/pageableSchema'; import { layerOvalSchema } from '@/models/layerOvalSchema'; import { layerLineSchema } from '@/models/layerLineSchema'; import { layerImageSchema } from '@/models/layerImageSchema'; +import arrayToKeyValue from '@/utils/array/arrayToKeyValue'; export const getLayer = createAsyncThunk< Layer | null, @@ -58,14 +59,13 @@ export const getLayersForModel = createAsyncThunk< axiosInstanceNewAPI.get(apiPath.getLayerLines(modelId, layer.id)), axiosInstanceNewAPI.get(apiPath.getLayerImages(modelId, layer.id)), ]); - return { details: layer, texts: textsResponse.data.content, rects: rectsResponse.data.content, ovals: ovalsResponse.data.content, lines: linesResponse.data.content, - images: imagesResponse.data.content, + images: arrayToKeyValue(imagesResponse.data.content, 'id'), }; }), ); @@ -75,7 +75,7 @@ export const getLayersForModel = createAsyncThunk< zod.array(layerRectSchema).safeParse(layer.rects).success && zod.array(layerOvalSchema).safeParse(layer.ovals).success && zod.array(layerLineSchema).safeParse(layer.lines).success && - zod.array(layerImageSchema).safeParse(layer.images).success + zod.array(layerImageSchema).safeParse(Object.values(layer.images)).success ); }); const layersVisibility = layers.reduce((acc: { [key: string]: boolean }, layer) => { @@ -149,7 +149,7 @@ export const removeLayer = createAsyncThunk< }); export const addLayerImageObject = createAsyncThunk< - void, + LayerImage | null, { modelId: number; layerId: number; @@ -164,14 +164,20 @@ export const addLayerImageObject = createAsyncThunk< // eslint-disable-next-line consistent-return >('vectorMap/addLayerImageObject', async ({ modelId, layerId, x, y, z, width, height, glyph }) => { try { - await axiosInstanceNewAPI.post<void>(apiPath.addLayerImageObject(modelId, layerId), { - x, - y, - z, - width, - height, - glyph, - }); + const { data } = await axiosInstanceNewAPI.post<LayerImage>( + apiPath.addLayerImageObject(modelId, layerId), + { + x, + y, + z, + width, + height, + glyph, + }, + ); + const isDataValid = validateDataUsingZodSchema(data, layerImageSchema); + + return isDataValid ? data : null; } catch (error) { return Promise.reject(getError({ error })); } diff --git a/src/redux/layers/layers.types.ts b/src/redux/layers/layers.types.ts index 049828d8..27dc36fc 100644 --- a/src/redux/layers/layers.types.ts +++ b/src/redux/layers/layers.types.ts @@ -22,7 +22,7 @@ export type LayerState = { rects: LayerRect[]; ovals: LayerOval[]; lines: LayerLine[]; - images: LayerImage[]; + images: { [key: string]: LayerImage }; }; export type LayerVisibilityState = { diff --git a/src/redux/modal/modal.reducers.ts b/src/redux/modal/modal.reducers.ts index b59bf868..f678ea91 100644 --- a/src/redux/modal/modal.reducers.ts +++ b/src/redux/modal/modal.reducers.ts @@ -141,7 +141,12 @@ export const openLayerFactoryModalReducer = ( export const openLayerImageObjectFactoryModalReducer = ( state: ModalState, - action: PayloadAction<{ x: number; y: number; width: number; height: number }>, + action: PayloadAction<{ + x: number; + y: number; + width: number; + height: number; + }>, ): void => { state.layerImageObjectFactoryState = action.payload; state.isOpen = true; diff --git a/src/utils/array/arrayToKeyValue.ts b/src/utils/array/arrayToKeyValue.ts new file mode 100644 index 00000000..52c7cf2e --- /dev/null +++ b/src/utils/array/arrayToKeyValue.ts @@ -0,0 +1,12 @@ +export default function arrayToKeyValue<T extends Record<string, never>, K extends keyof T>( + array: T[], + key: K, +): Record<T[K] & PropertyKey, T> { + return array.reduce( + (accumulator, currentItem) => { + accumulator[currentItem[key] as T[K] & PropertyKey] = currentItem; + return accumulator; + }, + {} as Record<T[K] & PropertyKey, T>, + ); +} -- GitLab From a3cf7e480f8e5f7e59c30f8abbd3a7b42d70b654 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mi=C5=82osz=20Grocholewski?= <m.grocholewski@atcomp.pl> Date: Fri, 20 Dec 2024 13:44:44 +0100 Subject: [PATCH 09/22] feat(image-layer): add toggle drawing image action --- src/components/Map/Map.component.tsx | 2 ++ .../MapActiveLayerSelector.component.tsx | 2 +- .../MapDrawActions.component.tsx | 27 +++++++++++++++ .../MapDrawActionsButton.component.tsx | 33 +++++++++++++++++++ .../MapVectorBackgroundSelector.component.tsx | 2 +- .../useOlMapAdditionalLayers.ts | 14 ++++++-- .../shapes/layer/getDrawImageInteraction.ts | 1 + .../mapEditTools/mapEditTools.constants.ts | 3 ++ src/redux/mapEditTools/mapEditTools.mock.ts | 5 +++ .../mapEditTools/mapEditTools.reducers.ts | 15 +++++++++ .../mapEditTools/mapEditTools.selectors.ts | 10 ++++++ src/redux/mapEditTools/mapEditTools.slice.ts | 15 +++++++++ src/redux/mapEditTools/mapEditTools.types.ts | 5 +++ src/redux/root/root.fixtures.ts | 2 ++ src/redux/store.ts | 2 ++ src/shared/Icon/Icon.component.tsx | 2 ++ src/shared/Icon/Icons/ImageIcon.tsx | 25 ++++++++++++++ src/types/iconTypes.ts | 3 +- 18 files changed, 163 insertions(+), 5 deletions(-) create mode 100644 src/components/Map/MapDrawActions/MapDrawActions.component.tsx create mode 100644 src/components/Map/MapDrawActions/MapDrawActionsButton.component.tsx create mode 100644 src/redux/mapEditTools/mapEditTools.constants.ts create mode 100644 src/redux/mapEditTools/mapEditTools.mock.ts create mode 100644 src/redux/mapEditTools/mapEditTools.reducers.ts create mode 100644 src/redux/mapEditTools/mapEditTools.selectors.ts create mode 100644 src/redux/mapEditTools/mapEditTools.slice.ts create mode 100644 src/redux/mapEditTools/mapEditTools.types.ts create mode 100644 src/shared/Icon/Icons/ImageIcon.tsx diff --git a/src/components/Map/Map.component.tsx b/src/components/Map/Map.component.tsx index 65392d69..3209e8ac 100644 --- a/src/components/Map/Map.component.tsx +++ b/src/components/Map/Map.component.tsx @@ -8,6 +8,7 @@ import { MapActiveLayerSelector } from '@/components/Map/MapActiveLayerSelector/ import { useAppSelector } from '@/redux/hooks/useAppSelector'; import { vectorRenderingSelector } from '@/redux/models/models.selectors'; import { MapAdditionalLogos } from '@/components/Map/MapAdditionalLogos'; +import { MapDrawActions } from '@/components/Map/MapDrawActions/MapDrawActions.component'; import { MapAdditionalActions } from './MapAdditionalActions'; import { MapAdditionalOptions } from './MapAdditionalOptions'; import { PluginsDrawer } from './PluginsDrawer'; @@ -25,6 +26,7 @@ export const Map = (): JSX.Element => { <> <MapVectorBackgroundSelector /> <MapActiveLayerSelector /> + <MapDrawActions /> </> )} <Drawer /> diff --git a/src/components/Map/MapActiveLayerSelector/MapActiveLayerSelector.component.tsx b/src/components/Map/MapActiveLayerSelector/MapActiveLayerSelector.component.tsx index d4fd65a1..0f6a8810 100644 --- a/src/components/Map/MapActiveLayerSelector/MapActiveLayerSelector.component.tsx +++ b/src/components/Map/MapActiveLayerSelector/MapActiveLayerSelector.component.tsx @@ -46,7 +46,7 @@ export const MapActiveLayerSelector = (): JSX.Element => { }, [activeLayer, currentModelId, dispatch, options]); return ( - <div className={twMerge('absolute right-[140px] top-[calc(64px+40px+24px)] z-10 flex')}> + <div className={twMerge('absolute right-6 top-[calc(64px+40px+84px)] z-10 flex')}> <Select options={options} selectedId={activeLayer} onChange={handleChange} width={140} /> </div> ); diff --git a/src/components/Map/MapDrawActions/MapDrawActions.component.tsx b/src/components/Map/MapDrawActions/MapDrawActions.component.tsx new file mode 100644 index 00000000..3d02bee2 --- /dev/null +++ b/src/components/Map/MapDrawActions/MapDrawActions.component.tsx @@ -0,0 +1,27 @@ +/* eslint-disable no-magic-numbers */ +import { MAP_EDIT_ACTIONS } from '@/redux/mapEditTools/mapEditTools.constants'; +import { mapEditToolsSetActiveAction } from '@/redux/mapEditTools/mapEditTools.slice'; +import { useAppSelector } from '@/redux/hooks/useAppSelector'; +import { mapEditToolsActiveActionSelector } from '@/redux/mapEditTools/mapEditTools.selectors'; +import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; +import { MapDrawActionsButton } from '@/components/Map/MapDrawActions/MapDrawActionsButton.component'; + +export const MapDrawActions = (): React.JSX.Element => { + const activeAction = useAppSelector(mapEditToolsActiveActionSelector); + const dispatch = useAppDispatch(); + + const toggleMapEditAction = (action: keyof typeof MAP_EDIT_ACTIONS): void => { + dispatch(mapEditToolsSetActiveAction(action)); + }; + + return ( + <div className="absolute right-6 top-[calc(64px+40px+144px)] z-10 flex flex-col gap-4"> + <MapDrawActionsButton + isActive={activeAction === MAP_EDIT_ACTIONS.DRAW_IMAGE} + toggleMapEditAction={() => toggleMapEditAction(MAP_EDIT_ACTIONS.DRAW_IMAGE)} + icon="image" + title="Draw image" + /> + </div> + ); +}; diff --git a/src/components/Map/MapDrawActions/MapDrawActionsButton.component.tsx b/src/components/Map/MapDrawActions/MapDrawActionsButton.component.tsx new file mode 100644 index 00000000..eacefa31 --- /dev/null +++ b/src/components/Map/MapDrawActions/MapDrawActionsButton.component.tsx @@ -0,0 +1,33 @@ +/* eslint-disable no-magic-numbers */ +import { Icon } from '@/shared/Icon'; +import type { IconTypes } from '@/types/iconTypes'; + +type MapDrawActionsButtonProps = { + isActive: boolean; + toggleMapEditAction: () => void; + icon: IconTypes; + title?: string; +}; + +export const MapDrawActionsButton = ({ + isActive, + toggleMapEditAction, + icon, + title = '', +}: MapDrawActionsButtonProps): React.JSX.Element => { + return ( + <button + type="button" + className={`flex h-12 w-12 items-center justify-center rounded-full ${ + isActive ? 'bg-primary-100' : 'bg-white drop-shadow-primary' + }`} + onClick={() => toggleMapEditAction()} + title={title} + > + <Icon + className={`h-[28px] w-[28px] ${isActive ? 'text-primary-500' : 'text-black'}`} + name={icon} + /> + </button> + ); +}; diff --git a/src/components/Map/MapVectorBackgroundSelector/MapVectorBackgroundSelector.component.tsx b/src/components/Map/MapVectorBackgroundSelector/MapVectorBackgroundSelector.component.tsx index 32af5af4..c4e2cafa 100644 --- a/src/components/Map/MapVectorBackgroundSelector/MapVectorBackgroundSelector.component.tsx +++ b/src/components/Map/MapVectorBackgroundSelector/MapVectorBackgroundSelector.component.tsx @@ -20,7 +20,7 @@ export const MapVectorBackgroundSelector = (): JSX.Element => { options={MAP_BACKGROUND_TYPES} selectedId={backgroundType} onChange={handleChange} - width={100} + width={140} /> </div> ); diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/config/additionalLayers/useOlMapAdditionalLayers.ts b/src/components/Map/MapViewer/MapViewerVector/utils/config/additionalLayers/useOlMapAdditionalLayers.ts index 1563b536..2cdceec6 100644 --- a/src/components/Map/MapViewer/MapViewerVector/utils/config/additionalLayers/useOlMapAdditionalLayers.ts +++ b/src/components/Map/MapViewer/MapViewerVector/utils/config/additionalLayers/useOlMapAdditionalLayers.ts @@ -23,6 +23,8 @@ import { useAppSelector } from '@/redux/hooks/useAppSelector'; import { mapDataSizeSelector } from '@/redux/map/map.selectors'; import getDrawImageInteraction from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/layer/getDrawImageInteraction'; import { LayerState } from '@/redux/layers/layers.types'; +import { mapEditToolsActiveActionSelector } from '@/redux/mapEditTools/mapEditTools.selectors'; +import { MAP_EDIT_ACTIONS } from '@/redux/mapEditTools/mapEditTools.constants'; export const useOlMapAdditionalLayers = ( mapInstance: MapInstance, @@ -31,6 +33,7 @@ export const useOlMapAdditionalLayers = ( VectorSource<Feature<Point> | Feature<Polygon> | Feature<LineString> | Feature<MultiPolygon>> > > => { + const activeAction = useAppSelector(mapEditToolsActiveActionSelector); const dispatch = useAppDispatch(); const mapSize = useSelector(mapDataSizeSelector); const currentModelId = useSelector(currentModelIdSelector); @@ -106,11 +109,18 @@ export const useOlMapAdditionalLayers = ( return; } mapInstance?.removeInteraction(drawImageInteraction); - if (!activeLayer || !vectorRendering) { + if (!activeLayer || !vectorRendering || activeAction !== MAP_EDIT_ACTIONS.DRAW_IMAGE) { return; } mapInstance?.addInteraction(drawImageInteraction); - }, [activeLayer, currentModelId, drawImageInteraction, mapInstance, vectorRendering]); + }, [ + activeAction, + activeLayer, + currentModelId, + drawImageInteraction, + mapInstance, + vectorRendering, + ]); return vectorLayers; }; diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/layer/getDrawImageInteraction.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/layer/getDrawImageInteraction.ts index a4f044ee..2b8d42c4 100644 --- a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/layer/getDrawImageInteraction.ts +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/layer/getDrawImageInteraction.ts @@ -12,6 +12,7 @@ import { openLayerImageObjectFactoryModal } from '@/redux/modal/modal.slice'; export default function getDrawImageInteraction(mapSize: MapSize, dispatch: AppDispatch): Draw { const drawImageInteraction = new Draw({ type: 'Circle', + freehand: true, geometryFunction: (coordinates, geometry): SimpleGeometry => { const newGeometry = geometry || new Polygon([]); if (!Array.isArray(coordinates) || coordinates.length < 2) { diff --git a/src/redux/mapEditTools/mapEditTools.constants.ts b/src/redux/mapEditTools/mapEditTools.constants.ts new file mode 100644 index 00000000..3f54d2b0 --- /dev/null +++ b/src/redux/mapEditTools/mapEditTools.constants.ts @@ -0,0 +1,3 @@ +export const MAP_EDIT_ACTIONS = { + DRAW_IMAGE: 'DRAW_IMAGE', +} as const; diff --git a/src/redux/mapEditTools/mapEditTools.mock.ts b/src/redux/mapEditTools/mapEditTools.mock.ts new file mode 100644 index 00000000..81dd0812 --- /dev/null +++ b/src/redux/mapEditTools/mapEditTools.mock.ts @@ -0,0 +1,5 @@ +import { MapEditToolsState } from '@/redux/mapEditTools/mapEditTools.types'; + +export const MAP_EDIT_TOOLS_STATE_INITIAL_MOCK: MapEditToolsState = { + activeAction: null, +}; diff --git a/src/redux/mapEditTools/mapEditTools.reducers.ts b/src/redux/mapEditTools/mapEditTools.reducers.ts new file mode 100644 index 00000000..2b83e994 --- /dev/null +++ b/src/redux/mapEditTools/mapEditTools.reducers.ts @@ -0,0 +1,15 @@ +/* eslint-disable no-magic-numbers */ +import { PayloadAction } from '@reduxjs/toolkit'; +import { MAP_EDIT_ACTIONS } from '@/redux/mapEditTools/mapEditTools.constants'; +import { MapEditToolsState } from '@/redux/mapEditTools/mapEditTools.types'; + +export const mapEditToolsSetActiveActionReducer = ( + state: MapEditToolsState, + action: PayloadAction<keyof typeof MAP_EDIT_ACTIONS>, +): void => { + if (state.activeAction !== action.payload) { + state.activeAction = action.payload; + } else { + state.activeAction = null; + } +}; diff --git a/src/redux/mapEditTools/mapEditTools.selectors.ts b/src/redux/mapEditTools/mapEditTools.selectors.ts new file mode 100644 index 00000000..545d0413 --- /dev/null +++ b/src/redux/mapEditTools/mapEditTools.selectors.ts @@ -0,0 +1,10 @@ +/* eslint-disable no-magic-numbers */ +import { createSelector } from '@reduxjs/toolkit'; +import { rootSelector } from '@/redux/root/root.selectors'; + +export const mapEditToolsSelector = createSelector(rootSelector, state => state.mapEditTools); + +export const mapEditToolsActiveActionSelector = createSelector( + mapEditToolsSelector, + state => state.activeAction, +); diff --git a/src/redux/mapEditTools/mapEditTools.slice.ts b/src/redux/mapEditTools/mapEditTools.slice.ts new file mode 100644 index 00000000..bea57d9c --- /dev/null +++ b/src/redux/mapEditTools/mapEditTools.slice.ts @@ -0,0 +1,15 @@ +import { createSlice } from '@reduxjs/toolkit'; +import { MAP_EDIT_TOOLS_STATE_INITIAL_MOCK } from '@/redux/mapEditTools/mapEditTools.mock'; +import { mapEditToolsSetActiveActionReducer } from '@/redux/mapEditTools/mapEditTools.reducers'; + +export const layersSlice = createSlice({ + name: 'layers', + initialState: MAP_EDIT_TOOLS_STATE_INITIAL_MOCK, + reducers: { + mapEditToolsSetActiveAction: mapEditToolsSetActiveActionReducer, + }, +}); + +export const { mapEditToolsSetActiveAction } = layersSlice.actions; + +export default layersSlice.reducer; diff --git a/src/redux/mapEditTools/mapEditTools.types.ts b/src/redux/mapEditTools/mapEditTools.types.ts new file mode 100644 index 00000000..8a000d1d --- /dev/null +++ b/src/redux/mapEditTools/mapEditTools.types.ts @@ -0,0 +1,5 @@ +import { MAP_EDIT_ACTIONS } from '@/redux/mapEditTools/mapEditTools.constants'; + +export type MapEditToolsState = { + activeAction: keyof typeof MAP_EDIT_ACTIONS | null; +}; diff --git a/src/redux/root/root.fixtures.ts b/src/redux/root/root.fixtures.ts index e2724495..c90d96c3 100644 --- a/src/redux/root/root.fixtures.ts +++ b/src/redux/root/root.fixtures.ts @@ -8,6 +8,7 @@ import { MODEL_ELEMENTS_INITIAL_STATE_MOCK } from '@/redux/modelElements/modelEl import { LAYERS_STATE_INITIAL_MOCK } from '@/redux/layers/layers.mock'; import { NEW_REACTIONS_INITIAL_STATE_MOCK } from '@/redux/newReactions/newReactions.mock'; import { GLYPHS_STATE_INITIAL_MOCK } from '@/redux/glyphs/glyphs.mock'; +import { MAP_EDIT_TOOLS_STATE_INITIAL_MOCK } from '@/redux/mapEditTools/mapEditTools.mock'; 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'; @@ -73,4 +74,5 @@ export const INITIAL_STORE_STATE_MOCK: RootState = { markers: MARKERS_INITIAL_STATE_MOCK, entityNumber: ENTITY_NUMBER_INITIAL_STATE_MOCK, comment: COMMENT_INITIAL_STATE_MOCK, + mapEditTools: MAP_EDIT_TOOLS_STATE_INITIAL_MOCK, }; diff --git a/src/redux/store.ts b/src/redux/store.ts index 031b51be..0e3a85b9 100644 --- a/src/redux/store.ts +++ b/src/redux/store.ts @@ -23,6 +23,7 @@ import reactionsReducer from '@/redux/reactions/reactions.slice'; import newReactionsReducer from '@/redux/newReactions/newReactions.slice'; import searchReducer from '@/redux/search/search.slice'; import userReducer from '@/redux/user/user.slice'; +import mapEditToolsReducer from '@/redux/mapEditTools/mapEditTools.slice'; import { autocompleteChemicalReducer, autocompleteDrugReducer, @@ -73,6 +74,7 @@ export const reducers = { contextMenu: contextMenuReducer, cookieBanner: cookieBannerReducer, user: userReducer, + mapEditTools: mapEditToolsReducer, configuration: configurationReducer, constant: constantReducer, overlayBioEntity: overlayBioEntityReducer, diff --git a/src/shared/Icon/Icon.component.tsx b/src/shared/Icon/Icon.component.tsx index f22e882b..784cfb0e 100644 --- a/src/shared/Icon/Icon.component.tsx +++ b/src/shared/Icon/Icon.component.tsx @@ -18,6 +18,7 @@ import { PlusIcon } from '@/shared/Icon/Icons/PlusIcon'; import type { IconComponentType, IconTypes } from '@/types/iconTypes'; import { DownloadIcon } from '@/shared/Icon/Icons/DownloadIcon'; +import { ImageIcon } from '@/shared/Icon/Icons/ImageIcon'; import { LocationIcon } from './Icons/LocationIcon'; import { MaginfierZoomInIcon } from './Icons/MagnifierZoomIn'; import { MaginfierZoomOutIcon } from './Icons/MagnifierZoomOut'; @@ -59,6 +60,7 @@ const icons: Record<IconTypes, IconComponentType> = { clear: ClearIcon, user: UserIcon, 'manage-user': ManageUserIcon, + image: ImageIcon, } as const; export const Icon = ({ name, className = '', ...rest }: IconProps): JSX.Element => { diff --git a/src/shared/Icon/Icons/ImageIcon.tsx b/src/shared/Icon/Icons/ImageIcon.tsx new file mode 100644 index 00000000..1c616b7f --- /dev/null +++ b/src/shared/Icon/Icons/ImageIcon.tsx @@ -0,0 +1,25 @@ +interface ImageIconProps { + className?: string; +} + +export const ImageIcon = ({ className }: ImageIconProps): JSX.Element => ( + <svg + width="24" + height="24" + viewBox="0 0 24 24" + fill="none" + className={className} + xmlns="http://www.w3.org/2000/svg" + > + <rect x="3" y="3" width="18" height="18" rx="2" stroke="currentColor" strokeWidth="1.5" /> + <circle cx="8" cy="8" r="1.5" stroke="currentColor" strokeWidth="1.5" fill="none" /> + <path + d="M4 18L9 13L12 16L16 12L20 18H4Z" + stroke="currentColor" + strokeWidth="1.5" + strokeLinecap="round" + strokeLinejoin="round" + fill="none" + /> + </svg> +); diff --git a/src/types/iconTypes.ts b/src/types/iconTypes.ts index 76818394..0ef11e99 100644 --- a/src/types/iconTypes.ts +++ b/src/types/iconTypes.ts @@ -24,6 +24,7 @@ export type IconTypes = | 'user' | 'manage-user' | 'download' - | 'question'; + | 'question' + | 'image'; export type IconComponentType = ({ className }: { className: string }) => JSX.Element; -- GitLab From 7e162b10891eedf12c229940b41eb0e01507b6c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mi=C5=82osz=20Grocholewski?= <m.grocholewski@atcomp.pl> Date: Fri, 20 Dec 2024 14:20:35 +0100 Subject: [PATCH 10/22] feat(autocomplete): add universal autocomplete component --- package-lock.json | 573 ++++++++++++++++-- package.json | 1 + .../LayerImageGlyphSelector.component.tsx | 82 --- .../LayerImageGlyphSelector.styles.css | 14 - ...LayerImageObjectFactoryModal.component.tsx | 14 +- .../Autocomplete/Autocomplete.component.tsx | 46 ++ .../Autocomplete/Autocomplete.styles.css | 7 + src/shared/Autocomplete/index.ts | 1 + 8 files changed, 570 insertions(+), 168 deletions(-) delete mode 100644 src/components/FunctionalArea/Modal/LayerImageObjectFactoryModal/LayerImageGlyphSelector.component.tsx delete mode 100644 src/components/FunctionalArea/Modal/LayerImageObjectFactoryModal/LayerImageGlyphSelector.styles.css create mode 100644 src/shared/Autocomplete/Autocomplete.component.tsx create mode 100644 src/shared/Autocomplete/Autocomplete.styles.css create mode 100644 src/shared/Autocomplete/index.ts diff --git a/package-lock.json b/package-lock.json index 93aa9055..6f42bd09 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,6 +37,7 @@ "react-dom": "18.2.0", "react-dropzone": "14.2.3", "react-redux": "8.1.3", + "react-select": "5.9.0", "sonner": "1.4.3", "tailwind-merge": "1.14.0", "tailwindcss": "3.4.13", @@ -136,7 +137,6 @@ "version": "7.23.5", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.23.5.tgz", "integrity": "sha512-CgH3s1a96LipHCmSUmYFPwY7MNx8C3avkq7i4Wl3cfa662ldtUe4VM1TPXX70pfmrlWTb6jLqTYrZyT2ZTJBgA==", - "dev": true, "dependencies": { "@babel/highlight": "^7.23.4", "chalk": "^2.4.2" @@ -149,7 +149,6 @@ "version": "3.2.1", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "dev": true, "dependencies": { "color-convert": "^1.9.0" }, @@ -161,7 +160,6 @@ "version": "2.4.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "dev": true, "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", @@ -175,7 +173,6 @@ "version": "1.9.3", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "dev": true, "dependencies": { "color-name": "1.1.3" } @@ -183,14 +180,12 @@ "node_modules/@babel/code-frame/node_modules/color-name": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", - "dev": true + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" }, "node_modules/@babel/code-frame/node_modules/has-flag": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "dev": true, "engines": { "node": ">=4" } @@ -199,7 +194,6 @@ "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dev": true, "dependencies": { "has-flag": "^3.0.0" }, @@ -360,7 +354,6 @@ "version": "7.22.15", "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.22.15.tgz", "integrity": "sha512-0pYVBnDKZO2fnSPCrgM/6WMc7eS20Fbok+0r88fp+YtWVLZrp4CkafFGIp+W0VKw4a22sgebPT99y+FDNMdP4w==", - "dev": true, "dependencies": { "@babel/types": "^7.22.15" }, @@ -424,7 +417,6 @@ "version": "7.23.4", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.23.4.tgz", "integrity": "sha512-803gmbQdqwdf4olxrX4AJyFBV/RTr3rSmOj0rKwesmzlfhYNDEs+/iOcznzpNWlJlIlTJC2QfPFcHB6DlzdVLQ==", - "dev": true, "engines": { "node": ">=6.9.0" } @@ -433,7 +425,6 @@ "version": "7.22.20", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz", "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==", - "dev": true, "engines": { "node": ">=6.9.0" } @@ -465,7 +456,6 @@ "version": "7.23.4", "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.23.4.tgz", "integrity": "sha512-acGdbYSfp2WheJoJm/EBBBLh/ID8KDc64ISZ9DYtBmC8/Q204PZJLHyzeB5qMzJ5trcOkybd78M4x2KWsUq++A==", - "dev": true, "dependencies": { "@babel/helper-validator-identifier": "^7.22.20", "chalk": "^2.4.2", @@ -479,7 +469,6 @@ "version": "3.2.1", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "dev": true, "dependencies": { "color-convert": "^1.9.0" }, @@ -491,7 +480,6 @@ "version": "2.4.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "dev": true, "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", @@ -505,7 +493,6 @@ "version": "1.9.3", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "dev": true, "dependencies": { "color-name": "1.1.3" } @@ -513,14 +500,12 @@ "node_modules/@babel/highlight/node_modules/color-name": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", - "dev": true + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" }, "node_modules/@babel/highlight/node_modules/has-flag": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "dev": true, "engines": { "node": ">=4" } @@ -529,7 +514,6 @@ "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dev": true, "dependencies": { "has-flag": "^3.0.0" }, @@ -785,7 +769,6 @@ "version": "7.23.6", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.6.tgz", "integrity": "sha512-+uarb83brBzPKN38NX1MkB6vb6+mwvR6amUulqAE7ccQw1pEl+bCia9TbdG1lsnFP7lZySvUn37CHyXQdfTwzg==", - "dev": true, "dependencies": { "@babel/helper-string-parser": "^7.23.4", "@babel/helper-validator-identifier": "^7.22.20", @@ -1217,6 +1200,133 @@ "ms": "^2.1.1" } }, + "node_modules/@emotion/babel-plugin": { + "version": "11.13.5", + "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", + "integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==", + "dependencies": { + "@babel/helper-module-imports": "^7.16.7", + "@babel/runtime": "^7.18.3", + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/serialize": "^1.3.3", + "babel-plugin-macros": "^3.1.0", + "convert-source-map": "^1.5.0", + "escape-string-regexp": "^4.0.0", + "find-root": "^1.1.0", + "source-map": "^0.5.7", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/babel-plugin/node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==" + }, + "node_modules/@emotion/babel-plugin/node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@emotion/babel-plugin/node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@emotion/cache": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz", + "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==", + "dependencies": { + "@emotion/memoize": "^0.9.0", + "@emotion/sheet": "^1.4.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/hash": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", + "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==" + }, + "node_modules/@emotion/memoize": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", + "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==" + }, + "node_modules/@emotion/react": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", + "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/cache": "^11.14.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "hoist-non-react-statics": "^3.3.1" + }, + "peerDependencies": { + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/serialize": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz", + "integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==", + "dependencies": { + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/unitless": "^0.10.0", + "@emotion/utils": "^1.4.2", + "csstype": "^3.0.2" + } + }, + "node_modules/@emotion/sheet": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz", + "integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==" + }, + "node_modules/@emotion/unitless": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz", + "integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==" + }, + "node_modules/@emotion/use-insertion-effect-with-fallbacks": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz", + "integrity": "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==", + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@emotion/utils": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz", + "integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==" + }, + "node_modules/@emotion/weak-memoize": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", + "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==" + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", @@ -1289,6 +1399,28 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@floating-ui/core": { + "version": "1.6.8", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.8.tgz", + "integrity": "sha512-7XJ9cPU+yI2QeLS+FCSlqNFZJq8arvswefkZrYI1yQBbftw6FyrZOxYSh+9S7z7TpeWlRt9zJ5IhM1WIL334jA==", + "dependencies": { + "@floating-ui/utils": "^0.2.8" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.6.12", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.12.tgz", + "integrity": "sha512-NP83c0HjokcGVEMeoStg317VD9W7eDlGK7457dMBANbKA6GJZdc7rjujdgqzTaz93jkGgc5P/jeWbaCHnMNc+w==", + "dependencies": { + "@floating-ui/core": "^1.6.0", + "@floating-ui/utils": "^0.2.8" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.8", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.8.tgz", + "integrity": "sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==" + }, "node_modules/@humanwhocodes/config-array": { "version": "0.11.13", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.13.tgz", @@ -2435,6 +2567,11 @@ "resolved": "https://registry.npmjs.org/@types/openlayers/-/openlayers-4.6.23.tgz", "integrity": "sha512-7jCPIa4D4LV03Rttae1AEqvkIN0+nc6Snz4IgA/IjsJD5O3ONxpscqIOdp1qAGuAsikR/ZC9vrPF9np8JRc6ig==" }, + "node_modules/@types/parse-json": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", + "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==" + }, "node_modules/@types/prop-types": { "version": "15.7.11", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.11.tgz", @@ -2484,6 +2621,14 @@ "redux": "^4.0.0" } }, + "node_modules/@types/react-transition-group": { + "version": "4.4.12", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", + "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==", + "peerDependencies": { + "@types/react": "*" + } + }, "node_modules/@types/redux-mock-store": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@types/redux-mock-store/-/redux-mock-store-1.0.6.tgz", @@ -3383,6 +3528,43 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/babel-plugin-macros": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", + "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", + "dependencies": { + "@babel/runtime": "^7.12.5", + "cosmiconfig": "^7.0.0", + "resolve": "^1.19.0" + }, + "engines": { + "node": ">=10", + "npm": ">=6" + } + }, + "node_modules/babel-plugin-macros/node_modules/cosmiconfig": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", + "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", + "dependencies": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.2.1", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.10.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/babel-plugin-macros/node_modules/yaml": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", + "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "engines": { + "node": ">= 6" + } + }, "node_modules/babel-preset-current-node-syntax": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.0.1.tgz", @@ -5044,6 +5226,15 @@ "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, "node_modules/domexception": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz", @@ -5211,7 +5402,6 @@ "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", - "dev": true, "dependencies": { "is-arrayish": "^0.2.1" } @@ -5363,7 +5553,6 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "dev": true, "engines": { "node": ">=0.8.0" } @@ -6569,8 +6758,7 @@ "node_modules/find-root": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", - "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==", - "dev": true + "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==" }, "node_modules/find-up": { "version": "4.1.0", @@ -7553,8 +7741,7 @@ "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", - "dev": true + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==" }, "node_modules/is-async-function": { "version": "2.0.0", @@ -9292,8 +9479,7 @@ "node_modules/json-parse-even-better-errors": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "dev": true + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==" }, "node_modules/json-schema": { "version": "0.4.0", @@ -10294,6 +10480,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==" + }, "node_modules/meow": { "version": "8.1.2", "resolved": "https://registry.npmjs.org/meow/-/meow-8.1.2.tgz", @@ -10984,7 +11175,6 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", - "dev": true, "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", @@ -11811,6 +12001,26 @@ } } }, + "node_modules/react-select": { + "version": "5.9.0", + "resolved": "https://registry.npmjs.org/react-select/-/react-select-5.9.0.tgz", + "integrity": "sha512-nwRKGanVHGjdccsnzhFte/PULziueZxGD8LL2WojON78Mvnq7LdAMEtu2frrwld1fr3geixg3iiMBIc/LLAZpw==", + "dependencies": { + "@babel/runtime": "^7.12.0", + "@emotion/cache": "^11.4.0", + "@emotion/react": "^11.8.1", + "@floating-ui/dom": "^1.0.1", + "@types/react-transition-group": "^4.4.0", + "memoize-one": "^6.0.0", + "prop-types": "^15.6.0", + "react-transition-group": "^4.3.0", + "use-isomorphic-layout-effect": "^1.2.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/react-themeable": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/react-themeable/-/react-themeable-1.1.0.tgz", @@ -11827,6 +12037,21 @@ "node": ">=0.10.0" } }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -12908,6 +13133,11 @@ } } }, + "node_modules/stylis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", + "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==" + }, "node_modules/sucrase": { "version": "3.35.0", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", @@ -13173,7 +13403,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==", - "dev": true, "engines": { "node": ">=4" } @@ -13568,6 +13797,19 @@ "react": ">=16.8.0" } }, + "node_modules/use-isomorphic-layout-effect": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.0.tgz", + "integrity": "sha512-q6ayo8DWoPZT0VdG4u3D3uxcgONP3Mevx2i2b0434cwWBoL+aelL1DzkXI6w3PhTZzUeR2kaVlZn70iCiseP6w==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/use-sync-external-store": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz", @@ -14177,7 +14419,6 @@ "version": "7.23.5", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.23.5.tgz", "integrity": "sha512-CgH3s1a96LipHCmSUmYFPwY7MNx8C3avkq7i4Wl3cfa662ldtUe4VM1TPXX70pfmrlWTb6jLqTYrZyT2ZTJBgA==", - "dev": true, "requires": { "@babel/highlight": "^7.23.4", "chalk": "^2.4.2" @@ -14187,7 +14428,6 @@ "version": "3.2.1", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "dev": true, "requires": { "color-convert": "^1.9.0" } @@ -14196,7 +14436,6 @@ "version": "2.4.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "dev": true, "requires": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", @@ -14207,7 +14446,6 @@ "version": "1.9.3", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "dev": true, "requires": { "color-name": "1.1.3" } @@ -14215,20 +14453,17 @@ "color-name": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", - "dev": true + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" }, "has-flag": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "dev": true + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==" }, "supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dev": true, "requires": { "has-flag": "^3.0.0" } @@ -14355,7 +14590,6 @@ "version": "7.22.15", "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.22.15.tgz", "integrity": "sha512-0pYVBnDKZO2fnSPCrgM/6WMc7eS20Fbok+0r88fp+YtWVLZrp4CkafFGIp+W0VKw4a22sgebPT99y+FDNMdP4w==", - "dev": true, "requires": { "@babel/types": "^7.22.15" } @@ -14400,14 +14634,12 @@ "@babel/helper-string-parser": { "version": "7.23.4", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.23.4.tgz", - "integrity": "sha512-803gmbQdqwdf4olxrX4AJyFBV/RTr3rSmOj0rKwesmzlfhYNDEs+/iOcznzpNWlJlIlTJC2QfPFcHB6DlzdVLQ==", - "dev": true + "integrity": "sha512-803gmbQdqwdf4olxrX4AJyFBV/RTr3rSmOj0rKwesmzlfhYNDEs+/iOcznzpNWlJlIlTJC2QfPFcHB6DlzdVLQ==" }, "@babel/helper-validator-identifier": { "version": "7.22.20", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz", - "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==", - "dev": true + "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==" }, "@babel/helper-validator-option": { "version": "7.23.5", @@ -14430,7 +14662,6 @@ "version": "7.23.4", "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.23.4.tgz", "integrity": "sha512-acGdbYSfp2WheJoJm/EBBBLh/ID8KDc64ISZ9DYtBmC8/Q204PZJLHyzeB5qMzJ5trcOkybd78M4x2KWsUq++A==", - "dev": true, "requires": { "@babel/helper-validator-identifier": "^7.22.20", "chalk": "^2.4.2", @@ -14441,7 +14672,6 @@ "version": "3.2.1", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "dev": true, "requires": { "color-convert": "^1.9.0" } @@ -14450,7 +14680,6 @@ "version": "2.4.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "dev": true, "requires": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", @@ -14461,7 +14690,6 @@ "version": "1.9.3", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "dev": true, "requires": { "color-name": "1.1.3" } @@ -14469,20 +14697,17 @@ "color-name": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", - "dev": true + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" }, "has-flag": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "dev": true + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==" }, "supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dev": true, "requires": { "has-flag": "^3.0.0" } @@ -14670,7 +14895,6 @@ "version": "7.23.6", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.6.tgz", "integrity": "sha512-+uarb83brBzPKN38NX1MkB6vb6+mwvR6amUulqAE7ccQw1pEl+bCia9TbdG1lsnFP7lZySvUn37CHyXQdfTwzg==", - "dev": true, "requires": { "@babel/helper-string-parser": "^7.23.4", "@babel/helper-validator-identifier": "^7.22.20", @@ -15016,6 +15240,116 @@ } } }, + "@emotion/babel-plugin": { + "version": "11.13.5", + "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", + "integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==", + "requires": { + "@babel/helper-module-imports": "^7.16.7", + "@babel/runtime": "^7.18.3", + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/serialize": "^1.3.3", + "babel-plugin-macros": "^3.1.0", + "convert-source-map": "^1.5.0", + "escape-string-regexp": "^4.0.0", + "find-root": "^1.1.0", + "source-map": "^0.5.7", + "stylis": "4.2.0" + }, + "dependencies": { + "convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==" + }, + "escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==" + }, + "source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==" + } + } + }, + "@emotion/cache": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz", + "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==", + "requires": { + "@emotion/memoize": "^0.9.0", + "@emotion/sheet": "^1.4.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "stylis": "4.2.0" + } + }, + "@emotion/hash": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", + "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==" + }, + "@emotion/memoize": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", + "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==" + }, + "@emotion/react": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", + "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", + "requires": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/cache": "^11.14.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "hoist-non-react-statics": "^3.3.1" + } + }, + "@emotion/serialize": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz", + "integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==", + "requires": { + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/unitless": "^0.10.0", + "@emotion/utils": "^1.4.2", + "csstype": "^3.0.2" + } + }, + "@emotion/sheet": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz", + "integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==" + }, + "@emotion/unitless": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz", + "integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==" + }, + "@emotion/use-insertion-effect-with-fallbacks": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz", + "integrity": "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==", + "requires": {} + }, + "@emotion/utils": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz", + "integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==" + }, + "@emotion/weak-memoize": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", + "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==" + }, "@eslint-community/eslint-utils": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", @@ -15068,6 +15402,28 @@ "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.56.0.tgz", "integrity": "sha512-gMsVel9D7f2HLkBma9VbtzZRehRogVRfbr++f06nL2vnCGCNlzOD+/MUov/F4p8myyAHspEhVobgjpX64q5m6A==" }, + "@floating-ui/core": { + "version": "1.6.8", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.8.tgz", + "integrity": "sha512-7XJ9cPU+yI2QeLS+FCSlqNFZJq8arvswefkZrYI1yQBbftw6FyrZOxYSh+9S7z7TpeWlRt9zJ5IhM1WIL334jA==", + "requires": { + "@floating-ui/utils": "^0.2.8" + } + }, + "@floating-ui/dom": { + "version": "1.6.12", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.12.tgz", + "integrity": "sha512-NP83c0HjokcGVEMeoStg317VD9W7eDlGK7457dMBANbKA6GJZdc7rjujdgqzTaz93jkGgc5P/jeWbaCHnMNc+w==", + "requires": { + "@floating-ui/core": "^1.6.0", + "@floating-ui/utils": "^0.2.8" + } + }, + "@floating-ui/utils": { + "version": "0.2.8", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.8.tgz", + "integrity": "sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==" + }, "@humanwhocodes/config-array": { "version": "0.11.13", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.13.tgz", @@ -15925,6 +16281,11 @@ "resolved": "https://registry.npmjs.org/@types/openlayers/-/openlayers-4.6.23.tgz", "integrity": "sha512-7jCPIa4D4LV03Rttae1AEqvkIN0+nc6Snz4IgA/IjsJD5O3ONxpscqIOdp1qAGuAsikR/ZC9vrPF9np8JRc6ig==" }, + "@types/parse-json": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", + "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==" + }, "@types/prop-types": { "version": "15.7.11", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.11.tgz", @@ -15974,6 +16335,12 @@ "redux": "^4.0.0" } }, + "@types/react-transition-group": { + "version": "4.4.12", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", + "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==", + "requires": {} + }, "@types/redux-mock-store": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@types/redux-mock-store/-/redux-mock-store-1.0.6.tgz", @@ -16606,6 +16973,35 @@ "@types/babel__traverse": "^7.0.6" } }, + "babel-plugin-macros": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", + "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", + "requires": { + "@babel/runtime": "^7.12.5", + "cosmiconfig": "^7.0.0", + "resolve": "^1.19.0" + }, + "dependencies": { + "cosmiconfig": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", + "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", + "requires": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.2.1", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.10.0" + } + }, + "yaml": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", + "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==" + } + } + }, "babel-preset-current-node-syntax": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.0.1.tgz", @@ -17840,6 +18236,15 @@ "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true }, + "dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "requires": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, "domexception": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz", @@ -17972,7 +18377,6 @@ "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", - "dev": true, "requires": { "is-arrayish": "^0.2.1" } @@ -18102,8 +18506,7 @@ "escape-string-regexp": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "dev": true + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==" }, "escodegen": { "version": "2.1.0", @@ -18933,8 +19336,7 @@ "find-root": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", - "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==", - "dev": true + "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==" }, "find-up": { "version": "4.1.0", @@ -19631,8 +20033,7 @@ "is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", - "dev": true + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==" }, "is-async-function": { "version": "2.0.0", @@ -20872,8 +21273,7 @@ "json-parse-even-better-errors": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "dev": true + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==" }, "json-schema": { "version": "0.4.0", @@ -21615,6 +22015,11 @@ "integrity": "sha512-hdN1wVrZbb29eBGiGjJbeP8JbKjq1urkHJ/LIP/NY48MZ1QVXUsQBV1G1zvYFHn1XE06cwjBsOI2K3Ulnj1YXQ==", "dev": true }, + "memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==" + }, "meow": { "version": "8.1.2", "resolved": "https://registry.npmjs.org/meow/-/meow-8.1.2.tgz", @@ -22111,7 +22516,6 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", - "dev": true, "requires": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", @@ -22602,6 +23006,22 @@ "use-sync-external-store": "^1.0.0" } }, + "react-select": { + "version": "5.9.0", + "resolved": "https://registry.npmjs.org/react-select/-/react-select-5.9.0.tgz", + "integrity": "sha512-nwRKGanVHGjdccsnzhFte/PULziueZxGD8LL2WojON78Mvnq7LdAMEtu2frrwld1fr3geixg3iiMBIc/LLAZpw==", + "requires": { + "@babel/runtime": "^7.12.0", + "@emotion/cache": "^11.4.0", + "@emotion/react": "^11.8.1", + "@floating-ui/dom": "^1.0.1", + "@types/react-transition-group": "^4.4.0", + "memoize-one": "^6.0.0", + "prop-types": "^15.6.0", + "react-transition-group": "^4.3.0", + "use-isomorphic-layout-effect": "^1.2.0" + } + }, "react-themeable": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/react-themeable/-/react-themeable-1.1.0.tgz", @@ -22617,6 +23037,17 @@ } } }, + "react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "requires": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + } + }, "read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -23430,6 +23861,11 @@ "client-only": "0.0.1" } }, + "stylis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", + "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==" + }, "sucrase": { "version": "3.35.0", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", @@ -23629,8 +24065,7 @@ "to-fast-properties": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", - "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==", - "dev": true + "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==" }, "to-regex-range": { "version": "5.0.1", @@ -23903,6 +24338,12 @@ "integrity": "sha512-6X8H/mikbrt0XE8e+JXRtZ8yYVvKkdYRfmIhWZYsP8rcNs9hk3APV8Ua2mFkKRLcJKVdnX2/Vwrmg2GWKUQEaQ==", "requires": {} }, + "use-isomorphic-layout-effect": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.0.tgz", + "integrity": "sha512-q6ayo8DWoPZT0VdG4u3D3uxcgONP3Mevx2i2b0434cwWBoL+aelL1DzkXI6w3PhTZzUeR2kaVlZn70iCiseP6w==", + "requires": {} + }, "use-sync-external-store": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz", diff --git a/package.json b/package.json index 213f3cd7..52f25ca9 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "react-dom": "18.2.0", "react-dropzone": "14.2.3", "react-redux": "8.1.3", + "react-select": "5.9.0", "sonner": "1.4.3", "tailwind-merge": "1.14.0", "tailwindcss": "3.4.13", diff --git a/src/components/FunctionalArea/Modal/LayerImageObjectFactoryModal/LayerImageGlyphSelector.component.tsx b/src/components/FunctionalArea/Modal/LayerImageObjectFactoryModal/LayerImageGlyphSelector.component.tsx deleted file mode 100644 index f5ae36b6..00000000 --- a/src/components/FunctionalArea/Modal/LayerImageObjectFactoryModal/LayerImageGlyphSelector.component.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import React, { ReactElement, useEffect, useState } from 'react'; -import Autosuggest from 'react-autosuggest'; -import { Glyph } from '@/types/models'; -import './LayerImageGlyphSelector.styles.css'; - -interface LayerImageGlyphSelectorProps { - glyphs: Glyph[]; - selectedGlyph: number | null; - onGlyphSelect: (glyphId: number) => void; -} - -const LayerImageGlyphSelector: React.FC<LayerImageGlyphSelectorProps> = ({ - glyphs, - selectedGlyph, - onGlyphSelect, -}) => { - const [searchValue, setSearchValue] = useState(''); - const [suggestions, setSuggestions] = useState<Glyph[]>([]); - - useEffect(() => { - if (selectedGlyph) { - setSearchValue(String(selectedGlyph)); - } else { - setSearchValue(''); - } - }, [selectedGlyph]); - - const getSuggestions = (inputValue: string): Glyph[] => { - if (!inputValue) { - return glyphs; - } - const input = inputValue.trim().toLowerCase(); - return glyphs.filter(glyph => String(glyph.file).toLowerCase().includes(input)); - }; - - const getSuggestionValue = (suggestion: Glyph): string => String(suggestion.file); - - const renderSuggestion = (suggestion: Glyph): ReactElement => ( - <div className="cursor-pointer p-2">{suggestion.file}</div> - ); - - const onChange = (event: React.FormEvent, { newValue }: { newValue: string }): void => { - setSearchValue(newValue); - }; - - const onSuggestionsFetchRequested = ({ value }: { value: string }): void => { - setSuggestions(getSuggestions(value)); - }; - - const onSuggestionsClearRequested = (): void => { - setSuggestions([]); - }; - - const onSuggestionSelected = ( - event: React.FormEvent, - { suggestion }: { suggestion: Glyph }, - ): void => { - onGlyphSelect(suggestion.id); - setSearchValue(String(suggestion.file)); - }; - - const inputProps = { - placeholder: 'Select glyph...', - value: searchValue, - onChange, - }; - - return ( - <Autosuggest - suggestions={suggestions} - onSuggestionsFetchRequested={onSuggestionsFetchRequested} - onSuggestionsClearRequested={onSuggestionsClearRequested} - shouldRenderSuggestions={() => true} - getSuggestionValue={getSuggestionValue} - renderSuggestion={renderSuggestion} - onSuggestionSelected={onSuggestionSelected} - inputProps={inputProps} - /> - ); -}; - -export default LayerImageGlyphSelector; diff --git a/src/components/FunctionalArea/Modal/LayerImageObjectFactoryModal/LayerImageGlyphSelector.styles.css b/src/components/FunctionalArea/Modal/LayerImageObjectFactoryModal/LayerImageGlyphSelector.styles.css deleted file mode 100644 index f1d46937..00000000 --- a/src/components/FunctionalArea/Modal/LayerImageObjectFactoryModal/LayerImageGlyphSelector.styles.css +++ /dev/null @@ -1,14 +0,0 @@ -.react-autosuggest__suggestions-container { - position: absolute; - z-index: 1000; - max-height: 400px; - overflow-y: auto; -} - -.react-autosuggest__input { - width: 100%; - height: 40px; - padding: 10px; - border: 1px solid #ccc; - border-radius: 4px 0 0 4px; -} diff --git a/src/components/FunctionalArea/Modal/LayerImageObjectFactoryModal/LayerImageObjectFactoryModal.component.tsx b/src/components/FunctionalArea/Modal/LayerImageObjectFactoryModal/LayerImageObjectFactoryModal.component.tsx index 5144ee8c..4bf27452 100644 --- a/src/components/FunctionalArea/Modal/LayerImageObjectFactoryModal/LayerImageObjectFactoryModal.component.tsx +++ b/src/components/FunctionalArea/Modal/LayerImageObjectFactoryModal/LayerImageObjectFactoryModal.component.tsx @@ -8,7 +8,6 @@ import { BASE_NEW_API_URL } from '@/constants'; import { apiPath } from '@/redux/apiPath'; import { Input } from '@/shared/Input'; import Image from 'next/image'; -import LayerImageGlyphSelector from '@/components/FunctionalArea/Modal/LayerImageObjectFactoryModal/LayerImageGlyphSelector.component'; import { Glyph } from '@/types/models'; import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; import { currentModelIdSelector } from '@/redux/models/models.selectors'; @@ -22,6 +21,7 @@ import { LoadingIndicator } from '@/shared/LoadingIndicator'; import './LayerImageObjectFactoryModal.styles.css'; import { useMapInstance } from '@/utils/context/mapInstanceContext'; import { layerAddImage } from '@/redux/layers/layers.slice'; +import { Autocomplete } from '@/shared/Autocomplete'; export const LayerImageObjectFactoryModal: React.FC = () => { const glyphs: Glyph[] = useAppSelector(glyphsDataSelector); @@ -38,7 +38,8 @@ export const LayerImageObjectFactoryModal: React.FC = () => { const [isSending, setIsSending] = useState<boolean>(false); const [previewUrl, setPreviewUrl] = useState<string | null>(null); - const handleGlyphChange = (glyphId: number | null): void => { + const handleGlyphChange = (glyph: Glyph | null): void => { + const glyphId = glyph?.id || null; setSelectedGlyph(glyphId); if (!glyphId) { return; @@ -139,10 +140,11 @@ export const LayerImageObjectFactoryModal: React.FC = () => { <div className="grid grid-cols-2 gap-2"> <div className="mb-4 flex flex-col gap-2"> <span>Glyph:</span> - <LayerImageGlyphSelector - selectedGlyph={selectedGlyph} - glyphs={glyphs} - onGlyphSelect={handleGlyphChange} + <Autocomplete<Glyph> + options={glyphs} + valueKey="id" + labelKey="file" + onChange={handleGlyphChange} /> </div> <div className="mb-4 flex flex-col gap-2"> diff --git a/src/shared/Autocomplete/Autocomplete.component.tsx b/src/shared/Autocomplete/Autocomplete.component.tsx new file mode 100644 index 00000000..0b4cf3be --- /dev/null +++ b/src/shared/Autocomplete/Autocomplete.component.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import Select, { SingleValue } from 'react-select'; +import './Autocomplete.styles.css'; + +type AutocompleteProps<T> = { + options: Array<T>; + valueKey?: keyof T; + labelKey?: keyof T; + placeholder?: string; + onChange: (value: T | null) => void; +}; + +type OptionType<T> = { + value: T[keyof T]; + label: string; + originalOption: T; +}; + +export const Autocomplete = <T,>({ + options, + valueKey = 'value' as keyof T, + labelKey = 'label' as keyof T, + placeholder = 'Select...', + onChange, +}: AutocompleteProps<T>): React.JSX.Element => { + const formattedOptions = options.map(option => ({ + value: option[valueKey], + label: option[labelKey] as string, + originalOption: option, + })); + + const handleChange = (selectedOption: SingleValue<OptionType<T>>): void => { + onChange(selectedOption ? selectedOption.originalOption : null); + }; + + return ( + <Select + options={formattedOptions} + onChange={handleChange} + placeholder={placeholder} + classNamePrefix="react-select" + /> + ); +}; + +Autocomplete.displayName = 'Autocomplete'; diff --git a/src/shared/Autocomplete/Autocomplete.styles.css b/src/shared/Autocomplete/Autocomplete.styles.css new file mode 100644 index 00000000..49f417b1 --- /dev/null +++ b/src/shared/Autocomplete/Autocomplete.styles.css @@ -0,0 +1,7 @@ +.react-select__control { + height: 40px; +} + +.react-select__menu { + margin: 0 !important; +} diff --git a/src/shared/Autocomplete/index.ts b/src/shared/Autocomplete/index.ts new file mode 100644 index 00000000..f78074f3 --- /dev/null +++ b/src/shared/Autocomplete/index.ts @@ -0,0 +1 @@ +export { Autocomplete } from './Autocomplete.component'; -- GitLab From 4c977837f3bb95cc821ec35ff3ed5e84efd21c6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mi=C5=82osz=20Grocholewski?= <m.grocholewski@atcomp.pl> Date: Mon, 23 Dec 2024 09:58:00 +0100 Subject: [PATCH 11/22] fix(vector-map): correct conditions for map actions --- ...LayerImageObjectFactoryModal.component.tsx | 6 ++--- src/components/Map/Map.component.tsx | 24 +++++++++++++------ .../MapActiveLayerSelector.component.tsx | 2 +- src/models/glyphSchema.ts | 1 + 4 files changed, 22 insertions(+), 11 deletions(-) diff --git a/src/components/FunctionalArea/Modal/LayerImageObjectFactoryModal/LayerImageObjectFactoryModal.component.tsx b/src/components/FunctionalArea/Modal/LayerImageObjectFactoryModal/LayerImageObjectFactoryModal.component.tsx index 4bf27452..5c054fb2 100644 --- a/src/components/FunctionalArea/Modal/LayerImageObjectFactoryModal/LayerImageObjectFactoryModal.component.tsx +++ b/src/components/FunctionalArea/Modal/LayerImageObjectFactoryModal/LayerImageObjectFactoryModal.component.tsx @@ -143,7 +143,7 @@ export const LayerImageObjectFactoryModal: React.FC = () => { <Autocomplete<Glyph> options={glyphs} valueKey="id" - labelKey="file" + labelKey="filename" onChange={handleGlyphChange} /> </div> @@ -164,8 +164,8 @@ export const LayerImageObjectFactoryModal: React.FC = () => { <Image src={previewUrl} alt="image preview" - layout="fill" - objectFit="contain" + fill + style={{ objectFit: 'contain' }} className="rounded" /> ) : ( diff --git a/src/components/Map/Map.component.tsx b/src/components/Map/Map.component.tsx index 3209e8ac..1ae88fa6 100644 --- a/src/components/Map/Map.component.tsx +++ b/src/components/Map/Map.component.tsx @@ -9,12 +9,26 @@ import { useAppSelector } from '@/redux/hooks/useAppSelector'; import { vectorRenderingSelector } from '@/redux/models/models.selectors'; import { MapAdditionalLogos } from '@/components/Map/MapAdditionalLogos'; import { MapDrawActions } from '@/components/Map/MapDrawActions/MapDrawActions.component'; +import { + layersActiveLayerSelector, + layersForCurrentModelSelector, + layersVisibilityForCurrentModelSelector, +} from '@/redux/layers/layers.selectors'; +import { useMemo } from 'react'; import { MapAdditionalActions } from './MapAdditionalActions'; import { MapAdditionalOptions } from './MapAdditionalOptions'; import { PluginsDrawer } from './PluginsDrawer'; export const Map = (): JSX.Element => { const vectorRendering = useAppSelector(vectorRenderingSelector); + const activeLayer = useAppSelector(layersActiveLayerSelector); + const layers = useAppSelector(layersForCurrentModelSelector); + const layersVisibility = useAppSelector(layersVisibilityForCurrentModelSelector); + + const visibleLayersLength: number = useMemo(() => { + return layers.filter(layer => layersVisibility[layer.details.id]).length; + }, [layers, layersVisibility]); + return ( <div className="relative z-0 h-screen w-full overflow-hidden bg-black" @@ -22,13 +36,9 @@ export const Map = (): JSX.Element => { > <MapViewer /> {!vectorRendering && <MapAdditionalOptions />} - {vectorRendering && ( - <> - <MapVectorBackgroundSelector /> - <MapActiveLayerSelector /> - <MapDrawActions /> - </> - )} + {vectorRendering && <MapVectorBackgroundSelector />} + {vectorRendering && visibleLayersLength && <MapActiveLayerSelector />} + {vectorRendering && activeLayer && <MapDrawActions />} <Drawer /> <PluginsDrawer /> <Legend /> diff --git a/src/components/Map/MapActiveLayerSelector/MapActiveLayerSelector.component.tsx b/src/components/Map/MapActiveLayerSelector/MapActiveLayerSelector.component.tsx index 0f6a8810..cd81e3df 100644 --- a/src/components/Map/MapActiveLayerSelector/MapActiveLayerSelector.component.tsx +++ b/src/components/Map/MapActiveLayerSelector/MapActiveLayerSelector.component.tsx @@ -46,7 +46,7 @@ export const MapActiveLayerSelector = (): JSX.Element => { }, [activeLayer, currentModelId, dispatch, options]); return ( - <div className={twMerge('absolute right-6 top-[calc(64px+40px+84px)] z-10 flex')}> + <div className={twMerge('z-11 absolute right-6 top-[calc(64px+40px+84px)] flex')}> <Select options={options} selectedId={activeLayer} onChange={handleChange} width={140} /> </div> ); diff --git a/src/models/glyphSchema.ts b/src/models/glyphSchema.ts index eedb213a..319fd589 100644 --- a/src/models/glyphSchema.ts +++ b/src/models/glyphSchema.ts @@ -3,4 +3,5 @@ import { z } from 'zod'; export const glyphSchema = z.object({ id: z.number(), file: z.number(), + filename: z.string().optional().nullable(), }); -- GitLab From 001d93a8f0f5572cf24bb0da522352debcedfae8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mi=C5=82osz=20Grocholewski?= <m.grocholewski@atcomp.pl> Date: Mon, 23 Dec 2024 10:46:23 +0100 Subject: [PATCH 12/22] feat(image-preview): add test for ImagePreview component --- .../Image/ImagePreview/ImagePreview.test.tsx | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 src/components/FunctionalArea/Image/ImagePreview/ImagePreview.test.tsx diff --git a/src/components/FunctionalArea/Image/ImagePreview/ImagePreview.test.tsx b/src/components/FunctionalArea/Image/ImagePreview/ImagePreview.test.tsx new file mode 100644 index 00000000..1baa4a67 --- /dev/null +++ b/src/components/FunctionalArea/Image/ImagePreview/ImagePreview.test.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import ImagePreview from './ImagePreview.component'; + +const createMockFile = (name: string, type: string, content: string): File => { + return new File([content], name, { type }); +}; + +describe('ImagePreview Component', () => { + it('should display "No Data Available" when no imageFile is provided', () => { + render(<ImagePreview />); + expect(screen.getByText(/No Data Available/i)).toBeInTheDocument(); + }); + + it('should display the image when a File is provided', async () => { + const mockFile = createMockFile('test-image.jpg', 'image/jpeg', 'dummy content'); + + render(<ImagePreview imageFile={mockFile} />); + + const imgElement = await waitFor(() => screen.getByRole('img', { name: 'Preview' })); + + expect(imgElement).toBeInTheDocument(); + expect(imgElement).toHaveAttribute('src'); + expect(imgElement).toHaveAttribute('alt', 'Preview'); + }); + + it('should display the image when a FileInterface object with a URL is provided', () => { + const mockFileInterface = { url: 'https://example.com/image.png' }; + + render(<ImagePreview imageFile={mockFileInterface} />); + + const image = screen.getByAltText(/Preview/i); + expect(image).toHaveAttribute('src', 'https://example.com/image.png'); + }); + + it('should update the image when imageFile changes', () => { + const { rerender } = render(<ImagePreview imageFile={null} />); + + expect(screen.getByText(/No Data Available/i)).toBeInTheDocument(); + + const mockFileInterface = { url: 'https://example.com/image.png' }; + rerender(<ImagePreview imageFile={mockFileInterface} />); + + const image = screen.getByAltText(/Preview/i); + expect(image).toHaveAttribute('src', 'https://example.com/image.png'); + }); +}); -- GitLab From 1b49e54ee4e852ff92964fe24ecc2d0e80963743 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mi=C5=82osz=20Grocholewski?= <m.grocholewski@atcomp.pl> Date: Tue, 7 Jan 2025 08:40:31 +0100 Subject: [PATCH 13/22] feat(layer-image): add test for LayerImageObjectFactoryModal --- .../ImagePreview/ImagePreview.component.tsx | 57 ------- .../Image/ImagePreview/ImagePreview.test.tsx | 47 ------ ...ImageObjectFactoryModal.component.test.tsx | 141 ++++++++++++++++++ ...LayerImageObjectFactoryModal.component.tsx | 3 +- src/components/Map/Map.component.tsx | 2 +- .../MapActiveLayerSelector.component.tsx | 2 +- src/models/fixtures/layerImageFixture.ts | 9 ++ .../Autocomplete/Autocomplete.component.tsx | 14 +- 8 files changed, 162 insertions(+), 113 deletions(-) delete mode 100644 src/components/FunctionalArea/Image/ImagePreview/ImagePreview.component.tsx delete mode 100644 src/components/FunctionalArea/Image/ImagePreview/ImagePreview.test.tsx create mode 100644 src/components/FunctionalArea/Modal/LayerImageObjectFactoryModal/LayerImageObjectFactoryModal.component.test.tsx create mode 100644 src/models/fixtures/layerImageFixture.ts diff --git a/src/components/FunctionalArea/Image/ImagePreview/ImagePreview.component.tsx b/src/components/FunctionalArea/Image/ImagePreview/ImagePreview.component.tsx deleted file mode 100644 index d5b13b0a..00000000 --- a/src/components/FunctionalArea/Image/ImagePreview/ImagePreview.component.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import React, { useEffect, useMemo, useState } from 'react'; - -interface FileInterface { - url: string; -} - -interface ImagePreviewProps { - imageFile?: File | FileInterface | null; -} - -const ImagePreview: React.FC<ImagePreviewProps> = ({ imageFile }) => { - const [imageSrc, setImageSrc] = useState<string | null>(null); - - const previewImage = (file: File): void => { - const reader = new FileReader(); - reader.onload = (event): void => { - if (event.target?.result && typeof event.target.result === 'string') { - setImageSrc(event.target.result); - } - }; - reader.readAsDataURL(file); - }; - - const setImageFile = useMemo(() => { - return (): void => { - if (imageFile) { - if (imageFile instanceof File) { - previewImage(imageFile); - } else if ('url' in imageFile) { - setImageSrc(imageFile.url); - } - } else { - setImageSrc(null); - } - }; - }, [imageFile]); - - useEffect(() => { - setImageFile(); - }, [imageFile, setImageFile]); - - return ( - <div style={{ padding: '16px', border: '1px solid #ccc', borderRadius: '8px' }}> - {imageSrc ? ( - <img - src={imageSrc} - alt="Preview" - style={{ maxHeight: '350px', borderRadius: '8px', objectFit: 'cover' }} - /> - ) : ( - <div>No Data Available</div> - )} - </div> - ); -}; - -export default ImagePreview; diff --git a/src/components/FunctionalArea/Image/ImagePreview/ImagePreview.test.tsx b/src/components/FunctionalArea/Image/ImagePreview/ImagePreview.test.tsx deleted file mode 100644 index 1baa4a67..00000000 --- a/src/components/FunctionalArea/Image/ImagePreview/ImagePreview.test.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import React from 'react'; -import { render, screen, waitFor } from '@testing-library/react'; -import ImagePreview from './ImagePreview.component'; - -const createMockFile = (name: string, type: string, content: string): File => { - return new File([content], name, { type }); -}; - -describe('ImagePreview Component', () => { - it('should display "No Data Available" when no imageFile is provided', () => { - render(<ImagePreview />); - expect(screen.getByText(/No Data Available/i)).toBeInTheDocument(); - }); - - it('should display the image when a File is provided', async () => { - const mockFile = createMockFile('test-image.jpg', 'image/jpeg', 'dummy content'); - - render(<ImagePreview imageFile={mockFile} />); - - const imgElement = await waitFor(() => screen.getByRole('img', { name: 'Preview' })); - - expect(imgElement).toBeInTheDocument(); - expect(imgElement).toHaveAttribute('src'); - expect(imgElement).toHaveAttribute('alt', 'Preview'); - }); - - it('should display the image when a FileInterface object with a URL is provided', () => { - const mockFileInterface = { url: 'https://example.com/image.png' }; - - render(<ImagePreview imageFile={mockFileInterface} />); - - const image = screen.getByAltText(/Preview/i); - expect(image).toHaveAttribute('src', 'https://example.com/image.png'); - }); - - it('should update the image when imageFile changes', () => { - const { rerender } = render(<ImagePreview imageFile={null} />); - - expect(screen.getByText(/No Data Available/i)).toBeInTheDocument(); - - const mockFileInterface = { url: 'https://example.com/image.png' }; - rerender(<ImagePreview imageFile={mockFileInterface} />); - - const image = screen.getByAltText(/Preview/i); - expect(image).toHaveAttribute('src', 'https://example.com/image.png'); - }); -}); diff --git a/src/components/FunctionalArea/Modal/LayerImageObjectFactoryModal/LayerImageObjectFactoryModal.component.test.tsx b/src/components/FunctionalArea/Modal/LayerImageObjectFactoryModal/LayerImageObjectFactoryModal.component.test.tsx new file mode 100644 index 00000000..5806621f --- /dev/null +++ b/src/components/FunctionalArea/Modal/LayerImageObjectFactoryModal/LayerImageObjectFactoryModal.component.test.tsx @@ -0,0 +1,141 @@ +/* eslint-disable no-magic-numbers */ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { act } from 'react-dom/test-utils'; +import { StoreType } from '@/redux/store'; +import { getReduxWrapperWithStore } from '@/utils/testing/getReduxWrapperWithStore'; +import { INITIAL_STORE_STATE_MOCK } from '@/redux/root/root.fixtures'; +import { GLYPHS_STATE_INITIAL_MOCK } from '@/redux/glyphs/glyphs.mock'; +import { apiPath } from '@/redux/apiPath'; +import { HttpStatusCode } from 'axios'; +import { layerImageFixture } from '@/models/fixtures/layerImageFixture'; +import { mockNetworkNewAPIResponse } from '@/utils/mockNetworkResponse'; +import { + LAYER_STATE_DEFAULT_DATA, + LAYERS_STATE_INITIAL_LAYER_MOCK, +} from '@/redux/layers/layers.mock'; +import { MODELS_DATA_MOCK_WITH_MAIN_MAP } from '@/redux/models/models.mock'; +import { overlayFixture } from '@/models/fixtures/overlaysFixture'; +import { showToast } from '@/utils/showToast'; +import { LayerImageObjectFactoryModal } from './LayerImageObjectFactoryModal.component'; + +const mockedAxiosNewClient = mockNetworkNewAPIResponse(); + +const glyph = { id: 1, file: 23, filename: 'Glyph1.png' }; + +jest.mock('../../../../utils/showToast'); + +const renderComponent = (): { store: StoreType } => { + const { Wrapper, store } = getReduxWrapperWithStore({ + ...INITIAL_STORE_STATE_MOCK, + glyphs: { + ...GLYPHS_STATE_INITIAL_MOCK, + data: [glyph], + }, + layers: { + 0: { + ...LAYERS_STATE_INITIAL_LAYER_MOCK, + data: { + ...LAYER_STATE_DEFAULT_DATA, + activeLayer: 1, + }, + }, + }, + modal: { + isOpen: true, + modalTitle: overlayFixture.name, + modalName: 'edit-overlay', + editOverlayState: overlayFixture, + molArtState: {}, + overviewImagesState: {}, + errorReportState: {}, + layerFactoryState: { id: undefined }, + layerImageObjectFactoryState: { + x: 1, + y: 1, + width: 1, + height: 1, + }, + }, + models: { + ...MODELS_DATA_MOCK_WITH_MAIN_MAP, + }, + }); + + return { + store, + ...render( + <Wrapper> + <LayerImageObjectFactoryModal /> + </Wrapper>, + ), + }; +}; + +describe('LayerImageObjectFactoryModal - component', () => { + it('should render LayerImageObjectFactoryModal component with initial state', () => { + renderComponent(); + + expect(screen.getByText(/Glyph:/i)).toBeInTheDocument(); + expect(screen.getByText(/File:/i)).toBeInTheDocument(); + expect(screen.getByText(/Submit/i)).toBeInTheDocument(); + expect(screen.getByText(/No Image/i)).toBeInTheDocument(); + }); + + it('should display a list of glyphs in the dropdown', async () => { + renderComponent(); + + const dropdown = screen.getByTestId('autocomplete'); + if (!dropdown.firstChild) { + throw new Error('Dropdown does not have a firstChild'); + } + fireEvent.keyDown(dropdown.firstChild, { key: 'ArrowDown' }); + await waitFor(() => expect(screen.getByText(glyph.filename)).toBeInTheDocument()); + fireEvent.click(screen.getByText(glyph.filename)); + }); + + it('should update the selected glyph on dropdown change', async () => { + renderComponent(); + + const dropdown = screen.getByTestId('autocomplete'); + if (!dropdown.firstChild) { + throw new Error('Dropdown does not have a firstChild'); + } + fireEvent.keyDown(dropdown.firstChild, { key: 'ArrowDown' }); + await waitFor(() => expect(screen.getByText(glyph.filename)).toBeInTheDocument()); + fireEvent.click(screen.getByText(glyph.filename)); + + await waitFor(() => { + const imgPreview: HTMLImageElement = screen.getByTestId('layer-image-preview'); + const decodedSrc = decodeURIComponent(imgPreview.src); + expect(decodedSrc).toContain(`glyphs/${glyph.id}/fileContent`); + }); + }); + + it('should handle form submission correctly', async () => { + mockedAxiosNewClient + .onPost(apiPath.addLayerImageObject(0, 1)) + .reply(HttpStatusCode.Ok, layerImageFixture); + renderComponent(); + + const submitButton = screen.getByText(/Submit/i); + + await act(async () => { + fireEvent.click(submitButton); + }); + + expect(showToast).toHaveBeenCalledWith({ + message: 'A new image object has been successfully added', + type: 'success', + }); + }); + + it('should display "No Image" when there is no image file', () => { + const { store } = renderComponent(); + + store.dispatch({ + type: 'glyphs/clearGlyphData', + }); + + expect(screen.getByText(/No Image/i)).toBeInTheDocument(); + }); +}); diff --git a/src/components/FunctionalArea/Modal/LayerImageObjectFactoryModal/LayerImageObjectFactoryModal.component.tsx b/src/components/FunctionalArea/Modal/LayerImageObjectFactoryModal/LayerImageObjectFactoryModal.component.tsx index 5c054fb2..b750d061 100644 --- a/src/components/FunctionalArea/Modal/LayerImageObjectFactoryModal/LayerImageObjectFactoryModal.component.tsx +++ b/src/components/FunctionalArea/Modal/LayerImageObjectFactoryModal/LayerImageObjectFactoryModal.component.tsx @@ -113,7 +113,6 @@ export const LayerImageObjectFactoryModal: React.FC = () => { } } }); - showToast({ type: 'success', message: 'A new image object has been successfully added', @@ -154,6 +153,7 @@ export const LayerImageObjectFactoryModal: React.FC = () => { type="file" accept="image/*" onChange={handleFileChange} + data-testid="image-file-input" className="w-full border border-[#ccc] bg-white p-2" /> </div> @@ -167,6 +167,7 @@ export const LayerImageObjectFactoryModal: React.FC = () => { fill style={{ objectFit: 'contain' }} className="rounded" + data-testid="layer-image-preview" /> ) : ( <div className="text-gray-500">No Image</div> diff --git a/src/components/Map/Map.component.tsx b/src/components/Map/Map.component.tsx index 1ae88fa6..e055ad95 100644 --- a/src/components/Map/Map.component.tsx +++ b/src/components/Map/Map.component.tsx @@ -38,7 +38,7 @@ export const Map = (): JSX.Element => { {!vectorRendering && <MapAdditionalOptions />} {vectorRendering && <MapVectorBackgroundSelector />} {vectorRendering && visibleLayersLength && <MapActiveLayerSelector />} - {vectorRendering && activeLayer && <MapDrawActions />} + {vectorRendering && activeLayer && visibleLayersLength && <MapDrawActions />} <Drawer /> <PluginsDrawer /> <Legend /> diff --git a/src/components/Map/MapActiveLayerSelector/MapActiveLayerSelector.component.tsx b/src/components/Map/MapActiveLayerSelector/MapActiveLayerSelector.component.tsx index cd81e3df..c07d33c2 100644 --- a/src/components/Map/MapActiveLayerSelector/MapActiveLayerSelector.component.tsx +++ b/src/components/Map/MapActiveLayerSelector/MapActiveLayerSelector.component.tsx @@ -46,7 +46,7 @@ export const MapActiveLayerSelector = (): JSX.Element => { }, [activeLayer, currentModelId, dispatch, options]); return ( - <div className={twMerge('z-11 absolute right-6 top-[calc(64px+40px+84px)] flex')}> + <div className={twMerge('absolute right-6 top-[calc(64px+40px+84px)] flex')}> <Select options={options} selectedId={activeLayer} onChange={handleChange} width={140} /> </div> ); diff --git a/src/models/fixtures/layerImageFixture.ts b/src/models/fixtures/layerImageFixture.ts new file mode 100644 index 00000000..d386d544 --- /dev/null +++ b/src/models/fixtures/layerImageFixture.ts @@ -0,0 +1,9 @@ +import { ZOD_SEED } from '@/constants'; +// eslint-disable-next-line import/no-extraneous-dependencies +import { createFixture } from 'zod-fixture'; +import { layerImageSchema } from '@/models/layerImageSchema'; + +export const layerImageFixture = createFixture(layerImageSchema, { + seed: ZOD_SEED, + array: { min: 1, max: 1 }, +}); diff --git a/src/shared/Autocomplete/Autocomplete.component.tsx b/src/shared/Autocomplete/Autocomplete.component.tsx index 0b4cf3be..232dca19 100644 --- a/src/shared/Autocomplete/Autocomplete.component.tsx +++ b/src/shared/Autocomplete/Autocomplete.component.tsx @@ -34,12 +34,14 @@ export const Autocomplete = <T,>({ }; return ( - <Select - options={formattedOptions} - onChange={handleChange} - placeholder={placeholder} - classNamePrefix="react-select" - /> + <div data-testid="autocomplete"> + <Select + options={formattedOptions} + onChange={handleChange} + placeholder={placeholder} + classNamePrefix="react-select" + /> + </div> ); }; -- GitLab From c5261685f3d9c7ef80d2beb5b4a1d1d3b3813a8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mi=C5=82osz=20Grocholewski?= <m.grocholewski@atcomp.pl> Date: Tue, 7 Jan 2025 09:00:28 +0100 Subject: [PATCH 14/22] feat(array-utils): add test for arrayToKeyValue --- src/components/SPA/MinervaSPA.component.tsx | 1 - src/redux/layers/layers.thunks.ts | 2 +- src/utils/array/arrayToKeyValue.test.ts | 46 +++++++++++++++++++++ src/utils/array/arrayToKeyValue.ts | 2 +- 4 files changed, 48 insertions(+), 3 deletions(-) create mode 100644 src/utils/array/arrayToKeyValue.test.ts diff --git a/src/components/SPA/MinervaSPA.component.tsx b/src/components/SPA/MinervaSPA.component.tsx index 2e9751e3..f086919b 100644 --- a/src/components/SPA/MinervaSPA.component.tsx +++ b/src/components/SPA/MinervaSPA.component.tsx @@ -24,7 +24,6 @@ export const MinervaSPA = (): JSX.Element => { <div className={twMerge('relative', manrope.variable)}> <FunctionalArea /> <Map /> - {/* <Modal /> */} <ContextMenu /> <CookieBanner /> </div> diff --git a/src/redux/layers/layers.thunks.ts b/src/redux/layers/layers.thunks.ts index 5a2ec2d4..f3f78c43 100644 --- a/src/redux/layers/layers.thunks.ts +++ b/src/redux/layers/layers.thunks.ts @@ -65,7 +65,7 @@ export const getLayersForModel = createAsyncThunk< rects: rectsResponse.data.content, ovals: ovalsResponse.data.content, lines: linesResponse.data.content, - images: arrayToKeyValue(imagesResponse.data.content, 'id'), + images: arrayToKeyValue(imagesResponse.data.content as Array<LayerImage>, 'id'), }; }), ); diff --git a/src/utils/array/arrayToKeyValue.test.ts b/src/utils/array/arrayToKeyValue.test.ts new file mode 100644 index 00000000..347d3f98 --- /dev/null +++ b/src/utils/array/arrayToKeyValue.test.ts @@ -0,0 +1,46 @@ +/* eslint-disable no-magic-numbers */ +import arrayToKeyValue from './arrayToKeyValue'; + +describe('arrayToKeyValue', () => { + interface Person { + id: number; + name: string; + age: number; + isActive: boolean; + } + + const people: Person[] = [ + { id: 1, name: 'John', age: 30, isActive: true }, + { id: 2, name: 'Anna', age: 25, isActive: false }, + { id: 3, name: 'Peter', age: 28, isActive: true }, + ]; + + it('create dict with key "id" and value "name"', () => { + const result = arrayToKeyValue(people, 'id'); + expect(result).toEqual({ + 1: people[0], + 2: people[1], + 3: people[2], + }); + }); + + it('create dict with key "name" and value "age"', () => { + const result = arrayToKeyValue(people, 'name'); + expect(result).toEqual({ + John: people[0], + Anna: people[1], + Peter: people[2], + }); + }); + + it('handles duplicate keys, overwriting previous values', () => { + const duplicateData = [ + { id: 1, name: 'John', age: 30, isActive: true }, + { id: 1, name: 'Anna', age: 25, isActive: false }, + ]; + const result = arrayToKeyValue(duplicateData, 'id'); + expect(result).toEqual({ + 1: duplicateData[1], + }); + }); +}); diff --git a/src/utils/array/arrayToKeyValue.ts b/src/utils/array/arrayToKeyValue.ts index 52c7cf2e..1b04bab5 100644 --- a/src/utils/array/arrayToKeyValue.ts +++ b/src/utils/array/arrayToKeyValue.ts @@ -1,4 +1,4 @@ -export default function arrayToKeyValue<T extends Record<string, never>, K extends keyof T>( +export default function arrayToKeyValue<T, K extends keyof T>( array: T[], key: K, ): Record<T[K] & PropertyKey, T> { -- GitLab From b70371a2464f0f3ee795e2040a7f0aba31e782d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mi=C5=82osz=20Grocholewski?= <m.grocholewski@atcomp.pl> Date: Tue, 7 Jan 2025 09:27:46 +0100 Subject: [PATCH 15/22] feat(autocomplete): add test for autocomplete --- .../Autocomplete.component.test.tsx | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 src/shared/Autocomplete/Autocomplete.component.test.tsx diff --git a/src/shared/Autocomplete/Autocomplete.component.test.tsx b/src/shared/Autocomplete/Autocomplete.component.test.tsx new file mode 100644 index 00000000..65662aed --- /dev/null +++ b/src/shared/Autocomplete/Autocomplete.component.test.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { Autocomplete } from './Autocomplete.component'; + +interface Option { + id: number; + name: string; +} + +describe('Autocomplete', () => { + const options: Option[] = [ + { id: 1, name: 'Option 1' }, + { id: 2, name: 'Option 2' }, + { id: 3, name: 'Option 3' }, + ]; + + it('renders the component with placeholder', () => { + render( + <Autocomplete + options={options} + valueKey="id" + labelKey="name" + placeholder="Select an option" + onChange={() => {}} + />, + ); + + const placeholder = screen.getByText('Select an option'); + expect(placeholder).toBeInTheDocument(); + }); + + it('displays options and handles selection', () => { + const handleChange = jest.fn(); + + render( + <Autocomplete + options={options} + valueKey="id" + labelKey="name" + placeholder="Select an option" + onChange={handleChange} + />, + ); + + const dropdown = screen.getByTestId('autocomplete'); + if (!dropdown.firstChild) { + throw new Error('Dropdown does not have a firstChild'); + } + fireEvent.keyDown(dropdown.firstChild, { key: 'ArrowDown' }); + + const option1 = screen.getByText('Option 1'); + const option2 = screen.getByText('Option 2'); + expect(option1).toBeInTheDocument(); + expect(option2).toBeInTheDocument(); + + fireEvent.click(option1); + + expect(handleChange).toHaveBeenCalledWith({ id: 1, name: 'Option 1' }); + }); +}); -- GitLab From 4636c288b3e72597430ebdf6713db1dfd59776f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mi=C5=82osz=20Grocholewski?= <m.grocholewski@atcomp.pl> Date: Tue, 7 Jan 2025 10:13:28 +0100 Subject: [PATCH 16/22] feat(draw-image): add test for getDrawImageInteraction --- .../layer/getDrawImageInteraction.test.ts | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 src/components/Map/MapViewer/MapViewerVector/utils/shapes/layer/getDrawImageInteraction.test.ts diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/layer/getDrawImageInteraction.test.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/layer/getDrawImageInteraction.test.ts new file mode 100644 index 00000000..0687acb6 --- /dev/null +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/layer/getDrawImageInteraction.test.ts @@ -0,0 +1,66 @@ +/* eslint-disable no-magic-numbers */ +import Draw from 'ol/interaction/Draw'; +import { latLngToPoint } from '@/utils/map/latLngToPoint'; +import modalReducer, { openLayerImageObjectFactoryModal } from '@/redux/modal/modal.slice'; +import { MapSize } from '@/redux/map/map.types'; +import { + createStoreInstanceUsingSliceReducer, + ToolkitStoreWithSingleSlice, +} from '@/utils/createStoreInstanceUsingSliceReducer'; +import { ModalState } from '@/redux/modal/modal.types'; +import { DEFAULT_TILE_SIZE } from '@/constants/map'; +import { Map } from 'ol'; +import getDrawImageInteraction from './getDrawImageInteraction'; + +jest.mock('../../../../../../../utils/map/latLngToPoint', () => ({ + latLngToPoint: jest.fn(latLng => ({ x: latLng[0], y: latLng[1] })), +})); + +describe('getDrawImageInteraction', () => { + let store = {} as ToolkitStoreWithSingleSlice<ModalState>; + const mockDispatch = jest.fn(() => {}); + + let mapSize: MapSize; + + beforeEach(() => { + mapSize = { + width: 800, + height: 600, + minZoom: 1, + maxZoom: 9, + tileSize: DEFAULT_TILE_SIZE, + }; + store = createStoreInstanceUsingSliceReducer('modal', modalReducer); + store.dispatch = mockDispatch; + }); + + it('returns a Draw interaction', () => { + const drawInteraction = getDrawImageInteraction(mapSize, store.dispatch); + expect(drawInteraction).toBeInstanceOf(Draw); + }); + + it('dispatches openLayerImageObjectFactoryModal on drawend', () => { + const dummyElement = document.createElement('div'); + const mapInstance = new Map({ target: dummyElement }); + const drawInteraction = getDrawImageInteraction(mapSize, store.dispatch); + mapInstance.addInteraction(drawInteraction); + drawInteraction.appendCoordinates([ + [0, 0], + [10, 10], + ]); + + drawInteraction.finishDrawing(); + + expect(latLngToPoint).toHaveBeenCalledTimes(4); + expect(store.dispatch).toHaveBeenCalledWith( + openLayerImageObjectFactoryModal( + expect.objectContaining({ + x: expect.any(Number), + y: expect.any(Number), + width: expect.any(Number), + height: expect.any(Number), + }), + ), + ); + }); +}); -- GitLab From 6a1f2a4a19287382211578481a1259eb0bef51eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mi=C5=82osz=20Grocholewski?= <m.grocholewski@atcomp.pl> Date: Tue, 7 Jan 2025 10:38:07 +0100 Subject: [PATCH 17/22] feat(map-draw-actions): add test for MapDrawActions --- .../MapDrawActions.component.test.tsx | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 src/components/Map/MapDrawActions/MapDrawActions.component.test.tsx diff --git a/src/components/Map/MapDrawActions/MapDrawActions.component.test.tsx b/src/components/Map/MapDrawActions/MapDrawActions.component.test.tsx new file mode 100644 index 00000000..dd9443f3 --- /dev/null +++ b/src/components/Map/MapDrawActions/MapDrawActions.component.test.tsx @@ -0,0 +1,51 @@ +import { MapDrawActions } from '@/components/Map/MapDrawActions/MapDrawActions.component'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { MAP_EDIT_ACTIONS } from '@/redux/mapEditTools/mapEditTools.constants'; +import { mapEditToolsSetActiveAction } from '@/redux/mapEditTools/mapEditTools.slice'; +import { + getReduxWrapperWithStore, + InitialStoreState, +} from '@/utils/testing/getReduxWrapperWithStore'; +import { StoreType } from '@/redux/store'; +import { MAP_EDIT_TOOLS_STATE_INITIAL_MOCK } from '@/redux/mapEditTools/mapEditTools.mock'; +import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; + +jest.mock('../../../redux/hooks/useAppDispatch', () => ({ + useAppDispatch: jest.fn(), +})); + +const renderComponent = (initialStoreState: InitialStoreState = {}): { store: StoreType } => { + const { Wrapper, store } = getReduxWrapperWithStore(initialStoreState); + + return ( + render( + <Wrapper> + <MapDrawActions /> + </Wrapper>, + ), + { + store, + } + ); +}; + +describe('MapDrawActions', () => { + const mockDispatch = jest.fn(() => {}); + + beforeEach(() => { + (useAppDispatch as jest.Mock).mockReturnValue(mockDispatch); + }); + + it('renders the MapDrawActionsButton and toggles action on click', () => { + renderComponent({ + mapEditTools: MAP_EDIT_TOOLS_STATE_INITIAL_MOCK, + }); + const button = screen.getByRole('button', { name: /draw image/i }); + expect(button).toBeInTheDocument(); + fireEvent.click(button); + + expect(mockDispatch).toHaveBeenCalledWith( + mapEditToolsSetActiveAction(MAP_EDIT_ACTIONS.DRAW_IMAGE), + ); + }); +}); -- GitLab From eb6d75505e64339d863a4f02df62990ce8648986 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mi=C5=82osz=20Grocholewski?= <m.grocholewski@atcomp.pl> Date: Wed, 8 Jan 2025 15:02:09 +0100 Subject: [PATCH 18/22] feat(vector-map): add transform for layer image objects --- package-lock.json | 424 ++++++++++++++++++ package.json | 2 + .../MapDrawActions.component.tsx | 6 + .../MapViewerVector/MapViewerVector.types.ts | 7 + .../mouseLeftClick/onMapLeftClick.test.ts | 15 +- .../mouseLeftClick/onMapLeftClick.ts | 4 +- .../useOlMapAdditionalLayers.ts | 39 +- .../reactionsLayer/processModelElements.ts | 1 + .../shapes/coords/getBoundingBoxFromExtent.ts | 23 + .../utils/shapes/elements/Glyph.test.ts | 8 + .../utils/shapes/elements/Glyph.ts | 167 +++++-- .../utils/shapes/layer/Layer.test.ts | 8 + .../utils/shapes/layer/Layer.ts | 31 +- .../shapes/layer/getDrawImageInteraction.ts | 20 +- .../layer/getTransformImageInteraction.ts | 59 +++ src/redux/apiPath.ts | 2 + src/redux/layers/layers.reducers.ts | 16 + src/redux/layers/layers.slice.ts | 5 +- src/redux/layers/layers.thunks.ts | 41 ++ .../mapEditTools/mapEditTools.constants.ts | 1 + src/shared/Icon/Icon.component.tsx | 2 + src/shared/Icon/Icons/ResizeImageIcon.tsx | 19 + src/types/iconTypes.ts | 3 +- 23 files changed, 828 insertions(+), 75 deletions(-) create mode 100644 src/components/Map/MapViewer/MapViewerVector/utils/shapes/coords/getBoundingBoxFromExtent.ts create mode 100644 src/components/Map/MapViewer/MapViewerVector/utils/shapes/layer/getTransformImageInteraction.ts create mode 100644 src/shared/Icon/Icons/ResizeImageIcon.tsx diff --git a/package-lock.json b/package-lock.json index 6f42bd09..b626982e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "molart": "1.15.0", "next": "13.4.19", "ol": "10.2.0", + "ol-ext": "4.0.24", "polished": "4.3.1", "postcss": "8.4.29", "query-string": "7.1.3", @@ -56,6 +57,7 @@ "@types/crypto-js": "4.2.2", "@types/is-uuid": "1.0.2", "@types/jest": "29.5.11", + "@types/ol-ext": "npm:@siedlerchr/types-ol-ext@3.6.1", "@types/react-autosuggest": "^10.1.11", "@types/react-redux": "7.1.33", "@types/redux-mock-store": "1.0.6", @@ -2562,6 +2564,16 @@ "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==", "dev": true }, + "node_modules/@types/ol-ext": { + "name": "@siedlerchr/types-ol-ext", + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/@siedlerchr/types-ol-ext/-/types-ol-ext-3.6.1.tgz", + "integrity": "sha512-CSqgXdS1d028TvnVXf2/dgnU66lRGHM9+R5E8lL2ilmm6ktHVZtsIhRR8rWSXu4Ybt1rGdMbwbDxofIbilWIyQ==", + "dev": true, + "peerDependencies": { + "jspdf": "^2.5.2" + } + }, "node_modules/@types/openlayers": { "version": "4.6.23", "resolved": "https://registry.npmjs.org/@types/openlayers/-/openlayers-4.6.23.tgz", @@ -2577,6 +2589,14 @@ "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.11.tgz", "integrity": "sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng==" }, + "node_modules/@types/raf": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz", + "integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==", + "dev": true, + "optional": true, + "peer": true + }, "node_modules/@types/rbush": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/rbush/-/rbush-3.0.3.tgz", @@ -3317,6 +3337,19 @@ "node": ">= 4.0.0" } }, + "node_modules/atob": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz", + "integrity": "sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==", + "dev": true, + "peer": true, + "bin": { + "atob": "bin/atob.js" + }, + "engines": { + "node": ">= 4.5.0" + } + }, "node_modules/attr-accept": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.2.tgz", @@ -3609,6 +3642,17 @@ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "dev": true, + "optional": true, + "peer": true, + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -3729,6 +3773,19 @@ "node-int64": "^0.4.0" } }, + "node_modules/btoa": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/btoa/-/btoa-1.2.1.tgz", + "integrity": "sha512-SB4/MIGlsiVkMcHmT+pSmIPoNDoHg+7cMzmt3Uxt628MTz2487DKSqK/fuhFBrkuqrYv5UCEnACpF4dTFNKc/g==", + "dev": true, + "peer": true, + "bin": { + "btoa": "bin/btoa.js" + }, + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/buffer": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", @@ -3892,6 +3949,35 @@ } ] }, + "node_modules/canvg": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.10.tgz", + "integrity": "sha512-qwR2FRNO9NlzTeKIPIKpnTY6fqwuYSequ8Ru8c0YkYU7U0oW+hLUvWadLvAu1Rl72OMNiFhoLu4f8eUjQ7l/+Q==", + "dev": true, + "optional": true, + "peer": true, + "dependencies": { + "@babel/runtime": "^7.12.5", + "@types/raf": "^3.4.0", + "core-js": "^3.8.3", + "raf": "^3.4.1", + "regenerator-runtime": "^0.13.7", + "rgbcolor": "^1.0.1", + "stackblur-canvas": "^2.0.0", + "svg-pathdata": "^6.0.3" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/canvg/node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "dev": true, + "optional": true, + "peer": true + }, "node_modules/caseless": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", @@ -4519,6 +4605,19 @@ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "dev": true }, + "node_modules/core-js": { + "version": "3.40.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.40.0.tgz", + "integrity": "sha512-7vsMc/Lty6AGnn7uFpYT56QesI5D2Y/UkgKounk87OP9Z2H9Z8kj6jzcSGAxFmUtDOS0ntK6lbQz+Nsa0Jj6mQ==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "peer": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/core-util-is": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", @@ -4610,6 +4709,17 @@ "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==" }, + "node_modules/css-line-break": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", + "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", + "dev": true, + "optional": true, + "peer": true, + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/css.escape": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", @@ -5248,6 +5358,14 @@ "node": ">=12" } }, + "node_modules/dompurify": { + "version": "2.5.8", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-2.5.8.tgz", + "integrity": "sha512-o1vSNgrmYMQObbSSvF/1brBYEQPHhV1+gsmrusO7/GXtp1T9rCS8cXFqVxK/9crT1jA6Ccv+5MTSjBNqr7Sovw==", + "dev": true, + "optional": true, + "peer": true + }, "node_modules/dot-prop": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz", @@ -6684,6 +6802,13 @@ "pend": "~1.2.0" } }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "dev": true, + "peer": true + }, "node_modules/figures": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", @@ -7399,6 +7524,21 @@ "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", "dev": true }, + "node_modules/html2canvas": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", + "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", + "dev": true, + "optional": true, + "peer": true, + "dependencies": { + "css-line-break": "^2.1.0", + "text-segmentation": "^1.0.3" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/http-proxy-agent": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", @@ -9552,6 +9692,25 @@ "node": "*" } }, + "node_modules/jspdf": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-2.5.2.tgz", + "integrity": "sha512-myeX9c+p7znDWPk0eTrujCzNjT+CXdXyk7YmJq5nD5V7uLLKmSXnlQ/Jn/kuo3X09Op70Apm0rQSnFWyGK8uEQ==", + "dev": true, + "peer": true, + "dependencies": { + "@babel/runtime": "^7.23.2", + "atob": "^2.1.2", + "btoa": "^1.2.1", + "fflate": "^0.8.1" + }, + "optionalDependencies": { + "canvg": "^3.0.6", + "core-js": "^3.6.0", + "dompurify": "^2.5.4", + "html2canvas": "^1.0.0-rc.5" + } + }, "node_modules/jsprim": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-2.0.2.tgz", @@ -11001,6 +11160,14 @@ "url": "https://opencollective.com/openlayers" } }, + "node_modules/ol-ext": { + "version": "4.0.24", + "resolved": "https://registry.npmjs.org/ol-ext/-/ol-ext-4.0.24.tgz", + "integrity": "sha512-VEf1+mjvbe35mMsszVsugqcvWfeGcU8TwS+GgXm3nGYqiHR7CckX2DWmM9B94QCDnrJWKKXBicfInbkoe2xT7w==", + "peerDependencies": { + "ol": ">= 5.3.0" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -11837,6 +12004,17 @@ "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz", "integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==" }, + "node_modules/raf": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", + "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", + "dev": true, + "optional": true, + "peer": true, + "dependencies": { + "performance-now": "^2.1.0" + } + }, "node_modules/randexp": { "version": "0.5.3", "resolved": "https://registry.npmjs.org/randexp/-/randexp-0.5.3.tgz", @@ -12408,6 +12586,17 @@ "integrity": "sha512-V2hovdzFbOi77/WajaSMXk2OLm+xNIeQdMMuB7icj7bk6zi2F8GGAxigcnDFpJHbNyNcgyJDiP+8nOrY5cZGrA==", "dev": true }, + "node_modules/rgbcolor": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz", + "integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==", + "dev": true, + "optional": true, + "peer": true, + "engines": { + "node": ">= 0.8.15" + } + }, "node_modules/rimraf": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", @@ -12848,6 +13037,17 @@ "node": ">=8" } }, + "node_modules/stackblur-canvas": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz", + "integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==", + "dev": true, + "optional": true, + "peer": true, + "engines": { + "node": ">=0.1.14" + } + }, "node_modules/stop-iteration-iterator": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.0.0.tgz", @@ -13224,6 +13424,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/svg-pathdata": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz", + "integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==", + "dev": true, + "optional": true, + "peer": true, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", @@ -13333,6 +13544,17 @@ "node": ">=0.10" } }, + "node_modules/text-segmentation": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", + "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", + "dev": true, + "optional": true, + "peer": true, + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -13823,6 +14045,17 @@ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" }, + "node_modules/utrie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", + "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", + "dev": true, + "optional": true, + "peer": true, + "dependencies": { + "base64-arraybuffer": "^1.0.2" + } + }, "node_modules/uuid": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", @@ -16276,6 +16509,13 @@ "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==", "dev": true }, + "@types/ol-ext": { + "version": "npm:@siedlerchr/types-ol-ext@3.6.1", + "resolved": "https://registry.npmjs.org/@siedlerchr/types-ol-ext/-/types-ol-ext-3.6.1.tgz", + "integrity": "sha512-CSqgXdS1d028TvnVXf2/dgnU66lRGHM9+R5E8lL2ilmm6ktHVZtsIhRR8rWSXu4Ybt1rGdMbwbDxofIbilWIyQ==", + "dev": true, + "requires": {} + }, "@types/openlayers": { "version": "4.6.23", "resolved": "https://registry.npmjs.org/@types/openlayers/-/openlayers-4.6.23.tgz", @@ -16291,6 +16531,14 @@ "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.11.tgz", "integrity": "sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng==" }, + "@types/raf": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz", + "integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==", + "dev": true, + "optional": true, + "peer": true + }, "@types/rbush": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/rbush/-/rbush-3.0.3.tgz", @@ -16824,6 +17072,13 @@ "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", "dev": true }, + "atob": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz", + "integrity": "sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==", + "dev": true, + "peer": true + }, "attr-accept": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.2.tgz", @@ -17037,6 +17292,14 @@ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" }, + "base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "dev": true, + "optional": true, + "peer": true + }, "base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -17117,6 +17380,13 @@ "node-int64": "^0.4.0" } }, + "btoa": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/btoa/-/btoa-1.2.1.tgz", + "integrity": "sha512-SB4/MIGlsiVkMcHmT+pSmIPoNDoHg+7cMzmt3Uxt628MTz2487DKSqK/fuhFBrkuqrYv5UCEnACpF4dTFNKc/g==", + "dev": true, + "peer": true + }, "buffer": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", @@ -17218,6 +17488,34 @@ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001572.tgz", "integrity": "sha512-1Pbh5FLmn5y4+QhNyJE9j3/7dK44dGB83/ZMjv/qJk86TvDbjk0LosiZo0i0WB0Vx607qMX9jYrn1VLHCkN4rw==" }, + "canvg": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.10.tgz", + "integrity": "sha512-qwR2FRNO9NlzTeKIPIKpnTY6fqwuYSequ8Ru8c0YkYU7U0oW+hLUvWadLvAu1Rl72OMNiFhoLu4f8eUjQ7l/+Q==", + "dev": true, + "optional": true, + "peer": true, + "requires": { + "@babel/runtime": "^7.12.5", + "@types/raf": "^3.4.0", + "core-js": "^3.8.3", + "raf": "^3.4.1", + "regenerator-runtime": "^0.13.7", + "rgbcolor": "^1.0.1", + "stackblur-canvas": "^2.0.0", + "svg-pathdata": "^6.0.3" + }, + "dependencies": { + "regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "dev": true, + "optional": true, + "peer": true + } + } + }, "caseless": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", @@ -17692,6 +17990,14 @@ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "dev": true }, + "core-js": { + "version": "3.40.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.40.0.tgz", + "integrity": "sha512-7vsMc/Lty6AGnn7uFpYT56QesI5D2Y/UkgKounk87OP9Z2H9Z8kj6jzcSGAxFmUtDOS0ntK6lbQz+Nsa0Jj6mQ==", + "dev": true, + "optional": true, + "peer": true + }, "core-util-is": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", @@ -17752,6 +18058,17 @@ "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==" }, + "css-line-break": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", + "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", + "dev": true, + "optional": true, + "peer": true, + "requires": { + "utrie": "^1.0.2" + } + }, "css.escape": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", @@ -18254,6 +18571,14 @@ "webidl-conversions": "^7.0.0" } }, + "dompurify": { + "version": "2.5.8", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-2.5.8.tgz", + "integrity": "sha512-o1vSNgrmYMQObbSSvF/1brBYEQPHhV1+gsmrusO7/GXtp1T9rCS8cXFqVxK/9crT1jA6Ccv+5MTSjBNqr7Sovw==", + "dev": true, + "optional": true, + "peer": true + }, "dot-prop": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz", @@ -19280,6 +19605,13 @@ "pend": "~1.2.0" } }, + "fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "dev": true, + "peer": true + }, "figures": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", @@ -19792,6 +20124,18 @@ "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", "dev": true }, + "html2canvas": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", + "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", + "dev": true, + "optional": true, + "peer": true, + "requires": { + "css-line-break": "^2.1.0", + "text-segmentation": "^1.0.3" + } + }, "http-proxy-agent": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", @@ -21332,6 +21676,23 @@ "through": ">=2.2.7 <3" } }, + "jspdf": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-2.5.2.tgz", + "integrity": "sha512-myeX9c+p7znDWPk0eTrujCzNjT+CXdXyk7YmJq5nD5V7uLLKmSXnlQ/Jn/kuo3X09Op70Apm0rQSnFWyGK8uEQ==", + "dev": true, + "peer": true, + "requires": { + "@babel/runtime": "^7.23.2", + "atob": "^2.1.2", + "btoa": "^1.2.1", + "canvg": "^3.0.6", + "core-js": "^3.6.0", + "dompurify": "^2.5.4", + "fflate": "^0.8.1", + "html2canvas": "^1.0.0-rc.5" + } + }, "jsprim": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-2.0.2.tgz", @@ -22385,6 +22746,12 @@ "rbush": "^4.0.0" } }, + "ol-ext": { + "version": "4.0.24", + "resolved": "https://registry.npmjs.org/ol-ext/-/ol-ext-4.0.24.tgz", + "integrity": "sha512-VEf1+mjvbe35mMsszVsugqcvWfeGcU8TwS+GgXm3nGYqiHR7CckX2DWmM9B94QCDnrJWKKXBicfInbkoe2xT7w==", + "requires": {} + }, "once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -22905,6 +23272,17 @@ "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz", "integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==" }, + "raf": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", + "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", + "dev": true, + "optional": true, + "peer": true, + "requires": { + "performance-now": "^2.1.0" + } + }, "randexp": { "version": "0.5.3", "resolved": "https://registry.npmjs.org/randexp/-/randexp-0.5.3.tgz", @@ -23329,6 +23707,14 @@ "integrity": "sha512-V2hovdzFbOi77/WajaSMXk2OLm+xNIeQdMMuB7icj7bk6zi2F8GGAxigcnDFpJHbNyNcgyJDiP+8nOrY5cZGrA==", "dev": true }, + "rgbcolor": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz", + "integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==", + "dev": true, + "optional": true, + "peer": true + }, "rimraf": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", @@ -23658,6 +24044,14 @@ } } }, + "stackblur-canvas": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz", + "integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==", + "dev": true, + "optional": true, + "peer": true + }, "stop-iteration-iterator": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.0.0.tgz", @@ -23923,6 +24317,14 @@ "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==" }, + "svg-pathdata": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz", + "integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==", + "dev": true, + "optional": true, + "peer": true + }, "symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", @@ -24005,6 +24407,17 @@ "integrity": "sha512-wiBrwC1EhBelW12Zy26JeOUkQ5mRu+5o8rpsJk5+2t+Y5vE7e842qtZDQ2g1NpX/29HdyFeJ4nSIhI47ENSxlQ==", "dev": true }, + "text-segmentation": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", + "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", + "dev": true, + "optional": true, + "peer": true, + "requires": { + "utrie": "^1.0.2" + } + }, "text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -24355,6 +24768,17 @@ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" }, + "utrie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", + "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", + "dev": true, + "optional": true, + "peer": true, + "requires": { + "base64-arraybuffer": "^1.0.2" + } + }, "uuid": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", diff --git a/package.json b/package.json index 52f25ca9..1d5e4494 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "molart": "1.15.0", "next": "13.4.19", "ol": "10.2.0", + "ol-ext": "4.0.24", "polished": "4.3.1", "postcss": "8.4.29", "query-string": "7.1.3", @@ -62,6 +63,7 @@ "zod-to-json-schema": "3.22.4" }, "devDependencies": { + "@types/ol-ext": "npm:@siedlerchr/types-ol-ext@3.6.1", "@commitlint/cli": "17.8.1", "@commitlint/config-conventional": "17.8.1", "@testing-library/jest-dom": "6.1.6", diff --git a/src/components/Map/MapDrawActions/MapDrawActions.component.tsx b/src/components/Map/MapDrawActions/MapDrawActions.component.tsx index 3d02bee2..69110b76 100644 --- a/src/components/Map/MapDrawActions/MapDrawActions.component.tsx +++ b/src/components/Map/MapDrawActions/MapDrawActions.component.tsx @@ -22,6 +22,12 @@ export const MapDrawActions = (): React.JSX.Element => { icon="image" title="Draw image" /> + <MapDrawActionsButton + isActive={activeAction === MAP_EDIT_ACTIONS.TRANSFORM_IMAGE} + toggleMapEditAction={() => toggleMapEditAction(MAP_EDIT_ACTIONS.TRANSFORM_IMAGE)} + icon="resize-image" + title="Transform image" + /> </div> ); }; diff --git a/src/components/Map/MapViewer/MapViewerVector/MapViewerVector.types.ts b/src/components/Map/MapViewer/MapViewerVector/MapViewerVector.types.ts index 63fddd60..709336b1 100644 --- a/src/components/Map/MapViewer/MapViewerVector/MapViewerVector.types.ts +++ b/src/components/Map/MapViewer/MapViewerVector/MapViewerVector.types.ts @@ -15,3 +15,10 @@ export type ScaleFunction = (resolution: number) => number; export type OverlayBioEntityGroupedElementsType = { [id: string]: Array<OverlayBioEntityRender & { amount: number }>; }; + +export type BoundingBox = { + x: number; + y: number; + width: number; + height: number; +}; diff --git a/src/components/Map/MapViewer/MapViewerVector/listeners/mouseClick/mouseLeftClick/onMapLeftClick.test.ts b/src/components/Map/MapViewer/MapViewerVector/listeners/mouseClick/mouseLeftClick/onMapLeftClick.test.ts index fb720a27..11211b8b 100644 --- a/src/components/Map/MapViewer/MapViewerVector/listeners/mouseClick/mouseLeftClick/onMapLeftClick.test.ts +++ b/src/components/Map/MapViewer/MapViewerVector/listeners/mouseClick/mouseLeftClick/onMapLeftClick.test.ts @@ -7,10 +7,11 @@ import { handleFeaturesClick } from '@/components/Map/MapViewer/utils/listeners/ import Map from 'ol/Map'; import { onMapLeftClick } from '@/components/Map/MapViewer/MapViewerVector/listeners/mouseClick/mouseLeftClick/onMapLeftClick'; import { Comment } from '@/types/models'; -import { Layer } from 'ol/layer'; import SimpleGeometry from 'ol/geom/SimpleGeometry'; import { Feature } from 'ol'; import { FEATURE_TYPE } from '@/constants/features'; +import VectorLayer from 'ol/layer/Vector'; +import { VECTOR_MAP_LAYER_TYPE } from '@/components/Map/MapViewer/MapViewerVector/MapViewerVector.constants'; import * as leftClickHandleAlias from './leftClickHandleAlias'; import * as clickHandleReaction from '../clickHandleReaction'; @@ -33,6 +34,8 @@ describe('onMapLeftClick', () => { const isResultDrawerOpen = true; const comments: Array<Comment> = []; let mapInstance: Map; + const vectorLayer = new VectorLayer({}); + vectorLayer.set('type', VECTOR_MAP_LAYER_TYPE); const event = { coordinate: [100, 50], pixel: [200, 100] }; const mapSize = { width: 90, @@ -51,11 +54,7 @@ describe('onMapLeftClick', () => { it('dispatches updateLastClick and resets data if no feature at pixel', async () => { const dispatch = jest.fn(); jest.spyOn(mapInstance, 'forEachFeatureAtPixel').mockImplementation((_, callback) => { - callback( - new Feature({ zIndex: 1 }), - null as unknown as Layer, - null as unknown as SimpleGeometry, - ); + callback(new Feature({ zIndex: 1 }), vectorLayer, null as unknown as SimpleGeometry); }); await onMapLeftClick( mapSize, @@ -80,7 +79,7 @@ describe('onMapLeftClick', () => { })); const feature = new Feature({ id: 1, type: FEATURE_TYPE.ALIAS, zIndex: 1 }); jest.spyOn(mapInstance, 'forEachFeatureAtPixel').mockImplementation((_, callback) => { - callback(feature, null as unknown as Layer, null as unknown as SimpleGeometry); + callback(feature, vectorLayer, null as unknown as SimpleGeometry); }); (handleFeaturesClick as jest.Mock).mockReturnValue({ shouldBlockCoordSearch: false }); @@ -104,7 +103,7 @@ describe('onMapLeftClick', () => { })); const feature = new Feature({ id: 1, type: FEATURE_TYPE.REACTION, zIndex: 1 }); jest.spyOn(mapInstance, 'forEachFeatureAtPixel').mockImplementation((_, callback) => { - callback(feature, null as unknown as Layer, null as unknown as SimpleGeometry); + callback(feature, vectorLayer, null as unknown as SimpleGeometry); }); (handleFeaturesClick as jest.Mock).mockReturnValue({ shouldBlockCoordSearch: false }); diff --git a/src/components/Map/MapViewer/MapViewerVector/listeners/mouseClick/mouseLeftClick/onMapLeftClick.ts b/src/components/Map/MapViewer/MapViewerVector/listeners/mouseClick/mouseLeftClick/onMapLeftClick.ts index 3a33837b..d9a8031c 100644 --- a/src/components/Map/MapViewer/MapViewerVector/listeners/mouseClick/mouseLeftClick/onMapLeftClick.ts +++ b/src/components/Map/MapViewer/MapViewerVector/listeners/mouseClick/mouseLeftClick/onMapLeftClick.ts @@ -15,6 +15,7 @@ import { resetReactionsData } from '@/redux/reactions/reactions.slice'; import { handleDataReset } from '@/components/Map/MapViewer/utils/listeners/mapSingleClick/handleDataReset'; import { FEATURE_TYPE } from '@/constants/features'; import { clickHandleReaction } from '@/components/Map/MapViewer/MapViewerVector/listeners/mouseClick/clickHandleReaction'; +import { VECTOR_MAP_LAYER_TYPE } from '@/components/Map/MapViewer/MapViewerVector/MapViewerVector.constants'; function isFeatureFilledCompartment(feature: FeatureLike): boolean { return feature.get('type') === FEATURE_TYPE.COMPARTMENT && feature.get('filled'); @@ -50,9 +51,10 @@ export const onMapLeftClick = let featureAtPixel: FeatureLike | undefined; mapInstance.forEachFeatureAtPixel( pixel, - feature => { + (feature, layer) => { const featureZIndex = feature.get('zIndex'); if ( + layer && layer.get('type') === VECTOR_MAP_LAYER_TYPE && (isFeatureFilledCompartment(feature) || isFeatureNotCompartment(feature)) && (featureZIndex === undefined || featureZIndex >= 0) ) { diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/config/additionalLayers/useOlMapAdditionalLayers.ts b/src/components/Map/MapViewer/MapViewerVector/utils/config/additionalLayers/useOlMapAdditionalLayers.ts index 2cdceec6..026c093e 100644 --- a/src/components/Map/MapViewer/MapViewerVector/utils/config/additionalLayers/useOlMapAdditionalLayers.ts +++ b/src/components/Map/MapViewer/MapViewerVector/utils/config/additionalLayers/useOlMapAdditionalLayers.ts @@ -1,5 +1,5 @@ /* eslint-disable no-magic-numbers */ -import { Feature } from 'ol'; +import { Collection, Feature } from 'ol'; import VectorLayer from 'ol/layer/Vector'; import VectorSource from 'ol/source/Vector'; import { useEffect, useMemo, useState } from 'react'; @@ -15,7 +15,7 @@ import { } from '@/redux/layers/layers.selectors'; import { usePointToProjection } from '@/utils/map/usePointToProjection'; import { MapInstance } from '@/types/map'; -import { LineString, MultiPolygon, Point } from 'ol/geom'; +import { Geometry, LineString, MultiPolygon, Point } from 'ol/geom'; import Polygon from 'ol/geom/Polygon'; import Layer from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/layer/Layer'; import { arrowTypesSelector, lineTypesSelector } from '@/redux/shapes/shapes.selectors'; @@ -25,6 +25,7 @@ import getDrawImageInteraction from '@/components/Map/MapViewer/MapViewerVector/ import { LayerState } from '@/redux/layers/layers.types'; import { mapEditToolsActiveActionSelector } from '@/redux/mapEditTools/mapEditTools.selectors'; import { MAP_EDIT_ACTIONS } from '@/redux/mapEditTools/mapEditTools.constants'; +import getTransformImageInteraction from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/layer/getTransformImageInteraction'; export const useOlMapAdditionalLayers = ( mapInstance: MapInstance, @@ -80,11 +81,12 @@ export const useOlMapAdditionalLayers = ( lineTypes, arrowTypes, mapInstance, + mapSize, pointToProjection, }); return additionalLayer.vectorLayer; }); - }, [layersState, lineTypes, arrowTypes, mapInstance, pointToProjection]); + }, [layersState, lineTypes, arrowTypes, mapInstance, mapSize, pointToProjection]); useEffect(() => { if (layersLoading === 'pending') { @@ -95,6 +97,24 @@ export const useOlMapAdditionalLayers = ( } }, [layersForCurrentModel, layersLoading, layersLoadingState]); + const transformInteraction = useMemo(() => { + if (!dispatch || !currentModelId || !activeLayer) { + return null; + } + let imagesFeatures: Collection<Feature<Geometry>> = new Collection(); + const vectorLayer = vectorLayers.find(layer => layer.get('id') === activeLayer); + if (vectorLayer) { + imagesFeatures = new Collection(vectorLayer.get('imagesFeatures')); + } + return getTransformImageInteraction( + dispatch, + mapSize, + currentModelId, + activeLayer, + imagesFeatures, + ); + }, [dispatch, mapSize, currentModelId, activeLayer, vectorLayers]); + useEffect(() => { vectorLayers.forEach(layer => { const layerId = layer.get('id'); @@ -104,6 +124,19 @@ export const useOlMapAdditionalLayers = ( }); }, [layersVisibilityForCurrentModel, vectorLayers]); + useEffect(() => { + if (!transformInteraction) { + return () => {}; + } + if (!activeLayer || !vectorRendering || activeAction !== MAP_EDIT_ACTIONS.TRANSFORM_IMAGE) { + return () => {}; + } + mapInstance?.addInteraction(transformInteraction); + return () => { + mapInstance?.removeInteraction(transformInteraction); + }; + }, [activeAction, activeLayer, mapInstance, transformInteraction, vectorRendering]); + useEffect(() => { if (!drawImageInteraction) { return; diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/config/reactionsLayer/processModelElements.ts b/src/components/Map/MapViewer/MapViewerVector/utils/config/reactionsLayer/processModelElements.ts index 2f5b97ba..60746292 100644 --- a/src/components/Map/MapViewer/MapViewerVector/utils/config/reactionsLayer/processModelElements.ts +++ b/src/components/Map/MapViewer/MapViewerVector/utils/config/reactionsLayer/processModelElements.ts @@ -46,6 +46,7 @@ export default function processModelElements( zIndex: element.z, pointToProjection, mapInstance, + mapSize, }); validElements.push(glyph); return; diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/coords/getBoundingBoxFromExtent.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/coords/getBoundingBoxFromExtent.ts new file mode 100644 index 00000000..efab1e2d --- /dev/null +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/coords/getBoundingBoxFromExtent.ts @@ -0,0 +1,23 @@ +/* eslint-disable no-magic-numbers */ +import { MapSize } from '@/redux/map/map.types'; +import { toLonLat } from 'ol/proj'; +import { latLngToPoint } from '@/utils/map/latLngToPoint'; +import { Extent } from 'ol/extent'; +import { BoundingBox } from '@/components/Map/MapViewer/MapViewerVector/MapViewerVector.types'; + +export default function getBoundingBoxFromExtent(extent: Extent, mapSize: MapSize): BoundingBox { + const [startLng, startLat] = toLonLat([extent[0], extent[3]]); + const startPoint = latLngToPoint([startLat, startLng], mapSize); + const [endLng, endLat] = toLonLat([extent[2], extent[1]]); + const endPoint = latLngToPoint([endLat, endLng], mapSize); + + const width = Math.abs(endPoint.x - startPoint.x); + const height = Math.abs(endPoint.y - startPoint.y); + + return { + width, + height, + x: startPoint.x, + y: startPoint.y, + }; +} diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/Glyph.test.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/Glyph.test.ts index 6ac8c5d2..bef1e037 100644 --- a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/Glyph.test.ts +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/Glyph.test.ts @@ -13,6 +13,13 @@ describe('Glyph', () => { let glyph: Glyph; let mapInstance: MapInstance; let pointToProjectionMock: jest.MockedFunction<UsePointToProjectionResult>; + const mapSize = { + width: 90, + height: 90, + tileSize: 256, + minZoom: 2, + maxZoom: 9, + }; beforeEach(() => { const dummyElement = document.createElement('div'); @@ -37,6 +44,7 @@ describe('Glyph', () => { zIndex: 1, pointToProjection: pointToProjectionMock, mapInstance, + mapSize, }; glyph = new Glyph(props); }); diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/Glyph.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/Glyph.ts index c7844ae3..53f71dc9 100644 --- a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/Glyph.ts +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/Glyph.ts @@ -15,6 +15,9 @@ import getStyle from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/st import { WHITE_COLOR } from '@/components/Map/MapViewer/MapViewerVector/MapViewerVector.constants'; import getFill from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/style/getFill'; import getScaledElementStyle from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/style/getScaledElementStyle'; +import getBoundingBoxFromExtent from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/coords/getBoundingBoxFromExtent'; +import { MapSize } from '@/redux/map/map.types'; +import { LayerImage } from '@/types/models'; export type GlyphProps = { elementId: number; @@ -26,6 +29,7 @@ export type GlyphProps = { zIndex: number; pointToProjection: UsePointToProjectionResult; mapInstance: MapInstance; + mapSize: MapSize; }; export default class Glyph { @@ -39,6 +43,10 @@ export default class Glyph { polygonStyle: Style; + polygon: Polygon = new Polygon([]); + + elementId: number; + width: number; height: number; @@ -47,6 +55,10 @@ export default class Glyph { y: number; + zIndex: number; + + glyphId: number | null; + widthOnMap: number; heightOnMap: number; @@ -55,8 +67,20 @@ export default class Glyph { minResolution: number; + imageWidth: number = 1; + + imageHeight: number = 1; + + imageWidthOnMap: number = 1; + + imageHeightOnMap: number = 1; + + mapInstance: MapInstance; + pointToProjection: UsePointToProjectionResult; + mapSize: MapSize; + constructor({ elementId, glyphId, @@ -67,11 +91,17 @@ export default class Glyph { zIndex, pointToProjection, mapInstance, + mapSize, }: GlyphProps) { + this.elementId = elementId; this.width = width; this.height = height; + this.mapSize = mapSize; + this.glyphId = glyphId; this.x = x; this.y = y; + this.zIndex = zIndex; + this.mapInstance = mapInstance; this.pointToProjection = pointToProjection; const point1 = this.pointToProjection({ x: 0, y: 0 }); const point2 = this.pointToProjection({ x: this.width, y: this.height }); @@ -81,26 +111,19 @@ export default class Glyph { const maxZoom = mapInstance?.getView().get('originalMaxZoom'); this.minResolution = mapInstance?.getView().getResolutionForZoom(maxZoom) || 1; this.pixelRatio = this.widthOnMap / this.minResolution / this.width; - const polygon = new Polygon([ - [ - pointToProjection({ x, y }), - pointToProjection({ x: x + width, y }), - pointToProjection({ x: x + width, y: y + height }), - pointToProjection({ x, y: y + height }), - pointToProjection({ x, y }), - ], - ]); + + this.drawPolygon(); this.polygonStyle = getStyle({ - geometry: polygon, - zIndex, + geometry: this.polygon, + zIndex: this.zIndex, borderColor: { ...WHITE_COLOR, alpha: 0 }, fillColor: { ...WHITE_COLOR, alpha: 0 }, }); this.noGlyphStyle = getStyle({ - geometry: polygon, - zIndex, + geometry: this.polygon, + zIndex: this.zIndex, fillColor: '#E7E7E7', }); this.noGlyphStyle.setText( @@ -113,61 +136,127 @@ export default class Glyph { ); this.feature = new Feature({ - geometry: polygon, - id: elementId, + geometry: this.polygon, + id: this.elementId, type: FEATURE_TYPE.GLYPH, - zIndex, - getAnchorAndCoords: (): { anchor: Array<number>; coords: Coordinate } => { - const center = mapInstance?.getView().getCenter(); + zIndex: this.zIndex, + getAnchorAndCoords: (coords: Coordinate): { anchor: Array<number>; coords: Coordinate } => { + const center = this.mapInstance?.getView().getCenter(); let anchorX = 0; let anchorY = 0; if (center) { - anchorX = - (center[0] - this.pointToProjection({ x: this.x, y: this.y })[0]) / this.widthOnMap; - anchorY = - -(center[1] - this.pointToProjection({ x: this.x, y: this.y })[1]) / this.heightOnMap; + anchorX = (center[0] - coords[0]) / this.widthOnMap; + anchorY = -(center[1] - coords[1]) / this.heightOnMap; } return { anchor: [anchorX, anchorY], coords: center || [0, 0] }; }, }); + this.feature.set('setCoordinates', this.setCoordinates.bind(this)); + this.feature.set('getGlyphData', this.getGlyphData.bind(this)); + this.feature.set('reset', this.reset.bind(this)); this.feature.setStyle(this.getStyle.bind(this)); - if (!glyphId) { + + if (!this.glyphId) { return; } const img = new Image(); img.onload = (): void => { - const imageWidth = img.naturalWidth; - const imageHeight = img.naturalHeight; - const heightScale = height / imageHeight; - const widthScale = width / imageWidth; - if (heightScale < widthScale) { - this.imageScale = heightScale; - this.widthOnMap = (this.heightOnMap * imageWidth) / imageHeight; - } else { - this.imageScale = widthScale; - this.heightOnMap = (this.widthOnMap * imageHeight) / imageWidth; - } + this.imageWidth = img.naturalWidth; + this.imageHeight = img.naturalHeight; + const imagePoint1 = this.pointToProjection({ x: 0, y: 0 }); + const imagePoint2 = this.pointToProjection({ x: this.imageWidth, y: this.imageHeight }); + this.imageWidthOnMap = Math.abs(imagePoint2[0] - imagePoint1[0]); + this.imageHeightOnMap = Math.abs(imagePoint2[1] - imagePoint1[1]); + this.setImageScaleAndDimensions(this.heightOnMap, this.widthOnMap); this.style = new Style({ image: new Icon({ anchor: [0, 0], img, - size: [imageWidth, imageHeight], + size: [this.imageWidth, this.imageHeight], }), - zIndex, + zIndex: this.zIndex, }); }; - img.src = `${BASE_NEW_API_URL}${apiPath.getGlyphImage(glyphId)}`; + img.src = `${BASE_NEW_API_URL}${apiPath.getGlyphImage(this.glyphId)}`; + } + + private drawPolygon(): void { + this.polygon = new Polygon([ + [ + this.pointToProjection({ x: this.x, y: this.y }), + this.pointToProjection({ x: this.x + this.width, y: this.y }), + this.pointToProjection({ x: this.x + this.width, y: this.y + this.height }), + this.pointToProjection({ x: this.x, y: this.y + this.height }), + this.pointToProjection({ x: this.x, y: this.y }), + ], + ]); + } + + private reset(): void { + this.drawPolygon(); + this.polygonStyle.setGeometry(this.polygon); + this.feature.setGeometry(this.polygon); + } + + protected setImageScaleAndDimensions(height: number, width: number): void { + this.widthOnMap = width; + this.heightOnMap = height; + const heightScale = height / this.imageHeightOnMap; + const widthScale = width / this.imageWidthOnMap; + if (heightScale < widthScale) { + this.imageScale = heightScale; + this.widthOnMap = (this.heightOnMap * this.imageWidth) / this.imageHeight; + } else { + this.imageScale = widthScale; + this.heightOnMap = (this.widthOnMap * this.imageHeight) / this.imageWidth; + } + } + + private setCoordinates(coords: Coordinate[][]): void { + const geometry = this.polygonStyle.getGeometry(); + if (geometry && geometry instanceof Polygon) { + geometry.setCoordinates(coords); + const boundingBox = getBoundingBoxFromExtent(geometry.getExtent(), this.mapSize); + this.x = boundingBox.x; + this.y = boundingBox.y; + this.width = boundingBox.width; + this.height = boundingBox.height; + } + } + + private getGlyphData(): LayerImage { + return { + id: this.elementId, + x: this.x, + y: this.y, + width: this.width, + height: this.height, + glyph: this.glyphId, + z: this.zIndex, + }; } protected getStyle(feature: FeatureLike, resolution: number): Style | Array<Style> | void { const scale = this.minResolution / resolution; const getAnchorAndCoords = feature.get('getAnchorAndCoords'); let anchor = [0, 0]; - let coords = this.pointToProjection({ x: this.x, y: this.y }); + let coords = [this.x, this.y]; + const geometry = feature.getGeometry(); + if (geometry && geometry instanceof Polygon) { + const polygonExtent = geometry.getExtent(); + if (polygonExtent) { + coords = [polygonExtent[0], polygonExtent[3]]; + const width = Math.abs(polygonExtent[0] - polygonExtent[2]); + const height = Math.abs(polygonExtent[1] - polygonExtent[3]); + this.setImageScaleAndDimensions(height, width); + } + } else { + return []; + } if (getAnchorAndCoords instanceof Function) { - const anchorAndCoords = getAnchorAndCoords(); + const anchorAndCoords = getAnchorAndCoords(coords); anchor = anchorAndCoords.anchor; coords = anchorAndCoords.coords; } diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/layer/Layer.test.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/layer/Layer.test.ts index 1cdd1aef..9da93414 100644 --- a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/layer/Layer.test.ts +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/layer/Layer.test.ts @@ -23,6 +23,13 @@ jest.mock('../style/rgbToHex'); describe('Layer', () => { let props: LayerProps; + const mapSize = { + width: 90, + height: 90, + tileSize: 256, + minZoom: 2, + maxZoom: 9, + }; beforeEach(() => { const dummyElement = document.createElement('div'); @@ -128,6 +135,7 @@ describe('Layer', () => { layerId: 23, pointToProjection: jest.fn(point => [point.x, point.y]), mapInstance, + mapSize, lineTypes: {}, arrowTypes: {}, }; diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/layer/Layer.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/layer/Layer.ts index e48faf93..65c6f613 100644 --- a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/layer/Layer.ts +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/layer/Layer.ts @@ -27,6 +27,7 @@ import { import getScaledElementStyle from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/style/getScaledElementStyle'; import { Stroke } from 'ol/style'; import Glyph from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/Glyph'; +import { MapSize } from '@/redux/map/map.types'; export interface LayerProps { texts: Array<LayerText>; @@ -39,10 +40,13 @@ export interface LayerProps { lineTypes: LineTypeDict; arrowTypes: ArrowTypeDict; mapInstance: MapInstance; + mapSize: MapSize; pointToProjection: UsePointToProjectionResult; } export default class Layer { + layerId: number; + texts: Array<LayerText>; rects: Array<LayerRect>; @@ -61,6 +65,8 @@ export default class Layer { mapInstance: MapInstance; + mapSize: MapSize; + vectorSource: VectorSource< Feature<Point> | Feature<Polygon> | Feature<LineString> | Feature<MultiPolygon> >; @@ -80,6 +86,7 @@ export default class Layer { lineTypes, arrowTypes, mapInstance, + mapSize, pointToProjection, }: LayerProps) { this.vectorSource = new VectorSource({}); @@ -93,11 +100,14 @@ export default class Layer { this.arrowTypes = arrowTypes; this.pointToProjection = pointToProjection; this.mapInstance = mapInstance; + this.mapSize = mapSize; + this.layerId = layerId; this.vectorSource.addFeatures(this.getTextsFeatures()); this.vectorSource.addFeatures(this.getRectsFeatures()); this.vectorSource.addFeatures(this.getOvalsFeatures()); - this.drawImages(); + const imagesFeatures = this.getImagesFeatures(); + this.vectorSource.addFeatures(imagesFeatures); const { linesFeatures, arrowsFeatures } = this.getLinesFeatures(); this.vectorSource.addFeatures(linesFeatures); @@ -111,6 +121,7 @@ export default class Layer { }); this.vectorLayer.set('id', layerId); + this.vectorLayer.set('imagesFeatures', imagesFeatures); this.vectorLayer.set('drawImage', this.drawImage.bind(this)); } @@ -290,13 +301,22 @@ export default class Layer { return { linesFeatures, arrowsFeatures }; }; - private drawImages(): void { - Object.values(this.images).forEach(image => { - this.drawImage(image); + private getImagesFeatures(): Feature<Polygon>[] { + return Object.values(this.images).map(image => { + return this.getGlyphFeature(image); }); } private drawImage(image: LayerImage): void { + const glyphFeature = this.getGlyphFeature(image); + const imagesFeatures = this.vectorLayer.get('imagesFeatures'); + if (imagesFeatures && Array.isArray(imagesFeatures)) { + imagesFeatures.push(glyphFeature); + } + this.vectorSource.addFeature(glyphFeature); + } + + private getGlyphFeature(image: LayerImage): Feature<Polygon> { const glyph = new Glyph({ elementId: image.id, glyphId: image.glyph, @@ -307,8 +327,9 @@ export default class Layer { zIndex: image.z, pointToProjection: this.pointToProjection, mapInstance: this.mapInstance, + mapSize: this.mapSize, }); - this.vectorSource.addFeature(glyph.feature); + return glyph.feature; } protected getStyle(feature: FeatureLike, resolution: number): Style | Array<Style> | void { diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/layer/getDrawImageInteraction.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/layer/getDrawImageInteraction.ts index 2b8d42c4..92580108 100644 --- a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/layer/getDrawImageInteraction.ts +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/layer/getDrawImageInteraction.ts @@ -2,12 +2,11 @@ import Draw from 'ol/interaction/Draw'; import SimpleGeometry from 'ol/geom/SimpleGeometry'; import Polygon from 'ol/geom/Polygon'; -import { toLonLat } from 'ol/proj'; -import { latLngToPoint } from '@/utils/map/latLngToPoint'; import { MapSize } from '@/redux/map/map.types'; import { AppDispatch } from '@/redux/store'; import { Coordinate } from 'ol/coordinate'; import { openLayerImageObjectFactoryModal } from '@/redux/modal/modal.slice'; +import getBoundingBoxFromExtent from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/coords/getBoundingBoxFromExtent'; export default function getDrawImageInteraction(mapSize: MapSize, dispatch: AppDispatch): Draw { const drawImageInteraction = new Draw({ @@ -44,22 +43,9 @@ export default function getDrawImageInteraction(mapSize: MapSize, dispatch: AppD const geometry = event.feature.getGeometry() as Polygon; const extent = geometry.getExtent(); - const [startLng, startLat] = toLonLat([extent[0], extent[3]]); - const startPoint = latLngToPoint([startLat, startLng], mapSize); - const [endLng, endLat] = toLonLat([extent[2], extent[1]]); - const endPoint = latLngToPoint([endLat, endLng], mapSize); + const boundingBox = getBoundingBoxFromExtent(extent, mapSize); - const width = Math.abs(endPoint.x - startPoint.x); - const height = Math.abs(endPoint.y - startPoint.y); - - dispatch( - openLayerImageObjectFactoryModal({ - x: startPoint.x, - y: startPoint.y, - width, - height, - }), - ); + dispatch(openLayerImageObjectFactoryModal(boundingBox)); }); return drawImageInteraction; diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/layer/getTransformImageInteraction.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/layer/getTransformImageInteraction.ts new file mode 100644 index 00000000..752eb905 --- /dev/null +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/layer/getTransformImageInteraction.ts @@ -0,0 +1,59 @@ +/* eslint-disable no-magic-numbers */ +import Polygon from 'ol/geom/Polygon'; +import { AppDispatch } from '@/redux/store'; +import Transform from 'ol-ext/interaction/Transform'; +import { Geometry } from 'ol/geom'; +import { Collection, Feature } from 'ol'; +import BaseEvent from 'ol/events/Event'; +import { updateLayerImageObject } from '@/redux/layers/layers.thunks'; +import { layerUpdateImage } from '@/redux/layers/layers.slice'; +import getBoundingBoxFromExtent from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/coords/getBoundingBoxFromExtent'; +import { MapSize } from '@/redux/map/map.types'; + +export default function getTransformImageInteraction( + dispatch: AppDispatch, + mapSize: MapSize, + modelId: number, + activeLayer: number, + featuresCollection: Collection<Feature<Geometry>>, +): Transform { + const transform = new Transform({ + features: featuresCollection, + scale: true, + rotate: false, + stretch: false, + keepRectangle: true, + translate: true, + }); + + transform.on(['scaleend', 'translateend'], async (event: BaseEvent | Event): Promise<void> => { + const transformEvent = event as unknown as { feature: Feature }; + const { feature } = transformEvent; + const setCoordinates = feature.get('setCoordinates'); + const getGlyphData = feature.get('getGlyphData'); + const reset = feature.get('reset'); + const geometry = feature.getGeometry(); + if (geometry && getGlyphData instanceof Function) { + const glyphData = getGlyphData(); + try { + const boundingBox = getBoundingBoxFromExtent(geometry.getExtent(), mapSize); + const layerImage = await dispatch( + updateLayerImageObject({ modelId, layerId: activeLayer, ...glyphData, ...boundingBox }), + ).unwrap(); + if (layerImage) { + dispatch(layerUpdateImage({ modelId, layerId: activeLayer, layerImage })); + } + if (geometry instanceof Polygon && setCoordinates instanceof Function) { + setCoordinates(geometry.getCoordinates()); + geometry.changed(); + } + } catch { + if (reset instanceof Function) { + reset(); + } + } + } + }); + + return transform; +} diff --git a/src/redux/apiPath.ts b/src/redux/apiPath.ts index 1def76d9..13db481d 100644 --- a/src/redux/apiPath.ts +++ b/src/redux/apiPath.ts @@ -67,6 +67,8 @@ export const apiPath = { `projects/${PROJECT_ID}/maps/${modelId}/layers/${layerId}`, addLayerImageObject: (modelId: number, layerId: number): string => `projects/${PROJECT_ID}/maps/${modelId}/layers/${layerId}/images/`, + updateLayerImageObject: (modelId: number, layerId: number, imageId: number): string => + `projects/${PROJECT_ID}/maps/${modelId}/layers/${layerId}/images/${imageId}`, getLayer: (modelId: number, layerId: number): string => `projects/${PROJECT_ID}/maps/${modelId}/layers/${layerId}`, getGlyphImage: (glyphId: number): string => diff --git a/src/redux/layers/layers.reducers.ts b/src/redux/layers/layers.reducers.ts index 9e41f8de..f5718ad4 100644 --- a/src/redux/layers/layers.reducers.ts +++ b/src/redux/layers/layers.reducers.ts @@ -79,3 +79,19 @@ export const layerAddImageReducer = ( } layer.images[layerImage.id] = layerImage; }; + +export const layerUpdateImageReducer = ( + state: LayersState, + action: PayloadAction<{ modelId: number; layerId: number; layerImage: LayerImage }>, +): void => { + const { modelId, layerId, layerImage } = action.payload; + const { data } = state[modelId]; + if (!data) { + return; + } + const layer = data.layers.find(layerState => layerState.details.id === layerId); + if (!layer) { + return; + } + layer.images[layerImage.id] = layerImage; +}; diff --git a/src/redux/layers/layers.slice.ts b/src/redux/layers/layers.slice.ts index 9f78f0dd..9e4ea3cd 100644 --- a/src/redux/layers/layers.slice.ts +++ b/src/redux/layers/layers.slice.ts @@ -3,6 +3,7 @@ import { LAYERS_STATE_INITIAL_MOCK } from '@/redux/layers/layers.mock'; import { getLayersForModelReducer, layerAddImageReducer, + layerUpdateImageReducer, setActiveLayerReducer, setLayerVisibilityReducer, } from '@/redux/layers/layers.reducers'; @@ -14,12 +15,14 @@ export const layersSlice = createSlice({ setLayerVisibility: setLayerVisibilityReducer, setActiveLayer: setActiveLayerReducer, layerAddImage: layerAddImageReducer, + layerUpdateImage: layerUpdateImageReducer, }, extraReducers: builder => { getLayersForModelReducer(builder); }, }); -export const { setLayerVisibility, setActiveLayer, layerAddImage } = layersSlice.actions; +export const { setLayerVisibility, setActiveLayer, layerAddImage, layerUpdateImage } = + layersSlice.actions; export default layersSlice.reducer; diff --git a/src/redux/layers/layers.thunks.ts b/src/redux/layers/layers.thunks.ts index f3f78c43..3de3990d 100644 --- a/src/redux/layers/layers.thunks.ts +++ b/src/redux/layers/layers.thunks.ts @@ -182,3 +182,44 @@ export const addLayerImageObject = createAsyncThunk< return Promise.reject(getError({ error })); } }); + +export const updateLayerImageObject = createAsyncThunk< + LayerImage | null, + { + modelId: number; + layerId: number; + id: number; + x: number; + y: number; + z: number; + width: number; + height: number; + glyph: number | null; + }, + ThunkConfig + // eslint-disable-next-line consistent-return +>( + 'vectorMap/updateLayerImageObject', + async ({ modelId, layerId, id, x, y, z, width, height, glyph }) => { + try { + const { data } = await axiosInstanceNewAPI.put<LayerImage>( + apiPath.updateLayerImageObject(modelId, layerId, id), + { + x, + y, + z, + width, + height, + glyph, + }, + ); + const isDataValid = validateDataUsingZodSchema(data, layerImageSchema); + if (isDataValid) { + return data; + } + return null; + } catch (error) { + return Promise.reject(getError({ error })); + } + }, +); diff --git a/src/redux/mapEditTools/mapEditTools.constants.ts b/src/redux/mapEditTools/mapEditTools.constants.ts index 3f54d2b0..2524c492 100644 --- a/src/redux/mapEditTools/mapEditTools.constants.ts +++ b/src/redux/mapEditTools/mapEditTools.constants.ts @@ -1,3 +1,4 @@ export const MAP_EDIT_ACTIONS = { DRAW_IMAGE: 'DRAW_IMAGE', + TRANSFORM_IMAGE: 'TRANSFORM_IMAGE', } as const; diff --git a/src/shared/Icon/Icon.component.tsx b/src/shared/Icon/Icon.component.tsx index 784cfb0e..4d888fc2 100644 --- a/src/shared/Icon/Icon.component.tsx +++ b/src/shared/Icon/Icon.component.tsx @@ -19,6 +19,7 @@ import { PlusIcon } from '@/shared/Icon/Icons/PlusIcon'; import type { IconComponentType, IconTypes } from '@/types/iconTypes'; import { DownloadIcon } from '@/shared/Icon/Icons/DownloadIcon'; import { ImageIcon } from '@/shared/Icon/Icons/ImageIcon'; +import { ResizeImageIcon } from '@/shared/Icon/Icons/ResizeImageIcon'; import { LocationIcon } from './Icons/LocationIcon'; import { MaginfierZoomInIcon } from './Icons/MagnifierZoomIn'; import { MaginfierZoomOutIcon } from './Icons/MagnifierZoomOut'; @@ -61,6 +62,7 @@ const icons: Record<IconTypes, IconComponentType> = { user: UserIcon, 'manage-user': ManageUserIcon, image: ImageIcon, + 'resize-image': ResizeImageIcon, } as const; export const Icon = ({ name, className = '', ...rest }: IconProps): JSX.Element => { diff --git a/src/shared/Icon/Icons/ResizeImageIcon.tsx b/src/shared/Icon/Icons/ResizeImageIcon.tsx new file mode 100644 index 00000000..71a0c324 --- /dev/null +++ b/src/shared/Icon/Icons/ResizeImageIcon.tsx @@ -0,0 +1,19 @@ +interface ResizeImageIconProps { + className?: string; +} + +export const ResizeImageIcon = ({ className }: ResizeImageIconProps): JSX.Element => ( + <svg + width="24" + height="24" + viewBox="0 0 24 24" + fill="none" + className={className} + xmlns="http://www.w3.org/2000/svg" + > + <rect x="6" y="2" width="17" height="17" stroke="currentColor" strokeWidth="1.5" fill="none" /> + <rect x="1" y="14" width="9" height="9" stroke="currentColor" strokeWidth="1.5" fill="white" /> + <line x1="10" y1="14" x2="18" y2="7" stroke="currentColor" strokeWidth="1.5" /> + <polygon points="12,5 20,5 20,13" fill="currentColor" strokeWidth="1.5" /> + </svg> +); diff --git a/src/types/iconTypes.ts b/src/types/iconTypes.ts index 0ef11e99..469b7699 100644 --- a/src/types/iconTypes.ts +++ b/src/types/iconTypes.ts @@ -25,6 +25,7 @@ export type IconTypes = | 'manage-user' | 'download' | 'question' - | 'image'; + | 'image' + | 'resize-image'; export type IconComponentType = ({ className }: { className: string }) => JSX.Element; -- GitLab From a0ad825f9cddb6964c36943f489f5c96dd0a0919 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mi=C5=82osz=20Grocholewski?= <m.grocholewski@atcomp.pl> Date: Thu, 9 Jan 2025 09:12:39 +0100 Subject: [PATCH 19/22] feat(vector-map): add test for getBoundBoxFromExtent --- .../coords/getBoundingBoxFromExtent.test.ts | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 src/components/Map/MapViewer/MapViewerVector/utils/shapes/coords/getBoundingBoxFromExtent.test.ts diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/coords/getBoundingBoxFromExtent.test.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/coords/getBoundingBoxFromExtent.test.ts new file mode 100644 index 00000000..532c485d --- /dev/null +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/coords/getBoundingBoxFromExtent.test.ts @@ -0,0 +1,26 @@ +/* eslint-disable no-magic-numbers */ +import { Extent } from 'ol/extent'; +import getBoundingBoxFromExtent from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/coords/getBoundingBoxFromExtent'; + +describe('getBoundingBoxFromExtent', () => { + it('should return a bounding box for extent', () => { + const extent: Extent = [0, 195700, 195700, 0]; + const mapSize = { + width: 512, + height: 512, + tileSize: 256, + minZoom: 2, + maxZoom: 4, + }; + + const result = getBoundingBoxFromExtent(extent, mapSize); + + expect(result).toHaveProperty('x', 1024); + expect(result).toHaveProperty('y', 1024); + expect(result).toHaveProperty('width'); + expect(result).toHaveProperty('height'); + + expect(result.width).toBeCloseTo(10); + expect(result.height).toBeCloseTo(10); + }); +}); -- GitLab From 6f18fefb1f5af75234c49677f693ed582c1fb0e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mi=C5=82osz=20Grocholewski?= <m.grocholewski@atcomp.pl> Date: Thu, 9 Jan 2025 10:08:39 +0100 Subject: [PATCH 20/22] feat(vector-map): add test for getTransformImageInteraction --- .../getTransformImageInteraction.test.ts | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 src/components/Map/MapViewer/MapViewerVector/utils/shapes/layer/getTransformImageInteraction.test.ts diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/layer/getTransformImageInteraction.test.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/layer/getTransformImageInteraction.test.ts new file mode 100644 index 00000000..113870e2 --- /dev/null +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/layer/getTransformImageInteraction.test.ts @@ -0,0 +1,53 @@ +/* eslint-disable no-magic-numbers */ +import modalReducer from '@/redux/modal/modal.slice'; +import { MapSize } from '@/redux/map/map.types'; +import { + createStoreInstanceUsingSliceReducer, + ToolkitStoreWithSingleSlice, +} from '@/utils/createStoreInstanceUsingSliceReducer'; +import { ModalState } from '@/redux/modal/modal.types'; +import { DEFAULT_TILE_SIZE } from '@/constants/map'; +import { Collection, Feature } from 'ol'; +import getTransformImageInteraction from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/layer/getTransformImageInteraction'; +import Transform from 'ol-ext/interaction/Transform'; +import { Geometry } from 'ol/geom'; + +jest.mock('../../../../../../../utils/map/latLngToPoint', () => ({ + latLngToPoint: jest.fn(latLng => ({ x: latLng[0], y: latLng[1] })), +})); + +describe('getTransformImageInteraction', () => { + let store = {} as ToolkitStoreWithSingleSlice<ModalState>; + let modelIdMock: number; + let layerIdMock: number; + let featuresCollectionMock: Collection<Feature<Geometry>>; + const mockDispatch = jest.fn(() => {}); + + let mapSize: MapSize; + + beforeEach(() => { + mapSize = { + width: 800, + height: 600, + minZoom: 1, + maxZoom: 9, + tileSize: DEFAULT_TILE_SIZE, + }; + store = createStoreInstanceUsingSliceReducer('modal', modalReducer); + store.dispatch = mockDispatch; + modelIdMock = 1; + layerIdMock = 1; + featuresCollectionMock = new Collection<Feature<Geometry>>(); + }); + + it('returns a Transform interaction', () => { + const transformInteraction = getTransformImageInteraction( + store.dispatch, + mapSize, + modelIdMock, + layerIdMock, + featuresCollectionMock, + ); + expect(transformInteraction).toBeInstanceOf(Transform); + }); +}); -- GitLab From 19df32ddbc992dab7c50a906e324dc9501314dac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mi=C5=82osz=20Grocholewski?= <m.grocholewski@atcomp.pl> Date: Thu, 9 Jan 2025 14:52:37 +0100 Subject: [PATCH 21/22] feat(layer-image): add glyph editing for layer image objects --- .../LayerImageObjectFactoryModal/index.ts | 1 - ...eObjectEditFactoryModal.component.test.tsx | 173 ++++++++++++++++++ ...rImageObjectEditFactoryModal.component.tsx | 98 ++++++++++ ...ImageObjectFactoryModal.component.test.tsx | 0 ...LayerImageObjectFactoryModal.component.tsx | 108 ++--------- .../LayerImageObjectForm.component.tsx | 121 ++++++++++++ .../LayerImageObjectForm.styles.css} | 0 .../Modal/LayerImageObjectModal/index.ts | 2 + .../FunctionalArea/Modal/Modal.component.tsx | 10 +- .../ModalLayout/ModalLayout.component.tsx | 3 +- .../MapDrawActions.component.tsx | 7 +- .../MapDrawEditActions.component.tsx | 50 +++++ .../utils/shapes/elements/Glyph.ts | 45 +++-- .../layer/getTransformImageInteraction.ts | 15 ++ src/redux/mapEditTools/mapEditTools.mock.ts | 1 + .../mapEditTools/mapEditTools.reducers.ts | 8 + .../mapEditTools/mapEditTools.selectors.ts | 5 + src/redux/mapEditTools/mapEditTools.slice.ts | 8 +- src/redux/mapEditTools/mapEditTools.types.ts | 2 + src/redux/modal/modal.reducers.ts | 6 + src/redux/modal/modal.slice.ts | 3 + .../Autocomplete/Autocomplete.component.tsx | 15 +- src/shared/Icon/Icon.component.tsx | 6 + src/shared/Icon/Icons/EditImageIcon.tsx | 29 +++ src/shared/Icon/Icons/PencilIcon.tsx | 30 +++ src/shared/Icon/Icons/TrashIcon.tsx | 30 +++ src/types/iconTypes.ts | 5 +- src/types/modal.ts | 3 +- 28 files changed, 656 insertions(+), 128 deletions(-) delete mode 100644 src/components/FunctionalArea/Modal/LayerImageObjectFactoryModal/index.ts create mode 100644 src/components/FunctionalArea/Modal/LayerImageObjectModal/LayerImageObjectEditFactoryModal.component.test.tsx create mode 100644 src/components/FunctionalArea/Modal/LayerImageObjectModal/LayerImageObjectEditFactoryModal.component.tsx rename src/components/FunctionalArea/Modal/{LayerImageObjectFactoryModal => LayerImageObjectModal}/LayerImageObjectFactoryModal.component.test.tsx (100%) rename src/components/FunctionalArea/Modal/{LayerImageObjectFactoryModal => LayerImageObjectModal}/LayerImageObjectFactoryModal.component.tsx (51%) create mode 100644 src/components/FunctionalArea/Modal/LayerImageObjectModal/LayerImageObjectForm.component.tsx rename src/components/FunctionalArea/Modal/{LayerImageObjectFactoryModal/LayerImageObjectFactoryModal.styles.css => LayerImageObjectModal/LayerImageObjectForm.styles.css} (100%) create mode 100644 src/components/FunctionalArea/Modal/LayerImageObjectModal/index.ts create mode 100644 src/components/Map/MapDrawActions/MapDrawEditActions.component.tsx create mode 100644 src/shared/Icon/Icons/EditImageIcon.tsx create mode 100644 src/shared/Icon/Icons/PencilIcon.tsx create mode 100644 src/shared/Icon/Icons/TrashIcon.tsx diff --git a/src/components/FunctionalArea/Modal/LayerImageObjectFactoryModal/index.ts b/src/components/FunctionalArea/Modal/LayerImageObjectFactoryModal/index.ts deleted file mode 100644 index 11947806..00000000 --- a/src/components/FunctionalArea/Modal/LayerImageObjectFactoryModal/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { LayerImageObjectFactoryModal } from './LayerImageObjectFactoryModal.component'; diff --git a/src/components/FunctionalArea/Modal/LayerImageObjectModal/LayerImageObjectEditFactoryModal.component.test.tsx b/src/components/FunctionalArea/Modal/LayerImageObjectModal/LayerImageObjectEditFactoryModal.component.test.tsx new file mode 100644 index 00000000..8095d60e --- /dev/null +++ b/src/components/FunctionalArea/Modal/LayerImageObjectModal/LayerImageObjectEditFactoryModal.component.test.tsx @@ -0,0 +1,173 @@ +/* eslint-disable no-magic-numbers */ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { act } from 'react-dom/test-utils'; +import { StoreType } from '@/redux/store'; +import { getReduxWrapperWithStore } from '@/utils/testing/getReduxWrapperWithStore'; +import { INITIAL_STORE_STATE_MOCK } from '@/redux/root/root.fixtures'; +import { GLYPHS_STATE_INITIAL_MOCK } from '@/redux/glyphs/glyphs.mock'; +import { apiPath } from '@/redux/apiPath'; +import { HttpStatusCode } from 'axios'; +import { layerImageFixture } from '@/models/fixtures/layerImageFixture'; +import { mockNetworkNewAPIResponse } from '@/utils/mockNetworkResponse'; +import { + LAYER_STATE_DEFAULT_DATA, + LAYERS_STATE_INITIAL_LAYER_MOCK, +} from '@/redux/layers/layers.mock'; +import { MODELS_DATA_MOCK_WITH_MAIN_MAP } from '@/redux/models/models.mock'; +import { overlayFixture } from '@/models/fixtures/overlaysFixture'; +import { showToast } from '@/utils/showToast'; +import { MapEditToolsState } from '@/redux/mapEditTools/mapEditTools.types'; +import { MAP_EDIT_TOOLS_STATE_INITIAL_MOCK } from '@/redux/mapEditTools/mapEditTools.mock'; +import { MAP_EDIT_ACTIONS } from '@/redux/mapEditTools/mapEditTools.constants'; +import { Feature } from 'ol'; +import Polygon from 'ol/geom/Polygon'; +import { LayerImageObjectEditFactoryModal } from './LayerImageObjectEditFactoryModal.component'; + +const mockedAxiosNewClient = mockNetworkNewAPIResponse(); + +const glyph = { id: 1, file: 23, filename: 'Glyph1.png' }; + +jest.mock('../../../../utils/showToast'); + +const renderComponent = ( + initialMapEditToolsState: MapEditToolsState = MAP_EDIT_TOOLS_STATE_INITIAL_MOCK, +): { store: StoreType } => { + const { Wrapper, store } = getReduxWrapperWithStore({ + ...INITIAL_STORE_STATE_MOCK, + glyphs: { + ...GLYPHS_STATE_INITIAL_MOCK, + data: [glyph], + }, + layers: { + 0: { + ...LAYERS_STATE_INITIAL_LAYER_MOCK, + data: { + ...LAYER_STATE_DEFAULT_DATA, + activeLayer: 1, + }, + }, + }, + modal: { + isOpen: true, + modalTitle: overlayFixture.name, + modalName: 'edit-overlay', + editOverlayState: overlayFixture, + molArtState: {}, + overviewImagesState: {}, + errorReportState: {}, + layerFactoryState: { id: undefined }, + layerImageObjectFactoryState: { + x: 1, + y: 1, + width: 1, + height: 1, + }, + }, + models: { + ...MODELS_DATA_MOCK_WITH_MAIN_MAP, + }, + mapEditTools: initialMapEditToolsState, + }); + return { + store, + ...render( + <Wrapper> + <LayerImageObjectEditFactoryModal /> + </Wrapper>, + ), + }; +}; + +describe('LayerImageObjectEditFactoryModal - component', () => { + it('should render LayerImageObjectEditFactoryModal component with initial state', () => { + renderComponent(); + + expect(screen.getByText(/Glyph:/i)).toBeInTheDocument(); + expect(screen.getByText(/File:/i)).toBeInTheDocument(); + expect(screen.getByText(/Submit/i)).toBeInTheDocument(); + expect(screen.getByText(/No Image/i)).toBeInTheDocument(); + }); + + it('should display a list of glyphs in the dropdown', async () => { + renderComponent(); + + const dropdown = screen.getByTestId('autocomplete'); + if (!dropdown.firstChild) { + throw new Error('Dropdown does not have a firstChild'); + } + fireEvent.keyDown(dropdown.firstChild, { key: 'ArrowDown' }); + await waitFor(() => expect(screen.getByText(glyph.filename)).toBeInTheDocument()); + fireEvent.click(screen.getByText(glyph.filename)); + }); + + it('should update the selected glyph on dropdown change', async () => { + renderComponent(); + + const dropdown = screen.getByTestId('autocomplete'); + if (!dropdown.firstChild) { + throw new Error('Dropdown does not have a firstChild'); + } + fireEvent.keyDown(dropdown.firstChild, { key: 'ArrowDown' }); + await waitFor(() => expect(screen.getByText(glyph.filename)).toBeInTheDocument()); + fireEvent.click(screen.getByText(glyph.filename)); + + await waitFor(() => { + const imgPreview: HTMLImageElement = screen.getByTestId('layer-image-preview'); + const decodedSrc = decodeURIComponent(imgPreview.src); + expect(decodedSrc).toContain(`glyphs/${glyph.id}/fileContent`); + }); + }); + + it('should handle form submission correctly', async () => { + mockedAxiosNewClient + .onPut(apiPath.updateLayerImageObject(0, 1, 1)) + .reply(HttpStatusCode.Ok, layerImageFixture); + const geometry = new Polygon([ + [ + [10, 10], + [10, 10], + ], + ]); + const layerObjectFeature = new Feature({ geometry }); + const glyphData = { + id: 1, + x: 1, + y: 1, + width: 1, + height: 1, + glyph: 1, + z: 1, + }; + const getGlyphDataMock = jest.fn(() => glyphData); + jest.spyOn(layerObjectFeature, 'get').mockImplementation(key => { + if (key === 'setGlyph') return (): void => {}; + if (key === 'getGlyphData') return getGlyphDataMock; + return undefined; + }); + renderComponent({ + activeAction: MAP_EDIT_ACTIONS.TRANSFORM_IMAGE, + layerImageObject: glyphData, + }); + + const submitButton = screen.getByText(/Submit/i); + + await act(async () => { + fireEvent.click(submitButton); + }); + + expect(showToast).toHaveBeenCalledWith({ + message: 'The layer image object has been successfully updated', + type: 'success', + }); + }); + + it('should display "No Image" when there is no image file', () => { + const { store } = renderComponent(); + + store.dispatch({ + type: 'glyphs/clearGlyphData', + }); + + expect(screen.getByText(/No Image/i)).toBeInTheDocument(); + }); +}); diff --git a/src/components/FunctionalArea/Modal/LayerImageObjectModal/LayerImageObjectEditFactoryModal.component.tsx b/src/components/FunctionalArea/Modal/LayerImageObjectModal/LayerImageObjectEditFactoryModal.component.tsx new file mode 100644 index 00000000..589b3a14 --- /dev/null +++ b/src/components/FunctionalArea/Modal/LayerImageObjectModal/LayerImageObjectEditFactoryModal.component.tsx @@ -0,0 +1,98 @@ +/* eslint-disable no-magic-numbers */ +import React, { useState } from 'react'; +import { useAppSelector } from '@/redux/hooks/useAppSelector'; + +import { mapEditToolsLayerImageObjectSelector } from '@/redux/mapEditTools/mapEditTools.selectors'; +import { LayerImageObjectForm } from '@/components/FunctionalArea/Modal/LayerImageObjectModal/LayerImageObjectForm.component'; +import { currentModelIdSelector } from '@/redux/models/models.selectors'; +import { layersActiveLayerSelector } from '@/redux/layers/layers.selectors'; +import { addGlyph } from '@/redux/glyphs/glyphs.thunks'; +import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; +import { updateLayerImageObject } from '@/redux/layers/layers.thunks'; +import { layerUpdateImage } from '@/redux/layers/layers.slice'; +import { showToast } from '@/utils/showToast'; +import { closeModal } from '@/redux/modal/modal.slice'; +import { SerializedError } from '@reduxjs/toolkit'; +import { useMapInstance } from '@/utils/context/mapInstanceContext'; +import VectorSource from 'ol/source/Vector'; + +export const LayerImageObjectEditFactoryModal: React.FC = () => { + const layerImageObject = useAppSelector(mapEditToolsLayerImageObjectSelector); + const { mapInstance } = useMapInstance(); + + const currentModelId = useAppSelector(currentModelIdSelector); + const activeLayer = useAppSelector(layersActiveLayerSelector); + const dispatch = useAppDispatch(); + + const [selectedGlyph, setSelectedGlyph] = useState<number | null>( + layerImageObject?.glyph || null, + ); + const [file, setFile] = useState<File | null>(null); + const [isSending, setIsSending] = useState<boolean>(false); + + const handleSubmit = async (): Promise<void> => { + if (!layerImageObject || !activeLayer) { + return; + } + setIsSending(true); + + try { + let glyphId = selectedGlyph; + if (file) { + const data = await dispatch(addGlyph(file)).unwrap(); + if (!data) { + return; + } + glyphId = data.id; + } + const layerImage = await dispatch( + updateLayerImageObject({ + modelId: currentModelId, + layerId: activeLayer, + ...layerImageObject, + glyph: glyphId, + }), + ).unwrap(); + if (layerImage) { + dispatch(layerUpdateImage({ modelId: currentModelId, layerId: activeLayer, layerImage })); + mapInstance?.getAllLayers().forEach(layer => { + if (layer.get('id') === activeLayer) { + const source = layer.getSource(); + if (source instanceof VectorSource) { + const feature = source.getFeatureById(layerImage.id); + const setGlyph = feature?.get('setGlyph'); + if (setGlyph && setGlyph instanceof Function) { + setGlyph(layerImage.glyph); + feature.changed(); + } + } + } + }); + } + showToast({ + type: 'success', + message: 'The layer image object has been successfully updated', + }); + dispatch(closeModal()); + } catch (error) { + const typedError = error as SerializedError; + showToast({ + type: 'error', + message: typedError.message || 'An error occurred while adding a new image object', + }); + } finally { + setIsSending(false); + } + }; + + return ( + <LayerImageObjectForm + file={file} + selectedGlyph={selectedGlyph} + isSending={isSending} + onSubmit={handleSubmit} + setFile={setFile} + setSelectedGlyph={setSelectedGlyph} + /> + ); +}; diff --git a/src/components/FunctionalArea/Modal/LayerImageObjectFactoryModal/LayerImageObjectFactoryModal.component.test.tsx b/src/components/FunctionalArea/Modal/LayerImageObjectModal/LayerImageObjectFactoryModal.component.test.tsx similarity index 100% rename from src/components/FunctionalArea/Modal/LayerImageObjectFactoryModal/LayerImageObjectFactoryModal.component.test.tsx rename to src/components/FunctionalArea/Modal/LayerImageObjectModal/LayerImageObjectFactoryModal.component.test.tsx diff --git a/src/components/FunctionalArea/Modal/LayerImageObjectFactoryModal/LayerImageObjectFactoryModal.component.tsx b/src/components/FunctionalArea/Modal/LayerImageObjectModal/LayerImageObjectFactoryModal.component.tsx similarity index 51% rename from src/components/FunctionalArea/Modal/LayerImageObjectFactoryModal/LayerImageObjectFactoryModal.component.tsx rename to src/components/FunctionalArea/Modal/LayerImageObjectModal/LayerImageObjectFactoryModal.component.tsx index b750d061..98bc5fc5 100644 --- a/src/components/FunctionalArea/Modal/LayerImageObjectFactoryModal/LayerImageObjectFactoryModal.component.tsx +++ b/src/components/FunctionalArea/Modal/LayerImageObjectModal/LayerImageObjectFactoryModal.component.tsx @@ -1,14 +1,7 @@ /* eslint-disable no-magic-numbers */ -import React, { useState, useRef } from 'react'; -import { glyphsDataSelector } from '@/redux/glyphs/glyphs.selectors'; +import React, { useState } from 'react'; import { useAppSelector } from '@/redux/hooks/useAppSelector'; import { layerImageObjectFactoryStateSelector } from '@/redux/modal/modal.selector'; -import { Button } from '@/shared/Button'; -import { BASE_NEW_API_URL } from '@/constants'; -import { apiPath } from '@/redux/apiPath'; -import { Input } from '@/shared/Input'; -import Image from 'next/image'; -import { Glyph } from '@/types/models'; import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; import { currentModelIdSelector } from '@/redux/models/models.selectors'; import { highestZIndexSelector, layersActiveLayerSelector } from '@/redux/layers/layers.selectors'; @@ -17,57 +10,22 @@ import { addGlyph } from '@/redux/glyphs/glyphs.thunks'; import { SerializedError } from '@reduxjs/toolkit'; import { showToast } from '@/utils/showToast'; import { closeModal } from '@/redux/modal/modal.slice'; -import { LoadingIndicator } from '@/shared/LoadingIndicator'; -import './LayerImageObjectFactoryModal.styles.css'; +import './LayerImageObjectForm.styles.css'; import { useMapInstance } from '@/utils/context/mapInstanceContext'; import { layerAddImage } from '@/redux/layers/layers.slice'; -import { Autocomplete } from '@/shared/Autocomplete'; +import { LayerImageObjectForm } from '@/components/FunctionalArea/Modal/LayerImageObjectModal/LayerImageObjectForm.component'; export const LayerImageObjectFactoryModal: React.FC = () => { - const glyphs: Glyph[] = useAppSelector(glyphsDataSelector); const currentModelId = useAppSelector(currentModelIdSelector); const activeLayer = useAppSelector(layersActiveLayerSelector); const layerImageObjectFactoryState = useAppSelector(layerImageObjectFactoryStateSelector); const dispatch = useAppDispatch(); - const fileInputRef = useRef<HTMLInputElement>(null); const highestZIndex = useAppSelector(highestZIndexSelector); const { mapInstance } = useMapInstance(); const [selectedGlyph, setSelectedGlyph] = useState<number | null>(null); const [file, setFile] = useState<File | null>(null); const [isSending, setIsSending] = useState<boolean>(false); - const [previewUrl, setPreviewUrl] = useState<string | null>(null); - - const handleGlyphChange = (glyph: Glyph | null): void => { - const glyphId = glyph?.id || null; - setSelectedGlyph(glyphId); - if (!glyphId) { - return; - } - setFile(null); - setPreviewUrl(`${BASE_NEW_API_URL}${apiPath.getGlyphImage(glyphId)}`); - - if (fileInputRef.current) { - fileInputRef.current.value = ''; - } - }; - - const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>): void => { - const uploadedFile = e.target.files?.[0] || null; - - setFile(uploadedFile); - if (!uploadedFile) { - return; - } - - setSelectedGlyph(null); - if (uploadedFile) { - const url = URL.createObjectURL(uploadedFile); - setPreviewUrl(url); - } else { - setPreviewUrl(null); - } - }; const handleSubmit = async (): Promise<void> => { if (!layerImageObjectFactoryState || !activeLayer) { @@ -130,57 +88,13 @@ export const LayerImageObjectFactoryModal: React.FC = () => { }; return ( - <div className="relative w-[800px] border border-t-[#E1E0E6] bg-white p-[24px]"> - {isSending && ( - <div className="c-layer-image-object-factory-loader"> - <LoadingIndicator width={44} height={44} /> - </div> - )} - <div className="grid grid-cols-2 gap-2"> - <div className="mb-4 flex flex-col gap-2"> - <span>Glyph:</span> - <Autocomplete<Glyph> - options={glyphs} - valueKey="id" - labelKey="filename" - onChange={handleGlyphChange} - /> - </div> - <div className="mb-4 flex flex-col gap-2"> - <span>File:</span> - <Input - ref={fileInputRef} - type="file" - accept="image/*" - onChange={handleFileChange} - data-testid="image-file-input" - className="w-full border border-[#ccc] bg-white p-2" - /> - </div> - </div> - - <div className="relative mb-4 flex h-[350px] w-full items-center justify-center overflow-hidden rounded border"> - {previewUrl ? ( - <Image - src={previewUrl} - alt="image preview" - fill - style={{ objectFit: 'contain' }} - className="rounded" - data-testid="layer-image-preview" - /> - ) : ( - <div className="text-gray-500">No Image</div> - )} - </div> - - <Button - type="button" - onClick={handleSubmit} - className="w-full justify-center text-base font-medium" - > - Submit - </Button> - </div> + <LayerImageObjectForm + file={file} + selectedGlyph={selectedGlyph} + isSending={isSending} + onSubmit={handleSubmit} + setFile={setFile} + setSelectedGlyph={setSelectedGlyph} + /> ); }; diff --git a/src/components/FunctionalArea/Modal/LayerImageObjectModal/LayerImageObjectForm.component.tsx b/src/components/FunctionalArea/Modal/LayerImageObjectModal/LayerImageObjectForm.component.tsx new file mode 100644 index 00000000..58a6efac --- /dev/null +++ b/src/components/FunctionalArea/Modal/LayerImageObjectModal/LayerImageObjectForm.component.tsx @@ -0,0 +1,121 @@ +/* eslint-disable no-magic-numbers */ +import React, { useRef, useMemo } from 'react'; +import { glyphsDataSelector } from '@/redux/glyphs/glyphs.selectors'; +import { useAppSelector } from '@/redux/hooks/useAppSelector'; +import { Button } from '@/shared/Button'; +import { BASE_NEW_API_URL } from '@/constants'; +import { apiPath } from '@/redux/apiPath'; +import { Input } from '@/shared/Input'; +import Image from 'next/image'; +import { Glyph } from '@/types/models'; +import { LoadingIndicator } from '@/shared/LoadingIndicator'; +import './LayerImageObjectForm.styles.css'; +import { Autocomplete } from '@/shared/Autocomplete'; + +type LayerImageObjectFormProps = { + onSubmit: () => void; + isSending: boolean; + selectedGlyph: number | null; + setSelectedGlyph: (glyphId: number | null) => void; + file: File | null; + setFile: (file: File | null) => void; +}; + +export const LayerImageObjectForm = ({ + onSubmit, + isSending, + selectedGlyph, + setSelectedGlyph, + file, + setFile, +}: LayerImageObjectFormProps): React.JSX.Element => { + const glyphs: Glyph[] = useAppSelector(glyphsDataSelector); + const fileInputRef = useRef<HTMLInputElement>(null); + const initialSelectedGlyph = glyphs.find(glyph => glyph.id === selectedGlyph); + + const previewUrl: string | null = useMemo(() => { + if (selectedGlyph) { + return `${BASE_NEW_API_URL}${apiPath.getGlyphImage(selectedGlyph)}`; + } + if (file) { + return URL.createObjectURL(file); + } + return null; + }, [file, selectedGlyph]); + + const handleGlyphChange = (glyph: Glyph | null): void => { + const glyphId = glyph?.id || null; + setSelectedGlyph(glyphId); + if (!glyphId) { + return; + } + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + }; + + const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>): void => { + const uploadedFile = e.target.files?.[0] || null; + setFile(uploadedFile); + if (!uploadedFile) { + return; + } + setSelectedGlyph(null); + }; + + return ( + <div className="relative w-[800px] border border-t-[#E1E0E6] bg-white p-[24px]"> + {isSending && ( + <div className="c-layer-image-object-factory-loader"> + <LoadingIndicator width={44} height={44} /> + </div> + )} + <div className="grid grid-cols-2 gap-2"> + <div className="mb-4 flex flex-col gap-2"> + <span>Glyph:</span> + <Autocomplete<Glyph> + options={glyphs} + initialValue={initialSelectedGlyph} + valueKey="id" + labelKey="filename" + onChange={handleGlyphChange} + /> + </div> + <div className="mb-4 flex flex-col gap-2"> + <span>File:</span> + <Input + ref={fileInputRef} + type="file" + accept="image/*" + onChange={handleFileChange} + data-testid="image-file-input" + className="w-full border border-[#ccc] bg-white p-2" + /> + </div> + </div> + + <div className="relative mb-4 flex h-[350px] w-full items-center justify-center overflow-hidden rounded border"> + {previewUrl ? ( + <Image + src={previewUrl} + alt="image preview" + fill + style={{ objectFit: 'contain' }} + className="rounded" + data-testid="layer-image-preview" + /> + ) : ( + <div className="text-gray-500">No Image</div> + )} + </div> + + <Button + type="button" + onClick={onSubmit} + className="w-full justify-center text-base font-medium" + > + Submit + </Button> + </div> + ); +}; diff --git a/src/components/FunctionalArea/Modal/LayerImageObjectFactoryModal/LayerImageObjectFactoryModal.styles.css b/src/components/FunctionalArea/Modal/LayerImageObjectModal/LayerImageObjectForm.styles.css similarity index 100% rename from src/components/FunctionalArea/Modal/LayerImageObjectFactoryModal/LayerImageObjectFactoryModal.styles.css rename to src/components/FunctionalArea/Modal/LayerImageObjectModal/LayerImageObjectForm.styles.css diff --git a/src/components/FunctionalArea/Modal/LayerImageObjectModal/index.ts b/src/components/FunctionalArea/Modal/LayerImageObjectModal/index.ts new file mode 100644 index 00000000..7a355330 --- /dev/null +++ b/src/components/FunctionalArea/Modal/LayerImageObjectModal/index.ts @@ -0,0 +1,2 @@ +export { LayerImageObjectFactoryModal } from './LayerImageObjectFactoryModal.component'; +export { LayerImageObjectEditFactoryModal } from './LayerImageObjectEditFactoryModal.component'; diff --git a/src/components/FunctionalArea/Modal/Modal.component.tsx b/src/components/FunctionalArea/Modal/Modal.component.tsx index feec78d9..79a6ac8d 100644 --- a/src/components/FunctionalArea/Modal/Modal.component.tsx +++ b/src/components/FunctionalArea/Modal/Modal.component.tsx @@ -5,7 +5,10 @@ import { AccessDeniedModal } from '@/components/FunctionalArea/Modal/AccessDenie import { AddCommentModal } from '@/components/FunctionalArea/Modal/AddCommentModal/AddCommentModal.component'; import { LicenseModal } from '@/components/FunctionalArea/Modal/LicenseModal'; import { ToSModal } from '@/components/FunctionalArea/Modal/ToSModal/ToSModal.component'; -import { LayerImageObjectFactoryModal } from '@/components/FunctionalArea/Modal/LayerImageObjectFactoryModal'; +import { + LayerImageObjectEditFactoryModal, + LayerImageObjectFactoryModal, +} from '@/components/FunctionalArea/Modal/LayerImageObjectModal'; import { EditOverlayModal } from './EditOverlayModal'; import { LoginModal } from './LoginModal'; import { ErrorReportModal } from './ErrorReportModal'; @@ -91,6 +94,11 @@ export const Modal = (): React.ReactNode => { <LayerImageObjectFactoryModal /> </ModalLayout> )} + {isOpen && modalName === 'layer-image-object-edit-factory' && ( + <ModalLayout> + <LayerImageObjectEditFactoryModal /> + </ModalLayout> + )} </> ); }; diff --git a/src/components/FunctionalArea/Modal/ModalLayout/ModalLayout.component.tsx b/src/components/FunctionalArea/Modal/ModalLayout/ModalLayout.component.tsx index 64816f0b..56e3afbc 100644 --- a/src/components/FunctionalArea/Modal/ModalLayout/ModalLayout.component.tsx +++ b/src/components/FunctionalArea/Modal/ModalLayout/ModalLayout.component.tsx @@ -34,7 +34,8 @@ export const ModalLayout = ({ children }: ModalLayoutProps): JSX.Element => { modalName === 'add-comment' && 'h-auto w-[400px]', modalName === 'error-report' && 'h-auto w-[800px]', modalName === 'layer-factory' && 'h-auto w-[400px]', - modalName === 'layer-image-object-factory' && 'h-auto w-[800px]', + ['layer-image-object-factory', 'layer-image-object-edit-factory'].includes(modalName) && + 'h-auto w-[800px]', ['edit-overlay', 'logged-in-menu'].includes(modalName) && 'h-auto w-[432px]', )} > diff --git a/src/components/Map/MapDrawActions/MapDrawActions.component.tsx b/src/components/Map/MapDrawActions/MapDrawActions.component.tsx index 69110b76..a4a02240 100644 --- a/src/components/Map/MapDrawActions/MapDrawActions.component.tsx +++ b/src/components/Map/MapDrawActions/MapDrawActions.component.tsx @@ -5,6 +5,7 @@ import { useAppSelector } from '@/redux/hooks/useAppSelector'; import { mapEditToolsActiveActionSelector } from '@/redux/mapEditTools/mapEditTools.selectors'; import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; import { MapDrawActionsButton } from '@/components/Map/MapDrawActions/MapDrawActionsButton.component'; +import { MapDrawEditActionsComponent } from '@/components/Map/MapDrawActions/MapDrawEditActions.component'; export const MapDrawActions = (): React.JSX.Element => { const activeAction = useAppSelector(mapEditToolsActiveActionSelector); @@ -15,18 +16,16 @@ export const MapDrawActions = (): React.JSX.Element => { }; return ( - <div className="absolute right-6 top-[calc(64px+40px+144px)] z-10 flex flex-col gap-4"> + <div className="absolute right-6 top-[calc(64px+40px+144px)] z-10 flex flex-col items-end gap-4"> <MapDrawActionsButton isActive={activeAction === MAP_EDIT_ACTIONS.DRAW_IMAGE} toggleMapEditAction={() => toggleMapEditAction(MAP_EDIT_ACTIONS.DRAW_IMAGE)} icon="image" title="Draw image" /> - <MapDrawActionsButton + <MapDrawEditActionsComponent isActive={activeAction === MAP_EDIT_ACTIONS.TRANSFORM_IMAGE} toggleMapEditAction={() => toggleMapEditAction(MAP_EDIT_ACTIONS.TRANSFORM_IMAGE)} - icon="resize-image" - title="Transform image" /> </div> ); diff --git a/src/components/Map/MapDrawActions/MapDrawEditActions.component.tsx b/src/components/Map/MapDrawActions/MapDrawEditActions.component.tsx new file mode 100644 index 00000000..153e93ad --- /dev/null +++ b/src/components/Map/MapDrawActions/MapDrawEditActions.component.tsx @@ -0,0 +1,50 @@ +/* eslint-disable no-magic-numbers */ +import { useAppSelector } from '@/redux/hooks/useAppSelector'; +import { mapEditToolsLayerImageObjectSelector } from '@/redux/mapEditTools/mapEditTools.selectors'; +import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; +import { MapDrawActionsButton } from '@/components/Map/MapDrawActions/MapDrawActionsButton.component'; +import { openLayerImageObjectEditFactoryModal } from '@/redux/modal/modal.slice'; + +type MapDrawEditActionsComponentProps = { + toggleMapEditAction: () => void; + isActive: boolean; +}; + +export const MapDrawEditActionsComponent = ({ + toggleMapEditAction, + isActive, +}: MapDrawEditActionsComponentProps): React.JSX.Element => { + const layerImageObject = useAppSelector(mapEditToolsLayerImageObjectSelector); + const dispatch = useAppDispatch(); + + const editMapObject = (): void => { + dispatch(openLayerImageObjectEditFactoryModal()); + }; + + return ( + <div className="flex flex-row-reverse gap-4"> + <MapDrawActionsButton + isActive={isActive} + toggleMapEditAction={toggleMapEditAction} + icon="pencil" + title="Edit image" + /> + {layerImageObject && ( + <> + <MapDrawActionsButton + isActive={false} + toggleMapEditAction={() => editMapObject()} + icon="edit-image" + title="Edit image" + /> + <MapDrawActionsButton + isActive={false} + toggleMapEditAction={() => {}} + icon="trash" + title="Remove image" + /> + </> + )} + </div> + ); +}; diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/Glyph.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/Glyph.ts index 53f71dc9..f2046aeb 100644 --- a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/Glyph.ts +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/Glyph.ts @@ -155,30 +155,14 @@ export default class Glyph { this.feature.set('setCoordinates', this.setCoordinates.bind(this)); this.feature.set('getGlyphData', this.getGlyphData.bind(this)); this.feature.set('reset', this.reset.bind(this)); + this.feature.set('setGlyph', this.setGlyph.bind(this)); + this.feature.setId(this.elementId); this.feature.setStyle(this.getStyle.bind(this)); if (!this.glyphId) { return; } - const img = new Image(); - img.onload = (): void => { - this.imageWidth = img.naturalWidth; - this.imageHeight = img.naturalHeight; - const imagePoint1 = this.pointToProjection({ x: 0, y: 0 }); - const imagePoint2 = this.pointToProjection({ x: this.imageWidth, y: this.imageHeight }); - this.imageWidthOnMap = Math.abs(imagePoint2[0] - imagePoint1[0]); - this.imageHeightOnMap = Math.abs(imagePoint2[1] - imagePoint1[1]); - this.setImageScaleAndDimensions(this.heightOnMap, this.widthOnMap); - this.style = new Style({ - image: new Icon({ - anchor: [0, 0], - img, - size: [this.imageWidth, this.imageHeight], - }), - zIndex: this.zIndex, - }); - }; - img.src = `${BASE_NEW_API_URL}${apiPath.getGlyphImage(this.glyphId)}`; + this.setGlyph(this.glyphId); } private drawPolygon(): void { @@ -225,6 +209,29 @@ export default class Glyph { } } + private setGlyph(glyph: number): void { + const img = new Image(); + img.onload = (): void => { + this.imageWidth = img.naturalWidth; + this.imageHeight = img.naturalHeight; + const imagePoint1 = this.pointToProjection({ x: 0, y: 0 }); + const imagePoint2 = this.pointToProjection({ x: this.imageWidth, y: this.imageHeight }); + this.imageWidthOnMap = Math.abs(imagePoint2[0] - imagePoint1[0]); + this.imageHeightOnMap = Math.abs(imagePoint2[1] - imagePoint1[1]); + this.setImageScaleAndDimensions(this.heightOnMap, this.widthOnMap); + this.style = new Style({ + image: new Icon({ + anchor: [0, 0], + img, + size: [this.imageWidth, this.imageHeight], + }), + zIndex: this.zIndex, + }); + }; + img.src = `${BASE_NEW_API_URL}${apiPath.getGlyphImage(glyph)}`; + this.glyphId = glyph; + } + private getGlyphData(): LayerImage { return { id: this.elementId, diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/layer/getTransformImageInteraction.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/layer/getTransformImageInteraction.ts index 752eb905..9e3f8142 100644 --- a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/layer/getTransformImageInteraction.ts +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/layer/getTransformImageInteraction.ts @@ -9,6 +9,7 @@ import { updateLayerImageObject } from '@/redux/layers/layers.thunks'; import { layerUpdateImage } from '@/redux/layers/layers.slice'; import getBoundingBoxFromExtent from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/coords/getBoundingBoxFromExtent'; import { MapSize } from '@/redux/map/map.types'; +import { mapEditToolsSetLayerObject } from '@/redux/mapEditTools/mapEditTools.slice'; export default function getTransformImageInteraction( dispatch: AppDispatch, @@ -26,6 +27,20 @@ export default function getTransformImageInteraction( translate: true, }); + transform.on('select', event => { + const transformEvent = event as unknown as { feature: Feature }; + const { feature } = transformEvent; + if (!feature) { + dispatch(mapEditToolsSetLayerObject(null)); + return; + } + const getGlyphData = feature.get('getGlyphData'); + if (getGlyphData && getGlyphData instanceof Function) { + const glyphData = getGlyphData(); + dispatch(mapEditToolsSetLayerObject(glyphData)); + } + }); + transform.on(['scaleend', 'translateend'], async (event: BaseEvent | Event): Promise<void> => { const transformEvent = event as unknown as { feature: Feature }; const { feature } = transformEvent; diff --git a/src/redux/mapEditTools/mapEditTools.mock.ts b/src/redux/mapEditTools/mapEditTools.mock.ts index 81dd0812..d6fe529c 100644 --- a/src/redux/mapEditTools/mapEditTools.mock.ts +++ b/src/redux/mapEditTools/mapEditTools.mock.ts @@ -2,4 +2,5 @@ import { MapEditToolsState } from '@/redux/mapEditTools/mapEditTools.types'; export const MAP_EDIT_TOOLS_STATE_INITIAL_MOCK: MapEditToolsState = { activeAction: null, + layerImageObject: null, }; diff --git a/src/redux/mapEditTools/mapEditTools.reducers.ts b/src/redux/mapEditTools/mapEditTools.reducers.ts index 2b83e994..d498472a 100644 --- a/src/redux/mapEditTools/mapEditTools.reducers.ts +++ b/src/redux/mapEditTools/mapEditTools.reducers.ts @@ -2,6 +2,7 @@ import { PayloadAction } from '@reduxjs/toolkit'; import { MAP_EDIT_ACTIONS } from '@/redux/mapEditTools/mapEditTools.constants'; import { MapEditToolsState } from '@/redux/mapEditTools/mapEditTools.types'; +import { LayerImage } from '@/types/models'; export const mapEditToolsSetActiveActionReducer = ( state: MapEditToolsState, @@ -13,3 +14,10 @@ export const mapEditToolsSetActiveActionReducer = ( state.activeAction = null; } }; + +export const mapEditToolsSetLayerObjectReducer = ( + state: MapEditToolsState, + action: PayloadAction<LayerImage | null>, +): void => { + state.layerImageObject = action.payload; +}; diff --git a/src/redux/mapEditTools/mapEditTools.selectors.ts b/src/redux/mapEditTools/mapEditTools.selectors.ts index 545d0413..d8c86f7e 100644 --- a/src/redux/mapEditTools/mapEditTools.selectors.ts +++ b/src/redux/mapEditTools/mapEditTools.selectors.ts @@ -8,3 +8,8 @@ export const mapEditToolsActiveActionSelector = createSelector( mapEditToolsSelector, state => state.activeAction, ); + +export const mapEditToolsLayerImageObjectSelector = createSelector( + mapEditToolsSelector, + state => state.layerImageObject, +); diff --git a/src/redux/mapEditTools/mapEditTools.slice.ts b/src/redux/mapEditTools/mapEditTools.slice.ts index bea57d9c..2db0001c 100644 --- a/src/redux/mapEditTools/mapEditTools.slice.ts +++ b/src/redux/mapEditTools/mapEditTools.slice.ts @@ -1,15 +1,19 @@ import { createSlice } from '@reduxjs/toolkit'; import { MAP_EDIT_TOOLS_STATE_INITIAL_MOCK } from '@/redux/mapEditTools/mapEditTools.mock'; -import { mapEditToolsSetActiveActionReducer } from '@/redux/mapEditTools/mapEditTools.reducers'; +import { + mapEditToolsSetActiveActionReducer, + mapEditToolsSetLayerObjectReducer, +} from '@/redux/mapEditTools/mapEditTools.reducers'; export const layersSlice = createSlice({ name: 'layers', initialState: MAP_EDIT_TOOLS_STATE_INITIAL_MOCK, reducers: { mapEditToolsSetActiveAction: mapEditToolsSetActiveActionReducer, + mapEditToolsSetLayerObject: mapEditToolsSetLayerObjectReducer, }, }); -export const { mapEditToolsSetActiveAction } = layersSlice.actions; +export const { mapEditToolsSetActiveAction, mapEditToolsSetLayerObject } = layersSlice.actions; export default layersSlice.reducer; diff --git a/src/redux/mapEditTools/mapEditTools.types.ts b/src/redux/mapEditTools/mapEditTools.types.ts index 8a000d1d..e141e6b8 100644 --- a/src/redux/mapEditTools/mapEditTools.types.ts +++ b/src/redux/mapEditTools/mapEditTools.types.ts @@ -1,5 +1,7 @@ import { MAP_EDIT_ACTIONS } from '@/redux/mapEditTools/mapEditTools.constants'; +import { LayerImage } from '@/types/models'; export type MapEditToolsState = { activeAction: keyof typeof MAP_EDIT_ACTIONS | null; + layerImageObject: LayerImage | null; }; diff --git a/src/redux/modal/modal.reducers.ts b/src/redux/modal/modal.reducers.ts index f678ea91..a7e1c774 100644 --- a/src/redux/modal/modal.reducers.ts +++ b/src/redux/modal/modal.reducers.ts @@ -153,3 +153,9 @@ export const openLayerImageObjectFactoryModalReducer = ( state.modalName = 'layer-image-object-factory'; state.modalTitle = 'Select glyph or upload file'; }; + +export const openLayerImageObjectEditFactoryModalReducer = (state: ModalState): void => { + state.isOpen = true; + state.modalName = 'layer-image-object-edit-factory'; + state.modalTitle = 'Edit layer image object'; +}; diff --git a/src/redux/modal/modal.slice.ts b/src/redux/modal/modal.slice.ts index a9baf72a..82da41c6 100644 --- a/src/redux/modal/modal.slice.ts +++ b/src/redux/modal/modal.slice.ts @@ -18,6 +18,7 @@ import { openToSModalReducer, openLayerFactoryModalReducer, openLayerImageObjectFactoryModalReducer, + openLayerImageObjectEditFactoryModalReducer, } from './modal.reducers'; const modalSlice = createSlice({ @@ -41,6 +42,7 @@ const modalSlice = createSlice({ openToSModal: openToSModalReducer, openLayerFactoryModal: openLayerFactoryModalReducer, openLayerImageObjectFactoryModal: openLayerImageObjectFactoryModalReducer, + openLayerImageObjectEditFactoryModal: openLayerImageObjectEditFactoryModalReducer, }, }); @@ -62,6 +64,7 @@ export const { openToSModal, openLayerFactoryModal, openLayerImageObjectFactoryModal, + openLayerImageObjectEditFactoryModal, } = modalSlice.actions; export default modalSlice.reducer; diff --git a/src/shared/Autocomplete/Autocomplete.component.tsx b/src/shared/Autocomplete/Autocomplete.component.tsx index 232dca19..aa602259 100644 --- a/src/shared/Autocomplete/Autocomplete.component.tsx +++ b/src/shared/Autocomplete/Autocomplete.component.tsx @@ -8,6 +8,7 @@ type AutocompleteProps<T> = { labelKey?: keyof T; placeholder?: string; onChange: (value: T | null) => void; + initialValue?: T | null; }; type OptionType<T> = { @@ -22,6 +23,7 @@ export const Autocomplete = <T,>({ labelKey = 'label' as keyof T, placeholder = 'Select...', onChange, + initialValue = null, }: AutocompleteProps<T>): React.JSX.Element => { const formattedOptions = options.map(option => ({ value: option[valueKey], @@ -29,13 +31,24 @@ export const Autocomplete = <T,>({ originalOption: option, })); + const initialFormattedValue = React.useMemo(() => { + if (!initialValue) { + return null; + } + return ( + formattedOptions.find(option => option.originalOption[valueKey] === initialValue[valueKey]) || + null + ); + }, [initialValue, valueKey, labelKey, formattedOptions]); + const handleChange = (selectedOption: SingleValue<OptionType<T>>): void => { onChange(selectedOption ? selectedOption.originalOption : null); }; return ( <div data-testid="autocomplete"> - <Select + <Select<OptionType<T>> + value={initialFormattedValue} options={formattedOptions} onChange={handleChange} placeholder={placeholder} diff --git a/src/shared/Icon/Icon.component.tsx b/src/shared/Icon/Icon.component.tsx index 4d888fc2..e9cf537c 100644 --- a/src/shared/Icon/Icon.component.tsx +++ b/src/shared/Icon/Icon.component.tsx @@ -20,6 +20,9 @@ import type { IconComponentType, IconTypes } from '@/types/iconTypes'; import { DownloadIcon } from '@/shared/Icon/Icons/DownloadIcon'; import { ImageIcon } from '@/shared/Icon/Icons/ImageIcon'; import { ResizeImageIcon } from '@/shared/Icon/Icons/ResizeImageIcon'; +import { PencilIcon } from '@/shared/Icon/Icons/PencilIcon'; +import { EditImageIcon } from '@/shared/Icon/Icons/EditImageIcon'; +import { TrashIcon } from '@/shared/Icon/Icons/TrashIcon'; import { LocationIcon } from './Icons/LocationIcon'; import { MaginfierZoomInIcon } from './Icons/MagnifierZoomIn'; import { MaginfierZoomOutIcon } from './Icons/MagnifierZoomOut'; @@ -63,6 +66,9 @@ const icons: Record<IconTypes, IconComponentType> = { 'manage-user': ManageUserIcon, image: ImageIcon, 'resize-image': ResizeImageIcon, + 'edit-image': EditImageIcon, + trash: TrashIcon, + pencil: PencilIcon, } as const; export const Icon = ({ name, className = '', ...rest }: IconProps): JSX.Element => { diff --git a/src/shared/Icon/Icons/EditImageIcon.tsx b/src/shared/Icon/Icons/EditImageIcon.tsx new file mode 100644 index 00000000..7cf80469 --- /dev/null +++ b/src/shared/Icon/Icons/EditImageIcon.tsx @@ -0,0 +1,29 @@ +interface EditImageIconProps { + className?: string; +} + +export const EditImageIcon = ({ className }: EditImageIconProps): JSX.Element => ( + <svg + width="24" + height="24" + viewBox="0 0 24 24" + fill="none" + className={className} + xmlns="http://www.w3.org/2000/svg" + > + <path + d="M21.2 6.4L12 15.6C11.2 16.4 8.8 16.8 8.3 16.3C7.8 15.8 8.2 13.4 9 12.6L18.4 3.2C18.6 3 18.8 2.8 19.1 2.7C19.4 2.5 19.7 2.4 20.1 2.4C20.4 2.4 20.7 2.5 21 2.7C21.3 2.9 21.5 3.1 21.7 3.4C21.9 3.7 22 4 22 4.4C22 4.7 21.9 5 21.7 5.3C21.5 5.6 21.3 5.8 21.2 6L21.2 6.4Z" + stroke="currentColor" + strokeWidth="1.5" + strokeLinecap="square" + strokeLinejoin="miter" + /> + <path + d="M11 4H6C5 4 4.2 4.4 3.6 5C3 5.6 2.6 6.4 2.6 8V18C2.6 19 3 19.8 3.6 20.4C4.2 21 5 21.4 6 21.4H17C18.8 21.4 19.6 20.4 19.6 18V13" + stroke="currentColor" + strokeWidth="1.5" + strokeLinecap="square" + strokeLinejoin="miter" + /> + </svg> +); diff --git a/src/shared/Icon/Icons/PencilIcon.tsx b/src/shared/Icon/Icons/PencilIcon.tsx new file mode 100644 index 00000000..7d86605f --- /dev/null +++ b/src/shared/Icon/Icons/PencilIcon.tsx @@ -0,0 +1,30 @@ +import React from 'react'; + +interface PencilIconProps { + className?: string; +} + +export const PencilIcon = ({ className }: PencilIconProps): JSX.Element => ( + <svg + className={className} + fill="currentColor" + height="24" + width="24" + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 306.637 306.637" + aria-hidden="true" + role="img" + strokeWidth="2" + > + <g> + <path + d="M12.809,238.52L0,306.637l68.118-12.809l184.277-184.277l-55.309-55.309L12.809,238.52z M60.79,279.943l-41.992,7.896 + l7.896-41.992L197.086,75.455l34.096,34.096L60.79,279.943z" + /> + <path + d="M251.329,0l-41.507,41.507l55.308,55.308l41.507-41.507L251.329,0z M231.035,41.507l20.294-20.294l34.095,34.095 + L265.13,75.602L231.035,41.507z" + /> + </g> + </svg> +); diff --git a/src/shared/Icon/Icons/TrashIcon.tsx b/src/shared/Icon/Icons/TrashIcon.tsx new file mode 100644 index 00000000..cf0b83b7 --- /dev/null +++ b/src/shared/Icon/Icons/TrashIcon.tsx @@ -0,0 +1,30 @@ +interface TrashIconProps { + className?: string; +} + +export const TrashIcon = ({ className }: TrashIconProps): JSX.Element => ( + <svg + width="24" + height="24" + viewBox="0 0 24 24" + fill="none" + className={className} + xmlns="http://www.w3.org/2000/svg" + > + <path d="M7 6H17" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" /> + <path d="M9 4H15V6H9V4Z" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" /> + <rect + x="6" + y="6" + width="12" + height="14" + rx="1" + stroke="currentColor" + strokeWidth="1.5" + fill="none" + /> + <path d="M9 10V16" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" /> + <path d="M12 10V16" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" /> + <path d="M15 10V16" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" /> + </svg> +); diff --git a/src/types/iconTypes.ts b/src/types/iconTypes.ts index 469b7699..dc1ee2b1 100644 --- a/src/types/iconTypes.ts +++ b/src/types/iconTypes.ts @@ -26,6 +26,9 @@ export type IconTypes = | 'download' | 'question' | 'image' - | 'resize-image'; + | 'resize-image' + | 'edit-image' + | 'trash' + | 'pencil'; export type IconComponentType = ({ className }: { className: string }) => JSX.Element; diff --git a/src/types/modal.ts b/src/types/modal.ts index edf1c858..88d3dcd3 100644 --- a/src/types/modal.ts +++ b/src/types/modal.ts @@ -13,4 +13,5 @@ export type ModalName = | 'terms-of-service' | 'logged-in-menu' | 'layer-factory' - | 'layer-image-object-factory'; + | 'layer-image-object-factory' + | 'layer-image-object-edit-factory'; -- GitLab From 023cc449ae8dbf42a25bebba63df9854afa17fd5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mi=C5=82osz=20Grocholewski?= <m.grocholewski@atcomp.pl> Date: Thu, 9 Jan 2025 15:51:05 +0100 Subject: [PATCH 22/22] feat(layer-image): add zIndex editing for layer image objects --- ...eObjectEditFactoryModal.component.test.tsx | 2 +- ...rImageObjectEditFactoryModal.component.tsx | 18 ++------ .../MapDrawEditActions.component.tsx | 41 +++++++++++++++++++ .../reactionsLayer/processModelElements.ts | 2 +- .../reactionsLayer/useOlMapReactionsLayer.ts | 2 +- .../shapes/elements/{ => Glyph}/Glyph.test.ts | 2 +- .../shapes/elements/{ => Glyph}/Glyph.ts | 41 ++++++++++++++----- .../shapes/elements/Glyph/updateGlyph.ts | 23 +++++++++++ .../utils/shapes/layer/Layer.ts | 2 +- .../layer/getTransformImageInteraction.ts | 7 ++-- src/shared/Icon/Icon.component.tsx | 4 ++ src/shared/Icon/Icons/ArrowDoubleDownIcon.tsx | 38 +++++++++++++++++ src/shared/Icon/Icons/ArrowDoubleUpIcon.tsx | 38 +++++++++++++++++ src/types/iconTypes.ts | 4 +- 14 files changed, 191 insertions(+), 33 deletions(-) rename src/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/{ => Glyph}/Glyph.test.ts (98%) rename src/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/{ => Glyph}/Glyph.ts (90%) create mode 100644 src/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/Glyph/updateGlyph.ts create mode 100644 src/shared/Icon/Icons/ArrowDoubleDownIcon.tsx create mode 100644 src/shared/Icon/Icons/ArrowDoubleUpIcon.tsx diff --git a/src/components/FunctionalArea/Modal/LayerImageObjectModal/LayerImageObjectEditFactoryModal.component.test.tsx b/src/components/FunctionalArea/Modal/LayerImageObjectModal/LayerImageObjectEditFactoryModal.component.test.tsx index 8095d60e..87a2adf0 100644 --- a/src/components/FunctionalArea/Modal/LayerImageObjectModal/LayerImageObjectEditFactoryModal.component.test.tsx +++ b/src/components/FunctionalArea/Modal/LayerImageObjectModal/LayerImageObjectEditFactoryModal.component.test.tsx @@ -140,7 +140,7 @@ describe('LayerImageObjectEditFactoryModal - component', () => { }; const getGlyphDataMock = jest.fn(() => glyphData); jest.spyOn(layerObjectFeature, 'get').mockImplementation(key => { - if (key === 'setGlyph') return (): void => {}; + if (key === 'update') return (): void => {}; if (key === 'getGlyphData') return getGlyphDataMock; return undefined; }); diff --git a/src/components/FunctionalArea/Modal/LayerImageObjectModal/LayerImageObjectEditFactoryModal.component.tsx b/src/components/FunctionalArea/Modal/LayerImageObjectModal/LayerImageObjectEditFactoryModal.component.tsx index 589b3a14..1af8c76a 100644 --- a/src/components/FunctionalArea/Modal/LayerImageObjectModal/LayerImageObjectEditFactoryModal.component.tsx +++ b/src/components/FunctionalArea/Modal/LayerImageObjectModal/LayerImageObjectEditFactoryModal.component.tsx @@ -14,7 +14,8 @@ import { showToast } from '@/utils/showToast'; import { closeModal } from '@/redux/modal/modal.slice'; import { SerializedError } from '@reduxjs/toolkit'; import { useMapInstance } from '@/utils/context/mapInstanceContext'; -import VectorSource from 'ol/source/Vector'; +import updateGlyph from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/Glyph/updateGlyph'; +import { mapEditToolsSetLayerObject } from '@/redux/mapEditTools/mapEditTools.slice'; export const LayerImageObjectEditFactoryModal: React.FC = () => { const layerImageObject = useAppSelector(mapEditToolsLayerImageObjectSelector); @@ -55,19 +56,8 @@ export const LayerImageObjectEditFactoryModal: React.FC = () => { ).unwrap(); if (layerImage) { dispatch(layerUpdateImage({ modelId: currentModelId, layerId: activeLayer, layerImage })); - mapInstance?.getAllLayers().forEach(layer => { - if (layer.get('id') === activeLayer) { - const source = layer.getSource(); - if (source instanceof VectorSource) { - const feature = source.getFeatureById(layerImage.id); - const setGlyph = feature?.get('setGlyph'); - if (setGlyph && setGlyph instanceof Function) { - setGlyph(layerImage.glyph); - feature.changed(); - } - } - } - }); + dispatch(mapEditToolsSetLayerObject(layerImage)); + updateGlyph(mapInstance, activeLayer, layerImage); } showToast({ type: 'success', diff --git a/src/components/Map/MapDrawActions/MapDrawEditActions.component.tsx b/src/components/Map/MapDrawActions/MapDrawEditActions.component.tsx index 153e93ad..6c30f1c1 100644 --- a/src/components/Map/MapDrawActions/MapDrawEditActions.component.tsx +++ b/src/components/Map/MapDrawActions/MapDrawEditActions.component.tsx @@ -4,6 +4,13 @@ import { mapEditToolsLayerImageObjectSelector } from '@/redux/mapEditTools/mapEd import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; import { MapDrawActionsButton } from '@/components/Map/MapDrawActions/MapDrawActionsButton.component'; import { openLayerImageObjectEditFactoryModal } from '@/redux/modal/modal.slice'; +import { updateLayerImageObject } from '@/redux/layers/layers.thunks'; +import { mapModelIdSelector } from '@/redux/map/map.selectors'; +import { layersActiveLayerSelector } from '@/redux/layers/layers.selectors'; +import { layerUpdateImage } from '@/redux/layers/layers.slice'; +import { useMapInstance } from '@/utils/context/mapInstanceContext'; +import { mapEditToolsSetLayerObject } from '@/redux/mapEditTools/mapEditTools.slice'; +import updateGlyph from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/Glyph/updateGlyph'; type MapDrawEditActionsComponentProps = { toggleMapEditAction: () => void; @@ -14,13 +21,35 @@ export const MapDrawEditActionsComponent = ({ toggleMapEditAction, isActive, }: MapDrawEditActionsComponentProps): React.JSX.Element => { + const currentModelId = useAppSelector(mapModelIdSelector); + const activeLayer = useAppSelector(layersActiveLayerSelector); const layerImageObject = useAppSelector(mapEditToolsLayerImageObjectSelector); const dispatch = useAppDispatch(); + const { mapInstance } = useMapInstance(); const editMapObject = (): void => { dispatch(openLayerImageObjectEditFactoryModal()); }; + const updateZIndex = async (value: number): Promise<void> => { + if (!activeLayer || !layerImageObject) { + return; + } + const layerImage = await dispatch( + updateLayerImageObject({ + modelId: currentModelId, + layerId: activeLayer, + ...layerImageObject, + z: layerImageObject.z + value, + }), + ).unwrap(); + if (layerImage) { + dispatch(layerUpdateImage({ modelId: currentModelId, layerId: activeLayer, layerImage })); + dispatch(mapEditToolsSetLayerObject(layerImage)); + updateGlyph(mapInstance, activeLayer, layerImage); + } + }; + return ( <div className="flex flex-row-reverse gap-4"> <MapDrawActionsButton @@ -43,6 +72,18 @@ export const MapDrawEditActionsComponent = ({ icon="trash" title="Remove image" /> + <MapDrawActionsButton + isActive={false} + toggleMapEditAction={() => updateZIndex(1)} + icon="arrow-double-up" + title="Remove image" + /> + <MapDrawActionsButton + isActive={false} + toggleMapEditAction={() => updateZIndex(-1)} + icon="arrow-double-down" + title="Remove image" + /> </> )} </div> diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/config/reactionsLayer/processModelElements.ts b/src/components/Map/MapViewer/MapViewerVector/utils/config/reactionsLayer/processModelElements.ts index 60746292..d832a64b 100644 --- a/src/components/Map/MapViewer/MapViewerVector/utils/config/reactionsLayer/processModelElements.ts +++ b/src/components/Map/MapViewer/MapViewerVector/utils/config/reactionsLayer/processModelElements.ts @@ -3,7 +3,7 @@ import MapElement from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/ import CompartmentCircle from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/CompartmentCircle'; import CompartmentSquare from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/CompartmentSquare'; import CompartmentPathway from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/CompartmentPathway'; -import Glyph from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/Glyph'; +import Glyph from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/Glyph/Glyph'; import { HorizontalAlign, VerticalAlign, diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/config/reactionsLayer/useOlMapReactionsLayer.ts b/src/components/Map/MapViewer/MapViewerVector/utils/config/reactionsLayer/useOlMapReactionsLayer.ts index bcf5cac4..fd83ac6f 100644 --- a/src/components/Map/MapViewer/MapViewerVector/utils/config/reactionsLayer/useOlMapReactionsLayer.ts +++ b/src/components/Map/MapViewer/MapViewerVector/utils/config/reactionsLayer/useOlMapReactionsLayer.ts @@ -21,7 +21,7 @@ import { getModelElementsForModel } from '@/redux/modelElements/modelElements.th import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; import CompartmentSquare from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/CompartmentSquare'; import CompartmentCircle from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/CompartmentCircle'; -import Glyph from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/Glyph'; +import Glyph from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/Glyph/Glyph'; import CompartmentPathway from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/CompartmentPathway'; import Reaction from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/reaction/Reaction'; import { diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/Glyph.test.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/Glyph/Glyph.test.ts similarity index 98% rename from src/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/Glyph.test.ts rename to src/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/Glyph/Glyph.test.ts index bef1e037..ff2a0ac2 100644 --- a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/Glyph.test.ts +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/Glyph/Glyph.test.ts @@ -4,7 +4,7 @@ import { Style } from 'ol/style'; import { UsePointToProjectionResult } from '@/utils/map/usePointToProjection'; import Glyph, { GlyphProps, -} from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/Glyph'; +} from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/Glyph/Glyph'; import { MapInstance } from '@/types/map'; import Polygon from 'ol/geom/Polygon'; diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/Glyph.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/Glyph/Glyph.ts similarity index 90% rename from src/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/Glyph.ts rename to src/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/Glyph/Glyph.ts index f2046aeb..a0676acd 100644 --- a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/Glyph.ts +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/Glyph/Glyph.ts @@ -154,15 +154,12 @@ export default class Glyph { this.feature.set('setCoordinates', this.setCoordinates.bind(this)); this.feature.set('getGlyphData', this.getGlyphData.bind(this)); - this.feature.set('reset', this.reset.bind(this)); - this.feature.set('setGlyph', this.setGlyph.bind(this)); + this.feature.set('refreshPolygon', this.refreshPolygon.bind(this)); + this.feature.set('update', this.update.bind(this)); this.feature.setId(this.elementId); this.feature.setStyle(this.getStyle.bind(this)); - if (!this.glyphId) { - return; - } - this.setGlyph(this.glyphId); + this.drawImage(); } private drawPolygon(): void { @@ -177,12 +174,34 @@ export default class Glyph { ]); } - private reset(): void { + private refreshPolygon(): void { this.drawPolygon(); this.polygonStyle.setGeometry(this.polygon); this.feature.setGeometry(this.polygon); } + private refreshZIndex(): void { + this.polygonStyle.setZIndex(this.zIndex); + this.noGlyphStyle.setZIndex(this.zIndex); + this.style.setZIndex(this.zIndex); + this.feature.changed(); + } + + private update(imageObject: LayerImage): void { + this.elementId = imageObject.id; + this.x = imageObject.x; + this.y = imageObject.y; + this.zIndex = imageObject.z; + this.width = imageObject.width; + this.height = imageObject.height; + this.glyphId = imageObject.glyph; + + this.refreshPolygon(); + this.refreshZIndex(); + this.drawImage(); + this.feature.changed(); + } + protected setImageScaleAndDimensions(height: number, width: number): void { this.widthOnMap = width; this.heightOnMap = height; @@ -209,7 +228,10 @@ export default class Glyph { } } - private setGlyph(glyph: number): void { + private drawImage(): void { + if (!this.glyphId) { + return; + } const img = new Image(); img.onload = (): void => { this.imageWidth = img.naturalWidth; @@ -228,8 +250,7 @@ export default class Glyph { zIndex: this.zIndex, }); }; - img.src = `${BASE_NEW_API_URL}${apiPath.getGlyphImage(glyph)}`; - this.glyphId = glyph; + img.src = `${BASE_NEW_API_URL}${apiPath.getGlyphImage(this.glyphId)}`; } private getGlyphData(): LayerImage { diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/Glyph/updateGlyph.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/Glyph/updateGlyph.ts new file mode 100644 index 00000000..05418973 --- /dev/null +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/Glyph/updateGlyph.ts @@ -0,0 +1,23 @@ +import VectorSource from 'ol/source/Vector'; +import { LayerImage } from '@/types/models'; +import { MapInstance } from '@/types/map'; + +export default function updateGlyph( + mapInstance: MapInstance, + layerId: number, + layerImage: LayerImage, +): void { + mapInstance?.getAllLayers().forEach(layer => { + if (layer.get('id') === layerId) { + const source = layer.getSource(); + if (source instanceof VectorSource) { + const feature = source.getFeatureById(layerImage.id); + const update = feature?.get('update'); + if (update && update instanceof Function) { + update(layerImage); + feature.changed(); + } + } + } + }); +} diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/layer/Layer.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/layer/Layer.ts index 65c6f613..84c3f317 100644 --- a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/layer/Layer.ts +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/layer/Layer.ts @@ -26,7 +26,7 @@ import { } from '@/components/Map/MapViewer/MapViewerVector/MapViewerVector.constants'; import getScaledElementStyle from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/style/getScaledElementStyle'; import { Stroke } from 'ol/style'; -import Glyph from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/Glyph'; +import Glyph from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/Glyph/Glyph'; import { MapSize } from '@/redux/map/map.types'; export interface LayerProps { diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/layer/getTransformImageInteraction.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/layer/getTransformImageInteraction.ts index 9e3f8142..e4e0dd53 100644 --- a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/layer/getTransformImageInteraction.ts +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/layer/getTransformImageInteraction.ts @@ -46,7 +46,7 @@ export default function getTransformImageInteraction( const { feature } = transformEvent; const setCoordinates = feature.get('setCoordinates'); const getGlyphData = feature.get('getGlyphData'); - const reset = feature.get('reset'); + const refreshPolygon = feature.get('refreshPolygon'); const geometry = feature.getGeometry(); if (geometry && getGlyphData instanceof Function) { const glyphData = getGlyphData(); @@ -57,14 +57,15 @@ export default function getTransformImageInteraction( ).unwrap(); if (layerImage) { dispatch(layerUpdateImage({ modelId, layerId: activeLayer, layerImage })); + dispatch(mapEditToolsSetLayerObject(layerImage)); } if (geometry instanceof Polygon && setCoordinates instanceof Function) { setCoordinates(geometry.getCoordinates()); geometry.changed(); } } catch { - if (reset instanceof Function) { - reset(); + if (refreshPolygon instanceof Function) { + refreshPolygon(); } } } diff --git a/src/shared/Icon/Icon.component.tsx b/src/shared/Icon/Icon.component.tsx index e9cf537c..44cade94 100644 --- a/src/shared/Icon/Icon.component.tsx +++ b/src/shared/Icon/Icon.component.tsx @@ -23,6 +23,8 @@ import { ResizeImageIcon } from '@/shared/Icon/Icons/ResizeImageIcon'; import { PencilIcon } from '@/shared/Icon/Icons/PencilIcon'; import { EditImageIcon } from '@/shared/Icon/Icons/EditImageIcon'; import { TrashIcon } from '@/shared/Icon/Icons/TrashIcon'; +import { ArrowDoubleUpIcon } from '@/shared/Icon/Icons/ArrowDoubleUpIcon'; +import { ArrowDoubleDownIcon } from '@/shared/Icon/Icons/ArrowDoubleDownIcon'; import { LocationIcon } from './Icons/LocationIcon'; import { MaginfierZoomInIcon } from './Icons/MagnifierZoomIn'; import { MaginfierZoomOutIcon } from './Icons/MagnifierZoomOut'; @@ -69,6 +71,8 @@ const icons: Record<IconTypes, IconComponentType> = { 'edit-image': EditImageIcon, trash: TrashIcon, pencil: PencilIcon, + 'arrow-double-up': ArrowDoubleUpIcon, + 'arrow-double-down': ArrowDoubleDownIcon, } as const; export const Icon = ({ name, className = '', ...rest }: IconProps): JSX.Element => { diff --git a/src/shared/Icon/Icons/ArrowDoubleDownIcon.tsx b/src/shared/Icon/Icons/ArrowDoubleDownIcon.tsx new file mode 100644 index 00000000..d7ab4e38 --- /dev/null +++ b/src/shared/Icon/Icons/ArrowDoubleDownIcon.tsx @@ -0,0 +1,38 @@ +interface ArrowDoubleDownIconProps { + className?: string; +} + +export const ArrowDoubleDownIcon = ({ className }: ArrowDoubleDownIconProps): JSX.Element => ( + <svg + width="24" + height="24" + viewBox="0 0 24 24" + fill="none" + className={className} + xmlns="http://www.w3.org/2000/svg" + > + <path + d="M8 8L8 18M8 18L5 15M8 18L11 15" + stroke="currentColor" + strokeWidth="1.5" + strokeLinecap="round" + strokeLinejoin="round" + /> + <path + d="M16 8L16 18M16 18L13 15M16 18L19 15" + stroke="currentColor" + strokeWidth="1.5" + strokeLinecap="round" + strokeLinejoin="round" + /> + <line + x1="4" + y1="6" + x2="20" + y2="6" + stroke="currentColor" + strokeWidth="1.5" + strokeLinecap="round" + /> + </svg> +); diff --git a/src/shared/Icon/Icons/ArrowDoubleUpIcon.tsx b/src/shared/Icon/Icons/ArrowDoubleUpIcon.tsx new file mode 100644 index 00000000..ed51a602 --- /dev/null +++ b/src/shared/Icon/Icons/ArrowDoubleUpIcon.tsx @@ -0,0 +1,38 @@ +interface ArrowDoubleUpIconProps { + className?: string; +} + +export const ArrowDoubleUpIcon = ({ className }: ArrowDoubleUpIconProps): JSX.Element => ( + <svg + width="24" + height="24" + viewBox="0 0 24 24" + fill="none" + className={className} + xmlns="http://www.w3.org/2000/svg" + > + <path + d="M8 16L8 6M8 6L5 9M8 6L11 9" + stroke="currentColor" + strokeWidth="1.5" + strokeLinecap="round" + strokeLinejoin="round" + /> + <path + d="M16 16L16 6M16 6L13 9M16 6L19 9" + stroke="currentColor" + strokeWidth="1.5" + strokeLinecap="round" + strokeLinejoin="round" + /> + <line + x1="4" + y1="18" + x2="20" + y2="18" + stroke="currentColor" + strokeWidth="1.5" + strokeLinecap="round" + /> + </svg> +); diff --git a/src/types/iconTypes.ts b/src/types/iconTypes.ts index dc1ee2b1..a631d12a 100644 --- a/src/types/iconTypes.ts +++ b/src/types/iconTypes.ts @@ -29,6 +29,8 @@ export type IconTypes = | 'resize-image' | 'edit-image' | 'trash' - | 'pencil'; + | 'pencil' + | 'arrow-double-up' + | 'arrow-double-down'; export type IconComponentType = ({ className }: { className: string }) => JSX.Element; -- GitLab