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
This commit is contained in:
Drew Slobodnjak
2025-03-07 13:16:17 -08:00
committed by GitHub
parent b26fdf8f5e
commit f0a8e86c28
8 changed files with 325 additions and 45 deletions
@@ -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!
}
},
@@ -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();
});
});
@@ -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) {
@@ -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;
}
@@ -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 });
});
});
@@ -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;
}
@@ -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();
@@ -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;