[ML] Color Range Legend component (#52794)

Introduces ColorRangeLegend, a reusable component to display a color range legend to go along with color coded table cells or visualizations such as heatmaps.
This commit is contained in:
Walter Rafelsberger 2019-12-12 19:16:32 +01:00 committed by GitHub
parent 065ca6e041
commit af1a876f21
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 485 additions and 56 deletions

View file

@ -0,0 +1,18 @@
/* Overrides for d3/svg default styles */
.mlColorRangeLegend {
text {
@include fontSize($euiFontSizeXS - 2px);
fill: $euiColorDarkShade;
}
.axis path {
fill: none;
stroke: none;
}
.axis line {
fill: none;
stroke: $euiColorMediumShade;
shape-rendering: crispEdges;
}
}

View file

@ -0,0 +1 @@
@import 'color_range_legend';

View file

@ -0,0 +1,153 @@
/*
* 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, { useEffect, useRef, FC } from 'react';
import d3 from 'd3';
import { EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui';
const COLOR_RANGE_RESOLUTION = 10;
interface ColorRangeLegendProps {
colorRange: (d: number) => string;
justifyTicks?: boolean;
showTicks?: boolean;
title?: string;
width?: number;
}
/**
* Component to render a legend for color ranges to be used for color coding
* table cells and visualizations.
*
* This current version supports normalized value ranges (0-1) only.
*
* @param props ColorRangeLegendProps
*/
export const ColorRangeLegend: FC<ColorRangeLegendProps> = ({
colorRange,
justifyTicks = false,
showTicks = true,
title,
width = 250,
}) => {
const d3Container = useRef<null | SVGSVGElement>(null);
const scale = d3.range(COLOR_RANGE_RESOLUTION + 1).map(d => ({
offset: (d / COLOR_RANGE_RESOLUTION) * 100,
stopColor: colorRange(d / COLOR_RANGE_RESOLUTION),
}));
useEffect(() => {
if (d3Container.current === null) {
return;
}
const wrapperHeight = 32;
const wrapperWidth = width;
// top: 2 — adjust vertical alignment with title text
// bottom: 20 — room for axis ticks and labels
// left/right: 1 — room for first and last axis tick
// when justifyTicks is enabled, the left margin is increased to not cut off the first tick label
const margin = { top: 2, bottom: 20, left: justifyTicks || !showTicks ? 1 : 4, right: 1 };
const legendWidth = wrapperWidth - margin.left - margin.right;
const legendHeight = wrapperHeight - margin.top - margin.bottom;
// remove, then redraw the legend
d3.select(d3Container.current)
.selectAll('*')
.remove();
const wrapper = d3
.select(d3Container.current)
.classed('mlColorRangeLegend', true)
.attr('width', wrapperWidth)
.attr('height', wrapperHeight)
.append('g')
.attr('transform', 'translate(' + margin.left + ',' + margin.top + ')');
// append gradient bar
const gradient = wrapper
.append('defs')
.append('linearGradient')
.attr('id', 'mlColorRangeGradient')
.attr('x1', '0%')
.attr('y1', '0%')
.attr('x2', '100%')
.attr('y2', '0%')
.attr('spreadMethod', 'pad');
scale.forEach(function(d) {
gradient
.append('stop')
.attr('offset', `${d.offset}%`)
.attr('stop-color', d.stopColor)
.attr('stop-opacity', 1);
});
wrapper
.append('rect')
.attr('x1', 0)
.attr('y1', 0)
.attr('width', legendWidth)
.attr('height', legendHeight)
.style('fill', 'url(#mlColorRangeGradient)');
const axisScale = d3.scale
.linear()
.domain([0, 1])
.range([0, legendWidth]);
// Using this formatter ensures we get e.g. `0` and not `0.0`, but still `0.1`, `0.2` etc.
const tickFormat = d3.format('');
const legendAxis = d3.svg
.axis()
.scale(axisScale)
.orient('bottom')
.tickFormat(tickFormat)
.tickSize(legendHeight + 4)
.ticks(legendWidth / 40);
wrapper
.append('g')
.attr('class', 'legend axis')
.attr('transform', 'translate(0, 0)')
.call(legendAxis);
// Adjust the alignment of the first and last tick text
// so that the tick labels don't overflow the color range.
if (justifyTicks || !showTicks) {
const text = wrapper.selectAll('text')[0];
if (text.length > 1) {
d3.select(text[0]).style('text-anchor', 'start');
d3.select(text[text.length - 1]).style('text-anchor', 'end');
}
}
if (!showTicks) {
wrapper.selectAll('.axis line').style('display', 'none');
}
}, [JSON.stringify(scale), d3Container.current]);
if (title === undefined) {
return <svg ref={d3Container} />;
}
return (
<EuiFlexGroup gutterSize="s">
<EuiFlexItem grow={false}>
<EuiText size="xs">
<strong>{title}</strong>
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<svg ref={d3Container} />
</EuiFlexItem>
</EuiFlexGroup>
);
};

View file

@ -0,0 +1,14 @@
/*
* 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.
*/
export { ColorRangeLegend } from './color_range_legend';
export {
colorRangeOptions,
colorRangeScaleOptions,
useColorRange,
COLOR_RANGE,
COLOR_RANGE_SCALE,
} from './use_color_range';

View file

@ -0,0 +1,59 @@
/*
* 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 { influencerColorScaleFactory } from './use_color_range';
jest.mock('../../contexts/ui/use_ui_chrome_context');
describe('useColorRange', () => {
test('influencerColorScaleFactory(1)', () => {
const influencerColorScale = influencerColorScaleFactory(1);
expect(influencerColorScale(0)).toBe(0);
expect(influencerColorScale(0.1)).toBe(0.1);
expect(influencerColorScale(0.2)).toBe(0.2);
expect(influencerColorScale(0.3)).toBe(0.3);
expect(influencerColorScale(0.4)).toBe(0.4);
expect(influencerColorScale(0.5)).toBe(0.5);
expect(influencerColorScale(0.6)).toBe(0.6);
expect(influencerColorScale(0.7)).toBe(0.7);
expect(influencerColorScale(0.8)).toBe(0.8);
expect(influencerColorScale(0.9)).toBe(0.9);
expect(influencerColorScale(1)).toBe(1);
});
test('influencerColorScaleFactory(2)', () => {
const influencerColorScale = influencerColorScaleFactory(2);
expect(influencerColorScale(0)).toBe(0);
expect(influencerColorScale(0.1)).toBe(0);
expect(influencerColorScale(0.2)).toBe(0);
expect(influencerColorScale(0.3)).toBe(0);
expect(influencerColorScale(0.4)).toBe(0);
expect(influencerColorScale(0.5)).toBe(0);
expect(influencerColorScale(0.6)).toBe(0.04999999999999999);
expect(influencerColorScale(0.7)).toBe(0.09999999999999998);
expect(influencerColorScale(0.8)).toBe(0.15000000000000002);
expect(influencerColorScale(0.9)).toBe(0.2);
expect(influencerColorScale(1)).toBe(0.25);
});
test('influencerColorScaleFactory(3)', () => {
const influencerColorScale = influencerColorScaleFactory(3);
expect(influencerColorScale(0)).toBe(0);
expect(influencerColorScale(0.1)).toBe(0);
expect(influencerColorScale(0.2)).toBe(0);
expect(influencerColorScale(0.3)).toBe(0);
expect(influencerColorScale(0.4)).toBe(0.05000000000000003);
expect(influencerColorScale(0.5)).toBe(0.125);
expect(influencerColorScale(0.6)).toBe(0.2);
expect(influencerColorScale(0.7)).toBe(0.27499999999999997);
expect(influencerColorScale(0.8)).toBe(0.35000000000000003);
expect(influencerColorScale(0.9)).toBe(0.425);
expect(influencerColorScale(1)).toBe(0.5);
});
});

View file

@ -0,0 +1,188 @@
/*
* 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 d3 from 'd3';
import euiThemeLight from '@elastic/eui/dist/eui_theme_light.json';
import euiThemeDark from '@elastic/eui/dist/eui_theme_dark.json';
import { i18n } from '@kbn/i18n';
import { useUiChromeContext } from '../../contexts/ui/use_ui_chrome_context';
/**
* Custom color scale factory that takes the amount of feature influencers
* into account to adjust the contrast of the color range. This is used for
* color coding for outlier detection where the amount of feature influencers
* affects the threshold from which the influencers value can actually be
* considered influential.
*
* @param n number of influencers
* @returns a function suitable as a preprocessor for d3.scale.linear()
*/
export const influencerColorScaleFactory = (n: number) => (t: number) => {
// for 1 influencer or less we fall back to a plain linear scale.
if (n <= 1) {
return t;
}
if (t < 1 / n) {
return 0;
}
if (t < 3 / n) {
return (n / 4) * (t - 1 / n);
}
return 0.5 + (t - 3 / n);
};
export enum COLOR_RANGE_SCALE {
LINEAR = 'linear',
INFLUENCER = 'influencer',
SQRT = 'sqrt',
}
/**
* Color range scale options in the format for EuiSelect's options prop.
*/
export const colorRangeScaleOptions = [
{
value: COLOR_RANGE_SCALE.LINEAR,
text: i18n.translate('xpack.ml.components.colorRangeLegend.linearScaleLabel', {
defaultMessage: 'Linear',
}),
},
{
value: COLOR_RANGE_SCALE.INFLUENCER,
text: i18n.translate('xpack.ml.components.colorRangeLegend.influencerScaleLabel', {
defaultMessage: 'Influencer custom scale',
}),
},
{
value: COLOR_RANGE_SCALE.SQRT,
text: i18n.translate('xpack.ml.components.colorRangeLegend.sqrtScaleLabel', {
defaultMessage: 'Sqrt',
}),
},
];
export enum COLOR_RANGE {
BLUE = 'blue',
RED = 'red',
RED_GREEN = 'red-green',
GREEN_RED = 'green-red',
YELLOW_GREEN_BLUE = 'yellow-green-blue',
}
/**
* Color range options in the format for EuiSelect's options prop.
*/
export const colorRangeOptions = [
{
value: COLOR_RANGE.BLUE,
text: i18n.translate('xpack.ml.components.colorRangeLegend.blueColorRangeLabel', {
defaultMessage: 'Blue',
}),
},
{
value: COLOR_RANGE.RED,
text: i18n.translate('xpack.ml.components.colorRangeLegend.redColorRangeLabel', {
defaultMessage: 'Red',
}),
},
{
value: COLOR_RANGE.RED_GREEN,
text: i18n.translate('xpack.ml.components.colorRangeLegend.redGreenColorRangeLabel', {
defaultMessage: 'Red - Green',
}),
},
{
value: COLOR_RANGE.GREEN_RED,
text: i18n.translate('xpack.ml.components.colorRangeLegend.greenRedColorRangeLabel', {
defaultMessage: 'Green - Red',
}),
},
{
value: COLOR_RANGE.YELLOW_GREEN_BLUE,
text: i18n.translate('xpack.ml.components.colorRangeLegend.yellowGreenBlueColorRangeLabel', {
defaultMessage: 'Yellow - Green - Blue',
}),
},
];
/**
* A custom Yellow-Green-Blue color range to demonstrate the support
* for more complex ranges with more than two colors.
*/
const coloursYGB = [
'#FFFFDD',
'#AAF191',
'#80D385',
'#61B385',
'#3E9583',
'#217681',
'#285285',
'#1F2D86',
'#000086',
];
const colourRangeYGB = d3.range(0, 1, 1.0 / (coloursYGB.length - 1));
colourRangeYGB.push(1);
const colorDomains = {
[COLOR_RANGE.BLUE]: [0, 1],
[COLOR_RANGE.RED]: [0, 1],
[COLOR_RANGE.RED_GREEN]: [0, 1],
[COLOR_RANGE.GREEN_RED]: [0, 1],
[COLOR_RANGE.YELLOW_GREEN_BLUE]: colourRangeYGB,
};
/**
* Custom hook to get a d3 based color range to be used for color coding in table cells.
*
* @param colorRange COLOR_RANGE enum.
* @param colorRangeScale COLOR_RANGE_SCALE enum.
* @param featureCount
*/
export const useColorRange = (
colorRange = COLOR_RANGE.BLUE,
colorRangeScale = COLOR_RANGE_SCALE.LINEAR,
featureCount = 1
) => {
const euiTheme = useUiChromeContext()
.getUiSettingsClient()
.get('theme:darkMode')
? euiThemeDark
: euiThemeLight;
const colorRanges = {
[COLOR_RANGE.BLUE]: [d3.rgb(euiTheme.euiColorEmptyShade), d3.rgb(euiTheme.euiColorVis1)],
[COLOR_RANGE.RED]: [d3.rgb(euiTheme.euiColorEmptyShade), d3.rgb(euiTheme.euiColorDanger)],
[COLOR_RANGE.RED_GREEN]: ['red', 'green'],
[COLOR_RANGE.GREEN_RED]: ['green', 'red'],
[COLOR_RANGE.YELLOW_GREEN_BLUE]: coloursYGB,
};
const linearScale = d3.scale
.linear()
.domain(colorDomains[colorRange])
// typings for .range() incorrectly don't allow passing in a color extent.
// @ts-ignore
.range(colorRanges[colorRange]);
const influencerColorScale = influencerColorScaleFactory(featureCount);
const influencerScaleLinearWrapper = (n: number) => linearScale(influencerColorScale(n));
const scaleTypes = {
[COLOR_RANGE_SCALE.LINEAR]: linearScale,
[COLOR_RANGE_SCALE.INFLUENCER]: influencerScaleLinearWrapper,
[COLOR_RANGE_SCALE.SQRT]: d3.scale
.sqrt()
.domain(colorDomains[colorRange])
// typings for .range() incorrectly don't allow passing in a color extent.
// @ts-ignore
.range(colorRanges[colorRange]),
};
return scaleTypes[colorRangeScale];
};

View file

@ -4,13 +4,11 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React, { FC, Fragment, useEffect, useState } from 'react';
import React, { FC, useEffect, useState } from 'react';
import moment from 'moment-timezone';
import { i18n } from '@kbn/i18n';
import d3 from 'd3';
import {
EuiBadge,
EuiButtonIcon,
@ -18,7 +16,6 @@ import {
EuiCheckbox,
EuiFlexGroup,
EuiFlexItem,
EuiFormRow,
EuiPanel,
EuiPopover,
EuiPopoverTitle,
@ -30,9 +27,12 @@ import {
Query,
} from '@elastic/eui';
import euiThemeLight from '@elastic/eui/dist/eui_theme_light.json';
import euiThemeDark from '@elastic/eui/dist/eui_theme_dark.json';
import {
useColorRange,
ColorRangeLegend,
COLOR_RANGE,
COLOR_RANGE_SCALE,
} from '../../../../../components/color_range_legend';
import {
ColumnType,
mlInMemoryTableBasicFactory,
@ -41,8 +41,6 @@ import {
SORT_DIRECTION,
} from '../../../../../components/ml_in_memory_table';
import { useUiChromeContext } from '../../../../../contexts/ui/use_ui_chrome_context';
import { formatHumanReadableDateTimeSeconds } from '../../../../../util/date_utils';
import { ml } from '../../../../../services/ml_api_service';
@ -67,16 +65,6 @@ import {
import { getTaskStateBadge } from '../../../analytics_management/components/analytics_list/columns';
import { SavedSearchQuery } from '../../../../../contexts/kibana';
const customColorScaleFactory = (n: number) => (t: number) => {
if (t < 1 / n) {
return 0;
}
if (t < 3 / n) {
return (n / 4) * (t - 1 / n);
}
return 0.5 + (t - 3 / n);
};
const FEATURE_INFLUENCE = 'feature_influence';
interface GetDataFrameAnalyticsResponse {
@ -102,6 +90,16 @@ interface Props {
jobStatus: DATA_FRAME_TASK_STATE;
}
const getFeatureCount = (jobConfig?: DataFrameAnalyticsConfig, tableItems: TableItem[] = []) => {
if (jobConfig === undefined || tableItems.length === 0) {
return 0;
}
return Object.keys(tableItems[0]).filter(key =>
key.includes(`${jobConfig.dest.results_field}.${FEATURE_INFLUENCE}.`)
).length;
};
export const Exploration: FC<Props> = React.memo(({ jobId, jobStatus }) => {
const [jobConfig, setJobConfig] = useState<DataFrameAnalyticsConfig | undefined>(undefined);
@ -126,12 +124,6 @@ export const Exploration: FC<Props> = React.memo(({ jobId, jobStatus }) => {
})();
}, []);
const euiTheme = useUiChromeContext()
.getUiSettingsClient()
.get('theme:darkMode')
? euiThemeDark
: euiThemeLight;
const [selectedFields, setSelectedFields] = useState([] as EsFieldName[]);
const [isColumnsPopoverVisible, setColumnsPopoverVisible] = useState(false);
@ -169,23 +161,13 @@ export const Exploration: FC<Props> = React.memo(({ jobId, jobStatus }) => {
const columns: Array<ColumnType<TableItem>> = [];
if (jobConfig !== undefined && selectedFields.length > 0 && tableItems.length > 0) {
// table cell color coding takes into account:
// - whether the theme is dark/light
// - the number of analysis features
// based on that
const cellBgColorScale = d3.scale
.linear()
.domain([0, 1])
// typings for .range() incorrectly don't allow passing in a color extent.
// @ts-ignore
.range([d3.rgb(euiTheme.euiColorEmptyShade), d3.rgb(euiTheme.euiColorVis1)]);
const featureCount = Object.keys(tableItems[0]).filter(key =>
key.includes(`${jobConfig.dest.results_field}.${FEATURE_INFLUENCE}.`)
).length;
const customScale = customColorScaleFactory(featureCount);
const cellBgColor = (n: number) => cellBgColorScale(customScale(n));
const cellBgColor = useColorRange(
COLOR_RANGE.BLUE,
COLOR_RANGE_SCALE.INFLUENCER,
getFeatureCount(jobConfig, tableItems)
);
if (jobConfig !== undefined && selectedFields.length > 0 && tableItems.length > 0) {
columns.push(
...selectedFields.sort(sortColumns(tableItems[0], jobConfig.dest.results_field)).map(k => {
const column: ColumnType<TableItem> = {
@ -504,21 +486,34 @@ export const Exploration: FC<Props> = React.memo(({ jobId, jobStatus }) => {
<EuiProgress size="xs" color="accent" max={1} value={0} />
)}
{(columns.length > 0 || searchQuery !== defaultSearchQuery) && sortField !== '' && (
<Fragment>
{tableItems.length === SEARCH_SIZE && (
<EuiFormRow
helpText={i18n.translate(
'xpack.ml.dataframe.analytics.exploration.documentsShownHelpText',
{
defaultMessage: 'Showing first {searchSize} documents',
values: { searchSize: SEARCH_SIZE },
}
<>
<EuiSpacer size="s" />
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
{tableItems.length === SEARCH_SIZE && (
<EuiText size="xs" color="subdued">
{i18n.translate(
'xpack.ml.dataframe.analytics.exploration.documentsShownHelpText',
{
defaultMessage: 'Showing first {searchSize} documents',
values: { searchSize: SEARCH_SIZE },
}
)}
</EuiText>
)}
>
<Fragment />
</EuiFormRow>
)}
<EuiSpacer />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<ColorRangeLegend
colorRange={cellBgColor}
title={i18n.translate(
'xpack.ml.dataframe.analytics.exploration.colorRangeLegendTitle',
{
defaultMessage: 'Feature influence score',
}
)}
/>
</EuiFlexItem>
</EuiFlexGroup>
<MlInMemoryTableBasic
allowNeutralSort={false}
className="mlDataFrameAnalyticsExploration"
@ -534,7 +529,7 @@ export const Exploration: FC<Props> = React.memo(({ jobId, jobStatus }) => {
search={search}
error={tableError}
/>
</Fragment>
</>
)}
</EuiPanel>
);

View file

@ -28,6 +28,7 @@
@import 'components/annotations/annotation_description_list/index'; // SASSTODO: This file overwrites EUI directly
@import 'components/anomalies_table/index'; // SASSTODO: This file overwrites EUI directly
@import 'components/chart_tooltip/index';
@import 'components/color_range_legend/index';
@import 'components/controls/index';
@import 'components/entity_cell/index';
@import 'components/field_title_bar/index';