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) { if (field.type !== FieldType.number) {
return { min: 0, max: 100, delta: 100 }; return { min: 0, max: 100, delta: 100 };
} }

View File

@ -66,11 +66,15 @@ export function useFieldDisplayNames(data: DataFrame[], filter?: (field: Field)
*/ */
export function useSelectOptions( export function useSelectOptions(
displayNames: FrameFieldsDisplayNames, displayNames: FrameFieldsDisplayNames,
currentName?: string currentName?: string,
firstItem?: SelectableValue<string>
): Array<SelectableValue<string>> { ): Array<SelectableValue<string>> {
return useMemo(() => { return useMemo(() => {
let found = false; let found = false;
const options: Array<SelectableValue<string>> = []; const options: Array<SelectableValue<string>> = [];
if (firstItem) {
options.push(firstItem);
}
for (const name of displayNames.display) { for (const name of displayNames.display) {
if (!found && name === currentName) { if (!found && name === currentName) {
found = true; 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 { import {
FieldCalcs,
FieldType, FieldType,
getFieldColorModeForField, getFieldColorModeForField,
GrafanaTheme2, GrafanaTheme2,
@ -7,22 +6,29 @@ import {
MapLayerOptions, MapLayerOptions,
MapLayerRegistryItem, MapLayerRegistryItem,
PanelData, PanelData,
reduceField,
ReducerID,
} from '@grafana/data'; } from '@grafana/data';
import Map from 'ol/Map'; import Map from 'ol/Map';
import Feature from 'ol/Feature'; import Feature from 'ol/Feature';
import * as layer from 'ol/layer'; import * as layer from 'ol/layer';
import * as source from 'ol/source'; import * as source from 'ol/source';
import { dataFrameToPoints, getLocationMatchers } from '../../utils/location'; 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 // Configuration options for Heatmap overlays
export interface HeatmapConfig { export interface HeatmapConfig {
weight: ScaleDimensionConfig;
blur: number; blur: number;
radius: number; radius: number;
} }
const defaultOptions: HeatmapConfig = { const defaultOptions: HeatmapConfig = {
weight: {
fixed: 1,
min: 0,
max: 1,
},
blur: 15, blur: 15,
radius: 5, radius: 5,
}; };
@ -77,47 +83,54 @@ export const heatmapLayer: MapLayerRegistryItem<HeatmapConfig> = {
return; // ??? return; // ???
} }
// Get the field of data values const weightDim = getScaledDimension(frame, config.weight);
const field = frame.fields.find(field => field.type === FieldType.number); // TODO!!!!
// 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.range,
]
});
// Map each data value into new points // Map each data value into new points
for (let i = 0; i < frame.length; i++) { for (let i = 0; i < frame.length; i++) {
const cluster = new Feature({ const cluster = new Feature({
geometry: info.points[i], geometry: info.points[i],
value: normalize(calcs, field.values.get(i)), value: weightDim.get(i),
}); });
vectorSource.addFeature(cluster); vectorSource.addFeature(cluster);
}; };
vectorLayer.setSource(vectorSource); vectorLayer.setSource(vectorSource);
// Set gradient of heatmap // Set heatmap gradient colors
const colorMode = getFieldColorModeForField(field); let colors = ['#00f', '#0ff', '#0f0', '#ff0', '#f00'];
if (colorMode.isContinuous && colorMode.getColors) {
// getColors return an array of color string from the color scheme chosen // Either the configured field or the first numeric field value
const colors = colorMode.getColors(theme); const field = weightDim.field ?? frame.fields.find(field => field.type === FieldType.number);
vectorLayer.setGradient(colors); if (field) {
} else { const colorMode = getFieldColorModeForField(field);
// Set the gradient back to default if threshold or single color is chosen if (colorMode.isContinuous && colorMode.getColors) {
vectorLayer.setGradient(['#00f', '#0ff', '#0f0', '#ff0', '#f00']); // getColors return an array of color string from the color scheme chosen
colors = colorMode.getColors(theme);
}
} }
vectorLayer.setGradient(colors);
}, },
}; };
}, },
// Heatmap overlay options // Heatmap overlay options
registerOptionsUI: (builder) => { registerOptionsUI: (builder) => {
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({ .addSliderInput({
path: 'config.radius', path: 'config.radius',
description: 'configures the size of clusters', description: 'configures the size of clusters',
@ -144,17 +157,3 @@ export const heatmapLayer: MapLayerRegistryItem<HeatmapConfig> = {
// fill in the default values // fill in the default values
defaultOptions, 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 Map from 'ol/Map';
import Feature from 'ol/Feature'; import Feature from 'ol/Feature';
import * as layer from 'ol/layer'; import * as layer from 'ol/layer';
@ -6,18 +6,30 @@ import * as source from 'ol/source';
import * as style from 'ol/style'; import * as style from 'ol/style';
import tinycolor from 'tinycolor2'; import tinycolor from 'tinycolor2';
import { dataFrameToPoints, getLocationMatchers } from '../../utils/location'; 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 // Configuration options for Circle overlays
export interface MarkersConfig { export interface MarkersConfig {
minSize: number, size: ScaleDimensionConfig;
maxSize: number, color: ColorDimensionConfig;
opacity: number, fillOpacity: number;
} }
const defaultOptions: MarkersConfig = { const defaultOptions: MarkersConfig = {
minSize: 1, size: {
maxSize: 10, fixed: 5,
opacity: 0.4, min: 5,
max: 10,
},
color: {
fixed: '#f00',
},
fillOpacity: 0.4,
}; };
export const MARKERS_LAYER_ID = "markers"; export const MARKERS_LAYER_ID = "markers";
@ -37,9 +49,7 @@ export const markersLayer: MapLayerRegistryItem<MarkersConfig> = {
* @param options * @param options
*/ */
create: (map: Map, options: MapLayerOptions<MarkersConfig>, theme: GrafanaTheme2): MapLayerHandler => { create: (map: Map, options: MapLayerOptions<MarkersConfig>, theme: GrafanaTheme2): MapLayerHandler => {
const config = { ...defaultOptions, ...options.config };
const matchers = getLocationMatchers(options.location); const matchers = getLocationMatchers(options.location);
const vectorLayer = new layer.Vector({}); const vectorLayer = new layer.Vector({});
return { return {
init: () => vectorLayer, init: () => vectorLayer,
@ -55,33 +65,25 @@ export const markersLayer: MapLayerRegistryItem<MarkersConfig> = {
return; // ??? return; // ???
} }
const field = frame.fields.find(field => field.type === FieldType.number); // TODO!!!! // Assert default values
// Return early if metric field is not matched const config = {
if (field === undefined) { ...defaultOptions,
return; ...options?.config,
}; };
const colorDim = getColorDimension(frame, config.color, theme);
// Retrieve the min, max and range of data values const sizeDim = getScaledDimension(frame, config.size);
const calcs = reduceField({ const opacity = options.config?.fillOpacity ?? defaultOptions.fillOpacity;
field: field,
reducers: [
ReducerID.min,
ReducerID.max,
ReducerID.range,
]
});
const features: Feature[] = []; const features: Feature[] = [];
// Map each data value into new points // Map each data value into new points
for (let i = 0; i < frame.length; i++) { for (let i = 0; i < frame.length; i++) {
// Get the circle color for a specific data value depending on color scheme // 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 // 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 // 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 // Create a new Feature for each point returned from dataFrameToPoints
const dot = new Feature({ const dot = new Feature({
@ -111,57 +113,45 @@ export const markersLayer: MapLayerRegistryItem<MarkersConfig> = {
}, },
}; };
}, },
// Circle overlay options // Marker overlay options
registerOptionsUI: (builder) => { registerOptionsUI: (builder) => {
builder builder
// .addFieldNamePicker({ .addCustomEditor({
// path: 'fieldMapping.metricField', id: 'config.color',
// name: 'Metric Field', path: 'config.color',
// defaultValue: defaultOptions.fieldMapping.metricField, name: 'Marker Color',
// settings: { editor: ColorDimensionEditor,
// filter: (f) => f.type === FieldType.number, settings: {},
// noFieldsMessage: 'No numeric fields found', defaultValue: { // Configured values
// }, fixed: 'grey',
// }) },
.addNumberInput({
path: 'config.minSize',
description: 'configures the min circle size',
name: 'Min Size',
defaultValue: defaultOptions.minSize,
}) })
.addNumberInput({ .addCustomEditor({
path: 'config.maxSize', id: 'config.size',
description: 'configures the max circle size', path: 'config.size',
name: 'Max Size', name: 'Marker Size',
defaultValue: defaultOptions.maxSize, editor: ScaleDimensionEditor,
settings: {
min: 1,
max: 100, // possible in the UI
},
defaultValue: { // Configured values
fixed: 5,
min: 1,
max: 20,
},
}) })
.addSliderInput({ .addSliderInput({
path: 'config.opacity', path: 'config.fillOpacity',
description: 'configures the amount of transparency', name: 'Fill opacity',
name: 'Opacity', defaultValue: defaultOptions.fillOpacity,
defaultValue: defaultOptions.opacity, settings: {
settings: { min: 0,
min: 0, max: 1,
max: 1, step: 0.1,
step: 0.1, },
}, });
});
}, },
// fill in the default values // fill in the default values
defaultOptions, 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;
};