fix(Radar): Radar chart normalisation (#33559)

Co-authored-by: Amaan Nawab <nelsondrew07@gmail.com>
This commit is contained in:
amaannawab923
2025-05-27 00:31:17 +05:30
committed by GitHub
parent 57183da315
commit bdfb698aa4
4 changed files with 336 additions and 17 deletions

View File

@@ -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<string>;
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<string, number>();
@@ -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),

View File

@@ -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<EchartsRadarFormData> &
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;
}

View File

@@ -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<string, unknown>[],
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>,
): string => {
const { color, name = '', value: values } = params;
const seriesName = name || 'series0';
const colorDot = `<span style="display:inline-block;margin-right:5px;border-radius:50%;width:5px;height:5px;background-color:${color}"></span>`;
// 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 }) => `
<div style="display:flex;">
<div>${colorDot}${metric}:</div>
<div style="font-weight:bold;margin-left:auto;">${value}</div>
</div>
`,
)
.join('');
return `
<div style="font-weight:bold;margin-bottom:5px;">${seriesName}</div>
${tooltipRows}
`;
};

View File

@@ -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<EchartsRadarFormData> = {
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,
},
]);
});
});