mirror of
https://github.com/grafana/grafana.git
synced 2024-11-22 00:47:38 -06:00
Geomap: add basic gazetteer support (#37082)
This commit is contained in:
parent
1881de8236
commit
9cd8e11c30
@ -33,8 +33,8 @@ export interface FrameGeometrySource {
|
||||
wkt?: string;
|
||||
lookup?: string;
|
||||
|
||||
// Path to a mappings file
|
||||
lookupSrc?: string;
|
||||
// Path to Gazetteer
|
||||
gazetteer?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -97,7 +97,7 @@ export interface MapLayerRegistryItem<TConfig = MapLayerOptions> extends Registr
|
||||
* Function that configures transformation and returns a transformer
|
||||
* @param options
|
||||
*/
|
||||
create: (map: Map, options: MapLayerOptions<TConfig>, theme: GrafanaTheme2) => MapLayerHandler;
|
||||
create: (map: Map, options: MapLayerOptions<TConfig>, theme: GrafanaTheme2) => Promise<MapLayerHandler>;
|
||||
|
||||
/**
|
||||
* Show custom elements in the panel edit UI
|
||||
|
@ -22,19 +22,6 @@ export function USAQueryEditor({ query, onChange }: Props) {
|
||||
value={usaQueryModes.find((ep) => ep.value === query.mode)}
|
||||
/>
|
||||
</InlineField>
|
||||
|
||||
<InlineField label="Fields">
|
||||
<Select
|
||||
options={fieldNames}
|
||||
onChange={(vals: SelectableValue[]) => {
|
||||
onChange({ ...query, fields: vals.map((v) => v.value) });
|
||||
}}
|
||||
width={28}
|
||||
isMulti={true}
|
||||
placeholder="all"
|
||||
value={query.fields}
|
||||
/>
|
||||
</InlineField>
|
||||
<InlineField label="Period">
|
||||
<Input
|
||||
value={query.period}
|
||||
|
@ -97,7 +97,7 @@ export class GeomapPanel extends Component<Props> {
|
||||
|
||||
if (options.layers !== oldOptions.layers) {
|
||||
console.log('layers changed');
|
||||
this.initLayers(options.layers ?? []);
|
||||
this.initLayers(options.layers ?? []); // async
|
||||
layersChanged = true;
|
||||
}
|
||||
return layersChanged;
|
||||
@ -119,7 +119,7 @@ export class GeomapPanel extends Component<Props> {
|
||||
this.overlayProps.bottomLeft = legends;
|
||||
}
|
||||
|
||||
initMapRef = (div: HTMLDivElement) => {
|
||||
initMapRef = async (div: HTMLDivElement) => {
|
||||
if (this.map) {
|
||||
this.map.dispose();
|
||||
}
|
||||
@ -143,12 +143,11 @@ export class GeomapPanel extends Component<Props> {
|
||||
this.map.addInteraction(this.mouseWheelZoom);
|
||||
this.initControls(options.controls);
|
||||
this.initBasemap(options.basemap);
|
||||
this.initLayers(options.layers);
|
||||
this.dataChanged(this.props.data, options.controls.showLegend);
|
||||
await this.initLayers(options.layers, options.controls?.showLegend);
|
||||
this.forceUpdate(); // first render
|
||||
};
|
||||
|
||||
initBasemap(cfg: MapLayerOptions) {
|
||||
async initBasemap(cfg: MapLayerOptions) {
|
||||
if (!this.map) {
|
||||
return;
|
||||
}
|
||||
@ -157,7 +156,8 @@ export class GeomapPanel extends Component<Props> {
|
||||
cfg = DEFAULT_BASEMAP_CONFIG;
|
||||
}
|
||||
const item = geomapLayerRegistry.getIfExists(cfg.type) ?? defaultBaseLayer;
|
||||
const layer = item.create(this.map, cfg, config.theme2).init();
|
||||
const handler = await item.create(this.map, cfg, config.theme2);
|
||||
const layer = handler.init();
|
||||
if (this.basemap) {
|
||||
this.map.removeLayer(this.basemap);
|
||||
this.basemap.dispose();
|
||||
@ -166,7 +166,7 @@ export class GeomapPanel extends Component<Props> {
|
||||
this.map.getLayers().insertAt(0, this.basemap);
|
||||
}
|
||||
|
||||
initLayers(layers: MapLayerOptions[]) {
|
||||
async initLayers(layers: MapLayerOptions[], showLegend?: boolean) {
|
||||
// 1st remove existing layers
|
||||
for (const state of this.layers) {
|
||||
this.map!.removeLayer(state.layer);
|
||||
@ -185,7 +185,7 @@ export class GeomapPanel extends Component<Props> {
|
||||
continue; // TODO -- panel warning?
|
||||
}
|
||||
|
||||
const handler = item.create(this.map!, overlay, config.theme2);
|
||||
const handler = await item.create(this.map!, overlay, config.theme2);
|
||||
const layer = handler.init();
|
||||
this.map!.addLayer(layer);
|
||||
this.layers.push({
|
||||
@ -194,6 +194,9 @@ export class GeomapPanel extends Component<Props> {
|
||||
handler,
|
||||
});
|
||||
}
|
||||
|
||||
// Update data after init layers
|
||||
this.dataChanged(this.props.data);
|
||||
}
|
||||
|
||||
initMapView(config: MapViewConfig): View {
|
||||
|
@ -0,0 +1,85 @@
|
||||
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';
|
||||
|
||||
const paths: Array<SelectableValue<string>> = [
|
||||
{
|
||||
label: 'Countries',
|
||||
description: 'Lookup countries by name, two letter code, or three leter code',
|
||||
value: COUNTRIES_GAZETTEER_PATH,
|
||||
},
|
||||
{
|
||||
label: 'USA States',
|
||||
description: 'Lookup states by name or 2 ',
|
||||
value: 'public/gazetteer/usa-states.json',
|
||||
},
|
||||
];
|
||||
|
||||
export const GazetteerPathEditor: FC<StandardEditorProps<string, any, any>> = ({ value, onChange, context }) => {
|
||||
const styles = getStyles(useTheme2());
|
||||
const [gaz, setGaz] = useState<Gazetteer>();
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
const p = await getGazetteer(value);
|
||||
setGaz(p);
|
||||
}
|
||||
fetchData();
|
||||
}, [value, setGaz]);
|
||||
|
||||
const { current, options } = useMemo(() => {
|
||||
let options = [...paths];
|
||||
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]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Select
|
||||
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>
|
||||
))}{' '}
|
||||
&ellipsis;
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const getStyles = stylesFactory((theme: GrafanaTheme2) => {
|
||||
return {
|
||||
keys: css`
|
||||
margin-top: 4px;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
|
||||
> span {
|
||||
margin-left: 4px;
|
||||
}
|
||||
`,
|
||||
};
|
||||
});
|
@ -14,6 +14,7 @@ import { DEFAULT_BASEMAP_CONFIG, geomapLayerRegistry } from '../layers/registry'
|
||||
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
|
||||
import { setOptionImmutably } from 'app/features/dashboard/components/PanelEditor/utils';
|
||||
import { fillOptionsPaneItems } from 'app/features/dashboard/components/PanelEditor/getVizualizationOptions';
|
||||
import { GazetteerPathEditor } from './GazetteerPathEditor';
|
||||
|
||||
export interface LayerEditorProps<TConfig = any> {
|
||||
options?: MapLayerOptions<TConfig>;
|
||||
@ -53,6 +54,7 @@ export const LayerEditor: FC<LayerEditorProps> = ({ options, onChange, data, fil
|
||||
{ value: FrameGeometrySourceMode.Auto, label: 'Auto' },
|
||||
{ value: FrameGeometrySourceMode.Coords, label: 'Coords' },
|
||||
{ value: FrameGeometrySourceMode.Geohash, label: 'Geohash' },
|
||||
{ value: FrameGeometrySourceMode.Lookup, label: 'Lookup' },
|
||||
],
|
||||
},
|
||||
})
|
||||
@ -84,6 +86,22 @@ export const LayerEditor: FC<LayerEditorProps> = ({ options, onChange, data, fil
|
||||
showIf: (opts: MapLayerOptions) => 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: MapLayerOptions) => opts.location?.mode === FrameGeometrySourceMode.Lookup,
|
||||
})
|
||||
.addCustomEditor({
|
||||
id: 'gazetteer',
|
||||
path: 'location.gazetteer',
|
||||
name: 'Gazetteer',
|
||||
editor: GazetteerPathEditor,
|
||||
showIf: (opts: MapLayerOptions) => opts.location?.mode === FrameGeometrySourceMode.Lookup,
|
||||
});
|
||||
}
|
||||
if (layer.registerOptionsUI) {
|
||||
|
36
public/app/plugins/panel/geomap/gazetteer/gazetteer.test.ts
Normal file
36
public/app/plugins/panel/geomap/gazetteer/gazetteer.test.ts
Normal file
@ -0,0 +1,36 @@
|
||||
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 lookups', () => {
|
||||
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'));
|
||||
});
|
||||
});
|
69
public/app/plugins/panel/geomap/gazetteer/gazetteer.ts
Normal file
69
public/app/plugins/panel/geomap/gazetteer/gazetteer.ts
Normal file
@ -0,0 +1,69 @@
|
||||
import { KeyValue } from '@grafana/data';
|
||||
import { getBackendSrv } from '@grafana/runtime';
|
||||
import { loadWorldmapPoints } from './worldmap';
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
61
public/app/plugins/panel/geomap/gazetteer/worldmap.ts
Normal file
61
public/app/plugins/panel/geomap/gazetteer/worldmap.ts
Normal file
@ -0,0 +1,61 @@
|
||||
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) {
|
||||
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;
|
||||
},
|
||||
};
|
||||
}
|
@ -31,7 +31,7 @@ export const carto: MapLayerRegistryItem<CartoConfig> = {
|
||||
* Function that configures transformation and returns a transformer
|
||||
* @param options
|
||||
*/
|
||||
create: (map: Map, options: MapLayerOptions<CartoConfig>, theme: GrafanaTheme2) => ({
|
||||
create: async (map: Map, options: MapLayerOptions<CartoConfig>, theme: GrafanaTheme2) => ({
|
||||
init: () => {
|
||||
const cfg = { ...defaultCartoConfig, ...options.config };
|
||||
let style = cfg.theme as string;
|
||||
|
@ -59,19 +59,17 @@ export const esriXYZTiles: MapLayerRegistryItem<ESRIXYZConfig> = {
|
||||
name: 'ArcGIS MapServer',
|
||||
isBaseMap: true,
|
||||
|
||||
create: (map: Map, options: MapLayerOptions<ESRIXYZConfig>, theme: GrafanaTheme2) => ({
|
||||
init: () => {
|
||||
const cfg = { ...options.config };
|
||||
const svc = publicServiceRegistry.getIfExists(cfg.config?.server ?? DEFAULT_SERVICE)!;
|
||||
if (svc.id !== CUSTOM_SERVICE) {
|
||||
const base = 'https://services.arcgisonline.com/ArcGIS/rest/services/';
|
||||
cfg.url = `${base}${svc.slug}/MapServer/tile/{z}/{y}/{x}`;
|
||||
cfg.attribution = `Tiles © <a href="${base}${svc.slug}/MapServer">ArcGIS</a>`;
|
||||
}
|
||||
// reuse the standard XYZ tile logic
|
||||
return xyzTiles.create(map, { ...options, config: cfg as XYZConfig }, theme).init();
|
||||
},
|
||||
}),
|
||||
create: async (map: Map, options: MapLayerOptions<ESRIXYZConfig>, theme: GrafanaTheme2) => {
|
||||
const cfg = { ...options.config };
|
||||
const svc = publicServiceRegistry.getIfExists(cfg.config?.server ?? DEFAULT_SERVICE)!;
|
||||
if (svc.id !== CUSTOM_SERVICE) {
|
||||
const base = 'https://services.arcgisonline.com/ArcGIS/rest/services/';
|
||||
cfg.url = `${base}${svc.slug}/MapServer/tile/{z}/{y}/{x}`;
|
||||
cfg.attribution = `Tiles © <a href="${base}${svc.slug}/MapServer">ArcGIS</a>`;
|
||||
}
|
||||
const opts = { ...options, config: cfg as XYZConfig };
|
||||
return xyzTiles.create(map, opts, theme);
|
||||
},
|
||||
|
||||
registerOptionsUI: (builder) => {
|
||||
builder
|
||||
|
@ -21,7 +21,7 @@ export const xyzTiles: MapLayerRegistryItem<XYZConfig> = {
|
||||
name: 'XYZ Tile layer',
|
||||
isBaseMap: true,
|
||||
|
||||
create: (map: Map, options: MapLayerOptions<XYZConfig>, theme: GrafanaTheme2) => ({
|
||||
create: async (map: Map, options: MapLayerOptions<XYZConfig>, theme: GrafanaTheme2) => ({
|
||||
init: () => {
|
||||
const cfg = { ...options.config };
|
||||
if (!cfg.url) {
|
||||
|
@ -12,7 +12,7 @@ export const standard: MapLayerRegistryItem = {
|
||||
* Function that configures transformation and returns a transformer
|
||||
* @param options
|
||||
*/
|
||||
create: (map: Map, options: MapLayerOptions) => ({
|
||||
create: async (map: Map, options: MapLayerOptions) => ({
|
||||
init: () => {
|
||||
return new TileLayer({
|
||||
source: new OSM(),
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { MapLayerRegistryItem, MapLayerOptions, MapLayerHandler, PanelData, GrafanaTheme2, PluginState } from '@grafana/data';
|
||||
import { MapLayerRegistryItem, MapLayerOptions, PanelData, GrafanaTheme2, PluginState } from '@grafana/data';
|
||||
import Map from 'ol/Map';
|
||||
import VectorLayer from 'ol/layer/Vector';
|
||||
import VectorSource from 'ol/source/Vector';
|
||||
@ -30,7 +30,7 @@ export const geojsonMapper: MapLayerRegistryItem<GeoJSONMapperConfig> = {
|
||||
* Function that configures transformation and returns a transformer
|
||||
* @param options
|
||||
*/
|
||||
create: (map: Map, options: MapLayerOptions<GeoJSONMapperConfig>, theme: GrafanaTheme2): MapLayerHandler => {
|
||||
create: async (map: Map, options: MapLayerOptions<GeoJSONMapperConfig>, theme: GrafanaTheme2) => {
|
||||
const config = { ...defaultOptions, ...options.config };
|
||||
|
||||
const source = new VectorSource({
|
||||
|
@ -2,7 +2,6 @@ import {
|
||||
FieldType,
|
||||
getFieldColorModeForField,
|
||||
GrafanaTheme2,
|
||||
MapLayerHandler,
|
||||
MapLayerOptions,
|
||||
MapLayerRegistryItem,
|
||||
PanelData,
|
||||
@ -47,9 +46,9 @@ export const heatmapLayer: MapLayerRegistryItem<HeatmapConfig> = {
|
||||
* Function that configures transformation and returns a transformer
|
||||
* @param options
|
||||
*/
|
||||
create: (map: Map, options: MapLayerOptions<HeatmapConfig>, theme: GrafanaTheme2): MapLayerHandler => {
|
||||
create: async (map: Map, options: MapLayerOptions<HeatmapConfig>, theme: GrafanaTheme2) => {
|
||||
const config = { ...defaultOptions, ...options.config };
|
||||
const matchers = getLocationMatchers(options.location);
|
||||
const matchers = await getLocationMatchers(options.location);
|
||||
|
||||
const vectorSource = new source.Vector();
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { MapLayerRegistryItem, MapLayerOptions, MapLayerHandler, PanelData, GrafanaTheme2, PluginState } from '@grafana/data';
|
||||
import { MapLayerRegistryItem, MapLayerOptions, PanelData, GrafanaTheme2, PluginState } from '@grafana/data';
|
||||
import Map from 'ol/Map';
|
||||
import Feature from 'ol/Feature';
|
||||
import * as style from 'ol/style';
|
||||
@ -26,7 +26,7 @@ export const lastPointTracker: MapLayerRegistryItem<LastPointConfig> = {
|
||||
* Function that configures transformation and returns a transformer
|
||||
* @param options
|
||||
*/
|
||||
create: (map: Map, options: MapLayerOptions<LastPointConfig>, theme: GrafanaTheme2): MapLayerHandler => {
|
||||
create: async (map: Map, options: MapLayerOptions<LastPointConfig>, theme: GrafanaTheme2) => {
|
||||
const point = new Feature({});
|
||||
const config = { ...defaultOptions, ...options.config };
|
||||
|
||||
@ -46,7 +46,7 @@ export const lastPointTracker: MapLayerRegistryItem<LastPointConfig> = {
|
||||
source: vectorSource,
|
||||
});
|
||||
|
||||
const matchers = getLocationMatchers(options.location);
|
||||
const matchers = await getLocationMatchers(options.location);
|
||||
return {
|
||||
init: () => vectorLayer,
|
||||
update: (data: PanelData) => {
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { MapLayerRegistryItem, MapLayerOptions, MapLayerHandler, PanelData, GrafanaTheme2, FrameGeometrySourceMode } from '@grafana/data';
|
||||
import { MapLayerRegistryItem, MapLayerOptions, PanelData, GrafanaTheme2, FrameGeometrySourceMode } from '@grafana/data';
|
||||
import Map from 'ol/Map';
|
||||
import Feature from 'ol/Feature';
|
||||
import * as layer from 'ol/layer';
|
||||
@ -11,7 +11,7 @@ import { getScaledDimension, } from '../../dims/scale';
|
||||
import { getColorDimension, } from '../../dims/color';
|
||||
import { ScaleDimensionEditor } from '../../dims/editors/ScaleDimensionEditor';
|
||||
import { ColorDimensionEditor } from '../../dims/editors/ColorDimensionEditor';
|
||||
import { markerMakers } from '../../utils/regularShapes';
|
||||
import { circleMarker, markerMakers } from '../../utils/regularShapes';
|
||||
|
||||
// Configuration options for Circle overlays
|
||||
export interface MarkersConfig {
|
||||
@ -59,8 +59,8 @@ export const markersLayer: MapLayerRegistryItem<MarkersConfig> = {
|
||||
* Function that configures transformation and returns a transformer
|
||||
* @param options
|
||||
*/
|
||||
create: (map: Map, options: MapLayerOptions<MarkersConfig>, theme: GrafanaTheme2): MapLayerHandler => {
|
||||
const matchers = getLocationMatchers(options.location);
|
||||
create: async (map: Map, options: MapLayerOptions<MarkersConfig>, theme: GrafanaTheme2) => {
|
||||
const matchers = await getLocationMatchers(options.location);
|
||||
const vectorLayer = new layer.Vector({});
|
||||
// Assert default values
|
||||
const config = {
|
||||
@ -68,47 +68,47 @@ export const markersLayer: MapLayerRegistryItem<MarkersConfig> = {
|
||||
...options?.config,
|
||||
};
|
||||
|
||||
let shape = markerMakers.getIfExists(config.shape);
|
||||
if (!shape) {
|
||||
shape = markerMakers.get('circle');
|
||||
}
|
||||
|
||||
const shape = markerMakers.getIfExists(config.shape) ?? circleMarker;
|
||||
|
||||
return {
|
||||
init: () => vectorLayer,
|
||||
update: (data: PanelData) => {
|
||||
if(!data.series?.length) {
|
||||
return; // ignore empty
|
||||
}
|
||||
const frame = data.series[0];
|
||||
const info = dataFrameToPoints(frame, matchers);
|
||||
if(info.warning) {
|
||||
console.log( 'WARN', info.warning);
|
||||
return; // ???
|
||||
}
|
||||
|
||||
const colorDim = getColorDimension(frame, config.color, theme);
|
||||
const sizeDim = getScaledDimension(frame, config.size);
|
||||
const opacity = options.config?.fillOpacity ?? defaultOptions.fillOpacity;
|
||||
|
||||
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
|
||||
const color = colorDim.get(i);
|
||||
// Set the opacity determined from user configuration
|
||||
const fillColor = tinycolor(color).setAlpha(opacity).toRgbString();
|
||||
// Get circle size from user configuration
|
||||
const radius = sizeDim.get(i);
|
||||
for(const frame of data.series) {
|
||||
const info = dataFrameToPoints(frame, matchers);
|
||||
if(info.warning) {
|
||||
console.log( 'Could not find locations', info.warning);
|
||||
continue; // ???
|
||||
}
|
||||
|
||||
// Create a new Feature for each point returned from dataFrameToPoints
|
||||
const dot = new Feature({
|
||||
geometry: info.points[i],
|
||||
});
|
||||
const colorDim = getColorDimension(frame, config.color, theme);
|
||||
const sizeDim = getScaledDimension(frame, config.size);
|
||||
const opacity = options.config?.fillOpacity ?? defaultOptions.fillOpacity;
|
||||
|
||||
// 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
|
||||
const color = colorDim.get(i);
|
||||
// Set the opacity determined from user configuration
|
||||
const fillColor = tinycolor(color).setAlpha(opacity).toRgbString();
|
||||
// Get circle size from user configuration
|
||||
const radius = sizeDim.get(i);
|
||||
|
||||
// Create a new Feature for each point returned from dataFrameToPoints
|
||||
const dot = new Feature({
|
||||
geometry: info.points[i],
|
||||
});
|
||||
|
||||
dot.setStyle(shape!.make(color, fillColor, radius));
|
||||
features.push(dot);
|
||||
};
|
||||
}
|
||||
|
||||
dot.setStyle(shape!.make(color, fillColor, radius));
|
||||
features.push(dot);
|
||||
};
|
||||
|
||||
// Source reads the data and provides a set of features to visualize
|
||||
const vectorSource = new source.Vector({ features });
|
||||
|
@ -8,7 +8,7 @@ const geohash = ['9q94r', 'dr5rs'];
|
||||
const names = ['A', 'B'];
|
||||
|
||||
describe('handle location parsing', () => {
|
||||
it('auto should find geohash field', () => {
|
||||
it('auto should find geohash field', async () => {
|
||||
const frame = toDataFrame({
|
||||
name: 'simple',
|
||||
fields: [
|
||||
@ -17,7 +17,7 @@ describe('handle location parsing', () => {
|
||||
],
|
||||
});
|
||||
|
||||
const matchers = getLocationMatchers();
|
||||
const matchers = await getLocationMatchers();
|
||||
const fields = getLocationFields(frame, matchers);
|
||||
expect(fields.mode).toEqual(FrameGeometrySourceMode.Geohash);
|
||||
expect(fields.geohash).toBeDefined();
|
||||
@ -38,7 +38,7 @@ describe('handle location parsing', () => {
|
||||
`);
|
||||
});
|
||||
|
||||
it('auto should find coordinate fields', () => {
|
||||
it('auto should find coordinate fields', async () => {
|
||||
const frame = toDataFrame({
|
||||
name: 'simple',
|
||||
fields: [
|
||||
@ -48,7 +48,7 @@ describe('handle location parsing', () => {
|
||||
],
|
||||
});
|
||||
|
||||
const matchers = getLocationMatchers();
|
||||
const matchers = await getLocationMatchers();
|
||||
const info = dataFrameToPoints(frame, matchers);
|
||||
expect(info.points.map((p) => toLonLat(p.getCoordinates()))).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
|
@ -10,6 +10,7 @@ import {
|
||||
} 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;
|
||||
@ -50,6 +51,7 @@ export interface LocationFieldMatchers {
|
||||
h3: FieldFinder;
|
||||
wkt: FieldFinder;
|
||||
lookup: FieldFinder;
|
||||
gazetteer?: Gazetteer;
|
||||
}
|
||||
|
||||
const defaultMatchers: LocationFieldMatchers = {
|
||||
@ -62,7 +64,7 @@ const defaultMatchers: LocationFieldMatchers = {
|
||||
lookup: matchLowerNames(new Set(['lookup'])),
|
||||
};
|
||||
|
||||
export function getLocationMatchers(src?: FrameGeometrySource): LocationFieldMatchers {
|
||||
export async function getLocationMatchers(src?: FrameGeometrySource): Promise<LocationFieldMatchers> {
|
||||
const info: LocationFieldMatchers = {
|
||||
...defaultMatchers,
|
||||
mode: src?.mode ?? FrameGeometrySourceMode.Auto,
|
||||
@ -77,6 +79,7 @@ export function getLocationMatchers(src?: FrameGeometrySource): LocationFieldMat
|
||||
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) {
|
||||
@ -172,6 +175,18 @@ export function dataFrameToPoints(frame: DataFrame, location: LocationFieldMatch
|
||||
}
|
||||
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';
|
||||
}
|
||||
@ -199,3 +214,15 @@ function getPointsFromGeohash(field: Field<string>): Point[] {
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
@ -6,21 +6,23 @@ interface MarkerMaker extends RegistryItem {
|
||||
hasFill: boolean;
|
||||
}
|
||||
|
||||
export const markerMakers = new Registry<MarkerMaker>(() => [
|
||||
{
|
||||
id: 'circle',
|
||||
name: 'Circle',
|
||||
hasFill: true,
|
||||
make: (color: string, fillColor: string, radius: number) => {
|
||||
return new Style({
|
||||
image: new Circle({
|
||||
stroke: new Stroke({ color: color }),
|
||||
fill: new Fill({ color: fillColor }),
|
||||
radius: radius,
|
||||
}),
|
||||
});
|
||||
},
|
||||
export const circleMarker: MarkerMaker = {
|
||||
id: 'circle',
|
||||
name: 'Circle',
|
||||
hasFill: true,
|
||||
make: (color: string, fillColor: string, radius: number) => {
|
||||
return new Style({
|
||||
image: new Circle({
|
||||
stroke: new Stroke({ color: color }),
|
||||
fill: new Fill({ color: fillColor }),
|
||||
radius: radius,
|
||||
}),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const markerMakers = new Registry<MarkerMaker>(() => [
|
||||
circleMarker,
|
||||
{
|
||||
id: 'square',
|
||||
name: 'Square',
|
||||
|
1508
public/gazetteer/countries.json
Normal file
1508
public/gazetteer/countries.json
Normal file
File diff suppressed because it is too large
Load Diff
332
public/gazetteer/usa-states.json
Normal file
332
public/gazetteer/usa-states.json
Normal file
@ -0,0 +1,332 @@
|
||||
[
|
||||
{
|
||||
"key": "AK",
|
||||
"latitude": 61.385,
|
||||
"longitude": -152.2683,
|
||||
"name": "Alaska"
|
||||
},
|
||||
{
|
||||
"key": "AL",
|
||||
"latitude": 32.799,
|
||||
"longitude": -86.8073,
|
||||
"name": "Alabama"
|
||||
},
|
||||
{
|
||||
"key": "AR",
|
||||
"latitude": 34.9513,
|
||||
"longitude": -92.3809,
|
||||
"name": "Arkansas"
|
||||
},
|
||||
{
|
||||
"key": "AS",
|
||||
"latitude": 14.2417,
|
||||
"longitude": -170.7197,
|
||||
"name": "American Samoa"
|
||||
},
|
||||
{
|
||||
"key": "AZ",
|
||||
"latitude": 33.7712,
|
||||
"longitude": -111.3877,
|
||||
"name": "Arizona"
|
||||
},
|
||||
{
|
||||
"key": "CA",
|
||||
"latitude": 36.17,
|
||||
"longitude": -119.7462,
|
||||
"name": "California"
|
||||
},
|
||||
{
|
||||
"key": "CO",
|
||||
"latitude": 39.0646,
|
||||
"longitude": -105.3272,
|
||||
"name": "Colorado"
|
||||
},
|
||||
{
|
||||
"key": "CT",
|
||||
"latitude": 41.5834,
|
||||
"longitude": -72.7622,
|
||||
"name": "Connecticut"
|
||||
},
|
||||
{
|
||||
"key": "DC",
|
||||
"latitude": 38.8964,
|
||||
"longitude": -77.0262,
|
||||
"name": "Washington DC"
|
||||
},
|
||||
{
|
||||
"key": "DE",
|
||||
"latitude": 39.3498,
|
||||
"longitude": -75.5148,
|
||||
"name": "Delaware"
|
||||
},
|
||||
{
|
||||
"key": "FL",
|
||||
"latitude": 27.8333,
|
||||
"longitude": -81.717,
|
||||
"name": "Florida"
|
||||
},
|
||||
{
|
||||
"key": "GA",
|
||||
"latitude": 32.9866,
|
||||
"longitude": -83.6487,
|
||||
"name": "Georgia"
|
||||
},
|
||||
{
|
||||
"key": "HI",
|
||||
"latitude": 21.1098,
|
||||
"longitude": -157.5311,
|
||||
"name": "Hawaii"
|
||||
},
|
||||
{
|
||||
"key": "IA",
|
||||
"latitude": 42.0046,
|
||||
"longitude": -93.214,
|
||||
"name": "Iowa"
|
||||
},
|
||||
{
|
||||
"key": "ID",
|
||||
"latitude": 44.2394,
|
||||
"longitude": -114.5103,
|
||||
"name": "Idaho"
|
||||
},
|
||||
{
|
||||
"key": "IL",
|
||||
"latitude": 40.3363,
|
||||
"longitude": -89.0022,
|
||||
"name": "Illinois"
|
||||
},
|
||||
{
|
||||
"key": "IN",
|
||||
"latitude": 39.8647,
|
||||
"longitude": -86.2604,
|
||||
"name": "Indiana"
|
||||
},
|
||||
{
|
||||
"key": "KS",
|
||||
"latitude": 38.5111,
|
||||
"longitude": -96.8005,
|
||||
"name": "Kansas"
|
||||
},
|
||||
{
|
||||
"key": "KY",
|
||||
"latitude": 37.669,
|
||||
"longitude": -84.6514,
|
||||
"name": "Kentucky"
|
||||
},
|
||||
{
|
||||
"key": "LA",
|
||||
"latitude": 31.1801,
|
||||
"longitude": -91.8749,
|
||||
"name": "Louisiana"
|
||||
},
|
||||
{
|
||||
"key": "MA",
|
||||
"latitude": 42.2373,
|
||||
"longitude": -71.5314,
|
||||
"name": "Massachusetts"
|
||||
},
|
||||
{
|
||||
"key": "MD",
|
||||
"latitude": 39.0724,
|
||||
"longitude": -76.7902,
|
||||
"name": "Maryland"
|
||||
},
|
||||
{
|
||||
"key": "ME",
|
||||
"latitude": 44.6074,
|
||||
"longitude": -69.3977,
|
||||
"name": "Maine"
|
||||
},
|
||||
{
|
||||
"key": "MI",
|
||||
"latitude": 43.3504,
|
||||
"longitude": -84.5603,
|
||||
"name": "Michigan"
|
||||
},
|
||||
{
|
||||
"key": "MN",
|
||||
"latitude": 45.7326,
|
||||
"longitude": -93.9196,
|
||||
"name": "Minnesota"
|
||||
},
|
||||
{
|
||||
"key": "MO",
|
||||
"latitude": 38.4623,
|
||||
"longitude": -92.302,
|
||||
"name": "Missouri"
|
||||
},
|
||||
{
|
||||
"key": "MP",
|
||||
"latitude": 14.8058,
|
||||
"longitude": 145.5505,
|
||||
"name": "Northern Mariana Islands"
|
||||
},
|
||||
{
|
||||
"key": "MS",
|
||||
"latitude": 32.7673,
|
||||
"longitude": -89.6812,
|
||||
"name": "Mississippi"
|
||||
},
|
||||
{
|
||||
"key": "MT",
|
||||
"latitude": 46.9048,
|
||||
"longitude": -110.3261,
|
||||
"name": "Montana"
|
||||
},
|
||||
{
|
||||
"key": "NC",
|
||||
"latitude": 35.6411,
|
||||
"longitude": -79.8431,
|
||||
"name": "North Carolina"
|
||||
},
|
||||
{
|
||||
"key": "ND",
|
||||
"latitude": 47.5362,
|
||||
"longitude": -99.793,
|
||||
"name": "North Dakota"
|
||||
},
|
||||
{
|
||||
"key": "NE",
|
||||
"latitude": 41.1289,
|
||||
"longitude": -98.2883,
|
||||
"name": "Nebraska"
|
||||
},
|
||||
{
|
||||
"key": "NH",
|
||||
"latitude": 43.4108,
|
||||
"longitude": -71.5653,
|
||||
"name": "New Hampshire"
|
||||
},
|
||||
{
|
||||
"key": "NJ",
|
||||
"latitude": 40.314,
|
||||
"longitude": -74.5089,
|
||||
"name": "New Jersey"
|
||||
},
|
||||
{
|
||||
"key": "NM",
|
||||
"latitude": 34.8375,
|
||||
"longitude": -106.2371,
|
||||
"name": "New Mexico"
|
||||
},
|
||||
{
|
||||
"key": "NV",
|
||||
"latitude": 38.4199,
|
||||
"longitude": -117.1219,
|
||||
"name": "Nevada"
|
||||
},
|
||||
{
|
||||
"key": "NY",
|
||||
"latitude": 42.1497,
|
||||
"longitude": -74.9384,
|
||||
"name": "New York"
|
||||
},
|
||||
{
|
||||
"key": "OH",
|
||||
"latitude": 40.3736,
|
||||
"longitude": -82.7755,
|
||||
"name": "Ohio"
|
||||
},
|
||||
{
|
||||
"key": "OK",
|
||||
"latitude": 35.5376,
|
||||
"longitude": -96.9247,
|
||||
"name": "Oklahoma"
|
||||
},
|
||||
{
|
||||
"key": "OR",
|
||||
"latitude": 44.5672,
|
||||
"longitude": -122.1269,
|
||||
"name": "Oregon"
|
||||
},
|
||||
{
|
||||
"key": "PA",
|
||||
"latitude": 40.5773,
|
||||
"longitude": -77.264,
|
||||
"name": "Pennsylvania"
|
||||
},
|
||||
{
|
||||
"key": "PR",
|
||||
"latitude": 18.2766,
|
||||
"longitude": -66.335,
|
||||
"name": "Puerto Rico"
|
||||
},
|
||||
{
|
||||
"key": "RI",
|
||||
"latitude": 41.6772,
|
||||
"longitude": -71.5101,
|
||||
"name": "Rhode Island"
|
||||
},
|
||||
{
|
||||
"key": "SC",
|
||||
"latitude": 33.8191,
|
||||
"longitude": -80.9066,
|
||||
"name": "South Carolina"
|
||||
},
|
||||
{
|
||||
"key": "SD",
|
||||
"latitude": 44.2853,
|
||||
"longitude": -99.4632,
|
||||
"name": "South Dakota"
|
||||
},
|
||||
{
|
||||
"key": "TN",
|
||||
"latitude": 35.7449,
|
||||
"longitude": -86.7489,
|
||||
"name": "Tennessee"
|
||||
},
|
||||
{
|
||||
"key": "TX",
|
||||
"latitude": 31.106,
|
||||
"longitude": -97.6475,
|
||||
"name": "Texas"
|
||||
},
|
||||
{
|
||||
"key": "UT",
|
||||
"latitude": 40.1135,
|
||||
"longitude": -111.8535,
|
||||
"name": "Utah"
|
||||
},
|
||||
{
|
||||
"key": "VA",
|
||||
"latitude": 37.768,
|
||||
"longitude": -78.2057,
|
||||
"name": "Virginia"
|
||||
},
|
||||
{
|
||||
"key": "VI",
|
||||
"latitude": 18.0001,
|
||||
"longitude": -64.8199,
|
||||
"name": "U.S. Virgin Islands"
|
||||
},
|
||||
{
|
||||
"key": "VT",
|
||||
"latitude": 44.0407,
|
||||
"longitude": -72.7093,
|
||||
"name": "Vermont"
|
||||
},
|
||||
{
|
||||
"key": "WA",
|
||||
"latitude": 47.3917,
|
||||
"longitude": -121.5708,
|
||||
"name": "Washington"
|
||||
},
|
||||
{
|
||||
"key": "WI",
|
||||
"latitude": 44.2563,
|
||||
"longitude": -89.6385,
|
||||
"name": "Wisconsin"
|
||||
},
|
||||
{
|
||||
"key": "WV",
|
||||
"latitude": 38.468,
|
||||
"longitude": -80.9696,
|
||||
"name": "West Virginia"
|
||||
},
|
||||
{
|
||||
"key": "WY",
|
||||
"latitude": 42.7475,
|
||||
"longitude": -107.2085,
|
||||
"name": "Wyoming"
|
||||
}
|
||||
]
|
@ -11,7 +11,8 @@
|
||||
"rootDirs": ["public/"],
|
||||
"typeRoots": ["node_modules/@types", "public/app/types"],
|
||||
"allowJs": true,
|
||||
"strictNullChecks": true
|
||||
"strictNullChecks": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"extends": "@grafana/tsconfig/base.json",
|
||||
"include": [
|
||||
|
Loading…
Reference in New Issue
Block a user