feat: Improves the Waterfall chart (#25557)

This commit is contained in:
Michael S. Molina 2023-11-03 13:24:15 -03:00 committed by GitHub
parent 8061d5cce9
commit d619078d25
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 487 additions and 392 deletions

View File

@ -32,6 +32,7 @@ export * from './SequentialScheme';
export { default as ColorSchemeRegistry } from './ColorSchemeRegistry';
export * from './colorSchemes';
export * from './utils';
export * from './types';
export {
default as getSharedLabelColor,
SharedLabelColor,

View File

@ -24,3 +24,10 @@ export interface ColorsInitLookup {
export interface ColorsLookup {
[key: string]: string;
}
export interface RgbaColor {
r: number;
g: number;
b: number;
a: number;
}

View File

@ -86,3 +86,36 @@ export function addAlpha(color: string, opacity: number): string {
return `${color}${alpha}`;
}
export function hexToRgb(h: string) {
let r = '0';
let g = '0';
let b = '0';
// 3 digits
if (h.length === 4) {
r = `0x${h[1]}${h[1]}`;
g = `0x${h[2]}${h[2]}`;
b = `0x${h[3]}${h[3]}`;
// 6 digits
} else if (h.length === 7) {
r = `0x${h[1]}${h[2]}`;
g = `0x${h[3]}${h[4]}`;
b = `0x${h[5]}${h[6]}`;
}
return `rgb(${+r}, ${+g}, ${+b})`;
}
export function rgbToHex(red: number, green: number, blue: number) {
let r = red.toString(16);
let g = green.toString(16);
let b = blue.toString(16);
if (r.length === 1) r = `0${r}`;
if (g.length === 1) g = `0${g}`;
if (b.length === 1) b = `0${b}`;
return `#${r}${g}${b}`;
}

View File

@ -17,7 +17,12 @@
* under the License.
*/
import { getContrastingColor, addAlpha } from '@superset-ui/core';
import {
getContrastingColor,
addAlpha,
hexToRgb,
rgbToHex,
} from '@superset-ui/core';
describe('color utils', () => {
describe('getContrastingColor', () => {
@ -82,4 +87,23 @@ describe('color utils', () => {
}).toThrow();
});
});
describe('hexToRgb', () => {
it('convert 3 digits hex', () => {
expect(hexToRgb('#fff')).toBe('rgb(255, 255, 255)');
});
it('convert 6 digits hex', () => {
expect(hexToRgb('#ffffff')).toBe('rgb(255, 255, 255)');
});
it('convert invalid hex', () => {
expect(hexToRgb('#ffffffffffffff')).toBe('rgb(0, 0, 0)');
});
});
describe('rgbToHex', () => {
it('convert rgb to hex - white', () => {
expect(rgbToHex(255, 255, 255)).toBe('#ffffff');
});
it('convert rgb to hex - black', () => {
expect(rgbToHex(0, 0, 0)).toBe('#000000');
});
});
});

View File

@ -78,8 +78,6 @@ import { convertInteger } from '../utils/convertInteger';
import { defaultGrid, defaultYAxis } from '../defaults';
import {
getPadding,
getTooltipTimeFormatter,
getXAxisFormatter,
transformEventAnnotation,
transformFormulaAnnotation,
transformIntervalAnnotation,
@ -88,7 +86,11 @@ import {
} from '../Timeseries/transformers';
import { TIMESERIES_CONSTANTS, TIMEGRAIN_TO_TIMESTAMP } from '../constants';
import { getDefaultTooltip } from '../utils/tooltip';
import { getYAxisFormatter } from '../utils/getYAxisFormatter';
import {
getTooltipTimeFormatter,
getXAxisFormatter,
getYAxisFormatter,
} from '../utils/formatters';
const getFormatter = (
customFormatters: Record<string, ValueFormatter>,

View File

@ -80,8 +80,6 @@ import { defaultGrid, defaultYAxis } from '../defaults';
import {
getBaselineSeriesForStream,
getPadding,
getTooltipTimeFormatter,
getXAxisFormatter,
transformEventAnnotation,
transformFormulaAnnotation,
transformIntervalAnnotation,
@ -94,7 +92,11 @@ import {
TIMEGRAIN_TO_TIMESTAMP,
} from '../constants';
import { getDefaultTooltip } from '../utils/tooltip';
import { getYAxisFormatter } from '../utils/getYAxisFormatter';
import {
getTooltipTimeFormatter,
getXAxisFormatter,
getYAxisFormatter,
} from '../utils/formatters';
export default function transformProps(
chartProps: EchartsTimeseriesChartProps,

View File

@ -24,14 +24,10 @@ import {
EventAnnotationLayer,
FilterState,
FormulaAnnotationLayer,
getTimeFormatter,
IntervalAnnotationLayer,
isTimeseriesAnnotationResult,
LegendState,
smartDateDetailedFormatter,
smartDateFormatter,
SupersetTheme,
TimeFormatter,
TimeseriesAnnotationLayer,
TimeseriesDataRecord,
ValueFormatter,
@ -582,27 +578,3 @@ export function getPadding(
: TIMESERIES_CONSTANTS.gridOffsetRight,
});
}
export function getTooltipTimeFormatter(
format?: string,
): TimeFormatter | StringConstructor {
if (format === smartDateFormatter.id) {
return smartDateDetailedFormatter;
}
if (format) {
return getTimeFormatter(format);
}
return String;
}
export function getXAxisFormatter(
format?: string,
): TimeFormatter | StringConstructor | undefined {
if (format === smartDateFormatter.id || !format) {
return undefined;
}
if (format) {
return getTimeFormatter(format);
}
return String;
}

View File

@ -16,59 +16,26 @@
* specific language governing permissions and limitations
* under the License.
*/
import React, { useCallback } from 'react';
import React from 'react';
import Echart from '../components/Echart';
import { allEventHandlers } from '../utils/eventHandlers';
import { WaterfallChartTransformedProps } from './types';
import { EventHandlers } from '../types';
export default function EchartsWaterfall(
props: WaterfallChartTransformedProps,
) {
const {
height,
width,
echartOptions,
setDataMask,
labelMap,
groupby,
refs,
selectedValues,
} = props;
const handleChange = useCallback(
(values: string[]) => {
const groupbyValues = values.map(value => labelMap[value]);
const { height, width, echartOptions, refs, onLegendStateChanged } = props;
setDataMask({
extraFormData: {
filters:
values.length === 0
? []
: groupby.map((col, idx) => {
const val = groupbyValues.map(v => v[idx]);
if (val === null || val === undefined)
return {
col,
op: 'IS NULL',
};
return {
col,
op: 'IN',
val: val as (string | number | boolean)[],
};
}),
},
filterState: {
value: groupbyValues.length ? groupbyValues : null,
selectedValues: values.length ? values : null,
},
});
const eventHandlers: EventHandlers = {
legendselectchanged: payload => {
onLegendStateChanged?.(payload.selected);
},
legendselectall: payload => {
onLegendStateChanged?.(payload.selected);
},
legendinverseselect: payload => {
onLegendStateChanged?.(payload.selected);
},
[setDataMask, groupby, labelMap],
);
const eventHandlers = {
...allEventHandlers(props),
handleChange,
};
return (
@ -78,7 +45,6 @@ export default function EchartsWaterfall(
width={width}
echartOptions={echartOptions}
eventHandlers={eventHandlers}
selectedValues={selectedValues}
/>
);
}

View File

@ -16,14 +16,24 @@
* specific language governing permissions and limitations
* under the License.
*/
import { buildQueryContext, QueryFormData } from '@superset-ui/core';
import {
buildQueryContext,
ensureIsArray,
getXAxisColumn,
isXAxisSet,
QueryFormData,
} from '@superset-ui/core';
export default function buildQuery(formData: QueryFormData) {
const { series, columns } = formData;
const columns = [
...(isXAxisSet(formData) ? ensureIsArray(getXAxisColumn(formData)) : []),
...ensureIsArray(formData.groupby),
];
return buildQueryContext(formData, baseQueryObject => [
{
...baseQueryObject,
columns: columns?.length ? [series, columns] : [series],
columns,
orderby: columns?.map(column => [column, true]),
},
]);
}

View File

@ -17,24 +17,26 @@
* under the License.
*/
import React from 'react';
import { ensureIsArray, t } from '@superset-ui/core';
import { t } from '@superset-ui/core';
import {
ControlPanelConfig,
ControlSubSectionHeader,
D3_TIME_FORMAT_DOCS,
DEFAULT_TIME_FORMAT,
formatSelectOptions,
getStandardizedControls,
sections,
sharedControls,
} from '@superset-ui/chart-controls';
import { showValueControl } from '../controls';
const config: ControlPanelConfig = {
controlPanelSections: [
sections.legacyTimeseriesTime,
{
label: t('Query'),
expanded: true,
controlSetRows: [
['series'],
['columns'],
['x_axis'],
['time_grain_sqla'],
['groupby'],
['metric'],
['adhoc_filters'],
['row_limit'],
@ -44,7 +46,6 @@ const config: ControlPanelConfig = {
label: t('Chart Options'),
expanded: true,
controlSetRows: [
['color_scheme'],
[showValueControl],
[
{
@ -58,21 +59,41 @@ const config: ControlPanelConfig = {
},
},
],
[
<ControlSubSectionHeader>
{t('Series colors')}
</ControlSubSectionHeader>,
],
[
{
name: 'rich_tooltip',
name: 'increase_color',
config: {
type: 'CheckboxControl',
label: t('Rich tooltip'),
label: t('Increase'),
type: 'ColorPickerControl',
default: { r: 90, g: 193, b: 137, a: 1 },
renderTrigger: true,
},
},
{
name: 'decrease_color',
config: {
label: t('Decrease'),
type: 'ColorPickerControl',
default: { r: 224, g: 67, b: 85, a: 1 },
renderTrigger: true,
},
},
{
name: 'total_color',
config: {
label: t('Total'),
type: 'ColorPickerControl',
default: { r: 102, g: 102, b: 102, a: 1 },
renderTrigger: true,
default: true,
description: t(
'Shows a list of all series available at that point in time',
),
},
},
],
[<div className="section-header">{t('X Axis')}</div>],
[<ControlSubSectionHeader>{t('X Axis')}</ControlSubSectionHeader>],
[
{
name: 'x_axis_label',
@ -84,6 +105,16 @@ const config: ControlPanelConfig = {
},
},
],
[
{
name: 'x_axis_time_format',
config: {
...sharedControls.x_axis_time_format,
default: DEFAULT_TIME_FORMAT,
description: `${D3_TIME_FORMAT_DOCS}.`,
},
},
],
[
{
name: 'x_ticks_layout',
@ -104,7 +135,7 @@ const config: ControlPanelConfig = {
},
},
],
[<div className="section-header">{t('Y Axis')}</div>],
[<ControlSubSectionHeader>{t('Y Axis')}</ControlSubSectionHeader>],
[
{
name: 'y_axis_label',
@ -117,26 +148,19 @@ const config: ControlPanelConfig = {
},
],
['y_axis_format'],
['currency_format'],
],
},
],
controlOverrides: {
columns: {
groupby: {
label: t('Breakdowns'),
description: t('Defines how each series is broken down'),
description:
t(`Breaks down the series by the category specified in this control.
This can help viewers understand how each category affects the overall value.`),
multi: false,
},
},
formDataOverrides: formData => {
const series = getStandardizedControls()
.popAllColumns()
.filter(col => !ensureIsArray(formData.columns).includes(col));
return {
...formData,
series,
metric: getStandardizedControls().shiftMetric(),
};
},
};
export default config;

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 53 KiB

View File

@ -22,6 +22,9 @@ import buildQuery from './buildQuery';
import controlPanel from './controlPanel';
import transformProps from './transformProps';
import thumbnail from './images/thumbnail.png';
import example1 from './images/example1.png';
import example2 from './images/example2.png';
import example3 from './images/example3.png';
import { EchartsWaterfallChartProps, EchartsWaterfallFormData } from './types';
export default class EchartsWaterfallChartPlugin extends ChartPlugin<
@ -44,14 +47,22 @@ export default class EchartsWaterfallChartPlugin extends ChartPlugin<
controlPanel,
loadChart: () => import('./EchartsWaterfall'),
metadata: new ChartMetadata({
behaviors: [Behavior.INTERACTIVE_CHART, Behavior.DRILL_TO_DETAIL],
behaviors: [Behavior.INTERACTIVE_CHART],
credits: ['https://echarts.apache.org'],
category: t('Evolution'),
description: '',
exampleGallery: [],
description: t(
`A waterfall chart is a form of data visualization that helps in understanding
the cumulative effect of sequentially introduced positive or negative values.
These intermediate values can either be time based or category based.`,
),
exampleGallery: [
{ url: example1 },
{ url: example2 },
{ url: example3 },
],
name: t('Waterfall Chart'),
tags: [t('Categorical'), t('Comparison'), t('ECharts')],
thumbnail,
tags: [],
}),
transformProps,
});

View File

@ -17,51 +17,68 @@
* under the License.
*/
import {
CategoricalColorNamespace,
CurrencyFormatter,
DataRecord,
getColumnLabel,
ensureIsArray,
GenericDataType,
getMetricLabel,
getNumberFormatter,
getTimeFormatter,
isAdhocColumn,
NumberFormatter,
rgbToHex,
SupersetTheme,
} from '@superset-ui/core';
import { EChartsOption, BarSeriesOption } from 'echarts';
import { CallbackDataParams } from 'echarts/types/src/util/types';
import {
EchartsWaterfallFormData,
EchartsWaterfallChartProps,
ISeriesData,
WaterfallChartTransformedProps,
ICallbackDataParams,
} from './types';
import { getDefaultTooltip } from '../utils/tooltip';
import { defaultGrid, defaultYAxis } from '../defaults';
import { ASSIST_MARK, LEGEND, TOKEN, TOTAL_MARK } from './constants';
import { extractGroupbyLabel, getColtypesMapping } from '../utils/series';
import { getColtypesMapping } from '../utils/series';
import { Refs } from '../types';
import { NULL_STRING } from '../constants';
function formatTooltip({
theme,
params,
numberFormatter,
richTooltip,
breakdownName,
defaultFormatter,
xAxisFormatter,
}: {
theme: SupersetTheme;
params: any;
numberFormatter: NumberFormatter;
richTooltip: boolean;
params: ICallbackDataParams[];
breakdownName?: string;
defaultFormatter: NumberFormatter | CurrencyFormatter;
xAxisFormatter: (value: number | string, index: number) => string;
}) {
const htmlMaker = (params: any) =>
`
<div>${params.name}</div>
const series = params.find(
param => param.seriesName !== ASSIST_MARK && param.data.value !== TOKEN,
);
// We may have no matching series depending on the legend state
if (!series) {
return '';
}
const isTotal = series?.seriesName === LEGEND.TOTAL;
if (!series) {
return NULL_STRING;
}
const createRow = (name: string, value: string) => `
<div>
${params.marker}
<span style="
font-size:${theme.typography.sizes.m}px;
color:${theme.colors.grayscale.base};
font-weight:${theme.typography.weights.normal};
margin-left:${theme.gridUnit * 0.5}px;"
>
${params.seriesName}:
${name}:
</span>
<span style="
float:right;
@ -70,42 +87,39 @@ function formatTooltip({
color:${theme.colors.grayscale.base};
font-weight:${theme.typography.weights.bold}"
>
${numberFormatter(params.data)}
${value}
</span>
</div>
`;
if (richTooltip) {
const [, increaseParams, decreaseParams, totalParams] = params;
if (increaseParams.data !== TOKEN || increaseParams.data === null) {
return htmlMaker(increaseParams);
}
if (decreaseParams.data !== TOKEN) {
return htmlMaker(decreaseParams);
}
if (totalParams.data !== TOKEN) {
return htmlMaker(totalParams);
}
} else if (params.seriesName !== ASSIST_MARK) {
return htmlMaker(params);
let result = '';
if (!isTotal || breakdownName) {
result = xAxisFormatter(series.name, series.dataIndex);
}
return '';
if (!isTotal) {
result += createRow(
series.seriesName!,
defaultFormatter(series.data.originalValue),
);
}
result += createRow(TOTAL_MARK, defaultFormatter(series.data.totalSum));
return result;
}
function transformer({
data,
breakdown,
series,
xAxis,
metric,
breakdown,
}: {
data: DataRecord[];
breakdown: string;
series: string;
xAxis: string;
metric: string;
breakdown?: string;
}) {
// Group by series (temporary map)
const groupedData = data.reduce((acc, cur) => {
const categoryLabel = cur[series] as string;
const categoryLabel = cur[xAxis] as string;
const categoryData = acc.get(categoryLabel) || [];
categoryData.push(cur);
acc.set(categoryLabel, categoryData);
@ -114,7 +128,7 @@ function transformer({
const transformedData: DataRecord[] = [];
if (breakdown?.length) {
if (breakdown) {
groupedData.forEach((value, key) => {
const tempValue = value;
// Calc total per period
@ -124,7 +138,7 @@ function transformer({
);
// Push total per period to the end of period values array
tempValue.push({
[series]: key,
[xAxis]: key,
[breakdown]: TOTAL_MARK,
[metric]: sum,
});
@ -138,13 +152,13 @@ function transformer({
0,
);
transformedData.push({
[series]: key,
[xAxis]: key,
[metric]: sum,
});
total += sum;
});
transformedData.push({
[series]: TOTAL_MARK,
[xAxis]: TOTAL_MARK,
[metric]: total,
});
}
@ -159,50 +173,53 @@ export default function transformProps(
width,
height,
formData,
legendState,
queriesData,
hooks,
filterState,
theme,
inContextMenu,
} = chartProps;
const refs: Refs = {};
const { data = [] } = queriesData[0];
const coltypeMapping = getColtypesMapping(queriesData[0]);
const { setDataMask = () => {}, onContextMenu } = hooks;
const { setDataMask = () => {}, onContextMenu, onLegendStateChanged } = hooks;
const {
colorScheme,
currencyFormat,
groupby,
increaseColor,
decreaseColor,
totalColor,
metric = '',
columns,
series,
xAxis,
xTicksLayout,
xAxisTimeFormat,
showLegend,
yAxisLabel,
xAxisLabel,
yAxisFormat,
richTooltip,
showValue,
sliceId,
} = formData as EchartsWaterfallFormData;
const colorFn = CategoricalColorNamespace.getScale(colorScheme as string);
const numberFormatter = getNumberFormatter(yAxisFormat);
const formatter = (params: CallbackDataParams) => {
const { value, seriesName } = params;
let formattedValue = numberFormatter(value as number);
if (seriesName === LEGEND.DECREASE) {
formattedValue = `-${formattedValue}`;
}
return formattedValue;
} = formData;
const defaultFormatter = currencyFormat?.symbol
? new CurrencyFormatter({ d3Format: yAxisFormat, currency: currencyFormat })
: getNumberFormatter(yAxisFormat);
const seriesformatter = (params: ICallbackDataParams) => {
const { data } = params;
const { originalValue } = data;
return defaultFormatter(originalValue as number);
};
const breakdown = columns?.length ? columns : '';
const groupby = breakdown ? [series, breakdown] : [series];
const groupbyArray = ensureIsArray(groupby);
const breakdownColumn = groupbyArray.length ? groupbyArray[0] : undefined;
const breakdownName = isAdhocColumn(breakdownColumn)
? breakdownColumn.label!
: breakdownColumn;
const xAxisName = isAdhocColumn(xAxis) ? xAxis.label! : xAxis;
const metricLabel = getMetricLabel(metric);
const columnLabels = groupby.map(getColumnLabel);
const columnsLabelMap = new Map<string, string[]>();
const transformedData = transformer({
data,
breakdown,
series,
breakdown: breakdownName,
xAxis: xAxisName,
metric: metricLabel,
});
@ -211,48 +228,128 @@ export default function transformProps(
const decreaseData: ISeriesData[] = [];
const totalData: ISeriesData[] = [];
let previousTotal = 0;
transformedData.forEach((datum, index, self) => {
const totalSum = self.slice(0, index + 1).reduce((prev, cur, i) => {
if (breakdown?.length) {
if (cur[breakdown] !== TOTAL_MARK || i === 0) {
if (breakdownName) {
if (cur[breakdownName] !== TOTAL_MARK || i === 0) {
return prev + ((cur[metricLabel] as number) ?? 0);
}
} else if (cur[series] !== TOTAL_MARK) {
} else if (cur[xAxisName] !== TOTAL_MARK) {
return prev + ((cur[metricLabel] as number) ?? 0);
}
return prev;
}, 0);
const joinedName = extractGroupbyLabel({
datum,
groupby: columnLabels,
coltypeMapping,
});
columnsLabelMap.set(
joinedName,
columnLabels.map(col => datum[col] as string),
);
const value = datum[metricLabel] as number;
const isNegative = value < 0;
if (datum[breakdown] === TOTAL_MARK || datum[series] === TOTAL_MARK) {
increaseData.push(TOKEN);
decreaseData.push(TOKEN);
assistData.push(TOKEN);
totalData.push(totalSum);
} else if (isNegative) {
increaseData.push(TOKEN);
decreaseData.push(Math.abs(value));
assistData.push(totalSum);
totalData.push(TOKEN);
} else {
increaseData.push(value);
decreaseData.push(TOKEN);
assistData.push(totalSum - value);
totalData.push(TOKEN);
const isTotal =
(breakdownName && datum[breakdownName] === TOTAL_MARK) ||
datum[xAxisName] === TOTAL_MARK;
const originalValue = datum[metricLabel] as number;
let value = originalValue;
const oppositeSigns = Math.sign(previousTotal) !== Math.sign(totalSum);
if (oppositeSigns) {
value = Math.sign(value) * (Math.abs(value) - Math.abs(previousTotal));
}
if (isTotal) {
increaseData.push({ value: TOKEN });
decreaseData.push({ value: TOKEN });
totalData.push({
value: totalSum,
originalValue: totalSum,
totalSum,
});
} else if (value < 0) {
increaseData.push({ value: TOKEN });
decreaseData.push({
value: totalSum < 0 ? value : -value,
originalValue,
totalSum,
});
totalData.push({ value: TOKEN });
} else {
increaseData.push({
value: totalSum > 0 ? value : -value,
originalValue,
totalSum,
});
decreaseData.push({ value: TOKEN });
totalData.push({ value: TOKEN });
}
const color = oppositeSigns
? value > 0
? rgbToHex(increaseColor.r, increaseColor.g, increaseColor.b)
: rgbToHex(decreaseColor.r, decreaseColor.g, decreaseColor.b)
: 'transparent';
let opacity = 1;
if (legendState?.[LEGEND.INCREASE] === false && value > 0) {
opacity = 0;
} else if (legendState?.[LEGEND.DECREASE] === false && value < 0) {
opacity = 0;
}
if (isTotal) {
assistData.push({ value: TOKEN });
} else if (index === 0) {
assistData.push({
value: 0,
});
} else if (oppositeSigns || Math.abs(totalSum) > Math.abs(previousTotal)) {
assistData.push({
value: previousTotal,
itemStyle: { color, opacity },
});
} else {
assistData.push({
value: totalSum,
itemStyle: { color, opacity },
});
}
previousTotal = totalSum;
});
let axisLabel;
const xAxisColumns: string[] = [];
const xAxisData = transformedData.map(row => {
let column = xAxisName;
let value = row[xAxisName];
if (breakdownName && row[breakdownName] !== TOTAL_MARK) {
column = breakdownName;
value = row[breakdownName];
}
if (!value) {
value = NULL_STRING;
}
if (typeof value !== 'string' && typeof value !== 'number') {
value = String(value);
}
xAxisColumns.push(column);
return value;
});
const xAxisFormatter = (value: number | string, index: number) => {
if (value === TOTAL_MARK) {
return TOTAL_MARK;
}
if (coltypeMapping[xAxisColumns[index]] === GenericDataType.TEMPORAL) {
if (typeof value === 'string') {
return getTimeFormatter(xAxisTimeFormat)(Number.parseInt(value, 10));
}
return getTimeFormatter(xAxisTimeFormat)(value);
}
return String(value);
};
let axisLabel: {
rotate?: number;
hideOverlap?: boolean;
show?: boolean;
formatter?: typeof xAxisFormatter;
};
if (xTicksLayout === '45°') {
axisLabel = { rotate: -45 };
} else if (xTicksLayout === '90°') {
@ -264,75 +361,59 @@ export default function transformProps(
} else {
axisLabel = { show: true };
}
axisLabel.formatter = xAxisFormatter;
axisLabel.hideOverlap = false;
let xAxisData: string[] = [];
if (breakdown?.length) {
xAxisData = transformedData.map(row => {
if (row[breakdown] === TOTAL_MARK) {
return row[series] as string;
}
return row[breakdown] as string;
});
} else {
xAxisData = transformedData.map(row => row[series] as string);
}
const seriesProps: Pick<BarSeriesOption, 'type' | 'stack' | 'emphasis'> = {
type: 'bar',
stack: 'stack',
emphasis: {
disabled: true,
},
};
const barSeries: BarSeriesOption[] = [
{
...seriesProps,
name: ASSIST_MARK,
type: 'bar',
stack: 'stack',
itemStyle: {
borderColor: 'transparent',
color: 'transparent',
},
emphasis: {
itemStyle: {
borderColor: 'transparent',
color: 'transparent',
},
},
data: assistData,
},
{
...seriesProps,
name: LEGEND.INCREASE,
type: 'bar',
stack: 'stack',
label: {
show: showValue,
position: 'top',
formatter,
formatter: seriesformatter,
},
itemStyle: {
color: colorFn(LEGEND.INCREASE, sliceId),
color: rgbToHex(increaseColor.r, increaseColor.g, increaseColor.b),
},
data: increaseData,
},
{
...seriesProps,
name: LEGEND.DECREASE,
type: 'bar',
stack: 'stack',
label: {
show: showValue,
position: 'bottom',
formatter,
formatter: seriesformatter,
},
itemStyle: {
color: colorFn(LEGEND.DECREASE, sliceId),
color: rgbToHex(decreaseColor.r, decreaseColor.g, decreaseColor.b),
},
data: decreaseData,
},
{
...seriesProps,
name: LEGEND.TOTAL,
type: 'bar',
stack: 'stack',
label: {
show: showValue,
position: 'top',
formatter,
formatter: seriesformatter,
},
itemStyle: {
color: colorFn(LEGEND.TOTAL, sliceId),
color: rgbToHex(totalColor.r, totalColor.g, totalColor.b),
},
data: totalData,
},
@ -348,11 +429,12 @@ export default function transformProps(
},
legend: {
show: showLegend,
selected: legendState,
data: [LEGEND.INCREASE, LEGEND.DECREASE, LEGEND.TOTAL],
},
xAxis: {
type: 'category',
data: xAxisData,
type: 'category',
name: xAxisLabel,
nameTextStyle: {
padding: [theme.gridUnit * 4, 0, 0, 0],
@ -368,19 +450,20 @@ export default function transformProps(
},
nameLocation: 'middle',
name: yAxisLabel,
axisLabel: { formatter: numberFormatter },
axisLabel: { formatter: defaultFormatter },
},
tooltip: {
...getDefaultTooltip(refs),
appendToBody: true,
trigger: richTooltip ? 'axis' : 'item',
trigger: 'axis',
show: !inContextMenu,
formatter: (params: any) =>
formatTooltip({
theme,
params,
numberFormatter,
richTooltip,
breakdownName,
defaultFormatter,
xAxisFormatter,
}),
},
series: barSeries,
@ -393,9 +476,7 @@ export default function transformProps(
height,
echartOptions,
setDataMask,
labelMap: Object.fromEntries(columnsLabelMap),
groupby,
selectedValues: filterState.selectedValues || [],
onContextMenu,
onLegendStateChanged,
};
}

View File

@ -19,16 +19,14 @@
import {
ChartDataResponseResult,
ChartProps,
QueryFormColumn,
QueryFormData,
QueryFormMetric,
RgbaColor,
} from '@superset-ui/core';
import { BarDataItemOption } from 'echarts/types/src/chart/bar/BarSeries';
import { OptionDataValue } from 'echarts/types/src/util/types';
import {
BaseTransformedProps,
CrossFilterTransformedProps,
LegendFormData,
} from '../types';
import { CallbackDataParams } from 'echarts/types/src/util/types';
import { BaseTransformedProps, LegendFormData } from '../types';
export type WaterfallFormXTicksLayout =
| '45°'
@ -37,20 +35,28 @@ export type WaterfallFormXTicksLayout =
| 'flat'
| 'staggered';
export type ISeriesData =
| BarDataItemOption
| OptionDataValue
| OptionDataValue[];
export type ISeriesData = {
originalValue?: number;
totalSum?: number;
} & BarDataItemOption;
export type ICallbackDataParams = CallbackDataParams & {
axisValueLabel: string;
data: ISeriesData;
};
export type EchartsWaterfallFormData = QueryFormData &
LegendFormData & {
increaseColor: RgbaColor;
decreaseColor: RgbaColor;
totalColor: RgbaColor;
metric: QueryFormMetric;
yAxisLabel: string;
xAxis: QueryFormColumn;
xAxisLabel: string;
yAxisFormat: string;
xAxisTimeFormat?: string;
xTicksLayout?: WaterfallFormXTicksLayout;
series: string;
columns?: string;
yAxisLabel: string;
yAxisFormat: string;
};
export const DEFAULT_FORM_DATA: Partial<EchartsWaterfallFormData> = {
@ -63,4 +69,4 @@ export interface EchartsWaterfallChartProps extends ChartProps {
}
export type WaterfallChartTransformedProps =
BaseTransformedProps<EchartsWaterfallFormData> & CrossFilterTransformedProps;
BaseTransformedProps<EchartsWaterfallFormData>;

View File

@ -21,8 +21,12 @@ import {
CurrencyFormatter,
ensureIsArray,
getNumberFormatter,
getTimeFormatter,
isSavedMetric,
QueryFormMetric,
smartDateDetailedFormatter,
smartDateFormatter,
TimeFormatter,
ValueFormatter,
} from '@superset-ui/core';
@ -51,3 +55,27 @@ export const getYAxisFormatter = (
}
return defaultFormatter ?? getNumberFormatter();
};
export function getTooltipTimeFormatter(
format?: string,
): TimeFormatter | StringConstructor {
if (format === smartDateFormatter.id) {
return smartDateDetailedFormatter;
}
if (format) {
return getTimeFormatter(format);
}
return String;
}
export function getXAxisFormatter(
format?: string,
): TimeFormatter | StringConstructor | undefined {
if (format === smartDateFormatter.id || !format) {
return undefined;
}
if (format) {
return getTimeFormatter(format);
}
return String;
}

View File

@ -24,15 +24,18 @@ describe('Waterfall buildQuery', () => {
datasource: '5__table',
granularity_sqla: 'ds',
metric: 'foo',
series: 'bar',
columns: 'baz',
viz_type: 'my_chart',
x_axis: 'bar',
groupby: ['baz'],
viz_type: 'waterfall',
};
it('should build query fields from form data', () => {
const queryContext = buildQuery(formData as unknown as SqlaFormData);
const [query] = queryContext.queries;
expect(query.metrics).toEqual(['foo']);
expect(query.columns).toEqual(['bar', 'baz']);
expect(query.columns?.[0]).toEqual(
expect.objectContaining({ sqlExpression: 'bar' }),
);
expect(query.columns?.[1]).toEqual('baz');
});
});

View File

@ -17,27 +17,41 @@
* under the License.
*/
import { ChartProps, supersetTheme } from '@superset-ui/core';
import { EchartsWaterfallChartProps } from '../../src/Waterfall/types';
import {
EchartsWaterfallChartProps,
WaterfallChartTransformedProps,
} from '../../src/Waterfall/types';
import transformProps from '../../src/Waterfall/transformProps';
const extractSeries = (props: WaterfallChartTransformedProps) => {
const { echartOptions } = props;
const { series } = echartOptions as unknown as {
series: [{ data: [{ value: number }] }];
};
return series.map(item => item.data).map(item => item.map(i => i.value));
};
describe('Waterfall tranformProps', () => {
const data = [
{ foo: 'Sylvester', bar: '2019', sum: 10 },
{ foo: 'Arnold', bar: '2019', sum: 3 },
{ foo: 'Sylvester', bar: '2020', sum: -10 },
{ foo: 'Arnold', bar: '2020', sum: 5 },
{ year: '2019', name: 'Sylvester', sum: 10 },
{ year: '2019', name: 'Arnold', sum: 3 },
{ year: '2020', name: 'Sylvester', sum: -10 },
{ year: '2020', name: 'Arnold', sum: 5 },
];
const formData = {
colorScheme: 'bnbColors',
datasource: '3__table',
x_axis: 'year',
metric: 'sum',
increaseColor: { r: 0, b: 0, g: 0 },
decreaseColor: { r: 0, b: 0, g: 0 },
totalColor: { r: 0, b: 0, g: 0 },
};
it('should tranform chart props for viz when breakdown not exist', () => {
const formData1 = {
colorScheme: 'bnbColors',
datasource: '3__table',
granularity_sqla: 'ds',
metric: 'sum',
series: 'bar',
};
const chartProps = new ChartProps({
formData: formData1,
formData: { ...formData, series: 'bar' },
width: 800,
height: 600,
queriesData: [
@ -47,43 +61,20 @@ describe('Waterfall tranformProps', () => {
],
theme: supersetTheme,
});
expect(
transformProps(chartProps as unknown as EchartsWaterfallChartProps),
).toEqual(
expect.objectContaining({
width: 800,
height: 600,
echartOptions: expect.objectContaining({
series: [
expect.objectContaining({
data: [0, 8, '-'],
}),
expect.objectContaining({
data: [13, '-', '-'],
}),
expect.objectContaining({
data: ['-', 5, '-'],
}),
expect.objectContaining({
data: ['-', '-', 8],
}),
],
}),
}),
const transformedProps = transformProps(
chartProps as unknown as EchartsWaterfallChartProps,
);
expect(extractSeries(transformedProps)).toEqual([
[0, 8, '-'],
[13, '-', '-'],
['-', 5, '-'],
['-', '-', 8],
]);
});
it('should tranform chart props for viz when breakdown exist', () => {
const formData1 = {
colorScheme: 'bnbColors',
datasource: '3__table',
granularity_sqla: 'ds',
metric: 'sum',
series: 'bar',
columns: 'foo',
};
const chartProps = new ChartProps({
formData: formData1,
formData: { ...formData, groupby: 'name' },
width: 800,
height: 600,
queriesData: [
@ -93,29 +84,14 @@ describe('Waterfall tranformProps', () => {
],
theme: supersetTheme,
});
expect(
transformProps(chartProps as unknown as EchartsWaterfallChartProps),
).toEqual(
expect.objectContaining({
width: 800,
height: 600,
echartOptions: expect.objectContaining({
series: [
expect.objectContaining({
data: [0, 10, '-', 3, 3, '-'],
}),
expect.objectContaining({
data: [10, 3, '-', '-', 5, '-'],
}),
expect.objectContaining({
data: ['-', '-', '-', 10, '-', '-'],
}),
expect.objectContaining({
data: ['-', '-', 13, '-', '-', 8],
}),
],
}),
}),
const transformedProps = transformProps(
chartProps as unknown as EchartsWaterfallChartProps,
);
expect(extractSeries(transformedProps)).toEqual([
[0, 10, '-', 3, 3, '-'],
[10, 3, '-', '-', 5, '-'],
['-', '-', '-', 10, '-', '-'],
['-', '-', 13, '-', '-', 8],
]);
});
});

View File

@ -19,8 +19,7 @@
import React from 'react';
import { render, screen } from 'spec/helpers/testing-library';
import userEvent from '@testing-library/user-event';
import { supersetTheme } from '@superset-ui/core';
import { hexToRgb } from 'src/utils/colorUtils';
import { supersetTheme, hexToRgb } from '@superset-ui/core';
import Collapse, { CollapseProps } from '.';
function renderCollapse(props?: CollapseProps) {

View File

@ -20,8 +20,7 @@ import React from 'react';
import { render, screen, within } from 'spec/helpers/testing-library';
import userEvent from '@testing-library/user-event';
import * as resizeDetector from 'react-resize-detector';
import { supersetTheme } from '@superset-ui/core';
import { hexToRgb } from 'src/utils/colorUtils';
import { supersetTheme, hexToRgb } from '@superset-ui/core';
import MetadataBar, {
MIN_NUMBER_ITEMS,
MAX_NUMBER_ITEMS,

View File

@ -854,6 +854,7 @@ export default function VizTypeGallery(props: VizTypeGalleryProps) {
<Examples>
{(selectedVizMetadata?.exampleGallery || []).map(example => (
<img
key={example.url}
src={example.url}
alt={example.caption}
title={example.caption}

View File

@ -1,50 +0,0 @@
/**
* 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 function hexToRgb(h: string) {
let r = '0';
let g = '0';
let b = '0';
// 3 digits
if (h.length === 4) {
r = `0x${h[1]}${h[1]}`;
g = `0x${h[2]}${h[2]}`;
b = `0x${h[3]}${h[3]}`;
// 6 digits
} else if (h.length === 7) {
r = `0x${h[1]}${h[2]}`;
g = `0x${h[3]}${h[4]}`;
b = `0x${h[5]}${h[6]}`;
}
return `rgb(${+r}, ${+g}, ${+b})`;
}
export function rgbToHex(red: number, green: number, blue: number) {
let r = red.toString(16);
let g = green.toString(16);
let b = blue.toString(16);
if (r.length === 1) r = `0${r}`;
if (g.length === 1) g = `0${g}`;
if (b.length === 1) b = `0${b}`;
return `#${r}${g}${b}`;
}