Geomap: Add rotation option for markers layer (#41992)

* add rotation option for markers layer

* add ScalerDimensionEditor
This commit is contained in:
nikki-kiga 2021-11-23 08:23:43 -08:00 committed by GitHub
parent 0921037f32
commit 92e1883845
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 396 additions and 32 deletions

View File

@ -68,7 +68,8 @@ export function useFieldDisplayNames(data: DataFrame[], filter?: (field: Field)
export function useSelectOptions(
displayNames: FrameFieldsDisplayNames,
currentName?: string,
firstItem?: SelectableValue<string>
firstItem?: SelectableValue<string>,
fieldType?: string
): Array<SelectableValue<string>> {
return useMemo(() => {
let found = false;
@ -81,11 +82,13 @@ export function useSelectOptions(
found = true;
}
const field = displayNames.fields.get(name);
options.push({
value: name,
label: name,
icon: field ? getFieldTypeIcon(field) : undefined,
});
if (!fieldType || fieldType === field?.type) {
options.push({
value: name,
label: name,
icon: field ? getFieldTypeIcon(field) : undefined,
});
}
}
for (const name of displayNames.raw) {
if (!displayNames.display.has(name)) {
@ -106,5 +109,5 @@ export function useSelectOptions(
});
}
return options;
}, [displayNames, currentName, firstItem]);
}, [displayNames, currentName, firstItem, fieldType]);
}

View File

@ -0,0 +1,110 @@
import React, { FC, useCallback } from 'react';
import { FieldType, GrafanaTheme2, SelectableValue, StandardEditorProps } from '@grafana/data';
import { ScalarDimensionConfig, ScalarDimensionMode, ScalarDimensionOptions } from '../types';
import { InlineField, InlineFieldRow, RadioButtonGroup, Select, useStyles2 } from '@grafana/ui';
import { useFieldDisplayNames, useSelectOptions } from '@grafana/ui/src/components/MatchersUI/utils';
import { NumberInput } from './NumberInput';
import { css } from '@emotion/css';
const fixedValueOption: SelectableValue<string> = {
label: 'Fixed value',
value: '_____fixed_____',
};
const scalarOptions = [
{ label: 'Mod', value: ScalarDimensionMode.Mod, description: 'Use field values, mod from max' },
{ label: 'Clamped', value: ScalarDimensionMode.Clamped, description: 'Use field values, clamped to max and min' },
];
export const ScalarDimensionEditor: FC<StandardEditorProps<ScalarDimensionConfig, ScalarDimensionOptions, any>> = (
props
) => {
const { value, context, onChange } = props;
const DEFAULT_VALUE = 0;
const fieldName = value?.field;
const isFixed = Boolean(!fieldName);
const names = useFieldDisplayNames(context.data);
const selectOptions = useSelectOptions(names, fieldName, fixedValueOption, FieldType.number);
const styles = useStyles2(getStyles);
const onSelectChange = useCallback(
(selection: SelectableValue<string>) => {
const field = selection.value;
if (field && field !== fixedValueOption.value) {
onChange({
...value,
field,
});
} else {
const fixed = value.fixed ?? DEFAULT_VALUE;
onChange({
...value,
field: undefined,
fixed,
});
}
},
[onChange, value]
);
const onModeChange = useCallback(
(mode) => {
onChange({
...value,
mode,
});
},
[onChange, value]
);
const onValueChange = useCallback(
(v: number | undefined) => {
onChange({
...value,
field: undefined,
fixed: v ?? DEFAULT_VALUE,
});
},
[onChange, value]
);
const val = value ?? {};
const mode = value?.mode ?? ScalarDimensionMode.Mod;
const selectedOption = isFixed ? fixedValueOption : selectOptions.find((v) => v.value === fieldName);
return (
<>
<div>
<InlineFieldRow>
<InlineField label="Limit" labelWidth={8} grow={true}>
<RadioButtonGroup value={mode} options={scalarOptions} onChange={onModeChange} fullWidth />
</InlineField>
</InlineFieldRow>
<Select
menuShouldPortal
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={val?.fixed ?? DEFAULT_VALUE} onChange={onValueChange} />
</InlineField>
</InlineFieldRow>
)}
</div>
</>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
range: css`
padding-top: 8px;
`,
});

View File

