mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
GeoMap: Implement circle overlays (#36680)
This commit is contained in:
parent
32a551b4ca
commit
dc8874cd2e
@ -9,6 +9,7 @@ export const CSVFileEditor = ({ onChange, query }: EditorProps) => {
|
||||
};
|
||||
|
||||
const files = [
|
||||
'flight_info_by_state.csv',
|
||||
'population_by_state.csv',
|
||||
'gdp_per_capita.csv',
|
||||
'js_libraries.csv',
|
||||
|
201
public/app/plugins/panel/geomap/layers/data/circlesOverlay.ts
Normal file
201
public/app/plugins/panel/geomap/layers/data/circlesOverlay.ts
Normal file
@ -0,0 +1,201 @@
|
||||
import { MapLayerRegistryItem, MapLayerConfig, MapLayerHandler, PanelData, GrafanaTheme2, reduceField, ReducerID, FieldCalcs } from '@grafana/data';
|
||||
import { dataFrameToPoints } from './utils'
|
||||
import { FieldMappingOptions, QueryFormat } from '../../types'
|
||||
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';
|
||||
|
||||
// Configuration options for Circle overlays
|
||||
export interface CircleConfig {
|
||||
queryFormat: QueryFormat,
|
||||
fieldMapping: FieldMappingOptions,
|
||||
minSize: number,
|
||||
maxSize: number,
|
||||
opacity: number,
|
||||
}
|
||||
|
||||
const defaultOptions: CircleConfig = {
|
||||
queryFormat: {
|
||||
locationType: 'coordinates',
|
||||
},
|
||||
fieldMapping: {
|
||||
metricField: '',
|
||||
geohashField: '',
|
||||
latitudeField: '',
|
||||
longitudeField: '',
|
||||
},
|
||||
minSize: 1,
|
||||
maxSize: 10,
|
||||
opacity: 0.4,
|
||||
};
|
||||
|
||||
/**
|
||||
* Map layer configuration for circle overlay
|
||||
*/
|
||||
export const circlesLayer: MapLayerRegistryItem<CircleConfig> = {
|
||||
id: 'circles',
|
||||
name: 'Circles',
|
||||
description: 'creates circle overlays for data values',
|
||||
isBaseMap: false,
|
||||
|
||||
/**
|
||||
* Function that configures transformation and returns a transformer
|
||||
* @param options
|
||||
*/
|
||||
create: (map: Map, options: MapLayerConfig<CircleConfig>, theme: GrafanaTheme2): MapLayerHandler => {
|
||||
const config = { ...defaultOptions, ...options.config };
|
||||
|
||||
const vectorLayer = new layer.Vector({});
|
||||
return {
|
||||
init: () => vectorLayer,
|
||||
update: (data: PanelData) => {
|
||||
const features: Feature[] = [];
|
||||
const frame = data.series[0];
|
||||
|
||||
// Get data values
|
||||
const points = dataFrameToPoints(frame, config.fieldMapping, config.queryFormat);
|
||||
const field = frame.fields.find(field => field.name === config.fieldMapping.metricField);
|
||||
// Return early if metric field is not matched
|
||||
if (field === undefined) {
|
||||
return;
|
||||
};
|
||||
|
||||
// Retrieve the min, max and range of data values
|
||||
const calcs = reduceField({
|
||||
field: field,
|
||||
reducers: [
|
||||
ReducerID.min,
|
||||
ReducerID.max,
|
||||
ReducerID.range,
|
||||
]
|
||||
});
|
||||
|
||||
// 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 = frame.fields[0].display!(field.values.get(i)).color;
|
||||
// Set the opacity determined from user configuration
|
||||
const fillColor = tinycolor(color).setAlpha(config.opacity).toRgbString();
|
||||
|
||||
// Get circle size from user configuration
|
||||
const radius = calcCircleSize(calcs, field.values.get(i), config.minSize, config.maxSize);
|
||||
|
||||
// Create a new Feature for each point returned from dataFrameToPoints
|
||||
const dot = new Feature({
|
||||
geometry: points[i],
|
||||
});
|
||||
|
||||
// Set the style of each feature dot
|
||||
dot.setStyle(new style.Style({
|
||||
image: new style.Circle({
|
||||
// Stroke determines the outline color of the circle
|
||||
stroke: new style.Stroke({
|
||||
color: color,
|
||||
}),
|
||||
// Fill determines the color to fill the whole circle
|
||||
fill: new style.Fill({
|
||||
color: tinycolor(fillColor).toString(),
|
||||
}),
|
||||
radius: radius,
|
||||
})
|
||||
}));
|
||||
features.push(dot);
|
||||
};
|
||||
|
||||
// Source reads the data and provides a set of features to visualize
|
||||
const vectorSource = new source.Vector({ features });
|
||||
vectorLayer.setSource(vectorSource);
|
||||
},
|
||||
};
|
||||
},
|
||||
// Circle overlay options
|
||||
registerOptionsUI: (builder) => {
|
||||
builder
|
||||
.addNumberInput({
|
||||
path: 'minSize',
|
||||
description: 'configures the min circle size',
|
||||
name: 'Min Size',
|
||||
defaultValue: defaultOptions.minSize,
|
||||
})
|
||||
.addNumberInput({
|
||||
path: 'maxSize',
|
||||
description: 'configures the max circle size',
|
||||
name: 'Max Size',
|
||||
defaultValue: defaultOptions.maxSize,
|
||||
})
|
||||
.addSliderInput({
|
||||
path: 'opacity',
|
||||
description: 'configures the amount of transparency',
|
||||
name: 'Opacity',
|
||||
defaultValue: defaultOptions.opacity,
|
||||
settings: {
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.1,
|
||||
},
|
||||
})
|
||||
.addSelect({
|
||||
path: 'queryFormat.locationType',
|
||||
name: 'Query Format',
|
||||
defaultValue: defaultOptions.queryFormat.locationType,
|
||||
settings: {
|
||||
options: [
|
||||
{
|
||||
value: 'coordinates',
|
||||
label: 'Coordinates',
|
||||
},
|
||||
{
|
||||
value: 'geohash',
|
||||
label: 'Geohash',
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
.addTextInput({
|
||||
path: 'fieldMapping.metricField',
|
||||
name: 'Metric Field',
|
||||
defaultValue: defaultOptions.fieldMapping.metricField,
|
||||
})
|
||||
.addTextInput({
|
||||
path: 'fieldMapping.latitudeField',
|
||||
name: 'Latitude Field',
|
||||
defaultValue: defaultOptions.fieldMapping.latitudeField,
|
||||
showIf: (config) =>
|
||||
config.queryFormat.locationType === 'coordinates',
|
||||
})
|
||||
.addTextInput({
|
||||
path: 'fieldMapping.longitudeField',
|
||||
name: 'Longitude Field',
|
||||
defaultValue: defaultOptions.fieldMapping.longitudeField,
|
||||
showIf: (config) =>
|
||||
config.queryFormat.locationType === 'coordinates',
|
||||
})
|
||||
.addTextInput({
|
||||
path: 'fieldMapping.geohashField',
|
||||
name: 'Geohash Field',
|
||||
defaultValue: defaultOptions.fieldMapping.geohashField,
|
||||
showIf: (config) =>
|
||||
config.queryFormat.locationType === 'geohash',
|
||||
});
|
||||
},
|
||||
// fill in the default values
|
||||
defaultOptions,
|
||||
};
|
||||
|
||||
/**
|
||||
* Function that scales the circle size depending on the current data and user defined configurations
|
||||
* Returns the scaled value in the range of min and max circle size
|
||||
* Ex. If the minSize and maxSize were 5, 15: all values returned will be between 5~15
|
||||
*/
|
||||
function calcCircleSize(calcs: FieldCalcs, value: number, minSize: number, maxSize: number) {
|
||||
if (calcs.range === 0) {
|
||||
return maxSize;
|
||||
}
|
||||
|
||||
const dataFactor = (value - calcs.min) / calcs.max;
|
||||
const circleSizeRange = maxSize - minSize;
|
||||
return circleSizeRange * dataFactor + minSize;
|
||||
};
|
@ -1,12 +1,12 @@
|
||||
import { circlesLayer } from './circlesOverlay';
|
||||
import { geojsonMapper } from './geojsonMapper';
|
||||
import { lastPointTracker } from './lastPointTracker';
|
||||
import { worldmapBehaviorLayer } from './worldmapBehavior';
|
||||
|
||||
/**
|
||||
* Registry for layer handlers
|
||||
*/
|
||||
export const dataLayers = [
|
||||
worldmapBehaviorLayer, // mimic the existing worldmap
|
||||
circlesLayer,
|
||||
lastPointTracker,
|
||||
geojsonMapper, // dummy for now
|
||||
];
|
||||
|
84
public/app/plugins/panel/geomap/layers/data/utils.ts
Normal file
84
public/app/plugins/panel/geomap/layers/data/utils.ts
Normal file
@ -0,0 +1,84 @@
|
||||
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;
|
||||
}
|
||||
}
|
@ -1,70 +0,0 @@
|
||||
import React from 'react';
|
||||
import { MapLayerRegistryItem, MapLayerConfig, MapLayerHandler, PanelData, GrafanaTheme2 } 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 {Point} from 'ol/geom';
|
||||
import { fromLonLat } from 'ol/proj';
|
||||
import tinycolor from 'tinycolor2';
|
||||
import { SimpleLegend } from '../../components/SimpleLegend';
|
||||
|
||||
export interface WorldmapConfig {
|
||||
// anything
|
||||
}
|
||||
|
||||
const defaultOptions: WorldmapConfig = {
|
||||
// icon: 'https://openlayers.org/en/latest/examples/data/icon.png',
|
||||
};
|
||||
|
||||
export const worldmapBehaviorLayer: MapLayerRegistryItem<WorldmapConfig> = {
|
||||
id: 'worldmap-behavior',
|
||||
name: 'Worldmap behavior',
|
||||
description: 'behave the same as worldmap plugin',
|
||||
isBaseMap: false,
|
||||
|
||||
/**
|
||||
* Function that configures transformation and returns a transformer
|
||||
* @param options
|
||||
*/
|
||||
create: (map: Map, options: MapLayerConfig<WorldmapConfig>, theme: GrafanaTheme2): MapLayerHandler => {
|
||||
// const config = { ...defaultOptions, ...options.config };
|
||||
const vectorLayer = new layer.Vector({});
|
||||
let legendInstance = <SimpleLegend txt={ `initalizing...`}/>;
|
||||
let count = 0;
|
||||
return {
|
||||
init: () => vectorLayer,
|
||||
legend: () => {
|
||||
return legendInstance;
|
||||
},
|
||||
update: (data: PanelData) => {
|
||||
count++;
|
||||
const features:Feature[] = [];
|
||||
for( let x=0; x<100; x+=20) {
|
||||
for( let y=0; y<40; y+=10) {
|
||||
const dot = new Feature({
|
||||
geometry: new Point(fromLonLat([x,y])),
|
||||
});
|
||||
dot.setStyle(new style.Style({
|
||||
image: new style.Circle({
|
||||
fill: new style.Fill({
|
||||
color: tinycolor({r:(x*2), g:(y*3), b:0}).toString(),
|
||||
}),
|
||||
radius: (4 + (y*0.5) + (x*0.1)),
|
||||
})
|
||||
}));
|
||||
features.push(dot);
|
||||
}
|
||||
}
|
||||
legendInstance = <SimpleLegend txt={ `Update: ${count}`} data={data}/>;
|
||||
|
||||
const vectorSource = new source.Vector({ features });
|
||||
vectorLayer.setSource(vectorSource);
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
// fill in the default values
|
||||
defaultOptions,
|
||||
};
|
@ -55,6 +55,12 @@ describe('Worldmap Migrations', () => {
|
||||
"showLegend": true,
|
||||
"showZoom": true,
|
||||
},
|
||||
"fieldMapping": Object {
|
||||
"geohashField": "",
|
||||
"latitudeField": "",
|
||||
"longitudeField": "",
|
||||
"metricField": "",
|
||||
},
|
||||
"layers": Array [],
|
||||
"view": Object {
|
||||
"center": Object {
|
||||
|
@ -42,6 +42,12 @@ export function worldmapToGeomapOptions(angular: any): { fieldConfig: FieldConfi
|
||||
layers: [
|
||||
// TODO? depends on current configs
|
||||
],
|
||||
fieldMapping: {
|
||||
metricField: '',
|
||||
geohashField: '',
|
||||
latitudeField: '',
|
||||
longitudeField: '',
|
||||
},
|
||||
};
|
||||
|
||||
let v = asNumber(angular.decimals);
|
||||
|
@ -49,4 +49,16 @@ export interface GeomapPanelOptions {
|
||||
controls: ControlsOptions;
|
||||
basemap: MapLayerConfig;
|
||||
layers: MapLayerConfig[];
|
||||
fieldMapping: FieldMappingOptions;
|
||||
}
|
||||
|
||||
export interface FieldMappingOptions {
|
||||
metricField: string;
|
||||
geohashField: string;
|
||||
latitudeField: string;
|
||||
longitudeField: string;
|
||||
}
|
||||
|
||||
export interface QueryFormat {
|
||||
locationType: string;
|
||||
}
|
||||
|
18
public/testdata/flight_info_by_state.csv
vendored
Normal file
18
public/testdata/flight_info_by_state.csv
vendored
Normal file
@ -0,0 +1,18 @@
|
||||
State, Lat,Lng,DestLocation,Count,Price
|
||||
Alaska, 61.3850,-152.2683,bdg,5,500
|
||||
Alabama, 32.7990,-86.8073,djf,3,300
|
||||
Arizona, 33.7712,-111.3877,9w0,10,150
|
||||
California, 36.1700,-119.7462,9q6,12,250
|
||||
Colorado, 39.0646,-105.3272,9wv,1,600
|
||||
Florida, 27.8333,-81.7170,dhv,5,500
|
||||
Iowa, 42.0046,-93.2140,9zm,7,700
|
||||
Illinois, 40.3363,-89.0022,dp0,1,400
|
||||
Indiana, 39.8647,-86.2604,dp4,5,540
|
||||
Kentucky, 37.6690,-84.6514,dne,6,630
|
||||
Massachusetts, 42.2373,-71.5314,drt,11,460
|
||||
Michigan, 43.3504,-84.5603,dpe,2,250
|
||||
North Carolina, 35.6411,-79.8431,dnr,5,500
|
||||
New Jersey, 40.3140,-74.5089,dr5,12,400
|
||||
New Mexico, 34.8375,-106.2371,9wh,15,800
|
||||
Ohio, 40.3736,-82.7755,dpj,9,930
|
||||
Oregon, 44.5672,-122.1269,9rc,3,360
|
|
Loading…
Reference in New Issue
Block a user