mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Gazetteer: reactor so the source is a DataFrame (#43783)
This commit is contained in:
@@ -1,98 +0,0 @@
|
||||
import React, { FC, useMemo, useState, useEffect } from 'react';
|
||||
import { StandardEditorProps, SelectableValue, GrafanaTheme2 } from '@grafana/data';
|
||||
import { Alert, Select, stylesFactory, useTheme2 } from '@grafana/ui';
|
||||
import { COUNTRIES_GAZETTEER_PATH, Gazetteer, getGazetteer } from '../gazetteer/gazetteer';
|
||||
import { css } from '@emotion/css';
|
||||
import { GazetteerPathEditorConfigSettings } from '../types';
|
||||
|
||||
const defaultPaths: Array<SelectableValue<string>> = [
|
||||
{
|
||||
label: 'Countries',
|
||||
description: 'Lookup countries by name, two letter code, or three letter code',
|
||||
value: COUNTRIES_GAZETTEER_PATH,
|
||||
},
|
||||
{
|
||||
label: 'USA States',
|
||||
description: 'Lookup states by name or 2 ',
|
||||
value: 'public/gazetteer/usa-states.json',
|
||||
},
|
||||
{
|
||||
label: 'Airports',
|
||||
description: 'Lookup airports by id or code',
|
||||
value: 'public/gazetteer/airports.geojson',
|
||||
},
|
||||
];
|
||||
|
||||
export const GazetteerPathEditor: FC<StandardEditorProps<string, any, any, GazetteerPathEditorConfigSettings>> = ({
|
||||
value,
|
||||
onChange,
|
||||
context,
|
||||
item,
|
||||
}) => {
|
||||
const styles = getStyles(useTheme2());
|
||||
const [gaz, setGaz] = useState<Gazetteer>();
|
||||
const settings = item.settings as any;
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
const p = await getGazetteer(value);
|
||||
setGaz(p);
|
||||
}
|
||||
fetchData();
|
||||
}, [value, setGaz]);
|
||||
|
||||
const { current, options } = useMemo(() => {
|
||||
let options = settings?.options ? [...settings.options] : [...defaultPaths];
|
||||
let current = options?.find((f) => f.value === gaz?.path);
|
||||
if (!current && gaz) {
|
||||
current = {
|
||||
label: gaz.path,
|
||||
value: gaz.path,
|
||||
};
|
||||
options.push(current);
|
||||
}
|
||||
return { options, current };
|
||||
}, [gaz, settings?.options]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Select
|
||||
menuShouldPortal
|
||||
value={current}
|
||||
options={options}
|
||||
onChange={(v) => onChange(v.value)}
|
||||
allowCustomValue={true}
|
||||
formatCreateLabel={(txt) => `Load from URL: ${txt}`}
|
||||
/>
|
||||
{gaz && (
|
||||
<>
|
||||
{gaz.error && <Alert title={gaz.error} severity={'warning'} />}
|
||||
{gaz.count && (
|
||||
<div className={styles.keys}>
|
||||
<b>({gaz.count})</b>
|
||||
{gaz.examples(10).map((k) => (
|
||||
<span key={k}>{k},</span>
|
||||
))}
|
||||
{gaz.count > 10 && ' ...'}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const getStyles = stylesFactory((theme: GrafanaTheme2) => {
|
||||
return {
|
||||
keys: css`
|
||||
margin-top: 4px;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
|
||||
> span {
|
||||
margin-left: 4px;
|
||||
}
|
||||
`,
|
||||
};
|
||||
});
|
||||
@@ -1,19 +1,12 @@
|
||||
import {
|
||||
MapLayerOptions,
|
||||
FrameGeometrySourceMode,
|
||||
FieldType,
|
||||
Field,
|
||||
MapLayerRegistryItem,
|
||||
PluginState,
|
||||
} from '@grafana/data';
|
||||
import { MapLayerOptions, MapLayerRegistryItem, PluginState } from '@grafana/data';
|
||||
import { DEFAULT_BASEMAP_CONFIG, geomapLayerRegistry } from '../layers/registry';
|
||||
import { GazetteerPathEditor } from './GazetteerPathEditor';
|
||||
import { NestedPanelOptions, NestedValueAccess } from '@grafana/data/src/utils/OptionsUIBuilders';
|
||||
import { defaultMarkersConfig } from '../layers/data/markersLayer';
|
||||
import { hasAlphaPanels } from 'app/core/config';
|
||||
import { MapLayerState } from '../types';
|
||||
import { get as lodashGet } from 'lodash';
|
||||
import { setOptionImmutably } from 'app/features/dashboard/components/PanelEditor/utils';
|
||||
import { addLocationFields } from 'app/features/geo/editor/locationEditor';
|
||||
|
||||
export interface LayerEditorOptions {
|
||||
state: MapLayerState;
|
||||
@@ -83,66 +76,7 @@ export function getLayerEditor(opts: LayerEditorOptions): NestedPanelOptions<Map
|
||||
}
|
||||
|
||||
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' },
|
||||
{ value: FrameGeometrySourceMode.Lookup, label: 'Lookup' },
|
||||
],
|
||||
},
|
||||
})
|
||||
.addFieldNamePicker({
|
||||
path: 'location.latitude',
|
||||
name: 'Latitude field',
|
||||
settings: {
|
||||
filter: (f: Field) => f.type === FieldType.number,
|
||||
noFieldsMessage: 'No numeric fields found',
|
||||
},
|
||||
showIf: (opts) => 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) => 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) => opts.location?.mode === FrameGeometrySourceMode.Geohash,
|
||||
// eslint-disable-next-line react/display-name
|
||||
// info: (props) => <div>HELLO</div>,
|
||||
})
|
||||
.addFieldNamePicker({
|
||||
path: 'location.lookup',
|
||||
name: 'Lookup field',
|
||||
settings: {
|
||||
filter: (f: Field) => f.type === FieldType.string,
|
||||
noFieldsMessage: 'No strings fields found',
|
||||
},
|
||||
showIf: (opts) => opts.location?.mode === FrameGeometrySourceMode.Lookup,
|
||||
})
|
||||
.addCustomEditor({
|
||||
id: 'gazetteer',
|
||||
path: 'location.gazetteer',
|
||||
name: 'Gazetteer',
|
||||
editor: GazetteerPathEditor,
|
||||
showIf: (opts) => opts.location?.mode === FrameGeometrySourceMode.Lookup,
|
||||
});
|
||||
addLocationFields('location', builder, options.location);
|
||||
}
|
||||
if (handler.registerOptionsUI) {
|
||||
handler.registerOptionsUI(builder);
|
||||
@@ -150,6 +84,7 @@ export function getLayerEditor(opts: LayerEditorOptions): NestedPanelOptions<Map
|
||||
if (layer.showOpacity) {
|
||||
// TODO -- add opacity check
|
||||
}
|
||||
|
||||
builder.addBooleanSwitch({
|
||||
path: 'tooltip',
|
||||
name: 'Display tooltip',
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
import { KeyValue } from '@grafana/data';
|
||||
import { getBackendSrv } from '@grafana/runtime';
|
||||
import { loadWorldmapPoints } from './worldmap';
|
||||
import { loadFromGeoJSON } from './geojson';
|
||||
|
||||
// http://geojson.xyz/
|
||||
|
||||
export interface PlacenameInfo {
|
||||
coords: [number, number]; // lon, lat (WGS84)
|
||||
props?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface Gazetteer {
|
||||
path: string;
|
||||
error?: string;
|
||||
find: (key: string) => PlacenameInfo | undefined;
|
||||
count?: number;
|
||||
examples: (count: number) => string[];
|
||||
}
|
||||
|
||||
// Without knowing the datatype pick a good lookup function
|
||||
export function loadGazetteer(path: string, data: any): Gazetteer {
|
||||
// Check for legacy worldmap syntax
|
||||
if (Array.isArray(data)) {
|
||||
const first = data[0] as any;
|
||||
if (first.latitude && first.longitude && (first.key || first.keys)) {
|
||||
return loadWorldmapPoints(path, data);
|
||||
}
|
||||
}
|
||||
|
||||
// try loading geojson
|
||||
const features = data?.features;
|
||||
if (Array.isArray(features) && data?.type === 'FeatureCollection') {
|
||||
return loadFromGeoJSON(path, data);
|
||||
}
|
||||
|
||||
return {
|
||||
path,
|
||||
error: 'Unable to parse locations',
|
||||
find: (k) => undefined,
|
||||
examples: (v) => [],
|
||||
};
|
||||
}
|
||||
|
||||
const registry: KeyValue<Gazetteer> = {};
|
||||
|
||||
export const COUNTRIES_GAZETTEER_PATH = 'public/gazetteer/countries.json';
|
||||
|
||||
/**
|
||||
* Given a path to a file return a cached lookup function
|
||||
*/
|
||||
export async function getGazetteer(path?: string): Promise<Gazetteer> {
|
||||
// When not specified, use the default path
|
||||
if (!path) {
|
||||
path = COUNTRIES_GAZETTEER_PATH;
|
||||
}
|
||||
|
||||
let lookup = registry[path];
|
||||
if (!lookup) {
|
||||
try {
|
||||
// block the async function
|
||||
const data = await getBackendSrv().get(path!);
|
||||
lookup = loadGazetteer(path, data);
|
||||
} catch (err) {
|
||||
console.warn('Error loading placename lookup', path, err);
|
||||
lookup = {
|
||||
path,
|
||||
error: 'Error loading URL',
|
||||
find: (k) => undefined,
|
||||
examples: (v) => [],
|
||||
};
|
||||
}
|
||||
registry[path] = lookup;
|
||||
}
|
||||
return lookup;
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
import { getGazetteer } from './gazetteer';
|
||||
|
||||
let backendResults: any = { hello: 'world' };
|
||||
|
||||
const geojsonObject = {
|
||||
type: 'FeatureCollection',
|
||||
features: [
|
||||
{
|
||||
id: 'A',
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'Point',
|
||||
coordinates: [0, 0],
|
||||
},
|
||||
properties: {
|
||||
hello: 'A',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'Point',
|
||||
coordinates: [1, 1],
|
||||
},
|
||||
properties: {
|
||||
some_code: 'B',
|
||||
hello: 'B',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'Point',
|
||||
coordinates: [2, 2],
|
||||
},
|
||||
properties: {
|
||||
an_id: 'C',
|
||||
hello: 'C',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
jest.mock('@grafana/runtime', () => ({
|
||||
...((jest.requireActual('@grafana/runtime') as unknown) as object),
|
||||
getBackendSrv: () => ({
|
||||
get: jest.fn().mockResolvedValue(backendResults),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('Placename lookup from geojson format', () => {
|
||||
beforeEach(() => {
|
||||
backendResults = { hello: 'world' };
|
||||
});
|
||||
|
||||
it('can lookup by id', async () => {
|
||||
backendResults = geojsonObject;
|
||||
const gaz = await getGazetteer('local');
|
||||
expect(gaz.error).toBeUndefined();
|
||||
expect(gaz.find('A')).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"coords": Array [
|
||||
0,
|
||||
0,
|
||||
],
|
||||
}
|
||||
`);
|
||||
});
|
||||
it('can look up by a code', async () => {
|
||||
backendResults = geojsonObject;
|
||||
const gaz = await getGazetteer('airports');
|
||||
expect(gaz.error).toBeUndefined();
|
||||
expect(gaz.find('B')).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"coords": Array [
|
||||
1,
|
||||
1,
|
||||
],
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('can look up by an id property', async () => {
|
||||
backendResults = geojsonObject;
|
||||
const gaz = await getGazetteer('airports');
|
||||
expect(gaz.error).toBeUndefined();
|
||||
expect(gaz.find('C')).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"coords": Array [
|
||||
2,
|
||||
2,
|
||||
],
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
@@ -1,75 +0,0 @@
|
||||
import GeoJSON from 'ol/format/GeoJSON';
|
||||
import { PlacenameInfo, Gazetteer } from './gazetteer';
|
||||
|
||||
export interface GeoJSONPoint {
|
||||
key?: string;
|
||||
keys?: string[]; // new in grafana 8.1+
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export function loadFromGeoJSON(path: string, body: any): Gazetteer {
|
||||
const data = new GeoJSON().readFeatures(body);
|
||||
let count = 0;
|
||||
const values = new Map<string, PlacenameInfo>();
|
||||
for (const f of data) {
|
||||
const coords = f.getGeometry().getFlatCoordinates(); //for now point, eventually geometry
|
||||
const info: PlacenameInfo = {
|
||||
coords: coords,
|
||||
};
|
||||
const id = f.getId();
|
||||
if (id) {
|
||||
if (typeof id === 'number') {
|
||||
values.set(id.toString(), info);
|
||||
} else {
|
||||
values.set(id, info);
|
||||
values.set(id.toUpperCase(), info);
|
||||
}
|
||||
}
|
||||
const properties = f.getProperties();
|
||||
if (properties) {
|
||||
for (const k of Object.keys(properties)) {
|
||||
if (k.includes('_code') || k.includes('_id')) {
|
||||
const value = properties[k];
|
||||
if (value) {
|
||||
if (typeof value === 'number') {
|
||||
values.set(value.toString(), info);
|
||||
} else {
|
||||
values.set(value, info);
|
||||
values.set(value.toUpperCase(), info);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
count++;
|
||||
}
|
||||
|
||||
return {
|
||||
path,
|
||||
find: (k) => {
|
||||
let v = values.get(k);
|
||||
if (!v && typeof k === 'string') {
|
||||
v = values.get(k.toUpperCase());
|
||||
}
|
||||
return v;
|
||||
},
|
||||
count,
|
||||
examples: (count) => {
|
||||
const first: string[] = [];
|
||||
if (values.size < 1) {
|
||||
first.push('no values found');
|
||||
} else {
|
||||
for (const key of values.keys()) {
|
||||
first.push(key);
|
||||
if (first.length >= count) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return first;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { getGazetteer } from './gazetteer';
|
||||
|
||||
let backendResults: any = { hello: 'world' };
|
||||
import countriesJSON from '../../../../../gazetteer/countries.json';
|
||||
|
||||
jest.mock('@grafana/runtime', () => ({
|
||||
...((jest.requireActual('@grafana/runtime') as unknown) as object),
|
||||
getBackendSrv: () => ({
|
||||
get: jest.fn().mockResolvedValue(backendResults),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('Placename lookup from worldmap format', () => {
|
||||
beforeEach(() => {
|
||||
backendResults = { hello: 'world' };
|
||||
});
|
||||
|
||||
it('unified worldmap config', async () => {
|
||||
backendResults = countriesJSON;
|
||||
const gaz = await getGazetteer('countries');
|
||||
expect(gaz.error).toBeUndefined();
|
||||
expect(gaz.find('US')).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"coords": Array [
|
||||
-95.712891,
|
||||
37.09024,
|
||||
],
|
||||
"props": Object {
|
||||
"name": "United States",
|
||||
},
|
||||
}
|
||||
`);
|
||||
// Items with 'keys' should get allow looking them up
|
||||
expect(gaz.find('US')).toEqual(gaz.find('USA'));
|
||||
});
|
||||
});
|
||||
@@ -1,61 +0,0 @@
|
||||
import { PlacenameInfo, Gazetteer } from './gazetteer';
|
||||
|
||||
// https://github.com/grafana/worldmap-panel/blob/master/src/data/countries.json
|
||||
export interface WorldmapPoint {
|
||||
key?: string;
|
||||
keys?: string[]; // new in grafana 8.1+
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export function loadWorldmapPoints(path: string, data: WorldmapPoint[]): Gazetteer {
|
||||
let count = 0;
|
||||
const values = new Map<string, PlacenameInfo>();
|
||||
for (const v of data) {
|
||||
const info: PlacenameInfo = {
|
||||
coords: [v.longitude, v.latitude],
|
||||
};
|
||||
if (v.name) {
|
||||
values.set(v.name, info);
|
||||
values.set(v.name.toUpperCase(), info);
|
||||
info.props = { name: v.name };
|
||||
}
|
||||
if (v.key) {
|
||||
values.set(v.key, info);
|
||||
values.set(v.key.toUpperCase(), info);
|
||||
}
|
||||
if (v.keys) {
|
||||
for (const key of v.keys) {
|
||||
values.set(key, info);
|
||||
values.set(key.toUpperCase(), info);
|
||||
}
|
||||
}
|
||||
count++;
|
||||
}
|
||||
return {
|
||||
path,
|
||||
find: (k) => {
|
||||
let v = values.get(k);
|
||||
if (!v && typeof k === 'string') {
|
||||
v = values.get(k.toUpperCase());
|
||||
}
|
||||
return v;
|
||||
},
|
||||
count,
|
||||
examples: (count) => {
|
||||
const first: string[] = [];
|
||||
if (values.size < 1) {
|
||||
first.push('no values found');
|
||||
} else {
|
||||
for (const key of values.keys()) {
|
||||
first.push(key);
|
||||
if (first.length >= count) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return first;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -7,12 +7,11 @@ import {
|
||||
PanelData,
|
||||
} 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 { dataFrameToPoints, getLocationMatchers } from '../../utils/location';
|
||||
import { getLocationMatchers } from 'app/features/geo/utils/location';
|
||||
import { ScaleDimensionConfig, getScaledDimension } from 'app/features/dimensions';
|
||||
import { ScaleDimensionEditor } from 'app/features/dimensions/editors';
|
||||
import { FrameVectorSource } from 'app/features/geo/utils/frameVectorSource';
|
||||
|
||||
// Configuration options for Heatmap overlays
|
||||
export interface HeatmapConfig {
|
||||
@@ -47,19 +46,19 @@ export const heatmapLayer: MapLayerRegistryItem<HeatmapConfig> = {
|
||||
*/
|
||||
create: async (map: Map, options: MapLayerOptions<HeatmapConfig>, theme: GrafanaTheme2) => {
|
||||
const config = { ...defaultOptions, ...options.config };
|
||||
const matchers = await getLocationMatchers(options.location);
|
||||
|
||||
const vectorSource = new source.Vector();
|
||||
|
||||
const location = await getLocationMatchers(options.location);
|
||||
const source = new FrameVectorSource(location);
|
||||
const WEIGHT_KEY = "_weight";
|
||||
|
||||
// Create a new Heatmap layer
|
||||
// Weight function takes a feature as attribute and returns a normalized weight value
|
||||
const vectorLayer = new layer.Heatmap({
|
||||
source: vectorSource,
|
||||
source,
|
||||
blur: config.blur,
|
||||
radius: config.radius,
|
||||
weight: function (feature) {
|
||||
var weight = feature.get('value');
|
||||
return weight;
|
||||
weight: (feature) => {
|
||||
return feature.get(WEIGHT_KEY);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -67,34 +66,18 @@ export const heatmapLayer: MapLayerRegistryItem<HeatmapConfig> = {
|
||||
init: () => vectorLayer,
|
||||
update: (data: PanelData) => {
|
||||
const frame = data.series[0];
|
||||
|
||||
// Remove previous data before updating
|
||||
const features = vectorLayer.getSource().getFeatures();
|
||||
features.forEach((feature) => {
|
||||
vectorLayer.getSource().removeFeature(feature);
|
||||
});
|
||||
|
||||
if (!frame) {
|
||||
return;
|
||||
}
|
||||
// Get data points (latitude and longitude coordinates)
|
||||
const info = dataFrameToPoints(frame, matchers);
|
||||
if (info.warning) {
|
||||
console.log('WARN', info.warning);
|
||||
return; // ???
|
||||
}
|
||||
source.update(frame);
|
||||
|
||||
const weightDim = getScaledDimension(frame, config.weight);
|
||||
|
||||
// Map each data value into new points
|
||||
for (let i = 0; i < frame.length; i++) {
|
||||
const cluster = new Feature({
|
||||
geometry: info.points[i],
|
||||
value: weightDim.get(i),
|
||||
});
|
||||
vectorSource.addFeature(cluster);
|
||||
}
|
||||
vectorLayer.setSource(vectorSource);
|
||||
source.forEachFeature( (f) => {
|
||||
const idx = f.get('rowIndex') as number;
|
||||
if(idx != null) {
|
||||
f.set(WEIGHT_KEY, weightDim.get(idx));
|
||||
}
|
||||
});
|
||||
|
||||
// Set heatmap gradient colors
|
||||
let colors = ['#00f', '#0ff', '#0f0', '#ff0', '#f00'];
|
||||
|
||||
@@ -4,7 +4,7 @@ import Feature from 'ol/Feature';
|
||||
import * as style from 'ol/style';
|
||||
import * as source from 'ol/source';
|
||||
import * as layer from 'ol/layer';
|
||||
import { dataFrameToPoints, getLocationMatchers } from '../../utils/location';
|
||||
import { getGeometryField, getLocationMatchers } from 'app/features/geo/utils/location';
|
||||
|
||||
export interface LastPointConfig {
|
||||
icon?: string;
|
||||
@@ -52,16 +52,11 @@ export const lastPointTracker: MapLayerRegistryItem<LastPointConfig> = {
|
||||
update: (data: PanelData) => {
|
||||
const frame = data.series[0];
|
||||
if (frame && frame.length) {
|
||||
const info = dataFrameToPoints(frame, matchers);
|
||||
if (info.warning) {
|
||||
console.log('WARN', info.warning);
|
||||
const out = getGeometryField(frame, matchers);
|
||||
if (!out.field) {
|
||||
return; // ???
|
||||
}
|
||||
|
||||
if (info.points?.length) {
|
||||
const last = info.points[info.points.length - 1];
|
||||
point.setGeometry(last);
|
||||
}
|
||||
point.setGeometry(out.field.values.get(frame.length - 1));
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -7,20 +7,18 @@ import {
|
||||
FrameGeometrySourceMode,
|
||||
} from '@grafana/data';
|
||||
import Map from 'ol/Map';
|
||||
import Feature, { FeatureLike } from 'ol/Feature';
|
||||
import { Point } from 'ol/geom';
|
||||
import * as source from 'ol/source';
|
||||
import { dataFrameToPoints, getLocationMatchers } from '../../utils/location';
|
||||
import { FeatureLike } from 'ol/Feature';
|
||||
import { getLocationMatchers } from 'app/features/geo/utils/location';
|
||||
import { getScaledDimension, getColorDimension, getTextDimension, getScalarDimension } from 'app/features/dimensions';
|
||||
import { ObservablePropsWrapper } from '../../components/ObservablePropsWrapper';
|
||||
import { MarkersLegend, MarkersLegendProps } from './MarkersLegend';
|
||||
import { ReplaySubject } from 'rxjs';
|
||||
import { getFeatures } from '../../utils/getFeatures';
|
||||
import { defaultStyleConfig, StyleConfig, StyleDimensions } from '../../style/types';
|
||||
import { StyleEditor } from './StyleEditor';
|
||||
import { getStyleConfigState } from '../../style/utils';
|
||||
import VectorLayer from 'ol/layer/Vector';
|
||||
import { isNumber } from 'lodash';
|
||||
import { FrameVectorSource } from 'app/features/geo/utils/frameVectorSource';
|
||||
|
||||
// Configuration options for Circle overlays
|
||||
export interface MarkersConfig {
|
||||
@@ -61,7 +59,6 @@ export const markersLayer: MapLayerRegistryItem<MarkersConfig> = {
|
||||
* @param options
|
||||
*/
|
||||
create: async (map: Map, options: MapLayerOptions<MarkersConfig>, theme: GrafanaTheme2) => {
|
||||
const matchers = await getLocationMatchers(options.location);
|
||||
// Assert default values
|
||||
const config = {
|
||||
...defaultOptions,
|
||||
@@ -75,9 +72,11 @@ export const markersLayer: MapLayerRegistryItem<MarkersConfig> = {
|
||||
}
|
||||
|
||||
const style = await getStyleConfigState(config.style);
|
||||
|
||||
// eventually can also use resolution for dynamic style
|
||||
const vectorLayer = new VectorLayer();
|
||||
const location = await getLocationMatchers(options.location);
|
||||
const source = new FrameVectorSource(location);
|
||||
const vectorLayer = new VectorLayer({
|
||||
source,
|
||||
});
|
||||
|
||||
if(!style.fields) {
|
||||
// Set a global style
|
||||
@@ -105,8 +104,7 @@ export const markersLayer: MapLayerRegistryItem<MarkersConfig> = {
|
||||
values.rotation = dims.rotation.get(idx);
|
||||
}
|
||||
return style.maker(values)
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -117,15 +115,7 @@ export const markersLayer: MapLayerRegistryItem<MarkersConfig> = {
|
||||
return; // ignore empty
|
||||
}
|
||||
|
||||
const features: Feature<Point>[] = [];
|
||||
|
||||
for (const frame of data.series) {
|
||||
const info = dataFrameToPoints(frame, matchers);
|
||||
if (info.warning) {
|
||||
console.log('Could not find locations', info.warning);
|
||||
continue; // ???
|
||||
}
|
||||
|
||||
if (style.fields) {
|
||||
const dims: StyleDimensions = {};
|
||||
if (style.fields.color) {
|
||||
@@ -143,12 +133,6 @@ export const markersLayer: MapLayerRegistryItem<MarkersConfig> = {
|
||||
style.dims = dims;
|
||||
}
|
||||
|
||||
const frameFeatures = getFeatures(frame, info);
|
||||
|
||||
if (frameFeatures) {
|
||||
features.push(...frameFeatures);
|
||||
}
|
||||
|
||||
// Post updates to the legend component
|
||||
if (legend) {
|
||||
legendProps.next({
|
||||
@@ -156,12 +140,10 @@ export const markersLayer: MapLayerRegistryItem<MarkersConfig> = {
|
||||
size: style.dims?.size,
|
||||
});
|
||||
}
|
||||
|
||||
source.update(frame);
|
||||
break; // Only the first frame for now!
|
||||
}
|
||||
|
||||
// Source reads the data and provides a set of features to visualize
|
||||
const vectorSource = new source.Vector({ features });
|
||||
vectorLayer.setSource(vectorSource);
|
||||
},
|
||||
|
||||
// Marker overlay options
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { MapLayerHandler, MapLayerOptions, SelectableValue } from '@grafana/data';
|
||||
import { MapLayerHandler, MapLayerOptions } from '@grafana/data';
|
||||
import { HideableFieldConfig } from '@grafana/schema';
|
||||
import { LayerElement } from 'app/core/components/Layers/types';
|
||||
import BaseLayer from 'ol/layer/Base';
|
||||
@@ -70,9 +70,6 @@ export enum ComparisonOperation {
|
||||
GT = 'gt',
|
||||
GTE = 'gte',
|
||||
}
|
||||
export interface GazetteerPathEditorConfigSettings {
|
||||
options?: Array<SelectableValue<string>>;
|
||||
}
|
||||
|
||||
//-------------------
|
||||
// Runtime model
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
@@ -1,26 +1,6 @@
|
||||
import { DataFrame, SelectableValue } from '@grafana/data';
|
||||
import { Feature } from 'ol';
|
||||
import { SelectableValue } from '@grafana/data';
|
||||
import { FeatureLike } from 'ol/Feature';
|
||||
import { Point } from 'ol/geom';
|
||||
import { GeometryTypeId } from '../style/types';
|
||||
import { LocationInfo } from './location';
|
||||
|
||||
export const getFeatures = (frame: DataFrame, info: LocationInfo): Array<Feature<Point>> | undefined => {
|
||||
const features: Array<Feature<Point>> = [];
|
||||
|
||||
// Map each data value into new points
|
||||
for (let i = 0; i < frame.length; i++) {
|
||||
features.push(
|
||||
new Feature({
|
||||
frame,
|
||||
rowIndex: i,
|
||||
geometry: info.points[i],
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return features;
|
||||
};
|
||||
|
||||
export interface LayerContentInfo {
|
||||
geometryType: GeometryTypeId;
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
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', async () => {
|
||||
const frame = toDataFrame({
|
||||
name: 'simple',
|
||||
fields: [
|
||||
{ name: 'name', type: FieldType.string, values: names },
|
||||
{ name: 'geohash', type: FieldType.number, values: geohash },
|
||||
],
|
||||
});
|
||||
|
||||
const matchers = await 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', async () => {
|
||||
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 = await getLocationMatchers();
|
||||
const info = dataFrameToPoints(frame, matchers);
|
||||
expect(info.points.map((p) => toLonLat(p.getCoordinates()))).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Array [
|
||||
0,
|
||||
0,
|
||||
],
|
||||
Array [
|
||||
-74.1,
|
||||
40.69999999999999,
|
||||
],
|
||||
]
|
||||
`);
|
||||
});
|
||||
});
|
||||
@@ -1,228 +0,0 @@
|
||||
import {
|
||||
FrameGeometrySource,
|
||||
FrameGeometrySourceMode,
|
||||
FieldMatcher,
|
||||
getFieldMatcher,
|
||||
FieldMatcherID,
|
||||
DataFrame,
|
||||
Field,
|
||||
getFieldDisplayName,
|
||||
} from '@grafana/data';
|
||||
import { Point } from 'ol/geom';
|
||||
import { fromLonLat } from 'ol/proj';
|
||||
import { getGazetteer, Gazetteer } from '../gazetteer/gazetteer';
|
||||
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;
|
||||
gazetteer?: Gazetteer;
|
||||
}
|
||||
|
||||
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 async function getLocationMatchers(src?: FrameGeometrySource): Promise<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 }));
|
||||
}
|
||||
info.gazetteer = await getGazetteer(src?.gazetteer);
|
||||
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.Lookup:
|
||||
if (fields.lookup) {
|
||||
if (location.gazetteer) {
|
||||
info.points = getPointsFromGazetteer(location.gazetteer, fields.lookup);
|
||||
} else {
|
||||
info.warning = 'Gazetteer not found';
|
||||
}
|
||||
} else {
|
||||
info.warning = 'Missing lookup 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;
|
||||
}
|
||||
|
||||
function getPointsFromGazetteer(gaz: Gazetteer, field: Field<string>): Point[] {
|
||||
const count = field.values.length;
|
||||
const points = new Array<Point>(count);
|
||||
for (let i = 0; i < count; i++) {
|
||||
const info = gaz.find(field.values.get(i));
|
||||
if (info?.coords) {
|
||||
points[i] = new Point(fromLonLat(info.coords));
|
||||
}
|
||||
}
|
||||
return points;
|
||||
}
|
||||
Reference in New Issue
Block a user