From 862054918d1a33ea69ff38e85b530b6b8173463a Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Fri, 12 Nov 2021 11:24:35 -0800 Subject: [PATCH] Geomap: styleConfig cleanup and symbol caching (#41622) Co-authored-by: nikki-kiga <42276368+nikki-kiga@users.noreply.github.com> --- .../panel/geomap/layers/data/geojsonMapper.ts | 7 + .../panel/geomap/layers/data/markersLayer.tsx | 102 ++----- .../geomap/layers/data/textLabelsLayer.ts | 82 +++--- .../plugins/panel/geomap/migrations.test.ts | 160 ++--------- public/app/plugins/panel/geomap/migrations.ts | 51 ++-- .../app/plugins/panel/geomap/style/markers.ts | 257 ++++++++++++++++++ public/app/plugins/panel/geomap/style/text.ts | 16 ++ .../app/plugins/panel/geomap/style/types.ts | 81 ++++++ .../app/plugins/panel/geomap/style/utils.ts | 18 ++ public/app/plugins/panel/geomap/types.ts | 13 +- .../plugins/panel/geomap/utils/getFeatures.ts | 15 +- .../plugins/panel/geomap/utils/prepareSVG.ts | 34 --- .../panel/geomap/utils/regularShapes.ts | 147 ---------- 13 files changed, 503 insertions(+), 480 deletions(-) create mode 100644 public/app/plugins/panel/geomap/style/markers.ts create mode 100644 public/app/plugins/panel/geomap/style/text.ts create mode 100644 public/app/plugins/panel/geomap/style/types.ts create mode 100644 public/app/plugins/panel/geomap/style/utils.ts delete mode 100644 public/app/plugins/panel/geomap/utils/prepareSVG.ts delete mode 100644 public/app/plugins/panel/geomap/utils/regularShapes.ts diff --git a/public/app/plugins/panel/geomap/layers/data/geojsonMapper.ts b/public/app/plugins/panel/geomap/layers/data/geojsonMapper.ts index c94196f2f32..dccff018f86 100644 --- a/public/app/plugins/panel/geomap/layers/data/geojsonMapper.ts +++ b/public/app/plugins/panel/geomap/layers/data/geojsonMapper.ts @@ -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 = { 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 = { 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, }, diff --git a/public/app/plugins/panel/geomap/layers/data/markersLayer.tsx b/public/app/plugins/panel/geomap/layers/data/markersLayer.tsx index b2701d75ec4..12a403718d7 100644 --- a/public/app/plugins/panel/geomap/layers/data/markersLayer.tsx +++ b/public/app/plugins/panel/geomap/layers/data/markersLayer.tsx @@ -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 = { legend = ; } - 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 = { 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[] = []; for (const frame of data.series) { @@ -148,15 +96,15 @@ export const markersLayer: MapLayerRegistryItem = { 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 = { 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 = { }, }) .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, diff --git a/public/app/plugins/panel/geomap/layers/data/textLabelsLayer.ts b/public/app/plugins/panel/geomap/layers/data/textLabelsLayer.ts index 0f81452e757..0923e9904ce 100644 --- a/public/app/plugins/panel/geomap/layers/data/textLabelsLayer.ts +++ b/public/app/plugins/panel/geomap/layers/data/textLabelsLayer.ts @@ -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 = { @@ -62,23 +58,6 @@ export const textLabelsLayer: MapLayerRegistryItem = { ...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 = { const features: Feature[] = []; + + 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 = { 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 = { 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 = { }, }) .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, }, }); }, diff --git a/public/app/plugins/panel/geomap/migrations.test.ts b/public/app/plugins/panel/geomap/migrations.test.ts index c4024e5acdc..ce6291708af 100644 --- a/public/app/plugins/panel/geomap/migrations.test.ts +++ b/public/app/plugins/panel/geomap/migrations.test.ts @@ -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", } `); diff --git a/public/app/plugins/panel/geomap/migrations.ts b/public/app/plugins/panel/geomap/migrations.ts index f832668ceff..f3e7d93dc81 100644 --- a/public/app/plugins/panel/geomap/migrations.ts +++ b/public/app/plugins/panel/geomap/migrations.ts @@ -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 => { - 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 }] }; } } } diff --git a/public/app/plugins/panel/geomap/style/markers.ts b/public/app/plugins/panel/geomap/style/markers.ts new file mode 100644 index 00000000000..6dec47d8183 --- /dev/null +++ b/public/app/plugins/panel/geomap/style/markers.ts @@ -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 { + 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(() => 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 { + 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; +} diff --git a/public/app/plugins/panel/geomap/style/text.ts b/public/app/plugins/panel/geomap/style/text.ts new file mode 100644 index 00000000000..0269855a567 --- /dev/null +++ b/public/app/plugins/panel/geomap/style/text.ts @@ -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}`, + }), + }); +}; diff --git a/public/app/plugins/panel/geomap/style/types.ts b/public/app/plugins/panel/geomap/style/types.ts new file mode 100644 index 00000000000..9a814bf0797 --- /dev/null +++ b/public/app/plugins/panel/geomap/style/types.ts @@ -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[]; diff --git a/public/app/plugins/panel/geomap/style/utils.ts b/public/app/plugins/panel/geomap/style/utils.ts new file mode 100644 index 00000000000..8bd9169ffcf --- /dev/null +++ b/public/app/plugins/panel/geomap/style/utils.ts @@ -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 | undefined { + const fields = new Set(); + + 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; +} diff --git a/public/app/plugins/panel/geomap/types.ts b/public/app/plugins/panel/geomap/types.ts index 6352fe05873..412989be952 100644 --- a/public/app/plugins/panel/geomap/types.ts +++ b/public/app/plugins/panel/geomap/types.ts @@ -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>; } + //------------------- // Runtime model //------------------- @@ -79,13 +78,3 @@ export interface MapLayerState { onChange: (cfg: MapLayerOptions) => 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[]; diff --git a/public/app/plugins/panel/geomap/utils/getFeatures.ts b/public/app/plugins/panel/geomap/utils/getFeatures.ts index 82f84dc1cfc..54c6e2530c9 100644 --- a/public/app/plugins/panel/geomap/utils/getFeatures.ts +++ b/public/app/plugins/panel/geomap/utils/getFeatures.ts @@ -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> | undefined => { const features: Array> = []; + 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); } diff --git a/public/app/plugins/panel/geomap/utils/prepareSVG.ts b/public/app/plugins/panel/geomap/utils/prepareSVG.ts deleted file mode 100644 index 2b6fb831cff..00000000000 --- a/public/app/plugins/panel/geomap/utils/prepareSVG.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { getPublicOrAbsoluteUrl } from 'app/features/dimensions'; - -const getUri = (url: string, size: number): Promise => { - 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; -}; diff --git a/public/app/plugins/panel/geomap/utils/regularShapes.ts b/public/app/plugins/panel/geomap/utils/regularShapes.ts deleted file mode 100644 index 0aef314bdf7..00000000000 --- a/public/app/plugins/panel/geomap/utils/regularShapes.ts +++ /dev/null @@ -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(() => 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; -};