/** * 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 { t } from '@apache-superset/core/translation'; import { CategoricalColorNamespace, getColumnLabel, getMetricLabel, getNumberFormatter, getTimeFormatter, NumberFormats, ValueFormatter, getValueFormatter, tooltipHtml, DataRecord, } from '@superset-ui/core'; import type { CallbackDataParams } from 'echarts/types/src/util/types'; import type { EChartsCoreOption } from 'echarts/core'; import type { PieSeriesOption } from 'echarts/charts'; import { DEFAULT_FORM_DATA as DEFAULT_PIE_FORM_DATA, EchartsPieChartProps, EchartsPieFormData, EchartsPieLabelType, PieChartDataItem, PieChartTransformedProps, } from './types'; import { DEFAULT_LEGEND_FORM_DATA, OpacityEnum } from '../constants'; import { extractGroupbyLabel, getChartPadding, getColtypesMapping, getLegendProps, sanitizeHtml, } from '../utils/series'; import { resolveLegendLayout } from '../utils/legendLayout'; 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); export function parseParams({ params, numberFormatter, sanitizeName = false, }: { params: Pick; numberFormatter: ValueFormatter; sanitizeName?: boolean; }): string[] { const { name: rawName = '', value, percent } = params; const name = sanitizeName ? sanitizeHtml(rawName) : rawName; const formattedValue = numberFormatter(value as number); const formattedPercent = percentFormatter((percent as number) / 100); return [name, formattedValue, formattedPercent]; } function getTotalValuePadding({ chartPadding, donut, width, height, }: { chartPadding: { bottom: number; left: number; right: number; top: number; }; donut: boolean; width: number; height: number; }) { const padding: { left?: string; top?: string; } = { top: donut ? 'middle' : '0', left: 'center', }; if (chartPadding.top) { padding.top = donut ? `${50 + (chartPadding.top / height / 2) * 100}%` : `${(chartPadding.top / height) * 100}%`; } if (chartPadding.bottom) { padding.top = donut ? `${50 - (chartPadding.bottom / height / 2) * 100}%` : '0'; } if (chartPadding.left) { // When legend is on the left, shift text right to center it in the available space const leftPaddingPercent = (chartPadding.left / width) * 100; const adjustedLeftPercent = 50 + leftPaddingPercent * 0.25; padding.left = `${adjustedLeftPercent}%`; } if (chartPadding.right) { // When legend is on the right, shift text left to center it in the available space const rightPaddingPercent = (chartPadding.right / width) * 100; const adjustedLeftPercent = 50 - rightPaddingPercent * 0.75; padding.left = `${adjustedLeftPercent}%`; } return padding; } export default function transformProps( chartProps: EchartsPieChartProps, ): PieChartTransformedProps { const { formData, height, hooks, filterState, queriesData, width, theme, inContextMenu, emitCrossFilters, datasource, } = chartProps; const { columnFormats = {}, currencyFormats = {}, currencyCodeColumn, } = datasource; const { data: rawData = [], detected_currency: detectedCurrency } = queriesData[0]; const coltypeMapping = getColtypesMapping(queriesData[0]); const { colorScheme, donut, groupby, innerRadius, labelsOutside, labelLine, labelType, labelTemplate, legendMargin, legendOrientation, legendType, legendSort, metric = '', numberFormat, currencyFormat, dateFormat, outerRadius, showLabels, showLegend, showLabelsThreshold, sliceId, showTotal, roseType, thresholdForOther, }: EchartsPieFormData = { ...DEFAULT_LEGEND_FORM_DATA, ...DEFAULT_PIE_FORM_DATA, ...formData, }; const refs: Refs = {}; const metricLabel = getMetricLabel(metric); const contributionLabel = getContributionLabel(metricLabel); const groupbyLabels = groupby.map(getColumnLabel); const minShowLabelAngle = (showLabelsThreshold || 0) * 3.6; const numberFormatter = getValueFormatter( metric, currencyFormats, columnFormats, numberFormat, currencyFormat, undefined, rawData, currencyCodeColumn, detectedCurrency, ); 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.colorText, opacity: filterState.selectedValues && !filterState.selectedValues.includes(otherName) ? OpacityEnum.SemiTransparent : OpacityEnum.NonTransparent, }, isOther: true, }; } } const labelMap = data.reduce((acc: Record, datum) => { const label = extractGroupbyLabel({ datum, groupby: groupbyLabels, coltypeMapping, timeFormatter: getTimeFormatter(dateFormat), }); return { ...acc, [label]: groupbyLabels.map(col => datum[col] as string), }; }, {}); const { setDataMask = () => {}, onContextMenu } = hooks; const colorFn = CategoricalColorNamespace.getScale(colorScheme as string); let totalValue = 0; const transformedData: PieSeriesOption[] = data.map(datum => { const name = extractGroupbyLabel({ datum, groupby: groupbyLabels, coltypeMapping, timeFormatter: getTimeFormatter(dateFormat), }); const isFiltered = filterState.selectedValues && !filterState.selectedValues.includes(name); const value = datum[metricLabel]; if (typeof value === 'number' || typeof value === 'string') { totalValue += convertInteger(value); } return { value, name, itemStyle: { color: colorFn(name, sliceId), opacity: isFiltered ? OpacityEnum.SemiTransparent : OpacityEnum.NonTransparent, }, }; }); if (otherDatum) { transformedData.push(otherDatum); totalValue += otherSum; } const selectedValues = (filterState.selectedValues || []).reduce( (acc: Record, selectedValue: string) => { const index = transformedData.findIndex( ({ name }) => name === selectedValue, ); return { ...acc, [index]: selectedValue, }; }, {}, ); const formatTemplate = ( template: string, formattedParams: { name: string; value: string; percent: string; }, rawParams: CallbackDataParams, ) => { // This function supports two forms of template variables: // 1. {name}, {value}, {percent}, for values formatted by number formatter. // 2. {a}, {b}, {c}, {d}, compatible with ECharts formatter. // // \n is supported to represent a new line. const items = { '{name}': formattedParams.name, '{value}': formattedParams.value, '{percent}': formattedParams.percent, '{a}': rawParams.seriesName || '', '{b}': rawParams.name, '{c}': `${rawParams.value}`, '{d}': `${rawParams.percent}`, '\\n': '\n', }; return Object.entries(items).reduce( (acc, [key, value]) => acc.replaceAll(key, value), template, ); }; const formatter = (params: CallbackDataParams) => { const [name, formattedValue, formattedPercent] = parseParams({ params, numberFormatter, }); switch (labelType) { case EchartsPieLabelType.Key: return name; case EchartsPieLabelType.Value: return formattedValue; case EchartsPieLabelType.Percent: return formattedPercent; case EchartsPieLabelType.KeyValue: return `${name}: ${formattedValue}`; case EchartsPieLabelType.KeyValuePercent: return `${name}: ${formattedValue} (${formattedPercent})`; case EchartsPieLabelType.KeyPercent: return `${name}: ${formattedPercent}`; case EchartsPieLabelType.ValuePercent: return `${formattedValue} (${formattedPercent})`; case EchartsPieLabelType.Template: if (!labelTemplate) { return ''; } return formatTemplate( labelTemplate, { name, value: formattedValue, percent: formattedPercent, }, params, ); default: return name; } }; const defaultLabel = { formatter, show: showLabels, color: theme.colorText, }; const legendData = transformedData .map(datum => datum.name) .sort((a: string, b: string) => { if (!legendSort) return 0; return legendSort === 'asc' ? a.localeCompare(b) : b.localeCompare(a); }); const { effectiveLegendMargin, effectiveLegendType } = resolveLegendLayout({ chartHeight: height, chartWidth: width, legendItems: legendData, legendMargin, orientation: legendOrientation, show: showLegend, theme, type: legendType, }); const chartPadding = getChartPadding( showLegend, legendOrientation, effectiveLegendMargin, ); const series: PieSeriesOption[] = [ { type: 'pie', ...chartPadding, animation: false, roseType: roseType || undefined, radius: [`${donut ? innerRadius : 0}%`, `${outerRadius}%`], center: ['50%', '50%'], avoidLabelOverlap: true, labelLine: labelsOutside && labelLine ? { show: true } : { show: false }, minShowLabelAngle, label: labelsOutside ? { ...defaultLabel, position: 'outer', alignTo: 'none', bleedMargin: 5, } : { ...defaultLabel, position: 'inner', }, emphasis: { label: { show: true, fontWeight: 'bold', backgroundColor: theme.colorBgContainer, }, }, data: transformedData, }, ]; const echartOptions: EChartsCoreOption = { grid: { ...defaultGrid, }, tooltip: { ...getDefaultTooltip(refs), show: !inContextMenu, trigger: 'item', formatter: (params: any) => { const [name, formattedValue, formattedPercent] = parseParams({ params, numberFormatter, sanitizeName: true, }); if (params?.data?.isOther) { return tooltipHtml(otherTooltipData, name); } return tooltipHtml( [[metricLabel, formattedValue, formattedPercent]], name, ); }, }, legend: { ...getLegendProps( effectiveLegendType, legendOrientation, showLegend, theme, ), data: legendData, }, graphic: showTotal ? { type: 'text', ...getTotalValuePadding({ chartPadding, donut, width, height }), style: { text: t('Total: %s', numberFormatter(totalValue)), fontSize: 16, fontWeight: 'bold', fill: theme.colorText, }, z: 10, } : null, series, }; return { formData, width, height, echartOptions, setDataMask, labelMap, groupby, selectedValues, onContextMenu, refs, emitCrossFilters, coltypeMapping, }; }