fix(heatmap): y-axis sorts in order (#36302)

Co-authored-by: Evan Rusackas <evan@preset.io>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Sam Firke
2025-12-04 12:52:10 -05:00
committed by GitHub
parent 1d8d30e5bb
commit 3345eb32c5
3 changed files with 443 additions and 13 deletions

View File

@@ -24,9 +24,11 @@ import {
getSequentialSchemeRegistry,
getTimeFormatter,
getValueFormatter,
logging,
rgbToHex,
addAlpha,
tooltipHtml,
DataRecordValue,
} from '@superset-ui/core';
import { GenericDataType } from '@apache-superset/core/api/core';
import memoizeOne from 'memoize-one';
@@ -38,13 +40,104 @@ import { HeatmapChartProps, HeatmapTransformedProps } from './types';
import { getDefaultTooltip } from '../utils/tooltip';
import { Refs } from '../types';
import { parseAxisBound } from '../utils/controls';
import { NULL_STRING } from '../constants';
import { getPercentFormatter } from '../utils/formatters';
type EChartsOption = ComposeOption<HeatmapSeriesOption>;
const DEFAULT_ECHARTS_BOUNDS = [0, 200];
/**
* Extract unique values for an axis from the data.
* Filters out null and undefined values.
*
* @param data - The dataset to extract values from
* @param columnName - The column to extract unique values from
* @returns Array of unique values from the specified column
*/
function extractUniqueValues(
data: Record<string, DataRecordValue>[],
columnName: string,
): DataRecordValue[] {
const uniqueSet = new Set<DataRecordValue>();
data.forEach(row => {
const value = row[columnName];
if (value !== null && value !== undefined) {
uniqueSet.add(value);
}
});
return Array.from(uniqueSet);
}
/**
* Sort axis values based on the sort configuration.
* Supports alphabetical (with numeric awareness) and metric value-based sorting.
*
* @param values - The unique values to sort
* @param data - The full dataset
* @param sortOption - Sort option string (e.g., 'alpha_asc', 'value_desc')
* @param metricLabel - Label of the metric for value-based sorting
* @param axisColumn - Column name for the axis being sorted
* @returns Sorted array of values
*/
function sortAxisValues(
values: DataRecordValue[],
data: Record<string, DataRecordValue>[],
sortOption: string | undefined,
metricLabel: string,
axisColumn: string,
): DataRecordValue[] {
if (!sortOption) {
// No sorting specified, return values as they appear in the data
return values;
}
const isAscending = sortOption.includes('asc');
const isValueSort = sortOption.includes('value');
if (isValueSort) {
// Sort by metric value - aggregate metric values for each axis category
const valueMap = new Map<DataRecordValue, number>();
data.forEach(row => {
const axisValue = row[axisColumn];
const metricValue = row[metricLabel];
if (
axisValue !== null &&
axisValue !== undefined &&
typeof metricValue === 'number'
) {
const current = valueMap.get(axisValue) || 0;
valueMap.set(axisValue, current + metricValue);
}
});
return [...values].sort((a, b) => {
const aValue = valueMap.get(a) || 0;
const bValue = valueMap.get(b) || 0;
return isAscending ? aValue - bValue : bValue - aValue;
});
}
// Alphabetical/lexicographic sort
return [...values].sort((a, b) => {
// Check if both values are numeric for proper numeric sorting
const aNum = typeof a === 'number' ? a : Number(a);
const bNum = typeof b === 'number' ? b : Number(b);
const aIsNumeric = Number.isFinite(aNum);
const bIsNumeric = Number.isFinite(bNum);
if (aIsNumeric && bIsNumeric) {
// Both are numeric, sort numerically
return isAscending ? aNum - bNum : bNum - aNum;
}
// At least one is non-numeric, use locale-aware string comparison
const aStr = String(a);
const bStr = String(b);
const comparison = aStr.localeCompare(bStr, undefined, { numeric: true });
return isAscending ? comparison : -comparison;
});
}
// Calculated totals per x and y categories plus total
const calculateTotals = memoizeOne(
(
@@ -101,6 +194,8 @@ export default function transformProps(
xAxisTimeFormat,
xAxisLabelRotation,
currencyFormat,
sortXAxis,
sortYAxis,
} = formData;
const metricLabel = getMetricLabel(metric);
const xAxisLabel = getColumnLabel(xAxis);
@@ -144,22 +239,60 @@ export default function transformProps(
DEFAULT_ECHARTS_BOUNDS[1];
}
// Extract and sort unique axis values
// Use colnames to get the actual column names in the data
const xAxisColumnName = colnames[0];
const yAxisColumnName = colnames[1];
const xAxisValues = extractUniqueValues(data, xAxisColumnName);
const yAxisValues = extractUniqueValues(data, yAxisColumnName);
const sortedXAxisValues = sortAxisValues(
xAxisValues,
data,
sortXAxis,
metricLabel,
xAxisColumnName,
);
const sortedYAxisValues = sortAxisValues(
yAxisValues,
data,
sortYAxis,
metricLabel,
yAxisColumnName,
);
// Create lookup maps for axis indices
const xAxisIndexMap = new Map<DataRecordValue, number>(
sortedXAxisValues.map((value, index) => [value, index]),
);
const yAxisIndexMap = new Map<DataRecordValue, number>(
sortedYAxisValues.map((value, index) => [value, index]),
);
const series: HeatmapSeriesOption[] = [
{
name: metricLabel,
type: 'heatmap',
data: data.map(row =>
colnames.map(col => {
const value = row[col];
if (value === null || value === undefined) {
return NULL_STRING;
}
if (typeof value === 'boolean' || typeof value === 'bigint') {
return String(value);
}
return value;
}),
),
data: data.flatMap(row => {
const xValue = row[xAxisColumnName];
const yValue = row[yAxisColumnName];
const metricValue = row[metricLabel];
// Convert to axis indices for ECharts when explicit axis data is provided
const xIndex = xAxisIndexMap.get(xValue);
const yIndex = yAxisIndexMap.get(yValue);
if (xIndex === undefined || yIndex === undefined) {
// Log a warning for debugging
logging.warn(
`Heatmap: Skipping row due to missing axis value. xValue: ${xValue}, yValue: ${yValue}, metricValue: ${metricValue}`,
row,
);
return [];
}
return [[xIndex, yIndex, metricValue] as [number, number, any]];
}),
label: {
show: showValues,
formatter: (params: CallbackDataParams) => {
@@ -249,6 +382,7 @@ export default function transformProps(
},
xAxis: {
type: 'category',
data: sortedXAxisValues,
axisLabel: {
formatter: xAxisFormatter,
interval: xscaleInterval === -1 ? 'auto' : xscaleInterval - 1,
@@ -257,6 +391,7 @@ export default function transformProps(
},
yAxis: {
type: 'category',
data: sortedYAxisValues,
axisLabel: {
formatter: yAxisFormatter,
interval: yscaleInterval === -1 ? 'auto' : yscaleInterval - 1,