From 20ef82946c9b987ad62765ef3e42265e11a38d09 Mon Sep 17 00:00:00 2001 From: lhawkins Date: Wed, 1 Apr 2026 16:44:02 -0400 Subject: [PATCH 1/2] Fix point size scaling when filtering to single value in dashboard When dashboard filters reduce data to a single point (or all points sharing the same size value), computeSizeScale now returns the midpoint of startSize/endSize instead of the minimum. The legend collapses to a static swatch instead of empty graduated icons, with singleValueColor ensuring the swatch matches the actual rendered point color. Also tightens singleValueColor type from number[] to RGBAColor, extracts shared helpers for single-value swatch rendering, and adds test coverage for all single-value legend paths in both Legend and MultiLegend. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/components/Legend.tsx | 71 ++++++-- .../src/components/MultiLegend.tsx | 30 +++- .../geoset-map-chart/src/transformProps.ts | 39 +++- .../geoset-map-chart/src/utils/colors.ts | 4 +- .../test/components/Legend.test.tsx | 87 ++++++++- .../test/components/MultiLegend.test.tsx | 168 ++++++++++++++++++ .../test/utils/colors.test.ts | 13 +- .../test/utils/percentileBounds.test.ts | 5 +- 8 files changed, 386 insertions(+), 31 deletions(-) diff --git a/superset-frontend/plugins/geoset-map-chart/src/components/Legend.tsx b/superset-frontend/plugins/geoset-map-chart/src/components/Legend.tsx index bf6f3172fa..e135edac2b 100644 --- a/superset-frontend/plugins/geoset-map-chart/src/components/Legend.tsx +++ b/superset-frontend/plugins/geoset-map-chart/src/components/Legend.tsx @@ -103,6 +103,9 @@ export type SizeLegend = { endSize: number; valueColumn: string; legendTitle?: string; + legendName?: string; + /** Actual computed color of the single remaining point (when noSizeRange). */ + singleValueColor?: RGBAColor; usesPercentBounds?: boolean; }; @@ -154,6 +157,23 @@ const Legend = ({ return formatNumber(d3Format, numValue); }; + /** Render a Swatch + label row for single-value size legends. */ + const renderSingleValueRow = ( + swatchFill: RGBAColor, + swatchStroke: RGBAColor, + label: string, + ) => ( +
+ + {label} +
+ ); + // --- Render metric legend if present and not forcing categorical --- const metricLegendContent = metricLegend ? (
@@ -190,8 +210,8 @@ const Legend = ({ ) : null; // --- Render size legend if present --- - const sizeLegendContent = - sizeLegend && sizeLegend.startSize !== sizeLegend.endSize ? ( + const sizeLegendContent = sizeLegend ? ( + sizeLegend.startSize !== sizeLegend.endSize ? (
{sizeLegend.legendTitle || toTitleCase(sizeLegend.valueColumn)} @@ -207,7 +227,24 @@ const Legend = ({ usesPercentBounds={sizeLegend.usesPercentBounds} />
- ) : null; + ) : ( + // Single-value fallback: show a standard entry instead of graduated icons +
+
+ {sizeLegend.legendTitle || toTitleCase(sizeLegend.valueColumn)} +
+ {renderSingleValueRow( + sizeLegend.singleValueColor ?? + metricLegend?.startColor ?? + fillColor, + sizeLegend.singleValueColor ?? + metricLegend?.startColor ?? + strokeColor, + sizeLegend.legendName || toTitleCase(sizeLegend.valueColumn), + )} +
+ ) + ) : null; // --- Combined metric+size: 4 gradient-colored circles replacing gradient bar + size circles --- const combinedMetricSizeContent = @@ -218,15 +255,25 @@ const Legend = ({ metricLegend.legendName || toTitleCase(sizeLegend.valueColumn)}
- + {sizeLegend.startSize !== sizeLegend.endSize ? ( + + ) : ( + renderSingleValueRow( + sizeLegend.singleValueColor ?? metricLegend.startColor, + sizeLegend.singleValueColor ?? metricLegend.startColor, + sizeLegend.legendName || + metricLegend.legendName || + toTitleCase(sizeLegend.valueColumn), + ) + )}
) : null; diff --git a/superset-frontend/plugins/geoset-map-chart/src/components/MultiLegend.tsx b/superset-frontend/plugins/geoset-map-chart/src/components/MultiLegend.tsx index f25d204bcd..befa2a92a2 100644 --- a/superset-frontend/plugins/geoset-map-chart/src/components/MultiLegend.tsx +++ b/superset-frontend/plugins/geoset-map-chart/src/components/MultiLegend.tsx @@ -234,12 +234,26 @@ const LegendEntryContent: React.FC<{ const { fill, stroke } = getDefaultColors(legendEntry); + /** Render a single-value size swatch (used when startSize === endSize). */ + const singleValueSizeRow = legendEntry.sizeEntry ? ( + + +
{legendEntry.legendName}
+
+ ) : null; + return (
- {/* SIMPLE - show icon and slice name (skip when sizeEntry handles the display) */} + {/* SIMPLE - show icon and slice name (skip when sizeEntry with a range handles the display) */} {legendEntry.type === 'simple' && legendEntry.simpleStyle && - !legendEntry.sizeEntry && ( + (!legendEntry.sizeEntry || + legendEntry.sizeEntry.startSize === legendEntry.sizeEntry.endSize) && ( {showEntryCheckbox && ( - )} + ) : ( + singleValueSizeRow + ))} {/* METRIC GRADIENT — only when NOT combined */} {!legendEntry.isCombinedMetricSize && legendEntry.metric && ( @@ -407,7 +423,7 @@ const LegendEntryContent: React.FC<{ {!legendEntry.isCombinedMetricSize && !(legendEntry.categories && legendEntry.categories.length > 0) && legendEntry.sizeEntry && - legendEntry.sizeEntry.startSize !== legendEntry.sizeEntry.endSize && ( + (legendEntry.sizeEntry.startSize !== legendEntry.sizeEntry.endSize ? ( - )} + ) : ( + singleValueSizeRow + ))}
); }; diff --git a/superset-frontend/plugins/geoset-map-chart/src/transformProps.ts b/superset-frontend/plugins/geoset-map-chart/src/transformProps.ts index 8f378997bd..1df9d090ca 100644 --- a/superset-frontend/plugins/geoset-map-chart/src/transformProps.ts +++ b/superset-frontend/plugins/geoset-map-chart/src/transformProps.ts @@ -255,7 +255,18 @@ export default function transformProps(chartProps: ChartProps) { ); if (bounds) { - const { lower, upper, usesPercentBounds } = bounds; + const { sortedValues, lower, upper, usesPercentBounds } = bounds; + + // Detect when all data points share the same size value (e.g. dashboard + // filter reduced to a single point). + const uniqueMin = sortedValues[0]; + const uniqueMax = sortedValues[sortedValues.length - 1]; + const noSizeRange = uniqueMin === uniqueMax; + + // The scale always uses the original startSize/endSize so the point + // keeps its natural size on the configured range. When lower === upper + // (no explicit bounds), computeSizeScale's range === 0 guard returns + // the midpoint automatically. sizeScale = computeSizeScale( { valueColumn: sizeValueColumn, @@ -266,15 +277,37 @@ export default function transformProps(chartProps: ChartProps) { }, [lower, upper], ); + + // The legend collapses startSize/endSize to the midpoint when there's + // no data variation, which suppresses graduated icons (nothing to + // differentiate). + const midSize = Math.round((startSize + endSize) / 2); + const legendStartSize = noSizeRange ? midSize : startSize; + const legendEndSize = noSizeRange ? midSize : endSize; + sizeLegend = { lower, upper, - startSize, - endSize, + startSize: legendStartSize, + endSize: legendEndSize, valueColumn: sizeValueColumn, legendTitle: legend?.title, + legendName: legend?.name || legend?.title || sizeValueColumn, usesPercentBounds, }; + + // When there's a single unique value and metric coloring is active, + // compute the actual rendered color so the legend swatch matches the + // point on the map (startColor is only the gradient start, not the + // actual color at the point's position in the range). + if (noSizeRange && metricColorScale && colorByValue?.valueColumn) { + const metricVal = Number( + rawData[0]?.[colorByValue.valueColumn] ?? NaN, + ); + if (!Number.isNaN(metricVal)) { + sizeLegend.singleValueColor = metricColorScale(metricVal) as RGBAColor; + } + } } } diff --git a/superset-frontend/plugins/geoset-map-chart/src/utils/colors.ts b/superset-frontend/plugins/geoset-map-chart/src/utils/colors.ts index a3eefda11d..82d646bbce 100644 --- a/superset-frontend/plugins/geoset-map-chart/src/utils/colors.ts +++ b/superset-frontend/plugins/geoset-map-chart/src/utils/colors.ts @@ -84,8 +84,8 @@ export function computeSizeScale( const upper = config.upperBound ?? max; const range = upper - lower; return (val: number) => { - if (val == null || Number.isNaN(val) || range === 0) - return config.startSize; + if (val == null || Number.isNaN(val)) return config.startSize; + if (range === 0) return Math.round((config.startSize + config.endSize) / 2); const t = Math.max(0, Math.min(1, (val - lower) / range)); return Math.round( config.startSize + t * (config.endSize - config.startSize), diff --git a/superset-frontend/plugins/geoset-map-chart/test/components/Legend.test.tsx b/superset-frontend/plugins/geoset-map-chart/test/components/Legend.test.tsx index e8fce58400..fa68ae40c5 100644 --- a/superset-frontend/plugins/geoset-map-chart/test/components/Legend.test.tsx +++ b/superset-frontend/plugins/geoset-map-chart/test/components/Legend.test.tsx @@ -299,12 +299,56 @@ describe('Legend', () => { expect(screen.getByText('Custom Title')).toBeInTheDocument(); }); - it('does not render when startSize equals endSize', () => { - const { container } = renderLegend({ - sizeLegend: { ...sizeLegend, startSize: 20, endSize: 20 }, + it('renders single-value fallback when startSize equals endSize', () => { + renderLegend({ + sizeLegend: { + ...sizeLegend, + startSize: 20, + endSize: 20, + legendName: 'Sensor A', + }, categories: {}, }); - expect(container).toBeEmptyDOMElement(); + // Should show the legend title and the legend name instead of graduated icons + expect(screen.getByText('Population')).toBeInTheDocument(); + expect(screen.getByText('Sensor A')).toBeInTheDocument(); + expect(screen.queryByTestId('graduated-icons')).not.toBeInTheDocument(); + }); + + it('single-value fallback uses singleValueColor for swatch when provided', () => { + renderLegend({ + sizeLegend: { + ...sizeLegend, + startSize: 20, + endSize: 20, + legendName: 'Sensor A', + singleValueColor: [255, 128, 0, 255] as [ + number, + number, + number, + number, + ], + }, + categories: {}, + }); + // The swatch should be present and graduated icons absent + expect(screen.getByText('Sensor A')).toBeInTheDocument(); + expect(screen.queryByTestId('graduated-icons')).not.toBeInTheDocument(); + }); + + it('single-value fallback falls back to fillColor when no singleValueColor', () => { + renderLegend({ + sizeLegend: { + ...sizeLegend, + startSize: 20, + endSize: 20, + legendName: 'Sensor A', + }, + fillColor: [0, 200, 100, 255] as [number, number, number, number], + categories: {}, + }); + expect(screen.getByText('Sensor A')).toBeInTheDocument(); + expect(screen.queryByTestId('graduated-icons')).not.toBeInTheDocument(); }); it('passes bounds to GraduatedIcons', () => { @@ -373,6 +417,41 @@ describe('Legend', () => { expect(container.querySelector('.gradient-bar')).not.toBeInTheDocument(); }); + it('renders single-value fallback when combined and startSize equals endSize', () => { + renderLegend({ + isCombinedMetricSize: true, + metricLegend, + sizeLegend: { + ...sizeLegend, + startSize: 20, + endSize: 20, + legendName: 'Single Sensor', + }, + categories: {}, + }); + expect(screen.getByText('Single Sensor')).toBeInTheDocument(); + expect(screen.queryByTestId('graduated-icons')).not.toBeInTheDocument(); + // Should not render the gradient bar either (combined mode suppresses it) + const { container } = renderLegend({ + isCombinedMetricSize: true, + metricLegend, + sizeLegend: { + ...sizeLegend, + startSize: 20, + endSize: 20, + legendName: 'Single Sensor', + singleValueColor: [200, 50, 50, 255] as [ + number, + number, + number, + number, + ], + }, + categories: {}, + }); + expect(container.querySelector('.gradient-bar')).not.toBeInTheDocument(); + }); + it('still renders categories alongside combined legend', () => { renderLegend({ isCombinedMetricSize: true, diff --git a/superset-frontend/plugins/geoset-map-chart/test/components/MultiLegend.test.tsx b/superset-frontend/plugins/geoset-map-chart/test/components/MultiLegend.test.tsx index fa69fa9660..538fb204f5 100644 --- a/superset-frontend/plugins/geoset-map-chart/test/components/MultiLegend.test.tsx +++ b/superset-frontend/plugins/geoset-map-chart/test/components/MultiLegend.test.tsx @@ -7,11 +7,38 @@ import { createCategoricalLegendEntry, createMetricLegendEntry, createLegendGroup, + createSizeLegend, createCategoryEntry, RED, GREEN, + TEAL, } from '../testFixtures'; +// Mock GraduatedIcons to isolate legend rendering behavior +jest.mock('../../src/components/GraduatedIcons', () => { + const MockGraduatedIcons = (props: any) => ( +
+ ); + MockGraduatedIcons.displayName = 'MockGraduatedIcons'; + return { __esModule: true, default: MockGraduatedIcons }; +}); + +jest.mock('../../src/components/CategorySizeGrid', () => { + const MockCategorySizeGrid = (props: any) => ( +
+ {props.categories.map((cat: any) => ( + {props.renderLabel(cat)} + ))} +
+ ); + MockCategorySizeGrid.displayName = 'MockCategorySizeGrid'; + return { __esModule: true, default: MockCategorySizeGrid }; +}); + // Mock Material-UI icon to avoid transform issues jest.mock('@material-ui/icons/MapTwoTone', () => { const MockMapIcon = (props: any) => ( @@ -564,4 +591,145 @@ describe('MultiLegend', () => { expect(screen.getByText('500+')).toBeInTheDocument(); }); }); + + describe('single-value size legend (startSize === endSize)', () => { + const collapsedSizeLegend = createSizeLegend({ + startSize: 17, + endSize: 17, + legendName: 'Single Point', + }); + + it('simple entry shows swatch and name when sizeEntry has equal sizes', () => { + renderWithTheme( + , + ); + userEvent.click(screen.getByText('Legend')); + // Name appears in both the simple row and the single-value size swatch row + expect(screen.getAllByText('Simple Single').length).toBeGreaterThanOrEqual( + 1, + ); + expect(screen.queryByTestId('graduated-icons')).not.toBeInTheDocument(); + }); + + it('combined metric+size shows swatch instead of graduated icons', () => { + renderWithTheme( + , + ); + userEvent.click(screen.getByText('Legend')); + expect(screen.getByText('Combined Single')).toBeInTheDocument(); + expect(screen.queryByTestId('graduated-icons')).not.toBeInTheDocument(); + }); + + it('standalone size legend shows swatch instead of graduated icons', () => { + renderWithTheme( + , + ); + userEvent.click(screen.getByText('Legend')); + expect(screen.getByText('Standalone Single')).toBeInTheDocument(); + expect(screen.queryByTestId('graduated-icons')).not.toBeInTheDocument(); + }); + + it('uses singleValueColor for swatch when provided', () => { + renderWithTheme( + , + ); + userEvent.click(screen.getByText('Legend')); + expect(screen.getByText('Colored Single')).toBeInTheDocument(); + expect(screen.queryByTestId('graduated-icons')).not.toBeInTheDocument(); + }); + + it('renders graduated icons when sizeEntry has different start/end sizes', () => { + renderWithTheme( + , + ); + userEvent.click(screen.getByText('Legend')); + expect(screen.getByTestId('graduated-icons')).toBeInTheDocument(); + }); + }); }); diff --git a/superset-frontend/plugins/geoset-map-chart/test/utils/colors.test.ts b/superset-frontend/plugins/geoset-map-chart/test/utils/colors.test.ts index cccc1776fc..b4835ec039 100644 --- a/superset-frontend/plugins/geoset-map-chart/test/utils/colors.test.ts +++ b/superset-frontend/plugins/geoset-map-chart/test/utils/colors.test.ts @@ -162,12 +162,13 @@ describe('computeSizeScale', () => { expect(scale(200)).toBe(50); }); - it('returns startSize when range is 0', () => { + it('returns midpoint size when range is 0 (single unique value)', () => { const scale = computeSizeScale( { ...config, lowerBound: 50, upperBound: 50 }, [50, 50], ); - expect(scale(50)).toBe(5); + // midpoint of startSize (5) and endSize (50) = 27.5, rounded = 28 + expect(scale(50)).toBe(28); }); it('returns startSize for null value', () => { @@ -175,6 +176,14 @@ describe('computeSizeScale', () => { expect(scale(null as any)).toBe(5); }); + it('returns startSize for null value even when range is 0', () => { + const scale = computeSizeScale( + { ...config, lowerBound: 50, upperBound: 50 }, + [50, 50], + ); + expect(scale(null as any)).toBe(5); + }); + it('falls back to dataDomain when bounds are null', () => { const noBoundsConfig: PointSizeConfig = { valueColumn: 'pop', diff --git a/superset-frontend/plugins/geoset-map-chart/test/utils/percentileBounds.test.ts b/superset-frontend/plugins/geoset-map-chart/test/utils/percentileBounds.test.ts index 94a65a34e0..3e2bd39116 100644 --- a/superset-frontend/plugins/geoset-map-chart/test/utils/percentileBounds.test.ts +++ b/superset-frontend/plugins/geoset-map-chart/test/utils/percentileBounds.test.ts @@ -407,10 +407,11 @@ describe('computeSizeScale', () => { expect(scale(null as any)).toBe(4); }); - it('returns startSize when range is zero', () => { + it('returns midpoint size when range is zero', () => { const zeroRange = { ...config, lowerBound: 50, upperBound: 50 }; const scale = computeSizeScale(zeroRange, [50, 50]); - expect(scale(50)).toBe(4); + // midpoint of startSize (4) and endSize (30) = 17 + expect(scale(50)).toBe(17); }); it('uses data domain when bounds are null', () => { From 9ea250061d468344f462417d623da5b81deb04c8 Mon Sep 17 00:00:00 2001 From: lhawkins Date: Wed, 1 Apr 2026 16:53:09 -0400 Subject: [PATCH 2/2] Clean up MultiLegend test: remove unused import, fix mock ordering Co-Authored-By: Claude Opus 4.6 (1M context) --- .../test/components/MultiLegend.test.tsx | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/superset-frontend/plugins/geoset-map-chart/test/components/MultiLegend.test.tsx b/superset-frontend/plugins/geoset-map-chart/test/components/MultiLegend.test.tsx index 538fb204f5..bf092dc0af 100644 --- a/superset-frontend/plugins/geoset-map-chart/test/components/MultiLegend.test.tsx +++ b/superset-frontend/plugins/geoset-map-chart/test/components/MultiLegend.test.tsx @@ -11,9 +11,10 @@ import { createCategoryEntry, RED, GREEN, - TEAL, } from '../testFixtures'; +import '../mocks/svgIcons'; + // Mock GraduatedIcons to isolate legend rendering behavior jest.mock('../../src/components/GraduatedIcons', () => { const MockGraduatedIcons = (props: any) => ( @@ -48,8 +49,6 @@ jest.mock('@material-ui/icons/MapTwoTone', () => { return { __esModule: true, default: MockMapIcon }; }); -import '../mocks/svgIcons'; - // Stable reference to avoid infinite useEffect loop from default `layerVisibility = {}` const EMPTY_VISIBILITY: Record = {}; @@ -620,9 +619,9 @@ describe('MultiLegend', () => { ); userEvent.click(screen.getByText('Legend')); // Name appears in both the simple row and the single-value size swatch row - expect(screen.getAllByText('Simple Single').length).toBeGreaterThanOrEqual( - 1, - ); + expect( + screen.getAllByText('Simple Single').length, + ).toBeGreaterThanOrEqual(1); expect(screen.queryByTestId('graduated-icons')).not.toBeInTheDocument(); });