From 692843631bc548212a2e1fbda17a33e71c96f04a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Mi=C5=82osz=20Grocholewski?= <m.grocholewski@atcomp.pl>
Date: Mon, 28 Oct 2024 10:32:44 +0100
Subject: [PATCH] Resolve MIN-39 "Feat/ glyphs"

---
 docs/types/BioEntity.md                       |   5 +-
 docs/types/Chemical.md                        |   5 +-
 docs/types/Drug.md                            |   5 +-
 .../BioEntitiesAccordion.component.test.tsx   |   4 +-
 .../reactionsLayer/useOlMapReactionsLayer.ts  | 135 +++++++++--------
 .../utils/shapes/Compartment.ts               | 137 ++++++++++++++++++
 .../utils/shapes/elements/BaseMultiPolygon.ts |   8 +-
 .../shapes/elements/CompartmentCircle.test.ts |   6 +-
 .../elements/CompartmentPathway.test.ts       |   6 +-
 .../shapes/elements/CompartmentSquare.test.ts |   6 +-
 .../utils/shapes/elements/Glyph.test.ts       |  75 ++++++++++
 .../utils/shapes/elements/Glyph.ts            | 123 ++++++++++++++++
 .../utils/shapes/elements/MapElement.test.ts  |   6 +-
 .../Map/MapViewer/utils/useOlMap.ts           |   8 +
 src/models/glyphSchema.ts                     |   1 +
 src/redux/apiPath.ts                          |   2 +
 src/utils/map/pointToLatLng.ts                |   1 -
 17 files changed, 454 insertions(+), 79 deletions(-)
 create mode 100644 src/components/Map/MapViewer/MapViewerVector/utils/shapes/Compartment.ts
 create mode 100644 src/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/Glyph.test.ts
 create mode 100644 src/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/Glyph.ts

diff --git a/docs/types/BioEntity.md b/docs/types/BioEntity.md
index faba904a..587d005d 100644
--- a/docs/types/BioEntity.md
+++ b/docs/types/BioEntity.md
@@ -162,9 +162,12 @@
           "properties": {
             "file": {
               "type": "number"
+            },
+            "id": {
+              "type": "number"
             }
           },
