[PieVis] PartitionVis integration to Lens. (#123937)

* Removed pie/donut/mosaic/treemap expressions from lens

* Replaced pie/donut/mosaic/treemap expressions with expressions from expression_partition_vis

* Fixed bug with __other__ labels.

* Cleaned up not used fields at Lens.

* Added support of empty results for multiple chart types.]

* Refactored visualization_noresults.

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Yaroslav Kuznietsov 2022-02-14 16:55:57 +02:00 committed by GitHub
parent 4c31b157be
commit 54de36f85a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
60 changed files with 1065 additions and 2074 deletions

View file

@ -12,7 +12,7 @@
"extraPublicDirs": [
"common"
],
"requiredPlugins": ["charts", "data", "expressions", "visualizations", "fieldFormats"],
"requiredPlugins": ["charts", "data", "expressions", "visualizations", "fieldFormats", "presentationUtil"],
"requiredBundles": ["kibanaReact"],
"optionalPlugins": []
}

View file

@ -5,33 +5,34 @@ exports[`PartitionVisComponent should render correct structure for donut 1`] = `
css={
Object {
"map": undefined,
"name": "1bdmk0u",
"name": "13h2mjc",
"next": undefined,
"styles": "
display:flex;flex:1 1 auto;min-height:0;min-width:0;;;
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
padding: 8px;
min-height: 0;
min-width: 0;
margin-left: auto;
margin-right: auto;
overflow: hidden;
width: 100%;
height: 100%;
;
inset: 0;
position: absolute;
padding: 8px;
",
"toString": [Function],
}
}
data-test-subj="visTypePieChart"
data-test-subj="partitionVisChart"
>
<div
css={
Object {
"map": undefined,
"name": "1lu5dww",
"name": "19k4zle",
"next": undefined,
"styles": "display:flex;flex:1 1 auto;min-height:0;min-width:0;",
"styles": "display:flex;flex:1 1 auto;min-height:0;min-width:0;width:100%;height:100%;",
"toString": [Function],
}
}
@ -400,33 +401,34 @@ exports[`PartitionVisComponent should render correct structure for mosaic 1`] =
css={
Object {
"map": undefined,
"name": "1bdmk0u",
"name": "13h2mjc",
"next": undefined,
"styles": "
display:flex;flex:1 1 auto;min-height:0;min-width:0;;;
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
padding: 8px;
min-height: 0;
min-width: 0;
margin-left: auto;
margin-right: auto;
overflow: hidden;
width: 100%;
height: 100%;
;
inset: 0;
position: absolute;
padding: 8px;
",
"toString": [Function],
}
}
data-test-subj="visTypePieChart"
data-test-subj="partitionVisChart"
>
<div
css={
Object {
"map": undefined,
"name": "1lu5dww",
"name": "19k4zle",
"next": undefined,
"styles": "display:flex;flex:1 1 auto;min-height:0;min-width:0;",
"styles": "display:flex;flex:1 1 auto;min-height:0;min-width:0;width:100%;height:100%;",
"toString": [Function],
}
}
@ -826,33 +828,34 @@ exports[`PartitionVisComponent should render correct structure for pie 1`] = `
css={
Object {
"map": undefined,
"name": "1bdmk0u",
"name": "13h2mjc",
"next": undefined,
"styles": "
display:flex;flex:1 1 auto;min-height:0;min-width:0;;;
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
padding: 8px;
min-height: 0;
min-width: 0;
margin-left: auto;
margin-right: auto;
overflow: hidden;
width: 100%;
height: 100%;
;
inset: 0;
position: absolute;
padding: 8px;
",
"toString": [Function],
}
}
data-test-subj="visTypePieChart"
data-test-subj="partitionVisChart"
>
<div
css={
Object {
"map": undefined,
"name": "1lu5dww",
"name": "19k4zle",
"next": undefined,
"styles": "display:flex;flex:1 1 auto;min-height:0;min-width:0;",
"styles": "display:flex;flex:1 1 auto;min-height:0;min-width:0;width:100%;height:100%;",
"toString": [Function],
}
}
@ -1205,33 +1208,34 @@ exports[`PartitionVisComponent should render correct structure for treemap 1`] =
css={
Object {
"map": undefined,
"name": "1bdmk0u",
"name": "13h2mjc",
"next": undefined,
"styles": "
display:flex;flex:1 1 auto;min-height:0;min-width:0;;;
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
padding: 8px;
min-height: 0;
min-width: 0;
margin-left: auto;
margin-right: auto;
overflow: hidden;
width: 100%;
height: 100%;
;
inset: 0;
position: absolute;
padding: 8px;
",
"toString": [Function],
}
}
data-test-subj="visTypePieChart"
data-test-subj="partitionVisChart"
>
<div
css={
Object {
"map": undefined,
"name": "1lu5dww",
"name": "19k4zle",
"next": undefined,
"styles": "display:flex;flex:1 1 auto;min-height:0;min-width:0;",
"styles": "display:flex;flex:1 1 auto;min-height:0;min-width:0;width:100%;height:100%;",
"toString": [Function],
}
}
@ -1615,33 +1619,34 @@ exports[`PartitionVisComponent should render correct structure for waffle 1`] =
css={
Object {
"map": undefined,
"name": "1bdmk0u",
"name": "13h2mjc",
"next": undefined,
"styles": "
display:flex;flex:1 1 auto;min-height:0;min-width:0;;;
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
padding: 8px;
min-height: 0;
min-width: 0;
margin-left: auto;
margin-right: auto;
overflow: hidden;
width: 100%;
height: 100%;
;
inset: 0;
position: absolute;
padding: 8px;
",
"toString": [Function],
}
}
data-test-subj="visTypePieChart"
data-test-subj="partitionVisChart"
>
<div
css={
Object {
"map": undefined,
"name": "1lu5dww",
"name": "19k4zle",
"next": undefined,
"styles": "display:flex;flex:1 1 auto;min-height:0;min-width:0;",
"styles": "display:flex;flex:1 1 auto;min-height:0;min-width:0;width:100%;height:100%;",
"toString": [Function],
}
}

View file

