[Maps] Add categorical styling (#54408)

This allows users to style fields by category. Users can either uses one of default color palettes or specify a custom ramp.
This commit is contained in:
Thomas Neirynck 2020-01-14 19:30:21 -05:00 committed by GitHub
parent 2e7b35e232
commit 0ff668ba46
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
32 changed files with 1459 additions and 378 deletions

View file

@ -142,3 +142,12 @@ export const LAYER_STYLE_TYPE = {
VECTOR: 'VECTOR',
HEATMAP: 'HEATMAP',
};
export const COLOR_MAP_TYPE = {
CATEGORICAL: 'CATEGORICAL',
ORDINAL: 'ORDINAL',
};
export const COLOR_PALETTE_MAX_SIZE = 10;
export const CATEGORICAL_DATA_TYPES = ['string', 'ip', 'boolean'];

View file

@ -82,7 +82,7 @@ export class ESAggMetricField extends AbstractField {
return !isMetricCountable(this.getAggType());
}
async getFieldMetaRequest(config) {
return this._esDocField.getFieldMetaRequest(config);
async getOrdinalFieldMetaRequest(config) {
return this._esDocField.getOrdinalFieldMetaRequest(config);
}
}

View file

@ -6,6 +6,7 @@
import { AbstractField } from './field';
import { ESTooltipProperty } from '../tooltips/es_tooltip_property';
import { COLOR_PALETTE_MAX_SIZE } from '../../../common/constants';
export class ESDocField extends AbstractField {
static type = 'ES_DOC';
@ -29,7 +30,7 @@ export class ESDocField extends AbstractField {
return true;
}
async getFieldMetaRequest(/* config */) {
async getOrdinalFieldMetaRequest() {
const field = await this._getField();
if (field.type !== 'number' && field.type !== 'date') {
@ -51,4 +52,29 @@ export class ESDocField extends AbstractField {
},
};
}
async getCategoricalFieldMetaRequest() {
const field = await this._getField();
if (field.type !== 'string') {
//UX does not support categorical styling for number/date fields
return null;
}
const topTerms = {
size: COLOR_PALETTE_MAX_SIZE - 1, //need additional color for the "other"-value
};
if (field.scripted) {
topTerms.script = {
source: field.script,
lang: field.lang,
};
} else {
topTerms.field = this._fieldName;
}
return {
[this._fieldName]: {
terms: topTerms,
},
};
}
}

View file

@ -45,7 +45,11 @@ export class AbstractField {
return false;
}
async getFieldMetaRequest(/* config */) {
async getOrdinalFieldMetaRequest(/* config */) {
return null;
}
async getCategoricalFieldMetaRequest() {
return null;
}
}

View file

@ -344,6 +344,10 @@ export class AbstractLayer {
return [];
}
async getCategoricalFields() {
return [];
}
async getFields() {
return [];
}

View file

@ -19,6 +19,7 @@ import {
getDefaultDynamicProperties,
VECTOR_STYLES,
} from '../../styles/vector/vector_style_defaults';
import { COLOR_GRADIENTS } from '../../styles/color_utils';
import { RENDER_AS } from './render_as';
import { CreateSourceEditor } from './create_source_editor';
import { UpdateSourceEditor } from './update_source_editor';
@ -249,7 +250,7 @@ export class ESGeoGridSource extends AbstractESAggSource {
name: COUNT_PROP_NAME,
origin: SOURCE_DATA_ID_ORIGIN,
},
color: 'Blues',
color: COLOR_GRADIENTS[0].value,
},
},
[VECTOR_STYLES.LINE_COLOR]: {

View file

@ -24,6 +24,7 @@ import { Schemas } from 'ui/vis/editors/default/schemas';
import { AggConfigs } from 'ui/agg_types';
import { AbstractESAggSource } from '../es_agg_source';
import { DynamicStyleProperty } from '../../styles/vector/properties/dynamic_style_property';
import { COLOR_GRADIENTS } from '../../styles/color_utils';
const MAX_GEOTILE_LEVEL = 29;
@ -136,7 +137,7 @@ export class ESPewPewSource extends AbstractESAggSource {
name: COUNT_PROP_NAME,
origin: SOURCE_DATA_ID_ORIGIN,
},
color: 'Blues',
color: COLOR_GRADIENTS[0].value,
},
},
[VECTOR_STYLES.LINE_WIDTH]: {

View file

@ -19,6 +19,7 @@ import {
ES_GEO_FIELD_TYPE,
DEFAULT_MAX_BUCKETS_LIMIT,
SORT_ORDER,
CATEGORICAL_DATA_TYPES,
} from '../../../../common/constants';
import { i18n } from '@kbn/i18n';
import { getDataSourceLabel } from '../../../../common/i18n_getters';
@ -125,6 +126,27 @@ export class ESSearchSource extends AbstractESSource {
}
}
async getCategoricalFields() {
try {
const indexPattern = await this.getIndexPattern();
const aggFields = [];
CATEGORICAL_DATA_TYPES.forEach(dataType => {
indexPattern.fields.getByType(dataType).forEach(field => {
if (field.aggregatable) {
aggFields.push(field);
}
});
});
return aggFields.map(field => {
return this.createField({ fieldName: field.name });
});
} catch (error) {
//error surfaces in the LayerTOC UI
return [];
}
}
async getFields() {
try {
const indexPattern = await this.getIndexPattern();

View file

@ -107,6 +107,10 @@ export class AbstractVectorSource extends AbstractSource {
return [...(await this.getDateFields()), ...(await this.getNumberFields())];
}
async getCategoricalFields() {
return [];
}
async getLeftJoinFields() {
return [];
}

View file

@ -12,6 +12,7 @@ import { ColorGradient } from './components/color_gradient';
import { euiPaletteColorBlind } from '@elastic/eui/lib/services';
import tinycolor from 'tinycolor2';
import chroma from 'chroma-js';
import { COLOR_PALETTE_MAX_SIZE } from '../../../common/constants';
const GRADIENT_INTERVALS = 8;
@ -51,6 +52,9 @@ export function getHexColorRangeStrings(colorRampName, numberColors = GRADIENT_I
}
export function getColorRampCenterColor(colorRampName) {
if (!colorRampName) {
return null;
}
const colorRamp = getColorRamp(colorRampName);
const centerIndex = Math.floor(colorRamp.value.length / 2);
return getColor(colorRamp.value, centerIndex);
@ -58,7 +62,10 @@ export function getColorRampCenterColor(colorRampName) {
// Returns an array of color stops
// [ stop_input_1: number, stop_output_1: color, stop_input_n: number, stop_output_n: color ]
export function getColorRampStops(colorRampName, numberColors = GRADIENT_INTERVALS) {
export function getOrdinalColorRampStops(colorRampName, numberColors = GRADIENT_INTERVALS) {
if (!colorRampName) {
return null;
}
return getHexColorRangeStrings(colorRampName, numberColors).reduce(
(accu, stopColor, idx, srcArr) => {
const stopNumber = idx / srcArr.length; // number between 0 and 1, increasing as index increases
@ -84,3 +91,62 @@ export function getLinearGradient(colorStrings) {
}
return `${linearGradient} ${colorStrings[colorStrings.length - 1]} 100%)`;
}
const COLOR_PALETTES_CONFIGS = [
{
id: 'palette_0',
colors: DEFAULT_FILL_COLORS.slice(0, COLOR_PALETTE_MAX_SIZE),
},
{
id: 'palette_1',
colors: [
'#a6cee3',
'#1f78b4',
'#b2df8a',
'#33a02c',
'#fb9a99',
'#e31a1c',
'#fdbf6f',
'#ff7f00',
'#cab2d6',
'#6a3d9a',
],
},
{
id: 'palette_2',
colors: [
'#8dd3c7',
'#ffffb3',
'#bebada',
'#fb8072',
'#80b1d3',
'#fdb462',
'#b3de69',
'#fccde5',
'#d9d9d9',
'#bc80bd',
],
},
];
export function getColorPalette(paletteId) {
const palette = COLOR_PALETTES_CONFIGS.find(palette => palette.id === paletteId);
return palette ? palette.colors : null;
}
export const COLOR_PALETTES = COLOR_PALETTES_CONFIGS.map(palette => {
const paletteDisplay = palette.colors.map(color => {
const style = {
backgroundColor: color,
width: '10%',
position: 'relative',
height: '100%',
display: 'inline-block',
};
return <div style={style}>&nbsp;</div>;
});
return {
value: palette.id,
inputDisplay: <div className={'mapColorGradient'}>{paletteDisplay}</div>,
};
});

View file

@ -7,7 +7,7 @@
import {
COLOR_GRADIENTS,
getColorRampCenterColor,
getColorRampStops,
getOrdinalColorRampStops,
getHexColorRangeStrings,
getLinearGradient,
getRGBColorRangeStrings,
@ -59,7 +59,7 @@ describe('getColorRampCenterColor', () => {
describe('getColorRampStops', () => {
it('Should create color stops for color ramp', () => {
expect(getColorRampStops('Blues')).toEqual([
expect(getOrdinalColorRampStops('Blues')).toEqual([
0,
'#f7faff',
0.125,

View file

@ -11,7 +11,7 @@ import { HeatmapStyleEditor } from './components/heatmap_style_editor';
import { HeatmapLegend } from './components/legend/heatmap_legend';
import { DEFAULT_HEATMAP_COLOR_RAMP_NAME } from './components/heatmap_constants';
import { LAYER_STYLE_TYPE } from '../../../../common/constants';
import { getColorRampStops } from '../color_utils';
import { getOrdinalColorRampStops } from '../color_utils';
import { i18n } from '@kbn/i18n';
import { EuiIcon } from '@elastic/eui';
@ -81,7 +81,7 @@ export class HeatmapStyle extends AbstractStyle {
const { colorRampName } = this._descriptor;
if (colorRampName && colorRampName !== DEFAULT_HEATMAP_COLOR_RAMP_NAME) {
const colorStops = getColorRampStops(colorRampName);
const colorStops = getOrdinalColorRampStops(colorRampName);
mbMap.setPaintProperty(layerId, 'heatmap-color', [
'interpolate',
['linear'],

View file

@ -0,0 +1,117 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { Component, Fragment } from 'react';
import { EuiSuperSelect, EuiSpacer } from '@elastic/eui';
import { ColorStopsOrdinal } from './color_stops_ordinal';
import { COLOR_MAP_TYPE } from '../../../../../../common/constants';
import { ColorStopsCategorical } from './color_stops_categorical';
const CUSTOM_COLOR_MAP = 'CUSTOM_COLOR_MAP';
export class ColorMapSelect extends Component {
state = {
selected: '',
};
static getDerivedStateFromProps(nextProps, prevState) {
if (nextProps.customColorMap === prevState.prevPropsCustomColorMap) {
return null;
}
return {
prevPropsCustomColorMap: nextProps.customColorMap, // reset tracker to latest value
customColorMap: nextProps.customColorMap, // reset customColorMap to latest value
};
}
_onColorMapSelect = selectedValue => {
const useCustomColorMap = selectedValue === CUSTOM_COLOR_MAP;
this.props.onChange({
color: useCustomColorMap ? null : selectedValue,
useCustomColorMap,
type: this.props.colorMapType,
});
};
_onCustomColorMapChange = ({ colorStops, isInvalid }) => {
// Manage invalid custom color map in local state
if (isInvalid) {
const newState = {
customColorMap: colorStops,
};
this.setState(newState);
return;
}
this.props.onChange({
useCustomColorMap: true,
customColorMap: colorStops,
type: this.props.colorMapType,
});
};
_renderColorStopsInput() {
let colorStopsInput;
if (this.props.useCustomColorMap) {
if (this.props.colorMapType === COLOR_MAP_TYPE.ORDINAL) {
colorStopsInput = (
<Fragment>
<EuiSpacer size="s" />
<ColorStopsOrdinal
colorStops={this.state.customColorMap}
onChange={this._onCustomColorMapChange}
/>
</Fragment>
);
} else if (this.props.colorMapType === COLOR_MAP_TYPE.CATEGORICAL) {
colorStopsInput = (
<Fragment>
<EuiSpacer size="s" />
<ColorStopsCategorical
colorStops={this.state.customColorMap}
onChange={this._onCustomColorMapChange}
/>
</Fragment>
);
}
}
return colorStopsInput;
}
render() {
const colorStopsInput = this._renderColorStopsInput();
const colorMapOptionsWithCustom = [
{
value: CUSTOM_COLOR_MAP,
inputDisplay: this.props.customOptionLabel,
},
...this.props.colorMapOptions,
];
let valueOfSelected;
if (this.props.useCustomColorMap) {
valueOfSelected = CUSTOM_COLOR_MAP;
} else {
valueOfSelected = this.props.colorMapOptions.find(option => option.value === this.props.color)
? this.props.color
: '';
}
return (
<Fragment>
<EuiSuperSelect
options={colorMapOptionsWithCustom}
onChange={this._onColorMapSelect}
valueOfSelected={valueOfSelected}
hasDividers={true}
/>
{colorStopsInput}
</Fragment>
);
}
}

View file

@ -1,106 +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;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { Component, Fragment } from 'react';
import PropTypes from 'prop-types';
import { EuiSuperSelect, EuiSpacer } from '@elastic/eui';
import { COLOR_GRADIENTS } from '../../../color_utils';
import { FormattedMessage } from '@kbn/i18n/react';
import { ColorStops } from './color_stops';
const CUSTOM_COLOR_RAMP = 'CUSTOM_COLOR_RAMP';
export class ColorRampSelect extends Component {
state = {};
static getDerivedStateFromProps(nextProps, prevState) {
if (nextProps.customColorRamp !== prevState.prevPropsCustomColorRamp) {
return {
prevPropsCustomColorRamp: nextProps.customColorRamp, // reset tracker to latest value
customColorRamp: nextProps.customColorRamp, // reset customColorRamp to latest value
};
}
return null;
}
_onColorRampSelect = selectedValue => {
const useCustomColorRamp = selectedValue === CUSTOM_COLOR_RAMP;
this.props.onChange({
color: useCustomColorRamp ? null : selectedValue,
useCustomColorRamp,
});
};
_onCustomColorRampChange = ({ colorStops, isInvalid }) => {
// Manage invalid custom color ramp in local state
if (isInvalid) {
this.setState({ customColorRamp: colorStops });
return;
}
this.props.onChange({
customColorRamp: colorStops,
});
};
render() {
const {
color,
onChange, // eslint-disable-line no-unused-vars
useCustomColorRamp,
customColorRamp, // eslint-disable-line no-unused-vars
...rest
} = this.props;
let colorStopsInput;
if (useCustomColorRamp) {
colorStopsInput = (
<Fragment>
<EuiSpacer size="s" />
<ColorStops
colorStops={this.state.customColorRamp}
onChange={this._onCustomColorRampChange}
/>
</Fragment>
);
}
const colorRampOptions = [
{
value: CUSTOM_COLOR_RAMP,
inputDisplay: (
<FormattedMessage
id="xpack.maps.style.customColorRampLabel"
defaultMessage="Custom color ramp"
/>
),
},
...COLOR_GRADIENTS,
];
return (
<Fragment>
<EuiSuperSelect
options={colorRampOptions}
onChange={this._onColorRampSelect}
valueOfSelected={useCustomColorRamp ? CUSTOM_COLOR_RAMP : color}
hasDividers={true}
{...rest}
/>
{colorStopsInput}
</Fragment>
);
}
}
ColorRampSelect.propTypes = {
color: PropTypes.string,
onChange: PropTypes.func.isRequired,
useCustomColorRamp: PropTypes.bool,
customColorRamp: PropTypes.array,
};

View file

@ -6,66 +6,106 @@
import _ from 'lodash';
import React from 'react';
import PropTypes from 'prop-types';
import { removeRow, isColorInvalid } from './color_stops_utils';
import { i18n } from '@kbn/i18n';
import { EuiButtonIcon, EuiColorPicker, EuiFlexGroup, EuiFlexItem, EuiFormRow } from '@elastic/eui';
import {
EuiColorPicker,
EuiFormRow,
EuiFieldNumber,
EuiFlexGroup,
EuiFlexItem,
EuiButtonIcon,
} from '@elastic/eui';
import { addRow, removeRow, isColorInvalid, isStopInvalid, isInvalid } from './color_stops_utils';
function getColorStopRow({ index, errors, stopInput, colorInput, deleteButton, onAdd }) {
return (
<EuiFormRow
key={index}
className="mapColorStop"
isInvalid={errors.length !== 0}
error={errors}
display="rowCompressed"
>
<div>
<EuiFlexGroup responsive={false} alignItems="center" gutterSize="xs">
<EuiFlexItem>{stopInput}</EuiFlexItem>
<EuiFlexItem>{colorInput}</EuiFlexItem>
</EuiFlexGroup>
<div className="mapColorStop__icons">
{deleteButton}
<EuiButtonIcon
iconType="plusInCircle"
color="primary"
aria-label="Add"
title="Add"
onClick={onAdd}
/>
</div>
</div>
</EuiFormRow>
);
}
const DEFAULT_COLOR = '#FF0000';
export function getDeleteButton(onRemove) {
return (
<EuiButtonIcon
iconType="trash"
color="danger"
aria-label={i18n.translate('xpack.maps.styles.colorStops.deleteButtonAriaLabel', {
defaultMessage: 'Delete',
})}
title={i18n.translate('xpack.maps.styles.colorStops.deleteButtonLabel', {
defaultMessage: 'Delete',
})}
onClick={onRemove}
/>
);
}
export const ColorStops = ({ colorStops = [{ stop: 0, color: DEFAULT_COLOR }], onChange }) => {
export const ColorStops = ({
onChange,
colorStops,
isStopsInvalid,
sanitizeStopInput,
getStopError,
renderStopInput,
addNewRow,
canDeleteStop,
}) => {
function getStopInput(stop, index) {
const onStopChange = e => {
const newColorStops = _.cloneDeep(colorStops);
const sanitizedValue = parseFloat(e.target.value);
newColorStops[index].stop = isNaN(sanitizedValue) ? '' : sanitizedValue;
newColorStops[index].stop = sanitizeStopInput(e.target.value);
const invalid = isStopsInvalid(newColorStops);
onChange({
colorStops: newColorStops,
isInvalid: isInvalid(newColorStops),
isInvalid: invalid,
});
};
let error;
if (isStopInvalid(stop)) {
error = 'Stop must be a number';
} else if (index !== 0 && colorStops[index - 1].stop >= stop) {
error = 'Stop must be greater than previous stop value';
}
const error = getStopError(stop, index);
return {
stopError: error,
stopInput: (
<EuiFieldNumber aria-label="Stop" value={stop} onChange={onStopChange} compressed />
),
stopInput: renderStopInput(stop, onStopChange, index),
};
}
function getColorInput(color, index) {
const onColorChange = color => {
const newColorStops = _.cloneDeep(colorStops);
newColorStops[index].color = color;
onChange({
colorStops: newColorStops,
isInvalid: isInvalid(newColorStops),
});
};
function getColorInput(onColorChange, color) {
return {
colorError: isColorInvalid(color) ? 'Color must provide a valid hex value' : undefined,
colorError: isColorInvalid(color)
? i18n.translate('xpack.maps.styles.colorStops.hexWarningLabel', {
defaultMessage: 'Color must provide a valid hex value',
})
: undefined,
colorInput: <EuiColorPicker onChange={onColorChange} color={color} compressed />,
};
}
const rows = colorStops.map((colorStop, index) => {
const onColorChange = color => {
const newColorStops = _.cloneDeep(colorStops);
newColorStops[index].color = color;
onChange({
colorStops: newColorStops,
isInvalid: isStopsInvalid(newColorStops),
});
};
const { stopError, stopInput } = getStopInput(colorStop.stop, index);
const { colorError, colorInput } = getColorInput(colorStop.color, index);
const { colorError, colorInput } = getColorInput(onColorChange, colorStop.color);
const errors = [];
if (stopError) {
errors.push(stopError);
@ -74,82 +114,28 @@ export const ColorStops = ({ colorStops = [{ stop: 0, color: DEFAULT_COLOR }], o
errors.push(colorError);
}
const onRemove = () => {
const newColorStops = removeRow(colorStops, index);
onChange({
colorStops: newColorStops,
isInvalid: isInvalid(newColorStops),
});
};
const onAdd = () => {
const newColorStops = addRow(colorStops, index);
const newColorStops = addNewRow(colorStops, index);
onChange({
colorStops: newColorStops,
isInvalid: isInvalid(newColorStops),
isInvalid: isStopsInvalid(newColorStops),
});
};
let deleteButton;
if (colorStops.length > 1) {
deleteButton = (
<EuiButtonIcon
iconType="trash"
color="danger"
aria-label="Delete"
title="Delete"
onClick={onRemove}
/>
);
if (canDeleteStop(colorStops, index)) {
const onRemove = () => {
const newColorStops = removeRow(colorStops, index);
onChange({
colorStops: newColorStops,
isInvalid: isStopsInvalid(newColorStops),
});
};
deleteButton = getDeleteButton(onRemove);
}
return (
<EuiFormRow
key={index}
className="mapColorStop"
isInvalid={errors.length !== 0}
error={errors}
display="rowCompressed"
>
<div>
<EuiFlexGroup responsive={false} alignItems="center" gutterSize="xs">
<EuiFlexItem>{stopInput}</EuiFlexItem>
<EuiFlexItem>{colorInput}</EuiFlexItem>
</EuiFlexGroup>
<div className="mapColorStop__icons">
{deleteButton}
<EuiButtonIcon
iconType="plusInCircle"
color="primary"
aria-label="Add"
title="Add"
onClick={onAdd}
/>
</div>
</div>
</EuiFormRow>
);
return getColorStopRow({ index, errors, stopInput, colorInput, deleteButton, onAdd });
});
return <div>{rows}</div>;
};
ColorStops.propTypes = {
/**
* Array of { stop, color }.
* Stops are numbers in strictly ascending order.
* The range is from the given stop number (inclusive) to the next stop number (exclusive).
* Colors are color hex strings (3 or 6 character).
*/
colorStops: PropTypes.arrayOf(
PropTypes.shape({
stopKey: PropTypes.number,
color: PropTypes.string,
})
),
/**
* Callback for when the color stops changes. Called with { colorStops, isInvalid }
*/
onChange: PropTypes.func.isRequired,
};

View file

@ -0,0 +1,117 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import { EuiFieldText } from '@elastic/eui';
import {
addCategoricalRow,
isCategoricalStopsInvalid,
getOtherCategoryLabel,
DEFAULT_CUSTOM_COLOR,
DEFAULT_NEXT_COLOR,
} from './color_stops_utils';
import { i18n } from '@kbn/i18n';
import { ColorStops } from './color_stops';
export const ColorStopsCategorical = ({
colorStops = [
{ stop: null, color: DEFAULT_CUSTOM_COLOR }, //first stop is the "other" color
{ stop: '', color: DEFAULT_NEXT_COLOR },
],
onChange,
}) => {
const sanitizeStopInput = value => {
return value;
};
const getStopError = (stop, index) => {
let count = 0;
for (let i = 1; i < colorStops.length; i++) {
if (colorStops[i].stop === stop && i !== index) {
count++;
}
}
return count
? i18n.translate('xpack.maps.styles.colorStops.categoricalStop.noDupesWarningLabel', {
defaultMessage: 'Stop values must be unique',
})
: null;
};
const renderStopInput = (stop, onStopChange, index) => {
const stopValue = typeof stop === 'string' ? stop : '';
if (index === 0) {
return (
<EuiFieldText
aria-label={i18n.translate(
'xpack.maps.styles.colorStops.categoricalStop.defaultCategoryAriaLabel',
{
defaultMessage: 'Default stop',
}
)}
value={stopValue}
placeholder={getOtherCategoryLabel()}
disabled
onChange={onStopChange}
compressed
/>
);
} else {
return (
<EuiFieldText
aria-label={i18n.translate(
'xpack.maps.styles.colorStops.categoricalStop.categoryAriaLabel',
{
defaultMessage: 'Category',
}
)}
value={stopValue}
onChange={onStopChange}
compressed
/>
);
}
};
const canDeleteStop = (colorStops, index) => {
return colorStops.length > 2 && index !== 0;
};
return (
<ColorStops
onChange={onChange}
colorStops={colorStops}
isStopsInvalid={isCategoricalStopsInvalid}
sanitizeStopInput={sanitizeStopInput}
getStopError={getStopError}
renderStopInput={renderStopInput}
canDeleteStop={canDeleteStop}
addNewRow={addCategoricalRow}
/>
);
};
ColorStopsCategorical.propTypes = {
/**
* Array of { stop, color }.
* Stops are any strings
* Stops cannot include duplicates
* Colors are color hex strings (3 or 6 character).
*/
colorStops: PropTypes.arrayOf(
PropTypes.shape({
stopKey: PropTypes.number,
color: PropTypes.string,
})
),
/**
* Callback for when the color stops changes. Called with { colorStops, isInvalid }
*/
onChange: PropTypes.func.isRequired,
};

View file

@ -0,0 +1,94 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import { ColorStops } from './color_stops';
import { EuiFieldNumber } from '@elastic/eui';
import {
addOrdinalRow,
isOrdinalStopInvalid,
isOrdinalStopsInvalid,
DEFAULT_CUSTOM_COLOR,
} from './color_stops_utils';
import { i18n } from '@kbn/i18n';
export const ColorStopsOrdinal = ({
colorStops = [{ stop: 0, color: DEFAULT_CUSTOM_COLOR }],
onChange,
}) => {
const sanitizeStopInput = value => {
const sanitizedValue = parseFloat(value);
return isNaN(sanitizedValue) ? '' : sanitizedValue;
};
const getStopError = (stop, index) => {
let error;
if (isOrdinalStopInvalid(stop)) {
error = i18n.translate('xpack.maps.styles.colorStops.ordinalStop.numberWarningLabel', {
defaultMessage: 'Stop must be a number',
});
} else if (index !== 0 && colorStops[index - 1].stop >= stop) {
error = i18n.translate(
'xpack.maps.styles.colorStops.ordinalStop.numberOrderingWarningLabel',
{
defaultMessage: 'Stop must be greater than previous stop value',
}
);
}
return error;
};
const renderStopInput = (stop, onStopChange) => {
return (
<EuiFieldNumber
aria-label={i18n.translate('xpack.maps.styles.colorStops.ordinalStop.stopLabel', {
defaultMessage: 'Stop',
})}
value={stop}
onChange={onStopChange}
compressed
/>
);
};
const canDeleteStop = colorStops => {
return colorStops.length > 1;
};
return (
<ColorStops
onChange={onChange}
colorStops={colorStops}
isStopsInvalid={isOrdinalStopsInvalid}
sanitizeStopInput={sanitizeStopInput}
getStopError={getStopError}
renderStopInput={renderStopInput}
canDeleteStop={canDeleteStop}
addNewRow={addOrdinalRow}
/>
);
};
ColorStopsOrdinal.propTypes = {
/**
* Array of { stop, color }.
* Stops are numbers in strictly ascending order.
* The range is from the given stop number (inclusive) to the next stop number (exclusive).
* Colors are color hex strings (3 or 6 character).
*/
colorStops: PropTypes.arrayOf(
PropTypes.shape({
stopKey: PropTypes.number,
color: PropTypes.string,
})
),
/**
* Callback for when the color stops changes. Called with { colorStops, isInvalid }
*/
onChange: PropTypes.func.isRequired,
};

View file

@ -5,6 +5,11 @@
*/
import { isValidHex } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import _ from 'lodash';
export const DEFAULT_CUSTOM_COLOR = '#FF0000';
export const DEFAULT_NEXT_COLOR = '#00FF00';
export function removeRow(colorStops, index) {
if (colorStops.length === 1) {
@ -14,7 +19,7 @@ export function removeRow(colorStops, index) {
return [...colorStops.slice(0, index), ...colorStops.slice(index + 1)];
}
export function addRow(colorStops, index) {
export function addOrdinalRow(colorStops, index) {
const currentStop = colorStops[index].stop;
let delta = 1;
if (index === colorStops.length - 1) {
@ -28,10 +33,20 @@ export function addRow(colorStops, index) {
const nextStop = colorStops[index + 1].stop;
delta = (nextStop - currentStop) / 2;
}
const nextValue = currentStop + delta;
return addRow(colorStops, index, nextValue);
}
export function addCategoricalRow(colorStops, index) {
const currentStop = colorStops[index].stop;
const nextValue = currentStop === '' ? currentStop + 'a' : '';
return addRow(colorStops, index, nextValue);
}
function addRow(colorStops, index, nextValue) {
const newRow = {
stop: currentStop + delta,
color: '#FF0000',
stop: nextValue,
color: DEFAULT_CUSTOM_COLOR,
};
return [...colorStops.slice(0, index + 1), newRow, ...colorStops.slice(index + 1)];
}
@ -40,11 +55,18 @@ export function isColorInvalid(color) {
return !isValidHex(color) || color === '';
}
export function isStopInvalid(stop) {
export function isOrdinalStopInvalid(stop) {
return stop === '' || isNaN(stop);
}
export function isInvalid(colorStops) {
export function isCategoricalStopsInvalid(colorStops) {
const nonDefaults = colorStops.slice(1); //
const values = nonDefaults.map(stop => stop.stop);
const uniques = _.uniq(values);
return values.length !== uniques.length;
}
export function isOrdinalStopsInvalid(colorStops) {
return colorStops.some((colorStop, index) => {
// expect stops to be in ascending order
let isDescending = false;
@ -53,6 +75,12 @@ export function isInvalid(colorStops) {
isDescending = prevStop >= colorStop.stop;
}
return isColorInvalid(colorStop.color) || isStopInvalid(colorStop.stop) || isDescending;
return isColorInvalid(colorStop.color) || isOrdinalStopInvalid(colorStop.stop) || isDescending;
});
}
export function getOtherCategoryLabel() {
return i18n.translate('xpack.maps.styles.categorical.otherCategoryLabel', {
defaultMessage: 'Other',
});
}

View file

@ -7,56 +7,146 @@
import _ from 'lodash';
import React, { Fragment } from 'react';
import { FieldSelect } from '../field_select';
import { ColorRampSelect } from './color_ramp_select';
import { ColorMapSelect } from './color_map_select';
import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui';
import { CATEGORICAL_DATA_TYPES, COLOR_MAP_TYPE } from '../../../../../../common/constants';
import { COLOR_GRADIENTS, COLOR_PALETTES } from '../../../color_utils';
import { i18n } from '@kbn/i18n';
export function DynamicColorForm({
fields,
onDynamicStyleChange,
staticDynamicSelect,
styleProperty,
}) {
const styleOptions = styleProperty.getOptions();
const onFieldChange = ({ field }) => {
onDynamicStyleChange(styleProperty.getStyleName(), { ...styleOptions, field });
export class DynamicColorForm extends React.Component {
state = {
colorMapType: COLOR_MAP_TYPE.ORDINAL,
};
const onColorChange = colorOptions => {
onDynamicStyleChange(styleProperty.getStyleName(), {
...styleOptions,
...colorOptions,
});
};
let colorRampSelect;
if (styleOptions.field && styleOptions.field.name) {
colorRampSelect = (
<ColorRampSelect
onChange={onColorChange}
color={styleOptions.color}
customColorRamp={styleOptions.customColorRamp}
useCustomColorRamp={_.get(styleOptions, 'useCustomColorRamp', false)}
compressed
/>
);
constructor() {
super();
this._isMounted = false;
}
return (
<Fragment>
<EuiFlexGroup gutterSize="none" justifyContent="flexEnd">
<EuiFlexItem grow={false}>{staticDynamicSelect}</EuiFlexItem>
<EuiFlexItem>
<FieldSelect
fields={fields}
selectedFieldName={_.get(styleOptions, 'field.name')}
onChange={onFieldChange}
compressed
/>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="s" />
{colorRampSelect}
</Fragment>
);
componentWillUnmount() {
this._isMounted = false;
}
componentDidMount() {
this._isMounted = true;
this._loadColorMapType();
}
componentDidUpdate() {
this._loadColorMapType();
}
async _loadColorMapType() {
const field = this.props.styleProperty.getField();
if (!field) {
return;
}
const dataType = await field.getDataType();
const colorMapType = CATEGORICAL_DATA_TYPES.includes(dataType)
? COLOR_MAP_TYPE.CATEGORICAL
: COLOR_MAP_TYPE.ORDINAL;
if (this._isMounted && this.state.colorMapType !== colorMapType) {
this.setState({ colorMapType }, () => {
const options = this.props.styleProperty.getOptions();
this.props.onDynamicStyleChange(this.props.styleProperty.getStyleName(), {
...options,
type: colorMapType,
});
});
}
}
_getColorSelector() {
const { onDynamicStyleChange, styleProperty } = this.props;
const styleOptions = styleProperty.getOptions();
if (!styleOptions.field || !styleOptions.field.name) {
return;
}
let colorSelect;
const onColorChange = colorOptions => {
const newColorOptions = {
type: colorOptions.type,
};
if (colorOptions.type === COLOR_MAP_TYPE.ORDINAL) {
newColorOptions.useCustomColorRamp = colorOptions.useCustomColorMap;
newColorOptions.customColorRamp = colorOptions.customColorMap;
newColorOptions.color = colorOptions.color;
} else {
newColorOptions.useCustomColorPalette = colorOptions.useCustomColorMap;
newColorOptions.customColorPalette = colorOptions.customColorMap;
newColorOptions.colorCategory = colorOptions.color;
}
onDynamicStyleChange(styleProperty.getStyleName(), {
...styleOptions,
...newColorOptions,
});
};
if (this.state.colorMapType === COLOR_MAP_TYPE.ORDINAL) {
const customOptionLabel = i18n.translate('xpack.maps.style.customColorRampLabel', {
defaultMessage: 'Custom color ramp',
});
colorSelect = (
<ColorMapSelect
colorMapOptions={COLOR_GRADIENTS}
customOptionLabel={customOptionLabel}
onChange={options => onColorChange(options)}
colorMapType={COLOR_MAP_TYPE.ORDINAL}
color={styleOptions.color}
customColorMap={styleOptions.customColorRamp}
useCustomColorMap={_.get(styleOptions, 'useCustomColorRamp', false)}
compressed
/>
);
} else if (this.state.colorMapType === COLOR_MAP_TYPE.CATEGORICAL) {
const customOptionLabel = i18n.translate('xpack.maps.style.customColorPaletteLabel', {
defaultMessage: 'Custom color palette',
});
colorSelect = (
<ColorMapSelect
colorMapOptions={COLOR_PALETTES}
customOptionLabel={customOptionLabel}
onChange={options => onColorChange(options)}
colorMapType={COLOR_MAP_TYPE.CATEGORICAL}
color={styleOptions.colorCategory}
customColorMap={styleOptions.customColorPalette}
useCustomColorMap={_.get(styleOptions, 'useCustomColorPalette', false)}
compressed
/>
);
}
return colorSelect;
}
render() {
const { fields, onDynamicStyleChange, staticDynamicSelect, styleProperty } = this.props;
const styleOptions = styleProperty.getOptions();
const onFieldChange = options => {
const field = options.field;
onDynamicStyleChange(styleProperty.getStyleName(), { ...styleOptions, field });
};
const colorSelect = this._getColorSelector();
return (
<Fragment>
<EuiFlexGroup gutterSize="none" justifyContent="flexEnd">
<EuiFlexItem grow={false}>{staticDynamicSelect}</EuiFlexItem>
<EuiFlexItem>
<FieldSelect
fields={fields}
selectedFieldName={_.get(styleOptions, 'field.name')}
onChange={onFieldChange}
compressed
/>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="s" />
{colorSelect}
</Fragment>
);
}
}

View file

@ -5,7 +5,8 @@
*/
import { VectorStyle } from '../../vector_style';
import { getColorRampCenterColor } from '../../../color_utils';
import { getColorRampCenterColor, getColorPalette } from '../../../color_utils';
import { COLOR_MAP_TYPE } from '../../../../../../common/constants';
export function extractColorFromStyleProperty(colorStyleProperty, defaultColor) {
if (!colorStyleProperty) {
@ -21,19 +22,37 @@ export function extractColorFromStyleProperty(colorStyleProperty, defaultColor)
return defaultColor;
}
// return middle of gradient for dynamic style property
if (colorStyleProperty.options.useCustomColorRamp) {
if (
!colorStyleProperty.options.customColorRamp ||
!colorStyleProperty.options.customColorRamp.length
) {
return defaultColor;
if (colorStyleProperty.options.type === COLOR_MAP_TYPE.CATEGORICAL) {
if (colorStyleProperty.options.useCustomColorPalette) {
return colorStyleProperty.options.customColorPalette &&
colorStyleProperty.options.customColorPalette.length
? colorStyleProperty.options.customColorPalette[0].colorCategory
: defaultColor;
}
// favor the lowest color in even arrays
const middleIndex = Math.floor((colorStyleProperty.options.customColorRamp.length - 1) / 2);
return colorStyleProperty.options.customColorRamp[middleIndex].color;
}
return getColorRampCenterColor(colorStyleProperty.options.color);
if (!colorStyleProperty.options.colorCategory) {
return null;
}
const palette = getColorPalette(colorStyleProperty.options.colorCategory);
return palette[0];
} else {
// return middle of gradient for dynamic style property
if (colorStyleProperty.options.useCustomColorRamp) {
if (
!colorStyleProperty.options.customColorRamp ||
!colorStyleProperty.options.customColorRamp.length
) {
return defaultColor;
}
// favor the lowest color in even arrays
const middleIndex = Math.floor((colorStyleProperty.options.customColorRamp.length - 1) / 2);
return colorStyleProperty.options.customColorRamp[middleIndex].color;
}
if (!colorStyleProperty.options.color) {
return null;
}
return getColorRampCenterColor(colorStyleProperty.options.color);
}
}

View file

@ -31,7 +31,7 @@ function getIsEnableToggleLabel(styleName) {
}
}
export class FieldMetaOptionsPopover extends Component {
export class OrdinalFieldMetaOptionsPopover extends Component {
state = {
isPopoverOpen: false,
};

View file

@ -5,7 +5,6 @@
*/
import React, { Component, Fragment } from 'react';
import { FieldMetaOptionsPopover } from './field_meta_options_popover';
import { getVectorStyleLabel } from './get_vector_style_label';
import { EuiFormRow, EuiSelect } from '@elastic/eui';
import { VectorStyle } from '../vector_style';
@ -80,12 +79,9 @@ export class StylePropEditor extends Component {
}
render() {
const fieldMetaOptionsPopover = this.props.styleProperty.isDynamic() ? (
<FieldMetaOptionsPopover
styleProperty={this.props.styleProperty}
onChange={this._onFieldMetaOptionsChange}
/>
) : null;
const fieldMetaOptionsPopover = this.props.styleProperty.renderFieldMetaPopover(
this._onFieldMetaOptionsChange
);
return (
<EuiFormRow

View file

@ -32,6 +32,7 @@ export class VectorStyleEditor extends Component {
state = {
dateFields: [],
numberFields: [],
categoricalFields: [],
fields: [],
defaultDynamicProperties: getDefaultDynamicProperties(),
defaultStaticProperties: getDefaultStaticProperties(),
@ -77,6 +78,13 @@ export class VectorStyleEditor extends Component {
this.setState({ numberFields: numberFieldsArray });
}
const categoricalFields = await this.props.layer.getCategoricalFields();
const categoricalFieldMeta = categoricalFields.map(getFieldMeta);
const categoricalFieldsArray = await Promise.all(categoricalFieldMeta);
if (this._isMounted && !_.isEqual(categoricalFieldsArray, this.state.categoricalFields)) {
this.setState({ categoricalFields: categoricalFieldsArray });
}
const fields = await this.props.layer.getFields();
const fieldPromises = fields.map(getFieldMeta);
const fieldsArray = await Promise.all(fieldPromises);
@ -110,6 +118,10 @@ export class VectorStyleEditor extends Component {
return [...this.state.dateFields, ...this.state.numberFields];
}
_getOrdinalAndCategoricalFields() {
return [...this.state.dateFields, ...this.state.numberFields, ...this.state.categoricalFields];
}
_handleSelectedFeatureChange = selectedFeature => {
this.setState({ selectedFeature });
};
@ -141,7 +153,7 @@ export class VectorStyleEditor extends Component {
onStaticStyleChange={this._onStaticStyleChange}
onDynamicStyleChange={this._onDynamicStyleChange}
styleProperty={this.props.styleProperties[VECTOR_STYLES.FILL_COLOR]}
fields={this._getOrdinalFields()}
fields={this._getOrdinalAndCategoricalFields()}
defaultStaticStyleOptions={
this.state.defaultStaticProperties[VECTOR_STYLES.FILL_COLOR].options
}
@ -159,7 +171,7 @@ export class VectorStyleEditor extends Component {
onStaticStyleChange={this._onStaticStyleChange}
onDynamicStyleChange={this._onDynamicStyleChange}
styleProperty={this.props.styleProperties[VECTOR_STYLES.LINE_COLOR]}
fields={this._getOrdinalFields()}
fields={this._getOrdinalAndCategoricalFields()}
defaultStaticStyleOptions={
this.state.defaultStaticProperties[VECTOR_STYLES.LINE_COLOR].options
}
@ -226,7 +238,7 @@ export class VectorStyleEditor extends Component {
onStaticStyleChange={this._onStaticStyleChange}
onDynamicStyleChange={this._onDynamicStyleChange}
styleProperty={this.props.styleProperties[VECTOR_STYLES.LABEL_COLOR]}
fields={this._getOrdinalFields()}
fields={this._getOrdinalAndCategoricalFields()}
defaultStaticStyleOptions={
this.state.defaultStaticProperties[VECTOR_STYLES.LABEL_COLOR].options
}
@ -255,7 +267,7 @@ export class VectorStyleEditor extends Component {
onStaticStyleChange={this._onStaticStyleChange}
onDynamicStyleChange={this._onDynamicStyleChange}
styleProperty={this.props.styleProperties[VECTOR_STYLES.LABEL_BORDER_COLOR]}
fields={this._getOrdinalFields()}
fields={this._getOrdinalAndCategoricalFields()}
defaultStaticStyleOptions={
this.state.defaultStaticProperties[VECTOR_STYLES.LABEL_BORDER_COLOR].options
}

View file

@ -1,8 +1,128 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Should render categorical legend 1`] = `""`;
exports[`Should render categorical legend with breaks from custom 1`] = `""`;
exports[`Should render ranged legend 1`] = `
exports[`Should render categorical legend with breaks from default 1`] = `
<div>
<EuiSpacer
size="s"
/>
<EuiFlexGroup
direction="column"
gutterSize="none"
>
<EuiFlexItem
key="0"
>
<EuiFlexGroup
direction="row"
gutterSize="none"
>
<EuiFlexItem>
<EuiText
size="xs"
>
US_format
</EuiText>
</EuiFlexItem>
<EuiFlexItem>
<VectorIcon
fillColor="none"
isLinesOnly={false}
isPointsOnly={true}
strokeColor="#5BBAA0"
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem
key="1"
>
<EuiFlexGroup
direction="row"
gutterSize="none"
>
<EuiFlexItem>
<EuiText
size="xs"
>
CN_format
</EuiText>
</EuiFlexItem>
<EuiFlexItem>
<VectorIcon
fillColor="none"
isLinesOnly={false}
isPointsOnly={true}
strokeColor="#6092C0"
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem
key="2"
>
<EuiFlexGroup
direction="row"
gutterSize="none"
>
<EuiFlexItem>
<EuiText
size="xs"
>
<EuiTextColor
color="secondary"
>
Other
</EuiTextColor>
</EuiText>
</EuiFlexItem>
<EuiFlexItem>
<VectorIcon
fillColor="none"
isLinesOnly={false}
isPointsOnly={true}
strokeColor="#D36086"
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
<EuiFlexGroup
gutterSize="xs"
justifyContent="spaceAround"
>
<EuiFlexItem
grow={false}
>
<EuiToolTip
content="foobar_label"
delay="regular"
position="top"
title="Border color"
>
<EuiText
className="eui-textTruncate"
size="xs"
style={
Object {
"maxWidth": "180px",
}
}
>
<small>
<strong>
foobar_label
</strong>
</small>
</EuiText>
</EuiToolTip>
</EuiFlexItem>
</EuiFlexGroup>
</div>
`;
exports[`Should render ordinal legend 1`] = `
<RangedStyleLegendRow
fieldLabel=""
header={
@ -15,3 +135,95 @@ exports[`Should render ranged legend 1`] = `
propertyLabel="Border color"
/>
`;
exports[`Should render ordinal legend with breaks 1`] = `
<div>
<EuiSpacer
size="s"
/>
<EuiFlexGroup
direction="column"
gutterSize="none"
>
<EuiFlexItem
key="0"
>
<EuiFlexGroup
direction="row"
gutterSize="none"
>
<EuiFlexItem>
<EuiText
size="xs"
>
0_format
</EuiText>
</EuiFlexItem>
<EuiFlexItem>
<VectorIcon
fillColor="none"
isLinesOnly={false}
isPointsOnly={true}
strokeColor="#FF0000"
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem
key="1"
>
<EuiFlexGroup
direction="row"
gutterSize="none"
>
<EuiFlexItem>
<EuiText
size="xs"
>
10_format
</EuiText>
</EuiFlexItem>
<EuiFlexItem>
<VectorIcon
fillColor="none"
isLinesOnly={false}
isPointsOnly={true}
strokeColor="#00FF00"
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
<EuiFlexGroup
gutterSize="xs"
justifyContent="spaceAround"
>
<EuiFlexItem
grow={false}
>
<EuiToolTip
content="foobar_label"
delay="regular"
position="top"
title="Border color"
>
<EuiText
className="eui-textTruncate"
size="xs"
style={
Object {
"maxWidth": "180px",
}
}
>
<small>
<strong>
foobar_label
</strong>
</small>
</EuiText>
</EuiToolTip>
</EuiFlexItem>
</EuiFlexGroup>
</div>
`;

View file

@ -7,12 +7,26 @@
import { DynamicStyleProperty } from './dynamic_style_property';
import _ from 'lodash';
import { getComputedFieldName } from '../style_util';
import { getColorRampStops } from '../../color_utils';
import { getOrdinalColorRampStops, getColorPalette } from '../../color_utils';
import { ColorGradient } from '../../components/color_gradient';
import React from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiText, EuiToolTip } from '@elastic/eui';
import {
EuiFlexGroup,
EuiFlexItem,
EuiSpacer,
EuiText,
EuiToolTip,
EuiTextColor,
} from '@elastic/eui';
import { VectorIcon } from '../components/legend/vector_icon';
import { VECTOR_STYLES } from '../vector_style_defaults';
import { COLOR_MAP_TYPE } from '../../../../../common/constants';
import {
isCategoricalStopsInvalid,
getOtherCategoryLabel,
} from '../components/color/color_stops_utils';
const EMPTY_STOPS = { stops: [], defaultColor: null };
export class DynamicColorProperty extends DynamicStyleProperty {
syncCircleColorWithMb(mbLayerId, mbMap, alpha) {
@ -60,7 +74,17 @@ export class DynamicColorProperty extends DynamicStyleProperty {
mbMap.setPaintProperty(mbLayerId, 'text-halo-color', color);
}
isCustomColorRamp() {
isOrdinal() {
return (
typeof this._options.type === 'undefined' || this._options.type === COLOR_MAP_TYPE.ORDINAL
);
}
isCategorical() {
return this._options.type === COLOR_MAP_TYPE.CATEGORICAL;
}
isCustomOrdinalColorRamp() {
return this._options.useCustomColorRamp;
}
@ -68,16 +92,16 @@ export class DynamicColorProperty extends DynamicStyleProperty {
return true;
}
isScaled() {
return !this.isCustomColorRamp();
isOrdinalScaled() {
return this.isOrdinal() && !this.isCustomOrdinalColorRamp();
}
isRanged() {
return !this.isCustomColorRamp();
isOrdinalRanged() {
return this.isOrdinal() && !this.isCustomOrdinalColorRamp();
}
hasBreaks() {
return this.isCustomColorRamp();
hasOrdinalBreaks() {
return (this.isOrdinal() && this.isCustomOrdinalColorRamp()) || this.isCategorical();
}
_getMbColor() {
@ -87,6 +111,15 @@ export class DynamicColorProperty extends DynamicStyleProperty {
return null;
}
const targetName = getComputedFieldName(this._styleName, this._options.field.name);
if (this.isCategorical()) {
return this._getMbDataDrivenCategoricalColor({ targetName });
} else {
return this._getMbDataDrivenOrdinalColor({ targetName });
}
}
_getMbDataDrivenOrdinalColor({ targetName }) {
if (
this._options.useCustomColorRamp &&
(!this._options.customColorRamp || !this._options.customColorRamp.length)
@ -94,15 +127,12 @@ export class DynamicColorProperty extends DynamicStyleProperty {
return null;
}
return this._getMBDataDrivenColor({
targetName: getComputedFieldName(this._styleName, this._options.field.name),
colorStops: this._getMBColorStops(),
isSteps: this._options.useCustomColorRamp,
});
}
const colorStops = this._getMbOrdinalColorStops();
if (!colorStops) {
return null;
}
_getMBDataDrivenColor({ targetName, colorStops, isSteps }) {
if (isSteps) {
if (this._options.useCustomColorRamp) {
const firstStopValue = colorStops[0];
const lessThenFirstStopValue = firstStopValue - 1;
return [
@ -112,7 +142,6 @@ export class DynamicColorProperty extends DynamicStyleProperty {
...colorStops,
];
}
return [
'interpolate',
['linear'],
@ -123,14 +152,92 @@ export class DynamicColorProperty extends DynamicStyleProperty {
];
}
_getMBColorStops() {
_getColorPaletteStops() {
if (this._options.useCustomColorPalette && this._options.customColorPalette) {
if (isCategoricalStopsInvalid(this._options.customColorPalette)) {
return EMPTY_STOPS;
}
const stops = [];
for (let i = 1; i < this._options.customColorPalette.length; i++) {
const config = this._options.customColorPalette[i];
stops.push({
stop: config.stop,
color: config.color,
});
}
return {
defaultColor: this._options.customColorPalette[0].color,
stops,
};
}
const fieldMeta = this.getFieldMeta();
if (!fieldMeta || !fieldMeta.categories) {
return EMPTY_STOPS;
}
const colors = getColorPalette(this._options.colorCategory);
if (!colors) {
return EMPTY_STOPS;
}
const maxLength = Math.min(colors.length, fieldMeta.categories.length + 1);
const stops = [];
for (let i = 0; i < maxLength - 1; i++) {
stops.push({
stop: fieldMeta.categories[i].key,
color: colors[i],
});
}
return {
stops,
defaultColor: colors[maxLength - 1],
};
}
_getMbDataDrivenCategoricalColor() {
if (
this._options.useCustomColorPalette &&
(!this._options.customColorPalette || !this._options.customColorPalette.length)
) {
return null;
}
const { stops, defaultColor } = this._getColorPaletteStops();
if (stops.length < 1) {
//occurs when no data
return null;
}
if (!defaultColor) {
return null;
}
const mbStops = [];
for (let i = 0; i < stops.length; i++) {
const stop = stops[i];
const branch = `${stop.stop}`;
if (typeof branch === 'string') {
mbStops.push(branch);
mbStops.push(stop.color);
}
}
mbStops.push(defaultColor); //last color is default color
return ['match', ['get', this._options.field.name], ...mbStops];
}
_getMbOrdinalColorStops() {
if (this._options.useCustomColorRamp) {
return this._options.customColorRamp.reduce((accumulatedStops, nextStop) => {
return [...accumulatedStops, nextStop.stop, nextStop.color];
}, []);
} else {
return getOrdinalColorRampStops(this._options.color);
}
return getColorRampStops(this._options.color);
}
renderRangeLegendHeader() {
@ -163,18 +270,47 @@ export class DynamicColorProperty extends DynamicStyleProperty {
);
}
_getColorRampStops() {
return this._options.useCustomColorRamp && this._options.customColorRamp
? this._options.customColorRamp
: [];
}
_getColorStops() {
if (this.isOrdinal()) {
return {
stops: this._getColorRampStops(),
defaultColor: null,
};
} else if (this.isCategorical()) {
return this._getColorPaletteStops();
} else {
return EMPTY_STOPS;
}
}
_renderColorbreaks({ isLinesOnly, isPointsOnly, symbolId }) {
if (!this._options.customColorRamp) {
return null;
const { stops, defaultColor } = this._getColorStops();
const colorAndLabels = stops.map(config => {
return {
label: this.formatField(config.stop),
color: config.color,
};
});
if (defaultColor) {
colorAndLabels.push({
label: <EuiTextColor color="secondary">{getOtherCategoryLabel()}</EuiTextColor>,
color: defaultColor,
});
}
return this._options.customColorRamp.map((config, index) => {
const value = this.formatField(config.stop);
return colorAndLabels.map((config, index) => {
return (
<EuiFlexItem key={index}>
<EuiFlexGroup direction={'row'} gutterSize={'none'}>
<EuiFlexItem>
<EuiText size={'xs'}>{value}</EuiText>
<EuiText size={'xs'}>{config.label}</EuiText>
</EuiFlexItem>
<EuiFlexItem>
{this._renderStopIcon(config.color, isLinesOnly, isPointsOnly, symbolId)}

View file

@ -15,12 +15,12 @@ import { shallow } from 'enzyme';
import { VECTOR_STYLES } from '../vector_style_defaults';
import { DynamicColorProperty } from './dynamic_color_property';
import { COLOR_MAP_TYPE } from '../../../../../common/constants';
const mockField = {
async getLabel() {
return 'foobar_label';
},
getName() {
return 'foobar';
},
@ -29,33 +29,61 @@ const mockField = {
},
};
test('Should render ranged legend', () => {
const colorStyle = new DynamicColorProperty(
{
color: 'Blues',
},
const getOrdinalFieldMeta = () => {
return { min: 0, max: 100 };
};
const getCategoricalFieldMeta = () => {
return {
categories: [
{
key: 'US',
count: 10,
},
{
key: 'CN',
count: 8,
},
],
};
};
const makeProperty = (options, getFieldMeta) => {
return new DynamicColorProperty(
options,
VECTOR_STYLES.LINE_COLOR,
mockField,
() => {
return { min: 0, max: 100 };
},
getFieldMeta,
() => {
return x => x + '_format';
}
);
};
const defaultLegendParams = {
isPointsOnly: true,
isLinesOnly: false,
};
test('Should render ordinal legend', async () => {
const colorStyle = makeProperty(
{
color: 'Blues',
type: undefined,
},
getOrdinalFieldMeta
);
const legendRow = colorStyle.renderLegendDetailRow(defaultLegendParams);
const legendRow = colorStyle.renderLegendDetailRow({
isPointsOnly: true,
isLinesOnly: false,
});
const component = shallow(legendRow);
expect(component).toMatchSnapshot();
});
test('Should render categorical legend', () => {
const colorStyle = new DynamicColorProperty(
test('Should render ordinal legend with breaks', async () => {
const colorStyle = makeProperty(
{
type: COLOR_MAP_TYPE.ORDINAL,
useCustomColorRamp: true,
customColorRamp: [
{
@ -68,21 +96,128 @@ test('Should render categorical legend', () => {
},
],
},
VECTOR_STYLES.LINE_COLOR,
mockField,
() => {
return { min: 0, max: 100 };
},
() => {
return x => x + '_format';
}
getOrdinalFieldMeta
);
const legendRow = colorStyle.renderLegendDetailRow({
isPointsOnly: true,
isLinesOnly: false,
});
const legendRow = colorStyle.renderLegendDetailRow(defaultLegendParams);
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 categorical legend with breaks from default', async () => {
const colorStyle = makeProperty(
{
type: COLOR_MAP_TYPE.CATEGORICAL,
useCustomColorPalette: false,
colorCategory: 'palette_0',
},
getCategoricalFieldMeta
);
const legendRow = colorStyle.renderLegendDetailRow(defaultLegendParams);
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 categorical legend with breaks from custom', async () => {
const colorStyle = makeProperty(
{
type: COLOR_MAP_TYPE.CATEGORICAL,
useCustomColorPalette: true,
customColorPalette: [
{
stop: null, //should include the default stop
color: '#FFFF00',
},
{
stop: 'US_STOP',
color: '#FF0000',
},
{
stop: 'CN_STOP',
color: '#00FF00',
},
],
},
getCategoricalFieldMeta
);
const legendRow = colorStyle.renderLegendDetailRow(defaultLegendParams);
const component = shallow(legendRow);
expect(component).toMatchSnapshot();
});
function makeFeatures(foobarPropValues) {
return foobarPropValues.map(value => {
return {
type: 'Feature',
properties: {
foobar: value,
},
};
});
}
test('Should pluck the categorical style-meta', async () => {
const colorStyle = makeProperty({
type: COLOR_MAP_TYPE.CATEGORICAL,
colorCategory: 'palette_0',
getCategoricalFieldMeta,
});
const features = makeFeatures(['CN', 'CN', 'US', 'CN', 'US', 'IN']);
const meta = colorStyle.pluckStyleMetaFromFeatures(features);
expect(meta).toEqual({
categories: [
{ key: 'CN', count: 3 },
{ key: 'US', count: 2 },
{ key: 'IN', count: 1 },
],
});
});
test('Should pluck the categorical style-meta from fieldmeta', async () => {
const colorStyle = makeProperty({
type: COLOR_MAP_TYPE.CATEGORICAL,
colorCategory: 'palette_0',
getCategoricalFieldMeta,
});
const meta = colorStyle.pluckStyleMetaFromFieldMetaData({
foobar: {
buckets: [
{
key: 'CN',
doc_count: 3,
},
{ key: 'US', doc_count: 2 },
{ key: 'IN', doc_count: 1 },
],
},
});
expect(meta).toEqual({
categories: [
{ key: 'CN', count: 3 },
{ key: 'US', count: 2 },
{ key: 'IN', count: 1 },
],
});
});

View file

@ -26,7 +26,7 @@ export class DynamicOrientationProperty extends DynamicStyleProperty {
return false;
}
isScaled() {
isOrdinalScaled() {
return false;
}
}

View file

@ -7,11 +7,12 @@
import _ from 'lodash';
import { AbstractStyleProperty } from './style_property';
import { DEFAULT_SIGMA } from '../vector_style_defaults';
import { STYLE_TYPE } from '../../../../../common/constants';
import { COLOR_PALETTE_MAX_SIZE, STYLE_TYPE } from '../../../../../common/constants';
import { scaleValue, getComputedFieldName } from '../style_util';
import React from 'react';
import { OrdinalLegend } from './components/ordinal_legend';
import { CategoricalLegend } from './components/categorical_legend';
import { OrdinalFieldMetaOptionsPopover } from '../components/ordinal_field_meta_options_popover';
export class DynamicStyleProperty extends AbstractStyleProperty {
static type = STYLE_TYPE.DYNAMIC;
@ -46,11 +47,15 @@ export class DynamicStyleProperty extends AbstractStyleProperty {
return true;
}
hasBreaks() {
isCategorical() {
return false;
}
isRanged() {
hasOrdinalBreaks() {
return false;
}
isOrdinalRanged() {
return true;
}
@ -68,21 +73,33 @@ export class DynamicStyleProperty extends AbstractStyleProperty {
}
supportsFieldMeta() {
return this.isComplete() && this.isScaled() && this._field.supportsFieldMeta();
if (this.isOrdinal()) {
return this.isComplete() && this.isOrdinalScaled() && this._field.supportsFieldMeta();
} else if (this.isCategorical()) {
return this.isComplete() && this._field.supportsFieldMeta();
} else {
return false;
}
}
async getFieldMetaRequest() {
const fieldMetaOptions = this.getFieldMetaOptions();
return this._field.getFieldMetaRequest({
sigma: _.get(fieldMetaOptions, 'sigma', DEFAULT_SIGMA),
});
if (this.isOrdinal()) {
const fieldMetaOptions = this.getFieldMetaOptions();
return this._field.getOrdinalFieldMetaRequest({
sigma: _.get(fieldMetaOptions, 'sigma', DEFAULT_SIGMA),
});
} else if (this.isCategorical()) {
return this._field.getCategoricalFieldMetaRequest();
} else {
return null;
}
}
supportsFeatureState() {
return true;
}
isScaled() {
isOrdinalScaled() {
return true;
}
@ -90,11 +107,7 @@ export class DynamicStyleProperty extends AbstractStyleProperty {
return _.get(this.getOptions(), 'fieldMetaOptions', {});
}
pluckStyleMetaFromFeatures(features) {
if (!this.isOrdinal()) {
return null;
}
_pluckOrdinalStyleMetaFromFeatures(features) {
const name = this.getField().getName();
let min = Infinity;
let max = -Infinity;
@ -116,11 +129,47 @@ export class DynamicStyleProperty extends AbstractStyleProperty {
};
}
pluckStyleMetaFromFieldMetaData(fieldMetaData) {
if (!this.isOrdinal()) {
return null;
_pluckCategoricalStyleMetaFromFeatures(features) {
const fieldName = this.getField().getName();
const counts = new Map();
for (let i = 0; i < features.length; i++) {
const feature = features[i];
const term = feature.properties[fieldName];
//properties object may be sparse, so need to check if the field is effectively present
if (typeof term !== undefined) {
if (counts.has(term)) {
counts.set(term, counts.get(term) + 1);
} else {
counts.set(term, 1);
}
}
}
const ordered = [];
for (const [key, value] of counts) {
ordered.push({ key, count: value });
}
ordered.sort((a, b) => {
return b.count - a.count;
});
const truncated = ordered.slice(0, COLOR_PALETTE_MAX_SIZE);
return {
categories: truncated,
};
}
pluckStyleMetaFromFeatures(features) {
if (this.isOrdinal()) {
return this._pluckOrdinalStyleMetaFromFeatures(features);
} else if (this.isCategorical()) {
return this._pluckCategoricalStyleMetaFromFeatures(features);
} else {
return null;
}
}
_pluckOrdinalStyleMetaFromFieldMetaData(fieldMetaData) {
const realFieldName = this._field.getESDocFieldName
? this._field.getESDocFieldName()
: this._field.getName();
@ -143,6 +192,33 @@ export class DynamicStyleProperty extends AbstractStyleProperty {
};
}
_pluckCategoricalStyleMetaFromFieldMetaData(fieldMetaData) {
const name = this.getField().getName();
if (!fieldMetaData[name] || !fieldMetaData[name].buckets) {
return null;
}
const ordered = fieldMetaData[name].buckets.map(bucket => {
return {
key: bucket.key,
count: bucket.doc_count,
};
});
return {
categories: ordered,
};
}
pluckStyleMetaFromFieldMetaData(fieldMetaData) {
if (this.isOrdinal()) {
return this._pluckOrdinalStyleMetaFromFieldMetaData(fieldMetaData);
} else if (this.isCategorical()) {
return this._pluckCategoricalStyleMetaFromFieldMetaData(fieldMetaData);
} else {
return null;
}
}
formatField(value) {
if (this.getField()) {
const fieldName = this.getField().getName();
@ -159,7 +235,7 @@ export class DynamicStyleProperty extends AbstractStyleProperty {
}
const valueAsFloat = parseFloat(value);
if (this.isScaled()) {
if (this.isOrdinalScaled()) {
return scaleValue(valueAsFloat, this.getFieldMeta());
}
if (isNaN(valueAsFloat)) {
@ -188,12 +264,28 @@ export class DynamicStyleProperty extends AbstractStyleProperty {
}
renderLegendDetailRow({ isPointsOnly, isLinesOnly, symbolId }) {
if (this.isRanged()) {
return this._renderRangeLegend();
} else if (this.hasBreaks()) {
if (this.isOrdinal()) {
if (this.isOrdinalRanged()) {
return this._renderRangeLegend();
} else if (this.hasOrdinalBreaks()) {
return this._renderCategoricalLegend({ isPointsOnly, isLinesOnly, symbolId });
} else {
return null;
}
} else if (this.isCategorical()) {
return this._renderCategoricalLegend({ isPointsOnly, isLinesOnly, symbolId });
} else {
return null;
}
}
renderFieldMetaPopover(onFieldMetaOptionsChange) {
if (!this.isOrdinal() || !this.supportsFieldMeta()) {
return null;
}
return (
<OrdinalFieldMetaOptionsPopover styleProperty={this} onChange={onFieldMetaOptionsChange} />
);
}
}

View file

@ -29,7 +29,7 @@ export class DynamicTextProperty extends DynamicStyleProperty {
return false;
}
isScaled() {
isOrdinalScaled() {
return false;
}
}

View file

@ -45,6 +45,10 @@ export class AbstractStyleProperty {
return null;
}
renderFieldMetaPopover() {
return null;
}
getDisplayStyleName() {
return getVectorStyleLabel(this.getStyleName());
}

View file

@ -6,7 +6,12 @@
import { VectorStyle } from './vector_style';
import { SYMBOLIZE_AS_CIRCLE, DEFAULT_ICON_SIZE } from './vector_constants';
import { COLOR_GRADIENTS, DEFAULT_FILL_COLORS, DEFAULT_LINE_COLORS } from '../color_utils';
import {
COLOR_GRADIENTS,
COLOR_PALETTES,
DEFAULT_FILL_COLORS,
DEFAULT_LINE_COLORS,
} from '../color_utils';
import chrome from 'ui/chrome';
const DEFAULT_ICON = 'airfield';
@ -136,6 +141,7 @@ export function getDefaultDynamicProperties() {
type: VectorStyle.STYLE_TYPE.DYNAMIC,
options: {
color: COLOR_GRADIENTS[0].value,
colorCategory: COLOR_PALETTES[0].value,
field: undefined,
fieldMetaOptions: {
isEnabled: true,
@ -146,7 +152,7 @@ export function getDefaultDynamicProperties() {
[VECTOR_STYLES.LINE_COLOR]: {
type: VectorStyle.STYLE_TYPE.DYNAMIC,
options: {
color: COLOR_GRADIENTS[0].value,
color: undefined,
field: undefined,
fieldMetaOptions: {
isEnabled: true,
@ -198,6 +204,7 @@ export function getDefaultDynamicProperties() {
type: VectorStyle.STYLE_TYPE.DYNAMIC,
options: {
color: COLOR_GRADIENTS[0].value,
colorCategory: COLOR_PALETTES[0].value,
field: undefined,
fieldMetaOptions: {
isEnabled: true,
@ -221,6 +228,7 @@ export function getDefaultDynamicProperties() {
type: VectorStyle.STYLE_TYPE.DYNAMIC,
options: {
color: COLOR_GRADIENTS[0].value,
colorCategory: COLOR_PALETTES[0].value,
field: undefined,
fieldMetaOptions: {
isEnabled: true,

View file

@ -213,6 +213,10 @@ export class VectorLayer extends AbstractLayer {
return [...(await this.getDateFields()), ...(await this.getNumberFields())];
}
async getCategoricalFields() {
return await this._source.getCategoricalFields();
}
async getFields() {
const sourceFields = await this._source.getFields();
return [...sourceFields, ...this._getJoinFields()];