kibana/x-pack/plugins/lens/public/pie_visualization/render_function.tsx
2020-09-15 11:24:02 +02:00

271 lines
9.2 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 React, { useState, useEffect } from 'react';
import color from 'color';
import { FormattedMessage } from '@kbn/i18n/react';
import { EuiText } from '@elastic/eui';
// @ts-ignore no types
import { euiPaletteColorBlindBehindText } from '@elastic/eui/lib/services';
import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json';
import {
Chart,
Datum,
Settings,
Partition,
PartitionConfig,
PartitionLayer,
PartitionLayout,
PartitionFillLabel,
RecursivePartial,
LayerValue,
Position,
} from '@elastic/charts';
import { FormatFactory, LensFilterEvent } from '../types';
import { VisualizationContainer } from '../visualization_container';
import { CHART_NAMES, DEFAULT_PERCENT_DECIMALS } from './constants';
import { ColumnGroups, PieExpressionProps } from './types';
import { getSliceValueWithFallback, getFilterContext } from './render_helpers';
import { EmptyPlaceholder } from '../shared_components';
import './visualization.scss';
import { desanitizeFilterContext } from '../utils';
import { ChartsPluginSetup } from '../../../../../src/plugins/charts/public';
import { LensIconChartDonut } from '../assets/chart_donut';
const EMPTY_SLICE = Symbol('empty_slice');
const sortedColors = euiPaletteColorBlindBehindText();
export function PieComponent(
props: PieExpressionProps & {
formatFactory: FormatFactory;
chartsThemeService: ChartsPluginSetup['theme'];
onClickValue: (data: LensFilterEvent['data']) => void;
}
) {
const [firstTable] = Object.values(props.data.tables);
const formatters: Record<string, ReturnType<FormatFactory>> = {};
const { chartsThemeService, onClickValue } = props;
const {
shape,
groups,
metric,
numberDisplay,
categoryDisplay,
legendDisplay,
legendPosition,
nestedLegend,
percentDecimals,
hideLabels,
} = props.args;
const isDarkMode = chartsThemeService.useDarkMode();
const chartTheme = chartsThemeService.useChartsTheme();
const chartBaseTheme = chartsThemeService.useChartsBaseTheme();
if (!hideLabels) {
firstTable.columns.forEach((column) => {
formatters[column.id] = props.formatFactory(column.formatHint);
});
}
// The datatable for pie charts should include subtotals, like this:
// [bucket, subtotal, bucket, count]
// But the user only configured [bucket, bucket, count]
const columnGroups: ColumnGroups = [];
firstTable.columns.forEach((col) => {
if (groups.includes(col.id)) {
columnGroups.push({
col,
metrics: [],
});
} else if (columnGroups.length > 0) {
columnGroups[columnGroups.length - 1].metrics.push(col);
}
});
const fillLabel: Partial<PartitionFillLabel> = {
textInvertible: false,
valueFont: {
fontWeight: 700,
},
};
if (numberDisplay === 'hidden') {
// Hides numbers from appearing inside chart, but they still appear in linkLabel
// and tooltips.
fillLabel.valueFormatter = () => '';
}
const layers: PartitionLayer[] = columnGroups.map(({ col }, layerIndex) => {
return {
groupByRollup: (d: Datum) => d[col.id] ?? EMPTY_SLICE,
showAccessor: (d: Datum) => d !== EMPTY_SLICE,
nodeLabel: (d: unknown) => {
if (hideLabels || d === EMPTY_SLICE) {
return '';
}
if (col.formatHint) {
return formatters[col.id].convert(d) ?? '';
}
return String(d);
},
fillLabel:
isDarkMode &&
shape === 'treemap' &&
layerIndex < columnGroups.length - 1 &&
categoryDisplay !== 'hide'
? { ...fillLabel, textColor: euiDarkVars.euiTextColor }
: fillLabel,
shape: {
fillColor: (d) => {
// Color is determined by round-robin on the index of the innermost slice
// This has to be done recursively until we get to the slice index
let parentIndex = 0;
let tempParent: typeof d | typeof d['parent'] = d;
while (tempParent.parent && tempParent.depth > 0) {
parentIndex = tempParent.sortIndex;
tempParent = tempParent.parent;
}
// Look up round-robin color from default palette
const outputColor = sortedColors[parentIndex % sortedColors.length];
if (shape === 'treemap') {
// Only highlight the innermost color of the treemap, as it accurately represents area
return layerIndex < columnGroups.length - 1 ? 'rgba(0,0,0,0)' : outputColor;
}
const lighten = (d.depth - 1) / (columnGroups.length * 2);
return color(outputColor, 'hsl').lighten(lighten).hex();
},
},
};
});
const config: RecursivePartial<PartitionConfig> = {
partitionLayout: shape === 'treemap' ? PartitionLayout.treemap : PartitionLayout.sunburst,
fontFamily: chartTheme.barSeriesStyle?.displayValue?.fontFamily,
outerSizeRatio: 1,
specialFirstInnermostSector: true,
clockwiseSectors: false,
minFontSize: 10,
maxFontSize: 16,
// Labels are added outside the outer ring when the slice is too small
linkLabel: {
maxCount: 5,
fontSize: 11,
// Dashboard background color is affected by dark mode, which we need
// to account for in outer labels
// This does not handle non-dashboard embeddables, which are allowed to
// have different backgrounds.
textColor: chartTheme.axes?.axisTitle?.fill,
},
sectorLineStroke: chartTheme.lineSeriesStyle?.point?.fill,
sectorLineWidth: 1.5,
circlePadding: 4,
};
if (shape === 'treemap') {
if (hideLabels || categoryDisplay === 'hide') {
config.fillLabel = { textColor: 'rgba(0,0,0,0)' };
}
} else {
config.emptySizeRatio = shape === 'donut' ? 0.3 : 0;
if (hideLabels || categoryDisplay === 'hide') {
// Force all labels to be linked, then prevent links from showing
config.linkLabel = { maxCount: 0, maximumSection: Number.POSITIVE_INFINITY };
} else if (categoryDisplay === 'inside') {
// Prevent links from showing
config.linkLabel = { maxCount: 0 };
}
}
const metricColumn = firstTable.columns.find((c) => c.id === metric)!;
const percentFormatter = props.formatFactory({
id: 'percent',
params: {
pattern: `0,0.[${'0'.repeat(percentDecimals ?? DEFAULT_PERCENT_DECIMALS)}]%`,
},
});
const [state, setState] = useState({ isReady: false });
// It takes a cycle for the chart to render. This prevents
// reporting from printing a blank chart placeholder.
useEffect(() => {
setState({ isReady: true });
}, []);
const reverseGroups = [...columnGroups].reverse();
const hasNegative = firstTable.rows.some((row) => {
const value = row[metricColumn.id];
return typeof value === 'number' && value < 0;
});
const isEmpty =
firstTable.rows.length === 0 ||
firstTable.rows.every((row) =>
groups.every((colId) => !row[colId] || typeof row[colId] === 'undefined')
);
if (isEmpty) {
return <EmptyPlaceholder icon={LensIconChartDonut} />;
}
if (hasNegative) {
return (
<EuiText className="lnsChart__empty" textAlign="center" color="subdued" size="xs">
<FormattedMessage
id="xpack.lens.pie.pieWithNegativeWarningLabel"
defaultMessage="{chartType} charts can't render with negative values. Try a different chart type."
values={{
chartType: CHART_NAMES[shape].label,
}}
/>
</EuiText>
);
}
return (
<VisualizationContainer className="lnsPieExpression__container" isReady={state.isReady}>
<Chart>
<Settings
// Legend is hidden in many scenarios
// - Tiny preview
// - Treemap does not need a legend because it uses category labels
// - Single layer pie/donut usually shows text, does not need legend
showLegend={
!hideLabels &&
(legendDisplay === 'show' ||
(legendDisplay === 'default' && columnGroups.length > 1 && shape !== 'treemap'))
}
legendPosition={legendPosition || Position.Right}
legendMaxDepth={nestedLegend ? undefined : 1 /* Color is based only on first layer */}
onElementClick={(args) => {
const context = getFilterContext(
args[0][0] as LayerValue[],
columnGroups.map(({ col }) => col.id),
firstTable
);
onClickValue(desanitizeFilterContext(context));
}}
theme={chartTheme}
baseTheme={chartBaseTheme}
/>
<Partition
id={shape}
data={firstTable.rows}
valueAccessor={(d: Datum) => getSliceValueWithFallback(d, reverseGroups, metricColumn)}
percentFormatter={(d: number) => percentFormatter.convert(d / 100)}
valueGetter={hideLabels || numberDisplay === 'value' ? undefined : 'percent'}
valueFormatter={(d: number) => (hideLabels ? '' : formatters[metricColumn.id].convert(d))}
layers={layers}
config={config}
topGroove={hideLabels || categoryDisplay === 'hide' ? 0 : undefined}
/>
</Chart>
</VisualizationContainer>
);
}