diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Heatmap/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Heatmap/transformProps.ts index 118082015cd..60c6aceaf29 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Heatmap/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Heatmap/transformProps.ts @@ -46,6 +46,12 @@ type EChartsOption = ComposeOption; const DEFAULT_ECHARTS_BOUNDS = [0, 200]; +/** + * Column name for the rank values added by the backend's rank post-processing operation. + * This is used when the heatmap is in normalized mode to color cells by percentile rank. + */ +const RANK_COLUMN_NAME = 'rank'; + /** * Extract unique values for an axis from the data. * Filters out null and undefined values. @@ -212,7 +218,7 @@ export default function transformProps( currencyFormats = {}, currencyCodeColumn, } = datasource; - const colorColumn = normalized ? 'rank' : metricLabel; + const colorColumn = normalized ? RANK_COLUMN_NAME : metricLabel; const colors = getSequentialSchemeRegistry().get(linearColorScheme)?.colors; const getAxisFormatter = (colType: GenericDataType) => (value: number | string) => { @@ -291,6 +297,7 @@ export default function transformProps( const xValue = row[xAxisColumnName]; const yValue = row[yAxisColumnName]; const metricValue = row[metricLabel]; + const rankValue = row[RANK_COLUMN_NAME]; // Convert to axis indices for ECharts when explicit axis data is provided const xIndex = xAxisIndexMap.get(xValue); @@ -304,8 +311,21 @@ export default function transformProps( ); return []; } - return [[xIndex, yIndex, metricValue] as [number, number, any]]; - }), + if (normalized && rankValue === undefined) { + logging.error( + `Heatmap: Skipping row due to missing rank value. xValue: ${xValue}, yValue: ${yValue}, metricValue: ${metricValue}`, + row, + ); + return []; + } + + // Include rank as 4th dimension when normalized is enabled + // This allows visualMap to use dimension: 3 to color by rank percentile + if (normalized) { + return [[xIndex, yIndex, metricValue, rankValue]]; + } + return [[xIndex, yIndex, metricValue]]; + }) as any, label: { show: showValues, formatter: (params: CallbackDataParams) => { @@ -336,6 +356,9 @@ export default function transformProps( bottom: bottomMargin, left: leftMargin, }, + legend: { + show: false, + }, series, tooltip: { ...getDefaultTooltip(refs), diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/Heatmap/buildQuery.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/Heatmap/buildQuery.test.ts new file mode 100644 index 00000000000..d4d64f37419 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-echarts/test/Heatmap/buildQuery.test.ts @@ -0,0 +1,82 @@ +/** + * 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 { QueryFormData } from '@superset-ui/core'; +import buildQuery from '../../src/Heatmap/buildQuery'; + +describe('Heatmap buildQuery - Rank Operation for Normalized Field', () => { + const baseFormData = { + datasource: '5__table', + granularity_sqla: 'ds', + metric: 'count', + x_axis: 'category', + groupby: ['region'], + viz_type: 'heatmap', + } as QueryFormData; + + test('should ALWAYS include rank operation when normalized=true', () => { + const formData = { + ...baseFormData, + normalized: true, + }; + + const queryContext = buildQuery(formData); + const [query] = queryContext.queries; + + const rankOperation = query.post_processing?.find( + op => op?.operation === 'rank', + ); + + expect(rankOperation).toBeDefined(); + expect(rankOperation?.operation).toBe('rank'); + }); + + test('should ALWAYS include rank operation when normalized=false', () => { + const formData = { + ...baseFormData, + normalized: false, + }; + + const queryContext = buildQuery(formData); + const [query] = queryContext.queries; + + const rankOperation = query.post_processing?.find( + op => op?.operation === 'rank', + ); + + expect(rankOperation).toBeDefined(); + expect(rankOperation?.operation).toBe('rank'); + }); + + test('should ALWAYS include rank operation when normalized is undefined', () => { + const formData = { + ...baseFormData, + // normalized not set + }; + + const queryContext = buildQuery(formData); + const [query] = queryContext.queries; + + const rankOperation = query.post_processing?.find( + op => op?.operation === 'rank', + ); + + expect(rankOperation).toBeDefined(); + expect(rankOperation?.operation).toBe('rank'); + }); +}); diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/Heatmap/transformProps.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/Heatmap/transformProps.test.ts index 23912ea8aaa..2fa5775ade1 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/test/Heatmap/transformProps.test.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/test/Heatmap/transformProps.test.ts @@ -291,4 +291,72 @@ describe('Heatmap transformProps', () => { // Y-axis: numbers sorted numerically (1, 2, 10 NOT 1, 10, 2) expect(yAxisData).toEqual([1, 2, 10]); }); + + test('should include rank as 4th dimension when normalized is true', () => { + const dataWithRank = [ + { day_of_week: 'Monday', hour: 9, count: 10, rank: 0.33 }, + { day_of_week: 'Monday', hour: 14, count: 15, rank: 0.67 }, + { day_of_week: 'Wednesday', hour: 11, count: 8, rank: 0.17 }, + { day_of_week: 'Friday', hour: 16, count: 20, rank: 1.0 }, + ]; + + const chartProps = createChartProps({ normalized: true }, dataWithRank); + + const result = transformProps(chartProps as HeatmapChartProps); + + const seriesData = (result.echartOptions.series as any)[0].data; + + // Each data point should be [xIndex, yIndex, metricValue, rankValue] + expect(Array.isArray(seriesData)).toBe(true); + expect(seriesData.length).toBe(4); + + // Check that data points have 4 dimensions when normalized + seriesData.forEach((point: any) => { + expect(Array.isArray(point)).toBe(true); + expect(point.length).toBe(4); + // First two should be indices (numbers) + expect(typeof point[0]).toBe('number'); + expect(typeof point[1]).toBe('number'); + // Third should be the metric value + expect(typeof point[2]).toBe('number'); + // Fourth should be the rank value + expect(typeof point[3]).toBe('number'); + expect(point[3]).toBeGreaterThanOrEqual(0); + expect(point[3]).toBeLessThanOrEqual(1); + }); + + // visualMap should use dimension 3 (4th element) for coloring + expect((result.echartOptions.visualMap as any).dimension).toBe(3); + }); + + test('should use 3 dimensions when normalized is false', () => { + const chartProps = createChartProps({ normalized: false }); + const result = transformProps(chartProps as HeatmapChartProps); + + const seriesData = (result.echartOptions.series as any)[0].data; + + // Each data point should be [xIndex, yIndex, metricValue] + seriesData.forEach((point: any) => { + expect(point.length).toBe(3); + }); + + // visualMap should use dimension 2 (3rd element) for coloring + expect((result.echartOptions.visualMap as any).dimension).toBe(2); + }); + + test('should always hide legend regardless of showLegend setting', () => { + // Test with showLegend: true + const chartPropsWithLegend = createChartProps({ showLegend: true }); + const resultWithLegend = transformProps( + chartPropsWithLegend as HeatmapChartProps, + ); + expect((resultWithLegend.echartOptions.legend as any).show).toBe(false); + + // Test with showLegend: false + const chartPropsWithoutLegend = createChartProps({ showLegend: false }); + const resultWithoutLegend = transformProps( + chartPropsWithoutLegend as HeatmapChartProps, + ); + expect((resultWithoutLegend.echartOptions.legend as any).show).toBe(false); + }); });