From dc8874cd2ef2defb8b38cb729042fc55027ee131 Mon Sep 17 00:00:00 2001 From: Eunice Kim Date: Mon, 12 Jul 2021 20:47:39 -0700 Subject: [PATCH] GeoMap: Implement circle overlays (#36680) --- .../testdata/components/CSVFileEditor.tsx | 1 + .../geomap/layers/data/circlesOverlay.ts | 201 ++++++++++++++++++ .../plugins/panel/geomap/layers/data/index.ts | 4 +- .../plugins/panel/geomap/layers/data/utils.ts | 84 ++++++++ .../geomap/layers/data/worldmapBehavior.tsx | 70 ------ .../plugins/panel/geomap/migrations.test.ts | 6 + public/app/plugins/panel/geomap/migrations.ts | 6 + public/app/plugins/panel/geomap/types.ts | 12 ++ public/testdata/flight_info_by_state.csv | 18 ++ 9 files changed, 330 insertions(+), 72 deletions(-) create mode 100644 public/app/plugins/panel/geomap/layers/data/circlesOverlay.ts create mode 100644 public/app/plugins/panel/geomap/layers/data/utils.ts delete mode 100644 public/app/plugins/panel/geomap/layers/data/worldmapBehavior.tsx create mode 100644 public/testdata/flight_info_by_state.csv diff --git a/public/app/plugins/datasource/testdata/components/CSVFileEditor.tsx b/public/app/plugins/datasource/testdata/components/CSVFileEditor.tsx index 7aeae4e74af..5c3e356812e 100644 --- a/public/app/plugins/datasource/testdata/components/CSVFileEditor.tsx +++ b/public/app/plugins/datasource/testdata/components/CSVFileEditor.tsx @@ -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', diff --git a/public/app/plugins/panel/geomap/layers/data/circlesOverlay.ts b/public/app/plugins/panel/geomap/layers/data/circlesOverlay.ts new file mode 100644 index 00000000000..60165f386c1 --- /dev/null +++ b/public/app/plugins/panel/geomap/layers/data/circlesOverlay.ts @@ -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 = { + 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, 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; +}; diff --git a/public/app/plugins/panel/geomap/layers/data/index.ts b/public/app/plugins/panel/geomap/layers/data/index.ts index cc904791eec..9f0fd8defee 100644 --- a/public/app/plugins/panel/geomap/layers/data/index.ts +++ b/public/app/plugins/panel/geomap/layers/data/index.ts @@ -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 ]; diff --git a/public/app/plugins/panel/geomap/layers/data/utils.ts b/public/app/plugins/panel/geomap/layers/data/utils.ts new file mode 100644 index 00000000000..b38bee56094 --- /dev/null +++ b/public/app/plugins/panel/geomap/layers/data/utils.ts @@ -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; + } +} diff --git a/public/app/plugins/panel/geomap/layers/data/worldmapBehavior.tsx b/public/app/plugins/panel/geomap/layers/data/worldmapBehavior.tsx deleted file mode 100644 index 3c6f08fe1b5..00000000000 --- a/public/app/plugins/panel/geomap/layers/data/worldmapBehavior.tsx +++ /dev/null @@ -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 = { - 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, theme: GrafanaTheme2): MapLayerHandler => { - // const config = { ...defaultOptions, ...options.config }; - const vectorLayer = new layer.Vector({}); - let legendInstance = ; - 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 = ; - - const vectorSource = new source.Vector({ features }); - vectorLayer.setSource(vectorSource); - }, - }; - }, - - // fill in the default values - defaultOptions, -}; diff --git a/public/app/plugins/panel/geomap/migrations.test.ts b/public/app/plugins/panel/geomap/migrations.test.ts index c14f26c2b63..32ed0a1bf41 100644 --- a/public/app/plugins/panel/geomap/migrations.test.ts +++ b/public/app/plugins/panel/geomap/migrations.test.ts @@ -55,6 +55,12 @@ describe('Worldmap Migrations', () => { "showLegend": true, "showZoom": true, }, + "fieldMapping": Object { + "geohashField": "", + "latitudeField": "", + "longitudeField": "", + "metricField": "", + }, "layers": Array [], "view": Object { "center": Object { diff --git a/public/app/plugins/panel/geomap/migrations.ts b/public/app/plugins/panel/geomap/migrations.ts index 5b3ca894b7c..c3f0bf61605 100644 --- a/public/app/plugins/panel/geomap/migrations.ts +++ b/public/app/plugins/panel/geomap/migrations.ts @@ -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); diff --git a/public/app/plugins/panel/geomap/types.ts b/public/app/plugins/panel/geomap/types.ts index 42bc87a49db..7fa7d4facbf 100644 --- a/public/app/plugins/panel/geomap/types.ts +++ b/public/app/plugins/panel/geomap/types.ts @@ -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; } diff --git a/public/testdata/flight_info_by_state.csv b/public/testdata/flight_info_by_state.csv new file mode 100644 index 00000000000..d4682862d8a --- /dev/null +++ b/public/testdata/flight_info_by_state.csv @@ -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