From f0a8e86c28fcc871628e444406f2bbb2ab0f26f4 Mon Sep 17 00:00:00 2001 From: Drew Slobodnjak <60050885+drew08t@users.noreply.github.com> Date: Fri, 7 Mar 2025 13:16:17 -0800 Subject: [PATCH] Geomap: WebGL for Marker Layer (#95457) * Geomap: Implement webgl for marker layer * Cover rgb color formatting * Adjust size * Adjust size and leave todo for adjustment * Add custom icon example * Use prepareSVG to handle custom icons * Apply icon offset * Add example of text labels for fixed case * Add text canvas layer and layerGroup * Use textMarker for text layer style * Fix geometry calcs * Only include text layer if needed * Remove extra line * Move color functions to utils * Pass webGL bool to marker maker * Move webgl symbol style to marker maker * Get hit detection working for webgl layers * Improve icon hit detection * Improve text performance * Fix types and simplify webgl styling * Simplify webgl regular shape lookup * Add comments * Set fallback color to white * Fix data fit for initial view * Update color utils to support colors with alpha * Add tests for color value function * Add tests for getWebGLStyle function * Clean up comments * Only show text if no symbol is specified * Remove size multiplier from webgl implementation * Add size multiplier back for now --- .../panel/geomap/layers/data/markersLayer.tsx | 100 +++++++++------- .../panel/geomap/style/markers.test.ts | 107 ++++++++++++++++++ .../app/plugins/panel/geomap/style/markers.ts | 56 ++++++++- .../app/plugins/panel/geomap/style/types.ts | 7 ++ .../plugins/panel/geomap/style/utils.test.ts | 22 +++- .../app/plugins/panel/geomap/style/utils.ts | 58 ++++++++++ .../panel/geomap/utils/getLayersExtent.ts | 5 +- .../app/plugins/panel/geomap/utils/layers.ts | 15 +++ 8 files changed, 325 insertions(+), 45 deletions(-) create mode 100644 public/app/plugins/panel/geomap/style/markers.test.ts diff --git a/public/app/plugins/panel/geomap/layers/data/markersLayer.tsx b/public/app/plugins/panel/geomap/layers/data/markersLayer.tsx index 9e4ef519d24..6b5339755df 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 = { }; 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(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(1); @@ -85,37 +93,8 @@ export const markersLayer: MapLayerRegistryItem = { legend = ; } - 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 = { 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 00000000000..cd7e5acf452 --- /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(''), + // 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(''), + 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 293ed5d741b..04772af1189 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 = { + 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 { +async function prepareSVG(url: string, size?: number, backgroundOpacity?: number): Promise { return fetch(url, { method: 'GET' }) .then((res) => { return res.text(); @@ -274,6 +281,23 @@ async function prepareSVG(url: string, size?: number): Promise { 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 { + // 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 { if (!symbol) { diff --git a/public/app/plugins/panel/geomap/style/types.ts b/public/app/plugins/panel/geomap/style/types.ts index 7d7d35af89a..a1e7329ee33 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 2b1e1f78e35..ed5bb4365d6 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 8116fdead31..9e66fc9a4ee 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 ed5a46442d9..90d733812be 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 834517fa7df..c1bf439f9d0 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;