Geomap: Add Property and values to GeoJSON style rule (#41845)

Co-authored-by: Ryan McKinley <ryantxu@gmail.com>
This commit is contained in:
nikki-kiga 2021-11-18 00:29:40 -08:00 committed by GitHub
parent 0c280319af
commit 7a3b52783c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 262 additions and 74 deletions

View File

@ -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;
};

View File

@ -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"

View File

@ -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({

View File

@ -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;
}

View File

@ -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",
]
`);
});
});

View File

@ -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;
}

View File

@ -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,
};
}