diff --git a/x-pack/plugins/maps/public/classes/styles/vector/components/legend/marker_size_legend.tsx b/x-pack/plugins/maps/public/classes/styles/vector/components/legend/marker_size_legend.tsx new file mode 100644 index 00000000000000..295e7c57b7a227 --- /dev/null +++ b/x-pack/plugins/maps/public/classes/styles/vector/components/legend/marker_size_legend.tsx @@ -0,0 +1,164 @@ +/* + * 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, { Component } from 'react'; +import { euiThemeVars } from '@kbn/ui-theme'; +import { EuiFlexGroup, EuiFlexItem, EuiText, EuiToolTip } from '@elastic/eui'; +import { DynamicSizeProperty } from '../../properties/dynamic_size_property'; + +const FONT_SIZE = 10; +const HALF_FONT_SIZE = FONT_SIZE / 2; +const MIN_MARKER_DISTANCE = (FONT_SIZE + 2) / 2; + +const EMPTY_VALUE = ''; + +interface Props { + style: DynamicSizeProperty; +} + +interface State { + label: string; +} + +export class MarkerSizeLegend extends Component { + private _isMounted: boolean = false; + + state: State = { + label: EMPTY_VALUE, + }; + + componentDidMount() { + this._isMounted = true; + this._loadLabel(); + } + + componentDidUpdate() { + this._loadLabel(); + } + + componentWillUnmount() { + this._isMounted = false; + } + + async _loadLabel() { + const field = this.props.style.getField(); + if (!field) { + return; + } + const label = await field.getLabel(); + if (this._isMounted && this.state.label !== label) { + this.setState({ label }); + } + } + + _formatValue(value: string | number) { + return value === EMPTY_VALUE ? value : this.props.style.formatField(value); + } + + _renderMarkers() { + const fieldMeta = this.props.style.getRangeFieldMeta(); + const options = this.props.style.getOptions(); + if (!fieldMeta || !options) { + return null; + } + + const circleStyle = { + fillOpacity: 0, + stroke: euiThemeVars.euiTextColor, + strokeWidth: 1, + }; + + const svgHeight = options.maxSize * 2 + HALF_FONT_SIZE + circleStyle.strokeWidth * 2; + const circleCenterX = options.maxSize + circleStyle.strokeWidth; + const circleBottomY = svgHeight - circleStyle.strokeWidth; + + function makeMarker(radius: number, formattedValue: string | number) { + const circleCenterY = circleBottomY - radius; + const circleTopY = circleCenterY - radius; + return ( + + + + {formattedValue} + + + + ); + } + + function getMarkerRadius(percentage: number) { + const delta = options.maxSize - options.minSize; + return percentage * delta + options.minSize; + } + + function getValue(percentage: number) { + // Markers interpolated by area instead of radius to be more consistent with how the human eye+brain perceive shapes + // and their visual relevance + // This function mirrors output of maplibre expression created from DynamicSizeProperty.getMbSizeExpression + const value = Math.pow(percentage * Math.sqrt(fieldMeta!.delta), 2) + fieldMeta!.min; + return fieldMeta!.delta > 3 ? Math.round(value) : value; + } + + const markers = []; + + if (fieldMeta.delta > 0) { + const smallestMarker = makeMarker(options.minSize, this._formatValue(fieldMeta.min)); + markers.push(smallestMarker); + + const markerDelta = options.maxSize - options.minSize; + if (markerDelta > MIN_MARKER_DISTANCE * 3) { + markers.push(makeMarker(getMarkerRadius(0.25), this._formatValue(getValue(0.25)))); + markers.push(makeMarker(getMarkerRadius(0.5), this._formatValue(getValue(0.5)))); + markers.push(makeMarker(getMarkerRadius(0.75), this._formatValue(getValue(0.75)))); + } else if (markerDelta > MIN_MARKER_DISTANCE) { + markers.push(makeMarker(getMarkerRadius(0.5), this._formatValue(getValue(0.5)))); + } + } + + const largestMarker = makeMarker(options.maxSize, this._formatValue(fieldMeta.max)); + markers.push(largestMarker); + + return ( + + {markers} + + ); + } + + render() { + return ( +
+ + + + + + {this.state.label} + + + + + + {this._renderMarkers()} +
+ ); + } +} diff --git a/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_size_property/__snapshots__/dynamic_size_property.test.tsx.snap b/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_size_property/__snapshots__/dynamic_size_property.test.tsx.snap index 9dc0e99669c791..bf239aa40e33a5 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_size_property/__snapshots__/dynamic_size_property.test.tsx.snap +++ b/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_size_property/__snapshots__/dynamic_size_property.test.tsx.snap @@ -1,6 +1,165 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`renderLegendDetailRow Should render as range 1`] = ` +exports[`renderLegendDetailRow Should render icon size scale 1`] = ` +
+ + + + + + + foobar_label + + + + + + + + + + + 0_format + + + + + + + 25_format + + + + + + + 100_format + + + + +
+`; + +exports[`renderLegendDetailRow Should render line width simple range 1`] = ` @@ -36,9 +196,10 @@ exports[`renderLegendDetailRow Should render as range 1`] = ` @@ -56,8 +217,9 @@ exports[`renderLegendDetailRow Should render as range 1`] = ` `; diff --git a/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_size_property/dynamic_size_property.test.tsx b/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_size_property/dynamic_size_property.test.tsx index 0446b9e30f47b7..9f92d81313da74 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_size_property/dynamic_size_property.test.tsx +++ b/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_size_property/dynamic_size_property.test.tsx @@ -20,7 +20,53 @@ import { IField } from '../../../../fields/field'; import { IVectorLayer } from '../../../../layers/vector_layer'; describe('renderLegendDetailRow', () => { - test('Should render as range', async () => { + test('Should render line width simple 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.LINE_WIDTH, + 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(); + }); + + test('Should render icon size scale', async () => { const field = { getLabel: async () => { return 'foobar_label'; diff --git a/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_size_property/dynamic_size_property.tsx b/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_size_property/dynamic_size_property.tsx index d8fe8463edba86..83ac50c7b4eaaa 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_size_property/dynamic_size_property.tsx +++ b/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_size_property/dynamic_size_property.tsx @@ -9,6 +9,7 @@ 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 { MarkerSizeLegend } from '../../components/legend/marker_size_legend'; import { makeMbClampedNumberExpression } from '../../style_util'; import { FieldFormatter, @@ -141,6 +142,10 @@ export class DynamicSizeProperty extends DynamicStyleProperty; + return this.getStyleName() === VECTOR_STYLES.ICON_SIZE && !this._isSymbolizedAsIcon ? ( + + ) : ( + + ); } }