x-pack/platform/plugins/shared/lens/public/visualizations/xy/visualization.tsx (1,319 lines of code) (raw):
/*
* 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 React, { useState } from 'react';
import { isEqual } from 'lodash';
import useObservable from 'react-use/lib/useObservable';
import { Position } from '@elastic/charts';
import { EuiPopover, EuiSelectable } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { i18n } from '@kbn/i18n';
import type { PaletteRegistry } from '@kbn/coloring';
import { IconChartBarReferenceLine, IconChartBarAnnotations } from '@kbn/chart-icons';
import { FieldFormatsStart } from '@kbn/field-formats-plugin/public';
import { CoreStart, CoreTheme, SavedObjectReference, ThemeServiceStart } from '@kbn/core/public';
import { EventAnnotationServiceType } from '@kbn/event-annotation-plugin/public';
import { getAnnotationAccessor } from '@kbn/event-annotation-components';
import { VIS_EVENT_TO_TRIGGER } from '@kbn/visualizations-plugin/public';
import type { DataPublicPluginStart } from '@kbn/data-plugin/public';
import type { IStorageWrapper } from '@kbn/kibana-utils-plugin/public';
import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public';
import { LayerTypes } from '@kbn/expression-xy-plugin/public';
import { SavedObjectTaggingPluginStart } from '@kbn/saved-objects-tagging-plugin/public';
import type { EventAnnotationGroupConfig } from '@kbn/event-annotation-common';
import { type AccessorConfig, DimensionTrigger } from '@kbn/visualization-ui-components';
import { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public';
import { getColorsFromMapping } from '@kbn/coloring';
import { ToolbarButton } from '@kbn/shared-ux-button-toolbar';
import { getKbnPalettes } from '@kbn/palettes';
import { generateId } from '../../id_generator';
import {
isDraggedDataViewField,
isOperationFromCompatibleGroup,
isOperationFromTheSameGroup,
nonNullable,
renewIDs,
getColorMappingDefaults,
} from '../../utils';
import { getSuggestions } from './xy_suggestions';
import { XyToolbar, updateLayer } from './xy_config_panel';
import {
DataDimensionEditor,
DataDimensionEditorDataSectionExtra,
} from './xy_config_panel/dimension_editor';
import {
ReferenceLayerHeader,
AnnotationsLayerHeader,
LayerHeaderContent,
} from './xy_config_panel/layer_header';
import type {
Visualization,
FramePublicAPI,
Suggestion,
UserMessage,
AnnotationGroups,
} from '../../types';
import type { FormBasedPersistedState } from '../../datasources/form_based/types';
import {
type State,
type XYLayerConfig,
type XYDataLayerConfig,
type SeriesType,
visualizationSubtypes,
visualizationTypes,
} from './types';
import {
getAnnotationLayerErrors,
isHorizontalChart,
annotationLayerHasUnsavedChanges,
isHorizontalSeries,
} from './state_helpers';
import { toExpression, toPreviewExpression, getSortedAccessors } from './to_expression';
import { getAccessorColorConfigs, getColorAssignments } from './color_assignment';
import { getColumnToLabelMap } from './state_helpers';
import {
getGroupsAvailableInData,
getReferenceConfiguration,
getReferenceSupportedLayer,
setReferenceDimension,
} from './reference_line_helpers';
import {
getAnnotationsConfiguration,
getAnnotationsSupportedLayer,
setAnnotationsDimension,
getUniqueLabels,
onAnnotationDrop,
} from './annotations/helpers';
import {
checkXAccessorCompatibility,
getAnnotationLayerTitle,
getAnnotationsLayers,
getAxisName,
getDataLayers,
getDescription,
getFirstDataLayer,
getLayersByType,
getReferenceLayers,
getVisualizationType,
isAnnotationsLayer,
isBucketed,
isByReferenceAnnotationsLayer,
isDataLayer,
isNumericDynamicMetric,
isReferenceLayer,
newLayerState,
supportedDataLayer,
validateLayersForDimension,
isTimeChart,
} from './visualization_helpers';
import { getAxesConfiguration, groupAxesByType } from './axes_configuration';
import type { XYByValueAnnotationLayerConfig, XYState } from './types';
import { defaultSeriesType } from './types';
import { defaultAnnotationLabel } from './annotations/helpers';
import { onDropForVisualization } from '../../editor_frame_service/editor_frame/config_panel/buttons/drop_targets_utils';
import { createAnnotationActions } from './annotations/actions';
import { AddLayerButton } from './add_layer';
import { LayerSettings } from './layer_settings';
import { IgnoredGlobalFiltersEntries } from '../../shared_components/ignore_global_filter';
import { getColorMappingTelemetryEvents } from '../../lens_ui_telemetry/color_telemetry_helpers';
import { getLegendStatsTelemetryEvents } from './legend_stats_telemetry_helpers';
import { XYPersistedState, convertPersistedState, convertToPersistable } from './persistence';
import { shouldDisplayTable } from '../../shared_components/legend/legend_settings_popover';
import {
ANNOTATION_MISSING_DATE_HISTOGRAM,
LAYER_SETTINGS_IGNORE_GLOBAL_FILTERS,
XY_MIXED_LOG_SCALE,
XY_MIXED_LOG_SCALE_DIMENSION,
XY_RENDER_ARRAY_VALUES,
XY_X_WRONG_DATA_TYPE,
XY_Y_WRONG_DATA_TYPE,
} from '../../user_messages_ids';
import { AnnotationsPanel } from './xy_config_panel/annotations_config_panel/annotations_panel';
import { ReferenceLinePanel } from './xy_config_panel/reference_line_config_panel/reference_line_panel';
import { convertToRuntimeState } from './runtime_state';
const XY_ID = 'lnsXY';
export type ExtraAppendLayerArg = EventAnnotationGroupConfig & { annotationGroupId: string };
export const getXyVisualization = ({
core,
storage,
data,
paletteService,
fieldFormats,
useLegacyTimeAxis,
kibanaTheme,
eventAnnotationService,
unifiedSearch,
dataViewsService,
savedObjectsTagging,
}: {
core: CoreStart;
storage: IStorageWrapper;
data: DataPublicPluginStart;
paletteService: PaletteRegistry;
eventAnnotationService: EventAnnotationServiceType;
fieldFormats: FieldFormatsStart;
useLegacyTimeAxis: boolean;
kibanaTheme: ThemeServiceStart;
unifiedSearch: UnifiedSearchPublicPluginStart;
dataViewsService: DataViewsPublicPluginStart;
savedObjectsTagging?: SavedObjectTaggingPluginStart;
}): Visualization<State, XYPersistedState, ExtraAppendLayerArg> => ({
id: XY_ID,
getVisualizationTypeId(state, layerId) {
const type = getVisualizationType(state, layerId);
return type === 'mixed' ? type : type.id;
},
visualizationTypes,
getLayerIds(state) {
return getLayersByType(state).map((l) => l.layerId);
},
getRemoveOperation(state, layerId) {
const dataLayers = getLayersByType(state, LayerTypes.DATA).map((l) => l.layerId);
return dataLayers.includes(layerId) && dataLayers.length === 1 ? 'clear' : 'remove';
},
removeLayer(state, layerId) {
return {
...state,
layers: state.layers.filter((l) => l.layerId !== layerId),
};
},
cloneLayer(state, layerId, newLayerId, clonedIDsMap) {
const toCopyLayer = state.layers.find((l) => l.layerId === layerId);
if (toCopyLayer) {
if (isAnnotationsLayer(toCopyLayer)) {
toCopyLayer.annotations.forEach((i) => clonedIDsMap.set(i.id, generateId()));
}
let newLayer = renewIDs(toCopyLayer, [...clonedIDsMap.keys()], (id: string) =>
clonedIDsMap.get(id)
);
newLayer.layerId = newLayerId;
if (isAnnotationsLayer(newLayer) && isByReferenceAnnotationsLayer(newLayer)) {
const byValueVersion: XYByValueAnnotationLayerConfig = {
annotations: newLayer.annotations,
ignoreGlobalFilters: newLayer.ignoreGlobalFilters,
layerId: newLayer.layerId,
layerType: newLayer.layerType,
indexPatternId: newLayer.indexPatternId,
};
newLayer = byValueVersion;
}
return {
...state,
layers: [...state.layers, newLayer],
};
}
return state;
},
appendLayer(state, layerId, layerType, indexPatternId, extraArg, seriesType) {
if (layerType === 'metricTrendline') {
return state;
}
return {
...state,
layers: [
...state.layers,
newLayerState({
layerId,
layerType,
seriesType:
seriesType || getDataLayers(state.layers)?.[0]?.seriesType || state.preferredSeriesType,
indexPatternId,
extraArg,
}),
],
};
},
clearLayer(state, layerId, indexPatternId) {
return {
...state,
layers: state.layers.map((l) =>
l.layerId !== layerId
? l
: newLayerState({
seriesType: state.preferredSeriesType,
layerId,
indexPatternId,
})
),
};
},
getPersistableState(state) {
return convertToPersistable(state);
},
getDescription,
switchVisualizationType(seriesType: string, state: State, layerId?: string) {
const dataLayer = layerId
? state.layers.find((l) => l.layerId === layerId)
: state.layers.at(0);
if (dataLayer && !isDataLayer(dataLayer)) {
throw new Error('Cannot switch series type for non-data layer');
}
if (!dataLayer) {
return state;
}
const currentStackingType = stackingTypes.find(({ subtypes }) =>
subtypes.includes(dataLayer.seriesType)
);
const chosenTypeIndex = defaultSeriesTypesByIndex.indexOf(seriesType);
const compatibleSeriesType: SeriesType = (currentStackingType?.subtypes[chosenTypeIndex] ||
seriesType) as SeriesType;
return {
...state,
preferredSeriesType: compatibleSeriesType,
layers: layerId
? state.layers.map((layer) =>
layer.layerId === layerId ? { ...layer, seriesType: compatibleSeriesType } : layer
)
: state.layers.map((layer) => ({ ...layer, seriesType: compatibleSeriesType })),
};
},
getSuggestions,
triggers: [VIS_EVENT_TO_TRIGGER.filter, VIS_EVENT_TO_TRIGGER.brush],
initialize(
addNewLayer,
persistedState,
mainPalette?,
datasourceStates?,
annotationGroups?: AnnotationGroups,
references?: SavedObjectReference[]
) {
if (persistedState) {
const convertedState = convertPersistedState(persistedState, annotationGroups, references);
return convertToRuntimeState(convertedState, datasourceStates);
}
return {
title: 'Empty XY chart',
legend: { isVisible: true, position: Position.Right },
valueLabels: 'hide',
preferredSeriesType: defaultSeriesType,
layers: [
{
layerId: addNewLayer(),
accessors: [],
position: Position.Top,
seriesType: defaultSeriesType,
showGridlines: false,
layerType: LayerTypes.DATA,
palette: mainPalette?.type === 'legacyPalette' ? mainPalette.value : undefined,
colorMapping:
mainPalette?.type === 'colorMapping' ? mainPalette.value : getColorMappingDefaults(),
},
],
};
},
convertToRuntimeState(state, datasourceStates) {
return convertToRuntimeState(state, datasourceStates);
},
getLayerType(layerId, state) {
return state?.layers.find(({ layerId: id }) => id === layerId)?.layerType;
},
getSupportedLayers(state, frame) {
return [
supportedDataLayer,
getAnnotationsSupportedLayer(state, frame),
getReferenceSupportedLayer(state, frame),
];
},
getSupportedActionsForLayer(
layerId,
state,
setState,
registerLibraryAnnotationGroup,
isSaveable
) {
const layerIndex = state.layers.findIndex((l) => l.layerId === layerId);
const layer = state.layers[layerIndex];
const actions = [];
if (isAnnotationsLayer(layer)) {
actions.push(
...createAnnotationActions({
state,
layer,
setState,
registerLibraryAnnotationGroup,
core,
isSaveable,
eventAnnotationService,
savedObjectsTagging,
dataViews: data.dataViews,
startServices: core,
})
);
}
return actions;
},
getCustomRemoveLayerText(layerId, state) {
const layerIndex = state.layers.findIndex((l) => l.layerId === layerId);
const layer = state.layers[layerIndex];
if (layer && isByReferenceAnnotationsLayer(layer)) {
return { title: `Delete "${getAnnotationLayerTitle(layer)}"` };
}
},
hasLayerSettings({ state, layerId: currentLayerId }) {
const layer = state.layers?.find(({ layerId }) => layerId === currentLayerId);
return { data: Boolean(layer && isAnnotationsLayer(layer)), appearance: false };
},
LayerSettingsComponent(props) {
return <LayerSettings {...props} />;
},
onIndexPatternChange(state, indexPatternId, layerId) {
const layerIndex = state.layers.findIndex((l) => l.layerId === layerId);
const layer = state.layers[layerIndex];
if (!layer || !isAnnotationsLayer(layer)) {
return state;
}
const newLayers = [...state.layers];
newLayers[layerIndex] = { ...layer, indexPatternId };
return {
...state,
layers: newLayers,
};
},
getConfiguration({ state, frame, layerId }) {
const layer = state.layers.find((l) => l.layerId === layerId);
if (!layer) {
return { groups: [] };
}
if (isAnnotationsLayer(layer)) {
return getAnnotationsConfiguration({ state, frame, layer });
}
const sortedAccessors: string[] = getSortedAccessors(
frame.datasourceLayers[layer.layerId],
layer
);
if (isReferenceLayer(layer)) {
return getReferenceConfiguration({ state, frame, layer, sortedAccessors });
}
const mappedAccessors = getMappedAccessors({
state,
frame,
layer,
fieldFormats,
paletteService,
accessors: sortedAccessors,
});
const dataLayer: XYDataLayerConfig = layer;
const dataLayers = getDataLayers(state.layers);
const isHorizontal = isHorizontalChart(state.layers);
const { left, right } = groupAxesByType([layer], frame.activeData);
// Check locally if it has one accessor OR one accessor per axis
const layerHasOnlyOneAccessor = Boolean(
dataLayer.accessors.length < 2 ||
(left.length && left.length < 2) ||
(right.length && right.length < 2)
);
// Check also for multiple layers that can stack for percentage charts
// Make sure that if multiple dimensions are defined for a single dataLayer, they should belong to the same axis
const hasOnlyOneAccessor =
layerHasOnlyOneAccessor &&
dataLayers.filter(
// check that the other layers are compatible with this one
(l) => {
if (
isDataLayer(l) &&
l.seriesType === dataLayer.seriesType &&
Boolean(l.xAccessor) === Boolean(dataLayer.xAccessor) &&
Boolean(l.splitAccessor) === Boolean(dataLayer.splitAccessor)
) {
const { left: localLeft, right: localRight } = groupAxesByType([l], frame.activeData);
// return true only if matching axis are found
return (
l.accessors.length &&
(Boolean(localLeft.length) === Boolean(left.length) ||
Boolean(localRight.length) === Boolean(right.length))
);
}
return false;
}
).length < 2;
const canUseColorMapping = layer.colorMapping ? true : false;
let colors: string[] = [];
if (canUseColorMapping) {
kibanaTheme.theme$
.subscribe({
next(theme) {
const palettes = getKbnPalettes(theme);
colors = getColorsFromMapping(palettes, theme.darkMode, layer.colorMapping);
},
})
.unsubscribe();
} else {
const palette = paletteService.get(dataLayer.palette?.name || 'default');
colors = palette.getCategoricalColors(10, dataLayer.palette?.params);
}
return {
groups: [
{
groupId: 'x',
groupLabel: getAxisName('x', { isHorizontal }),
accessors: dataLayer.xAccessor ? [{ columnId: dataLayer.xAccessor }] : [],
filterOperations: isBucketed,
supportsMoreColumns: !dataLayer.xAccessor,
dataTestSubj: 'lnsXY_xDimensionPanel',
},
{
groupId: 'y',
groupLabel: getAxisName('y', { isHorizontal }),
accessors: mappedAccessors,
filterOperations: isNumericDynamicMetric,
isMetricDimension: true,
supportsMoreColumns: true,
requiredMinDimensionCount: 1,
dataTestSubj: 'lnsXY_yDimensionPanel',
enableDimensionEditor: true,
},
{
groupId: 'breakdown',
groupLabel: i18n.translate('xpack.lens.xyChart.splitSeries', {
defaultMessage: 'Breakdown',
}),
accessors: dataLayer.splitAccessor
? [
{
columnId: dataLayer.splitAccessor,
triggerIconType: dataLayer.collapseFn ? 'aggregate' : 'colorBy',
palette: dataLayer.collapseFn ? undefined : colors,
},
]
: [],
filterOperations: isBucketed,
supportsMoreColumns: !dataLayer.splitAccessor,
dataTestSubj: 'lnsXY_splitDimensionPanel',
requiredMinDimensionCount:
dataLayer.seriesType.includes('percentage') && hasOnlyOneAccessor ? 1 : 0,
enableDimensionEditor: true,
isBreakdownDimension: true,
},
],
};
},
getMainPalette: (state) => {
if (!state || state.layers.length === 0) return;
const firstDataLayer = getFirstDataLayer(state.layers);
return firstDataLayer?.colorMapping
? { type: 'colorMapping', value: firstDataLayer.colorMapping }
: firstDataLayer?.palette
? { type: 'legacyPalette', value: firstDataLayer.palette }
: undefined;
},
getDropProps(dropProps) {
if (!dropProps.source) {
return;
}
const srcDataView = dropProps.source.indexPatternId;
const targetDataView = dropProps.target.indexPatternId;
if (!targetDataView || srcDataView !== targetDataView) {
return;
}
if (isDraggedDataViewField(dropProps.source)) {
if (dropProps.source.field.type === 'document') {
return;
}
return dropProps.target.isNewColumn
? { dropTypes: ['field_add'] }
: { dropTypes: ['field_replace'] };
}
if (isOperationFromTheSameGroup(dropProps.source, dropProps.target)) {
return dropProps.target.isNewColumn
? { dropTypes: ['duplicate_compatible'] }
: { dropTypes: ['reorder'] };
}
if (isOperationFromCompatibleGroup(dropProps.source, dropProps.target)) {
return {
dropTypes: dropProps.target.isNewColumn
? ['move_compatible', 'duplicate_compatible']
: ['replace_compatible', 'replace_duplicate_compatible', 'swap_compatible'],
};
}
},
onDrop(props) {
const targetLayer: XYLayerConfig | undefined = props.prevState.layers.find(
(l) => l.layerId === props.target.layerId
);
if (!targetLayer) {
throw new Error('target layer should exist');
}
if (isAnnotationsLayer(targetLayer)) {
return onAnnotationDrop?.(props) || props.prevState;
}
return onDropForVisualization(props, this);
},
setDimension(props) {
const { prevState, layerId, columnId, groupId } = props;
const foundLayer: XYLayerConfig | undefined = prevState.layers.find(
(l) => l.layerId === layerId
);
if (!foundLayer) {
return prevState;
}
if (isReferenceLayer(foundLayer)) {
return setReferenceDimension(props);
}
if (isAnnotationsLayer(foundLayer)) {
return setAnnotationsDimension(props);
}
const newLayer: XYDataLayerConfig = Object.assign({}, foundLayer);
if (groupId === 'x') {
newLayer.xAccessor = columnId;
}
if (groupId === 'y') {
newLayer.accessors = [...newLayer.accessors.filter((a) => a !== columnId), columnId];
}
if (groupId === 'breakdown') {
newLayer.splitAccessor = columnId;
}
return {
...prevState,
layers: prevState.layers.map((l) => (l.layerId === layerId ? newLayer : l)),
};
},
removeDimension({ prevState, layerId, columnId, frame }) {
const foundLayer = prevState.layers.find((l) => l.layerId === layerId);
if (!foundLayer) {
return prevState;
}
if (isAnnotationsLayer(foundLayer)) {
const newLayer = {
...foundLayer,
annotations: foundLayer.annotations.filter(({ id }) => id !== columnId),
};
const newLayers = prevState.layers.map((l) => (l.layerId === layerId ? newLayer : l));
return {
...prevState,
layers: newLayers,
};
}
const newLayer = { ...foundLayer };
if (isDataLayer(newLayer)) {
if (newLayer.xAccessor === columnId) {
delete newLayer.xAccessor;
} else if (newLayer.splitAccessor === columnId) {
delete newLayer.splitAccessor;
// as the palette is associated with the break down by dimension, remove it together with the dimension
delete newLayer.palette;
}
}
if (newLayer.accessors.includes(columnId)) {
newLayer.accessors = newLayer.accessors.filter((a) => a !== columnId);
}
if ('yConfig' in newLayer) {
newLayer.yConfig = newLayer.yConfig?.filter(({ forAccessor }) => forAccessor !== columnId);
}
let newLayers = prevState.layers.map((l) => (l.layerId === layerId ? newLayer : l));
// check if there's any reference layer and pull it off if all data layers have no dimensions set
// check for data layers if they all still have xAccessors
const groupsAvailable = getGroupsAvailableInData(
getDataLayers(prevState.layers),
frame.datasourceLayers,
frame.activeData
);
if (
(Object.keys(groupsAvailable) as Array<'x' | 'yLeft' | 'yRight'>).every(
(id) => !groupsAvailable[id]
)
) {
newLayers = newLayers.filter(
(layer) => isDataLayer(layer) || ('accessors' in layer && layer.accessors.length)
);
}
return {
...prevState,
layers: newLayers,
};
},
LayerPanelComponent(props) {
const { onChangeIndexPattern, ...otherProps } = props;
return (
<LayerHeaderContent
{...otherProps}
onChangeIndexPattern={(indexPatternId) => {
onChangeIndexPattern(indexPatternId);
}}
/>
);
},
isSubtypeCompatible(subtype1, subtype2) {
return (
(isHorizontalSeries(subtype1 as SeriesType) && isHorizontalSeries(subtype2 as SeriesType)) ||
(!isHorizontalSeries(subtype1 as SeriesType) && !isHorizontalSeries(subtype2 as SeriesType))
);
},
getSubtypeSwitch({ state, setState, layerId }) {
const index = state.layers.findIndex((l) => l.layerId === layerId);
const layer = state.layers[index];
if (!layer || !isDataLayer(layer) || layer.seriesType === 'line') {
return null;
}
return () => (
<SubtypeSwitch
layer={layer}
setLayerState={(newLayer: XYDataLayerConfig) =>
setState(updateLayer(state, newLayer, index))
}
/>
);
},
getCustomLayerHeader(props) {
const layer = props.state.layers.find((l) => l.layerId === props.layerId);
if (!layer) {
return undefined;
}
if (isReferenceLayer(layer)) {
return <ReferenceLayerHeader />;
}
if (isAnnotationsLayer(layer)) {
return (
<AnnotationsLayerHeader
title={getAnnotationLayerTitle(layer)}
hasUnsavedChanges={annotationLayerHasUnsavedChanges(layer)}
/>
);
}
return undefined;
},
ToolbarComponent(props) {
return <XyToolbar {...props} useLegacyTimeAxis={useLegacyTimeAxis} />;
},
DimensionEditorComponent(props) {
const allProps = {
...props,
datatableUtilities: data.datatableUtilities,
formatFactory: fieldFormats.deserialize,
paletteService,
};
const theme = useObservable<CoreTheme>(kibanaTheme.theme$, {
darkMode: false,
name: 'amsterdam',
});
const palettes = getKbnPalettes(theme);
const layer = props.state.layers.find((l) => l.layerId === props.layerId)!;
const dimensionEditor = isReferenceLayer(layer) ? (
<ReferenceLinePanel {...allProps} palettes={palettes} />
) : isAnnotationsLayer(layer) ? (
<AnnotationsPanel {...allProps} dataViewsService={dataViewsService} />
) : (
<DataDimensionEditor {...allProps} palettes={palettes} isDarkMode={theme.darkMode} />
);
return dimensionEditor;
},
DimensionEditorDataExtraComponent(props) {
const allProps = {
...props,
datatableUtilities: data.datatableUtilities,
formatFactory: fieldFormats.deserialize,
paletteService,
};
const layer = props.state.layers.find((l) => l.layerId === props.layerId)!;
if (isReferenceLayer(layer)) {
return null;
}
if (isAnnotationsLayer(layer)) {
return null;
}
return <DataDimensionEditorDataSectionExtra {...allProps} />;
},
getAddLayerButtonComponent: (props) => {
return (
<AddLayerButton
{...props}
eventAnnotationService={eventAnnotationService}
addLayer={async (type, loadedGroupInfo, _, seriesType) => {
if (type === LayerTypes.ANNOTATIONS && loadedGroupInfo) {
await props.ensureIndexPattern(
loadedGroupInfo.dataViewSpec ?? loadedGroupInfo.indexPatternId
);
props.registerLibraryAnnotationGroup({
id: loadedGroupInfo.annotationGroupId,
group: loadedGroupInfo,
});
}
props.addLayer(type, loadedGroupInfo, !!loadedGroupInfo, seriesType);
}}
/>
);
},
toExpression: (state, layers, _attributes, datasourceExpressionsByLayers = {}) =>
toExpression(
state,
layers,
paletteService,
datasourceExpressionsByLayers,
eventAnnotationService
),
toPreviewExpression: (state, layers, datasourceExpressionsByLayers = {}) =>
toPreviewExpression(
state,
layers,
paletteService,
datasourceExpressionsByLayers,
eventAnnotationService
),
getUserMessages(state, { frame }) {
const { datasourceLayers, dataViews, activeData } = frame;
const annotationLayers = getAnnotationsLayers(state.layers);
const errors: UserMessage[] = [];
const hasDateHistogram = isTimeChart(getDataLayers(state.layers), frame);
annotationLayers.forEach((layer) => {
layer.annotations.forEach((annotation) => {
if (!hasDateHistogram) {
errors.push({
uniqueId: ANNOTATION_MISSING_DATE_HISTOGRAM,
severity: 'error',
fixableInEditor: true,
displayLocations: [{ id: 'dimensionButton', dimensionId: annotation.id }],
shortMessage: i18n.translate(
'xpack.lens.xyChart.addAnnotationsLayerLabelDisabledHelp',
{
defaultMessage:
'Annotations require a time based chart to work. Add a date histogram.',
}
),
longMessage: '',
});
}
const errorMessages = getAnnotationLayerErrors(layer, annotation.id, dataViews);
errors.push(...errorMessages);
});
});
// check if the layers in the state are compatible with this type of chart
if (state && state.layers.length > 1) {
// Order is important here: Y Axis is fundamental to exist to make it valid
const yLayerValidation = validateLayersForDimension(
'y',
state.layers,
({ accessors }) => accessors == null || accessors.length === 0 // has no accessor
);
if (!yLayerValidation.valid) {
errors.push(yLayerValidation.error);
}
const breakDownLayerValidation = validateLayersForDimension(
'break_down',
state.layers,
({ splitAccessor, seriesType }) =>
seriesType.includes('percentage') && splitAccessor == null // check if no split accessor
);
if (!breakDownLayerValidation.valid) {
errors.push(breakDownLayerValidation.error);
}
}
// temporary fix for #87068
errors.push(
...checkXAccessorCompatibility(state, datasourceLayers).map<UserMessage>(
({ shortMessage, longMessage }) => ({
severity: 'error',
uniqueId: XY_X_WRONG_DATA_TYPE,
fixableInEditor: true,
displayLocations: [{ id: 'visualization' }],
shortMessage,
longMessage,
})
)
);
for (const layer of getDataLayers(state.layers)) {
const datasourceAPI = datasourceLayers[layer.layerId];
if (datasourceAPI) {
for (const accessor of layer.accessors) {
const operation = datasourceAPI.getOperationForColumnId(accessor);
if (operation && operation.dataType !== 'number') {
errors.push({
uniqueId: XY_Y_WRONG_DATA_TYPE,
severity: 'error',
fixableInEditor: true,
displayLocations: [{ id: 'visualization' }],
shortMessage: i18n.translate('xpack.lens.xyVisualization.dataTypeFailureYShort', {
defaultMessage: `Wrong data type for {axis}.`,
values: {
axis: getAxisName('y', { isHorizontal: isHorizontalChart(state.layers) }),
},
}),
longMessage: i18n.translate('xpack.lens.xyVisualization.dataTypeFailureYLong', {
defaultMessage: `The dimension {label} provided for the {axis} has the wrong data type. Expected number but have {dataType}`,
values: {
label: operation.label,
dataType: operation.dataType,
axis: getAxisName('y', { isHorizontal: isHorizontalChart(state.layers) }),
},
}),
});
}
}
}
}
const warnings: UserMessage[] = [];
if (state?.layers.length > 0 && activeData) {
const filteredLayers = [
...getDataLayers(state.layers),
...getReferenceLayers(state.layers),
].filter(({ accessors }) => accessors.length > 0);
const accessorsWithArrayValues = [];
for (const layer of filteredLayers) {
const { layerId, accessors } = layer;
const rows = activeData?.[layerId] && activeData[layerId].rows;
if (!rows) {
break;
}
const columnToLabel = getColumnToLabelMap(layer, datasourceLayers[layerId]);
for (const accessor of accessors) {
const hasArrayValues = rows.some((row) => Array.isArray(row[accessor]));
if (hasArrayValues) {
accessorsWithArrayValues.push(columnToLabel[accessor]);
}
}
}
accessorsWithArrayValues.forEach((label) =>
warnings.push({
uniqueId: XY_RENDER_ARRAY_VALUES,
severity: 'warning',
fixableInEditor: true,
displayLocations: [{ id: 'toolbar' }],
shortMessage: '',
longMessage: (
<FormattedMessage
key={label}
id="xpack.lens.xyVisualization.arrayValues"
defaultMessage="{label} contains array values. Your visualization may not render as expected."
values={{
label: <strong>{label}</strong>,
}}
/>
),
})
);
}
const shouldRotate = state?.layers.length ? isHorizontalChart(state.layers) : false;
const dataLayers = getDataLayers(state.layers);
const axisGroups = getAxesConfiguration(dataLayers, shouldRotate, frame.activeData);
const logAxisGroups = axisGroups.filter(
({ groupId }) =>
(groupId === 'left' && state.yLeftScale === 'log') ||
(groupId === 'right' && state.yRightScale === 'log')
);
if (logAxisGroups.length > 0) {
logAxisGroups
.map((axis) => {
const mixedDomainSeries = axis.series.filter((series) => {
let hasNegValues = false;
let hasPosValues = false;
const arr = activeData?.[series.layer]?.rows ?? [];
for (let index = 0; index < arr.length; index++) {
const value = arr[index][series.accessor];
if (value < 0) {
hasNegValues = true;
} else {
hasPosValues = true;
}
if (hasNegValues && hasPosValues) {
return true;
}
}
return false;
});
return {
...axis,
mixedDomainSeries,
};
})
.forEach((axisGroup) => {
if (axisGroup.mixedDomainSeries.length === 0) return;
const { groupId } = axisGroup;
warnings.push({
// TODO: can we push the group into the metadata and use a correct unique ID here?
uniqueId: `${XY_MIXED_LOG_SCALE}${groupId}`,
severity: 'warning',
shortMessage: '',
longMessage: (
<FormattedMessage
id="xpack.lens.xyVisualization.mixedLogScaleWarning"
defaultMessage="When the {axisName} axis is set to logarithmic scale, the dataset should not contain positive and negative data."
values={{
axisName:
groupId === 'left' ? (
<FormattedMessage
id="xpack.lens.xyVisualization.mixedLogScaleWarningLeft"
defaultMessage="left"
/>
) : (
<FormattedMessage
id="xpack.lens.xyVisualization.mixedLogScaleWarningRight"
defaultMessage="right"
/>
),
}}
/>
),
displayLocations: [{ id: 'toolbar' }],
fixableInEditor: true,
});
axisGroup.mixedDomainSeries.forEach(({ accessor }) => {
warnings.push({
// TODO: can we push the group into the metadata and use a correct unique ID here?
uniqueId: `${XY_MIXED_LOG_SCALE_DIMENSION}${accessor}`,
severity: 'warning',
shortMessage: '',
longMessage: (
<FormattedMessage
id="xpack.lens.xyVisualization.mixedLogScaleDimensionWarning"
defaultMessage="This metric is using logarithmic scale and should not contain positive and negative data."
/>
),
displayLocations: [{ id: 'dimensionButton', dimensionId: accessor }],
fixableInEditor: true,
});
});
});
}
const info = getNotifiableFeatures(state, frame, paletteService, fieldFormats);
return errors.concat(warnings, info);
},
getUniqueLabels(state) {
return getUniqueLabels(state.layers);
},
getUsedDataView(state, layerId) {
return getAnnotationsLayers(state.layers).find((l) => l.layerId === layerId)?.indexPatternId;
},
getUsedDataViews(state) {
return (
state?.layers.filter(isAnnotationsLayer).map(({ indexPatternId }) => indexPatternId) ?? []
);
},
DimensionTriggerComponent({ columnId, label }) {
if (label) {
return <DimensionTrigger id={columnId} label={label || defaultAnnotationLabel} />;
}
return null;
},
getSuggestionFromConvertToLensContext({ suggestions, context }) {
const allSuggestions = suggestions as Array<Suggestion<XYState, FormBasedPersistedState>>;
const suggestion: Suggestion<XYState, FormBasedPersistedState> = {
...allSuggestions[0],
datasourceState: {
...allSuggestions[0].datasourceState,
layers: allSuggestions.reduce(
(acc, s) => ({
...acc,
...s.datasourceState?.layers,
}),
{}
),
},
visualizationState: {
...allSuggestions[0].visualizationState,
...(context.configuration as XYState),
},
};
return suggestion;
},
isEqual(persistedState1, references1, persistedState2, references2, annotationGroups) {
const state1 = convertPersistedState(persistedState1, annotationGroups, references1);
const state2 = convertPersistedState(persistedState2, annotationGroups, references2);
return isEqual(state1, state2);
},
getVisualizationInfo(state, frame) {
return getVisualizationInfo(state, frame, paletteService, fieldFormats);
},
getTelemetryEventsOnSave(state, prevState) {
const dataLayers = getDataLayers(state.layers);
const prevLayers = prevState ? getDataLayers(prevState.layers) : undefined;
const colorMappingEvents = dataLayers.flatMap((l) => {
const prevLayer = prevLayers?.find((prevL) => prevL.layerId === l.layerId);
return getColorMappingTelemetryEvents(l.colorMapping, prevLayer?.colorMapping);
});
const legendStatsEvents = getLegendStatsTelemetryEvents(
state.legend.legendStats,
prevState ? prevState.legend.legendStats : undefined
);
return colorMappingEvents.concat(legendStatsEvents);
},
getRenderEventCounters(state) {
if (shouldDisplayTable(state.legend.legendStats ?? [])) {
return [`legend_stats`];
}
return [];
},
});
const getMappedAccessors = ({
accessors,
frame,
fieldFormats,
paletteService,
state,
layer,
}: {
accessors: string[];
frame: Pick<FramePublicAPI, 'activeData' | 'datasourceLayers'>;
paletteService: PaletteRegistry;
fieldFormats: FieldFormatsStart;
state: XYState;
layer: XYDataLayerConfig;
}) => {
let mappedAccessors: AccessorConfig[] = accessors.map((accessor) => ({
columnId: accessor,
}));
if (frame.activeData) {
const colorAssignments = getColorAssignments(
getDataLayers(state.layers),
{ tables: frame.activeData },
fieldFormats.deserialize
);
mappedAccessors = getAccessorColorConfigs(
colorAssignments,
frame,
{
...layer,
accessors: accessors.filter((sorted) => layer.accessors.includes(sorted)),
},
paletteService
);
}
return mappedAccessors;
};
function getVisualizationInfo(
state: XYState,
frame: Partial<FramePublicAPI> | undefined,
paletteService: PaletteRegistry,
fieldFormats: FieldFormatsStart
) {
const isHorizontal = isHorizontalChart(state.layers);
const visualizationLayersInfo = state.layers.map((layer) => {
const palette = [];
const dimensions = [];
let chartType: SeriesType | undefined;
let icon;
let label;
if (isDataLayer(layer)) {
chartType = layer.seriesType;
const layerVisType = visualizationSubtypes.find((visType) => visType.id === chartType);
icon = layerVisType?.icon;
label = layerVisType?.label;
if (layer.xAccessor) {
dimensions.push({
name: getAxisName('x', { isHorizontal }),
id: layer.xAccessor,
dimensionType: 'x',
});
}
if (layer.accessors && layer.accessors.length) {
layer.accessors.forEach((accessor) => {
dimensions.push({
name: getAxisName('y', { isHorizontal }),
id: accessor,
dimensionType: 'y',
});
});
if (frame?.datasourceLayers && frame.activeData) {
const sortedAccessors: string[] = getSortedAccessors(
frame.datasourceLayers[layer.layerId],
layer
);
const mappedAccessors = getMappedAccessors({
state,
frame: frame as Pick<FramePublicAPI, 'datasourceLayers' | 'activeData'>,
layer,
fieldFormats,
paletteService,
accessors: sortedAccessors,
});
palette.push(...mappedAccessors.flatMap(({ color }) => color));
}
}
if (layer.splitAccessor) {
dimensions.push({
name: i18n.translate('xpack.lens.xyChart.splitSeries', {
defaultMessage: 'Breakdown',
}),
dimensionType: 'breakdown',
id: layer.splitAccessor,
});
if (!layer.collapseFn) {
palette.push(
...paletteService
.get(layer.palette?.name || 'default')
.getCategoricalColors(10, layer.palette?.params)
);
}
}
}
if (isReferenceLayer(layer) && layer.accessors && layer.accessors.length) {
layer.accessors.forEach((accessor) => {
dimensions.push({
name: i18n.translate('xpack.lens.xyChart.layerReferenceLine', {
defaultMessage: 'Reference line',
}),
dimensionType: 'reference_line',
id: accessor,
});
});
label = i18n.translate('xpack.lens.xyChart.layerReferenceLineLabel', {
defaultMessage: 'Reference lines',
});
icon = IconChartBarReferenceLine;
if (frame?.datasourceLayers && frame.activeData) {
const sortedAccessors: string[] = getSortedAccessors(
frame.datasourceLayers[layer.layerId],
layer
);
palette.push(
...getReferenceConfiguration({
state,
frame: frame as Pick<FramePublicAPI, 'datasourceLayers' | 'activeData'>,
layer,
sortedAccessors,
}).groups.flatMap(({ accessors }) => accessors.map(({ color }) => color))
);
}
}
if (isAnnotationsLayer(layer) && layer.annotations && layer.annotations.length) {
layer.annotations.forEach((annotation) => {
dimensions.push({
name: i18n.translate('xpack.lens.xyChart.layerAnnotation', {
defaultMessage: 'Annotation',
}),
dimensionType: 'annotation',
id: annotation.id,
});
});
label = i18n.translate('xpack.lens.xyChart.layerAnnotationsLabel', {
defaultMessage: 'Annotations',
});
icon = IconChartBarAnnotations;
palette.push(
...layer.annotations
.filter(({ isHidden }) => !isHidden)
.map((annotation) => getAnnotationAccessor(annotation).color)
);
}
const finalPalette = palette?.filter(nonNullable);
return {
layerId: layer.layerId,
layerType: layer.layerType,
chartType,
icon,
label,
dimensions,
palette: finalPalette.length ? finalPalette : undefined,
};
});
return {
layers: visualizationLayersInfo,
};
}
function getNotifiableFeatures(
state: XYState,
frame: Pick<FramePublicAPI, 'dataViews'> & Partial<FramePublicAPI>,
paletteService: PaletteRegistry,
fieldFormats: FieldFormatsStart
): UserMessage[] {
const annotationsWithIgnoreFlag = getAnnotationsLayers(state.layers).filter(
(layer) =>
layer.ignoreGlobalFilters &&
// If all annotations are manual, do not report it
layer.annotations.some((annotation) => annotation.type !== 'manual')
);
if (!annotationsWithIgnoreFlag.length) {
return [];
}
const visualizationInfo = getVisualizationInfo(state, frame, paletteService, fieldFormats);
return [
{
uniqueId: LAYER_SETTINGS_IGNORE_GLOBAL_FILTERS,
severity: 'info',
fixableInEditor: false,
shortMessage: i18n.translate('xpack.lens.xyChart.layerAnnotationsIgnoreTitle', {
defaultMessage: 'Layers ignoring global filters',
}),
longMessage: (
<IgnoredGlobalFiltersEntries
layers={annotationsWithIgnoreFlag.map(({ layerId, indexPatternId }) => ({
layerId,
indexPatternId,
}))}
visualizationInfo={visualizationInfo}
dataViews={frame.dataViews}
/>
),
displayLocations: [{ id: 'embeddableBadge' }],
},
];
}
const defaultSeriesTypesByIndex = ['bar', 'area', 'bar_horizontal'];
export const stackingTypes = [
{
type: 'stacked',
label: i18n.translate('xpack.lens.shared.barLayerStacking.stacked', {
defaultMessage: 'Stacked',
}),
subtypes: ['bar_stacked', 'area_stacked', 'bar_horizontal_stacked'],
},
{
type: 'unstacked',
label: i18n.translate('xpack.lens.shared.barLayerStacking.unstacked', {
defaultMessage: 'Unstacked',
}),
subtypes: ['bar', 'area', 'bar_horizontal'],
},
{
type: 'percentage',
label: i18n.translate('xpack.lens.shared.barLayerStacking.percentage', {
defaultMessage: 'Percentage',
}),
subtypes: [
'bar_percentage_stacked',
'area_percentage_stacked',
'bar_horizontal_percentage_stacked',
],
},
];
const SubtypeSwitch = ({
layer,
setLayerState,
}: {
layer: XYDataLayerConfig;
setLayerState: (l: XYDataLayerConfig) => void;
}): JSX.Element | null => {
const [flyoutOpen, setFlyoutOpen] = useState(false);
const stackingType = stackingTypes.find(({ subtypes }) => subtypes.includes(layer.seriesType));
if (!stackingType) {
return null;
}
const subTypeIndex = stackingType.subtypes.indexOf(layer.seriesType);
const options = stackingTypes.map(({ label, subtypes }) => ({
label,
value: subtypes[subTypeIndex],
checked: subtypes[subTypeIndex] === layer.seriesType ? ('on' as const) : undefined,
}));
return (
<>
<EuiPopover
ownFocus
panelPaddingSize="none"
button={
<ToolbarButton
aria-label={i18n.translate('xpack.lens.xyChart.stackingOptions', {
defaultMessage: 'Stacking',
})}
onClick={() => setFlyoutOpen(true)}
fullWidth
size="s"
label={stackingType.label}
/>
}
isOpen={flyoutOpen}
closePopover={() => setFlyoutOpen(false)}
anchorPosition="downLeft"
>
<EuiSelectable
css={{ width: 200 }}
singleSelection
data-test-subj="lnsChartSwitchList"
options={options}
onChange={(newOptions) => {
setFlyoutOpen(false);
const chosenType = newOptions.find(({ checked }) => checked === 'on');
if (!chosenType) {
return;
}
setLayerState({
...layer,
seriesType: chosenType.value as SeriesType,
});
}}
>
{(list) => list}
</EuiSelectable>
</EuiPopover>
</>
);
};