Geomap: styleConfig cleanup and symbol caching (#41622)

Co-authored-by: nikki-kiga <42276368+nikki-kiga@users.noreply.github.com>
This commit is contained in:
Ryan McKinley 2021-11-12 11:24:35 -08:00 committed by GitHub
parent dfa14e9500
commit 862054918d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 503 additions and 480 deletions

View File

@ -12,6 +12,7 @@ import { ComparisonOperation, FeatureStyleConfig } from '../../types';
import { Stroke, Style } from 'ol/style';
import { FeatureLike } from 'ol/Feature';
import { GeomapStyleRulesEditor } from '../../editor/GeomapStyleRulesEditor';
import { circleMarker } from '../../style/markers';
export interface GeoJSONMapperConfig {
// URL for a geojson file
src?: string;
@ -77,6 +78,11 @@ export const geojsonMapper: MapLayerRegistryItem<GeoJSONMapperConfig> = {
const vectorLayer = new VectorLayer({
source,
style: (feature: FeatureLike) => {
const type = feature.getGeometry()?.getType();
if (type === 'Point') {
return circleMarker({color:DEFAULT_STYLE_RULE.fillColor});
}
if (feature && config?.styles?.length) {
for (const style of config.styles) {
//check if there is no style rule or if the rule matches feature property
@ -113,6 +119,7 @@ export const geojsonMapper: MapLayerRegistryItem<GeoJSONMapperConfig> = {
options: [
{ label: 'public/maps/countries.geojson', value: 'public/maps/countries.geojson' },
{ label: 'public/maps/usa-states.geojson', value: 'public/maps/usa-states.geojson' },
{ label: 'public/gazetteer/airports.geojson', value: 'public/gazetteer/airports.geojson' },
],
allowCustomValue: true,
},

View File

@ -11,53 +11,29 @@ import Feature from 'ol/Feature';
import { Point } from 'ol/geom';
import * as layer from 'ol/layer';
import * as source from 'ol/source';
import * as style from 'ol/style';
import { dataFrameToPoints, getLocationMatchers } from '../../utils/location';
import {
ColorDimensionConfig,
ScaleDimensionConfig,
getScaledDimension,
getColorDimension,
ResourceDimensionConfig,
ResourceDimensionMode,
ResourceFolderName,
getPublicOrAbsoluteUrl,
} from 'app/features/dimensions';
import { ScaleDimensionEditor, ColorDimensionEditor, ResourceDimensionEditor } from 'app/features/dimensions/editors';
import { ObservablePropsWrapper } from '../../components/ObservablePropsWrapper';
import { MarkersLegend, MarkersLegendProps } from './MarkersLegend';
import { getMarkerFromPath } from '../../utils/regularShapes';
import { ReplaySubject } from 'rxjs';
import { FeaturesStylesBuilderConfig, getFeatures } from '../../utils/getFeatures';
import { StyleMaker, StyleMakerConfig } from '../../types';
import { getSVGUri } from '../../utils/prepareSVG';
import { getMarkerMaker } from '../../style/markers';
import { defaultStyleConfig, StyleConfig } from '../../style/types';
// Configuration options for Circle overlays
export interface MarkersConfig {
size: ScaleDimensionConfig;
color: ColorDimensionConfig;
fillOpacity: number;
style: StyleConfig;
showLegend?: boolean;
markerSymbol: ResourceDimensionConfig;
}
const DEFAULT_SIZE = 5;
const defaultOptions: MarkersConfig = {
size: {
fixed: DEFAULT_SIZE,
min: 2,
max: 15,
},
color: {
fixed: 'dark-green', // picked from theme
},
fillOpacity: 0.4,
style: defaultStyleConfig,
showLegend: true,
markerSymbol: {
mode: ResourceDimensionMode.Fixed,
fixed: 'img/icons/marker/circle.svg',
},
};
export const MARKERS_LAYER_ID = 'markers';
@ -100,10 +76,8 @@ export const markersLayer: MapLayerRegistryItem<MarkersConfig> = {
legend = <ObservablePropsWrapper watch={legendProps} initialSubProps={{}} child={MarkersLegend} />;
}
const markerPath =
getPublicOrAbsoluteUrl(config.markerSymbol?.fixed) ?? getPublicOrAbsoluteUrl('img/icons/marker/circle.svg');
// double to match regularshapes using size as radius
const uri = await getSVGUri(markerPath, config.size.fixed * 2);
const style = config.style ?? defaultStyleConfig;
const markerMaker = await getMarkerMaker(style.symbol?.fixed);
return {
init: () => vectorLayer,
@ -113,32 +87,6 @@ export const markersLayer: MapLayerRegistryItem<MarkersConfig> = {
return; // ignore empty
}
const makeIconStyle = (cfg: StyleMakerConfig) => {
const icon = new style.Style({
image: new style.Icon({
src: uri,
color: cfg.color,
opacity: cfg.opacity,
// scale based on field value
scale: (DEFAULT_SIZE + cfg.size) / 100,
}),
});
// transparent bounding box for featureAtPixel detection
const boundingBox = new style.Style({
image: new style.RegularShape({
fill: new style.Fill({ color: 'rgba(0,0,0,0)' }),
points: 4,
radius: cfg.size,
angle: Math.PI / 4,
}),
});
return [icon, boundingBox]
};
const marker = getMarkerFromPath(config.markerSymbol?.fixed);
const shape: StyleMaker = marker?.make ?? makeIconStyle;
const features: Feature<Point>[] = [];
for (const frame of data.series) {
@ -148,15 +96,15 @@ export const markersLayer: MapLayerRegistryItem<MarkersConfig> = {
continue; // ???
}
const colorDim = getColorDimension(frame, config.color, theme);
const sizeDim = getScaledDimension(frame, config.size);
const opacity = options.config?.fillOpacity ?? defaultOptions.fillOpacity;
const colorDim = getColorDimension(frame, style.color ?? defaultStyleConfig.color, theme);
const sizeDim = getScaledDimension(frame, style.size ?? defaultStyleConfig.size);
const opacity = style?.opacity ?? defaultStyleConfig.opacity;
const featureDimensionConfig: FeaturesStylesBuilderConfig = {
colorDim: colorDim,
sizeDim: sizeDim,
opacity: opacity,
styleMaker: shape,
styleMaker: markerMaker,
};
const frameFeatures = getFeatures(frame, info, featureDimensionConfig);
@ -184,27 +132,22 @@ export const markersLayer: MapLayerRegistryItem<MarkersConfig> = {
registerOptionsUI: (builder) => {
builder
.addCustomEditor({
id: 'config.size',
path: 'config.size',
id: 'config.style.size',
path: 'config.style.size',
name: 'Marker Size',
editor: ScaleDimensionEditor,
settings: {
min: 1,
max: 100, // possible in the UI
},
defaultValue: {
// Configured values
fixed: DEFAULT_SIZE,
min: 1,
max: 20,
},
defaultValue: defaultOptions.style.size,
})
.addCustomEditor({
id: 'config.markerSymbol',
path: 'config.markerSymbol',
id: 'config.style.symbol',
path: 'config.style.symbol',
name: 'Marker Symbol',
editor: ResourceDimensionEditor,
defaultValue: defaultOptions.markerSymbol,
defaultValue: defaultOptions.style.symbol,
settings: {
resourceType: 'icon',
showSourceRadio: false,
@ -212,20 +155,17 @@ export const markersLayer: MapLayerRegistryItem<MarkersConfig> = {
},
})
.addCustomEditor({
id: 'config.color',
path: 'config.color',
id: 'config.style.color',
path: 'config.style.color',
name: 'Marker Color',
editor: ColorDimensionEditor,
settings: {},
defaultValue: {
// Configured values
fixed: 'grey',
},
defaultValue: defaultOptions.style.color,
})
.addSliderInput({
path: 'config.fillOpacity',
path: 'config.style.opacity',
name: 'Fill opacity',
defaultValue: defaultOptions.fillOpacity,
defaultValue: defaultOptions.style.opacity,
settings: {
min: 0,
max: 1,

View File

@ -2,47 +2,43 @@ import { GrafanaTheme2, MapLayerOptions, MapLayerRegistryItem, PanelData, Plugin
import Map from 'ol/Map';
import * as layer from 'ol/layer';
import * as source from 'ol/source';
import * as style from 'ol/style';
import { dataFrameToPoints, getLocationMatchers } from '../../utils/location';
import {
ColorDimensionConfig,
getColorDimension,
getScaledDimension,
getTextDimension,
ScaleDimensionConfig,
TextDimensionConfig,
TextDimensionMode,
} from 'app/features/dimensions';
import { ColorDimensionEditor, ScaleDimensionEditor, TextDimensionEditor } from 'app/features/dimensions/editors';
import { Fill, Stroke } from 'ol/style';
import { FeaturesStylesBuilderConfig, getFeatures } from '../../utils/getFeatures';
import { Feature } from 'ol';
import { Point } from 'ol/geom';
import { StyleMaker, StyleMakerConfig } from '../../types';
import { textMarkerMaker } from '../../style/text';
import { MarkersConfig } from './markersLayer';
interface TextLabelsConfig {
labelText: TextDimensionConfig;
color: ColorDimensionConfig;
fillOpacity: number;
fontSize: ScaleDimensionConfig;
}
export const TEXT_LABELS_LAYER = 'text-labels';
const defaultOptions: TextLabelsConfig = {
labelText: {
// Same configuration
type TextLabelsConfig = MarkersConfig;
const defaultOptions = {
style: {
text: {
fixed: '',
mode: TextDimensionMode.Field,
},
color: {
fixed: 'dark-blue',
},
fillOpacity: 0.6,
fontSize: {
opacity: 1,
size: {
fixed: 10,
min: 5,
max: 100,
},
},
showLegend: false,
};
export const textLabelsLayer: MapLayerRegistryItem<TextLabelsConfig> = {
@ -62,23 +58,6 @@ export const textLabelsLayer: MapLayerRegistryItem<TextLabelsConfig> = {
...options?.config,
};
const fontFamily = theme.typography.fontFamily;
const getTextStyle = (text: string, fillColor: string, fontSize: number) => {
return new style.Text({
text: text,
fill: new Fill({ color: fillColor }),
stroke: new Stroke({ color: fillColor }),
font: `normal ${fontSize}px ${fontFamily}`,
});
};
const getStyle: StyleMaker = (cfg: StyleMakerConfig) => {
return new style.Style({
text: getTextStyle(cfg.text ?? defaultOptions.labelText.fixed, cfg.fillColor, cfg.size),
});
};
return {
init: () => vectorLayer,
update: (data: PanelData) => {
@ -88,6 +67,9 @@ export const textLabelsLayer: MapLayerRegistryItem<TextLabelsConfig> = {
const features: Feature<Point>[] = [];
const style = config.style ?? defaultOptions.style;
for (const frame of data.series) {
const info = dataFrameToPoints(frame, matchers);
if (info.warning) {
@ -95,17 +77,17 @@ export const textLabelsLayer: MapLayerRegistryItem<TextLabelsConfig> = {
return;
}
const colorDim = getColorDimension(frame, config.color, theme);
const textDim = getTextDimension(frame, config.labelText);
const sizeDim = getScaledDimension(frame, config.fontSize);
const opacity = options.config?.fillOpacity ?? defaultOptions.fillOpacity;
const colorDim = getColorDimension(frame, style.color ?? defaultOptions.style.color, theme);
const sizeDim = getScaledDimension(frame, style.size ?? defaultOptions.style.size);
const opacity = style?.opacity ?? defaultOptions.style.opacity;
const textDim = getTextDimension(frame, style.text ?? defaultOptions.style.text );
const featureDimensionConfig: FeaturesStylesBuilderConfig = {
colorDim: colorDim,
sizeDim: sizeDim,
textDim: textDim,
opacity: opacity,
styleMaker: getStyle,
styleMaker: textMarkerMaker,
};
const frameFeatures = getFeatures(frame, info, featureDimensionConfig);
@ -122,22 +104,24 @@ export const textLabelsLayer: MapLayerRegistryItem<TextLabelsConfig> = {
registerOptionsUI: (builder) => {
builder
.addCustomEditor({
id: 'config.labelText',
id: 'config.style.text',
path: 'config.style.text',
name: 'Text label',
path: 'config.labelText',
editor: TextDimensionEditor,
defaultValue: defaultOptions.style.text,
})
.addCustomEditor({
id: 'config.color',
path: 'config.color',
id: 'config.style.color',
path: 'config.style.color',
name: 'Text color',
editor: ColorDimensionEditor,
defaultValue: defaultOptions.style.color,
settings: {},
})
.addSliderInput({
path: 'config.fillOpacity',
path: 'config.style.opacity',
name: 'Text opacity',
defaultValue: defaultOptions.fillOpacity,
defaultValue: defaultOptions.style.opacity,
settings: {
min: 0,
max: 1,
@ -145,14 +129,14 @@ export const textLabelsLayer: MapLayerRegistryItem<TextLabelsConfig> = {
},
})
.addCustomEditor({
id: 'config.fontSize',
path: 'config.fontSize',
id: 'config.style.size',
path: 'config.style.size',
name: 'Text size',
editor: ScaleDimensionEditor,
defaultValue: defaultOptions.style.size,
settings: {
fixed: defaultOptions.fontSize.fixed,
min: defaultOptions.fontSize.min,
max: defaultOptions.fontSize.max,
min: 2,
max: 50,
},
});
},

View File

@ -108,164 +108,64 @@ const simpleWorldmapConfig = {
describe('geomap migrations', () => {
it('updates marker', () => {
const panel = {
id: 2,
gridPos: {
h: 9,
w: 12,
x: 0,
y: 0,
},
const panel = ({
type: 'geomap',
title: 'Panel Title',
fieldConfig: {
defaults: {
thresholds: {
mode: 'absolute',
steps: [
{
color: 'green',
value: null,
},
{
color: 'red',
value: 80,
},
],
},
mappings: [],
color: {
mode: 'thresholds',
},
},
overrides: [],
},
options: {
view: {
id: 'zero',
lat: 0,
lon: 0,
zoom: 1,
},
basemap: {
type: 'default',
config: {},
},
layers: [
{
type: 'markers',
config: {
color: {
fixed: 'dark-green',
},
fillOpacity: 0.4,
markerSymbol: {
fixed: '',
mode: 'fixed',
},
shape: 'circle',
showLegend: true,
size: {
fixed: 5,
max: 15,
min: 2,
max: 15,
field: 'Count',
},
color: {
fixed: 'dark-green',
field: 'Price',
},
fillOpacity: 0.4,
shape: 'triangle',
showLegend: true,
},
location: {
mode: 'auto',
},
type: 'markers',
},
],
controls: {
showZoom: true,
mouseWheelZoom: true,
showAttribution: true,
showScale: false,
showDebug: false,
},
},
pluginVersion: '8.3.0-pre',
datasource: null,
} as PanelModel;
pluginVersion: '8.2.0',
} as any) as PanelModel;
panel.options = mapMigrationHandler(panel);
expect(panel).toMatchInlineSnapshot(`
Object {
"datasource": null,
"fieldConfig": Object {
"defaults": Object {
"color": Object {
"mode": "thresholds",
},
"mappings": Array [],
"thresholds": Object {
"mode": "absolute",
"steps": Array [
Object {
"color": "green",
"value": null,
},
Object {
"color": "red",
"value": 80,
},
],
},
},
"overrides": Array [],
},
"gridPos": Object {
"h": 9,
"w": 12,
"x": 0,
"y": 0,
},
"id": 2,
"options": Object {
"basemap": Object {
"config": Object {},
"type": "default",
},
"controls": Object {
"mouseWheelZoom": true,
"showAttribution": true,
"showDebug": false,
"showScale": false,
"showZoom": true,
},
"layers": Array [
Object {
"config": Object {
"color": Object {
"fixed": "dark-green",
},
"fillOpacity": 0.4,
"markerSymbol": Object {
"fixed": "img/icons/marker/circle.svg",
"mode": "fixed",
},
"showLegend": true,
"size": Object {
"fixed": 5,
"max": 15,
"min": 2,
"style": Object {
"color": Object {
"field": "Price",
"fixed": "dark-green",
},
"opacity": 0.4,
"size": Object {
"field": "Count",
"fixed": 5,
"max": 15,
"min": 2,
},
"symbol": Object {
"fixed": "img/icons/marker/triangle.svg",
"mode": "fixed",
},
},
},
"location": Object {
"mode": "auto",
},
"type": "markers",
},
],
"view": Object {
"id": "zero",
"lat": 0,
"lon": 0,
"zoom": 1,
},
},
"pluginVersion": "8.3.0-pre",
"title": "Panel Title",
"pluginVersion": "8.2.0",
"type": "geomap",
}
`);

View File

@ -1,6 +1,10 @@
import { FieldConfigSource, PanelModel, PanelTypeChangedHandler, Threshold, ThresholdsMode } from '@grafana/data';
import { ResourceDimensionMode } from 'app/features/dimensions';
import { cloneDeep } from 'lodash';
import { MarkersConfig } from './layers/data/markersLayer';
import { getMarkerAsPath } from './style/markers';
import { defaultStyleConfig } from './style/types';
import { GeomapPanelOptions } from './types';
import { markerMakers } from './utils/regularShapes';
import { MapCenterID } from './view';
/**
@ -100,23 +104,38 @@ function asNumber(v: any): number | undefined {
}
export const mapMigrationHandler = (panel: PanelModel): Partial<GeomapPanelOptions> => {
const pluginVersion = panel?.pluginVersion;
if (pluginVersion?.startsWith('8.1') || pluginVersion?.startsWith('8.2') || pluginVersion?.startsWith('8.3')) {
if (panel.options?.layers?.length > 0) {
const pluginVersion = panel?.pluginVersion ?? '';
// before 8.3, only one layer was supported!
if (pluginVersion.startsWith('8.1') || pluginVersion.startsWith('8.2')) {
const layers = panel.options?.layers;
if (layers?.length === 1) {
const layer = panel.options.layers[0];
if (layer?.type === 'markers') {
const shape = layer?.config?.shape;
if (shape) {
const marker = markerMakers.getIfExists(shape);
if (marker?.aliasIds && marker.aliasIds?.length > 0) {
layer.config.markerSymbol = {
fixed: marker.aliasIds[0],
mode: 'fixed',
};
delete layer.config.shape;
}
return { ...panel.options, layers: Object.assign([], ...panel.options.layers, { 0: layer }) };
if (layer?.type === 'markers' && layer.config) {
// Moving style to child object
const oldConfig = layer.config;
const config: MarkersConfig = {
style: cloneDeep(defaultStyleConfig),
showLegend: Boolean(oldConfig.showLegend),
};
if (oldConfig.size) {
config.style.size = oldConfig.size;
}
if (oldConfig.color) {
config.style.color = oldConfig.color;
}
if (oldConfig.fillOpacity) {
config.style.opacity = oldConfig.fillOpacity;
}
const symbol = getMarkerAsPath(oldConfig.shape);
if (symbol) {
config.style.symbol = {
fixed: symbol,
mode: ResourceDimensionMode.Fixed,
};
}
return { ...panel.options, layers: [{ ...layer, config }] };
}
}
}

View File

@ -0,0 +1,257 @@
import { Fill, RegularShape, Stroke, Circle, Style, Icon } from 'ol/style';
import { Registry, RegistryItem } from '@grafana/data';
import { DEFAULT_SIZE, StyleConfigValues, StyleMaker } from './types';
import { getPublicOrAbsoluteUrl } from 'app/features/dimensions';
import tinycolor from 'tinycolor2';
interface SymbolMaker extends RegistryItem {
aliasIds: string[];
make: StyleMaker;
}
enum RegularShapeId {
circle = 'circle',
square = 'square',
triangle = 'triangle',
star = 'star',
cross = 'cross',
x = 'x',
}
const MarkerShapePath = {
circle: 'img/icons/marker/circle.svg',
square: 'img/icons/marker/square.svg',
triangle: 'img/icons/marker/triangle.svg',
star: 'img/icons/marker/star.svg',
cross: 'img/icons/marker/cross.svg',
x: 'img/icons/marker/x-mark.svg',
};
export function getFillColor(cfg: StyleConfigValues) {
const opacity = cfg.opacity == null ? 0.8 : cfg.opacity;
if (opacity === 1) {
return new Fill({ color: cfg.color });
}
if (opacity > 0) {
const color = tinycolor(cfg.color).setAlpha(opacity).toRgbString();
return new Fill({ color });
}
return undefined;
}
export const circleMarker = (cfg: StyleConfigValues) => {
return new Style({
image: new Circle({
stroke: new Stroke({ color: cfg.color, width: cfg.lineWidth ?? 1 }),
fill: getFillColor(cfg),
radius: cfg.size ?? DEFAULT_SIZE,
}),
});
};
// Square and cross
const errorMarker = (cfg: StyleConfigValues) => {
const radius = cfg.size ?? DEFAULT_SIZE;
const stroke = new Stroke({ color: '#F00', width: 1 });
return [
new Style({
image: new RegularShape({
stroke,
points: 4,
radius,
angle: Math.PI / 4,
}),
}),
new Style({
image: new RegularShape({
stroke,
points: 4,
radius,
radius2: 0,
angle: 0,
}),
}),
];
};
const makers: SymbolMaker[] = [
{
id: RegularShapeId.circle,
name: 'Circle',
aliasIds: [MarkerShapePath.circle],
make: circleMarker,
},
{
id: RegularShapeId.square,
name: 'Square',
aliasIds: [MarkerShapePath.square],
make: (cfg: StyleConfigValues) => {
const radius = cfg.size ?? DEFAULT_SIZE;
return new Style({
image: new RegularShape({
stroke: new Stroke({ color: cfg.color, width: cfg.lineWidth ?? 1 }),
fill: getFillColor(cfg),
points: 4,
radius,
angle: Math.PI / 4,
}),
});
},
},
{
id: RegularShapeId.triangle,
name: 'Triangle',
aliasIds: [MarkerShapePath.triangle],
make: (cfg: StyleConfigValues) => {
const radius = cfg.size ?? DEFAULT_SIZE;
return new Style({
image: new RegularShape({
stroke: new Stroke({ color: cfg.color, width: cfg.lineWidth ?? 1 }),
fill: getFillColor(cfg),
points: 3,
radius,
rotation: Math.PI / 4,
angle: 0,
}),
});
},
},
{
id: RegularShapeId.star,
name: 'Star',
aliasIds: [MarkerShapePath.star],
make: (cfg: StyleConfigValues) => {
const radius = cfg.size ?? DEFAULT_SIZE;
return new Style({
image: new RegularShape({
stroke: new Stroke({ color: cfg.color, width: cfg.lineWidth ?? 1 }),
fill: getFillColor(cfg),
points: 5,
radius,
radius2: radius * 0.4,
angle: 0,
}),
});
},
},
{
id: RegularShapeId.cross,
name: 'Cross',
aliasIds: [MarkerShapePath.cross],
make: (cfg: StyleConfigValues) => {
const radius = cfg.size ?? DEFAULT_SIZE;
return new Style({
image: new RegularShape({
stroke: new Stroke({ color: cfg.color, width: cfg.lineWidth ?? 1 }),
points: 4,
radius,
radius2: 0,
angle: 0,
}),
});
},
},
{
id: RegularShapeId.x,
name: 'X',
aliasIds: [MarkerShapePath.x],
make: (cfg: StyleConfigValues) => {
const radius = cfg.size ?? DEFAULT_SIZE;
return new Style({
image: new RegularShape({
stroke: new Stroke({ color: cfg.color, width: cfg.lineWidth ?? 1 }),
points: 4,
radius,
radius2: 0,
angle: Math.PI / 4,
}),
});
},
},
];
async function prepareSVG(url: string): Promise<string> {
return fetch(url, { method: 'GET' })
.then((res) => {
return res.text();
})
.then((text) => {
const parser = new DOMParser();
const doc = parser.parseFromString(text, 'image/svg+xml');
const svg = doc.getElementsByTagName('svg')[0];
if (!svg) {
return '';
}
// open layers requires a white fill becaues it uses tint to set color
svg.setAttribute('fill', '#fff');
const svgString = new XMLSerializer().serializeToString(svg);
const svgURI = encodeURIComponent(svgString);
return `data:image/svg+xml,${svgURI}`;
})
.catch((error) => {
console.error(error);
return '';
});
}
// Really just a cache for the various symbol styles
const markerMakers = new Registry<SymbolMaker>(() => makers);
export function getMarkerAsPath(shape?: string): string | undefined {
const marker = markerMakers.getIfExists(shape);
if (marker?.aliasIds?.length) {
return marker.aliasIds[0];
}
return undefined;
}
// Will prepare symbols as necessary
export async function getMarkerMaker(symbol?: string): Promise<StyleMaker> {
if (!symbol) {
return circleMarker;
}
let maker = markerMakers.getIfExists(symbol);
if (maker) {
return maker.make;
}
// Prepare svg as icon
if (symbol.endsWith('.svg')) {
const src = await prepareSVG(getPublicOrAbsoluteUrl(symbol));
maker = {
id: symbol,
name: symbol,
aliasIds: [],
make: src
? (cfg: StyleConfigValues) => {
const radius = cfg.size ?? DEFAULT_SIZE;
return [
new Style({
image: new Icon({
src,
color: cfg.color,
opacity: cfg.opacity ?? 1,
scale: (DEFAULT_SIZE + radius) / 100,
}),
}),
// transparent bounding box for featureAtPixel detection
new Style({
image: new RegularShape({
fill: new Fill({ color: 'rgba(0,0,0,0)' }),
points: 4,
radius: cfg.size,
angle: Math.PI / 4,
}),
}),
];
}
: errorMarker,
};
markerMakers.register(maker);
return maker.make;
}
// defatult to showing a circle
return errorMarker;
}

View File

@ -0,0 +1,16 @@
import { Style, Text } from 'ol/style';
import { config } from '@grafana/runtime';
import { StyleConfigValues, StyleMaker } from './types';
import { getFillColor } from './markers';
export const textMarkerMaker: StyleMaker = (cfg: StyleConfigValues) => {
const fontFamily = config.theme2.typography.fontFamily;
const fontSize = cfg.size ?? 12;
return new Style({
text: new Text({
text: cfg.text ?? '?',
fill: getFillColor(cfg),
font: `normal ${fontSize}px ${fontFamily}`,
}),
});
};

View File

@ -0,0 +1,81 @@
import {
ColorDimensionConfig,
ResourceDimensionConfig,
ResourceDimensionMode,
ScaleDimensionConfig,
TextDimensionConfig,
} from 'app/features/dimensions';
import { Style } from 'ol/style';
export enum GeometryTypeId {
Point = 'point',
Line = 'line',
Polygon = 'polygon',
Any = '*any*',
}
// StyleConfig is saved in panel json and is used to configure how items get rendered
export interface StyleConfig {
color?: ColorDimensionConfig;
opacity?: number; // defaults to 80%
// For non-points
lineWidth?: number;
// Used for points and dynamic text
size?: ScaleDimensionConfig;
symbol?: ResourceDimensionConfig;
// Can show markers and text together!
text?: TextDimensionConfig;
textConfig?: TextStyleConfig;
}
export const DEFAULT_SIZE = 5;
export const defaultStyleConfig = Object.freeze({
size: {
fixed: DEFAULT_SIZE,
min: 2,
max: 15,
},
color: {
fixed: 'dark-green', // picked from theme
},
opacity: 0.4,
symbol: {
mode: ResourceDimensionMode.Fixed,
fixed: 'img/icons/marker/circle.svg',
},
});
/**
* Static options for text display. See:
* https://openlayers.org/en/latest/apidoc/module-ol_style_Text.html
*/
export interface TextStyleConfig {
fontSize?: number;
offsetX?: number;
offsetY?: number;
align?: 'left' | 'right' | 'center';
baseline?: 'bottom' | 'top' | 'middle';
}
// Applying the config to real data gives the values
export interface StyleConfigValues {
color: string;
opacity?: number;
lineWidth?: number;
size?: number;
symbol?: string; // the point symbol
rotation?: number;
text?: string;
// Pass though (not value dependant)
textConfig?: TextStyleConfig;
}
/**
* Given values create a style
*/
export type StyleMaker = (values: StyleConfigValues) => Style | Style[];

View File

@ -0,0 +1,18 @@
import { StyleConfig } from './types';
/** Return a distinct list of fields used to dynamically change the style */
export function getDependantFields(config: StyleConfig): Set<string> | undefined {
const fields = new Set<string>();
if (config.color?.field) {
fields.add(config.color.field);
}
if (config.size?.field) {
fields.add(config.size.field);
}
if (config.text?.field) {
fields.add(config.text.field);
}
return fields;
}

View File

@ -1,7 +1,6 @@
import { MapLayerHandler, MapLayerOptions, SelectableValue } from '@grafana/data';
import BaseLayer from 'ol/layer/Base';
import { Units } from 'ol/proj/Units';
import { Style } from 'ol/style';
import { MapCenterID } from './view';
export interface ControlsOptions {
@ -64,10 +63,10 @@ export enum ComparisonOperation {
GT = 'gt',
GTE = 'gte',
}
export interface GazetteerPathEditorConfigSettings {
options?: Array<SelectableValue<string>>;
}
//-------------------
// Runtime model
//-------------------
@ -79,13 +78,3 @@ export interface MapLayerState<TConfig = any> {
onChange: (cfg: MapLayerOptions<TConfig>) => void;
isBasemap?: boolean;
}
export interface StyleMakerConfig {
color: string;
fillColor: string;
size: number;
markerPath?: string;
text?: string;
opacity?: number;
}
export type StyleMaker = (config: StyleMakerConfig) => Style | Style[];

View File

@ -2,8 +2,7 @@ import { DataFrame } from '@grafana/data';
import { DimensionSupplier } from 'app/features/dimensions';
import { Feature } from 'ol';
import { Point } from 'ol/geom';
import tinycolor from 'tinycolor2';
import { StyleMaker } from '../types';
import { StyleMaker } from '../style/types';
import { LocationInfo } from './location';
export interface FeaturesStylesBuilderConfig {
@ -20,6 +19,7 @@ export const getFeatures = (
config: FeaturesStylesBuilderConfig
): Array<Feature<Point>> | undefined => {
const features: Array<Feature<Point>> = [];
const opacity = config.opacity;
// Map each data value into new points
for (let i = 0; i < frame.length; i++) {
@ -30,10 +30,7 @@ export const getFeatures = (
const size = config.sizeDim.get(i);
// Get the text for the feature based on text dimension
const label = config?.textDim ? config?.textDim.get(i) : undefined;
// Set the opacity determined from user configuration
const fillColor = tinycolor(color).setAlpha(config?.opacity).toRgbString();
const text = config?.textDim ? config?.textDim.get(i) : undefined;
// Create a new Feature for each point returned from dataFrameToPoints
const dot = new Feature(info.points[i]);
@ -42,11 +39,7 @@ export const getFeatures = (
rowIndex: i,
});
if (config?.textDim) {
dot.setStyle(config.styleMaker({ color, fillColor, size, text: label }));
} else {
dot.setStyle(config.styleMaker({ color, fillColor, size }));
}
dot.setStyle(config.styleMaker({ color, size, text, opacity }));
features.push(dot);
}

View File

@ -1,34 +0,0 @@
import { getPublicOrAbsoluteUrl } from 'app/features/dimensions';
const getUri = (url: string, size: number): Promise<string> => {
return fetch(url, { method: 'GET' })
.then((res) => {
return res.text();
})
.then((text) => {
const parser = new DOMParser();
const doc = parser.parseFromString(text, 'image/svg+xml');
const svg = doc.getElementsByTagName('svg')[0];
if (!svg) {
return '';
}
//set to white so ol color tint works
svg.setAttribute('fill', '#fff');
const svgString = new XMLSerializer().serializeToString(svg);
const svgURI = encodeURIComponent(svgString);
return `data:image/svg+xml,${svgURI}`;
})
.catch((error) => {
console.error(error);
return '';
});
};
export const getSVGUri = async (url: string, size: number) => {
const svgURI = await getUri(url, size);
if (!svgURI) {
return getPublicOrAbsoluteUrl('img/icons/marker/circle.svg');
}
return svgURI;
};

View File

@ -1,147 +0,0 @@
import { Fill, RegularShape, Stroke, Style, Circle } from 'ol/style';
import { Registry, RegistryItem } from '@grafana/data';
import { StyleMaker, StyleMakerConfig } from '../types';
export interface MarkerMaker extends RegistryItem {
// path to icon that will be shown (but then replaced)
aliasIds: string[];
make: StyleMaker;
hasFill: boolean;
}
export enum RegularShapeId {
circle = 'circle',
square = 'square',
triangle = 'triangle',
star = 'star',
cross = 'cross',
x = 'x',
}
const MarkerShapePath = {
circle: 'img/icons/marker/circle.svg',
square: 'img/icons/marker/square.svg',
triangle: 'img/icons/marker/triangle.svg',
star: 'img/icons/marker/star.svg',
cross: 'img/icons/marker/cross.svg',
x: 'img/icons/marker/x-mark.svg',
};
export const circleMarker: MarkerMaker = {
id: RegularShapeId.circle,
name: 'Circle',
hasFill: true,
aliasIds: [MarkerShapePath.circle],
make: (cfg: StyleMakerConfig) => {
return new Style({
image: new Circle({
stroke: new Stroke({ color: cfg.color }),
fill: new Fill({ color: cfg.fillColor }),
radius: cfg.size,
}),
});
},
};
const makers: MarkerMaker[] = [
circleMarker,
{
id: RegularShapeId.square,
name: 'Square',
hasFill: true,
aliasIds: [MarkerShapePath.square],
make: (cfg: StyleMakerConfig) => {
return new Style({
image: new RegularShape({
fill: new Fill({ color: cfg.fillColor }),
stroke: new Stroke({ color: cfg.color, width: 1 }),
points: 4,
radius: cfg.size,
angle: Math.PI / 4,
}),
});
},
},
{
id: RegularShapeId.triangle,
name: 'Triangle',
hasFill: true,
aliasIds: [MarkerShapePath.triangle],
make: (cfg: StyleMakerConfig) => {
return new Style({
image: new RegularShape({
fill: new Fill({ color: cfg.fillColor }),
stroke: new Stroke({ color: cfg.color, width: 1 }),
points: 3,
radius: cfg.size,
rotation: Math.PI / 4,
angle: 0,
}),
});
},
},
{
id: RegularShapeId.star,
name: 'Star',
hasFill: true,
aliasIds: [MarkerShapePath.star],
make: (cfg: StyleMakerConfig) => {
return new Style({
image: new RegularShape({
fill: new Fill({ color: cfg.fillColor }),
stroke: new Stroke({ color: cfg.color, width: 1 }),
points: 5,
radius: cfg.size,
radius2: cfg.size * 0.4,
angle: 0,
}),
});
},
},
{
id: RegularShapeId.cross,
name: 'Cross',
hasFill: false,
aliasIds: [MarkerShapePath.cross],
make: (cfg: StyleMakerConfig) => {
return new Style({
image: new RegularShape({
fill: new Fill({ color: cfg.fillColor }),
stroke: new Stroke({ color: cfg.color, width: 1 }),
points: 4,
radius: cfg.size,
radius2: 0,
angle: 0,
}),
});
},
},
{
id: RegularShapeId.x,
name: 'X',
hasFill: false,
aliasIds: [MarkerShapePath.x],
make: (cfg: StyleMakerConfig) => {
return new Style({
image: new RegularShape({
fill: new Fill({ color: cfg.fillColor }),
stroke: new Stroke({ color: cfg.color, width: 1 }),
points: 4,
radius: cfg.size,
radius2: 0,
angle: Math.PI / 4,
}),
});
},
},
];
export const markerMakers = new Registry<MarkerMaker>(() => makers);
export const getMarkerFromPath = (svgPath: string): MarkerMaker | undefined => {
for (const [key, val] of Object.entries(MarkerShapePath)) {
if (val === svgPath) {
return markerMakers.getIfExists(key);
}
}
return undefined;
};