From 5449bd9ae7667e244939851b879609a3e0d7dad7 Mon Sep 17 00:00:00 2001 From: nikki-kiga <42276368+nikki-kiga@users.noreply.github.com> Date: Wed, 27 Oct 2021 08:46:32 -0700 Subject: [PATCH] Geomap: Add text labels layer (#40778) * Geomap: add initial text labels layer * add fontsize to text labels layer * refactor feature styles in marker and text layers * hide template and pick default field Co-authored-by: Ryan McKinley --- .../editors/TextDimensionEditor.tsx | 2 +- public/app/features/dimensions/text.ts | 5 +- .../plugins/panel/geomap/layers/data/index.ts | 2 + .../panel/geomap/layers/data/markersLayer.tsx | 40 ++--- .../geomap/layers/data/textLabelsLayer.ts | 162 ++++++++++++++++++ public/app/plugins/panel/geomap/types.ts | 10 ++ .../plugins/panel/geomap/utils/getFeatures.ts | 54 ++++++ .../panel/geomap/utils/regularShapes.ts | 54 +++--- 8 files changed, 275 insertions(+), 54 deletions(-) create mode 100644 public/app/plugins/panel/geomap/layers/data/textLabelsLayer.ts create mode 100644 public/app/plugins/panel/geomap/utils/getFeatures.ts diff --git a/public/app/features/dimensions/editors/TextDimensionEditor.tsx b/public/app/features/dimensions/editors/TextDimensionEditor.tsx index ad2234d8405..275a2521536 100644 --- a/public/app/features/dimensions/editors/TextDimensionEditor.tsx +++ b/public/app/features/dimensions/editors/TextDimensionEditor.tsx @@ -12,7 +12,7 @@ import { FieldNamePicker } from '../../../../../packages/grafana-ui/src/componen const textOptions = [ { label: 'Fixed', value: TextDimensionMode.Fixed, description: 'Fixed value' }, { label: 'Field', value: TextDimensionMode.Field, description: 'Display field value' }, - { label: 'Template', value: TextDimensionMode.Template, description: 'use template text' }, + // { label: 'Template', value: TextDimensionMode.Template, description: 'use template text' }, ]; const dummyFieldSettings: StandardEditorsRegistryItem = { diff --git a/public/app/features/dimensions/text.ts b/public/app/features/dimensions/text.ts index 4933dcd6137..c77d0c3e21e 100644 --- a/public/app/features/dimensions/text.ts +++ b/public/app/features/dimensions/text.ts @@ -1,4 +1,4 @@ -import { DataFrame, Field, formattedValueToString } from '@grafana/data'; +import { DataFrame, Field, FieldType, formattedValueToString } from '@grafana/data'; import { DimensionSupplier, TextDimensionConfig, TextDimensionMode } from './types'; import { findField, getLastNotNullFieldValue } from './utils'; @@ -7,7 +7,8 @@ import { findField, getLastNotNullFieldValue } from './utils'; //--------------------------------------------------------- export function getTextDimension(frame: DataFrame | undefined, config: TextDimensionConfig): DimensionSupplier { - return getTextDimensionForField(findField(frame, config.field), config); + const field = config.field ? findField(frame, config.field) : frame?.fields.find((f) => f.type === FieldType.string); + return getTextDimensionForField(field, config); } export function getTextDimensionForField( diff --git a/public/app/plugins/panel/geomap/layers/data/index.ts b/public/app/plugins/panel/geomap/layers/data/index.ts index a70820a52c0..b8ecabee5fb 100644 --- a/public/app/plugins/panel/geomap/layers/data/index.ts +++ b/public/app/plugins/panel/geomap/layers/data/index.ts @@ -2,6 +2,7 @@ import { markersLayer } from './markersLayer'; import { geojsonMapper } from './geojsonMapper'; import { heatmapLayer } from './heatMap'; import { lastPointTracker } from './lastPointTracker'; +import { textLabelsLayer } from './textLabelsLayer'; /** * Registry for layer handlers @@ -11,4 +12,5 @@ export const dataLayers = [ heatmapLayer, lastPointTracker, geojsonMapper, // dummy for now + textLabelsLayer, ]; diff --git a/public/app/plugins/panel/geomap/layers/data/markersLayer.tsx b/public/app/plugins/panel/geomap/layers/data/markersLayer.tsx index 51283fd2ac2..0da8ff64d9d 100644 --- a/public/app/plugins/panel/geomap/layers/data/markersLayer.tsx +++ b/public/app/plugins/panel/geomap/layers/data/markersLayer.tsx @@ -12,8 +12,6 @@ import { Point } from 'ol/geom'; import * as layer from 'ol/layer'; import * as source from 'ol/source'; import * as style from 'ol/style'; - -import tinycolor from 'tinycolor2'; import { dataFrameToPoints, getLocationMatchers } from '../../utils/location'; import { ColorDimensionConfig, @@ -28,8 +26,10 @@ import { import { ScaleDimensionEditor, ColorDimensionEditor, ResourceDimensionEditor } from 'app/features/dimensions/editors'; import { ObservablePropsWrapper } from '../../components/ObservablePropsWrapper'; import { MarkersLegend, MarkersLegendProps } from './MarkersLegend'; -import { StyleMaker, getMarkerFromPath } from '../../utils/regularShapes'; +import { getMarkerFromPath } from '../../utils/regularShapes'; import { ReplaySubject } from 'rxjs'; +import { FeaturesStylesBuilderConfig, getFeatures } from '../../utils/getFeatures'; +import { StyleMaker, StyleMakerConfig } from '../../types'; // Configuration options for Circle overlays export interface MarkersConfig { @@ -112,13 +112,13 @@ export const markersLayer: MapLayerRegistryItem = { const marker = getMarkerFromPath(config.markerSymbol?.fixed); - const makeIconStyle = (color: string, fillColor: string, radius: number) => { + const makeIconStyle = (cfg: StyleMakerConfig) => { return new style.Style({ image: new style.Icon({ src: markerPath, - color, + color: cfg.color, // opacity, - scale: (DEFAULT_SIZE + radius) / 100, + scale: (DEFAULT_SIZE + cfg.size) / 100, }), }); }; @@ -138,23 +138,17 @@ export const markersLayer: MapLayerRegistryItem = { const sizeDim = getScaledDimension(frame, config.size); const opacity = options.config?.fillOpacity ?? defaultOptions.fillOpacity; - // Map each data value into new points - for (let i = 0; i < frame.length; i++) { - // Get the circle color for a specific data value depending on color scheme - const color = colorDim.get(i); - // Set the opacity determined from user configuration - const fillColor = tinycolor(color).setAlpha(opacity).toRgbString(); - // Get circle size from user configuration - const radius = sizeDim.get(i); - - // Create a new Feature for each point returned from dataFrameToPoints - const dot = new Feature(info.points[i]); - dot.setProperties({ - frame, - rowIndex: i, - }); - dot.setStyle(shape(color, fillColor, radius)); - features.push(dot); + const featureDimensionConfig: FeaturesStylesBuilderConfig = { + colorDim: colorDim, + sizeDim: sizeDim, + opacity: opacity, + styleMaker: shape, + }; + + const frameFeatures = getFeatures(frame, info, featureDimensionConfig); + + if (frameFeatures) { + features.push(...frameFeatures); } // Post updates to the legend component diff --git a/public/app/plugins/panel/geomap/layers/data/textLabelsLayer.ts b/public/app/plugins/panel/geomap/layers/data/textLabelsLayer.ts new file mode 100644 index 00000000000..cf0cbd0e567 --- /dev/null +++ b/public/app/plugins/panel/geomap/layers/data/textLabelsLayer.ts @@ -0,0 +1,162 @@ +import { GrafanaTheme2, MapLayerOptions, MapLayerRegistryItem, PanelData, PluginState } from '@grafana/data'; +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'; + +interface TextLabelsConfig { + labelText: TextDimensionConfig; + color: ColorDimensionConfig; + fillOpacity: number; + fontSize: ScaleDimensionConfig; +} + +export const TEXT_LABELS_LAYER = 'text-labels'; + +const defaultOptions: TextLabelsConfig = { + labelText: { + fixed: '', + mode: TextDimensionMode.Field, + }, + color: { + fixed: 'dark-blue', + }, + fillOpacity: 0.6, + fontSize: { + fixed: 10, + min: 5, + max: 100, + }, +}; + +export const textLabelsLayer: MapLayerRegistryItem = { + id: TEXT_LABELS_LAYER, + name: 'Text labels', + description: 'render text labels', + isBaseMap: false, + state: PluginState.alpha, + showLocation: true, + + create: async (map: Map, options: MapLayerOptions, theme: GrafanaTheme2) => { + const matchers = await getLocationMatchers(options.location); + const vectorLayer = new layer.Vector({}); + + const config = { + ...defaultOptions, + ...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) => { + if (!data.series?.length) { + return; + } + + const features: Feature[] = []; + + for (const frame of data.series) { + const info = dataFrameToPoints(frame, matchers); + if (info.warning) { + console.log('Could not find locations', info.warning); + 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 featureDimensionConfig: FeaturesStylesBuilderConfig = { + colorDim: colorDim, + sizeDim: sizeDim, + textDim: textDim, + opacity: opacity, + styleMaker: getStyle, + }; + + const frameFeatures = getFeatures(frame, info, featureDimensionConfig); + + if (frameFeatures) { + features.push(...frameFeatures); + } + } + + // Source reads the data and provides a set of features to visualize + const vectorSource = new source.Vector({ features }); + vectorLayer.setSource(vectorSource); + }, + }; + }, + registerOptionsUI: (builder) => { + builder + .addCustomEditor({ + id: 'config.labelText', + name: 'Text label', + path: 'config.labelText', + editor: TextDimensionEditor, + }) + .addCustomEditor({ + id: 'config.color', + path: 'config.color', + name: 'Text color', + editor: ColorDimensionEditor, + settings: {}, + }) + .addSliderInput({ + path: 'config.fillOpacity', + name: 'Text opacity', + defaultValue: defaultOptions.fillOpacity, + settings: { + min: 0, + max: 1, + step: 0.1, + }, + }) + .addCustomEditor({ + id: 'config.fontSize', + path: 'config.fontSize', + name: 'Text size', + editor: ScaleDimensionEditor, + settings: { + fixed: defaultOptions.fontSize.fixed, + min: defaultOptions.fontSize.min, + max: defaultOptions.fontSize.max, + }, + }); + }, + defaultOptions, +}; diff --git a/public/app/plugins/panel/geomap/types.ts b/public/app/plugins/panel/geomap/types.ts index b69ba1546be..ad1f37b7067 100644 --- a/public/app/plugins/panel/geomap/types.ts +++ b/public/app/plugins/panel/geomap/types.ts @@ -1,5 +1,6 @@ import { MapLayerOptions } from '@grafana/data'; import { Units } from 'ol/proj/Units'; +import { Style } from 'ol/style'; import { MapCenterID } from './view'; export interface ControlsOptions { @@ -61,3 +62,12 @@ export enum ComparisonOperation { GT = 'gt', GTE = 'gte', } +export interface StyleMakerConfig { + color: string; + fillColor: string; + size: number; + markerPath?: string; + text?: string; +} + +export type StyleMaker = (config: StyleMakerConfig) => Style; diff --git a/public/app/plugins/panel/geomap/utils/getFeatures.ts b/public/app/plugins/panel/geomap/utils/getFeatures.ts new file mode 100644 index 00000000000..82f84dc1cfc --- /dev/null +++ b/public/app/plugins/panel/geomap/utils/getFeatures.ts @@ -0,0 +1,54 @@ +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 { LocationInfo } from './location'; + +export interface FeaturesStylesBuilderConfig { + colorDim: DimensionSupplier; + sizeDim: DimensionSupplier; + opacity: number; + styleMaker: StyleMaker; + textDim?: DimensionSupplier; +} + +export const getFeatures = ( + frame: DataFrame, + info: LocationInfo, + config: FeaturesStylesBuilderConfig +): Array> | undefined => { + const features: Array> = []; + + // Map each data value into new points + for (let i = 0; i < frame.length; i++) { + // Get the color for the feature based on color scheme + const color = config.colorDim.get(i); + + // Get the size for the feature based on size dimension + 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(); + + // Create a new Feature for each point returned from dataFrameToPoints + const dot = new Feature(info.points[i]); + dot.setProperties({ + frame, + rowIndex: i, + }); + + if (config?.textDim) { + dot.setStyle(config.styleMaker({ color, fillColor, size, text: label })); + } else { + dot.setStyle(config.styleMaker({ color, fillColor, size })); + } + features.push(dot); + } + + return features; +}; diff --git a/public/app/plugins/panel/geomap/utils/regularShapes.ts b/public/app/plugins/panel/geomap/utils/regularShapes.ts index ad488cf7381..0aef314bdf7 100644 --- a/public/app/plugins/panel/geomap/utils/regularShapes.ts +++ b/public/app/plugins/panel/geomap/utils/regularShapes.ts @@ -1,8 +1,6 @@ import { Fill, RegularShape, Stroke, Style, Circle } from 'ol/style'; import { Registry, RegistryItem } from '@grafana/data'; - -export type StyleMaker = (color: string, fillColor: string, radius: number, markerPath?: string) => Style; - +import { StyleMaker, StyleMakerConfig } from '../types'; export interface MarkerMaker extends RegistryItem { // path to icon that will be shown (but then replaced) aliasIds: string[]; @@ -33,12 +31,12 @@ export const circleMarker: MarkerMaker = { name: 'Circle', hasFill: true, aliasIds: [MarkerShapePath.circle], - make: (color: string, fillColor: string, radius: number) => { + make: (cfg: StyleMakerConfig) => { return new Style({ image: new Circle({ - stroke: new Stroke({ color: color }), - fill: new Fill({ color: fillColor }), - radius: radius, + stroke: new Stroke({ color: cfg.color }), + fill: new Fill({ color: cfg.fillColor }), + radius: cfg.size, }), }); }, @@ -51,13 +49,13 @@ const makers: MarkerMaker[] = [ name: 'Square', hasFill: true, aliasIds: [MarkerShapePath.square], - make: (color: string, fillColor: string, radius: number) => { + make: (cfg: StyleMakerConfig) => { return new Style({ image: new RegularShape({ - fill: new Fill({ color: fillColor }), - stroke: new Stroke({ color: color, width: 1 }), + fill: new Fill({ color: cfg.fillColor }), + stroke: new Stroke({ color: cfg.color, width: 1 }), points: 4, - radius: radius, + radius: cfg.size, angle: Math.PI / 4, }), }); @@ -68,13 +66,13 @@ const makers: MarkerMaker[] = [ name: 'Triangle', hasFill: true, aliasIds: [MarkerShapePath.triangle], - make: (color: string, fillColor: string, radius: number) => { + make: (cfg: StyleMakerConfig) => { return new Style({ image: new RegularShape({ - fill: new Fill({ color: fillColor }), - stroke: new Stroke({ color: color, width: 1 }), + fill: new Fill({ color: cfg.fillColor }), + stroke: new Stroke({ color: cfg.color, width: 1 }), points: 3, - radius: radius, + radius: cfg.size, rotation: Math.PI / 4, angle: 0, }), @@ -86,14 +84,14 @@ const makers: MarkerMaker[] = [ name: 'Star', hasFill: true, aliasIds: [MarkerShapePath.star], - make: (color: string, fillColor: string, radius: number) => { + make: (cfg: StyleMakerConfig) => { return new Style({ image: new RegularShape({ - fill: new Fill({ color: fillColor }), - stroke: new Stroke({ color: color, width: 1 }), + fill: new Fill({ color: cfg.fillColor }), + stroke: new Stroke({ color: cfg.color, width: 1 }), points: 5, - radius: radius, - radius2: radius * 0.4, + radius: cfg.size, + radius2: cfg.size * 0.4, angle: 0, }), }); @@ -104,13 +102,13 @@ const makers: MarkerMaker[] = [ name: 'Cross', hasFill: false, aliasIds: [MarkerShapePath.cross], - make: (color: string, fillColor: string, radius: number) => { + make: (cfg: StyleMakerConfig) => { return new Style({ image: new RegularShape({ - fill: new Fill({ color: fillColor }), - stroke: new Stroke({ color: color, width: 1 }), + fill: new Fill({ color: cfg.fillColor }), + stroke: new Stroke({ color: cfg.color, width: 1 }), points: 4, - radius: radius, + radius: cfg.size, radius2: 0, angle: 0, }), @@ -122,13 +120,13 @@ const makers: MarkerMaker[] = [ name: 'X', hasFill: false, aliasIds: [MarkerShapePath.x], - make: (color: string, fillColor: string, radius: number) => { + make: (cfg: StyleMakerConfig) => { return new Style({ image: new RegularShape({ - fill: new Fill({ color: fillColor }), - stroke: new Stroke({ color: color, width: 1 }), + fill: new Fill({ color: cfg.fillColor }), + stroke: new Stroke({ color: cfg.color, width: 1 }), points: 4, - radius: radius, + radius: cfg.size, radius2: 0, angle: Math.PI / 4, }),