mirror of
https://github.com/apache/superset.git
synced 2026-05-07 08:54:23 +00:00
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:
committed by
Michael S. Molina
parent
435e405263
commit
2e09934bb5
11
superset-frontend/package-lock.json
generated
11
superset-frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) &&
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user