diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Radar/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Radar/transformProps.ts index 7b7af34ae5f6..2831aa7d455c 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Radar/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Radar/transformProps.ts @@ -24,6 +24,7 @@ import { getNumberFormatter, getTimeFormatter, NumberFormatter, + isDefined, } from '@superset-ui/core'; import type { CallbackDataParams } from 'echarts/types/src/util/types'; import type { RadarSeriesDataItemOption } from 'echarts/types/src/chart/radar/RadarSeries'; @@ -35,6 +36,7 @@ import { EchartsRadarFormData, EchartsRadarLabelType, RadarChartTransformedProps, + SeriesNormalizedMap, } from './types'; import { DEFAULT_LEGEND_FORM_DATA, OpacityEnum } from '../constants'; import { @@ -46,18 +48,31 @@ import { import { defaultGrid } from '../defaults'; import { Refs } from '../types'; import { getDefaultTooltip } from '../utils/tooltip'; +import { findGlobalMax, renderNormalizedTooltip } from './utils'; export function formatLabel({ params, labelType, numberFormatter, + getDenormalizedSeriesValue, + metricsWithCustomBounds, + metricLabels, }: { params: CallbackDataParams; labelType: EchartsRadarLabelType; numberFormatter: NumberFormatter; + getDenormalizedSeriesValue: (seriesName: string, value: string) => number; + metricsWithCustomBounds: Set; + metricLabels: string[]; }): string { - const { name = '', value } = params; - const formattedValue = numberFormatter(value as number); + const { name = '', value, dimensionIndex = 0 } = params; + const metricLabel = metricLabels[dimensionIndex]; + + const formattedValue = numberFormatter( + metricsWithCustomBounds.has(metricLabel) + ? (value as number) + : (getDenormalizedSeriesValue(name, String(value)) as number), + ); switch (labelType) { case EchartsRadarLabelType.Value: @@ -85,6 +100,7 @@ export default function transformProps( } = chartProps; const refs: Refs = {}; const { data = [] } = queriesData[0]; + const globalMax = findGlobalMax(data, Object.keys(data[0] || {})); const coltypeMapping = getColtypesMapping(queriesData[0]); const { @@ -111,14 +127,38 @@ export default function transformProps( const { setDataMask = () => {}, onContextMenu } = hooks; const colorFn = CategoricalColorNamespace.getScale(colorScheme as string); const numberFormatter = getNumberFormatter(numberFormat); + const denormalizedSeriesValues: SeriesNormalizedMap = {}; + + const getDenormalizedSeriesValue = ( + seriesName: string, + normalizedValue: string, + ): number => + denormalizedSeriesValues?.[seriesName]?.[normalizedValue] ?? + Number(normalizedValue); + + const metricLabels = metrics.map(getMetricLabel); + + const metricsWithCustomBounds = new Set( + metricLabels.filter(metricLabel => { + const config = columnConfig?.[metricLabel]; + const hasMax = !!isDefined(config?.radarMetricMaxValue); + const hasMin = + isDefined(config?.radarMetricMinValue) && + config?.radarMetricMinValue !== 0; + return hasMax || hasMin; + }), + ); + const formatter = (params: CallbackDataParams) => formatLabel({ params, numberFormatter, labelType, + getDenormalizedSeriesValue, + metricsWithCustomBounds, + metricLabels, }); - const metricLabels = metrics.map(getMetricLabel); const groupbyLabels = groupby.map(getColumnLabel); const metricLabelAndMaxValueMap = new Map(); @@ -212,28 +252,58 @@ export default function transformProps( {}, ); + const normalizeArray = (arr: number[], decimals = 10, seriesName: string) => + arr.map((value, index) => { + const metricLabel = metricLabels[index]; + if (metricsWithCustomBounds.has(metricLabel)) { + return value; + } + + const max = Math.max(...arr); + const normalizedValue = Number((value / max).toFixed(decimals)); + + denormalizedSeriesValues[seriesName][String(normalizedValue)] = value; + return normalizedValue; + }); + + // Normalize the transformed data + const normalizedTransformedData = transformedData.map(series => { + if (Array.isArray(series.value)) { + const seriesName = String(series?.name || ''); + denormalizedSeriesValues[seriesName] = {}; + + return { + ...series, + value: normalizeArray(series.value as number[], 10, seriesName), + }; + } + return series; + }); + const indicator = metricLabels.map(metricLabel => { + const isMetricWithCustomBounds = metricsWithCustomBounds.has(metricLabel); + if (!isMetricWithCustomBounds) { + return { + name: metricLabel, + max: 1, + min: 0, + }; + } const maxValueInControl = columnConfig?.[metricLabel]?.radarMetricMaxValue; const minValueInControl = columnConfig?.[metricLabel]?.radarMetricMinValue; // Ensure that 0 is at the center of the polar coordinates - const metricValueAsMax = + const maxValue = metricLabelAndMaxValueMap.get(metricLabel) === 0 ? Number.MAX_SAFE_INTEGER - : metricLabelAndMaxValueMap.get(metricLabel); - const max = - maxValueInControl === null ? metricValueAsMax : maxValueInControl; + : globalMax; + const max = isDefined(maxValueInControl) ? maxValueInControl : maxValue; let min: number; - // If the min value doesn't exist, set it to 0 (default), - // if it is null, set it to the min value of the data, - // otherwise, use the value from the control - if (minValueInControl === undefined) { - min = 0; - } else if (minValueInControl === null) { - min = metricLabelAndMinValueMap.get(metricLabel) || 0; - } else { + if (isDefined(minValueInControl)) { min = minValueInControl; + } else { + min = 0; } return { @@ -255,10 +325,24 @@ export default function transformProps( backgroundColor: theme.colors.grayscale.light5, }, }, - data: transformedData, + data: normalizedTransformedData, }, ]; + const NormalizedTooltipFormater = ( + params: CallbackDataParams & { + color: string; + name: string; + value: number[]; + }, + ) => + renderNormalizedTooltip( + params, + metricLabels, + getDenormalizedSeriesValue, + metricsWithCustomBounds, + ); + const echartOptions: EChartsCoreOption = { grid: { ...defaultGrid, @@ -267,6 +351,7 @@ export default function transformProps( ...getDefaultTooltip(refs), show: !inContextMenu, trigger: 'item', + formatter: NormalizedTooltipFormater, }, legend: { ...getLegendProps(legendType, legendOrientation, showLegend, theme), diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Radar/types.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Radar/types.ts index 19812012bba3..0f335683fe32 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Radar/types.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Radar/types.ts @@ -35,7 +35,7 @@ import { DEFAULT_LEGEND_FORM_DATA } from '../constants'; type RadarColumnConfig = Record< string, - { radarMetricMaxValue?: number; radarMetricMinValue?: number } + { radarMetricMaxValue?: number | null; radarMetricMinValue?: number } >; export type EchartsRadarFormData = QueryFormData & @@ -53,6 +53,7 @@ export type EchartsRadarFormData = QueryFormData & isCircle: boolean; numberFormat: string; dateFormat: string; + isNormalized: boolean; }; export enum EchartsRadarLabelType { @@ -83,3 +84,17 @@ export type RadarChartTransformedProps = BaseTransformedProps & ContextMenuTransformedProps & CrossFilterTransformedProps; + +/** + * Represents a mapping from a normalized value (as string) to an original numeric value. + */ +interface NormalizedValueMap { + [normalized: string]: number; +} + +/** + * Represents a collection of series, each containing its own NormalizedValueMap. + */ +export interface SeriesNormalizedMap { + [seriesName: string]: NormalizedValueMap; +} diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Radar/utils.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Radar/utils.ts new file mode 100644 index 000000000000..343d9bbd392d --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Radar/utils.ts @@ -0,0 +1,92 @@ +/** + * 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. + */ +/* + function for finding the max metric values among all series data for Radar Chart +*/ +export const findGlobalMax = ( + data: Record[], + metrics: string[], +): number => { + if (!data?.length || !metrics?.length) return 0; + + return data.reduce((globalMax, row) => { + const rowMax = metrics.reduce((max, metric) => { + const value = row[metric]; + return typeof value === 'number' && + Number.isFinite(value) && + !Number.isNaN(value) + ? Math.max(max, value) + : max; + }, 0); + + return Math.max(globalMax, rowMax); + }, 0); +}; + +interface TooltipParams { + color: string; + name?: string; + value: number[]; +} + +interface TooltipMetricValue { + metric: string; + value: number; +} + +export const renderNormalizedTooltip = ( + params: TooltipParams, + metrics: string[], + getDenormalizedValue: (seriesName: string, value: string) => number, + metricsWithCustomBounds: Set, +): string => { + const { color, name = '', value: values } = params; + const seriesName = name || 'series0'; + + const colorDot = ``; + + // Get metric values with denormalization if needed + const metricValues: TooltipMetricValue[] = metrics.map((metric, index) => { + const value = values[index]; + const originalValue = metricsWithCustomBounds.has(metric) + ? value + : getDenormalizedValue(name, String(value)); + + return { + metric, + value: originalValue, + }; + }); + + const tooltipRows = metricValues + .map( + ({ metric, value }) => ` +
+
${colorDot}${metric}:
+
${value}
+
+ `, + ) + .join(''); + + return ` +
${seriesName}
+ ${tooltipRows} + `; +}; diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/Radar/transformProps.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/Radar/transformProps.test.ts new file mode 100644 index 000000000000..c66e60b1c7e5 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-echarts/test/Radar/transformProps.test.ts @@ -0,0 +1,127 @@ +/** + * 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 { ChartProps, supersetTheme } from '@superset-ui/core'; +import { RadarSeriesOption } from 'echarts/charts'; +import transformProps from '../../src/Radar/transformProps'; +import { + EchartsRadarChartProps, + EchartsRadarFormData, +} from '../../src/Radar/types'; + +interface RadarIndicator { + name: string; + max: number; + min: number; +} + +type RadarShape = 'circle' | 'polygon'; + +interface RadarChartConfig { + shape: RadarShape; + indicator: RadarIndicator[]; +} + +interface RadarSeriesData { + value: number[]; + name: string; +} + +describe('Radar transformProps', () => { + const formData: Partial = { + colorScheme: 'supersetColors', + datasource: '3__table', + granularity_sqla: 'ds', + columnConfig: { + 'MAX(na_sales)': { + radarMetricMaxValue: null, + radarMetricMinValue: 0, + }, + 'SUM(eu_sales)': { + radarMetricMaxValue: 5000, + }, + }, + groupby: [], + metrics: [ + 'MAX(na_sales)', + 'SUM(jp_sales)', + 'SUM(other_sales)', + 'SUM(eu_sales)', + ], + viz_type: 'radar', + numberFormat: 'SMART_NUMBER', + dateFormat: 'smart_date', + showLegend: true, + showLabels: true, + isCircle: false, + }; + + const chartProps = new ChartProps({ + formData, + width: 800, + height: 600, + queriesData: [ + { + data: [ + { + 'MAX(na_sales)': 41.49, + 'SUM(jp_sales)': 1290.99, + 'SUM(other_sales)': 797.73, + 'SUM(eu_sales)': 2434.13, + }, + ], + }, + ], + theme: supersetTheme, + }); + + it('should transform chart props for normalized radar chart & normalize all metrics except the ones with custom min & max', () => { + const transformedProps = transformProps( + chartProps as EchartsRadarChartProps, + ); + const series = transformedProps.echartOptions.series as RadarSeriesOption[]; + const radar = transformedProps.echartOptions.radar as RadarChartConfig; + + expect((series[0].data as RadarSeriesData[])[0].value).toEqual([ + 0.0170451044, 0.5303701939, 0.3277269497, 2434.13, + ]); + + expect(radar.indicator).toEqual([ + { + name: 'MAX(na_sales)', + max: 1, + min: 0, + }, + { + name: 'SUM(jp_sales)', + max: 1, + min: 0, + }, + { + name: 'SUM(other_sales)', + max: 1, + min: 0, + }, + { + name: 'SUM(eu_sales)', + max: 5000, + min: 0, + }, + ]); + }); +});