GeoMap: Implement circle overlays (#36680)
This commit is contained in:
@@ -9,6 +9,7 @@ export const CSVFileEditor = ({ onChange, query }: EditorProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const files = [
|
const files = [
|
||||||
|
'flight_info_by_state.csv',
|
||||||
'population_by_state.csv',
|
'population_by_state.csv',
|
||||||
'gdp_per_capita.csv',
|
'gdp_per_capita.csv',
|
||||||
'js_libraries.csv',
|
'js_libraries.csv',
|
||||||
|
|||||||
@@ -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 { geojsonMapper } from './geojsonMapper';
|
||||||
import { lastPointTracker } from './lastPointTracker';
|
import { lastPointTracker } from './lastPointTracker';
|
||||||
import { worldmapBehaviorLayer } from './worldmapBehavior';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Registry for layer handlers
|
* Registry for layer handlers
|
||||||
*/
|
*/
|
||||||
export const dataLayers = [
|
export const dataLayers = [
|
||||||
worldmapBehaviorLayer, // mimic the existing worldmap
|
circlesLayer,
|
||||||
lastPointTracker,
|
lastPointTracker,
|
||||||
geojsonMapper, // dummy for now
|
geojsonMapper, // dummy for now
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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,
|
"showLegend": true,
|
||||||
"showZoom": true,
|
"showZoom": true,
|
||||||
},
|
},
|
||||||
|
"fieldMapping": Object {
|
||||||
|
"geohashField": "",
|
||||||
|
"latitudeField": "",
|
||||||
|
"longitudeField": "",
|
||||||
|
"metricField": "",
|
||||||
|
},
|
||||||
"layers": Array [],
|
"layers": Array [],
|
||||||
"view": Object {
|
"view": Object {
|
||||||
"center": Object {
|
"center": Object {
|
||||||
|
|||||||
@@ -42,6 +42,12 @@ export function worldmapToGeomapOptions(angular: any): { fieldConfig: FieldConfi
|
|||||||
layers: [
|
layers: [
|
||||||
// TODO? depends on current configs
|
// TODO? depends on current configs
|
||||||
],
|
],
|
||||||
|
fieldMapping: {
|
||||||
|
metricField: '',
|
||||||
|
geohashField: '',
|
||||||
|
latitudeField: '',
|
||||||
|
longitudeField: '',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
let v = asNumber(angular.decimals);
|
let v = asNumber(angular.decimals);
|
||||||
|
|||||||
@@ -49,4 +49,16 @@ export interface GeomapPanelOptions {
|
|||||||
controls: ControlsOptions;
|
controls: ControlsOptions;
|
||||||
basemap: MapLayerConfig;
|
basemap: MapLayerConfig;
|
||||||
layers: MapLayerConfig[];
|
layers: MapLayerConfig[];
|
||||||
|
fieldMapping: FieldMappingOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FieldMappingOptions {
|
||||||
|
metricField: string;
|
||||||
|
geohashField: string;
|
||||||
|
latitudeField: string;
|
||||||
|
longitudeField: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueryFormat {
|
||||||
|
locationType: string;
|
||||||
}
|
}
|
||||||
|
|||||||
+18
@@ -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
|
||||||
|
Reference in New Issue
Block a user