@ -2,4 +2,5 @@ export * from './ColorDimensionEditor';
export * from './IconSelector';
export * from './ResourceDimensionEditor';
export * from './ScaleDimensionEditor';
export * from './ScalarDimensionEditor';
export * from './TextDimensionEditor';

View File

@ -6,3 +6,4 @@ export * from './text';
export * from './utils';
export * from './resource';
export * from './context';
export * from './scalar';

View File

@ -0,0 +1,94 @@
import { ArrayVector, DataFrame, FieldType } from '@grafana/data';
import { ScalarDimensionMode } from '.';
import { getScalarDimension } from './scalar';
describe('scalar dimensions', () => {
it('handles string field', () => {
const values = ['-720', '10', '540', '90', '-210'];
const frame: DataFrame = {
name: 'a',
length: values.length,
fields: [
{
name: 'test',
type: FieldType.number,
values: new ArrayVector(values),
config: {
min: -720,
max: 540,
},
},
],
};
const supplier = getScalarDimension(frame, {
min: -360,
max: 360,
field: 'test',
fixed: 0,
mode: ScalarDimensionMode.Clamped,
});
const clamped = frame.fields[0].values.toArray().map((k, i) => supplier.get(i));
expect(clamped).toEqual([0, 0, 0, 0, 0]);
});
it('clamps out of range values', () => {
const values = [-720, 10, 540, 90, -210];
const frame: DataFrame = {
name: 'a',
length: values.length,
fields: [
{
name: 'test',
type: FieldType.number,
values: new ArrayVector(values),
config: {
min: -720,
max: 540,
},
},
],
};
const supplier = getScalarDimension(frame, {
min: -360,
max: 360,
field: 'test',
fixed: 0,
mode: ScalarDimensionMode.Clamped,
});
const clamped = frame.fields[0].values.toArray().map((k, i) => supplier.get(i));
expect(clamped).toEqual([-360, 10, 360, 90, -210]);
});
it('keeps remainder after divisible by max', () => {
const values = [-721, 10, 540, 390, -210];
const frame: DataFrame = {
name: 'a',
length: values.length,
fields: [
{
name: 'test',
type: FieldType.number,
values: new ArrayVector(values),
config: {
min: -721,
max: 540,
},
},
],
};
const supplier = getScalarDimension(frame, {
min: -360,
max: 360,
field: 'test',
fixed: 0,
mode: ScalarDimensionMode.Mod,
});
const remainder = frame.fields[0].values.toArray().map((k, i) => supplier.get(i));
expect(remainder).toEqual([-1, 10, 180, 30, -210]);
});
});

View File

@ -0,0 +1,59 @@
import { DataFrame, Field } from '@grafana/data';
import { DimensionSupplier, ScalarDimensionConfig, ScalarDimensionMode } from './types';
import { findField, getLastNotNullFieldValue } from './utils';
//---------------------------------------------------------
// Scalar dimension
//---------------------------------------------------------
export function getScalarDimension(
frame: DataFrame | undefined,
config: ScalarDimensionConfig
): DimensionSupplier<number> {
return getScalarDimensionForField(findField(frame, config?.field), config);
}
export function getScalarDimensionForField(
field: Field | undefined,
cfg: ScalarDimensionConfig
): DimensionSupplier<number> {
if (!field) {
const v = cfg.fixed ?? 0;
return {
isAssumed: Boolean(cfg.field?.length) || !cfg.fixed,
fixed: v,
value: () => v,
get: () => v,
};
}
//mod mode as default
let validated = (value: number) => {
return value % cfg.max;
};
//capped mode
if (cfg.mode === ScalarDimensionMode.Clamped) {
validated = (value: number) => {
if (value < cfg.min) {
return cfg.min;
}
if (value > cfg.max) {
return cfg.max;
}
return value;
};
}
const get = (i: number) => {
const v = field.values.get(i);
if (v === null || typeof v !== 'number') {
return 0;
}
return validated(v);
};
return {
field,
get,
value: () => getLastNotNullFieldValue(field),
};
}

View File

@ -51,6 +51,21 @@ export interface ScaleDimensionOptions {
hideRange?: boolean; // false
}
export enum ScalarDimensionMode {
Mod = 'mod',
Clamped = 'clamped',
}
export interface ScalarDimensionConfig extends BaseDimensionConfig<number> {
mode: ScalarDimensionMode;
min: number;
max: number;
}
export interface ScalarDimensionOptions {
min: number;
max: number;
}
export interface TextDimensionOptions {
// anything?
}

