Geomap: configure size and color with different fields (#36825)

This commit is contained in:
Ryan McKinley 2021-07-19 10:16:42 -07:00 committed by GitHub
parent 48941da02e
commit 85a14a0503
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 559 additions and 115 deletions

View File

@ -64,7 +64,7 @@ function getBooleanScaleCalculator(field: Field, theme: GrafanaTheme2): ScaleCal
};
}
function getMinMaxAndDelta(field: Field): NumericRange {
export function getMinMaxAndDelta(field: Field): NumericRange {
if (field.type !== FieldType.number) {
return { min: 0, max: 100, delta: 100 };
}

View File

@ -66,11 +66,15 @@ export function useFieldDisplayNames(data: DataFrame[], filter?: (field: Field)
*/
export function useSelectOptions(
displayNames: FrameFieldsDisplayNames,
currentName?: string
currentName?: string,
firstItem?: SelectableValue<string>
): Array<SelectableValue<string>> {
return useMemo(() => {
let found = false;
const options: Array<SelectableValue<string>> = [];
if (firstItem) {
options.push(firstItem);
}
for (const name of displayNames.display) {
if (!found && name === currentName) {
found = true;

View File

@ -0,0 +1,40 @@
import { DataFrame, getFieldColorModeForField, getScaleCalculator, GrafanaTheme2 } from '@grafana/data';
import { ColorDimensionConfig, DimensionSupplier } from './types';
import { findField } from './utils';
//---------------------------------------------------------
// Color dimension
//---------------------------------------------------------
export function getColorDimension(
frame: DataFrame,
config: ColorDimensionConfig,
theme: GrafanaTheme2
): DimensionSupplier<string> {
const field = findField(frame, config.field);
if (!field) {
const v = config.fixed ?? 'grey';
return {
isAssumed: Boolean(config.field?.length) || !config.fixed,
fixed: v,
get: (i) => v,
};
}
const mode = getFieldColorModeForField(field);
if (!mode.isByValue) {
const fixed = mode.getCalculator(field, theme)(0, 0);
return {
fixed,
get: (i) => fixed,
field,
};
}
const scale = getScaleCalculator(field, theme);
return {
get: (i) => {
const val = field.values.get(i);
return scale(val).color;
},
field,
};
}

View File

@ -0,0 +1,85 @@
import React, { FC, useCallback } from 'react';
import { GrafanaTheme2, SelectableValue, StandardEditorProps } from '@grafana/data';
import { ColorDimensionConfig } from '../types';
import { Select, ColorPicker, useStyles2 } from '@grafana/ui';
import {
useFieldDisplayNames,
useSelectOptions,
} from '../../../../../../../packages/grafana-ui/src/components/MatchersUI/utils';
import { css } from '@emotion/css';
const fixedColorOption: SelectableValue<string> = {
label: 'Fixed color',
value: '_____fixed_____',
};
export const ColorDimensionEditor: FC<StandardEditorProps<ColorDimensionConfig, any, any>> = (props) => {
const { value, context, onChange } = props;
const styles = useStyles2(getStyles);
const fieldName = value?.field;
const isFixed = Boolean(!fieldName);
const names = useFieldDisplayNames(context.data);
const selectOptions = useSelectOptions(names, fieldName, fixedColorOption);
const onSelectChange = useCallback(
(selection: SelectableValue<string>) => {
const field = selection.value;
if (field && field !== fixedColorOption.value) {
onChange({
...value,
field,
});
} else {
const fixed = value.fixed ?? 'grey';
onChange({
...value,
field: undefined,
fixed,
});
}
},
[onChange, value]
);
const onColorChange = useCallback(
(c: string) => {
onChange({
field: undefined,
fixed: c ?? 'grey',
});
},
[onChange]
);
const selectedOption = isFixed ? fixedColorOption : selectOptions.find((v) => v.value === fieldName);
return (
<>
<div className={styles.container}>
<Select
value={selectedOption}
options={selectOptions}
onChange={onSelectChange}
noOptionsMessage="No fields found"
/>
{isFixed && (
<div className={styles.picker}>
<ColorPicker color={value?.fixed ?? 'grey'} onChange={onColorChange} />
</div>
)}
</div>
</>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
container: css`
display: flex;
flex-wrap: nowrap;
justify-content: flex-end;
align-items: center;
`,
picker: css`
padding-left: 8px;
`,
});

View File

@ -0,0 +1,132 @@
import React, { FC, useCallback, useMemo } from 'react';
import { GrafanaTheme2, SelectableValue, StandardEditorProps } from '@grafana/data';
import { ScaleDimensionConfig, ScaleDimensionOptions } from '../types';
import { InlineField, InlineFieldRow, Select, useStyles2 } from '@grafana/ui';
import {
useFieldDisplayNames,
useSelectOptions,
} from '../../../../../../../packages/grafana-ui/src/components/MatchersUI/utils';
import { NumberInput } from '../../components/NumberInput';
import { css } from '@emotion/css';
import { validateScaleOptions, validateScaleConfig } from '../scale';
const fixedValueOption: SelectableValue<string> = {
label: 'Fixed value',
value: '_____fixed_____',
};
export const ScaleDimensionEditor: FC<StandardEditorProps<ScaleDimensionConfig, ScaleDimensionOptions, any>> = (
props
) => {
const { value, context, onChange, item } = props;
const { settings } = item;
const styles = useStyles2(getStyles);
const fieldName = value?.field;
const isFixed = Boolean(!fieldName);
const names = useFieldDisplayNames(context.data);
const selectOptions = useSelectOptions(names, fieldName, fixedValueOption);
const minMaxStep = useMemo(() => {
return validateScaleOptions(settings);
}, [settings]);
// Validate and update
const validateAndDoChange = useCallback(
(v: ScaleDimensionConfig) => {
// always called with a copy so no need to spread
onChange(validateScaleConfig(v, minMaxStep));
},
[onChange, minMaxStep]
);
const onSelectChange = useCallback(
(selection: SelectableValue<string>) => {
const field = selection.value;
if (field && field !== fixedValueOption.value) {
validateAndDoChange({
...value,
field,
});
} else {
validateAndDoChange({
...value,
field: undefined,
});
}
},
[validateAndDoChange, value]
);
const onMinChange = useCallback(
(min: number) => {
validateAndDoChange({
...value,
min,
});
},
[validateAndDoChange, value]
);
const onMaxChange = useCallback(
(max: number) => {
validateAndDoChange({
...value,
max,
});
},
[validateAndDoChange, value]
);
const onValueChange = useCallback(
(fixed: number) => {
validateAndDoChange({
...value,
fixed,
});
},
[validateAndDoChange, value]
);
const selectedOption = isFixed ? fixedValueOption : selectOptions.find((v) => v.value === fieldName);
return (
<>
<div>
<Select
value={selectedOption}
options={selectOptions}
onChange={onSelectChange}
noOptionsMessage="No fields found"
/>
</div>
<div className={styles.range}>
{isFixed && (
<InlineFieldRow>
<InlineField label="Value" labelWidth={8} grow={true}>
<NumberInput value={value.fixed} {...minMaxStep} onChange={onValueChange} />
</InlineField>
</InlineFieldRow>
)}
{!isFixed && !minMaxStep.hideRange && (
<>
<InlineFieldRow>
<InlineField label="Min" labelWidth={8} grow={true}>
<NumberInput value={value.min} {...minMaxStep} onChange={onMinChange} />
</InlineField>
</InlineFieldRow>
<InlineFieldRow>
<InlineField label="Max" labelWidth={8} grow={true}>
<NumberInput value={value.max} {...minMaxStep} onChange={onMaxChange} />
</InlineField>
</InlineFieldRow>
</>
)}
</div>
</>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
range: css`
padding-top: 8px;
`,
});

View File

@ -0,0 +1,38 @@
import { validateScaleConfig } from './scale';
describe('scale dimensions', () => {
it('should validate empty input', () => {
const out = validateScaleConfig({} as any, {
min: 5,
max: 10,
});
expect(out).toMatchInlineSnapshot(`
Object {
"fixed": 2.5,
"max": 10,
"min": 2.5,
}
`);
});
it('should assert min<max', () => {
const out = validateScaleConfig(
{
max: -3,
min: 7,
fixed: 100,
},
{
min: 5,
max: 10,
}
);
expect(out).toMatchInlineSnapshot(`
Object {
"fixed": 7,
"max": 7,
"min": 5,
}
`);
});
});

View File

@ -0,0 +1,93 @@
import { DataFrame } from '@grafana/data';
import { getMinMaxAndDelta } from '../../../../../../packages/grafana-data/src/field/scale';
import { ScaleDimensionConfig, DimensionSupplier, ScaleDimensionOptions } from './types';
import { findField } from './utils';
//---------------------------------------------------------
// Scale dimension
//---------------------------------------------------------
export function getScaledDimension(frame: DataFrame, config: ScaleDimensionConfig): DimensionSupplier<number> {
const field = findField(frame, config.field);
if (!field) {
const v = config.fixed ?? 0;
return {
isAssumed: Boolean(config.field?.length) || !config.fixed,
fixed: v,
get: () => v,
};
}
const info = getMinMaxAndDelta(field);
const delta = config.max - config.min;
const values = field.values;
if (values.length < 1 || delta <= 0 || info.delta <= 0) {
return {
fixed: config.min,
get: () => config.min,
};
}
return {
get: (i) => {
const value = field.values.get(i);
let percent = 0;
if (value !== -Infinity) {
percent = (value - info.min!) / info.delta;
}
return config.min + percent * delta;
},
field,
};
}
// This will mutate options
export function validateScaleOptions(options?: ScaleDimensionOptions): ScaleDimensionOptions {
if (!options) {
options = { min: 0, max: 1 };
}
if (options.min == null) {
options.min = 0;
}
if (options.max == null) {
options.max = 1;
}
return options;
}
/** Mutates and will return a valid version */
export function validateScaleConfig(copy: ScaleDimensionConfig, options: ScaleDimensionOptions): ScaleDimensionConfig {
let { min, max } = validateScaleOptions(options);
if (!copy) {
copy = {} as any;
}
if (copy.max == null) {
copy.max = max;
}
if (copy.min == null) {
copy.min = min;
}
// Make sure the order is right
if (copy.min > copy.max) {
const tmp = copy.max;
copy.max = copy.min;
copy.min = tmp;
}
// Validate range
if (copy.min < min) {
copy.min = min;
}
if (copy.max > max) {
copy.max = max;
}
if (copy.fixed == null) {
copy.fixed = copy.min = (copy.max - copy.min) / 2.0;
}
if (copy.fixed > copy.max) {
copy.fixed = copy.max;
} else if (copy.fixed < copy.min) {
copy.fixed = copy.min;
}
return copy;
}

View File

@ -0,0 +1,45 @@
import { Field } from '@grafana/data';
export interface BaseDimensionConfig<T = any> {
fixed: T;
field?: string;
}
export interface DimensionSupplier<T = any> {
/**
* This means an explicit value was not configured
*/
isAssumed?: boolean;
/**
* The fied used for
*/
field?: Field;
/**
* Explicit value -- if == null, then need a value pr index
*/
fixed?: T;
/**
* Supplier for the dimension value
*/
get: (index: number) => T;
}
/** This will map the field value% to a scaled value within the range */
export interface ScaleDimensionConfig extends BaseDimensionConfig<number> {
min: number;
max: number;
}
/** Places that use the value */
export interface ScaleDimensionOptions {
min: number;
max: number;
step?: number;
hideRange?: boolean; // false
}
/** Use the color value from field configs */
export interface ColorDimensionConfig extends BaseDimensionConfig<string> {}

View File

@ -0,0 +1,18 @@
import { DataFrame, Field, getFieldDisplayName } from '@grafana/data';
export function findField(frame: DataFrame, name?: string): Field | undefined {
if (!name?.length) {
return undefined;
}
for (const field of frame.fields) {
if (name === field.name) {
return field;
}
const disp = getFieldDisplayName(field, frame);
if (name === disp) {
return field;
}
}
return undefined;
}

View File

@ -1,5 +1,4 @@
import {
FieldCalcs,
FieldType,
getFieldColorModeForField,
GrafanaTheme2,
@ -7,22 +6,29 @@ import {
MapLayerOptions,
MapLayerRegistryItem,
PanelData,
reduceField,
ReducerID,
} 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 { ScaleDimensionConfig, } from '../../dims/types';
import { ScaleDimensionEditor } from '../../dims/editors/ScaleDimensionEditor';
import { getScaledDimension } from '../../dims/scale';
// Configuration options for Heatmap overlays
export interface HeatmapConfig {
weight: ScaleDimensionConfig;
blur: number;
radius: number;
}
const defaultOptions: HeatmapConfig = {
weight: {
fixed: 1,
min: 0,
max: 1,
},
blur: 15,
radius: 5,
};
@ -77,47 +83,54 @@ export const heatmapLayer: MapLayerRegistryItem<HeatmapConfig> = {
return; // ???
}
// Get the field of data values
const field = frame.fields.find(field => field.type === FieldType.number); // TODO!!!!
// Return early if metric field is not matched
if (field === undefined) {
return;
};
const weightDim = getScaledDimension(frame, config.weight);
// Retrieve the min, max and range of data values
const calcs = reduceField({
field: field,
reducers: [
ReducerID.min,
ReducerID.range,
]
});
// Map each data value into new points
for (let i = 0; i < frame.length; i++) {
const cluster = new Feature({
geometry: info.points[i],
value: normalize(calcs, field.values.get(i)),
value: weightDim.get(i),
});
vectorSource.addFeature(cluster);
};
vectorLayer.setSource(vectorSource);
// Set gradient of heatmap
// Set heatmap gradient colors
let colors = ['#00f', '#0ff', '#0f0', '#ff0', '#f00'];
// Either the configured field or the first numeric field value
const field = weightDim.field ?? frame.fields.find(field => field.type === FieldType.number);
if (field) {
const colorMode = getFieldColorModeForField(field);
if (colorMode.isContinuous && colorMode.getColors) {
// getColors return an array of color string from the color scheme chosen
const colors = colorMode.getColors(theme);
vectorLayer.setGradient(colors);
} else {
// Set the gradient back to default if threshold or single color is chosen
vectorLayer.setGradient(['#00f', '#0ff', '#0f0', '#ff0', '#f00']);
colors = colorMode.getColors(theme);
}
}
vectorLayer.setGradient(colors);
},
};
},
// Heatmap overlay options
registerOptionsUI: (builder) => {
builder
.addCustomEditor({
id: 'config.weight',
path: 'config.weight',
name: 'Weight values',
description: 'Scale the distribution for each row',
editor: ScaleDimensionEditor,
settings: {
min: 0, // no contribution
max: 1,
hideRange: true, // Don't show the scale factor
},
defaultValue: { // Configured values
fixed: 1,
min: 0,
max: 1,
},
})
.addSliderInput({
path: 'config.radius',
description: 'configures the size of clusters',
@ -144,17 +157,3 @@ export const heatmapLayer: MapLayerRegistryItem<HeatmapConfig> = {
// fill in the default values
defaultOptions,
};
/**
* Function that normalize the data values to a range between 0.1 and 1
* Returns the weights for each value input
*/
function normalize(calcs: FieldCalcs, value: number) {
// If all data values are the same, it should return the largest weight
if (calcs.range == 0) {
return 1;
};
// Normalize value in range of [0.1,1]
const norm = 0.1 + ((value - calcs.min) / calcs.range) * 0.9
return norm;
};

View File

@ -1,4 +1,4 @@
import { MapLayerRegistryItem, MapLayerOptions, MapLayerHandler, PanelData, GrafanaTheme2, reduceField, ReducerID, FieldCalcs, FieldType } from '@grafana/data';
import { MapLayerRegistryItem, MapLayerOptions, MapLayerHandler, PanelData, GrafanaTheme2 } from '@grafana/data';
import Map from 'ol/Map';
import Feature from 'ol/Feature';
import * as layer from 'ol/layer';
@ -6,18 +6,30 @@ import * as source from 'ol/source';
import * as style from 'ol/style';
import tinycolor from 'tinycolor2';
import { dataFrameToPoints, getLocationMatchers } from '../../utils/location';
import { ColorDimensionConfig, ScaleDimensionConfig, } from '../../dims/types';
import { getScaledDimension, } from '../../dims/scale';
import { getColorDimension, } from '../../dims/color';
import { ScaleDimensionEditor } from '../../dims/editors/ScaleDimensionEditor';
import { ColorDimensionEditor } from '../../dims/editors/ColorDimensionEditor';
// Configuration options for Circle overlays
export interface MarkersConfig {
minSize: number,
maxSize: number,
opacity: number,
size: ScaleDimensionConfig;
color: ColorDimensionConfig;
fillOpacity: number;
}
const defaultOptions: MarkersConfig = {
minSize: 1,
maxSize: 10,
opacity: 0.4,
size: {
fixed: 5,
min: 5,
max: 10,
},
color: {
fixed: '#f00',
},
fillOpacity: 0.4,
};
export const MARKERS_LAYER_ID = "markers";
@ -37,9 +49,7 @@ export const markersLayer: MapLayerRegistryItem<MarkersConfig> = {
* @param options
*/
create: (map: Map, options: MapLayerOptions<MarkersConfig>, theme: GrafanaTheme2): MapLayerHandler => {
const config = { ...defaultOptions, ...options.config };
const matchers = getLocationMatchers(options.location);
const vectorLayer = new layer.Vector({});
return {
init: () => vectorLayer,
@ -55,33 +65,25 @@ export const markersLayer: MapLayerRegistryItem<MarkersConfig> = {
return; // ???
}
const field = frame.fields.find(field => field.type === FieldType.number); // TODO!!!!
// Return early if metric field is not matched
if (field === undefined) {
return;
// Assert default values
const config = {
...defaultOptions,
...options?.config,
};
// Retrieve the min, max and range of data values
const calcs = reduceField({
field: field,
reducers: [
ReducerID.min,
ReducerID.max,
ReducerID.range,
]
});
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 = frame.fields[0].display!(field.values.get(i)).color;
const color = colorDim.get(i);
// Set the opacity determined from user configuration
const fillColor = tinycolor(color).setAlpha(config.opacity).toRgbString();
const fillColor = tinycolor(color).setAlpha(opacity).toRgbString();
// Get circle size from user configuration
const radius = calcCircleSize(calcs, field.values.get(i), config.minSize, config.maxSize);
const radius = sizeDim.get(i);
// Create a new Feature for each point returned from dataFrameToPoints
const dot = new Feature({
@ -111,35 +113,38 @@ export const markersLayer: MapLayerRegistryItem<MarkersConfig> = {
},
};
},
// Circle overlay options
// Marker overlay options
registerOptionsUI: (builder) => {
builder
// .addFieldNamePicker({
// path: 'fieldMapping.metricField',
// name: 'Metric Field',
// defaultValue: defaultOptions.fieldMapping.metricField,
// settings: {
// filter: (f) => f.type === FieldType.number,
// noFieldsMessage: 'No numeric fields found',
// },
// })
.addNumberInput({
path: 'config.minSize',
description: 'configures the min circle size',
name: 'Min Size',
defaultValue: defaultOptions.minSize,
.addCustomEditor({
id: 'config.color',
path: 'config.color',
name: 'Marker Color',
editor: ColorDimensionEditor,
settings: {},
defaultValue: { // Configured values
fixed: 'grey',
},
})
.addNumberInput({
path: 'config.maxSize',
description: 'configures the max circle size',
name: 'Max Size',
defaultValue: defaultOptions.maxSize,
.addCustomEditor({
id: 'config.size',
path: 'config.size',
name: 'Marker Size',
editor: ScaleDimensionEditor,
settings: {
min: 1,
max: 100, // possible in the UI
},
defaultValue: { // Configured values
fixed: 5,
min: 1,
max: 20,
},
})
.addSliderInput({
path: 'config.opacity',
description: 'configures the amount of transparency',
name: 'Opacity',
defaultValue: defaultOptions.opacity,
path: 'config.fillOpacity',
name: 'Fill opacity',
defaultValue: defaultOptions.fillOpacity,
settings: {
min: 0,
max: 1,
@ -150,18 +155,3 @@ export const markersLayer: MapLayerRegistryItem<MarkersConfig> = {
// 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;
};