mirror of https://github.com/grafana/grafana.git
Geomap: Add Property and values to GeoJSON style rule (#41845)
Co-authored-by: Ryan McKinley <ryantxu@gmail.com>
This commit is contained in:
parent
0c280319af
commit
7a3b52783c
|
|
@ -1,15 +1,14 @@
|
|||
import React, { FC, useCallback } from 'react';
|
||||
import { StandardEditorProps, StandardEditorsRegistryItem } from '@grafana/data';
|
||||
import { ComparisonOperation, FeatureStyleConfig } from '../types';
|
||||
import { FeatureStyleConfig } from '../types';
|
||||
import { Button } from '@grafana/ui';
|
||||
import { DEFAULT_STYLE_RULE } from '../layers/data/geojsonLayer';
|
||||
import { StyleRuleEditor, StyleRuleEditorSettings } from './StyleRuleEditor';
|
||||
|
||||
export const GeomapStyleRulesEditor: FC<StandardEditorProps<FeatureStyleConfig[], any, any>> = (props) => {
|
||||
const { value, onChange, context } = props;
|
||||
|
||||
const OPTIONS = getComparisonOperatorOptions();
|
||||
const { value, onChange, context, item } = props;
|
||||
|
||||
const settings = item.settings;
|
||||
const onAddRule = useCallback(() => {
|
||||
onChange([...value, DEFAULT_STYLE_RULE]);
|
||||
}, [onChange, value]);
|
||||
|
|
@ -32,7 +31,7 @@ export const GeomapStyleRulesEditor: FC<StandardEditorProps<FeatureStyleConfig[]
|
|||
value &&
|
||||
value.map((style, idx: number) => {
|
||||
const itemSettings: StandardEditorsRegistryItem<any, StyleRuleEditorSettings> = {
|
||||
settings: { options: OPTIONS },
|
||||
settings,
|
||||
} as any;
|
||||
|
||||
return (
|
||||
|
|
@ -55,11 +54,3 @@ export const GeomapStyleRulesEditor: FC<StandardEditorProps<FeatureStyleConfig[]
|
|||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const getComparisonOperatorOptions = () => {
|
||||
const options = [];
|
||||
for (const value of Object.values(ComparisonOperation)) {
|
||||
options.push({ value: value, label: value });
|
||||
}
|
||||
return options;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,35 +1,61 @@
|
|||
import React, { ChangeEvent, FC, useCallback } from 'react';
|
||||
import React, { FC, useCallback, useMemo } from 'react';
|
||||
import { GrafanaTheme2, SelectableValue, StandardEditorProps } from '@grafana/data';
|
||||
import { ComparisonOperation, FeatureStyleConfig } from '../types';
|
||||
import { Button, InlineField, InlineFieldRow, Input, Select, useStyles2 } from '@grafana/ui';
|
||||
import { Button, InlineField, InlineFieldRow, Select, useStyles2 } from '@grafana/ui';
|
||||
import { css } from '@emotion/css';
|
||||
import { StyleEditor } from '../layers/data/StyleEditor';
|
||||
import { defaultStyleConfig, StyleConfig } from '../style/types';
|
||||
import { DEFAULT_STYLE_RULE } from '../layers/data/geojsonLayer';
|
||||
import { Observable } from 'rxjs';
|
||||
import { useObservable } from 'react-use';
|
||||
import { getUniqueFeatureValues, LayerContentInfo } from '../utils/getFeatures';
|
||||
import { FeatureLike } from 'ol/Feature';
|
||||
import { getSelectionInfo } from '../utils/selection';
|
||||
import { NumberInput } from 'app/features/dimensions/editors/NumberInput';
|
||||
import { isNumber } from 'lodash';
|
||||
|
||||
export interface StyleRuleEditorSettings {
|
||||
options: SelectableValue[];
|
||||
features: Observable<FeatureLike[]>;
|
||||
layerInfo: Observable<LayerContentInfo>;
|
||||
}
|
||||
|
||||
const comparators = [
|
||||
{ label: '==', value: ComparisonOperation.EQ },
|
||||
{ label: '>', value: ComparisonOperation.GT },
|
||||
{ label: '>=', value: ComparisonOperation.GTE },
|
||||
{ label: '<', value: ComparisonOperation.LT },
|
||||
{ label: '<=', value: ComparisonOperation.LTE },
|
||||
];
|
||||
|
||||
export const StyleRuleEditor: FC<StandardEditorProps<FeatureStyleConfig, any, any, StyleRuleEditorSettings>> = (
|
||||
props
|
||||
) => {
|
||||
const { value, onChange, item, context } = props;
|
||||
const settings: StyleRuleEditorSettings = item.settings;
|
||||
const { features, layerInfo } = settings;
|
||||
|
||||
const propertyOptions = useObservable(layerInfo);
|
||||
const feats = useObservable(features);
|
||||
|
||||
const uniqueSelectables = useMemo(() => {
|
||||
const key = value?.check?.property;
|
||||
if (key && feats && value.check?.operation === ComparisonOperation.EQ) {
|
||||
return getUniqueFeatureValues(feats, key).map((v) => ({ value: v, label: v }));
|
||||
}
|
||||
return [];
|
||||
}, [feats, value]);
|
||||
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
const LABEL_WIDTH = 10;
|
||||
|
||||
const onChangeComparisonProperty = useCallback(
|
||||
(e: ChangeEvent<HTMLInputElement>) => {
|
||||
const onChangeProperty = useCallback(
|
||||
(selection?: SelectableValue) => {
|
||||
onChange({
|
||||
...value,
|
||||
check: {
|
||||
...value.check,
|
||||
property: e.currentTarget.value,
|
||||
operation: value.check?.operation ?? ComparisonOperation.EQ,
|
||||
value: value.check?.value ?? '',
|
||||
...value.check!,
|
||||
property: selection?.value,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
|
@ -41,25 +67,34 @@ export const StyleRuleEditor: FC<StandardEditorProps<FeatureStyleConfig, any, an
|
|||
onChange({
|
||||
...value,
|
||||
check: {
|
||||
...value.check,
|
||||
...value.check!,
|
||||
operation: selection.value ?? ComparisonOperation.EQ,
|
||||
property: value.check?.property ?? '',
|
||||
value: value.check?.value ?? '',
|
||||
},
|
||||
});
|
||||
},
|
||||
[onChange, value]
|
||||
);
|
||||
|
||||
const onChangeComparisonValue = useCallback(
|
||||
(e: ChangeEvent<HTMLInputElement>) => {
|
||||
const onChangeValue = useCallback(
|
||||
(selection?: SelectableValue) => {
|
||||
onChange({
|
||||
...value,
|
||||
check: {
|
||||
...value.check,
|
||||
value: e.currentTarget.value,
|
||||
operation: value.check?.operation ?? ComparisonOperation.EQ,
|
||||
property: value.check?.property ?? '',
|
||||
...value.check!,
|
||||
value: selection?.value,
|
||||
},
|
||||
});
|
||||
},
|
||||
[onChange, value]
|
||||
);
|
||||
|
||||
const onChangeNumericValue = useCallback(
|
||||
(v?: number) => {
|
||||
onChange({
|
||||
...value,
|
||||
check: {
|
||||
...value.check!,
|
||||
value: v!,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
|
@ -78,36 +113,57 @@ export const StyleRuleEditor: FC<StandardEditorProps<FeatureStyleConfig, any, an
|
|||
}, [onChange]);
|
||||
|
||||
const check = value.check ?? DEFAULT_STYLE_RULE.check!;
|
||||
const propv = getSelectionInfo(check.property, propertyOptions?.propertes);
|
||||
const valuev = getSelectionInfo(check.value, uniqueSelectables);
|
||||
|
||||
return (
|
||||
<div className={styles.rule}>
|
||||
<InlineFieldRow className={styles.row}>
|
||||
<InlineField label="Rule" labelWidth={LABEL_WIDTH} grow={true}>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={'Feature property'}
|
||||
value={check.property ?? ''}
|
||||
onChange={onChangeComparisonProperty}
|
||||
aria-label={'Feature property'}
|
||||
/>
|
||||
</InlineField>
|
||||
<InlineField className={styles.inline} grow={true}>
|
||||
<Select
|
||||
menuShouldPortal
|
||||
value={check.operation ?? ComparisonOperation.EQ}
|
||||
options={settings.options}
|
||||
placeholder={'Feature property'}
|
||||
value={propv.current}
|
||||
options={propv.options}
|
||||
onChange={onChangeProperty}
|
||||
aria-label={'Feature property'}
|
||||
isClearable
|
||||
allowCustomValue
|
||||
/>
|
||||
</InlineField>
|
||||
<InlineField className={styles.inline}>
|
||||
<Select
|
||||
menuShouldPortal
|
||||
value={comparators.find((v) => v.value === check.operation)}
|
||||
options={comparators}
|
||||
onChange={onChangeComparison}
|
||||
aria-label={'Comparison operator'}
|
||||
width={6}
|
||||
/>
|
||||
</InlineField>
|
||||
<InlineField className={styles.inline} grow={true}>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={'value'}
|
||||
value={`${check.value}` ?? ''}
|
||||
onChange={onChangeComparisonValue}
|
||||
aria-label={'Comparison value'}
|
||||
/>
|
||||
<>
|
||||
{check.operation === ComparisonOperation.EQ && (
|
||||
<Select
|
||||
menuShouldPortal
|
||||
placeholder={'value'}
|
||||
value={valuev.current}
|
||||
options={valuev.options}
|
||||
onChange={onChangeValue}
|
||||
aria-label={'Comparison value'}
|
||||
isClearable
|
||||
allowCustomValue
|
||||
/>
|
||||
)}
|
||||
{check.operation !== ComparisonOperation.EQ && (
|
||||
<NumberInput
|
||||
key={`${check.property}/${check.operation}`}
|
||||
value={isNumber(check.value) ? check.value : 0}
|
||||
placeholder="numeric value"
|
||||
onChange={onChangeNumericValue}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
</InlineField>
|
||||
<Button
|
||||
size="md"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,10 @@
|
|||
import { MapLayerRegistryItem, MapLayerOptions, PanelData, GrafanaTheme2, PluginState } from '@grafana/data';
|
||||
import {
|
||||
MapLayerRegistryItem,
|
||||
MapLayerOptions,
|
||||
PanelData,
|
||||
GrafanaTheme2,
|
||||
PluginState,
|
||||
} from '@grafana/data';
|
||||
import Map from 'ol/Map';
|
||||
import VectorLayer from 'ol/layer/Vector';
|
||||
import VectorSource from 'ol/source/Vector';
|
||||
|
|
@ -13,6 +19,10 @@ import { defaultStyleConfig, StyleConfig } from '../../style/types';
|
|||
import { getStyleConfigState } from '../../style/utils';
|
||||
import { polyStyle } from '../../style/markers';
|
||||
import { StyleEditor } from './StyleEditor';
|
||||
import { ReplaySubject } from 'rxjs';
|
||||
import { map as rxjsmap, first } from 'rxjs/operators';
|
||||
import { getLayerPropertyInfo } from '../../utils/getFeatures';
|
||||
|
||||
export interface GeoJSONMapperConfig {
|
||||
// URL for a geojson file
|
||||
src?: string;
|
||||
|
|
@ -64,16 +74,13 @@ export const geojsonLayer: MapLayerRegistryItem<GeoJSONMapperConfig> = {
|
|||
format: new GeoJSON(),
|
||||
});
|
||||
|
||||
const features = new ReplaySubject<FeatureLike[]>();
|
||||
|
||||
const key = source.on('change', () => {
|
||||
//one geojson loads
|
||||
if (source.getState() == 'ready') {
|
||||
unByKey(key);
|
||||
// var olFeatures = source.getFeatures(); // olFeatures.length === 1
|
||||
// window.setTimeout(function () {
|
||||
// var olFeatures = source.getFeatures(); // olFeatures.length > 1
|
||||
// // Only after using setTimeout can I search the feature list... :(
|
||||
// }, 100)
|
||||
|
||||
console.log('SOURCE READY!!!', source.getFeatures().length);
|
||||
features.next(source.getFeatures());
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -117,17 +124,13 @@ export const geojsonLayer: MapLayerRegistryItem<GeoJSONMapperConfig> = {
|
|||
init: () => vectorLayer,
|
||||
update: (data: PanelData) => {
|
||||
console.log('todo... find values matching the ID and update');
|
||||
|
||||
// // Update each feature
|
||||
// source.getFeatures().forEach((f) => {
|
||||
// console.log('Find: ', f.getId(), f.getProperties());
|
||||
// });
|
||||
},
|
||||
|
||||
// Geojson source url
|
||||
registerOptionsUI: (builder) => {
|
||||
const features = source.getFeatures();
|
||||
console.log('FEATURES', source.getState(), features.length, options);
|
||||
// get properties for first feature to use as ui options
|
||||
const layerInfo = features.pipe(
|
||||
first(),
|
||||
rxjsmap((v) => getLayerPropertyInfo(v)),
|
||||
);
|
||||
|
||||
builder
|
||||
.addSelect({
|
||||
|
|
@ -149,7 +152,10 @@ export const geojsonLayer: MapLayerRegistryItem<GeoJSONMapperConfig> = {
|
|||
name: 'Style Rules',
|
||||
description: 'Apply styles based on feature properties',
|
||||
editor: GeomapStyleRulesEditor,
|
||||
settings: {},
|
||||
settings: {
|
||||
features: features,
|
||||
layerInfo: layerInfo,
|
||||
},
|
||||
defaultValue: [],
|
||||
})
|
||||
.addCustomEditor({
|
||||
|
|
|
|||
|
|
@ -8,17 +8,18 @@ import { FeatureRuleConfig, ComparisonOperation } from '../types';
|
|||
* @returns boolean
|
||||
*/
|
||||
export const checkFeatureMatchesStyleRule = (rule: FeatureRuleConfig, feature: FeatureLike) => {
|
||||
const val = feature.get(rule.property);
|
||||
switch (rule.operation) {
|
||||
case ComparisonOperation.EQ:
|
||||
return feature.get(rule.property) === rule.value;
|
||||
return val === rule.value;
|
||||
case ComparisonOperation.GT:
|
||||
return feature.get(rule.property) > rule.value;
|
||||
return val > rule.value;
|
||||
case ComparisonOperation.GTE:
|
||||
return feature.get(rule.property) >= rule.value;
|
||||
return val >= rule.value;
|
||||
case ComparisonOperation.LT:
|
||||
return feature.get(rule.property) < rule.value;
|
||||
return val < rule.value;
|
||||
case ComparisonOperation.LTE:
|
||||
return feature.get(rule.property) <= rule.value;
|
||||
return val <= rule.value;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,43 @@
|
|||
import { Feature } from 'ol';
|
||||
import { Point } from 'ol/geom';
|
||||
import { GeometryTypeId } from '../style/types';
|
||||
import { getLayerPropertyInfo, getUniqueFeatureValues } from './getFeatures';
|
||||
|
||||
describe('get features utils', () => {
|
||||
const features = [
|
||||
new Feature({ a: 1, b: 30, hello: 'world', geometry: new Point([0, 0]) }),
|
||||
new Feature({ a: 2, b: 20, hello: 'world', geometry: new Point([0, 0]) }),
|
||||
new Feature({ a: 2, b: 10, c: 30, geometry: new Point([0, 0]) }),
|
||||
];
|
||||
|
||||
it('reads the distinct field names', () => {
|
||||
const info = getLayerPropertyInfo(features);
|
||||
expect(info.geometryType).toBe(GeometryTypeId.Point);
|
||||
expect(info.propertes.map((v) => v.value)).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
"a",
|
||||
"b",
|
||||
"hello",
|
||||
"c",
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it('can collect distinct values', () => {
|
||||
const uniqueA = getUniqueFeatureValues(features, 'a');
|
||||
const uniqueB = getUniqueFeatureValues(features, 'b');
|
||||
expect(uniqueA).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
"1",
|
||||
"2",
|
||||
]
|
||||
`);
|
||||
expect(uniqueB).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
"10",
|
||||
"20",
|
||||
"30",
|
||||
]
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
import { DataFrame } from '@grafana/data';
|
||||
import { DataFrame, SelectableValue } from '@grafana/data';
|
||||
import { Feature } from 'ol';
|
||||
import { FeatureLike } from 'ol/Feature';
|
||||
import { Point } from 'ol/geom';
|
||||
import { StyleConfigState } from '../style/types';
|
||||
import { GeometryTypeId, StyleConfigState } from '../style/types';
|
||||
import { LocationInfo } from './location';
|
||||
|
||||
export const getFeatures = (
|
||||
|
|
@ -41,3 +42,60 @@ export const getFeatures = (
|
|||
|
||||
return features;
|
||||
};
|
||||
|
||||
export interface LayerContentInfo {
|
||||
geometryType: GeometryTypeId;
|
||||
propertes: Array<SelectableValue<string>>;
|
||||
}
|
||||
|
||||
export function getLayerPropertyInfo(features: FeatureLike[]): LayerContentInfo {
|
||||
const types = new Set<string>();
|
||||
const props = new Set<string>();
|
||||
features.some((feature, idx) => {
|
||||
for (const key of Object.keys(feature.getProperties())) {
|
||||
if (key === 'geometry') {
|
||||
continue;
|
||||
}
|
||||
props.add(key);
|
||||
const g = feature.getGeometry();
|
||||
if (g) {
|
||||
types.add(g.getType());
|
||||
}
|
||||
}
|
||||
return idx > 10; // first 10 items
|
||||
});
|
||||
|
||||
let geometryType = GeometryTypeId.Any;
|
||||
if (types.size === 1) {
|
||||
switch (types.values().next().value) {
|
||||
case 'Point':
|
||||
case 'MultiPoint':
|
||||
geometryType = GeometryTypeId.Point;
|
||||
break;
|
||||
case 'Line':
|
||||
case 'MultiLine':
|
||||
geometryType = GeometryTypeId.Line;
|
||||
break;
|
||||
case 'Polygon':
|
||||
geometryType = GeometryTypeId.Polygon;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
geometryType,
|
||||
propertes: Array.from(props.keys()).map((v) => ({ label: v, value: v })),
|
||||
};
|
||||
}
|
||||
|
||||
export function getUniqueFeatureValues(features: FeatureLike[], key: string): string[] {
|
||||
const unique = new Set<string>();
|
||||
for (const feature of features) {
|
||||
const v = feature.get(key);
|
||||
if (v != null) {
|
||||
unique.add(`${v}`); // always string
|
||||
}
|
||||
}
|
||||
const buffer = Array.from(unique);
|
||||
buffer.sort();
|
||||
return buffer;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
import { SelectableValue } from '@grafana/data';
|
||||
|
||||
export interface SelectionInfo<T = any> {
|
||||
options: Array<SelectableValue<T>>;
|
||||
current?: SelectableValue<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The select component is really annoying -- if the current value is not in the list of options
|
||||
* it won't show up. This is a wrapper to make that happen.
|
||||
*/
|
||||
export function getSelectionInfo<T>(v?: T, options?: Array<SelectableValue<T>>): SelectionInfo<T> {
|
||||
if (v && !options) {
|
||||
const current = { label: `${v}`, value: v };
|
||||
return { options: [current], current };
|
||||
}
|
||||
if (!options) {
|
||||
options = [];
|
||||
}
|
||||
let current = options.find((item) => item.value === v);
|
||||
|
||||
if (v && !current) {
|
||||
current = {
|
||||
label: `${v} (not found)`,
|
||||
value: v,
|
||||
};
|
||||
options.push(current);
|
||||
}
|
||||
return {
|
||||
options,
|
||||
current,
|
||||
};
|
||||
}
|
||||
Loading…
Reference in New Issue