@ -6,26 +6,31 @@
* Side Public License, v 1.
*/
import { EuiThemeComputed } from '@elastic/eui';
import { css } from '@emotion/react';
import { EuiThemeComputed } from '@elastic/eui';
export const partitionVisWrapperStyle = css({
display: 'flex',
flex: '1 1 auto',
minHeight: 0,
minWidth: 0,
width: '100%',
height: '100%',
});
export const partitionVisContainerStyleFactory = (theme: EuiThemeComputed) => css`
${partitionVisWrapperStyle};
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
padding: ${theme.size.s};
export const partitionVisContainerStyle = css`
min-height: 0;
min-width: 0;
margin-left: auto;
margin-right: auto;
overflow: hidden;
width: 100%;
height: 100%;
`;
export const partitionVisContainerWithToggleStyleFactory = (theme: EuiThemeComputed) => css`
${partitionVisContainerStyle}
inset: 0;
position: absolute;
padding: ${theme.size.s};
`;

View file

@ -221,7 +221,9 @@ describe('PartitionVisComponent', function () {
} as unknown as Datatable;
const newProps = { ...wrapperProps, visData: newVisData };
const component = mount(<PartitionVisComponent {...newProps} />);
expect(findTestSubject(component, 'pieVisualizationError').text()).toEqual('No results found');
expect(findTestSubject(component, 'partitionVisEmptyValues').text()).toEqual(
'No results found'
);
});
it('renders the no results component if there are negative values', () => {
@ -250,8 +252,8 @@ describe('PartitionVisComponent', function () {
} as unknown as Datatable;
const newProps = { ...wrapperProps, visData: newVisData };
const component = mount(<PartitionVisComponent {...newProps} />);
expect(findTestSubject(component, 'pieVisualizationError').text()).toEqual(
"Pie/donut charts can't render with negative values."
expect(findTestSubject(component, 'partitionVisNegativeValues').text()).toEqual(
"Pie chart can't render with negative values."
);
});
});

View file

@ -20,12 +20,7 @@ import {
SeriesIdentifier,
} from '@elastic/charts';
import { useEuiTheme } from '@elastic/eui';
import {
LegendToggle,
ClickTriggerEvent,
ChartsPluginSetup,
PaletteRegistry,
} from '../../../../charts/public';
import { LegendToggle, ChartsPluginSetup, PaletteRegistry } from '../../../../charts/public';
import type { PersistedState } from '../../../../visualizations/public';
import {
Datatable,
@ -63,10 +58,12 @@ import { VisualizationNoResults } from './visualization_noresults';
import { VisTypePiePluginStartDependencies } from '../plugin';
import {
partitionVisWrapperStyle,
partitionVisContainerStyleFactory,
partitionVisContainerStyle,
partitionVisContainerWithToggleStyleFactory,
} from './partition_vis_component.styles';
import { ChartTypes } from '../../common/types';
import { filterOutConfig } from '../utils/filter_out_config';
import { FilterEvent } from '../types';
declare global {
interface Window {
@ -93,7 +90,6 @@ const PartitionVisComponent = (props: PartitionVisComponentProps) => {
const { visData, visParams: preVisParams, visType, services, syncColors } = props;
const visParams = useMemo(() => filterOutConfig(visType, preVisParams), [preVisParams, visType]);
const theme = useEuiTheme();
const chartTheme = props.chartsThemeService.useChartsTheme();
const chartBaseTheme = props.chartsThemeService.useChartsBaseTheme();
@ -103,8 +99,8 @@ const PartitionVisComponent = (props: PartitionVisComponentProps) => {
);
const formatters = useMemo(
() => generateFormatters(visParams, visData, services.fieldFormats.deserialize),
[services.fieldFormats.deserialize, visData, visParams]
() => generateFormatters(visData, services.fieldFormats.deserialize),
[services.fieldFormats.deserialize, visData]
);
const showLegendDefault = useCallback(() => {
@ -114,6 +110,8 @@ const PartitionVisComponent = (props: PartitionVisComponentProps) => {
const [showLegend, setShowLegend] = useState<boolean>(() => showLegendDefault());
const showToggleLegendElement = props.uiState !== undefined;
const [dimensions, setDimensions] = useState<undefined | PieContainerDimensions>();
const parentRef = useRef<HTMLDivElement>(null);
@ -157,11 +155,7 @@ const PartitionVisComponent = (props: PartitionVisComponentProps) => {
splitChartDimension,
splitChartFormatter
);
const event = {
name: 'filterBucket',
data: { data },
};
props.fireEvent(event);
props.fireEvent({ name: 'filter', data: { data } });
},
[props]
);
@ -169,11 +163,11 @@ const PartitionVisComponent = (props: PartitionVisComponentProps) => {
// handles legend action event data
const getLegendActionEventData = useCallback(
(vData: Datatable) =>
(series: SeriesIdentifier): ClickTriggerEvent | null => {
(series: SeriesIdentifier): FilterEvent => {
const data = getFilterEventData(vData, series);
return {
name: 'filterBucket',
name: 'filter',
data: {
negate: false,
data,
@ -184,7 +178,7 @@ const PartitionVisComponent = (props: PartitionVisComponentProps) => {
);
const handleLegendAction = useCallback(
(event: ClickTriggerEvent, negate = false) => {
(event: FilterEvent, negate = false) => {
props.fireEvent({
...event,
data: {
@ -318,6 +312,9 @@ const PartitionVisComponent = (props: PartitionVisComponentProps) => {
[visData.rows, metricColumn]
);
const isEmpty = visData.rows.length === 0;
const isMetricEmpty = visData.rows.every((row) => !row[metricColumn.id]);
/**
* Checks whether data have negative values.
* If so, the no data container is loaded.
@ -330,14 +327,23 @@ const PartitionVisComponent = (props: PartitionVisComponentProps) => {
}),
[visData.rows, metricColumn]
);
const flatLegend = isLegendFlat(visType, splitChartDimension);
const canShowPieChart = !isAllZeros && !hasNegative;
const canShowPieChart = !isEmpty && !isMetricEmpty && !isAllZeros && !hasNegative;
const { euiTheme } = useEuiTheme();
const chartContainerStyle = showToggleLegendElement
? partitionVisContainerWithToggleStyleFactory(euiTheme)
: partitionVisContainerStyle;
const partitionType = getPartitionType(visType);
return (
<div css={partitionVisContainerStyleFactory(theme.euiTheme)} data-test-subj="visTypePieChart">
<div css={chartContainerStyle} data-test-subj="partitionVisChart">
{!canShowPieChart ? (
<VisualizationNoResults hasNegativeValues={hasNegative} />
<VisualizationNoResults hasNegativeValues={hasNegative} chartType={visType} />
) : (
<div css={partitionVisWrapperStyle} ref={parentRef}>
<LegendColorPickerWrapperContext.Provider
@ -351,11 +357,13 @@ const PartitionVisComponent = (props: PartitionVisComponentProps) => {
distinctColors: visParams.distinctColors ?? false,
}}
>
<LegendToggle
onClick={toggleLegend}
showLegend={showLegend}
legendPosition={legendPosition}
/>
{showToggleLegendElement && (
<LegendToggle
onClick={toggleLegend}
showLegend={showLegend}
legendPosition={legendPosition}
/>
)}
<Chart size="100%">
<ChartSplit
splitColumnAccessor={splitChartColumnAccessor}
@ -363,7 +371,9 @@ const PartitionVisComponent = (props: PartitionVisComponentProps) => {
/>
<Settings
debugState={window._echDebugStateFlag ?? false}
showLegend={showLegend}
showLegend={
showLegend ?? shouldShowLegend(visType, visParams.legendDisplay, bucketColumns)
}
legendPosition={legendPosition}
legendMaxDepth={visParams.nestedLegend ? undefined : 1}
legendColorPicker={props.uiState ? LegendColorPickerWrapper : undefined}

View file

@ -6,27 +6,36 @@
* Side Public License, v 1.
*/
import React from 'react';
import { EuiEmptyPrompt, EuiText } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React, { FC } from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import { EmptyPlaceholder } from '../../../../charts/public';
import { ChartTypes } from '../../common/types';
import { getIcon } from '../utils';
export const VisualizationNoResults = ({ hasNegativeValues = false }) => {
return (
<EuiEmptyPrompt
iconType="visualizeApp"
iconColor="default"
data-test-subj="pieVisualizationError"
body={
<EuiText size="xs">
{hasNegativeValues
? i18n.translate('expressionPartitionVis.negativeValuesFound', {
defaultMessage: "Pie/donut charts can't render with negative values.",
})
: i18n.translate('expressionPartitionVis.noResultsFoundTitle', {
defaultMessage: 'No results found',
})}
</EuiText>
}
/>
);
interface Props {
hasNegativeValues?: boolean;
chartType: ChartTypes;
}
export const VisualizationNoResults: FC<Props> = ({ hasNegativeValues = false, chartType }) => {
if (hasNegativeValues) {
const message = (
<FormattedMessage
id="expressionPartitionVis.negativeValuesFound"
defaultMessage="{chartType} chart can't render with negative values."
values={{ chartType: `${chartType[0].toUpperCase()}${chartType.slice(1)}` }}
/>
);
return (
<EmptyPlaceholder
dataTestSubj="partitionVisNegativeValues"
icon="alert"
iconColor="warning"
message={message}
/>
);
}
return <EmptyPlaceholder dataTestSubj="partitionVisEmptyValues" icon={getIcon(chartType)} />;
};

View file

@ -10,35 +10,27 @@ import React, { lazy } from 'react';
import { render, unmountComponentAtNode } from 'react-dom';
import { I18nProvider } from '@kbn/i18n-react';
import { i18n } from '@kbn/i18n';
import { Datatable, ExpressionRenderDefinition } from '../../../../expressions/public';
import { VisualizationContainer } from '../../../../visualizations/public';
import { ExpressionRenderDefinition } from '../../../../expressions/public';
import type { PersistedState } from '../../../../visualizations/public';
import { VisTypePieDependencies } from '../plugin';
import { withSuspense } from '../../../../presentation_util/public';
import { KibanaThemeProvider } from '../../../../kibana_react/public';
import { PARTITION_VIS_RENDERER_NAME } from '../../common/constants';
import { ChartTypes, RenderValue } from '../../common/types';
import { VisTypePieDependencies } from '../plugin';
export const strings = {
getDisplayName: () =>
i18n.translate('expressionPartitionVis.renderer.pieVis.displayName', {
defaultMessage: 'Pie visualization',
i18n.translate('expressionPartitionVis.renderer.partitionVis.pie.displayName', {
defaultMessage: 'Partition visualization',
}),
getHelpDescription: () =>
i18n.translate('expressionPartitionVis.renderer.pieVis.helpDescription', {
defaultMessage: 'Render a pie',
i18n.translate('expressionPartitionVis.renderer.partitionVis.pie.helpDescription', {
defaultMessage: 'Render pie/donut/treemap/mosaic/waffle charts',
}),
};
const PartitionVisComponent = lazy(() => import('../components/partition_vis_component'));
function shouldShowNoResultsMessage(visData: Datatable | undefined): boolean {
const rows: object[] | undefined = visData?.rows;
const isZeroHits = !rows || !rows.length;
return Boolean(isZeroHits);
}
const LazyPartitionVisComponent = lazy(() => import('../components/partition_vis_component'));
const PartitionVisComponent = withSuspense(LazyPartitionVisComponent);
export const getPartitionVisRenderer: (
deps: VisTypePieDependencies
@ -48,8 +40,6 @@ export const getPartitionVisRenderer: (
help: strings.getHelpDescription(),
reuseDomNode: true,
render: async (domNode, { visConfig, visData, visType, syncColors }, handlers) => {
const showNoResult = shouldShowNoResultsMessage(visData);
handlers.onDestroy(() => {
unmountComponentAtNode(domNode);
});
@ -60,7 +50,7 @@ export const getPartitionVisRenderer: (
render(
<I18nProvider>
<KibanaThemeProvider theme$={services.kibanaTheme.theme$}>
<VisualizationContainer handlers={handlers} showNoResult={showNoResult}>
<div css={{ height: '100%' }}>
<PartitionVisComponent
chartsThemeService={theme}
palettesRegistry={palettesRegistry}
@ -73,10 +63,13 @@ export const getPartitionVisRenderer: (
services={{ data: services.data, fieldFormats: services.fieldFormats }}
syncColors={syncColors}
/>
</VisualizationContainer>
</div>
</KibanaThemeProvider>
</I18nProvider>,
domNode
domNode,
() => {
handlers.done();
}
);
},
});

View file

@ -0,0 +1,32 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import { EuiIconProps } from '@elastic/eui';
export const DonutIcon = ({ title, titleId, ...props }: Omit<EuiIconProps, 'type'>) => (
<svg
viewBox="0 0 30 22"
width={30}
height={22}
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-labelledby={titleId}
{...props}
>
{title ? <title id={titleId}>{title}</title> : null}
<path
d="M19.21 21.119a11 11 0 006.595-8.1c.11-.577-.355-1.082-.942-1.082H20.75c-.477 0-.878.342-1.046.788a5.028 5.028 0 11-6.474-6.474c.447-.168.788-.569.788-1.046V1.094c0-.588-.505-1.053-1.082-.943a11 11 0 106.272 20.968h.002z"
className="chart-icon__subdued"
/>
<path
d="M22.778 3.176A11 11 0 0017.084.154C16.507.042 16 .507 16 1.095v4.116c0 .475.34.875.784 1.044l.14.055A5.026 5.026 0 0119.7 9.17c.168.445.568.784 1.044.784h4.115c.588 0 1.053-.506.942-1.084a11 11 0 00-3.023-5.694z"
className="chart-icon__accent"
/>
</svg>
);

View file

@ -0,0 +1,13 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export { PieIcon } from './pie';
export { DonutIcon } from './donut';
export { TreemapIcon } from './treemap';
export { MosaicIcon } from './mosaic';
export { WaffleIcon } from './waffle';

View file

@ -0,0 +1,32 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import type { EuiIconProps } from '@elastic/eui';
export const MosaicIcon = ({ title, titleId, ...props }: Omit<EuiIconProps, 'type'>) => (
<svg
viewBox="0 0 30 22"
width={30}
height={22}
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-labelledby={titleId}
{...props}
>
{title ? <title id={titleId} /> : null}
<path
className="chart-icon__subdued"
d="M2 0a1 1 0 00-1 1v2a1 1 0 001 1h6a1 1 0 001-1V1a1 1 0 00-1-1H2zM2 14a1 1 0 00-1 1v6a1 1 0 001 1h6a1 1 0 001-1v-6a1 1 0 00-1-1H2zM11 13a1 1 0 011-1h6a1 1 0 011 1v8a1 1 0 01-1 1h-6a1 1 0 01-1-1v-8zM12 0a1 1 0 100 2h6a1 1 0 100-2h-6zM21 15a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1h-6a1 1 0 01-1-1v-6zM22 0a1 1 0 00-1 1v4a1 1 0 001 1h6a1 1 0 001-1V1a1 1 0 00-1-1h-6z"
/>
<path
className="chart-icon__accent"
d="M11 5a1 1 0 011-1h6a1 1 0 011 1v4a1 1 0 01-1 1h-6a1 1 0 01-1-1V5zM1 7a1 1 0 011-1h6a1 1 0 011 1v4a1 1 0 01-1 1H2a1 1 0 01-1-1V7zM22 8a1 1 0 00-1 1v2a1 1 0 001 1h6a1 1 0 001-1V9a1 1 0 00-1-1h-6z"
/>
</svg>
);

View file

@ -0,0 +1,32 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import { EuiIconProps } from '@elastic/eui';
export const PieIcon = ({ title, titleId, ...props }: Omit<EuiIconProps, 'type'>) => (
<svg
viewBox="0 0 30 22"
width={30}
height={22}
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-labelledby={titleId}
{...props}
>
{title ? <title id={titleId}>{title}</title> : null}
<path
d="M17.827 21.189a10.001 10.001 0 005.952-7.148c.124-.578-.343-1.091-.935-1.091H14a1 1 0 01-1-1V3.106c0-.592-.513-1.059-1.092-.935a10 10 0 105.919 19.018z"
className="chart-icon__subdued"
/>
<path
d="M22.462 3.538A12.29 12.29 0 0016.094.16C15.512.048 15 .514 15 1.106V10a1 1 0 001 1h8.895c.591 0 1.057-.512.945-1.094a12.288 12.288 0 00-3.378-6.368z"
className="chart-icon__accent"
/>
</svg>
);

View file

@ -0,0 +1,36 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import { EuiIconProps } from '@elastic/eui';
export const TreemapIcon = ({ title, titleId, ...props }: Omit<EuiIconProps, 'type'>) => (
<svg
viewBox="0 0 30 22"
width={30}
height={22}
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-labelledby={titleId}
{...props}
>
{title ? <title id={titleId}>{title}</title> : null}
<path
d="M0 1a1 1 0 011-1h13a1 1 0 011 1v20a1 1 0 01-1 1H1a1 1 0 01-1-1V1z"
className="chart-icon__subdued"
/>
<path
d="M17 1a1 1 0 011-1h11a1 1 0 011 1v12a1 1 0 01-1 1H18a1 1 0 01-1-1V1z"
className="chart-icon__accent"
/>
<path
d="M29 16H18a1 1 0 00-1 1v4a1 1 0 001 1h11a1 1 0 001-1v-4a1 1 0 00-1-1z"
className="chart-icon__subdued"
/>
</svg>
);

View file

@ -0,0 +1,32 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import type { EuiIconProps } from '@elastic/eui';
export const WaffleIcon = ({ title, titleId, ...props }: Omit<EuiIconProps, 'type'>) => (
<svg
viewBox="0 0 30 22"
width={30}
height={22}
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-labelledby={titleId}
{...props}
>
{title ? <title id={titleId} /> : null}
<path
className="chart-icon__accent"
d="M16 1a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 01-1 1h-2a1 1 0 01-1-1V1zM4 13a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1v-2zM17 6a1 1 0 00-1 1v2a1 1 0 001 1h2a1 1 0 001-1V7a1 1 0 00-1-1h-2zM23 0a1 1 0 00-1 1v2a1 1 0 001 1h2a1 1 0 001-1V1a1 1 0 00-1-1h-2zM5 0a1 1 0 00-1 1v2a1 1 0 001 1h2a1 1 0 001-1V1a1 1 0 00-1-1H5zM4 7a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V7zM11 0a1 1 0 00-1 1v2a1 1 0 001 1h2a1 1 0 001-1V1a1 1 0 00-1-1h-2zM10 7a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 01-1 1h-2a1 1 0 01-1-1V7zM11 12a1 1 0 00-1 1v2a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 00-1-1h-2zM22 7a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 01-1 1h-2a1 1 0 01-1-1V7z"
/>
<path
className="chart-icon__subdued"
d="M22 13a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 01-1 1h-2a1 1 0 01-1-1v-2zM4 19a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1v-2zM16 19a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 01-1 1h-2a1 1 0 01-1-1v-2zM11 18a1 1 0 00-1 1v2a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 00-1-1h-2zM23 18a1 1 0 00-1 1v2a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 00-1-1h-2zM16 13a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 01-1 1h-2a1 1 0 01-1-1v-2z"
/>
</svg>
);

View file

@ -5,6 +5,7 @@
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import type { ValueClickContext } from '../../../embeddable/public';
import { ChartsPluginSetup } from '../../../charts/public';
import { ExpressionsPublicPlugin, ExpressionsServiceStart } from '../../../expressions/public';
@ -19,3 +20,8 @@ export interface SetupDeps {
export interface StartDeps {
expression: ExpressionsServiceStart;
}
export interface FilterEvent {
name: 'filter';
data: ValueClickContext['data'];
}

View file

@ -9,13 +9,13 @@
import { LayerValue, SeriesIdentifier } from '@elastic/charts';
import { Datatable, DatatableColumn } from '../../../../expressions/public';
import { DataPublicPluginStart } from '../../../../data/public';
import { ClickTriggerEvent } from '../../../../charts/public';
import { ValueClickContext } from '../../../../embeddable/public';
import type { FieldFormat } from '../../../../field_formats/common';
import { BucketColumns } from '../../common/types';
import { FilterEvent } from '../types';
export const canFilter = async (
event: ClickTriggerEvent | null,
event: FilterEvent | null,
actions: DataPublicPluginStart['actions']
): Promise<boolean> => {
if (!event) {

View file

@ -8,31 +8,19 @@
import { fieldFormatsMock } from '../../../../field_formats/common/mocks';
import { Datatable } from '../../../../expressions';
import { createMockPieParams, createMockVisData } from '../mocks';
import { createMockVisData } from '../mocks';
import { generateFormatters, getAvailableFormatter, getFormatter } from './formatters';
import { BucketColumns } from '../../common/types';
describe('generateFormatters', () => {
const visParams = createMockPieParams();
const visData = createMockVisData();
const defaultFormatter = jest.fn((...args) => fieldFormatsMock.deserialize(...args));
beforeEach(() => {
defaultFormatter.mockClear();
});
it('returns empty object, if labels should not be should ', () => {
const formatters = generateFormatters(
{ ...visParams, labels: { ...visParams.labels, show: false } },
visData,
defaultFormatter
);
expect(formatters).toEqual({});
expect(defaultFormatter).toHaveBeenCalledTimes(0);
});
it('returns formatters, if columns have meta parameters', () => {
const formatters = generateFormatters(visParams, visData, defaultFormatter);
const formatters = generateFormatters(visData, defaultFormatter);
const formattingResult = fieldFormatsMock.deserialize();
const serializedFormatters = Object.keys(formatters).reduce(
@ -62,7 +50,7 @@ describe('generateFormatters', () => {
columns: visData.columns.map(({ meta, ...col }) => ({ ...col, meta: { type: 'string' } })),
};
const formatters = generateFormatters(visParams, newVisData, defaultFormatter);
const formatters = generateFormatters(newVisData, defaultFormatter);
expect(formatters).toEqual({
'col-0-2': undefined,

View file

@ -8,25 +8,16 @@
import type { FieldFormat, FormatFactory } from '../../../../field_formats/common';
import type { Datatable } from '../../../../expressions/public';
import { BucketColumns, PartitionVisParams } from '../../common/types';
import { BucketColumns } from '../../common/types';
export const generateFormatters = (
visParams: PartitionVisParams,
visData: Datatable,
formatFactory: FormatFactory
) => {
if (!visParams.labels.show) {
return {};
}
return visData.columns.reduce<Record<string, ReturnType<FormatFactory> | undefined>>(
export const generateFormatters = (visData: Datatable, formatFactory: FormatFactory) =>
visData.columns.reduce<Record<string, ReturnType<FormatFactory> | undefined>>(
(newFormatters, column) => ({
...newFormatters,
[column.id]: column?.meta?.params ? formatFactory(column.meta.params) : undefined,
}),
{}
);
};
export const getAvailableFormatter = (
column: Partial<BucketColumns>,

View file

@ -0,0 +1,19 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { ChartTypes } from '../../common/types';
import { PieIcon, DonutIcon, TreemapIcon, MosaicIcon, WaffleIcon } from '../icons';
export const getIcon = (chart: ChartTypes) =>
({
[ChartTypes.PIE]: PieIcon,
[ChartTypes.DONUT]: DonutIcon,
[ChartTypes.TREEMAP]: TreemapIcon,
[ChartTypes.MOSAIC]: MosaicIcon,
[ChartTypes.WAFFLE]: WaffleIcon,
}[chart]);

View file

@ -13,16 +13,16 @@ import { EuiContextMenuPanelDescriptor, EuiIcon, EuiPopover, EuiContextMenu } fr
import { LegendAction, SeriesIdentifier, useLegendAction } from '@elastic/charts';
import { DataPublicPluginStart } from '../../../../data/public';
import { PartitionVisParams } from '../../common/types';
import { ClickTriggerEvent } from '../../../../charts/public';
import { FieldFormatsStart } from '../../../../field_formats/public';
import { FilterEvent } from '../types';
export const getLegendActions = (
canFilter: (
data: ClickTriggerEvent | null,
data: FilterEvent | null,
actions: DataPublicPluginStart['actions']
) => Promise<boolean>,
getFilterEventData: (series: SeriesIdentifier) => ClickTriggerEvent | null,
onFilter: (data: ClickTriggerEvent, negate?: any) => void,
getFilterEventData: (series: SeriesIdentifier) => FilterEvent | null,
onFilter: (data: FilterEvent, negate?: any) => void,
visParams: PartitionVisParams,
actions: DataPublicPluginStart['actions'],
formatter: FieldFormatsStart

View file

@ -18,3 +18,4 @@ export { getColumnByAccessor } from './accessor';
export { isLegendFlat, shouldShowLegend } from './legend';
export { generateFormatters, getAvailableFormatter, getFormatter } from './formatters';
export { getPartitionType } from './get_partition_type';
export { getIcon } from './get_icon';

View file

@ -0,0 +1,90 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { PaletteDefinition, PaletteOutput } from '../../../../../charts/public';
import { chartPluginMock } from '../../../../../charts/public/mocks';
import { Datatable } from '../../../../../expressions';
import { byDataColorPaletteMap } from './get_color';
describe('#byDataColorPaletteMap', () => {
let datatable: Datatable;
let paletteDefinition: PaletteDefinition;
let palette: PaletteOutput;
const columnId = 'foo';
beforeEach(() => {
datatable = {
rows: [
{
[columnId]: '1',
},
{
[columnId]: '2',
},
],
} as unknown as Datatable;
paletteDefinition = chartPluginMock.createPaletteRegistry().get('default');
palette = { type: 'palette' } as PaletteOutput;
});
it('should create byDataColorPaletteMap', () => {
expect(byDataColorPaletteMap(datatable.rows, columnId, paletteDefinition, palette))
.toMatchInlineSnapshot(`
Object {
"getColor": [Function],
}
`);
});
it('should get color', () => {
const colorPaletteMap = byDataColorPaletteMap(
datatable.rows,
columnId,
paletteDefinition,
palette
);
expect(colorPaletteMap.getColor('1')).toBe('black');
});
it('should return undefined in case if values not in datatable', () => {
const colorPaletteMap = byDataColorPaletteMap(
datatable.rows,
columnId,
paletteDefinition,
palette
);
expect(colorPaletteMap.getColor('wrong')).toBeUndefined();
});
it('should increase rankAtDepth for each new value', () => {
const colorPaletteMap = byDataColorPaletteMap(
datatable.rows,
columnId,
paletteDefinition,
palette
);
colorPaletteMap.getColor('1');
colorPaletteMap.getColor('2');
expect(paletteDefinition.getCategoricalColor).toHaveBeenNthCalledWith(
1,
[{ name: '1', rankAtDepth: 0, totalSeriesAtDepth: 2 }],
{ behindText: false },
undefined
);
expect(paletteDefinition.getCategoricalColor).toHaveBeenNthCalledWith(
2,
[{ name: '2', rankAtDepth: 1, totalSeriesAtDepth: 2 }],
{ behindText: false },
undefined
);
});
});

View file

@ -0,0 +1,41 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { Datatable } from '../../../../../expressions';
import { extractUniqTermsMap } from './sort_predicate';
describe('#extractUniqTermsMap', () => {
it('should extract map', () => {
const table: Datatable = {
type: 'datatable',
columns: [
{ id: 'a', name: 'A', meta: { type: 'string' } },
{ id: 'b', name: 'B', meta: { type: 'string' } },
{ id: 'c', name: 'C', meta: { type: 'number' } },
],
rows: [
{ a: 'Hi', b: 'Two', c: 2 },
{ a: 'Test', b: 'Two', c: 5 },
{ a: 'Foo', b: 'Three', c: 6 },
],
};
expect(extractUniqTermsMap(table, 'a')).toMatchInlineSnapshot(`
Object {
"Foo": 2,
"Hi": 0,
"Test": 1,
}
`);
expect(extractUniqTermsMap(table, 'b')).toMatchInlineSnapshot(`
Object {
"Three": 1,
"Two": 0,
}
`);
});
});

View file

@ -15,6 +15,7 @@
"references": [
{ "path": "../../../core/tsconfig.json" },
{ "path": "../../expressions/tsconfig.json" },
{ "path": "../../presentation_util/tsconfig.json" },
{ "path": "../../data/tsconfig.json" },
{ "path": "../../field_formats/tsconfig.json" },
{ "path": "../../charts/tsconfig.json" },

View file

@ -13,14 +13,24 @@ import './empty_placeholder.scss';
export const EmptyPlaceholder = ({
icon,
iconColor = 'subdued',
message = <FormattedMessage id="charts.noDataLabel" defaultMessage="No results found" />,
dataTestSubj = 'emptyPlaceholder',
}: {
icon: IconType;
iconColor?: string;
message?: JSX.Element;
dataTestSubj?: string;
}) => (
<>
<EuiText className="chart__empty-placeholder" textAlign="center" color="subdued" size="xs">
<EuiIcon type={icon} color="subdued" size="l" />
<EuiText
data-test-subj={dataTestSubj}
className="chart__empty-placeholder"
textAlign="center"
color="subdued"
size="xs"
>
<EuiIcon type={icon} color={iconColor} size="l" />
<EuiSpacer size="s" />
<p>{message}</p>
</EuiText>

View file

@ -432,7 +432,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
'360,000',
'CN',
].sort();
if (await PageObjects.visChart.isNewLibraryChart('visTypePieChart')) {
if (await PageObjects.visChart.isNewLibraryChart('partitionVisChart')) {
await PageObjects.visEditor.clickOptionsTab();
await PageObjects.visEditor.togglePieLegend();
await PageObjects.visEditor.togglePieNestedLegend();

View file

@ -11,7 +11,7 @@ import chroma from 'chroma-js';
import { FtrService } from '../ftr_provider_context';
const pieChartSelector = 'visTypePieChart';
const partitionVisChartSelector = 'partitionVisChart';
const heatmapChartSelector = 'heatmapChart';
export class VisualizeChartPageObject extends FtrService {
@ -149,7 +149,7 @@ export class VisualizeChartPageObject extends FtrService {
}
private async toggleLegend(force = false) {
const isVisTypePieChart = await this.isNewLibraryChart(pieChartSelector);
const isVisTypePieChart = await this.isNewLibraryChart(partitionVisChartSelector);
const legendSelector = force || isVisTypePieChart ? '.echLegend' : '.visLegend';
await this.retry.try(async () => {
@ -182,10 +182,11 @@ export class VisualizeChartPageObject extends FtrService {
}
public async doesSelectedLegendColorExistForPie(matchingColor: string) {
if (await this.isNewLibraryChart(pieChartSelector)) {
if (await this.isNewLibraryChart(partitionVisChartSelector)) {
const hexMatchingColor = chroma(matchingColor).hex().toUpperCase();
const slices =
(await this.getEsChartDebugState(pieChartSelector))?.partition?.[0]?.partitions ?? [];
(await this.getEsChartDebugState(partitionVisChartSelector))?.partition?.[0]?.partitions ??
[];
return slices.some(({ color }) => {
return hexMatchingColor === chroma(color).hex().toUpperCase();
});
@ -195,7 +196,7 @@ export class VisualizeChartPageObject extends FtrService {
}
public async expectError() {
if (!this.isNewLibraryChart(pieChartSelector)) {
if (!this.isNewLibraryChart(partitionVisChartSelector)) {
await this.testSubjects.existOrFail('vislibVisualizeError');
}
}
@ -244,12 +245,13 @@ export class VisualizeChartPageObject extends FtrService {
}
public async getLegendEntries() {
const isVisTypePieChart = await this.isNewLibraryChart(pieChartSelector);
const isVisTypePieChart = await this.isNewLibraryChart(partitionVisChartSelector);
const isVisTypeHeatmapChart = await this.isNewLibraryChart(heatmapChartSelector);
if (isVisTypePieChart) {
const slices =
(await this.getEsChartDebugState(pieChartSelector))?.partition?.[0]?.partitions ?? [];
(await this.getEsChartDebugState(partitionVisChartSelector))?.partition?.[0]?.partitions ??
[];
return slices.map(({ name }) => name);
}
@ -290,7 +292,7 @@ export class VisualizeChartPageObject extends FtrService {
public async openLegendOptionColorsForPie(name: string, chartSelector: string) {
await this.waitForVisualizationRenderingStabilized();
await this.retry.try(async () => {
if (await this.isNewLibraryChart(pieChartSelector)) {
if (await this.isNewLibraryChart(partitionVisChartSelector)) {
const chart = await this.find.byCssSelector(chartSelector);
const legendItemColor = await chart.findByCssSelector(
`[data-ech-series-name="${name}"] .echLegendItem__color`

View file

@ -10,7 +10,7 @@ import expect from '@kbn/expect';
import { isNil } from 'lodash';
import { FtrService } from '../../ftr_provider_context';
const pieChartSelector = 'visTypePieChart';
const partitionVisChartSelector = 'partitionVisChart';
export class PieChartService extends FtrService {
private readonly log = this.ctx.getService('log');
@ -27,16 +27,16 @@ export class PieChartService extends FtrService {
async clickOnPieSlice(name?: string) {
this.log.debug(`PieChart.clickOnPieSlice(${name})`);
if (await this.visChart.isNewLibraryChart(pieChartSelector)) {
if (await this.visChart.isNewLibraryChart(partitionVisChartSelector)) {
const slices =
(await this.visChart.getEsChartDebugState(pieChartSelector))?.partition?.[0]?.partitions ??
[];
(await this.visChart.getEsChartDebugState(partitionVisChartSelector))?.partition?.[0]
?.partitions ?? [];
let sliceLabel = name || slices[0].name;
if (name === 'Other') {
sliceLabel = '__other__';
}
const pieSlice = slices.find((slice) => slice.name === sliceLabel);
const pie = await this.testSubjects.find(pieChartSelector);
const pie = await this.testSubjects.find(partitionVisChartSelector);
if (pieSlice) {
const pieSize = await pie.getSize();
const pieHeight = pieSize.height;
@ -88,10 +88,10 @@ export class PieChartService extends FtrService {
async getPieSliceStyle(name: string) {
this.log.debug(`VisualizePage.getPieSliceStyle(${name})`);
if (await this.visChart.isNewLibraryChart(pieChartSelector)) {
if (await this.visChart.isNewLibraryChart(partitionVisChartSelector)) {
const slices =
(await this.visChart.getEsChartDebugState(pieChartSelector))?.partition?.[0]?.partitions ??
[];
(await this.visChart.getEsChartDebugState(partitionVisChartSelector))?.partition?.[0]
?.partitions ?? [];
const selectedSlice = slices.filter((slice) => {
return slice.name.toString() === name.replace(',', '');
});
@ -103,10 +103,10 @@ export class PieChartService extends FtrService {
async getAllPieSliceColor(name: string) {
this.log.debug(`VisualizePage.getAllPieSliceColor(${name})`);
if (await this.visChart.isNewLibraryChart(pieChartSelector)) {
if (await this.visChart.isNewLibraryChart(partitionVisChartSelector)) {
const slices =
(await this.visChart.getEsChartDebugState(pieChartSelector))?.partition?.[0]?.partitions ??
[];
(await this.visChart.getEsChartDebugState(partitionVisChartSelector))?.partition?.[0]
?.partitions ?? [];
const selectedSlice = slices.filter((slice) => {
return slice.name.toString() === name.replace(',', '');
});
@ -143,10 +143,10 @@ export class PieChartService extends FtrService {
}
async getPieChartLabels() {
if (await this.visChart.isNewLibraryChart(pieChartSelector)) {
if (await this.visChart.isNewLibraryChart(partitionVisChartSelector)) {
const slices =
(await this.visChart.getEsChartDebugState(pieChartSelector))?.partition?.[0]?.partitions ??
[];
(await this.visChart.getEsChartDebugState(partitionVisChartSelector))?.partition?.[0]
?.partitions ?? [];
return slices.map((slice) => {
if (slice.name === '__missing__') {
return 'Missing';
@ -169,10 +169,10 @@ export class PieChartService extends FtrService {
async getPieSliceCount() {
this.log.debug('PieChart.getPieSliceCount');
if (await this.visChart.isNewLibraryChart(pieChartSelector)) {
if (await this.visChart.isNewLibraryChart(partitionVisChartSelector)) {
const slices =
(await this.visChart.getEsChartDebugState(pieChartSelector))?.partition?.[0]?.partitions ??
[];
(await this.visChart.getEsChartDebugState(partitionVisChartSelector))?.partition?.[0]
?.partitions ?? [];
return slices?.length;
}
const slices = await this.find.allByCssSelector('svg > g > g.arcs > path.slice');
@ -181,8 +181,8 @@ export class PieChartService extends FtrService {
async expectPieSliceCountEsCharts(expectedCount: number) {
const slices =
(await this.visChart.getEsChartDebugState(pieChartSelector))?.partition?.[0]?.partitions ??
[];
(await this.visChart.getEsChartDebugState(partitionVisChartSelector))?.partition?.[0]
?.partitions ?? [];
expect(slices.length).to.be(expectedCount);
}

View file

@ -17,6 +17,32 @@ export const NOT_INTERNATIONALIZED_PRODUCT_NAME = 'Lens Visualizations';
export const BASE_API_URL = '/api/lens';
export const LENS_EDIT_BY_VALUE = 'edit_by_value';
export const PieChartTypes = {
PIE: 'pie',
DONUT: 'donut',
TREEMAP: 'treemap',
MOSAIC: 'mosaic',
WAFFLE: 'waffle',
} as const;
export const CategoryDisplay = {
DEFAULT: 'default',
INSIDE: 'inside',
HIDE: 'hide',
} as const;
export const NumberDisplay = {
HIDDEN: 'hidden',
PERCENT: 'percent',
VALUE: 'value',
} as const;
export const LegendDisplay = {
DEFAULT: 'default',
SHOW: 'show',
HIDE: 'hide',
} as const;
export const layerTypes: Record<string, LayerType> = {
DATA: 'data',
REFERENCELINE: 'referenceLine',

View file

@ -12,7 +12,6 @@ export * from './merge_tables';
export * from './time_scale';
export * from './datatable';
export * from './metric_chart';
export * from './pie_chart';
export * from './xy_chart';
export * from './expression_types';

View file

@ -1,16 +0,0 @@
/*
* 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.
*/
export { pie } from './pie_chart';
export type {
SharedPieLayerState,
PieLayerState,
PieVisualizationState,
PieExpressionArgs,
PieExpressionProps,
} from './types';

View file

@ -1,131 +0,0 @@
/*
* 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 { Position } from '@elastic/charts';
import { i18n } from '@kbn/i18n';
import type { ExpressionFunctionDefinition } from '../../../../../../src/plugins/expressions/common';
import type { LensMultiTable } from '../../types';
import type { PieExpressionProps, PieExpressionArgs } from './types';
interface PieRender {
type: 'render';
as: 'lens_pie_renderer';
value: PieExpressionProps;
}
export const pie: ExpressionFunctionDefinition<
'lens_pie',
LensMultiTable,
PieExpressionArgs,
PieRender
> = {
name: 'lens_pie',
type: 'render',
help: i18n.translate('xpack.lens.pie.expressionHelpLabel', {
defaultMessage: 'Pie renderer',
}),
args: {
title: {
types: ['string'],
help: 'The chart title.',
},
description: {
types: ['string'],
help: '',
},
groups: {
types: ['string'],
multi: true,
help: '',
},
metric: {
types: ['string'],
help: '',
},
shape: {
types: ['string'],
options: ['pie', 'donut', 'treemap', 'mosaic'],
help: '',
},
hideLabels: {
types: ['boolean'],
help: '',
},
numberDisplay: {
types: ['string'],
options: ['hidden', 'percent', 'value'],
help: '',
},
categoryDisplay: {
types: ['string'],
options: ['default', 'inside', 'hide'],
help: '',
},
legendDisplay: {
types: ['string'],
options: ['default', 'show', 'hide'],
help: '',
},
nestedLegend: {
types: ['boolean'],
help: '',
},
legendMaxLines: {
types: ['number'],
help: '',
},
truncateLegend: {
types: ['boolean'],
help: '',
},
showValuesInLegend: {
types: ['boolean'],
help: '',
},
legendPosition: {
types: ['string'],
options: [Position.Top, Position.Right, Position.Bottom, Position.Left],
help: '',
},
percentDecimals: {
types: ['number'],
help: '',
},
palette: {
default: `{theme "palette" default={system_palette name="default"} }`,
help: '',
types: ['palette'],
},
emptySizeRatio: {
types: ['number'],
help: '',
},
ariaLabel: {
types: ['string'],
help: '',
required: false,
},
},
inputTypes: ['lens_multitable'],
fn(data: LensMultiTable, args: PieExpressionArgs, handlers) {
return {
type: 'render',
as: 'lens_pie_renderer',
value: {
data,
args: {
...args,
ariaLabel:
args.ariaLabel ??
(handlers.variables?.embeddableTitle as string) ??
handlers.getExecutionContext?.()?.description,
},
},
};
},
};

View file

@ -1,51 +0,0 @@
/*
* 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 type { PaletteOutput } from '../../../../../../src/plugins/charts/common';
import type { LensMultiTable, LayerType } from '../../types';
export type PieChartTypes = 'donut' | 'pie' | 'treemap' | 'mosaic' | 'waffle';
export interface SharedPieLayerState {
groups: string[];
metric?: string;
numberDisplay: 'hidden' | 'percent' | 'value';
categoryDisplay: 'default' | 'inside' | 'hide';
legendDisplay: 'default' | 'show' | 'hide';
legendPosition?: 'left' | 'right' | 'top' | 'bottom';
showValuesInLegend?: boolean;
nestedLegend?: boolean;
percentDecimals?: number;
emptySizeRatio?: number;
legendMaxLines?: number;
truncateLegend?: boolean;
}
export type PieLayerState = SharedPieLayerState & {
layerId: string;
layerType: LayerType;
};
export interface PieVisualizationState {
shape: PieChartTypes;
layers: PieLayerState[];
palette?: PaletteOutput;
}
export type PieExpressionArgs = SharedPieLayerState & {
title?: string;
description?: string;
shape: PieChartTypes;
hideLabels: boolean;
palette: PaletteOutput;
ariaLabel?: string;
};
export interface PieExpressionProps {
data: LensMultiTable;
args: PieExpressionArgs;
}

View file

@ -6,12 +6,16 @@
*/
import type { Filter, FilterMeta } from '@kbn/es-query';
import { Position } from '@elastic/charts';
import { $Values } from '@kbn/utility-types';
import type {
IFieldFormat,
SerializedFieldFormat,
} from '../../../../src/plugins/field_formats/common';
import type { Datatable } from '../../../../src/plugins/expressions/common';
import type { PaletteContinuity } from '../../../../src/plugins/charts/common';
import type { PaletteOutput } from '../../../../src/plugins/charts/common';
import { CategoryDisplay, LegendDisplay, NumberDisplay, PieChartTypes } from './constants';
export type FormatFactory = (mapping?: SerializedFieldFormat) => IFieldFormat;
@ -73,3 +77,41 @@ export type LayerType = 'data' | 'referenceLine';
// Shared by XY Chart and Heatmap as for now
export type ValueLabelConfig = 'hide' | 'inside' | 'outside';
export type PieChartType = $Values<typeof PieChartTypes>;
export type CategoryDisplayType = $Values<typeof CategoryDisplay>;
export type NumberDisplayType = $Values<typeof NumberDisplay>;
export type LegendDisplayType = $Values<typeof LegendDisplay>;
export enum EmptySizeRatios {
SMALL = 0.3,
MEDIUM = 0.54,
LARGE = 0.7,
}
export interface SharedPieLayerState {
groups: string[];
metric?: string;
numberDisplay: NumberDisplayType;
categoryDisplay: CategoryDisplayType;
legendDisplay: LegendDisplayType;
legendPosition?: Position;
showValuesInLegend?: boolean;
nestedLegend?: boolean;
percentDecimals?: number;
emptySizeRatio?: number;
legendMaxLines?: number;
truncateLegend?: boolean;
}
export type PieLayerState = SharedPieLayerState & {
layerId: string;
layerType: LayerType;
};
export interface PieVisualizationState {
shape: $Values<typeof PieChartTypes>;
layers: PieLayerState[];
palette?: PaletteOutput;
}

View file

@ -23,7 +23,8 @@ import type { LensByReferenceInput, LensByValueInput } from './embeddable';
import type { Document } from '../persistence';
import type { IndexPatternPersistedState } from '../indexpattern_datasource/types';
import type { XYState } from '../xy_visualization/types';
import type { PieVisualizationState, MetricState } from '../../common/expressions';
import type { MetricState } from '../../common/expressions';
import type { PieVisualizationState } from '../../common';
import type { DatatableVisualizationState } from '../datatable_visualization/visualization';
import type { HeatmapVisualizationState } from '../heatmap_visualization/types';
import type { GaugeVisualizationState } from '../visualizations/gauge/constants';

View file

@ -24,7 +24,6 @@ import { datatableColumn } from '../common/expressions/datatable/datatable_colum
import { mergeTables } from '../common/expressions/merge_tables';
import { renameColumns } from '../common/expressions/rename_columns/rename_columns';
import { pie } from '../common/expressions/pie_chart/pie_chart';
import { formatColumn } from '../common/expressions/format_column';
import { counterRate } from '../common/expressions/counter_rate';
import { getTimeScale } from '../common/expressions/time_scale/time_scale';
@ -39,7 +38,6 @@ export const setupExpressions = (
[lensMultitable].forEach((expressionType) => expressions.registerType(expressionType));
[
pie,
xyChart,
mergeTables,
counterRate,

View file

@ -14,9 +14,6 @@ export type {
export type { XYState } from './xy_visualization/types';
export type { DataType, OperationMetadata, Visualization } from './types';
export type {
PieVisualizationState,
PieLayerState,
SharedPieLayerState,
MetricState,
AxesSettingsConfig,
XYLayerConfig,
@ -26,7 +23,13 @@ export type {
XYCurveType,
YConfig,
} from '../common/expressions';
export type { ValueLabelConfig } from '../common/types';
export type {
ValueLabelConfig,
PieVisualizationState,
PieLayerState,
SharedPieLayerState,
} from '../common/types';
export type { DatatableVisualizationState } from './datatable_visualization/visualization';
export type { HeatmapVisualizationState } from './heatmap_visualization/types';
export type { GaugeVisualizationState } from './visualizations/gauge/constants';

View file

@ -6,9 +6,3 @@
*/
export const DEFAULT_PERCENT_DECIMALS = 2;
export enum EMPTY_SIZE_RATIOS {
SMALL = 0.3,
MEDIUM = 0.54,
LARGE = 0.7,
}

View file

@ -1,66 +0,0 @@
/*
* 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 from 'react';
import ReactDOM from 'react-dom';
import { i18n } from '@kbn/i18n';
import { I18nProvider } from '@kbn/i18n-react';
import type {
IInterpreterRenderHandlers,
ExpressionRenderDefinition,
} from 'src/plugins/expressions/public';
import { ThemeServiceStart } from 'kibana/public';
import { KibanaThemeProvider } from '../../../../../src/plugins/kibana_react/public';
import type { LensFilterEvent } from '../types';
import { PieComponent } from './render_function';
import type { FormatFactory } from '../../common';
import type { PieExpressionProps } from '../../common/expressions';
import type { ChartsPluginSetup, PaletteRegistry } from '../../../../../src/plugins/charts/public';
export const getPieRenderer = (dependencies: {
formatFactory: FormatFactory;
chartsThemeService: ChartsPluginSetup['theme'];
paletteService: PaletteRegistry;
kibanaTheme: ThemeServiceStart;
}): ExpressionRenderDefinition<PieExpressionProps> => ({
name: 'lens_pie_renderer',
displayName: i18n.translate('xpack.lens.pie.visualizationName', {
defaultMessage: 'Pie',
}),
help: '',
validate: () => undefined,
reuseDomNode: true,
render: (domNode: Element, config: PieExpressionProps, handlers: IInterpreterRenderHandlers) => {
const onClickValue = (data: LensFilterEvent['data']) => {
handlers.event({ name: 'filter', data });
};
ReactDOM.render(
<KibanaThemeProvider theme$={dependencies.kibanaTheme.theme$}>
<I18nProvider>
<MemoizedChart
{...config}
formatFactory={dependencies.formatFactory}
chartsThemeService={dependencies.chartsThemeService}
interactive={handlers.isInteractive()}
paletteService={dependencies.paletteService}
onClickValue={onClickValue}
renderMode={handlers.getRenderMode()}
syncColors={handlers.isSyncColorsEnabled()}
/>
</I18nProvider>
</KibanaThemeProvider>,
domNode,
() => {
handlers.done();
}
);
handlers.onDestroy(() => ReactDOM.unmountComponentAtNode(domNode));
},
});
const MemoizedChart = React.memo(PieComponent);

View file

@ -1,79 +0,0 @@
/*
* 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 from 'react';
import { LegendActionProps, SeriesIdentifier } from '@elastic/charts';
import { EuiPopover } from '@elastic/eui';
import { mountWithIntl } from '@kbn/test-jest-helpers';
import { ComponentType, ReactWrapper } from 'enzyme';
import type { Datatable } from 'src/plugins/expressions/public';
import { getLegendAction } from './get_legend_action';
import { LegendActionPopover } from '../shared_components';
const table: Datatable = {
type: 'datatable',
columns: [
{ id: 'a', name: 'A', meta: { type: 'string' } },
{ id: 'b', name: 'B', meta: { type: 'number' } },
],
rows: [
{ a: 'Hi', b: 2 },
{ a: 'Test', b: 4 },
{ a: 'Foo', b: 6 },
],
};
describe('getLegendAction', function () {
let wrapperProps: LegendActionProps;
const Component: ComponentType<LegendActionProps> = getLegendAction(table, jest.fn());
let wrapper: ReactWrapper<LegendActionProps>;
beforeAll(() => {
wrapperProps = {
color: 'rgb(109, 204, 177)',
label: 'Bar',
series: [
{
specId: 'donut',
key: 'Bar',
},
] as unknown as SeriesIdentifier[],
};
});
it('is not rendered if row does not exist', () => {
wrapper = mountWithIntl(<Component {...wrapperProps} />);
expect(wrapper).toEqual({});
expect(wrapper.find(EuiPopover).length).toBe(0);
});
it('is rendered if row is detected', () => {
const newProps = {
...wrapperProps,
label: 'Hi',
series: [
{
specId: 'donut',
key: 'Hi',
},
] as unknown as SeriesIdentifier[],
};
wrapper = mountWithIntl(<Component {...newProps} />);
expect(wrapper.find(EuiPopover).length).toBe(1);
expect(wrapper.find(EuiPopover).prop('title')).toEqual('Hi, filter options');
expect(wrapper.find(LegendActionPopover).prop('context')).toEqual({
data: [
{
column: 0,
row: 0,
table,
value: 'Hi',
},
],
});
});
});

View file

@ -1,44 +0,0 @@
/*
* 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 from 'react';
import type { LegendAction } from '@elastic/charts';
import type { Datatable } from 'src/plugins/expressions/public';
import type { LensFilterEvent } from '../types';
import { LegendActionPopover } from '../shared_components';
export const getLegendAction = (
table: Datatable,
onFilter: (data: LensFilterEvent['data']) => void
): LegendAction =>
React.memo(({ series: [pieSeries], label }) => {
const data = table.columns.reduce<LensFilterEvent['data']['data']>((acc, { id }, column) => {
const value = pieSeries.key;
const row = table.rows.findIndex((r) => r[id] === value);
if (row > -1) {
acc.push({
table,
column,
row,
value,
});
}
return acc;
}, []);
if (data.length === 0) {
return null;
}
const context: LensFilterEvent['data'] = {
data,
};
return <LegendActionPopover label={label} context={context} onFilter={onFilter} />;
});

View file

@ -6,16 +6,12 @@
*/
import type { CoreSetup } from 'src/core/public';
import type { ExpressionsSetup } from 'src/plugins/expressions/public';
import type { EditorFrameSetup } from '../types';
import type { UiActionsStart } from '../../../../../src/plugins/ui_actions/public';
import type { ChartsPluginSetup } from '../../../../../src/plugins/charts/public';
import type { FormatFactory } from '../../common';
export interface PieVisualizationPluginSetupPlugins {
editorFrame: EditorFrameSetup;
expressions: ExpressionsSetup;
formatFactory: FormatFactory;
charts: ChartsPluginSetup;
}
@ -24,22 +20,11 @@ export interface PieVisualizationPluginStartPlugins {
}
export class PieVisualization {
setup(
core: CoreSetup,
{ expressions, formatFactory, editorFrame, charts }: PieVisualizationPluginSetupPlugins
) {
setup(core: CoreSetup, { editorFrame, charts }: PieVisualizationPluginSetupPlugins) {
editorFrame.registerVisualization(async () => {
const { getPieVisualization, getPieRenderer } = await import('../async_services');
const { getPieVisualization } = await import('../async_services');
const palettes = await charts.palettes.getPalettes();
expressions.registerRenderer(
getPieRenderer({
formatFactory,
chartsThemeService: charts.theme,
paletteService: palettes,
kibanaTheme: core.theme,
})
);
return getPieVisualization({ paletteService: palettes, kibanaTheme: core.theme });
});
}

View file

@ -6,24 +6,20 @@
*/
import { i18n } from '@kbn/i18n';
import { ArrayEntry, PartitionLayout } from '@elastic/charts';
import type { EuiIconProps } from '@elastic/eui';
import type { DatatableColumn } from '../../../../../src/plugins/expressions';
import { LensIconChartDonut } from '../assets/chart_donut';
import { LensIconChartPie } from '../assets/chart_pie';
import { LensIconChartTreemap } from '../assets/chart_treemap';
import { LensIconChartMosaic } from '../assets/chart_mosaic';
import { LensIconChartWaffle } from '../assets/chart_waffle';
import { EMPTY_SIZE_RATIOS } from './constants';
import type { SharedPieLayerState } from '../../common/expressions';
import type { PieChartTypes } from '../../common/expressions/pie_chart/types';
import type { DatatableColumn } from '../../../../../src/plugins/expressions';
import { CategoryDisplay, NumberDisplay, SharedPieLayerState, EmptySizeRatios } from '../../common';
import type { PieChartType } from '../../common/types';
interface PartitionChartMeta {
icon: ({ title, titleId, ...props }: Omit<EuiIconProps, 'type'>) => JSX.Element;
label: string;
partitionType: PartitionLayout;
groupLabel: string;
maxBuckets: number;
isExperimental?: boolean;
@ -40,7 +36,7 @@ interface PartitionChartMeta {
}>;
emptySizeRatioOptions?: Array<{
id: string;
value: EMPTY_SIZE_RATIOS;
value: EmptySizeRatios;
label: string;
}>;
};
@ -50,10 +46,6 @@ interface PartitionChartMeta {
hideNestedLegendSwitch?: boolean;
getShowLegendDefault?: (bucketColumns: DatatableColumn[]) => boolean;
};
sortPredicate?: (
bucketColumns: DatatableColumn[],
sortingMap: Record<string, number>
) => (node1: ArrayEntry, node2: ArrayEntry) => number;
}
const groupLabel = i18n.translate('xpack.lens.pie.groupLabel', {
@ -62,19 +54,19 @@ const groupLabel = i18n.translate('xpack.lens.pie.groupLabel', {
const categoryOptions: PartitionChartMeta['toolbarPopover']['categoryOptions'] = [
{
value: 'default',
value: CategoryDisplay.DEFAULT,
inputDisplay: i18n.translate('xpack.lens.pieChart.showCategoriesLabel', {
defaultMessage: 'Inside or outside',
}),
},
{
value: 'inside',
value: CategoryDisplay.INSIDE,
inputDisplay: i18n.translate('xpack.lens.pieChart.fitInsideOnlyLabel', {
defaultMessage: 'Inside only',
}),
},
{
value: 'hide',
value: CategoryDisplay.HIDE,
inputDisplay: i18n.translate('xpack.lens.pieChart.categoriesInLegendLabel', {
defaultMessage: 'Hide labels',
}),
@ -83,13 +75,13 @@ const categoryOptions: PartitionChartMeta['toolbarPopover']['categoryOptions'] =
const categoryOptionsTreemap: PartitionChartMeta['toolbarPopover']['categoryOptions'] = [
{
value: 'default',
value: CategoryDisplay.DEFAULT,
inputDisplay: i18n.translate('xpack.lens.pieChart.showTreemapCategoriesLabel', {
defaultMessage: 'Show labels',
}),
},
{
value: 'hide',
value: CategoryDisplay.HIDE,
inputDisplay: i18n.translate('xpack.lens.pieChart.categoriesInLegendLabel', {
defaultMessage: 'Hide labels',
}),
@ -98,19 +90,19 @@ const categoryOptionsTreemap: PartitionChartMeta['toolbarPopover']['categoryOpti
const numberOptions: PartitionChartMeta['toolbarPopover']['numberOptions'] = [
{
value: 'hidden',
value: NumberDisplay.HIDDEN,
inputDisplay: i18n.translate('xpack.lens.pieChart.hiddenNumbersLabel', {
defaultMessage: 'Hide from chart',
}),
},
{
value: 'percent',
value: NumberDisplay.PERCENT,
inputDisplay: i18n.translate('xpack.lens.pieChart.showPercentValuesLabel', {
defaultMessage: 'Show percent',
}),
},
{
value: 'value',
value: NumberDisplay.VALUE,
inputDisplay: i18n.translate('xpack.lens.pieChart.showFormatterValuesLabel', {
defaultMessage: 'Show value',
}),
@ -120,34 +112,33 @@ const numberOptions: PartitionChartMeta['toolbarPopover']['numberOptions'] = [
const emptySizeRatioOptions: PartitionChartMeta['toolbarPopover']['emptySizeRatioOptions'] = [
{
id: 'emptySizeRatioOption-small',
value: EMPTY_SIZE_RATIOS.SMALL,
value: EmptySizeRatios.SMALL,
label: i18n.translate('xpack.lens.pieChart.emptySizeRatioOptions.small', {
defaultMessage: 'Small',
}),
},
{
id: 'emptySizeRatioOption-medium',
value: EMPTY_SIZE_RATIOS.MEDIUM,
value: EmptySizeRatios.MEDIUM,
label: i18n.translate('xpack.lens.pieChart.emptySizeRatioOptions.medium', {
defaultMessage: 'Medium',
}),
},
{
id: 'emptySizeRatioOption-large',
value: EMPTY_SIZE_RATIOS.LARGE,
value: EmptySizeRatios.LARGE,
label: i18n.translate('xpack.lens.pieChart.emptySizeRatioOptions.large', {
defaultMessage: 'Large',
}),
},
];
export const PartitionChartsMeta: Record<PieChartTypes, PartitionChartMeta> = {
export const PartitionChartsMeta: Record<PieChartType, PartitionChartMeta> = {
donut: {
icon: LensIconChartDonut,
label: i18n.translate('xpack.lens.pie.donutLabel', {
defaultMessage: 'Donut',
}),
partitionType: PartitionLayout.sunburst,
groupLabel,
maxBuckets: 3,
toolbarPopover: {
@ -164,7 +155,6 @@ export const PartitionChartsMeta: Record<PieChartTypes, PartitionChartMeta> = {
label: i18n.translate('xpack.lens.pie.pielabel', {
defaultMessage: 'Pie',
}),
partitionType: PartitionLayout.sunburst,
groupLabel,
maxBuckets: 3,
toolbarPopover: {
@ -180,7 +170,6 @@ export const PartitionChartsMeta: Record<PieChartTypes, PartitionChartMeta> = {
label: i18n.translate('xpack.lens.pie.treemaplabel', {
defaultMessage: 'Treemap',
}),
partitionType: PartitionLayout.treemap,
groupLabel,
maxBuckets: 2,
toolbarPopover: {
@ -196,7 +185,6 @@ export const PartitionChartsMeta: Record<PieChartTypes, PartitionChartMeta> = {
label: i18n.translate('xpack.lens.pie.mosaiclabel', {
defaultMessage: 'Mosaic',
}),
partitionType: PartitionLayout.mosaic,
groupLabel,
maxBuckets: 2,
isExperimental: true,
@ -208,23 +196,12 @@ export const PartitionChartsMeta: Record<PieChartTypes, PartitionChartMeta> = {
getShowLegendDefault: () => false,
},
requiredMinDimensionCount: 2,
sortPredicate:
(bucketColumns, sortingMap) =>
([name1, node1], [, node2]) => {
// Sorting for first group
if (bucketColumns.length === 1 || (node1.children.length && name1 in sortingMap)) {
return sortingMap[name1];
}
// Sorting for second group
return node2.value - node1.value;
},
},
waffle: {
icon: LensIconChartWaffle,
label: i18n.translate('xpack.lens.pie.wafflelabel', {
defaultMessage: 'Waffle',
}),
partitionType: PartitionLayout.waffle,
groupLabel,
maxBuckets: 1,
isExperimental: true,
@ -239,9 +216,5 @@ export const PartitionChartsMeta: Record<PieChartTypes, PartitionChartMeta> = {
hideNestedLegendSwitch: true,
getShowLegendDefault: () => true,
},
sortPredicate:
() =>
([, node1], [, node2]) =>
node2.value - node1.value,
},
};

View file

@ -5,5 +5,4 @@
* 2.0.
*/
export * from './expression';
export * from './visualization';

View file

@ -1,430 +0,0 @@
/*
* 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 from 'react';
import {
Partition,
SeriesIdentifier,
Settings,
NodeColorAccessor,
ShapeTreeNode,
HierarchyOfArrays,
Chart,
PartialTheme,
} from '@elastic/charts';
import { shallow } from 'enzyme';
import type { LensMultiTable } from '../../common';
import type { PieExpressionArgs } from '../../common/expressions';
import { PieComponent } from './render_function';
import { VisualizationContainer } from '../visualization_container';
import { EmptyPlaceholder } from '../../../../../src/plugins/charts/public';
import { chartPluginMock } from '../../../../../src/plugins/charts/public/mocks';
import { LensIconChartDonut } from '../assets/chart_donut';
const chartsThemeService = chartPluginMock.createSetupContract().theme;
describe('PieVisualization component', () => {
let getFormatSpy: jest.Mock;
let convertSpy: jest.Mock;
beforeEach(() => {
convertSpy = jest.fn((x) => x);
getFormatSpy = jest.fn();
getFormatSpy.mockReturnValue({ convert: convertSpy });
});
describe('legend options', () => {
const data: LensMultiTable = {
type: 'lens_multitable',
tables: {
first: {
type: 'datatable',
columns: [
{ id: 'a', name: 'a', meta: { type: 'number' } },
{ id: 'b', name: 'b', meta: { type: 'string' } },
{ id: 'c', name: 'c', meta: { type: 'number' } },
],
rows: [
{ a: 6, b: 'I', c: 2, d: 'Row 1' },
{ a: 1, b: 'J', c: 5, d: 'Row 2' },
],
},
},
};
const args: PieExpressionArgs = {
shape: 'pie',
groups: ['a', 'b'],
metric: 'c',
numberDisplay: 'hidden',
categoryDisplay: 'default',
legendDisplay: 'default',
legendMaxLines: 1,
truncateLegend: true,
nestedLegend: false,
percentDecimals: 3,
hideLabels: false,
palette: { name: 'mock', type: 'palette' },
};
function getDefaultArgs() {
return {
data,
formatFactory: getFormatSpy,
onClickValue: jest.fn(),
chartsThemeService,
paletteService: chartPluginMock.createPaletteRegistry(),
renderMode: 'view' as const,
syncColors: false,
};
}
test('it shows legend on correct side', () => {
const component = shallow(
<PieComponent args={{ ...args, legendPosition: 'top' }} {...getDefaultArgs()} />
);
expect(component.find(Settings).prop('legendPosition')).toEqual('top');
});
test('it shows legend for 2 groups using default legendDisplay', () => {
const component = shallow(<PieComponent args={args} {...getDefaultArgs()} />);
expect(component.find(Settings).prop('showLegend')).toEqual(true);
});
test('it hides legend for 1 group using default legendDisplay', () => {
const component = shallow(
<PieComponent args={{ ...args, groups: ['a'] }} {...getDefaultArgs()} />
);
expect(component.find(Settings).prop('showLegend')).toEqual(false);
});
test('it hides legend that would show otherwise in preview mode', () => {
const component = shallow(
<PieComponent args={{ ...args, hideLabels: true }} {...getDefaultArgs()} />
);
expect(component.find(Settings).prop('showLegend')).toEqual(false);
});
test('it sets the correct lines per legend item', () => {
const component = shallow(<PieComponent args={args} {...getDefaultArgs()} />);
expect(component.find(Settings).prop<PartialTheme[]>('theme')[0]).toMatchObject({
background: {
color: undefined,
},
legend: {
labelOptions: {
maxLines: 1,
},
},
});
});
test('it calls the color function with the right series layers', () => {
const defaultArgs = getDefaultArgs();
const component = shallow(
<PieComponent
args={args}
{...defaultArgs}
data={{
...data,
tables: {
first: {
...data.tables.first,
rows: [
{ a: 'empty', b: 'first', c: 1, d: 'Row 1' },
{ a: 'css', b: 'first', c: 1, d: 'Row 1' },
{ a: 'css', b: 'second', c: 1, d: 'Row 1' },
{ a: 'css', b: 'third', c: 1, d: 'Row 1' },
{ a: 'gz', b: 'first', c: 1, d: 'Row 1' },
],
},
},
}}
/>
);
(component.find(Partition).prop('layers')![1].shape!.fillColor as NodeColorAccessor)(
{
dataName: 'third',
depth: 2,
parent: {
children: [
['first', {}],
['second', {}],
['third', {}],
],
depth: 1,
value: 200,
dataName: 'css',
parent: {
children: [
['empty', {}],
['css', {}],
['gz', {}],
],
depth: 0,
sortIndex: 0,
value: 500,
},
sortIndex: 1,
},
value: 41,
sortIndex: 2,
} as unknown as ShapeTreeNode,
0,
[] as HierarchyOfArrays
);
expect(defaultArgs.paletteService.get('mock').getCategoricalColor).toHaveBeenCalledWith(
[
{
name: 'css',
rankAtDepth: 1,
totalSeriesAtDepth: 3,
},
{
name: 'third',
rankAtDepth: 2,
totalSeriesAtDepth: 3,
},
],
{
maxDepth: 2,
totalSeries: 5,
syncColors: false,
behindText: true,
},
undefined
);
});
test('it hides legend with 2 groups for treemap', () => {
const component = shallow(
<PieComponent args={{ ...args, shape: 'treemap' }} {...getDefaultArgs()} />
);
expect(component.find(Settings).prop('showLegend')).toEqual(false);
});
test('it shows treemap legend only when forced on', () => {
const component = shallow(
<PieComponent
args={{ ...args, legendDisplay: 'show', shape: 'treemap' }}
{...getDefaultArgs()}
/>
);
expect(component.find(Settings).prop('showLegend')).toEqual(true);
});
test('it defaults to 1-level legend depth', () => {
const component = shallow(<PieComponent args={args} {...getDefaultArgs()} />);
expect(component.find(Settings).prop('legendMaxDepth')).toEqual(1);
});
test('it shows nested legend only when forced on', () => {
const component = shallow(
<PieComponent args={{ ...args, nestedLegend: true }} {...getDefaultArgs()} />
);
expect(component.find(Settings).prop('legendMaxDepth')).toBeUndefined();
});
test('it calls filter callback with the given context', () => {
const defaultArgs = getDefaultArgs();
const component = shallow(<PieComponent args={{ ...args }} {...defaultArgs} />);
component.find(Settings).first().prop('onElementClick')!([
[
[
{
groupByRollup: 6,
value: 6,
depth: 1,
path: [],
sortIndex: 1,
smAccessorValue: '',
},
],
{} as SeriesIdentifier,
],
]);
expect(defaultArgs.onClickValue.mock.calls[0][0]).toMatchInlineSnapshot(`
Object {
"data": Array [
Object {
"column": 0,
"row": 0,
"table": Object {
"columns": Array [
Object {
"id": "a",
"meta": Object {
"type": "number",
},
"name": "a",
},
Object {
"id": "b",
"meta": Object {
"type": "string",
},
"name": "b",
},
Object {
"id": "c",
"meta": Object {
"type": "number",
},
"name": "c",
},
],
"rows": Array [
Object {
"a": 6,
"b": "I",
"c": 2,
"d": "Row 1",
},
Object {
"a": 1,
"b": "J",
"c": 5,
"d": "Row 2",
},
],
"type": "datatable",
},
"value": 6,
},
],
}
`);
});
test('does not set click listener and legend actions on non-interactive mode', () => {
const defaultArgs = getDefaultArgs();
const component = shallow(
<PieComponent args={{ ...args }} {...defaultArgs} interactive={false} />
);
expect(component.find(Settings).first().prop('onElementClick')).toBeUndefined();
expect(component.find(Settings).first().prop('legendAction')).toBeUndefined();
});
test('it renders the empty placeholder when metric contains only falsy data', () => {
const defaultData = getDefaultArgs().data;
const emptyData: LensMultiTable = {
...defaultData,
tables: {
first: {
...defaultData.tables.first,
rows: [
{ a: 0, b: 'I', c: 0, d: 'Row 1' },
{ a: 0, b: 'J', c: null, d: 'Row 2' },
],
},
},
};
const component = shallow(
<PieComponent args={args} {...getDefaultArgs()} data={emptyData} />
);
expect(component.find(VisualizationContainer)).toHaveLength(1);
expect(component.find(EmptyPlaceholder)).toHaveLength(1);
});
test('it renders the chart when metric contains truthy data and buckets contain only falsy data', () => {
const defaultData = getDefaultArgs().data;
const emptyData: LensMultiTable = {
...defaultData,
tables: {
first: {
...defaultData.tables.first,
// a and b are buckets, c is a metric
rows: [{ a: 0, b: undefined, c: 12 }],
},
},
};
const component = shallow(
<PieComponent args={args} {...getDefaultArgs()} data={emptyData} />
);
expect(component.find(VisualizationContainer)).toHaveLength(1);
expect(component.find(EmptyPlaceholder)).toHaveLength(0);
expect(component.find(Chart)).toHaveLength(1);
});
test('it shows emptyPlaceholder for undefined grouped data', () => {
const defaultData = getDefaultArgs().data;
const emptyData: LensMultiTable = {
...defaultData,
tables: {
first: {
...defaultData.tables.first,
rows: [
{ a: undefined, b: 'I', c: undefined, d: 'Row 1' },
{ a: undefined, b: 'J', c: undefined, d: 'Row 2' },
],
},
},
};
const component = shallow(
<PieComponent args={args} {...getDefaultArgs()} data={emptyData} />
);
expect(component.find(VisualizationContainer)).toHaveLength(1);
expect(component.find(EmptyPlaceholder).prop('icon')).toEqual(LensIconChartDonut);
});
test('it should dynamically shrink the chart area to when some small slices are detected', () => {
const defaultData = getDefaultArgs().data;
const emptyData: LensMultiTable = {
...defaultData,
tables: {
first: {
...defaultData.tables.first,
rows: [
{ a: 60, b: 'I', c: 200, d: 'Row 1' },
{ a: 1, b: 'J', c: 0.1, d: 'Row 2' },
],
},
},
};
const component = shallow(
<PieComponent args={args} {...getDefaultArgs()} data={emptyData} />
);
expect(
component.find(Settings).prop<PartialTheme[]>('theme')[0].partition?.outerSizeRatio
).toBeCloseTo(1 / 1.05);
});
test('it should bound the shrink the chart area to ~20% when some small slices are detected', () => {
const defaultData = getDefaultArgs().data;
const emptyData: LensMultiTable = {
...defaultData,
tables: {
first: {
...defaultData.tables.first,
rows: [
{ a: 60, b: 'I', c: 200, d: 'Row 1' },
{ a: 1, b: 'J', c: 0.1, d: 'Row 2' },
{ a: 1, b: 'K', c: 0.1, d: 'Row 3' },
{ a: 1, b: 'G', c: 0.1, d: 'Row 4' },
{ a: 1, b: 'H', c: 0.1, d: 'Row 5' },
],
},
},
};
const component = shallow(
<PieComponent args={args} {...getDefaultArgs()} data={emptyData} />
);
expect(
component.find(Settings).prop<PartialTheme[]>('theme')[0].partition?.outerSizeRatio
).toBeCloseTo(1 / 1.2);
});
});
});

View file

@ -1,354 +0,0 @@
/*
* 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 { uniq } from 'lodash';
import React from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import { Required } from '@kbn/utility-types';
import { EuiText } from '@elastic/eui';
import {
Chart,
Datum,
LayerValue,
Partition,
PartitionLayer,
Position,
Settings,
ElementClickListener,
PartialTheme,
} from '@elastic/charts';
import { RenderMode } from 'src/plugins/expressions';
import type { LensFilterEvent } from '../types';
import { VisualizationContainer } from '../visualization_container';
import { DEFAULT_PERCENT_DECIMALS } from './constants';
import { PartitionChartsMeta } from './partition_charts_meta';
import type { FormatFactory } from '../../common';
import type { PieExpressionProps } from '../../common/expressions';
import {
getSliceValue,
getFilterContext,
isTreemapOrMosaicShape,
byDataColorPaletteMap,
extractUniqTermsMap,
} from './render_helpers';
import { EmptyPlaceholder } from '../../../../../src/plugins/charts/public';
import './visualization.scss';
import {
ChartsPluginSetup,
PaletteRegistry,
SeriesLayer,
} from '../../../../../src/plugins/charts/public';
import { LensIconChartDonut } from '../assets/chart_donut';
import { getLegendAction } from './get_legend_action';
declare global {
interface Window {
/**
* Flag used to enable debugState on elastic charts
*/
_echDebugStateFlag?: boolean;
}
}
const EMPTY_SLICE = Symbol('empty_slice');
export function PieComponent(
props: PieExpressionProps & {
formatFactory: FormatFactory;
chartsThemeService: ChartsPluginSetup['theme'];
interactive?: boolean;
paletteService: PaletteRegistry;
onClickValue: (data: LensFilterEvent['data']) => void;
renderMode: RenderMode;
syncColors: boolean;
}
) {
const [firstTable] = Object.values(props.data.tables);
const formatters: Record<string, ReturnType<FormatFactory>> = {};
const { chartsThemeService, paletteService, syncColors, onClickValue } = props;
const {
shape,
groups,
metric,
numberDisplay,
categoryDisplay,
legendDisplay,
legendPosition,
nestedLegend,
percentDecimals,
emptySizeRatio,
legendMaxLines,
truncateLegend,
hideLabels,
palette,
showValuesInLegend,
} = props.args;
const chartTheme = chartsThemeService.useChartsTheme();
const chartBaseTheme = chartsThemeService.useChartsBaseTheme();
const isDarkMode = chartsThemeService.useDarkMode();
if (!hideLabels) {
firstTable.columns.forEach((column) => {
formatters[column.id] = props.formatFactory(column.meta.params);
});
}
const fillLabel: PartitionLayer['fillLabel'] = {
valueFont: {
fontWeight: 700,
},
};
if (numberDisplay === 'hidden') {
// Hides numbers from appearing inside chart, but they still appear in linkLabel
// and tooltips.
fillLabel.valueFormatter = () => '';
}
const bucketColumns = firstTable.columns.filter((col) => groups.includes(col.id));
const totalSeriesCount = uniq(
firstTable.rows.map((row) => {
return bucketColumns.map(({ id: columnId }) => row[columnId]).join(',');
})
).length;
const shouldUseByDataPalette = !syncColors && ['mosaic'].includes(shape) && bucketColumns[1]?.id;
let byDataPalette: ReturnType<typeof byDataColorPaletteMap>;
if (shouldUseByDataPalette) {
byDataPalette = byDataColorPaletteMap(
firstTable,
bucketColumns[1].id,
paletteService.get(palette.name),
palette
);
}
let sortingMap: Record<string, number> = {};
if (shape === 'mosaic') {
sortingMap = extractUniqTermsMap(firstTable, bucketColumns[0].id);
}
const layers: PartitionLayer[] = bucketColumns.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.meta.params) {
return formatters[col.id].convert(d) ?? '';
}
return String(d);
},
fillLabel,
sortPredicate: PartitionChartsMeta[shape].sortPredicate?.(bucketColumns, sortingMap),
shape: {
fillColor: (d) => {
const seriesLayers: SeriesLayer[] = [];
// Mind the difference here: the contrast computation for the text ignores the alpha/opacity
// therefore change it for dask mode
const defaultColor = isDarkMode ? 'rgba(0,0,0,0)' : 'rgba(255,255,255,0)';
// 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 tempParent: typeof d | typeof d['parent'] = d;
while (tempParent.parent && tempParent.depth > 0) {
seriesLayers.unshift({
name: String(tempParent.parent.children[tempParent.sortIndex][0]),
rankAtDepth: tempParent.sortIndex,
totalSeriesAtDepth: tempParent.parent.children.length,
});
tempParent = tempParent.parent;
}
if (byDataPalette && seriesLayers[1]) {
return byDataPalette.getColor(seriesLayers[1].name) || defaultColor;
}
if (isTreemapOrMosaicShape(shape)) {
// Only highlight the innermost color of the treemap, as it accurately represents area
if (layerIndex < bucketColumns.length - 1) {
return defaultColor;
}
// only use the top level series layer for coloring
if (seriesLayers.length > 1) {
seriesLayers.pop();
}
}
const outputColor = paletteService.get(palette.name).getCategoricalColor(
seriesLayers,
{
behindText: categoryDisplay !== 'hide' || isTreemapOrMosaicShape(shape),
maxDepth: bucketColumns.length,
totalSeries: totalSeriesCount,
syncColors,
},
palette.params
);
return outputColor || defaultColor;
},
},
};
});
const { legend, partitionType, label: chartType } = PartitionChartsMeta[shape];
const themeOverrides: Required<PartialTheme, 'partition'> = {
chartMargins: { top: 0, bottom: 0, left: 0, right: 0 },
background: {
color: undefined, // removes background for embeddables
},
legend: {
labelOptions: { maxLines: truncateLegend ? legendMaxLines ?? 1 : 0 },
},
partition: {
fontFamily: chartTheme.barSeriesStyle?.displayValue?.fontFamily,
outerSizeRatio: 1,
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 (isTreemapOrMosaicShape(shape)) {
if (hideLabels || categoryDisplay === 'hide') {
themeOverrides.partition.fillLabel = { textColor: 'rgba(0,0,0,0)' };
}
} else {
themeOverrides.partition.emptySizeRatio = shape === 'donut' ? emptySizeRatio : 0;
if (hideLabels || categoryDisplay === 'hide') {
// Force all labels to be linked, then prevent links from showing
themeOverrides.partition.linkLabel = {
maxCount: 0,
maximumSection: Number.POSITIVE_INFINITY,
};
} else if (categoryDisplay === 'inside') {
// Prevent links from showing
themeOverrides.partition.linkLabel = { maxCount: 0 };
} else {
// if it contains any slice below 2% reduce the ratio
// first step: sum it up the overall sum
const overallSum = firstTable.rows.reduce((sum, row) => sum + row[metric!], 0);
const slices = firstTable.rows.map((row) => row[metric!] / overallSum);
const smallSlices = slices.filter((value) => value < 0.02).length;
if (smallSlices) {
// shrink up to 20% to give some room for the linked values
themeOverrides.partition.outerSizeRatio = 1 / (1 + Math.min(smallSlices * 0.05, 0.2));
}
}
}
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 hasNegative = firstTable.rows.some((row) => {
const value = row[metricColumn.id];
return typeof value === 'number' && value < 0;
});
const isMetricEmpty = firstTable.rows.every((row) => {
return !row[metricColumn.id];
});
const isEmpty =
firstTable.rows.length === 0 ||
firstTable.rows.every((row) => groups.every((colId) => typeof row[colId] === 'undefined')) ||
isMetricEmpty;
if (isEmpty) {
return (
<VisualizationContainer className="lnsPieExpression__container">
<EmptyPlaceholder icon={LensIconChartDonut} />
</VisualizationContainer>
);
}
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."
values={{
chartType,
}}
/>
</EuiText>
);
}
const onElementClickHandler: ElementClickListener = (args) => {
const context = getFilterContext(args[0][0] as LayerValue[], groups, firstTable);
onClickValue(context);
};
return (
<VisualizationContainer className="lnsPieExpression__container">
<Chart>
<Settings
tooltip={{ boundary: document.getElementById('app-fixed-viewport') ?? undefined }}
debugState={window._echDebugStateFlag ?? false}
// 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' &&
(legend.getShowLegendDefault?.(bucketColumns) ?? false)))
}
flatLegend={legend.flat}
showLegendExtra={showValuesInLegend}
legendPosition={legendPosition || Position.Right}
legendMaxDepth={nestedLegend ? undefined : 1 /* Color is based only on first layer */}
onElementClick={props.interactive ?? true ? onElementClickHandler : undefined}
legendAction={props.interactive ? getLegendAction(firstTable, onClickValue) : undefined}
theme={[themeOverrides, chartTheme]}
baseTheme={chartBaseTheme}
ariaLabel={props.args.ariaLabel}
ariaUseDefaultSummary={!props.args.ariaLabel}
/>
<Partition
id={shape}
data={firstTable.rows}
layout={partitionType}
specialFirstInnermostSector
valueAccessor={(d: Datum) => getSliceValue(d, 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}
topGroove={hideLabels || categoryDisplay === 'hide' ? 0 : undefined}
/>
</Chart>
</VisualizationContainer>
);
}

View file

@ -6,321 +6,11 @@
*/
import type { Datatable } from 'src/plugins/expressions/public';
import type { PaletteDefinition, PaletteOutput } from 'src/plugins/charts/public';
import {
getSliceValue,
getFilterContext,
byDataColorPaletteMap,
extractUniqTermsMap,
checkTableForContainsSmallValues,
shouldShowValuesInLegend,
} from './render_helpers';
import { chartPluginMock } from '../../../../../src/plugins/charts/public/mocks';
import type { PieLayerState } from '../../common/expressions';
import { checkTableForContainsSmallValues, shouldShowValuesInLegend } from './render_helpers';
import { PieLayerState, PieChartTypes } from '../../common';
describe('render helpers', () => {
describe('#getSliceValue', () => {
it('returns the metric when positive number', () => {
expect(
getSliceValue(
{ a: 'Cat', b: 'Home', c: 5 },
{
id: 'c',
name: 'C',
meta: { type: 'number' },
}
)
).toEqual(5);
});
it('returns the metric when negative number', () => {
expect(
getSliceValue(
{ a: 'Cat', b: 'Home', c: -100 },
{
id: 'c',
name: 'C',
meta: { type: 'number' },
}
)
).toEqual(0);
});
it('returns 0 when metric value is 0', () => {
expect(
getSliceValue(
{ a: 'Cat', b: 'Home', c: 0 },
{
id: 'c',
name: 'C',
meta: { type: 'number' },
}
)
).toEqual(0);
});
it('returns 0 when metric value is infinite', () => {
expect(
getSliceValue(
{ a: 'Cat', b: 'Home', c: Number.POSITIVE_INFINITY },
{
id: 'c',
name: 'C',
meta: { type: 'number' },
}
)
).toEqual(0);
});
});
describe('#getFilterContext', () => {
it('handles single slice click for single ring', () => {
const table: Datatable = {
type: 'datatable',
columns: [
{ id: 'a', name: 'A', meta: { type: 'string' } },
{ id: 'b', name: 'B', meta: { type: 'number' } },
],
rows: [
{ a: 'Hi', b: 2 },
{ a: 'Test', b: 4 },
{ a: 'Foo', b: 6 },
],
};
expect(
getFilterContext(
[
{
groupByRollup: 'Test',
value: 100,
depth: 1,
path: [],
sortIndex: 1,
smAccessorValue: '',
},
],
['a'],
table
)
).toEqual({
data: [
{
row: 1,
column: 0,
value: 'Test',
table,
},
],
});
});
it('handles single slice click with 2 rings', () => {
const table: Datatable = {
type: 'datatable',
columns: [
{ id: 'a', name: 'A', meta: { type: 'string' } },
{ id: 'b', name: 'B', meta: { type: 'string' } },
{ id: 'c', name: 'C', meta: { type: 'number' } },
],
rows: [
{ a: 'Hi', b: 'Two', c: 2 },
{ a: 'Test', b: 'Two', c: 5 },
{ a: 'Foo', b: 'Three', c: 6 },
],
};
expect(
getFilterContext(
[
{
groupByRollup: 'Test',
value: 100,
depth: 1,
path: [],
sortIndex: 1,
smAccessorValue: '',
},
],
['a', 'b'],
table
)
).toEqual({
data: [
{
row: 1,
column: 0,
value: 'Test',
table,
},
],
});
});
it('finds right row for multi slice click', () => {
const table: Datatable = {
type: 'datatable',
columns: [
{ id: 'a', name: 'A', meta: { type: 'string' } },
{ id: 'b', name: 'B', meta: { type: 'string' } },
{ id: 'c', name: 'C', meta: { type: 'number' } },
],
rows: [
{ a: 'Hi', b: 'Two', c: 2 },
{ a: 'Test', b: 'Two', c: 5 },
{ a: 'Foo', b: 'Three', c: 6 },
],
};
expect(
getFilterContext(
[
{
groupByRollup: 'Test',
value: 100,
depth: 1,
path: [],
sortIndex: 1,
smAccessorValue: '',
},
{
groupByRollup: 'Two',
value: 5,
depth: 1,
path: [],
sortIndex: 1,
smAccessorValue: '',
},
],
['a', 'b'],
table
)
).toEqual({
data: [
{
row: 1,
column: 0,
value: 'Test',
table,
},
{
row: 1,
column: 1,
value: 'Two',
table,
},
],
});
});
});
describe('#extractUniqTermsMap', () => {
it('should extract map', () => {
const table: Datatable = {
type: 'datatable',
columns: [
{ id: 'a', name: 'A', meta: { type: 'string' } },
{ id: 'b', name: 'B', meta: { type: 'string' } },
{ id: 'c', name: 'C', meta: { type: 'number' } },
],
rows: [
{ a: 'Hi', b: 'Two', c: 2 },
{ a: 'Test', b: 'Two', c: 5 },
{ a: 'Foo', b: 'Three', c: 6 },
],
};
expect(extractUniqTermsMap(table, 'a')).toMatchInlineSnapshot(`
Object {
"Foo": 2,
"Hi": 0,
"Test": 1,
}
`);
expect(extractUniqTermsMap(table, 'b')).toMatchInlineSnapshot(`
Object {
"Three": 1,
"Two": 0,
}
`);
});
});
describe('#byDataColorPaletteMap', () => {
let datatable: Datatable;
let paletteDefinition: PaletteDefinition;
let palette: PaletteOutput;
const columnId = 'foo';
beforeEach(() => {
datatable = {
rows: [
{
[columnId]: '1',
},
{
[columnId]: '2',
},
],
} as unknown as Datatable;
paletteDefinition = chartPluginMock.createPaletteRegistry().get('default');
palette = { type: 'palette' } as PaletteOutput;
});
it('should create byDataColorPaletteMap', () => {
expect(byDataColorPaletteMap(datatable, columnId, paletteDefinition, palette))
.toMatchInlineSnapshot(`
Object {
"getColor": [Function],
}
`);
});
it('should get color', () => {
const colorPaletteMap = byDataColorPaletteMap(
datatable,
columnId,
paletteDefinition,
palette
);
expect(colorPaletteMap.getColor('1')).toBe('black');
});
it('should return undefined in case if values not in datatable', () => {
const colorPaletteMap = byDataColorPaletteMap(
datatable,
columnId,
paletteDefinition,
palette
);
expect(colorPaletteMap.getColor('wrong')).toBeUndefined();
});
it('should increase rankAtDepth for each new value', () => {
const colorPaletteMap = byDataColorPaletteMap(
datatable,
columnId,
paletteDefinition,
palette
);
colorPaletteMap.getColor('1');
colorPaletteMap.getColor('2');
expect(paletteDefinition.getCategoricalColor).toHaveBeenNthCalledWith(
1,
[{ name: '1', rankAtDepth: 0, totalSeriesAtDepth: 2 }],
{ behindText: false },
undefined
);
expect(paletteDefinition.getCategoricalColor).toHaveBeenNthCalledWith(
2,
[{ name: '2', rankAtDepth: 1, totalSeriesAtDepth: 2 }],
{ behindText: false },
undefined
);
});
});
describe('#checkTableForContainsSmallValues', () => {
let datatable: Datatable;
const columnId = 'foo';
@ -380,23 +70,35 @@ describe('render helpers', () => {
describe('#shouldShowValuesInLegend', () => {
it('should firstly read the state value', () => {
expect(
shouldShowValuesInLegend({ showValuesInLegend: true } as PieLayerState, 'waffle')
shouldShowValuesInLegend(
{ showValuesInLegend: true } as PieLayerState,
PieChartTypes.WAFFLE
)
).toBeTruthy();
expect(
shouldShowValuesInLegend({ showValuesInLegend: false } as PieLayerState, 'waffle')
shouldShowValuesInLegend(
{ showValuesInLegend: false } as PieLayerState,
PieChartTypes.WAFFLE
)
).toBeFalsy();
});
it('should read value from meta in case of value in state is undefined', () => {
expect(
shouldShowValuesInLegend({ showValuesInLegend: undefined } as PieLayerState, 'waffle')
shouldShowValuesInLegend(
{ showValuesInLegend: undefined } as PieLayerState,
PieChartTypes.WAFFLE
)
).toBeTruthy();
expect(shouldShowValuesInLegend({} as PieLayerState, 'waffle')).toBeTruthy();
expect(shouldShowValuesInLegend({} as PieLayerState, PieChartTypes.WAFFLE)).toBeTruthy();
expect(
shouldShowValuesInLegend({ showValuesInLegend: undefined } as PieLayerState, 'pie')
shouldShowValuesInLegend(
{ showValuesInLegend: undefined } as PieLayerState,
PieChartTypes.PIE
)
).toBeFalsy();
});
});

View file

@ -5,47 +5,14 @@
* 2.0.
*/
import type { Datum, LayerValue } from '@elastic/charts';
import type { Datatable, DatatableColumn } from 'src/plugins/expressions/public';
import type { LensFilterEvent } from '../types';
import type { PieChartTypes, PieLayerState } from '../../common/expressions/pie_chart/types';
import type { PaletteDefinition, PaletteOutput } from '../../../../../src/plugins/charts/public';
import type { Datatable } from 'src/plugins/expressions/public';
import type { PieChartType, PieLayerState } from '../../common/types';
import { PartitionChartsMeta } from './partition_charts_meta';
export function getSliceValue(d: Datum, metricColumn: DatatableColumn) {
const value = d[metricColumn.id];
return Number.isFinite(value) && value >= 0 ? value : 0;
}
export function getFilterContext(
clickedLayers: LayerValue[],
layerColumnIds: string[],
table: Datatable
): LensFilterEvent['data'] {
const matchingIndex = table.rows.findIndex((row) =>
clickedLayers.every((layer, index) => {
const columnId = layerColumnIds[index];
return row[columnId] === layer.groupByRollup;
})
);
return {
data: clickedLayers.map((clickedLayer, index) => ({
column: table.columns.findIndex((col) => col.id === layerColumnIds[index]),
row: matchingIndex,
value: clickedLayer.groupByRollup,
table,
})),
};
}
export const isPartitionShape = (shape: PieChartTypes | string) =>
export const isPartitionShape = (shape: PieChartType | string) =>
['donut', 'pie', 'treemap', 'mosaic', 'waffle'].includes(shape);
export const isTreemapOrMosaicShape = (shape: PieChartTypes | string) =>
['treemap', 'mosaic'].includes(shape);
export const shouldShowValuesInLegend = (layer: PieLayerState, shape: PieChartTypes) => {
export const shouldShowValuesInLegend = (layer: PieLayerState, shape: PieChartType) => {
if ('showValues' in PartitionChartsMeta[shape]?.legend) {
return layer.showValuesInLegend ?? PartitionChartsMeta[shape]?.legend?.showValues ?? true;
}
@ -53,58 +20,6 @@ export const shouldShowValuesInLegend = (layer: PieLayerState, shape: PieChartTy
return false;
};
export const extractUniqTermsMap = (dataTable: Datatable, columnId: string) =>
[...new Set(dataTable.rows.map((item) => item[columnId]))].reduce(
(acc, item, index) => ({
...acc,
[item]: index,
}),
{}
);
export const byDataColorPaletteMap = (
dataTable: Datatable,
columnId: string,
paletteDefinition: PaletteDefinition,
{ params }: PaletteOutput
) => {
const colorMap = new Map<string, string | undefined>(
dataTable.rows.map((item) => [String(item[columnId]), undefined])
);
let rankAtDepth = 0;
return {
getColor: (item: unknown) => {
const key = String(item);
if (colorMap.has(key)) {
let color = colorMap.get(key);
if (color) {
return color;
}
color =
paletteDefinition.getCategoricalColor(
[
{
name: key,
totalSeriesAtDepth: colorMap.size,
rankAtDepth: rankAtDepth++,
},
],
{
behindText: false,
},
params
) || undefined;
colorMap.set(key, color);
return color;
}
},
};
};
export const checkTableForContainsSmallValues = (
dataTable: Datatable,
columnId: string,

View file

@ -8,7 +8,14 @@
import { PaletteOutput } from 'src/plugins/charts/public';
import { suggestions } from './suggestions';
import type { DataType, SuggestionRequest } from '../types';
import type { PieLayerState, PieVisualizationState } from '../../common/expressions';
import {
CategoryDisplay,
LegendDisplay,
NumberDisplay,
PieChartTypes,
PieLayerState,
PieVisualizationState,
} from '../../common';
import { layerTypes } from '../../common';
describe('suggestions', () => {
@ -53,16 +60,16 @@ describe('suggestions', () => {
changeType: 'unchanged',
},
state: {
shape: 'pie',
shape: PieChartTypes.PIE,
layers: [
{
layerId: 'first',
layerType: layerTypes.DATA,
groups: [],
metric: 'a',
numberDisplay: 'hidden',
categoryDisplay: 'default',
legendDisplay: 'default',
numberDisplay: NumberDisplay.HIDDEN,
categoryDisplay: CategoryDisplay.DEFAULT,
legendDisplay: LegendDisplay.DEFAULT,
},
],
},
@ -168,7 +175,7 @@ describe('suggestions', () => {
changeType: 'initial',
},
state: {
shape: 'mosaic',
shape: PieChartTypes.MOSAIC,
layers: [{} as PieLayerState],
},
keptLayerIds: ['first'],
@ -380,7 +387,7 @@ describe('suggestions', () => {
expect(results).toContainEqual(
expect.objectContaining({
state: expect.objectContaining({ shape: 'donut' }),
state: expect.objectContaining({ shape: PieChartTypes.DONUT }),
})
);
});
@ -412,7 +419,7 @@ describe('suggestions', () => {
expect(results).toContainEqual(
expect.objectContaining({
state: expect.objectContaining({ shape: 'pie' }),
state: expect.objectContaining({ shape: PieChartTypes.PIE }),
})
);
});
@ -542,7 +549,7 @@ describe('suggestions', () => {
changeType: 'unchanged',
},
state: {
shape: 'treemap',
shape: PieChartTypes.TREEMAP,
palette,
layers: [
{
@ -551,9 +558,9 @@ describe('suggestions', () => {
groups: ['a'],
metric: 'b',
numberDisplay: 'hidden',
categoryDisplay: 'inside',
legendDisplay: 'show',
numberDisplay: NumberDisplay.HIDDEN,
categoryDisplay: CategoryDisplay.INSIDE,
legendDisplay: LegendDisplay.SHOW,
percentDecimals: 0,
legendMaxLines: 1,
truncateLegend: true,
@ -566,7 +573,7 @@ describe('suggestions', () => {
).toContainEqual(
expect.objectContaining({
state: {
shape: 'donut',
shape: PieChartTypes.DONUT,
palette,
layers: [
{
@ -575,8 +582,8 @@ describe('suggestions', () => {
groups: ['a'],
metric: 'b',
numberDisplay: 'hidden',
categoryDisplay: 'inside',
numberDisplay: NumberDisplay.HIDDEN,
categoryDisplay: CategoryDisplay.INSIDE,
legendDisplay: 'show',
percentDecimals: 0,
legendMaxLines: 1,
@ -601,7 +608,7 @@ describe('suggestions', () => {
changeType: 'unchanged',
},
state: {
shape: 'treemap',
shape: PieChartTypes.TREEMAP,
layers: [
{
layerId: 'first',
@ -609,9 +616,9 @@ describe('suggestions', () => {
groups: [],
metric: 'a',
numberDisplay: 'hidden',
categoryDisplay: 'default',
legendDisplay: 'default',
numberDisplay: NumberDisplay.HIDDEN,
categoryDisplay: CategoryDisplay.DEFAULT,
legendDisplay: LegendDisplay.DEFAULT,
},
],
},
@ -651,16 +658,16 @@ describe('suggestions', () => {
changeType: 'extended',
},
state: {
shape: 'treemap',
shape: PieChartTypes.TREEMAP,
layers: [
{
layerId: 'first',
layerType: layerTypes.DATA,
groups: ['a', 'b'],
metric: 'e',
numberDisplay: 'value',
categoryDisplay: 'default',
legendDisplay: 'default',
numberDisplay: NumberDisplay.VALUE,
categoryDisplay: CategoryDisplay.DEFAULT,
legendDisplay: LegendDisplay.DEFAULT,
},
],
},
@ -700,16 +707,16 @@ describe('suggestions', () => {
changeType: 'initial',
},
state: {
shape: 'treemap',
shape: PieChartTypes.TREEMAP,
layers: [
{
layerId: 'first',
layerType: layerTypes.DATA,
groups: ['a', 'b'],
metric: 'e',
numberDisplay: 'percent',
categoryDisplay: 'default',
legendDisplay: 'default',
numberDisplay: NumberDisplay.PERCENT,
categoryDisplay: CategoryDisplay.DEFAULT,
legendDisplay: LegendDisplay.DEFAULT,
},
],
},
@ -737,7 +744,7 @@ describe('suggestions', () => {
changeType: 'unchanged',
},
state: {
shape: 'pie',
shape: PieChartTypes.PIE,
layers: [
{
layerId: 'first',
@ -745,9 +752,9 @@ describe('suggestions', () => {
groups: ['a'],
metric: 'b',
numberDisplay: 'hidden',
categoryDisplay: 'inside',
legendDisplay: 'show',
numberDisplay: NumberDisplay.HIDDEN,
categoryDisplay: CategoryDisplay.INSIDE,
legendDisplay: LegendDisplay.SHOW,
percentDecimals: 0,
legendMaxLines: 1,
truncateLegend: true,
@ -760,7 +767,7 @@ describe('suggestions', () => {
).toContainEqual(
expect.objectContaining({
state: {
shape: 'treemap',
shape: PieChartTypes.TREEMAP,
layers: [
{
layerId: 'first',
@ -768,8 +775,8 @@ describe('suggestions', () => {
groups: ['a'],
metric: 'b',
numberDisplay: 'hidden',
categoryDisplay: 'default', // This is changed
numberDisplay: NumberDisplay.HIDDEN,
categoryDisplay: CategoryDisplay.DEFAULT, // This is changed
legendDisplay: 'show',
percentDecimals: 0,
legendMaxLines: 1,
@ -794,7 +801,7 @@ describe('suggestions', () => {
changeType: 'unchanged',
},
state: {
shape: 'mosaic',
shape: PieChartTypes.MOSAIC,
layers: [
{
layerId: 'first',
@ -802,9 +809,9 @@ describe('suggestions', () => {
groups: [],
metric: 'a',
numberDisplay: 'hidden',
categoryDisplay: 'default',
legendDisplay: 'default',
numberDisplay: NumberDisplay.HIDDEN,
categoryDisplay: CategoryDisplay.DEFAULT,
legendDisplay: LegendDisplay.DEFAULT,
},
],
},
@ -836,7 +843,7 @@ describe('suggestions', () => {
changeType: 'unchanged',
},
state: {
shape: 'treemap',
shape: PieChartTypes.TREEMAP,
layers: [
{
layerId: 'first',
@ -844,9 +851,9 @@ describe('suggestions', () => {
groups: ['a', 'b'],
metric: 'c',
numberDisplay: 'hidden',
categoryDisplay: 'inside',
legendDisplay: 'show',
numberDisplay: NumberDisplay.HIDDEN,
categoryDisplay: CategoryDisplay.INSIDE,
legendDisplay: LegendDisplay.SHOW,
percentDecimals: 0,
legendMaxLines: 1,
truncateLegend: true,
@ -871,7 +878,7 @@ describe('suggestions', () => {
changeType: 'unchanged',
},
state: {
shape: 'waffle',
shape: PieChartTypes.WAFFLE,
layers: [
{
layerId: 'first',
@ -879,9 +886,9 @@ describe('suggestions', () => {
groups: [],
metric: 'a',
numberDisplay: 'hidden',
categoryDisplay: 'default',
legendDisplay: 'default',
numberDisplay: NumberDisplay.HIDDEN,
categoryDisplay: CategoryDisplay.DEFAULT,
legendDisplay: LegendDisplay.DEFAULT,
},
],
},
@ -909,16 +916,16 @@ describe('suggestions', () => {
changeType: 'unchanged',
},
state: {
shape: 'pie',
shape: PieChartTypes.PIE,
layers: [
{
layerId: 'first',
layerType: layerTypes.DATA,
groups: ['a', 'b'],
metric: 'c',
numberDisplay: 'hidden',
categoryDisplay: 'inside',
legendDisplay: 'show',
numberDisplay: NumberDisplay.HIDDEN,
categoryDisplay: CategoryDisplay.INSIDE,
legendDisplay: LegendDisplay.SHOW,
percentDecimals: 0,
legendMaxLines: 1,
truncateLegend: true,

View file

@ -8,11 +8,17 @@
import { partition } from 'lodash';
import { i18n } from '@kbn/i18n';
import type { SuggestionRequest, TableSuggestionColumn, VisualizationSuggestion } from '../types';
import { layerTypes } from '../../common';
import type { PieVisualizationState } from '../../common/expressions';
import {
CategoryDisplay,
layerTypes,
LegendDisplay,
NumberDisplay,
PieChartTypes,
PieVisualizationState,
} from '../../common';
import type { PieChartType } from '../../common/types';
import { PartitionChartsMeta } from './partition_charts_meta';
import { isPartitionShape } from './render_helpers';
import { PieChartTypes } from '../../common/expressions/pie_chart/types';
function hasIntervalScale(columns: TableSuggestionColumn[]) {
return columns.some((col) => col.operation.scale === 'interval');
@ -43,14 +49,19 @@ function getNewShape(
let newShape: PieVisualizationState['shape'] | undefined;
if (groups.length !== 1 && !subVisualizationId) {
newShape = 'pie';
newShape = PieChartTypes.PIE;
}
return newShape ?? 'donut';
return newShape ?? PieChartTypes.DONUT;
}
function hasCustomSuggestionsExists(shape: PieChartTypes | string | undefined) {
return shape ? ['treemap', 'waffle', 'mosaic'].includes(shape) : false;
function hasCustomSuggestionsExists(shape: PieChartType | string | undefined) {
const shapes: Array<PieChartType | string> = [
PieChartTypes.TREEMAP,
PieChartTypes.WAFFLE,
PieChartTypes.MOSAIC,
];
return shape ? shapes.includes(shape) : false;
}
const maximumGroupLength = Math.max(
@ -116,9 +127,9 @@ export function suggestions({
layerId: table.layerId,
groups: groups.map((col) => col.columnId),
metric: metricColumnId,
numberDisplay: 'percent',
categoryDisplay: 'default',
legendDisplay: 'default',
numberDisplay: NumberDisplay.PERCENT,
categoryDisplay: CategoryDisplay.DEFAULT,
legendDisplay: LegendDisplay.DEFAULT,
nestedLegend: false,
layerType: layerTypes.DATA,
},
@ -137,13 +148,18 @@ export function suggestions({
...baseSuggestion,
title: i18n.translate('xpack.lens.pie.suggestionLabel', {
defaultMessage: 'As {chartName}',
values: { chartName: PartitionChartsMeta[newShape === 'pie' ? 'donut' : 'pie'].label },
values: {
chartName:
PartitionChartsMeta[
newShape === PieChartTypes.PIE ? PieChartTypes.DONUT : PieChartTypes.PIE
].label,
},
description: 'chartName is already translated',
}),
score: 0.1,
state: {
...baseSuggestion.state,
shape: newShape === 'pie' ? 'donut' : 'pie',
shape: newShape === PieChartTypes.PIE ? PieChartTypes.DONUT : PieChartTypes.PIE,
},
hide: true,
});
@ -159,9 +175,9 @@ export function suggestions({
}),
// Use a higher score when currently active, to prevent chart type switching
// on the user unintentionally
score: state?.shape === 'treemap' ? 0.7 : 0.5,
score: state?.shape === PieChartTypes.TREEMAP ? 0.7 : 0.5,
state: {
shape: 'treemap',
shape: PieChartTypes.TREEMAP,
palette: mainPalette || state?.palette,
layers: [
state?.layers[0]
@ -171,8 +187,8 @@ export function suggestions({
groups: groups.map((col) => col.columnId),
metric: metricColumnId,
categoryDisplay:
state.layers[0].categoryDisplay === 'inside'
? 'default'
state.layers[0].categoryDisplay === CategoryDisplay.INSIDE
? CategoryDisplay.DEFAULT
: state.layers[0].categoryDisplay,
layerType: layerTypes.DATA,
}
@ -180,9 +196,9 @@ export function suggestions({
layerId: table.layerId,
groups: groups.map((col) => col.columnId),
metric: metricColumnId,
numberDisplay: 'percent',
categoryDisplay: 'default',
legendDisplay: 'default',
numberDisplay: NumberDisplay.PERCENT,
categoryDisplay: CategoryDisplay.DEFAULT,
legendDisplay: LegendDisplay.DEFAULT,
nestedLegend: false,
layerType: layerTypes.DATA,
},
@ -194,21 +210,21 @@ export function suggestions({
table.changeType === 'reduced' ||
!state ||
hasIntervalScale(groups) ||
(state && state.shape === 'treemap'),
(state && state.shape === PieChartTypes.TREEMAP),
});
}
if (
groups.length <= PartitionChartsMeta.mosaic.maxBuckets &&
(!subVisualizationId || subVisualizationId === 'mosaic')
(!subVisualizationId || subVisualizationId === PieChartTypes.MOSAIC)
) {
results.push({
title: i18n.translate('xpack.lens.pie.mosaicSuggestionLabel', {
defaultMessage: 'As Mosaic',
}),
score: state?.shape === 'mosaic' ? 0.7 : 0.5,
score: state?.shape === PieChartTypes.MOSAIC ? 0.7 : 0.5,
state: {
shape: 'mosaic',
shape: PieChartTypes.MOSAIC,
palette: mainPalette || state?.palette,
layers: [
state?.layers[0]
@ -217,16 +233,16 @@ export function suggestions({
layerId: table.layerId,
groups: groups.map((col) => col.columnId),
metric: metricColumnId,
categoryDisplay: 'default',
categoryDisplay: CategoryDisplay.DEFAULT,
layerType: layerTypes.DATA,
}
: {
layerId: table.layerId,
groups: groups.map((col) => col.columnId),
metric: metricColumnId,
numberDisplay: 'percent',
categoryDisplay: 'default',
legendDisplay: 'default',
numberDisplay: NumberDisplay.PERCENT,
categoryDisplay: CategoryDisplay.DEFAULT,
legendDisplay: LegendDisplay.DEFAULT,
nestedLegend: false,
layerType: layerTypes.DATA,
},
@ -239,15 +255,15 @@ export function suggestions({
if (
groups.length <= PartitionChartsMeta.waffle.maxBuckets &&
(!subVisualizationId || subVisualizationId === 'waffle')
(!subVisualizationId || subVisualizationId === PieChartTypes.WAFFLE)
) {
results.push({
title: i18n.translate('xpack.lens.pie.waffleSuggestionLabel', {
defaultMessage: 'As Waffle',
}),
score: state?.shape === 'waffle' ? 0.7 : 0.5,
score: state?.shape === PieChartTypes.WAFFLE ? 0.7 : 0.5,
state: {
shape: 'waffle',
shape: PieChartTypes.WAFFLE,
palette: mainPalette || state?.palette,
layers: [
state?.layers[0]
@ -256,16 +272,16 @@ export function suggestions({
layerId: table.layerId,
groups: groups.map((col) => col.columnId),
metric: metricColumnId,
categoryDisplay: 'default',
categoryDisplay: CategoryDisplay.DEFAULT,
layerType: layerTypes.DATA,
}
: {
layerId: table.layerId,
groups: groups.map((col) => col.columnId),
metric: metricColumnId,
numberDisplay: 'percent',
categoryDisplay: 'default',
legendDisplay: 'default',
numberDisplay: NumberDisplay.PERCENT,
categoryDisplay: CategoryDisplay.DEFAULT,
legendDisplay: LegendDisplay.DEFAULT,
nestedLegend: false,
layerType: layerTypes.DATA,
},

View file

@ -6,13 +6,62 @@
*/
import type { Ast } from '@kbn/interpreter';
import type { PaletteRegistry } from 'src/plugins/charts/public';
import { Position } from '@elastic/charts';
import type { PaletteOutput, PaletteRegistry } from '../../../../../src/plugins/charts/public';
import {
buildExpression,
buildExpressionFunction,
} from '../../../../../src/plugins/expressions/public';
import type { Operation, DatasourcePublicAPI } from '../types';
import { DEFAULT_PERCENT_DECIMALS, EMPTY_SIZE_RATIOS } from './constants';
import { DEFAULT_PERCENT_DECIMALS } from './constants';
import { shouldShowValuesInLegend } from './render_helpers';
import type { PieLayerState, PieVisualizationState } from '../../common/expressions';
import {
CategoryDisplay,
NumberDisplay,
PieChartTypes,
PieLayerState,
PieVisualizationState,
EmptySizeRatios,
LegendDisplay,
} from '../../common';
import { getDefaultVisualValuesForLayer } from '../shared_components/datasource_default_values';
interface Attributes {
isPreview: boolean;
title?: string;
description?: string;
}
interface OperationColumnId {
columnId: string;
operation: Operation;
}
type GenerateExpressionAstFunction = (
state: PieVisualizationState,
attributes: Attributes,
operations: OperationColumnId[],
layer: PieLayerState,
datasourceLayers: Record<string, DatasourcePublicAPI>,
paletteService: PaletteRegistry
) => Ast | null;
type GenerateExpressionAstArguments = (
state: PieVisualizationState,
attributes: Attributes,
operations: OperationColumnId[],
layer: PieLayerState,
datasourceLayers: Record<string, DatasourcePublicAPI>,
paletteService: PaletteRegistry
) => Ast['chain'][number]['arguments'];
type GenerateLabelsAstArguments = (
state: PieVisualizationState,
attributes: Attributes,
layer: PieLayerState
) => [Ast];
export const getSortedGroups = (datasource: DatasourcePublicAPI, layer: PieLayerState) => {
const originalOrder = datasource
.getTableSpec()
@ -22,6 +71,199 @@ export const getSortedGroups = (datasource: DatasourcePublicAPI, layer: PieLayer
return Array.from(new Set(originalOrder.concat(layer.groups)));
};
const prepareDimension = (accessor: string) => {
const visdimension = buildExpressionFunction('visdimension', { accessor });
return buildExpression([visdimension]).toAst();
};
const generateCommonLabelsAstArgs: GenerateLabelsAstArguments = (state, attributes, layer) => {
const show = [!attributes.isPreview && layer.categoryDisplay !== CategoryDisplay.HIDE];
const position = layer.categoryDisplay !== CategoryDisplay.HIDE ? [layer.categoryDisplay] : [];
const values = [layer.numberDisplay !== NumberDisplay.HIDDEN];
const valuesFormat = layer.numberDisplay !== NumberDisplay.HIDDEN ? [layer.numberDisplay] : [];
const percentDecimals = [layer.percentDecimals ?? DEFAULT_PERCENT_DECIMALS];
return [
{
type: 'expression',
chain: [
{
type: 'function',
function: 'partitionLabels',
arguments: { show, position, values, valuesFormat, percentDecimals },
},
],
},
];
};
const generateWaffleLabelsAstArguments: GenerateLabelsAstArguments = (...args) => {
const [labelsExpr] = generateCommonLabelsAstArgs(...args);
const [labels] = labelsExpr.chain;
return [
{
...labelsExpr,
chain: [{ ...labels, percentDecimals: DEFAULT_PERCENT_DECIMALS }],
},
];
};
const generatePaletteAstArguments = (
paletteService: PaletteRegistry,
palette?: PaletteOutput
): [Ast] =>
palette
? [
{
type: 'expression',
chain: [
{
type: 'function',
function: 'theme',
arguments: {
variable: ['palette'],
default: [paletteService.get(palette.name).toExpression(palette.params)],
},
},
],
},
]
: [paletteService.get('default').toExpression()];
const generateCommonArguments: GenerateExpressionAstArguments = (
state,
attributes,
operations,
layer,
datasourceLayers,
paletteService
) => ({
labels: generateCommonLabelsAstArgs(state, attributes, layer),
buckets: operations.map((o) => o.columnId).map(prepareDimension),
metric: layer.metric ? [prepareDimension(layer.metric)] : [],
legendDisplay: [attributes.isPreview ? LegendDisplay.HIDE : layer.legendDisplay],
legendPosition: [layer.legendPosition || Position.Right],
maxLegendLines: [layer.legendMaxLines ?? 1],
nestedLegend: [!!layer.nestedLegend],
truncateLegend: [
layer.truncateLegend ?? getDefaultVisualValuesForLayer(state, datasourceLayers).truncateText,
],
palette: generatePaletteAstArguments(paletteService, state.palette),
});
const generatePieVisAst: GenerateExpressionAstFunction = (...rest) => ({
type: 'expression',
chain: [
{
type: 'function',
function: 'pieVis',
arguments: {
...generateCommonArguments(...rest),
respectSourceOrder: [false],
startFromSecondLargestSlice: [true],
},
},
],
});
const generateDonutVisAst: GenerateExpressionAstFunction = (...rest) => {
const [, , , layer] = rest;
return {
type: 'expression',
chain: [
{
type: 'function',
function: 'pieVis',
arguments: {
...generateCommonArguments(...rest),
respectSourceOrder: [false],
isDonut: [true],
startFromSecondLargestSlice: [true],
emptySizeRatio: [layer.emptySizeRatio ?? EmptySizeRatios.SMALL],
},
},
],
};
};
const generateTreemapVisAst: GenerateExpressionAstFunction = (...rest) => {
const [, , , layer] = rest;
return {
type: 'expression',
chain: [
{
type: 'function',
function: 'treemapVis',
arguments: {
...generateCommonArguments(...rest),
nestedLegend: [!!layer.nestedLegend],
},
},
],
};
};
const generateMosaicVisAst: GenerateExpressionAstFunction = (...rest) => ({
type: 'expression',
chain: [
{
type: 'function',
function: 'mosaicVis',
arguments: generateCommonArguments(...rest),
},
],
});
const generateWaffleVisAst: GenerateExpressionAstFunction = (...rest) => {
const { buckets, nestedLegend, ...args } = generateCommonArguments(...rest);
const [state, attributes, , layer] = rest;
return {
type: 'expression',
chain: [
{
type: 'function',
function: 'waffleVis',
arguments: {
...args,
bucket: buckets,
labels: generateWaffleLabelsAstArguments(state, attributes, layer),
showValuesInLegend: [shouldShowValuesInLegend(layer, state.shape)],
},
},
],
};
};
const generateExprAst: GenerateExpressionAstFunction = (state, ...restArgs) =>
({
[PieChartTypes.PIE]: () => generatePieVisAst(state, ...restArgs),
[PieChartTypes.DONUT]: () => generateDonutVisAst(state, ...restArgs),
[PieChartTypes.TREEMAP]: () => generateTreemapVisAst(state, ...restArgs),
[PieChartTypes.MOSAIC]: () => generateMosaicVisAst(state, ...restArgs),
[PieChartTypes.WAFFLE]: () => generateWaffleVisAst(state, ...restArgs),
}[state.shape]());
function expressionHelper(
state: PieVisualizationState,
datasourceLayers: Record<string, DatasourcePublicAPI>,
paletteService: PaletteRegistry,
attributes: Attributes = { isPreview: false }
): Ast | null {
const layer = state.layers[0];
const datasource = datasourceLayers[layer.layerId];
const groups = getSortedGroups(datasource, layer);
const operations = groups
.map((columnId) => ({ columnId, operation: datasource.getOperationForColumnId(columnId) }))
.filter((o): o is { columnId: string; operation: Operation } => !!o.operation);
if (!layer.metric || !operations.length) {
return null;
}
return generateExprAst(state, attributes, operations, layer, datasourceLayers, paletteService);
}
export function toExpression(
state: PieVisualizationState,
datasourceLayers: Record<string, DatasourcePublicAPI>,
@ -34,82 +276,6 @@ export function toExpression(
});
}
function expressionHelper(
state: PieVisualizationState,
datasourceLayers: Record<string, DatasourcePublicAPI>,
paletteService: PaletteRegistry,
attributes: { isPreview: boolean; title?: string; description?: string } = { isPreview: false }
): Ast | null {
const layer = state.layers[0];
const datasource = datasourceLayers[layer.layerId];
const groups = getSortedGroups(datasource, layer);
const operations = groups
.map((columnId) => ({ columnId, operation: datasource.getOperationForColumnId(columnId) }))
.filter((o): o is { columnId: string; operation: Operation } => !!o.operation);
if (!layer.metric || !operations.length) {
return null;
}
return {
type: 'expression',
chain: [
{
type: 'function',
function: 'lens_pie',
arguments: {
title: [attributes.title || ''],
description: [attributes.description || ''],
shape: [state.shape],
hideLabels: [attributes.isPreview],
groups: operations.map((o) => o.columnId),
metric: [layer.metric],
numberDisplay: [layer.numberDisplay],
categoryDisplay: [layer.categoryDisplay],
legendDisplay: [layer.legendDisplay],
legendPosition: [layer.legendPosition || 'right'],
emptySizeRatio: [layer.emptySizeRatio ?? EMPTY_SIZE_RATIOS.SMALL],
showValuesInLegend: [shouldShowValuesInLegend(layer, state.shape)],
percentDecimals: [
state.shape === 'waffle'
? DEFAULT_PERCENT_DECIMALS
: layer.percentDecimals ?? DEFAULT_PERCENT_DECIMALS,
],
legendMaxLines: [layer.legendMaxLines ?? 1],
truncateLegend: [
layer.truncateLegend ??
getDefaultVisualValuesForLayer(state, datasourceLayers).truncateText,
],
nestedLegend: [!!layer.nestedLegend],
...(state.palette
? {
palette: [
{
type: 'expression',
chain: [
{
type: 'function',
function: 'theme',
arguments: {
variable: ['palette'],
default: [
paletteService
.get(state.palette.name)
.toExpression(state.palette.params),
],
},
},
],
},
],
}
: {}),
},
},
],
};
}
export function toPreviewExpression(
state: PieVisualizationState,
datasourceLayers: Record<string, DatasourcePublicAPI>,

View file

@ -20,7 +20,7 @@ import type { Position } from '@elastic/charts';
import type { PaletteRegistry } from 'src/plugins/charts/public';
import { DEFAULT_PERCENT_DECIMALS } from './constants';
import { PartitionChartsMeta } from './partition_charts_meta';
import type { PieVisualizationState, SharedPieLayerState } from '../../common/expressions';
import { LegendDisplay, PieVisualizationState, SharedPieLayerState } from '../../common';
import { VisualizationDimensionEditorProps, VisualizationToolbarProps } from '../types';
import { ToolbarPopover, LegendSettingsPopover, useDebouncedValue } from '../shared_components';
import { PalettePicker } from '../shared_components';
@ -34,21 +34,21 @@ const legendOptions: Array<{
}> = [
{
id: 'pieLegendDisplay-default',
value: 'default',
value: LegendDisplay.DEFAULT,
label: i18n.translate('xpack.lens.pieChart.legendVisibility.auto', {
defaultMessage: 'Auto',
}),
},
{
id: 'pieLegendDisplay-show',
value: 'show',
value: LegendDisplay.SHOW,
label: i18n.translate('xpack.lens.pieChart.legendVisibility.show', {
defaultMessage: 'Show',
}),
},
{
id: 'pieLegendDisplay-hide',
value: 'hide',
value: LegendDisplay.HIDE,
label: i18n.translate('xpack.lens.pieChart.legendVisibility.hide', {
defaultMessage: 'Hide',
}),

View file

@ -1,7 +0,0 @@
.lnsPieExpression__container {
height: 100%;
width: 100%;
// the FocusTrap is adding extra divs which are making the visualization redraw twice
// with a visible glitch. This make the chart library resilient to this extra reflow
overflow-x: hidden;
}

View file

@ -6,7 +6,13 @@
*/
import { getPieVisualization } from './visualization';
import type { PieVisualizationState } from '../../common/expressions';
import {
PieVisualizationState,
PieChartTypes,
CategoryDisplay,
NumberDisplay,
LegendDisplay,
} from '../../common';
import { layerTypes } from '../../common';
import { chartPluginMock } from '../../../../../src/plugins/charts/public/mocks';
import { createMockDatasource, createMockFramePublicAPI } from '../mocks';
@ -24,16 +30,16 @@ const pieVisualization = getPieVisualization({
function getExampleState(): PieVisualizationState {
return {
shape: 'pie',
shape: PieChartTypes.PIE,
layers: [
{
layerId: LAYER_ID,
layerType: layerTypes.DATA,
groups: [],
metric: undefined,
numberDisplay: 'percent',
categoryDisplay: 'default',
legendDisplay: 'default',
numberDisplay: NumberDisplay.PERCENT,
categoryDisplay: CategoryDisplay.DEFAULT,
legendDisplay: LegendDisplay.DEFAULT,
nestedLegend: false,
},
],
@ -81,14 +87,14 @@ describe('pie_visualization', () => {
groups: ['a'],
layerId: LAYER_ID,
layerType: layerTypes.DATA,
numberDisplay: 'percent',
categoryDisplay: 'default',
legendDisplay: 'default',
numberDisplay: NumberDisplay.PERCENT,
categoryDisplay: CategoryDisplay.DEFAULT,
legendDisplay: LegendDisplay.DEFAULT,
nestedLegend: false,
metric: undefined,
},
],
shape: 'donut',
shape: PieChartTypes.DONUT,
};
const setDimensionResult = pieVisualization.setDimension({
prevState,
@ -100,7 +106,7 @@ describe('pie_visualization', () => {
expect(setDimensionResult).toEqual(
expect.objectContaining({
shape: 'donut',
shape: PieChartTypes.DONUT,
})
);
});

View file

@ -20,21 +20,21 @@ import type {
VisualizationDimensionGroupConfig,
} from '../types';
import { getSortedGroups, toExpression, toPreviewExpression } from './to_expression';
import type { PieLayerState, PieVisualizationState } from '../../common/expressions';
import { layerTypes } from '../../common';
import { CategoryDisplay, layerTypes, LegendDisplay, NumberDisplay } from '../../common';
import { suggestions } from './suggestions';
import { PartitionChartsMeta } from './partition_charts_meta';
import { DimensionEditor, PieToolbar } from './toolbar';
import { checkTableForContainsSmallValues } from './render_helpers';
import { PieChartTypes, PieLayerState, PieVisualizationState } from '../../common';
function newLayerState(layerId: string): PieLayerState {
return {
layerId,
groups: [],
metric: undefined,
numberDisplay: 'percent',
categoryDisplay: 'default',
legendDisplay: 'default',
numberDisplay: NumberDisplay.PERCENT,
categoryDisplay: CategoryDisplay.DEFAULT,
legendDisplay: LegendDisplay.DEFAULT,
nestedLegend: false,
layerType: layerTypes.DATA,
};
@ -108,7 +108,7 @@ export const getPieVisualization = ({
initialize(addNewLayer, state, mainPalette) {
return (
state || {
shape: 'donut',
shape: PieChartTypes.DONUT,
layers: [newLayerState(addNewLayer())],
palette: mainPalette,
}

View file

@ -7,7 +7,6 @@
import type { CoreSetup } from 'kibana/server';
import {
pie,
xyChart,
counterRate,
metricChart,
@ -36,7 +35,6 @@ export const setupExpressions = (
[lensMultitable].forEach((expressionType) => expressions.registerType(expressionType));
[
pie,
xyChart,
counterRate,
metricChart,

View file

@ -24,7 +24,7 @@ import { i18n } from '@kbn/i18n';
import { FormattedMessage, FormattedDate, FormattedTime, FormattedRelative } from '@kbn/i18n-react';
import moment from 'moment-timezone';
import {
import type {
TypedLensByValueInput,
PersistedIndexPatternLayer,
PieVisualizationState,

View file

@ -631,17 +631,14 @@
"xpack.lens.pie.addLayer": "ビジュアライゼーションレイヤーを追加",
"xpack.lens.pie.arrayValues": "{label}には配列値が含まれます。可視化が想定通りに表示されない場合があります。",
"xpack.lens.pie.donutLabel": "ドーナッツ",
"xpack.lens.pie.expressionHelpLabel": "円表示",
"xpack.lens.pie.groupLabel": "比率",
"xpack.lens.pie.groupsizeLabel": "サイズ単位",
"xpack.lens.pie.pielabel": "円",
"xpack.lens.pie.pieWithNegativeWarningLabel": "{chartType}グラフは負の値では表示できません。",
"xpack.lens.pie.sliceGroupLabel": "スライス",
"xpack.lens.pie.suggestionLabel": "{chartName}として",
"xpack.lens.pie.treemapGroupLabel": "グループ分けの条件",
"xpack.lens.pie.treemaplabel": "ツリーマップ",
"xpack.lens.pie.treemapSuggestionLabel": "ツリーマップとして",
"xpack.lens.pie.visualizationName": "円",
"xpack.lens.pieChart.categoriesInLegendLabel": "ラベルを非表示",
"xpack.lens.pieChart.fitInsideOnlyLabel": "内部のみ",
"xpack.lens.pieChart.hiddenNumbersLabel": "グラフから非表示",
@ -2983,8 +2980,6 @@
"expressionPartitionVis.legend.filterForValueButtonAriaLabel": "値でフィルター",
"expressionPartitionVis.legend.filterOptionsLegend": "{legendDataLabel}、フィルターオプション",
"expressionPartitionVis.legend.filterOutValueButtonAriaLabel": "値を除外",
"expressionPartitionVis.negativeValuesFound": "円/ドーナツグラフは負の値では表示できません。",
"expressionPartitionVis.noResultsFoundTitle": "結果が見つかりませんでした",
"fieldFormats.advancedSettings.format.bytesFormat.numeralFormatLinkText": "数字フォーマット",
"fieldFormats.advancedSettings.format.bytesFormatText": "「バイト」フォーマットのデフォルト{numeralFormatLink}です",
"fieldFormats.advancedSettings.format.bytesFormatTitle": "バイトフォーマット",

View file

@ -643,17 +643,14 @@
"xpack.lens.pie.addLayer": "添加可视化图层",
"xpack.lens.pie.arrayValues": "{label} 包含数组值。您的可视化可能无法正常渲染。",
"xpack.lens.pie.donutLabel": "圆环图",
"xpack.lens.pie.expressionHelpLabel": "饼图呈现器",
"xpack.lens.pie.groupLabel": "比例",
"xpack.lens.pie.groupsizeLabel": "大小调整依据",
"xpack.lens.pie.pielabel": "饼图",
"xpack.lens.pie.pieWithNegativeWarningLabel": "{chartType} 图表无法使用负值进行呈现。",
"xpack.lens.pie.sliceGroupLabel": "切片依据",
"xpack.lens.pie.suggestionLabel": "为 {chartName}",
"xpack.lens.pie.treemapGroupLabel": "分组依据",
"xpack.lens.pie.treemaplabel": "树状图",
"xpack.lens.pie.treemapSuggestionLabel": "为树状图",
"xpack.lens.pie.visualizationName": "饼图",
"xpack.lens.pieChart.categoriesInLegendLabel": "隐藏标签",
"xpack.lens.pieChart.fitInsideOnlyLabel": "仅内部",
"xpack.lens.pieChart.hiddenNumbersLabel": "在图表中隐藏",
@ -2767,8 +2764,6 @@
"expressionPartitionVis.legend.filterForValueButtonAriaLabel": "筛留值",
"expressionPartitionVis.legend.filterOptionsLegend": "{legendDataLabel}, 筛选选项",
"expressionPartitionVis.legend.filterOutValueButtonAriaLabel": "筛除值",
"expressionPartitionVis.negativeValuesFound": "饼图/圆环图无法使用负值进行呈现。",
"expressionPartitionVis.noResultsFoundTitle": "找不到结果",
"fieldFormats.advancedSettings.format.bytesFormat.numeralFormatLinkText": "数值格式",
"fieldFormats.advancedSettings.format.bytesFormatText": "“字节”格式的默认{numeralFormatLink}",
"fieldFormats.advancedSettings.format.bytesFormatTitle": "字节格式",

View file

@ -11,7 +11,6 @@ import { FtrProviderContext } from '../../ftr_provider_context';
export default function ({ getPageObjects, getService }: FtrProviderContext) {
const PageObjects = getPageObjects(['common', 'dashboard', 'visualize', 'lens', 'timePicker']);
const find = getService('find');
const esArchiver = getService('esArchiver');
const testSubjects = getService('testSubjects');
const dashboardPanelActions = getService('dashboardPanelActions');
@ -49,8 +48,8 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
await PageObjects.lens.saveAndReturn();
await PageObjects.dashboard.waitForRenderComplete();
const pieExists = await find.existsByCssSelector('.lnsPieExpression__container');
expect(pieExists).to.be(true);
const partitionVisExists = await testSubjects.exists('partitionVisChart');
expect(partitionVisExists).to.be(true);
});
it('editing and saving a lens by value panel retains number of panels', async () => {

View file

@ -103,8 +103,8 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
await PageObjects.lens.saveAndReturn();
await PageObjects.dashboard.waitForRenderComplete();
const pieExists = await find.existsByCssSelector('.lnsPieExpression__container');
expect(pieExists).to.be(true);
const partitionVisExists = await testSubjects.exists('partitionVisChart');
expect(partitionVisExists).to.be(true);
});
it('disables save to library button without visualize save permissions', async () => {