[Metrics UI] Allow users to configure Inventory View palettes (#66948)

This commit is contained in:
Chris Cowan 2020-05-21 11:47:08 -07:00 committed by GitHub
parent cd9084d439
commit 78db3eaffe
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 710 additions and 137 deletions

View file

@ -50,6 +50,19 @@ export const inventoryViewSavedObjectType: SavedObjectsType = {
},
},
},
legend: {
properties: {
palette: {
type: 'keyword',
},
steps: {
type: 'long',
},
reverseColors: {
type: 'boolean',
},
},
},
groupBy: {
type: 'nested',
properties: {

View file

@ -10,6 +10,8 @@ import ApolloClient from 'apollo-client';
import { AxiosRequestConfig } from 'axios';
import React from 'react';
import { Observable } from 'rxjs';
import * as rt from 'io-ts';
import { i18n } from '@kbn/i18n';
import { SourceQuery } from '../graphql/types';
import {
SnapshotMetricInput,
@ -125,29 +127,76 @@ export interface InfraWaffleMapGroupOfNodes extends InfraWaffleMapGroupBase {
nodes: InfraWaffleMapNode[];
}
export interface InfraWaffleMapStepRule {
value: number;
operator: InfraWaffleMapRuleOperator;
color: string;
label?: string;
}
export const OperatorRT = rt.keyof({
gt: null,
gte: null,
lt: null,
lte: null,
eq: null,
});
export interface InfraWaffleMapGradientRule {
value: number;
color: string;
}
export const PALETTES = {
status: i18n.translate('xpack.infra.legendControls.palettes.status', {
defaultMessage: 'Status',
}),
temperature: i18n.translate('xpack.infra.legendControls.palettes.temperature', {
defaultMessage: 'Temperature',
}),
cool: i18n.translate('xpack.infra.legendControls.palettes.cool', {
defaultMessage: 'Cool',
}),
warm: i18n.translate('xpack.infra.legendControls.palettes.warm', {
defaultMessage: 'Warm',
}),
positive: i18n.translate('xpack.infra.legendControls.palettes.positive', {
defaultMessage: 'Positive',
}),
negative: i18n.translate('xpack.infra.legendControls.palettes.negative', {
defaultMessage: 'Negative',
}),
};
export interface InfraWaffleMapStepLegend {
type: 'step';
rules: InfraWaffleMapStepRule[];
}
export const InventoryColorPaletteRT = rt.keyof(PALETTES);
export type InventoryColorPalette = rt.TypeOf<typeof InventoryColorPaletteRT>;
export interface InfraWaffleMapGradientLegend {
type: 'gradient';
rules: InfraWaffleMapGradientRule[];
}
export const StepRuleRT = rt.intersection([
rt.type({
value: rt.number,
operator: OperatorRT,
color: rt.string,
}),
rt.partial({ label: rt.string }),
]);
export type InfraWaffleMapLegend = InfraWaffleMapStepLegend | InfraWaffleMapGradientLegend;
export const StepLegendRT = rt.type({
type: rt.literal('step'),
rules: rt.array(StepRuleRT),
});
export type InfraWaffleMapStepRule = rt.TypeOf<typeof StepRuleRT>;
export type InfraWaffleMapStepLegend = rt.TypeOf<typeof StepLegendRT>;
export const GradientRuleRT = rt.type({
value: rt.number,
color: rt.string,
});
export const GradientLegendRT = rt.type({
type: rt.literal('gradient'),
rules: rt.array(GradientRuleRT),
});
export type InfraWaffleMapGradientRule = rt.TypeOf<typeof GradientRuleRT>;
export type InfraWaffleMapGradientLegend = rt.TypeOf<typeof GradientLegendRT>;
export const SteppedGradientLegendRT = rt.type({
type: rt.literal('steppedGradient'),
rules: rt.array(GradientRuleRT),
});
export type InfraWaffleMapSteppedGradientLegend = rt.TypeOf<typeof SteppedGradientLegendRT>;
export const LegendRT = rt.union([StepLegendRT, GradientLegendRT, SteppedGradientLegendRT]);
export type InfraWaffleMapLegend = rt.TypeOf<typeof LegendRT>;
export enum InfraWaffleMapRuleOperator {
gt = 'gt',

View file

@ -7,7 +7,7 @@
import React, { useCallback } from 'react';
import { useInterval } from 'react-use';
import { euiPaletteColorBlind, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { convertIntervalToString } from '../../../../utils/convert_interval_to_string';
import { NodesOverview, calculateBoundsFromNodes } from './nodes_overview';
import { PageContent } from '../../../../components/page';
@ -16,7 +16,7 @@ import { useWaffleTimeContext } from '../hooks/use_waffle_time';
import { useWaffleFiltersContext } from '../hooks/use_waffle_filters';
import { useWaffleOptionsContext } from '../hooks/use_waffle_options';
import { useSourceContext } from '../../../../containers/source';
import { InfraFormatterType, InfraWaffleMapGradientLegend } from '../../../../lib/lib';
import { InfraFormatterType } from '../../../../lib/lib';
import { euiStyled } from '../../../../../../observability/public';
import { Toolbar } from './toolbars/toolbar';
import { ViewSwitcher } from './waffle/view_switcher';
@ -24,8 +24,7 @@ import { SavedViews } from './saved_views';
import { IntervalLabel } from './waffle/interval_label';
import { Legend } from './waffle/legend';
import { createInventoryMetricFormatter } from '../lib/create_inventory_metric_formatter';
const euiVisColorPalette = euiPaletteColorBlind();
import { createLegend } from '../lib/create_legend';
export const Layout = () => {
const { sourceId, source } = useSourceContext();
@ -40,6 +39,7 @@ export const Layout = () => {
view,
autoBounds,
boundsOverride,
legend,
} = useWaffleOptionsContext();
const { currentTime, jumpToTime, isAutoReloading } = useWaffleTimeContext();
const { filterQueryAsJson, applyFilterQuery } = useWaffleFiltersContext();
@ -57,13 +57,7 @@ export const Layout = () => {
const options = {
formatter: InfraFormatterType.percent,
formatTemplate: '{{value}}',
legend: {
type: 'gradient',
rules: [
{ value: 0, color: '#D3DAE6' },
{ value: 1, color: euiVisColorPalette[1] },
],
} as InfraWaffleMapGradientLegend,
legend: createLegend(legend.palette, legend.steps, legend.reverseColors),
metric,
sort,
fields: source?.configuration?.fields,

View file

@ -33,6 +33,7 @@ export const ToolbarWrapper = (props: Props) => {
accountId,
view,
region,
legend,
sort,
customMetrics,
changeCustomMetrics,
@ -59,6 +60,7 @@ export const ToolbarWrapper = (props: Props) => {
nodeType,
region,
accountId,
legend,
customMetrics,
changeCustomMetrics,
})}

View file

@ -3,15 +3,22 @@
* 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 React, { useCallback } from 'react';
import { euiStyled } from '../../../../../../../observability/public';
import { InfraFormatter, InfraWaffleMapBounds, InfraWaffleMapLegend } from '../../../../../lib/lib';
import {
InfraFormatter,
InfraWaffleMapBounds,
InfraWaffleMapLegend,
SteppedGradientLegendRT,
StepLegendRT,
GradientLegendRT,
} from '../../../../../lib/lib';
import { GradientLegend } from './gradient_legend';
import { LegendControls } from './legend_controls';
import { isInfraWaffleMapGradientLegend, isInfraWaffleMapStepLegend } from '../../lib/type_guards';
import { StepLegend } from './steps_legend';
import { useWaffleOptionsContext } from '../../hooks/use_waffle_options';
import { useWaffleOptionsContext, WaffleLegendOptions } from '../../hooks/use_waffle_options';
import { SteppedGradientLegend } from './stepped_gradient_legend';
interface Props {
legend: InfraWaffleMapLegend;
bounds: InfraWaffleMapBounds;
@ -22,6 +29,7 @@ interface Props {
interface LegendControlOptions {
auto: boolean;
bounds: InfraWaffleMapBounds;
legend: WaffleLegendOptions;
}
export const Legend: React.FC<Props> = ({ dataBounds, legend, bounds, formatter }) => {
@ -29,24 +37,35 @@ export const Legend: React.FC<Props> = ({ dataBounds, legend, bounds, formatter
changeBoundsOverride,
changeAutoBounds,
autoBounds,
legend: legendOptions,
changeLegend,
boundsOverride,
} = useWaffleOptionsContext();
const handleChange = useCallback(
(options: LegendControlOptions) => {
changeBoundsOverride(options.bounds);
changeAutoBounds(options.auto);
changeLegend(options.legend);
},
[changeBoundsOverride, changeAutoBounds, changeLegend]
);
return (
<LegendContainer>
<LegendControls
options={legendOptions}
dataBounds={dataBounds}
bounds={bounds}
autoBounds={autoBounds}
boundsOverride={boundsOverride}
onChange={(options: LegendControlOptions) => {
changeBoundsOverride(options.bounds);
changeAutoBounds(options.auto);
}}
onChange={handleChange}
/>
{isInfraWaffleMapGradientLegend(legend) && (
{GradientLegendRT.is(legend) && (
<GradientLegend formatter={formatter} legend={legend} bounds={bounds} />
)}
{isInfraWaffleMapStepLegend(legend) && <StepLegend formatter={formatter} legend={legend} />}
{StepLegendRT.is(legend) && <StepLegend formatter={formatter} legend={legend} />}
{SteppedGradientLegendRT.is(legend) && (
<SteppedGradientLegend formatter={formatter} bounds={bounds} legend={legend} />
)}
</LegendContainer>
);
};

View file

@ -5,11 +5,10 @@
*/
import {
EuiButtonEmpty,
EuiButton,
EuiButtonIcon,
EuiFieldNumber,
EuiFlexGroup,
EuiFlexItem,
EuiForm,
EuiFormRow,
EuiPopover,
@ -17,27 +16,65 @@ import {
EuiSpacer,
EuiSwitch,
EuiSwitchEvent,
EuiText,
EuiSelect,
EuiRange,
EuiFlexGroup,
EuiFlexItem,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import React, { SyntheticEvent, useState } from 'react';
import React, { SyntheticEvent, useState, useCallback, useEffect } from 'react';
import { first, last } from 'lodash';
import { euiStyled } from '../../../../../../../observability/public';
import { InfraWaffleMapBounds } from '../../../../../lib/lib';
import { InfraWaffleMapBounds, InventoryColorPalette, PALETTES } from '../../../../../lib/lib';
import { WaffleLegendOptions } from '../../hooks/use_waffle_options';
import { getColorPalette } from '../../lib/get_color_palette';
import { convertBoundsToPercents } from '../../lib/convert_bounds_to_percents';
import { SwatchLabel } from './swatch_label';
import { PalettePreview } from './palette_preview';
interface Props {
onChange: (options: { auto: boolean; bounds: InfraWaffleMapBounds }) => void;
onChange: (options: {
auto: boolean;
bounds: InfraWaffleMapBounds;
legend: WaffleLegendOptions;
}) => void;
bounds: InfraWaffleMapBounds;
dataBounds: InfraWaffleMapBounds;
autoBounds: boolean;
boundsOverride: InfraWaffleMapBounds;
options: WaffleLegendOptions;
}
export const LegendControls = ({ autoBounds, boundsOverride, onChange, dataBounds }: Props) => {
const PALETTE_NAMES: InventoryColorPalette[] = [
'temperature',
'status',
'cool',
'warm',
'positive',
'negative',
];
const PALETTE_OPTIONS = PALETTE_NAMES.map(name => ({ text: PALETTES[name], value: name }));
export const LegendControls = ({
autoBounds,
boundsOverride,
onChange,
dataBounds,
options,
}: Props) => {
const [isPopoverOpen, setPopoverState] = useState(false);
const [draftAuto, setDraftAuto] = useState(autoBounds);
const [draftBounds, setDraftBounds] = useState(autoBounds ? dataBounds : boundsOverride); // should come from bounds prop
const [draftLegend, setLegendOptions] = useState(options);
const [draftBounds, setDraftBounds] = useState(convertBoundsToPercents(boundsOverride)); // should come from bounds prop
useEffect(() => {
if (draftAuto) {
setDraftBounds(convertBoundsToPercents(dataBounds));
}
}, [autoBounds, dataBounds, draftAuto, onChange, options]);
const buttonComponent = (
<EuiButtonIcon
iconType="controlsHorizontal"
@ -49,108 +86,259 @@ export const LegendControls = ({ autoBounds, boundsOverride, onChange, dataBound
/>
);
const handleAutoChange = (e: EuiSwitchEvent) => {
setDraftAuto(e.target.checked);
};
const handleAutoChange = useCallback(
(e: EuiSwitchEvent) => {
const auto = e.target.checked;
setDraftAuto(auto);
if (!auto) {
setDraftBounds(convertBoundsToPercents(boundsOverride));
}
},
[boundsOverride]
);
const createBoundsHandler = (name: string) => (e: SyntheticEvent<HTMLInputElement>) => {
const value = parseFloat(e.currentTarget.value);
setDraftBounds({ ...draftBounds, [name]: value });
};
const handleReverseColors = useCallback(
(e: EuiSwitchEvent) => {
setLegendOptions(previous => ({ ...previous, reverseColors: e.target.checked }));
},
[setLegendOptions]
);
const handlePopoverClose = () => {
const handleMaxBounds = useCallback(
(e: SyntheticEvent<HTMLInputElement>) => {
const value = parseFloat(e.currentTarget.value);
// Auto correct the max to be one larger then the min OR 100
const max = value <= draftBounds.min ? draftBounds.min + 1 : value > 100 ? 100 : value;
setDraftBounds({ ...draftBounds, max });
},
[draftBounds]
);
const handleMinBounds = useCallback(
(e: SyntheticEvent<HTMLInputElement>) => {
const value = parseFloat(e.currentTarget.value);
// Auto correct the min to be one smaller then the max OR ZERO
const min = value >= draftBounds.max ? draftBounds.max - 1 : value < 0 ? 0 : value;
setDraftBounds({ ...draftBounds, min });
},
[draftBounds]
);
const handleApplyClick = useCallback(() => {
onChange({
auto: draftAuto,
bounds: { min: draftBounds.min / 100, max: draftBounds.max / 100 },
legend: draftLegend,
});
}, [onChange, draftAuto, draftBounds, draftLegend]);
const handleCancelClick = useCallback(() => {
setDraftBounds(convertBoundsToPercents(boundsOverride));
setDraftAuto(autoBounds);
setLegendOptions(options);
setPopoverState(false);
};
}, [autoBounds, boundsOverride, options]);
const handleApplyClick = () => {
onChange({ auto: draftAuto, bounds: draftBounds });
};
const handleStepsChange = useCallback(
e => {
setLegendOptions(previous => ({ ...previous, steps: parseInt(e.target.value, 10) }));
},
[setLegendOptions]
);
const handlePaletteChange = useCallback(
e => {
setLegendOptions(previous => ({ ...previous, palette: e.target.value }));
},
[setLegendOptions]
);
const commited =
draftAuto === autoBounds &&
boundsOverride.min === draftBounds.min &&
boundsOverride.max === draftBounds.max;
boundsOverride.min * 100 === draftBounds.min &&
boundsOverride.max * 100 === draftBounds.max &&
options.steps === draftLegend.steps &&
options.reverseColors === draftLegend.reverseColors &&
options.palette === draftLegend.palette;
const boundsValidRange = draftBounds.min < draftBounds.max;
const paletteColors = getColorPalette(
draftLegend.palette,
draftLegend.steps,
draftLegend.reverseColors
);
const errors = !boundsValidRange
? [
i18n.translate('xpack.infra.legnedControls.boundRangeError', {
defaultMessage: 'Minimum must be smaller then the maximum',
}),
]
: [];
return (
<ControlContainer>
<EuiPopover
isOpen={isPopoverOpen}
closePopover={handlePopoverClose}
closePopover={handleCancelClick}
id="legendControls"
button={buttonComponent}
withTitle
>
<EuiPopoverTitle>Legend Options</EuiPopoverTitle>
<EuiForm>
<EuiFormRow>
<EuiSwitch
name="bounds"
label={i18n.translate('xpack.infra.legendControls.switchLabel', {
defaultMessage: 'Auto calculate range',
})}
checked={draftAuto}
onChange={handleAutoChange}
<EuiForm style={{ width: 500 }}>
<EuiFormRow
fullWidth
display="columnCompressed"
label={i18n.translate('xpack.infra.legendControls.colorPaletteLabel', {
defaultMessage: 'Color palette',
})}
>
<>
<EuiSelect
options={PALETTE_OPTIONS}
value={draftLegend.palette}
id="palette"
onChange={handlePaletteChange}
compressed
/>
<EuiSpacer size="m" />
<PalettePreview
palette={draftLegend.palette}
steps={draftLegend.steps}
reverse={draftLegend.reverseColors}
/>
</>
</EuiFormRow>
<EuiFormRow
fullWidth
display="columnCompressed"
label={i18n.translate('xpack.infra.legendControls.stepsLabel', {
defaultMessage: 'Number of colors',
})}
>
<EuiRange
id="steps"
min={2}
max={20}
step={1}
value={draftLegend.steps}
onChange={handleStepsChange}
showValue
compressed
fullWidth
/>
</EuiFormRow>
<EuiSpacer />
{(!boundsValidRange && (
<EuiText color="danger" grow={false} size="s">
<p>
<FormattedMessage
id="xpack.infra.legendControls.errorMessage"
defaultMessage="Min should be less than max"
/>
</p>
</EuiText>
)) ||
null}
<EuiFlexGroup style={{ marginTop: 0 }}>
<EuiFlexItem>
<EuiFormRow
<EuiFormRow
fullWidth
display="columnCompressed"
label={i18n.translate('xpack.infra.legendControls.reverseDirectionLabel', {
defaultMessage: 'Reverse direction',
})}
>
<EuiSwitch
showLabel={false}
name="reverseColors"
label="reverseColors"
checked={draftLegend.reverseColors}
onChange={handleReverseColors}
compressed
/>
</EuiFormRow>
<EuiFormRow
fullWidth
display="columnCompressed"
label={i18n.translate('xpack.infra.legendControls.switchLabel', {
defaultMessage: 'Auto calculate range',
})}
>
<EuiSwitch
showLabel={false}
name="bounds"
label="bounds"
checked={draftAuto}
onChange={handleAutoChange}
compressed
/>
</EuiFormRow>
<EuiFormRow
fullWidth
label={
<SwatchLabel
color={first(paletteColors)}
label={i18n.translate('xpack.infra.legendControls.minLabel', {
defaultMessage: 'Min',
defaultMessage: 'Minimum',
})}
/>
}
isInvalid={!boundsValidRange}
display="columnCompressed"
error={errors}
>
<div style={{ maxWidth: 150 }}>
<EuiFieldNumber
disabled={draftAuto}
step={1}
value={isNaN(draftBounds.min) ? '' : draftBounds.min}
isInvalid={!boundsValidRange}
>
<EuiFieldNumber
disabled={draftAuto}
step={0.1}
value={isNaN(draftBounds.min) ? '' : draftBounds.min}
isInvalid={!boundsValidRange}
name="legendMin"
onChange={createBoundsHandler('min')}
/>
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem>
<EuiFormRow
name="legendMin"
onChange={handleMinBounds}
append="%"
compressed
/>
</div>
</EuiFormRow>
<EuiFormRow
fullWidth
display="columnCompressed"
label={
<SwatchLabel
color={last(paletteColors)}
label={i18n.translate('xpack.infra.legendControls.maxLabel', {
defaultMessage: 'Max',
defaultMessage: 'Maxium',
})}
/>
}
isInvalid={!boundsValidRange}
error={errors}
>
<div style={{ maxWidth: 150 }}>
<EuiFieldNumber
disabled={draftAuto}
step={1}
isInvalid={!boundsValidRange}
>
<EuiFieldNumber
disabled={draftAuto}
step={0.1}
isInvalid={!boundsValidRange}
value={isNaN(draftBounds.max) ? '' : draftBounds.max}
name="legendMax"
onChange={createBoundsHandler('max')}
value={isNaN(draftBounds.max) ? '' : draftBounds.max}
name="legendMax"
onChange={handleMaxBounds}
append="%"
compressed
/>
</div>
</EuiFormRow>
<EuiSpacer size="m" />
<EuiFlexGroup justifyContent="flexEnd">
<EuiFlexItem grow={false}>
<EuiButtonEmpty type="submit" size="s" onClick={handleCancelClick}>
<FormattedMessage
id="xpack.infra.legendControls.cancelButton"
defaultMessage="Cancel"
/>
</EuiFormRow>
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
type="submit"
size="s"
fill
disabled={commited || !boundsValidRange}
onClick={handleApplyClick}
>
<FormattedMessage
id="xpack.infra.legendControls.applyButton"
defaultMessage="Apply"
/>
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
<EuiButton
type="submit"
size="s"
fill
disabled={commited || !boundsValidRange}
onClick={handleApplyClick}
>
<FormattedMessage id="xpack.infra.legendControls.applyButton" defaultMessage="Apply" />
</EuiButton>
</EuiForm>
</EuiPopover>
</ControlContainer>

View file

@ -0,0 +1,44 @@
/*
* 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 { euiStyled } from '../../../../../../../observability/public';
import { InventoryColorPalette } from '../../../../../lib/lib';
import { getColorPalette } from '../../lib/get_color_palette';
interface Props {
palette: InventoryColorPalette;
steps: number;
reverse: boolean;
}
export const PalettePreview = ({ steps, palette, reverse }: Props) => {
const colors = getColorPalette(palette, steps, reverse);
return (
<Swatches>
{colors.map(color => (
<Swatch key={color} style={{ backgroundColor: color }} />
))}
</Swatches>
);
};
const Swatch = euiStyled.div`
width: 16px;
height: 12px;
flex: 0 0 auto;
&:first-child {
border-radius: ${props => props.theme.eui.euiBorderRadius} 0 0 ${props =>
props.theme.eui.euiBorderRadius};
}
&:last-child {
border-radius: 0 ${props => props.theme.eui.euiBorderRadius} ${props =>
props.theme.eui.euiBorderRadius} 0;
`;
const Swatches = euiStyled.div`
display: flex;
`;

View file

@ -0,0 +1,106 @@
/*
* 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 { euiStyled } from '../../../../../../../observability/public';
import {
InfraWaffleMapBounds,
InfraFormatter,
InfraWaffleMapSteppedGradientLegend,
} from '../../../../../lib/lib';
interface Props {
legend: InfraWaffleMapSteppedGradientLegend;
bounds: InfraWaffleMapBounds;
formatter: InfraFormatter;
}
export const SteppedGradientLegend: React.FC<Props> = ({ legend, bounds, formatter }) => {
return (
<LegendContainer>
<Ticks>
<TickLabel value={0} bounds={bounds} formatter={formatter} />
<TickLabel value={0.5} bounds={bounds} formatter={formatter} />
<TickLabel value={1} bounds={bounds} formatter={formatter} />
</Ticks>
<GradientContainer>
{legend.rules.map((rule, index) => (
<GradientStep
key={`step-${index}-${rule.value}`}
style={{ backgroundColor: rule.color }}
/>
))}
</GradientContainer>
</LegendContainer>
);
};
interface TickProps {
bounds: InfraWaffleMapBounds;
value: number;
formatter: InfraFormatter;
}
const TickLabel = ({ value, bounds, formatter }: TickProps) => {
const normalizedValue = value === 0 ? bounds.min : bounds.max * value;
const style = { left: `${value * 100}%` };
const label = formatter(normalizedValue);
return <Tick style={style}>{label}</Tick>;
};
const GradientStep = euiStyled.div`
height: ${props => props.theme.eui.paddingSizes.s};
flex: 1 1 auto;
&:first-child {
border-radius: ${props => props.theme.eui.euiBorderRadius} 0 0 ${props =>
props.theme.eui.euiBorderRadius};
}
&:last-child {
border-radius: 0 ${props => props.theme.eui.euiBorderRadius} ${props =>
props.theme.eui.euiBorderRadius} 0;
}
`;
const Ticks = euiStyled.div`
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
top: -18px;
`;
const Tick = euiStyled.div`
position: absolute;
font-size: 11px;
text-align: center;
top: 0;
left: 0;
white-space: nowrap;
transform: translate(-50%, 0);
&:first-child {
padding-left: 5px;
transform: translate(0, 0);
}
&:last-child {
padding-right: 5px;
transform: translate(-100%, 0);
}
`;
const GradientContainer = euiStyled.div`
display: flex;
flex-direction; row;
align-items: stretch;
flex-grow: 1;
`;
const LegendContainer = euiStyled.div`
position: absolute;
height: 10px;
bottom: 0;
left: 0;
right: 40px;
`;

View file

@ -0,0 +1,28 @@
/*
* 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 { EuiColorPickerSwatch, EuiText, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import React from 'react';
export interface Props {
color: string;
label: string;
}
export const SwatchLabel = ({ label, color }: Props) => {
return (
<EuiFlexGroup alignItems="center" gutterSize="s">
<EuiFlexItem grow={false}>
<EuiColorPickerSwatch color={color} />
</EuiFlexItem>
<EuiFlexItem>
<EuiText size="xs">
<strong>{label}</strong>
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
);
};

View file

@ -10,6 +10,7 @@ import { pipe } from 'fp-ts/lib/pipeable';
import { fold } from 'fp-ts/lib/Either';
import { constant, identity } from 'fp-ts/lib/function';
import createContainer from 'constate';
import { InventoryColorPaletteRT } from '../../../../lib/lib';
import {
SnapshotMetricInput,
SnapshotGroupBy,
@ -32,6 +33,11 @@ export const DEFAULT_WAFFLE_OPTIONS_STATE: WaffleOptionsState = {
accountId: '',
region: '',
customMetrics: [],
legend: {
palette: 'cool',
steps: 10,
reverseColors: false,
},
sort: { by: 'name', direction: 'desc' },
};
@ -100,6 +106,13 @@ export const useWaffleOptions = () => {
[setState]
);
const changeLegend = useCallback(
(legend: WaffleLegendOptions) => {
setState(previous => ({ ...previous, legend }));
},
[setState]
);
const changeSort = useCallback(
(sort: WaffleSortOption) => {
setState(previous => ({ ...previous, sort }));
@ -120,11 +133,20 @@ export const useWaffleOptions = () => {
changeAccount,
changeRegion,
changeCustomMetrics,
changeLegend,
changeSort,
setWaffleOptionsState: setState,
};
};
const WaffleLegendOptionsRT = rt.type({
palette: InventoryColorPaletteRT,
steps: rt.number,
reverseColors: rt.boolean,
});
export type WaffleLegendOptions = rt.TypeOf<typeof WaffleLegendOptionsRT>;
export const WaffleSortOptionRT = rt.type({
by: rt.keyof({ name: null, value: null }),
direction: rt.keyof({ asc: null, desc: null }),
@ -149,6 +171,7 @@ export const WaffleOptionsStateRT = rt.type({
accountId: rt.string,
region: rt.string,
customMetrics: rt.array(SnapshotCustomMetricInputRT),
legend: WaffleLegendOptionsRT,
sort: WaffleSortOptionRT,
});

View file

@ -28,6 +28,7 @@ export const useWaffleViewState = () => {
autoBounds,
accountId,
region,
legend,
sort,
setWaffleOptionsState,
} = useWaffleOptionsContext();
@ -49,6 +50,7 @@ export const useWaffleViewState = () => {
time: currentTime,
autoReload: isAutoReloading,
filterQuery,
legend,
};
const defaultViewState: WaffleViewState = {
@ -72,6 +74,7 @@ export const useWaffleViewState = () => {
autoBounds: newState.autoBounds,
accountId: newState.accountId,
region: newState.region,
legend: newState.legend,
});
if (newState.time) {
setWaffleTimeState({

View file

@ -8,12 +8,14 @@ import { eq, first, gt, gte, last, lt, lte, sortBy } from 'lodash';
import { mix, parseToRgb, toColorString } from 'polished';
import {
InfraWaffleMapBounds,
InfraWaffleMapGradientLegend,
InfraWaffleMapLegend,
InfraWaffleMapRuleOperator,
InfraWaffleMapStepLegend,
GradientLegendRT,
SteppedGradientLegendRT,
StepLegendRT,
InfraWaffleMapStepRule,
InfraWaffleMapGradientRule,
} from '../../../../lib/lib';
import { isInfraWaffleMapGradientLegend, isInfraWaffleMapStepLegend } from './type_guards';
const OPERATOR_TO_FN = {
[InfraWaffleMapRuleOperator.eq]: eq,
@ -34,11 +36,16 @@ export const colorFromValue = (
defaultColor = 'rgba(217, 217, 217, 1)'
): string => {
try {
if (isInfraWaffleMapStepLegend(legend)) {
return convertToRgbString(calculateStepColor(legend, value, defaultColor));
if (StepLegendRT.is(legend)) {
return convertToRgbString(calculateStepColor(legend.rules, value, defaultColor));
}
if (isInfraWaffleMapGradientLegend(legend)) {
return convertToRgbString(calculateGradientColor(legend, value, bounds, defaultColor));
if (GradientLegendRT.is(legend)) {
return convertToRgbString(calculateGradientColor(legend.rules, value, bounds, defaultColor));
}
if (SteppedGradientLegendRT.is(legend)) {
return convertToRgbString(
calculateSteppedGradientColor(legend.rules, value, bounds, defaultColor)
);
}
return defaultColor;
} catch (error) {
@ -50,12 +57,37 @@ const normalizeValue = (min: number, max: number, value: number): number => {
return (value - min) / (max - min);
};
export const calculateSteppedGradientColor = (
rules: InfraWaffleMapGradientRule[],
value: number | string,
bounds: InfraWaffleMapBounds,
defaultColor = 'rgba(217, 217, 217, 1)'
) => {
const normalizedValue = normalizeValue(bounds.min, bounds.max, Number(value));
const steps = rules.length;
// Since the stepped legend matches a range we need to ensure anything outside
// the max bounds get's the maximum color.
if (gte(normalizedValue, last(rules).value)) {
return last(rules).color;
}
return rules.reduce((color: string, rule) => {
const min = rule.value - 1 / steps;
const max = rule.value;
if (gte(normalizedValue, min) && lte(normalizedValue, max)) {
return rule.color;
}
return color;
}, first(rules).color || defaultColor);
};
export const calculateStepColor = (
legend: InfraWaffleMapStepLegend,
rules: InfraWaffleMapStepRule[],
value: number | string,
defaultColor = 'rgba(217, 217, 217, 1)'
): string => {
return sortBy(legend.rules, 'sortBy').reduce((color: string, rule) => {
return rules.reduce((color: string, rule) => {
const operatorFn = OPERATOR_TO_FN[rule.operator];
if (operatorFn(value, rule.value)) {
return rule.color;
@ -65,19 +97,19 @@ export const calculateStepColor = (
};
export const calculateGradientColor = (
legend: InfraWaffleMapGradientLegend,
rules: InfraWaffleMapGradientRule[],
value: number | string,
bounds: InfraWaffleMapBounds,
defaultColor = 'rgba(0, 179, 164, 1)'
): string => {
if (legend.rules.length === 0) {
if (rules.length === 0) {
return defaultColor;
}
if (legend.rules.length === 1) {
return last(legend.rules).color;
if (rules.length === 1) {
return last(rules).color;
}
const { min, max } = bounds;
const sortedRules = sortBy(legend.rules, 'value');
const sortedRules = sortBy(rules, 'value');
const normValue = normalizeValue(min, max, Number(value));
const startRule = sortedRules.reduce((acc, rule) => {
if (rule.value <= normValue) {

View file

@ -0,0 +1,11 @@
/*
* 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 { InfraWaffleMapBounds } from '../../../../lib/lib';
export const convertBoundsToPercents = (bounds: InfraWaffleMapBounds) => ({
min: bounds.min * 100,
max: (bounds.max || 1) * 100,
});

View file

@ -0,0 +1,23 @@
/*
* 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 { InventoryColorPalette, InfraWaffleMapSteppedGradientLegend } from '../../../../lib/lib';
import { getColorPalette } from './get_color_palette';
export const createLegend = (
name: InventoryColorPalette,
steps: number = 10,
reverse: boolean = false
): InfraWaffleMapSteppedGradientLegend => {
const paletteColors = getColorPalette(name, steps, reverse);
return {
type: 'steppedGradient',
rules: paletteColors.map((color, index) => ({
color,
value: (index + 1) / steps,
})),
};
};

View file

@ -0,0 +1,40 @@
/*
* 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 {
euiPaletteCool,
euiPaletteForStatus,
euiPaletteForTemperature,
euiPaletteNegative,
euiPalettePositive,
euiPaletteWarm,
} from '@elastic/eui';
import { InventoryColorPalette } from '../../../../lib/lib';
const createColorPalette = (name: InventoryColorPalette = 'cool', steps: number = 10) => {
switch (name) {
case 'temperature':
return euiPaletteForTemperature(steps);
case 'status':
return euiPaletteForStatus(steps);
case 'warm':
return euiPaletteWarm(steps);
case 'positive':
return euiPalettePositive(steps);
case 'negative':
return euiPaletteNegative(steps);
default:
return euiPaletteCool(steps);
}
};
export const getColorPalette = (
name: InventoryColorPalette = 'cool',
steps: number = 10,
reverse: boolean = false
) => {
return reverse ? createColorPalette(name, steps).reverse() : createColorPalette(name, steps);
};

View file

@ -7641,7 +7641,6 @@
"xpack.infra.kibanaMetrics.nodeDoesNotExistErrorMessage": "{nodeId} が存在しません。",
"xpack.infra.legendControls.applyButton": "適用",
"xpack.infra.legendControls.buttonLabel": "凡例を校正",
"xpack.infra.legendControls.errorMessage": "最低値は最高値よりも低く設定する必要があります",
"xpack.infra.legendControls.maxLabel": "最高",
"xpack.infra.legendControls.minLabel": "最低",
"xpack.infra.legendControls.switchLabel": "自動計算範囲",

View file

@ -7647,7 +7647,6 @@
"xpack.infra.kibanaMetrics.nodeDoesNotExistErrorMessage": "{nodeId} 不存在。",
"xpack.infra.legendControls.applyButton": "应用",
"xpack.infra.legendControls.buttonLabel": "配置图例",
"xpack.infra.legendControls.errorMessage": "最小值应小于最大值",
"xpack.infra.legendControls.maxLabel": "最大值",
"xpack.infra.legendControls.minLabel": "最小值",
"xpack.infra.legendControls.switchLabel": "自动计算范围",