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 bf6f3172f..e135edac2 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 f25d204bc..befa2a92a 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 8f378997b..1df9d090c 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 a3eefda11..82d646bbc 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 e8fce5840..fa68ae40c 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 fa69fa966..bf092dc0a 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,39 @@ import {
createCategoricalLegendEntry,
createMetricLegendEntry,
createLegendGroup,
+ createSizeLegend,
createCategoryEntry,
RED,
GREEN,
} from '../testFixtures';
+import '../mocks/svgIcons';
+
+// 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) => (
@@ -21,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 = {};
@@ -564,4 +590,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 cccc1776f..b4835ec03 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 94a65a34e..3e2bd3911 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', () => {