mirror of
https://github.com/apache/superset.git
synced 2026-04-17 23:25:05 +00:00
feat(Pie Chart): threshold for Other (#33348)
This commit is contained in:
committed by
GitHub
parent
8a8fb49617
commit
fa1693dc5f
@@ -16,14 +16,30 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { buildQueryContext, QueryFormData } from '@superset-ui/core';
|
||||
import {
|
||||
buildQueryContext,
|
||||
getMetricLabel,
|
||||
QueryFormData,
|
||||
} from '@superset-ui/core';
|
||||
import { getContributionLabel } from './utils';
|
||||
|
||||
export default function buildQuery(formData: QueryFormData) {
|
||||
const { metric, sort_by_metric } = formData;
|
||||
const metricLabel = getMetricLabel(metric);
|
||||
|
||||
return buildQueryContext(formData, baseQueryObject => [
|
||||
{
|
||||
...baseQueryObject,
|
||||
...(sort_by_metric && { orderby: [[metric, false]] }),
|
||||
post_processing: [
|
||||
{
|
||||
operation: 'contribution',
|
||||
options: {
|
||||
columns: [metricLabel],
|
||||
rename_columns: [getContributionLabel(metricLabel)],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
export const CONTRIBUTION_SUFFIX = '__contribution' as const;
|
||||
@@ -84,6 +84,23 @@ const config: ControlPanelConfig = {
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
name: 'threshold_for_other',
|
||||
config: {
|
||||
type: 'NumberControl',
|
||||
label: t('Threshold for Other'),
|
||||
min: 0,
|
||||
step: 0.5,
|
||||
max: 100,
|
||||
default: 0,
|
||||
renderTrigger: true,
|
||||
description: t(
|
||||
'Values less than this percentage will be grouped into the Other category.',
|
||||
),
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
name: 'roseType',
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
ValueFormatter,
|
||||
getValueFormatter,
|
||||
tooltipHtml,
|
||||
DataRecord,
|
||||
} from '@superset-ui/core';
|
||||
import type { CallbackDataParams } from 'echarts/types/src/util/types';
|
||||
import type { EChartsCoreOption } from 'echarts/core';
|
||||
@@ -36,6 +37,7 @@ import {
|
||||
EchartsPieChartProps,
|
||||
EchartsPieFormData,
|
||||
EchartsPieLabelType,
|
||||
PieChartDataItem,
|
||||
PieChartTransformedProps,
|
||||
} from './types';
|
||||
import { DEFAULT_LEGEND_FORM_DATA, OpacityEnum } from '../constants';
|
||||
@@ -50,6 +52,7 @@ import { defaultGrid } from '../defaults';
|
||||
import { convertInteger } from '../utils/convertInteger';
|
||||
import { getDefaultTooltip } from '../utils/tooltip';
|
||||
import { Refs } from '../types';
|
||||
import { getContributionLabel } from './utils';
|
||||
|
||||
const percentFormatter = getNumberFormatter(NumberFormats.PERCENT_2_POINT);
|
||||
|
||||
@@ -133,7 +136,7 @@ export default function transformProps(
|
||||
datasource,
|
||||
} = chartProps;
|
||||
const { columnFormats = {}, currencyFormats = {} } = datasource;
|
||||
const { data = [] } = queriesData[0];
|
||||
const { data: rawData = [] } = queriesData[0];
|
||||
const coltypeMapping = getColtypesMapping(queriesData[0]);
|
||||
|
||||
const {
|
||||
@@ -159,6 +162,7 @@ export default function transformProps(
|
||||
sliceId,
|
||||
showTotal,
|
||||
roseType,
|
||||
thresholdForOther,
|
||||
}: EchartsPieFormData = {
|
||||
...DEFAULT_LEGEND_FORM_DATA,
|
||||
...DEFAULT_PIE_FORM_DATA,
|
||||
@@ -166,17 +170,68 @@ export default function transformProps(
|
||||
};
|
||||
const refs: Refs = {};
|
||||
const metricLabel = getMetricLabel(metric);
|
||||
const contributionLabel = getContributionLabel(metricLabel);
|
||||
const groupbyLabels = groupby.map(getColumnLabel);
|
||||
const minShowLabelAngle = (showLabelsThreshold || 0) * 3.6;
|
||||
|
||||
const keys = data.map(datum =>
|
||||
extractGroupbyLabel({
|
||||
datum,
|
||||
groupby: groupbyLabels,
|
||||
coltypeMapping,
|
||||
timeFormatter: getTimeFormatter(dateFormat),
|
||||
}),
|
||||
const numberFormatter = getValueFormatter(
|
||||
metric,
|
||||
currencyFormats,
|
||||
columnFormats,
|
||||
numberFormat,
|
||||
currencyFormat,
|
||||
);
|
||||
|
||||
let data = rawData;
|
||||
const otherRows: DataRecord[] = [];
|
||||
const otherTooltipData: string[][] = [];
|
||||
let otherDatum: PieChartDataItem | null = null;
|
||||
let otherSum = 0;
|
||||
if (thresholdForOther) {
|
||||
let contributionSum = 0;
|
||||
data = data.filter(datum => {
|
||||
const contribution = datum[contributionLabel] as number;
|
||||
if (!contribution || contribution * 100 >= thresholdForOther) {
|
||||
return true;
|
||||
}
|
||||
otherSum += datum[metricLabel] as number;
|
||||
contributionSum += contribution;
|
||||
otherRows.push(datum);
|
||||
otherTooltipData.push([
|
||||
extractGroupbyLabel({
|
||||
datum,
|
||||
groupby: groupbyLabels,
|
||||
coltypeMapping,
|
||||
timeFormatter: getTimeFormatter(dateFormat),
|
||||
}),
|
||||
numberFormatter(datum[metricLabel] as number),
|
||||
percentFormatter(contribution),
|
||||
]);
|
||||
return false;
|
||||
});
|
||||
const otherName = t('Other');
|
||||
otherTooltipData.push([
|
||||
t('Total'),
|
||||
numberFormatter(otherSum),
|
||||
percentFormatter(contributionSum),
|
||||
]);
|
||||
if (otherSum) {
|
||||
otherDatum = {
|
||||
name: otherName,
|
||||
value: otherSum,
|
||||
itemStyle: {
|
||||
color: theme.colors.grayscale.dark1,
|
||||
opacity:
|
||||
filterState.selectedValues &&
|
||||
!filterState.selectedValues.includes(otherName)
|
||||
? OpacityEnum.SemiTransparent
|
||||
: OpacityEnum.NonTransparent,
|
||||
},
|
||||
isOther: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const labelMap = data.reduce((acc: Record<string, string[]>, datum) => {
|
||||
const label = extractGroupbyLabel({
|
||||
datum,
|
||||
@@ -192,13 +247,6 @@ export default function transformProps(
|
||||
|
||||
const { setDataMask = () => {}, onContextMenu } = hooks;
|
||||
const colorFn = CategoricalColorNamespace.getScale(colorScheme as string);
|
||||
const numberFormatter = getValueFormatter(
|
||||
metric,
|
||||
currencyFormats,
|
||||
columnFormats,
|
||||
numberFormat,
|
||||
currencyFormat,
|
||||
);
|
||||
|
||||
let totalValue = 0;
|
||||
|
||||
@@ -229,6 +277,10 @@ export default function transformProps(
|
||||
},
|
||||
};
|
||||
});
|
||||
if (otherDatum) {
|
||||
transformedData.push(otherDatum);
|
||||
totalValue += otherSum;
|
||||
}
|
||||
|
||||
const selectedValues = (filterState.selectedValues || []).reduce(
|
||||
(acc: Record<string, number>, selectedValue: string) => {
|
||||
@@ -372,6 +424,9 @@ export default function transformProps(
|
||||
numberFormatter,
|
||||
sanitizeName: true,
|
||||
});
|
||||
if (params?.data?.isOther) {
|
||||
return tooltipHtml(otherTooltipData, name);
|
||||
}
|
||||
return tooltipHtml(
|
||||
[[metricLabel, formattedValue, formattedPercent]],
|
||||
name,
|
||||
@@ -380,7 +435,7 @@ export default function transformProps(
|
||||
},
|
||||
legend: {
|
||||
...getLegendProps(legendType, legendOrientation, showLegend, theme),
|
||||
data: keys,
|
||||
data: transformedData.map(datum => datum.name),
|
||||
},
|
||||
graphic: showTotal
|
||||
? {
|
||||
|
||||
@@ -47,6 +47,7 @@ export type EchartsPieFormData = QueryFormData &
|
||||
dateFormat: string;
|
||||
showLabelsThreshold: number;
|
||||
roseType: 'radius' | 'area' | null;
|
||||
thresholdForOther: number;
|
||||
};
|
||||
|
||||
export enum EchartsPieLabelType {
|
||||
@@ -82,9 +83,20 @@ export const DEFAULT_FORM_DATA: EchartsPieFormData = {
|
||||
showLabelsThreshold: 5,
|
||||
dateFormat: 'smart_date',
|
||||
roseType: null,
|
||||
thresholdForOther: 0,
|
||||
};
|
||||
|
||||
export type PieChartTransformedProps =
|
||||
BaseTransformedProps<EchartsPieFormData> &
|
||||
ContextMenuTransformedProps &
|
||||
CrossFilterTransformedProps;
|
||||
|
||||
export interface PieChartDataItem {
|
||||
name: string;
|
||||
value: number;
|
||||
itemStyle: {
|
||||
color: string;
|
||||
opacity: number;
|
||||
};
|
||||
isOther?: boolean;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { CONTRIBUTION_SUFFIX } from './constants';
|
||||
|
||||
export const getContributionLabel = (metricLabel: string) =>
|
||||
`${metricLabel}${CONTRIBUTION_SUFFIX}`;
|
||||
@@ -28,7 +28,7 @@ import type {
|
||||
CallbackDataParams,
|
||||
} from 'echarts/types/src/util/types';
|
||||
import transformProps, { parseParams } from '../../src/Pie/transformProps';
|
||||
import { EchartsPieChartProps } from '../../src/Pie/types';
|
||||
import { EchartsPieChartProps, PieChartDataItem } from '../../src/Pie/types';
|
||||
|
||||
describe('Pie transformProps', () => {
|
||||
const formData: SqlaFormData = {
|
||||
@@ -46,8 +46,13 @@ describe('Pie transformProps', () => {
|
||||
queriesData: [
|
||||
{
|
||||
data: [
|
||||
{ foo: 'Sylvester', bar: 1, sum__num: 10 },
|
||||
{ foo: 'Arnold', bar: 2, sum__num: 2.5 },
|
||||
{
|
||||
foo: 'Sylvester',
|
||||
bar: 1,
|
||||
sum__num: 10,
|
||||
sum__num__contribution: 0.8,
|
||||
},
|
||||
{ foo: 'Arnold', bar: 2, sum__num: 2.5, sum__num__contribution: 0.2 },
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -215,3 +220,77 @@ describe('Pie label string template', () => {
|
||||
).toEqual('Tablet:123456\n55.5');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Other category', () => {
|
||||
const defaultFormData: SqlaFormData = {
|
||||
colorScheme: 'bnbColors',
|
||||
datasource: '3__table',
|
||||
granularity_sqla: 'ds',
|
||||
metric: 'metric',
|
||||
groupby: ['foo', 'bar'],
|
||||
viz_type: 'my_viz',
|
||||
};
|
||||
|
||||
const getChartProps = (formData: Partial<SqlaFormData>) =>
|
||||
new ChartProps({
|
||||
formData: {
|
||||
...defaultFormData,
|
||||
...formData,
|
||||
},
|
||||
width: 800,
|
||||
height: 600,
|
||||
queriesData: [
|
||||
{
|
||||
data: [
|
||||
{
|
||||
foo: 'foo 1',
|
||||
bar: 'bar 1',
|
||||
metric: 1,
|
||||
metric__contribution: 1 / 15, // 6.7%
|
||||
},
|
||||
{
|
||||
foo: 'foo 2',
|
||||
bar: 'bar 2',
|
||||
metric: 2,
|
||||
metric__contribution: 2 / 15, // 13.3%
|
||||
},
|
||||
{
|
||||
foo: 'foo 3',
|
||||
bar: 'bar 3',
|
||||
metric: 3,
|
||||
metric__contribution: 3 / 15, // 20%
|
||||
},
|
||||
{
|
||||
foo: 'foo 4',
|
||||
bar: 'bar 4',
|
||||
metric: 4,
|
||||
metric__contribution: 4 / 15, // 26.7%
|
||||
},
|
||||
{
|
||||
foo: 'foo 5',
|
||||
bar: 'bar 5',
|
||||
metric: 5,
|
||||
metric__contribution: 5 / 15, // 33.3%
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
theme: supersetTheme,
|
||||
});
|
||||
|
||||
it('generates Other category', () => {
|
||||
const chartProps = getChartProps({
|
||||
threshold_for_other: 20,
|
||||
});
|
||||
const transformed = transformProps(chartProps as EchartsPieChartProps);
|
||||
const series = transformed.echartOptions.series as PieSeriesOption[];
|
||||
const data = series[0].data as PieChartDataItem[];
|
||||
expect(data).toHaveLength(4);
|
||||
expect(data[0].value).toBe(3);
|
||||
expect(data[1].value).toBe(4);
|
||||
expect(data[2].value).toBe(5);
|
||||
expect(data[3].value).toBe(1 + 2);
|
||||
expect(data[3].name).toBe('Other');
|
||||
expect(data[3].isOther).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { render, screen, userEvent } from 'spec/helpers/testing-library';
|
||||
import NumberControl from '.';
|
||||
|
||||
const mockedProps = {
|
||||
min: -5,
|
||||
max: 10,
|
||||
step: 1,
|
||||
default: 0,
|
||||
};
|
||||
|
||||
test('render', () => {
|
||||
const { container } = render(<NumberControl {...mockedProps} />);
|
||||
expect(container).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('type number', async () => {
|
||||
const props = {
|
||||
...mockedProps,
|
||||
onChange: jest.fn(),
|
||||
};
|
||||
render(<NumberControl {...props} />);
|
||||
const input = screen.getByRole('spinbutton');
|
||||
await userEvent.type(input, '9');
|
||||
expect(props.onChange).toHaveBeenCalledTimes(1);
|
||||
expect(props.onChange).toHaveBeenLastCalledWith(9);
|
||||
});
|
||||
|
||||
test('type >max', async () => {
|
||||
const props = {
|
||||
...mockedProps,
|
||||
onChange: jest.fn(),
|
||||
};
|
||||
render(<NumberControl {...props} />);
|
||||
const input = screen.getByRole('spinbutton');
|
||||
await userEvent.type(input, '20');
|
||||
expect(props.onChange).toHaveBeenCalledTimes(1);
|
||||
expect(props.onChange).toHaveBeenLastCalledWith(2);
|
||||
});
|
||||
|
||||
test('type NaN', async () => {
|
||||
const props = {
|
||||
...mockedProps,
|
||||
onChange: jest.fn(),
|
||||
};
|
||||
render(<NumberControl {...props} />);
|
||||
const input = screen.getByRole('spinbutton');
|
||||
await userEvent.type(input, 'not a number');
|
||||
expect(props.onChange).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { styled } from '@superset-ui/core';
|
||||
import { InputNumber } from 'src/components/Input';
|
||||
import ControlHeader, { ControlHeaderProps } from '../../ControlHeader';
|
||||
|
||||
type NumberValueType = number | undefined;
|
||||
|
||||
export interface NumberControlProps extends ControlHeaderProps {
|
||||
onChange?: (value: NumberValueType) => void;
|
||||
value?: NumberValueType;
|
||||
label?: string;
|
||||
description?: string;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const FullWidthDiv = styled.div`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const FullWidthInputNumber = styled(InputNumber)`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
function parseValue(value: string | number | null | undefined) {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return undefined;
|
||||
}
|
||||
const num = Number(value);
|
||||
return Number.isNaN(num) ? undefined : num;
|
||||
}
|
||||
|
||||
export default function NumberControl({
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
placeholder,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
...rest
|
||||
}: NumberControlProps) {
|
||||
return (
|
||||
<FullWidthDiv>
|
||||
<ControlHeader {...rest} />
|
||||
<FullWidthInputNumber
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={value => onChange?.(parseValue(value))}
|
||||
disabled={disabled}
|
||||
aria-label={rest.label}
|
||||
/>
|
||||
</FullWidthDiv>
|
||||
);
|
||||
}
|
||||
@@ -53,6 +53,7 @@ import { ComparisonRangeLabel } from './ComparisonRangeLabel';
|
||||
import LayerConfigsControl from './LayerConfigsControl/LayerConfigsControl';
|
||||
import MapViewControl from './MapViewControl/MapViewControl';
|
||||
import ZoomConfigControl from './ZoomConfigControl/ZoomConfigControl';
|
||||
import NumberControl from './NumberControl';
|
||||
|
||||
const controlMap = {
|
||||
AnnotationLayerControl,
|
||||
@@ -90,6 +91,7 @@ const controlMap = {
|
||||
ComparisonRangeLabel,
|
||||
TimeOffsetControl,
|
||||
ZoomConfigControl,
|
||||
NumberControl,
|
||||
...sharedControlComponents,
|
||||
};
|
||||
export default controlMap;
|
||||
|
||||
Reference in New Issue
Block a user