/** * 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 { CurrencyFormatter, DataRecord, ensureIsArray, getMetricLabel, getNumberFormatter, getTimeFormatter, isAdhocColumn, NumberFormatter, rgbToHex, tooltipHtml, } from '@superset-ui/core'; import { GenericDataType } from '@apache-superset/core/common'; import type { ComposeOption } from 'echarts/core'; import type { BarSeriesOption } from 'echarts/charts'; import { 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 { getColtypesMapping } from '../utils/series'; import { Refs } from '../types'; import { NULL_STRING } from '../constants'; type EChartsOption = ComposeOption; function formatTooltip({ params, breakdownName, defaultFormatter, xAxisFormatter, totalMark, }: { params: ICallbackDataParams[]; breakdownName?: string; defaultFormatter: NumberFormatter | CurrencyFormatter; xAxisFormatter: (value: number | string, index: number) => string; totalMark: string; }) { 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 === totalMark; if (!series) { return NULL_STRING; } const title = !isTotal || breakdownName ? xAxisFormatter(series.name, series.dataIndex) : undefined; const rows: string[][] = []; if (!isTotal) { rows.push([ series.seriesName!, defaultFormatter(series.data.originalValue), ]); } rows.push([totalMark, defaultFormatter(series.data.totalSum)]); return tooltipHtml(rows, title); } function transformer({ data, xAxis, metric, breakdown, totalMark, showTotal, }: { data: DataRecord[]; xAxis: string; metric: string; breakdown?: string; totalMark: string; showTotal: boolean; }) { // Group by series (temporary map) const groupedData = data.reduce((acc, cur) => { const categoryLabel = cur[xAxis] as string; const categoryData = acc.get(categoryLabel) || []; categoryData.push(cur); acc.set(categoryLabel, categoryData); return acc; }, new Map()); const transformedData: DataRecord[] = []; if (breakdown) { groupedData.forEach((value, key) => { const tempValue = value; // Calc total per period const sum = tempValue.reduce( (acc, cur) => acc + ((cur[metric] as number) ?? 0), 0, ); // Push total per period to the end of period values array if (showTotal) { tempValue.push({ [xAxis]: key, [breakdown]: totalMark, [metric]: sum, }); } transformedData.push(...tempValue); }); } else { let total = 0; groupedData.forEach((value, key) => { const sum = value.reduce( (acc, cur) => acc + ((cur[metric] as number) ?? 0), 0, ); transformedData.push({ [xAxis]: key, [metric]: sum, }); total += sum; }); if (showTotal) { transformedData.push({ [xAxis]: totalMark, [metric]: total, }); } } return transformedData; } export default function transformProps( chartProps: EchartsWaterfallChartProps, ): WaterfallChartTransformedProps { const { width, height, formData, legendState, queriesData, hooks, theme, inContextMenu, } = chartProps; const refs: Refs = {}; const { data = [] } = queriesData[0]; const coltypeMapping = getColtypesMapping(queriesData[0]); const { setDataMask = () => {}, onContextMenu, onLegendStateChanged } = hooks; const { currencyFormat, granularitySqla = '', groupby, increaseColor = { r: 90, g: 193, b: 137 }, decreaseColor = { r: 224, g: 67, b: 85 }, totalColor = { r: 102, g: 102, b: 102 }, metric = '', xAxis, xTicksLayout, xAxisTimeFormat, showLegend, showXAxis = true, showYAxis = true, yAxisLabel, xAxisLabel, yAxisFormat, showValue, showTotal, totalLabel, increaseLabel, decreaseLabel, } = formData; const defaultFormatter = currencyFormat?.symbol ? new CurrencyFormatter({ d3Format: yAxisFormat, currency: currencyFormat }) : getNumberFormatter(yAxisFormat); const totalMark = totalLabel || TOTAL_MARK; const legendNames = { INCREASE: increaseLabel || LEGEND.INCREASE, DECREASE: decreaseLabel || LEGEND.DECREASE, TOTAL: totalLabel || LEGEND.TOTAL, }; const seriesformatter = (params: ICallbackDataParams) => { const { data } = params; const { originalValue } = data; return defaultFormatter(originalValue as number); }; const groupbyArray = ensureIsArray(groupby); const breakdownColumn = groupbyArray.length ? groupbyArray[0] : undefined; const breakdownName = isAdhocColumn(breakdownColumn) ? breakdownColumn.label! : breakdownColumn; const xAxisColumn = xAxis || granularitySqla; const xAxisName = isAdhocColumn(xAxisColumn) ? xAxisColumn.label! : xAxisColumn; const metricLabel = getMetricLabel(metric); const transformedData = transformer({ data, breakdown: breakdownName, xAxis: xAxisName, metric: metricLabel, totalMark, showTotal, }); const assistData: ISeriesData[] = []; const increaseData: ISeriesData[] = []; 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 (breakdownName) { if (cur[breakdownName] !== totalMark || i === 0) { return prev + ((cur[metricLabel] as number) ?? 0); } } else if (cur[xAxisName] !== totalMark) { return prev + ((cur[metricLabel] as number) ?? 0); } return prev; }, 0); const isTotal = (breakdownName && datum[breakdownName] === totalMark) || datum[xAxisName] === totalMark; 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?.[legendNames.INCREASE] === false && value > 0) { opacity = 0; } else if (legendState?.[legendNames.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] !== totalMark) { 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 === totalMark) { return totalMark; } 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°') { axisLabel = { rotate: -90 }; } else if (xTicksLayout === 'flat') { axisLabel = { rotate: 0 }; } else if (xTicksLayout === 'staggered') { axisLabel = { rotate: -45 }; } else { axisLabel = { show: true }; } axisLabel.formatter = xAxisFormatter; axisLabel.hideOverlap = false; const seriesProps: Pick = { type: 'bar', stack: 'stack', emphasis: { disabled: true, }, }; const labelProps = { show: showValue, formatter: seriesformatter, color: theme.colorText, borderColor: theme.colorBgBase, borderWidth: 1, }; const barSeries: BarSeriesOption[] = [ { ...seriesProps, name: ASSIST_MARK, data: assistData, }, { ...seriesProps, name: legendNames.INCREASE, label: { ...labelProps, position: 'top', }, itemStyle: { color: rgbToHex(increaseColor.r, increaseColor.g, increaseColor.b), }, data: increaseData, }, { ...seriesProps, name: legendNames.DECREASE, label: { ...labelProps, position: 'bottom', }, itemStyle: { color: rgbToHex(decreaseColor.r, decreaseColor.g, decreaseColor.b), }, data: decreaseData, }, { ...seriesProps, name: legendNames.TOTAL, label: { ...labelProps, position: 'top', }, itemStyle: { color: rgbToHex(totalColor.r, totalColor.g, totalColor.b), }, data: totalData, }, ]; const echartOptions: EChartsOption = { grid: { ...defaultGrid, top: theme.sizeUnit * 7, // Reclaim the axis-oriented padding when an axis is hidden so an // axis-free chart gets a clean, tight layout instead of empty margins. bottom: theme.sizeUnit * (showXAxis ? 7 : 3), left: theme.sizeUnit * (showYAxis ? 5 : 2), right: theme.sizeUnit * 7, }, legend: { show: showLegend, selected: legendState, data: [legendNames.INCREASE, legendNames.DECREASE, legendNames.TOTAL], }, xAxis: { show: showXAxis, data: xAxisData, type: 'category', name: xAxisLabel, nameTextStyle: { padding: [theme.sizeUnit * 4, 0, 0, 0], }, nameLocation: 'middle', axisLabel, }, yAxis: { ...defaultYAxis, show: showYAxis, type: 'value', nameTextStyle: { padding: [0, 0, theme.sizeUnit * 5, 0], }, nameLocation: 'middle', name: yAxisLabel, axisLabel: { formatter: defaultFormatter }, }, tooltip: { ...getDefaultTooltip(refs), appendToBody: true, trigger: 'axis', show: !inContextMenu, formatter: (params: any) => formatTooltip({ params, breakdownName, defaultFormatter, xAxisFormatter, totalMark, }), }, series: barSeries, }; return { refs, formData, width, height, echartOptions, setDataMask, onContextMenu, onLegendStateChanged, }; }