Repository: grafana/grafana
Pull request #95457: Geomap: WebGL for Marker Layer
Author: login: drew08t
Labels: name: area/frontend
name: add to changelog
name: no stalebot
description: exempt from stalebot
name: area/panel/geomap
name: no-backport
description: Skip backport of PR
Created: 2024-10-28T00:33:24Z | Merged: 2025-03-07T21:16:18Z

Move marker layer from vector image to webgl.
Before (10k styled markers):
![main_10k_lq](https://github.com/user-attachments/assets/8e40f0e4-594e-412a-ace1-ed9e1f483fdb)

After (10k, 50k, 100k, 500k styled markers):
![webgl_10k_lq](https://github.com/user-attachments/assets/0dd23c57-b8bb-4c44-bbaf-2c365c5cbdeb)
![webgl_50k_lq](https://github.com/user-attachments/assets/73a3be1f-f873-486f-a572-ddd9b1482aac)
![webgl_100k_lq](https://github.com/user-attachments/assets/2abbf7c0-19ee-4be5-8cc2-0ee03a63670d)
![webgl_500k_lq](https://github.com/user-attachments/assets/0378d1c4-0be5-4347-ad37-f962865c7a37)

- [x] Color
- [x] Size
- [x] Rotation
- [x] Opacity
- [x] Icon Type
- [x] Icon Alignment
- [x] Text
- [x] Hit Detection
- [x] Hit Detection ~~Box~~ Circle for WebGL Layers
- [x] Basic Types
- [x] Robust Icon Type
- [x] Data Fit for Initial View - see https://github.com/grafana/grafana/pull/101391
- [x] Tests
- [x] ~~Feature Flag?~~ team consensus reached to not use a feature flag
- [x] What's New https://github.com/grafana/website/pull/24372

closes https://github.com/grafana/support-escalations/issues/12263, closes 
 https://github.com/grafana/grafana/issues/88317, closes https://github.com/grafana/support-escalations/issues/12719


Diff:
diff --git a/public/app/plugins/panel/geomap/layers/data/markersLayer.tsx b/public/app/plugins/panel/geomap/layers/data/markersLayer.tsx
index 9e4ef519d246a..6b5339755df62 100644
--- a/public/app/plugins/panel/geomap/layers/data/markersLayer.tsx
+++ b/public/app/plugins/panel/geomap/layers/data/markersLayer.tsx
@@ -1,9 +1,11 @@
-import { isNumber } from 'lodash';
-import { FeatureLike } from 'ol/Feature';
 import Map from 'ol/Map';
-import VectorImage from 'ol/layer/VectorImage';
+import { Point } from 'ol/geom';
+import { VectorImage } from 'ol/layer';
+import LayerGroup from 'ol/layer/Group';
+import WebGLPointsLayer from 'ol/layer/WebGLPoints.js';
 import { ReactNode } from 'react';
 import { ReplaySubject } from 'rxjs';
+import tinycolor from 'tinycolor2';
 
 import {
   MapLayerRegistryItem,
@@ -19,9 +21,10 @@ import { getLocationMatchers } from 'app/features/geo/utils/location';
 import { MarkersLegend, MarkersLegendProps } from '../../components/MarkersLegend';
 import { ObservablePropsWrapper } from '../../components/ObservablePropsWrapper';
 import { StyleEditor } from '../../editor/StyleEditor';
-import { defaultStyleConfig, StyleConfig } from '../../style/types';
-import { getStyleConfigState } from '../../style/utils';
-import { getStyleDimension} from '../../utils/utils';
+import { getWebGLStyle, textMarker } from '../../style/markers';
+import { DEFAULT_SIZE, defaultStyleConfig, StyleConfig } from '../../style/types';
+import { getDisplacement, getRGBValues, getStyleConfigState, styleUsesText } from '../../style/utils';
+import { getStyleDimension } from '../../utils/utils';
 
 // Configuration options for Circle overlays
 export interface MarkersConfig {
@@ -72,11 +75,16 @@ export const markersLayer: MapLayerRegistryItem<MarkersConfig> = {
     };
 
     const style = await getStyleConfigState(config.style);
+    const symbol = config.style.symbol?.fixed;
+    const webGLStyle = await getWebGLStyle(symbol, config.style.opacity);
+    const hasText = styleUsesText(config.style);
     const location = await getLocationMatchers(options.location);
-    const source = new FrameVectorSource(location);
-    const vectorLayer = new VectorImage({
-      source,
-      declutter: false // TODO consider making this an option or explore grouping strategies
+    const source = new FrameVectorSource<Point>(location);
+    const symbolLayer = new WebGLPointsLayer({ source, style: webGLStyle });
+    const textLayer = new VectorImage({ source, declutter: true });
+    const layers = new LayerGroup({
+      // If text and no symbol, only show text - fall back on default symbol
+      layers: hasText && symbol ? [symbolLayer, textLayer] : hasText && !symbol ? [textLayer] : [symbolLayer],
     });
 
     const legendProps = new ReplaySubject<MarkersLegendProps>(1);
@@ -85,37 +93,8 @@ export const markersLayer: MapLayerRegistryItem<MarkersConfig> = {
       legend = <ObservablePropsWrapper watch={legendProps} initialSubProps={{}} child={MarkersLegend} />;
     }
 
-    if (!style.fields) {
-      // Set a global style
-      vectorLayer.setStyle(style.maker(style.base));
-    } else {
-      vectorLayer.setStyle((feature: FeatureLike) => {
-        const idx: number = feature.get('rowIndex');
-        const dims = style.dims;
-        if (!dims || !isNumber(idx)) {
-          return style.maker(style.base);
-        }
-
-        const values = { ...style.base };
-
-        if (dims.color) {
-          values.color = dims.color.get(idx);
-        }
-        if (dims.size) {
-          values.size = dims.size.get(idx);
-        }
-        if (dims.text) {
-          values.text = dims.text.get(idx);
-        }
-        if (dims.rotation) {
-          values.rotation = dims.rotation.get(idx);
-        }
-        return style.maker(values);
-      });
-    }
-
     return {
-      init: () => vectorLayer,
+      init: () => layers,
       legend: legend,
       update: (data: PanelData) => {
         if (!data.series?.length) {
@@ -132,11 +111,50 @@ export const markersLayer: MapLayerRegistryItem<MarkersConfig> = {
               styleConfig: style,
               size: style.dims?.size,
               layerName: options.name,
-              layer: vectorLayer,
+              layer: symbolLayer,
             });
           }
 
           source.update(frame);
+          source.forEachFeature((feature) => {
+            const idx: number = feature.get('rowIndex');
+            const dims = style.dims;
+            const values = { ...style.base };
+
+            if (dims?.color) {
+              values.color = dims.color.get(idx);
+            }
+            if (dims?.size) {
+              values.size = dims.size.get(idx);
+            }
+            if (dims?.text) {
+              values.text = dims.text.get(idx);
+            }
+            if (dims?.rotation) {
+              values.rotation = dims.rotation.get(idx);
+            }
+            const colorString = tinycolor(theme.visualization.getColorByName(values.color)).toString();
+            const colorValues = getRGBValues(colorString);
+
+            const radius = values.size ?? DEFAULT_SIZE;
+            const displacement = getDisplacement(values.symbolAlign ?? defaultStyleConfig.symbolAlign, radius);
+
+            // WebGLPointsLayer uses style expressions instead of style functions
+            feature.setProperties({ red: colorValues?.r ?? 255 });
+            feature.setProperties({ green: colorValues?.g ?? 255 });
+            feature.setProperties({ blue: colorValues?.b ?? 255 });
+            feature.setProperties({ size: (values.size ?? 1) * 2 }); // TODO unify sizing across all source types
+            feature.setProperties({ rotation: ((values.rotation ?? 0) * Math.PI) / 180 });
+            feature.setProperties({ opacity: (values.opacity ?? 1) * (colorValues?.a ?? 1) });
+            feature.setProperties({ offsetX: displacement[0] });
+            feature.setProperties({ offsetY: displacement[1] });
+
+            // Set style to be used by VectorLayer (text only)
+            if (hasText) {
+              const textStyle = textMarker(values);
+              feature.setStyle(textStyle);
+            }
+          });
           break; // Only the first frame for now!
         }
       },
diff --git a/public/app/plugins/panel/geomap/style/markers.test.ts b/public/app/plugins/panel/geomap/style/markers.test.ts
new file mode 100644
index 0000000000000..cd7e5acf45276
--- /dev/null
+++ b/public/app/plugins/panel/geomap/style/markers.test.ts
@@ -0,0 +1,107 @@
+import { getPublicOrAbsoluteUrl } from 'app/features/dimensions';
+
+import { getWebGLStyle } from './markers';
+
+// Mock dependencies
+jest.mock('app/features/dimensions', () => ({
+  getPublicOrAbsoluteUrl: jest.fn(),
+}));
+
+describe('getWebGLStyle', () => {
+  beforeEach(() => {
+    jest.clearAllMocks();
+  });
+
+  it('returns default circle style when no symbol is provided', async () => {
+    const result = await getWebGLStyle();
+    expect(result).toEqual({
+      symbol: {
+        symbolType: 'circle',
+        size: ['get', 'size', 'number'],
+        color: ['color', ['get', 'red'], ['get', 'green'], ['get', 'blue']],
+        offset: ['array', ['get', 'offsetX'], ['get', 'offsetY']],
+        rotation: ['get', 'rotation', 'number'],
+        opacity: ['get', 'opacity', 'number'],
+      },
+    });
+  });
+
+  it('returns circle style for known WebGL regular shape', async () => {
+    const result = await getWebGLStyle('img/icons/marker/circle.svg');
+    if (result.symbol) {
+      expect(result.symbol.symbolType).toBe('circle');
+      expect(result.symbol).not.toHaveProperty('src');
+    }
+  });
+
+  it('returns square style for known WebGL regular shape', async () => {
+    const result = await getWebGLStyle('img/icons/marker/square.svg');
+    if (result.symbol) {
+      expect(result.symbol.symbolType).toBe('square');
+      expect(result.symbol).not.toHaveProperty('src');
+    }
+  });
+
+  it('returns triangle style for known WebGL regular shape', async () => {
+    const result = await getWebGLStyle('img/icons/marker/triangle.svg');
+    if (result.symbol) {
+      expect(result.symbol.symbolType).toBe('triangle');
+      expect(result.symbol).not.toHaveProperty('src');
+    }
+  });
+
+  it('returns image style with src for custom SVG symbol', async () => {
+    (getPublicOrAbsoluteUrl as jest.Mock).mockReturnValue('test.svg');
+    global.fetch = jest.fn(() =>
+      Promise.resolve({
+        text: () => Promise.resolve('<svg width="100" height="100"></svg>'),
+        // Add minimal Response properties to satisfy TypeScript
+        ok: true,
+        status: 200,
+        headers: new Headers(),
+      } as Response)
+    );
+    const result = await getWebGLStyle('test.svg');
+    if (result.symbol) {
+      expect(result.symbol.symbolType).toBe('image');
+      expect(result.symbol.src).toContain('data:image/svg+xml');
+    }
+  });
+
+  it('includes background circle with opacity-adjusted stroke when opacity is provided', async () => {
+    (getPublicOrAbsoluteUrl as jest.Mock).mockReturnValue('custom.svg');
+    global.fetch = jest.fn(() =>
+      Promise.resolve({
+        text: () => Promise.resolve('<svg width="100" height="100" viewBox="0 0 100 100"></svg>'),
+        ok: true,
+        status: 200,
+        headers: new Headers(),
+      } as Response)
+    );
+    const result = await getWebGLStyle('custom.svg', 0.5);
+    if (result.symbol?.src) {
+      expect(result.symbol.symbolType).toBe('image');
+      expect(result.symbol.src).toContain('circle');
+      const decodedSrc = decodeURIComponent(result.symbol.src);
+      expect(decodedSrc).toContain('stroke="rgba(255,255,255,0.2)"'); // 0.1 / 0.5 = 0.2
+    }
+  });
+
+  it('handles fetch error gracefully', async () => {
+    // Mock console.error to suppress output and verify it's called
+    const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
+
+    (getPublicOrAbsoluteUrl as jest.Mock).mockReturnValue('error.svg');
+    global.fetch = jest.fn(() => Promise.reject(new Error('Fetch failed')));
+    const result = await getWebGLStyle('error.svg');
+    if (result.symbol) {
+      expect(result.symbol.symbolType).toBe('image');
+      expect(result.symbol.src).toBe(''); // Empty SVG
+    }
+    // Verify console.error was called with the expected error
+    expect(consoleErrorSpy).toHaveBeenCalledWith(new Error('Fetch failed'));
+
+    // Clean up the spy
+    consoleErrorSpy.mockRestore();
+  });
+});
diff --git a/public/app/plugins/panel/geomap/style/markers.ts b/public/app/plugins/panel/geomap/style/markers.ts
index 293ed5d741bd8..04772af1189a9 100644
--- a/public/app/plugins/panel/geomap/style/markers.ts
+++ b/public/app/plugins/panel/geomap/style/markers.ts
@@ -1,4 +1,5 @@
 import { Fill, RegularShape, Stroke, Circle, Style, Icon, Text } from 'ol/style';
+import { LiteralStyle } from 'ol/style/literal';
 import tinycolor from 'tinycolor2';
 
 import { Registry, RegistryItem, textUtil } from '@grafana/data';
@@ -31,6 +32,12 @@ const MarkerShapePath = {
   x: 'img/icons/marker/x-mark.svg',
 };
 
+const WebGLRegularShapes: Record<string, string> = {
+  circle: 'img/icons/marker/circle.svg',
+  square: 'img/icons/marker/square.svg',
+  triangle: 'img/icons/marker/triangle.svg',
+};
+
 export function getFillColor(cfg: StyleConfigValues) {
   const opacity = cfg.opacity == null ? 0.8 : cfg.opacity;
   if (opacity === 1) {
@@ -251,7 +258,7 @@ const makers: SymbolMaker[] = [
   },
 ];
 
-async function prepareSVG(url: string, size?: number): Promise<string> {
+async function prepareSVG(url: string, size?: number, backgroundOpacity?: number): Promise<string> {
   return fetch(url, { method: 'GET' })
     .then((res) => {
       return res.text();
@@ -274,6 +281,23 @@ async function prepareSVG(url: string, size?: number): Promise<string> {
       svg.setAttribute('fill', '#fff');
       svg.setAttribute('width', `${width}px`);
       svg.setAttribute('height', `${height}px`);
+
+      // add a mostly transparent circle behind the icon for webGL hit detection
+      // TODO open layers discards fully transparent elements for hit detection
+      if (backgroundOpacity) {
+        const viewBox = svg.getAttribute('viewBox')?.split(' ') ?? [0, 0, width, height];
+        const viewCenterX = Number(viewBox[2]) / 2;
+        const viewCenterY = Number(viewBox[3]) / 2;
+        const circleElement = doc.createElementNS('http://www.w3.org/2000/svg', 'circle');
+        circleElement.setAttribute('cx', viewCenterX.toString());
+        circleElement.setAttribute('cy', viewCenterY.toString());
+        circleElement.setAttribute('fill', 'none');
+        circleElement.setAttribute('r', (viewCenterX / 2).toString());
+        circleElement.setAttribute('stroke', `rgba(255,255,255,${backgroundOpacity})`);
+        circleElement.setAttribute('stroke-width', viewCenterX.toString());
+        svg.prepend(circleElement);
+      }
+
       const svgString = new XMLSerializer().serializeToString(svg);
       const svgURI = encodeURIComponent(svgString);
       return `data:image/svg+xml,${svgURI}`;
@@ -295,6 +319,36 @@ export function getMarkerAsPath(shape?: string): string | undefined {
   return undefined;
 }
 
+// Returns literal style for WebGL markers
+export async function getWebGLStyle(symbol?: string, opacity?: number): Promise<LiteralStyle> {
+  // style expressions
+  const symbolStyle: LiteralStyle = {
+    symbol: {
+      symbolType: 'circle',
+      size: ['get', 'size', 'number'],
+      color: ['color', ['get', 'red'], ['get', 'green'], ['get', 'blue']],
+      offset: ['array', ['get', 'offsetX'], ['get', 'offsetY']],
+      rotation: ['get', 'rotation', 'number'],
+      opacity: ['get', 'opacity', 'number'],
+    },
+  };
+  // set symbolType and src if a symbol is provided
+  if (symbol && symbolStyle.symbol) {
+    const imageString = 'image';
+    const symbolType = Object.keys(WebGLRegularShapes).find((key) => WebGLRegularShapes[key] === symbol) ?? imageString;
+    symbolStyle.symbol = { ...symbolStyle.symbol, symbolType };
+    if (symbolType === imageString) {
+      const backgroundOpacity = opacity === 0 ? 0 : 0.1 / (opacity ?? 1);
+      symbolStyle.symbol = {
+        ...symbolStyle.symbol,
+        src: await prepareSVG(getPublicOrAbsoluteUrl(symbol), undefined, backgroundOpacity),
+      };
+    }
+  }
+
+  return symbolStyle;
+}
+
 // Will prepare symbols as necessary
 export async function getMarkerMaker(symbol?: string, hasTextLabel?: boolean): Promise<StyleMaker> {
   if (!symbol) {
diff --git a/public/app/plugins/panel/geomap/style/types.ts b/public/app/plugins/panel/geomap/style/types.ts
index 7d7d35af89ac7..a1e7329ee330c 100644
--- a/public/app/plugins/panel/geomap/style/types.ts
+++ b/public/app/plugins/panel/geomap/style/types.ts
@@ -171,3 +171,10 @@ export interface StyleConfigState {
  * Given values create a style
  */
 export type StyleMaker = (values: StyleConfigValues) => Style | Style[];
+
+export interface ColorValue {
+  r: number;
+  g: number;
+  b: number;
+  a?: number;
+}
diff --git a/public/app/plugins/panel/geomap/style/utils.test.ts b/public/app/plugins/panel/geomap/style/utils.test.ts
index 2b1e1f78e35c5..ed5bb4365d67e 100644
--- a/public/app/plugins/panel/geomap/style/utils.test.ts
+++ b/public/app/plugins/panel/geomap/style/utils.test.ts
@@ -1,7 +1,7 @@
 import { ResourceDimensionMode } from '@grafana/schema';
 
 import { HorizontalAlign, VerticalAlign, StyleConfig, SymbolAlign } from './types';
-import { getDisplacement, getStyleConfigState } from './utils';
+import { getDisplacement, getRGBValues, getStyleConfigState } from './utils';
 
 describe('style utils', () => {
   it('should fill in default values', async () => {
@@ -74,4 +74,24 @@ describe('style utils', () => {
     const displacement = getDisplacement(symbolAlign, radius);
     expect(displacement).toEqual([0, 0]);
   });
+  it('should return correct color values for hex default color', async () => {
+    const colorString = '#37872d';
+    const colorValues = getRGBValues(colorString);
+    expect(colorValues).toEqual({ r: 55, g: 135, b: 45 });
+  });
+  it('should return correct color values for rgb color', async () => {
+    const colorString = 'rgb(242, 73, 92)';
+    const colorValues = getRGBValues(colorString);
+    expect(colorValues).toEqual({ r: 242, g: 73, b: 92 });
+  });
+  it('should return correct color values for rgba color', async () => {
+    const colorString = 'rgba(90, 0, 135, 0.5)';
+    const colorValues = getRGBValues(colorString);
+    expect(colorValues).toEqual({ r: 90, g: 0, b: 135, a: 0.5 });
+  });
+  it('should return correct color values for transparent color', async () => {
+    const colorString = 'rgba(0, 0, 0, 0)';
+    const colorValues = getRGBValues(colorString);
+    expect(colorValues).toEqual({ r: 0, g: 0, b: 0, a: 0 });
+  });
 });
diff --git a/public/app/plugins/panel/geomap/style/utils.ts b/public/app/plugins/panel/geomap/style/utils.ts
index 8116fdead31d1..9e66fc9a4ee67 100644
--- a/public/app/plugins/panel/geomap/style/utils.ts
+++ b/public/app/plugins/panel/geomap/style/utils.ts
@@ -10,6 +10,7 @@ import {
   StyleConfigFields,
   StyleConfigState,
   SymbolAlign,
+  ColorValue,
 } from './types';
 
 /** Indicate if the style wants to show text values */
@@ -91,3 +92,60 @@ export function getDisplacement(symbolAlign: SymbolAlign, radius: number) {
   }
   return displacement;
 }
+
+export function getRGBValues(colorString: string): ColorValue | null {
+  // Check if it's a hex color
+  if (colorString.startsWith('#')) {
+    return getRGBFromHex(colorString);
+  }
+
+  // Check if it's an RGB color
+  else if (colorString.startsWith('rgb')) {
+    return getRGBFromRGBString(colorString);
+  }
+
+  // Handle other color formats if needed
+  else {
+    console.warn(`Unsupported color format: ${colorString}`);
+  }
+  return null;
+}
+
+function getRGBFromHex(hexColor: string): ColorValue {
+  // Remove the '#' character
+  hexColor = hexColor.slice(1);
+
+  // Convert hex to decimal values
+  const r = parseInt(hexColor.slice(0, 2), 16);
+  const g = parseInt(hexColor.slice(2, 4), 16);
+  const b = parseInt(hexColor.slice(4, 6), 16);
+
+  return { r, g, b };
+}
+
+function getRGBFromRGBString(rgbString: string): ColorValue | null {
+  // Use regex to extract the numbers, supporting both rgb(r,g,b) and rgba(r,g,b,a) formats
+  const matches = rgbString.match(/\d+\.?\d*/g);
+
+  if (matches) {
+    if (matches.length === 3) {
+      return {
+        r: parseInt(matches[0], 10),
+        g: parseInt(matches[1], 10),
+        b: parseInt(matches[2], 10),
+      };
+    } else if (matches.length === 4) {
+      return {
+        r: parseInt(matches[0], 10),
+        g: parseInt(matches[1], 10),
+        b: parseInt(matches[2], 10),
+        a: parseFloat(matches[3]), // Using parseFloat for alpha as it can be decimal (0-1)
+      };
+    } else {
+      console.warn(`Unsupported color format: ${rgbString}`);
+    }
+  } else {
+    console.warn(`Unsupported color format: ${rgbString}`);
+  }
+  return null;
+}
diff --git a/public/app/plugins/panel/geomap/utils/getLayersExtent.ts b/public/app/plugins/panel/geomap/utils/getLayersExtent.ts
index ed5a46442d9e2..90d733812be82 100644
--- a/public/app/plugins/panel/geomap/utils/getLayersExtent.ts
+++ b/public/app/plugins/panel/geomap/utils/getLayersExtent.ts
@@ -2,6 +2,7 @@ import { createEmpty, extend, Extent } from 'ol/extent';
 import LayerGroup from 'ol/layer/Group';
 import VectorLayer from 'ol/layer/Vector';
 import VectorImage from 'ol/layer/VectorImage';
+import WebGLPointsLayer from 'ol/layer/WebGLPoints.js';
 
 import { MapLayerState } from '../types';
 
@@ -51,9 +52,9 @@ export function getLayerGroupExtent(lg: LayerGroup, lastOnly: boolean) {
   return lg
     .getLayers()
     .getArray()
-    .filter((l) => l instanceof VectorLayer || l instanceof VectorImage)
+    .filter((l) => l instanceof VectorLayer || l instanceof VectorImage || l instanceof WebGLPointsLayer)
     .map((l) => {
-      if (l instanceof VectorLayer || l instanceof VectorImage) {
+      if (l instanceof VectorLayer || l instanceof VectorImage || l instanceof WebGLPointsLayer) {
         if (lastOnly) {
           // Return last coordinate only
           const feat = l.getSource().getFeatures();
diff --git a/public/app/plugins/panel/geomap/utils/layers.ts b/public/app/plugins/panel/geomap/utils/layers.ts
index 834517fa7df5a..c1bf439f9d022 100644
--- a/public/app/plugins/panel/geomap/utils/layers.ts
+++ b/public/app/plugins/panel/geomap/utils/layers.ts
@@ -1,5 +1,7 @@
 import { Map as OpenLayersMap } from 'ol';
 import { FeatureLike } from 'ol/Feature';
+import LayerGroup from 'ol/layer/Group';
+import WebGLPointsLayer from 'ol/layer/WebGLPoints';
 import { Subject } from 'rxjs';
 
 import { getFrameMatchers, MapLayerHandler, MapLayerOptions, PanelData, textUtil } from '@grafana/data';
@@ -149,6 +151,19 @@ export async function initLayer(
   // eslint-disable-next-line
   (state.layer as any).__state = state;
 
+  // Pass state into WebGLPointsLayers contained in a LayerGroup
+  if (layer instanceof LayerGroup) {
+    layer
+      .getLayers()
+      .getArray()
+      .forEach((layer) => {
+        if (layer instanceof WebGLPointsLayer) {
+          // eslint-disable-next-line
+          (layer as any).__state = state;
+        }
+      });
+  }
+
   applyLayerFilter(handler, options, panel.props.data);
 
   return state;


Reviews:
user: login: drew08t
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-01-15T00:17:45Z
user: login: Develer
body: Looks like I found a small regression. When I clean a marker symbol and a text label exists - there should be no markers on map.

Before:

https://github.com/user-attachments/assets/caa66897-b8a8-44f0-91f4-e21132f74ec5

After:

https://github.com/user-attachments/assets/ca0f0527-467c-4223-bd08-9db01730e49c



state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-05T13:20:20Z
user: login: Develer
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-05T13:33:02Z
user: login: Develer
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-05T14:13:21Z
user: login: drew08t
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-05T18:46:43Z
user: login: Develer
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-06T12:22:40Z
user: login: Develer
body: LGTM 🚀
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-06T12:23:15Z
user: login: drew08t
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-06T20:24:27Z
user: login: adela-almasan
body: Looks good! 🚀  As we talked, let's add a follow-up to look a bit more into that marker size difference.
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-06T23:34:38Z

Review comments:
diff_hunk: @@ -72,11 +76,27 @@ export const markersLayer: MapLayerRegistryItem<MarkersConfig> = {
     };
 
     const style = await getStyleConfigState(config.style);
+    //TODO make this more robust and handle webgl supported shapes: circle, square, triangle, RegularShape
+    const src = await prepareSVG(getPublicOrAbsoluteUrl(style.config.symbol?.fixed ?? ''));
path: public/app/plugins/panel/geomap/layers/data/markersLayer.tsx
user: login: drew08t
body: Need to address this TODO still. Default symbol also needs to be handled.
created_at: 2025-01-15T00:17:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -91,3 +92,60 @@ export function getDisplacement(symbolAlign: SymbolAlign, radius: number) {
   }
   return displacement;
 }
+
+export function getRGBValues(colorString: string): ColorValue | null {
+  // Check if it's a hex color
+  if (colorString.startsWith('#')) {
+    return getRGBFromHex(colorString);
+  }
+
+  // Check if it's an RGB color
+  else if (colorString.startsWith('rgb')) {
+    return getRGBFromRGBString(colorString);
+  }
+
+  // Handle other color formats if needed
+  else {
+    console.warn(`Unsupported color format: ${colorString}`);
+  }
+  return null;
+}
+
+function getRGBFromHex(hexColor: string): ColorValue {
+  // Remove the '#' character
+  hexColor = hexColor.slice(1);
+
+  // Convert hex to decimal values
+  const r = parseInt(hexColor.slice(0, 2), 16);
+  const g = parseInt(hexColor.slice(2, 4), 16);
+  const b = parseInt(hexColor.slice(4, 6), 16);
+
+  return { r, g, b };
+}
+
+function getRGBFromRGBString(rgbString: string): ColorValue | null {
+  // Use regex to extract the numbers, supporting both rgb(r,g,b) and rgba(r,g,b,a) formats
+  const matches = rgbString.match(/\d+\.?\d*/g);
+
+  if (matches) {
+    if (matches.length === 3) {
+      return {
+        r: parseInt(matches[0], 10),
+        g: parseInt(matches[1], 10),
+        b: parseInt(matches[2], 10),
+      };
+    } else if (matches.length === 4) {
+      return {
+        r: parseInt(matches[0], 10),
+        g: parseInt(matches[1], 10),
+        b: parseInt(matches[2], 10),
+        a: parseFloat(matches[3]), // Using parseFloat for alpha as it can be decimal (0-1)
+      };
+    } else {
+      console.warn(`Unsupported color format: ${rgbString}`);
+    }
+  } else {
+    console.warn(`Unsupported color format: ${rgbString}`);
+  }
+  return null;
path: public/app/plugins/panel/geomap/style/utils.ts
user: login: Develer
body: maybe switch construction would be more clean here, but only if you want 😉
created_at: 2025-03-05T13:33:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -132,11 +110,50 @@ export const markersLayer: MapLayerRegistryItem<MarkersConfig> = {
               styleConfig: style,
               size: style.dims?.size,
               layerName: options.name,
-              layer: vectorLayer,
+              layer: symbolLayer,
             });
           }
 
           source.update(frame);
+          source.forEachFeature((feature) => {
path: public/app/plugins/panel/geomap/layers/data/markersLayer.tsx
user: login: Develer
body: oh I see, this loop replaced `vectorLayer.setStyle` right?
created_at: 2025-03-05T14:13:21Z
author_association: CONTRIBUTOR
diff_hunk: @@ -132,11 +110,50 @@ export const markersLayer: MapLayerRegistryItem<MarkersConfig> = {
               styleConfig: style,
               size: style.dims?.size,
               layerName: options.name,
-              layer: vectorLayer,
+              layer: symbolLayer,
             });
           }
 
           source.update(frame);
+          source.forEachFeature((feature) => {
path: public/app/plugins/panel/geomap/layers/data/markersLayer.tsx
user: login: drew08t
body: Correct, we are adding properties to each feature because webgl does not support style functions.
created_at: 2025-03-05T18:46:43Z
author_association: CONTRIBUTOR
diff_hunk: @@ -75,15 +75,16 @@ export const markersLayer: MapLayerRegistryItem<MarkersConfig> = {
     };
 
     const style = await getStyleConfigState(config.style);
-
-    const webGLStyle = await getWebGLStyle(config.style.symbol?.fixed, config.style.opacity);
+    const symbol = config.style.symbol?.fixed;
+    const webGLStyle = await getWebGLStyle(symbol, config.style.opacity);
     const hasText = styleUsesText(config.style);
     const location = await getLocationMatchers(options.location);
     const source = new FrameVectorSource<Point>(location);
     const symbolLayer = new WebGLPointsLayer({ source, style: webGLStyle });
     const textLayer = new VectorImage({ source, declutter: true });
     const layers = new LayerGroup({
-      layers: hasText ? [symbolLayer, textLayer] : [symbolLayer],
+      // If text and no symbol, only show text - fall back on default symbol
+      layers: hasText && symbol ? [symbolLayer, textLayer] : hasText && !symbol ? [textLayer] : [symbolLayer],
path: public/app/plugins/panel/geomap/layers/data/markersLayer.tsx
user: login: Develer
body: this part is a bit difficult to read, could you add some brackets?

or maybe re-organize a bit: `hasText` exists in both sides, then we check `symbol` variable and if `symbol` then we have `[symbolLayer, textLayer]` if not, then `[textLayer]`, in any other cases just `[symbolLayer]`. If I get it right.

something like this?
`hasText ? (symbol ? [symbolLayer, textLayer] : [textLayer]) : [symbolLayer]`

wdyt?
created_at: 2025-03-06T12:22:40Z
author_association: CONTRIBUTOR
diff_hunk: @@ -132,11 +111,50 @@ export const markersLayer: MapLayerRegistryItem<MarkersConfig> = {
               styleConfig: style,
               size: style.dims?.size,
               layerName: options.name,
-              layer: vectorLayer,
+              layer: symbolLayer,
             });
           }
 
           source.update(frame);
+          source.forEachFeature((feature) => {
+            const idx: number = feature.get('rowIndex');
+            const dims = style.dims;
+            const values = { ...style.base };
+
+            if (dims?.color) {
+              values.color = dims.color.get(idx);
+            }
+            if (dims?.size) {
+              values.size = dims.size.get(idx);
+            }
+            if (dims?.text) {
+              values.text = dims.text.get(idx);
+            }
+            if (dims?.rotation) {
+              values.rotation = dims.rotation.get(idx);
+            }
+            const colorString = tinycolor(theme.visualization.getColorByName(values.color)).toString();
+            const colorValues = getRGBValues(colorString);
+
+            const radius = values.size ?? DEFAULT_SIZE;
+            const displacement = getDisplacement(values.symbolAlign ?? defaultStyleConfig.symbolAlign, radius);
+
+            // WebGLPointsLayer uses style expressions instead of style functions
+            feature.setProperties({ red: colorValues?.r ?? 255 });
+            feature.setProperties({ green: colorValues?.g ?? 255 });
+            feature.setProperties({ blue: colorValues?.b ?? 255 });
+            feature.setProperties({ size: (values.size ?? 1) * 2 });
path: public/app/plugins/panel/geomap/layers/data/markersLayer.tsx
user: login: drew08t
body: This ` * 2` is causing the discrepancy. I added this early on because circle markers were not matching the existing size. As it turns out, the old implementation's circle markers are too large (the red square is how big the marker should be, 25px):

![image](https://github.com/user-attachments/assets/700ff6be-d173-4fd1-8e34-9f6535b0f1a7)

After removing this ` * 2`, this is what the markers look like in WebGL:

![image](https://github.com/user-attachments/assets/9719bc31-c6d6-4b45-9c67-d4cdc0fc353c)

What do you think @adela-almasan?


created_at: 2025-03-06T20:24:27Z
author_association: CONTRIBUTOR

PR comments:
user: login: drew08t
created_at: 2024-12-09T16:20:43Z
body: Will need to add text as a separate vectorLayer as it is not supported by `WebGLPointsLayer`.
author_association: CONTRIBUTOR
user: login: drew08t
created_at: 2025-03-05T20:56:03Z
body: > Looks like I found a small regression. When I clean a marker symbol and a text label exists - there should be no markers on map.
> 
> Before:
> 
>  Screen.Recording.2025-03-05.at.14.15.09.mov 
> After:
> 
>  Screen.Recording.2025-03-05.at.14.14.26.mov

Good catch!
author_association: CONTRIBUTOR
user: login: drew08t
created_at: 2025-03-05T23:00:27Z
body: You can use this dashboard for testing with 100k markers.
[Marker Test - 100k-1741215589777.json](https://github.com/user-attachments/files/19098029/Marker.Test.-.100k-1741215589777.json)

author_association: CONTRIBUTOR
user: login: drew08t
created_at: 2025-03-05T23:44:55Z
body: > Looks like I found a small regression. When I clean a marker symbol and a text label exists - there should be no markers on map.
> 
> Before:
> 
>  Screen.Recording.2025-03-05.at.14.15.09.mov 
> After:
> 
>  Screen.Recording.2025-03-05.at.14.14.26.mov

Updated. The correct logic is to show only text if no symbol is selected. If neither text nor a symbol is selected, it should display the default marker. In this case, a circle.
author_association: CONTRIBUTOR
user: login: adela-almasan
created_at: 2025-03-06T17:31:53Z
body: Looks great overall! A small regression - when changing the symbol, there seems to be a difference in size. In the attached screen rec I'm comparing my local (first half) with the same gdev from play. Not a blocker, can be a follow up.

https://github.com/user-attachments/assets/486fb5a4-f6a9-40cc-9ad8-87009d81a6b4


author_association: CONTRIBUTOR
user: login: drew08t
created_at: 2025-03-06T18:25:46Z
body: > Looks great overall! A small regression - when changing the symbol, there seems to be a difference in size. In the attached screen rec I'm comparing my local (first half) with the same gdev from play. Not a blocker, can be a follow up.
> 
>  Screen.Recording.2025-03-06.at.11.00.33.AM.mov

Great catch on this. I took at look at different icons at size = 25 and compared them: 
[markerComparison.pdf](https://github.com/user-attachments/files/19113346/markerComparison.pdf)

Conclusion:
`WebGLRegularShapes` (square, circle, triangle) are consistently too large, except for triangle which is only slightly too large - though that might be an optical illusion because of the bounding box. Maybe we can calculate a size ratio for these regular shapes? There is a performance benefit to using the regular shape instead of sourcing the vector graphic - probably should make these 3 the top 3 default for that reason.

For all other icon types that rely on vector sources, WebGL seems to be consistently larger than it's supposed to be. I will double check the sizing logic.

author_association: CONTRIBUTOR
user: login: ghost
created_at: 2025-03-06T21:16:59Z
body: ## [Codecov](https://codecov.grafana-dev.net/gh/grafana/grafana/pull/95457?dropdown=coverage&src=pr&el=h1) Report
All modified and coverable lines are covered by tests :white_check_mark:

| [Flag](https://codecov.grafana-dev.net/gh/grafana/grafana/pull/95457/flags?src=pr&el=flags) | Coverage Δ | |
|---|---|---|
| [be-unit](https://codecov.grafana-dev.net/gh/grafana/grafana/pull/95457/flags?src=pr&el=flag) | `40.68% <ø> (?)` | |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=grafana#carryforward-flags-in-the-pull-request-comment) to find out more.

author_association: NONE
user: login: drew08t
created_at: 2025-03-06T23:52:15Z
body: > Looks good! 🚀 As we talked, let's add a follow-up to look a bit more into that marker size difference.

I made a follow up issue to [unify marker sizes](https://github.com/grafana/grafana/issues/101749).
author_association: CONTRIBUTOR
user: login: grvsoni
created_at: 2025-03-07T11:17:26Z
body: Hi Team,
Any rough ETA for this getting merged?
author_association: CONTRIBUTOR