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/reactionsLayer/useOlMapReactionsLayer.ts b/src/components/Map/MapViewer/MapViewerVector/utils/config/reactionsLayer/useOlMapReactionsLayer.ts index 60d5330aab6cdb1c12abe8685333ad7a20cc1bdb..6e52b495d0a18d95ad94e2cbe5a104b3e7d48178 100644 --- a/src/components/Map/MapViewer/MapViewerVector/utils/config/reactionsLayer/useOlMapReactionsLayer.ts +++ b/src/components/Map/MapViewer/MapViewerVector/utils/config/reactionsLayer/useOlMapReactionsLayer.ts @@ -16,6 +16,8 @@ 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/CompartmentSquare'; +import CompartmentCircle from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/CompartmentCircle'; export const useOlMapReactionsLayer = ({ mapInstance, @@ -33,10 +35,10 @@ export const useOlMapReactionsLayer = ({ const shapes = useSelector(bioShapesSelector); const lineTypes = useSelector(lineTypesSelector); - const elements: Array<MapElement> = useMemo(() => { + const elements: Array<MapElement | CompartmentCircle | CompartmentSquare> = useMemo(() => { if (!modelElements || !shapes) return []; - const validElements: Array<MapElement> = []; + const validElements: Array<MapElement | CompartmentCircle | CompartmentSquare> = []; modelElements.content.forEach(element => { const shape = shapes.find(bioShape => bioShape.sboTerm === element.sboTerm); if (shape) { @@ -60,6 +62,7 @@ export const useOlMapReactionsLayer = ({ nameVerticalAlign: element.nameVerticalAlign as VerticalAlign, nameHorizontalAlign: element.nameHorizontalAlign as HorizontalAlign, text: element.name, + fontSize: element.fontSize, pointToProjection, mapInstance, modifications: element.modificationResidues, @@ -67,6 +70,35 @@ export const useOlMapReactionsLayer = ({ 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 validElements; diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/BaseMultiPolygon.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/BaseMultiPolygon.ts new file mode 100644 index 0000000000000000000000000000000000000000..62ed6ea9181ddf2187a3c32e8ac1fd5e91324c2e --- /dev/null +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/BaseMultiPolygon.ts @@ -0,0 +1,178 @@ +/* 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 getText from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/getText'; +import { rgbToHex } from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/rgbToHex'; + +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, textStyle } = getText({ + text: this.text, + fontSize: this.fontSize, + x: this.nameX, + y: this.nameY, + width: this.nameWidth, + height: this.nameHeight, + color: rgbToHex(this.fontColor), + zIndex: this.zIndex, + verticalAlign: this.nameVerticalAlign, + horizontalAlign: this.nameHorizontalAlign, + pointToProjection: this.pointToProjection, + }); + this.styles.push(textStyle); + this.polygonsTexts.push(this.text); + this.polygons.push(new Polygon([[textCoords, textCoords]])); + } + } + + 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/Compartment.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/Compartment.ts new file mode 100644 index 0000000000000000000000000000000000000000..deb5a5c560f97199afa9600bc6628fcfea347d83 --- /dev/null +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/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/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/getFill'; +import { rgbToHex } from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/rgbToHex'; +import getStroke from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/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/CompartmentCircle.test.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/CompartmentCircle.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..cfc57c56fcbc043f6fcdb0d4ebfcf1f1e9a2185c --- /dev/null +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/CompartmentCircle.test.ts @@ -0,0 +1,118 @@ +/* eslint-disable no-magic-numbers */ +import { Feature, Map } from 'ol'; +import { Fill, Style, Text } from 'ol/style'; +import { Polygon, MultiPolygon } from 'ol/geom'; +import getText from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/getText'; +import getMultiPolygon from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/getMultiPolygon'; +import getStroke from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/getStroke'; +import getFill from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/getFill'; +import { rgbToHex } from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/rgbToHex'; +import 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/CompartmentCircle'; +import getEllipseCoords from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/getEllipseCoords'; + +jest.mock('./getText'); +jest.mock('./getMultiPolygon'); +jest.mock('./getStroke'); +jest.mock('./getEllipseCoords'); +jest.mock('./getFill'); +jest.mock('./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, + }; + + (getText as jest.Mock).mockReturnValue({ + textCoords: [0, 0], + textStyle: new Style({ + text: new Text({ + text: props.text, + font: `bold ${props.fontSize}px Arial`, + fill: new Fill({ + color: '#000', + }), + placement: 'point', + textAlign: 'center', + textBaseline: 'middle', + }), + }), + }); + (getMultiPolygon as jest.Mock).mockReturnValue([ + new Polygon([ + [ + [0, 0], + [1, 1], + [2, 2], + ], + ]), + ]); + (getStroke as jest.Mock).mockReturnValue(new Style()); + (getFill as jest.Mock).mockReturnValue(new Style()); + (rgbToHex as jest.Mock).mockReturnValue('#FFFFFF'); + (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/CompartmentCircle.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/CompartmentCircle.ts new file mode 100644 index 0000000000000000000000000000000000000000..f5f3c51a0cf0eac641fa8666f5d48170b12962d3 --- /dev/null +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/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/getEllipseCoords'; +import Compartment from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/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/CompartmentSquare.test.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/CompartmentSquare.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..d9913b382f90c275bcaeb71ccf3b10262fe3582c --- /dev/null +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/CompartmentSquare.test.ts @@ -0,0 +1,118 @@ +/* eslint-disable no-magic-numbers */ +import { Feature, Map } from 'ol'; +import { Fill, Style, Text } from 'ol/style'; +import { Polygon, MultiPolygon } from 'ol/geom'; +import getText from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/getText'; +import getMultiPolygon from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/getMultiPolygon'; +import getStroke from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/getStroke'; +import getFill from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/getFill'; +import { rgbToHex } from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/rgbToHex'; +import 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/CompartmentSquare'; +import getPolygonCoords from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/getPolygonCoords'; + +jest.mock('./getText'); +jest.mock('./getMultiPolygon'); +jest.mock('./getStroke'); +jest.mock('./getPolygonCoords'); +jest.mock('./getFill'); +jest.mock('./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, + }; + + (getText as jest.Mock).mockReturnValue({ + textCoords: [0, 0], + textStyle: new Style({ + text: new Text({ + text: props.text, + font: `bold ${props.fontSize}px Arial`, + fill: new Fill({ + color: '#000', + }), + placement: 'point', + textAlign: 'center', + textBaseline: 'middle', + }), + }), + }); + (getMultiPolygon as jest.Mock).mockReturnValue([ + new Polygon([ + [ + [0, 0], + [1, 1], + [2, 2], + ], + ]), + ]); + (getStroke as jest.Mock).mockReturnValue(new Style()); + (getFill as jest.Mock).mockReturnValue(new Style()); + (rgbToHex as jest.Mock).mockReturnValue('#FFFFFF'); + (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/CompartmentSquare.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/CompartmentSquare.ts new file mode 100644 index 0000000000000000000000000000000000000000..79962ee718462b919b32947caf2f4ece25eecd01 --- /dev/null +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/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/getPolygonCoords'; +import Compartment from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/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/MapElement.test.ts index 0ed5313bcf38fc80ace22bec8a0982cb420f5760..004f90cd3cfc3350b66ac47f7b2f78de97a35ae4 100644 --- a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/MapElement.test.ts +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/MapElement.test.ts @@ -107,14 +107,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/MapElement.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/MapElement.ts index e0943fb44bf4a3cb655f262e80cef366ef24b1a4..f1ba348fd4ef6b2602eb4a26fbe13e16f52bba41 100644 --- a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/MapElement.ts +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/MapElement.ts @@ -1,13 +1,10 @@ /* 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, LineType, Modification, Shape } from '@/types/models'; import { MapInstance } from '@/types/map'; @@ -20,6 +17,7 @@ import { BLACK_COLOR, WHITE_COLOR, } from '@/components/Map/MapViewer/MapViewerVector/MapViewerVector.constants'; +import BaseMultiPolygon from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/BaseMultiPolygon'; export type MapElementProps = { shapes: Array<Shape>; @@ -34,7 +32,7 @@ export type MapElementProps = { lineWidth?: number; lineType?: string; text?: string; - fontSize?: string | number; + fontSize?: number; nameX: number; nameY: number; nameHeight: number; @@ -48,16 +46,18 @@ export type MapElementProps = { modifications?: Array<Modification>; }; -export default class MapElement { - textStyle: Style | undefined; +export default class MapElement extends BaseMultiPolygon { + shapes: Array<Shape>; + + lineWidth: number; - polygons: Array<Polygon> = []; + lineType: string | undefined; - styles: Array<Style> = []; + bioShapes: Array<BioShape>; - polygonsTexts: Array<string> = []; + lineTypes: Array<LineType>; - multiPolygonFeature: Feature; + modifications: Array<Modification>; constructor({ shapes, @@ -85,32 +85,44 @@ export default class MapElement { lineTypes = [], 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]])); - } + 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.bioShapes = bioShapes; + this.lineTypes = lineTypes; + this.modifications = modifications; + this.createPolygons(); + this.drawText(); + this.drawMultiPolygonFeature(mapInstance); + } + protected createPolygons(): void { const multiPolygon: Array<Polygon> = []; - modifications.forEach(modification => { + this.modifications.forEach(modification => { if (modification.state === null) { return; } - const shape = bioShapes.find(bioShape => bioShape.sboTerm === modification.sboTerm); + const shape = this.bioShapes.find(bioShape => bioShape.sboTerm === modification.sboTerm); if (!shape) { return; } @@ -120,7 +132,7 @@ export default class MapElement { width: modification.width, height: modification.height, shapes: shape.shapes, - pointToProjection, + pointToProjection: this.pointToProjection, mirror: modification.direction && modification.direction === 'RIGHT', }); multiPolygon.push(...multiPolygonModification.flat()); @@ -151,12 +163,19 @@ export default class MapElement { }); }); - const elementMultiPolygon = getMultiPolygon({ x, y, width, height, shapes, pointToProjection }); + const elementMultiPolygon = getMultiPolygon({ + x: this.x, + y: this.y, + width: this.width, + height: this.height, + shapes: this.shapes, + pointToProjection: this.pointToProjection, + }); this.polygons = [...this.polygons, ...multiPolygon, ...elementMultiPolygon.flat()]; let lineDash: Array<number> = []; - if (lineType) { - const lineTypeFound = lineTypes.find(type => type.name === lineType); + if (this.lineType) { + const lineTypeFound = this.lineTypes.find(type => type.name === this.lineType); if (lineTypeFound) { lineDash = lineTypeFound.pattern; } @@ -165,48 +184,11 @@ export default class MapElement { this.styles.push( new Style({ geometry: polygon, - stroke: getStroke({ color: rgbToHex(borderColor), width: lineWidth, lineDash }), - fill: getFill({ color: rgbToHex(fillColor) }), - zIndex, + stroke: getStroke({ color: rgbToHex(this.borderColor), width: this.lineWidth, lineDash }), + fill: getFill({ color: rgbToHex(this.fillColor) }), + zIndex: this.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/getEllipseCoords.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getEllipseCoords.ts index 5d1e7f668ebabd78939c0af7d9a424c7e10517ac..b3a307a0c160a492c2e270588679dba5ded727af 100644 --- a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getEllipseCoords.ts +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getEllipseCoords.ts @@ -3,24 +3,10 @@ import getCoordsX from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/ import getCoordsY from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/getCoordsY'; import { Coordinate } from 'ol/coordinate'; import { UsePointToProjectionResult } from '@/utils/map/usePointToProjection'; - -type EllipseCenter = { - type: string; - absoluteX: number; - absoluteY: number; - relativeX: number; - relativeY: number; - relativeHeightForX: number | null; - relativeWidthForY: number | null; -}; - -type EllipseRadius = { - type: string; - absoluteX: number; - absoluteY: number; - relativeX: number; - relativeY: number; -}; +import { + EllipseCenter, + EllipseRadius, +} from '@/components/Map/MapViewer/MapViewerVector/MapViewerVector.types'; export default function getEllipseCoords({ x, diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getText.test.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getText.test.ts index a9f0490707370aca9a3e9fe595aab4e83174bcff..e25b4df766b169378fa2e31f029ad0ca54e0c7eb 100644 --- a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getText.test.ts +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getText.test.ts @@ -18,7 +18,7 @@ describe('getText', () => { }); expect(textCoords).toEqual([50, 50]); - expect(textStyle.getText()?.getFont()).toEqual('bold 12px Arial'); + expect(textStyle.getText()?.getFont()).toEqual('12pt Arial'); }); it('should return correct text coordinates and style when text is aligned to bottom', () => { @@ -36,6 +36,6 @@ describe('getText', () => { expect(textCoords).toEqual([70, 121]); - expect(textStyle.getText()?.getFont()).toEqual('bold 18px Arial'); + expect(textStyle.getText()?.getFont()).toEqual('18pt Arial'); }); }); diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getText.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getText.ts index 10e021881d8e448edd9559a142d819d3b97de00c..09dffbe2806cd54da01562c5dba15818bba63c05 100644 --- a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getText.ts +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getText.ts @@ -61,19 +61,22 @@ export default function getText({ geometry: (feature: FeatureLike): Geometry | RenderFeature | undefined => { const geometry = feature.getGeometry(); if (geometry && geometry.getType() === 'MultiPolygon') { - return (geometry as MultiPolygon).getPolygon(0).getInteriorPoint(); + return (geometry as MultiPolygon) + .getPolygon((geometry as MultiPolygon).getPolygons().length - 1) + .getInteriorPoint(); } return undefined; }, text: new Text({ text, - font: `bold ${fontSize}px Arial`, + font: `${fontSize}pt Arial`, fill: new Fill({ color, }), placement: 'point', - textAlign: 'center', + textAlign: horizontalAlign.toLowerCase() as CanvasTextAlign, textBaseline: 'middle', + overflow: true, }), zIndex, }); diff --git a/src/models/modelElementSchema.ts b/src/models/modelElementSchema.ts index 326f6d9f9c0610f553ff83872daee4d0c5972058..3f650899441c4ef31cbba8dba6f640eedc51a5b7 100644 --- a/src/models/modelElementSchema.ts +++ b/src/models/modelElementSchema.ts @@ -37,6 +37,10 @@ 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(),