Geomap: use a common configuration builder to find location fields (#36768)

This commit is contained in:
Ryan McKinley
2021-07-15 12:00:19 -07:00
committed by GitHub
parent dc5778c303
commit 8de218d5f1
21 changed files with 534 additions and 296 deletions

View File

@@ -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<CartoConfig> = {
* Function that configures transformation and returns a transformer
* @param options
*/
create: (map: Map, options: MapLayerConfig<CartoConfig>, theme: GrafanaTheme2) => ({
create: (map: Map, options: MapLayerOptions<CartoConfig>, theme: GrafanaTheme2) => ({
init: () => {
const cfg = { ...defaultCartoConfig, ...options.config };
let style = cfg.theme as string;
@@ -55,7 +55,7 @@ export const carto: MapLayerRegistryItem<CartoConfig> = {
registerOptionsUI: (builder) => {
builder
.addRadio({
path: 'theme',
path: 'config.theme',
name: 'Theme',
settings: {
options: [
@@ -67,7 +67,7 @@ export const carto: MapLayerRegistryItem<CartoConfig> = {
defaultValue: defaultCartoConfig.theme!,
})
.addBooleanSwitch({
path: 'showLabels',
path: 'config.showLabels',
name: 'Show labels',
description: '',
defaultValue: defaultCartoConfig.showLabels,

View File

@@ -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<ESRIXYZConfig> = {
name: 'ArcGIS MapServer',
isBaseMap: true,
create: (map: Map, options: MapLayerConfig<ESRIXYZConfig>, theme: GrafanaTheme2) => ({
create: (map: Map, options: MapLayerOptions<ESRIXYZConfig>, theme: GrafanaTheme2) => ({
init: () => {
const cfg = { ...options.config };
const svc = publicServiceRegistry.getIfExists(cfg.server ?? DEFAULT_SERVICE)!;
@@ -74,14 +74,14 @@ export const esriXYZTiles: MapLayerRegistryItem<ESRIXYZConfig> = {
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<ESRIXYZConfig> = {
showIf: (cfg) => cfg.server === CUSTOM_SERVICE,
})
.addTextInput({
path: 'attribution',
path: 'config.attribution',
name: 'Attribution',
settings: {
placeholder: defaultXYZConfig.attribution,

View File

@@ -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<XYZConfig> = {
name: 'XYZ Tile layer',
isBaseMap: true,
create: (map: Map, options: MapLayerConfig<XYZConfig>, theme: GrafanaTheme2) => ({
create: (map: Map, options: MapLayerOptions<XYZConfig>, theme: GrafanaTheme2) => ({
init: () => {
const cfg = { ...options.config };
if (!cfg.url) {
@@ -42,7 +42,7 @@ export const xyzTiles: MapLayerRegistryItem<XYZConfig> = {
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<XYZConfig> = {
},
})
.addTextInput({
path: 'attribution',
path: 'config.attribution',
name: 'Attribution',
settings: {
placeholder: defaultXYZConfig.attribution,

View File

@@ -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(),

View File

@@ -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<GeoJSONMapperConfig> = {
* Function that configures transformation and returns a transformer
* @param options
*/
create: (map: Map, options: MapLayerConfig<GeoJSONMapperConfig>, theme: GrafanaTheme2): MapLayerHandler => {
create: (map: Map, options: MapLayerOptions<GeoJSONMapperConfig>, theme: GrafanaTheme2): MapLayerHandler => {
const config = { ...defaultOptions, ...options.config };
const source = new VectorSource({

View File

@@ -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<LastPointConfig> = {
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<LastPointConfig>, theme: GrafanaTheme2): MapLayerHandler => {
create: (map: Map, options: MapLayerOptions<LastPointConfig>, theme: GrafanaTheme2): MapLayerHandler => {
const point = new Feature({});
const config = { ...defaultOptions, ...options.config };
@@ -45,28 +45,21 @@ export const lastPointTracker: MapLayerRegistryItem<LastPointConfig> = {
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);
}
}
},

View File

@@ -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<MarkersConfig> = {
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<MarkersConfig>, theme: GrafanaTheme2): MapLayerHandler => {
create: (map: Map, options: MapLayerOptions<MarkersConfig>, 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<MarkersConfig> = {
]
});
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<MarkersConfig> = {
// 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<MarkersConfig> = {
// 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 <div>Select a field that contains <a href="https://en.wikipedia.org/wiki/Geohash">geohash</a> values in each row.</div>
}
const first = reduceField({field, reducers:[ReducerID.firstNotNull]})[ReducerID.firstNotNull] as string;
if(!first) {
return <div>No values found</div>
}
// const coords = decodeGeohash(first);
// if(coords) {
// return <div>First value: {`${coords}`} // {new Date().toISOString()}</div>
// }
// return <div>Invalid first value: {`${first}`}</div>;
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,

View File

@@ -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;
}
}