diff --git a/src/components/FunctionalArea/NavBar/NavBar.component.test.tsx b/src/components/FunctionalArea/NavBar/NavBar.component.test.tsx index c5bff4c3eade40bfea3f82240ff36741467ab2e9..08d6c36c21ccbd80c7ccba9280e367952c96f99d 100644 --- a/src/components/FunctionalArea/NavBar/NavBar.component.test.tsx +++ b/src/components/FunctionalArea/NavBar/NavBar.component.test.tsx @@ -1,13 +1,10 @@ -import drawerReducer from '@/redux/drawer/drawer.slice'; -import type { DrawerState } from '@/redux/drawer/drawer.types'; -import { ToolkitStoreWithSingleSlice } from '@/utils/createStoreInstanceUsingSliceReducer'; -import { getReduxWrapperUsingSliceReducer } from '@/utils/testing/getReduxWrapperUsingSliceReducer'; import { render, screen } from '@testing-library/react'; +import { getReduxWrapperWithStore } from '@/utils/testing/getReduxWrapperWithStore'; +import { StoreType } from '@/redux/store'; import { NavBar } from './NavBar.component'; -const renderComponent = (): { store: ToolkitStoreWithSingleSlice<DrawerState> } => { - const { Wrapper, store } = getReduxWrapperUsingSliceReducer('drawer', drawerReducer); - +const renderComponent = (): { store: StoreType } => { + const { Wrapper, store } = getReduxWrapperWithStore(); return ( render( <Wrapper> diff --git a/src/components/FunctionalArea/NavBar/NavBar.component.tsx b/src/components/FunctionalArea/NavBar/NavBar.component.tsx index 6631b8004159cc2793eb371fd775672e22209904..7eb7b2b0f556a4f4fe4e7a805ed00a916a97cd8a 100644 --- a/src/components/FunctionalArea/NavBar/NavBar.component.tsx +++ b/src/components/FunctionalArea/NavBar/NavBar.component.tsx @@ -11,11 +11,16 @@ import { store } from '@/redux/store'; import Image from 'next/image'; import { useAppSelector } from '@/redux/hooks/useAppSelector'; import { projectIdSelector } from '@/redux/project/project.selectors'; +import { Switch } from '@/shared/Switch'; +import { currentModelIdSelector, vectorRenderingSelector } from '@/redux/models/models.selectors'; +import { setModelVectorRendering } from '@/redux/models/models.slice'; export const NavBar = (): JSX.Element => { const dispatch = useAppDispatch(); const projectId = useAppSelector(projectIdSelector); + const vectorRendering = useAppSelector(vectorRenderingSelector); + const currentModelId = useAppSelector(currentModelIdSelector); const toggleDrawerInfo = (): void => { if (store.getState().drawer.isOpen && store.getState().drawer.drawerName === 'project-info') { @@ -77,7 +82,15 @@ export const NavBar = (): JSX.Element => { <IconButton icon="legend" onClick={toggleDrawerLegend} title="Legend" /> </div> </div> - + <div className="flex flex-col items-center gap-[10px] text-center text-[12px]"> + <span>Vector rendering</span> + <Switch + isChecked={vectorRendering} + onToggle={value => + dispatch(setModelVectorRendering({ vectorRendering: value, mapId: currentModelId })) + } + /> + </div> <div className="flex flex-col items-center gap-[20px]" data-testid="nav-logos-and-powered-by"> <Image className="rounded rounded-e rounded-s bg-white-pearl pb-[7px]" diff --git a/src/components/Map/Map.component.tsx b/src/components/Map/Map.component.tsx index 9b032ffe2b6b4d24336e184b405218644fe2a6ff..67d4d216c793458cefe6cd83af70a72989112876 100644 --- a/src/components/Map/Map.component.tsx +++ b/src/components/Map/Map.component.tsx @@ -1,23 +1,18 @@ /* eslint-disable no-magic-numbers */ import { Drawer } from '@/components/Map/Drawer'; import { Legend } from '@/components/Map/Legend'; -import { MapVectorViewer } from '@/components/Map/MapVectorViewer'; import { MapViewer } from '@/components/Map/MapViewer'; -import { useAppSelector } from '@/redux/hooks/useAppSelector'; -import { vectorRenderingSelector } from '@/redux/models/models.selectors'; import { MapAdditionalActions } from './MapAdditionalActions'; import { MapAdditionalOptions } from './MapAdditionalOptions'; import { PluginsDrawer } from './PluginsDrawer'; export const Map = (): JSX.Element => { - const vectorRendering = useAppSelector(vectorRenderingSelector); return ( <div className="relative z-0 h-screen w-full overflow-hidden bg-black" data-testid="map-container" > - {!vectorRendering && <MapViewer />} - {vectorRendering && <MapVectorViewer />} + <MapViewer /> <MapAdditionalOptions /> <Drawer /> <PluginsDrawer /> diff --git a/src/components/Map/MapVectorViewer/MapVectorViewer.component.test.tsx b/src/components/Map/MapVectorViewer/MapVectorViewer.component.test.tsx deleted file mode 100644 index fd005a8f54795b13441537a69b8557286940fa43..0000000000000000000000000000000000000000 --- a/src/components/Map/MapVectorViewer/MapVectorViewer.component.test.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import { StoreType } from '@/redux/store'; -import { initialMapStateFixture } from '@/redux/map/map.fixtures'; -import { BACKGROUND_INITIAL_STATE_MOCK } from '@/redux/backgrounds/background.mock'; -import { getReduxWrapperWithStore } from '@/utils/testing/getReduxWrapperWithStore'; -import { MapVectorViewer } from './MapVectorViewer.component'; -import { MAP_VECTOR_VIEWER_ROLE } from './MapVectorViewer.constants'; - -const renderComponent = (): { store: StoreType } => { - const { Wrapper, store } = getReduxWrapperWithStore({ - map: initialMapStateFixture, - backgrounds: BACKGROUND_INITIAL_STATE_MOCK, - }); - - return ( - render( - <Wrapper> - <MapVectorViewer /> - </Wrapper>, - ), - { - store, - } - ); -}; - -describe('MapVectorViewer - component', () => { - it('should render component container', () => { - renderComponent(); - - expect(screen.getByRole(MAP_VECTOR_VIEWER_ROLE)).toBeInTheDocument(); - }); - - it('should render openlayers map inside the component', () => { - renderComponent(); - - const FIRST_NODE = 0; - - expect(screen.getByRole(MAP_VECTOR_VIEWER_ROLE).childNodes[FIRST_NODE]).toHaveClass( - 'ol-viewport', - ); - }); -}); diff --git a/src/components/Map/MapVectorViewer/MapVectorViewer.component.tsx b/src/components/Map/MapVectorViewer/MapVectorViewer.component.tsx deleted file mode 100644 index f58823942aa909afcad442bdb8eccf2cb9c22df7..0000000000000000000000000000000000000000 --- a/src/components/Map/MapVectorViewer/MapVectorViewer.component.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import 'ol/ol.css'; -import { useOlMap } from '@/components/Map/MapVectorViewer/utils/useOlMap'; -import { MAP_VECTOR_VIEWER_ROLE } from '@/components/Map/MapVectorViewer/MapVectorViewer.constants'; - -export const MapVectorViewer = (): JSX.Element => { - const { mapRef } = useOlMap(); - return ( - <div - ref={mapRef} - role={MAP_VECTOR_VIEWER_ROLE} - className="absolute left-[88px] top-[104px] h-[calc(100%-104px)] w-[calc(100%-88px)] bg-[#e4e2de]" - /> - ); -}; diff --git a/src/components/Map/MapVectorViewer/MapVectorViewer.constants.ts b/src/components/Map/MapVectorViewer/MapVectorViewer.constants.ts deleted file mode 100644 index b08c13495aa5acdec1306a36e563824382b4f48a..0000000000000000000000000000000000000000 --- a/src/components/Map/MapVectorViewer/MapVectorViewer.constants.ts +++ /dev/null @@ -1 +0,0 @@ -export const MAP_VECTOR_VIEWER_ROLE = 'map-vector-viewer'; diff --git a/src/components/Map/MapVectorViewer/MapVectorViewer.types.ts b/src/components/Map/MapVectorViewer/MapVectorViewer.types.ts deleted file mode 100644 index f6750e5cb1b7db0b38c29ba97fc7ca3c5cbd7b26..0000000000000000000000000000000000000000 --- a/src/components/Map/MapVectorViewer/MapVectorViewer.types.ts +++ /dev/null @@ -1,7 +0,0 @@ -import View from 'ol/View'; -import BaseLayer from 'ol/layer/Base'; - -export type MapConfig = { - view: View; - layers: BaseLayer[]; -}; diff --git a/src/components/Map/MapVectorViewer/index.ts b/src/components/Map/MapVectorViewer/index.ts deleted file mode 100644 index 0c1b0e15bd7527a057bd808fd625815b34022500..0000000000000000000000000000000000000000 --- a/src/components/Map/MapVectorViewer/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { MapVectorViewer } from './MapVectorViewer.component'; diff --git a/src/components/Map/MapVectorViewer/utils/config/reactionsLayer/useOlMapReactionsLayer.test.ts b/src/components/Map/MapVectorViewer/utils/config/reactionsLayer/useOlMapReactionsLayer.test.ts deleted file mode 100644 index 286f831e48e9c7c1e6b832daaeadc2bfc4752d2d..0000000000000000000000000000000000000000 --- a/src/components/Map/MapVectorViewer/utils/config/reactionsLayer/useOlMapReactionsLayer.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { getReduxWrapperWithStore } from '@/utils/testing/getReduxWrapperWithStore'; -import { renderHook } from '@testing-library/react'; -import VectorLayer from 'ol/layer/Vector'; -import { useOlMapReactionsLayer } from '@/components/Map/MapVectorViewer/utils/config/reactionsLayer/useOlMapReactionsLayer'; - -describe('useOlMapReactionsLayer - util', () => { - const { Wrapper } = getReduxWrapperWithStore(); - - it('should return VectorLayer', () => { - const { result } = renderHook(() => useOlMapReactionsLayer(), { - wrapper: Wrapper, - }); - - expect(result.current).toBeInstanceOf(VectorLayer); - expect(result.current.getSourceState()).toBe('ready'); - }); -}); diff --git a/src/components/Map/MapVectorViewer/utils/config/reactionsLayer/useOlMapReactionsLayer.ts b/src/components/Map/MapVectorViewer/utils/config/reactionsLayer/useOlMapReactionsLayer.ts deleted file mode 100644 index 3f77f5c506f79fa1e70a757812661db21a760e8c..0000000000000000000000000000000000000000 --- a/src/components/Map/MapVectorViewer/utils/config/reactionsLayer/useOlMapReactionsLayer.ts +++ /dev/null @@ -1,47 +0,0 @@ -/* eslint-disable no-magic-numbers */ -import { Feature } from 'ol'; -import VectorLayer from 'ol/layer/Vector'; -import VectorSource from 'ol/source/Vector'; -import { useMemo } from 'react'; -import BasePolygon from '@/components/Map/MapVectorViewer/utils/shapes/BasePolygon'; -import Polygon from 'ol/geom/Polygon'; -import { usePointToProjection } from '@/utils/map/usePointToProjection'; - -export const useOlMapReactionsLayer = (): VectorLayer<VectorSource<Feature<Polygon>>> => { - const pointToProjection = usePointToProjection(); - const basePolygon = new BasePolygon({ - points: [ - pointToProjection({ x: 100, y: 100 }), - pointToProjection({ x: 188, y: 100 }), - pointToProjection({ x: 210, y: 200 }), - pointToProjection({ x: 100, y: 200 }), - ], - }); - - const basePolygon2 = new BasePolygon({ - points: [ - pointToProjection({ x: 500, y: 200 }), - pointToProjection({ x: 560, y: 230 }), - pointToProjection({ x: 620, y: 200 }), - pointToProjection({ x: 620, y: 250 }), - pointToProjection({ x: 560, y: 280 }), - pointToProjection({ x: 500, y: 250 }), - ], - fill: '#CCFFCC', - text: 'Test', - }); - - const vectorSource = useMemo(() => { - return new VectorSource({ - features: [basePolygon.polygonFeature, basePolygon2.polygonFeature], - }); - }, [basePolygon.polygonFeature, basePolygon2.polygonFeature]); - - return useMemo( - () => - new VectorLayer({ - source: vectorSource, - }), - [vectorSource], - ); -}; diff --git a/src/components/Map/MapVectorViewer/utils/config/useOlMapLayers.ts b/src/components/Map/MapVectorViewer/utils/config/useOlMapLayers.ts deleted file mode 100644 index 66c0143687f2ae615f479ee597620618e9e4d335..0000000000000000000000000000000000000000 --- a/src/components/Map/MapVectorViewer/utils/config/useOlMapLayers.ts +++ /dev/null @@ -1,23 +0,0 @@ -/* eslint-disable no-magic-numbers */ -import { MapInstance } from '@/types/map'; -import { useEffect } from 'react'; -import { useOlMapWhiteCardLayer } from '@/components/Map/MapVectorViewer/utils/config/useOlMapWhiteCardLayer'; -import { MapConfig } from '../../MapVectorViewer.types'; -import { useOlMapReactionsLayer } from './reactionsLayer/useOlMapReactionsLayer'; - -interface UseOlMapLayersInput { - mapInstance: MapInstance; -} - -export const useOlMapLayers = ({ mapInstance }: UseOlMapLayersInput): MapConfig['layers'] => { - const reactionsLayer = useOlMapReactionsLayer(); - const whiteCardLayer = useOlMapWhiteCardLayer(); - useEffect(() => { - if (!mapInstance) { - return; - } - mapInstance.setLayers([whiteCardLayer, reactionsLayer]); - }, [whiteCardLayer, reactionsLayer, mapInstance]); - - return [whiteCardLayer, reactionsLayer]; -}; diff --git a/src/components/Map/MapVectorViewer/utils/config/useOlMapView.test.ts b/src/components/Map/MapVectorViewer/utils/config/useOlMapView.test.ts deleted file mode 100644 index caf22097ec6215693ea94e20156c045083f5ee6d..0000000000000000000000000000000000000000 --- a/src/components/Map/MapVectorViewer/utils/config/useOlMapView.test.ts +++ /dev/null @@ -1,116 +0,0 @@ -/* eslint-disable no-magic-numbers */ -import { - BACKGROUNDS_MOCK, - BACKGROUND_INITIAL_STATE_MOCK, -} from '@/redux/backgrounds/background.mock'; -import { MAP_DATA_INITIAL_STATE, OPENED_MAPS_INITIAL_STATE } from '@/redux/map/map.constants'; -import { initialMapStateFixture } from '@/redux/map/map.fixtures'; -import { setMapPosition } from '@/redux/map/map.slice'; -import { getReduxWrapperWithStore } from '@/utils/testing/getReduxWrapperWithStore'; -import { act, renderHook, waitFor } from '@testing-library/react'; -import { View } from 'ol'; -import Map from 'ol/Map'; -import React from 'react'; -import { useOlMap } from '@/components/Map/MapVectorViewer/utils/useOlMap'; -import { useOlMapView } from '@/components/Map/MapVectorViewer/utils/config/useOlMapView'; - -const useRefValue = { - current: null, -}; - -Object.defineProperty(useRefValue, 'current', { - get: jest.fn(() => ({ - innerHTML: '', - appendChild: jest.fn(), - addEventListener: jest.fn(), - getRootNode: jest.fn(), - })), - set: jest.fn(() => ({ - innerHTML: '', - appendChild: jest.fn(), - addEventListener: jest.fn(), - getRootNode: jest.fn(), - })), -}); - -jest.spyOn(React, 'useRef').mockReturnValue(useRefValue); - -describe('useOlMapView - util', () => { - it('should modify view of the map instance on INITIAL position config change', async () => { - const { Wrapper, store } = getReduxWrapperWithStore({ - map: initialMapStateFixture, - backgrounds: { ...BACKGROUND_INITIAL_STATE_MOCK, data: BACKGROUNDS_MOCK }, - }); - - const dummyElement = document.createElement('div'); - const { result: hohResult } = renderHook(() => useOlMap({ target: dummyElement }), { - wrapper: Wrapper, - }); - - const setViewSpy = jest.spyOn(hohResult.current.mapInstance as Map, 'setView'); - const CALLED_TWICE = 2; - - await act(() => { - store.dispatch( - setMapPosition({ - x: 0, - y: 0, - }), - ); - }); - - renderHook(() => useOlMapView({ mapInstance: hohResult.current.mapInstance }), { - wrapper: Wrapper, - }); - - await waitFor(() => expect(setViewSpy).toBeCalledTimes(CALLED_TWICE)); - }); - - it('should return valid View instance', async () => { - const { Wrapper } = getReduxWrapperWithStore({ - map: { - data: { - ...MAP_DATA_INITIAL_STATE, - size: { - width: 256, - height: 256, - tileSize: 256, - minZoom: 1, - maxZoom: 1, - }, - position: { - initial: { - x: 128, - y: 128, - }, - last: { - x: 128, - y: 128, - }, - }, - }, - loading: 'idle', - error: { - name: '', - message: '', - }, - openedMaps: OPENED_MAPS_INITIAL_STATE, - }, - backgrounds: BACKGROUND_INITIAL_STATE_MOCK, - }); - const dummyElement = document.createElement('div'); - const { result: hohResult } = renderHook(() => useOlMap({ target: dummyElement }), { - wrapper: Wrapper, - }); - - const { result } = renderHook( - () => useOlMapView({ mapInstance: hohResult.current.mapInstance }), - { - wrapper: Wrapper, - }, - ); - - expect(result.current).toBeInstanceOf(View); - expect(result.current.getCenter()).toStrictEqual([0, -0]); - }); -}); diff --git a/src/components/Map/MapVectorViewer/utils/config/useOlMapView.ts b/src/components/Map/MapVectorViewer/utils/config/useOlMapView.ts deleted file mode 100644 index 4d0669133b0c6d879948683f45d16a2c5dfd81e7..0000000000000000000000000000000000000000 --- a/src/components/Map/MapVectorViewer/utils/config/useOlMapView.ts +++ /dev/null @@ -1,77 +0,0 @@ -/* eslint-disable no-magic-numbers */ -import { EXTENT_PADDING_MULTIPLICATOR, OPTIONS } from '@/constants/map'; -import { mapDataInitialPositionSelector, mapDataSizeSelector } from '@/redux/map/map.selectors'; -import { MapInstance, Point } from '@/types/map'; -import { usePointToProjection } from '@/utils/map/usePointToProjection'; -import { View } from 'ol'; -import { Extent, boundingExtent } from 'ol/extent'; -import { useEffect, useMemo } from 'react'; -import { useSelector } from 'react-redux'; -import { MapConfig } from '@/components/Map/MapVectorViewer/MapVectorViewer.types'; - -interface UseOlMapViewInput { - mapInstance: MapInstance; -} - -export const useOlMapView = ({ mapInstance }: UseOlMapViewInput): MapConfig['view'] => { - const mapInitialPosition = useSelector(mapDataInitialPositionSelector); - const mapSize = useSelector(mapDataSizeSelector); - const pointToProjection = usePointToProjection(); - - const extent = useMemo((): Extent => { - const extentPadding = { - horizontal: mapSize.width * EXTENT_PADDING_MULTIPLICATOR, - vertical: mapSize.height * EXTENT_PADDING_MULTIPLICATOR, - }; - - const topLeftPoint: Point = { - x: mapSize.width + extentPadding.horizontal, - y: mapSize.height + extentPadding.vertical, - }; - - const bottomRightPoint: Point = { - x: -extentPadding.horizontal, - y: -extentPadding.vertical, - }; - - return boundingExtent([topLeftPoint, bottomRightPoint].map(pointToProjection)); - }, [pointToProjection, mapSize]); - - const center = useMemo((): Point => { - const centerPoint: Point = { - x: mapInitialPosition.x, - y: mapInitialPosition.y, - }; - - const [x, y] = pointToProjection(centerPoint); - - return { - x, - y, - }; - }, [mapInitialPosition, pointToProjection]); - - const viewConfig = useMemo( - () => ({ - center: [center.x, center.y], - zoom: mapInitialPosition.z, - showFullExtent: OPTIONS.showFullExtent, - maxZoom: mapSize.maxZoom, - minZoom: mapSize.minZoom, - extent, - }), - [mapInitialPosition.z, mapSize.maxZoom, mapSize.minZoom, center, extent], - ); - - const view = useMemo(() => new View(viewConfig), [viewConfig]); - - useEffect(() => { - if (!mapInstance) { - return; - } - - mapInstance.setView(view); - }, [view, mapInstance]); - - return view; -}; diff --git a/src/components/Map/MapVectorViewer/utils/listeners/onMapPositionChange.test.ts b/src/components/Map/MapVectorViewer/utils/listeners/onMapPositionChange.test.ts deleted file mode 100644 index 6b6df6372b3dc19fc2f49bf51334f1ec129ff34e..0000000000000000000000000000000000000000 --- a/src/components/Map/MapVectorViewer/utils/listeners/onMapPositionChange.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -/* eslint-disable no-magic-numbers */ -import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; -import { mapDataSelector } from '@/redux/map/map.selectors'; -import { MapSize } from '@/redux/map/map.types'; -import { Point } from '@/types/map'; -import { getReduxWrapperWithStore } from '@/utils/testing/getReduxWrapperWithStore'; -import { renderHook } from '@testing-library/react'; -import { ObjectEvent } from 'openlayers'; -import { onMapPositionChange } from './onMapPositionChange'; - -const getEvent = (targetValues: ObjectEvent['target']['values_']): ObjectEvent => - ({ - target: { - values_: targetValues, - }, - }) as unknown as ObjectEvent; - -describe('onMapPositionChange - util', () => { - const cases: [MapSize, ObjectEvent['target']['values_'], Point][] = [ - [ - { - width: 26779.25, - height: 13503, - tileSize: 256, - minZoom: 2, - maxZoom: 9, - }, - { - center: [-18320768.57141088, 18421138.0064355], - zoom: 6, - }, - { - x: 4589, - y: 4320, - z: 6, - }, - ], - [ - { - width: 5170, - height: 1535.1097689075634, - tileSize: 256, - minZoom: 2, - maxZoom: 7, - }, - { - center: [-17172011.827663105, 18910737.010646995], - zoom: 6.68620779943448, - }, - { - x: 1479, - y: 581, - z: 6.68620779943448, - }, - ], - ]; - - it.each(cases)( - 'should set map data position to valid one', - (mapSize, targetValues, lastPosition) => { - const { Wrapper, store } = getReduxWrapperWithStore(); - const { result } = renderHook(() => useAppDispatch(), { wrapper: Wrapper }); - const dispatch = result.current; - const event = getEvent(targetValues); - - onMapPositionChange(mapSize, dispatch)(event); - - const { position } = mapDataSelector(store.getState()); - expect(position.last).toMatchObject(lastPosition); - }, - ); -}); diff --git a/src/components/Map/MapVectorViewer/utils/listeners/onMapPositionChange.ts b/src/components/Map/MapVectorViewer/utils/listeners/onMapPositionChange.ts deleted file mode 100644 index 7102fec7fdecfd1f771295030dd82e30c42bc767..0000000000000000000000000000000000000000 --- a/src/components/Map/MapVectorViewer/utils/listeners/onMapPositionChange.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { setMapPosition } from '@/redux/map/map.slice'; -import { MapSize } from '@/redux/map/map.types'; -import { AppDispatch } from '@/redux/store'; -import { latLngToPoint } from '@/utils/map/latLngToPoint'; -import { toLonLat } from 'ol/proj'; -import { ObjectEvent } from 'openlayers'; - -/* prettier-ignore */ -export const onMapPositionChange = - (mapSize: MapSize, dispatch: AppDispatch) => - (e: ObjectEvent): void => { - // eslint-disable-next-line no-underscore-dangle - const { center, zoom } = e.target.values_; - const [lng, lat] = toLonLat(center); - const { x, y } = latLngToPoint([lat, lng], mapSize, { rounded: true }); - - dispatch( - setMapPosition({ - x, - y, - z: zoom, - }), - ); - }; diff --git a/src/components/Map/MapVectorViewer/utils/listeners/useOlMapListeners.test.ts b/src/components/Map/MapVectorViewer/utils/listeners/useOlMapListeners.test.ts deleted file mode 100644 index bae5bcf5a610b45d1762012ed0d132438f03eba2..0000000000000000000000000000000000000000 --- a/src/components/Map/MapVectorViewer/utils/listeners/useOlMapListeners.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { renderHook } from '@testing-library/react'; -import { View } from 'ol'; -import { getReduxWrapperWithStore } from '@/utils/testing/getReduxWrapperWithStore'; -import { initialMapDataFixture, openedMapsThreeSubmapsFixture } from '@/redux/map/map.fixtures'; -import * as positionListener from './onMapPositionChange'; -import { useOlMapListeners } from './useOlMapListeners'; - -jest.mock('./onMapPositionChange', () => ({ - __esModule: true, - onMapPositionChange: jest.fn(), -})); - -jest.mock('use-debounce', () => { - return { - useDebounce: (): void => {}, - useDebouncedCallback: (): void => {}, - }; -}); - -describe('useOlMapListeners - util', () => { - const { Wrapper } = getReduxWrapperWithStore({ - map: { - data: { ...initialMapDataFixture }, - loading: 'succeeded', - error: { message: '', name: '' }, - openedMaps: openedMapsThreeSubmapsFixture, - }, - }); - - beforeEach(() => { - jest.clearAllMocks(); - }); - - describe('on change:center view event', () => { - it('should run onMapPositionChange event', () => { - const CALLED_ONCE = 1; - const view = new View(); - - renderHook(() => useOlMapListeners({ view }), { wrapper: Wrapper }); - view.dispatchEvent('change:center'); - - expect(positionListener.onMapPositionChange).toBeCalledTimes(CALLED_ONCE); - }); - }); -}); diff --git a/src/components/Map/MapVectorViewer/utils/listeners/useOlMapListeners.ts b/src/components/Map/MapVectorViewer/utils/listeners/useOlMapListeners.ts deleted file mode 100644 index 4b7348302c41de78e45e10d78d491b15ad7f3924..0000000000000000000000000000000000000000 --- a/src/components/Map/MapVectorViewer/utils/listeners/useOlMapListeners.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { OPTIONS } from '@/constants/map'; -import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; -import { mapDataSizeSelector } from '@/redux/map/map.selectors'; -import { View } from 'ol'; -import { unByKey } from 'ol/Observable'; -import { useEffect } from 'react'; -import { useSelector } from 'react-redux'; -import { useDebouncedCallback } from 'use-debounce'; -import { onMapPositionChange } from './onMapPositionChange'; - -interface UseOlMapListenersInput { - view: View; -} - -export const useOlMapListeners = ({ view }: UseOlMapListenersInput): void => { - const mapSize = useSelector(mapDataSizeSelector); - const dispatch = useAppDispatch(); - - const handleChangeCenter = useDebouncedCallback( - onMapPositionChange(mapSize, dispatch), - OPTIONS.queryPersistTime, - { leading: false }, - ); - - useEffect(() => { - const key = view.on('change:center', handleChangeCenter); - - return () => unByKey(key); - }, [view, handleChangeCenter]); -}; diff --git a/src/components/Map/MapVectorViewer/utils/shapes/BasePolygon.test.ts b/src/components/Map/MapVectorViewer/utils/shapes/BasePolygon.test.ts deleted file mode 100644 index 50d60cbef84675f97d6ebc4279787a6e60e9c52a..0000000000000000000000000000000000000000 --- a/src/components/Map/MapVectorViewer/utils/shapes/BasePolygon.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -/* eslint-disable no-magic-numbers */ -import { Coordinate } from 'ol/coordinate'; -import BasePolygon from '@/components/Map/MapVectorViewer/utils/shapes/BasePolygon'; -import { usePointToProjection, UsePointToProjectionResult } from '@/utils/map/usePointToProjection'; -import Style from 'ol/style/Style'; -import { - GetReduxWrapperUsingSliceReducer, - getReduxWrapperWithStore, -} from '@/utils/testing/getReduxWrapperWithStore'; -import { renderHook } from '@testing-library/react'; -import { initialMapStateFixture } from '@/redux/map/map.fixtures'; - -const getPointToProjection = ( - wrapper: ReturnType<GetReduxWrapperUsingSliceReducer>['Wrapper'], -): UsePointToProjectionResult => { - const { result: usePointToProjectionHook } = renderHook(() => usePointToProjection(), { - wrapper, - }); - - return usePointToProjectionHook.current; -}; - -describe('BasePolygon', () => { - const { Wrapper } = getReduxWrapperWithStore({ - map: initialMapStateFixture, - }); - const pointToProjection = getPointToProjection(Wrapper); - - it('should create a polygon with default styles', () => { - const points: Array<Coordinate> = [ - pointToProjection({ x: 0, y: 0 }), - pointToProjection({ x: 10, y: 0 }), - pointToProjection({ x: 10, y: 10 }), - pointToProjection({ x: 0, y: 10 }), - ]; - - const polygon = new BasePolygon({ points }); - - expect(polygon).toBeInstanceOf(BasePolygon); - - expect(polygon.polygonFeature.getGeometry()?.getCoordinates()).toEqual([points]); - - const style = polygon.polygonFeature.getStyle(); - if (style instanceof Style) { - expect(style.getFill()?.getColor()).toBe('#fff'); - - expect(style.getText()?.getText()).toBe(''); - } - }); - - it('should create a polygon with custom fill and text', () => { - const points: Array<Coordinate> = [ - pointToProjection({ x: 0, y: 0 }), - pointToProjection({ x: 10, y: 0 }), - pointToProjection({ x: 10, y: 10 }), - pointToProjection({ x: 0, y: 10 }), - ]; - - const polygon = new BasePolygon({ points, fill: '#ff0000', text: 'Test' }); - const style = polygon.polygonFeature.getStyle(); - - if (style instanceof Style) { - expect(style.getFill()?.getColor()).toBe('#ff0000'); - - expect(style.getText()?.getText()).toBe('Test'); - } - }); -}); diff --git a/src/components/Map/MapVectorViewer/utils/shapes/BasePolygon.ts b/src/components/Map/MapVectorViewer/utils/shapes/BasePolygon.ts deleted file mode 100644 index 7e20478e6921cb0b470e186e7577793592ef0f39..0000000000000000000000000000000000000000 --- a/src/components/Map/MapVectorViewer/utils/shapes/BasePolygon.ts +++ /dev/null @@ -1,57 +0,0 @@ -/* eslint-disable no-magic-numbers */ -import { Fill, Stroke, Style, Text } from 'ol/style'; -import Feature from 'ol/Feature'; -import { Polygon } from 'ol/geom'; -import { Coordinate } from 'ol/coordinate'; - -function getPolygonStyle(text: string, fill: string): Style { - return new Style({ - stroke: new Stroke({ - color: '#000', - width: 1, - }), - fill: new Fill({ - color: fill, - }), - text: new Text({ - text, - font: '18px Calibri,sans-serif', - fill: new Fill({ - color: '#000', - }), - placement: 'point', - }), - }); -} - -export default class BasePolygon { - private readonly polygonCoords: Array<Array<Array<number>>>; - - private readonly polygonStyle: Style; - - polygonFeature: Feature<Polygon>; - - constructor({ - points, - fill = '#fff', - text = '', - }: { - points: Array<Coordinate>; - fill?: string; - text?: string; - }) { - this.polygonCoords = [points]; - - this.polygonFeature = this.getPolygonFeature(); - - this.polygonStyle = getPolygonStyle(text, fill); - - this.polygonFeature.setStyle(this.polygonStyle); - } - - getPolygonFeature(): Feature<Polygon> { - return new Feature({ - geometry: new Polygon(this.polygonCoords), - }); - } -} diff --git a/src/components/Map/MapVectorViewer/utils/useOlMap.test.ts b/src/components/Map/MapVectorViewer/utils/useOlMap.test.ts deleted file mode 100644 index 79bc1ed1bb956a9ad94c51a635a721d6a1a93f3d..0000000000000000000000000000000000000000 --- a/src/components/Map/MapVectorViewer/utils/useOlMap.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { renderHook, waitFor } from '@testing-library/react'; -import { Map } from 'ol'; -import React from 'react'; -import { getReduxWrapperWithStore } from '@/utils/testing/getReduxWrapperWithStore'; -import { initialMapStateFixture } from '@/redux/map/map.fixtures'; -import { BACKGROUND_INITIAL_STATE_MOCK } from '@/redux/backgrounds/background.mock'; -import { useOlMap } from './useOlMap'; - -const useRefValue = { - current: null, -}; - -Object.defineProperty(useRefValue, 'current', { - get: jest.fn(() => ({ - innerHTML: '', - appendChild: jest.fn(), - addEventListener: jest.fn(), - getRootNode: jest.fn(), - })), - set: jest.fn(() => ({ - innerHTML: '', - appendChild: jest.fn(), - addEventListener: jest.fn(), - getRootNode: jest.fn(), - })), -}); - -jest.spyOn(React, 'useRef').mockReturnValue(useRefValue); - -describe('useOlMap - util', () => { - const { Wrapper } = getReduxWrapperWithStore({ - map: initialMapStateFixture, - backgrounds: BACKGROUND_INITIAL_STATE_MOCK, - }); - - describe('when initializing', () => { - it('should set map instance', async () => { - const dummyElement = document.createElement('div'); - const { result } = renderHook(() => useOlMap({ target: dummyElement }), { wrapper: Wrapper }); - await waitFor(() => expect(result.current.mapInstance).toBeInstanceOf(Map)); - }); - - it('should render content inside the target element', async () => { - const FIRST_NODE = 0; - const dummyElement = document.createElement('div'); - renderHook(() => useOlMap({ target: dummyElement }), { wrapper: Wrapper }); - - expect(dummyElement.childNodes[FIRST_NODE]).toHaveClass('ol-viewport'); - }); - }); -}); diff --git a/src/components/Map/MapVectorViewer/utils/useOlMap.ts b/src/components/Map/MapVectorViewer/utils/useOlMap.ts deleted file mode 100644 index 387afc6581c0b963a975df314fd50856fec3c974..0000000000000000000000000000000000000000 --- a/src/components/Map/MapVectorViewer/utils/useOlMap.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { MapInstance } from '@/types/map'; -import { useMapInstance } from '@/utils/context/mapInstanceContext'; -import Map from 'ol/Map'; -import { Zoom } from 'ol/control'; -import React, { MutableRefObject, useEffect } from 'react'; -import { useOlMapLayers } from '@/components/Map/MapVectorViewer/utils/config/useOlMapLayers'; -import { useOlMapView } from '@/components/Map/MapVectorViewer/utils/config/useOlMapView'; -import { useOlMapListeners } from '@/components/Map/MapVectorViewer/utils/listeners/useOlMapListeners'; - -interface UseOlMapInput { - target?: HTMLElement; -} -interface UseOlMapOutput { - mapRef: MutableRefObject<null | HTMLDivElement>; - mapInstance: MapInstance; -} - -type UseOlMap = (input?: UseOlMapInput) => UseOlMapOutput; - -export const useOlMap: UseOlMap = ({ target } = {}) => { - const mapRef = React.useRef<null | HTMLDivElement>(null); - const { mapInstance, handleSetMapInstance } = useMapInstance(); - - const view = useOlMapView({ mapInstance }); - useOlMapLayers({ mapInstance }); - useOlMapListeners({ view }); - - useEffect(() => { - if (!mapRef.current || mapRef.current.innerHTML !== '') { - return; - } - - const map = new Map({ - target: target || mapRef.current, - }); - map.getControls().forEach(mapControl => { - if (mapControl instanceof Zoom) { - map.removeControl(mapControl); - } - }); - - handleSetMapInstance(map); - }, [target, handleSetMapInstance]); - - return { - mapRef, - mapInstance, - }; -}; diff --git a/src/components/Map/MapViewer/MapViewerVector/MapViewerVector.constants.ts b/src/components/Map/MapViewer/MapViewerVector/MapViewerVector.constants.ts new file mode 100644 index 0000000000000000000000000000000000000000..d038cdf39f083a21b48dad564fcd513094c0adc2 --- /dev/null +++ b/src/components/Map/MapViewer/MapViewerVector/MapViewerVector.constants.ts @@ -0,0 +1,11 @@ +import { ColorObject } from '@/components/Map/MapViewer/MapViewerVector/MapViewerVector.types'; + +export const WHITE_COLOR: ColorObject = { + alpha: 255, + rgb: 16777215, +}; + +export const BLACK_COLOR: ColorObject = { + alpha: 255, + rgb: -16777216, +}; diff --git a/src/components/Map/MapViewer/MapViewerVector/MapViewerVector.types.ts b/src/components/Map/MapViewer/MapViewerVector/MapViewerVector.types.ts new file mode 100644 index 0000000000000000000000000000000000000000..78e365e3a1fc14eccc28c28ec737740f1f77519b --- /dev/null +++ b/src/components/Map/MapViewer/MapViewerVector/MapViewerVector.types.ts @@ -0,0 +1,47 @@ +import View from 'ol/View'; +import BaseLayer from 'ol/layer/Base'; + +export type MapConfig = { + view: View; + layers: BaseLayer[]; +}; + +export type VerticalAlign = 'TOP' | 'MIDDLE' | 'BOTTOM'; +export type HorizontalAlign = 'LEFT' | 'RIGHT' | 'CENTER' | 'END' | 'START'; + +export type ColorObject = { + alpha: number; + rgb: number; +}; + +export type ShapePoint = { + type: string; + absoluteX: number; + absoluteY: number; + relativeX: number; + relativeY: number; + relativeHeightForX: number | null; + relativeWidthForY: number | null; +}; + +export type ShapeCurvePoint = { + type: string; + absoluteX1: number; + absoluteY1: number; + relativeX1: number; + relativeY1: number; + relativeHeightForX1: number | null; + relativeWidthForY1: number | null; + absoluteX2: number; + absoluteY2: number; + relativeX2: number; + relativeY2: number; + relativeHeightForX2: number | null; + relativeWidthForY2: number | null; + absoluteX3: number; + absoluteY3: number; + relativeX3: number; + relativeY3: number; + relativeHeightForX3: number | null; + relativeWidthForY3: number | null; +}; diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/config/reactionsLayer/useOlMapReactionsLayer.test.ts b/src/components/Map/MapViewer/MapViewerVector/utils/config/reactionsLayer/useOlMapReactionsLayer.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..b54a29b43672e2c9e8a34749bf856ade5079e463 --- /dev/null +++ b/src/components/Map/MapViewer/MapViewerVector/utils/config/reactionsLayer/useOlMapReactionsLayer.test.ts @@ -0,0 +1,25 @@ +import { getReduxWrapperWithStore } from '@/utils/testing/getReduxWrapperWithStore'; +import { renderHook } from '@testing-library/react'; +import VectorLayer from 'ol/layer/Vector'; +import { initialMapStateFixture } from '@/redux/map/map.fixtures'; +import { BACKGROUND_INITIAL_STATE_MOCK } from '@/redux/backgrounds/background.mock'; +import { Map } from 'ol'; +import { useOlMapReactionsLayer } from '@/components/Map/MapViewer/MapViewerVector/utils/config/reactionsLayer/useOlMapReactionsLayer'; + +describe('useOlMapReactionsLayer - util', () => { + it('should return VectorLayer', () => { + const { Wrapper } = getReduxWrapperWithStore({ + map: initialMapStateFixture, + backgrounds: BACKGROUND_INITIAL_STATE_MOCK, + }); + + const dummyElement = document.createElement('div'); + const mapInstance = new Map({ target: dummyElement }); + const { result } = renderHook(() => useOlMapReactionsLayer({ mapInstance }), { + wrapper: Wrapper, + }); + + expect(result.current).toBeInstanceOf(VectorLayer); + expect(result.current.getSourceState()).toBe('ready'); + }); +}); diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/config/reactionsLayer/useOlMapReactionsLayer.ts b/src/components/Map/MapViewer/MapViewerVector/utils/config/reactionsLayer/useOlMapReactionsLayer.ts new file mode 100644 index 0000000000000000000000000000000000000000..ba0cb0480e5fdec0ce8942cc6d717ede32e98cd6 --- /dev/null +++ b/src/components/Map/MapViewer/MapViewerVector/utils/config/reactionsLayer/useOlMapReactionsLayer.ts @@ -0,0 +1,87 @@ +/* eslint-disable no-magic-numbers */ +import { Feature } from 'ol'; +import VectorLayer from 'ol/layer/Vector'; +import VectorSource from 'ol/source/Vector'; +import { useEffect, useMemo } from 'react'; +import { usePointToProjection } from '@/utils/map/usePointToProjection'; +import CustomMultiPolygon from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/CustomMultiPolygon'; +import { useSelector } from 'react-redux'; +import { shapesSelector } from '@/redux/shapes/shapes.selectors'; +import { MapInstance } from '@/types/map'; +import { + HorizontalAlign, + VerticalAlign, +} from '@/components/Map/MapViewer/MapViewerVector/MapViewerVector.types'; +import { modelElementsSelector } from '@/redux/modelElements/modelElements.selector'; +import { currentModelIdSelector } from '@/redux/models/models.selectors'; +import { getModelElements } from '@/redux/modelElements/modelElements.thunks'; +import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; + +export const useOlMapReactionsLayer = ({ + mapInstance, +}: { + mapInstance: MapInstance; +}): VectorLayer<VectorSource<Feature>> => { + const dispatch = useAppDispatch(); + const modelElements = useSelector(modelElementsSelector); + const currentModelId = useSelector(currentModelIdSelector); + useEffect(() => { + dispatch(getModelElements(currentModelId)); + }, [currentModelId, dispatch]); + + const pointToProjection = usePointToProjection(); + const shapes = useSelector(shapesSelector); + + const elements = useMemo(() => { + if (modelElements) { + return modelElements.content.map(element => { + const shape = shapes.data.find(bioShape => bioShape.sboTerm === element.sboTerm); + if (shape) { + return new CustomMultiPolygon({ + shapes: shape.shapes, + x: element.x, + y: element.y, + nameX: element.nameX, + nameY: element.nameY, + nameHeight: element.nameHeight, + nameWidth: element.nameWidth, + width: element.width, + height: element.height, + zIndex: element.z, + lineWidth: element.lineWidth, + fontColor: element.fontColor, + fillColor: element.fillColor, + borderColor: element.borderColor, + nameVerticalAlign: element.nameVerticalAlign as VerticalAlign, + nameHorizontalAlign: element.nameHorizontalAlign as HorizontalAlign, + text: element.name, + pointToProjection, + mapInstance, + }); + } + return undefined; + }); + } + return []; + }, [mapInstance, pointToProjection, shapes.data, modelElements]); + + const features = useMemo(() => { + return elements + .filter((element): element is CustomMultiPolygon => element !== undefined) + .map(element => element.multiPolygonFeature); + }, [elements]); + + const vectorSource = useMemo(() => { + return new VectorSource({ + features, + }); + }, [features]); + + return useMemo( + () => + new VectorLayer({ + source: vectorSource, + }), + [vectorSource], + ); +}; diff --git a/src/components/Map/MapVectorViewer/utils/config/useOlMapLayers.test.ts b/src/components/Map/MapViewer/MapViewerVector/utils/config/useOlMapVectorLayers.test.ts similarity index 67% rename from src/components/Map/MapVectorViewer/utils/config/useOlMapLayers.test.ts rename to src/components/Map/MapViewer/MapViewerVector/utils/config/useOlMapVectorLayers.test.ts index 0dd9389dc3961bc7db5bb2a30f69034cf73da59a..eee20645689fe08ed27adb4803c8e567adce9e73 100644 --- a/src/components/Map/MapVectorViewer/utils/config/useOlMapLayers.test.ts +++ b/src/components/Map/MapViewer/MapViewerVector/utils/config/useOlMapVectorLayers.test.ts @@ -1,15 +1,12 @@ /* eslint-disable no-magic-numbers */ -import { BACKGROUND_INITIAL_STATE_MOCK } from '@/redux/backgrounds/background.mock'; import { MAP_DATA_INITIAL_STATE, OPENED_MAPS_INITIAL_STATE } from '@/redux/map/map.constants'; -import { initialMapStateFixture } from '@/redux/map/map.fixtures'; import { getReduxWrapperWithStore } from '@/utils/testing/getReduxWrapperWithStore'; -import { renderHook, waitFor } from '@testing-library/react'; -import { Map } from 'ol'; +import { renderHook } from '@testing-library/react'; import BaseLayer from 'ol/layer/Base'; import VectorLayer from 'ol/layer/Vector'; import React from 'react'; -import { useOlMapLayers } from '@/components/Map/MapVectorViewer/utils/config/useOlMapLayers'; -import { useOlMap } from '@/components/Map/MapVectorViewer/utils/useOlMap'; +import { useOlMap } from '@/components/Map/MapViewer/utils/useOlMap'; +import { useOlMapVectorLayers } from '@/components/Map/MapViewer/MapViewerVector/utils/config/useOlMapVectorLayers'; const useRefValue = { current: null, @@ -33,24 +30,6 @@ Object.defineProperty(useRefValue, 'current', { jest.spyOn(React, 'useRef').mockReturnValue(useRefValue); describe('useOlMapLayers - util', () => { - it('should modify layers of the map instance on init', async () => { - const { Wrapper } = getReduxWrapperWithStore({ - map: initialMapStateFixture, - backgrounds: BACKGROUND_INITIAL_STATE_MOCK, - }); - - const dummyElement = document.createElement('div'); - const mapInstance = new Map({ target: dummyElement }); - const setLayersSpy = jest.spyOn(mapInstance, 'setLayers'); - const CALLED_ONCE = 1; - - renderHook(() => useOlMapLayers({ mapInstance }), { - wrapper: Wrapper, - }); - - await waitFor(() => expect(setLayersSpy).toBeCalledTimes(CALLED_ONCE)); - }); - const getRenderedHookResults = (): BaseLayer[] => { const { Wrapper } = getReduxWrapperWithStore({ map: { @@ -88,7 +67,7 @@ describe('useOlMapLayers - util', () => { }); const { result } = renderHook( - () => useOlMapLayers({ mapInstance: hohResult.current.mapInstance }), + () => useOlMapVectorLayers({ mapInstance: hohResult.current.mapInstance }), { wrapper: Wrapper, }, diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/config/useOlMapVectorLayers.ts b/src/components/Map/MapViewer/MapViewerVector/utils/config/useOlMapVectorLayers.ts new file mode 100644 index 0000000000000000000000000000000000000000..52cf8ab0c4b70dad32e1733178ba511edd77ec9e --- /dev/null +++ b/src/components/Map/MapViewer/MapViewerVector/utils/config/useOlMapVectorLayers.ts @@ -0,0 +1,16 @@ +/* eslint-disable no-magic-numbers */ +import { MapInstance } from '@/types/map'; +import { useOlMapWhiteCardLayer } from '@/components/Map/MapViewer/MapViewerVector/utils/config/useOlMapWhiteCardLayer'; +import { MapConfig } from '../../MapViewerVector.types'; +import { useOlMapReactionsLayer } from './reactionsLayer/useOlMapReactionsLayer'; + +interface UseOlMapLayersInput { + mapInstance: MapInstance; +} + +export const useOlMapVectorLayers = ({ mapInstance }: UseOlMapLayersInput): MapConfig['layers'] => { + const reactionsLayer = useOlMapReactionsLayer({ mapInstance }); + const whiteCardLayer = useOlMapWhiteCardLayer(); + + return [whiteCardLayer, reactionsLayer]; +}; diff --git a/src/components/Map/MapVectorViewer/utils/config/useOlMapWhiteCardLayer.test.ts b/src/components/Map/MapViewer/MapViewerVector/utils/config/useOlMapWhiteCardLayer.test.ts similarity index 100% rename from src/components/Map/MapVectorViewer/utils/config/useOlMapWhiteCardLayer.test.ts rename to src/components/Map/MapViewer/MapViewerVector/utils/config/useOlMapWhiteCardLayer.test.ts diff --git a/src/components/Map/MapVectorViewer/utils/config/useOlMapWhiteCardLayer.ts b/src/components/Map/MapViewer/MapViewerVector/utils/config/useOlMapWhiteCardLayer.ts similarity index 100% rename from src/components/Map/MapVectorViewer/utils/config/useOlMapWhiteCardLayer.ts rename to src/components/Map/MapViewer/MapViewerVector/utils/config/useOlMapWhiteCardLayer.ts diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/CustomMultiPolygon.test.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/CustomMultiPolygon.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..f5de8dbe0c3516fbd031b857f3a654c09585efbc --- /dev/null +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/CustomMultiPolygon.test.ts @@ -0,0 +1,120 @@ +/* eslint-disable no-magic-numbers */ +import { Feature, Map } from 'ol'; +import { Fill, Style, Text } from 'ol/style'; +import { Polygon, MultiPolygon } from 'ol/geom'; +import getText from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/getText'; +import getMultiPolygon from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/getMultiPolygon'; +import getStroke from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/getStroke'; +import getFill from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/getFill'; +import { rgbToHex } from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/rgbToHex'; +import CustomMultiPolygon, { + CustomMultiPolygonProps, +} from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/CustomMultiPolygon'; +import View from 'ol/View'; +import { + WHITE_COLOR, + BLACK_COLOR, +} from '@/components/Map/MapViewer/MapViewerVector/MapViewerVector.constants'; + +jest.mock('./getText'); +jest.mock('./getMultiPolygon'); +jest.mock('./getStroke'); +jest.mock('./getFill'); +jest.mock('./rgbToHex'); + +describe('CustomMultiPolygon', () => { + let props: CustomMultiPolygonProps; + + beforeEach(() => { + const dummyElement = document.createElement('div'); + const mapInstance = new Map({ + target: dummyElement, + view: new View({ + zoom: 5, + minZoom: 3, + maxZoom: 7, + }), + }); + props = { + shapes: [], + x: 0, + y: 0, + width: 100, + height: 100, + zIndex: 1, + fillColor: WHITE_COLOR, + borderColor: BLACK_COLOR, + fontColor: BLACK_COLOR, + lineWidth: 2, + text: 'Test Text', + fontSize: 12, + nameX: 10, + nameY: 20, + nameHeight: 30, + nameWidth: 40, + nameVerticalAlign: 'MIDDLE', + nameHorizontalAlign: 'CENTER', + pointToProjection: jest.fn(), + mapInstance, + }; + + (getText as jest.Mock).mockReturnValue({ + textCoords: [0, 0], + textStyle: new Style({ + text: new Text({ + text: props.text, + font: `bold ${props.fontSize}px Arial`, + fill: new Fill({ + color: '#000', + }), + placement: 'point', + textAlign: 'center', + textBaseline: 'middle', + }), + }), + }); + (getMultiPolygon as jest.Mock).mockReturnValue([ + new Polygon([ + [ + [0, 0], + [1, 1], + [2, 2], + ], + ]), + ]); + (getStroke as jest.Mock).mockReturnValue(new Style()); + (getFill as jest.Mock).mockReturnValue(new Style()); + (rgbToHex as jest.Mock).mockReturnValue('#FFFFFF'); + }); + + it('should initialize with correct default properties', () => { + const multiPolygon = new CustomMultiPolygon(props); + + expect(multiPolygon.polygons.length).toBe(2); + expect(multiPolygon.multiPolygonFeature).toBeInstanceOf(Feature); + expect(multiPolygon.multiPolygonFeature.getGeometry()).toBeInstanceOf(MultiPolygon); + }); + + it('should apply correct styles to the feature', () => { + const multiPolygon = new CustomMultiPolygon(props); + const feature = multiPolygon.multiPolygonFeature; + + const style = feature.getStyleFunction()?.call(multiPolygon, feature, 1); + + if (Array.isArray(style)) { + expect(style.length).toBeGreaterThan(0); + } else { + expect(style).toBeInstanceOf(Style); + } + }); + + it('should update text style based on resolution', () => { + const multiPolygon = new CustomMultiPolygon(props); + const feature = multiPolygon.multiPolygonFeature; + + multiPolygon.styleFunction(feature, 1000); + if (multiPolygon.textStyle) { + expect(multiPolygon.textStyle.getText()?.getScale()).toBe(1.22); + } + }); +}); diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/CustomMultiPolygon.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/CustomMultiPolygon.ts new file mode 100644 index 0000000000000000000000000000000000000000..4b339096ca4ee28686df66a5e3edd930a60878d0 --- /dev/null +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/CustomMultiPolygon.ts @@ -0,0 +1,150 @@ +/* eslint-disable no-magic-numbers */ +import { Style } from 'ol/style'; +import Feature, { FeatureLike } from 'ol/Feature'; +import { Geometry, MultiPolygon } from 'ol/geom'; +import { UsePointToProjectionResult } from '@/utils/map/usePointToProjection'; +import getStroke from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/getStroke'; +import getFill from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/getFill'; +import RenderFeature from 'ol/render/Feature'; +import Polygon from 'ol/geom/Polygon'; +import getMultiPolygon from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/getMultiPolygon'; +import getText from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/getText'; +import { rgbToHex } from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/rgbToHex'; +import { Shape } from '@/types/models'; +import { MapInstance } from '@/types/map'; +import { + ColorObject, + HorizontalAlign, + VerticalAlign, +} from '@/components/Map/MapViewer/MapViewerVector/MapViewerVector.types'; +import { + BLACK_COLOR, + WHITE_COLOR, +} from '@/components/Map/MapViewer/MapViewerVector/MapViewerVector.constants'; + +export type CustomMultiPolygonProps = { + shapes: Array<Shape>; + x: number; + y: number; + width: number; + height: number; + zIndex: number; + fillColor?: ColorObject; + borderColor?: ColorObject; + fontColor?: ColorObject; + lineWidth?: number; + text?: string; + fontSize?: string | number; + nameX: number; + nameY: number; + nameHeight: number; + nameWidth: number; + nameVerticalAlign?: VerticalAlign; + nameHorizontalAlign?: HorizontalAlign; + pointToProjection: UsePointToProjectionResult; + mapInstance: MapInstance; +}; + +export default class CustomMultiPolygon { + multiPolygonStyle: Style; + + textStyle: Style | undefined; + + polygons: Array<Polygon> = []; + + multiPolygonFeature: Feature; + + constructor({ + shapes, + x, + y, + width, + height, + zIndex, + fillColor = WHITE_COLOR, + borderColor = BLACK_COLOR, + fontColor = BLACK_COLOR, + lineWidth = 1, + text = '', + fontSize = 12, + nameX, + nameY, + nameHeight, + nameWidth, + nameVerticalAlign = 'MIDDLE', + nameHorizontalAlign = 'CENTER', + pointToProjection, + mapInstance, + }: CustomMultiPolygonProps) { + if (text) { + const { textCoords, textStyle } = getText({ + text, + fontSize, + x: nameX, + y: nameY, + width: nameWidth, + height: nameHeight, + color: rgbToHex(fontColor), + verticalAlign: nameVerticalAlign, + horizontalAlign: nameHorizontalAlign, + pointToProjection, + }); + this.textStyle = new Style({ + geometry: (feature: FeatureLike): Geometry | RenderFeature | undefined => { + const geometry = feature.getGeometry(); + if (geometry && geometry.getType() === 'MultiPolygon') { + return (geometry as MultiPolygon).getPolygon(0).getInteriorPoint(); + } + return undefined; + }, + text: textStyle.getText() || undefined, + zIndex, + }); + this.polygons.push(new Polygon([[textCoords, textCoords]])); + } + + this.polygons = [ + ...this.polygons, + ...getMultiPolygon({ x, y, width, height, shapes, pointToProjection }), + ]; + + this.multiPolygonFeature = new Feature({ + geometry: new MultiPolygon([...this.polygons]), + getTextScale: (resolution: number): number => { + const maxZoom = mapInstance?.getView().getMaxZoom(); + if (maxZoom) { + const minResolution = mapInstance?.getView().getResolutionForZoom(maxZoom); + if (minResolution) { + return Math.round((minResolution / resolution) * 100) / 100; + } + } + return 1; + }, + }); + + this.multiPolygonStyle = new Style({ + stroke: getStroke({ color: rgbToHex(borderColor), width: lineWidth }), + fill: getFill({ color: rgbToHex(fillColor) }), + zIndex, + }); + + this.multiPolygonFeature.setStyle(this.styleFunction.bind(this)); + } + + styleFunction(feature: FeatureLike, resolution: number): Style | Array<Style> | void { + const getTextScale = feature.get('getTextScale'); + let textScale = 1; + if (getTextScale instanceof Function) { + textScale = getTextScale(resolution); + } + const styles = []; + if (this.textStyle) { + const text = this.textStyle.getText(); + if (text) { + this.textStyle.getText()?.setScale(textScale); + styles.push(this.textStyle); + } + } + return [...styles, this.multiPolygonStyle]; + } +} diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getBezierCurve.test.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getBezierCurve.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..cfb6a22c586c24efeedb0dd4725e7deb1a77e697 --- /dev/null +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getBezierCurve.test.ts @@ -0,0 +1,78 @@ +/* eslint-disable no-magic-numbers */ +import getBezierCurve from './getBezierCurve'; + +describe('getBezierCurve', () => { + it('should return an array of coordinates representing a Bezier curve', () => { + const p0 = [0, 0]; + const p1 = [10, 20]; + const p2 = [20, 20]; + const p3 = [30, 0]; + + const result = getBezierCurve({ + p0, + p1, + p2, + p3, + numPoints: 50, + }); + + expect(result).toHaveLength(51); + + expect(result[0]).toEqual(p0); + expect(result[result.length - 1]).toEqual(p3); + + result.forEach(coord => { + expect(coord).toHaveLength(2); + expect(typeof coord[0]).toBe('number'); + expect(typeof coord[1]).toBe('number'); + }); + }); + + it('should generate a different number of points based on numPoints', () => { + const p0 = [0, 0]; + const p1 = [10, 20]; + const p2 = [20, 20]; + const p3 = [30, 0]; + + const result100 = getBezierCurve({ + p0, + p1, + p2, + p3, + numPoints: 100, + }); + + const result10 = getBezierCurve({ + p0, + p1, + p2, + p3, + numPoints: 10, + }); + + expect(result100).toHaveLength(101); + expect(result10).toHaveLength(11); + }); + + it('should return correct coordinates for a simple straight line', () => { + const p0 = [0, 0]; + const p1 = [0, 0]; + const p2 = [10, 10]; + const p3 = [10, 10]; + + const result = getBezierCurve({ + p0, + p1, + p2, + p3, + numPoints: 10, + }); + + expect(result[0]).toEqual([0, 0]); + expect(result[result.length - 1]).toEqual([10, 10]); + + result.forEach(coord => { + expect(coord[0]).toBe(coord[1]); + }); + }); +}); diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getBezierCurve.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getBezierCurve.ts new file mode 100644 index 0000000000000000000000000000000000000000..814e7aac4dcab68cf9cb5a0e04b0763eaf97f0bd --- /dev/null +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getBezierCurve.ts @@ -0,0 +1,39 @@ +/* eslint-disable no-magic-numbers */ +import { Coordinate } from 'ol/coordinate'; + +export default function getBezierCurve({ + p0, + p1, + p2, + p3, + numPoints = 50, +}: { + p0: Coordinate; + p1: Coordinate; + p2: Coordinate; + p3: Coordinate; + numPoints?: number; +}): Array<Coordinate> { + const curve: Array<Coordinate> = []; + + for (let i = 0; i < numPoints; i += 1) { + const t = parseFloat((i / (numPoints - 1)).toFixed(3)); + + const x = + (1 - t) ** 3 * p0[0] + + 3 * (1 - t) ** 2 * t * p1[0] + + 3 * (1 - t) * t ** 2 * p2[0] + + t ** 3 * p3[0]; + + const y = + (1 - t) ** 3 * p0[1] + + 3 * (1 - t) ** 2 * t * p1[1] + + 3 * (1 - t) * t ** 2 * p2[1] + + t ** 3 * p3[1]; + + curve.push([x, y]); + } + curve.push(p3); + + return curve; +} diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getCoordsX.test.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getCoordsX.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..ddf8648e49fb6ade7eed11b17217fa3dd397385d --- /dev/null +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getCoordsX.test.ts @@ -0,0 +1,53 @@ +/* eslint-disable no-magic-numbers */ + +import getCoordsX from './getCoordsX'; + +describe('getCoordsX', () => { + it('should calculate the correct coordinate when all parameters are provided', () => { + const x = 10; + const absoluteX = 20; + const relativeX = 10; + const relativeHeightForX = 5; + const height = 100; + const width = 200; + + const result = getCoordsX(x, absoluteX, relativeX, relativeHeightForX, height, width); + expect(result).toBe(55); + }); + + it('should handle null for relativeHeightForX correctly', () => { + const x = 10; + const absoluteX = 20; + const relativeX = 10; + const relativeHeightForX = null; + const height = 100; + const width = 200; + + const result = getCoordsX(x, absoluteX, relativeX, relativeHeightForX, height, width); + expect(result).toBe(50); + }); + + it('should calculate the correct coordinate when relativeX and relativeHeightForX are zero', () => { + const x = 10; + const absoluteX = 20; + const relativeX = 0; + const relativeHeightForX = 0; + const height = 100; + const width = 200; + + const result = getCoordsX(x, absoluteX, relativeX, relativeHeightForX, height, width); + expect(result).toBe(30); + }); + + it('should handle negative values for parameters', () => { + const x = -10; + const absoluteX = -20; + const relativeX = -10; + const relativeHeightForX = -5; + const height = 100; + const width = 200; + + const result = getCoordsX(x, absoluteX, relativeX, relativeHeightForX, height, width); + expect(result).toBe(-55); + }); +}); diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getCoordsX.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getCoordsX.ts new file mode 100644 index 0000000000000000000000000000000000000000..4cc48ddbfb89cf1b12b2040998b0cb5a31dccf8b --- /dev/null +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getCoordsX.ts @@ -0,0 +1,11 @@ +/* eslint-disable no-magic-numbers */ +export default function getCoordsX( + x: number, + absoluteX: number, + relativeX: number, + relativeHeightForX: number | null, + height: number, + width: number, +): number { + return x + absoluteX + (width * relativeX) / 100 + (height * (relativeHeightForX || 0)) / 100; +} diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getCoordsY.test.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getCoordsY.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..d5c34139d43433c15c55b3878cc843b3f8517d08 --- /dev/null +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getCoordsY.test.ts @@ -0,0 +1,52 @@ +/* eslint-disable no-magic-numbers */ +import getCoordsY from './getCoordsY'; + +describe('getCoordsY', () => { + it('should calculate the correct coordinate when all parameters are provided', () => { + const x = 10; + const absoluteY = 20; + const relativeY = 10; + const relativeWidthForY = 5; + const height = 80; + const width = 150; + + const result = getCoordsY(x, absoluteY, relativeY, relativeWidthForY, height, width); + expect(result).toBe(45.5); + }); + + it('should handle null for relativeWidthForY correctly', () => { + const x = 10; + const absoluteY = 20; + const relativeY = 10; + const relativeWidthForY = null; + const height = 100; + const width = 140; + + const result = getCoordsY(x, absoluteY, relativeY, relativeWidthForY, height, width); + expect(result).toBe(40); + }); + + it('should calculate the correct coordinate when relativeY and relativeWidthForY are zero', () => { + const x = 10; + const absoluteY = 20; + const relativeY = 0; + const relativeWidthForY = 0; + const height = 100; + const width = 200; + + const result = getCoordsY(x, absoluteY, relativeY, relativeWidthForY, height, width); + expect(result).toBe(30); + }); + + it('should handle negative values for parameters', () => { + const x = -10; + const absoluteY = -20; + const relativeY = -10; + const relativeWidthForY = -5; + const height = 100; + const width = 200; + + const result = getCoordsY(x, absoluteY, relativeY, relativeWidthForY, height, width); + expect(result).toBe(-50); + }); +}); diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getCoordsY.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getCoordsY.ts new file mode 100644 index 0000000000000000000000000000000000000000..2fc132f219bf75fd736f03ceddafdc767b39103e --- /dev/null +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getCoordsY.ts @@ -0,0 +1,11 @@ +/* eslint-disable no-magic-numbers */ +export default function getCoordsY( + y: number, + absoluteY: number, + relativeY: number, + relativeWidthForY: number | null, + height: number, + width: number, +): number { + return y + absoluteY + (height * relativeY) / 100 + (width * (relativeWidthForY || 0)) / 100; +} diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getCurveCoords.test.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getCurveCoords.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..8ce8b569bb7a63899c8e4cea5895ae78d73b775d --- /dev/null +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getCurveCoords.test.ts @@ -0,0 +1,72 @@ +/* eslint-disable no-magic-numbers */ +import getCoordsX from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/getCoordsX'; +import getCoordsY from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/getCoordsY'; +import { UsePointToProjectionResult } from '@/utils/map/usePointToProjection'; +import { ShapeCurvePoint } from '@/components/Map/MapViewer/MapViewerVector/MapViewerVector.types'; +import getCurveCoords from './getCurveCoords'; + +jest.mock('./getCoordsX'); +jest.mock('./getCoordsY'); + +describe('getCurveCoords', () => { + const mockPointToProjection: UsePointToProjectionResult = jest.fn(point => [point.x, point.y]); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should calculate correct coordinates for p1, p2, and p3', () => { + const x = 10; + const y = 20; + const height = 100; + const width = 200; + + const point: ShapeCurvePoint = { + type: 'REL_ABS_POINT', + absoluteX1: 10, + relativeX1: 5, + relativeHeightForX1: 2, + absoluteY1: 15, + relativeY1: 3, + relativeWidthForY1: 1, + absoluteX2: 12, + relativeX2: 7, + relativeHeightForX2: 4, + absoluteY2: 16, + relativeY2: 4, + relativeWidthForY2: 2, + absoluteX3: 14, + relativeX3: 9, + relativeHeightForX3: 6, + absoluteY3: 18, + relativeY3: 5, + relativeWidthForY3: 3, + }; + + (getCoordsX as jest.Mock) + .mockReturnValueOnce(25) + .mockReturnValueOnce(35) + .mockReturnValueOnce(45); + (getCoordsY as jest.Mock) + .mockReturnValueOnce(30) + .mockReturnValueOnce(40) + .mockReturnValueOnce(50); + + const result = getCurveCoords({ + x, + y, + point, + height, + width, + pointToProjection: mockPointToProjection, + }); + + expect(result.p1).toEqual([35, 40]); + expect(result.p2).toEqual([45, 50]); + expect(result.p3).toEqual([25, 30]); + + expect(getCoordsX).toHaveBeenCalledTimes(3); + expect(getCoordsY).toHaveBeenCalledTimes(3); + expect(mockPointToProjection).toHaveBeenCalledTimes(3); + }); +}); diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getCurveCoords.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getCurveCoords.ts new file mode 100644 index 0000000000000000000000000000000000000000..2b6956348eafea711c9636ced732b4fa3422d729 --- /dev/null +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getCurveCoords.ts @@ -0,0 +1,35 @@ +import { UsePointToProjectionResult } from '@/utils/map/usePointToProjection'; +import { Coordinate } from 'ol/coordinate'; +import getCoordsX from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/getCoordsX'; +import getCoordsY from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/getCoordsY'; +import { ShapeCurvePoint } from '@/components/Map/MapViewer/MapViewerVector/MapViewerVector.types'; + +export default function getCurveCoords({ + x, + y, + point, + height, + width, + pointToProjection, +}: { + x: number; + y: number; + point: ShapeCurvePoint; + height: number; + width: number; + pointToProjection: UsePointToProjectionResult; +}): { p1: Coordinate; p2: Coordinate; p3: Coordinate } { + const p3 = pointToProjection({ + x: getCoordsX(x, point.absoluteX1, point.relativeX1, point.relativeHeightForX1, height, width), + y: getCoordsY(y, point.absoluteY1, point.relativeY1, point.relativeWidthForY1, height, width), + }); + const p1 = pointToProjection({ + x: getCoordsX(x, point.absoluteX2, point.relativeX2, point.relativeHeightForX2, height, width), + y: getCoordsY(y, point.absoluteY2, point.relativeY2, point.relativeWidthForY2, height, width), + }); + const p2 = pointToProjection({ + x: getCoordsX(x, point.absoluteX3, point.relativeX3, point.relativeHeightForX3, height, width), + y: getCoordsY(y, point.absoluteY3, point.relativeY3, point.relativeWidthForY3, height, width), + }); + return { p1, p2, p3 }; +} diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getEllipseCoords.test.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getEllipseCoords.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..cf6f4c49f75ca81e8f569d17d040f7e55e6c2817 --- /dev/null +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getEllipseCoords.test.ts @@ -0,0 +1,62 @@ +/* eslint-disable no-magic-numbers */ +import getCoordsX from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/getCoordsX'; +import getCoordsY from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/getCoordsY'; +import { UsePointToProjectionResult } from '@/utils/map/usePointToProjection'; +import getEllipseCoords from './getEllipseCoords'; + +jest.mock('./getCoordsX'); +jest.mock('./getCoordsY'); + +describe('getEllipseCoords', () => { + const mockPointToProjection: UsePointToProjectionResult = jest.fn(point => [point.x, point.y]); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should calculate correct coordinates for an ellipse', () => { + const x = 10; + const y = 20; + const height = 100; + const width = 200; + const center = { + type: 'center', + absoluteX: 15, + absoluteY: 25, + relativeX: 5, + relativeY: 10, + relativeHeightForX: null, + relativeWidthForY: null, + }; + const radius = { + type: 'radius', + absoluteX: 30, + absoluteY: 40, + relativeX: 10, + relativeY: 15, + }; + const points = 4; + + (getCoordsX as jest.Mock).mockReturnValue(35); + (getCoordsY as jest.Mock).mockReturnValue(55); + + const result = getEllipseCoords({ + x, + y, + center, + radius, + height, + width, + pointToProjection: mockPointToProjection, + points, + }); + + expect(result).toHaveLength(points + 1); + + expect(result[0]).toEqual(result[result.length - 1]); + + expect(getCoordsX).toHaveBeenCalledTimes(1); + expect(getCoordsY).toHaveBeenCalledTimes(1); + expect(mockPointToProjection).toHaveBeenCalledTimes(points); + }); +}); diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getEllipseCoords.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getEllipseCoords.ts new file mode 100644 index 0000000000000000000000000000000000000000..fa713e8ab27865ac7c42a49cacb98f400e4123da --- /dev/null +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getEllipseCoords.ts @@ -0,0 +1,76 @@ +/* eslint-disable no-magic-numbers */ +import getCoordsX from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/getCoordsX'; +import getCoordsY from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/getCoordsY'; +import { Coordinate } from 'ol/coordinate'; +import { UsePointToProjectionResult } from '@/utils/map/usePointToProjection'; + +type EllipseCenter = { + type: string; + absoluteX: number; + absoluteY: number; + relativeX: number; + relativeY: number; + relativeHeightForX: number | null; + relativeWidthForY: number | null; +}; + +type EllipseRadius = { + type: string; + absoluteX: number; + absoluteY: number; + relativeX: number; + relativeY: number; +}; + +export default function getEllipseCoords({ + x, + y, + center, + radius, + height, + width, + pointToProjection, + points = 64, +}: { + x: number; + y: number; + center: EllipseCenter; + radius: EllipseRadius; + height: number; + width: number; + pointToProjection: UsePointToProjectionResult; + points?: number; +}): Array<Coordinate> { + const centerX = getCoordsX( + x, + center.absoluteX, + center.relativeX, + center.relativeHeightForX, + height, + width, + ); + const centerY = getCoordsY( + y, + center.absoluteY, + center.relativeY, + center.relativeWidthForY, + height, + width, + ); + const radiusX = radius.absoluteX + (radius.relativeX * width) / 100; + const radiusY = radius.absoluteY + (radius.relativeY * height) / 100; + let angle; + let coordsX; + let coordsY; + const coordinates: Array<Coordinate> = []; + + for (let i = 0; i < points; i += 1) { + angle = (i / points) * 2 * Math.PI; + coordsX = centerX + radiusX * Math.cos(angle); + coordsY = centerY + radiusY * Math.sin(angle); + coordinates.push(pointToProjection({ x: coordsX, y: coordsY })); + } + coordinates.push(coordinates[0]); + + return coordinates; +} diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getFill.test.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getFill.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..2af3fe0fb197ee0851a9b51747aca5ba205b9d09 --- /dev/null +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getFill.test.ts @@ -0,0 +1,17 @@ +import { Fill } from 'ol/style'; +import getFill from './getFill'; + +describe('getFill', () => { + it('should return a Fill object with the default color when no color is provided', () => { + const result = getFill({}); + expect(result).toBeInstanceOf(Fill); + expect(result.getColor()).toBe('#fff'); + }); + + it('should return a Fill object with the provided color', () => { + const color = '#ff0000'; + const result = getFill({ color }); + expect(result).toBeInstanceOf(Fill); + expect(result.getColor()).toBe(color); + }); +}); diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getFill.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getFill.ts new file mode 100644 index 0000000000000000000000000000000000000000..78a401c2366654cfc207adee5258899dc626e982 --- /dev/null +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getFill.ts @@ -0,0 +1,8 @@ +/* eslint-disable no-magic-numbers */ +import { Fill } from 'ol/style'; + +export default function getFill({ color = '#fff' }: { color?: string }): Fill { + return new Fill({ + color, + }); +} diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getMultiPolygon.test.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getMultiPolygon.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..76036f498b7d92ab4040ed570e0fd6b7959582e4 --- /dev/null +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getMultiPolygon.test.ts @@ -0,0 +1,235 @@ +/* eslint-disable no-magic-numbers */ +import getPolygonCoords from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/getPolygonCoords'; +import getEllipseCoords from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/getEllipseCoords'; +import Polygon from 'ol/geom/Polygon'; +import { Shape } from '@/types/models'; +import getMultiPolygon from './getMultiPolygon'; + +jest.mock('./getPolygonCoords'); +jest.mock('./getEllipseCoords'); + +describe('getMultiPolygon', () => { + const mockPointToProjection = jest.fn(point => [point.x, point.y]); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return an array of Polygons for POLYGON shapes', () => { + const x = 10; + const y = 20; + const width = 100; + const height = 200; + + const shapes: Shape[] = [ + { + type: 'POLYGON', + points: [ + { + type: 'REL_ABS_POINT', + absoluteX: 0.0, + absoluteY: 0.0, + relativeX: 0.0, + relativeY: 0.0, + relativeHeightForX: null, + relativeWidthForY: null, + }, + { + type: 'REL_ABS_POINT', + absoluteX: 0.0, + absoluteY: 0.0, + relativeX: 75.0, + relativeY: 0.0, + relativeHeightForX: null, + relativeWidthForY: null, + }, + { + type: 'REL_ABS_POINT', + absoluteX: 0.0, + absoluteY: 0.0, + relativeX: 100.0, + relativeY: 100.0, + relativeHeightForX: null, + relativeWidthForY: null, + }, + { + type: 'REL_ABS_POINT', + absoluteX: 0.0, + absoluteY: 0.0, + relativeX: 25.0, + relativeY: 100.0, + relativeHeightForX: null, + relativeWidthForY: null, + }, + ], + }, + ]; + + const mockPolygonCoords = [ + [ + [0, 0], + [2, 0], + [2, 2], + [0, 2], + ], + ]; + (getPolygonCoords as jest.Mock).mockReturnValue(mockPolygonCoords); + + const result = getMultiPolygon({ + x, + y, + width, + height, + shapes, + pointToProjection: mockPointToProjection, + }); + + expect(result).toHaveLength(1); + expect(result[0]).toBeInstanceOf(Polygon); + expect(result[0].getCoordinates()).toEqual([mockPolygonCoords]); + + expect(getPolygonCoords).toHaveBeenCalledTimes(1); + }); + + it('should return an array of Polygons for ELLIPSE shapes', () => { + const x = 30; + const y = 40; + const width = 100; + const height = 200; + + const shapes: Shape[] = [ + { + type: 'ELLIPSE', + center: { + type: 'REL_ABS_POINT', + absoluteX: 0.0, + absoluteY: 0.0, + relativeX: 50.0, + relativeY: 50.0, + relativeHeightForX: null, + relativeWidthForY: null, + }, + radius: { + type: 'REL_ABS_RADIUS', + absoluteX: -7.0, + absoluteY: -7.0, + relativeX: 50.0, + relativeY: 50.0, + }, + }, + ]; + + const mockEllipseCoords = [ + [ + [0, 0], + [30, 0], + [45, 60], + [30, 120], + [0, 120], + [-15, 60], + ], + ]; + (getEllipseCoords as jest.Mock).mockReturnValue(mockEllipseCoords); + + const result = getMultiPolygon({ + x, + y, + width, + height, + shapes, + pointToProjection: mockPointToProjection, + }); + + expect(result).toHaveLength(1); + expect(result[0]).toBeInstanceOf(Polygon); + expect(result[0].getCoordinates()).toEqual([mockEllipseCoords]); + + expect(getEllipseCoords).toHaveBeenCalledTimes(1); + }); + + it('should handle multiple shapes (POLYGON and ELLIPSE)', () => { + const x = 10; + const y = 20; + const width = 100; + const height = 200; + + const shapes: Shape[] = [ + { + type: 'ELLIPSE', + center: { + type: 'REL_ABS_POINT', + absoluteX: 0.0, + absoluteY: 0.0, + relativeX: 50.0, + relativeY: 50.0, + relativeHeightForX: null, + relativeWidthForY: null, + }, + radius: { + type: 'REL_ABS_RADIUS', + absoluteX: -7.0, + absoluteY: -7.0, + relativeX: 50.0, + relativeY: 50.0, + }, + }, + { + type: 'POLYGON', + points: [ + { + type: 'REL_ABS_POINT', + absoluteX: 7.0, + absoluteY: 0.0, + relativeX: 50.0, + relativeY: 0.0, + relativeHeightForX: null, + relativeWidthForY: null, + }, + { + type: 'REL_ABS_POINT', + absoluteX: -7.0, + absoluteY: 0.0, + relativeX: 50.0, + relativeY: 100.0, + relativeHeightForX: null, + relativeWidthForY: null, + }, + ], + }, + ]; + + const mockPolygonCoords = [ + [ + [0, 0], + [2, 0], + [2, 2], + [0, 2], + ], + ]; + const mockEllipseCoords = [ + [ + [0, 0], + [30, 0], + [45, 60], + [30, 120], + [0, 120], + [-15, 60], + ], + ]; + (getPolygonCoords as jest.Mock).mockReturnValue(mockPolygonCoords); + (getEllipseCoords as jest.Mock).mockReturnValue(mockEllipseCoords); + + const result = getMultiPolygon({ + x, + y, + width, + height, + shapes, + pointToProjection: mockPointToProjection, + }); + + expect(result).toHaveLength(2); + expect(getPolygonCoords).toHaveBeenCalledTimes(1); + expect(getEllipseCoords).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getMultiPolygon.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getMultiPolygon.ts new file mode 100644 index 0000000000000000000000000000000000000000..c417891bfc5474b5b70e614bde42aadc8e5aee8c --- /dev/null +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getMultiPolygon.ts @@ -0,0 +1,40 @@ +import getPolygonCoords from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/getPolygonCoords'; +import getEllipseCoords from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/getEllipseCoords'; +import { UsePointToProjectionResult } from '@/utils/map/usePointToProjection'; +import Polygon from 'ol/geom/Polygon'; +import { Coordinate } from 'ol/coordinate'; +import { Shape } from '@/types/models'; + +export default function getMultiPolygon({ + x, + y, + width, + height, + shapes, + pointToProjection, +}: { + x: number; + y: number; + width: number; + height: number; + shapes: Array<Shape>; + pointToProjection: UsePointToProjectionResult; +}): Array<Polygon> { + return shapes.map(shape => { + let coords: Array<Coordinate> = []; + if (shape.type === 'POLYGON') { + coords = getPolygonCoords({ points: shape.points, x, y, height, width, pointToProjection }); + } else if (shape.type === 'ELLIPSE') { + coords = getEllipseCoords({ + x, + y, + center: shape.center, + radius: shape.radius, + height, + width, + pointToProjection, + }); + } + return new Polygon([coords]); + }); +} diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getPolygonCoords.test.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getPolygonCoords.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..e1911f7bc6ca8a5e0360c8662d7f76a4be94cbd2 --- /dev/null +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getPolygonCoords.test.ts @@ -0,0 +1,142 @@ +/* eslint-disable no-magic-numbers */ +import getCoordsX from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/getCoordsX'; +import getCoordsY from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/getCoordsY'; +import getCurveCoords from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/getCurveCoords'; +import getBezierCurve from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/getBezierCurve'; +import { + ShapePoint, + ShapeCurvePoint, +} from '@/components/Map/MapViewer/MapViewerVector/MapViewerVector.types'; +import getPolygonCoords from './getPolygonCoords'; + +jest.mock('./getCoordsX'); +jest.mock('./getCoordsY'); +jest.mock('./getCurveCoords'); +jest.mock('./getBezierCurve'); + +describe('getPolygonCoords', () => { + const mockPointToProjection = jest.fn(point => [point.x, point.y]); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return coordinates for ShapePoint (simple point)', () => { + const x = 10; + const y = 20; + const height = 100; + const width = 200; + + const points: Array<ShapePoint | ShapeCurvePoint> = [ + { + type: 'REL_ABS_POINT', + absoluteX: 0, + absoluteY: 0, + relativeX: 0, + relativeY: 0, + relativeHeightForX: null, + relativeWidthForY: null, + }, + { + type: 'REL_ABS_POINT', + absoluteX: 50, + absoluteY: 50, + relativeX: 10, + relativeY: 10, + relativeHeightForX: null, + relativeWidthForY: null, + }, + ]; + + (getCoordsX as jest.Mock).mockReturnValueOnce(10).mockReturnValueOnce(80); + (getCoordsY as jest.Mock).mockReturnValueOnce(20).mockReturnValueOnce(80); + + const result = getPolygonCoords({ + points, + x, + y, + height, + width, + pointToProjection: mockPointToProjection, + }); + + expect(result).toEqual([ + [10, 20], + [80, 80], + ]); + + expect(getCoordsX).toHaveBeenCalledTimes(2); + expect(getCoordsY).toHaveBeenCalledTimes(2); + }); + + it('should handle a mix of ShapePoint and ShapeCurvePoint', () => { + const x = 10; + const y = 20; + const height = 100; + const width = 200; + + const points: Array<ShapePoint | ShapeCurvePoint> = [ + { + type: 'REL_ABS_POINT', + absoluteX: 0, + absoluteY: 0, + relativeX: 0, + relativeY: 0, + relativeHeightForX: null, + relativeWidthForY: null, + }, + { + type: 'REL_ABS_POINT', + absoluteX1: 50, + absoluteY1: 50, + relativeX1: 10, + relativeY1: 10, + relativeHeightForX1: null, + relativeWidthForY1: null, + absoluteX2: 60, + absoluteY2: 60, + relativeX2: 20, + relativeY2: 20, + relativeHeightForX2: null, + relativeWidthForY2: null, + absoluteX3: 70, + absoluteY3: 70, + relativeX3: 30, + relativeY3: 30, + relativeHeightForX3: null, + relativeWidthForY3: null, + }, + ]; + + const mockBezierCurve = [ + [15, 25], + [35, 45], + [55, 65], + ]; + + (getCoordsX as jest.Mock).mockReturnValueOnce(10); + (getCoordsY as jest.Mock).mockReturnValueOnce(20); + (getCurveCoords as jest.Mock).mockReturnValue({ + p1: [30, 40], + p2: [50, 60], + p3: [70, 80], + }); + (getBezierCurve as jest.Mock).mockReturnValue(mockBezierCurve); + + const result = getPolygonCoords({ + points, + x, + y, + height, + width, + pointToProjection: mockPointToProjection, + }); + + expect(result).toEqual([[10, 20], ...mockBezierCurve]); + + expect(getCoordsX).toHaveBeenCalledTimes(1); + expect(getCoordsY).toHaveBeenCalledTimes(1); + expect(getCurveCoords).toHaveBeenCalledTimes(1); + expect(getBezierCurve).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getPolygonCoords.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getPolygonCoords.ts new file mode 100644 index 0000000000000000000000000000000000000000..01f8169f07d14ac8ffe228a0110d35c9036927ed --- /dev/null +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getPolygonCoords.ts @@ -0,0 +1,53 @@ +/* eslint-disable no-magic-numbers */ +import { Coordinate } from 'ol/coordinate'; +import { UsePointToProjectionResult } from '@/utils/map/usePointToProjection'; +import getBezierCurve from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/getBezierCurve'; +import getCoordsX from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/getCoordsX'; +import getCoordsY from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/getCoordsY'; +import getCurveCoords from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/getCurveCoords'; +import { + ShapeCurvePoint, + ShapePoint, +} from '@/components/Map/MapViewer/MapViewerVector/MapViewerVector.types'; + +export default function getPolygonCoords({ + points, + x, + y, + height, + width, + pointToProjection, +}: { + points: Array<ShapePoint | ShapeCurvePoint>; + x: number; + y: number; + height: number; + width: number; + pointToProjection: UsePointToProjectionResult; +}): Array<Coordinate> { + let lastPoint: Coordinate; + return points.flatMap(point => { + if ('absoluteX' in point) { + const coordsX = getCoordsX( + x, + point.absoluteX, + point.relativeX, + point.relativeHeightForX, + height, + width, + ); + const coordsY = getCoordsY( + y, + point.absoluteY, + point.relativeY, + point.relativeWidthForY, + height, + width, + ); + lastPoint = pointToProjection({ x: coordsX, y: coordsY }); + return [[...lastPoint]]; + } + const { p1, p2, p3 } = getCurveCoords({ x, y, point, height, width, pointToProjection }); + return getBezierCurve({ p0: lastPoint, p1, p2, p3, numPoints: 50 }); + }); +} diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getStroke.test.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getStroke.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..70a27921c1a7510ddf9f4792000176aa962b41d6 --- /dev/null +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getStroke.test.ts @@ -0,0 +1,21 @@ +/* eslint-disable no-magic-numbers */ +import { Stroke } from 'ol/style'; +import getStroke from './getStroke'; + +describe('getStroke', () => { + it('should return a Stoke object with the default color and width', () => { + const result = getStroke({}); + expect(result).toBeInstanceOf(Stroke); + expect(result.getColor()).toBe('#000'); + expect(result.getWidth()).toBe(1); + }); + + it('should return a Stoke object with the provided color and width', () => { + const color = '#ff0000'; + const width = 2; + const result = getStroke({ color, width }); + expect(result).toBeInstanceOf(Stroke); + expect(result.getColor()).toBe(color); + expect(result.getWidth()).toBe(width); + }); +}); diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getStroke.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getStroke.ts new file mode 100644 index 0000000000000000000000000000000000000000..37816224714777d7917bc77067caa4354b75c8ee --- /dev/null +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getStroke.ts @@ -0,0 +1,15 @@ +/* eslint-disable no-magic-numbers */ +import { Stroke } from 'ol/style'; + +export default function getStroke({ + color = '#000', + width = 1, +}: { + color?: string; + width?: number; +}): Stroke { + return new Stroke({ + color, + width, + }); +} diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getText.test.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getText.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..a9f0490707370aca9a3e9fe595aab4e83174bcff --- /dev/null +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getText.test.ts @@ -0,0 +1,41 @@ +/* eslint-disable no-magic-numbers */ +import { UsePointToProjectionResult } from '@/utils/map/usePointToProjection'; +import getText from './getText'; + +describe('getText', () => { + const mockPointToProjection: UsePointToProjectionResult = jest.fn(point => [point.x, point.y]); + it('should return correct text coordinates and style when text is centered', () => { + const { textCoords, textStyle } = getText({ + x: 0, + y: 0, + height: 100, + width: 100, + text: 'Text test', + fontSize: 12, + verticalAlign: 'MIDDLE', + horizontalAlign: 'CENTER', + pointToProjection: mockPointToProjection, + }); + + expect(textCoords).toEqual([50, 50]); + expect(textStyle.getText()?.getFont()).toEqual('bold 12px Arial'); + }); + + it('should return correct text coordinates and style when text is aligned to bottom', () => { + const { textCoords, textStyle } = getText({ + x: 20, + y: 30, + height: 100, + width: 100, + text: 'Text test', + fontSize: 18, + verticalAlign: 'BOTTOM', + horizontalAlign: 'CENTER', + pointToProjection: mockPointToProjection, + }); + + expect(textCoords).toEqual([70, 121]); + + expect(textStyle.getText()?.getFont()).toEqual('bold 18px Arial'); + }); +}); diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getText.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getText.ts new file mode 100644 index 0000000000000000000000000000000000000000..b9a1f867e70d7752afda69c075119bf544127cf6 --- /dev/null +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getText.ts @@ -0,0 +1,73 @@ +/* eslint-disable no-magic-numbers */ +import { Fill, Text } from 'ol/style'; +import Style from 'ol/style/Style'; +import { UsePointToProjectionResult } from '@/utils/map/usePointToProjection'; +import { Feature } from 'ol'; +import { Point } from 'ol/geom'; +import { Coordinate } from 'ol/coordinate'; +import { + HorizontalAlign, + VerticalAlign, +} from '@/components/Map/MapViewer/MapViewerVector/MapViewerVector.types'; + +export default function getText({ + x, + y, + height, + width, + text = '', + fontSize = 12, + color = '#000', + verticalAlign = 'MIDDLE', + horizontalAlign = 'CENTER', + pointToProjection, +}: { + x: number; + y: number; + height: number; + width: number; + text: string; + fontSize?: string | number; + color?: string; + verticalAlign?: VerticalAlign; + horizontalAlign?: HorizontalAlign; + pointToProjection: UsePointToProjectionResult; +}): { textCoords: Coordinate; textStyle: Style } { + const minX = x; + const maxX = x + width; + const minY = y; + const maxY = y + height; + + let textY = (minY + maxY) / 2; + if (verticalAlign === 'TOP') { + textY = minY + +fontSize / 2; + } else if (verticalAlign === 'BOTTOM') { + textY = maxY - +fontSize / 2; + } + + let textX = (minX + maxX) / 2; + if (['LEFT', 'START'].includes(horizontalAlign)) { + textX = minX; + } else if (['RIGHT', 'END'].includes(horizontalAlign)) { + textX = maxX; + } + + const textCoords = pointToProjection({ x: textX, y: textY }); + const textFeature = new Feature({ geometry: new Point(textCoords) }); + + const textStyle = new Style({ + text: new Text({ + text, + font: `bold ${fontSize}px Arial`, + fill: new Fill({ + color, + }), + placement: 'point', + textAlign: 'center', + textBaseline: 'middle', + }), + }); + textFeature.setStyle(textStyle); + + return { textCoords, textStyle }; +} diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/rgbToHex.test.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/rgbToHex.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..7c92f413ac1c369b46681b30ca37fd40e0749260 --- /dev/null +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/rgbToHex.test.ts @@ -0,0 +1,26 @@ +import { ColorObject } from '@/components/Map/MapViewer/MapViewerVector/MapViewerVector.types'; +import { rgbToHex } from './rgbToHex'; + +describe('rgbToHex', () => { + it('should correctly convert RGB and alpha values to hex', () => { + const color: ColorObject = { + rgb: -16222216, + alpha: 255, + }; + + const result = rgbToHex(color); + + expect(result).toBe('#0877F8FF'); + }); + + it('should correctly handle alpha values less than 255', () => { + const color: ColorObject = { + rgb: -16777216, + alpha: 128, + }; + + const result = rgbToHex(color); + + expect(result).toBe('#00000080'); + }); +}); diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/rgbToHex.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/rgbToHex.ts new file mode 100644 index 0000000000000000000000000000000000000000..1fe9df8418b92bcd0a942a13b23155d299c76fdc --- /dev/null +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/rgbToHex.ts @@ -0,0 +1,9 @@ +/* eslint-disable no-magic-numbers */ +import { ColorObject } from '@/components/Map/MapViewer/MapViewerVector/MapViewerVector.types'; + +export function rgbToHex(color: ColorObject): string { + const positiveRgb = color.rgb < 0 ? color.rgb + 0x100000000 : color.rgb; + const hexRgb = positiveRgb.toString(16).slice(-6).padStart(6, '0').toUpperCase(); + const hexAlpha = color.alpha.toString(16).padStart(2, '0').toUpperCase(); + return `#${hexRgb}${hexAlpha}`; +} diff --git a/src/components/Map/MapViewer/utils/config/useOlMapLayers.test.ts b/src/components/Map/MapViewer/utils/config/useOlMapLayers.test.ts index cee56323c59bb31a4071c527285eca44a37c9c44..c8d29fa33d44ff5b00be2eac73e8d57364d47b12 100644 --- a/src/components/Map/MapViewer/utils/config/useOlMapLayers.test.ts +++ b/src/components/Map/MapViewer/utils/config/useOlMapLayers.test.ts @@ -1,15 +1,11 @@ /* eslint-disable no-magic-numbers */ -import { BACKGROUND_INITIAL_STATE_MOCK } from '@/redux/backgrounds/background.mock'; import { MAP_DATA_INITIAL_STATE, OPENED_MAPS_INITIAL_STATE } from '@/redux/map/map.constants'; -import { initialMapStateFixture } from '@/redux/map/map.fixtures'; import { getReduxWrapperWithStore } from '@/utils/testing/getReduxWrapperWithStore'; -import { renderHook, waitFor } from '@testing-library/react'; -import { Map } from 'ol'; +import { renderHook } from '@testing-library/react'; import BaseLayer from 'ol/layer/Base'; import TileLayer from 'ol/layer/Tile'; import VectorLayer from 'ol/layer/Vector'; import React from 'react'; -import { useOlMap } from '../useOlMap'; import { useOlMapLayers } from './useOlMapLayers'; const useRefValue = { @@ -34,24 +30,6 @@ Object.defineProperty(useRefValue, 'current', { jest.spyOn(React, 'useRef').mockReturnValue(useRefValue); describe('useOlMapLayers - util', () => { - it('should modify layers of the map instance on init', async () => { - const { Wrapper } = getReduxWrapperWithStore({ - map: initialMapStateFixture, - backgrounds: BACKGROUND_INITIAL_STATE_MOCK, - }); - - const dummyElement = document.createElement('div'); - const mapInstance = new Map({ target: dummyElement }); - const setLayersSpy = jest.spyOn(mapInstance, 'setLayers'); - const CALLED_ONCE = 1; - - renderHook(() => useOlMapLayers({ mapInstance }), { - wrapper: Wrapper, - }); - - await waitFor(() => expect(setLayersSpy).toBeCalledTimes(CALLED_ONCE)); - }); - const getRenderedHookResults = (): BaseLayer[] => { const { Wrapper } = getReduxWrapperWithStore({ map: { @@ -83,18 +61,11 @@ describe('useOlMapLayers - util', () => { openedMaps: OPENED_MAPS_INITIAL_STATE, }, }); - const dummyElement = document.createElement('div'); - const { result: hohResult } = renderHook(() => useOlMap({ target: dummyElement }), { + + const { result } = renderHook(() => useOlMapLayers(), { wrapper: Wrapper, }); - const { result } = renderHook( - () => useOlMapLayers({ mapInstance: hohResult.current.mapInstance }), - { - wrapper: Wrapper, - }, - ); - return result.current; }; diff --git a/src/components/Map/MapViewer/utils/config/useOlMapLayers.ts b/src/components/Map/MapViewer/utils/config/useOlMapLayers.ts index 5739c30201df49638f2e1656189c0ab561886450..925b92d842cafa41753999063738d69b13d886b0 100644 --- a/src/components/Map/MapViewer/utils/config/useOlMapLayers.ts +++ b/src/components/Map/MapViewer/utils/config/useOlMapLayers.ts @@ -1,6 +1,4 @@ /* eslint-disable no-magic-numbers */ -import { MapInstance } from '@/types/map'; -import { useEffect } from 'react'; import { useOlMapCommentsLayer } from '@/components/Map/MapViewer/utils/config/commentsLayer/useOlMapCommentsLayer'; import { MapConfig } from '../../MapViewer.types'; import { useOlMapOverlaysLayer } from './overlaysLayer/useOlMapOverlaysLayer'; @@ -8,24 +6,12 @@ import { useOlMapPinsLayer } from './pinsLayer/useOlMapPinsLayer'; import { useOlMapReactionsLayer } from './reactionsLayer/useOlMapReactionsLayer'; import { useOlMapTileLayer } from './useOlMapTileLayer'; -interface UseOlMapLayersInput { - mapInstance: MapInstance; -} - -export const useOlMapLayers = ({ mapInstance }: UseOlMapLayersInput): MapConfig['layers'] => { +export const useOlMapLayers = (): MapConfig['layers'] => { const tileLayer = useOlMapTileLayer(); const pinsLayer = useOlMapPinsLayer(); const reactionsLayer = useOlMapReactionsLayer(); const overlaysLayer = useOlMapOverlaysLayer(); const commentsLayer = useOlMapCommentsLayer(); - useEffect(() => { - if (!mapInstance) { - return; - } - - mapInstance.setLayers([tileLayer, reactionsLayer, overlaysLayer, pinsLayer, commentsLayer]); - }, [reactionsLayer, tileLayer, pinsLayer, mapInstance, overlaysLayer, commentsLayer]); - - return [tileLayer, pinsLayer, reactionsLayer, overlaysLayer]; + return [tileLayer, pinsLayer, reactionsLayer, overlaysLayer, commentsLayer]; }; diff --git a/src/components/Map/MapViewer/utils/listeners/mapSingleClick/onMapSingleClick.ts b/src/components/Map/MapViewer/utils/listeners/mapSingleClick/onMapSingleClick.ts index e04f2880a546ad753225e2301de34487753f3c53..bc86c2326c8b0d31c21e7d2f0839d9b1332f4cba 100644 --- a/src/components/Map/MapViewer/utils/listeners/mapSingleClick/onMapSingleClick.ts +++ b/src/components/Map/MapViewer/utils/listeners/mapSingleClick/onMapSingleClick.ts @@ -38,6 +38,5 @@ export const onMapSingleClick = if (!searchResults || searchResults.length === SIZE_OF_EMPTY_ARRAY) { return; } - handleSearchResultAction({ searchResults, dispatch, point, searchDistance, maxZoom, zoom, isResultDrawerOpen }); }; diff --git a/src/components/Map/MapViewer/utils/listeners/useOlMapListeners.ts b/src/components/Map/MapViewer/utils/listeners/useOlMapListeners.ts index 33013f2182bcfc3a424a5568ec7be512966e4d4d..c0dbb4b334030bedc41334617286d886b64286d0 100644 --- a/src/components/Map/MapViewer/utils/listeners/useOlMapListeners.ts +++ b/src/components/Map/MapViewer/utils/listeners/useOlMapListeners.ts @@ -7,7 +7,7 @@ import { mapDataMaxZoomValue, mapDataSizeSelector, } from '@/redux/map/map.selectors'; -import { currentModelIdSelector } from '@/redux/models/models.selectors'; +import { currentModelIdSelector, vectorRenderingSelector } from '@/redux/models/models.selectors'; import { MapInstance } from '@/types/map'; import { View } from 'ol'; import { unByKey } from 'ol/Observable'; @@ -17,6 +17,7 @@ import { useEffect, useRef } from 'react'; import { useSelector } from 'react-redux'; import { useDebouncedCallback } from 'use-debounce'; import { allCommentsSelectorOfCurrentMap } from '@/redux/comment/comment.selectors'; +import { useAppSelector } from '@/redux/hooks/useAppSelector'; import { onMapRightClick } from './mapRightClick/onMapRightClick'; import { onMapSingleClick } from './mapSingleClick/onMapSingleClick'; import { onMapPositionChange } from './onMapPositionChange'; @@ -40,7 +41,7 @@ export const useOlMapListeners = ({ view, mapInstance }: UseOlMapListenersInput) const dispatch = useAppDispatch(); const comments = useSelector(allCommentsSelectorOfCurrentMap); - + const vectorRendering = useAppSelector(vectorRenderingSelector); useHandlePinIconClick(); const handleRightClick = useDebouncedCallback( @@ -74,12 +75,11 @@ export const useOlMapListeners = ({ view, mapInstance }: UseOlMapListenersInput) useEffect(() => { const key = view.on('change:center', handleChangeCenter); - return () => unByKey(key); - }, [view, handleChangeCenter]); + }, [view, handleChangeCenter, vectorRendering]); useEffect(() => { - if (!mapInstance) { + if (!mapInstance || vectorRendering) { return; } @@ -87,10 +87,10 @@ export const useOlMapListeners = ({ view, mapInstance }: UseOlMapListenersInput) // eslint-disable-next-line consistent-return return () => unByKey(key); - }, [mapInstance]); + }, [mapInstance, vectorRendering]); useEffect(() => { - if (!mapInstance) { + if (!mapInstance || vectorRendering) { return; } @@ -100,10 +100,10 @@ export const useOlMapListeners = ({ view, mapInstance }: UseOlMapListenersInput) // eslint-disable-next-line consistent-return return () => unByKey(key); - }, [mapInstance, handleMapSingleClick]); + }, [mapInstance, handleMapSingleClick, vectorRendering]); useEffect(() => { - if (!mapInstance) { + if (!mapInstance || vectorRendering) { return; } @@ -120,5 +120,5 @@ export const useOlMapListeners = ({ view, mapInstance }: UseOlMapListenersInput) // eslint-disable-next-line consistent-return return () => mapInstance.getViewport().removeEventListener('contextmenu', rightClickEvent); - }, [mapInstance, handleRightClick]); + }, [mapInstance, handleRightClick, vectorRendering]); }; diff --git a/src/components/Map/MapViewer/utils/useOlMap.ts b/src/components/Map/MapViewer/utils/useOlMap.ts index 49ec30027077460bfdde9b9ff2b6e89961c0273a..5244411aa621727be223cde1628d82c7d6817d7e 100644 --- a/src/components/Map/MapViewer/utils/useOlMap.ts +++ b/src/components/Map/MapViewer/utils/useOlMap.ts @@ -2,7 +2,11 @@ import { MapInstance } from '@/types/map'; import { useMapInstance } from '@/utils/context/mapInstanceContext'; import Map from 'ol/Map'; import { Zoom } from 'ol/control'; -import React, { MutableRefObject, useEffect } from 'react'; +import React, { MutableRefObject, useEffect, useMemo } from 'react'; +import { useOlMapVectorLayers } from '@/components/Map/MapViewer/MapViewerVector/utils/config/useOlMapVectorLayers'; +import LayerGroup from 'ol/layer/Group'; +import { useAppSelector } from '@/redux/hooks/useAppSelector'; +import { vectorRenderingSelector } from '@/redux/models/models.selectors'; import { useOlMapLayers } from './config/useOlMapLayers'; import { useOlMapView } from './config/useOlMapView'; import { useOlMapListeners } from './listeners/useOlMapListeners'; @@ -18,10 +22,23 @@ interface UseOlMapOutput { type UseOlMap = (input?: UseOlMapInput) => UseOlMapOutput; export const useOlMap: UseOlMap = ({ target } = {}) => { + const vectorRendering = useAppSelector(vectorRenderingSelector); const mapRef = React.useRef<null | HTMLDivElement>(null); const { mapInstance, handleSetMapInstance } = useMapInstance(); const view = useOlMapView({ mapInstance }); - useOlMapLayers({ mapInstance }); + + const rasterLayers = useOlMapLayers(); + const rasterLayersGroup = useMemo(() => { + return new LayerGroup({ + layers: rasterLayers, + }); + }, [rasterLayers]); + const vectorLayers = useOlMapVectorLayers({ mapInstance }); + const vectorLayersGroup = useMemo(() => { + return new LayerGroup({ + layers: vectorLayers, + }); + }, [vectorLayers]); useOlMapListeners({ view, mapInstance }); useEffect(() => { @@ -44,6 +61,23 @@ export const useOlMap: UseOlMap = ({ target } = {}) => { handleSetMapInstance(map); }, [target, handleSetMapInstance]); + useEffect(() => { + if (!mapInstance) { + return; + } + mapInstance.setLayers([vectorLayersGroup, rasterLayersGroup]); + }, [mapInstance, rasterLayersGroup, vectorLayersGroup]); + + useEffect(() => { + if (vectorRendering) { + rasterLayersGroup.setVisible(false); + vectorLayersGroup.setVisible(true); + } else { + vectorLayersGroup.setVisible(false); + rasterLayersGroup.setVisible(true); + } + }, [rasterLayersGroup, vectorLayersGroup, vectorRendering]); + return { mapRef, mapInstance, diff --git a/src/models/bioShapeSchema.ts b/src/models/bioShapeSchema.ts new file mode 100644 index 0000000000000000000000000000000000000000..273cf028fb763bb327ada5b692b747d7f0027b36 --- /dev/null +++ b/src/models/bioShapeSchema.ts @@ -0,0 +1,7 @@ +import { z } from 'zod'; +import { shapeSchema } from '@/models/shapeSchema'; + +export const bioShapeSchema = z.object({ + sboTerm: z.string(), + shapes: z.array(shapeSchema), +}); diff --git a/src/models/fixtures/modelElementsFixture.ts b/src/models/fixtures/modelElementsFixture.ts new file mode 100644 index 0000000000000000000000000000000000000000..87c8fb2edbe3b7183b177da0cce8d1d302603c19 --- /dev/null +++ b/src/models/fixtures/modelElementsFixture.ts @@ -0,0 +1,9 @@ +import { ZOD_SEED } from '@/constants'; +// eslint-disable-next-line import/no-extraneous-dependencies +import { createFixture } from 'zod-fixture'; +import { modelElementsSchema } from '@/models/modelElementsSchema'; + +export const modelElementsFixture = createFixture(modelElementsSchema, { + seed: ZOD_SEED, + array: { min: 3, max: 3 }, +}); diff --git a/src/models/fixtures/shapesFixture.ts b/src/models/fixtures/shapesFixture.ts new file mode 100644 index 0000000000000000000000000000000000000000..e1ce1e00f3772e84fe94c31173c12aa6da986202 --- /dev/null +++ b/src/models/fixtures/shapesFixture.ts @@ -0,0 +1,10 @@ +import { ZOD_SEED } from '@/constants'; +import { z } from 'zod'; +// eslint-disable-next-line import/no-extraneous-dependencies +import { createFixture } from 'zod-fixture'; +import { bioShapeSchema } from '@/models/bioShapeSchema'; + +export const shapesFixture = createFixture(z.array(bioShapeSchema), { + seed: ZOD_SEED, + array: { min: 3, max: 3 }, +}); diff --git a/src/models/modelElementSchema.ts b/src/models/modelElementSchema.ts new file mode 100644 index 0000000000000000000000000000000000000000..0555bd9ce7b8a7d48d1ba9f8760725c7400018e4 --- /dev/null +++ b/src/models/modelElementSchema.ts @@ -0,0 +1,50 @@ +import { z } from 'zod'; +import { colorSchema } from '@/models/colorSchema'; +import { referenceSchema } from '@/models/referenceSchema'; +import { submodelSchema } from '@/models/submodelSchema'; + +export const modelElementSchema = z.object({ + id: z.number(), + model: z.number().nullable(), + glyph: z.number().nullable(), + submodel: submodelSchema.nullable(), + x: z.number(), + y: z.number(), + z: z.number(), + width: z.number(), + height: z.number(), + fontSize: z.number(), + fontColor: colorSchema, + fillColor: colorSchema, + borderColor: colorSchema, + visibilityLevel: z.string(), + transparencyLevel: z.string(), + notes: z.string(), + symbol: z.string().nullable(), + fullName: z.string().nullable(), + abbreviation: z.string().nullable(), + formula: z.string().nullable(), + name: z.string(), + nameX: z.number(), + nameY: z.number(), + nameWidth: z.number(), + nameHeight: z.number(), + nameVerticalAlign: z.enum(['TOP', 'MIDDLE', 'BOTTOM']), + nameHorizontalAlign: z.enum(['LEFT', 'RIGHT', 'CENTER', 'END', 'START']), + synonyms: z.array(z.string()), + formerSymbols: z.array(z.string()), + activity: z.boolean().optional(), + lineWidth: z.number().optional(), + complex: z.number().nullable().optional(), + initialAmount: z.number().nullable().optional(), + charge: z.number().nullable().optional(), + initialConcentration: z.number().nullable().optional(), + onlySubstanceUnits: z.boolean().nullable().optional(), + homodimer: z.number().nullable().optional(), + hypothetical: z.boolean().nullable().optional(), + boundaryCondition: z.boolean().nullable().optional(), + constant: z.boolean().nullable().optional(), + substanceUnits: z.boolean().nullable().optional(), + references: z.array(referenceSchema), + sboTerm: z.string(), +}); diff --git a/src/models/modelElementsSchema.ts b/src/models/modelElementsSchema.ts new file mode 100644 index 0000000000000000000000000000000000000000..19969ba1c00f2155a545e9c1a45ea2ce867a4eea --- /dev/null +++ b/src/models/modelElementsSchema.ts @@ -0,0 +1,11 @@ +import { z } from 'zod'; +import { modelElementSchema } from '@/models/modelElementSchema'; + +export const modelElementsSchema = z.object({ + content: z.array(modelElementSchema), + totalPages: z.number(), + totalElements: z.number(), + numberOfElements: z.number(), + size: z.number(), + number: z.number(), +}); diff --git a/src/models/shapeEllipseSchema.ts b/src/models/shapeEllipseSchema.ts new file mode 100644 index 0000000000000000000000000000000000000000..36059a6abde49caf9cba39518a9a6821ca9ec306 --- /dev/null +++ b/src/models/shapeEllipseSchema.ts @@ -0,0 +1,9 @@ +import { z } from 'zod'; +import { shapeRelAbsPointSchema } from '@/models/shapeRelAbsPointSchema'; +import { shapeRelAbsRadiusSchema } from '@/models/shapeRelAbsRadiusSchema'; + +export const shapeEllipseSchema = z.object({ + type: z.literal('ELLIPSE'), + center: shapeRelAbsPointSchema, + radius: shapeRelAbsRadiusSchema, +}); diff --git a/src/models/shapePolygonSchema.ts b/src/models/shapePolygonSchema.ts new file mode 100644 index 0000000000000000000000000000000000000000..ef39a4ee9319b9affb7deae4f7ade6767860fa15 --- /dev/null +++ b/src/models/shapePolygonSchema.ts @@ -0,0 +1,8 @@ +import { z } from 'zod'; +import { shapeRelAbsBezierPointSchema } from '@/models/shapeRelAbsBezierPointSchema'; +import { shapeRelAbsPointSchema } from '@/models/shapeRelAbsPointSchema'; + +export const shapePolygonSchema = z.object({ + type: z.literal('POLYGON'), + points: z.array(z.union([shapeRelAbsPointSchema, shapeRelAbsBezierPointSchema])), +}); diff --git a/src/models/shapeRelAbsBezierPointSchema.ts b/src/models/shapeRelAbsBezierPointSchema.ts new file mode 100644 index 0000000000000000000000000000000000000000..94868549e88445ed241673a5b10bd2b6665a008c --- /dev/null +++ b/src/models/shapeRelAbsBezierPointSchema.ts @@ -0,0 +1,23 @@ +import { z } from 'zod'; + +export const shapeRelAbsBezierPointSchema = z.object({ + type: z.literal('REL_ABS_BEZIER_POINT'), + absoluteX1: z.number(), + absoluteY1: z.number(), + relativeX1: z.number(), + relativeY1: z.number(), + relativeHeightForX1: z.number().nullable(), + relativeWidthForY1: z.number().nullable(), + absoluteX2: z.number(), + absoluteY2: z.number(), + relativeX2: z.number(), + relativeY2: z.number(), + relativeHeightForX2: z.number().nullable(), + relativeWidthForY2: z.number().nullable(), + absoluteX3: z.number(), + absoluteY3: z.number(), + relativeX3: z.number(), + relativeY3: z.number(), + relativeHeightForX3: z.number().nullable(), + relativeWidthForY3: z.number().nullable(), +}); diff --git a/src/models/shapeRelAbsPointSchema.ts b/src/models/shapeRelAbsPointSchema.ts new file mode 100644 index 0000000000000000000000000000000000000000..26bc59a9361bf676be30ce70d18b4752b4837363 --- /dev/null +++ b/src/models/shapeRelAbsPointSchema.ts @@ -0,0 +1,11 @@ +import { z } from 'zod'; + +export const shapeRelAbsPointSchema = z.object({ + type: z.literal('REL_ABS_POINT'), + absoluteX: z.number(), + absoluteY: z.number(), + relativeX: z.number(), + relativeY: z.number(), + relativeHeightForX: z.number().nullable(), + relativeWidthForY: z.number().nullable(), +}); diff --git a/src/models/shapeRelAbsRadiusSchema.ts b/src/models/shapeRelAbsRadiusSchema.ts new file mode 100644 index 0000000000000000000000000000000000000000..6050859d0756ce6941822e74be36baa56e602f32 --- /dev/null +++ b/src/models/shapeRelAbsRadiusSchema.ts @@ -0,0 +1,9 @@ +import { z } from 'zod'; + +export const shapeRelAbsRadiusSchema = z.object({ + type: z.literal('REL_ABS_RADIUS'), + absoluteX: z.number(), + absoluteY: z.number(), + relativeX: z.number(), + relativeY: z.number(), +}); diff --git a/src/models/shapeSchema.ts b/src/models/shapeSchema.ts new file mode 100644 index 0000000000000000000000000000000000000000..ba86b238a60f9334802cf0d70c51dfe0501aa607 --- /dev/null +++ b/src/models/shapeSchema.ts @@ -0,0 +1,5 @@ +import { z } from 'zod'; +import { shapeEllipseSchema } from '@/models/shapeEllipseSchema'; +import { shapePolygonSchema } from '@/models/shapePolygonSchema'; + +export const shapeSchema = z.union([shapeEllipseSchema, shapePolygonSchema]); diff --git a/src/redux/apiPath.ts b/src/redux/apiPath.ts index 944cfa00adaa176e99eb468559a8e841fac84832..01fae40c7ce0e33a8e140d734d054c440aa3425e 100644 --- a/src/redux/apiPath.ts +++ b/src/redux/apiPath.ts @@ -48,6 +48,9 @@ export const apiPath = { getChemicalsStringWithColumnsTarget: (columns: string, target: string): string => `projects/${PROJECT_ID}/chemicals:search?columns=${columns}&target=${target}`, getModelsString: (): string => `projects/${PROJECT_ID}/models/`, + getModelElements: (modelId: number): string => + `projects/${PROJECT_ID}/maps/${modelId}/bioEntities/elements/?size=10000`, + getShapes: (): string => `projects/${PROJECT_ID}/shapes/`, getChemicalsStringWithQuery: (searchQuery: string): string => `projects/${PROJECT_ID}/chemicals:search?query=${searchQuery}`, getAllOverlaysByProjectIdQuery: ( diff --git a/src/redux/modelElements/modelElements.constants.ts b/src/redux/modelElements/modelElements.constants.ts new file mode 100644 index 0000000000000000000000000000000000000000..c3b221d39b259cc60755364bda17bb758e5d87cd --- /dev/null +++ b/src/redux/modelElements/modelElements.constants.ts @@ -0,0 +1 @@ +export const MODEL_ELEMENTS_FETCHING_ERROR_PREFIX = 'Failed to fetch model elements'; diff --git a/src/redux/modelElements/modelElements.mock.ts b/src/redux/modelElements/modelElements.mock.ts new file mode 100644 index 0000000000000000000000000000000000000000..5ad63a0231db2dc6257cfa4fede7e11fa8ec65b8 --- /dev/null +++ b/src/redux/modelElements/modelElements.mock.ts @@ -0,0 +1,8 @@ +import { DEFAULT_ERROR } from '@/constants/errors'; +import { ModelElementsState } from '@/redux/modelElements/modelElements.types'; + +export const MODEL_ELEMENTS_INITIAL_STATE_MOCK: ModelElementsState = { + data: null, + loading: 'idle', + error: DEFAULT_ERROR, +}; diff --git a/src/redux/modelElements/modelElements.reducers.test.ts b/src/redux/modelElements/modelElements.reducers.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..d896072208c1657f6d40c7b5fdf352944bbd03a2 --- /dev/null +++ b/src/redux/modelElements/modelElements.reducers.test.ts @@ -0,0 +1,83 @@ +/* eslint-disable no-magic-numbers */ +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 { ModelElementsState } from '@/redux/modelElements/modelElements.types'; +import modelElementsReducer from '@/redux/modelElements/modelElements.slice'; +import { getModelElements } from '@/redux/modelElements/modelElements.thunks'; +import { modelElementsFixture } from '@/models/fixtures/modelElementsFixture'; + +const mockedAxiosClient = mockNetworkNewAPIResponse(); + +const INITIAL_STATE: ModelElementsState = { + data: null, + loading: 'idle', + error: { name: '', message: '' }, +}; + +describe('model elements reducer', () => { + let store = {} as ToolkitStoreWithSingleSlice<ModelElementsState>; + beforeEach(() => { + store = createStoreInstanceUsingSliceReducer('modelElements', modelElementsReducer); + }); + + it('should match initial state', () => { + const action = { type: 'unknown' }; + + expect(modelElementsReducer(undefined, action)).toEqual(INITIAL_STATE); + }); + + it('should update store after successful getModelElements query', async () => { + mockedAxiosClient + .onGet(apiPath.getModelElements(0)) + .reply(HttpStatusCode.Ok, modelElementsFixture); + + const { type } = await store.dispatch(getModelElements(0)); + const { data, loading, error } = store.getState().modelElements; + + expect(type).toBe('vectorMap/getModelElements/fulfilled'); + expect(loading).toEqual('succeeded'); + expect(error).toEqual({ message: '', name: '' }); + expect(data).toEqual(modelElementsFixture); + }); + + it('should update store after failed getModelElements query', async () => { + mockedAxiosClient.onGet(apiPath.getModelElements(0)).reply(HttpStatusCode.NotFound, []); + + const action = await store.dispatch(getModelElements(0)); + const { data, loading, error } = store.getState().modelElements; + + expect(action.type).toBe('vectorMap/getModelElements/rejected'); + expect(() => unwrapResult(action)).toThrow( + "Failed to fetch model elements: 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(null); + }); + + it('should update store on loading getModelElements query', async () => { + mockedAxiosClient + .onGet(apiPath.getModelElements(0)) + .reply(HttpStatusCode.Ok, modelElementsFixture); + + const modelElementsPromise = store.dispatch(getModelElements(0)); + + const { data, loading } = store.getState().modelElements; + expect(data).toEqual(null); + expect(loading).toEqual('pending'); + + modelElementsPromise.then(() => { + const { data: dataPromiseFulfilled, loading: promiseFulfilled } = + store.getState().modelElements; + + expect(dataPromiseFulfilled).toEqual(modelElementsFixture); + expect(promiseFulfilled).toEqual('succeeded'); + }); + }); +}); diff --git a/src/redux/modelElements/modelElements.reducers.ts b/src/redux/modelElements/modelElements.reducers.ts new file mode 100644 index 0000000000000000000000000000000000000000..fda618dfa3442ea5c0029e2db6bc2a3b6855edaa --- /dev/null +++ b/src/redux/modelElements/modelElements.reducers.ts @@ -0,0 +1,18 @@ +import { ActionReducerMapBuilder } from '@reduxjs/toolkit'; +import { getModelElements } from '@/redux/modelElements/modelElements.thunks'; +import { ModelElementsState } from '@/redux/modelElements/modelElements.types'; + +export const getModelElementsReducer = ( + builder: ActionReducerMapBuilder<ModelElementsState>, +): void => { + builder.addCase(getModelElements.pending, state => { + state.loading = 'pending'; + }); + builder.addCase(getModelElements.fulfilled, (state, action) => { + state.data = action.payload || null; + state.loading = 'succeeded'; + }); + builder.addCase(getModelElements.rejected, state => { + state.loading = 'failed'; + }); +}; diff --git a/src/redux/modelElements/modelElements.selector.ts b/src/redux/modelElements/modelElements.selector.ts new file mode 100644 index 0000000000000000000000000000000000000000..54b4a75b00d98355f601dfa2ed2c49c1911f4258 --- /dev/null +++ b/src/redux/modelElements/modelElements.selector.ts @@ -0,0 +1,7 @@ +import { createSelector } from '@reduxjs/toolkit'; +import { rootSelector } from '@/redux/root/root.selectors'; + +export const modelElementsSelector = createSelector( + rootSelector, + state => state.modelElements.data, +); diff --git a/src/redux/modelElements/modelElements.slice.ts b/src/redux/modelElements/modelElements.slice.ts new file mode 100644 index 0000000000000000000000000000000000000000..b9e7f4119ab512ee9327b6f82854d42b2cd308f1 --- /dev/null +++ b/src/redux/modelElements/modelElements.slice.ts @@ -0,0 +1,20 @@ +import { createSlice } from '@reduxjs/toolkit'; +import { getModelElementsReducer } from '@/redux/modelElements/modelElements.reducers'; +import { ModelElementsState } from '@/redux/modelElements/modelElements.types'; + +const initialState: ModelElementsState = { + data: null, + loading: 'idle', + error: { name: '', message: '' }, +}; + +export const modelElements = createSlice({ + name: 'modelElements', + initialState, + reducers: {}, + extraReducers: builder => { + getModelElementsReducer(builder); + }, +}); + +export default modelElements.reducer; diff --git a/src/redux/modelElements/modelElements.thunks.test.ts b/src/redux/modelElements/modelElements.thunks.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..c5611de7faa4a7de5420a58d5450111a9f0e373d --- /dev/null +++ b/src/redux/modelElements/modelElements.thunks.test.ts @@ -0,0 +1,41 @@ +/* eslint-disable no-magic-numbers */ +import { apiPath } from '@/redux/apiPath'; +import { + ToolkitStoreWithSingleSlice, + createStoreInstanceUsingSliceReducer, +} from '@/utils/createStoreInstanceUsingSliceReducer'; +import { mockNetworkNewAPIResponse } from '@/utils/mockNetworkResponse'; +import { HttpStatusCode } from 'axios'; +import { ModelElementsState } from '@/redux/modelElements/modelElements.types'; +import modelElementsReducer from '@/redux/modelElements/modelElements.slice'; +import { getModelElements } from '@/redux/modelElements/modelElements.thunks'; +import { modelElementsFixture } from '@/models/fixtures/modelElementsFixture'; + +const mockedAxiosClient = mockNetworkNewAPIResponse(); + +describe('model elements thunks', () => { + let store = {} as ToolkitStoreWithSingleSlice<ModelElementsState>; + beforeEach(() => { + store = createStoreInstanceUsingSliceReducer('modelElements', modelElementsReducer); + }); + + describe('getModelElements', () => { + it('should return data when data response from API is valid', async () => { + mockedAxiosClient + .onGet(apiPath.getModelElements(0)) + .reply(HttpStatusCode.Ok, modelElementsFixture); + + const { payload } = await store.dispatch(getModelElements(0)); + expect(payload).toEqual(modelElementsFixture); + }); + + it('should return undefined when data response from API is not valid ', async () => { + mockedAxiosClient + .onGet(apiPath.getModelElements(0)) + .reply(HttpStatusCode.Ok, { randomProperty: 'randomValue' }); + + const { payload } = await store.dispatch(getModelElements(0)); + expect(payload).toEqual(undefined); + }); + }); +}); diff --git a/src/redux/modelElements/modelElements.thunks.ts b/src/redux/modelElements/modelElements.thunks.ts new file mode 100644 index 0000000000000000000000000000000000000000..7898db9a04b263622c80cd3192c7ec4417935892 --- /dev/null +++ b/src/redux/modelElements/modelElements.thunks.ts @@ -0,0 +1,24 @@ +import { apiPath } from '@/redux/apiPath'; +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 { modelElementsSchema } from '@/models/modelElementsSchema'; +import { ModelElements } from '@/types/models'; +import { MODEL_ELEMENTS_FETCHING_ERROR_PREFIX } from '@/redux/modelElements/modelElements.constants'; + +export const getModelElements = createAsyncThunk<ModelElements | undefined, number, ThunkConfig>( + 'vectorMap/getModelElements', + async (modelId: number) => { + try { + const response = await axiosInstanceNewAPI.get<ModelElements>( + apiPath.getModelElements(modelId), + ); + const isDataValid = validateDataUsingZodSchema(response.data, modelElementsSchema); + return isDataValid ? response.data : undefined; + } catch (error) { + return Promise.reject(getError({ error, prefix: MODEL_ELEMENTS_FETCHING_ERROR_PREFIX })); + } + }, +); diff --git a/src/redux/modelElements/modelElements.types.ts b/src/redux/modelElements/modelElements.types.ts new file mode 100644 index 0000000000000000000000000000000000000000..0dfdb426840f0a55ec4429ca78f06587d23f0f5f --- /dev/null +++ b/src/redux/modelElements/modelElements.types.ts @@ -0,0 +1,4 @@ +import { FetchDataState } from '@/types/fetchDataState'; +import { ModelElements } from '@/types/models'; + +export type ModelElementsState = FetchDataState<ModelElements, null>; diff --git a/src/redux/models/models.reducers.ts b/src/redux/models/models.reducers.ts index 4b9f7fc67c95ba1f80df7f907cbc4bb8fc1a3d28..ee7b9a63f5ac133a8b5fcfa6bf6f936698fb1305 100644 --- a/src/redux/models/models.reducers.ts +++ b/src/redux/models/models.reducers.ts @@ -1,4 +1,5 @@ -import { ActionReducerMapBuilder } from '@reduxjs/toolkit'; +/* eslint-disable no-magic-numbers */ +import { ActionReducerMapBuilder, PayloadAction } from '@reduxjs/toolkit'; import { getModels } from './models.thunks'; import { ModelsState } from './models.types'; @@ -15,3 +16,14 @@ export const getModelsReducer = (builder: ActionReducerMapBuilder<ModelsState>): // TODO to discuss manage state of failure }); }; + +export const setModelVectorRenderingReducer = ( + state: ModelsState, + action: PayloadAction<{ vectorRendering: boolean; mapId: number }>, +): void => { + const { payload } = action; + const modelIndex = state.data.findIndex(model => model.idObject === payload.mapId); + if (modelIndex !== -1) { + state.data[modelIndex].vectorRendering = payload.vectorRendering; + } +}; diff --git a/src/redux/models/models.slice.ts b/src/redux/models/models.slice.ts index 5c969f3e0e1d6eb2b0e5183d3a5bc7b454248f60..48cf86543d6af1dc4ac35c04e4e4c8e8f85ad260 100644 --- a/src/redux/models/models.slice.ts +++ b/src/redux/models/models.slice.ts @@ -1,6 +1,6 @@ import { ModelsState } from '@/redux/models/models.types'; import { createSlice } from '@reduxjs/toolkit'; -import { getModelsReducer } from './models.reducers'; +import { getModelsReducer, setModelVectorRenderingReducer } from './models.reducers'; const initialState: ModelsState = { data: [], @@ -11,10 +11,14 @@ const initialState: ModelsState = { export const modelsSlice = createSlice({ name: 'models', initialState, - reducers: {}, + reducers: { + setModelVectorRendering: setModelVectorRenderingReducer, + }, extraReducers: builder => { getModelsReducer(builder); }, }); +export const { setModelVectorRendering } = modelsSlice.actions; + export default modelsSlice.reducer; diff --git a/src/redux/root/init.thunks.ts b/src/redux/root/init.thunks.ts index 20a722afb23f2b73870dea3ee777170a060f1ac9..4e161b6e786697a53f445b4b880fcafab26c3b7b 100644 --- a/src/redux/root/init.thunks.ts +++ b/src/redux/root/init.thunks.ts @@ -15,6 +15,7 @@ import { import { openSelectProjectModal } from '@/redux/modal/modal.slice'; import { getProjects } from '@/redux/projects/projects.thunks'; import { getSubmapConnectionsBioEntity } from '@/redux/bioEntity/thunks/getSubmapConnectionsBioEntity'; +import { getShapes } from '@/redux/shapes/shapes.thunks'; import { getAllBackgroundsByProjectId } from '../backgrounds/backgrounds.thunks'; import { getConfiguration, getConfigurationOptions } from '../configuration/configuration.thunks'; import { @@ -58,6 +59,7 @@ export const fetchInitialAppData = createAsyncThunk< dispatch(getAllBackgroundsByProjectId(PROJECT_ID)), dispatch(getAllPublicOverlaysByProjectId(PROJECT_ID)), dispatch(getModels()), + dispatch(getShapes()), ]); if (queryData.pluginsId) { diff --git a/src/redux/root/root.fixtures.ts b/src/redux/root/root.fixtures.ts index e260f230ea8bd4a0060fe58399cb9ef2b9965087..7800ab1931b4009204cc14282bbe5fdaee7e8bf0 100644 --- a/src/redux/root/root.fixtures.ts +++ b/src/redux/root/root.fixtures.ts @@ -3,6 +3,8 @@ import { PROJECTS_STATE_INITIAL_MOCK } from '@/redux/projects/projects.mock'; import { OAUTH_INITIAL_STATE_MOCK } from '@/redux/oauth/oauth.mock'; import { COMMENT_INITIAL_STATE_MOCK } from '@/redux/comment/comment.mock'; import { AUTOCOMPLETE_INITIAL_STATE } from '@/redux/autocomplete/autocomplete.constants'; +import { BIO_SHAPES_STATE_INITIAL_MOCK } from '@/redux/shapes/shapes.mock'; +import { MODEL_ELEMENTS_INITIAL_STATE_MOCK } from '@/redux/modelElements/modelElements.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'; @@ -36,10 +38,12 @@ export const INITIAL_STORE_STATE_MOCK: RootState = { autocompleteChemical: AUTOCOMPLETE_INITIAL_STATE, search: SEARCH_STATE_INITIAL_MOCK, project: PROJECT_STATE_INITIAL_MOCK, + shapes: BIO_SHAPES_STATE_INITIAL_MOCK, projects: PROJECTS_STATE_INITIAL_MOCK, drugs: DRUGS_INITIAL_STATE_MOCK, chemicals: CHEMICALS_INITIAL_STATE_MOCK, models: MODELS_INITIAL_STATE_MOCK, + modelElements: MODEL_ELEMENTS_INITIAL_STATE_MOCK, bioEntity: BIOENTITY_INITIAL_STATE_MOCK, backgrounds: BACKGROUND_INITIAL_STATE_MOCK, drawer: drawerInitialStateMock, diff --git a/src/redux/shapes/shapes.constants.ts b/src/redux/shapes/shapes.constants.ts new file mode 100644 index 0000000000000000000000000000000000000000..04d8b1caed90ca32d964ad2a87e3de34c30e8938 --- /dev/null +++ b/src/redux/shapes/shapes.constants.ts @@ -0,0 +1 @@ +export const SHAPES_FETCHING_ERROR_PREFIX = 'Failed to fetch shapes'; diff --git a/src/redux/shapes/shapes.mock.ts b/src/redux/shapes/shapes.mock.ts new file mode 100644 index 0000000000000000000000000000000000000000..d9ee5018d64fe273b524f309299c1c8765666874 --- /dev/null +++ b/src/redux/shapes/shapes.mock.ts @@ -0,0 +1,8 @@ +import { DEFAULT_ERROR } from '@/constants/errors'; +import { BioShapesState } from '@/redux/shapes/shapes.types'; + +export const BIO_SHAPES_STATE_INITIAL_MOCK: BioShapesState = { + data: [], + loading: 'idle', + error: DEFAULT_ERROR, +}; diff --git a/src/redux/shapes/shapes.reducers.test.ts b/src/redux/shapes/shapes.reducers.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..41b8df566da00a148a623d3627e75f618a2c9512 --- /dev/null +++ b/src/redux/shapes/shapes.reducers.test.ts @@ -0,0 +1,76 @@ +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 { shapesFixture } from '@/models/fixtures/shapesFixture'; +import shapesReducer from './shapes.slice'; +import { getShapes } from './shapes.thunks'; +import { BioShapesState } from './shapes.types'; + +const mockedAxiosClient = mockNetworkNewAPIResponse(); + +const INITIAL_STATE: BioShapesState = { + data: [], + loading: 'idle', + error: { name: '', message: '' }, +}; + +describe('shapes reducer', () => { + let store = {} as ToolkitStoreWithSingleSlice<BioShapesState>; + beforeEach(() => { + store = createStoreInstanceUsingSliceReducer('shapes', shapesReducer); + }); + + it('should match initial state', () => { + const action = { type: 'unknown' }; + + expect(shapesReducer(undefined, action)).toEqual(INITIAL_STATE); + }); + + it('should update store after succesfull getShapes query', async () => { + mockedAxiosClient.onGet(apiPath.getShapes()).reply(HttpStatusCode.Ok, shapesFixture); + + const { type } = await store.dispatch(getShapes()); + const { data, loading, error } = store.getState().shapes; + expect(type).toBe('vectorMap/getShapes/fulfilled'); + expect(loading).toEqual('succeeded'); + expect(error).toEqual({ message: '', name: '' }); + expect(data).toEqual(shapesFixture); + }); + + it('should update store after failed getShapes query', async () => { + mockedAxiosClient.onGet(apiPath.getShapes()).reply(HttpStatusCode.NotFound, []); + + const action = await store.dispatch(getShapes()); + const { data, loading, error } = store.getState().shapes; + + expect(action.type).toBe('vectorMap/getShapes/rejected'); + expect(() => unwrapResult(action)).toThrow( + "Failed to fetch shapes: 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 getShapes query', async () => { + mockedAxiosClient.onGet(apiPath.getShapes()).reply(HttpStatusCode.Ok, shapesFixture); + + const shapesPromise = store.dispatch(getShapes()); + + const { data, loading } = store.getState().shapes; + expect(data).toEqual([]); + expect(loading).toEqual('pending'); + + shapesPromise.then(() => { + const { data: dataPromiseFulfilled, loading: promiseFulfilled } = store.getState().shapes; + + expect(dataPromiseFulfilled).toEqual(shapesFixture); + expect(promiseFulfilled).toEqual('succeeded'); + }); + }); +}); diff --git a/src/redux/shapes/shapes.reducers.ts b/src/redux/shapes/shapes.reducers.ts new file mode 100644 index 0000000000000000000000000000000000000000..72ea84f0fba44c9e14aa8b0076496be48cd6fe5f --- /dev/null +++ b/src/redux/shapes/shapes.reducers.ts @@ -0,0 +1,16 @@ +import { ActionReducerMapBuilder } from '@reduxjs/toolkit'; +import { BioShapesState } from '@/redux/shapes/shapes.types'; +import { getShapes } from '@/redux/shapes/shapes.thunks'; + +export const getShapesReducer = (builder: ActionReducerMapBuilder<BioShapesState>): void => { + builder.addCase(getShapes.pending, state => { + state.loading = 'pending'; + }); + builder.addCase(getShapes.fulfilled, (state, action) => { + state.data = action.payload || []; + state.loading = 'succeeded'; + }); + builder.addCase(getShapes.rejected, state => { + state.loading = 'failed'; + }); +}; diff --git a/src/redux/shapes/shapes.selectors.ts b/src/redux/shapes/shapes.selectors.ts new file mode 100644 index 0000000000000000000000000000000000000000..6faab0fe791516a689f72b1b8be01d12298b8220 --- /dev/null +++ b/src/redux/shapes/shapes.selectors.ts @@ -0,0 +1,9 @@ +import { createSelector } from '@reduxjs/toolkit'; +import { rootSelector } from '@/redux/root/root.selectors'; + +export const shapesSelector = createSelector(rootSelector, state => state.shapes); + +export const shapeBySBOSelector = createSelector( + [shapesSelector, (_state, shapeSBO: string): string => shapeSBO], + (shapes, shapeSBO) => (shapes?.data || []).find(({ sboTerm }) => sboTerm === shapeSBO), +); diff --git a/src/redux/shapes/shapes.slice.ts b/src/redux/shapes/shapes.slice.ts new file mode 100644 index 0000000000000000000000000000000000000000..06c08ec04e1a92e70d963c584b47154e013b15a3 --- /dev/null +++ b/src/redux/shapes/shapes.slice.ts @@ -0,0 +1,20 @@ +import { BioShapesState } from '@/redux/shapes/shapes.types'; +import { createSlice } from '@reduxjs/toolkit'; +import { getShapesReducer } from '@/redux/shapes/shapes.reducers'; + +const initialState: BioShapesState = { + data: [], + loading: 'idle', + error: { name: '', message: '' }, +}; + +export const shapesSlice = createSlice({ + name: 'shapes', + initialState, + reducers: {}, + extraReducers: builder => { + getShapesReducer(builder); + }, +}); + +export default shapesSlice.reducer; diff --git a/src/redux/shapes/shapes.thunks.test.ts b/src/redux/shapes/shapes.thunks.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..d4cb142cccf4f31fbb1c82b95c8ea36d6d4119cc --- /dev/null +++ b/src/redux/shapes/shapes.thunks.test.ts @@ -0,0 +1,36 @@ +import { apiPath } from '@/redux/apiPath'; +import { + ToolkitStoreWithSingleSlice, + createStoreInstanceUsingSliceReducer, +} from '@/utils/createStoreInstanceUsingSliceReducer'; +import { mockNetworkNewAPIResponse } from '@/utils/mockNetworkResponse'; +import { HttpStatusCode } from 'axios'; +import { BioShapesState } from '@/redux/shapes/shapes.types'; +import { shapesFixture } from '@/models/fixtures/shapesFixture'; +import shapesReducer from './shapes.slice'; +import { getShapes } from './shapes.thunks'; + +const mockedAxiosClient = mockNetworkNewAPIResponse(); + +describe('shapes thunks', () => { + let store = {} as ToolkitStoreWithSingleSlice<BioShapesState>; + beforeEach(() => { + store = createStoreInstanceUsingSliceReducer('shapes', shapesReducer); + }); + describe('getShapes', () => { + it('should return data when data response from API is valid', async () => { + mockedAxiosClient.onGet(apiPath.getShapes()).reply(HttpStatusCode.Ok, shapesFixture); + + const { payload } = await store.dispatch(getShapes()); + expect(payload).toEqual(shapesFixture); + }); + it('should return undefined when data response from API is not valid ', async () => { + mockedAxiosClient + .onGet(apiPath.getShapes()) + .reply(HttpStatusCode.Ok, { randomProperty: 'randomValue' }); + + const { payload } = await store.dispatch(getShapes()); + expect(payload).toEqual(undefined); + }); + }); +}); diff --git a/src/redux/shapes/shapes.thunks.ts b/src/redux/shapes/shapes.thunks.ts new file mode 100644 index 0000000000000000000000000000000000000000..2bb0b7ecf5d6d33c63eb60b946a1d1e7ecc58822 --- /dev/null +++ b/src/redux/shapes/shapes.thunks.ts @@ -0,0 +1,24 @@ +import { bioShapeSchema } from '@/models/bioShapeSchema'; +import { apiPath } from '@/redux/apiPath'; +import { BioShape } from '@/types/models'; +import { validateDataUsingZodSchema } from '@/utils/validateDataUsingZodSchema'; +import { createAsyncThunk } from '@reduxjs/toolkit'; +import { z } from 'zod'; +import { ThunkConfig } from '@/types/store'; +import { getError } from '@/utils/error-report/getError'; +import { SHAPES_FETCHING_ERROR_PREFIX } from '@/redux/shapes/shapes.constants'; +import { axiosInstanceNewAPI } from '@/services/api/utils/axiosInstance'; + +export const getShapes = createAsyncThunk<BioShape[] | undefined, void, ThunkConfig>( + 'vectorMap/getShapes', + async () => { + try { + const response = await axiosInstanceNewAPI.get<BioShape[]>(apiPath.getShapes()); + const isDataValid = validateDataUsingZodSchema(response.data, z.array(bioShapeSchema)); + + return isDataValid ? response.data : undefined; + } catch (error) { + return Promise.reject(getError({ error, prefix: SHAPES_FETCHING_ERROR_PREFIX })); + } + }, +); diff --git a/src/redux/shapes/shapes.types.ts b/src/redux/shapes/shapes.types.ts new file mode 100644 index 0000000000000000000000000000000000000000..e4b006f1906eb3ef0003fe127c6f61188ba907fd --- /dev/null +++ b/src/redux/shapes/shapes.types.ts @@ -0,0 +1,4 @@ +import { FetchDataState } from '@/types/fetchDataState'; +import { BioShape } from '@/types/models'; + +export type BioShapesState = FetchDataState<BioShape[], []>; diff --git a/src/redux/store.ts b/src/redux/store.ts index f11f6e57248b0a405241aa54ee29d321e1b29ffc..2df3467569d8cc85a234814cc001060be1ca5c99 100644 --- a/src/redux/store.ts +++ b/src/redux/store.ts @@ -10,6 +10,8 @@ import drugsReducer from '@/redux/drugs/drugs.slice'; 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 modelElementsReducer from '@/redux/modelElements/modelElements.slice'; import oauthReducer from '@/redux/oauth/oauth.slice'; import overlayBioEntityReducer from '@/redux/overlayBioEntity/overlayBioEntity.slice'; import overlaysReducer from '@/redux/overlays/overlays.slice'; @@ -59,6 +61,8 @@ export const reducers = { backgrounds: backgroundsReducer, overlays: overlaysReducer, models: modelsReducer, + shapes: shapesReducer, + modelElements: modelElementsReducer, reactions: reactionsReducer, contextMenu: contextMenuReducer, cookieBanner: cookieBannerReducer, diff --git a/src/shared/Switch/Switch.component.tsx b/src/shared/Switch/Switch.component.tsx new file mode 100644 index 0000000000000000000000000000000000000000..355e84b92d9aaf3e49ab8f0c41d01e8ee28037b8 --- /dev/null +++ b/src/shared/Switch/Switch.component.tsx @@ -0,0 +1,71 @@ +import { twMerge } from 'tailwind-merge'; +import { useEffect, useState } from 'react'; + +type VariantStyle = 'primary' | 'secondary' | 'ghost' | 'quiet'; + +export interface SwitchProps { + variantStyles?: VariantStyle; + isChecked?: boolean; + onToggle?: (checked: boolean) => void; +} + +const variants = { + primary: { + switch: 'bg-greyscale-700', + circle: 'bg-white-pearl', + }, + secondary: { + switch: 'bg-primary-100', + circle: 'bg-white-pearl', + }, + ghost: { + switch: 'bg-greyscale-600', + circle: 'bg-white-pearl', + }, + quiet: { + switch: 'bg-greyscale-500', + circle: 'bg-white-pearl', + }, +} as const; + +export const Switch = ({ + variantStyles = 'primary', + isChecked = false, + onToggle, +}: SwitchProps): JSX.Element => { + const [checked, setChecked] = useState(isChecked); + + useEffect(() => { + setChecked(isChecked); + }, [isChecked]); + + const handleToggle = (): void => { + const newChecked = !checked; + setChecked(newChecked); + if (onToggle) { + onToggle(newChecked); + } + }; + + return ( + <button + type="button" + className={twMerge( + 'relative inline-flex h-5 w-10 cursor-pointer rounded-full transition-colors duration-300 ease-in-out', + variants[variantStyles].switch, + checked ? 'bg-primary-600' : '', + )} + onClick={handleToggle} + > + <span + className={twMerge( + 'absolute left-0 top-0 h-5 w-5 rounded-full transition-transform duration-300 ease-in-out', + variants[variantStyles].circle, + checked ? 'translate-x-6' : 'translate-x-0', + )} + /> + </button> + ); +}; + +Switch.displayName = 'Switch'; diff --git a/src/shared/Switch/index.tsx b/src/shared/Switch/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ce66469a5ea5baf250a33b9e4b0ef1a9e25036d8 --- /dev/null +++ b/src/shared/Switch/index.tsx @@ -0,0 +1 @@ +export { Switch } from './Switch.component'; diff --git a/src/types/models.ts b/src/types/models.ts index c55955ac291dc9571cd2879f730777ebb663682e..878d99dfe3b4a8316dbfd11db5cbacd1c3eae2f7 100644 --- a/src/types/models.ts +++ b/src/types/models.ts @@ -65,6 +65,9 @@ import { commentSchema } from '@/models/commentSchema'; import { userSchema } from '@/models/userSchema'; import { javaStacktraceSchema } from '@/models/javaStacktraceSchema'; import { oauthSchema } from '@/models/oauthSchema'; +import { bioShapeSchema } from '@/models/bioShapeSchema'; +import { shapeSchema } from '@/models/shapeSchema'; +import { modelElementsSchema } from '@/models/modelElementsSchema'; export type Project = z.infer<typeof projectSchema>; export type OverviewImageView = z.infer<typeof overviewImageView>; @@ -72,6 +75,9 @@ export type OverviewImageLink = z.infer<typeof overviewImageLink>; export type OverviewImageLinkImage = z.infer<typeof overviewImageLinkImage>; export type OverviewImageLinkModel = z.infer<typeof overviewImageLinkModel>; export type MapModel = z.infer<typeof mapModelSchema>; +export type BioShape = z.infer<typeof bioShapeSchema>; +export type ModelElements = z.infer<typeof modelElementsSchema>; +export type Shape = z.infer<typeof shapeSchema>; export type MapOverlay = z.infer<typeof mapOverlay>; export type MapBackground = z.infer<typeof mapBackground>; export type Organism = z.infer<typeof organism>;