kibana/x-pack/plugins/lens/public/xy_visualization/expression.tsx
Marco Liberati d3d3fa7bd2
[Lens] New value labels config option for bar charts (#81776)
Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
2020-11-06 16:34:30 +01:00

814 lines
27 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 './expression.scss';
import React, { useState, useEffect } from 'react';
import ReactDOM from 'react-dom';
import moment from 'moment';
import {
Chart,
Settings,
Axis,
LineSeries,
AreaSeries,
BarSeries,
Position,
GeometryValue,
XYChartSeriesIdentifier,
StackMode,
RecursivePartial,
Theme,
VerticalAlignment,
HorizontalAlignment,
} from '@elastic/charts';
import { I18nProvider } from '@kbn/i18n/react';
import {
ExpressionFunctionDefinition,
ExpressionRenderDefinition,
ExpressionValueSearchContext,
Datatable,
DatatableRow,
} from 'src/plugins/expressions/public';
import { IconType } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import {
LensMultiTable,
FormatFactory,
ILensInterpreterRenderHandlers,
LensFilterEvent,
LensBrushEvent,
} from '../types';
import { XYArgs, SeriesType, visualizationTypes } from './types';
import { VisualizationContainer } from '../visualization_container';
import { isHorizontalChart, getSeriesColor } from './state_helpers';
import { parseInterval } from '../../../../../src/plugins/data/common';
import {
ChartsPluginSetup,
PaletteRegistry,
SeriesLayer,
} from '../../../../../src/plugins/charts/public';
import { EmptyPlaceholder } from '../shared_components';
import { desanitizeFilterContext } from '../utils';
import { fittingFunctionDefinitions, getFitOptions } from './fitting_functions';
import { getAxesConfiguration } from './axes_configuration';
import { getColorAssignments } from './color_assignment';
type InferPropType<T> = T extends React.FunctionComponent<infer P> ? P : T;
type SeriesSpec = InferPropType<typeof LineSeries> &
InferPropType<typeof BarSeries> &
InferPropType<typeof AreaSeries>;
export interface XYChartProps {
data: LensMultiTable;
args: XYArgs;
}
export interface XYRender {
type: 'render';
as: 'lens_xy_chart_renderer';
value: XYChartProps;
}
type XYChartRenderProps = XYChartProps & {
chartsThemeService: ChartsPluginSetup['theme'];
paletteService: PaletteRegistry;
formatFactory: FormatFactory;
timeZone: string;
histogramBarTarget: number;
onClickValue: (data: LensFilterEvent['data']) => void;
onSelectRange: (data: LensBrushEvent['data']) => void;
};
export const xyChart: ExpressionFunctionDefinition<
'lens_xy_chart',
LensMultiTable | ExpressionValueSearchContext | null,
XYArgs,
XYRender
> = {
name: 'lens_xy_chart',
type: 'render',
inputTypes: ['lens_multitable', 'kibana_context', 'null'],
help: i18n.translate('xpack.lens.xyChart.help', {
defaultMessage: 'An X/Y chart',
}),
args: {
title: {
types: ['string'],
help: 'The chart title.',
},
description: {
types: ['string'],
help: '',
},
xTitle: {
types: ['string'],
help: i18n.translate('xpack.lens.xyChart.xTitle.help', {
defaultMessage: 'X axis title',
}),
},
yTitle: {
types: ['string'],
help: i18n.translate('xpack.lens.xyChart.yLeftTitle.help', {
defaultMessage: 'Y left axis title',
}),
},
yRightTitle: {
types: ['string'],
help: i18n.translate('xpack.lens.xyChart.yRightTitle.help', {
defaultMessage: 'Y right axis title',
}),
},
legend: {
types: ['lens_xy_legendConfig'],
help: i18n.translate('xpack.lens.xyChart.legend.help', {
defaultMessage: 'Configure the chart legend.',
}),
},
fittingFunction: {
types: ['string'],
options: [...fittingFunctionDefinitions.map(({ id }) => id)],
help: i18n.translate('xpack.lens.xyChart.fittingFunction.help', {
defaultMessage: 'Define how missing values are treated',
}),
},
valueLabels: {
types: ['string'],
options: ['hide', 'inside'],
help: '',
},
tickLabelsVisibilitySettings: {
types: ['lens_xy_tickLabelsConfig'],
help: i18n.translate('xpack.lens.xyChart.tickLabelsSettings.help', {
defaultMessage: 'Show x and y axes tick labels',
}),
},
gridlinesVisibilitySettings: {
types: ['lens_xy_gridlinesConfig'],
help: i18n.translate('xpack.lens.xyChart.gridlinesSettings.help', {
defaultMessage: 'Show x and y axes gridlines',
}),
},
axisTitlesVisibilitySettings: {
types: ['lens_xy_axisTitlesVisibilityConfig'],
help: i18n.translate('xpack.lens.xyChart.axisTitlesSettings.help', {
defaultMessage: 'Show x and y axes titles',
}),
},
layers: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
types: ['lens_xy_layer'] as any,
help: 'Layers of visual series',
multi: true,
},
},
fn(data: LensMultiTable, args: XYArgs) {
return {
type: 'render',
as: 'lens_xy_chart_renderer',
value: {
data,
args,
},
};
},
};
export const getXyChartRenderer = (dependencies: {
formatFactory: Promise<FormatFactory>;
chartsThemeService: ChartsPluginSetup['theme'];
paletteService: PaletteRegistry;
histogramBarTarget: number;
timeZone: string;
}): ExpressionRenderDefinition<XYChartProps> => ({
name: 'lens_xy_chart_renderer',
displayName: 'XY chart',
help: i18n.translate('xpack.lens.xyChart.renderer.help', {
defaultMessage: 'X/Y chart renderer',
}),
validate: () => undefined,
reuseDomNode: true,
render: async (
domNode: Element,
config: XYChartProps,
handlers: ILensInterpreterRenderHandlers
) => {
handlers.onDestroy(() => ReactDOM.unmountComponentAtNode(domNode));
const onClickValue = (data: LensFilterEvent['data']) => {
handlers.event({ name: 'filter', data });
};
const onSelectRange = (data: LensBrushEvent['data']) => {
handlers.event({ name: 'brush', data });
};
const formatFactory = await dependencies.formatFactory;
ReactDOM.render(
<I18nProvider>
<XYChartReportable
{...config}
formatFactory={formatFactory}
chartsThemeService={dependencies.chartsThemeService}
paletteService={dependencies.paletteService}
timeZone={dependencies.timeZone}
histogramBarTarget={dependencies.histogramBarTarget}
onClickValue={onClickValue}
onSelectRange={onSelectRange}
/>
</I18nProvider>,
domNode,
() => handlers.done()
);
},
});
function mergeThemeWithValueLabelsStyling(
theme: RecursivePartial<Theme>,
valuesLabelMode: string = 'hide',
isHorizontal: boolean
) {
const VALUE_LABELS_MAX_FONTSIZE = 15;
const VALUE_LABELS_MIN_FONTSIZE = 10;
const VALUE_LABELS_VERTICAL_OFFSET = -10;
const VALUE_LABELS_HORIZONTAL_OFFSET = 10;
if (valuesLabelMode === 'hide') {
return theme;
}
return {
...theme,
...{
barSeriesStyle: {
...theme.barSeriesStyle,
displayValue: {
fontSize: { min: VALUE_LABELS_MIN_FONTSIZE, max: VALUE_LABELS_MAX_FONTSIZE },
fill: { textInverted: true, textBorder: 2 },
alignment: isHorizontal
? {
vertical: VerticalAlignment.Middle,
}
: { horizontal: HorizontalAlignment.Center },
offsetX: isHorizontal ? VALUE_LABELS_HORIZONTAL_OFFSET : 0,
offsetY: isHorizontal ? 0 : VALUE_LABELS_VERTICAL_OFFSET,
},
},
},
};
}
function getIconForSeriesType(seriesType: SeriesType): IconType {
return visualizationTypes.find((c) => c.id === seriesType)!.icon || 'empty';
}
const MemoizedChart = React.memo(XYChart);
export function XYChartReportable(props: XYChartRenderProps) {
const [state, setState] = useState({
isReady: false,
});
// It takes a cycle for the XY chart to render. This prevents
// reporting from printing a blank chart placeholder.
useEffect(() => {
setState({ isReady: true });
}, [setState]);
return (
<VisualizationContainer
className="lnsXyExpression__container"
isReady={state.isReady}
reportTitle={props.args.title}
reportDescription={props.args.description}
>
<MemoizedChart {...props} />
</VisualizationContainer>
);
}
export function XYChart({
data,
args,
formatFactory,
timeZone,
chartsThemeService,
paletteService,
histogramBarTarget,
onClickValue,
onSelectRange,
}: XYChartRenderProps) {
const { legend, layers, fittingFunction, gridlinesVisibilitySettings, valueLabels } = args;
const chartTheme = chartsThemeService.useChartsTheme();
const chartBaseTheme = chartsThemeService.useChartsBaseTheme();
const filteredLayers = layers.filter(({ layerId, xAccessor, accessors, splitAccessor }) => {
return !(
!accessors.length ||
!data.tables[layerId] ||
data.tables[layerId].rows.length === 0 ||
(xAccessor &&
data.tables[layerId].rows.every((row) => typeof row[xAccessor] === 'undefined')) ||
// stacked percentage bars have no xAccessors but splitAccessor with undefined values in them when empty
(!xAccessor &&
splitAccessor &&
data.tables[layerId].rows.every((row) => typeof row[splitAccessor] === 'undefined'))
);
});
if (filteredLayers.length === 0) {
const icon: IconType = layers.length > 0 ? getIconForSeriesType(layers[0].seriesType) : 'bar';
return <EmptyPlaceholder icon={icon} />;
}
// use formatting hint of first x axis column to format ticks
const xAxisColumn = data.tables[filteredLayers[0].layerId].columns.find(
({ id }) => id === filteredLayers[0].xAccessor
);
const xAxisFormatter = formatFactory(xAxisColumn && xAxisColumn.meta?.params);
const layersAlreadyFormatted: Record<string, boolean> = {};
// This is a safe formatter for the xAccessor that abstracts the knowledge of already formatted layers
const safeXAccessorLabelRenderer = (value: unknown): string =>
xAxisColumn && layersAlreadyFormatted[xAxisColumn.id]
? (value as string)
: xAxisFormatter.convert(value);
const chartHasMoreThanOneSeries =
filteredLayers.length > 1 ||
filteredLayers.some((layer) => layer.accessors.length > 1) ||
filteredLayers.some((layer) => layer.splitAccessor);
const shouldRotate = isHorizontalChart(filteredLayers);
const yAxesConfiguration = getAxesConfiguration(
filteredLayers,
shouldRotate,
data.tables,
formatFactory
);
const xTitle = args.xTitle || (xAxisColumn && xAxisColumn.name);
const axisTitlesVisibilitySettings = args.axisTitlesVisibilitySettings || {
x: true,
yLeft: true,
yRight: true,
};
const tickLabelsVisibilitySettings = args.tickLabelsVisibilitySettings || {
x: true,
yLeft: true,
yRight: true,
};
const filteredBarLayers = filteredLayers.filter((layer) => layer.seriesType.includes('bar'));
const chartHasMoreThanOneBarSeries =
filteredBarLayers.length > 1 ||
filteredBarLayers.some((layer) => layer.accessors.length > 1) ||
filteredBarLayers.some((layer) => layer.splitAccessor);
function calculateMinInterval() {
// check all the tables to see if all of the rows have the same timestamp
// that would mean that chart will draw a single bar
const isSingleTimestampInXDomain = () => {
const firstRowValue =
data.tables[filteredLayers[0].layerId].rows[0][filteredLayers[0].xAccessor!];
for (const layer of filteredLayers) {
if (
layer.xAccessor &&
data.tables[layer.layerId].rows.some((row) => row[layer.xAccessor!] !== firstRowValue)
) {
return false;
}
}
return true;
};
// add minInterval only for single point in domain
if (data.dateRange && isSingleTimestampInXDomain()) {
const params = xAxisColumn?.meta?.sourceParams?.params as Record<string, string>;
if (params?.interval !== 'auto') return parseInterval(params?.interval)?.asMilliseconds();
const { fromDate, toDate } = data.dateRange;
const duration = moment(toDate).diff(moment(fromDate));
const targetMs = duration / histogramBarTarget;
return isNaN(targetMs) ? 0 : Math.max(Math.floor(targetMs), 1);
}
return undefined;
}
const isTimeViz = data.dateRange && filteredLayers.every((l) => l.xScaleType === 'time');
const isHistogramViz = filteredLayers.every((l) => l.isHistogram);
const xDomain = isTimeViz
? {
min: data.dateRange?.fromDate.getTime(),
max: data.dateRange?.toDate.getTime(),
minInterval: calculateMinInterval(),
}
: undefined;
const getYAxesTitles = (
axisSeries: Array<{ layer: string; accessor: string }>,
groupId: string
) => {
const yTitle = groupId === 'right' ? args.yRightTitle : args.yTitle;
return (
yTitle ||
axisSeries
.map(
(series) =>
data.tables[series.layer].columns.find((column) => column.id === series.accessor)?.name
)
.filter((name) => Boolean(name))[0]
);
};
const getYAxesStyle = (groupId: string) => {
const style = {
tickLabel: {
visible:
groupId === 'right'
? tickLabelsVisibilitySettings?.yRight
: tickLabelsVisibilitySettings?.yLeft,
},
axisTitle: {
visible:
groupId === 'right'
? axisTitlesVisibilitySettings?.yRight
: axisTitlesVisibilitySettings?.yLeft,
},
};
return style;
};
const shouldShowValueLabels =
// No stacked bar charts
filteredLayers.every((layer) => !layer.seriesType.includes('stacked')) &&
// No histogram charts
!isHistogramViz;
const baseThemeWithMaybeValueLabels = !shouldShowValueLabels
? chartTheme
: mergeThemeWithValueLabelsStyling(chartTheme, valueLabels, shouldRotate);
const colorAssignments = getColorAssignments(args.layers, data, formatFactory);
return (
<Chart>
<Settings
showLegend={
legend.isVisible && !legend.showSingleSeries
? chartHasMoreThanOneSeries
: legend.isVisible
}
legendPosition={legend.position}
showLegendExtra={false}
theme={baseThemeWithMaybeValueLabels}
baseTheme={chartBaseTheme}
tooltip={{
headerFormatter: (d) => safeXAccessorLabelRenderer(d.value),
}}
rotation={shouldRotate ? 90 : 0}
xDomain={xDomain}
onBrushEnd={({ x }) => {
if (!x) {
return;
}
const [min, max] = x;
if (!xAxisColumn || !isHistogramViz) {
return;
}
const table = data.tables[filteredLayers[0].layerId];
const xAxisColumnIndex = table.columns.findIndex(
(el) => el.id === filteredLayers[0].xAccessor
);
const timeFieldName = isTimeViz
? table.columns[xAxisColumnIndex]?.meta?.field
: undefined;
const context: LensBrushEvent['data'] = {
range: [min, max],
table,
column: xAxisColumnIndex,
timeFieldName,
};
onSelectRange(context);
}}
onElementClick={([[geometry, series]]) => {
// for xyChart series is always XYChartSeriesIdentifier and geometry is always type of GeometryValue
const xySeries = series as XYChartSeriesIdentifier;
const xyGeometry = geometry as GeometryValue;
const layer = filteredLayers.find((l) =>
xySeries.seriesKeys.some((key: string | number) => l.accessors.includes(key.toString()))
);
if (!layer) {
return;
}
const table = data.tables[layer.layerId];
const points = [
{
row: table.rows.findIndex((row) => {
if (layer.xAccessor) {
if (layersAlreadyFormatted[layer.xAccessor]) {
// stringify the value to compare with the chart value
return xAxisFormatter.convert(row[layer.xAccessor]) === xyGeometry.x;
}
return row[layer.xAccessor] === xyGeometry.x;
}
}),
column: table.columns.findIndex((col) => col.id === layer.xAccessor),
value: xyGeometry.x,
},
];
if (xySeries.seriesKeys.length > 1) {
const pointValue = xySeries.seriesKeys[0];
points.push({
row: table.rows.findIndex(
(row) => layer.splitAccessor && row[layer.splitAccessor] === pointValue
),
column: table.columns.findIndex((col) => col.id === layer.splitAccessor),
value: pointValue,
});
}
const xAxisFieldName = table.columns.find((el) => el.id === layer.xAccessor)?.meta?.field;
const timeFieldName = xDomain && xAxisFieldName;
const context: LensFilterEvent['data'] = {
data: points.map((point) => ({
row: point.row,
column: point.column,
value: point.value,
table,
})),
timeFieldName,
};
onClickValue(desanitizeFilterContext(context));
}}
/>
<Axis
id="x"
position={shouldRotate ? Position.Left : Position.Bottom}
title={xTitle}
gridLine={{
visible: gridlinesVisibilitySettings?.x,
strokeWidth: 2,
}}
hide={filteredLayers[0].hide || !filteredLayers[0].xAccessor}
tickFormat={(d) => safeXAccessorLabelRenderer(d)}
style={{
tickLabel: {
visible: tickLabelsVisibilitySettings?.x,
},
axisTitle: {
visible: axisTitlesVisibilitySettings.x,
},
}}
/>
{yAxesConfiguration.map((axis) => (
<Axis
key={axis.groupId}
id={axis.groupId}
groupId={axis.groupId}
position={axis.position}
title={getYAxesTitles(axis.series, axis.groupId)}
gridLine={{
visible:
axis.groupId === 'right'
? gridlinesVisibilitySettings?.yRight
: gridlinesVisibilitySettings?.yLeft,
}}
hide={filteredLayers[0].hide}
tickFormat={(d) => axis.formatter?.convert(d) || ''}
style={getYAxesStyle(axis.groupId)}
/>
))}
{filteredLayers.flatMap((layer, layerIndex) =>
layer.accessors.map((accessor, accessorIndex) => {
const {
splitAccessor,
seriesType,
accessors,
xAccessor,
layerId,
columnToLabel,
yScaleType,
xScaleType,
isHistogram,
palette,
} = layer;
const columnToLabelMap: Record<string, string> = columnToLabel
? JSON.parse(columnToLabel)
: {};
const table = data.tables[layerId];
const isPrimitive = (value: unknown): boolean =>
value != null && typeof value !== 'object';
// what if row values are not primitive? That is the case of, for instance, Ranges
// remaps them to their serialized version with the formatHint metadata
// In order to do it we need to make a copy of the table as the raw one is required for more features (filters, etc...) later on
const tableConverted: Datatable = {
...table,
rows: table.rows.map((row: DatatableRow) => {
const newRow = { ...row };
for (const column of table.columns) {
const record = newRow[column.id];
if (record && !isPrimitive(record)) {
newRow[column.id] = formatFactory(column.meta.params).convert(record);
}
}
return newRow;
}),
};
// save the id of the layer with the custom table
table.columns.reduce<Record<string, boolean>>(
(alreadyFormatted: Record<string, boolean>, { id }) => {
if (alreadyFormatted[id]) {
return alreadyFormatted;
}
alreadyFormatted[id] = table.rows.some(
(row, i) => row[id] !== tableConverted.rows[i][id]
);
return alreadyFormatted;
},
layersAlreadyFormatted
);
// For date histogram chart type, we're getting the rows that represent intervals without data.
// To not display them in the legend, they need to be filtered out.
const rows = tableConverted.rows.filter(
(row) =>
!(xAccessor && typeof row[xAccessor] === 'undefined') &&
!(
splitAccessor &&
typeof row[splitAccessor] === 'undefined' &&
typeof row[accessor] === 'undefined'
)
);
if (!xAccessor) {
rows.forEach((row) => {
row.unifiedX = i18n.translate('xpack.lens.xyChart.emptyXLabel', {
defaultMessage: '(empty)',
});
});
}
const yAxis = yAxesConfiguration.find((axisConfiguration) =>
axisConfiguration.series.find((currentSeries) => currentSeries.accessor === accessor)
);
const seriesProps: SeriesSpec = {
splitSeriesAccessors: splitAccessor ? [splitAccessor] : [],
stackAccessors: seriesType.includes('stacked') ? [xAccessor as string] : [],
id: `${splitAccessor}-${accessor}`,
xAccessor: xAccessor || 'unifiedX',
yAccessors: [accessor],
data: rows,
xScaleType: xAccessor ? xScaleType : 'ordinal',
yScaleType,
color: ({ yAccessor, seriesKeys }) => {
const overwriteColor = getSeriesColor(layer, accessor);
if (overwriteColor !== null) {
return overwriteColor;
}
const colorAssignment = colorAssignments[palette.name];
const seriesLayers: SeriesLayer[] = [
{
name: splitAccessor ? String(seriesKeys[0]) : columnToLabelMap[seriesKeys[0]],
totalSeriesAtDepth: colorAssignment.totalSeriesCount,
rankAtDepth: colorAssignment.getRank(
layer,
String(seriesKeys[0]),
String(yAccessor)
),
},
];
return paletteService.get(palette.name).getColor(
seriesLayers,
{
maxDepth: 1,
behindText: false,
totalSeries: colorAssignment.totalSeriesCount,
},
palette.params
);
},
groupId: yAxis?.groupId,
enableHistogramMode:
isHistogram &&
(seriesType.includes('stacked') || !splitAccessor) &&
(seriesType.includes('stacked') ||
!seriesType.includes('bar') ||
!chartHasMoreThanOneBarSeries),
stackMode: seriesType.includes('percentage') ? StackMode.Percentage : undefined,
timeZone,
areaSeriesStyle: {
point: {
visible: !xAccessor,
radius: 5,
},
},
lineSeriesStyle: {
point: {
visible: !xAccessor,
radius: 5,
},
},
name(d) {
const splitHint = table.columns.find((col) => col.id === splitAccessor)?.meta?.params;
// For multiple y series, the name of the operation is used on each, either:
// * Key - Y name
// * Formatted value - Y name
if (accessors.length > 1) {
const result = d.seriesKeys
.map((key: string | number, i) => {
if (
i === 0 &&
splitHint &&
splitAccessor &&
!layersAlreadyFormatted[splitAccessor]
) {
return formatFactory(splitHint).convert(key);
}
return splitAccessor && i === 0 ? key : columnToLabelMap[key] ?? '';
})
.join(' - ');
return result;
}
// For formatted split series, format the key
// This handles splitting by dates, for example
if (splitHint) {
if (splitAccessor && layersAlreadyFormatted[splitAccessor]) {
return d.seriesKeys[0];
}
return formatFactory(splitHint).convert(d.seriesKeys[0]);
}
// This handles both split and single-y cases:
// * If split series without formatting, show the value literally
// * If single Y, the seriesKey will be the accessor, so we show the human-readable name
return splitAccessor ? d.seriesKeys[0] : columnToLabelMap[d.seriesKeys[0]] ?? '';
},
};
const index = `${layerIndex}-${accessorIndex}`;
switch (seriesType) {
case 'line':
return (
<LineSeries key={index} {...seriesProps} fit={getFitOptions(fittingFunction)} />
);
case 'bar':
case 'bar_stacked':
case 'bar_percentage_stacked':
case 'bar_horizontal':
case 'bar_horizontal_stacked':
case 'bar_horizontal_percentage_stacked':
const valueLabelsSettings = {
displayValueSettings: {
// This format double fixes two issues in elastic-chart
// * when rotating the chart, the formatter is not correctly picked
// * in some scenarios value labels are not strings, and this breaks the elastic-chart lib
valueFormatter: (d: unknown) => yAxis?.formatter?.convert(d) || '',
showValueLabel: shouldShowValueLabels && valueLabels !== 'hide',
isAlternatingValueLabel: false,
isValueContainedInElement: true,
hideClippedValue: true,
},
};
return <BarSeries key={index} {...seriesProps} {...valueLabelsSettings} />;
case 'area_stacked':
case 'area_percentage_stacked':
return (
<AreaSeries key={index} {...seriesProps} fit={getFitOptions(fittingFunction)} />
);
case 'area':
return (
<AreaSeries key={index} {...seriesProps} fit={getFitOptions(fittingFunction)} />
);
default:
return assertNever(seriesType);
}
})
)}
</Chart>
);
}
function assertNever(x: never): never {
throw new Error('Unexpected series type: ' + x);
}