563 lines
17 KiB
TypeScript
563 lines
17 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;
|
|
* you may not use this file except in compliance with the Elastic License.
|
|
*/
|
|
|
|
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, LayerConfig } 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>> {
|
|
if (
|
|
// We only render line charts for multi-row queries. We require at least
|
|
// two columns: one for x and at least one for y, and y columns must be numeric.
|
|
// We reject any datasource suggestions which have a column of an unknown type.
|
|
!table.isMultiRow ||
|
|
table.columns.length <= 1 ||
|
|
table.columns.every((col) => col.operation.dataType !== 'number') ||
|
|
table.columns.some((col) => !columnSortOrder.hasOwnProperty(col.operation.dataType))
|
|
) {
|
|
if (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,
|
|
};
|
|
});
|
|
}
|
|
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 [x, ...yValues] = prioritizeColumns(values);
|
|
return getSuggestionsForLayer({
|
|
layerId: table.layerId,
|
|
changeType: table.changeType,
|
|
xValue: x,
|
|
yValues,
|
|
splitBy: undefined,
|
|
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 }),
|
|
title: visType.label,
|
|
hide: visType.id !== 'bar_stacked',
|
|
};
|
|
})
|
|
.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: LayerConfig | {} = 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')),
|
|
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);
|
|
}
|