View File

@ -18,6 +18,7 @@ import {
ColorDimensionEditor,
ResourceDimensionEditor,
ScaleDimensionEditor,
ScalarDimensionEditor,
TextDimensionEditor,
} from 'app/features/dimensions/editors';
import {
@ -27,14 +28,16 @@ import {
ResourceFolderName,
TextDimensionConfig,
defaultTextConfig,
ScalarDimensionConfig,
} from 'app/features/dimensions/types';
import { defaultStyleConfig, StyleConfig, TextAlignment, TextBaseline } from '../../style/types';
import { defaultStyleConfig, GeometryTypeId, StyleConfig, TextAlignment, TextBaseline } from '../../style/types';
import { styleUsesText } from '../../style/utils';
import { LayerContentInfo } from '../../utils/getFeatures';
export interface StyleEditorOptions {
layerInfo?: Observable<LayerContentInfo>;
simpleFixedValues?: boolean;
displayRotation?: boolean;
}
export const StyleEditor: FC<StandardEditorProps<StyleConfig, StyleEditorOptions, any>> = ({
@ -43,6 +46,8 @@ export const StyleEditor: FC<StandardEditorProps<StyleConfig, StyleEditorOptions
onChange,
item,
}) => {
const settings = item.settings;
const onSizeChange = (sizeValue: ScaleDimensionConfig | undefined) => {
onChange({ ...value, size: sizeValue });
};
@ -59,6 +64,10 @@ export const StyleEditor: FC<StandardEditorProps<StyleConfig, StyleEditorOptions
onChange({ ...value, opacity: opacityValue });
};
const onRotationChange = (rotationValue: ScalarDimensionConfig | undefined) => {
onChange({ ...value, rotation: rotationValue });
};
const onTextChange = (textValue: TextDimensionConfig | undefined) => {
onChange({ ...value, text: textValue });
};
@ -84,38 +93,54 @@ export const StyleEditor: FC<StandardEditorProps<StyleConfig, StyleEditorOptions
};
let featuresHavePoints = false;
if (item.settings?.layerInfo) {
const propertyOptions = useObservable(item.settings?.layerInfo);
featuresHavePoints = propertyOptions?.geometryType === 'point';
if (settings?.layerInfo) {
const propertyOptions = useObservable(settings?.layerInfo);
featuresHavePoints = propertyOptions?.geometryType === GeometryTypeId.Point;
}
const hasTextLabel = styleUsesText(value);
// Simple fixed value display
if (item.settings?.simpleFixedValues) {
if (settings?.simpleFixedValues) {
return (
<>
{featuresHavePoints && (
<InlineFieldRow>
<InlineField label={'Symbol'}>
<ResourceDimensionEditor
value={value.symbol ?? defaultStyleConfig.symbol}
<>
<InlineFieldRow>
<InlineField label={'Symbol'}>
<ResourceDimensionEditor
value={value.symbol ?? defaultStyleConfig.symbol}
context={context}
onChange={onSymbolChange}
item={
{
settings: {
resourceType: 'icon',
folderName: ResourceFolderName.Marker,
placeholderText: hasTextLabel ? 'Select a symbol' : 'Select a symbol or add a text label',
placeholderValue: defaultStyleConfig.symbol.fixed,
showSourceRadio: false,
},
} as any
}
/>
</InlineField>
</InlineFieldRow>
<Field label={'Rotation angle'}>
<ScalarDimensionEditor
value={value.rotation ?? defaultStyleConfig.rotation}
context={context}
onChange={onSymbolChange}
onChange={onRotationChange}
item={
{
settings: {
resourceType: 'icon',
folderName: ResourceFolderName.Marker,
placeholderText: hasTextLabel ? 'Select a symbol' : 'Select a symbol or add a text label',
placeholderValue: defaultStyleConfig.symbol.fixed,
showSourceRadio: false,
min: defaultStyleConfig.rotation.min,
max: defaultStyleConfig.rotation.max,
},
} as any
}
/>
</InlineField>
</InlineFieldRow>
</Field>
</>
)}
<InlineFieldRow>
<InlineField label="Color" labelWidth={10}>
@ -210,6 +235,23 @@ export const StyleEditor: FC<StandardEditorProps<StyleConfig, StyleEditorOptions
}
/>
</Field>
{settings?.displayRotation && (
<Field label={'Rotation angle'}>
<ScalarDimensionEditor
value={value.rotation ?? defaultStyleConfig.rotation}
context={context}
onChange={onRotationChange}
item={
{
settings: {
min: defaultStyleConfig.rotation.min,
max: defaultStyleConfig.rotation.max,
},
} as any
}
/>
</Field>
)}
<Field label={'Text label'}>
<TextDimensionEditor
value={value.text ?? defaultTextConfig}

View File

@ -12,7 +12,7 @@ import { Point } from 'ol/geom';
import * as layer from 'ol/layer';
import * as source from 'ol/source';
import { dataFrameToPoints, getLocationMatchers } from '../../utils/location';
import { getScaledDimension, getColorDimension, getTextDimension } from 'app/features/dimensions';
import { getScaledDimension, getColorDimension, getTextDimension, getScalarDimension } from 'app/features/dimensions';
import { ObservablePropsWrapper } from '../../components/ObservablePropsWrapper';
import { MarkersLegend, MarkersLegendProps } from './MarkersLegend';
import { ReplaySubject } from 'rxjs';
@ -107,6 +107,9 @@ export const markersLayer: MapLayerRegistryItem<MarkersConfig> = {
if (style.fields.text) {
dims.text = getTextDimension(frame, style.config.text!);
}
if (style.fields.rotation) {
dims.rotation = getScalarDimension(frame, style.config.rotation ?? defaultStyleConfig.rotation);
}
style.dims = dims;
}
@ -139,7 +142,9 @@ export const markersLayer: MapLayerRegistryItem<MarkersConfig> = {
path: 'config.style',
name: 'Styles',
editor: StyleEditor,
settings: {},
settings: {
displayRotation: true,
},
defaultValue: defaultOptions.style,
})
.addBooleanSwitch({

View File

@ -150,6 +150,12 @@ describe('geomap migrations', () => {
"fixed": "dark-green",
},
"opacity": 0.4,
"rotation": Object {
"fixed": 0,
"max": 360,
"min": -360,
"mode": "mod",
},
"size": Object {
"field": "Count",
"fixed": 5,

View File

@ -121,13 +121,14 @@ const makers: SymbolMaker[] = [
aliasIds: [MarkerShapePath.square],
make: (cfg: StyleConfigValues) => {
const radius = cfg.size ?? DEFAULT_SIZE;
const rotation = cfg.rotation ?? 0;
return new Style({
image: new RegularShape({
stroke: new Stroke({ color: cfg.color, width: cfg.lineWidth ?? 1 }),
fill: getFillColor(cfg),
points: 4,
radius,
angle: Math.PI / 4,
rotation: (rotation * Math.PI) / 180 + Math.PI / 4,
}),
text: textLabel(cfg),
});
@ -139,13 +140,14 @@ const makers: SymbolMaker[] = [
aliasIds: [MarkerShapePath.triangle],
make: (cfg: StyleConfigValues) => {
const radius = cfg.size ?? DEFAULT_SIZE;
const rotation = cfg.rotation ?? 0;
return new Style({
image: new RegularShape({
stroke: new Stroke({ color: cfg.color, width: cfg.lineWidth ?? 1 }),
fill: getFillColor(cfg),
points: 3,
radius,
rotation: Math.PI / 4,
rotation: (rotation * Math.PI) / 180,
angle: 0,
}),
text: textLabel(cfg),
@ -158,6 +160,7 @@ const makers: SymbolMaker[] = [
aliasIds: [MarkerShapePath.star],
make: (cfg: StyleConfigValues) => {
const radius = cfg.size ?? DEFAULT_SIZE;
const rotation = cfg.rotation ?? 0;
return new Style({
image: new RegularShape({
stroke: new Stroke({ color: cfg.color, width: cfg.lineWidth ?? 1 }),
@ -166,6 +169,7 @@ const makers: SymbolMaker[] = [
radius,
radius2: radius * 0.4,
angle: 0,
rotation: (rotation * Math.PI) / 180,
}),
text: textLabel(cfg),
});
@ -177,6 +181,7 @@ const makers: SymbolMaker[] = [
aliasIds: [MarkerShapePath.cross],
make: (cfg: StyleConfigValues) => {
const radius = cfg.size ?? DEFAULT_SIZE;
const rotation = cfg.rotation ?? 0;
return new Style({
image: new RegularShape({
stroke: new Stroke({ color: cfg.color, width: cfg.lineWidth ?? 1 }),
@ -184,6 +189,7 @@ const makers: SymbolMaker[] = [
radius,
radius2: 0,
angle: 0,
rotation: (rotation * Math.PI) / 180,
}),
text: textLabel(cfg),
});
@ -195,13 +201,14 @@ const makers: SymbolMaker[] = [
aliasIds: [MarkerShapePath.x],
make: (cfg: StyleConfigValues) => {
const radius = cfg.size ?? DEFAULT_SIZE;
const rotation = cfg.rotation ?? 0;
return new Style({
image: new RegularShape({
stroke: new Stroke({ color: cfg.color, width: cfg.lineWidth ?? 1 }),
points: 4,
radius,
radius2: 0,
angle: Math.PI / 4,
rotation: (rotation * Math.PI) / 180 + Math.PI / 4,
}),
text: textLabel(cfg),
});
@ -265,6 +272,7 @@ export async function getMarkerMaker(symbol?: string, hasTextLabel?: boolean): P
make: src
? (cfg: StyleConfigValues) => {
const radius = cfg.size ?? DEFAULT_SIZE;
const rotation = cfg.rotation ?? 0;
return [
new Style({
image: new Icon({
@ -272,6 +280,7 @@ export async function getMarkerMaker(symbol?: string, hasTextLabel?: boolean): P
color: cfg.color,
opacity: cfg.opacity ?? 1,
scale: (DEFAULT_SIZE + radius) / 100,
rotation: (rotation * Math.PI) / 180,
}),
text: !cfg?.text ? undefined : textLabel(cfg),
}),
@ -281,7 +290,7 @@ export async function getMarkerMaker(symbol?: string, hasTextLabel?: boolean): P
fill: new Fill({ color: 'rgba(0,0,0,0)' }),
points: 4,
radius: cfg.size,
angle: Math.PI / 4,
rotation: (rotation * Math.PI) / 180 + Math.PI / 4,
}),
}),
];

View File

@ -4,6 +4,8 @@ import {
ResourceDimensionConfig,
ResourceDimensionMode,
ScaleDimensionConfig,
ScalarDimensionConfig,
ScalarDimensionMode,
TextDimensionConfig,
} from 'app/features/dimensions';
import { Style } from 'ol/style';
@ -30,6 +32,9 @@ export interface StyleConfig {
// Can show markers and text together!
text?: TextDimensionConfig;
textConfig?: TextStyleConfig;
// Allow for rotation of markers
rotation?: ScalarDimensionConfig;
}
export const DEFAULT_SIZE = 5;
@ -66,6 +71,12 @@ export const defaultStyleConfig = Object.freeze({
offsetX: 0,
offsetY: 0,
},
rotation: {
fixed: 0,
mode: ScalarDimensionMode.Mod,
min: -360,
max: 360,
},
});
/**
@ -99,12 +110,14 @@ export interface StyleConfigFields {
color?: string;
size?: string;
text?: string;
rotation?: string;
}
export interface StyleDimensions {
color?: DimensionSupplier<string>;
size?: DimensionSupplier<number>;
text?: DimensionSupplier<string>;
rotation?: DimensionSupplier<number>;
}
export interface StyleConfigState {

View File

@ -35,7 +35,7 @@ export async function getStyleConfigState(cfg?: StyleConfig): Promise<StyleConfi
opacity: cfg.opacity ?? defaultStyleConfig.opacity,
lineWidth: cfg.lineWidth ?? 1,
size: cfg.size?.fixed ?? defaultStyleConfig.size.fixed,
rotation: 0, // dynamic will follow path
rotation: cfg.rotation?.fixed ?? defaultStyleConfig.rotation.fixed, // add ability follow path later
},
maker,
};
@ -46,6 +46,9 @@ export async function getStyleConfigState(cfg?: StyleConfig): Promise<StyleConfi
if (cfg.size?.field?.length) {
fields.size = cfg.size.field;
}
if (cfg.rotation?.field?.length) {
fields.rotation = cfg.rotation.field;
}
if (hasText) {
state.base.text = cfg.text?.fixed;

View File

@ -31,6 +31,9 @@ export const getFeatures = (
if (dims.size) {
values.size = dims.size.get(i);
}
if (dims.rotation) {
values.rotation = dims.rotation.get(i);
}
if (dims.text) {
values.text = dims.text.get(i);
}