-          "required": ["file"],
+          "required": ["file", "id"],
           "additionalProperties": false
         },
         {
diff --git a/docs/types/Chemical.md b/docs/types/Chemical.md
index 61add593..c595975e 100644
--- a/docs/types/Chemical.md
+++ b/docs/types/Chemical.md
@@ -230,9 +230,12 @@
                       "properties": {
                         "file": {
                           "type": "number"
+                        },
+                        "id": {
+                          "type": "number"
                         }
                       },
-                      "required": ["file"],
+                      "required": ["file", "id"],
                       "additionalProperties": false
                     },
                     {
diff --git a/docs/types/Drug.md b/docs/types/Drug.md
index f7b21990..538edf56 100644
--- a/docs/types/Drug.md
+++ b/docs/types/Drug.md
@@ -211,9 +211,12 @@
                       "properties": {
                         "file": {
                           "type": "number"
+                        },
+                        "id": {
+                          "type": "number"
                         }
                       },
-                      "required": ["file"],
+                      "required": ["file", "id"],
                       "additionalProperties": false
                     },
                     {
diff --git a/src/components/Map/Drawer/SearchDrawerWrapper/GroupedSearchResults/BioEntitiesAccordion/BioEntitiesAccordion.component.test.tsx b/src/components/Map/Drawer/SearchDrawerWrapper/GroupedSearchResults/BioEntitiesAccordion/BioEntitiesAccordion.component.test.tsx
index 5067368d..33686dce 100644
--- a/src/components/Map/Drawer/SearchDrawerWrapper/GroupedSearchResults/BioEntitiesAccordion/BioEntitiesAccordion.component.test.tsx
+++ b/src/components/Map/Drawer/SearchDrawerWrapper/GroupedSearchResults/BioEntitiesAccordion/BioEntitiesAccordion.component.test.tsx
@@ -96,8 +96,8 @@ describe('BioEntitiesAccordion - component', () => {
     });
 
     expect(screen.getByText('Content (10)')).toBeInTheDocument();
-    expect(screen.getByText('Core PD map (4)')).toBeInTheDocument();
-    expect(screen.getByText('Histamine signaling (4)')).toBeInTheDocument();
+    expect(screen.getByText('Core PD map (6)')).toBeInTheDocument();
+    expect(screen.getByText('Histamine signaling (2)')).toBeInTheDocument();
     expect(screen.getByText('PRKN substrates (2)')).toBeInTheDocument();
   });
 
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 cf9262f8..b4bce8d7 100644
--- a/src/components/Map/MapViewer/MapViewerVector/utils/config/reactionsLayer/useOlMapReactionsLayer.ts
+++ b/src/components/Map/MapViewer/MapViewerVector/utils/config/reactionsLayer/useOlMapReactionsLayer.ts
@@ -19,6 +19,7 @@ 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';
+import Glyph from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/Glyph';
 import CompartmentPathway from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/CompartmentPathway';
 
 export const useOlMapReactionsLayer = ({
@@ -37,48 +38,70 @@ export const useOlMapReactionsLayer = ({
   const shapes = useSelector(bioShapesSelector);
   const lineTypes = useSelector(lineTypesSelector);
 
-  const elements: Array<MapElement | CompartmentCircle | CompartmentSquare | CompartmentPathway> =
-    useMemo(() => {
-      if (!modelElements || !shapes) return [];
+  const elements: Array<
+    MapElement | CompartmentCircle | CompartmentSquare | CompartmentPathway | Glyph
+  > = useMemo(() => {
+    if (!modelElements || !shapes) {
+      return [];
+    }
 
-      const validElements: Array<
-        MapElement | CompartmentCircle | CompartmentSquare | CompartmentPathway
-      > = [];
-      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,
-              nameX: element.nameX,
-              nameY: element.nameY,
-              nameHeight: element.nameHeight,
-              nameWidth: element.nameWidth,
-              width: element.width,
-              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,
-              lineTypes,
-              bioShapes: shapes,
-            }),
-          );
-        } else if (element.sboTerm === 'SBO:0000290') {
-          const compartmentProps = {
+    const validElements: Array<
+      MapElement | CompartmentCircle | CompartmentSquare | CompartmentPathway | Glyph
+    > = [];
+    modelElements.content.forEach((element: ModelElement) => {
+      if (element.glyph) {
+        const glyph = new Glyph({
+          id: element.glyph.id,
+          x: element.x,
+          y: element.y,
+          width: element.width,
+          height: element.height,
+          zIndex: element.z,
+          pointToProjection,
+          mapInstance,
+        });
+        validElements.push(glyph);
+        return;
+      }
+
+      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));
+        } else if (element.shape === 'PATHWAY') {
+          validElements.push(new CompartmentPathway(compartmentProps));
+        }
+        return;
+      }
+      const shape = shapes.find(bioShape => bioShape.sboTerm === element.sboTerm);
+      if (shape) {
+        validElements.push(
+          new MapElement({
+            shapes: shape.shapes,
             x: element.x,
             y: element.y,
             nameX: element.nameX,
@@ -88,33 +111,31 @@ export const useOlMapReactionsLayer = ({
             width: element.width,
             height: element.height,
             zIndex: element.z,
-            innerWidth: element.innerWidth,
-            outerWidth: element.outerWidth,
-            thickness: element.thickness,
+            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,
-          };
-          if (element.shape === 'OVAL_COMPARTMENT') {
-            validElements.push(new CompartmentCircle(compartmentProps));
-          } else if (element.shape === 'SQUARE_COMPARTMENT') {
-            validElements.push(new CompartmentSquare(compartmentProps));
-          } else if (element.shape === 'PATHWAY') {
-            validElements.push(new CompartmentPathway(compartmentProps));
-          }
-        }
-      });
-      return validElements;
-    }, [modelElements, shapes, pointToProjection, mapInstance, lineTypes]);
+            modifications: element.modificationResidues,
+            lineTypes,
+            bioShapes: shapes,
+          }),
+        );
+      }
+    });
+    return validElements;
+  }, [modelElements, shapes, pointToProjection, mapInstance, lineTypes]);
 
   const features = useMemo(() => {
-    return elements.map(element => element.multiPolygonFeature);
+    return elements.map(element => element.feature);
   }, [elements]);
 
   const vectorSource = useMemo(() => {
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 00000000..bdf60269
--- /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 {
+  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';
+import { Color } from '@/types/models';
+
+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: Color;
+  nameVerticalAlign: VerticalAlign;
+  nameHorizontalAlign: HorizontalAlign;
+  fillColor: Color;
+  borderColor: Color;
+  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/BaseMultiPolygon.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/BaseMultiPolygon.ts
index ef477137..1852806c 100644
--- a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/BaseMultiPolygon.ts
+++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/BaseMultiPolygon.ts
@@ -73,7 +73,7 @@ export default abstract class BaseMultiPolygon {
 
   polygonsTexts: Array<string> = [];
 
-  multiPolygonFeature: Feature = new Feature();
+  feature: Feature = new Feature();
 
   pointToProjection: UsePointToProjectionResult;
 
@@ -145,21 +145,21 @@ export default abstract class BaseMultiPolygon {
   }
 
   protected drawMultiPolygonFeature(mapInstance: MapInstance): void {
-    this.multiPolygonFeature = new Feature({
+    this.feature = 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 minResolution / resolution;
           }
         }
         return 1;
       },
     });
 
-    this.multiPolygonFeature.setStyle(this.styleFunction.bind(this));
+    this.feature.setStyle(this.styleFunction.bind(this));
   }
 
   protected styleFunction(feature: FeatureLike, resolution: number): Style | Array<Style> | void {
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
index 47c47d38..4c0a4aff 100644
--- a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/CompartmentCircle.test.ts
+++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/CompartmentCircle.test.ts
@@ -101,13 +101,13 @@ describe('MapElement', () => {
     const multiPolygon = new CompartmentCircle(props);
 
     expect(multiPolygon.polygons.length).toBe(4);
-    expect(multiPolygon.multiPolygonFeature).toBeInstanceOf(Feature);
-    expect(multiPolygon.multiPolygonFeature.getGeometry()).toBeInstanceOf(MultiPolygon);
+    expect(multiPolygon.feature).toBeInstanceOf(Feature);
+    expect(multiPolygon.feature.getGeometry()).toBeInstanceOf(MultiPolygon);
   });
 
   it('should apply correct styles to the feature', () => {
     const multiPolygon = new CompartmentCircle(props);
-    const feature = multiPolygon.multiPolygonFeature;
+    const { feature } = multiPolygon;
 
     const style = feature.getStyleFunction()?.call(multiPolygon, feature, 1);
 
diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/CompartmentPathway.test.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/CompartmentPathway.test.ts
index 8eb98121..c47c01b5 100644
--- a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/CompartmentPathway.test.ts
+++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/CompartmentPathway.test.ts
@@ -99,13 +99,13 @@ describe('MapElement', () => {
     const multiPolygon = new CompartmentPathway(props);
 
     expect(multiPolygon.polygons.length).toBe(2);
-    expect(multiPolygon.multiPolygonFeature).toBeInstanceOf(Feature);
-    expect(multiPolygon.multiPolygonFeature.getGeometry()).toBeInstanceOf(MultiPolygon);
+    expect(multiPolygon.feature).toBeInstanceOf(Feature);
+    expect(multiPolygon.feature.getGeometry()).toBeInstanceOf(MultiPolygon);
   });
 
   it('should apply correct styles to the feature', () => {
     const multiPolygon = new CompartmentPathway(props);
-    const feature = multiPolygon.multiPolygonFeature;
+    const { feature } = multiPolygon;
 
     const style = feature.getStyleFunction()?.call(multiPolygon, feature, 1);
 
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
index 5fd6ac55..a368e292 100644
--- a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/CompartmentSquare.test.ts
+++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/CompartmentSquare.test.ts
@@ -101,13 +101,13 @@ describe('MapElement', () => {
     const multiPolygon = new CompartmentSquare(props);
 
     expect(multiPolygon.polygons.length).toBe(4);
-    expect(multiPolygon.multiPolygonFeature).toBeInstanceOf(Feature);
-    expect(multiPolygon.multiPolygonFeature.getGeometry()).toBeInstanceOf(MultiPolygon);
+    expect(multiPolygon.feature).toBeInstanceOf(Feature);
+    expect(multiPolygon.feature.getGeometry()).toBeInstanceOf(MultiPolygon);
   });
 
   it('should apply correct styles to the feature', () => {
     const multiPolygon = new CompartmentSquare(props);
-    const feature = multiPolygon.multiPolygonFeature;
+    const { feature } = multiPolygon;
 
     const style = feature.getStyleFunction()?.call(multiPolygon, feature, 1);
 
diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/Glyph.test.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/Glyph.test.ts
new file mode 100644
index 00000000..e0235269
--- /dev/null
+++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/Glyph.test.ts
@@ -0,0 +1,75 @@
+/* eslint-disable no-magic-numbers */
+import { Feature, Map, View } from 'ol';
+import { Style, Icon } from 'ol/style';
+import { UsePointToProjectionResult } from '@/utils/map/usePointToProjection';
+import Glyph, {
+  GlyphProps,
+} from '@/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/Glyph';
+import { MapInstance } from '@/types/map';
+import Polygon from 'ol/geom/Polygon';
+import { BASE_NEW_API_URL } from '@/constants';
+
+describe('Glyph', () => {
+  let props: GlyphProps;
+  let glyph: Glyph;
+  let mapInstance: MapInstance;
+  let pointToProjectionMock: jest.MockedFunction<UsePointToProjectionResult>;
+
+  beforeEach(() => {
+    const dummyElement = document.createElement('div');
+    mapInstance = new Map({
+      target: dummyElement,
+      view: new View({
+        zoom: 5,
+        minZoom: 3,
+        maxZoom: 7,
+      }),
+    });
+
+    pointToProjectionMock = jest.fn().mockReturnValue([10, 20]);
+
+    props = {
+      id: 1,
+      x: 10,
+      y: 20,
+      width: 32,
+      height: 32,
+      zIndex: 1,
+      pointToProjection: pointToProjectionMock,
+      mapInstance,
+    };
+    glyph = new Glyph(props);
+  });
+
+  it('should initialize with correct feature and style properties', () => {
+    expect(glyph.feature).toBeInstanceOf(Feature);
+    const geometry = glyph.feature.getGeometry();
+    expect(geometry).toBeInstanceOf(Polygon);
+    expect(geometry?.getCoordinates()).toEqual([
+      [
+        [10, 20],
+        [10, 20],
+        [10, 20],
+        [10, 20],
+        [10, 20],
+      ],
+    ]);
+
+    expect(glyph.style).toBeInstanceOf(Style);
+    const image = glyph.style.getImage() as Icon;
+    expect(image).toBeInstanceOf(Icon);
+    expect(image.getSrc()).toBe(`${BASE_NEW_API_URL}projects/pdmap_appu_test/glyphs/1/fileContent`);
+  });
+
+  it('should scale image based on map resolution', () => {
+    const getImageScale = glyph.feature.get('getImageScale');
+    const getAnchorAndCoords = glyph.feature.get('getAnchorAndCoords');
+    if (mapInstance) {
+      const resolution = mapInstance
+        .getView()
+        .getResolutionForZoom(mapInstance.getView().getMaxZoom());
+      expect(getImageScale(resolution)).toBe(1);
+      expect(getAnchorAndCoords()).toEqual({ anchor: [0, 0], coords: [0, 0] });
+    }
+  });
+});
diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/Glyph.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/Glyph.ts
new file mode 100644
index 00000000..0d497ac9
--- /dev/null
+++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/Glyph.ts
@@ -0,0 +1,123 @@
+/* eslint-disable no-magic-numbers */
+import { UsePointToProjectionResult } from '@/utils/map/usePointToProjection';
+import { Feature } from 'ol';
+import Style from 'ol/style/Style';
+import Icon from 'ol/style/Icon';
+import { FeatureLike } from 'ol/Feature';
+import { MapInstance } from '@/types/map';
+import { apiPath } from '@/redux/apiPath';
+import { BASE_NEW_API_URL } from '@/constants';
+import Polygon from 'ol/geom/Polygon';
+import { Point } from 'ol/geom';
+import { Coordinate } from 'ol/coordinate';
+
+export type GlyphProps = {
+  id: number;
+  x: number;
+  y: number;
+  width: number;
+  height: number;
+  zIndex: number;
+  pointToProjection: UsePointToProjectionResult;
+  mapInstance: MapInstance;
+};
+
+export default class Glyph {
+  feature: Feature<Polygon>;
+
+  style: Style;
+
+  width: number;
+
+  height: number;
+
+  x: number;
+
+  y: number;
+
+  widthOnMap: number;
+
+  heightOnMap: number;
+
+  pixelRatio: number = 1;
+
+  pointToProjection: UsePointToProjectionResult;
+
+  constructor({ id, x, y, width, height, zIndex, pointToProjection, mapInstance }: GlyphProps) {
+    this.width = width;
+    this.height = height;
+    this.x = x;
+    this.y = y;
+    this.pointToProjection = pointToProjection;
+    const point1 = this.pointToProjection({ x: 0, y: 0 });
+    const point2 = this.pointToProjection({ x: this.width, y: this.height });
+    this.widthOnMap = Math.abs(point2[0] - point1[0]);
+    this.heightOnMap = Math.abs(point2[1] - point1[1]);
+    const minResolution = mapInstance?.getView().getMinResolution();
+    if (minResolution) {
+      this.pixelRatio = this.widthOnMap / minResolution / this.width;
+    }
+    const polygon = new Polygon([
+      [
+        pointToProjection({ x, y }),
+        pointToProjection({ x: x + width, y }),
+        pointToProjection({ x: x + width, y: y + height }),
+        pointToProjection({ x, y: y + height }),
+        pointToProjection({ x, y }),
+      ],
+    ]);
+    const iconFeature = new Feature({
+      geometry: polygon,
+      getImageScale: (resolution: number): number => {
+        if (mapInstance) {
+          return mapInstance.getView().getMinResolution() / resolution;
+        }
+        return 1;
+      },
+      getAnchorAndCoords: (): { anchor: Array<number>; coords: Coordinate } => {
+        const center = mapInstance?.getView().getCenter();
+        let anchorX = 0;
+        let anchorY = 0;
+        if (center) {
+          anchorX =
+            (center[0] - this.pointToProjection({ x: this.x, y: this.y })[0]) / this.widthOnMap;
+          anchorY =
+            -(center[1] - this.pointToProjection({ x: this.x, y: this.y })[1]) / this.heightOnMap;
+        }
+        return { anchor: [anchorX, anchorY], coords: center || [0, 0] };
+      },
+    });
+    this.style = new Style({
+      image: new Icon({
+        anchor: [0, 0],
+        src: `${BASE_NEW_API_URL}${apiPath.getGlyphImage(id)}`,
+        size: [width, height],
+      }),
+      zIndex,
+    });
+    iconFeature.setStyle(this.styleFunction.bind(this));
+    this.feature = iconFeature;
+  }
+
+  protected styleFunction(feature: FeatureLike, resolution: number): Style | Array<Style> | void {
+    const getImageScale = feature.get('getImageScale');
+    const getAnchorAndCoords = feature.get('getAnchorAndCoords');
+    let imageScale = 1;
+    let anchor = [0, 0];
+    let coords = this.pointToProjection({ x: this.x, y: this.y });
+    if (getImageScale instanceof Function) {
+      imageScale = getImageScale(resolution);
+    }
+    if (getAnchorAndCoords instanceof Function) {
+      const anchorAndCoords = getAnchorAndCoords();
+      anchor = anchorAndCoords.anchor;
+      coords = anchorAndCoords.coords;
+    }
+    if (this.style.getImage()) {
+      this.style.getImage()?.setScale(imageScale * this.pixelRatio);
+      (this.style.getImage() as Icon).setAnchor(anchor);
+      this.style.setGeometry(new Point(coords));
+    }
+    return this.style;
+  }
+}
diff --git a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/MapElement.test.ts b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/MapElement.test.ts
index 51aaeb65..5b38d585 100644
--- a/src/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/MapElement.test.ts
+++ b/src/components/Map/MapViewer/MapViewerVector/utils/shapes/elements/MapElement.test.ts
@@ -93,13 +93,13 @@ describe('MapElement', () => {
     const multiPolygon = new MapElement(props);
 
     expect(multiPolygon.polygons.length).toBe(2);
-    expect(multiPolygon.multiPolygonFeature).toBeInstanceOf(Feature);
-    expect(multiPolygon.multiPolygonFeature.getGeometry()).toBeInstanceOf(MultiPolygon);
+    expect(multiPolygon.feature).toBeInstanceOf(Feature);
+    expect(multiPolygon.feature.getGeometry()).toBeInstanceOf(MultiPolygon);
   });
 
   it('should apply correct styles to the feature', () => {
     const multiPolygon = new MapElement(props);
-    const feature = multiPolygon.multiPolygonFeature;
+    const { feature } = multiPolygon;
 
     const style = feature.getStyleFunction()?.call(multiPolygon, feature, 1);
 
diff --git a/src/components/Map/MapViewer/utils/useOlMap.ts b/src/components/Map/MapViewer/utils/useOlMap.ts
index f65c7f04..e80f32ef 100644
--- a/src/components/Map/MapViewer/utils/useOlMap.ts
+++ b/src/components/Map/MapViewer/utils/useOlMap.ts
@@ -7,6 +7,7 @@ import { useOlMapVectorLayers } from '@/components/Map/MapViewer/MapViewerVector
 import LayerGroup from 'ol/layer/Group';
 import { useAppSelector } from '@/redux/hooks/useAppSelector';
 import { vectorRenderingSelector } from '@/redux/models/models.selectors';
+import { defaults, MouseWheelZoom } from 'ol/interaction';
 import { useOlMapLayers } from './config/useOlMapLayers';
 import { useOlMapView } from './config/useOlMapView';
 import { useOlMapListeners } from './listeners/useOlMapListeners';
@@ -48,6 +49,13 @@ export const useOlMap: UseOlMap = ({ target } = {}) => {
     }
 
     const map = new Map({
+      interactions: defaults({
+        mouseWheelZoom: false,
+      }).extend([
+        new MouseWheelZoom({
+          duration: 0,
+        }),
+      ]),
       target: target || mapRef.current,
     });
 
diff --git a/src/models/glyphSchema.ts b/src/models/glyphSchema.ts
index ed69e814..eedb213a 100644
--- a/src/models/glyphSchema.ts
+++ b/src/models/glyphSchema.ts
@@ -1,5 +1,6 @@
 import { z } from 'zod';
 
 export const glyphSchema = z.object({
+  id: z.number(),
   file: z.number(),
 });
diff --git a/src/redux/apiPath.ts b/src/redux/apiPath.ts
index fc94e5d7..0c8b1125 100644
--- a/src/redux/apiPath.ts
+++ b/src/redux/apiPath.ts
@@ -62,6 +62,8 @@ export const apiPath = {
     `projects/${PROJECT_ID}/maps/${modelId}/layers/${layerId}/ovals/`,
   getLayerLines: (modelId: number, layerId: number): string =>
     `projects/${PROJECT_ID}/maps/${modelId}/layers/${layerId}/lines/`,
+  getGlyphImage: (glyphId: number): string =>
+    `projects/${PROJECT_ID}/glyphs/${glyphId}/fileContent`,
   getChemicalsStringWithQuery: (searchQuery: string): string =>
     `projects/${PROJECT_ID}/chemicals:search?query=${searchQuery}`,
   getAllOverlaysByProjectIdQuery: (
diff --git a/src/utils/map/pointToLatLng.ts b/src/utils/map/pointToLatLng.ts
index cf8a6e58..5c74e704 100644
--- a/src/utils/map/pointToLatLng.ts
+++ b/src/utils/map/pointToLatLng.ts
@@ -24,7 +24,6 @@ export const pointToLngLat = (point: Point, mapSize?: MapSize): LatLng => {
   if (!isMapSizeValid || !mapSize) {
     return LATLNG_FALLBACK;
   }
-
   const { x: xOffset, y: yOffset } = getPointOffset(point, mapSize);
   const pixelsPerLonDegree = mapSize.tileSize / FULL_CIRCLE_DEGREES;
   const pixelsPerLonRadian = mapSize.tileSize / (2 * Math.PI);
-- 
GitLab