kibana/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts
Brandon Kobel 4584a8b570
Elastic License 2.0 (#90099)
* Updating everything except the license headers themselves

* Applying ESLint rules

* Manually replacing the stragglers
2021-02-03 18:12:39 -08:00

577 lines
18 KiB
TypeScript

/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
import { partition } from 'lodash';
import { Position } from '@elastic/charts';
import { PaletteOutput } from 'src/plugins/charts/public';
import {
SuggestionRequest,
VisualizationSuggestion,
TableSuggestionColumn,
TableSuggestion,
TableChangeType,
} from '../types';
import { State, SeriesType, XYState, visualizationTypes, XYLayerConfig } from './types';
import { getIconForSeries } from './state_helpers';
const columnSortOrder = {
document: 0,
date: 1,
string: 2,
ip: 3,
boolean: 4,
number: 5,
};
/**
* Generate suggestions for the xy chart.
*
* @param opts
*/
export function getSuggestions({
table,
state,
keptLayerIds,
subVisualizationId,
mainPalette,
}: SuggestionRequest<State>): Array<VisualizationSuggestion<State>> {
const incompleteTable =
!table.isMultiRow ||
table.columns.length <= 1 ||
table.columns.every((col) => col.operation.dataType !== 'number') ||
table.columns.some((col) => !columnSortOrder.hasOwnProperty(col.operation.dataType));
if (incompleteTable && table.changeType === 'unchanged' && state) {
// this isn't a table we would switch to, but we have a state already. In this case, just use the current state for all series types
return visualizationTypes.map((visType) => {
const seriesType = visType.id as SeriesType;
return {
seriesType,
score: 0,
state: {
...state,
preferredSeriesType: seriesType,
layers: state.layers.map((layer) => ({ ...layer, seriesType })),
},
previewIcon: getIconForSeries(seriesType),
title: visType.label,
hide: true,
};
});
}
if (incompleteTable && state && !subVisualizationId) {
// reject incomplete configurations if the sub visualization isn't specifically requested
// this allows to switch chart types via switcher with incomplete configurations, but won't
// cause incomplete suggestions getting auto applied on dropped fields
return [];
}
const suggestions = getSuggestionForColumns(
table,
keptLayerIds,
state,
subVisualizationId as SeriesType | undefined,
mainPalette
);
if (suggestions && suggestions instanceof Array) {
return suggestions;
}
return suggestions ? [suggestions] : [];
}
function getSuggestionForColumns(
table: TableSuggestion,
keptLayerIds: string[],
currentState?: State,
seriesType?: SeriesType,
mainPalette?: PaletteOutput
): VisualizationSuggestion<State> | Array<VisualizationSuggestion<State>> | undefined {
const [buckets, values] = partition(table.columns, (col) => col.operation.isBucketed);
if (buckets.length === 1 || buckets.length === 2) {
const [x, splitBy] = getBucketMappings(table, currentState);
return getSuggestionsForLayer({
layerId: table.layerId,
changeType: table.changeType,
xValue: x,
yValues: values,
splitBy,
currentState,
tableLabel: table.label,
keptLayerIds,
requestedSeriesType: seriesType,
mainPalette,
});
} else if (buckets.length === 0) {
const [yValues, [xValue, splitBy]] = partition(
prioritizeColumns(values),
(col) => col.operation.dataType === 'number' && !col.operation.isBucketed
);
return getSuggestionsForLayer({
layerId: table.layerId,
changeType: table.changeType,
xValue,
yValues,
splitBy,
currentState,
tableLabel: table.label,
keptLayerIds,
requestedSeriesType: seriesType,
mainPalette,
});
}
}
function flipSeriesType(seriesType: SeriesType) {
switch (seriesType) {
case 'bar_horizontal':
return 'bar';
case 'bar_horizontal_stacked':
return 'bar_stacked';
case 'bar':
return 'bar_horizontal';
case 'bar_horizontal_percentage_stacked':
return 'bar_percentage_stacked';
case 'bar_percentage_stacked':
return 'bar_horizontal_percentage_stacked';
default:
return 'bar_horizontal';
}
}
function getBucketMappings(table: TableSuggestion, currentState?: State) {
const currentLayer =
currentState && currentState.layers.find(({ layerId }) => layerId === table.layerId);
const buckets = table.columns.filter((col) => col.operation.isBucketed);
// reverse the buckets before prioritization to always use the most inner
// bucket of the highest-prioritized group as x value (don't use nested
// buckets as split series)
const prioritizedBuckets = prioritizeColumns([...buckets].reverse());
if (!currentLayer || table.changeType === 'initial') {
return prioritizedBuckets;
}
if (table.changeType === 'reorder') {
return buckets;
}
// if existing table is just modified, try to map buckets to the current dimensions
const currentXColumnIndex = prioritizedBuckets.findIndex(
({ columnId }) => columnId === currentLayer.xAccessor
);
const currentXScaleType =
currentXColumnIndex > -1 && prioritizedBuckets[currentXColumnIndex].operation.scale;
if (
currentXScaleType &&
// make sure histograms get mapped to x dimension even when changing current bucket/dimension mapping
(currentXScaleType === 'interval' || prioritizedBuckets[0].operation.scale !== 'interval')
) {
const [x] = prioritizedBuckets.splice(currentXColumnIndex, 1);
prioritizedBuckets.unshift(x);
}
const currentSplitColumnIndex = prioritizedBuckets.findIndex(
({ columnId }) => columnId === currentLayer.splitAccessor
);
if (currentSplitColumnIndex > -1) {
const [splitBy] = prioritizedBuckets.splice(currentSplitColumnIndex, 1);
prioritizedBuckets.push(splitBy);
}
return prioritizedBuckets;
}
// This shuffles columns around so that the left-most column defualts to:
// date, string, boolean, then number, in that priority. We then use this
// order to pluck out the x column, and the split / stack column.
function prioritizeColumns(columns: TableSuggestionColumn[]) {
return [...columns].sort(
(a, b) => columnSortOrder[a.operation.dataType] - columnSortOrder[b.operation.dataType]
);
}
function getSuggestionsForLayer({
layerId,
changeType,
xValue,
yValues,
splitBy,
currentState,
tableLabel,
keptLayerIds,
requestedSeriesType,
mainPalette,
}: {
layerId: string;
changeType: TableChangeType;
xValue?: TableSuggestionColumn;
yValues: TableSuggestionColumn[];
splitBy?: TableSuggestionColumn;
currentState?: State;
tableLabel?: string;
keptLayerIds: string[];
requestedSeriesType?: SeriesType;
mainPalette?: PaletteOutput;
}): VisualizationSuggestion<State> | Array<VisualizationSuggestion<State>> {
const title = getSuggestionTitle(yValues, xValue, tableLabel);
const seriesType: SeriesType =
requestedSeriesType || getSeriesType(currentState, layerId, xValue);
const options = {
currentState,
seriesType,
layerId,
title,
yValues,
splitBy,
changeType,
xValue,
keptLayerIds,
// only use palette if there is a breakdown by dimension
mainPalette: splitBy ? mainPalette : undefined,
};
// handles the simplest cases, acting as a chart switcher
if (!currentState && changeType === 'unchanged') {
// Chart switcher needs to include every chart type
return visualizationTypes
.map((visType) => {
return {
...buildSuggestion({
...options,
seriesType: visType.id as SeriesType,
// explicitly hide everything besides stacked bars, use default hiding logic for stacked bars
hide: visType.id === 'bar_stacked' ? undefined : true,
}),
title: visType.label,
};
})
.sort((a, b) => (a.state.preferredSeriesType === 'bar_stacked' ? -1 : 1));
}
const isSameState = currentState && changeType === 'unchanged';
if (!isSameState) {
return buildSuggestion(options);
}
// Suggestions are either changing the data, or changing the way the data is used
const sameStateSuggestions: Array<VisualizationSuggestion<State>> = [];
// if current state is using the same data, suggest same chart with different presentational configuration
if (seriesType.includes('bar') && (!xValue || xValue.operation.scale === 'ordinal')) {
// flip between horizontal/vertical for ordinal scales
sameStateSuggestions.push(
buildSuggestion({
...options,
title: i18n.translate('xpack.lens.xySuggestions.flipTitle', { defaultMessage: 'Flip' }),
seriesType: flipSeriesType(seriesType),
})
);
} else {
// change chart type for interval or ratio scales on x axis
const newSeriesType = altSeriesType(seriesType);
sameStateSuggestions.push(
buildSuggestion({
...options,
seriesType: newSeriesType,
title: newSeriesType.startsWith('bar')
? i18n.translate('xpack.lens.xySuggestions.barChartTitle', {
defaultMessage: 'Bar chart',
})
: i18n.translate('xpack.lens.xySuggestions.lineChartTitle', {
defaultMessage: 'Line chart',
}),
})
);
}
if (seriesType !== 'line' && splitBy && !seriesType.includes('percentage')) {
// flip between stacked/unstacked
sameStateSuggestions.push(
buildSuggestion({
...options,
seriesType: toggleStackSeriesType(seriesType),
title: seriesType.endsWith('stacked')
? i18n.translate('xpack.lens.xySuggestions.unstackedChartTitle', {
defaultMessage: 'Unstacked',
})
: i18n.translate('xpack.lens.xySuggestions.stackedChartTitle', {
defaultMessage: 'Stacked',
}),
})
);
}
if (
seriesType !== 'line' &&
seriesType.includes('stacked') &&
!seriesType.includes('percentage')
) {
const percentageOptions = { ...options };
if (percentageOptions.xValue?.operation.scale === 'ordinal' && !percentageOptions.splitBy) {
percentageOptions.splitBy = percentageOptions.xValue;
delete percentageOptions.xValue;
}
// percentage suggestion
sameStateSuggestions.push(
buildSuggestion({
...options,
// hide the suggestion if split by is missing
hide: !percentageOptions.splitBy,
seriesType: asPercentageSeriesType(seriesType),
title: i18n.translate('xpack.lens.xySuggestions.asPercentageTitle', {
defaultMessage: 'Percentage',
}),
})
);
}
// Combine all pre-built suggestions with hidden suggestions for remaining chart types
return sameStateSuggestions.concat(
visualizationTypes
.filter((visType) => {
return !sameStateSuggestions.find(
(suggestion) => suggestion.state.preferredSeriesType === visType.id
);
})
.map((visType) => {
return {
...buildSuggestion({ ...options, seriesType: visType.id as SeriesType }),
hide: true,
};
})
);
}
function toggleStackSeriesType(oldSeriesType: SeriesType) {
switch (oldSeriesType) {
case 'area':
return 'area_stacked';
case 'area_stacked':
return 'area';
case 'bar':
return 'bar_stacked';
case 'bar_stacked':
return 'bar';
default:
return oldSeriesType;
}
}
function asPercentageSeriesType(oldSeriesType: SeriesType) {
switch (oldSeriesType) {
case 'area_stacked':
return 'area_percentage_stacked';
case 'bar_stacked':
return 'bar_percentage_stacked';
case 'bar_horizontal_stacked':
return 'bar_horizontal_percentage_stacked';
default:
return oldSeriesType;
}
}
// Until the area chart rendering bug is fixed, avoid suggesting area charts
// https://github.com/elastic/elastic-charts/issues/388
function altSeriesType(oldSeriesType: SeriesType) {
switch (oldSeriesType) {
case 'area':
return 'line';
case 'area_stacked':
return 'bar_stacked';
case 'bar':
return 'line';
case 'bar_stacked':
return 'line';
case 'line':
default:
return 'bar_stacked';
}
}
function getSeriesType(
currentState: XYState | undefined,
layerId: string,
xValue?: TableSuggestionColumn
): SeriesType {
const defaultType = 'bar_stacked';
const oldLayer = getExistingLayer(currentState, layerId);
const oldLayerSeriesType = oldLayer ? oldLayer.seriesType : false;
const closestSeriesType =
oldLayerSeriesType || (currentState && currentState.preferredSeriesType) || defaultType;
// Attempt to keep the seriesType consistent on initial add of a layer
// Ordinal scales should always use a bar because there is no interpolation between buckets
if (xValue && xValue.operation.scale && xValue.operation.scale === 'ordinal') {
return closestSeriesType.startsWith('bar') ? closestSeriesType : defaultType;
}
return closestSeriesType;
}
function getSuggestionTitle(
yValues: TableSuggestionColumn[],
xValue: TableSuggestionColumn | undefined,
tableLabel: string | undefined
) {
const yTitle = yValues
.map((col) => col.operation.label)
.join(
i18n.translate('xpack.lens.xySuggestions.yAxixConjunctionSign', {
defaultMessage: ' & ',
description:
'A character that can be used for conjunction of multiple enumarated items. Make sure to include spaces around it if needed.',
})
);
const xTitle =
xValue?.operation.label ||
i18n.translate('xpack.lens.xySuggestions.emptyAxisTitle', {
defaultMessage: '(empty)',
});
const title =
tableLabel ||
(xValue?.operation.dataType === 'date'
? i18n.translate('xpack.lens.xySuggestions.dateSuggestion', {
defaultMessage: '{yTitle} over {xTitle}',
description:
'Chart description for charts over time, like "Transfered bytes over log.timestamp"',
values: { xTitle, yTitle },
})
: i18n.translate('xpack.lens.xySuggestions.nonDateSuggestion', {
defaultMessage: '{yTitle} of {xTitle}',
description:
'Chart description for a value of some groups, like "Top URLs of top 5 countries"',
values: { xTitle, yTitle },
}));
return title;
}
function buildSuggestion({
currentState,
seriesType,
layerId,
title,
yValues,
splitBy,
changeType,
xValue,
keptLayerIds,
hide,
mainPalette,
}: {
currentState: XYState | undefined;
seriesType: SeriesType;
title: string;
yValues: TableSuggestionColumn[];
xValue?: TableSuggestionColumn;
splitBy: TableSuggestionColumn | undefined;
layerId: string;
changeType: TableChangeType;
keptLayerIds: string[];
hide?: boolean;
mainPalette?: PaletteOutput;
}) {
if (seriesType.includes('percentage') && xValue?.operation.scale === 'ordinal' && !splitBy) {
splitBy = xValue;
xValue = undefined;
}
const existingLayer: XYLayerConfig | {} = getExistingLayer(currentState, layerId) || {};
const accessors = yValues.map((col) => col.columnId);
const newLayer = {
...existingLayer,
palette: mainPalette || ('palette' in existingLayer ? existingLayer.palette : undefined),
layerId,
seriesType,
xAccessor: xValue?.columnId,
splitAccessor: splitBy?.columnId,
accessors,
yConfig:
'yConfig' in existingLayer && existingLayer.yConfig
? existingLayer.yConfig.filter(({ forAccessor }) => accessors.indexOf(forAccessor) !== -1)
: undefined,
};
// Maintain consistent order for any layers that were saved
const keptLayers = currentState
? currentState.layers
// Remove layers that aren't being suggested
.filter((layer) => keptLayerIds.includes(layer.layerId))
// Update in place
.map((layer) => (layer.layerId === layerId ? newLayer : layer))
// Replace the seriesType on all previous layers
.map((layer) => ({
...layer,
seriesType,
}))
: [];
const state: State = {
legend: currentState ? currentState.legend : { isVisible: true, position: Position.Right },
valueLabels: currentState?.valueLabels || 'hide',
fittingFunction: currentState?.fittingFunction || 'None',
xTitle: currentState?.xTitle,
yTitle: currentState?.yTitle,
yRightTitle: currentState?.yRightTitle,
axisTitlesVisibilitySettings: currentState?.axisTitlesVisibilitySettings || {
x: true,
yLeft: true,
yRight: true,
},
tickLabelsVisibilitySettings: currentState?.tickLabelsVisibilitySettings || {
x: true,
yLeft: true,
yRight: true,
},
gridlinesVisibilitySettings: currentState?.gridlinesVisibilitySettings || {
x: true,
yLeft: true,
yRight: true,
},
preferredSeriesType: seriesType,
layers: Object.keys(existingLayer).length ? keptLayers : [...keptLayers, newLayer],
};
return {
title,
score: getScore(yValues, splitBy, changeType),
hide:
hide ??
// Only advertise very clear changes when XY chart is not active
((!currentState && changeType !== 'unchanged' && changeType !== 'extended') ||
// Don't advertise removing dimensions
(currentState && changeType === 'reduced') ||
// Don't advertise charts without y axis
yValues.length === 0 ||
// Don't advertise charts without at least one split
(!xValue && !splitBy)),
state,
previewIcon: getIconForSeries(seriesType),
};
}
function getScore(
yValues: TableSuggestionColumn[],
splitBy: TableSuggestionColumn | undefined,
changeType: TableChangeType
) {
// Unchanged table suggestions half the score because the underlying data doesn't change
const changeFactor = changeType === 'unchanged' ? 0.5 : 1;
// chart with multiple y values and split series will have a score of 1, single y value and no split series reduce score
return (((yValues.length > 1 ? 2 : 1) + (splitBy ? 1 : 0)) / 3) * changeFactor;
}
function getExistingLayer(currentState: XYState | undefined, layerId: string) {
return currentState && currentState.layers.find((layer) => layer.layerId === layerId);
}