diff --git a/superset-frontend/packages/superset-ui-core/src/color/index.ts b/superset-frontend/packages/superset-ui-core/src/color/index.ts index 3bbdb5d0dc578..cb7e569b47892 100644 --- a/superset-frontend/packages/superset-ui-core/src/color/index.ts +++ b/superset-frontend/packages/superset-ui-core/src/color/index.ts @@ -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, diff --git a/superset-frontend/packages/superset-ui-core/src/color/types.ts b/superset-frontend/packages/superset-ui-core/src/color/types.ts index 2e4b528f9bfa5..bc8b50d7a9163 100644 --- a/superset-frontend/packages/superset-ui-core/src/color/types.ts +++ b/superset-frontend/packages/superset-ui-core/src/color/types.ts @@ -24,3 +24,10 @@ export interface ColorsInitLookup { export interface ColorsLookup { [key: string]: string; } + +export interface RgbaColor { + r: number; + g: number; + b: number; + a: number; +} diff --git a/superset-frontend/packages/superset-ui-core/src/color/utils.ts b/superset-frontend/packages/superset-ui-core/src/color/utils.ts index 1b362efe3e100..55284f16b4a52 100644 --- a/superset-frontend/packages/superset-ui-core/src/color/utils.ts +++ b/superset-frontend/packages/superset-ui-core/src/color/utils.ts @@ -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}`; +} diff --git a/superset-frontend/packages/superset-ui-core/test/color/utils.test.ts b/superset-frontend/packages/superset-ui-core/test/color/utils.test.ts index 308eec726b73a..131ea04a782d7 100644 --- a/superset-frontend/packages/superset-ui-core/test/color/utils.test.ts +++ b/superset-frontend/packages/superset-ui-core/test/color/utils.test.ts @@ -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'); + }); + }); }); diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/transformProps.ts index 25b7e5364aad7..47411e2477e89 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/transformProps.ts @@ -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, diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts index f76c457e1c34d..d44ae93580489 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts @@ -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, diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformers.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformers.ts index 0bcc5baf8dcca..3b62417f16335 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformers.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformers.ts @@ -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; -} diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Waterfall/EchartsWaterfall.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Waterfall/EchartsWaterfall.tsx index a448c9f93eab6..b69d2d72b7a8e 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Waterfall/EchartsWaterfall.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Waterfall/EchartsWaterfall.tsx @@ -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} /> ); } diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Waterfall/buildQuery.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Waterfall/buildQuery.ts index 353ee8fa20e00..e47effb3c2723 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Waterfall/buildQuery.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Waterfall/buildQuery.ts @@ -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]), }, ]); } diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Waterfall/controlPanel.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Waterfall/controlPanel.tsx index 852f2680b3c15..7a71dd4fcba55 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Waterfall/controlPanel.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Waterfall/controlPanel.tsx @@ -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 = { }, }, ], + [ + + {t('Series colors')} + , + ], [ { - 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', - ), }, }, ], - [
{t('X Axis')}
], + [{t('X Axis')}], [ { 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 = { }, }, ], - [
{t('Y Axis')}
], + [{t('Y Axis')}], [ { 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; diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Waterfall/images/example1.png b/superset-frontend/plugins/plugin-chart-echarts/src/Waterfall/images/example1.png new file mode 100644 index 0000000000000..4785cace4adbf Binary files /dev/null and b/superset-frontend/plugins/plugin-chart-echarts/src/Waterfall/images/example1.png differ diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Waterfall/images/example2.png b/superset-frontend/plugins/plugin-chart-echarts/src/Waterfall/images/example2.png new file mode 100644 index 0000000000000..aee32be99193d Binary files /dev/null and b/superset-frontend/plugins/plugin-chart-echarts/src/Waterfall/images/example2.png differ diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Waterfall/images/example3.png b/superset-frontend/plugins/plugin-chart-echarts/src/Waterfall/images/example3.png new file mode 100644 index 0000000000000..6e3248b03eef5 Binary files /dev/null and b/superset-frontend/plugins/plugin-chart-echarts/src/Waterfall/images/example3.png differ diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Waterfall/images/thumbnail.png b/superset-frontend/plugins/plugin-chart-echarts/src/Waterfall/images/thumbnail.png index 91ef20f515f92..95a79df5907b8 100644 Binary files a/superset-frontend/plugins/plugin-chart-echarts/src/Waterfall/images/thumbnail.png and b/superset-frontend/plugins/plugin-chart-echarts/src/Waterfall/images/thumbnail.png differ diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Waterfall/index.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Waterfall/index.ts index 5242434f94c1f..c0d7a11067420 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Waterfall/index.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Waterfall/index.ts @@ -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, }); diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Waterfall/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Waterfall/transformProps.ts index 8ea8f688264bf..7b5faed1b20a5 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Waterfall/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Waterfall/transformProps.ts @@ -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) => - ` -
${params.name}
+ 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) => `
- ${params.marker} - ${params.seriesName}: + ${name}: - ${numberFormatter(params.data)} + ${value}
`; - 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(); 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); + 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); - decreaseData.push(TOKEN); - assistData.push(totalSum - value); - totalData.push(TOKEN); + 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; + }); + + 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; }); - let axisLabel; + 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 = { + 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, }; } diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Waterfall/types.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Waterfall/types.ts index 9821cf3146392..4386501199c8b 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Waterfall/types.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Waterfall/types.ts @@ -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 = { @@ -63,4 +69,4 @@ export interface EchartsWaterfallChartProps extends ChartProps { } export type WaterfallChartTransformedProps = - BaseTransformedProps & CrossFilterTransformedProps; + BaseTransformedProps; diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/utils/getYAxisFormatter.ts b/superset-frontend/plugins/plugin-chart-echarts/src/utils/formatters.ts similarity index 74% rename from superset-frontend/plugins/plugin-chart-echarts/src/utils/getYAxisFormatter.ts rename to superset-frontend/plugins/plugin-chart-echarts/src/utils/formatters.ts index 00843c16126f0..5416fa1577635 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/utils/getYAxisFormatter.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/utils/formatters.ts @@ -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; +} diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/Waterfall/buildQuery.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/Waterfall/buildQuery.test.ts index 9c5d28376ba28..0eb72be3ef998 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/test/Waterfall/buildQuery.test.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/test/Waterfall/buildQuery.test.ts @@ -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'); }); }); diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/Waterfall/transformProps.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/Waterfall/transformProps.test.ts index c221b9303358a..a4abec6d49621 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/test/Waterfall/transformProps.test.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/test/Waterfall/transformProps.test.ts @@ -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], + ]); }); }); diff --git a/superset-frontend/src/components/Collapse/Collapse.test.tsx b/superset-frontend/src/components/Collapse/Collapse.test.tsx index 99cc62302779e..75e004604a973 100644 --- a/superset-frontend/src/components/Collapse/Collapse.test.tsx +++ b/superset-frontend/src/components/Collapse/Collapse.test.tsx @@ -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) { diff --git a/superset-frontend/src/components/MetadataBar/MetadataBar.test.tsx b/superset-frontend/src/components/MetadataBar/MetadataBar.test.tsx index 549b917ec1386..7a7f7430f23dc 100644 --- a/superset-frontend/src/components/MetadataBar/MetadataBar.test.tsx +++ b/superset-frontend/src/components/MetadataBar/MetadataBar.test.tsx @@ -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, diff --git a/superset-frontend/src/explore/components/controls/VizTypeControl/VizTypeGallery.tsx b/superset-frontend/src/explore/components/controls/VizTypeControl/VizTypeGallery.tsx index c194d2fae1bc5..2563dba01cb7a 100644 --- a/superset-frontend/src/explore/components/controls/VizTypeControl/VizTypeGallery.tsx +++ b/superset-frontend/src/explore/components/controls/VizTypeControl/VizTypeGallery.tsx @@ -854,6 +854,7 @@ export default function VizTypeGallery(props: VizTypeGalleryProps) { {(selectedVizMetadata?.exampleGallery || []).map(example => (