Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};

Expand Down Expand Up @@ -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,
) => (
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<Swatch
fill={swatchFill}
stroke={swatchStroke}
icon={icon}
geometryType={geometryType}
/>
<span>{label}</span>
</div>
);

// --- Render metric legend if present and not forcing categorical ---
const metricLegendContent = metricLegend ? (
<div className="metric-legend">
Expand Down Expand Up @@ -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 ? (
<div className="metric-legend">
<div className="legend-title">
{sizeLegend.legendTitle || toTitleCase(sizeLegend.valueColumn)}
Expand All @@ -207,7 +227,24 @@ const Legend = ({
usesPercentBounds={sizeLegend.usesPercentBounds}
/>
</div>
) : null;
) : (
// Single-value fallback: show a standard entry instead of graduated icons
<div className="metric-legend">
<div className="legend-title">
{sizeLegend.legendTitle || toTitleCase(sizeLegend.valueColumn)}
</div>
{renderSingleValueRow(
sizeLegend.singleValueColor ??
metricLegend?.startColor ??
fillColor,
sizeLegend.singleValueColor ??
metricLegend?.startColor ??
strokeColor,
sizeLegend.legendName || toTitleCase(sizeLegend.valueColumn),
)}
</div>
)
) : null;

// --- Combined metric+size: 4 gradient-colored circles replacing gradient bar + size circles ---
const combinedMetricSizeContent =
Expand All @@ -218,15 +255,25 @@ const Legend = ({
metricLegend.legendName ||
toTitleCase(sizeLegend.valueColumn)}
</div>
<GraduatedIcons
responsive
lower={sizeLegend.lower}
upper={sizeLegend.upper}
startColor={metricLegend.startColor}
endColor={metricLegend.endColor}
icon={icon}
usesPercentBounds={sizeLegend.usesPercentBounds}
/>
{sizeLegend.startSize !== sizeLegend.endSize ? (
<GraduatedIcons
responsive
lower={sizeLegend.lower}
upper={sizeLegend.upper}
startColor={metricLegend.startColor}
endColor={metricLegend.endColor}
icon={icon}
usesPercentBounds={sizeLegend.usesPercentBounds}
/>
) : (
renderSingleValueRow(
sizeLegend.singleValueColor ?? metricLegend.startColor,
sizeLegend.singleValueColor ?? metricLegend.startColor,
sizeLegend.legendName ||
metricLegend.legendName ||
toTitleCase(sizeLegend.valueColumn),
)
)}
</div>
) : null;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 ? (
<CategoryRow>
<Swatch
fill={legendEntry.sizeEntry.singleValueColor ?? fill}
stroke={legendEntry.sizeEntry.singleValueColor ?? stroke}
icon={legendEntry.icon}
geometryType={legendEntry.geometryType}
/>
<div>{legendEntry.legendName}</div>
</CategoryRow>
) : null;

return (
<div>
{/* 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) && (
<CategoryRow>
{showEntryCheckbox && (
<VisibilityCheckbox
Expand Down Expand Up @@ -344,7 +358,7 @@ const LegendEntryContent: React.FC<{
{legendEntry.isCombinedMetricSize &&
legendEntry.metric &&
legendEntry.sizeEntry &&
legendEntry.sizeEntry.startSize !== legendEntry.sizeEntry.endSize && (
(legendEntry.sizeEntry.startSize !== legendEntry.sizeEntry.endSize ? (
<GraduatedIcons
lower={legendEntry.sizeEntry.lower}
upper={legendEntry.sizeEntry.upper}
Expand All @@ -353,7 +367,9 @@ const LegendEntryContent: React.FC<{
icon={legendEntry.icon}
usesPercentBounds={legendEntry.sizeEntry.usesPercentBounds}
/>
)}
) : (
singleValueSizeRow
))}

{/* METRIC GRADIENT — only when NOT combined */}
{!legendEntry.isCombinedMetricSize && legendEntry.metric && (
Expand Down Expand Up @@ -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 ? (
<GraduatedIcons
lower={legendEntry.sizeEntry.lower}
upper={legendEntry.sizeEntry.upper}
Expand All @@ -417,7 +433,9 @@ const LegendEntryContent: React.FC<{
icon={legendEntry.icon}
usesPercentBounds={legendEntry.sizeEntry.usesPercentBounds}
/>
)}
) : (
singleValueSizeRow
))}
</div>
);
};
Expand Down
39 changes: 36 additions & 3 deletions superset-frontend/plugins/geoset-map-chart/src/transformProps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
}
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading