mirror of
https://github.com/apache/superset.git
synced 2026-04-17 23:25:05 +00:00
fix(Radar): Radar chart normalisation (#33559)
Co-authored-by: Amaan Nawab <nelsondrew07@gmail.com>
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
`;
|
||||
};
|
||||
Reference in New Issue
Block a user