[Maps] custom color ramp (#41603)

* [Maps] custom color ramp

* round value down to find center color

* do not update redux state with invalide color stops

* rename EuiColorStop to ColorStop

* remove untracked file

* fix jest tests

* review feedback

* use steps instead of interpolate

* add percy functional test to verify rendering of interpolate and step color expressions

* add padding to color stop row so add/remove icons do not overlap color select
This commit is contained in:
Nathan Reese 2019-08-15 12:21:28 -06:00 committed by GitHub
parent df72f91878
commit c6335656d1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 497 additions and 41 deletions

View file

@ -1,2 +1,3 @@
@import './components/color_gradient';
@import './components/static_dynamic_style_row';
@import './components/vector/color/color_stops';

View file

@ -49,7 +49,6 @@ export function getColorRampStops(colorRampName, numberColors = GRADIENT_INTERVA
export const COLOR_GRADIENTS = Object.keys(vislibColorMaps).map(colorRampName => ({
value: colorRampName,
text: colorRampName,
inputDisplay: <ColorGradient colorRampName={colorRampName}/>
}));

View file

@ -17,7 +17,6 @@ describe('COLOR_GRADIENTS', () => {
it('Should contain EuiSuperSelect options list of color ramps', () => {
expect(COLOR_GRADIENTS.length).toBe(6);
const colorGradientOption = COLOR_GRADIENTS[0];
expect(colorGradientOption.text).toBe('Blues');
expect(colorGradientOption.value).toBe('Blues');
});
});

View file

@ -35,42 +35,36 @@ exports[`HeatmapStyleEditor is rendered 1`] = `
"inputDisplay": <ColorGradient
colorRampName="Blues"
/>,
"text": "Blues",
"value": "Blues",
},
Object {
"inputDisplay": <ColorGradient
colorRampName="Greens"
/>,
"text": "Greens",
"value": "Greens",
},
Object {
"inputDisplay": <ColorGradient
colorRampName="Greys"
/>,
"text": "Greys",
"value": "Greys",
},
Object {
"inputDisplay": <ColorGradient
colorRampName="Reds"
/>,
"text": "Reds",
"value": "Reds",
},
Object {
"inputDisplay": <ColorGradient
colorRampName="Yellow to Red"
/>,
"text": "Yellow to Red",
"value": "Yellow to Red",
},
Object {
"inputDisplay": <ColorGradient
colorRampName="Green to Red"
/>,
"text": "Green to Red",
"value": "Green to Red",
},
]

View file

@ -0,0 +1,29 @@
.mapColorStop {
position: relative;
padding-right: $euiSizeXL + $euiSizeXS;
&:hover,
&:focus {
.mapColorStop__icons {
visibility: visible;
opacity: 1;
display: block;
animation: mapColorStopBecomeVisible $euiAnimSpeedFast $euiAnimSlightResistance;
}
}
}
.mapColorStop__icons {
flex-shrink: 0;
display: none;
position: absolute;
right: 0;
top: 50%;
margin-right: -$euiSizeS;
margin-top: -$euiSizeM;
}
@keyframes mapColorStopBecomeVisible {
0% { opacity: 0; }
100% { opacity: 1; }
}

View file

@ -4,29 +4,95 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import React, { Component, Fragment } from 'react';
import PropTypes from 'prop-types';
import { EuiSuperSelect } from '@elastic/eui';
import { EuiSuperSelect, EuiSpacer } from '@elastic/eui';
import { COLOR_GRADIENTS } from '../../../color_utils';
import { FormattedMessage } from '@kbn/i18n/react';
import { ColorStops } from './color_stops';
export function ColorRampSelect({ color, onChange }) {
const onColorRampChange = (selectedColorRampString) => {
onChange({
color: selectedColorRampString
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,
});
};
return (
<EuiSuperSelect
options={COLOR_GRADIENTS}
onChange={onColorRampChange}
valueOfSelected={color}
hasDividers={true}
/>
);
_onCustomColorRampChange = ({ colorStops, isInvalid }) => {
// Manage invalid custom color ramp in local state
if (isInvalid) {
this.setState({ customColorRamp: colorStops });
return;
}
this.props.onChange({
customColorRamp: colorStops,
});
};
render() {
let colorStopsInput;
if (this.props.useCustomColorRamp) {
colorStopsInput = (
<Fragment>
<EuiSpacer size="m" />
<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={this.props.useCustomColorRamp ? CUSTOM_COLOR_RAMP : this.props.color}
hasDividers={true}
/>
{colorStopsInput}
</Fragment>
);
}
}
ColorRampSelect.propTypes = {
color: PropTypes.string.isRequired,
color: PropTypes.string,
onChange: PropTypes.func.isRequired,
useCustomColorRamp: PropTypes.bool,
customColorRamp: PropTypes.array,
};

View file

@ -0,0 +1,169 @@
/*
* 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 _ from 'lodash';
import React from 'react';
import PropTypes from 'prop-types';
import {
EuiColorPicker,
EuiFormRow,
EuiFieldNumber,
EuiFlexGroup,
EuiFlexItem,
EuiButtonIcon
} from '@elastic/eui';
import {
addRow,
removeRow,
isColorInvalid,
isStopInvalid,
isInvalid,
} from './color_stops_utils';
const DEFAULT_COLOR = '#FF0000';
export const ColorStops = ({
colorStops = [{ stop: 0, color: DEFAULT_COLOR }],
onChange,
}) => {
function getStopInput(stop, index) {
const onStopChange = e => {
const newColorStops = _.cloneDeep(colorStops);
const sanitizedValue = parseFloat(e.target.value);
newColorStops[index].stop = isNaN(sanitizedValue) ? '' : sanitizedValue;
onChange({
colorStops: newColorStops,
isInvalid: isInvalid(newColorStops),
});
};
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';
}
return {
stopError: error,
stopInput: (
<EuiFieldNumber
aria-label="Stop"
value={stop}
onChange={onStopChange}
/>
),
};
}
function getColorInput(color, index) {
const onColorChange = color => {
const newColorStops = _.cloneDeep(colorStops);
newColorStops[index].color = color;
onChange({
colorStops: newColorStops,
isInvalid: isInvalid(newColorStops),
});
};
return {
colorError: isColorInvalid(color)
? 'Color must provide a valid hex value'
: undefined,
colorInput: <EuiColorPicker onChange={onColorChange} color={color} />,
};
}
const rows = colorStops.map((colorStop, index) => {
const { stopError, stopInput } = getStopInput(colorStop.stop, index);
const { colorError, colorInput } = getColorInput(colorStop.color, index);
const errors = [];
if (stopError) {
errors.push(stopError);
}
if (colorError) {
errors.push(colorError);
}
const onRemove = () => {
const newColorStops = removeRow(colorStops, index);
onChange({
colorStops: newColorStops,
isInvalid: isInvalid(newColorStops),
});
};
const onAdd = () => {
const newColorStops = addRow(colorStops, index);
onChange({
colorStops: newColorStops,
isInvalid: isInvalid(newColorStops),
});
};
let deleteButton;
if (colorStops.length > 1) {
deleteButton = (
<EuiButtonIcon
iconType="trash"
color="danger"
aria-label="Delete"
title="Delete"
onClick={onRemove}
/>
);
}
return (
<EuiFormRow
key={index}
className="mapColorStop"
isInvalid={errors.length !== 0}
error={errors}
>
<div>
<EuiFlexGroup responsive={false} alignItems="center" gutterSize="s">
<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 <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,66 @@
/*
* 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 { isValidHex } from '@elastic/eui';
export function removeRow(colorStops, index) {
if (colorStops.length === 1) {
return colorStops;
}
return [...colorStops.slice(0, index), ...colorStops.slice(index + 1)];
}
export function addRow(colorStops, index) {
const currentStop = colorStops[index].stop;
let delta = 1;
if (index === colorStops.length - 1) {
// Adding row to end of list.
if (index !== 0) {
const prevStop = colorStops[index - 1].stop;
delta = currentStop - prevStop;
}
} else {
// Adding row in middle of list.
const nextStop = colorStops[index + 1].stop;
delta = (nextStop - currentStop) / 2;
}
const newRow = {
stop: currentStop + delta,
color: '#FF0000',
};
return [
...colorStops.slice(0, index + 1),
newRow,
...colorStops.slice(index + 1),
];
}
export function isColorInvalid(color) {
return !isValidHex(color) || color === '';
}
export function isStopInvalid(stop) {
return stop === '' || isNaN(stop);
}
export function isInvalid(colorStops) {
return colorStops.some((colorStop, index) => {
// expect stops to be in ascending order
let isDescending = false;
if (index !== 0) {
const prevStop = colorStops[index - 1].stop;
isDescending = prevStop >= colorStop.stop;
}
return (
isColorInvalid(colorStop.color) ||
isStopInvalid(colorStop.stop) ||
isDescending
);
});
}

View file

@ -17,8 +17,8 @@ export function DynamicColorSelection({ ordinalFields, onChange, styleOptions })
onChange({ ...styleOptions, field });
};
const onColorChange = ({ color }) => {
onChange({ ...styleOptions, color });
const onColorChange = colorOptions => {
onChange({ ...styleOptions, ...colorOptions });
};
return (
@ -26,6 +26,8 @@ export function DynamicColorSelection({ ordinalFields, onChange, styleOptions })
<ColorRampSelect
onChange={onColorChange}
color={styleOptions.color}
customColorRamp={styleOptions.customColorRamp}
useCustomColorRamp={_.get(styleOptions, 'useCustomColorRamp', false)}
/>
<EuiSpacer size="s" />
<FieldSelect

View file

@ -97,6 +97,17 @@ function extractColorFromStyleProperty(colorStyleProperty, defaultColor) {
}
// 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;
}
return getColorRampCenterColor(colorStyleProperty.options.color);
}

View file

@ -12,8 +12,10 @@ export const staticColorShape = PropTypes.shape({
});
export const dynamicColorShape = PropTypes.shape({
color: PropTypes.string.isRequired,
color: PropTypes.string,
field: fieldShape,
customColorRamp: PropTypes.array,
useCustomColorRamp: PropTypes.bool,
});
export const staticOrientationShape = PropTypes.shape({

View file

@ -326,14 +326,22 @@ export class VectorStyle extends AbstractStyle {
// "feature-state" data expressions are not supported with layout properties.
// To work around this limitation, some styling values must fall back to geojson property values.
let supportsFeatureState = true;
let isScaled = true;
let supportsFeatureState;
let isScaled;
if (styleName === 'iconSize'
&& this._descriptor.properties.symbol.options.symbolizeAs === SYMBOLIZE_AS_ICON) {
supportsFeatureState = false;
isScaled = true;
} else if (styleName === 'iconOrientation') {
supportsFeatureState = false;
isScaled = false;
} else if ((styleName === 'fillColor' || styleName === 'lineColor')
&& options.useCustomColorRamp) {
supportsFeatureState = true;
isScaled = false;
} else {
supportsFeatureState = true;
isScaled = true;
}
return {
@ -417,9 +425,20 @@ export class VectorStyle extends AbstractStyle {
return hasGeoJsonProperties;
}
_getMBDataDrivenColor({ fieldName, color }) {
const colorStops = getColorRampStops(color);
_getMBDataDrivenColor({ fieldName, colorStops, isSteps }) {
const targetName = VectorStyle.getComputedFieldName(fieldName);
if (isSteps) {
const firstStopValue = colorStops[0];
const lessThenFirstStopValue = firstStopValue - 1;
return [
'step',
['coalesce', ['feature-state', targetName], lessThenFirstStopValue],
'rgba(0,0,0,0)', // MB will assign the base value to any features that is below the first stop value
...colorStops
];
}
return [
'interpolate',
['linear'],
@ -448,14 +467,31 @@ export class VectorStyle extends AbstractStyle {
const isDynamicConfigComplete = _.has(styleDescriptor, 'options.field.name')
&& _.has(styleDescriptor, 'options.color');
if (isDynamicConfigComplete) {
return this._getMBDataDrivenColor({
fieldName: styleDescriptor.options.field.name,
color: styleDescriptor.options.color,
});
if (!isDynamicConfigComplete) {
return null;
}
return null;
if (styleDescriptor.options.useCustomColorRamp &&
(!styleDescriptor.options.customColorRamp ||
!styleDescriptor.options.customColorRamp.length)) {
return null;
}
return this._getMBDataDrivenColor({
fieldName: styleDescriptor.options.field.name,
colorStops: this._getMBColorStops(styleDescriptor),
isSteps: styleDescriptor.options.useCustomColorRamp,
});
}
_getMBColorStops(styleDescriptor) {
if (styleDescriptor.options.useCustomColorRamp) {
return styleDescriptor.options.customColorRamp.reduce((accumulatedStops, nextStop) => {
return [...accumulatedStops, nextStop.stop, nextStop.color];
}, []);
}
return getColorRampStops(styleDescriptor.options.color);
}
_isSizeDynamicConfigComplete(styleDescriptor) {

View file

@ -27,7 +27,8 @@
],
"type": "polygon"
},
"name": "alpha"
"name": "alpha",
"prop1": 1
}
}
}
@ -61,7 +62,8 @@
],
"type": "polygon"
},
"name": "bravo"
"name": "bravo",
"prop1": 2
}
}
}
@ -95,7 +97,8 @@
],
"type": "polygon"
},
"name": "charlie"
"name": "charlie",
"prop1": 3
}
}
}
@ -127,7 +130,8 @@
],
"type": "linestring"
},
"name": "tango"
"name": "tango",
"prop1": 4
}
}
}

View file

@ -9,6 +9,9 @@
},
"name": {
"type": "keyword"
},
"prop1": {
"type": "byte"
}
}
},

View file

@ -36,7 +36,7 @@
"index": ".kibana",
"source": {
"index-pattern": {
"fields": "[{\"name\":\"_id\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"_index\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"_score\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"_source\",\"type\":\"_source\",\"count\":0,\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"_type\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"geometry\",\"type\":\"geo_shape\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"name\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true}]",
"fields" : "[{\"name\":\"_id\",\"type\":\"string\",\"esTypes\":[\"_id\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"_index\",\"type\":\"string\",\"esTypes\":[\"_index\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"_score\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"_source\",\"type\":\"_source\",\"esTypes\":[\"_source\"],\"count\":0,\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"_type\",\"type\":\"string\",\"esTypes\":[\"_type\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"geometry\",\"type\":\"geo_shape\",\"esTypes\":[\"geo_shape\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"name\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"prop1\",\"type\":\"number\",\"esTypes\":[\"byte\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true}]",
"title": "geo_shapes*"
},
"type": "index-pattern"
@ -556,6 +556,67 @@
}
}
{
"type": "doc",
"value": {
"id": "map:4cd3e220-bf64-11e9-bbcc-7db09a1519e9",
"index": ".kibana",
"source": {
"map": {
"title" : "join and dynamic coloring demo",
"description" : "",
"mapStateJSON" : "{\"zoom\":3.42,\"center\":{\"lon\":81.67747,\"lat\":1.80586},\"timeFilters\":{\"from\":\"now-17m\",\"to\":\"now\",\"mode\":\"quick\"},\"refreshConfig\":{\"isPaused\":true,\"interval\":1000},\"query\":{\"language\":\"kuery\",\"query\":\"\"},\"filters\":[]}",
"layerListJSON" : "[{\"id\":\"n1t6f\",\"label\":null,\"minZoom\":0,\"maxZoom\":24,\"sourceDescriptor\":{\"id\":\"62eca1fc-fe42-11e8-8eb2-f2801f1b9fd1\",\"type\":\"ES_SEARCH\",\"geoField\":\"geometry\",\"limit\":2048,\"filterByMapBounds\":false,\"showTooltip\":true,\"tooltipProperties\":[\"name\"],\"indexPatternRefName\":\"layer_0_source_index_pattern\"},\"visible\":true,\"temporary\":false,\"style\":{\"type\":\"VECTOR\",\"properties\":{\"fillColor\":{\"type\":\"DYNAMIC\",\"options\":{\"field\":{\"label\":\"max(prop1) group by meta_for_geo_shapes*.shape_name\",\"name\":\"__kbnjoin__max_of_prop1_groupby_meta_for_geo_shapes*.shape_name\",\"origin\":\"join\"},\"color\":null,\"useCustomColorRamp\":true,\"customColorRamp\":[{\"stop\":0,\"color\":\"#E6C220\"},{\"stop\":5,\"color\":\"#F98510\"},{\"stop\":11,\"color\":\"#DB1374\"}]}},\"lineColor\":{\"type\":\"DYNAMIC\",\"options\":{\"color\":\"Blues\",\"field\":{\"label\":\"prop1\",\"name\":\"prop1\",\"origin\":\"source\"},\"useCustomColorRamp\":false}},\"lineWidth\":{\"type\":\"STATIC\",\"options\":{\"size\":10}},\"iconSize\":{\"type\":\"STATIC\",\"options\":{\"size\":10}},\"iconOrientation\":{\"type\":\"STATIC\",\"options\":{\"orientation\":0}},\"symbol\":{\"options\":{\"symbolizeAs\":\"circle\",\"symbolId\":\"airfield\"}}}},\"type\":\"VECTOR\",\"joins\":[{\"leftField\":\"name\",\"right\":{\"id\":\"855ccb86-fe42-11e8-8eb2-f2801f1b9fd1\",\"indexPatternTitle\":\"meta_for_geo_shapes*\",\"term\":\"shape_name\",\"metrics\":[{\"type\":\"max\",\"field\":\"prop1\"}],\"indexPatternRefName\":\"layer_0_join_0_index_pattern\"}}]}]",
"uiStateJSON" : "{\"isLayerTOCOpen\":true,\"openTOCDetails\":[]}",
"bounds" : {
"type" : "Polygon",
"coordinates" : [
[
[
29.45263,
23.75812
],
[
29.45263,
-20.41148
],
[
133.90231,
-20.41148
],
[
133.90231,
23.75812
],
[
29.45263,
23.75812
]
]
]
}
},
"type": "map",
"references" : [
{
"name" : "layer_0_source_index_pattern",
"type" : "index-pattern",
"id" : "561253e0-f731-11e8-8487-11b9dd924f96"
},
{
"name" : "layer_0_join_0_index_pattern",
"type" : "index-pattern",
"id" : "e20b2a30-f735-11e8-8ce0-9723965e01e3"
}
],
"migrationVersion" : {
"map" : "7.2.0"
},
"updated_at" : "2019-08-15T13:56:15.793Z"
}
}
}
{
"type": "doc",
"value": {

View file

@ -21,5 +21,19 @@ export default function ({ getPageObjects, getService }) {
});
});
describe('dynamic coloring', () => {
before(async () => {
await PageObjects.maps.loadSavedMap('join and dynamic coloring demo');
await PageObjects.maps.enterFullScreen();
await PageObjects.maps.closeLegend();
});
// eslint-disable-next-line max-len
it('should symbolize fill color with custom steps from join value and border color with dynamic color ramp from prop value', async () => {
await visualTesting.snapshot();
});
});
});
}