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);
+  });
+});