diff --git a/src/components/FunctionalArea/NavBar/NavBar.component.tsx b/src/components/FunctionalArea/NavBar/NavBar.component.tsx index 7eb7b2b0f556a4f4fe4e7a805ed00a916a97cd8a..644b830ccbb8784355cb4100ab19b12332e38d03 100644 --- a/src/components/FunctionalArea/NavBar/NavBar.component.tsx +++ b/src/components/FunctionalArea/NavBar/NavBar.component.tsx @@ -50,6 +50,14 @@ export const NavBar = (): JSX.Element => { } }; + const toggleDrawerLayers = (): void => { + if (store.getState().drawer.isOpen && store.getState().drawer.drawerName === 'layers') { + dispatch(closeDrawer()); + } else { + dispatch(openDrawer('layers')); + } + }; + const toggleDrawerLegend = (): void => { if (store.getState().legend.isOpen) { dispatch(closeLegend()); @@ -77,6 +85,7 @@ export const NavBar = (): JSX.Element => { </a> <IconButton icon="plugin" onClick={toggleDrawerPlugins} title="Available plugins" /> <IconButton icon="export" onClick={toggleDrawerExport} title="Export" /> + <IconButton icon="layers" onClick={toggleDrawerLayers} title="Layers" /> </div> <div className="flex flex-col gap-[10px]"> <IconButton icon="legend" onClick={toggleDrawerLegend} title="Legend" /> diff --git a/src/components/Map/Drawer/Drawer.component.tsx b/src/components/Map/Drawer/Drawer.component.tsx index 71f10406f94ba978b951b7649e921f4c6013fa53..b18e949fed1a9db8afe490c00ce6f3feb175f098 100644 --- a/src/components/Map/Drawer/Drawer.component.tsx +++ b/src/components/Map/Drawer/Drawer.component.tsx @@ -3,6 +3,7 @@ import { drawerSelector } from '@/redux/drawer/drawer.selectors'; import { useAppSelector } from '@/redux/hooks/useAppSelector'; import { twMerge } from 'tailwind-merge'; import { CommentDrawer } from '@/components/Map/Drawer/CommentDrawer'; +import { LayersDrawer } from '@/components/Map/Drawer/LayersDrawer/LayersDrawer.component'; import { AvailablePluginsDrawer } from './AvailablePluginsDrawer'; import { BioEntityDrawer } from './BioEntityDrawer/BioEntityDrawer.component'; import { ExportDrawer } from './ExportDrawer'; @@ -32,6 +33,7 @@ export const Drawer = (): JSX.Element => { {isOpen && drawerName === 'export' && <ExportDrawer />} {isOpen && drawerName === 'available-plugins' && <AvailablePluginsDrawer />} {isOpen && drawerName === 'comment' && <CommentDrawer />} + {isOpen && drawerName === 'layers' && <LayersDrawer />} </div> ); }; diff --git a/src/components/Map/Drawer/LayersDrawer/LayersDrawer.component.test.tsx b/src/components/Map/Drawer/LayersDrawer/LayersDrawer.component.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e40ea7514e5274b5ffdb0aa00c9b8644a2662376 --- /dev/null +++ b/src/components/Map/Drawer/LayersDrawer/LayersDrawer.component.test.tsx @@ -0,0 +1,46 @@ +import { + InitialStoreState, + getReduxWrapperWithStore, +} from '@/utils/testing/getReduxWrapperWithStore'; +import { StoreType } from '@/redux/store'; +import { render, screen } from '@testing-library/react'; +import { openedExportDrawerFixture } from '@/redux/drawer/drawerFixture'; +import { LayersDrawer } from '@/components/Map/Drawer/LayersDrawer/LayersDrawer.component'; + +const renderComponent = (initialStoreState: InitialStoreState = {}): { store: StoreType } => { + const { Wrapper, store } = getReduxWrapperWithStore(initialStoreState); + + return ( + render( + <Wrapper> + <LayersDrawer /> + </Wrapper>, + ), + { + store, + } + ); +}; + +describe('ExportDrawer - component', () => { + it('should display drawer heading', () => { + renderComponent(); + + expect(screen.getByText('Layers')).toBeInTheDocument(); + }); + + it('should close drawer after clicking close button', () => { + const { store } = renderComponent({ + drawer: openedExportDrawerFixture, + }); + const closeButton = screen.getByRole('close-drawer-button'); + + closeButton.click(); + + const { + drawer: { isOpen }, + } = store.getState(); + + expect(isOpen).toBe(false); + }); +}); diff --git a/src/components/Map/Drawer/LayersDrawer/LayersDrawer.component.tsx b/src/components/Map/Drawer/LayersDrawer/LayersDrawer.component.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7e6142984a4a9887d16fa281dec26a75695f8f41 --- /dev/null +++ b/src/components/Map/Drawer/LayersDrawer/LayersDrawer.component.tsx @@ -0,0 +1,31 @@ +import { useAppSelector } from '@/redux/hooks/useAppSelector'; +import { DrawerHeading } from '@/shared/DrawerHeading'; +import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; +import { layersSelector, layersVisibilitySelector } from '@/redux/layers/layers.selectors'; +import { Switch } from '@/shared/Switch'; +import { setLayerVisibility } from '@/redux/layers/layers.slice'; + +export const LayersDrawer = (): JSX.Element => { + const layers = useAppSelector(layersSelector); + const layersVisibility = useAppSelector(layersVisibilitySelector); + const dispatch = useAppDispatch(); + + return ( + <div data-testid="layers-drawer" className="h-full max-h-full"> + <DrawerHeading title="Layers" /> + <div className="flex h-[calc(100%-93px)] max-h-[calc(100%-93px)] flex-col overflow-y-auto px-6"> + {layers.map(layer => ( + <div key={layer.details.id} className="flex items-center justify-between border-b p-4"> + <h1>{layer.details.name}</h1> + <Switch + isChecked={layersVisibility[layer.details.layerId]} + onToggle={value => + dispatch(setLayerVisibility({ visible: value, layerId: layer.details.layerId })) + } + /> + </div> + ))} + </div> + </div> + ); +}; diff --git a/src/components/Map/Drawer/LayersDrawer/index.ts b/src/components/Map/Drawer/LayersDrawer/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..c608bacebc5f5ee5b6f5863d44ecfca878b67bae --- /dev/null +++ b/src/components/Map/Drawer/LayersDrawer/index.ts @@ -0,0 +1 @@ +export { LayersDrawer } from './LayersDrawer.component'; diff --git a/src/components/Map/MapViewer/MapViewerVector/MapViewerVector.constants.ts b/src/components/Map/MapViewer/MapViewerVector/MapViewerVector.constants.ts index d038cdf39f083a21b48dad564fcd513094c0adc2..31cae9f134bb5f8d1f4a3d9df4570604782aaa4f 100644 --- a/src/components/Map/MapViewer/MapViewerVector/MapViewerVector.constants.ts +++ b/src/components/Map/MapViewer/MapViewerVector/MapViewerVector.constants.ts @@ -1,4 +1,10 @@ -import { ColorObject } from '@/components/Map/MapViewer/MapViewerVector/MapViewerVector.types'; +import { + ColorObject, + EllipseCenter, + EllipseRadius, + ShapeCurvePoint, + ShapePoint, +} from '@/components/Map/MapViewer/MapViewerVector/MapViewerVector.types'; export const WHITE_COLOR: ColorObject = { alpha: 255, @@ -9,3 +15,153 @@ export const BLACK_COLOR: ColorObject = { alpha: 255, rgb: -16777216, }; + +export const COMPARTMENT_SQUARE_POINTS: Array<ShapePoint | ShapeCurvePoint> = [ + { + type: 'REL_ABS_POINT', + absoluteX: 10.0, + absoluteY: 0.0, + relativeX: 0.0, + relativeY: 0.0, + relativeHeightForX: null, + relativeWidthForY: null, + }, + { + type: 'REL_ABS_POINT', + absoluteX: -10.0, + absoluteY: 0.0, + relativeX: 100.0, + relativeY: 0.0, + relativeHeightForX: null, + relativeWidthForY: null, + }, + { + type: 'REL_ABS_BEZIER_POINT', + absoluteX1: 0.0, + absoluteY1: 10.0, + relativeX1: 100.0, + relativeY1: 0.0, + relativeHeightForX1: null, + relativeWidthForY1: null, + absoluteX2: -5.0, + absoluteY2: 0.0, + relativeX2: 100.0, + relativeY2: 0.0, + relativeHeightForX2: null, + relativeWidthForY2: null, + absoluteX3: 0.0, + absoluteY3: 5.0, + relativeX3: 100.0, + relativeY3: 0.0, + relativeHeightForX3: null, + relativeWidthForY3: null, + }, + { + type: 'REL_ABS_POINT', + absoluteX: 0.0, + absoluteY: -10.0, + relativeX: 100.0, + relativeY: 100.0, + relativeHeightForX: null, + relativeWidthForY: null, + }, + { + type: 'REL_ABS_BEZIER_POINT', + absoluteX1: -10.0, + absoluteY1: 0.0, + relativeX1: 100.0, + relativeY1: 100.0, + relativeHeightForX1: null, + relativeWidthForY1: null, + absoluteX2: 0.0, + absoluteY2: -5.0, + relativeX2: 100.0, + relativeY2: 100.0, + relativeHeightForX2: null, + relativeWidthForY2: null, + absoluteX3: -5.0, + absoluteY3: 0.0, + relativeX3: 100.0, + relativeY3: 100.0, + relativeHeightForX3: null, + relativeWidthForY3: null, + }, + { + type: 'REL_ABS_POINT', + absoluteX: 10.0, + absoluteY: 0.0, + relativeX: 0.0, + relativeY: 100.0, + relativeHeightForX: null, + relativeWidthForY: null, + }, + { + type: 'REL_ABS_BEZIER_POINT', + absoluteX1: 0.0, + absoluteY1: -10.0, + relativeX1: 0.0, + relativeY1: 100.0, + relativeHeightForX1: null, + relativeWidthForY1: null, + absoluteX2: 5.0, + absoluteY2: 0.0, + relativeX2: 0.0, + relativeY2: 100.0, + relativeHeightForX2: null, + relativeWidthForY2: null, + absoluteX3: 0.0, + absoluteY3: -5.0, + relativeX3: 0.0, + relativeY3: 100.0, + relativeHeightForX3: null, + relativeWidthForY3: null, + }, + { + type: 'REL_ABS_POINT', + absoluteX: 0.0, + absoluteY: 10.0, + relativeX: 0.0, + relativeY: 0.0, + relativeHeightForX: null, + relativeWidthForY: null, + }, + { + type: 'REL_ABS_BEZIER_POINT', + absoluteX1: 10.0, + absoluteY1: 0.0, + relativeX1: 0.0, + relativeY1: 0.0, + relativeHeightForX1: null, + relativeWidthForY1: null, + absoluteX2: 0.0, + absoluteY2: 5.0, + relativeX2: 0.0, + relativeY2: 0.0, + relativeHeightForX2: null, + relativeWidthForY2: null, + absoluteX3: 5.0, + absoluteY3: 0.0, + relativeX3: 0.0, + relativeY3: 0.0, + relativeHeightForX3: null, + relativeWidthForY3: null, + }, +]; + +export const COMPARTMENT_CIRCLE_CENTER: EllipseCenter = { + type: 'REL_ABS_POINT', + absoluteX: 0.0, + absoluteY: 0.0, + relativeX: 50.0, + relativeY: 50.0, + relativeHeightForX: null, + relativeWidthForY: null, +}; + +export const COMPARTMENT_CIRCLE_RADIUS: EllipseRadius = { + type: 'REL_ABS_RADIUS', + absoluteX: 0.0, + absoluteY: 0.0, + relativeX: 50.0, + relativeY: 50.0, +}; diff --git a/src/components/Map/MapViewer/MapViewerVector/MapViewerVector.types.ts b/src/components/Map/MapViewer/MapViewerVector/MapViewerVector.types.ts index 78e365e3a1fc14eccc28c28ec737740f1f77519b..4c5ee626be061e6f81432f40e596f75e4a1d0c4c 100644 --- a/src/components/Map/MapViewer/MapViewerVector/MapViewerVector.types.ts +++ b/src/components/Map/MapViewer/MapViewerVector/MapViewerVector.types.ts @@ -45,3 +45,21 @@ export type ShapeCurvePoint = { relativeHeightForX3: number | null; relativeWidthForY3: number | null; }; + +export type EllipseCenter = { + type: string; + absoluteX: number; + absoluteY: number; + relativeX: number; + relativeY: number; + relativeHeightForX: number | null; + relativeWidthForY: number | null; +}; + +export type EllipseRadius = { + type: string; + absoluteX: number; + absoluteY: number; + relativeX: number; + relativeY: number; +}; diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/config/additionalLayers/useOlMapAdditionalLayers.test.ts b/src/components/Map/MapViewer/MapViewerVector/utils/config/additionalLayers/useOlMapAdditionalLayers.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..68151ba06537964a865a132937e502a0c894dc08 --- /dev/null +++ b/src/components/Map/MapViewer/MapViewerVector/utils/config/additionalLayers/useOlMapAdditionalLayers.test.ts @@ -0,0 +1,25 @@ +/* eslint-disable no-magic-numbers */ +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 { useOlMapAdditionalLayers } from '@/components/Map/MapViewer/MapViewerVector/utils/config/additionalLayers/useOlMapAdditionalLayers'; + +describe('useOlMapAdditionalLayers - 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(() => useOlMapAdditionalLayers(mapInstance), { + wrapper: Wrapper, + }); + + expect(result.current).toBeInstanceOf(Array<VectorLayer>); + }); +}); diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/config/additionalLayers/useOlMapAdditionalLayers.ts b/src/components/Map/MapViewer/MapViewerVector/utils/config/additionalLayers/useOlMapAdditionalLayers.ts new file mode 100644 index 0000000000000000000000000000000000000000..98a4276aea101f2916ace06afc65d05e5c1c356f --- /dev/null +++ b/src/components/Map/MapViewer/MapViewerVector/utils/config/additionalLayers/useOlMapAdditionalLayers.ts @@ -0,0 +1,65 @@ +/* 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 { useSelector } from 'react-redux'; +import { currentModelIdSelector } from '@/redux/models/models.selectors'; +import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; +import { getLayers } from '@/redux/layers/layers.thunks'; +import { layersSelector, layersVisibilitySelector } from '@/redux/layers/layers.selectors'; +import { usePointToProjection } from '@/utils/map/usePointToProjection'; +import { MapInstance } from '@/types/map'; +import { LineString, MultiPolygon, Point } from 'ol/geom'; +import Polygon from 'ol/geom/Polygon'; +import Layer from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/layer/Layer'; +import { arrowTypesSelector, lineTypesSelector } from '@/redux/shapes/shapes.selectors'; + +export const useOlMapAdditionalLayers = ( + mapInstance: MapInstance, +): Array< + VectorLayer< + VectorSource<Feature<Point> | Feature<Polygon> | Feature<LineString> | Feature<MultiPolygon>> + > +> => { + const dispatch = useAppDispatch(); + const currentModelId = useSelector(currentModelIdSelector); + const mapLayers = useSelector(layersSelector); + const layersVisibility = useSelector(layersVisibilitySelector); + const lineTypes = useSelector(lineTypesSelector); + const arrowTypes = useSelector(arrowTypesSelector); + const pointToProjection = usePointToProjection(); + + useEffect(() => { + dispatch(getLayers(currentModelId)); + }, [currentModelId, dispatch]); + + const vectorLayers = useMemo(() => { + return mapLayers.map(layer => { + const additionalLayer = new Layer({ + texts: layer.texts, + rects: layer.rects, + ovals: layer.ovals, + lines: layer.lines, + visible: layer.details.visible, + layerId: layer.details.layerId, + lineTypes, + arrowTypes, + mapInstance, + pointToProjection, + }); + return additionalLayer.vectorLayer; + }); + }, [arrowTypes, lineTypes, mapInstance, mapLayers, pointToProjection]); + + useEffect(() => { + vectorLayers.forEach(layer => { + const layerId = layer.get('id'); + if (layerId && layersVisibility[layerId] !== undefined) { + layer.setVisible(layersVisibility[layerId]); + } + }); + }, [layersVisibility, vectorLayers]); + + return vectorLayers; +}; diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/config/reactionsLayer/useOlMapReactionsLayer.ts b/src/components/Map/MapViewer/MapViewerVector/utils/config/reactionsLayer/useOlMapReactionsLayer.ts index 9fe39ef0a5c20fccd577f51cb780624d37f57beb..9bc0d80919ec75843004806eb9e57fed2c5bc96c 100644 --- a/src/components/Map/MapViewer/MapViewerVector/utils/config/reactionsLayer/useOlMapReactionsLayer.ts +++ b/src/components/Map/MapViewer/MapViewerVector/utils/config/reactionsLayer/useOlMapReactionsLayer.ts @@ -4,9 +4,9 @@ import VectorLayer from 'ol/layer/Vector'; import VectorSource from 'ol/source/Vector'; import { useEffect, useMemo } from 'react'; import { usePointToProjection } from '@/utils/map/usePointToProjection'; -import MapElement from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/MapElement'; +import MapElement from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/MapElement'; import { useSelector } from 'react-redux'; -import { shapesSelector } from '@/redux/shapes/shapes.selectors'; +import { bioShapesSelector, lineTypesSelector } from '@/redux/shapes/shapes.selectors'; import { MapInstance } from '@/types/map'; import { HorizontalAlign, @@ -16,6 +16,9 @@ import { modelElementsSelector } from '@/redux/modelElements/modelElements.selec import { currentModelIdSelector } from '@/redux/models/models.selectors'; import { getModelElements } from '@/redux/modelElements/modelElements.thunks'; import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; +import CompartmentSquare from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/CompartmentSquare'; +import CompartmentCircle from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/CompartmentCircle'; +import { ModelElement } from '@/types/models'; export const useOlMapReactionsLayer = ({ mapInstance, @@ -30,14 +33,18 @@ export const useOlMapReactionsLayer = ({ }, [currentModelId, dispatch]); const pointToProjection = usePointToProjection(); - const shapes = useSelector(shapesSelector); + const shapes = useSelector(bioShapesSelector); + const lineTypes = useSelector(lineTypesSelector); - const elements = useMemo(() => { - if (modelElements) { - return modelElements.content.map(element => { - const shape = shapes.data.find(bioShape => bioShape.sboTerm === element.sboTerm); - if (shape) { - return new MapElement({ + const elements: Array<MapElement | CompartmentCircle | CompartmentSquare> = useMemo(() => { + if (!modelElements || !shapes) return []; + + const validElements: Array<MapElement | CompartmentCircle | CompartmentSquare> = []; + modelElements.content.forEach((element: ModelElement) => { + const shape = shapes.find(bioShape => bioShape.sboTerm === element.sboTerm); + if (shape) { + validElements.push( + new MapElement({ shapes: shape.shapes, x: element.x, y: element.y, @@ -49,28 +56,59 @@ export const useOlMapReactionsLayer = ({ height: element.height, zIndex: element.z, lineWidth: element.lineWidth, + lineType: element.borderLineType, fontColor: element.fontColor, fillColor: element.fillColor, borderColor: element.borderColor, nameVerticalAlign: element.nameVerticalAlign as VerticalAlign, nameHorizontalAlign: element.nameHorizontalAlign as HorizontalAlign, + homodimer: element.homodimer, + activity: element.activity, text: element.name, + fontSize: element.fontSize, pointToProjection, mapInstance, modifications: element.modificationResidues, - bioShapes: shapes.data, - }); + lineTypes, + bioShapes: shapes, + }), + ); + } else if (element.sboTerm === 'SBO:0000290') { + const compartmentProps = { + 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, + innerWidth: element.innerWidth, + outerWidth: element.outerWidth, + thickness: element.thickness, + fontColor: element.fontColor, + fillColor: element.fillColor, + borderColor: element.borderColor, + nameVerticalAlign: element.nameVerticalAlign as VerticalAlign, + nameHorizontalAlign: element.nameHorizontalAlign as HorizontalAlign, + text: element.name, + fontSize: element.fontSize, + pointToProjection, + mapInstance, + }; + if (element.shape === 'OVAL_COMPARTMENT') { + validElements.push(new CompartmentCircle(compartmentProps)); + } else if (element.shape === 'SQUARE_COMPARTMENT') { + validElements.push(new CompartmentSquare(compartmentProps)); } - return undefined; - }); - } - return []; - }, [mapInstance, pointToProjection, shapes.data, modelElements]); + } + }); + return validElements; + }, [modelElements, shapes, pointToProjection, mapInstance, lineTypes]); const features = useMemo(() => { - return elements - .filter((element): element is MapElement => element !== undefined) - .map(element => element.multiPolygonFeature); + return elements.map(element => element.multiPolygonFeature); }, [elements]); const vectorSource = useMemo(() => { diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/config/useOlMapVectorLayers.ts b/src/components/Map/MapViewer/MapViewerVector/utils/config/useOlMapVectorLayers.ts index 52cf8ab0c4b70dad32e1733178ba511edd77ec9e..7ebb6e709f662cef89e30112e052bad29c871324 100644 --- a/src/components/Map/MapViewer/MapViewerVector/utils/config/useOlMapVectorLayers.ts +++ b/src/components/Map/MapViewer/MapViewerVector/utils/config/useOlMapVectorLayers.ts @@ -1,6 +1,7 @@ /* eslint-disable no-magic-numbers */ import { MapInstance } from '@/types/map'; import { useOlMapWhiteCardLayer } from '@/components/Map/MapViewer/MapViewerVector/utils/config/useOlMapWhiteCardLayer'; +import { useOlMapAdditionalLayers } from '@/components/Map/MapViewer/MapViewerVector/utils/config/additionalLayers/useOlMapAdditionalLayers'; import { MapConfig } from '../../MapViewerVector.types'; import { useOlMapReactionsLayer } from './reactionsLayer/useOlMapReactionsLayer'; @@ -11,6 +12,7 @@ interface UseOlMapLayersInput { export const useOlMapVectorLayers = ({ mapInstance }: UseOlMapLayersInput): MapConfig['layers'] => { const reactionsLayer = useOlMapReactionsLayer({ mapInstance }); const whiteCardLayer = useOlMapWhiteCardLayer(); + const additionalLayers = useOlMapAdditionalLayers(mapInstance); - return [whiteCardLayer, reactionsLayer]; + return [whiteCardLayer, reactionsLayer, ...additionalLayers]; }; diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/MapElement.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/MapElement.ts deleted file mode 100644 index 2439b0d6758613331262a28d19e886c78daa6f89..0000000000000000000000000000000000000000 --- a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/MapElement.ts +++ /dev/null @@ -1,201 +0,0 @@ -/* eslint-disable no-magic-numbers */ -import { Style, Text } from 'ol/style'; -import Feature, { FeatureLike } from 'ol/Feature'; -import { 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 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 { BioShape, Modification, 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 MapElementProps = { - 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; - bioShapes?: Array<BioShape>; - modifications?: Array<Modification>; -}; - -export default class MapElement { - textStyle: Style | undefined; - - polygons: Array<Polygon> = []; - - styles: Array<Style> = []; - - polygonsTexts: Array<string> = []; - - 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, - bioShapes = [], - modifications = [], - }: MapElementProps) { - if (text) { - const { textCoords, textStyle } = getText({ - text, - fontSize, - x: nameX, - y: nameY, - width: nameWidth, - height: nameHeight, - color: rgbToHex(fontColor), - zIndex, - verticalAlign: nameVerticalAlign, - horizontalAlign: nameHorizontalAlign, - pointToProjection, - }); - this.styles.push(textStyle); - this.polygonsTexts.push(text); - this.polygons.push(new Polygon([[textCoords, textCoords]])); - } - - const multiPolygon: Array<Polygon> = []; - modifications.forEach(modification => { - if (modification.state === null) { - return; - } - - const shape = bioShapes.find(bioShape => bioShape.sboTerm === modification.sboTerm); - if (!shape) { - return; - } - const multiPolygonModification = getMultiPolygon({ - x: modification.x, - y: modification.y, - width: modification.width, - height: modification.height, - shapes: shape.shapes, - pointToProjection, - mirror: modification.direction && modification.direction === 'RIGHT', - }); - multiPolygon.push(...multiPolygonModification.flat()); - multiPolygonModification.forEach(polygon => { - const modificationStyle = new Style({ - geometry: polygon, - stroke: getStroke({ color: rgbToHex(modification.borderColor) }), - fill: getFill({ color: rgbToHex(modification.fillColor) }), - zIndex: modification.z, - }); - const modificationText = modification.stateAbbreviation - ? modification.stateAbbreviation - : modification.name; - if (modificationText) { - modificationStyle.setText( - new Text({ - text: modificationText, - font: `${modification.fontSize}px Arial`, - textAlign: 'center', - textBaseline: 'middle', - fill: getFill({ color: '#000' }), - overflow: true, - }), - ); - this.polygonsTexts.push(modification.name); - } - this.styles.push(modificationStyle); - }); - }); - - const elementMultiPolygon = getMultiPolygon({ x, y, width, height, shapes, pointToProjection }); - this.polygons = [...this.polygons, ...multiPolygon, ...elementMultiPolygon.flat()]; - - elementMultiPolygon.forEach(polygon => { - this.styles.push( - new Style({ - geometry: polygon, - stroke: getStroke({ color: rgbToHex(borderColor), width: lineWidth }), - fill: getFill({ color: rgbToHex(fillColor) }), - zIndex, - }), - ); - }); - - 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.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); - } - let index = 0; - this.styles.forEach(style => { - if (style.getText()) { - if (textScale > 0.3) { - style.getText()?.setScale(textScale); - style.getText()?.setText(this.polygonsTexts[index]); - index += 1; - } else { - style.getText()?.setText(undefined); - } - } - }); - return this.styles; - } -} diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getBezierCurve.test.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/coords/getBezierCurve.test.ts similarity index 100% rename from src/components/Map/MapViewer/MapViewerVector/utils/shapes/getBezierCurve.test.ts rename to src/components/Map/MapViewer/MapViewerVector/utils/shapes/coords/getBezierCurve.test.ts diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getBezierCurve.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/coords/getBezierCurve.ts similarity index 100% rename from src/components/Map/MapViewer/MapViewerVector/utils/shapes/getBezierCurve.ts rename to src/components/Map/MapViewer/MapViewerVector/utils/shapes/coords/getBezierCurve.ts diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getCentroid.test.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/coords/getCentroid.test.ts similarity index 100% rename from src/components/Map/MapViewer/MapViewerVector/utils/shapes/getCentroid.test.ts rename to src/components/Map/MapViewer/MapViewerVector/utils/shapes/coords/getCentroid.test.ts diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getCentroid.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/coords/getCentroid.ts similarity index 100% rename from src/components/Map/MapViewer/MapViewerVector/utils/shapes/getCentroid.ts rename to src/components/Map/MapViewer/MapViewerVector/utils/shapes/coords/getCentroid.ts diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getCoordsX.test.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/coords/getCoordsX.test.ts similarity index 100% rename from src/components/Map/MapViewer/MapViewerVector/utils/shapes/getCoordsX.test.ts rename to src/components/Map/MapViewer/MapViewerVector/utils/shapes/coords/getCoordsX.test.ts diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getCoordsX.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/coords/getCoordsX.ts similarity index 100% rename from src/components/Map/MapViewer/MapViewerVector/utils/shapes/getCoordsX.ts rename to src/components/Map/MapViewer/MapViewerVector/utils/shapes/coords/getCoordsX.ts diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getCoordsY.test.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/coords/getCoordsY.test.ts similarity index 100% rename from src/components/Map/MapViewer/MapViewerVector/utils/shapes/getCoordsY.test.ts rename to src/components/Map/MapViewer/MapViewerVector/utils/shapes/coords/getCoordsY.test.ts diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getCoordsY.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/coords/getCoordsY.ts similarity index 100% rename from src/components/Map/MapViewer/MapViewerVector/utils/shapes/getCoordsY.ts rename to src/components/Map/MapViewer/MapViewerVector/utils/shapes/coords/getCoordsY.ts diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getCurveCoords.test.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/coords/getCurveCoords.test.ts similarity index 96% rename from src/components/Map/MapViewer/MapViewerVector/utils/shapes/getCurveCoords.test.ts rename to src/components/Map/MapViewer/MapViewerVector/utils/shapes/coords/getCurveCoords.test.ts index 8ce8b569bb7a63899c8e4cea5895ae78d73b775d..d212e9eaa9e2a867253336ad0a1a97aadeede98f 100644 --- a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getCurveCoords.test.ts +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/coords/getCurveCoords.test.ts @@ -1,6 +1,6 @@ /* 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 getCoordsX from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/coords/getCoordsX'; +import getCoordsY from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/coords/getCoordsY'; import { UsePointToProjectionResult } from '@/utils/map/usePointToProjection'; import { ShapeCurvePoint } from '@/components/Map/MapViewer/MapViewerVector/MapViewerVector.types'; import getCurveCoords from './getCurveCoords'; diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getCurveCoords.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/coords/getCurveCoords.ts similarity index 95% rename from src/components/Map/MapViewer/MapViewerVector/utils/shapes/getCurveCoords.ts rename to src/components/Map/MapViewer/MapViewerVector/utils/shapes/coords/getCurveCoords.ts index 2b6956348eafea711c9636ced732b4fa3422d729..ebfed6b651b1ce599cc7a0d053acebcf93771a91 100644 --- a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getCurveCoords.ts +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/coords/getCurveCoords.ts @@ -1,7 +1,7 @@ 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 getCoordsX from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/coords/getCoordsX'; +import getCoordsY from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/coords/getCoordsY'; import { ShapeCurvePoint } from '@/components/Map/MapViewer/MapViewerVector/MapViewerVector.types'; export default function getCurveCoords({ diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getEllipseCoords.test.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/coords/getEllipseCoords.test.ts similarity index 95% rename from src/components/Map/MapViewer/MapViewerVector/utils/shapes/getEllipseCoords.test.ts rename to src/components/Map/MapViewer/MapViewerVector/utils/shapes/coords/getEllipseCoords.test.ts index cf6f4c49f75ca81e8f569d17d040f7e55e6c2817..4a0d514a3829c004d7af79ad435d619821b92ebb 100644 --- a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getEllipseCoords.test.ts +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/coords/getEllipseCoords.test.ts @@ -1,6 +1,6 @@ /* 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 getCoordsX from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/coords/getCoordsX'; +import getCoordsY from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/coords/getCoordsY'; import { UsePointToProjectionResult } from '@/utils/map/usePointToProjection'; import getEllipseCoords from './getEllipseCoords'; diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getEllipseCoords.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/coords/getEllipseCoords.ts similarity index 53% rename from src/components/Map/MapViewer/MapViewerVector/utils/shapes/getEllipseCoords.ts rename to src/components/Map/MapViewer/MapViewerVector/utils/shapes/coords/getEllipseCoords.ts index 5d1e7f668ebabd78939c0af7d9a424c7e10517ac..45a7df697933f76806fabd13bbea2b790e2e2855 100644 --- a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getEllipseCoords.ts +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/coords/getEllipseCoords.ts @@ -1,26 +1,12 @@ /* 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 getCoordsX from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/coords/getCoordsX'; +import getCoordsY from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/coords/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; -}; +import { + EllipseCenter, + EllipseRadius, +} from '@/components/Map/MapViewer/MapViewerVector/MapViewerVector.types'; export default function getEllipseCoords({ x, @@ -34,31 +20,39 @@ export default function getEllipseCoords({ }: { x: number; y: number; - center: EllipseCenter; - radius: EllipseRadius; + 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 centerX = x; + let centerY = y; + let radiusX = width / 2; + let radiusY = height / 2; + if (center) { + centerX = getCoordsX( + x, + center.absoluteX, + center.relativeX, + center.relativeHeightForX, + height, + width, + ); + centerY = getCoordsY( + y, + center.absoluteY, + center.relativeY, + center.relativeWidthForY, + height, + width, + ); + } + if (radius) { + radiusX = radius.absoluteX + (radius.relativeX * width) / 100; + radiusY = radius.absoluteY + (radius.relativeY * height) / 100; + } let angle; let coordsX; let coordsY; diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getPolygonCoords.test.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/coords/getPolygonCoords.test.ts similarity index 95% rename from src/components/Map/MapViewer/MapViewerVector/utils/shapes/getPolygonCoords.test.ts rename to src/components/Map/MapViewer/MapViewerVector/utils/shapes/coords/getPolygonCoords.test.ts index e1911f7bc6ca8a5e0360c8662d7f76a4be94cbd2..d88a2c0406337584ebac191f2c3da05cc2566b6d 100644 --- a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getPolygonCoords.test.ts +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/coords/getPolygonCoords.test.ts @@ -1,8 +1,8 @@ /* 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 getCoordsX from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/coords/getCoordsX'; +import getCoordsY from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/coords/getCoordsY'; +import getCurveCoords from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/coords/getCurveCoords'; +import getBezierCurve from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/coords/getBezierCurve'; import { ShapePoint, ShapeCurvePoint, diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getPolygonCoords.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/coords/getPolygonCoords.ts similarity index 90% rename from src/components/Map/MapViewer/MapViewerVector/utils/shapes/getPolygonCoords.ts rename to src/components/Map/MapViewer/MapViewerVector/utils/shapes/coords/getPolygonCoords.ts index b60b001608ad7d4c59f898e7973402ada54be259..dac21118d14a9bf5d51b109f0a401fbbf0af8fc0 100644 --- a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getPolygonCoords.ts +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/coords/getPolygonCoords.ts @@ -1,10 +1,10 @@ /* 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 getBezierCurve from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/coords/getBezierCurve'; +import getCoordsX from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/coords/getCoordsX'; +import getCoordsY from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/coords/getCoordsY'; +import getCurveCoords from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/coords/getCurveCoords'; import { ShapeCurvePoint, ShapePoint, diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/coords/getRotation.test.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/coords/getRotation.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..363add1a3eef27e0422ff5b8f01601c9a7c85f1c --- /dev/null +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/coords/getRotation.test.ts @@ -0,0 +1,19 @@ +/* eslint-disable no-magic-numbers */ +import getRotation from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/coords/getRotation'; +import { Coordinate } from 'ol/coordinate'; + +const testCases: Array<[Coordinate, Coordinate, number]> = [ + [[0, 0], [1, 1], -0.785], + [[1, 1], [1, 2], -1.57], + [[2, 2], [3, 0], 1.107], +]; + +describe('getRotation', () => { + it.each(testCases)( + 'should return the correct rotation for start: %s and end: %s', + (start: Coordinate, end: Coordinate, expected: number) => { + const result = getRotation(start, end); + expect(result).toBeCloseTo(expected); + }, + ); +}); diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/coords/getRotation.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/coords/getRotation.ts new file mode 100644 index 0000000000000000000000000000000000000000..5a647fcef1ec19a10fe9cf407df883f44141cca1 --- /dev/null +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/coords/getRotation.ts @@ -0,0 +1,6 @@ +/* eslint-disable no-magic-numbers */ +import { Coordinate } from 'ol/coordinate'; + +export default function getRotation(start: Coordinate, end: Coordinate): number { + return Math.atan2(-end[1] + start[1], end[0] - start[0]); +} diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/BaseMultiPolygon.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/BaseMultiPolygon.ts new file mode 100644 index 0000000000000000000000000000000000000000..30ece3e99fcd17b3c7c15715b7a590b8b6368870 --- /dev/null +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/BaseMultiPolygon.ts @@ -0,0 +1,185 @@ +/* eslint-disable no-magic-numbers */ +import Polygon from 'ol/geom/Polygon'; +import { Style } from 'ol/style'; +import Feature, { FeatureLike } from 'ol/Feature'; +import { MultiPolygon } from 'ol/geom'; +import { UsePointToProjectionResult } from '@/utils/map/usePointToProjection'; +import { + ColorObject, + HorizontalAlign, + VerticalAlign, +} from '@/components/Map/MapViewer/MapViewerVector/MapViewerVector.types'; +import { MapInstance } from '@/types/map'; +import getTextStyle from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/text/getTextStyle'; +import { rgbToHex } from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/style/rgbToHex'; +import getTextCoords from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/text/getTextCoords'; + +export interface BaseMapElementProps { + x: number; + y: number; + width: number; + height: number; + zIndex: number; + text: string; + fontSize: number; + nameX: number; + nameY: number; + nameWidth: number; + nameHeight: number; + fontColor: ColorObject; + nameVerticalAlign: VerticalAlign; + nameHorizontalAlign: HorizontalAlign; + fillColor: ColorObject; + borderColor: ColorObject; + pointToProjection: UsePointToProjectionResult; +} + +export default abstract class BaseMultiPolygon { + x: number; + + y: number; + + width: number; + + height: number; + + zIndex: number; + + text: string; + + fontSize: number; + + nameX: number; + + nameY: number; + + nameWidth: number; + + nameHeight: number; + + fontColor: ColorObject; + + nameVerticalAlign: VerticalAlign; + + nameHorizontalAlign: HorizontalAlign; + + fillColor: ColorObject; + + borderColor: ColorObject; + + polygons: Array<Polygon> = []; + + styles: Array<Style> = []; + + polygonsTexts: Array<string> = []; + + multiPolygonFeature: Feature = new Feature(); + + pointToProjection: UsePointToProjectionResult; + + constructor({ + x, + y, + width, + height, + zIndex, + text, + fontSize, + nameX, + nameY, + nameWidth, + nameHeight, + fontColor, + nameVerticalAlign, + nameHorizontalAlign, + fillColor, + borderColor, + pointToProjection, + }: BaseMapElementProps) { + this.x = x; + this.y = y; + this.width = width; + this.height = height; + this.zIndex = zIndex; + this.text = text; + this.fontSize = fontSize; + this.nameX = nameX; + this.nameY = nameY; + this.nameWidth = nameWidth; + this.nameHeight = nameHeight; + this.fontColor = fontColor; + this.nameVerticalAlign = nameVerticalAlign; + this.nameHorizontalAlign = nameHorizontalAlign; + this.fillColor = fillColor; + this.borderColor = borderColor; + this.pointToProjection = pointToProjection; + } + + protected abstract createPolygons(): void; + + protected drawText(): void { + if (this.text) { + const textCoords = getTextCoords({ + x: this.nameX, + y: this.nameY, + width: this.nameWidth, + height: this.nameHeight, + fontSize: this.fontSize, + verticalAlign: this.nameVerticalAlign, + horizontalAlign: this.nameHorizontalAlign, + pointToProjection: this.pointToProjection, + }); + const textPolygon = new Polygon([[textCoords, textCoords]]); + const textStyle = getTextStyle({ + text: this.text, + fontSize: this.fontSize, + color: rgbToHex(this.fontColor), + zIndex: this.zIndex, + horizontalAlign: this.nameHorizontalAlign, + }); + textStyle.setGeometry(textPolygon); + this.styles.push(textStyle); + this.polygonsTexts.push(this.text); + this.polygons.push(textPolygon); + } + } + + protected drawMultiPolygonFeature(mapInstance: MapInstance): void { + 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.multiPolygonFeature.setStyle(this.styleFunction.bind(this)); + } + + protected styleFunction(feature: FeatureLike, resolution: number): Style | Array<Style> | void { + const getTextScale = feature.get('getTextScale'); + let textScale = 1; + if (getTextScale instanceof Function) { + textScale = getTextScale(resolution); + } + let index = 0; + this.styles.forEach(style => { + if (style.getText()) { + if (this.fontSize * textScale > 4) { + style.getText()?.setScale(textScale); + style.getText()?.setText(this.polygonsTexts[index]); + index += 1; + } else { + style.getText()?.setText(undefined); + } + } + }); + return this.styles; + } +} diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/Compartment.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/Compartment.ts new file mode 100644 index 0000000000000000000000000000000000000000..786f8c1e29588a6f2a0879495b2f6d0012b4c673 --- /dev/null +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/Compartment.ts @@ -0,0 +1,137 @@ +/* eslint-disable no-magic-numbers */ +import { UsePointToProjectionResult } from '@/utils/map/usePointToProjection'; +import { + ColorObject, + HorizontalAlign, + VerticalAlign, +} from '@/components/Map/MapViewer/MapViewerVector/MapViewerVector.types'; +import BaseMultiPolygon from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/BaseMultiPolygon'; +import { Coordinate } from 'ol/coordinate'; +import Polygon from 'ol/geom/Polygon'; +import { Style } from 'ol/style'; +import getFill from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/style/getFill'; +import { rgbToHex } from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/style/rgbToHex'; +import getStroke from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/style/getStroke'; +import { MapInstance } from '@/types/map'; + +export interface CompartmentProps { + x: number; + y: number; + width: number; + height: number; + thickness: number; + outerWidth: number; + innerWidth: number; + zIndex: number; + text: string; + fontSize: number; + nameX: number; + nameY: number; + nameWidth: number; + nameHeight: number; + fontColor: ColorObject; + nameVerticalAlign: VerticalAlign; + nameHorizontalAlign: HorizontalAlign; + fillColor: ColorObject; + borderColor: ColorObject; + pointToProjection: UsePointToProjectionResult; + mapInstance: MapInstance; +} + +export default abstract class Compartment extends BaseMultiPolygon { + outerCoords: Array<Coordinate> = []; + + innerCoords: Array<Coordinate> = []; + + outerWidth: number; + + innerWidth: number; + + thickness: number; + + constructor({ + x, + y, + width, + height, + thickness, + outerWidth, + innerWidth, + zIndex, + text, + fontSize, + nameX, + nameY, + nameWidth, + nameHeight, + fontColor, + nameVerticalAlign, + nameHorizontalAlign, + fillColor, + borderColor, + pointToProjection, + mapInstance, + }: CompartmentProps) { + super({ + x, + y, + width, + height, + zIndex, + text, + fontSize, + nameX, + nameY, + nameWidth, + nameHeight, + fontColor, + nameVerticalAlign, + nameHorizontalAlign, + fillColor, + borderColor, + pointToProjection, + }); + this.outerWidth = outerWidth; + this.innerWidth = innerWidth; + this.thickness = thickness; + this.getCompartmentCoords(); + this.createPolygons(); + this.drawText(); + this.drawMultiPolygonFeature(mapInstance); + } + + protected abstract getCompartmentCoords(): void; + + protected createPolygons(): void { + const framePolygon = new Polygon([this.outerCoords, this.innerCoords]); + this.styles.push( + new Style({ + geometry: framePolygon, + fill: getFill({ color: rgbToHex({ ...this.fillColor, alpha: 128 }) }), + zIndex: this.zIndex, + }), + ); + this.polygons.push(framePolygon); + + const outerPolygon = new Polygon([this.outerCoords]); + this.styles.push( + new Style({ + geometry: outerPolygon, + stroke: getStroke({ color: rgbToHex(this.borderColor), width: this.outerWidth }), + zIndex: this.zIndex, + }), + ); + this.polygons.push(outerPolygon); + + const innerPolygon = new Polygon([this.innerCoords]); + this.styles.push( + new Style({ + geometry: innerPolygon, + stroke: getStroke({ color: rgbToHex(this.borderColor), width: this.innerWidth }), + fill: getFill({ color: rgbToHex({ ...this.fillColor, alpha: 9 }) }), + zIndex: this.zIndex, + }), + ); + this.polygons.push(innerPolygon); + } +} diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/CompartmentCircle.test.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/CompartmentCircle.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..47c47d389b5aa9ecc893045687eb015171ce62ea --- /dev/null +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/CompartmentCircle.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 getTextStyle from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/text/getTextStyle'; +import getMultiPolygon from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/getMultiPolygon'; +import getStroke from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/style/getStroke'; +import getFill from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/style/getFill'; +import { rgbToHex } from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/style/rgbToHex'; +import View from 'ol/View'; +import { + WHITE_COLOR, + BLACK_COLOR, +} from '@/components/Map/MapViewer/MapViewerVector/MapViewerVector.constants'; +import CompartmentCircle, { + CompartmentCircleProps, +} from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/CompartmentCircle'; +import getEllipseCoords from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/coords/getEllipseCoords'; +import getTextCoords from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/text/getTextCoords'; + +jest.mock('../text/getTextStyle'); +jest.mock('../text/getTextCoords'); +jest.mock('./getMultiPolygon'); +jest.mock('../style/getStroke'); +jest.mock('../coords/getEllipseCoords'); +jest.mock('../style/getFill'); +jest.mock('../style/rgbToHex'); + +describe('MapElement', () => { + let props: CompartmentCircleProps; + + beforeEach(() => { + const dummyElement = document.createElement('div'); + const mapInstance = new Map({ + target: dummyElement, + view: new View({ + zoom: 5, + minZoom: 3, + maxZoom: 7, + }), + }); + props = { + x: 0, + y: 0, + width: 100, + height: 100, + zIndex: 1, + fillColor: WHITE_COLOR, + borderColor: BLACK_COLOR, + fontColor: BLACK_COLOR, + innerWidth: 1, + outerWidth: 2, + thickness: 12, + text: 'Test Text', + fontSize: 12, + nameX: 10, + nameY: 20, + nameHeight: 30, + nameWidth: 40, + nameVerticalAlign: 'MIDDLE', + nameHorizontalAlign: 'CENTER', + pointToProjection: jest.fn(), + mapInstance, + }; + + (getTextStyle as jest.Mock).mockReturnValue( + 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', + }), + }), + ); + (getTextCoords as jest.Mock).mockReturnValue([10, 10]); + (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'); + (getEllipseCoords as jest.Mock).mockReturnValue([ + [10, 10], + [20, 20], + [30, 30], + ]); + }); + + it('should initialize with correct default properties', () => { + const multiPolygon = new CompartmentCircle(props); + + expect(multiPolygon.polygons.length).toBe(4); + expect(multiPolygon.multiPolygonFeature).toBeInstanceOf(Feature); + expect(multiPolygon.multiPolygonFeature.getGeometry()).toBeInstanceOf(MultiPolygon); + }); + + it('should apply correct styles to the feature', () => { + const multiPolygon = new CompartmentCircle(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); + } + }); +}); diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/CompartmentCircle.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/CompartmentCircle.ts new file mode 100644 index 0000000000000000000000000000000000000000..5665fc2925d96517ac0b9a133fe1da982562c26d --- /dev/null +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/CompartmentCircle.ts @@ -0,0 +1,113 @@ +/* eslint-disable no-magic-numbers */ +import { UsePointToProjectionResult } from '@/utils/map/usePointToProjection'; +import { MapInstance } from '@/types/map'; +import { + ColorObject, + HorizontalAlign, + VerticalAlign, +} from '@/components/Map/MapViewer/MapViewerVector/MapViewerVector.types'; +import { + BLACK_COLOR, + COMPARTMENT_CIRCLE_CENTER, + COMPARTMENT_CIRCLE_RADIUS, + WHITE_COLOR, +} from '@/components/Map/MapViewer/MapViewerVector/MapViewerVector.constants'; +import getEllipseCoords from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/coords/getEllipseCoords'; +import Compartment from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/Compartment'; + +export type CompartmentCircleProps = { + x: number; + y: number; + width: number; + height: number; + zIndex: number; + fillColor?: ColorObject; + borderColor?: ColorObject; + fontColor?: ColorObject; + innerWidth?: number; + outerWidth?: number; + thickness?: number; + text?: string; + fontSize?: number; + nameX: number; + nameY: number; + nameHeight: number; + nameWidth: number; + nameVerticalAlign?: VerticalAlign; + nameHorizontalAlign?: HorizontalAlign; + pointToProjection: UsePointToProjectionResult; + mapInstance: MapInstance; +}; + +export default class CompartmentCircle extends Compartment { + constructor({ + x, + y, + width, + height, + zIndex, + fillColor = WHITE_COLOR, + borderColor = BLACK_COLOR, + fontColor = BLACK_COLOR, + innerWidth = 1, + outerWidth = 2, + thickness = 12, + text = '', + fontSize = 12, + nameX, + nameY, + nameHeight, + nameWidth, + nameVerticalAlign = 'MIDDLE', + nameHorizontalAlign = 'CENTER', + pointToProjection, + mapInstance, + }: CompartmentCircleProps) { + super({ + x, + y, + width, + height, + thickness, + outerWidth, + innerWidth, + zIndex, + text, + fontSize, + nameX, + nameY, + nameWidth, + nameHeight, + fontColor, + nameVerticalAlign, + nameHorizontalAlign, + fillColor, + borderColor, + pointToProjection, + mapInstance, + }); + } + + protected getCompartmentCoords(): void { + this.outerCoords = getEllipseCoords({ + x: this.x, + y: this.y, + center: COMPARTMENT_CIRCLE_CENTER, + radius: COMPARTMENT_CIRCLE_RADIUS, + height: this.height, + width: this.width, + points: 40, + pointToProjection: this.pointToProjection, + }); + this.innerCoords = getEllipseCoords({ + x: this.x + this.thickness, + y: this.y + this.thickness, + center: COMPARTMENT_CIRCLE_CENTER, + radius: COMPARTMENT_CIRCLE_RADIUS, + height: this.height - 2 * this.thickness, + width: this.width - 2 * this.thickness, + points: 40, + pointToProjection: this.pointToProjection, + }); + } +} diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/CompartmentSquare.test.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/CompartmentSquare.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..5fd6ac55e3334d2f42a015635c97fa281f4144e3 --- /dev/null +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/CompartmentSquare.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 getTextStyle from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/text/getTextStyle'; +import getMultiPolygon from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/getMultiPolygon'; +import getStroke from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/style/getStroke'; +import getFill from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/style/getFill'; +import { rgbToHex } from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/style/rgbToHex'; +import View from 'ol/View'; +import { + WHITE_COLOR, + BLACK_COLOR, +} from '@/components/Map/MapViewer/MapViewerVector/MapViewerVector.constants'; +import CompartmentSquare, { + CompartmentSquareProps, +} from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/CompartmentSquare'; +import getPolygonCoords from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/coords/getPolygonCoords'; +import getTextCoords from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/text/getTextCoords'; + +jest.mock('../text/getTextStyle'); +jest.mock('./getMultiPolygon'); +jest.mock('../text/getTextCoords'); +jest.mock('../style/getStroke'); +jest.mock('../coords/getPolygonCoords'); +jest.mock('../style/getFill'); +jest.mock('../style/rgbToHex'); + +describe('MapElement', () => { + let props: CompartmentSquareProps; + + beforeEach(() => { + const dummyElement = document.createElement('div'); + const mapInstance = new Map({ + target: dummyElement, + view: new View({ + zoom: 5, + minZoom: 3, + maxZoom: 7, + }), + }); + props = { + x: 0, + y: 0, + width: 100, + height: 100, + zIndex: 1, + fillColor: WHITE_COLOR, + borderColor: BLACK_COLOR, + fontColor: BLACK_COLOR, + innerWidth: 1, + outerWidth: 2, + thickness: 12, + text: 'Test Text', + fontSize: 12, + nameX: 10, + nameY: 20, + nameHeight: 30, + nameWidth: 40, + nameVerticalAlign: 'MIDDLE', + nameHorizontalAlign: 'CENTER', + pointToProjection: jest.fn(), + mapInstance, + }; + + (getTextStyle as jest.Mock).mockReturnValue( + 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', + }), + }), + ); + (getTextCoords as jest.Mock).mockReturnValue([10, 10]); + (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'); + (getPolygonCoords as jest.Mock).mockReturnValue([ + [10, 10], + [20, 20], + [30, 30], + ]); + }); + + it('should initialize with correct default properties', () => { + const multiPolygon = new CompartmentSquare(props); + + expect(multiPolygon.polygons.length).toBe(4); + expect(multiPolygon.multiPolygonFeature).toBeInstanceOf(Feature); + expect(multiPolygon.multiPolygonFeature.getGeometry()).toBeInstanceOf(MultiPolygon); + }); + + it('should apply correct styles to the feature', () => { + const multiPolygon = new CompartmentSquare(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); + } + }); +}); diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/CompartmentSquare.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/CompartmentSquare.ts new file mode 100644 index 0000000000000000000000000000000000000000..d868bc11c118535e1b51057a32da6a45f336a6d1 --- /dev/null +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/CompartmentSquare.ts @@ -0,0 +1,108 @@ +/* eslint-disable no-magic-numbers */ +import { UsePointToProjectionResult } from '@/utils/map/usePointToProjection'; +import { MapInstance } from '@/types/map'; +import { + ColorObject, + HorizontalAlign, + VerticalAlign, +} from '@/components/Map/MapViewer/MapViewerVector/MapViewerVector.types'; +import { + BLACK_COLOR, + COMPARTMENT_SQUARE_POINTS, + WHITE_COLOR, +} from '@/components/Map/MapViewer/MapViewerVector/MapViewerVector.constants'; +import getPolygonCoords from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/coords/getPolygonCoords'; +import Compartment from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/Compartment'; + +export type CompartmentSquareProps = { + x: number; + y: number; + width: number; + height: number; + zIndex: number; + fillColor?: ColorObject; + borderColor?: ColorObject; + fontColor?: ColorObject; + innerWidth?: number; + outerWidth?: number; + thickness?: number; + text?: string; + fontSize?: number; + nameX: number; + nameY: number; + nameHeight: number; + nameWidth: number; + nameVerticalAlign?: VerticalAlign; + nameHorizontalAlign?: HorizontalAlign; + pointToProjection: UsePointToProjectionResult; + mapInstance: MapInstance; +}; + +export default class CompartmentSquare extends Compartment { + constructor({ + x, + y, + width, + height, + zIndex, + fillColor = WHITE_COLOR, + borderColor = BLACK_COLOR, + fontColor = BLACK_COLOR, + innerWidth = 1, + outerWidth = 2, + thickness = 12, + text = '', + fontSize = 12, + nameX, + nameY, + nameHeight, + nameWidth, + nameVerticalAlign = 'MIDDLE', + nameHorizontalAlign = 'CENTER', + pointToProjection, + mapInstance, + }: CompartmentSquareProps) { + super({ + x, + y, + width, + height, + thickness, + outerWidth, + innerWidth, + zIndex, + text, + fontSize, + nameX, + nameY, + nameWidth, + nameHeight, + fontColor, + nameVerticalAlign, + nameHorizontalAlign, + fillColor, + borderColor, + pointToProjection, + mapInstance, + }); + } + + protected getCompartmentCoords(): void { + this.outerCoords = getPolygonCoords({ + points: COMPARTMENT_SQUARE_POINTS, + x: this.x, + y: this.y, + height: this.height, + width: this.width, + pointToProjection: this.pointToProjection, + }); + this.innerCoords = getPolygonCoords({ + points: COMPARTMENT_SQUARE_POINTS, + x: this.x + this.thickness, + y: this.y + this.thickness, + height: this.height - 2 * this.thickness, + width: this.width - 2 * this.thickness, + pointToProjection: this.pointToProjection, + }); + } +} diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/MapElement.test.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/MapElement.test.ts similarity index 79% rename from src/components/Map/MapViewer/MapViewerVector/utils/shapes/MapElement.test.ts rename to src/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/MapElement.test.ts index 0ed5313bcf38fc80ace22bec8a0982cb420f5760..51aaeb6572a795ed8e191fd4590e03674f5f3c13 100644 --- a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/MapElement.test.ts +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/MapElement.test.ts @@ -2,25 +2,27 @@ 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 getTextStyle from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/text/getTextStyle'; +import getMultiPolygon from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/getMultiPolygon'; +import getStroke from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/style/getStroke'; +import getFill from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/style/getFill'; +import { rgbToHex } from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/style/rgbToHex'; import MapElement, { MapElementProps, -} from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/MapElement'; +} from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/MapElement'; import View from 'ol/View'; import { WHITE_COLOR, BLACK_COLOR, } from '@/components/Map/MapViewer/MapViewerVector/MapViewerVector.constants'; +import getTextCoords from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/text/getTextCoords'; -jest.mock('./getText'); +jest.mock('../text/getTextStyle'); +jest.mock('../text/getTextCoords'); jest.mock('./getMultiPolygon'); -jest.mock('./getStroke'); -jest.mock('./getFill'); -jest.mock('./rgbToHex'); +jest.mock('../style/getStroke'); +jest.mock('../style/getFill'); +jest.mock('../style/rgbToHex'); describe('MapElement', () => { let props: MapElementProps; @@ -58,9 +60,8 @@ describe('MapElement', () => { mapInstance, }; - (getText as jest.Mock).mockReturnValue({ - textCoords: [0, 0], - textStyle: new Style({ + (getTextStyle as jest.Mock).mockReturnValue( + new Style({ text: new Text({ text: props.text, font: `bold ${props.fontSize}px Arial`, @@ -72,7 +73,8 @@ describe('MapElement', () => { textBaseline: 'middle', }), }), - }); + ); + (getTextCoords as jest.Mock).mockReturnValue([10, 10]); (getMultiPolygon as jest.Mock).mockReturnValue([ new Polygon([ [ @@ -107,14 +109,4 @@ describe('MapElement', () => { expect(style).toBeInstanceOf(Style); } }); - - it('should update text style based on resolution', () => { - const multiPolygon = new MapElement(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/elements/MapElement.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/MapElement.ts new file mode 100644 index 0000000000000000000000000000000000000000..025ec2d83a1d2068b35dcb627b69c832c45290e6 --- /dev/null +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/MapElement.ts @@ -0,0 +1,241 @@ +/* eslint-disable no-magic-numbers */ +import { Style, Text } from 'ol/style'; +import { UsePointToProjectionResult } from '@/utils/map/usePointToProjection'; +import getStroke from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/style/getStroke'; +import getFill from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/style/getFill'; +import Polygon from 'ol/geom/Polygon'; +import getMultiPolygon from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/getMultiPolygon'; +import { rgbToHex } from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/style/rgbToHex'; +import { BioShape, LineType, Modification, 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'; +import BaseMultiPolygon from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/BaseMultiPolygon'; +import getStyle from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/style/getStyle'; + +export type MapElementProps = { + shapes: Array<Shape>; + x: number; + y: number; + width: number; + height: number; + zIndex: number; + fillColor?: ColorObject; + borderColor?: ColorObject; + fontColor?: ColorObject; + lineWidth?: number; + lineType?: string; + text?: string; + fontSize?: number; + nameX: number; + nameY: number; + nameHeight: number; + nameWidth: number; + nameVerticalAlign?: VerticalAlign; + nameHorizontalAlign?: HorizontalAlign; + homodimer?: number; + activity?: boolean; + pointToProjection: UsePointToProjectionResult; + mapInstance: MapInstance; + bioShapes?: Array<BioShape>; + lineTypes?: Array<LineType>; + modifications?: Array<Modification>; +}; + +export default class MapElement extends BaseMultiPolygon { + shapes: Array<Shape>; + + lineWidth: number; + + lineType: string | undefined; + + bioShapes: Array<BioShape>; + + lineTypes: Array<LineType>; + + homodimer: number; + + activity: boolean | undefined; + + modifications: Array<Modification>; + + lineDash: Array<number> = []; + + constructor({ + shapes, + x, + y, + width, + height, + zIndex, + fillColor = WHITE_COLOR, + borderColor = BLACK_COLOR, + fontColor = BLACK_COLOR, + lineWidth = 1, + lineType, + text = '', + fontSize = 12, + nameX, + nameY, + nameHeight, + nameWidth, + nameVerticalAlign = 'MIDDLE', + nameHorizontalAlign = 'CENTER', + homodimer = 1, + activity, + pointToProjection, + mapInstance, + bioShapes = [], + lineTypes = [], + modifications = [], + }: MapElementProps) { + super({ + x, + y, + width, + height, + zIndex, + text, + fontSize, + nameX, + nameY, + nameWidth, + nameHeight, + fontColor, + nameVerticalAlign, + nameHorizontalAlign, + fillColor, + borderColor, + pointToProjection, + }); + this.shapes = shapes; + this.lineWidth = lineWidth; + this.lineType = lineType; + this.homodimer = homodimer; + this.activity = activity; + this.bioShapes = bioShapes; + this.lineTypes = lineTypes; + this.modifications = modifications; + this.createPolygons(); + this.drawText(); + this.drawMultiPolygonFeature(mapInstance); + } + + protected createPolygons(): void { + let multiPolygonModifications: Array<Polygon> = []; + this.modifications.forEach(modification => { + if (modification.state === null) { + return; + } + + const shape = this.bioShapes.find(bioShape => bioShape.sboTerm === modification.sboTerm); + if (!shape) { + return; + } + multiPolygonModifications = getMultiPolygon({ + x: modification.x, + y: modification.y, + width: modification.width, + height: modification.height, + shapes: shape.shapes, + pointToProjection: this.pointToProjection, + mirror: modification.direction && modification.direction === 'RIGHT', + }); + this.polygons.push(...multiPolygonModifications); + multiPolygonModifications.forEach(polygon => { + const modificationStyle = new Style({ + geometry: polygon, + stroke: getStroke({ color: rgbToHex(modification.borderColor) }), + fill: getFill({ color: rgbToHex(modification.fillColor) }), + zIndex: modification.z, + }); + const modificationText = modification.stateAbbreviation + ? modification.stateAbbreviation + : modification.name; + if (modificationText) { + modificationStyle.setText( + new Text({ + text: modificationText, + font: `${modification.fontSize}px Arial`, + textAlign: 'center', + textBaseline: 'middle', + fill: getFill({ color: '#000' }), + overflow: true, + }), + ); + this.polygonsTexts.push(modification.name); + } + this.styles.push(modificationStyle); + }); + }); + + if (this.lineType) { + const lineTypeFound = this.lineTypes.find(type => type.name === this.lineType); + if (lineTypeFound) { + this.lineDash = lineTypeFound.pattern; + } + } + + const homodimerOffset = (this.homodimer - 1) * 6; + for (let i = 0; i < this.homodimer; i += 1) { + const homodimerShift = (this.homodimer - i - 1) * 6; + if (this.activity) { + this.drawActiveBorder(homodimerShift, homodimerOffset); + } + this.drawElementPolygon(homodimerShift, homodimerOffset); + } + } + + drawActiveBorder(homodimerShift: number, homodimerOffset: number): void { + const activityBorderElement = getMultiPolygon({ + x: this.x + homodimerShift - 5, + y: this.y + homodimerShift - 5, + width: this.width - homodimerOffset + 10, + height: this.height - homodimerOffset + 10, + shapes: this.shapes, + pointToProjection: this.pointToProjection, + }); + activityBorderElement.forEach(polygon => { + this.styles.push( + getStyle({ + geometry: polygon, + fillColor: { rgb: 0, alpha: 0 }, + lineDash: [3, 5], + zIndex: this.zIndex, + }), + ); + }); + this.polygons.push(...activityBorderElement); + } + + drawElementPolygon(homodimerShift: number, homodimerOffset: number): void { + const elementPolygon = getMultiPolygon({ + x: this.x + homodimerShift, + y: this.y + homodimerShift, + width: this.width - homodimerOffset, + height: this.height - homodimerOffset, + shapes: this.shapes, + pointToProjection: this.pointToProjection, + }); + elementPolygon.forEach(polygon => { + this.styles.push( + getStyle({ + geometry: polygon, + borderColor: this.borderColor, + fillColor: this.fillColor, + lineWidth: this.lineWidth, + lineDash: this.lineDash, + zIndex: this.zIndex, + }), + ); + }); + this.polygons.push(...elementPolygon); + } +} diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getMultiPolygon.test.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/getMultiPolygon.test.ts similarity index 96% rename from src/components/Map/MapViewer/MapViewerVector/utils/shapes/getMultiPolygon.test.ts rename to src/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/getMultiPolygon.test.ts index 76036f498b7d92ab4040ed570e0fd6b7959582e4..dbe051bd31e7812e40e29baf8487387202fe57ff 100644 --- a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getMultiPolygon.test.ts +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/getMultiPolygon.test.ts @@ -1,12 +1,12 @@ /* 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 getPolygonCoords from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/coords/getPolygonCoords'; +import getEllipseCoords from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/coords/getEllipseCoords'; import Polygon from 'ol/geom/Polygon'; import { Shape } from '@/types/models'; import getMultiPolygon from './getMultiPolygon'; -jest.mock('./getPolygonCoords'); -jest.mock('./getEllipseCoords'); +jest.mock('../coords/getPolygonCoords'); +jest.mock('../coords/getEllipseCoords'); describe('getMultiPolygon', () => { const mockPointToProjection = jest.fn(point => [point.x, point.y]); diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getMultiPolygon.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/getMultiPolygon.ts similarity index 91% rename from src/components/Map/MapViewer/MapViewerVector/utils/shapes/getMultiPolygon.ts rename to src/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/getMultiPolygon.ts index e5effdb72c71b538411248bf6223245bf5cb9c8b..d91c741abd1a4e477e3c5f6ed09a4fafad650b11 100644 --- a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getMultiPolygon.ts +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/getMultiPolygon.ts @@ -1,11 +1,11 @@ /* 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 getPolygonCoords from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/coords/getPolygonCoords'; +import getEllipseCoords from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/coords/getEllipseCoords'; import { UsePointToProjectionResult } from '@/utils/map/usePointToProjection'; import Polygon from 'ol/geom/Polygon'; import { Coordinate } from 'ol/coordinate'; import { Shape } from '@/types/models'; -import getCentroid from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/getCentroid'; +import getCentroid from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/coords/getCentroid'; export default function getMultiPolygon({ x, diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getStroke.test.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getStroke.test.ts deleted file mode 100644 index 70a27921c1a7510ddf9f4792000176aa962b41d6..0000000000000000000000000000000000000000 --- a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getStroke.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -/* 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/getText.test.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getText.test.ts deleted file mode 100644 index a9f0490707370aca9a3e9fe595aab4e83174bcff..0000000000000000000000000000000000000000 --- a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getText.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -/* 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 deleted file mode 100644 index 10e021881d8e448edd9559a142d819d3b97de00c..0000000000000000000000000000000000000000 --- a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getText.ts +++ /dev/null @@ -1,82 +0,0 @@ -/* eslint-disable no-magic-numbers */ -import { Fill, Text } from 'ol/style'; -import Style from 'ol/style/Style'; -import { UsePointToProjectionResult } from '@/utils/map/usePointToProjection'; -import { Coordinate } from 'ol/coordinate'; -import { - HorizontalAlign, - VerticalAlign, -} from '@/components/Map/MapViewer/MapViewerVector/MapViewerVector.types'; -import { FeatureLike } from 'ol/Feature'; -import { Geometry, MultiPolygon } from 'ol/geom'; -import RenderFeature from 'ol/render/Feature'; - -export default function getText({ - x, - y, - height, - width, - text = '', - fontSize = 12, - color = '#000', - zIndex = 1, - verticalAlign = 'MIDDLE', - horizontalAlign = 'CENTER', - pointToProjection, -}: { - x: number; - y: number; - height: number; - width: number; - text: string; - fontSize?: string | number; - color?: string; - zIndex?: number; - 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 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: new Text({ - text, - font: `bold ${fontSize}px Arial`, - fill: new Fill({ - color, - }), - placement: 'point', - textAlign: 'center', - textBaseline: 'middle', - }), - zIndex, - }); - - return { textCoords, textStyle }; -} diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/layer/Layer.test.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/layer/Layer.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..ab6e4eb00b44740e62fdecc33d08a60d26b8e88e --- /dev/null +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/layer/Layer.test.ts @@ -0,0 +1,145 @@ +/* eslint-disable no-magic-numbers */ +import { Map } from 'ol'; +import { Style, Text } from 'ol/style'; +import getTextStyle from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/text/getTextStyle'; +import getStroke from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/style/getStroke'; +import { rgbToHex } from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/style/rgbToHex'; +import View from 'ol/View'; +import { + WHITE_COLOR, + BLACK_COLOR, +} from '@/components/Map/MapViewer/MapViewerVector/MapViewerVector.constants'; +import getTextCoords from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/text/getTextCoords'; +import Layer, { + LayerProps, +} from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/layer/Layer'; +import VectorSource from 'ol/source/Vector'; +import VectorLayer from 'ol/layer/Vector'; + +jest.mock('../text/getTextCoords'); +jest.mock('../text/getTextStyle'); +jest.mock('../style/getStroke'); +jest.mock('../style/rgbToHex'); + +describe('Layer', () => { + let props: LayerProps; + + beforeEach(() => { + const dummyElement = document.createElement('div'); + const mapInstance = new Map({ + target: dummyElement, + view: new View({ + zoom: 5, + minZoom: 3, + maxZoom: 7, + }), + }); + props = { + texts: [ + { + id: 1, + x: 10, + y: 10, + z: 3, + width: 100, + height: 100, + fontSize: 12, + size: 12312, + notes: 'XYZ', + glyph: null, + elementId: '34', + verticalAlign: 'MIDDLE', + horizontalAlign: 'CENTER', + backgroundColor: WHITE_COLOR, + borderColor: BLACK_COLOR, + color: BLACK_COLOR, + }, + ], + rects: [ + { + id: 1, + x: 10, + y: 10, + z: 3, + width: 100, + height: 100, + size: 12312, + elementId: '341', + lineWidth: 2, + borderColor: BLACK_COLOR, + fillColor: WHITE_COLOR, + }, + ], + ovals: [ + { + id: 1, + x: 10, + y: 10, + z: 3, + width: 100, + height: 100, + size: 12312, + elementId: '341', + lineWidth: 2, + borderColor: BLACK_COLOR, + }, + ], + lines: [ + { + id: 120899, + width: 5.0, + color: { + alpha: 255, + rgb: -16777216, + }, + z: 0, + segments: [ + { + x1: 36.0, + y1: 39.0, + x2: 213.0, + y2: 41.0, + }, + ], + startArrow: { + arrowType: 'NONE', + angle: 2.748893571891069, + lineType: 'SOLID', + length: 15.0, + }, + endArrow: { + arrowType: 'NONE', + angle: 2.748893571891069, + lineType: 'SOLID', + length: 15.0, + }, + lineType: 'SOLID', + }, + ], + visible: true, + layerId: '23', + pointToProjection: jest.fn(point => [point.x, point.y]), + mapInstance, + lineTypes: [], + arrowTypes: [], + }; + (getTextStyle as jest.Mock).mockReturnValue( + new Style({ + text: new Text({}), + }), + ); + (getTextCoords as jest.Mock).mockReturnValue([10, 10]); + (getStroke as jest.Mock).mockReturnValue(new Style()); + (rgbToHex as jest.Mock).mockReturnValue('#FFFFFF'); + }); + + it('should initialize a Layer class', () => { + const layer = new Layer(props); + + expect(layer.textFeatures.length).toBe(1); + expect(layer.rectFeatures.length).toBe(1); + expect(layer.ovalFeatures.length).toBe(1); + expect(layer.vectorSource).toBeInstanceOf(VectorSource); + expect(layer.vectorLayer).toBeInstanceOf(VectorLayer); + }); +}); diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/layer/Layer.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/layer/Layer.ts new file mode 100644 index 0000000000000000000000000000000000000000..259bff25fcd3512e1d5cbaf83f9aafa0f3448867 --- /dev/null +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/layer/Layer.ts @@ -0,0 +1,320 @@ +/* eslint-disable no-magic-numbers */ +import { + Arrow, + ArrowType, + LayerLine, + LayerOval, + LayerRect, + LayerText, + LineType, +} from '@/types/models'; +import { MapInstance } from '@/types/map'; +import { UsePointToProjectionResult } from '@/utils/map/usePointToProjection'; +import { Feature } from 'ol'; +import { LineString, MultiPolygon, Point } from 'ol/geom'; +import Text from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/text/Text'; +import Polygon from 'ol/geom/Polygon'; +import VectorSource from 'ol/source/Vector'; +import VectorLayer from 'ol/layer/Vector'; +import getEllipseCoords from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/coords/getEllipseCoords'; +import getStyle from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/style/getStyle'; +import { + HorizontalAlign, + VerticalAlign, +} from '@/components/Map/MapViewer/MapViewerVector/MapViewerVector.types'; +import getMultiPolygon from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/getMultiPolygon'; +import Style from 'ol/style/Style'; +import { BLACK_COLOR } from '@/components/Map/MapViewer/MapViewerVector/MapViewerVector.constants'; +import getRotation from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/coords/getRotation'; + +export interface LayerProps { + texts: Array<LayerText>; + rects: Array<LayerRect>; + ovals: Array<LayerOval>; + lines: Array<LayerLine>; + visible: boolean; + layerId: string; + lineTypes: Array<LineType>; + arrowTypes: Array<ArrowType>; + mapInstance: MapInstance; + pointToProjection: UsePointToProjectionResult; +} + +export default class Layer { + texts: Array<LayerText>; + + rects: Array<LayerRect>; + + ovals: Array<LayerOval>; + + lines: Array<LayerLine>; + + lineTypes: Array<LineType>; + + arrowTypes: Array<ArrowType>; + + textFeatures: Array<Feature<Point>>; + + rectFeatures: Array<Feature<Polygon>>; + + ovalFeatures: Array<Feature<Polygon>>; + + lineFeatures: Array<Feature<LineString>>; + + arrowFeatures: Array<Feature<MultiPolygon>>; + + vectorSource: VectorSource< + Feature<Point> | Feature<Polygon> | Feature<LineString> | Feature<MultiPolygon> + >; + + vectorLayer: VectorLayer< + VectorSource<Feature<Point> | Feature<Polygon> | Feature<LineString> | Feature<MultiPolygon>> + >; + + constructor({ + texts, + rects, + ovals, + lines, + visible, + layerId, + lineTypes, + arrowTypes, + mapInstance, + pointToProjection, + }: LayerProps) { + this.texts = texts; + this.rects = rects; + this.ovals = ovals; + this.lines = lines; + this.lineTypes = lineTypes; + this.arrowTypes = arrowTypes; + this.textFeatures = this.getTextsFeatures(mapInstance, pointToProjection); + this.rectFeatures = this.getRectsFeatures(pointToProjection); + this.ovalFeatures = this.getOvalsFeatures(pointToProjection); + const { linesFeatures, arrowsFeatures } = this.getLinesFeatures(pointToProjection); + this.lineFeatures = linesFeatures; + this.arrowFeatures = arrowsFeatures; + this.vectorSource = new VectorSource({ + features: [ + ...this.textFeatures, + ...this.rectFeatures, + ...this.ovalFeatures, + ...this.lineFeatures, + ...this.arrowFeatures, + ], + }); + this.vectorLayer = new VectorLayer({ + source: this.vectorSource, + visible, + }); + this.vectorLayer.set('id', layerId); + } + + private getTextsFeatures = ( + mapInstance: MapInstance, + pointToProjection: UsePointToProjectionResult, + ): Array<Feature<Point>> => { + const textObjects = this.texts.map(text => { + return new Text({ + x: text.x, + y: text.y, + zIndex: text.z, + width: text.width, + height: text.height, + fontColor: text.color, + fontSize: text.fontSize, + text: text.notes, + verticalAlign: text.verticalAlign as VerticalAlign, + horizontalAlign: text.horizontalAlign as HorizontalAlign, + pointToProjection, + mapInstance, + }); + }); + return textObjects.map(text => text.feature); + }; + + private getRectsFeatures = ( + pointToProjection: UsePointToProjectionResult, + ): Array<Feature<Polygon>> => { + return this.rects.map(rect => { + const polygon = new Polygon([ + [ + pointToProjection({ x: rect.x, y: rect.y }), + pointToProjection({ x: rect.x + rect.width, y: rect.y }), + pointToProjection({ x: rect.x + rect.width, y: rect.y + rect.height }), + pointToProjection({ x: rect.x, y: rect.y + rect.height }), + ], + ]); + const polygonStyle = getStyle({ + geometry: polygon, + borderColor: rect.borderColor, + fillColor: rect.fillColor, + lineWidth: rect.lineWidth, + zIndex: rect.z, + }); + const rectFeature = new Feature<Polygon>({ + geometry: polygon, + }); + rectFeature.setStyle(polygonStyle); + return rectFeature; + }); + }; + + private getOvalsFeatures = ( + pointToProjection: UsePointToProjectionResult, + ): Array<Feature<Polygon>> => { + return this.ovals.map(oval => { + const coords = getEllipseCoords({ + x: oval.x, + y: oval.y, + height: oval.height, + width: oval.width, + pointToProjection, + points: 36, + }); + const polygon = new Polygon([coords]); + const polygonStyle = getStyle({ + geometry: polygon, + borderColor: oval.borderColor, + fillColor: { rgb: 0, alpha: 0 }, + lineWidth: oval.lineWidth, + zIndex: oval.z, + }); + const ovalFeature = new Feature<Polygon>({ + geometry: polygon, + }); + ovalFeature.setStyle(polygonStyle); + return ovalFeature; + }); + }; + + private getLinesFeatures = ( + pointToProjection: UsePointToProjectionResult, + ): { + linesFeatures: Array<Feature<LineString>>; + arrowsFeatures: Array<Feature<MultiPolygon>>; + } => { + const linesFeatures: Array<Feature<LineString>> = []; + const arrowsFeatures: Array<Feature<MultiPolygon>> = []; + + this.lines.forEach(line => { + const points = line.segments + .map((segment, index) => { + if (index === 0) { + return [ + pointToProjection({ x: segment.x1, y: segment.y1 }), + pointToProjection({ x: segment.x2, y: segment.y2 }), + ]; + } + return [pointToProjection({ x: segment.x2, y: segment.y2 })]; + }) + .flat(); + const lineString = new LineString(points); + + let lineDash; + const lineTypeFound = this.lineTypes.find(type => type.name === line.lineType); + if (lineTypeFound) { + lineDash = lineTypeFound.pattern; + } + const lineStyle = getStyle({ + geometry: lineString, + borderColor: line.color, + lineWidth: line.width, + lineDash, + zIndex: line.z, + }); + const lineFeature = new Feature<LineString>({ + geometry: lineString, + }); + lineFeature.setStyle(lineStyle); + linesFeatures.push(lineFeature); + arrowsFeatures.push(...this.getLineArrowsFeatures(line, pointToProjection)); + }); + return { linesFeatures, arrowsFeatures }; + }; + + private getLineArrowsFeatures = ( + line: LayerLine, + pointToProjection: UsePointToProjectionResult, + ): Array<Feature<MultiPolygon>> => { + const arrowsFeatures: Array<Feature<MultiPolygon>> = []; + const firstSegment = line.segments[0]; + const startArrowRotation = getRotation( + [firstSegment.x1, firstSegment.y1], + [firstSegment.x2, firstSegment.y2], + ); + const startArrowFeature = this.getLineArrowFeature( + line.startArrow, + firstSegment.x1, + firstSegment.y1, + line.z, + startArrowRotation, + line.width, + pointToProjection, + ); + if (startArrowFeature) { + arrowsFeatures.push(startArrowFeature); + } + + const lastSegment = line.segments[line.segments.length - 1]; + const endArrowRotation = getRotation( + [lastSegment.x1, lastSegment.y1], + [lastSegment.x2, lastSegment.y2], + ); + const endArrowFeature = this.getLineArrowFeature( + line.endArrow, + lastSegment.x2, + lastSegment.y2, + line.z, + endArrowRotation, + line.width, + pointToProjection, + ); + if (endArrowFeature) { + arrowsFeatures.push(endArrowFeature); + } + return arrowsFeatures; + }; + + private getLineArrowFeature = ( + arrow: Arrow, + x: number, + y: number, + zIndex: number, + rotation: number, + lineWidth: number, + pointToProjection: UsePointToProjectionResult, + ): undefined | Feature<MultiPolygon> => { + const arrowShapes = this.arrowTypes.find(arrowType => arrowType.arrowType === arrow.arrowType) + ?.shapes; + if (!arrowShapes) { + return undefined; + } + const arrowMultiPolygon = getMultiPolygon({ + x, + y: y - arrow.length / 2, + width: arrow.length, + height: arrow.length, + shapes: arrowShapes, + pointToProjection, + }); + const arrowStyles: Array<Style> = []; + arrowMultiPolygon.forEach(polygon => { + const style = getStyle({ + geometry: polygon, + zIndex, + borderColor: BLACK_COLOR, + fillColor: BLACK_COLOR, + lineWidth, + }); + arrowStyles.push(style); + polygon.rotate(rotation, pointToProjection({ x, y })); + }); + const arrowFeature = new Feature({ + geometry: new MultiPolygon(arrowMultiPolygon), + }); + arrowFeature.setStyle(arrowStyles); + return arrowFeature; + }; +} diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getFill.test.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/style/getFill.test.ts similarity index 100% rename from src/components/Map/MapViewer/MapViewerVector/utils/shapes/getFill.test.ts rename to src/components/Map/MapViewer/MapViewerVector/utils/shapes/style/getFill.test.ts diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getFill.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/style/getFill.ts similarity index 100% rename from src/components/Map/MapViewer/MapViewerVector/utils/shapes/getFill.ts rename to src/components/Map/MapViewer/MapViewerVector/utils/shapes/style/getFill.ts diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/style/getStroke.test.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/style/getStroke.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..9f485a73152e822c029bf5f79334050c2a49b9b9 --- /dev/null +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/style/getStroke.test.ts @@ -0,0 +1,27 @@ +/* eslint-disable no-magic-numbers */ +import { Stroke } from 'ol/style'; +import getStroke from './getStroke'; + +describe('getStroke', () => { + it('should return a Stroke object with the default color and width', () => { + const result = getStroke({}); + expect(result).toBeInstanceOf(Stroke); + expect(result.getColor()).toEqual('#000'); + expect(result.getWidth()).toEqual(1); + expect(result.getLineDash()).toEqual([]); + expect(result.getLineCap()).toEqual('butt'); + }); + + it('should return a Stroke object with the provided values', () => { + const color = '#ff0000'; + const width = 2; + const lineDash = [10, 5]; + const lineCap = 'round'; + const result = getStroke({ color, width, lineDash, lineCap }); + expect(result).toBeInstanceOf(Stroke); + expect(result.getColor()).toEqual(color); + expect(result.getWidth()).toEqual(width); + expect(result.getLineDash()).toEqual(lineDash); + expect(result.getLineCap()).toEqual(lineCap); + }); +}); diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getStroke.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/style/getStroke.ts similarity index 64% rename from src/components/Map/MapViewer/MapViewerVector/utils/shapes/getStroke.ts rename to src/components/Map/MapViewer/MapViewerVector/utils/shapes/style/getStroke.ts index 37816224714777d7917bc77067caa4354b75c8ee..5625aedbf1134a372b9d5864aff453bf0d104d60 100644 --- a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getStroke.ts +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/style/getStroke.ts @@ -4,12 +4,18 @@ import { Stroke } from 'ol/style'; export default function getStroke({ color = '#000', width = 1, + lineDash = [], + lineCap = 'butt', }: { color?: string; width?: number; + lineDash?: Array<number>; + lineCap?: string; }): Stroke { return new Stroke({ color, width, + lineDash, + lineCap: lineCap as CanvasLineCap, }); } diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/style/getStyle.test.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/style/getStyle.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..e46f9ef0c2cea4a7ba48e7f338a08918a5c9bb18 --- /dev/null +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/style/getStyle.test.ts @@ -0,0 +1,52 @@ +/* eslint-disable no-magic-numbers */ +import getStyle from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/style/getStyle'; +import Style from 'ol/style/Style'; +import Polygon from 'ol/geom/Polygon'; + +describe('getStyle', () => { + it('should return a Style object with the default values', () => { + const result = getStyle({}); + expect(result).toBeInstanceOf(Style); + expect(result.getGeometry()).toEqual(null); + expect(result.getStroke()?.getWidth()).toEqual(1); + expect(result.getStroke()?.getColor()).toEqual('#000000FF'); + expect(result.getStroke()?.getLineDash()).toEqual([]); + expect(result.getFill()?.getColor()).toEqual('#FFFFFFFF'); + expect(result.getZIndex()).toEqual(1); + }); + + it('should return a Style object with the provided color and width', () => { + const geometry = new Polygon([ + [ + [10, 10], + [10, 10], + ], + ]); + const borderColor = { + alpha: 255, + rgb: -16777216, + }; + const fillColor = { + alpha: 255, + rgb: -5646081, + }; + const lineWidth = 3; + const lineDash = [10, 5]; + const zIndex = 2; + const result = getStyle({ + geometry, + borderColor, + fillColor, + lineWidth, + lineDash, + zIndex, + }); + expect(result).toBeInstanceOf(Style); + expect(result.getGeometry()).toEqual(geometry); + expect(result.getStroke()?.getWidth()).toEqual(lineWidth); + expect(result.getStroke()?.getColor()).toEqual('#000000FF'); + expect(result.getStroke()?.getLineDash()).toEqual(lineDash); + expect(result.getFill()?.getColor()).toEqual('#A9D8FFFF'); + expect(result.getZIndex()).toEqual(zIndex); + }); +}); diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/style/getStyle.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/style/getStyle.ts new file mode 100644 index 0000000000000000000000000000000000000000..62f1615786437795cdaa34e9e920d629fe67d53a --- /dev/null +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/style/getStyle.ts @@ -0,0 +1,38 @@ +/* eslint-disable no-magic-numbers */ +import Style from 'ol/style/Style'; +import { Geometry } from 'ol/geom'; +import getStroke from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/style/getStroke'; +import { rgbToHex } from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/style/rgbToHex'; +import { + BLACK_COLOR, + WHITE_COLOR, +} from '@/components/Map/MapViewer/MapViewerVector/MapViewerVector.constants'; +import { ColorObject } from '@/components/Map/MapViewer/MapViewerVector/MapViewerVector.types'; +import getFill from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/style/getFill'; + +export default function getStyle({ + geometry, + borderColor = BLACK_COLOR, + fillColor = WHITE_COLOR, + lineWidth = 1, + lineDash = [], + zIndex = 1, +}: { + geometry?: Geometry; + borderColor?: ColorObject; + fillColor?: ColorObject; + lineWidth?: number; + lineDash?: Array<number>; + zIndex?: number; +}): Style { + return new Style({ + geometry, + stroke: getStroke({ + color: rgbToHex(borderColor), + width: lineWidth, + lineDash, + }), + fill: getFill({ color: rgbToHex(fillColor) }), + zIndex, + }); +} diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/rgbToHex.test.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/style/rgbToHex.test.ts similarity index 100% rename from src/components/Map/MapViewer/MapViewerVector/utils/shapes/rgbToHex.test.ts rename to src/components/Map/MapViewer/MapViewerVector/utils/shapes/style/rgbToHex.test.ts diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/rgbToHex.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/style/rgbToHex.ts similarity index 100% rename from src/components/Map/MapViewer/MapViewerVector/utils/shapes/rgbToHex.ts rename to src/components/Map/MapViewer/MapViewerVector/utils/shapes/style/rgbToHex.ts diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/text/Text.test.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/text/Text.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..68b8d9f8f1a73b0d99e2251282512c5e211d4859 --- /dev/null +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/text/Text.test.ts @@ -0,0 +1,73 @@ +/* eslint-disable no-magic-numbers */ +import { Map } from 'ol'; +import { Style } from 'ol/style'; +import Text, { TextProps } from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/text/Text'; +import getTextStyle from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/text/getTextStyle'; +import { rgbToHex } from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/style/rgbToHex'; +import View from 'ol/View'; +import { BLACK_COLOR } from '@/components/Map/MapViewer/MapViewerVector/MapViewerVector.constants'; +import getTextCoords from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/text/getTextCoords'; + +jest.mock('./getTextCoords'); +jest.mock('./getTextStyle'); +jest.mock('../style/rgbToHex'); + +describe('Text', () => { + let props: TextProps; + + beforeEach(() => { + const dummyElement = document.createElement('div'); + const mapInstance = new Map({ + target: dummyElement, + view: new View({ + zoom: 5, + minZoom: 3, + maxZoom: 7, + }), + }); + props = { + x: 0, + y: 0, + width: 100, + height: 100, + zIndex: 1, + text: 'Test', + fontSize: 12, + fontColor: BLACK_COLOR, + verticalAlign: 'MIDDLE', + horizontalAlign: 'CENTER', + pointToProjection: jest.fn(), + mapInstance, + }; + + (getTextStyle as jest.Mock).mockReturnValue(new Style()); + (getTextCoords as jest.Mock).mockReturnValue([10, 10]); + (rgbToHex as jest.Mock).mockReturnValue('#FFFFFF'); + }); + + it('should apply correct styles to the feature', () => { + const text = new Text(props); + const { feature } = text; + + const style = feature.getStyleFunction()?.call(text, feature, 1); + + if (Array.isArray(style)) { + expect(style.length).toBeGreaterThan(0); + } else { + expect(style).toBeInstanceOf(Style); + } + }); + + it('should hide text when the scaled font size is too small', () => { + const text = new Text(props); + const { feature } = text; + + const style = feature.getStyleFunction()?.call(text, feature, 20); + + if (Array.isArray(style)) { + expect(style[0].getText()?.getText()).toBeUndefined(); + } else { + expect(style?.getText()?.getText()).toBeUndefined(); + } + }); +}); diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/text/Text.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/text/Text.ts new file mode 100644 index 0000000000000000000000000000000000000000..687cf2fc76e8607c47b4159b0e3f18a6d3202b47 --- /dev/null +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/text/Text.ts @@ -0,0 +1,115 @@ +/* eslint-disable no-magic-numbers */ +import { + ColorObject, + HorizontalAlign, + VerticalAlign, +} from '@/components/Map/MapViewer/MapViewerVector/MapViewerVector.types'; +import { rgbToHex } from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/style/rgbToHex'; +import { UsePointToProjectionResult } from '@/utils/map/usePointToProjection'; +import Style from 'ol/style/Style'; +import { Point } from 'ol/geom'; +import { Feature } from 'ol'; +import { FeatureLike } from 'ol/Feature'; +import { MapInstance } from '@/types/map'; +import getTextCoords from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/text/getTextCoords'; +import getTextStyle from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/text/getTextStyle'; + +export interface TextProps { + x: number; + y: number; + width: number; + height: number; + zIndex: number; + text: string; + fontSize: number; + fontColor: ColorObject; + verticalAlign: VerticalAlign; + horizontalAlign: HorizontalAlign; + pointToProjection: UsePointToProjectionResult; + mapInstance: MapInstance; +} + +export default class Text { + text: string; + + fontSize: number; + + style: Style; + + point: Point; + + feature: Feature<Point>; + + constructor({ + x, + y, + width, + height, + zIndex, + text, + fontSize, + fontColor, + verticalAlign, + horizontalAlign, + pointToProjection, + mapInstance, + }: TextProps) { + this.text = text; + this.fontSize = fontSize; + + const textCoords = getTextCoords({ + x, + y, + height, + width, + fontSize, + verticalAlign, + horizontalAlign, + pointToProjection, + }); + const textStyle = getTextStyle({ + text, + fontSize, + color: rgbToHex(fontColor), + zIndex, + horizontalAlign, + }); + this.point = new Point(textCoords); + this.style = textStyle; + this.style.setGeometry(this.point); + + this.feature = new Feature({ + geometry: this.point, + 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.feature.setStyle(this.styleFunction.bind(this)); + } + + protected styleFunction(feature: FeatureLike, resolution: number): Style | Array<Style> | void { + const getTextScale = feature.get('getTextScale'); + let textScale = 1; + if (getTextScale instanceof Function) { + textScale = getTextScale(resolution); + } + + if (this.style.getText()) { + if (this.fontSize * textScale > 4) { + this.style.getText()?.setScale(textScale); + this.style.getText()?.setText(this.text); + } else { + this.style.getText()?.setText(undefined); + } + } + return this.style; + } +} diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/text/getTextCoords.test.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/text/getTextCoords.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..6121a92d201c129bd1787002439609afdaf38f39 --- /dev/null +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/text/getTextCoords.test.ts @@ -0,0 +1,20 @@ +/* eslint-disable no-magic-numbers */ +import getTextCoords from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/text/getTextCoords'; +import { UsePointToProjectionResult } from '@/utils/map/usePointToProjection'; + +describe('getTextCoords', () => { + it('should return a text coords', () => { + const mockPointToProjection: UsePointToProjectionResult = jest.fn(point => [point.x, point.y]); + const textCoords = getTextCoords({ + x: 20, + y: 20, + height: 100, + width: 100, + fontSize: 12, + verticalAlign: 'MIDDLE', + horizontalAlign: 'CENTER', + pointToProjection: mockPointToProjection, + }); + expect(textCoords).toEqual([70, 70]); + }); +}); diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/text/getTextCoords.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/text/getTextCoords.ts new file mode 100644 index 0000000000000000000000000000000000000000..3dcbb8fa775a7eedc9d4615c565f7d3f6da8afad --- /dev/null +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/text/getTextCoords.ts @@ -0,0 +1,48 @@ +/* eslint-disable no-magic-numbers */ +import { + HorizontalAlign, + VerticalAlign, +} from '@/components/Map/MapViewer/MapViewerVector/MapViewerVector.types'; +import { UsePointToProjectionResult } from '@/utils/map/usePointToProjection'; +import { Coordinate } from 'ol/coordinate'; + +export default function getTextCoords({ + x, + y, + height, + width, + fontSize, + verticalAlign, + horizontalAlign, + pointToProjection, +}: { + x: number; + y: number; + height: number; + width: number; + fontSize: number; + verticalAlign: VerticalAlign; + horizontalAlign: HorizontalAlign; + pointToProjection: UsePointToProjectionResult; +}): Coordinate { + 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 * 4) / 6; + } else if (verticalAlign === 'BOTTOM') { + textY = maxY - (fontSize * 4) / 6; + } + + let textX = (minX + maxX) / 2; + if (['LEFT', 'START'].includes(horizontalAlign)) { + textX = minX; + } else if (['RIGHT', 'END'].includes(horizontalAlign)) { + textX = maxX; + } + + return pointToProjection({ x: textX, y: textY }); +} diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/text/getTextStyle.test.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/text/getTextStyle.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..d16623b5d20e27252138ee74dac0f5096c6cfaf9 --- /dev/null +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/text/getTextStyle.test.ts @@ -0,0 +1,26 @@ +/* eslint-disable no-magic-numbers */ +import getTextStyle from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/text/getTextStyle'; +import Style from 'ol/style/Style'; + +describe('getTextStyle', () => { + it('should return a text style object', () => { + const text = 'Text styl test'; + const fontSize = 12; + const color = '#CCFFCC'; + const zIndex = 122; + const horizontalAlign = 'CENTER'; + const textStyle = getTextStyle({ + text, + fontSize, + color, + zIndex, + horizontalAlign, + }); + expect(textStyle).toBeInstanceOf(Style); + expect(textStyle.getText()?.getText()).toBe(text); + expect(textStyle.getText()?.getFont()).toBe('12pt Arial'); + expect(textStyle.getText()?.getFill()?.getColor()).toBe(color); + expect(textStyle.getZIndex()).toBe(zIndex); + expect(textStyle.getText()?.getTextAlign()).toBe(horizontalAlign.toLowerCase()); + }); +}); diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/text/getTextStyle.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/text/getTextStyle.ts new file mode 100644 index 0000000000000000000000000000000000000000..da9eed330814e1a0fb1083bf0ce3236da58d8c0b --- /dev/null +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/text/getTextStyle.ts @@ -0,0 +1,32 @@ +import { Fill, Text } from 'ol/style'; +import Style from 'ol/style/Style'; +import { HorizontalAlign } from '@/components/Map/MapViewer/MapViewerVector/MapViewerVector.types'; + +export default function getTextStyle({ + text, + fontSize, + color, + zIndex, + horizontalAlign, +}: { + text: string; + fontSize: number; + color: string; + zIndex: number; + horizontalAlign: HorizontalAlign; +}): Style { + return new Style({ + text: new Text({ + text, + font: `${fontSize}pt Arial`, + fill: new Fill({ + color, + }), + placement: 'point', + textAlign: horizontalAlign.toLowerCase() as CanvasTextAlign, + textBaseline: 'middle', + overflow: true, + }), + zIndex, + }); +} diff --git a/src/models/arrowTypeSchema.ts b/src/models/arrowTypeSchema.ts new file mode 100644 index 0000000000000000000000000000000000000000..929f6b5a749677411933645508923d7ca3bc0a5f --- /dev/null +++ b/src/models/arrowTypeSchema.ts @@ -0,0 +1,7 @@ +import { z } from 'zod'; +import { shapeSchema } from '@/models/shapeSchema'; + +export const arrowTypeSchema = z.object({ + arrowType: z.string(), + shapes: z.array(shapeSchema), +}); diff --git a/src/models/fixtures/arrowTypesFixture.ts b/src/models/fixtures/arrowTypesFixture.ts new file mode 100644 index 0000000000000000000000000000000000000000..2d4523530d792f059ed3985838f7a59ed31e7cf2 --- /dev/null +++ b/src/models/fixtures/arrowTypesFixture.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 { arrowTypeSchema } from '@/models/arrowTypeSchema'; + +export const arrowTypesFixture = createFixture(z.array(arrowTypeSchema), { + seed: ZOD_SEED, + array: { min: 3, max: 3 }, +}); diff --git a/src/models/fixtures/shapesFixture.ts b/src/models/fixtures/bioShapesFixture.ts similarity index 79% rename from src/models/fixtures/shapesFixture.ts rename to src/models/fixtures/bioShapesFixture.ts index e1ce1e00f3772e84fe94c31173c12aa6da986202..23a5884a008a622137c7acc082edb61ced883bda 100644 --- a/src/models/fixtures/shapesFixture.ts +++ b/src/models/fixtures/bioShapesFixture.ts @@ -4,7 +4,7 @@ import { z } from 'zod'; import { createFixture } from 'zod-fixture'; import { bioShapeSchema } from '@/models/bioShapeSchema'; -export const shapesFixture = createFixture(z.array(bioShapeSchema), { +export const bioShapesFixture = createFixture(z.array(bioShapeSchema), { seed: ZOD_SEED, array: { min: 3, max: 3 }, }); diff --git a/src/models/fixtures/layerLinesFixture.ts b/src/models/fixtures/layerLinesFixture.ts new file mode 100644 index 0000000000000000000000000000000000000000..fc15d3c2d364031d896d8803f30cad37d997f8fd --- /dev/null +++ b/src/models/fixtures/layerLinesFixture.ts @@ -0,0 +1,10 @@ +import { ZOD_SEED } from '@/constants'; +// eslint-disable-next-line import/no-extraneous-dependencies +import { createFixture } from 'zod-fixture'; +import { pageableSchema } from '@/models/pageableSchema'; +import { layerLineSchema } from '@/models/layerLineSchema'; + +export const layerLinesFixture = createFixture(pageableSchema(layerLineSchema), { + seed: ZOD_SEED, + array: { min: 3, max: 3 }, +}); diff --git a/src/models/fixtures/layerOvalsFixture.ts b/src/models/fixtures/layerOvalsFixture.ts new file mode 100644 index 0000000000000000000000000000000000000000..50544273d9e93617b5814b56ad6892b29bbd68ff --- /dev/null +++ b/src/models/fixtures/layerOvalsFixture.ts @@ -0,0 +1,10 @@ +import { ZOD_SEED } from '@/constants'; +// eslint-disable-next-line import/no-extraneous-dependencies +import { createFixture } from 'zod-fixture'; +import { pageableSchema } from '@/models/pageableSchema'; +import { layerOvalSchema } from '@/models/layerOvalSchema'; + +export const layerOvalsFixture = createFixture(pageableSchema(layerOvalSchema), { + seed: ZOD_SEED, + array: { min: 3, max: 3 }, +}); diff --git a/src/models/fixtures/layerRectsFixture.ts b/src/models/fixtures/layerRectsFixture.ts new file mode 100644 index 0000000000000000000000000000000000000000..98469f8b3a0fe5886264b403c72ec85ff9e48b54 --- /dev/null +++ b/src/models/fixtures/layerRectsFixture.ts @@ -0,0 +1,10 @@ +import { ZOD_SEED } from '@/constants'; +// eslint-disable-next-line import/no-extraneous-dependencies +import { createFixture } from 'zod-fixture'; +import { pageableSchema } from '@/models/pageableSchema'; +import { layerRectSchema } from '@/models/layerRectSchema'; + +export const layerRectsFixture = createFixture(pageableSchema(layerRectSchema), { + seed: ZOD_SEED, + array: { min: 3, max: 3 }, +}); diff --git a/src/models/fixtures/layerTextsFixture.ts b/src/models/fixtures/layerTextsFixture.ts new file mode 100644 index 0000000000000000000000000000000000000000..26e01467b6b15a6ade31cfd0093aa75fb66f4e8b --- /dev/null +++ b/src/models/fixtures/layerTextsFixture.ts @@ -0,0 +1,10 @@ +import { ZOD_SEED } from '@/constants'; +// eslint-disable-next-line import/no-extraneous-dependencies +import { createFixture } from 'zod-fixture'; +import { layerTextSchema } from '@/models/layerTextSchema'; +import { pageableSchema } from '@/models/pageableSchema'; + +export const layerTextsFixture = createFixture(pageableSchema(layerTextSchema), { + seed: ZOD_SEED, + array: { min: 3, max: 3 }, +}); diff --git a/src/models/fixtures/layersFixture.ts b/src/models/fixtures/layersFixture.ts new file mode 100644 index 0000000000000000000000000000000000000000..65a4841af311501d3a7a55e475f813ee00cc2b25 --- /dev/null +++ b/src/models/fixtures/layersFixture.ts @@ -0,0 +1,10 @@ +import { ZOD_SEED } from '@/constants'; +// eslint-disable-next-line import/no-extraneous-dependencies +import { createFixture } from 'zod-fixture'; +import { layerSchema } from '@/models/layerSchema'; +import { pageableSchema } from '@/models/pageableSchema'; + +export const layersFixture = createFixture(pageableSchema(layerSchema), { + seed: ZOD_SEED, + array: { min: 1, max: 1 }, +}); diff --git a/src/models/fixtures/lineTypesFixture.ts b/src/models/fixtures/lineTypesFixture.ts new file mode 100644 index 0000000000000000000000000000000000000000..56833e16cf716570cb9009df2ad4bdd6cba0d8c3 --- /dev/null +++ b/src/models/fixtures/lineTypesFixture.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 { lineTypeSchema } from '@/models/lineTypeSchema'; + +export const lineTypesFixture = createFixture(z.array(lineTypeSchema), { + seed: ZOD_SEED, + array: { min: 3, max: 3 }, +}); diff --git a/src/models/fixtures/modelElementsFixture.ts b/src/models/fixtures/modelElementsFixture.ts index 87c8fb2edbe3b7183b177da0cce8d1d302603c19..0c3df37b83e3924d499e3470556dc1402e4635bf 100644 --- a/src/models/fixtures/modelElementsFixture.ts +++ b/src/models/fixtures/modelElementsFixture.ts @@ -1,9 +1,10 @@ import { ZOD_SEED } from '@/constants'; // eslint-disable-next-line import/no-extraneous-dependencies import { createFixture } from 'zod-fixture'; -import { modelElementsSchema } from '@/models/modelElementsSchema'; +import { modelElementSchema } from '@/models/modelElementSchema'; +import { pageableSchema } from '@/models/pageableSchema'; -export const modelElementsFixture = createFixture(modelElementsSchema, { +export const modelElementsFixture = createFixture(pageableSchema(modelElementSchema), { seed: ZOD_SEED, array: { min: 3, max: 3 }, }); diff --git a/src/models/layerLineSchema.ts b/src/models/layerLineSchema.ts new file mode 100644 index 0000000000000000000000000000000000000000..e454f9302cae6a7f0ccec85aa087a0bf8413aa43 --- /dev/null +++ b/src/models/layerLineSchema.ts @@ -0,0 +1,15 @@ +import { z } from 'zod'; +import { colorSchema } from '@/models/colorSchema'; +import { segmentSchema } from '@/models/segmentSchema'; +import { arrowSchema } from '@/models/arrowSchema'; + +export const layerLineSchema = z.object({ + id: z.number().int().positive(), + width: z.number(), + color: colorSchema, + z: z.number(), + segments: z.array(segmentSchema), + startArrow: arrowSchema, + endArrow: arrowSchema, + lineType: z.string(), +}); diff --git a/src/models/layerOvalSchema.ts b/src/models/layerOvalSchema.ts new file mode 100644 index 0000000000000000000000000000000000000000..abd708efd3456e8d744d7182c5656a9199a66b8d --- /dev/null +++ b/src/models/layerOvalSchema.ts @@ -0,0 +1,15 @@ +import { z } from 'zod'; +import { colorSchema } from '@/models/colorSchema'; + +export const layerOvalSchema = z.object({ + id: z.number().int().positive(), + x: z.number(), + y: z.number(), + z: z.number(), + width: z.number(), + height: z.number(), + lineWidth: z.number(), + size: z.number(), + elementId: z.string(), + borderColor: colorSchema, +}); diff --git a/src/models/layerRectSchema.ts b/src/models/layerRectSchema.ts new file mode 100644 index 0000000000000000000000000000000000000000..440c96d4bcc43c8a55de0c8ba2f6b13b50443e09 --- /dev/null +++ b/src/models/layerRectSchema.ts @@ -0,0 +1,16 @@ +import { z } from 'zod'; +import { colorSchema } from '@/models/colorSchema'; + +export const layerRectSchema = z.object({ + id: z.number().int().positive(), + x: z.number(), + y: z.number(), + z: z.number(), + width: z.number(), + height: z.number(), + lineWidth: z.number(), + size: z.number(), + fillColor: colorSchema, + borderColor: colorSchema, + elementId: z.string(), +}); diff --git a/src/models/layerSchema.ts b/src/models/layerSchema.ts new file mode 100644 index 0000000000000000000000000000000000000000..be2ba10b1a0fe09971903c64ff8128e564244366 --- /dev/null +++ b/src/models/layerSchema.ts @@ -0,0 +1,10 @@ +import { z } from 'zod'; + +export const layerSchema = z.object({ + id: z.number(), + layerId: z.string(), + name: z.string(), + visible: z.boolean(), + locked: z.boolean(), + empty: z.boolean(), +}); diff --git a/src/models/layerTextSchema.ts b/src/models/layerTextSchema.ts new file mode 100644 index 0000000000000000000000000000000000000000..3ad77ed0e59946a8a2a8586eb8ab8b237e67f618 --- /dev/null +++ b/src/models/layerTextSchema.ts @@ -0,0 +1,22 @@ +import { z } from 'zod'; +import { colorSchema } from '@/models/colorSchema'; +import { glyphSchema } from '@/models/glyphSchema'; + +export const layerTextSchema = z.object({ + id: z.number(), + x: z.number(), + y: z.number(), + z: z.number(), + width: z.number(), + height: z.number(), + fontSize: z.number(), + size: z.number(), + notes: z.string(), + glyph: glyphSchema.nullable(), + elementId: z.string(), + verticalAlign: z.string(), + horizontalAlign: z.string(), + backgroundColor: colorSchema, + borderColor: colorSchema, + color: colorSchema, +}); diff --git a/src/models/lineTypeSchema.ts b/src/models/lineTypeSchema.ts new file mode 100644 index 0000000000000000000000000000000000000000..3527033cc0a3aebe4f4459838bcf05c7258ecc2b --- /dev/null +++ b/src/models/lineTypeSchema.ts @@ -0,0 +1,6 @@ +import { z } from 'zod'; + +export const lineTypeSchema = z.object({ + name: z.string(), + pattern: z.array(z.number()), +}); diff --git a/src/models/modelElementSchema.ts b/src/models/modelElementSchema.ts index 7b3291a1d3bbde11ad58e0b27a19df6d77b73959..98f2882560db3d36ae8b4a7e415b506e2e3e083f 100644 --- a/src/models/modelElementSchema.ts +++ b/src/models/modelElementSchema.ts @@ -37,16 +37,21 @@ export const modelElementSchema = z.object({ formerSymbols: z.array(z.string()), activity: z.boolean().optional(), lineWidth: z.number().optional(), + innerWidth: z.number().optional(), + outerWidth: z.number().optional(), + thickness: z.number().optional(), + shape: z.enum(['SQUARE_COMPARTMENT', 'OVAL_COMPARTMENT', 'PATHWAY']).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(), + homodimer: z.number().optional(), hypothetical: z.boolean().nullable().optional(), boundaryCondition: z.boolean().nullable().optional(), constant: z.boolean().nullable().optional(), substanceUnits: z.boolean().nullable().optional(), + borderLineType: z.string().optional(), references: z.array(referenceSchema), sboTerm: z.string(), modificationResidues: z.array(modelElementModificationSchema).optional(), diff --git a/src/models/modelElementsSchema.ts b/src/models/modelElementsSchema.ts deleted file mode 100644 index 19969ba1c00f2155a545e9c1a45ea2ce867a4eea..0000000000000000000000000000000000000000 --- a/src/models/modelElementsSchema.ts +++ /dev/null @@ -1,11 +0,0 @@ -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/redux/apiPath.ts b/src/redux/apiPath.ts index 01fae40c7ce0e33a8e140d734d054c440aa3425e..c193f101d8e4be76a24fc3520f497b4cb9d9e3ec 100644 --- a/src/redux/apiPath.ts +++ b/src/redux/apiPath.ts @@ -51,6 +51,17 @@ export const apiPath = { getModelElements: (modelId: number): string => `projects/${PROJECT_ID}/maps/${modelId}/bioEntities/elements/?size=10000`, getShapes: (): string => `projects/${PROJECT_ID}/shapes/`, + getLineTypes: (): string => `projects/${PROJECT_ID}/lineTypes/`, + getArrowTypes: (): string => `projects/${PROJECT_ID}/arrowTypes/`, + getLayers: (modelId: number): string => `projects/${PROJECT_ID}/maps/${modelId}/layers/`, + getLayerTexts: (modelId: number, layerId: number): string => + `projects/${PROJECT_ID}/maps/${modelId}/layers/${layerId}/texts/`, + getLayerRects: (modelId: number, layerId: number): string => + `projects/${PROJECT_ID}/maps/${modelId}/layers/${layerId}/rects/`, + getLayerOvals: (modelId: number, layerId: number): string => + `projects/${PROJECT_ID}/maps/${modelId}/layers/${layerId}/ovals/`, + getLayerLines: (modelId: number, layerId: number): string => + `projects/${PROJECT_ID}/maps/${modelId}/layers/${layerId}/lines/`, getChemicalsStringWithQuery: (searchQuery: string): string => `projects/${PROJECT_ID}/chemicals:search?query=${searchQuery}`, getAllOverlaysByProjectIdQuery: ( diff --git a/src/redux/layers/layers.constants.ts b/src/redux/layers/layers.constants.ts new file mode 100644 index 0000000000000000000000000000000000000000..56736b9f36f7a1d64e98587554a243b025247470 --- /dev/null +++ b/src/redux/layers/layers.constants.ts @@ -0,0 +1 @@ +export const LAYERS_FETCHING_ERROR_PREFIX = 'Failed to fetch layers'; diff --git a/src/redux/layers/layers.mock.ts b/src/redux/layers/layers.mock.ts new file mode 100644 index 0000000000000000000000000000000000000000..9ec2ce4c48696d6c1b75c008bfcc2f1624475ebd --- /dev/null +++ b/src/redux/layers/layers.mock.ts @@ -0,0 +1,11 @@ +import { LayersState } from '@/redux/layers/layers.types'; +import { DEFAULT_ERROR } from '@/constants/errors'; + +export const LAYERS_STATE_INITIAL_MOCK: LayersState = { + data: { + layers: [], + layersVisibility: {}, + }, + loading: 'idle', + error: DEFAULT_ERROR, +}; diff --git a/src/redux/layers/layers.reducers.test.ts b/src/redux/layers/layers.reducers.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..827c1dff5f1b500b5d59fd70c2cf14581c4dec40 --- /dev/null +++ b/src/redux/layers/layers.reducers.test.ts @@ -0,0 +1,128 @@ +/* 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 { LAYERS_STATE_INITIAL_MOCK } from '@/redux/layers/layers.mock'; +import { getLayers } from '@/redux/layers/layers.thunks'; +import { layersFixture } from '@/models/fixtures/layersFixture'; +import { layerTextsFixture } from '@/models/fixtures/layerTextsFixture'; +import { layerRectsFixture } from '@/models/fixtures/layerRectsFixture'; +import { layerOvalsFixture } from '@/models/fixtures/layerOvalsFixture'; +import { layerLinesFixture } from '@/models/fixtures/layerLinesFixture'; +import { LayersState } from './layers.types'; +import layersReducer from './layers.slice'; + +const mockedAxiosClient = mockNetworkNewAPIResponse(); + +const INITIAL_STATE: LayersState = LAYERS_STATE_INITIAL_MOCK; + +describe('layers reducer', () => { + let store = {} as ToolkitStoreWithSingleSlice<LayersState>; + beforeEach(() => { + store = createStoreInstanceUsingSliceReducer('layers', layersReducer); + }); + + it('should match initial state', () => { + const action = { type: 'unknown' }; + + expect(layersReducer(undefined, action)).toEqual(INITIAL_STATE); + }); + + it('should update store after successful getLayers query', async () => { + mockedAxiosClient.onGet(apiPath.getLayers(1)).reply(HttpStatusCode.Ok, layersFixture); + mockedAxiosClient + .onGet(apiPath.getLayerTexts(1, layersFixture.content[0].id)) + .reply(HttpStatusCode.Ok, layerTextsFixture); + mockedAxiosClient + .onGet(apiPath.getLayerRects(1, layersFixture.content[0].id)) + .reply(HttpStatusCode.Ok, layerRectsFixture); + mockedAxiosClient + .onGet(apiPath.getLayerOvals(1, layersFixture.content[0].id)) + .reply(HttpStatusCode.Ok, layerOvalsFixture); + mockedAxiosClient + .onGet(apiPath.getLayerLines(1, layersFixture.content[0].id)) + .reply(HttpStatusCode.Ok, layerLinesFixture); + + const { type } = await store.dispatch(getLayers(1)); + const { data, loading, error } = store.getState().layers; + expect(type).toBe('vectorMap/getLayers/fulfilled'); + expect(loading).toEqual('succeeded'); + expect(error).toEqual({ message: '', name: '' }); + expect(data).toEqual({ + layers: [ + { + details: layersFixture.content[0], + texts: layerTextsFixture.content, + rects: layerRectsFixture.content, + ovals: layerOvalsFixture.content, + lines: layerLinesFixture.content, + }, + ], + layersVisibility: { + [layersFixture.content[0].layerId]: layersFixture.content[0].visible, + }, + }); + }); + + it('should update store after failed getLayers query', async () => { + mockedAxiosClient.onGet(apiPath.getLayers(1)).reply(HttpStatusCode.NotFound, []); + + const action = await store.dispatch(getLayers(1)); + const { data, loading, error } = store.getState().layers; + + expect(action.type).toBe('vectorMap/getLayers/rejected'); + expect(() => unwrapResult(action)).toThrow( + "Failed to fetch layers: 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({ layers: [], layersVisibility: {} }); + }); + + it('should update store on loading getLayers query', async () => { + mockedAxiosClient.onGet(apiPath.getLayers(1)).reply(HttpStatusCode.Ok, layersFixture); + mockedAxiosClient + .onGet(apiPath.getLayerTexts(1, layersFixture.content[0].id)) + .reply(HttpStatusCode.Ok, layerTextsFixture); + mockedAxiosClient + .onGet(apiPath.getLayerRects(1, layersFixture.content[0].id)) + .reply(HttpStatusCode.Ok, layerRectsFixture); + mockedAxiosClient + .onGet(apiPath.getLayerOvals(1, layersFixture.content[0].id)) + .reply(HttpStatusCode.Ok, layerOvalsFixture); + mockedAxiosClient + .onGet(apiPath.getLayerLines(1, layersFixture.content[0].id)) + .reply(HttpStatusCode.Ok, layerLinesFixture); + + const layersPromise = store.dispatch(getLayers(1)); + + const { data, loading } = store.getState().layers; + expect(data).toEqual({ layers: [], layersVisibility: {} }); + expect(loading).toEqual('pending'); + + layersPromise.then(() => { + const { data: dataPromiseFulfilled, loading: promiseFulfilled } = store.getState().layers; + + expect(dataPromiseFulfilled).toEqual({ + layers: [ + { + details: layersFixture.content[0], + texts: layerTextsFixture.content, + rects: layerRectsFixture.content, + ovals: layerOvalsFixture.content, + lines: layerLinesFixture.content, + }, + ], + layersVisibility: { + [layersFixture.content[0].layerId]: layersFixture.content[0].visible, + }, + }); + expect(promiseFulfilled).toEqual('succeeded'); + }); + }); +}); diff --git a/src/redux/layers/layers.reducers.ts b/src/redux/layers/layers.reducers.ts new file mode 100644 index 0000000000000000000000000000000000000000..ed75a68735150b29e6a2fa96f61360853f5f7160 --- /dev/null +++ b/src/redux/layers/layers.reducers.ts @@ -0,0 +1,30 @@ +/* eslint-disable no-magic-numbers */ +import { ActionReducerMapBuilder, PayloadAction } from '@reduxjs/toolkit'; +import { getLayers } from '@/redux/layers/layers.thunks'; +import { LayersState } from '@/redux/layers/layers.types'; + +export const getLayersReducer = (builder: ActionReducerMapBuilder<LayersState>): void => { + builder.addCase(getLayers.pending, state => { + state.loading = 'pending'; + }); + builder.addCase(getLayers.fulfilled, (state, action) => { + state.data = action.payload || { + layers: [], + layersVisibility: {}, + }; + state.loading = 'succeeded'; + }); + builder.addCase(getLayers.rejected, state => { + state.loading = 'failed'; + }); +}; + +export const setLayerVisibilityReducer = ( + state: LayersState, + action: PayloadAction<{ visible: boolean; layerId: string }>, +): void => { + const { payload } = action; + if (state.data && state.data.layersVisibility[payload.layerId] !== undefined) { + state.data.layersVisibility[payload.layerId] = payload.visible; + } +}; diff --git a/src/redux/layers/layers.selectors.ts b/src/redux/layers/layers.selectors.ts new file mode 100644 index 0000000000000000000000000000000000000000..987ec4ac2855d7e3c1a30857301034ef4dec7daf --- /dev/null +++ b/src/redux/layers/layers.selectors.ts @@ -0,0 +1,12 @@ +import { createSelector } from '@reduxjs/toolkit'; +import { rootSelector } from '@/redux/root/root.selectors'; + +export const layersSelector = createSelector( + rootSelector, + state => state.layers?.data?.layers || [], +); + +export const layersVisibilitySelector = createSelector( + rootSelector, + state => state.layers?.data?.layersVisibility || {}, +); diff --git a/src/redux/layers/layers.slice.ts b/src/redux/layers/layers.slice.ts new file mode 100644 index 0000000000000000000000000000000000000000..47da06b01b1205dcdf422265bcf36e660f311fd3 --- /dev/null +++ b/src/redux/layers/layers.slice.ts @@ -0,0 +1,18 @@ +import { createSlice } from '@reduxjs/toolkit'; +import { LAYERS_STATE_INITIAL_MOCK } from '@/redux/layers/layers.mock'; +import { getLayersReducer, setLayerVisibilityReducer } from '@/redux/layers/layers.reducers'; + +export const layersSlice = createSlice({ + name: 'layers', + initialState: LAYERS_STATE_INITIAL_MOCK, + reducers: { + setLayerVisibility: setLayerVisibilityReducer, + }, + extraReducers: builder => { + getLayersReducer(builder); + }, +}); + +export const { setLayerVisibility } = layersSlice.actions; + +export default layersSlice.reducer; diff --git a/src/redux/layers/layers.thunks.test.ts b/src/redux/layers/layers.thunks.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..a6c0018672be6bcdad62f83e1464ea8f3fbe2d60 --- /dev/null +++ b/src/redux/layers/layers.thunks.test.ts @@ -0,0 +1,68 @@ +/* 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 { LayersState } from '@/redux/layers/layers.types'; +import { getLayers } from '@/redux/layers/layers.thunks'; +import { layersFixture } from '@/models/fixtures/layersFixture'; +import { layerTextsFixture } from '@/models/fixtures/layerTextsFixture'; +import { layerRectsFixture } from '@/models/fixtures/layerRectsFixture'; +import { layerOvalsFixture } from '@/models/fixtures/layerOvalsFixture'; +import { layerLinesFixture } from '@/models/fixtures/layerLinesFixture'; +import layersReducer from './layers.slice'; + +const mockedAxiosClient = mockNetworkNewAPIResponse(); + +describe('layers thunks', () => { + let store = {} as ToolkitStoreWithSingleSlice<LayersState>; + beforeEach(() => { + store = createStoreInstanceUsingSliceReducer('layers', layersReducer); + }); + + describe('getLayers', () => { + it('should return data when data response from API is valid', async () => { + mockedAxiosClient.onGet(apiPath.getLayers(1)).reply(HttpStatusCode.Ok, layersFixture); + mockedAxiosClient + .onGet(apiPath.getLayerTexts(1, layersFixture.content[0].id)) + .reply(HttpStatusCode.Ok, layerTextsFixture); + mockedAxiosClient + .onGet(apiPath.getLayerRects(1, layersFixture.content[0].id)) + .reply(HttpStatusCode.Ok, layerRectsFixture); + mockedAxiosClient + .onGet(apiPath.getLayerOvals(1, layersFixture.content[0].id)) + .reply(HttpStatusCode.Ok, layerOvalsFixture); + mockedAxiosClient + .onGet(apiPath.getLayerLines(1, layersFixture.content[0].id)) + .reply(HttpStatusCode.Ok, layerLinesFixture); + + const { payload } = await store.dispatch(getLayers(1)); + expect(payload).toEqual({ + layers: [ + { + details: layersFixture.content[0], + texts: layerTextsFixture.content, + rects: layerRectsFixture.content, + ovals: layerOvalsFixture.content, + lines: layerLinesFixture.content, + }, + ], + layersVisibility: { + [layersFixture.content[0].layerId]: layersFixture.content[0].visible, + }, + }); + }); + + it('should return undefined when data response from API is not valid ', async () => { + mockedAxiosClient + .onGet(apiPath.getLayers(1)) + .reply(HttpStatusCode.Ok, { randomProperty: 'randomValue' }); + + const { payload } = await store.dispatch(getLayers(1)); + expect(payload).toEqual(undefined); + }); + }); +}); diff --git a/src/redux/layers/layers.thunks.ts b/src/redux/layers/layers.thunks.ts new file mode 100644 index 0000000000000000000000000000000000000000..9aa71e8301dcc714a7e52922d71656275e412887 --- /dev/null +++ b/src/redux/layers/layers.thunks.ts @@ -0,0 +1,65 @@ +import { z } from 'zod'; +import { apiPath } from '@/redux/apiPath'; +import { Layer, Layers } from '@/types/models'; +import { validateDataUsingZodSchema } from '@/utils/validateDataUsingZodSchema'; +import { createAsyncThunk } from '@reduxjs/toolkit'; +import { ThunkConfig } from '@/types/store'; +import { getError } from '@/utils/error-report/getError'; +import { axiosInstanceNewAPI } from '@/services/api/utils/axiosInstance'; +import { layerSchema } from '@/models/layerSchema'; +import { LAYERS_FETCHING_ERROR_PREFIX } from '@/redux/layers/layers.constants'; +import { LayersVisibilitiesState } from '@/redux/layers/layers.types'; +import { layerTextSchema } from '@/models/layerTextSchema'; +import { layerRectSchema } from '@/models/layerRectSchema'; +import { pageableSchema } from '@/models/pageableSchema'; +import { layerOvalSchema } from '@/models/layerOvalSchema'; +import { layerLineSchema } from '@/models/layerLineSchema'; + +export const getLayers = createAsyncThunk<LayersVisibilitiesState | undefined, number, ThunkConfig>( + 'vectorMap/getLayers', + async (modelId: number) => { + try { + const { data } = await axiosInstanceNewAPI.get<Layers>(apiPath.getLayers(modelId)); + const isDataValid = validateDataUsingZodSchema(data, pageableSchema(layerSchema)); + if (!isDataValid) { + return undefined; + } + let layers = await Promise.all( + data.content.map(async (layer: Layer) => { + const [textsResponse, rectsResponse, ovalsResponse, linesResponse] = await Promise.all([ + axiosInstanceNewAPI.get(apiPath.getLayerTexts(modelId, layer.id)), + axiosInstanceNewAPI.get(apiPath.getLayerRects(modelId, layer.id)), + axiosInstanceNewAPI.get(apiPath.getLayerOvals(modelId, layer.id)), + axiosInstanceNewAPI.get(apiPath.getLayerLines(modelId, layer.id)), + ]); + + return { + details: layer, + texts: textsResponse.data.content, + rects: rectsResponse.data.content, + ovals: ovalsResponse.data.content, + lines: linesResponse.data.content, + }; + }), + ); + layers = layers.filter(layer => { + return ( + z.array(layerTextSchema).safeParse(layer.texts).success && + z.array(layerRectSchema).safeParse(layer.rects).success && + z.array(layerOvalSchema).safeParse(layer.ovals).success && + z.array(layerLineSchema).safeParse(layer.lines).success + ); + }); + const layersVisibility = layers.reduce((acc: { [key: string]: boolean }, layer) => { + acc[layer.details.layerId] = layer.details.visible; + return acc; + }, {}); + return { + layers, + layersVisibility, + }; + } catch (error) { + return Promise.reject(getError({ error, prefix: LAYERS_FETCHING_ERROR_PREFIX })); + } + }, +); diff --git a/src/redux/layers/layers.types.ts b/src/redux/layers/layers.types.ts new file mode 100644 index 0000000000000000000000000000000000000000..636376906b991185f851a8ec1b1550b7dcdab440 --- /dev/null +++ b/src/redux/layers/layers.types.ts @@ -0,0 +1,21 @@ +import { FetchDataState } from '@/types/fetchDataState'; +import { Layer, LayerLine, LayerOval, LayerRect, LayerText } from '@/types/models'; + +export type LayerState = { + details: Layer; + texts: LayerText[]; + rects: LayerRect[]; + ovals: LayerOval[]; + lines: LayerLine[]; +}; + +export type LayerVisibilityState = { + [key: string]: boolean; +}; + +export type LayersVisibilitiesState = { + layersVisibility: LayerVisibilityState; + layers: LayerState[]; +}; + +export type LayersState = FetchDataState<LayersVisibilitiesState>; diff --git a/src/redux/modelElements/modelElements.thunks.ts b/src/redux/modelElements/modelElements.thunks.ts index 7898db9a04b263622c80cd3192c7ec4417935892..2c7e2f25a2b850d845d923c99e191a1f29b60b1c 100644 --- a/src/redux/modelElements/modelElements.thunks.ts +++ b/src/redux/modelElements/modelElements.thunks.ts @@ -4,9 +4,10 @@ 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'; +import { modelElementSchema } from '@/models/modelElementSchema'; +import { pageableSchema } from '@/models/pageableSchema'; export const getModelElements = createAsyncThunk<ModelElements | undefined, number, ThunkConfig>( 'vectorMap/getModelElements', @@ -15,7 +16,10 @@ export const getModelElements = createAsyncThunk<ModelElements | undefined, numb const response = await axiosInstanceNewAPI.get<ModelElements>( apiPath.getModelElements(modelId), ); - const isDataValid = validateDataUsingZodSchema(response.data, modelElementsSchema); + const isDataValid = validateDataUsingZodSchema( + response.data, + pageableSchema(modelElementSchema), + ); return isDataValid ? response.data : undefined; } catch (error) { return Promise.reject(getError({ error, prefix: MODEL_ELEMENTS_FETCHING_ERROR_PREFIX })); diff --git a/src/redux/root/init.thunks.ts b/src/redux/root/init.thunks.ts index 4e161b6e786697a53f445b4b880fcafab26c3b7b..34f7765ecc3687074d2e1bc31c9b364fdb757e5d 100644 --- a/src/redux/root/init.thunks.ts +++ b/src/redux/root/init.thunks.ts @@ -15,7 +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 { getArrowTypes, getLineTypes, getShapes } from '@/redux/shapes/shapes.thunks'; import { getAllBackgroundsByProjectId } from '../backgrounds/backgrounds.thunks'; import { getConfiguration, getConfigurationOptions } from '../configuration/configuration.thunks'; import { @@ -60,8 +60,9 @@ export const fetchInitialAppData = createAsyncThunk< dispatch(getAllPublicOverlaysByProjectId(PROJECT_ID)), dispatch(getModels()), dispatch(getShapes()), + dispatch(getLineTypes()), + dispatch(getArrowTypes()), ]); - if (queryData.pluginsId) { await dispatch( getInitPlugins({ diff --git a/src/redux/root/root.fixtures.ts b/src/redux/root/root.fixtures.ts index 7800ab1931b4009204cc14282bbe5fdaee7e8bf0..8b1cec49d6e6fe6104c2487e898f080f322e2a1a 100644 --- a/src/redux/root/root.fixtures.ts +++ b/src/redux/root/root.fixtures.ts @@ -3,8 +3,9 @@ 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 { SHAPES_STATE_INITIAL_MOCK } from '@/redux/shapes/shapes.mock'; import { MODEL_ELEMENTS_INITIAL_STATE_MOCK } from '@/redux/modelElements/modelElements.mock'; +import { LAYERS_STATE_INITIAL_MOCK } from '@/redux/layers/layers.mock'; import { 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'; @@ -38,12 +39,13 @@ 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, + shapes: 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, + layers: LAYERS_STATE_INITIAL_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 index 04d8b1caed90ca32d964ad2a87e3de34c30e8938..18da4b1b0575dbe776b1c3b8e5e660a2b70e1ce1 100644 --- a/src/redux/shapes/shapes.constants.ts +++ b/src/redux/shapes/shapes.constants.ts @@ -1 +1,5 @@ export const SHAPES_FETCHING_ERROR_PREFIX = 'Failed to fetch shapes'; + +export const LINE_TYPES_FETCHING_ERROR_PREFIX = 'Failed to fetch line types'; + +export const ARROW_TYPES_FETCHING_ERROR_PREFIX = 'Failed to fetch arrow types'; diff --git a/src/redux/shapes/shapes.mock.ts b/src/redux/shapes/shapes.mock.ts index d9ee5018d64fe273b524f309299c1c8765666874..b1ad3a5eaa627abffc1793bbb41735afa9164f6a 100644 --- a/src/redux/shapes/shapes.mock.ts +++ b/src/redux/shapes/shapes.mock.ts @@ -1,8 +1,20 @@ import { DEFAULT_ERROR } from '@/constants/errors'; -import { BioShapesState } from '@/redux/shapes/shapes.types'; +import { ShapesState } from '@/redux/shapes/shapes.types'; -export const BIO_SHAPES_STATE_INITIAL_MOCK: BioShapesState = { - data: [], - loading: 'idle', - error: DEFAULT_ERROR, +export const SHAPES_STATE_INITIAL_MOCK: ShapesState = { + bioShapesState: { + data: [], + loading: 'idle', + error: DEFAULT_ERROR, + }, + lineTypesState: { + data: [], + loading: 'idle', + error: DEFAULT_ERROR, + }, + arrowTypesState: { + data: [], + loading: 'idle', + error: DEFAULT_ERROR, + }, }; diff --git a/src/redux/shapes/shapes.reducers.test.ts b/src/redux/shapes/shapes.reducers.test.ts index 41b8df566da00a148a623d3627e75f618a2c9512..9eab2b219d230f81f163b8464c5813f93a503005 100644 --- a/src/redux/shapes/shapes.reducers.test.ts +++ b/src/redux/shapes/shapes.reducers.test.ts @@ -6,21 +6,20 @@ import { import { mockNetworkNewAPIResponse } from '@/utils/mockNetworkResponse'; import { HttpStatusCode } from 'axios'; import { unwrapResult } from '@reduxjs/toolkit'; -import { shapesFixture } from '@/models/fixtures/shapesFixture'; +import { bioShapesFixture } from '@/models/fixtures/bioShapesFixture'; +import { SHAPES_STATE_INITIAL_MOCK } from '@/redux/shapes/shapes.mock'; +import { lineTypesFixture } from '@/models/fixtures/lineTypesFixture'; +import { arrowTypesFixture } from '@/models/fixtures/arrowTypesFixture'; import shapesReducer from './shapes.slice'; -import { getShapes } from './shapes.thunks'; -import { BioShapesState } from './shapes.types'; +import { getArrowTypes, getLineTypes, getShapes } from './shapes.thunks'; +import { ShapesState } from './shapes.types'; const mockedAxiosClient = mockNetworkNewAPIResponse(); -const INITIAL_STATE: BioShapesState = { - data: [], - loading: 'idle', - error: { name: '', message: '' }, -}; +const INITIAL_STATE: ShapesState = SHAPES_STATE_INITIAL_MOCK; describe('shapes reducer', () => { - let store = {} as ToolkitStoreWithSingleSlice<BioShapesState>; + let store = {} as ToolkitStoreWithSingleSlice<ShapesState>; beforeEach(() => { store = createStoreInstanceUsingSliceReducer('shapes', shapesReducer); }); @@ -31,22 +30,44 @@ describe('shapes reducer', () => { expect(shapesReducer(undefined, action)).toEqual(INITIAL_STATE); }); - it('should update store after succesfull getShapes query', async () => { - mockedAxiosClient.onGet(apiPath.getShapes()).reply(HttpStatusCode.Ok, shapesFixture); + it('should update store after successful getShapes query', async () => { + mockedAxiosClient.onGet(apiPath.getShapes()).reply(HttpStatusCode.Ok, bioShapesFixture); const { type } = await store.dispatch(getShapes()); - const { data, loading, error } = store.getState().shapes; + const { data, loading, error } = store.getState().shapes.bioShapesState; expect(type).toBe('vectorMap/getShapes/fulfilled'); expect(loading).toEqual('succeeded'); expect(error).toEqual({ message: '', name: '' }); - expect(data).toEqual(shapesFixture); + expect(data).toEqual(bioShapesFixture); + }); + + it('should update store after successful getLineTypes query', async () => { + mockedAxiosClient.onGet(apiPath.getLineTypes()).reply(HttpStatusCode.Ok, lineTypesFixture); + + const { type } = await store.dispatch(getLineTypes()); + const { data, loading, error } = store.getState().shapes.lineTypesState; + expect(type).toBe('vectorMap/getLineTypes/fulfilled'); + expect(loading).toEqual('succeeded'); + expect(error).toEqual({ message: '', name: '' }); + expect(data).toEqual(lineTypesFixture); + }); + + it('should update store after successful getArrowTypes query', async () => { + mockedAxiosClient.onGet(apiPath.getArrowTypes()).reply(HttpStatusCode.Ok, arrowTypesFixture); + + const { type } = await store.dispatch(getArrowTypes()); + const { data, loading, error } = store.getState().shapes.arrowTypesState; + expect(type).toBe('vectorMap/getArrowTypes/fulfilled'); + expect(loading).toEqual('succeeded'); + expect(error).toEqual({ message: '', name: '' }); + expect(data).toEqual(arrowTypesFixture); }); 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; + const { data, loading, error } = store.getState().shapes.bioShapesState; expect(action.type).toBe('vectorMap/getShapes/rejected'); expect(() => unwrapResult(action)).toThrow( @@ -57,19 +78,86 @@ describe('shapes reducer', () => { expect(data).toEqual([]); }); + it('should update store after failed getLineTypes query', async () => { + mockedAxiosClient.onGet(apiPath.getLineTypes()).reply(HttpStatusCode.NotFound, []); + + const action = await store.dispatch(getLineTypes()); + const { data, loading, error } = store.getState().shapes.lineTypesState; + + expect(action.type).toBe('vectorMap/getLineTypes/rejected'); + expect(() => unwrapResult(action)).toThrow( + "Failed to fetch line types: 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 after failed getArrowTypes query', async () => { + mockedAxiosClient.onGet(apiPath.getArrowTypes()).reply(HttpStatusCode.NotFound, []); + + const action = await store.dispatch(getArrowTypes()); + const { data, loading, error } = store.getState().shapes.arrowTypesState; + + expect(action.type).toBe('vectorMap/getArrowTypes/rejected'); + expect(() => unwrapResult(action)).toThrow( + "Failed to fetch arrow types: 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); + mockedAxiosClient.onGet(apiPath.getShapes()).reply(HttpStatusCode.Ok, bioShapesFixture); const shapesPromise = store.dispatch(getShapes()); - const { data, loading } = store.getState().shapes; + const { data, loading } = store.getState().shapes.bioShapesState; expect(data).toEqual([]); expect(loading).toEqual('pending'); shapesPromise.then(() => { - const { data: dataPromiseFulfilled, loading: promiseFulfilled } = store.getState().shapes; + const { data: dataPromiseFulfilled, loading: promiseFulfilled } = + store.getState().shapes.bioShapesState; + + expect(dataPromiseFulfilled).toEqual(bioShapesFixture); + expect(promiseFulfilled).toEqual('succeeded'); + }); + }); + + it('should update store on loading getLineTypes query', async () => { + mockedAxiosClient.onGet(apiPath.getLineTypes()).reply(HttpStatusCode.Ok, lineTypesFixture); + + const lineTypesPromise = store.dispatch(getLineTypes()); + + const { data, loading } = store.getState().shapes.lineTypesState; + expect(data).toEqual([]); + expect(loading).toEqual('pending'); + + lineTypesPromise.then(() => { + const { data: dataPromiseFulfilled, loading: promiseFulfilled } = + store.getState().shapes.lineTypesState; + + expect(dataPromiseFulfilled).toEqual(lineTypesFixture); + expect(promiseFulfilled).toEqual('succeeded'); + }); + }); + + it('should update store on loading getArrowTypes query', async () => { + mockedAxiosClient.onGet(apiPath.getArrowTypes()).reply(HttpStatusCode.Ok, arrowTypesFixture); + + const arrowTypesPromise = store.dispatch(getArrowTypes()); + + const { data, loading } = store.getState().shapes.arrowTypesState; + expect(data).toEqual([]); + expect(loading).toEqual('pending'); + + arrowTypesPromise.then(() => { + const { data: dataPromiseFulfilled, loading: promiseFulfilled } = + store.getState().shapes.arrowTypesState; - expect(dataPromiseFulfilled).toEqual(shapesFixture); + expect(dataPromiseFulfilled).toEqual(arrowTypesFixture); expect(promiseFulfilled).toEqual('succeeded'); }); }); diff --git a/src/redux/shapes/shapes.reducers.ts b/src/redux/shapes/shapes.reducers.ts index 72ea84f0fba44c9e14aa8b0076496be48cd6fe5f..525cde54ca6b47cf1e06cf7af8f3b623e8c4c8c0 100644 --- a/src/redux/shapes/shapes.reducers.ts +++ b/src/redux/shapes/shapes.reducers.ts @@ -1,16 +1,42 @@ import { ActionReducerMapBuilder } from '@reduxjs/toolkit'; -import { BioShapesState } from '@/redux/shapes/shapes.types'; -import { getShapes } from '@/redux/shapes/shapes.thunks'; +import { ShapesState } from '@/redux/shapes/shapes.types'; +import { getArrowTypes, getLineTypes, getShapes } from '@/redux/shapes/shapes.thunks'; -export const getShapesReducer = (builder: ActionReducerMapBuilder<BioShapesState>): void => { +export const getShapesReducer = (builder: ActionReducerMapBuilder<ShapesState>): void => { builder.addCase(getShapes.pending, state => { - state.loading = 'pending'; + state.bioShapesState.loading = 'pending'; }); builder.addCase(getShapes.fulfilled, (state, action) => { - state.data = action.payload || []; - state.loading = 'succeeded'; + state.bioShapesState.data = action.payload || []; + state.bioShapesState.loading = 'succeeded'; }); builder.addCase(getShapes.rejected, state => { - state.loading = 'failed'; + state.bioShapesState.loading = 'failed'; + }); +}; + +export const getLineTypesReducer = (builder: ActionReducerMapBuilder<ShapesState>): void => { + builder.addCase(getLineTypes.pending, state => { + state.lineTypesState.loading = 'pending'; + }); + builder.addCase(getLineTypes.fulfilled, (state, action) => { + state.lineTypesState.data = action.payload || []; + state.lineTypesState.loading = 'succeeded'; + }); + builder.addCase(getLineTypes.rejected, state => { + state.lineTypesState.loading = 'failed'; + }); +}; + +export const getArrowTypesReducer = (builder: ActionReducerMapBuilder<ShapesState>): void => { + builder.addCase(getArrowTypes.pending, state => { + state.arrowTypesState.loading = 'pending'; + }); + builder.addCase(getArrowTypes.fulfilled, (state, action) => { + state.arrowTypesState.data = action.payload || []; + state.arrowTypesState.loading = 'succeeded'; + }); + builder.addCase(getArrowTypes.rejected, state => { + state.arrowTypesState.loading = 'failed'; }); }; diff --git a/src/redux/shapes/shapes.selectors.ts b/src/redux/shapes/shapes.selectors.ts index 6faab0fe791516a689f72b1b8be01d12298b8220..35569752e5b3dbd9a26ccac4b22210dff147b7f6 100644 --- a/src/redux/shapes/shapes.selectors.ts +++ b/src/redux/shapes/shapes.selectors.ts @@ -3,7 +3,23 @@ import { rootSelector } from '@/redux/root/root.selectors'; export const shapesSelector = createSelector(rootSelector, state => state.shapes); +export const bioShapesSelector = createSelector( + shapesSelector, + shapes => shapes.bioShapesState.data, +); + +export const lineTypesSelector = createSelector( + shapesSelector, + shapes => shapes.lineTypesState.data, +); + +export const arrowTypesSelector = createSelector( + shapesSelector, + shapes => shapes.arrowTypesState.data, +); + export const shapeBySBOSelector = createSelector( [shapesSelector, (_state, shapeSBO: string): string => shapeSBO], - (shapes, shapeSBO) => (shapes?.data || []).find(({ sboTerm }) => sboTerm === shapeSBO), + (shapes, shapeSBO) => + (shapes?.bioShapesState.data || []).find(({ sboTerm }) => sboTerm === shapeSBO), ); diff --git a/src/redux/shapes/shapes.slice.ts b/src/redux/shapes/shapes.slice.ts index 06c08ec04e1a92e70d963c584b47154e013b15a3..7fcecfa0678aac6cb76db024c05cbe0ced0ca638 100644 --- a/src/redux/shapes/shapes.slice.ts +++ b/src/redux/shapes/shapes.slice.ts @@ -1,19 +1,19 @@ -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: '' }, -}; +import { + getArrowTypesReducer, + getLineTypesReducer, + getShapesReducer, +} from '@/redux/shapes/shapes.reducers'; +import { SHAPES_STATE_INITIAL_MOCK } from '@/redux/shapes/shapes.mock'; export const shapesSlice = createSlice({ name: 'shapes', - initialState, + initialState: SHAPES_STATE_INITIAL_MOCK, reducers: {}, extraReducers: builder => { getShapesReducer(builder); + getLineTypesReducer(builder); + getArrowTypesReducer(builder); }, }); diff --git a/src/redux/shapes/shapes.thunks.test.ts b/src/redux/shapes/shapes.thunks.test.ts index d4cb142cccf4f31fbb1c82b95c8ea36d6d4119cc..7b9980bf1679f1b5aac404a44e655db53b28f1d2 100644 --- a/src/redux/shapes/shapes.thunks.test.ts +++ b/src/redux/shapes/shapes.thunks.test.ts @@ -5,25 +5,29 @@ import { } 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 { bioShapesFixture } from '@/models/fixtures/bioShapesFixture'; +import { ShapesState } from '@/redux/shapes/shapes.types'; +import { lineTypesFixture } from '@/models/fixtures/lineTypesFixture'; +import { arrowTypesFixture } from '@/models/fixtures/arrowTypesFixture'; import shapesReducer from './shapes.slice'; -import { getShapes } from './shapes.thunks'; +import { getArrowTypes, getLineTypes, getShapes } from './shapes.thunks'; const mockedAxiosClient = mockNetworkNewAPIResponse(); describe('shapes thunks', () => { - let store = {} as ToolkitStoreWithSingleSlice<BioShapesState>; + let store = {} as ToolkitStoreWithSingleSlice<ShapesState>; 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); + mockedAxiosClient.onGet(apiPath.getShapes()).reply(HttpStatusCode.Ok, bioShapesFixture); const { payload } = await store.dispatch(getShapes()); - expect(payload).toEqual(shapesFixture); + expect(payload).toEqual(bioShapesFixture); }); + it('should return undefined when data response from API is not valid ', async () => { mockedAxiosClient .onGet(apiPath.getShapes()) @@ -33,4 +37,40 @@ describe('shapes thunks', () => { expect(payload).toEqual(undefined); }); }); + + describe('getLineTypes', () => { + it('should return data when data response from API is valid', async () => { + mockedAxiosClient.onGet(apiPath.getLineTypes()).reply(HttpStatusCode.Ok, lineTypesFixture); + + const { payload } = await store.dispatch(getLineTypes()); + expect(payload).toEqual(lineTypesFixture); + }); + + it('should return undefined when data response from API is not valid ', async () => { + mockedAxiosClient + .onGet(apiPath.getLineTypes()) + .reply(HttpStatusCode.Ok, { randomProperty: 'randomValue' }); + + const { payload } = await store.dispatch(getLineTypes()); + expect(payload).toEqual(undefined); + }); + }); + + describe('getArrowTypes', () => { + it('should return data when data response from API is valid', async () => { + mockedAxiosClient.onGet(apiPath.getArrowTypes()).reply(HttpStatusCode.Ok, arrowTypesFixture); + + const { payload } = await store.dispatch(getArrowTypes()); + expect(payload).toEqual(arrowTypesFixture); + }); + + it('should return undefined when data response from API is not valid ', async () => { + mockedAxiosClient + .onGet(apiPath.getArrowTypes()) + .reply(HttpStatusCode.Ok, { randomProperty: 'randomValue' }); + + const { payload } = await store.dispatch(getArrowTypes()); + expect(payload).toEqual(undefined); + }); + }); }); diff --git a/src/redux/shapes/shapes.thunks.ts b/src/redux/shapes/shapes.thunks.ts index 2bb0b7ecf5d6d33c63eb60b946a1d1e7ecc58822..f882d2fb6f0f64acc5b703ed77309cf1a25f0f1d 100644 --- a/src/redux/shapes/shapes.thunks.ts +++ b/src/redux/shapes/shapes.thunks.ts @@ -1,24 +1,58 @@ import { bioShapeSchema } from '@/models/bioShapeSchema'; import { apiPath } from '@/redux/apiPath'; -import { BioShape } from '@/types/models'; +import { ArrowType, BioShape, LineType } 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 { + ARROW_TYPES_FETCHING_ERROR_PREFIX, + LINE_TYPES_FETCHING_ERROR_PREFIX, + SHAPES_FETCHING_ERROR_PREFIX, +} from '@/redux/shapes/shapes.constants'; import { axiosInstanceNewAPI } from '@/services/api/utils/axiosInstance'; +import { lineTypeSchema } from '@/models/lineTypeSchema'; +import { arrowTypeSchema } from '@/models/arrowTypeSchema'; 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)); + const { data } = await axiosInstanceNewAPI.get<BioShape[]>(apiPath.getShapes()); + const isDataValid = validateDataUsingZodSchema(data, z.array(bioShapeSchema)); - return isDataValid ? response.data : undefined; + return isDataValid ? data : undefined; } catch (error) { return Promise.reject(getError({ error, prefix: SHAPES_FETCHING_ERROR_PREFIX })); } }, ); + +export const getLineTypes = createAsyncThunk<LineType[] | undefined, void, ThunkConfig>( + 'vectorMap/getLineTypes', + async () => { + try { + const { data } = await axiosInstanceNewAPI.get<LineType[]>(apiPath.getLineTypes()); + const isDataValid = validateDataUsingZodSchema(data, z.array(lineTypeSchema)); + + return isDataValid ? data : undefined; + } catch (error) { + return Promise.reject(getError({ error, prefix: LINE_TYPES_FETCHING_ERROR_PREFIX })); + } + }, +); + +export const getArrowTypes = createAsyncThunk<ArrowType[] | undefined, void, ThunkConfig>( + 'vectorMap/getArrowTypes', + async () => { + try { + const { data } = await axiosInstanceNewAPI.get<ArrowType[]>(apiPath.getArrowTypes()); + const isDataValid = validateDataUsingZodSchema(data, z.array(arrowTypeSchema)); + + return isDataValid ? data : undefined; + } catch (error) { + return Promise.reject(getError({ error, prefix: ARROW_TYPES_FETCHING_ERROR_PREFIX })); + } + }, +); diff --git a/src/redux/shapes/shapes.types.ts b/src/redux/shapes/shapes.types.ts index e4b006f1906eb3ef0003fe127c6f61188ba907fd..e597d194d1ebef945cfc49da1ca881dd9dc62664 100644 --- a/src/redux/shapes/shapes.types.ts +++ b/src/redux/shapes/shapes.types.ts @@ -1,4 +1,14 @@ import { FetchDataState } from '@/types/fetchDataState'; -import { BioShape } from '@/types/models'; +import { ArrowType, BioShape, LineType } from '@/types/models'; + +export type LineTypesState = FetchDataState<LineType[], []>; + +export type ArrowTypesState = FetchDataState<ArrowType[], []>; export type BioShapesState = FetchDataState<BioShape[], []>; + +export type ShapesState = { + lineTypesState: LineTypesState; + arrowTypesState: ArrowTypesState; + bioShapesState: BioShapesState; +}; diff --git a/src/redux/store.ts b/src/redux/store.ts index 2df3467569d8cc85a234814cc001060be1ca5c99..f6b8ad65c8648756943c421cc42fd796f75e6bd7 100644 --- a/src/redux/store.ts +++ b/src/redux/store.ts @@ -12,6 +12,7 @@ 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 layersReducer from '@/redux/layers/layers.slice'; import oauthReducer from '@/redux/oauth/oauth.slice'; import overlayBioEntityReducer from '@/redux/overlayBioEntity/overlayBioEntity.slice'; import overlaysReducer from '@/redux/overlays/overlays.slice'; @@ -63,6 +64,7 @@ export const reducers = { models: modelsReducer, shapes: shapesReducer, modelElements: modelElementsReducer, + layers: layersReducer, reactions: reactionsReducer, contextMenu: contextMenuReducer, cookieBanner: cookieBannerReducer, diff --git a/src/shared/Icon/Icon.component.tsx b/src/shared/Icon/Icon.component.tsx index b74341317ff42714f9dac5e71c159e040ccb7443..b05d1f5704360b583f6d716d55db7c26081dcb03 100644 --- a/src/shared/Icon/Icon.component.tsx +++ b/src/shared/Icon/Icon.component.tsx @@ -7,6 +7,7 @@ import { ChevronUpIcon } from '@/shared/Icon/Icons/ChevronUpIcon'; import { CloseIcon } from '@/shared/Icon/Icons/CloseIcon'; import { DotsIcon } from '@/shared/Icon/Icons/DotsIcon'; import { ExportIcon } from '@/shared/Icon/Icons/ExportIcon'; +import { LayersIcon } from '@/shared/Icon/Icons/LayersIcon'; import { InfoIcon } from '@/shared/Icon/Icons/InfoIcon'; import { LegendIcon } from '@/shared/Icon/Icons/LegendIcon'; import { PageIcon } from '@/shared/Icon/Icons/PageIcon'; @@ -40,6 +41,7 @@ const icons: Record<IconTypes, IconComponentType> = { dots: DotsIcon, admin: AdminIcon, export: ExportIcon, + layers: LayersIcon, info: InfoIcon, legend: LegendIcon, page: PageIcon, diff --git a/src/shared/Icon/Icons/LayersIcon.tsx b/src/shared/Icon/Icons/LayersIcon.tsx new file mode 100644 index 0000000000000000000000000000000000000000..bebcbea3e67f7c083ab14369650a45523340de1b --- /dev/null +++ b/src/shared/Icon/Icons/LayersIcon.tsx @@ -0,0 +1,39 @@ +interface LayersIconProps { + className?: string; +} + +export const LayersIcon = ({ className }: LayersIconProps): JSX.Element => ( + <svg + width="20" + height="20" + viewBox="0 0 24 24" + fill="none" + className={className} + xmlns="http://www.w3.org/2000/svg" + > + <path + d="M12 4L4 8.5L12 13L20 8.5L12 4Z" + stroke="black" + strokeWidth="1" + strokeLinecap="round" + strokeLinejoin="round" + fill="none" + /> + <path + d="M4 12.5L12 17L20 12.5" + stroke="black" + strokeWidth="1" + strokeLinecap="round" + strokeLinejoin="round" + fill="none" + /> + <path + d="M4 16.5L12 21L20 16.5" + stroke="black" + strokeWidth="1" + strokeLinecap="round" + strokeLinejoin="round" + fill="none" + /> + </svg> +); diff --git a/src/types/drawerName.ts b/src/types/drawerName.ts index d6f7961005116de9743cb35d7b2c7797de8aca3c..4a7f5114bd6949063557d0098d406b1b0a07bb2a 100644 --- a/src/types/drawerName.ts +++ b/src/types/drawerName.ts @@ -10,4 +10,5 @@ export type DrawerName = | 'overlays' | 'bio-entity' | 'comment' - | 'available-plugins'; + | 'available-plugins' + | 'layers'; diff --git a/src/types/iconTypes.ts b/src/types/iconTypes.ts index 07442161b637612612cfbf6aeb787733b18ebaa0..6feeea09e29c8d0c4a0eb0c46df5d514cb0dd67e 100644 --- a/src/types/iconTypes.ts +++ b/src/types/iconTypes.ts @@ -8,6 +8,7 @@ export type IconTypes = | 'dots' | 'admin' | 'export' + | 'layers' | 'info' | 'legend' | 'page' diff --git a/src/types/models.ts b/src/types/models.ts index e42c235b929f6e80a25975e896d38179d20d9311..13dd6e2892a7b1d5f4c7b7790b56284d73172e74 100644 --- a/src/types/models.ts +++ b/src/types/models.ts @@ -67,8 +67,17 @@ 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'; import { modelElementModificationSchema } from '@/models/modelElementModificationSchema'; +import { lineTypeSchema } from '@/models/lineTypeSchema'; +import { layerSchema } from '@/models/layerSchema'; +import { layerTextSchema } from '@/models/layerTextSchema'; +import { layerRectSchema } from '@/models/layerRectSchema'; +import { pageableSchema } from '@/models/pageableSchema'; +import { modelElementSchema } from '@/models/modelElementSchema'; +import { layerOvalSchema } from '@/models/layerOvalSchema'; +import { layerLineSchema } from '@/models/layerLineSchema'; +import { arrowTypeSchema } from '@/models/arrowTypeSchema'; +import { arrowSchema } from '@/models/arrowSchema'; export type Project = z.infer<typeof projectSchema>; export type OverviewImageView = z.infer<typeof overviewImageView>; @@ -77,7 +86,19 @@ 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 LineType = z.infer<typeof lineTypeSchema>; +export type ArrowType = z.infer<typeof arrowTypeSchema>; +const layersSchema = pageableSchema(layerSchema); +export type Layers = z.infer<typeof layersSchema>; +export type Layer = z.infer<typeof layerSchema>; +export type LayerText = z.infer<typeof layerTextSchema>; +export type LayerRect = z.infer<typeof layerRectSchema>; +export type LayerOval = z.infer<typeof layerOvalSchema>; +export type LayerLine = z.infer<typeof layerLineSchema>; +export type Arrow = z.infer<typeof arrowSchema>; +const modelElementsSchema = pageableSchema(modelElementSchema); export type ModelElements = z.infer<typeof modelElementsSchema>; +export type ModelElement = z.infer<typeof modelElementSchema>; export type Shape = z.infer<typeof shapeSchema>; export type Modification = z.infer<typeof modelElementModificationSchema>; export type MapOverlay = z.infer<typeof mapOverlay>;