From 8de218d5f1017aaa469a86f77a414a66b2da3a0a Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Thu, 15 Jul 2021 12:00:19 -0700 Subject: [PATCH] Geomap: use a common configuration builder to find location fields (#36768) --- jest.config.js | 3 + packages/grafana-data/src/geo/layer.ts | 53 ++++- .../app/plugins/panel/geomap/GeomapPanel.tsx | 31 +-- .../panel/geomap/editor/BaseLayerEditor.tsx | 6 +- .../panel/geomap/editor/DataLayersEditor.tsx | 6 +- .../panel/geomap/editor/LayerEditor.tsx | 92 ++++++-- .../panel/geomap/layers/basemaps/carto.ts | 8 +- .../panel/geomap/layers/basemaps/esri.ts | 10 +- .../panel/geomap/layers/basemaps/generic.ts | 8 +- .../panel/geomap/layers/basemaps/osm.ts | 4 +- .../panel/geomap/layers/data/geojsonMapper.ts | 4 +- .../geomap/layers/data/lastPointTracker.ts | 31 ++- .../panel/geomap/layers/data/markersLayer.tsx | 133 +++--------- .../plugins/panel/geomap/layers/data/utils.ts | 84 -------- .../plugins/panel/geomap/migrations.test.ts | 6 - public/app/plugins/panel/geomap/migrations.ts | 6 - public/app/plugins/panel/geomap/module.tsx | 17 +- public/app/plugins/panel/geomap/types.ts | 18 +- .../app/plugins/panel/geomap/utils/geohash.ts | 43 ++++ .../panel/geomap/utils/location.test.ts | 66 ++++++ .../plugins/panel/geomap/utils/location.ts | 201 ++++++++++++++++++ 21 files changed, 534 insertions(+), 296 deletions(-) delete mode 100644 public/app/plugins/panel/geomap/layers/data/utils.ts create mode 100644 public/app/plugins/panel/geomap/utils/geohash.ts create mode 100644 public/app/plugins/panel/geomap/utils/location.test.ts create mode 100644 public/app/plugins/panel/geomap/utils/location.ts diff --git a/jest.config.js b/jest.config.js index 30182570b0f..5a5832fd28e 100644 --- a/jest.config.js +++ b/jest.config.js @@ -8,6 +8,9 @@ module.exports = { transform: { '^.+\\.(ts|tsx|js|jsx)$': 'ts-jest', }, + transformIgnorePatterns: [ + 'node_modules/(?!(ol)/)', // <- exclude the open layers library + ], moduleDirectories: ['node_modules', 'public'], roots: ['/public/app', '/public/test', '/packages', '/scripts'], testRegex: '(\\.|/)(test)\\.(jsx?|tsx?)$', diff --git a/packages/grafana-data/src/geo/layer.ts b/packages/grafana-data/src/geo/layer.ts index 1e45da41f93..215c8bcd6e2 100644 --- a/packages/grafana-data/src/geo/layer.ts +++ b/packages/grafana-data/src/geo/layer.ts @@ -6,6 +6,37 @@ import { GrafanaTheme2 } from '../themes'; import { PanelOptionsEditorBuilder } from '../utils'; import { ReactNode } from 'react'; +/** + * @alpha + */ +export enum FrameGeometrySourceMode { + Auto = 'auto', // Will scan fields and find best match + Geohash = 'geohash', + Coords = 'coords', // lon field, lat field + Lookup = 'lookup', // keys > location + // H3 = 'h3', + // WKT = 'wkt, + // geojson? geometry text +} + +/** + * @alpha + */ +export interface FrameGeometrySource { + mode: FrameGeometrySourceMode; + + // Field mappings + geohash?: string; + latitude?: string; + longitude?: string; + h3?: string; + wkt?: string; + lookup?: string; + + // Path to a mappings file + lookupSrc: string; +} + /** * This gets saved in panel json * @@ -16,17 +47,20 @@ import { ReactNode } from 'react'; * * @alpha */ -export interface MapLayerConfig { +export interface MapLayerOptions { type: string; name?: string; // configured display name + // Custom options depending on the type + config?: TConfig; + + // Common method to define geometry fields + location?: FrameGeometrySource; + // Common properties: // https://openlayers.org/en/latest/apidoc/module-ol_layer_Base-BaseLayer.html // Layer opacity (0-1) opacity?: number; - - // Custom options depending on the type - config?: TCustom; } /** @@ -43,22 +77,27 @@ export interface MapLayerHandler { * * @alpha */ -export interface MapLayerRegistryItem extends RegistryItemWithOptions { +export interface MapLayerRegistryItem extends RegistryItemWithOptions { /** * This layer can be used as a background */ isBaseMap?: boolean; + /** + * Show location controls + */ + showLocation?: boolean; + /** * Show transparency controls in UI (for non-basemaps) */ - showTransparency?: boolean; + showOpacity?: boolean; /** * Function that configures transformation and returns a transformer * @param options */ - create: (map: Map, options: MapLayerConfig, theme: GrafanaTheme2) => MapLayerHandler; + create: (map: Map, options: MapLayerOptions, theme: GrafanaTheme2) => MapLayerHandler; /** * Show custom elements in the panel edit UI diff --git a/public/app/plugins/panel/geomap/GeomapPanel.tsx b/public/app/plugins/panel/geomap/GeomapPanel.tsx index f72424fe327..cde9d910e58 100644 --- a/public/app/plugins/panel/geomap/GeomapPanel.tsx +++ b/public/app/plugins/panel/geomap/GeomapPanel.tsx @@ -8,7 +8,7 @@ import BaseLayer from 'ol/layer/Base'; import { defaults as interactionDefaults } from 'ol/interaction'; import MouseWheelZoom from 'ol/interaction/MouseWheelZoom'; -import { PanelData, MapLayerHandler, MapLayerConfig, PanelProps, GrafanaTheme } from '@grafana/data'; +import { PanelData, MapLayerHandler, MapLayerOptions, PanelProps, GrafanaTheme } from '@grafana/data'; import { config } from '@grafana/runtime'; import { ControlsOptions, GeomapPanelOptions, MapViewConfig } from './types'; @@ -24,7 +24,7 @@ import { getGlobalStyles } from './globalStyles'; import { Global } from '@emotion/react'; interface MapLayerState { - config: MapLayerConfig; + config: MapLayerOptions; handler: MapLayerHandler; layer: BaseLayer; // used to add|remove } @@ -36,11 +36,10 @@ type Props = PanelProps; export class GeomapPanel extends Component { globalCSS = getGlobalStyles(config.theme2); - map: Map; - - basemap: BaseLayer; + map?: Map; + basemap?: BaseLayer; layers: MapLayerState[] = []; - mouseWheelZoom: MouseWheelZoom; + mouseWheelZoom?: MouseWheelZoom; style = getStyles(config.theme); overlayProps: OverlayProps = {}; @@ -78,7 +77,7 @@ export class GeomapPanel extends Component { if (options.view !== oldOptions.view) { console.log('View changed'); - this.map.setView(this.initMapView(options.view)); + this.map!.setView(this.initMapView(options.view)); } if (options.controls !== oldOptions.controls) { @@ -145,7 +144,10 @@ export class GeomapPanel extends Component { this.forceUpdate(); // first render }; - initBasemap(cfg: MapLayerConfig) { + initBasemap(cfg: MapLayerOptions) { + if (!this.map) { + return; + } if (!cfg) { cfg = { type: defaultGrafanaThemedMap.id }; } @@ -159,10 +161,10 @@ export class GeomapPanel extends Component { this.map.getLayers().insertAt(0, this.basemap); } - initLayers(layers: MapLayerConfig[]) { + initLayers(layers: MapLayerOptions[]) { // 1st remove existing layers for (const state of this.layers) { - this.map.removeLayer(state.layer); + this.map!.removeLayer(state.layer); state.layer.dispose(); } @@ -178,9 +180,9 @@ export class GeomapPanel extends Component { continue; // TODO -- panel warning? } - const handler = item.create(this.map, overlay, config.theme2); + const handler = item.create(this.map!, overlay, config.theme2); const layer = handler.init(); - this.map.addLayer(layer); + this.map!.addLayer(layer); this.layers.push({ config: overlay, layer, @@ -235,6 +237,9 @@ export class GeomapPanel extends Component { } initControls(options: ControlsOptions) { + if (!this.map) { + return; + } this.map.getControls().clear(); if (options.showZoom) { @@ -250,7 +255,7 @@ export class GeomapPanel extends Component { ); } - this.mouseWheelZoom.setActive(Boolean(options.mouseWheelZoom)); + this.mouseWheelZoom!.setActive(Boolean(options.mouseWheelZoom)); if (options.showAttribution) { this.map.addControl(new Attribution({ collapsed: true, collapsible: true })); diff --git a/public/app/plugins/panel/geomap/editor/BaseLayerEditor.tsx b/public/app/plugins/panel/geomap/editor/BaseLayerEditor.tsx index 16dbbc4cfd7..5f22cd72ee6 100644 --- a/public/app/plugins/panel/geomap/editor/BaseLayerEditor.tsx +++ b/public/app/plugins/panel/geomap/editor/BaseLayerEditor.tsx @@ -1,12 +1,12 @@ import React, { FC } from 'react'; -import { StandardEditorProps, MapLayerConfig } from '@grafana/data'; +import { StandardEditorProps, MapLayerOptions } from '@grafana/data'; import { GeomapPanelOptions } from '../types'; import { LayerEditor } from './LayerEditor'; -export const BaseLayerEditor: FC> = ({ +export const BaseLayerEditor: FC> = ({ value, onChange, context, }) => { - return Boolean(v.isBaseMap)} />; + return Boolean(v.isBaseMap)} />; }; diff --git a/public/app/plugins/panel/geomap/editor/DataLayersEditor.tsx b/public/app/plugins/panel/geomap/editor/DataLayersEditor.tsx index 3a33c302992..029521972c4 100644 --- a/public/app/plugins/panel/geomap/editor/DataLayersEditor.tsx +++ b/public/app/plugins/panel/geomap/editor/DataLayersEditor.tsx @@ -1,17 +1,17 @@ import React, { FC } from 'react'; -import { StandardEditorProps, MapLayerConfig } from '@grafana/data'; +import { StandardEditorProps, MapLayerOptions } from '@grafana/data'; import { GeomapPanelOptions } from '../types'; import { LayerEditor } from './LayerEditor'; // For now this supports a *single* data layer -- eventually we should support more than one -export const DataLayersEditor: FC> = ({ +export const DataLayersEditor: FC> = ({ value, onChange, context, }) => { return ( { console.log('Change overlays:', cfg); diff --git a/public/app/plugins/panel/geomap/editor/LayerEditor.tsx b/public/app/plugins/panel/geomap/editor/LayerEditor.tsx index 441bd8d42d8..aca90ac5be6 100644 --- a/public/app/plugins/panel/geomap/editor/LayerEditor.tsx +++ b/public/app/plugins/panel/geomap/editor/LayerEditor.tsx @@ -1,11 +1,14 @@ import React, { FC, useMemo } from 'react'; import { Select } from '@grafana/ui'; import { - MapLayerConfig, + MapLayerOptions, DataFrame, MapLayerRegistryItem, PanelOptionsEditorBuilder, StandardEditorContext, + FrameGeometrySourceMode, + FieldType, + Field, } from '@grafana/data'; import { geomapLayerRegistry } from '../layers/registry'; import { defaultGrafanaThemedMap } from '../layers/basemaps'; @@ -14,37 +17,87 @@ import { setOptionImmutably } from 'app/features/dashboard/components/PanelEdito import { fillOptionsPaneItems } from 'app/features/dashboard/components/PanelEditor/getVizualizationOptions'; export interface LayerEditorProps { - config?: MapLayerConfig; + options?: MapLayerOptions; data: DataFrame[]; // All results - onChange: (config: MapLayerConfig) => void; + onChange: (options: MapLayerOptions) => void; filter: (item: MapLayerRegistryItem) => boolean; } -export const LayerEditor: FC = ({ config, onChange, data, filter }) => { +export const LayerEditor: FC = ({ options, onChange, data, filter }) => { // all basemaps const layerTypes = useMemo(() => { return geomapLayerRegistry.selectOptions( - config?.type // the selected value - ? [config.type] // as an array + options?.type // the selected value + ? [options.type] // as an array : [defaultGrafanaThemedMap.id], filter ); - }, [config?.type, filter]); + }, [options?.type, filter]); // The options change with each layer type const optionsEditorBuilder = useMemo(() => { - const layer = geomapLayerRegistry.getIfExists(config?.type); - if (!layer || !layer.registerOptionsUI) { + const layer = geomapLayerRegistry.getIfExists(options?.type); + if (!layer || !(layer.registerOptionsUI || layer.showLocation || layer.showOpacity)) { return null; } const builder = new PanelOptionsEditorBuilder(); - layer.registerOptionsUI(builder); + if (layer.showLocation) { + builder + .addRadio({ + path: 'location.mode', + name: 'Location', + description: '', + defaultValue: FrameGeometrySourceMode.Auto, + settings: { + options: [ + { value: FrameGeometrySourceMode.Auto, label: 'Auto' }, + { value: FrameGeometrySourceMode.Coords, label: 'Coords' }, + { value: FrameGeometrySourceMode.Geohash, label: 'Geohash' }, + ], + }, + }) + .addFieldNamePicker({ + path: 'location.latitude', + name: 'Latitude Field', + settings: { + filter: (f: Field) => f.type === FieldType.number, + noFieldsMessage: 'No numeric fields found', + }, + showIf: (opts: MapLayerOptions) => opts.location?.mode === FrameGeometrySourceMode.Coords, + }) + .addFieldNamePicker({ + path: 'location.longitude', + name: 'Longitude Field', + settings: { + filter: (f: Field) => f.type === FieldType.number, + noFieldsMessage: 'No numeric fields found', + }, + showIf: (opts: MapLayerOptions) => opts.location?.mode === FrameGeometrySourceMode.Coords, + }) + .addFieldNamePicker({ + path: 'location.geohash', + name: 'Geohash Field', + settings: { + filter: (f: Field) => f.type === FieldType.string, + noFieldsMessage: 'No strings fields found', + }, + showIf: (opts: MapLayerOptions) => opts.location?.mode === FrameGeometrySourceMode.Geohash, + // eslint-disable-next-line react/display-name + // info: (props) =>
HELLO
, + }); + } + if (layer.registerOptionsUI) { + layer.registerOptionsUI(builder); + } + if (layer.showOpacity) { + // TODO -- add opacity check + } return builder; - }, [config?.type]); + }, [options?.type]); // The react componnets const layerOptions = useMemo(() => { - const layer = geomapLayerRegistry.getIfExists(config?.type); + const layer = geomapLayerRegistry.getIfExists(options?.type); if (!optionsEditorBuilder || !layer) { return null; } @@ -56,10 +109,10 @@ export const LayerEditor: FC = ({ config, onChange, data, filt const context: StandardEditorContext = { data, - options: config?.config, + options: options, }; - const currentConfig = { ...layer.defaultOptions, ...config?.config }; + const currentOptions = { ...options, config: { ...layer.defaultOptions, ...options?.config } }; const reg = optionsEditorBuilder.getRegistry(); // Load the options into categories @@ -71,10 +124,7 @@ export const LayerEditor: FC = ({ config, onChange, data, filt // Custom upate function (path: string, value: any) => { - onChange({ - ...config, - config: setOptionImmutably(currentConfig, path, value), - } as MapLayerConfig); + onChange(setOptionImmutably(currentOptions, path, value) as any); }, context ); @@ -85,7 +135,7 @@ export const LayerEditor: FC = ({ config, onChange, data, filt {category.items.map((item) => item.render())} ); - }, [optionsEditorBuilder, onChange, data, config]); + }, [optionsEditorBuilder, onChange, data, options]); return (
@@ -98,9 +148,11 @@ export const LayerEditor: FC = ({ config, onChange, data, filt console.warn('layer does not exist', v); return; } + onChange({ + ...options, // keep current options type: layer.id, - config: layer.defaultOptions, // clone? + config: { ...layer.defaultOptions }, // clone? }); }} /> diff --git a/public/app/plugins/panel/geomap/layers/basemaps/carto.ts b/public/app/plugins/panel/geomap/layers/basemaps/carto.ts index 574a7de5a7d..a4f0228c879 100644 --- a/public/app/plugins/panel/geomap/layers/basemaps/carto.ts +++ b/public/app/plugins/panel/geomap/layers/basemaps/carto.ts @@ -1,4 +1,4 @@ -import { MapLayerRegistryItem, MapLayerConfig, GrafanaTheme2 } from '@grafana/data'; +import { MapLayerRegistryItem, MapLayerOptions, GrafanaTheme2 } from '@grafana/data'; import Map from 'ol/Map'; import XYZ from 'ol/source/XYZ'; import TileLayer from 'ol/layer/Tile'; @@ -31,7 +31,7 @@ export const carto: MapLayerRegistryItem = { * Function that configures transformation and returns a transformer * @param options */ - create: (map: Map, options: MapLayerConfig, theme: GrafanaTheme2) => ({ + create: (map: Map, options: MapLayerOptions, theme: GrafanaTheme2) => ({ init: () => { const cfg = { ...defaultCartoConfig, ...options.config }; let style = cfg.theme as string; @@ -55,7 +55,7 @@ export const carto: MapLayerRegistryItem = { registerOptionsUI: (builder) => { builder .addRadio({ - path: 'theme', + path: 'config.theme', name: 'Theme', settings: { options: [ @@ -67,7 +67,7 @@ export const carto: MapLayerRegistryItem = { defaultValue: defaultCartoConfig.theme!, }) .addBooleanSwitch({ - path: 'showLabels', + path: 'config.showLabels', name: 'Show labels', description: '', defaultValue: defaultCartoConfig.showLabels, diff --git a/public/app/plugins/panel/geomap/layers/basemaps/esri.ts b/public/app/plugins/panel/geomap/layers/basemaps/esri.ts index 7e03cfcb0c3..cee39c0ad08 100644 --- a/public/app/plugins/panel/geomap/layers/basemaps/esri.ts +++ b/public/app/plugins/panel/geomap/layers/basemaps/esri.ts @@ -1,4 +1,4 @@ -import { MapLayerRegistryItem, MapLayerConfig, GrafanaTheme2, RegistryItem, Registry } from '@grafana/data'; +import { MapLayerRegistryItem, MapLayerOptions, GrafanaTheme2, RegistryItem, Registry } from '@grafana/data'; import Map from 'ol/Map'; import { xyzTiles, defaultXYZConfig, XYZConfig } from './generic'; @@ -57,7 +57,7 @@ export const esriXYZTiles: MapLayerRegistryItem = { name: 'ArcGIS MapServer', isBaseMap: true, - create: (map: Map, options: MapLayerConfig, theme: GrafanaTheme2) => ({ + create: (map: Map, options: MapLayerOptions, theme: GrafanaTheme2) => ({ init: () => { const cfg = { ...options.config }; const svc = publicServiceRegistry.getIfExists(cfg.server ?? DEFAULT_SERVICE)!; @@ -74,14 +74,14 @@ export const esriXYZTiles: MapLayerRegistryItem = { registerOptionsUI: (builder) => { builder .addSelect({ - path: 'server', + path: 'config.server', name: 'Server instance', settings: { options: publicServiceRegistry.selectOptions().options, }, }) .addTextInput({ - path: 'url', + path: 'config.url', name: 'URL template', description: 'Must include {x}, {y} or {-y}, and {z} placeholders', settings: { @@ -90,7 +90,7 @@ export const esriXYZTiles: MapLayerRegistryItem = { showIf: (cfg) => cfg.server === CUSTOM_SERVICE, }) .addTextInput({ - path: 'attribution', + path: 'config.attribution', name: 'Attribution', settings: { placeholder: defaultXYZConfig.attribution, diff --git a/public/app/plugins/panel/geomap/layers/basemaps/generic.ts b/public/app/plugins/panel/geomap/layers/basemaps/generic.ts index 01b0dcdf51a..89ab35c42b7 100644 --- a/public/app/plugins/panel/geomap/layers/basemaps/generic.ts +++ b/public/app/plugins/panel/geomap/layers/basemaps/generic.ts @@ -1,4 +1,4 @@ -import { MapLayerRegistryItem, MapLayerConfig, GrafanaTheme2 } from '@grafana/data'; +import { MapLayerRegistryItem, MapLayerOptions, GrafanaTheme2 } from '@grafana/data'; import Map from 'ol/Map'; import XYZ from 'ol/source/XYZ'; import TileLayer from 'ol/layer/Tile'; @@ -21,7 +21,7 @@ export const xyzTiles: MapLayerRegistryItem = { name: 'XYZ Tile layer', isBaseMap: true, - create: (map: Map, options: MapLayerConfig, theme: GrafanaTheme2) => ({ + create: (map: Map, options: MapLayerOptions, theme: GrafanaTheme2) => ({ init: () => { const cfg = { ...options.config }; if (!cfg.url) { @@ -42,7 +42,7 @@ export const xyzTiles: MapLayerRegistryItem = { registerOptionsUI: (builder) => { builder .addTextInput({ - path: 'url', + path: 'config.url', name: 'URL template', description: 'Must include {x}, {y} or {-y}, and {z} placeholders', settings: { @@ -50,7 +50,7 @@ export const xyzTiles: MapLayerRegistryItem = { }, }) .addTextInput({ - path: 'attribution', + path: 'config.attribution', name: 'Attribution', settings: { placeholder: defaultXYZConfig.attribution, diff --git a/public/app/plugins/panel/geomap/layers/basemaps/osm.ts b/public/app/plugins/panel/geomap/layers/basemaps/osm.ts index 4fe5ca41bf6..88a20a09b3c 100644 --- a/public/app/plugins/panel/geomap/layers/basemaps/osm.ts +++ b/public/app/plugins/panel/geomap/layers/basemaps/osm.ts @@ -1,4 +1,4 @@ -import { MapLayerRegistryItem, MapLayerConfig } from '@grafana/data'; +import { MapLayerRegistryItem, MapLayerOptions } from '@grafana/data'; import Map from 'ol/Map'; import OSM from 'ol/source/OSM'; import TileLayer from 'ol/layer/Tile'; @@ -12,7 +12,7 @@ const standard: MapLayerRegistryItem = { * Function that configures transformation and returns a transformer * @param options */ - create: (map: Map, options: MapLayerConfig) => ({ + create: (map: Map, options: MapLayerOptions) => ({ init: () => { return new TileLayer({ source: new OSM(), diff --git a/public/app/plugins/panel/geomap/layers/data/geojsonMapper.ts b/public/app/plugins/panel/geomap/layers/data/geojsonMapper.ts index 7641af90236..99766175135 100644 --- a/public/app/plugins/panel/geomap/layers/data/geojsonMapper.ts +++ b/public/app/plugins/panel/geomap/layers/data/geojsonMapper.ts @@ -1,4 +1,4 @@ -import { MapLayerRegistryItem, MapLayerConfig, MapLayerHandler, PanelData, GrafanaTheme2 } from '@grafana/data'; +import { MapLayerRegistryItem, MapLayerOptions, MapLayerHandler, PanelData, GrafanaTheme2 } from '@grafana/data'; import Map from 'ol/Map'; import VectorLayer from 'ol/layer/Vector'; import VectorSource from 'ol/source/Vector'; @@ -29,7 +29,7 @@ export const geojsonMapper: MapLayerRegistryItem = { * Function that configures transformation and returns a transformer * @param options */ - create: (map: Map, options: MapLayerConfig, theme: GrafanaTheme2): MapLayerHandler => { + create: (map: Map, options: MapLayerOptions, theme: GrafanaTheme2): MapLayerHandler => { const config = { ...defaultOptions, ...options.config }; const source = new VectorSource({ diff --git a/public/app/plugins/panel/geomap/layers/data/lastPointTracker.ts b/public/app/plugins/panel/geomap/layers/data/lastPointTracker.ts index 7954ddbfb6e..8811ac6b9e9 100644 --- a/public/app/plugins/panel/geomap/layers/data/lastPointTracker.ts +++ b/public/app/plugins/panel/geomap/layers/data/lastPointTracker.ts @@ -1,11 +1,10 @@ -import { MapLayerRegistryItem, MapLayerConfig, MapLayerHandler, PanelData, Field, GrafanaTheme2 } from '@grafana/data'; +import { MapLayerRegistryItem, MapLayerOptions, MapLayerHandler, PanelData, GrafanaTheme2 } from '@grafana/data'; import Map from 'ol/Map'; import Feature from 'ol/Feature'; import * as style from 'ol/style'; import * as source from 'ol/source'; import * as layer from 'ol/layer'; -import Point from 'ol/geom/Point'; -import { fromLonLat } from 'ol/proj'; +import { dataFrameToPoints, getLocationMatchers } from '../../utils/location'; export interface LastPointConfig { icon?: string; @@ -20,12 +19,13 @@ export const lastPointTracker: MapLayerRegistryItem = { name: 'Icon at last point', description: 'Show an icon at the last point', isBaseMap: false, + showLocation: true, /** * Function that configures transformation and returns a transformer * @param options */ - create: (map: Map, options: MapLayerConfig, theme: GrafanaTheme2): MapLayerHandler => { + create: (map: Map, options: MapLayerOptions, theme: GrafanaTheme2): MapLayerHandler => { const point = new Feature({}); const config = { ...defaultOptions, ...options.config }; @@ -45,28 +45,21 @@ export const lastPointTracker: MapLayerRegistryItem = { source: vectorSource, }); + const matchers = getLocationMatchers(options.location); return { init: () => vectorLayer, update: (data: PanelData) => { const frame = data.series[0]; if (frame && frame.length) { - let lat: Field | undefined = undefined; - let lng: Field | undefined = undefined; - for (const field of frame.fields) { - if (field.name === 'lat') { - lat = field; - } else if (field.name === 'lng') { - lng = field; - } + const info = dataFrameToPoints(frame, matchers); + if(info.warning) { + console.log( 'WARN', info.warning); + return; // ??? } - if (lat && lng) { - const idx = lat.values.length - 1; - const latV = lat.values.get(idx); - const lngV = lng.values.get(idx); - if (latV != null && lngV != null) { - point.setGeometry(new Point(fromLonLat([lngV, latV]))); - } + if(info.points?.length) { + const last = info.points[info.points.length-1]; + point.setGeometry(last); } } }, diff --git a/public/app/plugins/panel/geomap/layers/data/markersLayer.tsx b/public/app/plugins/panel/geomap/layers/data/markersLayer.tsx index bf514bec6d0..7089fdd3bd3 100644 --- a/public/app/plugins/panel/geomap/layers/data/markersLayer.tsx +++ b/public/app/plugins/panel/geomap/layers/data/markersLayer.tsx @@ -1,33 +1,20 @@ -import React from 'react'; -import { MapLayerRegistryItem, MapLayerConfig, MapLayerHandler, PanelData, GrafanaTheme2, reduceField, ReducerID, FieldCalcs, FieldType } from '@grafana/data'; -import { dataFrameToPoints } from './utils' -import { FieldMappingOptions, QueryFormat } from '../../types' +import { MapLayerRegistryItem, MapLayerOptions, MapLayerHandler, PanelData, GrafanaTheme2, reduceField, ReducerID, FieldCalcs, FieldType } from '@grafana/data'; import Map from 'ol/Map'; import Feature from 'ol/Feature'; 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'; // Configuration options for Circle overlays export interface MarkersConfig { - queryFormat: QueryFormat, - fieldMapping: FieldMappingOptions, minSize: number, maxSize: number, opacity: number, } const defaultOptions: MarkersConfig = { - queryFormat: { - locationType: 'coordinates', - }, - fieldMapping: { - metricField: '', - geohashField: '', - latitudeField: '', - longitudeField: '', - }, minSize: 1, maxSize: 10, opacity: 0.4, @@ -43,24 +30,32 @@ export const markersLayer: MapLayerRegistryItem = { name: 'Markers', description: 'use markers to render each data point', isBaseMap: false, + showLocation: true, /** * Function that configures transformation and returns a transformer * @param options */ - create: (map: Map, options: MapLayerConfig, theme: GrafanaTheme2): MapLayerHandler => { + create: (map: Map, options: MapLayerOptions, theme: GrafanaTheme2): MapLayerHandler => { const config = { ...defaultOptions, ...options.config }; + const matchers = getLocationMatchers(options.location); const vectorLayer = new layer.Vector({}); return { init: () => vectorLayer, update: (data: PanelData) => { - const features: Feature[] = []; - const frame = data.series[0]; + if(!data.series?.length) { + return; // ignore empty + } - // Get data values - const points = dataFrameToPoints(frame, config.fieldMapping, config.queryFormat); - const field = frame.fields.find(field => field.name === config.fieldMapping.metricField); + const frame = data.series[0]; + const info = dataFrameToPoints(frame, matchers); + if(info.warning) { + console.log( 'WARN', info.warning); + return; // ??? + } + + const field = frame.fields.find(field => field.type === FieldType.number); // TODO!!!! // Return early if metric field is not matched if (field === undefined) { return; @@ -76,6 +71,8 @@ export const markersLayer: MapLayerRegistryItem = { ] }); + const features: Feature[] = []; + // 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 @@ -88,7 +85,7 @@ export const markersLayer: MapLayerRegistryItem = { // Create a new Feature for each point returned from dataFrameToPoints const dot = new Feature({ - geometry: points[i], + geometry: info.points[i], }); // Set the style of each feature dot @@ -117,97 +114,29 @@ export const markersLayer: MapLayerRegistryItem = { // Circle overlay options registerOptionsUI: (builder) => { builder - .addSelect({ - path: 'queryFormat.locationType', - name: 'Location source', - defaultValue: defaultOptions.queryFormat.locationType, - settings: { - options: [ - { - value: 'coordinates', - label: 'Latitude/Longitude fields', - }, - { - value: 'geohash', - label: 'Geohash field', - }, - ], - }, - }) - .addFieldNamePicker({ - path: 'fieldMapping.latitudeField', - name: 'Latitude Field', - defaultValue: defaultOptions.fieldMapping.latitudeField, - settings: { - filter: (f) => f.type === FieldType.number, - noFieldsMessage: 'No numeric fields found', - }, - showIf: (config) => - config.queryFormat.locationType === 'coordinates', - }) - .addFieldNamePicker({ - path: 'fieldMapping.longitudeField', - name: 'Longitude Field', - defaultValue: defaultOptions.fieldMapping.longitudeField, - settings: { - filter: (f) => f.type === FieldType.number, - noFieldsMessage: 'No numeric fields found', - }, - showIf: (config) => - config.queryFormat.locationType === 'coordinates', - }) - .addFieldNamePicker({ - path: 'fieldMapping.geohashField', - name: 'Geohash Field', - defaultValue: defaultOptions.fieldMapping.geohashField, - settings: { - filter: (f) => f.type === FieldType.string, - noFieldsMessage: 'No strings fields found', - info: ({ - name, - field, - }) => { - if(!name || !field) { - return
Select a field that contains geohash values in each row.
- } - const first = reduceField({field, reducers:[ReducerID.firstNotNull]})[ReducerID.firstNotNull] as string; - if(!first) { - return
No values found
- } - // const coords = decodeGeohash(first); - // if(coords) { - // return
First value: {`${coords}`} // {new Date().toISOString()}
- // } - // return
Invalid first value: {`${first}`}
; - return null; - } - }, - showIf: (config) => - config.queryFormat.locationType === 'geohash', - }) - .addFieldNamePicker({ - path: 'fieldMapping.metricField', - name: 'Metric Field', - defaultValue: defaultOptions.fieldMapping.metricField, - settings: { - filter: (f) => f.type === FieldType.number, - noFieldsMessage: 'No numeric fields found', - }, - }) + // .addFieldNamePicker({ + // path: 'fieldMapping.metricField', + // name: 'Metric Field', + // defaultValue: defaultOptions.fieldMapping.metricField, + // settings: { + // filter: (f) => f.type === FieldType.number, + // noFieldsMessage: 'No numeric fields found', + // }, + // }) .addNumberInput({ - path: 'minSize', + path: 'config.minSize', description: 'configures the min circle size', name: 'Min Size', defaultValue: defaultOptions.minSize, }) .addNumberInput({ - path: 'maxSize', + path: 'config.maxSize', description: 'configures the max circle size', name: 'Max Size', defaultValue: defaultOptions.maxSize, }) .addSliderInput({ - path: 'opacity', + path: 'config.opacity', description: 'configures the amount of transparency', name: 'Opacity', defaultValue: defaultOptions.opacity, diff --git a/public/app/plugins/panel/geomap/layers/data/utils.ts b/public/app/plugins/panel/geomap/layers/data/utils.ts deleted file mode 100644 index b38bee56094..00000000000 --- a/public/app/plugins/panel/geomap/layers/data/utils.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { DataFrame, DataFrameView } from '@grafana/data'; -import { Point } from 'ol/geom'; -import { fromLonLat } from 'ol/proj'; -import { FieldMappingOptions, QueryFormat } from '../../types' - - /** - * Function that formats dataframe into a Point[] - */ -export function dataFrameToPoints(frame: DataFrame, fieldMapping: FieldMappingOptions, queryFormat: QueryFormat): Point[] { - - let points: Point[] = []; - - if (frame && frame.length > 0) { - - // For each data point, create a Point - const view = new DataFrameView(frame); - view.forEach(row => { - - let lng; - let lat; - - // Coordinate Data - if (queryFormat.locationType === "coordinates") { - lng = row[fieldMapping.longitudeField]; - lat = row[fieldMapping.latitudeField]; - } - // Geohash Data - else if (queryFormat.locationType === "geohash"){ - const encodedGeohash = row[fieldMapping.geohashField]; - const decodedGeohash = decodeGeohash(encodedGeohash); - lng = decodedGeohash.longitude; - lat = decodedGeohash.latitude; - } - points.push(new Point(fromLonLat([lng,lat]))); - }); - } - return points; -} - - /** - * Function that decodes input geohash into latitude and longitude - */ -function decodeGeohash(geohash: string) { - if (!geohash || geohash.length === 0) { - throw new Error('Missing geohash value'); - } - const BITS = [16, 8, 4, 2, 1]; - const BASE32 = '0123456789bcdefghjkmnpqrstuvwxyz'; - let isEven = true; - const lat: number[] = []; - const lon: number[] = []; - lat[0] = -90.0; - lat[1] = 90.0; - lon[0] = -180.0; - lon[1] = 180.0; - let base32Decoded: number; - - geohash.split('').forEach((item: string) => { - base32Decoded = BASE32.indexOf(item); - BITS.forEach(mask => { - if (isEven) { - refineInterval(lon, base32Decoded, mask); - } - else { - refineInterval(lat, base32Decoded, mask); - } - isEven = !isEven; - }); - }); - const latCenter = (lat[0] + lat[1]) / 2; - const lonCenter = (lon[0] + lon[1]) / 2; - - return { latitude: latCenter, longitude: lonCenter }; -} - -function refineInterval(interval: any[], base32Decoded: number, mask: number) { - /* tslint:disable no-bitwise*/ - if (base32Decoded & mask) { - interval[0] = (interval[0] + interval[1]) / 2; - } - else { - interval[1] = (interval[0] + interval[1]) / 2; - } -} diff --git a/public/app/plugins/panel/geomap/migrations.test.ts b/public/app/plugins/panel/geomap/migrations.test.ts index 32ed0a1bf41..c14f26c2b63 100644 --- a/public/app/plugins/panel/geomap/migrations.test.ts +++ b/public/app/plugins/panel/geomap/migrations.test.ts @@ -55,12 +55,6 @@ describe('Worldmap Migrations', () => { "showLegend": true, "showZoom": true, }, - "fieldMapping": Object { - "geohashField": "", - "latitudeField": "", - "longitudeField": "", - "metricField": "", - }, "layers": Array [], "view": Object { "center": Object { diff --git a/public/app/plugins/panel/geomap/migrations.ts b/public/app/plugins/panel/geomap/migrations.ts index c3f0bf61605..5b3ca894b7c 100644 --- a/public/app/plugins/panel/geomap/migrations.ts +++ b/public/app/plugins/panel/geomap/migrations.ts @@ -42,12 +42,6 @@ export function worldmapToGeomapOptions(angular: any): { fieldConfig: FieldConfi layers: [ // TODO? depends on current configs ], - fieldMapping: { - metricField: '', - geohashField: '', - latitudeField: '', - longitudeField: '', - }, }; let v = asNumber(angular.decimals); diff --git a/public/app/plugins/panel/geomap/module.tsx b/public/app/plugins/panel/geomap/module.tsx index c609aa2ba99..17c6a09dda1 100644 --- a/public/app/plugins/panel/geomap/module.tsx +++ b/public/app/plugins/panel/geomap/module.tsx @@ -1,4 +1,4 @@ -import { PanelPlugin } from '@grafana/data'; +import { FrameGeometrySourceMode, PanelPlugin } from '@grafana/data'; import { BaseLayerEditor } from './editor/BaseLayerEditor'; import { DataLayersEditor } from './editor/DataLayersEditor'; import { GeomapPanel } from './GeomapPanel'; @@ -6,6 +6,8 @@ import { MapCenterEditor } from './editor/MapCenterEditor'; import { defaultView, GeomapPanelOptions } from './types'; import { MapZoomEditor } from './editor/MapZoomEditor'; import { mapPanelChangedHandler } from './migrations'; +import { defaultGrafanaThemedMap } from './layers/basemaps'; +import { MARKERS_LAYER_ID } from './layers/data/markersLayer'; export const plugin = new PanelPlugin(GeomapPanel) .setNoPadding() @@ -46,6 +48,10 @@ export const plugin = new PanelPlugin(GeomapPanel) path: 'basemap', name: 'Base Layer', editor: BaseLayerEditor, + defaultValue: { + type: defaultGrafanaThemedMap.id, + config: defaultGrafanaThemedMap.defaultOptions, + }, }); builder.addCustomEditor({ @@ -54,6 +60,15 @@ export const plugin = new PanelPlugin(GeomapPanel) path: 'layers', name: 'Data Layer', editor: DataLayersEditor, + defaultValue: [ + { + type: MARKERS_LAYER_ID, + config: {}, + location: { + mode: FrameGeometrySourceMode.Auto, + }, + }, + ], }); // The controls section diff --git a/public/app/plugins/panel/geomap/types.ts b/public/app/plugins/panel/geomap/types.ts index 7fa7d4facbf..84230046e8f 100644 --- a/public/app/plugins/panel/geomap/types.ts +++ b/public/app/plugins/panel/geomap/types.ts @@ -1,4 +1,4 @@ -import { MapLayerConfig } from '@grafana/data'; +import { MapLayerOptions } from '@grafana/data'; import Units from 'ol/proj/Units'; import { MapCenterID } from './view'; @@ -47,18 +47,6 @@ export const defaultView: MapViewConfig = { export interface GeomapPanelOptions { view: MapViewConfig; controls: ControlsOptions; - basemap: MapLayerConfig; - layers: MapLayerConfig[]; - fieldMapping: FieldMappingOptions; -} - -export interface FieldMappingOptions { - metricField: string; - geohashField: string; - latitudeField: string; - longitudeField: string; -} - -export interface QueryFormat { - locationType: string; + basemap: MapLayerOptions; + layers: MapLayerOptions[]; } diff --git a/public/app/plugins/panel/geomap/utils/geohash.ts b/public/app/plugins/panel/geomap/utils/geohash.ts new file mode 100644 index 00000000000..1a2ebf0e0b6 --- /dev/null +++ b/public/app/plugins/panel/geomap/utils/geohash.ts @@ -0,0 +1,43 @@ +/** + * Function that decodes input geohash into latitude and longitude + */ +export function decodeGeohash(geohash: string): [number, number] | undefined { + if (!geohash?.length) { + return undefined; + } + const BITS = [16, 8, 4, 2, 1]; + const BASE32 = '0123456789bcdefghjkmnpqrstuvwxyz'; + let isEven = true; + const lat: number[] = []; + const lon: number[] = []; + lat[0] = -90.0; + lat[1] = 90.0; + lon[0] = -180.0; + lon[1] = 180.0; + let base32Decoded: number; + + geohash.split('').forEach((item: string) => { + base32Decoded = BASE32.indexOf(item); + BITS.forEach((mask) => { + if (isEven) { + refineInterval(lon, base32Decoded, mask); + } else { + refineInterval(lat, base32Decoded, mask); + } + isEven = !isEven; + }); + }); + const latCenter = (lat[0] + lat[1]) / 2; + const lonCenter = (lon[0] + lon[1]) / 2; + + return [lonCenter, latCenter]; +} + +function refineInterval(interval: any[], base32Decoded: number, mask: number) { + /* tslint:disable no-bitwise*/ + if (base32Decoded & mask) { + interval[0] = (interval[0] + interval[1]) / 2; + } else { + interval[1] = (interval[0] + interval[1]) / 2; + } +} diff --git a/public/app/plugins/panel/geomap/utils/location.test.ts b/public/app/plugins/panel/geomap/utils/location.test.ts new file mode 100644 index 00000000000..e296159207e --- /dev/null +++ b/public/app/plugins/panel/geomap/utils/location.test.ts @@ -0,0 +1,66 @@ +import { toDataFrame, FieldType, FrameGeometrySourceMode } from '@grafana/data'; +import { toLonLat } from 'ol/proj'; +import { dataFrameToPoints, getLocationFields, getLocationMatchers } from './location'; + +const longitude = [0, -74.1]; +const latitude = [0, 40.7]; +const geohash = ['9q94r', 'dr5rs']; +const names = ['A', 'B']; + +describe('handle location parsing', () => { + it('auto should find geohash field', () => { + const frame = toDataFrame({ + name: 'simple', + fields: [ + { name: 'name', type: FieldType.string, values: names }, + { name: 'geohash', type: FieldType.number, values: geohash }, + ], + }); + + const matchers = getLocationMatchers(); + const fields = getLocationFields(frame, matchers); + expect(fields.mode).toEqual(FrameGeometrySourceMode.Geohash); + expect(fields.geohash).toBeDefined(); + expect(fields.geohash?.name).toEqual('geohash'); + + const info = dataFrameToPoints(frame, matchers); + expect(info.points.map((p) => toLonLat(p.getCoordinates()))).toMatchInlineSnapshot(` + Array [ + Array [ + -122.01416015625001, + 36.979980468750014, + ], + Array [ + -73.98193359375, + 40.71533203125, + ], + ] + `); + }); + + it('auto should find coordinate fields', () => { + const frame = toDataFrame({ + name: 'simple', + fields: [ + { name: 'name', type: FieldType.string, values: names }, + { name: 'latitude', type: FieldType.number, values: latitude }, + { name: 'longitude', type: FieldType.number, values: longitude }, + ], + }); + + const matchers = getLocationMatchers(); + const info = dataFrameToPoints(frame, matchers); + expect(info.points.map((p) => toLonLat(p.getCoordinates()))).toMatchInlineSnapshot(` + Array [ + Array [ + 0, + 0, + ], + Array [ + -74.1, + 40.69999999999999, + ], + ] + `); + }); +}); diff --git a/public/app/plugins/panel/geomap/utils/location.ts b/public/app/plugins/panel/geomap/utils/location.ts new file mode 100644 index 00000000000..1f0ec51aa0b --- /dev/null +++ b/public/app/plugins/panel/geomap/utils/location.ts @@ -0,0 +1,201 @@ +import { + FrameGeometrySource, + FrameGeometrySourceMode, + FieldMatcher, + getFieldMatcher, + FieldMatcherID, + DataFrame, + Field, + getFieldDisplayName, +} from '@grafana/data'; +import { Point } from 'ol/geom'; +import { fromLonLat } from 'ol/proj'; +import { decodeGeohash } from './geohash'; + +export type FieldFinder = (frame: DataFrame) => Field | undefined; + +function getFieldFinder(matcher: FieldMatcher): FieldFinder { + return (frame: DataFrame) => { + for (const field of frame.fields) { + if (matcher(field, frame, [])) { + return field; + } + } + return undefined; + }; +} + +function matchLowerNames(names: Set): FieldFinder { + return (frame: DataFrame) => { + for (const field of frame.fields) { + if (names.has(field.name.toLowerCase())) { + return field; + } + const disp = getFieldDisplayName(field, frame); + if (names.has(disp)) { + return field; + } + } + return undefined; + }; +} + +export interface LocationFieldMatchers { + mode: FrameGeometrySourceMode; + + // Field mappings + geohash: FieldFinder; + latitude: FieldFinder; + longitude: FieldFinder; + h3: FieldFinder; + wkt: FieldFinder; + lookup: FieldFinder; +} + +const defaultMatchers: LocationFieldMatchers = { + mode: FrameGeometrySourceMode.Auto, + geohash: matchLowerNames(new Set(['geohash'])), + latitude: matchLowerNames(new Set(['latitude', 'lat'])), + longitude: matchLowerNames(new Set(['longitude', 'lon', 'lng'])), + h3: matchLowerNames(new Set(['h3'])), + wkt: matchLowerNames(new Set(['wkt'])), + lookup: matchLowerNames(new Set(['lookup'])), +}; + +export function getLocationMatchers(src?: FrameGeometrySource): LocationFieldMatchers { + const info: LocationFieldMatchers = { + ...defaultMatchers, + mode: src?.mode ?? FrameGeometrySourceMode.Auto, + }; + switch (info.mode) { + case FrameGeometrySourceMode.Geohash: + if (src?.geohash) { + info.geohash = getFieldFinder(getFieldMatcher({ id: FieldMatcherID.byName, options: src.geohash })); + } + break; + case FrameGeometrySourceMode.Lookup: + if (src?.lookup) { + info.lookup = getFieldFinder(getFieldMatcher({ id: FieldMatcherID.byName, options: src.lookup })); + } + break; + case FrameGeometrySourceMode.Coords: + if (src?.latitude) { + info.latitude = getFieldFinder(getFieldMatcher({ id: FieldMatcherID.byName, options: src.latitude })); + } + if (src?.longitude) { + info.longitude = getFieldFinder(getFieldMatcher({ id: FieldMatcherID.byName, options: src.longitude })); + } + break; + } + return info; +} +export interface LocationFields { + mode: FrameGeometrySourceMode; + + // Field mappings + geohash?: Field; + latitude?: Field; + longitude?: Field; + h3?: Field; + wkt?: Field; + lookup?: Field; +} + +export function getLocationFields(frame: DataFrame, location: LocationFieldMatchers): LocationFields { + const fields: LocationFields = { + mode: location.mode ?? FrameGeometrySourceMode.Auto, + }; + + // Find the best option + if (fields.mode === FrameGeometrySourceMode.Auto) { + fields.latitude = location.latitude(frame); + fields.longitude = location.longitude(frame); + if (fields.latitude && fields.longitude) { + fields.mode = FrameGeometrySourceMode.Coords; + return fields; + } + fields.geohash = location.geohash(frame); + if (fields.geohash) { + fields.mode = FrameGeometrySourceMode.Geohash; + return fields; + } + fields.lookup = location.geohash(frame); + if (fields.lookup) { + fields.mode = FrameGeometrySourceMode.Lookup; + return fields; + } + } + + switch (fields.mode) { + case FrameGeometrySourceMode.Coords: + fields.latitude = location.latitude(frame); + fields.longitude = location.longitude(frame); + break; + case FrameGeometrySourceMode.Geohash: + fields.geohash = location.geohash(frame); + break; + case FrameGeometrySourceMode.Lookup: + fields.lookup = location.lookup(frame); + break; + } + + return fields; +} + +export interface LocationInfo { + warning?: string; + points: Point[]; +} + +export function dataFrameToPoints(frame: DataFrame, location: LocationFieldMatchers): LocationInfo { + const info: LocationInfo = { + points: [], + }; + if (!frame?.length) { + return info; + } + const fields = getLocationFields(frame, location); + switch (fields.mode) { + case FrameGeometrySourceMode.Coords: + if (fields.latitude && fields.longitude) { + info.points = getPointsFromLonLat(fields.longitude, fields.latitude); + } else { + info.warning = 'Missing latitude/longitude fields'; + } + break; + + case FrameGeometrySourceMode.Geohash: + if (fields.geohash) { + info.points = getPointsFromGeohash(fields.geohash); + } else { + info.warning = 'Missing geohash field'; + } + break; + + case FrameGeometrySourceMode.Auto: + info.warning = 'Unable to find location fields'; + } + + return info; +} + +function getPointsFromLonLat(lon: Field, lat: Field): Point[] { + const count = lat.values.length; + const points = new Array(count); + for (let i = 0; i < count; i++) { + points[i] = new Point(fromLonLat([lon.values.get(i), lat.values.get(i)])); + } + return points; +} + +function getPointsFromGeohash(field: Field): Point[] { + const count = field.values.length; + const points = new Array(count); + for (let i = 0; i < count; i++) { + const coords = decodeGeohash(field.values.get(i)); + if (coords) { + points[i] = new Point(fromLonLat(coords)); + } + } + return points; +}