You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by mi...@apache.org on 2026/03/20 19:43:14 UTC
(superset) 13/32: fix(map-box): prevent clusters from being smaller than individual points (#38458)
This is an automated email from the ASF dual-hosted git repository.
michaelsmolina pushed a commit to branch 6.1
in repository https://gitbox.apache.org/repos/asf/superset.git
commit 2e09934bb5b9aa82bc975ee07bee5dfe5cba1328
Author: João Pedro Alves Barbosa <93...@users.noreply.github.com>
AuthorDate: Tue Mar 17 15:08:05 2026 -0300
fix(map-box): prevent clusters from being smaller than individual points (#38458)
Co-authored-by: Claude Opus 4.6 <no...@anthropic.com>
---
superset-frontend/package-lock.json | 11 +
superset-frontend/package.json | 1 +
.../src/ScatterPlotGlowOverlay.tsx | 61 +-
.../test/ScatterPlotGlowOverlay.test.tsx | 663 +++++++++++++++++++--
4 files changed, 660 insertions(+), 76 deletions(-)
diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json
index b0dd8471377..e70078d95e5 100644
--- a/superset-frontend/package-lock.json
+++ b/superset-frontend/package-lock.json
@@ -242,6 +242,7 @@
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-jest-dom": "^5.5.0",
"eslint-plugin-lodash": "^7.4.0",
+ "eslint-plugin-no-only-tests": "^3.3.0",
"eslint-plugin-prettier": "^5.5.5",
"eslint-plugin-react-prefer-function-component": "^5.0.0",
"eslint-plugin-react-you-might-not-need-an-effect": "^0.9.1",
@@ -24351,6 +24352,16 @@
"eslint": ">=2"
}
},
+ "node_modules/eslint-plugin-no-only-tests": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-no-only-tests/-/eslint-plugin-no-only-tests-3.3.0.tgz",
+ "integrity": "sha512-brcKcxGnISN2CcVhXJ/kEQlNa0MEfGRtwKtWA16SkqXHKitaKIMrfemJKLKX1YqDU5C/5JY3PvZXd5jEW04e0Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=5.0.0"
+ }
+ },
"node_modules/eslint-plugin-prettier": {
"version": "5.5.5",
"resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.5.tgz",
diff --git a/superset-frontend/package.json b/superset-frontend/package.json
index eea5175c6a2..e93e2af19b3 100644
--- a/superset-frontend/package.json
+++ b/superset-frontend/package.json
@@ -323,6 +323,7 @@
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-jest-dom": "^5.5.0",
"eslint-plugin-lodash": "^7.4.0",
+ "eslint-plugin-no-only-tests": "^3.3.0",
"eslint-plugin-prettier": "^5.5.5",
"eslint-plugin-react-prefer-function-component": "^5.0.0",
"eslint-plugin-react-you-might-not-need-an-effect": "^0.9.1",
diff --git a/superset-frontend/plugins/legacy-plugin-chart-map-box/src/ScatterPlotGlowOverlay.tsx b/superset-frontend/plugins/legacy-plugin-chart-map-box/src/ScatterPlotGlowOverlay.tsx
index e5220a0d538..2a5d87bb1c0 100644
--- a/superset-frontend/plugins/legacy-plugin-chart-map-box/src/ScatterPlotGlowOverlay.tsx
+++ b/superset-frontend/plugins/legacy-plugin-chart-map-box/src/ScatterPlotGlowOverlay.tsx
@@ -24,6 +24,10 @@ import roundDecimal from './utils/roundDecimal';
import luminanceFromRGB from './utils/luminanceFromRGB';
import 'mapbox-gl/dist/mapbox-gl.css';
+// Shared radius bounds keep cluster and point sizing in sync.
+export const MIN_CLUSTER_RADIUS_RATIO = 1 / 6;
+export const MAX_POINT_RADIUS_RATIO = 1 / 3;
+
interface GeoJSONLocation {
geometry: {
coordinates: [number, number];
@@ -181,13 +185,16 @@ class ScatterPlotGlowOverlay extends PureComponent<ScatterPlotGlowOverlayProps>
}
});
- const filteredLabels = clusterLabelMap.filter(
- v => !Number.isNaN(v),
- ) as number[];
- // Guard against empty array or zero max to prevent NaN from division
- const maxLabel =
- filteredLabels.length > 0 ? Math.max(...filteredLabels) : 1;
- const safeMaxLabel = maxLabel > 0 ? maxLabel : 1;
+ const finiteClusterLabels = clusterLabelMap
+ .map(value => Number(value))
+ .filter(value => Number.isFinite(value));
+ const safeMaxAbsLabel =
+ finiteClusterLabels.length > 0
+ ? Math.max(
+ Math.max(...finiteClusterLabels.map(value => Math.abs(value))),
+ 1,
+ )
+ : 1;
// Calculate min/max radius values for Pixels mode scaling
let minRadiusValue = Infinity;
@@ -200,8 +207,11 @@ class ScatterPlotGlowOverlay extends PureComponent<ScatterPlotGlowOverlayProps>
location.properties.radius != null
) {
const radiusValueRaw = location.properties.radius;
- const radiusValue = Number(radiusValueRaw);
- if (Number.isFinite(radiusValue)) {
+ const radiusValue =
+ typeof radiusValueRaw === 'string' && radiusValueRaw.trim() === ''
+ ? null
+ : Number(radiusValueRaw);
+ if (radiusValue != null && Number.isFinite(radiusValue)) {
minRadiusValue = Math.min(minRadiusValue, radiusValue);
maxRadiusValue = Math.max(maxRadiusValue, radiusValue);
}
@@ -239,8 +249,13 @@ class ScatterPlotGlowOverlay extends PureComponent<ScatterPlotGlowOverlayProps>
const safeNumericLabel = Number.isFinite(numericLabel)
? numericLabel
: 0;
+ const minClusterRadius =
+ pointRadiusUnit === 'Pixels'
+ ? radius * MAX_POINT_RADIUS_RATIO
+ : radius * MIN_CLUSTER_RADIUS_RATIO;
+ const ratio = Math.abs(safeNumericLabel) / safeMaxAbsLabel;
const scaledRadius = roundDecimal(
- (safeNumericLabel / safeMaxLabel) ** 0.5 * radius,
+ minClusterRadius + ratio ** 0.5 * (radius - minClusterRadius),
1,
);
const fontHeight = roundDecimal(scaledRadius * 0.5, 1);
@@ -274,10 +289,12 @@ class ScatterPlotGlowOverlay extends PureComponent<ScatterPlotGlowOverlayProps>
if (Number.isFinite(safeNumericLabel)) {
let label: string | number = clusterLabel;
- if (safeNumericLabel >= 10000) {
- label = `${Math.round(safeNumericLabel / 1000)}k`;
- } else if (safeNumericLabel >= 1000) {
- label = `${Math.round(safeNumericLabel / 100) / 10}k`;
+ const absLabel = Math.abs(safeNumericLabel);
+ const sign = safeNumericLabel < 0 ? '-' : '';
+ if (absLabel >= 10000) {
+ label = `${sign}${Math.round(absLabel / 1000)}k`;
+ } else if (absLabel >= 1000) {
+ label = `${sign}${Math.round(absLabel / 100) / 10}k`;
}
this.drawText(ctx, pixelRounded, {
fontHeight,
@@ -288,10 +305,18 @@ class ScatterPlotGlowOverlay extends PureComponent<ScatterPlotGlowOverlayProps>
});
}
} else {
- const defaultRadius = radius / 6;
+ const defaultRadius = radius * MIN_CLUSTER_RADIUS_RATIO;
const rawRadius = location.properties.radius;
+ const numericRadiusProperty =
+ rawRadius != null &&
+ !(typeof rawRadius === 'string' && rawRadius.trim() === '')
+ ? Number(rawRadius)
+ : null;
const radiusProperty =
- typeof rawRadius === 'number' ? rawRadius : null;
+ numericRadiusProperty != null &&
+ Number.isFinite(numericRadiusProperty)
+ ? numericRadiusProperty
+ : null;
const pointMetric = location.properties.metric ?? null;
let pointRadius: number = radiusProperty ?? defaultRadius;
let pointLabel: string | number | undefined;
@@ -311,8 +336,8 @@ class ScatterPlotGlowOverlay extends PureComponent<ScatterPlotGlowOverlayProps>
} else if (pointRadiusUnit === 'Pixels') {
// Scale pixel values to a reasonable range (radius/6 to radius/3)
// This ensures points are visible and proportional to their values
- const MIN_POINT_RADIUS = radius / 6;
- const MAX_POINT_RADIUS = radius / 3;
+ const MIN_POINT_RADIUS = radius * MIN_CLUSTER_RADIUS_RATIO;
+ const MAX_POINT_RADIUS = radius * MAX_POINT_RADIUS_RATIO;
if (
Number.isFinite(minRadiusValue) &&
diff --git a/superset-frontend/plugins/legacy-plugin-chart-map-box/test/ScatterPlotGlowOverlay.test.tsx b/superset-frontend/plugins/legacy-plugin-chart-map-box/test/ScatterPlotGlowOverlay.test.tsx
index 27dffc2ed2f..8ce96810548 100644
--- a/superset-frontend/plugins/legacy-plugin-chart-map-box/test/ScatterPlotGlowOverlay.test.tsx
+++ b/superset-frontend/plugins/legacy-plugin-chart-map-box/test/ScatterPlotGlowOverlay.test.tsx
@@ -20,11 +20,58 @@
import { render } from '@testing-library/react';
import ScatterPlotGlowOverlay from '../src/ScatterPlotGlowOverlay';
+type MockGradient = {
+ addColorStop: jest.Mock<void, [number, string]>;
+};
+
+type MockCanvasContext = {
+ clearRect: jest.Mock<void, [number, number, number, number]>;
+ beginPath: jest.Mock<void, []>;
+ arc: jest.Mock<void, [number, number, number, number, number]>;
+ fill: jest.Mock<void, []>;
+ fillText: jest.Mock<void, [string, number, number]>;
+ measureText: jest.Mock<{ width: number }, [string]>;
+ createRadialGradient: jest.Mock<
+ MockGradient,
+ [number, number, number, number, number, number]
+ >;
+ globalCompositeOperation: string;
+ fillStyle: string | CanvasGradient;
+ font: string;
+ textAlign: CanvasTextAlign;
+ textBaseline: CanvasTextBaseline;
+ shadowBlur: number;
+ shadowColor: string;
+};
+
+type LocationProperties = Record<
+ string,
+ number | string | boolean | null | undefined
+>;
+
+type TestLocation = {
+ geometry: { coordinates: [number, number] };
+ properties: LocationProperties;
+};
+
+type MockRedrawParams = {
+ width: number;
+ height: number;
+ ctx: MockCanvasContext;
+ isDragging: boolean;
+ project: (lngLat: [number, number]) => [number, number];
+};
+
+declare global {
+ // eslint-disable-next-line no-var
+ var mockRedraw: unknown;
+}
+
// Mock react-map-gl's CanvasOverlay
jest.mock('react-map-gl', () => ({
- CanvasOverlay: ({ redraw }: { redraw: Function }) => {
+ CanvasOverlay: ({ redraw }: { redraw: unknown }) => {
// Store the redraw function so tests can call it
- (global as any).mockRedraw = redraw;
+ global.mockRedraw = redraw;
return <div data-testid="canvas-overlay" />;
},
}));
@@ -37,21 +84,30 @@ jest.mock('../src/utils/luminanceFromRGB', () => ({
// Test helpers
const createMockCanvas = () => {
- const ctx: any = {
+ const ctx: MockCanvasContext = {
clearRect: jest.fn(),
beginPath: jest.fn(),
arc: jest.fn(),
fill: jest.fn(),
fillText: jest.fn(),
- measureText: jest.fn(() => ({ width: 10 })),
- createRadialGradient: jest.fn(() => ({
- addColorStop: jest.fn(),
- })),
+ measureText: jest.fn((_: string) => ({ width: 10 })),
+ createRadialGradient: jest.fn(
+ (
+ _x0: number,
+ _y0: number,
+ _r0: number,
+ _x1: number,
+ _y1: number,
+ _r1: number,
+ ) => ({
+ addColorStop: jest.fn<void, [number, string]>(),
+ }),
+ ),
globalCompositeOperation: '',
fillStyle: '',
font: '',
- textAlign: '',
- textBaseline: '',
+ textAlign: 'center',
+ textBaseline: 'middle',
shadowBlur: 0,
shadowColor: '',
};
@@ -59,7 +115,9 @@ const createMockCanvas = () => {
return ctx;
};
-const createMockRedrawParams = (overrides = {}) => ({
+const createMockRedrawParams = (
+ overrides: Partial<MockRedrawParams> = {},
+): MockRedrawParams => ({
width: 800,
height: 600,
ctx: createMockCanvas(),
@@ -70,18 +128,31 @@ const createMockRedrawParams = (overrides = {}) => ({
const createLocation = (
coordinates: [number, number],
- properties: Record<string, any>,
-) => ({
+ properties: LocationProperties,
+): TestLocation => ({
geometry: { coordinates },
properties,
});
+const triggerRedraw = (
+ overrides: Partial<MockRedrawParams> = {},
+): MockRedrawParams => {
+ const redrawParams = createMockRedrawParams(overrides);
+ if (typeof global.mockRedraw !== 'function') {
+ throw new Error('CanvasOverlay redraw callback was not registered');
+ }
+ (global.mockRedraw as (params: MockRedrawParams) => void)(redrawParams);
+ return redrawParams;
+};
+
const defaultProps = {
- lngLatAccessor: (loc: any) => loc.geometry.coordinates,
+ lngLatAccessor: (loc: TestLocation) => loc.geometry.coordinates,
dotRadius: 60,
- rgb: ['', 255, 0, 0] as any,
+ rgb: ['', 255, 0, 0] as [string, number, number, number],
globalOpacity: 1,
};
+const MIN_VISIBLE_POINT_RADIUS = 10;
+const MAX_VISIBLE_POINT_RADIUS = 20;
test('renders map with varying radius values in Pixels mode', () => {
const locations = [
@@ -90,17 +161,26 @@ test('renders map with varying radius values in Pixels mode', () => {
createLocation([300, 300], { radius: 100, cluster: false }),
];
- expect(() => {
- render(
- <ScatterPlotGlowOverlay
- {...defaultProps}
- locations={locations}
- pointRadiusUnit="Pixels"
- />,
- );
- const redrawParams = createMockRedrawParams();
- (global as any).mockRedraw(redrawParams);
- }).not.toThrow();
+ render(
+ <ScatterPlotGlowOverlay
+ {...defaultProps}
+ locations={locations}
+ pointRadiusUnit="Pixels"
+ />,
+ );
+ const redrawParams = triggerRedraw();
+
+ const arcCalls = redrawParams.ctx.arc.mock.calls;
+
+ // With dotRadius=60, pixel-sized points should map to the visible 10-20 range.
+ arcCalls.forEach(call => {
+ expect(call[2]).toBeGreaterThanOrEqual(MIN_VISIBLE_POINT_RADIUS);
+ expect(call[2]).toBeLessThanOrEqual(MAX_VISIBLE_POINT_RADIUS);
+ });
+
+ // Ordering should be preserved: radius 10 < 50 < 100
+ expect(arcCalls[0][2]).toBeLessThan(arcCalls[1][2]);
+ expect(arcCalls[1][2]).toBeLessThan(arcCalls[2][2]);
});
test('handles dataset with uniform radius values', () => {
@@ -118,8 +198,7 @@ test('handles dataset with uniform radius values', () => {
pointRadiusUnit="Pixels"
/>,
);
- const redrawParams = createMockRedrawParams();
- (global as any).mockRedraw(redrawParams);
+ triggerRedraw();
}).not.toThrow();
});
@@ -138,8 +217,7 @@ test('renders successfully when data contains non-finite values', () => {
pointRadiusUnit="Pixels"
/>,
);
- const redrawParams = createMockRedrawParams();
- (global as any).mockRedraw(redrawParams);
+ triggerRedraw();
}).not.toThrow();
});
@@ -150,17 +228,47 @@ test('handles radius values provided as strings', () => {
createLocation([300, 300], { radius: '100', cluster: false }),
];
- expect(() => {
- render(
- <ScatterPlotGlowOverlay
- {...defaultProps}
- locations={locations}
- pointRadiusUnit="Pixels"
- />,
- );
- const redrawParams = createMockRedrawParams();
- (global as any).mockRedraw(redrawParams);
- }).not.toThrow();
+ render(
+ <ScatterPlotGlowOverlay
+ {...defaultProps}
+ locations={locations}
+ pointRadiusUnit="Pixels"
+ />,
+ );
+ const redrawParams = triggerRedraw();
+
+ const arcCalls = redrawParams.ctx.arc.mock.calls;
+
+ arcCalls.forEach(call => {
+ expect(call[2]).toBeGreaterThanOrEqual(MIN_VISIBLE_POINT_RADIUS);
+ expect(call[2]).toBeLessThanOrEqual(MAX_VISIBLE_POINT_RADIUS);
+ });
+
+ expect(arcCalls[0][2]).toBeLessThan(arcCalls[1][2]);
+ expect(arcCalls[1][2]).toBeLessThan(arcCalls[2][2]);
+});
+
+test('treats blank radius strings as missing values', () => {
+ const locations = [
+ createLocation([100, 100], { radius: '', cluster: false }),
+ createLocation([200, 200], { radius: ' ', cluster: false }),
+ createLocation([300, 300], { radius: '100', cluster: false }),
+ ];
+
+ render(
+ <ScatterPlotGlowOverlay
+ {...defaultProps}
+ locations={locations}
+ pointRadiusUnit="Pixels"
+ />,
+ );
+ const redrawParams = triggerRedraw();
+
+ const arcCalls = redrawParams.ctx.arc.mock.calls;
+
+ expect(arcCalls[0][2]).toBe(MIN_VISIBLE_POINT_RADIUS);
+ expect(arcCalls[1][2]).toBe(MIN_VISIBLE_POINT_RADIUS);
+ expect(arcCalls[2][2]).toBe(15);
});
test('renders points when radius values are missing', () => {
@@ -178,8 +286,7 @@ test('renders points when radius values are missing', () => {
pointRadiusUnit="Pixels"
/>,
);
- const redrawParams = createMockRedrawParams();
- (global as any).mockRedraw(redrawParams);
+ triggerRedraw();
}).not.toThrow();
});
@@ -203,8 +310,7 @@ test('renders both cluster and non-cluster points correctly', () => {
pointRadiusUnit="Pixels"
/>,
);
- const redrawParams = createMockRedrawParams();
- (global as any).mockRedraw(redrawParams);
+ triggerRedraw();
}).not.toThrow();
});
@@ -223,8 +329,7 @@ test('renders map with multiple points with different radius values', () => {
pointRadiusUnit="Pixels"
/>,
);
- const redrawParams = createMockRedrawParams();
- (global as any).mockRedraw(redrawParams);
+ triggerRedraw();
}).not.toThrow();
});
@@ -243,8 +348,7 @@ test('renders map with Kilometers mode', () => {
zoom={10}
/>,
);
- const redrawParams = createMockRedrawParams();
- (global as any).mockRedraw(redrawParams);
+ triggerRedraw();
}).not.toThrow();
});
@@ -263,8 +367,7 @@ test('renders map with Miles mode', () => {
zoom={10}
/>,
);
- const redrawParams = createMockRedrawParams();
- (global as any).mockRedraw(redrawParams);
+ triggerRedraw();
}).not.toThrow();
});
@@ -281,8 +384,7 @@ test('displays metric property labels on points', () => {
pointRadiusUnit="Pixels"
/>,
);
- const redrawParams = createMockRedrawParams();
- (global as any).mockRedraw(redrawParams);
+ triggerRedraw();
}).not.toThrow();
});
@@ -295,8 +397,7 @@ test('handles empty dataset without errors', () => {
pointRadiusUnit="Pixels"
/>,
);
- const redrawParams = createMockRedrawParams();
- (global as any).mockRedraw(redrawParams);
+ triggerRedraw();
}).not.toThrow();
});
@@ -315,8 +416,7 @@ test('handles extreme outlier radius values without breaking', () => {
pointRadiusUnit="Pixels"
/>,
);
- const redrawParams = createMockRedrawParams();
- (global as any).mockRedraw(redrawParams);
+ triggerRedraw();
}).not.toThrow();
});
@@ -340,7 +440,454 @@ test('renders successfully with mixed extreme and negative radius values', () =>
}).not.toThrow();
expect(() => {
- const redrawParams = createMockRedrawParams();
- (global as any).mockRedraw(redrawParams);
+ triggerRedraw();
}).not.toThrow();
});
+
+test('cluster radius is always >= max individual point radius in Pixels mode', () => {
+ const locations = [
+ createLocation([100, 100], {
+ cluster: true,
+ point_count: 2,
+ sum: 1,
+ }),
+ createLocation([200, 200], { cluster: false, radius: 1 }),
+ createLocation([300, 300], { cluster: false, radius: 100 }),
+ ];
+
+ render(
+ <ScatterPlotGlowOverlay
+ {...defaultProps}
+ locations={locations}
+ aggregation="sum"
+ pointRadiusUnit="Pixels"
+ />,
+ );
+ const redrawParams = triggerRedraw();
+
+ const arcCalls = redrawParams.ctx.arc.mock.calls;
+
+ // cluster with label=1 (index 0) should not be smaller than the largest point bubble
+ expect(arcCalls[0][2]).toBeGreaterThanOrEqual(MAX_VISIBLE_POINT_RADIUS);
+ // point radii span the configured pixel range
+ expect(arcCalls[1][2]).toBe(MIN_VISIBLE_POINT_RADIUS);
+ expect(arcCalls[2][2]).toBe(MAX_VISIBLE_POINT_RADIUS);
+ expect(arcCalls[0][2]).toBeGreaterThanOrEqual(arcCalls[2][2]);
+});
+
+test('largest cluster gets full dotRadius', () => {
+ const locations = [
+ createLocation([100, 100], {
+ cluster: true,
+ point_count: 10,
+ sum: 50,
+ }),
+ createLocation([200, 200], {
+ cluster: true,
+ point_count: 50,
+ sum: 100,
+ }),
+ ];
+
+ render(
+ <ScatterPlotGlowOverlay
+ {...defaultProps}
+ locations={locations}
+ aggregation="sum"
+ pointRadiusUnit="Pixels"
+ />,
+ );
+ const redrawParams = triggerRedraw();
+
+ const arcCalls = redrawParams.ctx.arc.mock.calls;
+ // The largest cluster (label=100, maxLabel=100) should get full radius
+ expect(arcCalls[1][2]).toBe(defaultProps.dotRadius);
+});
+
+test('cluster radii preserve proportional ordering', () => {
+ const locations = [
+ createLocation([100, 100], {
+ cluster: true,
+ point_count: 5,
+ sum: 10,
+ }),
+ createLocation([200, 200], {
+ cluster: true,
+ point_count: 25,
+ sum: 50,
+ }),
+ createLocation([300, 300], {
+ cluster: true,
+ point_count: 50,
+ sum: 100,
+ }),
+ ];
+
+ render(
+ <ScatterPlotGlowOverlay
+ {...defaultProps}
+ locations={locations}
+ aggregation="sum"
+ pointRadiusUnit="Pixels"
+ />,
+ );
+ const redrawParams = triggerRedraw();
+
+ const arcCalls = redrawParams.ctx.arc.mock.calls;
+ const r10 = arcCalls[0][2];
+ const r50 = arcCalls[1][2];
+ const r100 = arcCalls[2][2];
+
+ expect(r10).toBeLessThan(r50);
+ expect(r50).toBeLessThan(r100);
+});
+
+test('negative cluster label produces valid finite radius', () => {
+ const locations = [
+ createLocation([100, 100], {
+ cluster: true,
+ point_count: 3,
+ sum: -5,
+ }),
+ ];
+
+ render(
+ <ScatterPlotGlowOverlay
+ {...defaultProps}
+ locations={locations}
+ aggregation="sum"
+ />,
+ );
+ const redrawParams = triggerRedraw();
+
+ const arcCalls = redrawParams.ctx.arc.mock.calls;
+ const radiusValue = arcCalls[0][2];
+ expect(Number.isFinite(radiusValue)).toBe(true);
+ expect(radiusValue).toBeGreaterThanOrEqual(MIN_VISIBLE_POINT_RADIUS);
+});
+
+test('ignores non-finite cluster labels when computing cluster scaling bounds', () => {
+ const locations = [
+ createLocation([100, 100], {
+ cluster: true,
+ point_count: 3,
+ sum: 'invalid',
+ }),
+ createLocation([200, 200], {
+ cluster: true,
+ point_count: 3,
+ sum: 100,
+ }),
+ ];
+
+ render(
+ <ScatterPlotGlowOverlay
+ {...defaultProps}
+ locations={locations}
+ aggregation="sum"
+ pointRadiusUnit="Pixels"
+ />,
+ );
+ const redrawParams = triggerRedraw();
+
+ const arcCalls = redrawParams.ctx.arc.mock.calls;
+
+ expect(arcCalls[0][2]).toBeGreaterThanOrEqual(MAX_VISIBLE_POINT_RADIUS);
+ expect(arcCalls[1][2]).toBe(defaultProps.dotRadius);
+});
+
+test('single cluster with small maxLabel gets full dotRadius', () => {
+ const locations = [
+ createLocation([100, 100], {
+ cluster: true,
+ point_count: 1,
+ sum: 1,
+ }),
+ ];
+
+ render(
+ <ScatterPlotGlowOverlay
+ {...defaultProps}
+ locations={locations}
+ aggregation="sum"
+ />,
+ );
+ const redrawParams = triggerRedraw();
+
+ const arcCalls = redrawParams.ctx.arc.mock.calls;
+ // When there's only one cluster, label=maxLabel, so it gets full radius
+ expect(arcCalls[0][2]).toBe(defaultProps.dotRadius);
+});
+
+test('all-negative cluster labels produce differentiated radii by magnitude', () => {
+ const locations = [
+ createLocation([100, 100], {
+ cluster: true,
+ point_count: 3,
+ sum: -100,
+ }),
+ createLocation([200, 200], {
+ cluster: true,
+ point_count: 3,
+ sum: -10,
+ }),
+ createLocation([300, 300], {
+ cluster: true,
+ point_count: 3,
+ sum: -1,
+ }),
+ ];
+
+ render(
+ <ScatterPlotGlowOverlay
+ {...defaultProps}
+ locations={locations}
+ aggregation="sum"
+ />,
+ );
+ const redrawParams = triggerRedraw();
+
+ const arcCalls = redrawParams.ctx.arc.mock.calls;
+ const rNeg100 = arcCalls[0][2];
+ const rNeg10 = arcCalls[1][2];
+ const rNeg1 = arcCalls[2][2];
+
+ // Higher magnitude = bigger circle: |-100| > |-10| > |-1|
+ expect(rNeg1).toBeLessThan(rNeg10);
+ expect(rNeg10).toBeLessThan(rNeg100);
+ expect(Number.isFinite(rNeg100)).toBe(true);
+ expect(Number.isFinite(rNeg10)).toBe(true);
+ expect(Number.isFinite(rNeg1)).toBe(true);
+ expect(rNeg1).toBeGreaterThanOrEqual(MIN_VISIBLE_POINT_RADIUS);
+ expect(rNeg100).toBe(defaultProps.dotRadius);
+});
+
+test('mixed positive-and-negative cluster labels size by magnitude', () => {
+ const locations = [
+ createLocation([100, 100], {
+ cluster: true,
+ point_count: 3,
+ sum: -50,
+ }),
+ createLocation([200, 200], {
+ cluster: true,
+ point_count: 3,
+ sum: 0,
+ }),
+ createLocation([300, 300], {
+ cluster: true,
+ point_count: 3,
+ sum: 100,
+ }),
+ ];
+
+ render(
+ <ScatterPlotGlowOverlay
+ {...defaultProps}
+ locations={locations}
+ aggregation="sum"
+ />,
+ );
+ const redrawParams = triggerRedraw();
+
+ const arcCalls = redrawParams.ctx.arc.mock.calls;
+ const rNeg50 = arcCalls[0][2];
+ const rZero = arcCalls[1][2];
+ const r100 = arcCalls[2][2];
+
+ // Magnitude ordering: |0| < |-50| < |100|
+ expect(rZero).toBeLessThan(rNeg50);
+ expect(rNeg50).toBeLessThan(r100);
+ expect(rZero).toBeGreaterThanOrEqual(MIN_VISIBLE_POINT_RADIUS);
+ expect(r100).toBe(defaultProps.dotRadius);
+});
+
+test('all-identical negative labels get equal full radii', () => {
+ const locations = [
+ createLocation([100, 100], {
+ cluster: true,
+ point_count: 3,
+ sum: -5,
+ }),
+ createLocation([200, 200], {
+ cluster: true,
+ point_count: 3,
+ sum: -5,
+ }),
+ createLocation([300, 300], {
+ cluster: true,
+ point_count: 3,
+ sum: -5,
+ }),
+ ];
+
+ render(
+ <ScatterPlotGlowOverlay
+ {...defaultProps}
+ locations={locations}
+ aggregation="sum"
+ />,
+ );
+ const redrawParams = triggerRedraw();
+
+ const arcCalls = redrawParams.ctx.arc.mock.calls;
+ const r1 = arcCalls[0][2];
+ const r2 = arcCalls[1][2];
+ const r3 = arcCalls[2][2];
+
+ expect(r1).toBe(r2);
+ expect(r2).toBe(r3);
+ expect(r1).toBe(defaultProps.dotRadius);
+});
+
+test('single negative cluster gets full radius', () => {
+ const locations = [
+ createLocation([100, 100], {
+ cluster: true,
+ point_count: 3,
+ sum: -5,
+ }),
+ ];
+
+ render(
+ <ScatterPlotGlowOverlay
+ {...defaultProps}
+ locations={locations}
+ aggregation="sum"
+ />,
+ );
+ const redrawParams = triggerRedraw();
+
+ const arcCalls = redrawParams.ctx.arc.mock.calls;
+ expect(arcCalls[0][2]).toBe(defaultProps.dotRadius);
+});
+
+test('large negative cluster labels are abbreviated', () => {
+ const locations = [
+ createLocation([100, 100], {
+ cluster: true,
+ point_count: 3,
+ sum: -50000,
+ }),
+ ];
+
+ render(
+ <ScatterPlotGlowOverlay
+ {...defaultProps}
+ locations={locations}
+ aggregation="sum"
+ />,
+ );
+ const redrawParams = triggerRedraw();
+
+ const fillTextCalls = redrawParams.ctx.fillText.mock.calls;
+ const labelArg = fillTextCalls[0][0];
+ expect(labelArg).toBe('-50k');
+});
+
+test.each([
+ ['sum', [{ sum: -100 }, { sum: -10 }, { sum: -1 }]],
+ ['min', [{ min: -100 }, { min: -10 }, { min: -1 }]],
+ ['max', [{ max: -100 }, { max: -10 }, { max: -1 }]],
+ ['mean', [{ sum: -300 }, { sum: -30 }, { sum: -3 }]],
+])(
+ 'negative %s cluster labels preserve magnitude-based ordering',
+ (aggregation, labelProps) => {
+ const locations = [
+ createLocation([100, 100], {
+ cluster: true,
+ point_count: 3,
+ ...labelProps[0],
+ }),
+ createLocation([200, 200], {
+ cluster: true,
+ point_count: 3,
+ ...labelProps[1],
+ }),
+ createLocation([300, 300], {
+ cluster: true,
+ point_count: 3,
+ ...labelProps[2],
+ }),
+ ];
+
+ render(
+ <ScatterPlotGlowOverlay
+ {...defaultProps}
+ locations={locations}
+ aggregation={aggregation}
+ />,
+ );
+ const redrawParams = triggerRedraw();
+
+ const arcCalls = redrawParams.ctx.arc.mock.calls;
+ const largestRadius = arcCalls[0][2];
+ const middleRadius = arcCalls[1][2];
+ const smallestRadius = arcCalls[2][2];
+
+ expect(smallestRadius).toBeLessThan(middleRadius);
+ expect(middleRadius).toBeLessThan(largestRadius);
+ expect(largestRadius).toBe(defaultProps.dotRadius);
+ },
+);
+
+test('zero-value cluster is visible with minimum radius', () => {
+ const locations = [
+ createLocation([100, 100], {
+ cluster: true,
+ point_count: 5,
+ sum: 0,
+ }),
+ createLocation([200, 200], {
+ cluster: true,
+ point_count: 10,
+ sum: 100,
+ }),
+ ];
+
+ render(
+ <ScatterPlotGlowOverlay
+ {...defaultProps}
+ locations={locations}
+ aggregation="sum"
+ pointRadiusUnit="Pixels"
+ />,
+ );
+ const redrawParams = triggerRedraw();
+
+ const arcCalls = redrawParams.ctx.arc.mock.calls;
+ const zeroClusterRadius = arcCalls[0][2];
+
+ expect(Number.isFinite(zeroClusterRadius)).toBe(true);
+ expect(zeroClusterRadius).toBeGreaterThanOrEqual(MAX_VISIBLE_POINT_RADIUS);
+});
+
+test('all-zero clusters use a finite radius', () => {
+ const locations = [
+ createLocation([100, 100], {
+ cluster: true,
+ point_count: 5,
+ sum: 0,
+ }),
+ createLocation([200, 200], {
+ cluster: true,
+ point_count: 10,
+ sum: 0,
+ }),
+ ];
+
+ render(
+ <ScatterPlotGlowOverlay
+ {...defaultProps}
+ locations={locations}
+ aggregation="sum"
+ pointRadiusUnit="Pixels"
+ />,
+ );
+ const redrawParams = triggerRedraw();
+
+ redrawParams.ctx.arc.mock.calls.forEach(call => {
+ expect(Number.isFinite(call[2])).toBe(true);
+ expect(call[2]).toBeGreaterThanOrEqual(MAX_VISIBLE_POINT_RADIUS);
+ });
+});