From 0e1f0dd8f5279587cb4d5b2c1aa0ac9666174130 Mon Sep 17 00:00:00 2001 From: Alexander Zobnin Date: Mon, 27 Jun 2022 19:45:09 +0300 Subject: [PATCH] Geomap: Route/path visualization (#43554) Co-authored-by: Ryan McKinley --- .../features/geo/utils/frameVectorSource.ts | 26 +++- .../plugins/panel/geomap/layers/data/index.ts | 3 +- .../panel/geomap/layers/data/routeLayer.tsx | 147 ++++++++++++++++++ .../app/plugins/panel/geomap/style/markers.ts | 20 +++ 4 files changed, 193 insertions(+), 3 deletions(-) create mode 100644 public/app/plugins/panel/geomap/layers/data/routeLayer.tsx diff --git a/public/app/features/geo/utils/frameVectorSource.ts b/public/app/features/geo/utils/frameVectorSource.ts index 704af664887..d760b0f2b50 100644 --- a/public/app/features/geo/utils/frameVectorSource.ts +++ b/public/app/features/geo/utils/frameVectorSource.ts @@ -1,8 +1,8 @@ import { Feature } from 'ol'; -import { Geometry } from 'ol/geom'; +import { Geometry, LineString, Point } from 'ol/geom'; import VectorSource from 'ol/source/Vector'; -import { DataFrame } from '@grafana/data'; +import { DataFrame, Field } from '@grafana/data'; import { getGeometryField, LocationFieldMatchers } from './location'; @@ -34,4 +34,26 @@ export class FrameVectorSource extends VectorSour // only call this at the end this.changed(); } + + updateLineString(frame: DataFrame) { + this.clear(true); + const info = getGeometryField(frame, this.location); + if (!info.field) { + this.changed(); + return; + } + + const field = info.field as Field; + const geometry = new LineString(field.values.toArray().map((p) => p.getCoordinates())) as Geometry; + this.addFeatureInternal( + new Feature({ + frame, + rowIndex: 0, + geometry: geometry as T, + }) + ); + + // only call this at the end + this.changed(); + } } diff --git a/public/app/plugins/panel/geomap/layers/data/index.ts b/public/app/plugins/panel/geomap/layers/data/index.ts index e02fe6ff538..8f325b0a456 100644 --- a/public/app/plugins/panel/geomap/layers/data/index.ts +++ b/public/app/plugins/panel/geomap/layers/data/index.ts @@ -2,9 +2,10 @@ import { markersLayer } from './markersLayer'; import { geojsonLayer } from './geojsonLayer'; import { heatmapLayer } from './heatMap'; import { lastPointTracker } from './lastPointTracker'; +import { routeLayer } from './routeLayer'; import { dayNightLayer } from './dayNightLayer'; /** * Registry for layer handlers */ -export const dataLayers = [markersLayer, heatmapLayer, lastPointTracker, geojsonLayer, dayNightLayer]; +export const dataLayers = [markersLayer, heatmapLayer, lastPointTracker, geojsonLayer, dayNightLayer, routeLayer]; diff --git a/public/app/plugins/panel/geomap/layers/data/routeLayer.tsx b/public/app/plugins/panel/geomap/layers/data/routeLayer.tsx new file mode 100644 index 00000000000..29f4840eed4 --- /dev/null +++ b/public/app/plugins/panel/geomap/layers/data/routeLayer.tsx @@ -0,0 +1,147 @@ +import { + MapLayerRegistryItem, + MapLayerOptions, + PanelData, + GrafanaTheme2, + FrameGeometrySourceMode, + PluginState, + EventBus, +} from '@grafana/data'; +import Map from 'ol/Map'; +import { FeatureLike } from 'ol/Feature'; +import { getLocationMatchers } from 'app/features/geo/utils/location'; +import { getColorDimension } from 'app/features/dimensions'; +import { defaultStyleConfig, StyleConfig, StyleDimensions } from '../../style/types'; +import { StyleEditor } from './StyleEditor'; +import { getStyleConfigState } from '../../style/utils'; +import VectorLayer from 'ol/layer/Vector'; +import { isNumber } from 'lodash'; +import { routeStyle } from '../../style/markers'; +import { FrameVectorSource } from 'app/features/geo/utils/frameVectorSource'; + +// Configuration options for Circle overlays +export interface RouteConfig { + style: StyleConfig; +} + +const defaultOptions: RouteConfig = { + style: { + ...defaultStyleConfig, + opacity: 1, + lineWidth: 2, + }, +}; + +export const ROUTE_LAYER_ID = 'route'; + +// Used by default when nothing is configured +export const defaultRouteConfig: MapLayerOptions = { + type: ROUTE_LAYER_ID, + name: '', // will get replaced + config: defaultOptions, + location: { + mode: FrameGeometrySourceMode.Auto, + }, + tooltip: false, +}; + +/** + * Map layer configuration for circle overlay + */ +export const routeLayer: MapLayerRegistryItem = { + id: ROUTE_LAYER_ID, + name: 'Route', + description: 'Render data points as a route', + isBaseMap: false, + showLocation: true, + state: PluginState.alpha, + + /** + * Function that configures transformation and returns a transformer + * @param options + */ + create: async (map: Map, options: MapLayerOptions, eventBus: EventBus, theme: GrafanaTheme2) => { + // Assert default values + const config = { + ...defaultOptions, + ...options?.config, + }; + + const style = await getStyleConfigState(config.style); + const location = await getLocationMatchers(options.location); + const source = new FrameVectorSource(location); + const vectorLayer = new VectorLayer({ + source, + }); + + if (!style.fields) { + // Set a global style + vectorLayer.setStyle(routeStyle(style.base)); + } else { + vectorLayer.setStyle((feature: FeatureLike) => { + const idx = feature.get('rowIndex') as number; + const dims = style.dims; + if (!dims || !isNumber(idx)) { + return routeStyle(style.base); + } + + const values = { ...style.base }; + + if (dims.color) { + values.color = dims.color.get(idx); + } + return routeStyle(values); + }); + } + + return { + init: () => vectorLayer, + update: (data: PanelData) => { + if (!data.series?.length) { + return; // ignore empty + } + + for (const frame of data.series) { + if (style.fields) { + const dims: StyleDimensions = {}; + if (style.fields.color) { + dims.color = getColorDimension(frame, style.config.color ?? defaultStyleConfig.color, theme); + } + style.dims = dims; + } + + source.updateLineString(frame); + break; // Only the first frame for now! + } + }, + + // Route layer options + registerOptionsUI: (builder) => { + builder + .addCustomEditor({ + id: 'config.style', + path: 'config.style', + name: 'Style', + editor: StyleEditor, + settings: { + simpleFixedValues: true, + }, + defaultValue: defaultOptions.style, + }) + .addSliderInput({ + path: 'config.style.lineWidth', + name: 'Line width', + defaultValue: defaultOptions.style.lineWidth, + settings: { + min: 1, + max: 10, + step: 1, + }, + }); + }, + }; + }, + + // fill in the default values + defaultOptions, +}; diff --git a/public/app/plugins/panel/geomap/style/markers.ts b/public/app/plugins/panel/geomap/style/markers.ts index 309dcefed97..2ec1f68294b 100644 --- a/public/app/plugins/panel/geomap/style/markers.ts +++ b/public/app/plugins/panel/geomap/style/markers.ts @@ -42,6 +42,18 @@ export function getFillColor(cfg: StyleConfigValues) { return undefined; } +export function getStrokeStyle(cfg: StyleConfigValues) { + const opacity = cfg.opacity == null ? 0.8 : cfg.opacity; + if (opacity === 1) { + return new Stroke({ color: cfg.color, width: cfg.lineWidth ?? 1 }); + } + if (opacity > 0) { + const color = tinycolor(cfg.color).setAlpha(opacity).toRgbString(); + return new Stroke({ color, width: cfg.lineWidth ?? 1 }); + } + return undefined; +} + const textLabel = (cfg: StyleConfigValues) => { if (!cfg.text) { return undefined; @@ -88,6 +100,14 @@ export const polyStyle = (cfg: StyleConfigValues) => { }); }; +export const routeStyle = (cfg: StyleConfigValues) => { + return new Style({ + fill: getFillColor(cfg), + stroke: getStrokeStyle(cfg), + text: textLabel(cfg), + }); +}; + // Square and cross const errorMarker = (cfg: StyleConfigValues) => { const radius = cfg.size ?? DEFAULT_SIZE;