[Maps] scale marker size by area (#131911)

* [Maps] scale marker size by area

* icon size

* clean up

* more clean-up

* add tests

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Nathan Reese 2022-05-11 06:34:31 -06:00 committed by GitHub
parent 63f2d66471
commit 71c17a4631
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 470 additions and 277 deletions

View file

@ -1,162 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import type { Map as MbMap } from '@kbn/mapbox-gl';
import { DynamicStyleProperty } from './dynamic_style_property';
import { OrdinalLegend } from '../components/legend/ordinal_legend';
import { makeMbClampedNumberExpression } from '../style_util';
import {
FieldFormatter,
HALF_MAKI_ICON_SIZE,
MB_LOOKUP_FUNCTION,
VECTOR_STYLES,
} from '../../../../../common/constants';
import { SizeDynamicOptions } from '../../../../../common/descriptor_types';
import { IField } from '../../../fields/field';
import { IVectorLayer } from '../../../layers/vector_layer';
export class DynamicSizeProperty extends DynamicStyleProperty<SizeDynamicOptions> {
private readonly _isSymbolizedAsIcon: boolean;
constructor(
options: SizeDynamicOptions,
styleName: VECTOR_STYLES,
field: IField | null,
vectorLayer: IVectorLayer,
getFieldFormatter: (fieldName: string) => null | FieldFormatter,
isSymbolizedAsIcon: boolean
) {
super(options, styleName, field, vectorLayer, getFieldFormatter);
this._isSymbolizedAsIcon = isSymbolizedAsIcon;
}
supportsFeatureState() {
// mb style "icon-size" does not support feature state
if (this.getStyleName() === VECTOR_STYLES.ICON_SIZE && this._isSymbolizedAsIcon) {
return false;
}
// mb style "text-size" does not support feature state
if (this.getStyleName() === VECTOR_STYLES.LABEL_SIZE) {
return false;
}
return true;
}
syncHaloWidthWithMb(mbLayerId: string, mbMap: MbMap) {
const haloWidth = this.getMbSizeExpression();
mbMap.setPaintProperty(mbLayerId, 'icon-halo-width', haloWidth);
}
syncIconSizeWithMb(symbolLayerId: string, mbMap: MbMap) {
const rangeFieldMeta = this.getRangeFieldMeta();
if (this._isSizeDynamicConfigComplete() && rangeFieldMeta) {
const targetName = this.getMbFieldName();
// Using property state instead of feature-state because layout properties do not support feature-state
mbMap.setLayoutProperty(symbolLayerId, 'icon-size', [
'interpolate',
['linear'],
makeMbClampedNumberExpression({
minValue: rangeFieldMeta.min,
maxValue: rangeFieldMeta.max,
fallback: 0,
lookupFunction: MB_LOOKUP_FUNCTION.GET,
fieldName: targetName,
}),
rangeFieldMeta.min,
this._options.minSize / HALF_MAKI_ICON_SIZE,
rangeFieldMeta.max,
this._options.maxSize / HALF_MAKI_ICON_SIZE,
]);
} else {
mbMap.setLayoutProperty(symbolLayerId, 'icon-size', null);
}
}
syncCircleStrokeWidthWithMb(mbLayerId: string, mbMap: MbMap) {
const lineWidth = this.getMbSizeExpression();
mbMap.setPaintProperty(mbLayerId, 'circle-stroke-width', lineWidth);
}
syncCircleRadiusWithMb(mbLayerId: string, mbMap: MbMap) {
const circleRadius = this.getMbSizeExpression();
mbMap.setPaintProperty(mbLayerId, 'circle-radius', circleRadius);
}
syncLineWidthWithMb(mbLayerId: string, mbMap: MbMap) {
const lineWidth = this.getMbSizeExpression();
mbMap.setPaintProperty(mbLayerId, 'line-width', lineWidth);
}
syncLabelSizeWithMb(mbLayerId: string, mbMap: MbMap) {
const lineWidth = this.getMbSizeExpression();
mbMap.setLayoutProperty(mbLayerId, 'text-size', lineWidth);
}
getMbSizeExpression() {
const rangeFieldMeta = this.getRangeFieldMeta();
if (!this._isSizeDynamicConfigComplete() || !rangeFieldMeta) {
// return min of size to avoid flashing
// returning minimum allows "growing" of the symbols when the meta comes in
// A grow effect us less visually jarring as shrinking.
// especially relevant when displaying fine-grained grids using mvt
return this._options.minSize >= 0 ? this._options.minSize : null;
}
return this._getMbDataDrivenSize({
targetName: this.getMbFieldName(),
minSize: this._options.minSize,
maxSize: this._options.maxSize,
minValue: rangeFieldMeta.min,
maxValue: rangeFieldMeta.max,
});
}
_getMbDataDrivenSize({
targetName,
minSize,
maxSize,
minValue,
maxValue,
}: {
targetName: string;
minSize: number;
maxSize: number;
minValue: number;
maxValue: number;
}) {
const stops =
minValue === maxValue ? [maxValue, maxSize] : [minValue, minSize, maxValue, maxSize];
return [
'interpolate',
['linear'],
makeMbClampedNumberExpression({
lookupFunction: this.getMbLookupFunction(),
maxValue,
minValue,
fieldName: targetName,
fallback: 0,
}),
...stops,
];
}
_isSizeDynamicConfigComplete() {
return (
this._field &&
this._field.isValid() &&
this._options.minSize >= 0 &&
this._options.maxSize >= 0
);
}
renderLegendDetailRow() {
return <OrdinalLegend style={this} />;
}
}

View file

@ -0,0 +1,134 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
jest.mock('../../components/vector_style_editor', () => ({
VectorStyleEditor: () => {
return <div>mockVectorStyleEditor</div>;
},
}));
import React from 'react';
import { shallow } from 'enzyme';
import { DynamicSizeProperty } from './dynamic_size_property';
import { FIELD_ORIGIN, RawValue, VECTOR_STYLES } from '../../../../../../common/constants';
import { IField } from '../../../../fields/field';
import { IVectorLayer } from '../../../../layers/vector_layer';
describe('renderLegendDetailRow', () => {
test('Should render as range', async () => {
const field = {
getLabel: async () => {
return 'foobar_label';
},
getName: () => {
return 'foodbar';
},
getOrigin: () => {
return FIELD_ORIGIN.SOURCE;
},
supportsFieldMetaFromEs: () => {
return true;
},
supportsFieldMetaFromLocalData: () => {
return true;
},
} as unknown as IField;
const sizeProp = new DynamicSizeProperty(
{ minSize: 0, maxSize: 10, fieldMetaOptions: { isEnabled: true } },
VECTOR_STYLES.ICON_SIZE,
field,
{} as unknown as IVectorLayer,
() => {
return (value: RawValue) => value + '_format';
},
false
);
sizeProp.getRangeFieldMeta = () => {
return {
min: 0,
max: 100,
delta: 100,
};
};
const legendRow = sizeProp.renderLegendDetailRow();
const component = shallow(legendRow);
// Ensure all promises resolve
await new Promise((resolve) => process.nextTick(resolve));
// Ensure the state changes are reflected
component.update();
expect(component).toMatchSnapshot();
});
});
describe('getMbSizeExpression', () => {
test('Should return interpolation expression with single stop when range.delta is 0', async () => {
const field = {
isValid: () => {
return true;
},
getName: () => {
return 'foobar';
},
getMbFieldName: () => {
return 'foobar';
},
getOrigin: () => {
return FIELD_ORIGIN.SOURCE;
},
getSource: () => {
return {
isMvt: () => {
return false;
},
};
},
supportsFieldMetaFromEs: () => {
return true;
},
} as unknown as IField;
const sizeProp = new DynamicSizeProperty(
{ minSize: 8, maxSize: 32, fieldMetaOptions: { isEnabled: true } },
VECTOR_STYLES.ICON_SIZE,
field,
{} as unknown as IVectorLayer,
() => {
return (value: RawValue) => value + '_format';
},
false
);
sizeProp.getRangeFieldMeta = () => {
return {
min: 100,
max: 100,
delta: 0,
};
};
expect(sizeProp.getMbSizeExpression()).toEqual([
'interpolate',
['linear'],
[
'sqrt',
[
'coalesce',
[
'case',
['==', ['feature-state', 'foobar'], null],
100,
['max', ['min', ['to-number', ['feature-state', 'foobar']], 100], 100],
],
100,
],
],
10,
32,
]);
});
});

View file

@ -0,0 +1,146 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import type { Map as MbMap } from '@kbn/mapbox-gl';
import { DynamicStyleProperty } from '../dynamic_style_property';
import { OrdinalLegend } from '../../components/legend/ordinal_legend';
import { makeMbClampedNumberExpression } from '../../style_util';
import {
FieldFormatter,
HALF_MAKI_ICON_SIZE,
VECTOR_STYLES,
} from '../../../../../../common/constants';
import type { SizeDynamicOptions } from '../../../../../../common/descriptor_types';
import type { IField } from '../../../../fields/field';
import type { IVectorLayer } from '../../../../layers/vector_layer';
export class DynamicSizeProperty extends DynamicStyleProperty<SizeDynamicOptions> {
private readonly _isSymbolizedAsIcon: boolean;
constructor(
options: SizeDynamicOptions,
styleName: VECTOR_STYLES,
field: IField | null,
vectorLayer: IVectorLayer,
getFieldFormatter: (fieldName: string) => null | FieldFormatter,
isSymbolizedAsIcon: boolean
) {
super(options, styleName, field, vectorLayer, getFieldFormatter);
this._isSymbolizedAsIcon = isSymbolizedAsIcon;
}
supportsFeatureState() {
// mb style "icon-size" does not support feature state
if (this.getStyleName() === VECTOR_STYLES.ICON_SIZE && this._isSymbolizedAsIcon) {
return false;
}
// mb style "text-size" does not support feature state
if (this.getStyleName() === VECTOR_STYLES.LABEL_SIZE) {
return false;
}
return true;
}
syncHaloWidthWithMb(mbLayerId: string, mbMap: MbMap) {
mbMap.setPaintProperty(mbLayerId, 'icon-halo-width', this.getMbSizeExpression());
}
syncIconSizeWithMb(symbolLayerId: string, mbMap: MbMap) {
mbMap.setLayoutProperty(symbolLayerId, 'icon-size', this.getMbSizeExpression());
}
syncCircleStrokeWidthWithMb(mbLayerId: string, mbMap: MbMap) {
mbMap.setPaintProperty(mbLayerId, 'circle-stroke-width', this.getMbSizeExpression());
}
syncCircleRadiusWithMb(mbLayerId: string, mbMap: MbMap) {
mbMap.setPaintProperty(mbLayerId, 'circle-radius', this.getMbSizeExpression());
}
syncLineWidthWithMb(mbLayerId: string, mbMap: MbMap) {
mbMap.setPaintProperty(mbLayerId, 'line-width', this.getMbSizeExpression());
}
syncLabelSizeWithMb(mbLayerId: string, mbMap: MbMap) {
mbMap.setLayoutProperty(mbLayerId, 'text-size', this.getMbSizeExpression());
}
/*
* Returns interpolation expression linearly translating domain values [minValue, maxValue] to display range [minSize, maxSize]
*/
getMbSizeExpression() {
const rangeFieldMeta = this.getRangeFieldMeta();
if (!this._isSizeDynamicConfigComplete() || !rangeFieldMeta) {
// return min of size to avoid flashing
// returning minimum allows "growing" of the symbols when the meta comes in
// A grow effect us less visually jarring as shrinking.
// especially relevant when displaying fine-grained grids using mvt
return this._options.minSize >= 0 ? this._options.minSize : null;
}
const isArea = this.getStyleName() === VECTOR_STYLES.ICON_SIZE;
// isArea === true
// It's a mistake to linearly map a data value to an area dimension (i.e. cirle radius).
// Area squares area dimension ("pie * r * r" or "x * x"), visually distorting proportions.
// Since it is the quadratic function that is causing this,
// we need to counteract its effects by applying its inverse function — the square-root function.
// https://bl.ocks.org/guilhermesimoes/e6356aa90a16163a6f917f53600a2b4a
// can not take square root of 0 or negative number
// shift values to be positive integers >= 1
const valueShift = rangeFieldMeta.min < 1 ? Math.abs(rangeFieldMeta.min) + 1 : 0;
const maxValueStopInput = isArea
? Math.sqrt(rangeFieldMeta.max + valueShift)
: rangeFieldMeta.max;
const minValueStopInput = isArea
? Math.sqrt(rangeFieldMeta.min + valueShift)
: rangeFieldMeta.min;
const maxRangeStopOutput =
this.getStyleName() === VECTOR_STYLES.ICON_SIZE && this._isSymbolizedAsIcon
? this._options.maxSize / HALF_MAKI_ICON_SIZE
: this._options.maxSize;
const minRangeStopOutput =
this.getStyleName() === VECTOR_STYLES.ICON_SIZE && this._isSymbolizedAsIcon
? this._options.minSize / HALF_MAKI_ICON_SIZE
: this._options.minSize;
const stops =
rangeFieldMeta.min === rangeFieldMeta.max
? [maxValueStopInput, maxRangeStopOutput]
: [minValueStopInput, minRangeStopOutput, maxValueStopInput, maxRangeStopOutput];
const valueExpression = makeMbClampedNumberExpression({
lookupFunction: this.getMbLookupFunction(),
maxValue: rangeFieldMeta.max,
minValue: rangeFieldMeta.min,
fieldName: this.getMbFieldName(),
fallback: rangeFieldMeta.min,
});
const valueShiftExpression =
rangeFieldMeta.min < 1 ? ['+', valueExpression, valueShift] : valueExpression;
const sqrtValueExpression = ['sqrt', valueShiftExpression];
const inputExpression = isArea ? sqrtValueExpression : valueExpression;
return ['interpolate', ['linear'], inputExpression, ...stops];
}
_isSizeDynamicConfigComplete() {
return (
this._field &&
this._field.isValid() &&
this._options.minSize >= 0 &&
this._options.maxSize >= 0
);
}
renderLegendDetailRow() {
return <OrdinalLegend style={this} />;
}
}

View file

@ -5,86 +5,13 @@
* 2.0.
*/
jest.mock('../components/vector_style_editor', () => ({
VectorStyleEditor: () => {
return <div>mockVectorStyleEditor</div>;
},
}));
import React from 'react';
import { shallow } from 'enzyme';
import { DynamicSizeProperty } from './dynamic_size_property';
import { FIELD_ORIGIN, RawValue, VECTOR_STYLES } from '../../../../../common/constants';
import { IField } from '../../../fields/field';
import type { Map as MbMap } from '@kbn/mapbox-gl';
import { IVectorLayer } from '../../../layers/vector_layer';
import { FIELD_ORIGIN, RawValue, VECTOR_STYLES } from '../../../../../../common/constants';
import { IField } from '../../../../fields/field';
import { IVectorLayer } from '../../../../layers/vector_layer';
export class MockMbMap {
_paintPropertyCalls: unknown[];
constructor() {
this._paintPropertyCalls = [];
}
setPaintProperty(...args: unknown[]) {
this._paintPropertyCalls.push([...args]);
}
getPaintPropertyCalls(): unknown[] {
return this._paintPropertyCalls;
}
}
describe('renderLegendDetailRow', () => {
test('Should render as range', async () => {
const field = {
getLabel: async () => {
return 'foobar_label';
},
getName: () => {
return 'foodbar';
},
getOrigin: () => {
return FIELD_ORIGIN.SOURCE;
},
supportsFieldMetaFromEs: () => {
return true;
},
supportsFieldMetaFromLocalData: () => {
return true;
},
} as unknown as IField;
const sizeProp = new DynamicSizeProperty(
{ minSize: 0, maxSize: 10, fieldMetaOptions: { isEnabled: true } },
VECTOR_STYLES.ICON_SIZE,
field,
{} as unknown as IVectorLayer,
() => {
return (value: RawValue) => value + '_format';
},
false
);
sizeProp.getRangeFieldMeta = () => {
return {
min: 0,
max: 100,
delta: 100,
};
};
const legendRow = sizeProp.renderLegendDetailRow();
const component = shallow(legendRow);
// Ensure all promises resolve
await new Promise((resolve) => process.nextTick(resolve));
// Ensure the state changes are reflected
component.update();
expect(component).toMatchSnapshot();
});
});
describe('syncSize', () => {
test('Should sync with circle-radius prop', async () => {
describe('getMbSizeExpression - circle', () => {
test('Should return interpolation expression with square-root function', async () => {
const field = {
isValid: () => {
return true;
@ -109,7 +36,7 @@ describe('syncSize', () => {
return true;
},
} as unknown as IField;
const sizeProp = new DynamicSizeProperty(
const iconSize = new DynamicSizeProperty(
{ minSize: 8, maxSize: 32, fieldMetaOptions: { isEnabled: true } },
VECTOR_STYLES.ICON_SIZE,
field,
@ -119,51 +46,48 @@ describe('syncSize', () => {
},
false
);
sizeProp.getRangeFieldMeta = () => {
iconSize.getRangeFieldMeta = () => {
return {
min: 0,
max: 100,
delta: 100,
};
};
const mockMbMap = new MockMbMap() as unknown as MbMap;
sizeProp.syncCircleRadiusWithMb('foobar', mockMbMap);
// @ts-expect-error
expect(mockMbMap.getPaintPropertyCalls()).toEqual([
expect(iconSize.getMbSizeExpression()).toEqual([
'interpolate',
['linear'],
[
'foobar',
'circle-radius',
'sqrt',
[
'interpolate',
['linear'],
'+',
[
'coalesce',
[
'case',
['==', ['feature-state', 'foobar'], null],
-1,
0,
['max', ['min', ['to-number', ['feature-state', 'foobar']], 100], 0],
],
0,
],
0,
8,
100,
32,
1,
],
],
1,
8,
10.04987562112089,
32,
]);
});
test('Should truncate interpolate expression to max when no delta', async () => {
test('Should return interpolation expression without value shift when range.min is > 1', async () => {
const field = {
isValid: () => {
return true;
},
getName: () => {
return 'foobar';
return 'foodbar';
},
getMbFieldName: () => {
return 'foobar';
@ -182,7 +106,7 @@ describe('syncSize', () => {
return true;
},
} as unknown as IField;
const sizeProp = new DynamicSizeProperty(
const iconSize = new DynamicSizeProperty(
{ minSize: 8, maxSize: 32, fieldMetaOptions: { isEnabled: true } },
VECTOR_STYLES.ICON_SIZE,
field,
@ -192,39 +116,106 @@ describe('syncSize', () => {
},
false
);
sizeProp.getRangeFieldMeta = () => {
iconSize.getRangeFieldMeta = () => {
return {
min: 100,
min: 1,
max: 100,
delta: 0,
delta: 100,
};
};
const mockMbMap = new MockMbMap() as unknown as MbMap;
sizeProp.syncCircleRadiusWithMb('foobar', mockMbMap);
// @ts-expect-error
expect(mockMbMap.getPaintPropertyCalls()).toEqual([
expect(iconSize.getMbSizeExpression()).toEqual([
'interpolate',
['linear'],
[
'foobar',
'circle-radius',
'sqrt',
[
'interpolate',
['linear'],
'coalesce',
[
'case',
['==', ['feature-state', 'foobar'], null],
1,
['max', ['min', ['to-number', ['feature-state', 'foobar']], 100], 1],
],
1,
],
],
1,
8,
10,
32,
]);
});
});
describe('getMbSizeExpression - icon', () => {
test('Should return interpolation expression with square-root function', async () => {
const field = {
isValid: () => {
return true;
},
getName: () => {
return 'foodbar';
},
getMbFieldName: () => {
return 'foobar';
},
getOrigin: () => {
return FIELD_ORIGIN.SOURCE;
},
getSource: () => {
return {
isMvt: () => {
return false;
},
};
},
supportsFieldMetaFromEs: () => {
return true;
},
} as unknown as IField;
const iconSize = new DynamicSizeProperty(
{ minSize: 8, maxSize: 32, fieldMetaOptions: { isEnabled: true } },
VECTOR_STYLES.ICON_SIZE,
field,
{} as unknown as IVectorLayer,
() => {
return (value: RawValue) => value + '_format';
},
true
);
iconSize.getRangeFieldMeta = () => {
return {
min: 0,
max: 100,
delta: 100,
};
};
expect(iconSize.getMbSizeExpression()).toEqual([
'interpolate',
['linear'],
[
'sqrt',
[
'+',
[
'coalesce',
[
'case',
['==', ['feature-state', 'foobar'], null],
99,
['max', ['min', ['to-number', ['feature-state', 'foobar']], 100], 100],
['==', ['get', 'foobar'], null],
0,
['max', ['min', ['to-number', ['get', 'foobar']], 100], 0],
],
0,
],
100,
32,
1,
],
],
1,
1,
10.04987562112089,
4,
]);
});
});

View file

@ -0,0 +1,8 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export { DynamicSizeProperty } from './dynamic_size_property';

View file

@ -0,0 +1,76 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { DynamicSizeProperty } from './dynamic_size_property';
import { FIELD_ORIGIN, RawValue, VECTOR_STYLES } from '../../../../../../common/constants';
import { IField } from '../../../../fields/field';
import { IVectorLayer } from '../../../../layers/vector_layer';
describe('getMbSizeExpression', () => {
test('Should return interpolation expression', async () => {
const field = {
isValid: () => {
return true;
},
getName: () => {
return 'foodbar';
},
getMbFieldName: () => {
return 'foobar';
},
getOrigin: () => {
return FIELD_ORIGIN.SOURCE;
},
getSource: () => {
return {
isMvt: () => {
return false;
},
};
},
supportsFieldMetaFromEs: () => {
return true;
},
} as unknown as IField;
const lineWidth = new DynamicSizeProperty(
{ minSize: 8, maxSize: 32, fieldMetaOptions: { isEnabled: true } },
VECTOR_STYLES.LINE_WIDTH,
field,
{} as unknown as IVectorLayer,
() => {
return (value: RawValue) => value + '_format';
},
false
);
lineWidth.getRangeFieldMeta = () => {
return {
min: 0,
max: 100,
delta: 100,
};
};
expect(lineWidth.getMbSizeExpression()).toEqual([
'interpolate',
['linear'],
[
'coalesce',
[
'case',
['==', ['feature-state', 'foobar'], null],
0,
['max', ['min', ['to-number', ['feature-state', 'foobar']], 100], 0],
],
0,
],
0,
8,
100,
32,
]);
});
});

View file

@ -87,7 +87,7 @@ export function makeMbClampedNumberExpression({
[
'case',
['==', [lookupFunction, fieldName], null],
minValue - 1, // == does a JS-y like check where returns true for null and undefined
fallback, // == does a JS-y like check where returns true for null and undefined
clamp,
],
fallback,