fix(map-box): prevent clusters from being smaller than individual points (#38458)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
João Pedro Alves Barbosa
2026-03-17 15:08:05 -03:00
committed by Michael S. Molina
parent 435e405263
commit 2e09934bb5
4 changed files with 660 additions and 76 deletions

View File

@@ -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",

View File

@@ -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",

View File

@@ -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) &&

View File

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