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
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 534 additions and 296 deletions

View File

@ -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: ['<rootDir>/public/app', '<rootDir>/public/test', '<rootDir>/packages', '<rootDir>/scripts'],
testRegex: '(\\.|/)(test)\\.(jsx?|tsx?)$',

View File

@ -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<TCustom = any> {
export interface MapLayerOptions<TConfig = any> {
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<TConfig = MapLayerConfig> extends RegistryItemWithOptions {
export interface MapLayerRegistryItem<TConfig = MapLayerOptions> 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<TConfig>, theme: GrafanaTheme2) => MapLayerHandler;
create: (map: Map, options: MapLayerOptions<TConfig>, theme: GrafanaTheme2) => MapLayerHandler;
/**
* Show custom elements in the panel edit UI

View File

@ -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<GeomapPanelOptions>;
export class GeomapPanel extends Component<Props> {
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<Props> {
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<Props> {
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<Props> {
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<Props> {
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<Props> {
}
initControls(options: ControlsOptions) {
if (!this.map) {
return;
}
this.map.getControls().clear();
if (options.showZoom) {
@ -250,7 +255,7 @@ export class GeomapPanel extends Component<Props> {
);
}
this.mouseWheelZoom.setActive(Boolean(options.mouseWheelZoom));
this.mouseWheelZoom!.setActive(Boolean(options.mouseWheelZoom));
if (options.showAttribution) {
this.map.addControl(new Attribution({ collapsed: true, collapsible: true }));

View File

@ -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<StandardEditorProps<MapLayerConfig, any, GeomapPanelOptions>> = ({
export const BaseLayerEditor: FC<StandardEditorProps<MapLayerOptions, any, GeomapPanelOptions>> = ({
value,
onChange,
context,
}) => {
return <LayerEditor config={value} data={context.data} onChange={onChange} filter={(v) => Boolean(v.isBaseMap)} />;
return <LayerEditor options={value} data={context.data} onChange={onChange} filter={(v) => Boolean(v.isBaseMap)} />;
};

View File

@ -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<StandardEditorProps<MapLayerConfig[], any, GeomapPanelOptions>> = ({
export const DataLayersEditor: FC<StandardEditorProps<MapLayerOptions[], any, GeomapPanelOptions>> = ({
value,
onChange,
context,
}) => {
return (
<LayerEditor
config={value?.length ? value[0] : undefined}
options={value?.length ? value[0] : undefined}
data={context.data}
onChange={(cfg) => {
console.log('Change overlays:', cfg);

View File

@ -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<TConfig = any> {
config?: MapLayerConfig<TConfig>;
options?: MapLayerOptions<TConfig>;
data: DataFrame[]; // All results
onChange: (config: MapLayerConfig<TConfig>) => void;
onChange: (options: MapLayerOptions<TConfig>) => void;
filter: (item: MapLayerRegistryItem) => boolean;
}
export const LayerEditor: FC<LayerEditorProps> = ({ config, onChange, data, filter }) => {
export const LayerEditor: FC<LayerEditorProps> = ({ 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) => <div>HELLO</div>,
});
}
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<LayerEditorProps> = ({ config, onChange, data, filt
const context: StandardEditorContext<any> = {
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<LayerEditorProps> = ({ 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<LayerEditorProps> = ({ config, onChange, data, filt
{category.items.map((item) => item.render())}
</>
);
}, [optionsEditorBuilder, onChange, data, config]);
}, [optionsEditorBuilder, onChange, data, options]);
return (
<div>
@ -98,9 +148,11 @@ export const LayerEditor: FC<LayerEditorProps> = ({ 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?
});
}}
/>

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

View File

@ -55,12 +55,6 @@ describe('Worldmap Migrations', () => {
"showLegend": true,
"showZoom": true,
},
"fieldMapping": Object {
"geohashField": "",
"latitudeField": "",
"longitudeField": "",
"metricField": "",
},
"layers": Array [],
"view": Object {
"center": Object {

View File

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

View File

@ -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<GeomapPanelOptions>(GeomapPanel)
.setNoPadding()
@ -46,6 +48,10 @@ export const plugin = new PanelPlugin<GeomapPanelOptions>(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<GeomapPanelOptions>(GeomapPanel)
path: 'layers',
name: 'Data Layer',
editor: DataLayersEditor,
defaultValue: [
{
type: MARKERS_LAYER_ID,
config: {},
location: {
mode: FrameGeometrySourceMode.Auto,
},
},
],
});
// The controls section

View File

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

View File

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

View File

@ -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,
],
]
`);
});
});

View File

@ -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<string>): 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<number>, lat: Field<number>): Point[] {
const count = lat.values.length;
const points = new Array<Point>(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<string>): Point[] {
const count = field.values.length;
const points = new Array<Point>(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;
}