mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Geomap: use a common configuration builder to find location fields (#36768)
This commit is contained in:
parent
dc5778c303
commit
8de218d5f1
@ -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?)$',
|
||||
|
@ -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
|
||||
|
@ -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 }));
|
||||
|
@ -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)} />;
|
||||
};
|
||||
|
@ -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);
|
||||
|
@ -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?
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -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(),
|
||||
|
@ -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({
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@ -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,
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
@ -55,12 +55,6 @@ describe('Worldmap Migrations', () => {
|
||||
"showLegend": true,
|
||||
"showZoom": true,
|
||||
},
|
||||
"fieldMapping": Object {
|
||||
"geohashField": "",
|
||||
"latitudeField": "",
|
||||
"longitudeField": "",
|
||||
"metricField": "",
|
||||
},
|
||||
"layers": Array [],
|
||||
"view": Object {
|
||||
"center": Object {
|
||||
|
@ -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);
|
||||
|
@ -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
|
||||
|
@ -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[];
|
||||
}
|
||||
|
43
public/app/plugins/panel/geomap/utils/geohash.ts
Normal file
43
public/app/plugins/panel/geomap/utils/geohash.ts
Normal 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;
|
||||
}
|
||||
}
|
66
public/app/plugins/panel/geomap/utils/location.test.ts
Normal file
66
public/app/plugins/panel/geomap/utils/location.test.ts
Normal 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,
|
||||
],
|
||||
]
|
||||
`);
|
||||
});
|
||||
});
|
201
public/app/plugins/panel/geomap/utils/location.ts
Normal file
201
public/app/plugins/panel/geomap/utils/location.ts
Normal 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;
|
||||
}
|
Loading…
Reference in New Issue
Block a user