diff --git a/packages/grafana-data/src/field/scale.ts b/packages/grafana-data/src/field/scale.ts index c30fda912b5..51acd3af63f 100644 --- a/packages/grafana-data/src/field/scale.ts +++ b/packages/grafana-data/src/field/scale.ts @@ -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 }; } diff --git a/packages/grafana-ui/src/components/MatchersUI/utils.ts b/packages/grafana-ui/src/components/MatchersUI/utils.ts index 69537a3add3..e49f0b76617 100644 --- a/packages/grafana-ui/src/components/MatchersUI/utils.ts +++ b/packages/grafana-ui/src/components/MatchersUI/utils.ts @@ -66,11 +66,15 @@ export function useFieldDisplayNames(data: DataFrame[], filter?: (field: Field) */ export function useSelectOptions( displayNames: FrameFieldsDisplayNames, - currentName?: string + currentName?: string, + firstItem?: SelectableValue ): Array> { return useMemo(() => { let found = false; const options: Array> = []; + if (firstItem) { + options.push(firstItem); + } for (const name of displayNames.display) { if (!found && name === currentName) { found = true; diff --git a/public/app/plugins/panel/geomap/dims/color.ts b/public/app/plugins/panel/geomap/dims/color.ts new file mode 100644 index 00000000000..8ec6a0feb1e --- /dev/null +++ b/public/app/plugins/panel/geomap/dims/color.ts @@ -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 { + 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, + }; +} diff --git a/public/app/plugins/panel/geomap/dims/editors/ColorDimensionEditor.tsx b/public/app/plugins/panel/geomap/dims/editors/ColorDimensionEditor.tsx new file mode 100644 index 00000000000..0ea9fc5fa43 --- /dev/null +++ b/public/app/plugins/panel/geomap/dims/editors/ColorDimensionEditor.tsx @@ -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 = { + label: 'Fixed color', + value: '_____fixed_____', +}; + +export const ColorDimensionEditor: FC> = (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) => { + 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 ( + <> +
+ +
+
+ {isFixed && ( + + + + + + )} + {!isFixed && !minMaxStep.hideRange && ( + <> + + + + + + + + + + + + )} +
+ + ); +}; + +const getStyles = (theme: GrafanaTheme2) => ({ + range: css` + padding-top: 8px; + `, +}); diff --git a/public/app/plugins/panel/geomap/dims/scale.test.ts b/public/app/plugins/panel/geomap/dims/scale.test.ts new file mode 100644 index 00000000000..dece6bab39c --- /dev/null +++ b/public/app/plugins/panel/geomap/dims/scale.test.ts @@ -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 { + const out = validateScaleConfig( + { + max: -3, + min: 7, + fixed: 100, + }, + { + min: 5, + max: 10, + } + ); + expect(out).toMatchInlineSnapshot(` + Object { + "fixed": 7, + "max": 7, + "min": 5, + } + `); + }); +}); diff --git a/public/app/plugins/panel/geomap/dims/scale.ts b/public/app/plugins/panel/geomap/dims/scale.ts new file mode 100644 index 00000000000..9b2357adec1 --- /dev/null +++ b/public/app/plugins/panel/geomap/dims/scale.ts @@ -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 { + 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; +} diff --git a/public/app/plugins/panel/geomap/dims/types.ts b/public/app/plugins/panel/geomap/dims/types.ts new file mode 100644 index 00000000000..a5c3c74999e --- /dev/null +++ b/public/app/plugins/panel/geomap/dims/types.ts @@ -0,0 +1,45 @@ +import { Field } from '@grafana/data'; + +export interface BaseDimensionConfig { + fixed: T; + field?: string; +} + +export interface DimensionSupplier { + /** + * 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 { + 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 {} diff --git a/public/app/plugins/panel/geomap/dims/utils.ts b/public/app/plugins/panel/geomap/dims/utils.ts new file mode 100644 index 00000000000..7ca95d8f802 --- /dev/null +++ b/public/app/plugins/panel/geomap/dims/utils.ts @@ -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; +} diff --git a/public/app/plugins/panel/geomap/layers/data/heatMap.tsx b/public/app/plugins/panel/geomap/layers/data/heatMap.tsx index 9f8df637732..b6804bdbbf2 100644 --- a/public/app/plugins/panel/geomap/layers/data/heatMap.tsx +++ b/public/app/plugins/panel/geomap/layers/data/heatMap.tsx @@ -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 = { 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 - 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']); + // 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 + 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 = { // 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; -}; diff --git a/public/app/plugins/panel/geomap/layers/data/markersLayer.tsx b/public/app/plugins/panel/geomap/layers/data/markersLayer.tsx index 7089fdd3bd3..1ef93c7ac5a 100644 --- a/public/app/plugins/panel/geomap/layers/data/markersLayer.tsx +++ b/public/app/plugins/panel/geomap/layers/data/markersLayer.tsx @@ -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 = { * @param options */ create: (map: Map, options: MapLayerOptions, 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 = { 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,57 +113,45 @@ export const markersLayer: MapLayerRegistryItem = { }, }; }, - // 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, - settings: { - min: 0, - max: 1, - step: 0.1, - }, - }); + path: 'config.fillOpacity', + name: 'Fill opacity', + defaultValue: defaultOptions.fillOpacity, + settings: { + min: 0, + max: 1, + step: 0.1, + }, + }); }, // 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; -};