diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json index e3a986c973f..7db5fa7cc65 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.2", @@ -24333,6 +24334,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 b1a82dd245e..dfe4f0029b8 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.2", 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 } }); - 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 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 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 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 }); } } 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 } 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; +}; + +type MockCanvasContext = { + clearRect: jest.Mock; + beginPath: jest.Mock; + arc: jest.Mock; + fill: jest.Mock; + fillText: jest.Mock; + 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
; }, })); @@ -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(), + }), + ), 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 => ({ width: 800, height: 600, ctx: createMockCanvas(), @@ -70,18 +128,31 @@ const createMockRedrawParams = (overrides = {}) => ({ const createLocation = ( coordinates: [number, number], - properties: Record, -) => ({ + properties: LocationProperties, +): TestLocation => ({ geometry: { coordinates }, properties, }); +const triggerRedraw = ( + overrides: Partial = {}, +): 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( - , - ); - const redrawParams = createMockRedrawParams(); - (global as any).mockRedraw(redrawParams); - }).not.toThrow(); + render( + , + ); + 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( - , - ); - const redrawParams = createMockRedrawParams(); - (global as any).mockRedraw(redrawParams); - }).not.toThrow(); + render( + , + ); + 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( + , + ); + 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( + , + ); + 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( + , + ); + 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( + , + ); + 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( + , + ); + 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( + , + ); + 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( + , + ); + 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( + , + ); + 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( + , + ); + 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( + , + ); + 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( + , + ); + 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( + , + ); + 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( + , + ); + 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( + , + ); + 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( + , + ); + 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); + }); +});