-
Notifications
You must be signed in to change notification settings - Fork 16.7k
fix(Radar): Radar chart normalisation #33559
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
7a9f687
1cec095
696d5d9
672c16f
3df058e
dc5820d
dce9fe3
7f51dca
e88e98d
4e92bb6
6d6d237
0e4927a
e0fd65f
b374343
9daf1a6
0c1f934
d0e4da2
11f3ba2
009dec2
11dd197
0cc2183
4cd764d
022ed85
b7d608a
e0959d2
a6a13a6
9c6606e
66707a8
a82c59d
edde6c9
b158611
bf2fb5e
86478bd
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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, | ||
amaannawab923 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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; | ||
| }); | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We normalise the values for each series only if we have isNormalised as true |
||
|
|
||
| // 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, | ||
| }; | ||
| } | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If the values are normalised we consider min & max as 0 & 1 respectively & hence we dont need to calculate the rest as they are only in case when isNormalised is not true & some column config is provided |
||
| 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), | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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; | ||
| } | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Interfaces of Series -> Normalised -> denormalised values |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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} | ||
| `; | ||
| }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We use an Object to store the denormalised values belonging to each series because after normalising we need to pass the denormalised values to the label formatter & tooltip formatter to keep the chart consistent & preserve original values