mirror of
https://github.com/apache/superset.git
synced 2026-07-27 00:52:33 +00:00
Compare commits
9 Commits
fix/resolv
...
tdd/issue-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
065f44c865 | ||
|
|
424c94d202 | ||
|
|
9826ce9100 | ||
|
|
c346fba656 | ||
|
|
8794b8a729 | ||
|
|
53076ec5e7 | ||
|
|
8c959a3008 | ||
|
|
5881cc8de0 | ||
|
|
b94aa33de9 |
@@ -62,24 +62,29 @@ export function formatLabel({
|
|||||||
params: CallbackDataParams;
|
params: CallbackDataParams;
|
||||||
labelType: EchartsRadarLabelType;
|
labelType: EchartsRadarLabelType;
|
||||||
numberFormatter: NumberFormatter;
|
numberFormatter: NumberFormatter;
|
||||||
getDenormalizedSeriesValue: (seriesName: string, value: string) => number;
|
getDenormalizedSeriesValue: (
|
||||||
|
seriesName: string,
|
||||||
|
value: string,
|
||||||
|
) => number | null;
|
||||||
metricsWithCustomBounds: Set<string>;
|
metricsWithCustomBounds: Set<string>;
|
||||||
metricLabels: string[];
|
metricLabels: string[];
|
||||||
}): string {
|
}): string {
|
||||||
const { name = '', value, dimensionIndex = 0 } = params;
|
const { name = '', value, dimensionIndex = 0 } = params;
|
||||||
const metricLabel = metricLabels[dimensionIndex];
|
const metricLabel = metricLabels[dimensionIndex];
|
||||||
|
|
||||||
const formattedValue = numberFormatter(
|
const rawValue = metricsWithCustomBounds.has(metricLabel)
|
||||||
metricsWithCustomBounds.has(metricLabel)
|
? (value as number | null)
|
||||||
? (value as number)
|
: getDenormalizedSeriesValue(name, String(value));
|
||||||
: (getDenormalizedSeriesValue(name, String(value)) as number),
|
|
||||||
);
|
// A missing metric is preserved as null so it renders as a gap; skip
|
||||||
|
// formatting it into a misleading "NaN" label.
|
||||||
|
const formattedValue = rawValue == null ? '' : numberFormatter(rawValue);
|
||||||
|
|
||||||
switch (labelType) {
|
switch (labelType) {
|
||||||
case EchartsRadarLabelType.Value:
|
case EchartsRadarLabelType.Value:
|
||||||
return formattedValue;
|
return formattedValue;
|
||||||
case EchartsRadarLabelType.KeyValue:
|
case EchartsRadarLabelType.KeyValue:
|
||||||
return `${name}: ${formattedValue}`;
|
return formattedValue ? `${name}: ${formattedValue}` : name;
|
||||||
default:
|
default:
|
||||||
return name;
|
return name;
|
||||||
}
|
}
|
||||||
@@ -134,9 +139,20 @@ export default function transformProps(
|
|||||||
const getDenormalizedSeriesValue = (
|
const getDenormalizedSeriesValue = (
|
||||||
seriesName: string,
|
seriesName: string,
|
||||||
normalizedValue: string,
|
normalizedValue: string,
|
||||||
): number =>
|
): number | null => {
|
||||||
denormalizedSeriesValues?.[seriesName]?.[normalizedValue] ??
|
const seriesMap = denormalizedSeriesValues?.[seriesName];
|
||||||
Number(normalizedValue);
|
// A missing metric is recorded explicitly as `null`, so use
|
||||||
|
// `hasOwnProperty` rather than `??`/`?.` to distinguish "recorded as
|
||||||
|
// null" from "never recorded" (which falls back to parsing the raw
|
||||||
|
// normalized value).
|
||||||
|
if (
|
||||||
|
seriesMap &&
|
||||||
|
Object.prototype.hasOwnProperty.call(seriesMap, normalizedValue)
|
||||||
|
) {
|
||||||
|
return seriesMap[normalizedValue];
|
||||||
|
}
|
||||||
|
return Number(normalizedValue);
|
||||||
|
};
|
||||||
|
|
||||||
const metricLabels = metrics.map(getMetricLabel);
|
const metricLabels = metrics.map(getMetricLabel);
|
||||||
|
|
||||||
@@ -254,15 +270,36 @@ export default function transformProps(
|
|||||||
{},
|
{},
|
||||||
);
|
);
|
||||||
|
|
||||||
const normalizeArray = (arr: number[], decimals = 10, seriesName: string) =>
|
const normalizeArray = (
|
||||||
|
arr: (number | null)[],
|
||||||
|
decimals = 10,
|
||||||
|
seriesName: string,
|
||||||
|
): (number | null)[] =>
|
||||||
arr.map((value, index) => {
|
arr.map((value, index) => {
|
||||||
const metricLabel = metricLabels[index];
|
const metricLabel = metricLabels[index];
|
||||||
if (metricsWithCustomBounds.has(metricLabel)) {
|
if (metricsWithCustomBounds.has(metricLabel)) {
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
const max = Math.max(...arr);
|
// Preserve missing (null/undefined) metric values so they render as a
|
||||||
const normalizedValue = Number((value / max).toFixed(decimals));
|
// gap. Dividing null/undefined by max coerces it to 0, which would plot
|
||||||
|
// the point at the center of the radar as if it were a real zero.
|
||||||
|
// Explicitly record the denormalized entry as null (rather than
|
||||||
|
// leaving it unset) so downstream label/tooltip lookups can tell a
|
||||||
|
// recorded gap apart from an unrecorded value and skip formatting it.
|
||||||
|
if (value == null || !Number.isFinite(value)) {
|
||||||
|
denormalizedSeriesValues[seriesName][String(null)] = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const max = Math.max(
|
||||||
|
...arr.filter((v): v is number => v != null && Number.isFinite(v)),
|
||||||
|
);
|
||||||
|
// A series whose only finite value is a real 0 has max === 0, and
|
||||||
|
// dividing by it turns that 0 into NaN. Pass the value through
|
||||||
|
// unscaled instead so the lone zero still plots at the center.
|
||||||
|
const normalizedValue =
|
||||||
|
max === 0 ? value : Number((value / max).toFixed(decimals));
|
||||||
|
|
||||||
denormalizedSeriesValues[seriesName][String(normalizedValue)] = value;
|
denormalizedSeriesValues[seriesName][String(normalizedValue)] = value;
|
||||||
return normalizedValue;
|
return normalizedValue;
|
||||||
@@ -276,7 +313,11 @@ export default function transformProps(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...series,
|
...series,
|
||||||
value: normalizeArray(series.value as number[], 10, seriesName),
|
value: normalizeArray(
|
||||||
|
series.value as (number | null)[],
|
||||||
|
10,
|
||||||
|
seriesName,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return series;
|
return series;
|
||||||
@@ -356,7 +397,7 @@ export default function transformProps(
|
|||||||
params: CallbackDataParams & {
|
params: CallbackDataParams & {
|
||||||
color: string;
|
color: string;
|
||||||
name: string;
|
name: string;
|
||||||
value: number[];
|
value: (number | null)[];
|
||||||
},
|
},
|
||||||
) =>
|
) =>
|
||||||
renderNormalizedTooltip(
|
renderNormalizedTooltip(
|
||||||
|
|||||||
@@ -85,10 +85,12 @@ export type RadarChartTransformedProps =
|
|||||||
CrossFilterTransformedProps;
|
CrossFilterTransformedProps;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Represents a mapping from a normalized value (as string) to an original numeric value.
|
* Represents a mapping from a normalized value (as string) to an original
|
||||||
|
* numeric value. A `null` entry is an explicit record of a missing metric,
|
||||||
|
* distinct from an absent key (which has not been recorded at all).
|
||||||
*/
|
*/
|
||||||
interface NormalizedValueMap {
|
interface NormalizedValueMap {
|
||||||
[normalized: string]: number;
|
[normalized: string]: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
* specific language governing permissions and limitations
|
* specific language governing permissions and limitations
|
||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
|
import { t } from '@apache-superset/core/translation';
|
||||||
import { NumberFormatter } from '@superset-ui/core';
|
import { NumberFormatter } from '@superset-ui/core';
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -44,7 +45,7 @@ export const findGlobalMax = (
|
|||||||
interface TooltipParams {
|
interface TooltipParams {
|
||||||
color: string;
|
color: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
value: number[];
|
value: (number | null)[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TooltipMetricValue {
|
interface TooltipMetricValue {
|
||||||
@@ -55,7 +56,7 @@ interface TooltipMetricValue {
|
|||||||
export const renderNormalizedTooltip = (
|
export const renderNormalizedTooltip = (
|
||||||
params: TooltipParams,
|
params: TooltipParams,
|
||||||
metrics: string[],
|
metrics: string[],
|
||||||
getDenormalizedValue: (seriesName: string, value: string) => number,
|
getDenormalizedValue: (seriesName: string, value: string) => number | null,
|
||||||
metricsWithCustomBounds: Set<string>,
|
metricsWithCustomBounds: Set<string>,
|
||||||
formatter?: NumberFormatter,
|
formatter?: NumberFormatter,
|
||||||
): string => {
|
): string => {
|
||||||
@@ -73,7 +74,14 @@ export const renderNormalizedTooltip = (
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
metric,
|
metric,
|
||||||
value: formatter ? formatter(originalValue) : originalValue,
|
// A missing metric stays null; show it plainly rather than running it
|
||||||
|
// through the formatter, which would render a misleading "NaN".
|
||||||
|
value:
|
||||||
|
originalValue == null
|
||||||
|
? t('N/A')
|
||||||
|
: formatter
|
||||||
|
? formatter(originalValue)
|
||||||
|
: originalValue,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,13 @@ interface RadarChartConfig {
|
|||||||
interface RadarSeriesData {
|
interface RadarSeriesData {
|
||||||
value: number[];
|
value: number[];
|
||||||
name: string;
|
name: string;
|
||||||
|
label?: {
|
||||||
|
formatter: (params: {
|
||||||
|
name: string;
|
||||||
|
value: number | null;
|
||||||
|
dimensionIndex: number;
|
||||||
|
}) => string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const formData: Partial<EchartsRadarFormData> = {
|
const formData: Partial<EchartsRadarFormData> = {
|
||||||
@@ -204,6 +211,111 @@ describe('legend sorting', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Regression for #30270: "Wrong visualization of missing values in radar
|
||||||
|
// charts". A null metric value must NOT be transformed into 0. In
|
||||||
|
// `normalizeArray`, `null / max` coerces the null to 0, so a missing data
|
||||||
|
// point ends up plotted at the center of the radar as if it were a real
|
||||||
|
// zero, instead of being left out (a gap). This test feeds a datum with a
|
||||||
|
// null metric and asserts the null is preserved in the normalized series
|
||||||
|
// value rather than silently becoming 0.
|
||||||
|
const missingValueData = [
|
||||||
|
{
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
name: 'Series A',
|
||||||
|
'SUM(jp_sales)': 10,
|
||||||
|
'SUM(other_sales)': null,
|
||||||
|
'SUM(eu_sales)': 30,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const missingValueProps = new ChartProps({
|
||||||
|
formData: {
|
||||||
|
...formData,
|
||||||
|
// No columnConfig custom bounds so every metric is normalized.
|
||||||
|
columnConfig: {},
|
||||||
|
groupby: ['name'],
|
||||||
|
metrics: ['SUM(jp_sales)', 'SUM(other_sales)', 'SUM(eu_sales)'],
|
||||||
|
},
|
||||||
|
width: 800,
|
||||||
|
height: 600,
|
||||||
|
queriesData: missingValueData,
|
||||||
|
theme: supersetTheme,
|
||||||
|
});
|
||||||
|
|
||||||
|
test('preserves a null metric instead of plotting it as 0', () => {
|
||||||
|
const result = transformProps(missingValueProps as EchartsRadarChartProps);
|
||||||
|
const series = result.echartOptions.series as RadarSeriesOption[];
|
||||||
|
const value = (series[0].data as RadarSeriesData[])[0].value as (
|
||||||
|
number | null
|
||||||
|
)[];
|
||||||
|
|
||||||
|
// Index 1 corresponds to 'SUM(other_sales)', which was null in the datum.
|
||||||
|
// The correct behavior is to keep the gap (null/undefined) so ECharts does
|
||||||
|
// not draw a point at the center. On master this is 0, so this fails.
|
||||||
|
expect(value[1]).not.toBe(0);
|
||||||
|
expect(value[1] == null).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps a lone zero metric as 0 instead of NaN when all others are null', () => {
|
||||||
|
// Edge case: when a series' only non-null metric is a real 0, the
|
||||||
|
// per-series max is 0 and `0 / 0` is NaN. The zero must survive
|
||||||
|
// normalization as a plottable 0 while the nulls stay gaps.
|
||||||
|
const props = new ChartProps({
|
||||||
|
formData: {
|
||||||
|
...formData,
|
||||||
|
columnConfig: {},
|
||||||
|
groupby: ['name'],
|
||||||
|
metrics: ['SUM(jp_sales)', 'SUM(other_sales)', 'SUM(eu_sales)'],
|
||||||
|
},
|
||||||
|
width: 800,
|
||||||
|
height: 600,
|
||||||
|
queriesData: [
|
||||||
|
{
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
name: 'Series A',
|
||||||
|
'SUM(jp_sales)': null,
|
||||||
|
'SUM(other_sales)': 0,
|
||||||
|
'SUM(eu_sales)': null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
theme: supersetTheme,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = transformProps(props as EchartsRadarChartProps);
|
||||||
|
const series = result.echartOptions.series as RadarSeriesOption[];
|
||||||
|
const value = (series[0].data as RadarSeriesData[])[0].value as (
|
||||||
|
number | null
|
||||||
|
)[];
|
||||||
|
|
||||||
|
expect(value[0] == null).toBe(true);
|
||||||
|
expect(value[1]).toBe(0);
|
||||||
|
expect(value[2] == null).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('label formatter renders a missing metric as blank instead of NaN', () => {
|
||||||
|
const result = transformProps(missingValueProps as EchartsRadarChartProps);
|
||||||
|
const series = result.echartOptions.series as RadarSeriesOption[];
|
||||||
|
const seriesData = (series[0].data as RadarSeriesData[])[0];
|
||||||
|
const { label } = seriesData;
|
||||||
|
if (!label) throw new Error('expected series data to have a label config');
|
||||||
|
|
||||||
|
// Index 1 corresponds to 'SUM(other_sales)', which is null. Denormalizing
|
||||||
|
// it must not fall through to `Number('null')` (NaN) in the label text.
|
||||||
|
const formatted = label.formatter({
|
||||||
|
name: 'Series A',
|
||||||
|
value: null,
|
||||||
|
dimensionIndex: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(formatted).not.toContain('NaN');
|
||||||
|
});
|
||||||
|
|
||||||
describe('radar center positioning', () => {
|
describe('radar center positioning', () => {
|
||||||
const getCenter = (overrides: Partial<EchartsRadarFormData> = {}) => {
|
const getCenter = (overrides: Partial<EchartsRadarFormData> = {}) => {
|
||||||
const props = new ChartProps({
|
const props = new ChartProps({
|
||||||
|
|||||||
@@ -52,4 +52,22 @@ describe('renderNormalizedTooltip', () => {
|
|||||||
expect(tooltip).toContain('100');
|
expect(tooltip).toContain('100');
|
||||||
expect(tooltip).toContain('200');
|
expect(tooltip).toContain('200');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should render a missing metric as N/A instead of NaN', () => {
|
||||||
|
// A missing metric is denormalized to `null` (see #30270); the tooltip
|
||||||
|
// must not run it through the formatter, which would print "NaN".
|
||||||
|
const getDenormalizedValueWithGap = jest.fn((_, value) =>
|
||||||
|
value === 'null' ? null : Number(value),
|
||||||
|
);
|
||||||
|
const formatter = getNumberFormatter(',.2f');
|
||||||
|
const tooltip = renderNormalizedTooltip(
|
||||||
|
{ ...params, value: [100, null] },
|
||||||
|
metrics,
|
||||||
|
getDenormalizedValueWithGap,
|
||||||
|
metricsWithCustomBounds,
|
||||||
|
formatter,
|
||||||
|
);
|
||||||
|
expect(tooltip).toContain('N/A');
|
||||||
|
expect(tooltip).not.toContain('NaN');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user