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 ba0cb0480e5fdec0ce8942cc6d717ede32e98cd6..9fe39ef0a5c20fccd577f51cb780624d37f57beb 100644 --- a/src/components/Map/MapViewer/MapViewerVector/utils/config/reactionsLayer/useOlMapReactionsLayer.ts +++ b/src/components/Map/MapViewer/MapViewerVector/utils/config/reactionsLayer/useOlMapReactionsLayer.ts @@ -4,7 +4,7 @@ import VectorLayer from 'ol/layer/Vector'; import VectorSource from 'ol/source/Vector'; import { useEffect, useMemo } from 'react'; import { usePointToProjection } from '@/utils/map/usePointToProjection'; -import CustomMultiPolygon from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/CustomMultiPolygon'; +import MapElement from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/MapElement'; import { useSelector } from 'react-redux'; import { shapesSelector } from '@/redux/shapes/shapes.selectors'; import { MapInstance } from '@/types/map'; @@ -37,7 +37,7 @@ export const useOlMapReactionsLayer = ({ return modelElements.content.map(element => { const shape = shapes.data.find(bioShape => bioShape.sboTerm === element.sboTerm); if (shape) { - return new CustomMultiPolygon({ + return new MapElement({ shapes: shape.shapes, x: element.x, y: element.y, @@ -57,6 +57,8 @@ export const useOlMapReactionsLayer = ({ text: element.name, pointToProjection, mapInstance, + modifications: element.modificationResidues, + bioShapes: shapes.data, }); } return undefined; @@ -67,7 +69,7 @@ export const useOlMapReactionsLayer = ({ const features = useMemo(() => { return elements - .filter((element): element is CustomMultiPolygon => element !== undefined) + .filter((element): element is MapElement => element !== undefined) .map(element => element.multiPolygonFeature); }, [elements]); diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/CustomMultiPolygon.test.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/MapElement.test.ts similarity index 91% rename from src/components/Map/MapViewer/MapViewerVector/utils/shapes/CustomMultiPolygon.test.ts rename to src/components/Map/MapViewer/MapViewerVector/utils/shapes/MapElement.test.ts index f5de8dbe0c3516fbd031b857f3a654c09585efbc..0ed5313bcf38fc80ace22bec8a0982cb420f5760 100644 --- a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/CustomMultiPolygon.test.ts +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/MapElement.test.ts @@ -7,9 +7,9 @@ import getMultiPolygon from '@/components/Map/MapViewer/MapViewerVector/utils/sh import getStroke from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/getStroke'; import getFill from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/getFill'; import { rgbToHex } from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/rgbToHex'; -import CustomMultiPolygon, { - CustomMultiPolygonProps, -} from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/CustomMultiPolygon'; +import MapElement, { + MapElementProps, +} from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/MapElement'; import View from 'ol/View'; import { WHITE_COLOR, @@ -22,8 +22,8 @@ jest.mock('./getStroke'); jest.mock('./getFill'); jest.mock('./rgbToHex'); -describe('CustomMultiPolygon', () => { - let props: CustomMultiPolygonProps; +describe('MapElement', () => { + let props: MapElementProps; beforeEach(() => { const dummyElement = document.createElement('div'); @@ -88,7 +88,7 @@ describe('CustomMultiPolygon', () => { }); it('should initialize with correct default properties', () => { - const multiPolygon = new CustomMultiPolygon(props); + const multiPolygon = new MapElement(props); expect(multiPolygon.polygons.length).toBe(2); expect(multiPolygon.multiPolygonFeature).toBeInstanceOf(Feature); @@ -96,7 +96,7 @@ describe('CustomMultiPolygon', () => { }); it('should apply correct styles to the feature', () => { - const multiPolygon = new CustomMultiPolygon(props); + const multiPolygon = new MapElement(props); const feature = multiPolygon.multiPolygonFeature; const style = feature.getStyleFunction()?.call(multiPolygon, feature, 1); @@ -109,7 +109,7 @@ describe('CustomMultiPolygon', () => { }); it('should update text style based on resolution', () => { - const multiPolygon = new CustomMultiPolygon(props); + const multiPolygon = new MapElement(props); const feature = multiPolygon.multiPolygonFeature; multiPolygon.styleFunction(feature, 1000); diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/CustomMultiPolygon.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/MapElement.ts similarity index 52% rename from src/components/Map/MapViewer/MapViewerVector/utils/shapes/CustomMultiPolygon.ts rename to src/components/Map/MapViewer/MapViewerVector/utils/shapes/MapElement.ts index 4b339096ca4ee28686df66a5e3edd930a60878d0..2439b0d6758613331262a28d19e886c78daa6f89 100644 --- a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/CustomMultiPolygon.ts +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/MapElement.ts @@ -1,16 +1,15 @@ /* eslint-disable no-magic-numbers */ -import { Style } from 'ol/style'; +import { Style, Text } from 'ol/style'; import Feature, { FeatureLike } from 'ol/Feature'; -import { Geometry, MultiPolygon } from 'ol/geom'; +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 RenderFeature from 'ol/render/Feature'; import Polygon from 'ol/geom/Polygon'; import getMultiPolygon from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/getMultiPolygon'; import getText from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/getText'; import { rgbToHex } from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/rgbToHex'; -import { Shape } from '@/types/models'; +import { BioShape, Modification, Shape } from '@/types/models'; import { MapInstance } from '@/types/map'; import { ColorObject, @@ -22,7 +21,7 @@ import { WHITE_COLOR, } from '@/components/Map/MapViewer/MapViewerVector/MapViewerVector.constants'; -export type CustomMultiPolygonProps = { +export type MapElementProps = { shapes: Array<Shape>; x: number; y: number; @@ -43,15 +42,19 @@ export type CustomMultiPolygonProps = { nameHorizontalAlign?: HorizontalAlign; pointToProjection: UsePointToProjectionResult; mapInstance: MapInstance; + bioShapes?: Array<BioShape>; + modifications?: Array<Modification>; }; -export default class CustomMultiPolygon { - multiPolygonStyle: Style; - +export default class MapElement { textStyle: Style | undefined; polygons: Array<Polygon> = []; + styles: Array<Style> = []; + + polygonsTexts: Array<string> = []; + multiPolygonFeature: Feature; constructor({ @@ -75,7 +78,9 @@ export default class CustomMultiPolygon { nameHorizontalAlign = 'CENTER', pointToProjection, mapInstance, - }: CustomMultiPolygonProps) { + bioShapes = [], + modifications = [], + }: MapElementProps) { if (text) { const { textCoords, textStyle } = getText({ text, @@ -85,28 +90,76 @@ export default class CustomMultiPolygon { width: nameWidth, height: nameHeight, color: rgbToHex(fontColor), + zIndex, verticalAlign: nameVerticalAlign, horizontalAlign: nameHorizontalAlign, pointToProjection, }); - this.textStyle = new Style({ - geometry: (feature: FeatureLike): Geometry | RenderFeature | undefined => { - const geometry = feature.getGeometry(); - if (geometry && geometry.getType() === 'MultiPolygon') { - return (geometry as MultiPolygon).getPolygon(0).getInteriorPoint(); - } - return undefined; - }, - text: textStyle.getText() || undefined, - zIndex, - }); + this.styles.push(textStyle); + this.polygonsTexts.push(text); this.polygons.push(new Polygon([[textCoords, textCoords]])); } - this.polygons = [ - ...this.polygons, - ...getMultiPolygon({ x, y, width, height, shapes, pointToProjection }), - ]; + 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]), @@ -122,12 +175,6 @@ export default class CustomMultiPolygon { }, }); - this.multiPolygonStyle = new Style({ - stroke: getStroke({ color: rgbToHex(borderColor), width: lineWidth }), - fill: getFill({ color: rgbToHex(fillColor) }), - zIndex, - }); - this.multiPolygonFeature.setStyle(this.styleFunction.bind(this)); } @@ -137,14 +184,18 @@ export default class CustomMultiPolygon { if (getTextScale instanceof Function) { textScale = getTextScale(resolution); } - const styles = []; - if (this.textStyle) { - const text = this.textStyle.getText(); - if (text) { - this.textStyle.getText()?.setScale(textScale); - styles.push(this.textStyle); + 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 [...styles, this.multiPolygonStyle]; + }); + return this.styles; } } diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getCentroid.test.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getCentroid.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..e6f1cc8ab8e606757f5ac70c896514e9ada18fed --- /dev/null +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getCentroid.test.ts @@ -0,0 +1,21 @@ +/* eslint-disable no-magic-numbers */ +import { Coordinate } from 'ol/coordinate'; +import getCentroid from './getCentroid'; + +describe('getCentroid', () => { + it('should return a centroid for coordinates', () => { + const coords: Array<Coordinate> = [ + [0, 0], + [20, 0], + [35, 10], + [20, 20], + [10, 30], + [0, 20], + ]; + + const result = getCentroid(coords); + + expect(result[0]).toBeCloseTo(13.46); + expect(result[1]).toBeCloseTo(12.05); + }); +}); diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getCentroid.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getCentroid.ts new file mode 100644 index 0000000000000000000000000000000000000000..686fb61848a7d7bc6ef3c069a694980b99025537 --- /dev/null +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getCentroid.ts @@ -0,0 +1,27 @@ +/* eslint-disable no-magic-numbers */ +import { Coordinate } from 'ol/coordinate'; + +export default function getCentroid(coords: Array<Coordinate>): Coordinate { + let area = 0; + let centroidX = 0; + let centroidY = 0; + const numPoints = coords.length; + + for (let i = 0; i < numPoints; i += 1) { + const x1 = coords[i][0]; + const y1 = coords[i][1]; + const x2 = coords[(i + 1) % numPoints][0]; + const y2 = coords[(i + 1) % numPoints][1]; + const crossProduct = x1 * y2 - x2 * y1; + area += crossProduct; + centroidX += (x1 + x2) * crossProduct; + centroidY += (y1 + y2) * crossProduct; + } + + area /= 2; + + centroidX /= 6 * area; + centroidY /= 6 * area; + + return [centroidX, centroidY]; +} diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getEllipseCoords.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getEllipseCoords.ts index fa713e8ab27865ac7c42a49cacb98f400e4123da..5d1e7f668ebabd78939c0af7d9a424c7e10517ac 100644 --- a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getEllipseCoords.ts +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getEllipseCoords.ts @@ -30,7 +30,7 @@ export default function getEllipseCoords({ height, width, pointToProjection, - points = 64, + points = 20, }: { x: number; y: number; diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getMultiPolygon.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getMultiPolygon.ts index c417891bfc5474b5b70e614bde42aadc8e5aee8c..e5effdb72c71b538411248bf6223245bf5cb9c8b 100644 --- a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getMultiPolygon.ts +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getMultiPolygon.ts @@ -1,9 +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 { 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'; export default function getMultiPolygon({ x, @@ -12,6 +14,7 @@ export default function getMultiPolygon({ height, shapes, pointToProjection, + mirror, }: { x: number; y: number; @@ -19,6 +22,7 @@ export default function getMultiPolygon({ height: number; shapes: Array<Shape>; pointToProjection: UsePointToProjectionResult; + mirror?: boolean; }): Array<Polygon> { return shapes.map(shape => { let coords: Array<Coordinate> = []; @@ -35,6 +39,17 @@ export default function getMultiPolygon({ pointToProjection, }); } + + if (mirror) { + const centroid = getCentroid(coords); + + coords = coords.map(coord => { + const mirroredX = 2 * centroid[0] - coord[0]; + + return [mirroredX, coord[1]]; + }); + } + return new Polygon([coords]); }); } diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getPolygonCoords.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getPolygonCoords.ts index 01f8169f07d14ac8ffe228a0110d35c9036927ed..b60b001608ad7d4c59f898e7973402ada54be259 100644 --- a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getPolygonCoords.ts +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getPolygonCoords.ts @@ -48,6 +48,6 @@ export default function getPolygonCoords({ return [[...lastPoint]]; } const { p1, p2, p3 } = getCurveCoords({ x, y, point, height, width, pointToProjection }); - return getBezierCurve({ p0: lastPoint, p1, p2, p3, numPoints: 50 }); + return getBezierCurve({ p0: lastPoint, p1, p2, p3, numPoints: 20 }); }); } diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getText.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getText.ts index b9a1f867e70d7752afda69c075119bf544127cf6..10e021881d8e448edd9559a142d819d3b97de00c 100644 --- a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getText.ts +++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/getText.ts @@ -2,13 +2,14 @@ import { Fill, Text } from 'ol/style'; import Style from 'ol/style/Style'; import { UsePointToProjectionResult } from '@/utils/map/usePointToProjection'; -import { Feature } from 'ol'; -import { Point } from 'ol/geom'; import { Coordinate } from 'ol/coordinate'; import { HorizontalAlign, VerticalAlign, } from '@/components/Map/MapViewer/MapViewerVector/MapViewerVector.types'; +import { FeatureLike } from 'ol/Feature'; +import { Geometry, MultiPolygon } from 'ol/geom'; +import RenderFeature from 'ol/render/Feature'; export default function getText({ x, @@ -18,6 +19,7 @@ export default function getText({ text = '', fontSize = 12, color = '#000', + zIndex = 1, verticalAlign = 'MIDDLE', horizontalAlign = 'CENTER', pointToProjection, @@ -29,6 +31,7 @@ export default function getText({ text: string; fontSize?: string | number; color?: string; + zIndex?: number; verticalAlign?: VerticalAlign; horizontalAlign?: HorizontalAlign; pointToProjection: UsePointToProjectionResult; @@ -53,9 +56,15 @@ export default function getText({ } const textCoords = pointToProjection({ x: textX, y: textY }); - const textFeature = new Feature({ geometry: new Point(textCoords) }); 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`, @@ -66,8 +75,8 @@ export default function getText({ textAlign: 'center', textBaseline: 'middle', }), + zIndex, }); - textFeature.setStyle(textStyle); return { textCoords, textStyle }; } diff --git a/src/models/modelElementModificationSchema.ts b/src/models/modelElementModificationSchema.ts new file mode 100644 index 0000000000000000000000000000000000000000..0d604ceaa065a955f37be81e44f86f79320c624b --- /dev/null +++ b/src/models/modelElementModificationSchema.ts @@ -0,0 +1,24 @@ +import { z } from 'zod'; +import { colorSchema } from '@/models/colorSchema'; + +export const modelElementModificationSchema = z.object({ + id: z.number().int().positive(), + idModificationResidue: z.string(), + name: z.string(), + x: z.number(), + y: z.number(), + z: z.number(), + width: z.number(), + height: z.number(), + species: z.number(), + borderColor: colorSchema, + fillColor: colorSchema, + fontSize: z.number(), + state: z.string().nullable().optional(), + stateAbbreviation: z.string().nullable().optional(), + direction: z.enum(['RIGHT', 'LEFT']).optional(), + active: z.boolean().optional(), + sboTerm: z.string(), + size: z.number(), + elementId: z.string(), +}); diff --git a/src/models/modelElementSchema.ts b/src/models/modelElementSchema.ts index 12da12253cc0e9b2a99f36baac1113cfe5d77c6f..7b3291a1d3bbde11ad58e0b27a19df6d77b73959 100644 --- a/src/models/modelElementSchema.ts +++ b/src/models/modelElementSchema.ts @@ -2,6 +2,7 @@ import { z } from 'zod'; import { colorSchema } from '@/models/colorSchema'; import { referenceSchema } from '@/models/referenceSchema'; import { submodelSchema } from '@/models/submodelSchema'; +import { modelElementModificationSchema } from '@/models/modelElementModificationSchema'; import { glyphSchema } from '@/models/glyphSchema'; export const modelElementSchema = z.object({ @@ -48,4 +49,5 @@ export const modelElementSchema = z.object({ substanceUnits: z.boolean().nullable().optional(), references: z.array(referenceSchema), sboTerm: z.string(), + modificationResidues: z.array(modelElementModificationSchema).optional(), }); diff --git a/src/types/models.ts b/src/types/models.ts index 878d99dfe3b4a8316dbfd11db5cbacd1c3eae2f7..e42c235b929f6e80a25975e896d38179d20d9311 100644 --- a/src/types/models.ts +++ b/src/types/models.ts @@ -68,6 +68,7 @@ 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'; export type Project = z.infer<typeof projectSchema>; export type OverviewImageView = z.infer<typeof overviewImageView>; @@ -78,6 +79,7 @@ export type MapModel = z.infer<typeof mapModelSchema>; export type BioShape = z.infer<typeof bioShapeSchema>; export type ModelElements = z.infer<typeof modelElementsSchema>; export type Shape = z.infer<typeof shapeSchema>; +export type Modification = z.infer<typeof modelElementModificationSchema>; export type MapOverlay = z.infer<typeof mapOverlay>; export type MapBackground = z.infer<typeof mapBackground>; export type Organism = z.infer<typeof organism>;